From 735fe724c05c859b667bf6a71722ed18d7a47818 Mon Sep 17 00:00:00 2001 From: Corinne Rauch Date: Fri, 9 Oct 2026 02:42:42 -0400 Subject: [PATCH] fix(jsonschema): support non-URL `$id values Allow JSON Schema conversion to handle plain and relative values instead of assuming every identifier is a URL. Adds a regression test covering a schema with a non-URL root id.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Corinne Rauch --- .../fromJsonSchema/cto/jsonSchemaVisitor.js | 63 ++++++++++++++----- .../fromJsonSchema/cto/jsonSchemaVisitor.js | 25 ++++++++ 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js b/lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js index 318b0b86..c64ae4e2 100644 --- a/lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js +++ b/lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js @@ -172,23 +172,56 @@ class JsonSchemaVisitor { * @private */ parseIdUri(id) { - if (!id) { return; } - - // TODO (MCR) - support non-URL URI $id values - // https://datatracker.ietf.org/doc/html/draft-wright-json-schema-01#section-9.2 - const url = new URL(id); - let namespace = url.hostname.split('.').reverse().join('.'); - const path = url.pathname.split('/'); - const type = Identifiers.normalizeIdentifier( - // @ts-ignore - path.pop() - .replace(/\.json$/, '') // Convention is to add .schema.json to $id - .replace(/\.schema$/, '') - ); + if (!id || typeof id !== 'string') { return; } + + const sanitizeTypeToken = (token) => { + let decoded = token; + + try { + decoded = decodeURI(token).replace(/%2C/ig, ','); + } catch (error) { + // ignore invalid URI encoding and use the raw token + } - namespace += path.length > 0 ? path.join('.') : ''; + return Identifiers.normalizeIdentifier( + decoded + .replace(/\.json$/, '') // Convention is to add .schema.json to $id + .replace(/\.schema$/, '') + ); + }; - return { namespace, type }; + try { + const url = new URL(id); + const path = (url.pathname || '') + .split('/') + .filter(pathSegment => pathSegment.length > 0); + const namespaceSegments = url.hostname + ? url.hostname.split('.').filter(Boolean).reverse() + : []; + const type = sanitizeTypeToken( + path[path.length - 1] || url.hostname.split('.').filter(Boolean)[0] || 'Root' + ); + + const namespace = [ + ...namespaceSegments, + ...path.slice(0, -1) + ].filter(segment => segment.length > 0).join('.'); + + return { namespace, type }; + } catch (error) { + const uriPath = id + .split(/[/?#]/) + .filter(pathSegment => pathSegment.length > 0); + + if (uriPath.length === 0) { + return; + } + + const type = sanitizeTypeToken(uriPath.pop()); + const namespace = uriPath.join('.'); + + return { namespace, type }; + } } /** * Infers a primitive Concerto type from a JSON Schema model property. diff --git a/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js b/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js index 804aed7b..d3b4d174 100644 --- a/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js +++ b/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js @@ -130,6 +130,31 @@ enum Root { }`); }); + it('should generate for a schema with a non-URL $id value', async () => { + const inferredConcertoJsonModel = JsonSchemaVisitor + .parse({ + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'mySchema', + type: 'object', + properties: { + name: { type: 'string' } + } + }) + .accept( + jsonSchemaVisitor, jsonSchemaVisitorParameters + ); + + const inferredConcertoModel = Printer.toCTO( + inferredConcertoJsonModel.models[0] + ); + + inferredConcertoModel.should.equal(`namespace com.test@1.0.0 + +concept mySchema { + o String name optional +}`); + }); + it('should generate for a simple array definition', async () => { const inferredConcertoJsonModel = JsonSchemaVisitor .parse({