From cc62e3acea54b82d5e180f87f03481f07689742c Mon Sep 17 00:00:00 2001 From: Jonathan Arnold Date: Fri, 13 Feb 2026 15:31:35 +0200 Subject: [PATCH] fix(schemaregistry): add support for format to validation --- package-lock.json | 55 +++++-- schemaregistry/package.json | 1 + schemaregistry/serde/json.ts | 6 +- schemaregistry/test/serde/json.spec.ts | 193 +++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f78c731a..c74d0adb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2186,8 +2186,7 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bufbuild/protoc-gen-es": { "version": "2.11.0", @@ -4457,7 +4456,6 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4528,7 +4526,6 @@ "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", @@ -4568,7 +4565,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -4842,7 +4838,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4900,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", @@ -5066,7 +5100,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -5365,7 +5398,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6179,7 +6211,6 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -6314,7 +6345,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8045,7 +8075,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9130,7 +9159,6 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -11627,7 +11655,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11841,7 +11868,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12339,6 +12365,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.12.0", diff --git a/schemaregistry/package.json b/schemaregistry/package.json index 01b55eb1c..efa5716fe 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.12.0", 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],