diff --git a/.changeset/root-ref-sibling-keywords.md b/.changeset/root-ref-sibling-keywords.md new file mode 100644 index 0000000000..2002b61ff3 --- /dev/null +++ b/.changeset/root-ref-sibling-keywords.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Fixed the `bundle` command losing schema keywords (such as `title`, `properties`, or `required`) written next to a `$ref` when the referenced schemas started with their own `$ref`. diff --git a/packages/core/src/__tests__/bundle.test.ts b/packages/core/src/__tests__/bundle.test.ts index 447a7cbe14..279028221b 100644 --- a/packages/core/src/__tests__/bundle.test.ts +++ b/packages/core/src/__tests__/bundle.test.ts @@ -976,6 +976,140 @@ describe('sibling $ref resolution by spec', () => { expect(problems).toHaveLength(0); expect(res.parsed).toMatchSnapshot(); }); + + it('should keep sibling keywords when an external schema file has a root-level $ref', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'), + }); + + expect(problems).toHaveLength(0); + expect((res.parsed as any).components.schemas.BadRequest).toEqual({ + title: 'Bad request', + type: 'object', + $ref: '#/components/schemas/BaseProblem', + properties: { + status: { type: 'integer', minimum: 400, maximum: 400 }, + }, + required: ['status'], + }); + expect( + (res.parsed as any).paths['/demo'].get.responses['400'].content['application/json'].schema + ).toEqual({ $ref: '#/components/schemas/BadRequest' }); + }); + + it('should bundle refs inside $ref siblings that collide with resolved schema keys', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-sibling-collision.yaml'), + }); + + expect(problems).toHaveLength(0); + const schema = (res.parsed as any).paths['/demo'].get.responses['200'].content[ + 'application/json' + ].schema; + expect(schema).toEqual({ + $ref: '#/components/schemas/Test', + properties: { + second: { $ref: '#/components/schemas/BaseProblem' }, + }, + }); + expect((res.parsed as any).components.schemas.BaseProblem).toEqual({ + type: 'object', + properties: { + title: { type: 'string' }, + }, + }); + }); + + it('should not crash when a $ref with siblings resolves to null', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Nullish: + $ref: '#/components/schemas/Target' + description: composed over null + Target: null + `, + '' + ); + + const { bundle: res, problems } = await bundleDocument({ + document, + externalRefResolver: new BaseResolver(), + config: await createConfig({}), + types: Oas3Types, + }); + + expect(problems).toHaveLength(0); + expect((res.parsed as any).components.schemas.Nullish).toEqual({ + $ref: '#/components/schemas/Target', + description: 'composed over null', + }); + }); + + it('should bundle discriminator mappings that point to composed schemas', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-discriminator-mapping.yaml'), + }); + + expect(problems).toHaveLength(0); + const schemas = (res.parsed as any).components.schemas; + expect(schemas.Pet.discriminator.mapping.cat).toEqual('#/components/schemas/Cat'); + expect(schemas.Cat).toEqual({ + title: 'Cat', + $ref: '#/components/schemas/BaseProblem', + properties: { + toy: { $ref: '#/components/schemas/Toy' }, + }, + }); + expect(schemas.Toy).toEqual({ type: 'string' }); + }); + + it('should keep referenced composed schemas when removing unused components', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'), + removeUnusedComponents: true, + }); + + expect(problems).toHaveLength(0); + expect(Object.keys((res.parsed as any).components.schemas).sort()).toEqual([ + 'BadRequest', + 'BaseProblem', + ]); + }); + + it('should keep sibling keywords when a referenced component has a root-level $ref', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'), + }); + + expect(problems).toHaveLength(0); + const schemas = (res.parsed as any).components.schemas; + expect(schemas.Problem).toEqual({ + $ref: '#/components/schemas/ProblemAlias', + description: 'Composed problem', + }); + expect(schemas.ProblemAlias).toEqual({ + $ref: '#/components/schemas/BaseProblem', + title: 'Problem alias', + }); + expect(schemas.BaseProblem).toEqual({ + type: 'object', + properties: { + title: { type: 'string' }, + }, + }); + }); }); describe('bundle with --component-names-strategy title', () => { diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/openapi-discriminator-mapping.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-discriminator-mapping.yaml new file mode 100644 index 0000000000..94f3219f18 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-discriminator-mapping.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Discriminator mapping to a composed schema + version: 1.0.0 +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + discriminator: + propertyName: type + mapping: + cat: ./schemas/Cat.yaml diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/openapi-root-ref-siblings.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-root-ref-siblings.yaml new file mode 100644 index 0000000000..140409bb07 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-root-ref-siblings.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with sibling keywords + version: 1.0.0 +paths: + /demo: + get: + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: ./schemas/BadRequest.yaml +components: + schemas: + BadRequest: + $ref: ./schemas/BadRequest.yaml + ProblemAlias: + $ref: ./schemas/BaseProblem.yaml + title: Problem alias + Problem: + $ref: '#/components/schemas/ProblemAlias' + description: Composed problem diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/openapi-sibling-collision.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-sibling-collision.yaml new file mode 100644 index 0000000000..7fdcad52d9 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-sibling-collision.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Sibling properties colliding with the referenced schema keys + version: 1.0.0 +paths: + /demo: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + properties: + second: + $ref: ./schemas/BaseProblem.yaml +components: + schemas: + Test: + type: object + title: Test schema + properties: + first: + type: integer diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BadRequest.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BadRequest.yaml new file mode 100644 index 0000000000..a6a8c05c72 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BadRequest.yaml @@ -0,0 +1,10 @@ +title: Bad request +type: object +$ref: ./BaseProblem.yaml +properties: + status: + type: integer + minimum: 400 + maximum: 400 +required: + - status diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BaseProblem.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BaseProblem.yaml new file mode 100644 index 0000000000..0a27ba2939 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/BaseProblem.yaml @@ -0,0 +1,4 @@ +type: object +properties: + title: + type: string diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Cat.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Cat.yaml new file mode 100644 index 0000000000..abbda1c0b7 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Cat.yaml @@ -0,0 +1,5 @@ +title: Cat +$ref: ./BaseProblem.yaml +properties: + toy: + $ref: ./Toy.yaml diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Toy.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Toy.yaml new file mode 100644 index 0000000000..5c21d88b9e --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/schemas/Toy.yaml @@ -0,0 +1 @@ +type: string diff --git a/packages/core/src/__tests__/resolve.test.ts b/packages/core/src/__tests__/resolve.test.ts index 40b8b4828e..22ea6c2f2e 100644 --- a/packages/core/src/__tests__/resolve.test.ts +++ b/packages/core/src/__tests__/resolve.test.ts @@ -411,6 +411,72 @@ describe('collect refs', () => { expect(Array.from(resolvedRefs.values()).pop()!.node).toEqual({ type: 'string' }); }); + it('should record a $ref with sibling keys as a chain hop while following transitive refs', async () => { + const rootDocument = parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + $ref: "#/aliased" + aliased: + $ref: '#/target' + description: alias with sibling + target: + contact: {} + `, + 'foobar.yaml' + ); + + const resolvedRefs = await resolveDocument({ + rootDocument, + externalRefResolver: new BaseResolver(), + rootType: normalizeTypes(Oas3Types).Root, + }); + + const aliasedRef = resolvedRefs.get('foobar.yaml::#/aliased')!; + expect(aliasedRef.node).toEqual({ contact: {} }); + expect(aliasedRef.chain).toHaveLength(1); + expect(aliasedRef.chain![0].node).toEqual({ + $ref: '#/target', + description: 'alias with sibling', + }); + expect(aliasedRef.chain![0].location.pointer).toEqual('#/aliased'); + expect(resolvedRefs.get('foobar.yaml::#/target')!.node).toEqual({ contact: {} }); + }); + + it('should record each level of a multi-hop composed chain independently', async () => { + const rootDocument = parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + $ref: "#/first" + first: + $ref: '#/second' + description: first hop + second: + $ref: '#/target' + description: second hop + target: + contact: {} + `, + 'foobar.yaml' + ); + + const resolvedRefs = await resolveDocument({ + rootDocument, + externalRefResolver: new BaseResolver(), + rootType: normalizeTypes(Oas3Types).Root, + }); + + const outerRef = resolvedRefs.get('foobar.yaml::#/first')!; + expect(outerRef.node).toEqual({ contact: {} }); + expect(outerRef.chain!.map((hop) => hop.location.pointer)).toEqual(['#/first', '#/second']); + + // the inner resolution must not be polluted by outer hops + const innerRef = resolvedRefs.get('foobar.yaml::#/second')!; + expect(innerRef.node).toEqual({ contact: {} }); + expect(innerRef.chain!.map((hop) => hop.location.pointer)).toEqual(['#/second']); + }); + it('should throw error if ref is folder', async () => { const cwd = path.join(__dirname, 'fixtures/resolve'); const rootDocument = parseYamlToDocument( diff --git a/packages/core/src/bundle/bundle-visitor.ts b/packages/core/src/bundle/bundle-visitor.ts index 77c2b68aa4..067ba9a0da 100644 --- a/packages/core/src/bundle/bundle-visitor.ts +++ b/packages/core/src/bundle/bundle-visitor.ts @@ -20,7 +20,7 @@ import { isString } from '../utils/is-string.js'; import { makeRefId } from '../utils/make-ref-id.js'; import { toPascalCase } from '../utils/to-pascal-case.js'; import { type Oas3Visitor, type Oas2Visitor } from '../visitors.js'; -import { type UserContext, type ResolveResult, type Problem } from '../walk.js'; +import { type UserContext, type ResolveResult, type NonUndefined, type Problem } from '../walk.js'; import { type ComponentNamesStrategy } from './bundle-document.js'; type ComponentTarget = { node: unknown; location: Location }; @@ -151,17 +151,24 @@ export function makeBundleVisitor({ reportUnresolvedRef(resolved, ctx.report, ctx.location); return; } + + // a composed $ref (with sibling keys) in the chain is the effective target, + // so the composition survives in the bundled output + const target = resolved.chain?.length + ? { node: resolved.chain[0].node, location: resolved.chain[0].location } + : { node: resolved.node, location: resolved.location }; + if ( - resolved.location.source === rootDocument.source && - resolved.location.source === ctx.location.source && + target.location.source === rootDocument.source && + target.location.source === ctx.location.source && ctx.type.name !== 'scalar' && !dereference ) { // Normalize explicit self-file refs (api.yaml#/x -> #/x). Already-internal refs // stay as authored: rewriting them would collapse $ref chains, and AsyncAPI 3 // operation messages must point to channel messages, not to components. - if (parseRef(node.$ref).uri !== null && node.$ref !== resolved.location.pointer) { - node.$ref = resolved.location.pointer; + if (parseRef(node.$ref).uri !== null && node.$ref !== target.location.pointer) { + node.$ref = target.location.pointer; } return; @@ -173,13 +180,13 @@ export function makeBundleVisitor({ const componentType = mapTypeToComponent(ctx.type.name, version); if (!componentType) { - replaceRef(node, resolved, ctx); + replaceRef(node, target, ctx); } else { if (dereference) { - saveComponent(componentType, resolved, ctx); - replaceRef(node, resolved, ctx); + saveComponent(componentType, target, ctx); + replaceRef(node, target, ctx); } else { - node.$ref = saveComponent(componentType, resolved, ctx); + node.$ref = saveComponent(componentType, target, ctx); resolveBundledComponent(node, resolved, ctx); } } @@ -240,7 +247,11 @@ export function makeBundleVisitor({ return; } - discriminator.defaultMapping = saveComponent(schemaComponentType, resolved, ctx); + discriminator.defaultMapping = saveComponent( + schemaComponentType, + resolved.chain?.[0] ?? resolved, + ctx + ); }, DiscriminatorMapping: { leave(mapping, ctx) { @@ -255,22 +266,35 @@ export function makeBundleVisitor({ return; } - mapping[name] = saveComponent(schemaComponentType, resolved, ctx); + mapping[name] = saveComponent( + schemaComponentType, + resolved.chain?.[0] ?? resolved, + ctx + ); } }, }, }; } - function resolveBundledComponent(node: OasRef, resolved: ResolveResult, ctx: UserContext) { - const newRefId = makeRefId(ctx.location.source.absoluteRef, node.$ref); - resolvedRefMap.set(newRefId, { - document: rootDocument, + // Makes the rewritten ref resolvable: registers the rewritten pointer in the map + // with the same resolution (chain-end node plus composed hops) the original ref had. + function resolveBundledComponent( + node: OasRef, + resolved: ResolveResult, + ctx: UserContext + ) { + const resolvedRef = { + resolved: true as const, isRemote: false, node: resolved.node, + chain: resolved.chain, + document: rootDocument, nodePointer: node.$ref, - resolved: true, - }); + }; + resolvedRefMap.set(makeRefId(ctx.location.source.absoluteRef, node.$ref), resolvedRef); + // saved components are walked again as part of the bundled document + resolvedRefMap.set(makeRefId(rootDocument.source.absoluteRef, node.$ref), resolvedRef); } function saveComponent(componentType: string, target: ComponentTarget, ctx: UserContext) { @@ -290,12 +314,12 @@ export function makeBundleVisitor({ } function isEqualOrEqualRef(node: unknown, target: ComponentTarget, ctx: UserContext) { - if ( - isRef(node) && - ctx.resolve(node, rootLocation.absolutePointer).location?.absolutePointer === - target.location.absolutePointer - ) { - return true; + if (isRef(node)) { + const resolved = ctx.resolve(node, rootLocation.absolutePointer); + const effectiveLocation = resolved.chain?.[0]?.location ?? resolved.location; + if (effectiveLocation?.absolutePointer === target.location.absolutePointer) { + return true; + } } return dequal(node, target.node); diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts index 14cebd115f..1023cde3f1 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -130,7 +130,15 @@ export function isAnchor(ref: string) { export function replaceRef(ref: OasRef, resolved: ResolveResult, ctx: UserContext) { if (!isPlainObject(resolved.node)) { - ctx.parent[ctx.key] = resolved.node; + if (ctx.parent) { + ctx.parent[ctx.key] = resolved.node; + } else { + // a composed chain hop has no parent to replace, so dereference it in place + // by dropping the $ref and keeping the sibling keys + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete ref.$ref; + } } else { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts index aac6c68e6c..01cfaf5aa1 100644 --- a/packages/core/src/resolve.ts +++ b/packages/core/src/resolve.ts @@ -14,6 +14,7 @@ import { isAbsoluteUrl, isAnchor, isExternalValue, + Location, } from './ref-utils.js'; import { isNamedType, SpecExtension, type NormalizedNodeType } from './types/index.js'; import type { OasRef } from './typings/openapi.js'; @@ -181,6 +182,16 @@ export class BaseResolver { } } +// A $ref target that is itself a $ref with sibling keys (a JSON Schema 2020-12 composition). +// Resolution chases through it to the chain end, but the hop is recorded so consumers +// that care about the composition (the bundler, chain-aware rules) can access it. +// The document is kept alongside the location so hop subtrees can be resolved too. +export type ResolvedRefChainHop = { + node: unknown; + document: Document; + location: Location; +}; + export type ResolvedRef = | { resolved: false; @@ -190,6 +201,7 @@ export type ResolvedRef = source?: Source; error?: ResolveError | YamlParseError; node?: any; + chain?: undefined; } | { resolved: true; @@ -198,6 +210,7 @@ export type ResolvedRef = nodePointer: string; isRemote: boolean; error?: undefined; + chain?: ResolvedRefChainHop[]; }; export type ResolvedRefMap = Map; @@ -344,6 +357,15 @@ export async function resolveDocument(opts: { resolvedRef.nodePointer!, type ); + // chain hops carry sibling keys that can contain refs of their own + for (const chainHop of resolvedRef.chain ?? []) { + resolveRefsInParallel( + chainHop.node, + chainHop.document, + chainHop.location.pointer, + type + ); + } } }); resolvePromises.push(promise); @@ -449,6 +471,8 @@ export async function resolveDocument(opts: { ); } else if (isRef(target)) { resolvedRef = await followRef(targetDoc, target, pushRef(refStack, target)); + // a chain collected while traversing into a ref does not belong to the outer ref + resolvedRef.chain = undefined; targetDoc = resolvedRef.document || targetDoc; if (isPlainObject(resolvedRef.node)) { @@ -477,7 +501,22 @@ export async function resolveDocument(opts: { resolvedRef.document = targetDoc; const refId = makeRefId(document.source.absoluteRef, ref.$ref); if (resolvedRef.document && isRef(target)) { + // A $ref with sibling keys is a composition (JSON Schema 2020-12), not a transparent + // alias — record it as a chain hop so the composition survives for consumers that need it. + const chainHop: ResolvedRefChainHop | undefined = + Object.keys(target).length > 1 + ? { + node: target, + document: resolvedRef.document, + location: new Location(resolvedRef.document.source, resolvedRef.nodePointer!), + } + : undefined; resolvedRef = await followRef(resolvedRef.document, target, pushRef(refStack, target)); + if (chainHop && resolvedRef.resolved) { + // rebuilt rather than mutated: the returned chain array is shared with the + // map entry the inner recursion has just written + resolvedRef = { ...resolvedRef, chain: [chainHop, ...(resolvedRef.chain ?? [])] }; + } } resolvedRefMap.set(refId, resolvedRef); return { ...resolvedRef }; diff --git a/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts b/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts index cdc8c35aeb..a560e94732 100644 --- a/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts +++ b/packages/core/src/rules/common/__tests__/no-required-schema-properties-undefined.test.ts @@ -950,4 +950,299 @@ describe('no-required-schema-properties-undefined', () => { ] `); }); + + it('should not report a required property defined as a sibling of $ref', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + BadRequest: + $ref: '#/components/schemas/Base' + properties: + status: + type: integer + required: + - status + - title + Base: + type: object + properties: + title: + type: string + Usage: + $ref: '#/components/schemas/BadRequest' + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report a required property defined further down a composed $ref chain', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Usage: + $ref: '#/components/schemas/Middle' + Middle: + $ref: '#/components/schemas/Mid2' + title: Middle + required: + - deepProp + Mid2: + $ref: '#/components/schemas/Leaf' + title: Mid2 + Leaf: + type: object + properties: + deepProp: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report a required property defined in an allOf sibling of a referenced composed $ref', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Usage: + $ref: '#/components/schemas/Composed' + Composed: + $ref: '#/components/schemas/Base' + allOf: + - type: object + properties: + extra: + type: string + required: + - extra + Base: + type: object + properties: + title: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report an undefined required property written next to an inline $ref', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Payload: + type: object + properties: + data: + $ref: '#/components/schemas/Base' + required: + - name + - missing + Base: + type: object + properties: + name: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/schemas/Payload/properties/data/required/1", + "reportOnKey": false, + "source": "foobar.yaml", + }, + ], + "message": "Required property 'missing' is not defined.", + "reference": "https://redocly.com/docs/cli/rules/common/no-required-schema-properties-undefined", + "ruleId": "no-required-schema-properties-undefined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should ignore a non-array required on a composed parameter $ref', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /demo: + get: + parameters: + - $ref: '#/components/parameters/Alias' + responses: {} + components: + parameters: + Alias: + $ref: '#/components/parameters/Base' + required: true + Base: + name: q + in: query + schema: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should find a property on a composed hop even when the chain end was already visited', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Cat: + allOf: + - $ref: '#/components/schemas/Leaf' + - $ref: '#/components/schemas/Composed' + required: + - special + Composed: + $ref: '#/components/schemas/Leaf' + properties: + special: + type: string + Leaf: + type: object + properties: + name: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should still report a genuinely undefined property in a composed $ref chain', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Usage: + $ref: '#/components/schemas/Middle' + Middle: + $ref: '#/components/schemas/Leaf' + title: Middle + required: + - missingProp + Leaf: + type: object + properties: + deepProp: + type: string + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-required-schema-properties-undefined': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/schemas/Middle/required/0", + "reportOnKey": false, + "source": "foobar.yaml", + }, + ], + "message": "Required property 'missingProp' is not defined.", + "reference": "https://redocly.com/docs/cli/rules/common/no-required-schema-properties-undefined", + "ruleId": "no-required-schema-properties-undefined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/rules/common/__tests__/struct.test.ts b/packages/core/src/rules/common/__tests__/struct.test.ts index 758dbc2350..993a39e60f 100644 --- a/packages/core/src/rules/common/__tests__/struct.test.ts +++ b/packages/core/src/rules/common/__tests__/struct.test.ts @@ -826,4 +826,34 @@ describe('AsyncAPI bindings struct', () => { expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); }); + + it('should not report $ref on a referenced schema with sibling keywords', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Composed: + $ref: '#/components/schemas/Base' + title: Composed + Base: + type: object + Usage: + $ref: '#/components/schemas/Composed' + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { struct: 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); }); diff --git a/packages/core/src/rules/common/no-required-schema-properties-undefined.ts b/packages/core/src/rules/common/no-required-schema-properties-undefined.ts index 0da4453f7b..dbe5703e74 100644 --- a/packages/core/src/rules/common/no-required-schema-properties-undefined.ts +++ b/packages/core/src/rules/common/no-required-schema-properties-undefined.ts @@ -1,9 +1,11 @@ -import type { Oas3Schema, Oas3_1Schema } from '../../typings/openapi.js'; +import { isRef, type Location } from '../../ref-utils.js'; +import type { Oas3Schema, Oas3_1Schema, OasRef } from '../../typings/openapi.js'; import type { Oas2Schema } from '../../typings/swagger.js'; import { getOwn } from '../../utils/get-own.js'; import { isNotEmptyArray } from '../../utils/is-not-empty-array.js'; +import { isPlainObject } from '../../utils/is-plain-object.js'; import type { Async2Rule, Async3Rule, Arazzo1Rule, Oas2Rule, Oas3Rule } from '../../visitors.js'; -import type { UserContext } from '../../walk.js'; +import type { ResolveResult, UserContext } from '../../walk.js'; import { resolveSchema } from '../utils.js'; type AnySchema = @@ -18,49 +20,136 @@ export const NoRequiredSchemaPropertiesUndefined: | Async2Rule | Arazzo1Rule = () => { const parents: AnySchema[] = []; + const validatedRefNodes = new Set(); + + const definesProperty = ( + schema: AnySchema, + propertyName: string, + visited: Set, + ctx: UserContext, + resolveFrom?: string + ): boolean => { + if (schema.properties && getOwn(schema.properties, propertyName) !== undefined) { + return true; + } + + if (schema.allOf?.some((s) => hasProperty(s, propertyName, visited, ctx, resolveFrom))) { + return true; + } + + if ( + isNotEmptyArray(schema.anyOf) && + schema.anyOf.every((s) => hasProperty(s, propertyName, new Set(visited), ctx, resolveFrom)) + ) { + return true; + } + + if ( + isNotEmptyArray(schema.oneOf) && + schema.oneOf.every((s) => hasProperty(s, propertyName, new Set(visited), ctx, resolveFrom)) + ) { + return true; + } + + return false; + }; + + const hasProperty = ( + schemaOrRef: AnySchema | undefined, + propertyName: string, + visited: Set, + ctx: UserContext, + resolveFrom?: string + ): boolean => { + // A JSON Schema 2020-12 $ref can carry sibling keywords that compose the target, + // so check them before the $ref is resolved away. + if ( + isRef(schemaOrRef) && + definesProperty(schemaOrRef as AnySchema, propertyName, visited, ctx, resolveFrom) + ) { + return true; + } + + const { schema, location, chain } = resolveSchema(schemaOrRef, ctx, resolveFrom); + if (!schema) return false; + + if (!visited.has(schema)) { + visited.add(schema); + if (definesProperty(schema, propertyName, visited, ctx, location)) { + return true; + } + } + + // composed $refs the resolution chased through contribute their sibling keywords too, + // even when the chain end was already visited through another branch + for (const chainHop of chain ?? []) { + if (!isPlainObject(chainHop.node) || visited.has(chainHop.node)) { + continue; + } + visited.add(chainHop.node as AnySchema); + if ( + definesProperty( + chainHop.node as AnySchema, + propertyName, + visited, + ctx, + chainHop.location.source.absoluteRef + ) + ) { + return true; + } + } + + return false; + }; + + const reportUndefinedRequired = ( + schema: AnySchema, + schemaLocation: Location, + ctx: UserContext, + resolveFrom?: string + ) => { + if (!isNotEmptyArray(schema.required)) return; + + for (const [i, requiredProperty] of schema.required.entries()) { + if (!hasProperty(schema, requiredProperty, new Set(), ctx, resolveFrom)) { + ctx.report({ + message: `Required property '${requiredProperty}' is not defined.`, + location: schemaLocation.child(['required', i]), + reference: + 'https://redocly.com/docs/cli/rules/common/no-required-schema-properties-undefined', + }); + } + } + }; + return { + ref: { + leave(refNode: OasRef, ctx: UserContext, resolved: ResolveResult) { + if (ctx.type.name !== 'Schema') return; + + // composed $refs are never visited as Schema nodes, so the `required` sibling + // keywords of the ref itself and of the chain hops are validated here + const composedSchemas = [ + { node: refNode as unknown, location: ctx.location }, + ...(resolved.chain ?? []), + ]; + for (const { node, location } of composedSchemas) { + if (!isPlainObject(node) || validatedRefNodes.has(node)) { + continue; + } + validatedRefNodes.add(node); + reportUndefinedRequired(node as AnySchema, location, ctx, location.source.absoluteRef); + } + }, + }, Schema: { leave(_: AnySchema) { parents.pop(); }, enter(currentSchema: AnySchema, ctx: UserContext) { parents.push(currentSchema); - if (!currentSchema.required) return; - - const hasProperty = ( - schemaOrRef: AnySchema | undefined, - propertyName: string, - visited: Set, - resolveFrom?: string - ): boolean => { - const { schema, location } = resolveSchema(schemaOrRef, ctx, resolveFrom); - if (!schema || visited.has(schema)) return false; - visited.add(schema); - - if (schema.properties && getOwn(schema.properties, propertyName) !== undefined) { - return true; - } - - if (schema.allOf?.some((s) => hasProperty(s, propertyName, visited, location))) { - return true; - } - - if ( - isNotEmptyArray(schema.anyOf) && - schema.anyOf.every((s) => hasProperty(s, propertyName, new Set(visited), location)) - ) { - return true; - } - - if ( - isNotEmptyArray(schema.oneOf) && - schema.oneOf.every((s) => hasProperty(s, propertyName, new Set(visited), location)) - ) { - return true; - } - - return false; - }; + if (!isNotEmptyArray(currentSchema.required)) return; const isCompositionChild = (parent: AnySchema, child: AnySchema): boolean => { const matchesChild = (s: AnySchema) => resolveSchema(s, ctx).schema === child; @@ -83,8 +172,8 @@ export const NoRequiredSchemaPropertiesUndefined: for (const [i, requiredProperty] of currentSchema.required.entries()) { if ( - !hasProperty(currentSchema, requiredProperty, new Set()) && - !hasProperty(compositionRoot, requiredProperty, new Set()) + !hasProperty(currentSchema, requiredProperty, new Set(), ctx) && + !hasProperty(compositionRoot, requiredProperty, new Set(), ctx) ) { ctx.report({ message: `Required property '${requiredProperty}' is not defined.`, diff --git a/packages/core/src/rules/oas3/__tests__/no-unused-components.test.ts b/packages/core/src/rules/oas3/__tests__/no-unused-components.test.ts index 558c087ae2..ecc64efd12 100644 --- a/packages/core/src/rules/oas3/__tests__/no-unused-components.test.ts +++ b/packages/core/src/rules/oas3/__tests__/no-unused-components.test.ts @@ -448,4 +448,41 @@ describe('Oas3 no-unused-components', () => { ] `); }); + + it('should not report a referenced schema composing another schema with a root-level $ref', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /demo: + get: + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Composed' + components: + schemas: + Composed: + $ref: '#/components/schemas/Base' + title: Composed + Base: + type: object + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-unused-components': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); }); diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts index c389c402e0..8002bee51b 100644 --- a/packages/core/src/rules/utils.ts +++ b/packages/core/src/rules/utils.ts @@ -13,7 +13,7 @@ import type { import type { Oas2Tag } from '../typings/swagger.js'; import { isDefined } from '../utils/is-defined.js'; import { isPlainObject } from '../utils/is-plain-object.js'; -import type { NonUndefined, UserContext } from '../walk.js'; +import type { NonUndefined, ResolvedChainHop, UserContext } from '../walk.js'; import type { AjvValidator } from './ajv.js'; export const resolveSchema = ( @@ -23,11 +23,16 @@ export const resolveSchema = ( ): { schema: T | undefined; location: string | undefined; + chain?: ResolvedChainHop[]; } => { if (isRef(schemaOrRef)) { const resolved = ctx.resolve(schemaOrRef, resolveFrom); return resolved - ? { schema: resolved.node, location: resolved.location?.source.absoluteRef } + ? { + schema: resolved.node, + location: resolved.location?.source.absoluteRef, + chain: resolved.chain, + } : { schema: undefined, location: resolveFrom }; } diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 539ba90853..24c15446f8 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -2,7 +2,13 @@ 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 type { + ResolveError, + Source, + ResolvedRefMap, + ResolvedRefChainHop, + 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'; @@ -29,9 +35,22 @@ export type NonUndefined = | object | Record; +// A composed $ref (with sibling keys) the resolution chased through on the way to `node`. +export type ResolvedChainHop = ResolvedRefChainHop; + export type ResolveResult = - | { node: T; location: Location; error?: ResolveError | YamlParseError } - | { node: undefined; location: undefined; error?: ResolveError | YamlParseError }; + | { + node: T; + location: Location; + error?: ResolveError | YamlParseError; + chain?: ResolvedChainHop[]; + } + | { + node: undefined; + location: undefined; + error?: ResolveError | YamlParseError; + chain?: ResolvedChainHop[]; + }; export type ResolveFn = ( node: Referenced, @@ -132,6 +151,9 @@ export function walkDocument(opts: { }) { const { document, rootType, normalizedVisitors, resolvedRefMap, ctx } = opts; const seenNodesPerType: Record> = {}; + const walkedComposedRefs = new Set(); + const composedRefWalkId = (type: NormalizedNodeType, location: Location) => + `${type.name}::${location.absolutePointer}`; const ignoredNodes = new Set(); // Pre-compute combined enter/leave arrays per type to avoid per-node array allocations @@ -165,22 +187,32 @@ export function walkDocument(opts: { }; } - const { resolved, node, document, nodePointer, error } = resolvedRef; + const { resolved, node, document, nodePointer, error, chain } = resolvedRef; const newLocation = resolved ? new Location(document!.source, nodePointer!) : error instanceof YamlParseError ? new Location(error.source, '') : undefined; - return { location: newLocation, node, error }; + return { location: newLocation, node, error, chain }; }; const rawLocation = location; let currentLocation = location; const nodeIsRef = isRef(node); - const { node: resolvedNode, location: resolvedLocation, error } = resolve(node); + const { + node: resolvedNode, + location: resolvedLocation, + error, + chain: resolvedChain, + } = resolve(node); const enteredContexts: Set = new Set(); + if (nodeIsRef && Object.keys(node).length > 1) { + // composed $refs can also be reached as chain hops — record this walk to avoid a repeat + walkedComposedRefs.add(composedRefWalkId(type, location)); + } + if (nodeIsRef) { const refEnterVisitors = normalizedVisitors.ref.enter; for (const { visit: visitor, ruleId, severity, message, context } of refEnterVisitors) { @@ -202,7 +234,7 @@ export function walkDocument(opts: { config: ctx.config, getVisitorData: () => getVisitorDataFn(ruleId), }, - { node: resolvedNode, location: resolvedLocation, error } + { node: resolvedNode, location: resolvedLocation, error, chain: resolvedChain } ); if (resolvedLocation?.source.absoluteRef && ctx.refTypes) { ctx.refTypes.set(resolvedLocation?.source.absoluteRef, type); @@ -211,6 +243,38 @@ export function walkDocument(opts: { } if (resolvedNode !== undefined && resolvedLocation && type.name !== 'scalar') { + // the isRef check narrows `node` to OasRef, but sibling keys are read from it too + const rawNode = node as Record; + + const walkProp = (propName: string, value: unknown, loc: Location, valueParent: unknown) => { + let propType = getOwn(type.properties, propName); + if (propType === undefined) propType = type.additionalProperties; + if (typeof propType === 'function') propType = propType(value, propName); + + if ( + propType === undefined && + type.extensionsPrefix && + propName.startsWith(type.extensionsPrefix) + ) { + propType = SpecExtension; + } + + if (!isNamedType(propType) && propType?.directResolveAs) { + propType = propType.directResolveAs; + value = { $ref: value }; + } + + if (propType && propType.name === undefined && propType.resolvable !== false) { + propType = { name: 'scalar', properties: {} }; + } + + if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { + return; + } + + walkNode(value, propType, loc.child([propName]), valueParent, propName); + }; + currentLocation = resolvedLocation; const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); let visitedBySome = false; @@ -320,51 +384,30 @@ export function walkDocument(opts: { ); } - if (nodeIsRef) { - props.push(...Object.keys(node).filter((k) => k !== '$ref' && !props.includes(k))); // properties on the same level as $ref - } - for (const propName of props) { - let value = resolvedNode[propName]; - - let loc = resolvedLocation; - - if (value === undefined) { - value = node[propName]; - loc = location; // properties on the same level as $ref should resolve against original location, not target - } - - let propType = getOwn(type.properties, propName); - if (propType === undefined) propType = type.additionalProperties; - if (typeof propType === 'function') propType = propType(value, propName); - - if ( - propType === undefined && - type.extensionsPrefix && - propName.startsWith(type.extensionsPrefix) - ) { - propType = SpecExtension; - } - - if (!isNamedType(propType) && propType?.directResolveAs) { - propType = propType.directResolveAs; - value = { $ref: value }; - } - - if (propType && propType.name === undefined && propType.resolvable !== false) { - propType = { name: 'scalar', properties: {} }; - } - - if (isRef(node[propName]) && propType?.name === 'scalar') { - walkNode(node[propName], propType, location.child([propName]), node, propName); - continue; + const resolvedValue = resolvedNode[propName]; + if (resolvedValue !== undefined) { + walkProp(propName, resolvedValue, resolvedLocation, resolvedNode); } + } + } + } - if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { - continue; - } + if (nodeIsRef && resolvedChain?.length) { + // the target composes another $ref: the hop is walked as a ref node from its own + // location (it has no parent there); its resolution carries the rest of the chain + const chainHop = resolvedChain[0]; + if (!walkedComposedRefs.has(composedRefWalkId(type, chainHop.location))) { + walkNode(chainHop.node, type, chainHop.location, undefined, key); + } + } - walkNode(value, propType, loc.child([propName]), resolvedNode, propName); + if (nodeIsRef) { + // $ref sibling keys belong to the ref node itself and walk from its own location; + // the resolved node's (or hops') keys are walked in their own frames + for (const propName of Object.keys(node)) { + if (propName !== '$ref' && rawNode[propName] !== resolvedNode?.[propName]) { + walkProp(propName, rawNode[propName], location, node); } } } @@ -419,7 +462,7 @@ export function walkDocument(opts: { config: ctx.config, getVisitorData: () => getVisitorDataFn(ruleId), }, - { node: resolvedNode, location: resolvedLocation, error } + { node: resolvedNode, location: resolvedLocation, error, chain: resolvedChain } ); } } diff --git a/tests/e2e/bundle/bundle.test.ts b/tests/e2e/bundle/bundle.test.ts index 26312010d9..cb176366dc 100644 --- a/tests/e2e/bundle/bundle.test.ts +++ b/tests/e2e/bundle/bundle.test.ts @@ -190,6 +190,14 @@ describe('bundle with option: dereferenced', () => { const result = getCommandOutput(args, { testPath }); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); }); + + it('should compose sibling keywords of a root-level $ref', async () => { + const testPath = join(__dirname, `sibling-refs-root-in-file`); + const args = getParams(indexEntryPoint, ['bundle', 'openapi.yaml', '--dereferenced']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); + }); }); describe('bundle with long description', () => { diff --git a/tests/e2e/bundle/sibling-refs-root-external-file/openapi.yaml b/tests/e2e/bundle/sibling-refs-root-external-file/openapi.yaml new file mode 100644 index 0000000000..501b360418 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-external-file/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with siblings across files + version: 1.0.0 +paths: + /demo: + get: + operationId: getDemo + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: ./schemas/BadRequest.yaml +components: + schemas: + BadRequest: + $ref: ./schemas/BadRequest.yaml + BaseProblem: + $ref: ./schemas/BaseProblem.yaml diff --git a/tests/e2e/bundle/sibling-refs-root-external-file/redocly.yaml b/tests/e2e/bundle/sibling-refs-root-external-file/redocly.yaml new file mode 100644 index 0000000000..f6f9f27544 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-external-file/redocly.yaml @@ -0,0 +1,3 @@ +apis: + main: + root: ./openapi.yaml diff --git a/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BadRequest.yaml b/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BadRequest.yaml new file mode 100644 index 0000000000..a6a8c05c72 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BadRequest.yaml @@ -0,0 +1,10 @@ +title: Bad request +type: object +$ref: ./BaseProblem.yaml +properties: + status: + type: integer + minimum: 400 + maximum: 400 +required: + - status diff --git a/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BaseProblem.yaml b/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BaseProblem.yaml new file mode 100644 index 0000000000..0a27ba2939 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-external-file/schemas/BaseProblem.yaml @@ -0,0 +1,4 @@ +type: object +properties: + title: + type: string diff --git a/tests/e2e/bundle/sibling-refs-root-external-file/snapshot.txt b/tests/e2e/bundle/sibling-refs-root-external-file/snapshot.txt new file mode 100644 index 0000000000..6b1cb9c18c --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-external-file/snapshot.txt @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with siblings across files + version: 1.0.0 +paths: + /demo: + get: + operationId: getDemo + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' +components: + schemas: + BadRequest: + title: Bad request + type: object + $ref: '#/components/schemas/BaseProblem' + properties: + status: + type: integer + minimum: 400 + maximum: 400 + required: + - status + BaseProblem: + type: object + properties: + title: + type: string + +bundling openapi.yaml using configuration for api 'main'... +📦 Created a bundle for openapi.yaml at stdout ms. diff --git a/tests/e2e/bundle/sibling-refs-root-in-file/openapi.yaml b/tests/e2e/bundle/sibling-refs-root-in-file/openapi.yaml new file mode 100644 index 0000000000..523404def6 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-in-file/openapi.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with siblings in the same file + version: 1.0.0 +paths: + /demo: + get: + operationId: getDemo + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' +components: + schemas: + BadRequest: + title: Bad request + type: object + $ref: '#/components/schemas/BaseProblem' + properties: + status: + type: integer + minimum: 400 + maximum: 400 + required: + - status + BaseProblem: + type: object + properties: + title: + type: string diff --git a/tests/e2e/bundle/sibling-refs-root-in-file/redocly.yaml b/tests/e2e/bundle/sibling-refs-root-in-file/redocly.yaml new file mode 100644 index 0000000000..f6f9f27544 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-in-file/redocly.yaml @@ -0,0 +1,3 @@ +apis: + main: + root: ./openapi.yaml diff --git a/tests/e2e/bundle/sibling-refs-root-in-file/snapshot.txt b/tests/e2e/bundle/sibling-refs-root-in-file/snapshot.txt new file mode 100644 index 0000000000..419063e563 --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-in-file/snapshot.txt @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with siblings in the same file + version: 1.0.0 +paths: + /demo: + get: + operationId: getDemo + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' +components: + schemas: + BadRequest: + title: Bad request + type: object + $ref: '#/components/schemas/BaseProblem' + properties: + status: + type: integer + minimum: 400 + maximum: 400 + required: + - status + BaseProblem: + type: object + properties: + title: + type: string + +bundling openapi.yaml using configuration for api 'main'... +📦 Created a bundle for openapi.yaml at stdout ms. diff --git a/tests/e2e/bundle/sibling-refs-root-in-file/snapshot_2.txt b/tests/e2e/bundle/sibling-refs-root-in-file/snapshot_2.txt new file mode 100644 index 0000000000..e2e65e349b --- /dev/null +++ b/tests/e2e/bundle/sibling-refs-root-in-file/snapshot_2.txt @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Root-level $ref with siblings in the same file + version: 1.0.0 +paths: + /demo: + get: + operationId: getDemo + responses: + '400': + description: Bad request + content: + application/json: + schema: + title: Bad request + type: object + properties: &ref_0 + status: + type: integer + minimum: 400 + maximum: 400 + required: &ref_1 + - status +components: + schemas: + BadRequest: + title: Bad request + type: object + properties: *ref_0 + required: *ref_1 + BaseProblem: + type: object + properties: + title: + type: string + +bundling openapi.yaml using configuration for api 'main'... +📦 Created a bundle for openapi.yaml at stdout ms.