Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/core/src/__tests__/walk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/ref-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 23 additions & 5 deletions packages/core/src/walk.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -151,7 +151,7 @@ export function walkDocument<T extends BaseVisitor>(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) {
Expand All @@ -163,7 +163,16 @@ export function walkDocument<T extends BaseVisitor>(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;
Expand All @@ -172,7 +181,16 @@ export function walkDocument<T extends BaseVisitor>(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<VisitorLevelContext> = new Set();
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/lint/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
39 changes: 39 additions & 0 deletions tests/e2e/lint/nested-id-ref-resolution/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions tests/e2e/lint/nested-id-ref-resolution/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apis:
main:
root: ./openapi.yaml

rules:
no-unresolved-refs: error
no-unused-components: error
6 changes: 6 additions & 0 deletions tests/e2e/lint/nested-id-ref-resolution/snapshot.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

validating openapi.yaml using lint rules for api 'main'...
openapi.yaml: validated in <test>ms

Woohoo! Your API description is valid. 🎉

25 changes: 25 additions & 0 deletions tests/e2e/lint/relative-id-ref-resolution/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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'
7 changes: 7 additions & 0 deletions tests/e2e/lint/relative-id-ref-resolution/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apis:
main:
root: ./openapi.yaml

rules:
no-unresolved-refs: error
no-unused-components: error
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type: string
6 changes: 6 additions & 0 deletions tests/e2e/lint/relative-id-ref-resolution/snapshot.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

validating openapi.yaml using lint rules for api 'main'...
openapi.yaml: validated in <test>ms

Woohoo! Your API description is valid. 🎉

Loading