diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index fef6b41301..7e8c57b537 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -1676,6 +1676,67 @@ describe('context.resolve', () => { }), }); }); + + it('should use source location derived from resolved schema $id IRI', async () => { + const testRuleSet: Oas3RuleSet = { + test: vi.fn(() => { + return { + Schema: vi.fn((schema, { resolve }) => { + if (schema.properties?.value?.$ref) { + const { location, node } = resolve(schema.properties.value); + expect(node).toMatchObject({ + $id: 'https://example.com/schemas/shared.yaml', + type: 'object', + }); + expect(location?.pointer).toEqual('#/'); + expect(location?.source.absoluteRef).toEqual( + 'https://example.com/schemas/shared.yaml' + ); + } + }), + }; + }), + }; + + const document = parseYamlToDocument( + outdent` + openapi: 3.2.0 + info: + title: test + version: 1.0.0 + paths: {} + components: + schemas: + Canonical: + $id: 'https://example.com/schemas/shared.yaml' + type: object + Holder: + type: object + properties: + value: + $ref: '#/components/schemas/Canonical' + `, + 'foobar.yaml' + ); + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + plugins: [ + { + id: 'test', + rules: { + oas3: testRuleSet, + }, + }, + ], + rules: { + 'test/test': 'error', + }, + }), + }); + }); }); describe('type extensions', () => { diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts index 03e4e64d24..1a91879c7c 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -19,6 +19,10 @@ export function isExternalValue(node: unknown) { return isPlainObject(node) && typeof node.externalValue === 'string'; } +export function hasSchemaId(node: unknown): node is { $id: string } { + return isPlainObject(node) && typeof node.$id === 'string'; +} + export class Location { constructor( public source: Source, @@ -111,6 +115,13 @@ export function resolvePath(base: string, relative: string): string { return path.resolve(base, relative); } +export function resolveSchemaId(baseAbsoluteRef: string, schemaId: string) { + if (isAbsoluteUrl(schemaId) || path.isAbsolute(schemaId)) { + return schemaId; + } + return resolvePath(getDir(baseAbsoluteRef), schemaId); +} + export function isMappingRef(mapping: string) { // TODO: proper detection of mapping refs return ( diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts index 3a9f25e106..1db1fa4b37 100644 --- a/packages/core/src/resolve.ts +++ b/packages/core/src/resolve.ts @@ -13,6 +13,7 @@ import { isAbsoluteUrl, isAnchor, isExternalValue, + hasSchemaId, } from './ref-utils.js'; import { isNamedType, SpecExtension, type NormalizedNodeType } from './types/index.js'; import type { OasRef } from './typings/openapi.js'; @@ -251,6 +252,23 @@ export async function resolveDocument(opts: { return; } + const normalizedNodeId = hasSchemaId(node) + ? externalRefResolver.resolveExternalRef(rootNodeDocument.source.absoluteRef, node.$id) + : undefined; + + if (normalizedNodeId && normalizedNodeId !== nodeAbsoluteRef && node !== rootNode) { + const nestedDocument = { + source: new Source( + normalizedNodeId, + rootNodeDocument.source.body, + rootNodeDocument.source.mimeType + ), + parsed: node, + }; + resolveRefsInParallel(node, nestedDocument, '', type); + return; + } + const nodeId = `${type.name}::${nodeAbsoluteRef}`; if (seenNodes.has(nodeId)) { return; diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 2e80eea4f7..1c74134c0f 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -1,8 +1,8 @@ import type { Config, RuleSeverity } from './config/index.js'; import { YamlParseError } from './errors/yaml-parse-error.js'; import type { SpecVersion } from './oas-types.js'; -import { Location, isRef } from './ref-utils.js'; -import type { ResolveError, Source, ResolvedRefMap, Document } from './resolve.js'; +import { Location, isRef, resolveSchemaId, hasSchemaId } from './ref-utils.js'; +import { Source, type ResolveError, type ResolvedRefMap, type Document } from './resolve.js'; import { isNamedType, SpecExtension, type NormalizedNodeType } from './types/index.js'; import type { Referenced } from './typings/openapi.js'; import { getOwn } from './utils/get-own.js'; @@ -151,7 +151,7 @@ export function walkDocument(opts: { key: string | number ) { const resolve: ResolveFn = (ref, from = currentLocation.source.absoluteRef) => { - if (!isRef(ref)) return { location, node: ref }; + if (!isRef(ref)) return { location: currentLocation, node: ref }; const refId = makeRefId(from, ref.$ref); const resolvedRef = resolvedRefMap.get(refId); if (!resolvedRef) { @@ -163,7 +163,16 @@ export function walkDocument(opts: { const { resolved, node, document, nodePointer, error } = resolvedRef; const newLocation = resolved - ? new Location(document!.source, nodePointer!) + ? hasSchemaId(node) + ? new Location( + new Source( + resolveSchemaId(document!.source.absoluteRef, node.$id), + document!.source.body, + document!.source.mimeType + ), + '#/' + ) + : new Location(document!.source, nodePointer!) : error instanceof YamlParseError ? new Location(error.source, '') : undefined; @@ -172,7 +181,16 @@ export function walkDocument(opts: { }; const rawLocation = location; - let currentLocation = location; + let currentLocation = hasSchemaId(node) + ? new Location( + new Source( + resolveSchemaId(location.source.absoluteRef, node.$id), + location.source.body, + location.source.mimeType + ), + '#/' + ) + : location; const nodeIsRef = isRef(node); const { node: resolvedNode, location: resolvedLocation, error } = resolve(node); const enteredContexts: Set = new Set(); diff --git a/tests/e2e/lint/lint.test.ts b/tests/e2e/lint/lint.test.ts index 6806f62a7f..dd3444a9b2 100644 --- a/tests/e2e/lint/lint.test.ts +++ b/tests/e2e/lint/lint.test.ts @@ -109,4 +109,24 @@ describe('lint', () => { const result = getCommandOutput(args, {}); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); }); + + test('lint nested root $id with local $defs refs', async () => { + const dirName = 'nested-id-ref-resolution'; + const testPath = join(__dirname, `${dirName}`); + + const args = getParams(indexEntryPoint, ['lint', 'openapi.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('lint relative $id IRI ref resolution', async () => { + const dirName = 'relative-id-ref-resolution'; + const testPath = join(__dirname, `${dirName}`); + + const args = getParams(indexEntryPoint, ['lint', 'openapi.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); }); diff --git a/tests/e2e/lint/nested-id-ref-resolution/openapi.yaml b/tests/e2e/lint/nested-id-ref-resolution/openapi.yaml new file mode 100644 index 0000000000..9824701690 --- /dev/null +++ b/tests/e2e/lint/nested-id-ref-resolution/openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.2.0 +info: + title: nested-id-ref-resolution + version: 1.0.0 +paths: + /userData: + get: + operationId: getUserData + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/userData' + +components: + schemas: + userData: + $id: 'https://api.example.com/classroom/userData' + type: array + items: + type: object + properties: + completedChallenges: + type: array + items: + $ref: '#/$defs/Challenge' + $defs: + Challenge: + type: object + properties: + files: + type: array + unevaluatedItems: false + items: + $ref: '#/$defs/File' + File: + type: string diff --git a/tests/e2e/lint/nested-id-ref-resolution/redocly.yaml b/tests/e2e/lint/nested-id-ref-resolution/redocly.yaml new file mode 100644 index 0000000000..4b2d4a8886 --- /dev/null +++ b/tests/e2e/lint/nested-id-ref-resolution/redocly.yaml @@ -0,0 +1,7 @@ +apis: + main: + root: ./openapi.yaml + +rules: + no-unresolved-refs: error + no-unused-components: error diff --git a/tests/e2e/lint/nested-id-ref-resolution/snapshot.txt b/tests/e2e/lint/nested-id-ref-resolution/snapshot.txt new file mode 100644 index 0000000000..51d6b3745b --- /dev/null +++ b/tests/e2e/lint/nested-id-ref-resolution/snapshot.txt @@ -0,0 +1,6 @@ + +validating openapi.yaml using lint rules for api 'main'... +openapi.yaml: validated in ms + +Woohoo! Your API description is valid. 🎉 + diff --git a/tests/e2e/lint/relative-id-ref-resolution/openapi.yaml b/tests/e2e/lint/relative-id-ref-resolution/openapi.yaml new file mode 100644 index 0000000000..38870d2afa --- /dev/null +++ b/tests/e2e/lint/relative-id-ref-resolution/openapi.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: relative-id-ref-resolution + version: 1.0.0 +paths: + /wrapper: + get: + operationId: getWrapper + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Wrapper' + +components: + schemas: + Wrapper: + $id: 'schemas/Wrapper.yaml' + type: object + properties: + embedded: + $id: 'nested/child.yaml' + $ref: 'sibling.yaml' diff --git a/tests/e2e/lint/relative-id-ref-resolution/redocly.yaml b/tests/e2e/lint/relative-id-ref-resolution/redocly.yaml new file mode 100644 index 0000000000..4b2d4a8886 --- /dev/null +++ b/tests/e2e/lint/relative-id-ref-resolution/redocly.yaml @@ -0,0 +1,7 @@ +apis: + main: + root: ./openapi.yaml + +rules: + no-unresolved-refs: error + no-unused-components: error diff --git a/tests/e2e/lint/relative-id-ref-resolution/schemas/nested/sibling.yaml b/tests/e2e/lint/relative-id-ref-resolution/schemas/nested/sibling.yaml new file mode 100644 index 0000000000..5c21d88b9e --- /dev/null +++ b/tests/e2e/lint/relative-id-ref-resolution/schemas/nested/sibling.yaml @@ -0,0 +1 @@ +type: string diff --git a/tests/e2e/lint/relative-id-ref-resolution/snapshot.txt b/tests/e2e/lint/relative-id-ref-resolution/snapshot.txt new file mode 100644 index 0000000000..51d6b3745b --- /dev/null +++ b/tests/e2e/lint/relative-id-ref-resolution/snapshot.txt @@ -0,0 +1,6 @@ + +validating openapi.yaml using lint rules for api 'main'... +openapi.yaml: validated in ms + +Woohoo! Your API description is valid. 🎉 +