Skip to content

Commit 4e2bfd7

Browse files
committed
feat(validator): add property validator
Add property validator that also specifically checks for geoJSONs. The pattern should be scalable to multiple specific types of properties. Updated the metadata validator to validate properties in metadata when applicable. Add and update test suite
1 parent dd6439a commit 4e2bfd7

File tree

7 files changed

+445
-37
lines changed

7 files changed

+445
-37
lines changed

src/resources/schema/metadata.json

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,26 @@
3636
"type": "array",
3737
"items": {
3838
"type": "object",
39-
"properties": {
40-
"trait_type": {
41-
"type": "string"
39+
"oneOf": [
40+
{
41+
"properties": {
42+
"trait_type": { "type": "string" },
43+
"value": { "type": "string" }
44+
},
45+
"required": ["trait_type", "value"],
46+
"additionalProperties": false
4247
},
43-
"value": {
44-
"type": "string"
48+
{
49+
"properties": {
50+
"trait_type": { "type": "string" },
51+
"type": { "type": "string" },
52+
"src": { "type": "string" },
53+
"name": { "type": "string" }
54+
},
55+
"required": ["trait_type", "type", "src", "name"],
56+
"additionalProperties": false
4557
}
46-
}
58+
]
4759
}
4860
},
4961
"hypercert": {

src/types/metadata.d.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,18 @@ export interface HypercertMetadata {
3737
* A CID pointer to the merke tree proof json on ipfs
3838
*/
3939
allowList?: string;
40-
properties?: {
41-
trait_type?: string;
42-
value?: string;
43-
[k: string]: unknown;
44-
}[];
40+
properties?: (
41+
| {
42+
trait_type: string;
43+
value: string;
44+
}
45+
| {
46+
trait_type: string;
47+
type: string;
48+
src: string;
49+
name: string;
50+
}
51+
)[];
4552
hypercert?: HypercertClaimdata;
4653
}
4754
/**

src/validator/ValidatorFactory.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MerkleProofData, MerkleProofValidator } from "./validators/MerkleProofV
44
import { MetadataValidator, ClaimDataValidator } from "./validators/MetadataValidator";
55
import { AllowlistValidator } from "./validators/AllowListValidator";
66
import { AllowlistValidationParams } from "./validators/AllowListValidator";
7+
import { PropertyValidator, PropertyValue } from "./validators/PropertyValidator";
78

89
export class ValidatorFactory {
910
static createMetadataValidator(): IValidator<HypercertMetadata> {
@@ -21,4 +22,8 @@ export class ValidatorFactory {
2122
static createMerkleProofValidator(): IValidator<MerkleProofData> {
2223
return new MerkleProofValidator();
2324
}
25+
26+
static createPropertyValidator(): IValidator<PropertyValue> {
27+
return new PropertyValidator();
28+
}
2429
}

src/validator/validators/MetadataValidator.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,37 @@ import { HypercertClaimdata, HypercertMetadata } from "src/types/metadata";
22
import { SchemaValidator } from "../base/SchemaValidator";
33
import claimDataSchema from "../../resources/schema/claimdata.json";
44
import metaDataSchema from "../../resources/schema/metadata.json";
5+
import { PropertyValidator } from "./PropertyValidator";
56

67
export class MetadataValidator extends SchemaValidator<HypercertMetadata> {
8+
private propertyValidator: PropertyValidator;
9+
710
constructor() {
811
super(metaDataSchema, [claimDataSchema]);
12+
this.propertyValidator = new PropertyValidator();
13+
}
14+
15+
validate(data: unknown) {
16+
const result = super.validate(data);
17+
const errors = [...(result.errors || [])];
18+
19+
if (data) {
20+
const metadata = data as HypercertMetadata;
21+
if (metadata.properties?.length) {
22+
const propertyErrors = metadata.properties
23+
.map((property) => this.propertyValidator.validate(property))
24+
.filter((result) => !result.isValid)
25+
.flatMap((result) => result.errors);
26+
27+
errors.push(...propertyErrors);
28+
}
29+
}
30+
31+
return {
32+
isValid: errors.length === 0,
33+
data: errors.length === 0 ? result.data : undefined,
34+
errors,
35+
};
936
}
1037
}
1138

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { ValidationError } from "../interfaces";
2+
import { SchemaValidator } from "../base/SchemaValidator";
3+
import { HypercertMetadata } from "src/types";
4+
import metaDataSchema from "../../resources/schema/metadata.json";
5+
6+
export type PropertyValues = HypercertMetadata["properties"];
7+
type PropertyValue = NonNullable<PropertyValues>[number];
8+
9+
interface PropertyValidationStrategy {
10+
validate(property: NonNullable<PropertyValue>): ValidationError[];
11+
}
12+
13+
interface GeoJSONProperty {
14+
trait_type: string;
15+
type: string;
16+
src: string;
17+
name: string;
18+
}
19+
20+
class GeoJSONValidationStrategy implements PropertyValidationStrategy {
21+
private readonly MIME_TYPE = "applications/geo+json";
22+
23+
validate(property: NonNullable<PropertyValue>): ValidationError[] {
24+
if (!this.isGeoJSONProperty(property)) {
25+
return [
26+
{
27+
field: "type",
28+
code: "missing_type",
29+
message: "GeoJSON property must have type field",
30+
},
31+
];
32+
}
33+
34+
const errors: ValidationError[] = [];
35+
36+
if (property.type !== this.MIME_TYPE) {
37+
errors.push({
38+
field: "type",
39+
code: "invalid_mime_type",
40+
message: `GeoJSON type must be ${this.MIME_TYPE}`,
41+
});
42+
}
43+
44+
if (!property.src?.startsWith("ipfs://") && !property.src?.startsWith("https://")) {
45+
errors.push({
46+
field: "src",
47+
code: "invalid_url",
48+
message: "GeoJSON src must start with ipfs:// or https://",
49+
});
50+
}
51+
52+
if (!property.name?.endsWith(".geojson")) {
53+
errors.push({
54+
field: "name",
55+
code: "invalid_file_extension",
56+
message: "GeoJSON name must end with .geojson",
57+
});
58+
}
59+
60+
return errors;
61+
}
62+
63+
private isGeoJSONProperty(property: any): property is GeoJSONProperty {
64+
return "type" in property && "src" in property && "name" in property;
65+
}
66+
}
67+
68+
export class PropertyValidator extends SchemaValidator<PropertyValue> {
69+
private readonly validationStrategies: Record<string, PropertyValidationStrategy> = {
70+
geoJSON: new GeoJSONValidationStrategy(),
71+
};
72+
73+
constructor() {
74+
super(metaDataSchema.properties.properties.items);
75+
}
76+
77+
validate(data: unknown) {
78+
const result = super.validate(data);
79+
80+
if (!result.isValid || !result.data) {
81+
return result;
82+
}
83+
84+
const property = result.data as NonNullable<PropertyValue>;
85+
const strategy = this.validationStrategies[property.trait_type];
86+
87+
if (strategy) {
88+
const errors = strategy.validate(property);
89+
if (errors.length > 0) {
90+
return {
91+
isValid: false,
92+
data: undefined,
93+
errors,
94+
};
95+
}
96+
}
97+
98+
return result;
99+
}
100+
}

test/validator/validators/MetadataValidator.test.ts

Lines changed: 145 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,41 +43,160 @@ describe("MetadataValidator", () => {
4343
hypercert: validClaimData,
4444
};
4545

46-
it("should validate valid metadata", () => {
47-
const result = validator.validate(validMetadata);
46+
describe("Basic Metadata Validation", () => {
47+
it("should validate valid metadata", () => {
48+
const result = validator.validate(validMetadata);
49+
expect(result.isValid).to.be.true;
50+
expect(result.data).to.deep.equal(validMetadata);
51+
expect(result.errors).to.be.empty;
52+
});
4853

49-
expect(result.isValid).to.be.true;
50-
expect(result.data).to.deep.equal(validMetadata);
51-
expect(result.errors).to.be.empty;
54+
it("should validate required fields", () => {
55+
const invalidMetadata = {
56+
description: "Test Description",
57+
image: "ipfs://test",
58+
};
59+
60+
const result = validator.validate(invalidMetadata);
61+
expect(result.isValid).to.be.false;
62+
expect(result.errors[0].field).to.equal("name");
63+
});
5264
});
5365

54-
it("should validate required fields", () => {
55-
const invalidMetadata = {
56-
description: "Test Description",
57-
image: "ipfs://test",
58-
};
66+
describe("Property Validation", () => {
67+
it("should validate metadata with valid properties", () => {
68+
const metadataWithProperties = {
69+
...validMetadata,
70+
properties: [
71+
{
72+
trait_type: "category",
73+
value: "education",
74+
},
75+
{
76+
trait_type: "geoJSON",
77+
type: "applications/geo+json",
78+
src: "ipfs://QmExample",
79+
name: "location.geojson",
80+
},
81+
],
82+
};
5983

60-
const result = validator.validate(invalidMetadata);
84+
const result = validator.validate(metadataWithProperties);
85+
expect(result.isValid).to.be.true;
86+
expect(result.data).to.deep.equal(metadataWithProperties);
87+
});
6188

62-
expect(result.isValid).to.be.false;
63-
expect(result.errors[0].field).to.equal("name");
89+
it("should reject metadata with invalid simple property", () => {
90+
const metadataWithInvalidProperty = {
91+
...validMetadata,
92+
properties: [
93+
{
94+
trait_type: "category",
95+
// missing required 'value' field
96+
},
97+
],
98+
};
99+
100+
const result = validator.validate(metadataWithInvalidProperty);
101+
expect(result.isValid).to.be.false;
102+
expect(result.errors).to.have.length.greaterThan(0);
103+
});
104+
105+
it("should reject metadata with invalid geoJSON property", () => {
106+
const metadataWithInvalidGeoJSON = {
107+
...validMetadata,
108+
properties: [
109+
{
110+
trait_type: "geoJSON",
111+
type: "wrong/type",
112+
src: "invalid://QmExample",
113+
name: "location.wrong",
114+
},
115+
],
116+
};
117+
118+
const result = validator.validate(metadataWithInvalidGeoJSON);
119+
expect(result.isValid).to.be.false;
120+
expect(result.errors).to.have.length(3); // MIME type, URL, and file extension errors
121+
});
122+
123+
it("should collect all property validation errors", () => {
124+
const metadataWithMultipleInvalidProperties = {
125+
...validMetadata,
126+
properties: [
127+
{
128+
trait_type: "category",
129+
// missing value
130+
},
131+
{
132+
trait_type: "geoJSON",
133+
type: "wrong/type",
134+
src: "invalid://QmExample",
135+
name: "location.wrong",
136+
},
137+
],
138+
};
139+
140+
const result = validator.validate(metadataWithMultipleInvalidProperties);
141+
expect(result.isValid).to.be.false;
142+
expect(result.errors.length).to.be.greaterThan(3); // Schema error plus GeoJSON errors
143+
});
144+
145+
it("should handle empty properties array", () => {
146+
const metadataWithEmptyProperties = {
147+
...validMetadata,
148+
properties: [],
149+
};
150+
151+
const result = validator.validate(metadataWithEmptyProperties);
152+
expect(result.isValid).to.be.true;
153+
});
64154
});
65155

66-
it("should validate nested claim data", () => {
67-
const invalidMetadata = {
68-
...validMetadata,
69-
hypercert: {
70-
...validClaimData,
71-
impact_scope: undefined,
72-
},
73-
};
156+
describe("Combined Validation", () => {
157+
it("should validate metadata with both valid properties and claim data", () => {
158+
const completeMetadata = {
159+
...validMetadata,
160+
properties: [
161+
{
162+
trait_type: "category",
163+
value: "education",
164+
},
165+
{
166+
trait_type: "geoJSON",
167+
type: "applications/geo+json",
168+
src: "ipfs://QmExample",
169+
name: "location.geojson",
170+
},
171+
],
172+
};
74173

75-
const result = validator.validate(invalidMetadata);
174+
const result = validator.validate(completeMetadata);
175+
expect(result.isValid).to.be.true;
176+
expect(result.data).to.deep.equal(completeMetadata);
177+
});
76178

77-
expect(result.isValid).to.be.false;
78-
expect(result.errors[0].field).to.equal("/hypercert");
79-
// or if we want to check the specific error message:
80-
expect(result.errors[0].message).to.include("impact_scope");
179+
it("should collect errors from both metadata and property validation", () => {
180+
const invalidMetadata = {
181+
description: "Test Description", // missing required name
182+
image: "ipfs://test",
183+
properties: [
184+
{
185+
trait_type: "geoJSON",
186+
type: "wrong/type",
187+
src: "invalid://QmExample",
188+
name: "location.wrong",
189+
},
190+
],
191+
};
192+
193+
const result = validator.validate(invalidMetadata);
194+
195+
console.log(result.errors);
196+
197+
expect(result.isValid).to.be.false;
198+
expect(result.errors).to.have.length.greaterThan(3); // Schema errors plus property errors
199+
});
81200
});
82201
});
83202

0 commit comments

Comments
 (0)