diff --git a/package-lock.json b/package-lock.json index 329dd0096..eb1e38c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4895,6 +4895,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -12325,6 +12364,7 @@ "@types/simple-oauth2": "^5.0.7", "@types/validator": "^13.12.0", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "avsc": "^5.7.7", "axios": "^1.13.5", diff --git a/schemaregistry/package.json b/schemaregistry/package.json index 8ecf696a8..da71e8d04 100644 --- a/schemaregistry/package.json +++ b/schemaregistry/package.json @@ -44,6 +44,7 @@ "@types/simple-oauth2": "^5.0.7", "@types/validator": "^13.12.0", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "avsc": "^5.7.7", "axios": "^1.13.5", diff --git a/schemaregistry/serde/json.ts b/schemaregistry/serde/json.ts index 2e613e70b..0e9f1c3e3 100644 --- a/schemaregistry/serde/json.ts +++ b/schemaregistry/serde/json.ts @@ -13,6 +13,7 @@ import { import Ajv, {ErrorObject} from "ajv"; import Ajv2019 from "ajv/dist/2019"; import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; import * as draft6MetaSchema from 'ajv/dist/refs/json-schema-draft-06.json' import * as draft7MetaSchema from 'ajv/dist/refs/json-schema-draft-07.json' import { @@ -272,6 +273,7 @@ async function toValidateFunction( if (spec === 'http://json-schema.org/draft/2020-12/schema' || spec === 'https://json-schema.org/draft/2020-12/schema') { const ajv2020 = new Ajv2020({ ...conf as JsonSerdeConfig, allErrors: true }) + addFormats(ajv2020) ajv2020.addKeyword("confluent:tags") deps.forEach((schema, name) => { ajv2020.addSchema(JSON.parse(schema), name) @@ -279,6 +281,7 @@ async function toValidateFunction( fn = ajv2020.compile(json) } else { const ajv = new Ajv2019({ ...conf as JsonSerdeConfig, allErrors: true }) + addFormats(ajv) ajv.addKeyword("confluent:tags") ajv.addMetaSchema(draft6MetaSchema) ajv.addMetaSchema(draft7MetaSchema) @@ -504,6 +507,3 @@ function disjoint(tags1: Set, tags2: Set): boolean { } return true } - - - diff --git a/schemaregistry/test/serde/json.spec.ts b/schemaregistry/test/serde/json.spec.ts index 0da15cb0e..9890f8f90 100644 --- a/schemaregistry/test/serde/json.spec.ts +++ b/schemaregistry/test/serde/json.spec.ts @@ -275,6 +275,63 @@ const messageSchema = ` } } ` +const schemaWithFormats = ` +{ + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "website": { + "type": "string", + "format": "uri" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "ipv4Address": { + "type": "string", + "format": "ipv4" + }, + "uuid": { + "type": "string", + "format": "uuid" + } + }, + "required": ["email", "createdAt"] +} +` +const schemaWithFormats2020_12 = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "website": { + "type": "string", + "format": "uri" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "ipv4Address": { + "type": "string", + "format": "ipv4" + }, + "uuid": { + "type": "string", + "format": "uuid" + } + }, + "required": ["email", "createdAt"] +} +` describe('JsonSerializer', () => { afterEach(async () => { @@ -516,6 +573,142 @@ describe('JsonSerializer', () => { await expect(() => ser.serialize(topic, diffObj)).rejects.toThrow(SerializationError) }) + it('format validation', async () => { + let conf: ClientConfig = { + baseURLs: [baseURL], + cacheCapacity: 1000 + } + let client = SchemaRegistryClient.newClient(conf) + let ser = new JsonSerializer(client, SerdeType.VALUE, { + useLatestVersion: true, + validate: true + }) + + let info: SchemaInfo = { + schemaType: 'JSON', + schema: schemaWithFormats + } + + await client.register(subject, info, false) + + let validObjectWithCorrectFormats = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + ipv4Address: '192.168.1.1', + uuid: '550e8400-e29b-41d4-a716-446655440000' + } + let bytes = await ser.serialize(topic, validObjectWithCorrectFormats) + + let deser = new JsonDeserializer(client, SerdeType.VALUE, {}) + let obj2 = await deser.deserialize(topic, bytes) + expect(obj2).toEqual(validObjectWithCorrectFormats) + + let invalidEmailObj = { + email: 'not-an-email', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z' + } + await expect(() => ser.serialize(topic, invalidEmailObj)).rejects.toThrow(SerializationError) + + let invalidUriObj = { + email: 'user@example.com', + website: 'not a uri', + createdAt: '2024-01-15T10:30:00Z' + } + await expect(() => ser.serialize(topic, invalidUriObj)).rejects.toThrow(SerializationError) + + let invalidDateObj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: 'not-a-date' + } + await expect(() => ser.serialize(topic, invalidDateObj)).rejects.toThrow(SerializationError) + + let invalidIpv4Obj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + ipv4Address: '999.999.999.999' + } + await expect(() => ser.serialize(topic, invalidIpv4Obj)).rejects.toThrow(SerializationError) + + let invalidUuidObj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + uuid: 'not-a-uuid' + } + await expect(() => ser.serialize(topic, invalidUuidObj)).rejects.toThrow(SerializationError) + }) + it('format validation 2020-12', async () => { + let conf: ClientConfig = { + baseURLs: [baseURL], + cacheCapacity: 1000 + } + let client = SchemaRegistryClient.newClient(conf) + let ser = new JsonSerializer(client, SerdeType.VALUE, { + useLatestVersion: true, + validate: true + }) + + let info: SchemaInfo = { + schemaType: 'JSON', + schema: schemaWithFormats2020_12 + } + + await client.register(subject, info, false) + + let validObjectWithCorrectFormats = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + ipv4Address: '192.168.1.1', + uuid: '550e8400-e29b-41d4-a716-446655440000' + } + let bytes = await ser.serialize(topic, validObjectWithCorrectFormats) + + let deser = new JsonDeserializer(client, SerdeType.VALUE, {}) + let obj2 = await deser.deserialize(topic, bytes) + expect(obj2).toEqual(validObjectWithCorrectFormats) + + let invalidEmailObj = { + email: 'not-an-email', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z' + } + await expect(() => ser.serialize(topic, invalidEmailObj)).rejects.toThrow(SerializationError) + + let invalidUriObj = { + email: 'user@example.com', + website: 'not a uri', + createdAt: '2024-01-15T10:30:00Z' + } + await expect(() => ser.serialize(topic, invalidUriObj)).rejects.toThrow(SerializationError) + + let invalidDateObj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: 'not-a-date' + } + await expect(() => ser.serialize(topic, invalidDateObj)).rejects.toThrow(SerializationError) + + let invalidIpv4Obj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + ipv4Address: '999.999.999.999' + } + await expect(() => ser.serialize(topic, invalidIpv4Obj)).rejects.toThrow(SerializationError) + + let invalidUuidObj = { + email: 'user@example.com', + website: 'https://example.com', + createdAt: '2024-01-15T10:30:00Z', + uuid: 'not-a-uuid' + } + await expect(() => ser.serialize(topic, invalidUuidObj)).rejects.toThrow(SerializationError) + }) it('cel field transform', async () => { let conf: ClientConfig = { baseURLs: [baseURL],