From 98c9224a293e47248da32c12626c3951104cf772 Mon Sep 17 00:00:00 2001 From: Shyam-Raghuwanshi Date: Sun, 22 Feb 2026 23:49:54 +0530 Subject: [PATCH] fix: resolve JSON Schema $ref URIs against $id base URIs --- .../parser/src/resolve-json-schema-id-uri.ts | 135 +++++++ packages/parser/src/validate.ts | 11 + .../test/resolve-json-schema-id-uri.spec.ts | 343 ++++++++++++++++++ 3 files changed, 489 insertions(+) create mode 100644 packages/parser/src/resolve-json-schema-id-uri.ts create mode 100644 packages/parser/test/resolve-json-schema-id-uri.spec.ts diff --git a/packages/parser/src/resolve-json-schema-id-uri.ts b/packages/parser/src/resolve-json-schema-id-uri.ts new file mode 100644 index 000000000..1ecb12edf --- /dev/null +++ b/packages/parser/src/resolve-json-schema-id-uri.ts @@ -0,0 +1,135 @@ +/** + * Resolves JSON Schema `$ref` URIs against `$id` base URIs. + * + * Per JSON Schema draft-07 (Section 8.2), the `$id` keyword defines a URI for + * the schema and the base URI that other URI references within the schema are + * resolved against. A subschema's `$id` is resolved against the base URI of + * its parent schema. + * + * The underlying reference resolver (`@stoplight/json-ref-resolver`) does not + * follow this JSON Schema specification behavior. This module pre-processes + * the document to rewrite `$ref` values so they resolve correctly. + * + * @see https://github.com/asyncapi/parser-js/issues/403 + * @see https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-01#section-8.2 + */ + +/** + * Resolves a relative URI against a base URI, following RFC 3986 semantics. + */ +function resolveUri(base: string, ref: string): string { + // If the ref is already an absolute URI, return as-is + if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(ref)) { + return ref; + } + + // If the ref starts with '#', it's a fragment-only reference — local to the document, not affected by $id + if (ref.startsWith('#')) { + return ref; + } + + try { + // Use URL constructor for proper RFC 3986 resolution + const resolved = new URL(ref, base); + return resolved.href; + } catch { + // If URL construction fails, return the original ref unchanged + return ref; + } +} + +/** + * Checks whether a given `$id` value is an absolute URI (has a scheme). + */ +function isAbsoluteUri(uri: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(uri); +} + +/** + * Checks whether a given `$ref` value is a relative URI that should be + * resolved against a `$id` base URI. Fragment-only refs (starting with '#') + * and already-absolute refs are excluded. + */ +function isRelativeRef(ref: string): boolean { + // Fragment-only references are resolved within the document, not via $id + if (ref.startsWith('#')) { + return false; + } + // Already absolute URIs don't need resolution + if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(ref)) { + return false; + } + return true; +} + +/** + * Recursively walks through a schema object, tracking `$id` base URIs, + * and rewrites relative `$ref` values to resolve against the closest + * ancestor `$id` base URI. + */ +function walkAndResolveRefs(schema: any, baseUri: string, visited: Set): void { + if (typeof schema !== 'object' || schema === null || visited.has(schema)) { + return; + } + visited.add(schema); + + // Determine the current base URI: if this schema has an `$id`, resolve it + // against the parent's base URI to get the new base. + let currentBase = baseUri; + if (typeof schema.$id === 'string' && schema.$id.length > 0) { + if (isAbsoluteUri(schema.$id)) { + currentBase = schema.$id; + } else if (baseUri) { + // Relative $id: resolve against parent base + try { + currentBase = new URL(schema.$id, baseUri).href; + } catch { + // If resolution fails, keep the parent base + } + } + } + + // If this schema has a `$ref` and the ref is relative, resolve it + // against the current base URI. + if (typeof schema.$ref === 'string' && currentBase && isRelativeRef(schema.$ref)) { + schema.$ref = resolveUri(currentBase, schema.$ref); + } + + // Recurse into all object properties and array items + for (const key of Object.keys(schema)) { + if (key === '$id' || key === '$ref') { + continue; + } + const value = schema[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'object' && item !== null) { + walkAndResolveRefs(item, currentBase, visited); + } + } + } else if (typeof value === 'object' && value !== null) { + walkAndResolveRefs(value, currentBase, visited); + } + } +} + +/** + * Pre-processes an AsyncAPI document (as a parsed JS object) to resolve + * `$ref` URIs within schemas that use `$id` to set a base URI. + * + * This function mutates the input object in-place. + * + * @param document - The parsed AsyncAPI document object. + * @returns The same object, with `$ref` values rewritten where needed. + */ +export function resolveJsonSchemaIdUri(document: Record): Record { + if (typeof document !== 'object' || document === null) { + return document; + } + + const visited = new Set(); + // Walk the entire document. The base URI starts empty — only schemas + // that declare `$id` with an absolute URI will trigger rewriting. + walkAndResolveRefs(document, '', visited); + return document; +} diff --git a/packages/parser/src/validate.ts b/packages/parser/src/validate.ts index 3662f963a..bf02bcb14 100644 --- a/packages/parser/src/validate.ts +++ b/packages/parser/src/validate.ts @@ -2,6 +2,7 @@ import { Document } from '@stoplight/spectral-core'; import { Yaml } from '@stoplight/spectral-parsers'; import { createSpectral } from './spectral'; import { normalizeInput, mergePatch, hasErrorDiagnostic, hasWarningDiagnostic, hasInfoDiagnostic, hasHintDiagnostic, createUncaghtDiagnostic } from './utils'; +import { resolveJsonSchemaIdUri } from './resolve-json-schema-id-uri'; import type { Spectral, IRunOpts } from '@stoplight/spectral-core'; import type { Parser } from './parser'; @@ -47,6 +48,16 @@ export async function validate(parser: Parser, parserSpectral: Spectral, asyncap const { allowedSeverity } = mergePatch(defaultOptions, options); const stringifiedDocument = normalizeInput(asyncapi as Exclude); document = new Document(stringifiedDocument, Yaml, options.source) as Document; + + // Pre-process: resolve $ref URIs against $id base URIs per JSON Schema + // draft-07 spec (Section 8.2), since the underlying Spectral resolver + // does not follow $id-based URI resolution. + // Mutate the parsed data in-place to preserve YAML source ranges. + // @see https://github.com/asyncapi/parser-js/issues/403 + if (document.data && typeof document.data === 'object') { + resolveJsonSchemaIdUri(document.data as Record); + } + // add input data (asyncapi argument) to the document to reuse it in rules (document as any).__parserInput = asyncapi; diff --git a/packages/parser/test/resolve-json-schema-id-uri.spec.ts b/packages/parser/test/resolve-json-schema-id-uri.spec.ts new file mode 100644 index 000000000..2e2a8841e --- /dev/null +++ b/packages/parser/test/resolve-json-schema-id-uri.spec.ts @@ -0,0 +1,343 @@ +import { resolveJsonSchemaIdUri } from '../src/resolve-json-schema-id-uri'; + +describe('resolveJsonSchemaIdUri()', function() { + describe('basic $id / $ref resolution', function() { + it('should resolve relative $ref against absolute $id base URI', function() { + const doc = { + components: { + schemas: { + mySchema: { + $id: 'http://example.com/', + type: 'object', + properties: { + sentAt: { + $ref: '/components/schemas/sentAt' + } + } + }, + sentAt: { + type: 'string', + format: 'date-time' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.components.schemas.mySchema.properties.sentAt.$ref) + .toEqual('http://example.com/components/schemas/sentAt'); + }); + + it('should resolve relative $ref with path against absolute $id', function() { + const doc = { + payload: { + $id: 'http://localhost.com/', + type: 'object', + properties: { + sentAt: { + $ref: '/components/schemas/sentAt' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.payload.properties.sentAt.$ref) + .toEqual('http://localhost.com/components/schemas/sentAt'); + }); + + it('should not modify fragment-only $ref (starting with #)', function() { + const doc = { + payload: { + $id: 'http://example.com/', + type: 'object', + properties: { + sentAt: { + $ref: '#/definitions/sentAt' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.payload.properties.sentAt.$ref) + .toEqual('#/definitions/sentAt'); + }); + + it('should not modify already-absolute $ref', function() { + const doc = { + payload: { + $id: 'http://example.com/', + type: 'object', + properties: { + sentAt: { + $ref: 'http://other.com/schemas/sentAt' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.payload.properties.sentAt.$ref) + .toEqual('http://other.com/schemas/sentAt'); + }); + + it('should not modify $ref when no $id is present in ancestors', function() { + const doc = { + payload: { + type: 'object', + properties: { + sentAt: { + $ref: '/components/schemas/sentAt' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.payload.properties.sentAt.$ref) + .toEqual('/components/schemas/sentAt'); + }); + }); + + describe('nested $id resolution', function() { + it('should resolve $ref against the closest ancestor $id', function() { + const doc = { + outer: { + $id: 'http://example.com/outer/', + type: 'object', + properties: { + inner: { + $id: 'http://example.com/inner/', + type: 'object', + properties: { + ref: { + $ref: 'schema.json' + } + } + }, + outerRef: { + $ref: 'other.json' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + // innerRef should resolve against inner $id + expect(doc.outer.properties.inner.properties.ref.$ref) + .toEqual('http://example.com/inner/schema.json'); + // outerRef should resolve against outer $id + expect(doc.outer.properties.outerRef.$ref) + .toEqual('http://example.com/outer/other.json'); + }); + + it('should resolve relative $id against parent $id', function() { + const doc = { + schema: { + $id: 'http://example.com/root/', + type: 'object', + properties: { + nested: { + $id: 'nested/', + type: 'object', + properties: { + ref: { + $ref: 'schema.json' + } + } + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + // nested $id 'nested/' resolves against 'http://example.com/root/' => 'http://example.com/root/nested/' + // $ref 'schema.json' resolves against 'http://example.com/root/nested/' => 'http://example.com/root/nested/schema.json' + expect(doc.schema.properties.nested.properties.ref.$ref) + .toEqual('http://example.com/root/nested/schema.json'); + }); + }); + + describe('array handling', function() { + it('should resolve $ref inside array items', function() { + const doc = { + schema: { + $id: 'http://example.com/', + type: 'object', + oneOf: [ + { + $ref: 'first.json' + }, + { + $ref: 'second.json' + } + ] + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.schema.oneOf[0].$ref).toEqual('http://example.com/first.json'); + expect(doc.schema.oneOf[1].$ref).toEqual('http://example.com/second.json'); + }); + }); + + describe('edge cases', function() { + it('should handle non-object input gracefully', function() { + expect(resolveJsonSchemaIdUri(null as any)).toBeNull(); + expect(resolveJsonSchemaIdUri(undefined as any)).toBeUndefined(); + expect(resolveJsonSchemaIdUri('string' as any)).toEqual('string'); + }); + + it('should handle empty document', function() { + const doc = {}; + resolveJsonSchemaIdUri(doc); + expect(doc).toEqual({}); + }); + + it('should handle circular references without infinite loop', function() { + const inner: any = { + $id: 'http://example.com/', + type: 'object', + properties: {} + }; + // Create circular reference + inner.properties.self = inner; + + const doc = { schema: inner }; + // Should not throw or infinite loop + resolveJsonSchemaIdUri(doc); + }); + + it('should handle $id with https scheme', function() { + const doc = { + payload: { + $id: 'https://secure.example.com/', + type: 'object', + properties: { + ref: { + $ref: '/path/to/schema' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.payload.properties.ref.$ref) + .toEqual('https://secure.example.com/path/to/schema'); + }); + + it('should preserve $id value as-is', function() { + const doc = { + schema: { + $id: 'http://example.com/schemas/', + type: 'object' + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.schema.$id).toEqual('http://example.com/schemas/'); + }); + }); + + describe('AsyncAPI-like document structure', function() { + it('should handle the exact scenario from issue #403', function() { + const doc = { + asyncapi: '2.2.0', + info: { + title: 'Test overriding dereferenced objects', + version: '1.0.0' + }, + channels: { + test: { + publish: { + message: { + $ref: '#/components/messages/myMessage' + } + } + } + }, + components: { + messages: { + myMessage: { + schemaFormat: 'application/schema+json;version=draft-07', + name: 'MyMessage', + payload: { + $id: 'http://localhost.com/', + type: 'object', + properties: { + sentAt: { + $ref: '/components/schemas/sentAt' + } + } + } + } + }, + schemas: { + sentAt: { + type: 'string', + format: 'date-time', + description: 'Date and time when the message was sent.' + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + // The $ref inside payload should be resolved against the $id base URI + expect(doc.components.messages.myMessage.payload.properties.sentAt.$ref) + .toEqual('http://localhost.com/components/schemas/sentAt'); + + // The $ref to #/components/messages/myMessage should remain unchanged (fragment-only) + expect(doc.channels.test.publish.message.$ref) + .toEqual('#/components/messages/myMessage'); + }); + + it('should handle v3 AsyncAPI document with $id in schema', function() { + const doc = { + asyncapi: '3.0.0', + info: { + title: 'Test $id resolution', + version: '1.0.0' + }, + channels: { + testChannel: { + address: 'test', + messages: { + myMessage: { + payload: { + schemaFormat: 'application/schema+json;version=draft-07', + schema: { + $id: 'http://example.com/schemas/', + type: 'object', + properties: { + data: { + $ref: 'data.json' + } + } + } + } + } + } + } + } + }; + + resolveJsonSchemaIdUri(doc); + + expect(doc.channels.testChannel.messages.myMessage.payload.schema.properties.data.$ref) + .toEqual('http://example.com/schemas/data.json'); + }); + }); +});