diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 0000000000..0abc20ff82 --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': patch +'@redocly/cli': patch +--- + +Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md new file mode 100644 index 0000000000..707caa43be --- /dev/null +++ b/.changeset/seven-waves-create.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. diff --git a/docs/@v2/commands/stats.md b/docs/@v2/commands/stats.md index 5243a4b536..13185b94f9 100644 --- a/docs/@v2/commands/stats.md +++ b/docs/@v2/commands/stats.md @@ -21,6 +21,7 @@ The metrics reported depend on the type of API description: - Webhooks - Operations - Tags +- Vendor Extensions **AsyncAPI 2.x and AsyncAPI 3.x** @@ -31,6 +32,9 @@ The metrics reported depend on the type of API description: - Channels - Operations - Tags +- Vendor Extensions + +For **Vendor Extensions**, the count is the number of distinct `x-` extensions used, and each extension is listed with how many times it occurs. If you're interested in the technical details, the statistics are calculated using the counting logic from the `StatsVisitor` module. @@ -123,6 +127,11 @@ Document: museum.yaml stats: 🎣 Webhooks: 0 👷 Operations: 8 🔖 Tags: 3 +🧩 Vendor Extensions: 4 + - x-badges: 1 + - x-codeSamples: 2 + - x-internal: 2 + - x-metadata: 1 museum.yaml: stats processed in 4ms @@ -139,6 +148,9 @@ Document: asyncapi.yaml stats: 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 2 + - x-internal: 1 + - x-metadata: 1 asyncapi.yaml: stats processed in 4ms @@ -187,6 +199,16 @@ The following is an example JSON output for an OpenAPI description: "tags": { "metric": "🔖 Tags", "total": 3 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 4, + "counts": { + "x-badges": 1, + "x-codeSamples": 2, + "x-internal": 2, + "x-metadata": 1 + } } } @@ -214,6 +236,15 @@ The following is an example source output for an OpenAPI description: | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 4 | + +#### 🧩 Vendor Extensions +| Extension | Count | +| --- | --- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-internal | 2 | +| x-metadata | 1 | @@ -230,6 +261,16 @@ Here's the rendered example source output: | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 4 | + +**🧩 Vendor Extensions** + +| Extension | Count | +| ------------- | ----- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-internal | 2 | +| x-metadata | 1 | For AsyncAPI descriptions, the table includes a `📡 Channels` row instead of the `🔗 Links`, `🔀 Path Items`, and `🎣 Webhooks` rows. diff --git a/packages/cli/src/commands/stats/print-stats/json.ts b/packages/cli/src/commands/stats/print-stats/json.ts index 98772fa22f..f4137e2625 100644 --- a/packages/cli/src/commands/stats/print-stats/json.ts +++ b/packages/cli/src/commands/stats/print-stats/json.ts @@ -5,14 +5,12 @@ import { } from '@redocly/openapi-core'; export function printStatsJson(statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator) { - const json: any = {}; - for (const key of Object.keys(statsAccumulator)) { - const stat = statsAccumulator[key as keyof typeof statsAccumulator]; - json[key] = { - metric: stat.metric, - total: stat.total, - }; - } + const json = Object.fromEntries( + Object.entries(statsAccumulator).map(([key, { metric, total, counts }]) => [ + key, + { metric, total, counts }, + ]) + ); logger.output(JSON.stringify(json, null, 2)); } diff --git a/packages/cli/src/commands/stats/print-stats/markdown.ts b/packages/cli/src/commands/stats/print-stats/markdown.ts index a3158ac2ee..f2068453fc 100644 --- a/packages/cli/src/commands/stats/print-stats/markdown.ts +++ b/packages/cli/src/commands/stats/print-stats/markdown.ts @@ -8,10 +8,18 @@ export function printStatsMarkdown( statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator ) { let output = '| Feature | Count |\n| --- | --- |\n'; - for (const key of Object.keys(statsAccumulator)) { - const stat = statsAccumulator[key as keyof typeof statsAccumulator]; - output += '| ' + stat.metric + ' | ' + stat.total + ' |\n'; + const breakdowns: string[] = []; + for (const { metric, total, counts } of Object.values(statsAccumulator)) { + output += `| ${metric} | ${total} |\n`; + const countEntries = Object.entries(counts ?? {}); + if (countEntries.length) { + breakdowns.push( + `\n#### ${metric}\n| Extension | Count |\n| --- | --- |\n` + + countEntries.map(([name, count]) => `| ${name} | ${count} |`).join('\n') + + '\n' + ); + } } - logger.output(output); + logger.output(output + breakdowns.join('')); } diff --git a/packages/cli/src/commands/stats/print-stats/stylish.ts b/packages/cli/src/commands/stats/print-stats/stylish.ts index 936550c7b2..034d47fe19 100644 --- a/packages/cli/src/commands/stats/print-stats/stylish.ts +++ b/packages/cli/src/commands/stats/print-stats/stylish.ts @@ -8,10 +8,11 @@ import * as colors from 'colorette'; export function printStatsStylish( statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator ) { - for (const node in statsAccumulator) { - const stat = statsAccumulator[node as keyof typeof statsAccumulator]; - const { metric, total, color } = stat; - const colorFn = colors[color as keyof typeof colors] as (text: string) => string; + for (const { metric, total, color, counts } of Object.values(statsAccumulator)) { + const colorFn = colors[color]; logger.output(colorFn(`${metric}: ${total} \n`)); + for (const [name, count] of Object.entries(counts ?? {})) { + logger.output(colorFn(` - ${name}: ${count} \n`)); + } } } diff --git a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts index 08fd732b58..9fa0a93532 100644 --- a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts +++ b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts @@ -20,6 +20,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) { webhooks: { metric: '🎣 Webhooks', total: 0, color: 'green' }, operations: { metric: '👷 Operations', total: 0, color: 'yellow' }, tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan', counts: {} }, }; const statsAccumulatorAsync: AsyncAPIStatsAccumulator = { refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, @@ -29,6 +30,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) { channels: { metric: '📡 Channels', total: 0, color: 'green' }, operations: { metric: '👷 Operations', total: 0, color: 'yellow' }, tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan', counts: {} }, }; let statsVisitor, statsAccumulator; diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index bc90eff282..61d9e0c6fe 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -1366,6 +1366,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", + "Scopes", "XCodeSample", "XCodeSampleList", "XServerList", diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index 42c089806e..555f365142 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -5,7 +5,7 @@ import { outdent } from 'outdent'; import { parseYamlToDocument, replaceSourceWithRef } from '../../__tests__/utils.js'; import { createConfig } from '../config/index.js'; import { lintDocument } from '../lint.js'; -import { type Oas2RuleSet, type Oas3RuleSet } from '../oas-types.js'; +import { type Async2RuleSet, type Oas2RuleSet, type Oas3RuleSet } from '../oas-types.js'; import { BaseResolver, type Document } from '../resolve.js'; import { listOf } from '../types/index.js'; @@ -1836,6 +1836,473 @@ describe('type extensions', () => { }); }); +describe('spec extensions dispatch', () => { + it('should dispatch a typed extension to SpecExtension visitors and keep its typed walk', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`enter extension ${ctx.key}`), + leave: (_node: any, ctx: any) => calls.push(`leave extension ${ctx.key}`), + }, + XCodeSampleList: { + enter: () => calls.push('typed walk of x-codeSamples'), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual([ + 'typed walk of x-codeSamples', + 'enter extension x-codeSamples', + 'leave extension x-codeSamples', + ]); + }); + + it('should give a full SpecExtension lifecycle to a declared extension with a plain schema', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`enter ${ctx.key}`), + leave: (_node: any, ctx: any) => calls.push(`leave ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-hideTryItPanel: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['enter x-hideTryItPanel', 'leave x-hideTryItPanel']); + }); + + it('should activate nested SpecExtension visitors for every extension kind', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + Operation: { + SpecExtension: { + enter: (_node: any, ctx: any, parents: any) => + calls.push(`${parents.Operation.operationId} > ${ctx.key}`), + }, + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + x-hideTryItPanel: true + x-unknown-ext: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['get > x-codeSamples', 'get > x-hideTryItPanel', 'get > x-unknown-ext']); + }); + + it('should honor the skip hook of SpecExtension visitors for declared extensions', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + skip: (_node: any, key: any) => key === 'x-codeSamples', + enter: (_node: any, ctx: any) => calls.push(`enter ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + x-hideTryItPanel: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['enter x-hideTryItPanel']); + }); + + it('should visit a declared extension node once, not once per visitor kind', async () => { + const anyVisits: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + any: { + enter: (_node: any, ctx: any) => anyVisits.push(ctx.location.pointer), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + const extensionVisits = anyVisits.filter( + (pointer) => pointer === '#/paths/~1pet/get/x-codeSamples' + ); + expect(extensionVisits).toHaveLength(1); + }); + + it('should keep struct validation for a typed extension with an invalid shape', async () => { + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + title: t + version: '1' + paths: + /pet: + get: + operationId: get + x-codeSamples: just a string + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ rules: { struct: 'error' } }), + }); + + expect(results.map((problem) => problem.message)).toContain( + 'Expected type `XCodeSampleList` (array) but got `string`' + ); + }); + + it('should dispatch an x- key as SpecExtension even when additionalProperties matches it', async () => { + const calls: string[] = []; + + const testRuleSet: Async2RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + asyncapi: 2.6.0 + info: + title: t + version: '1' + channels: + user/signedup: + subscribe: + message: + x-msg-ext: hello + payload: + type: object + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { async2: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-msg-ext']); + }); + + it('should dispatch an x- key inside Swagger 2.0 scopes, leaving scope names to the map', async () => { + const calls: string[] = []; + + const testRuleSet: Oas2RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + swagger: '2.0' + info: + title: t + version: '1' + paths: {} + securityDefinitions: + oauth: + type: oauth2 + flow: implicit + authorizationUrl: https://example.com/auth + scopes: + read: Read access + x-scopes-ext: internal note + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas2: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-scopes-ext']); + }); + + it('should dispatch an x- key on a callback, leaving expression keys to the map', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + callbacks: + onEvent: + x-callback-ext: true + '{$request.body#/url}': + post: + responses: + '200': + description: ok + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-callback-ext']); + }); + + it('should dispatch a declared extension whose value is a $ref, visiting the ref once', async () => { + const extensionCalls: string[] = []; + const refCalls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => extensionCalls.push(`extension ${ctx.key}`), + }, + ref: { + enter: (node: any) => refCalls.push(node.$ref), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-webhooks: + $ref: '#/components/x-hooks-source' + components: + x-hooks-source: + newPet: + post: + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(extensionCalls).toContain('extension x-webhooks'); + expect(refCalls.filter((ref) => ref === '#/components/x-hooks-source')).toHaveLength(1); + }); + + it('should not re-visit $refs inside a declared extension subtree', async () => { + const refCalls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + ref: { + enter: (node: any) => refCalls.push(node.$ref), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-webhooks: + newPet: + $ref: '#/components/x-hook-item' + components: + x-hook-item: + post: + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(refCalls.filter((ref) => ref === '#/components/x-hook-item')).toHaveLength(1); + }); + + it('should visit every occurrence of extensions with equal scalar values', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(ctx.location.pointer), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-internal: true + /dog: + get: + operationId: getDog + x-internal: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['#/paths/~1pet/get/x-internal', '#/paths/~1dog/get/x-internal']); + }); +}); + describe('ignoreNextRules', () => { it('should correctly skip top level', async () => { const calls: string[] = []; diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts index c3e142b2da..375ec11a8f 100644 --- a/packages/core/src/resolve.ts +++ b/packages/core/src/resolve.ts @@ -321,16 +321,14 @@ export async function resolveDocument(opts: { for (const propName of Object.keys(node)) { let propValue = node[propName]; let propType = getOwn(type.properties, propName); - if (propType === undefined) propType = type.additionalProperties; + if (propType === undefined) { + propType = + type.extensionsPrefix && propName.startsWith(type.extensionsPrefix) + ? SpecExtension + : type.additionalProperties; + } if (typeof propType === 'function') propType = propType(propValue, propName); if (propType === undefined) propType = unknownType; - if ( - type.extensionsPrefix && - propName.startsWith(type.extensionsPrefix) && - propType === unknownType - ) { - propType = SpecExtension; - } if (!isNamedType(propType) && propType?.directResolveAs) { propType = propType.directResolveAs; diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 23f17d94f2..d0e173e8d8 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -1,4 +1,9 @@ -import type { OASStatsAccumulator, AsyncAPIStatsAccumulator } from '../../typings/common.js'; +import type { + OASStatsAccumulator, + AsyncAPIStatsAccumulator, + StatsAccumulator, + StatsRow, +} from '../../typings/common.js'; import type { Oas3Link, Oas3Operation, @@ -8,9 +13,34 @@ import type { OasRef, } from '../../typings/openapi.js'; import type { Oas2Parameter } from '../../typings/swagger.js'; +import type { UserContext } from '../../walk.js'; + +function countExtension(row: StatsRow, ctx: UserContext) { + const counts = (row.counts ??= {}); + const extensionName = ctx.key.toString(); + counts[extensionName] = (counts[extensionName] ?? 0) + 1; +} + +function finalizeStats(statsAccumulator: StatsAccumulator) { + for (const row of Object.values(statsAccumulator)) { + if (row.items) { + row.total = row.items.size; + } + } + const { xExtensions } = statsAccumulator; + const counts = xExtensions.counts ?? {}; + const extensionNames = Object.keys(counts).sort(); + xExtensions.total = extensionNames.length; + xExtensions.counts = Object.fromEntries(extensionNames.map((name) => [name, counts[name]])); +} export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { return { + SpecExtension: { + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -74,10 +104,7 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.links.total = statsAccumulator.links.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator); }, }, }; @@ -85,6 +112,11 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { return { + SpecExtension: { + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -116,10 +148,8 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, Parameter: { - leave(parameter: any) { - if (parameter.name) { - statsAccumulator.parameters.items!.add(parameter.name); - } + leave(_: unknown, { key }: UserContext) { + statsAccumulator.parameters.items!.add(key.toString()); }, }, }, @@ -133,9 +163,7 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator); }, }, }; @@ -143,6 +171,11 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { return { + SpecExtension: { + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -164,10 +197,8 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { statsAccumulator.channels.total++; }, Parameter: { - leave(parameter: any) { - if (parameter.name) { - statsAccumulator.parameters.items!.add(parameter.name); - } + leave(_: unknown, { key }: UserContext) { + statsAccumulator.parameters.items!.add(key.toString()); }, }, }, @@ -193,9 +224,7 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator); }, }, }; diff --git a/packages/core/src/types/asyncapi-bindings.ts b/packages/core/src/types/asyncapi-bindings.ts index ce4a895cbd..382875d78c 100644 --- a/packages/core/src/types/asyncapi-bindings.ts +++ b/packages/core/src/types/asyncapi-bindings.ts @@ -849,6 +849,7 @@ const Ros2MessageBinding: NodeType = { }; export const ServerBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpServerBinding', ws: 'WsServerBinding', @@ -872,6 +873,7 @@ export const ServerBindings: NodeType = { }; export const ChannelBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpChannelBinding', ws: 'WsChannelBinding', @@ -898,6 +900,7 @@ export const ChannelBindings: NodeType = { }; export const OperationBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpOperationBinding', ws: 'WsOperationBinding', @@ -921,6 +924,7 @@ export const OperationBindings: NodeType = { }; export const MessageBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpMessageBinding', ws: 'WsMessageBinding', diff --git a/packages/core/src/types/asyncapi2.ts b/packages/core/src/types/asyncapi2.ts index be8f8fd179..fbd1d5b932 100644 --- a/packages/core/src/types/asyncapi2.ts +++ b/packages/core/src/types/asyncapi2.ts @@ -9,6 +9,7 @@ import { } from './json-schema-draft7.shared.js'; const Root: NodeType = { + extensionsPrefix: 'x-', properties: { asyncapi: null, // TODO: validate semver format and supported version info: 'Info', @@ -27,6 +28,7 @@ const Root: NodeType = { }; const Channel: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -53,6 +55,7 @@ const ChannelMap: NodeType = { }; export const Tag: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', description: 'REQUIRED. The name of the tag.' }, description: { @@ -67,6 +70,7 @@ export const Tag: NodeType = { }; export const ExternalDocs: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -91,6 +95,7 @@ const SecurityRequirement: NodeType = { }; const Server: NodeType = { + extensionsPrefix: 'x-', properties: { url: { type: 'string', @@ -130,6 +135,7 @@ export const ServerMap: NodeType = { }; export const ServerVariable: NodeType = { + extensionsPrefix: 'x-', properties: { enum: { type: 'array', @@ -158,6 +164,7 @@ export const ServerVariable: NodeType = { }; const Info: NodeType = { + extensionsPrefix: 'x-', properties: { title: { type: 'string', @@ -187,6 +194,7 @@ const Info: NodeType = { }; export const Contact: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', @@ -207,6 +215,7 @@ export const Contact: NodeType = { }; export const License: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', @@ -223,6 +232,7 @@ export const License: NodeType = { }; const Parameter: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -240,6 +250,7 @@ const Parameter: NodeType = { }; export const CorrelationId: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -258,6 +269,7 @@ export const CorrelationId: NodeType = { }; const Message: NodeType = { + extensionsPrefix: 'x-', properties: { messageId: { type: 'string', @@ -305,6 +317,7 @@ const Message: NodeType = { }; const OperationTrait: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', summary: { @@ -332,6 +345,7 @@ const OperationTrait: NodeType = { }; const MessageTrait: NodeType = { + extensionsPrefix: 'x-', properties: { messageId: { type: 'string', @@ -379,6 +393,7 @@ const MessageTrait: NodeType = { }; const Operation: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', summary: { @@ -408,6 +423,7 @@ const Operation: NodeType = { }; export const MessageExample: NodeType = { + extensionsPrefix: 'x-', properties: { payload: { isExample: true, @@ -431,6 +447,7 @@ export const MessageExample: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { messages: 'NamedMessages', parameters: 'NamedParameters', @@ -450,6 +467,7 @@ const Components: NodeType = { }; const ImplicitFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -460,6 +478,7 @@ const ImplicitFlow: NodeType = { }; const PasswordFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -470,6 +489,7 @@ const PasswordFlow: NodeType = { }; const ClientCredentials: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -480,6 +500,7 @@ const ClientCredentials: NodeType = { }; const AuthorizationCode: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, authorizationUrl: { type: 'string' }, @@ -491,6 +512,7 @@ const AuthorizationCode: NodeType = { }; export const SecuritySchemeFlows: NodeType = { + extensionsPrefix: 'x-', properties: { implicit: 'ImplicitFlow', password: 'PasswordFlow', diff --git a/packages/core/src/types/asyncapi3.ts b/packages/core/src/types/asyncapi3.ts index 01ab538403..038cf740f8 100644 --- a/packages/core/src/types/asyncapi3.ts +++ b/packages/core/src/types/asyncapi3.ts @@ -21,6 +21,7 @@ import { } from './json-schema-draft7.shared.js'; const Root: NodeType = { + extensionsPrefix: 'x-', properties: { asyncapi: { type: 'string', @@ -47,6 +48,7 @@ const Root: NodeType = { }; const Channel: NodeType = { + extensionsPrefix: 'x-', properties: { address: { type: 'string', @@ -76,6 +78,7 @@ const Channel: NodeType = { }; const Server: NodeType = { + extensionsPrefix: 'x-', properties: { host: { type: 'string', @@ -113,6 +116,7 @@ const Server: NodeType = { }; const Info: NodeType = { + extensionsPrefix: 'x-', properties: { title: { type: 'string', @@ -144,6 +148,7 @@ const Info: NodeType = { }; const Parameter: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -175,6 +180,7 @@ const Parameter: NodeType = { }; const Message: NodeType = { + extensionsPrefix: 'x-', properties: { headers: 'Schema', payload: (value: Record) => { @@ -223,6 +229,7 @@ const Message: NodeType = { }; const OperationTrait: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', title: { @@ -249,6 +256,7 @@ const OperationTrait: NodeType = { }; const MessageTrait: NodeType = { + extensionsPrefix: 'x-', properties: { headers: (value: unknown) => { if (typeof value === 'function' || isPlainObject(value)) { @@ -295,6 +303,7 @@ const MessageTrait: NodeType = { }; const Operation: NodeType = { + extensionsPrefix: 'x-', properties: { action: { type: 'string', @@ -329,6 +338,7 @@ const Operation: NodeType = { }; const OperationReply: NodeType = { + extensionsPrefix: 'x-', properties: { channel: 'Channel', messages: 'MessageList', @@ -339,6 +349,7 @@ const OperationReply: NodeType = { }; const OperationReplyAddress: NodeType = { + extensionsPrefix: 'x-', properties: { location: { type: 'string', @@ -356,6 +367,7 @@ const OperationReplyAddress: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { messages: 'NamedMessages', parameters: 'NamedParameters', @@ -382,6 +394,7 @@ const Components: NodeType = { }; const ImplicitFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -392,6 +405,7 @@ const ImplicitFlow: NodeType = { }; const PasswordFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -402,6 +416,7 @@ const PasswordFlow: NodeType = { }; const ClientCredentials: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -412,6 +427,7 @@ const ClientCredentials: NodeType = { }; const AuthorizationCode: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, authorizationUrl: { type: 'string' }, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 7014ac1518..40324e440b 100755 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -68,7 +68,7 @@ export function listOf( export function mapOf( typeName: string, - opts: { description?: string; documentationLink?: string } = {} + opts: { description?: string; documentationLink?: string; extensionsPrefix?: string } = {} ) { return { name: `${typeName}Map`, diff --git a/packages/core/src/types/json-schema-draft7.shared.ts b/packages/core/src/types/json-schema-draft7.shared.ts index e704b095c1..6a81adf628 100644 --- a/packages/core/src/types/json-schema-draft7.shared.ts +++ b/packages/core/src/types/json-schema-draft7.shared.ts @@ -2,6 +2,7 @@ import { isMappingRef } from '../ref-utils.js'; import { listOf, type NodeType } from './index.js'; export const Schema: NodeType = { + extensionsPrefix: 'x-', properties: { $id: { type: 'string' }, $schema: { type: 'string' }, diff --git a/packages/core/src/types/oas2.ts b/packages/core/src/types/oas2.ts index 8401848d14..ce5c635c3d 100644 --- a/packages/core/src/types/oas2.ts +++ b/packages/core/src/types/oas2.ts @@ -71,6 +71,7 @@ const License: NodeType = { }; const Paths: NodeType = { + extensionsPrefix: 'x-', properties: {}, additionalProperties: (_value: unknown, key: string) => key.startsWith('/') ? 'PathItem' : undefined, @@ -215,6 +216,7 @@ const ParameterItems: NodeType = { }; const Responses: NodeType = { + extensionsPrefix: 'x-', properties: { default: 'Response', }, @@ -366,6 +368,12 @@ const Xml: NodeType = { extensionsPrefix: 'x-', }; +const Scopes: NodeType = { + extensionsPrefix: 'x-', + properties: {}, + additionalProperties: { type: 'string' }, +}; + const SecurityScheme: NodeType = { properties: { type: { enum: ['basic', 'apiKey', 'oauth2'] }, @@ -375,7 +383,7 @@ const SecurityScheme: NodeType = { flow: { enum: ['implicit', 'password', 'application', 'accessCode'] }, authorizationUrl: { type: 'string' }, tokenUrl: { type: 'string' }, - scopes: { type: 'object', additionalProperties: { type: 'string' } }, + scopes: 'Scopes', 'x-defaultClientId': { type: 'string' }, }, required(value) { @@ -472,6 +480,7 @@ export const Oas2Types = { NamedParameters: mapOf('Parameter'), NamedSecuritySchemes: mapOf('SecurityScheme'), SecurityScheme, + Scopes, XCodeSample, XCodeSampleList: listOf('XCodeSample'), XServerList: listOf('XServer'), diff --git a/packages/core/src/types/oas3.ts b/packages/core/src/types/oas3.ts index c23569005b..2d1efc5bef 100755 --- a/packages/core/src/types/oas3.ts +++ b/packages/core/src/types/oas3.ts @@ -213,6 +213,7 @@ const License: NodeType = { }; const Paths: NodeType = { + extensionsPrefix: 'x-', properties: {}, additionalProperties: (_value: unknown, key: string) => key.startsWith('/') ? 'PathItem' : undefined, @@ -515,6 +516,7 @@ const Header: NodeType = { }; const Responses: NodeType = { + extensionsPrefix: 'x-', properties: { default: 'Response' }, additionalProperties: (_v: unknown, key: string) => responseCodeRegexp.test(key) ? 'Response' : undefined, @@ -892,6 +894,7 @@ export const Oas3Types = { }), Operation, Callback: mapOf('PathItem', { + extensionsPrefix: 'x-', description: 'https://redocly.com/learn/openapi/openapi-visual-reference/callbacks#callback-object', }), diff --git a/packages/core/src/types/oas3_2.ts b/packages/core/src/types/oas3_2.ts index cb46a651a7..34839638ec 100755 --- a/packages/core/src/types/oas3_2.ts +++ b/packages/core/src/types/oas3_2.ts @@ -256,6 +256,7 @@ const Discriminator: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { ...Oas3_1Types.Components.properties, mediaTypes: 'NamedMediaTypes', diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index 294747bfe7..78cfd23b48 100644 --- a/packages/core/src/typings/common.ts +++ b/packages/core/src/typings/common.ts @@ -3,6 +3,7 @@ export interface StatsRow { total: number; color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan'; items?: Set; + counts?: Record; } export type OASStatsName = @@ -14,7 +15,8 @@ export type OASStatsName = | 'links' | 'schemas' | 'webhooks' - | 'parameters'; + | 'parameters' + | 'xExtensions'; export type AsyncAPIStatsName = | 'operations' @@ -23,7 +25,8 @@ export type AsyncAPIStatsName = | 'externalDocs' | 'channels' | 'schemas' - | 'parameters'; + | 'parameters' + | 'xExtensions'; export type StatsName = OASStatsName | AsyncAPIStatsName; export type OASStatsAccumulator = Record; diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 6f81f1488b..cececde4a2 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -172,7 +172,8 @@ export function walkDocument(opts: { type: NormalizedNodeType, location: Location, parent: any, - key: string | number + key: string | number, + isDeclaredExtension: boolean = false ) { const resolve: ResolveFn = (ref, from = currentLocation.source.absoluteRef) => { if (!isRef(ref)) return { location, node: ref }; @@ -246,14 +247,16 @@ export function walkDocument(opts: { if (resolvedNode !== undefined && resolvedLocation && type.name !== 'scalar') { 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); + const isExtensionKey = + type.extensionsPrefix !== undefined && propName.startsWith(type.extensionsPrefix); + const isDeclaredExtension = isExtensionKey && propType !== undefined; - if ( - propType === undefined && - type.extensionsPrefix && - propName.startsWith(type.extensionsPrefix) - ) { + if (propType === undefined) { + propType = isExtensionKey ? SpecExtension : type.additionalProperties; + } + if (typeof propType === 'function') propType = propType(value, propName); + if (isDeclaredExtension && !isNamedType(propType)) { + // a declared extension with a plain schema gets no typed walk — visit it as SpecExtension propType = SpecExtension; } @@ -270,15 +273,32 @@ export function walkDocument(opts: { return; } - walkNode(value, propType, loc.child([propName]), valueParent, propName); + walkNode( + value, + propType, + loc.child([propName]), + valueParent, + propName, + isDeclaredExtension && propType !== SpecExtension + ); }; currentLocation = resolvedLocation; - const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); + // primitives have no identity to dedupe by — use their location; objects dedupe by identity + const seenKey = + isPlainObject(resolvedNode) || Array.isArray(resolvedNode) + ? resolvedNode + : location.absolutePointer; + const isNodeSeen = seenNodesPerType[type.name]?.has?.(seenKey); let visitedBySome = false; - const currentEnterVisitors = + let currentEnterVisitors = combinedEnter[type.name] || anyEnter.concat(normalizedVisitors[type.name]?.enter || []); + if (isDeclaredExtension) { + currentEnterVisitors = currentEnterVisitors.concat( + normalizedVisitors.SpecExtension?.enter || [] + ); + } const activatedContexts: Array = []; const ignoreKey = `${currentLocation.absolutePointer}${currentLocation.pointer}`; @@ -346,7 +366,7 @@ export function walkDocument(opts: { if (visitedBySome || !isNodeSeen) { seenNodesPerType[type.name] = seenNodesPerType[type.name] || new Set(); - seenNodesPerType[type.name].add(resolvedNode); + seenNodesPerType[type.name].add(seenKey); if (Array.isArray(resolvedNode)) { const itemsType = type.items; @@ -376,8 +396,8 @@ export function walkDocument(opts: { props.push(...Object.keys(resolvedNode).filter((k) => !props.includes(k))); } else if (type.extensionsPrefix) { props.push( - ...Object.keys(resolvedNode).filter((k) => - k.startsWith(type.extensionsPrefix as string) + ...Object.keys(resolvedNode).filter( + (k) => k.startsWith(type.extensionsPrefix as string) && !props.includes(k) ) ); } @@ -408,8 +428,13 @@ export function walkDocument(opts: { } } - const currentLeaveVisitors = + let currentLeaveVisitors = combinedLeave[type.name] || (normalizedVisitors[type.name]?.leave || []).concat(anyLeave); + if (isDeclaredExtension) { + currentLeaveVisitors = (normalizedVisitors.SpecExtension?.leave || []).concat( + currentLeaveVisitors + ); + } for (const context of activatedContexts.reverse()) { if (context.isSkippedLevel) { diff --git a/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt b/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt index 3c2bbb7512..457d8f8c5d 100644 --- a/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt +++ b/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt @@ -7,6 +7,7 @@ 🎣 Webhooks: 0 👷 Operations: 2 🔖 Tags: 0 +🧩 Vendor Extensions: 0 Document: openapi.yaml stats: diff --git a/tests/e2e/stats/stats-async2-json/snapshot.txt b/tests/e2e/stats/stats-async2-json/snapshot.txt index 0a82e4236d..0148a97e29 100644 --- a/tests/e2e/stats/stats-async2-json/snapshot.txt +++ b/tests/e2e/stats/stats-async2-json/snapshot.txt @@ -13,7 +13,7 @@ }, "parameters": { "metric": "👉 Parameters", - "total": 0 + "total": 1 }, "channels": { "metric": "📡 Channels", @@ -26,6 +26,11 @@ "tags": { "metric": "🔖 Tags", "total": 2 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 0, + "counts": {} } } Document: async.yaml stats: diff --git a/tests/e2e/stats/stats-async2-stylish/snapshot.txt b/tests/e2e/stats/stats-async2-stylish/snapshot.txt index 215b1e80dd..9b9dbb434e 100644 --- a/tests/e2e/stats/stats-async2-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async2-stylish/snapshot.txt @@ -1,10 +1,11 @@ 🚗 References: 2 📦 External Documents: 1 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 1 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: async.yaml stats: diff --git a/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml b/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml index f3529f1ac2..157a63cdd5 100644 --- a/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml +++ b/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml @@ -15,6 +15,8 @@ channels: parameters: userId: description: User ID + orgId: + description: Organization ID messages: UserSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/tests/e2e/stats/stats-async3-stylish/snapshot.txt b/tests/e2e/stats/stats-async3-stylish/snapshot.txt index 686e3643e8..9345eda969 100644 --- a/tests/e2e/stats/stats-async3-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async3-stylish/snapshot.txt @@ -1,10 +1,11 @@ 🚗 References: 4 📦 External Documents: 1 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 2 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: asyncapi3.yaml stats: diff --git a/tests/e2e/stats/stats-extensions/asyncapi.yaml b/tests/e2e/stats/stats-extensions/asyncapi.yaml new file mode 100644 index 0000000000..6411c8260a --- /dev/null +++ b/tests/e2e/stats/stats-extensions/asyncapi.yaml @@ -0,0 +1,24 @@ +asyncapi: '2.6.0' +info: + title: AsyncAPI vendor extensions fixture + version: '1.0.0' + x-metadata: + team: Docs +tags: + - name: signup + x-displayName: Sign up events +channels: + user/signedup: + x-channel-ext: true + subscribe: + operationId: userSignedUp + x-internal: true + message: + x-internal: true + payload: + type: object + x-enumDescriptions: + FREE: Free tier + properties: + userId: + type: string diff --git a/tests/e2e/stats/stats-extensions/asyncapi3.yaml b/tests/e2e/stats/stats-extensions/asyncapi3.yaml new file mode 100644 index 0000000000..0b4f2f20e5 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/asyncapi3.yaml @@ -0,0 +1,19 @@ +asyncapi: 3.0.0 +info: + title: AsyncAPI 3 vendor extensions fixture + version: '1.0.0' +channels: + userSignedup: + x-channel-ext: 1 + address: user/signedup + messages: + userSignedUp: + x-msg-ext: a + payload: + type: object +operations: + onSignup: + x-op-ext: true + action: receive + channel: + $ref: '#/channels/userSignedup' diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml new file mode 100644 index 0000000000..4f0e5b87fe --- /dev/null +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -0,0 +1,89 @@ +openapi: 3.0.0 +info: + title: Vendor extensions fixture + version: '1.0' + x-metadata: + department: Platform + team: Docs + x-outer: + x-inner: 1 +x-tagGroups: + - name: Core + tags: + - group-tag-name +x-ignoredHeaderParameters: + - X-Trace-Id +x-servers: + - url: https://legacy.example.com +x-webhooks: + newPet: + post: + tags: + - webhook-tag + responses: + '200': + description: ok + x-query: + operationId: webhookQuery + responses: + '200': + description: ok +paths: + x-paths-ext: true + /a: + get: + operationId: a + x-codeSamples: + - lang: curl + source: curl https://example.com/a + x-badges: + - name: Beta + color: purple + - name: New + color: green + position: before + x-internal: true + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + $ref: '#/components/responses/SharedResponse' + x-query: + operationId: queryA + tags: + - query-tag + responses: + '200': + description: ok + /b: + get: + operationId: b + x-internal: true + x-codeSamples: + - lang: python + source: print("hi") + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + $ref: '#/components/responses/SharedResponse' + x-sibling-ext: true +components: + parameters: + Shared: + name: p + in: query + x-hideReplay: true + schema: + type: string + responses: + SharedResponse: + description: ok + schemas: + x-MySchema: + type: string + Pet: + type: object + properties: + x-trace-id: + type: string diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt new file mode 100644 index 0000000000..aa5bc01073 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt @@ -0,0 +1,19 @@ +🚗 References: 0 +📦 External Documents: 0 +📈 Schemas: 0 +👉 Parameters: 0 +📡 Channels: 1 +👷 Operations: 1 +🔖 Tags: 1 +🧩 Vendor Extensions: 5 + - x-channel-ext: 1 + - x-displayName: 1 + - x-enumDescriptions: 1 + - x-internal: 2 + - x-metadata: 1 + +Document: asyncapi.yaml stats: + + +asyncapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt new file mode 100644 index 0000000000..3df9cf9d42 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt @@ -0,0 +1,17 @@ +🚗 References: 1 +📦 External Documents: 0 +📈 Schemas: 0 +👉 Parameters: 0 +📡 Channels: 1 +👷 Operations: 1 +🔖 Tags: 0 +🧩 Vendor Extensions: 3 + - x-channel-ext: 1 + - x-msg-ext: 1 + - x-op-ext: 1 + +Document: asyncapi3.yaml stats: + + +asyncapi3.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt new file mode 100644 index 0000000000..08db2f358c --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -0,0 +1,62 @@ +{ + "refs": { + "metric": "🚗 References", + "total": 2 + }, + "externalDocs": { + "metric": "📦 External Documents", + "total": 0 + }, + "schemas": { + "metric": "📈 Schemas", + "total": 2 + }, + "parameters": { + "metric": "👉 Parameters", + "total": 1 + }, + "links": { + "metric": "🔗 Links", + "total": 0 + }, + "pathItems": { + "metric": "🔀 Path Items", + "total": 2 + }, + "webhooks": { + "metric": "🎣 Webhooks", + "total": 2 + }, + "operations": { + "metric": "👷 Operations", + "total": 3 + }, + "tags": { + "metric": "🔖 Tags", + "total": 2 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 13, + "counts": { + "x-badges": 1, + "x-codeSamples": 2, + "x-hideReplay": 1, + "x-ignoredHeaderParameters": 1, + "x-internal": 2, + "x-metadata": 1, + "x-outer": 1, + "x-paths-ext": 1, + "x-query": 2, + "x-servers": 1, + "x-sibling-ext": 1, + "x-tagGroups": 1, + "x-webhooks": 1 + } + } +} +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt new file mode 100644 index 0000000000..4b57e835fc --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -0,0 +1,35 @@ +| Feature | Count | +| --- | --- | +| 🚗 References | 2 | +| 📦 External Documents | 0 | +| 📈 Schemas | 2 | +| 👉 Parameters | 1 | +| 🔗 Links | 0 | +| 🔀 Path Items | 2 | +| 🎣 Webhooks | 2 | +| 👷 Operations | 3 | +| 🔖 Tags | 2 | +| 🧩 Vendor Extensions | 13 | + +#### 🧩 Vendor Extensions +| Extension | Count | +| --- | --- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-hideReplay | 1 | +| x-ignoredHeaderParameters | 1 | +| x-internal | 2 | +| x-metadata | 1 | +| x-outer | 1 | +| x-paths-ext | 1 | +| x-query | 2 | +| x-servers | 1 | +| x-sibling-ext | 1 | +| x-tagGroups | 1 | +| x-webhooks | 1 | + +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt new file mode 100644 index 0000000000..a46ef5ff3b --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -0,0 +1,29 @@ +🚗 References: 2 +📦 External Documents: 0 +📈 Schemas: 2 +👉 Parameters: 1 +🔗 Links: 0 +🔀 Path Items: 2 +🎣 Webhooks: 2 +👷 Operations: 3 +🔖 Tags: 2 +🧩 Vendor Extensions: 13 + - x-badges: 1 + - x-codeSamples: 2 + - x-hideReplay: 1 + - x-ignoredHeaderParameters: 1 + - x-internal: 2 + - x-metadata: 1 + - x-outer: 1 + - x-paths-ext: 1 + - x-query: 2 + - x-servers: 1 + - x-sibling-ext: 1 + - x-tagGroups: 1 + - x-webhooks: 1 + +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-json/snapshot.txt b/tests/e2e/stats/stats-json/snapshot.txt index 40cec1731c..3996cbe444 100644 --- a/tests/e2e/stats/stats-json/snapshot.txt +++ b/tests/e2e/stats/stats-json/snapshot.txt @@ -34,6 +34,11 @@ "tags": { "metric": "🔖 Tags", "total": 3 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 0, + "counts": {} } } Document: museum.yaml stats: diff --git a/tests/e2e/stats/stats-markdown/snapshot.txt b/tests/e2e/stats/stats-markdown/snapshot.txt index a901e9bb08..77c3627a0f 100644 --- a/tests/e2e/stats/stats-markdown/snapshot.txt +++ b/tests/e2e/stats/stats-markdown/snapshot.txt @@ -9,6 +9,7 @@ | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 0 | Document: museum.yaml stats: diff --git a/tests/e2e/stats/stats-stylish/snapshot.txt b/tests/e2e/stats/stats-stylish/snapshot.txt index f9acf6fe0d..91dac033ab 100644 --- a/tests/e2e/stats/stats-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-stylish/snapshot.txt @@ -7,6 +7,7 @@ 🎣 Webhooks: 0 👷 Operations: 8 🔖 Tags: 3 +🧩 Vendor Extensions: 0 Document: museum.yaml stats: diff --git a/tests/e2e/stats/stats.test.ts b/tests/e2e/stats/stats.test.ts index 2ae7473f06..5ac5c2e0b8 100644 --- a/tests/e2e/stats/stats.test.ts +++ b/tests/e2e/stats/stats.test.ts @@ -50,4 +50,45 @@ describe('stats', () => { const result = getCommandOutput(args, { testPath }); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); }); + + test('stats should report vendor extension counts (JSON format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-json.txt')); + }); + + test('stats should report vendor extension counts (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-stylish.txt')); + }); + + test('stats should report vendor extension counts for AsyncAPI (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'asyncapi.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-asyncapi-stylish.txt') + ); + }); + + test('stats should report vendor extension counts for AsyncAPI 3 (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'asyncapi3.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-asyncapi3-stylish.txt') + ); + }); + + test('stats should report vendor extension counts (Markdown format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=markdown']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-markdown.txt') + ); + }); });