diff --git a/.changeset/hip-crabs-help.md b/.changeset/hip-crabs-help.md new file mode 100644 index 000000000..1aceb3aab --- /dev/null +++ b/.changeset/hip-crabs-help.md @@ -0,0 +1,53 @@ +--- +'@hey-api/openapi-ts': minor +--- + +feat: upgraded input filters + +### Upgraded input filters + +Input filters now avoid generating invalid output without requiring you to specify every missing schema as in the previous releases. As part of this release, we changed the way filters are configured and removed the support for regular expressions. Let us know if regular expressions are still useful for you and want to bring them back! + +::: code-group + +```js [include] +export default { + input: { + // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path + filters: { + operations: { + include: ['GET /api/v1/foo'], // [!code ++] + }, + schemas: { + include: ['foo'], // [!code ++] + }, + }, + include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +```js [exclude] +export default { + input: { + // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path + exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] + filters: { + operations: { + exclude: ['GET /api/v1/foo'], // [!code ++] + }, + schemas: { + exclude: ['foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +::: diff --git a/docs/openapi-ts/configuration.md b/docs/openapi-ts/configuration.md index 472f6a807..00294e82a 100644 --- a/docs/openapi-ts/configuration.md +++ b/docs/openapi-ts/configuration.md @@ -193,15 +193,153 @@ You can also prevent your output from being linted by adding your output path to ## Filters -If you work with large specifications and want to generate output from their subset, you can use regular expressions to select the relevant definitions. Set `input.include` to match resource references to be included or `input.exclude` to match resource references to be excluded. When both regular expressions match the same definition, `input.exclude` takes precedence over `input.include`. +If you work with large specifications and want to generate output from their subset, you can use `input.filters` to select the relevant resources. + +### Operations + +Set `include` to match operations to be included or `exclude` to match operations to be excluded. When both rules match the same operation, `exclude` takes precedence over `include`. + +::: code-group + +```js [include] +export default { + input: { + filters: { + operations: { + include: ['GET /api/v1/foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +```js [exclude] +export default { + input: { + filters: { + operations: { + exclude: ['GET /api/v1/foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +::: + +### Tags + +Set `include` to match tags to be included or `exclude` to match tags to be excluded. When both rules match the same tag, `exclude` takes precedence over `include`. + +::: code-group + +```js [include] +export default { + input: { + filters: { + tags: { + include: ['v2'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +```js [exclude] +export default { + input: { + filters: { + tags: { + exclude: ['v1'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +::: + +### Deprecated + +You can filter out deprecated resources by setting `deprecated` to `false`. + +```js +export default { + input: { + filters: { + deprecated: false, // [!code ++] + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +### Schemas + +Set `include` to match schemas to be included or `exclude` to match schemas to be excluded. When both rules match the same schema, `exclude` takes precedence over `include`. + +::: code-group + +```js [include] +export default { + input: { + filters: { + schemas: { + include: ['Foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +```js [exclude] +export default { + input: { + filters: { + schemas: { + exclude: ['Foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +::: + +### Request Bodies + +Set `include` to match request bodies to be included or `exclude` to match request bodies to be excluded. When both rules match the same request body, `exclude` takes precedence over `include`. ::: code-group ```js [include] export default { input: { - // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path // [!code ++] - include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code ++] + filters: { + requestBodies: { + include: ['Payload'], // [!code ++] + }, + }, path: 'https://get.heyapi.dev/hey-api/backend', }, output: 'src/client', @@ -212,8 +350,11 @@ export default { ```js [exclude] export default { input: { - // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path // [!code ++] - exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code ++] + filters: { + requestBodies: { + exclude: ['Payload'], // [!code ++] + }, + }, path: 'https://get.heyapi.dev/hey-api/backend', }, output: 'src/client', @@ -223,6 +364,40 @@ export default { ::: +### Orphaned resources + +If you only want to exclude orphaned resources, set `orphans` to `false`. This is the default value when combined with any other filters. If this isn't the desired behavior, you may want to set `orphans` to `true` to always preserve unused resources. + +```js +export default { + input: { + filters: { + orphans: false, // [!code ++] + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +### Order + +For performance reasons, we don't preserve the original order when filtering out resources. If maintaining the original order is important to you, set `preserveOrder` to `true`. + +```js +export default { + input: { + filters: { + preserveOrder: true, // [!code ++] + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + ## Watch Mode ::: warning diff --git a/docs/openapi-ts/migrating.md b/docs/openapi-ts/migrating.md index 368096d2e..76a975d57 100644 --- a/docs/openapi-ts/migrating.md +++ b/docs/openapi-ts/migrating.md @@ -27,6 +27,56 @@ This config option is deprecated and will be removed in favor of [clients](./cli This config option is deprecated and will be removed. +## v0.68.0 + +### Upgraded input filters + +Input filters now avoid generating invalid output without requiring you to specify every missing schema as in the previous releases. As part of this release, we changed the way filters are configured and removed the support for regular expressions. Let us know if regular expressions are still useful for you and want to bring them back! + +::: code-group + +```js [include] +export default { + input: { + // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path + filters: { + operations: { + include: ['GET /api/v1/foo'], // [!code ++] + }, + schemas: { + include: ['foo'], // [!code ++] + }, + }, + include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +```js [exclude] +export default { + input: { + // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path + exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] + filters: { + operations: { + exclude: ['GET /api/v1/foo'], // [!code ++] + }, + schemas: { + exclude: ['foo'], // [!code ++] + }, + }, + path: 'https://get.heyapi.dev/hey-api/backend', + }, + output: 'src/client', + plugins: ['@hey-api/client-fetch'], +}; +``` + +::: + ## v0.67.0 ### Respecting `moduleResolution` value in `tsconfig.json` diff --git a/packages/openapi-ts-tests/test/2.0.x.test.ts b/packages/openapi-ts-tests/test/2.0.x.test.ts index 2db57e6b1..33ded97d1 100644 --- a/packages/openapi-ts-tests/test/2.0.x.test.ts +++ b/packages/openapi-ts-tests/test/2.0.x.test.ts @@ -224,7 +224,9 @@ describe(`OpenAPI ${version}`, () => { { config: createConfig({ input: { - exclude: ['@deprecated'], + filters: { + deprecated: false, + }, path: 'exclude-deprecated.yaml', }, output: 'exclude-deprecated', diff --git a/packages/openapi-ts-tests/test/3.0.x.test.ts b/packages/openapi-ts-tests/test/3.0.x.test.ts index 97fcceac5..53efa7b67 100644 --- a/packages/openapi-ts-tests/test/3.0.x.test.ts +++ b/packages/openapi-ts-tests/test/3.0.x.test.ts @@ -426,7 +426,9 @@ describe(`OpenAPI ${version}`, () => { { config: createConfig({ input: { - exclude: ['@deprecated'], + filters: { + deprecated: false, + }, path: 'exclude-deprecated.yaml', }, output: 'exclude-deprecated', diff --git a/packages/openapi-ts-tests/test/3.1.x.test.ts b/packages/openapi-ts-tests/test/3.1.x.test.ts index 9ef789e2c..eb3ea9a32 100644 --- a/packages/openapi-ts-tests/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/test/3.1.x.test.ts @@ -440,7 +440,9 @@ describe(`OpenAPI ${version}`, () => { { config: createConfig({ input: { - exclude: ['@deprecated'], + filters: { + deprecated: false, + }, path: 'exclude-deprecated.yaml', }, output: 'exclude-deprecated', diff --git a/packages/openapi-ts-tests/test/openapi-ts.config.ts b/packages/openapi-ts-tests/test/openapi-ts.config.ts index 6d967ba47..835752c89 100644 --- a/packages/openapi-ts-tests/test/openapi-ts.config.ts +++ b/packages/openapi-ts-tests/test/openapi-ts.config.ts @@ -16,17 +16,22 @@ export default defineConfig(() => { // experimentalParser: false, input: { // branch: 'main', - // exclude: [ - // '^#/components/schemas/ModelWithCircularReference$', - // '@deprecated', - // ], // fetch: { // headers: { // 'x-foo': 'bar', // }, // }, - // include: - // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', + filters: { + deprecated: false, + // operations: { + // include: ['POST /foo'], + // }, + orphans: false, + // preserveOrder: true, + // tags: { + // exclude: ['bar'], + // }, + }, // organization: 'hey-api', // path: { // components: {}, @@ -115,7 +120,7 @@ export default defineConfig(() => { }, { exportFromIndex: true, - name: '@tanstack/react-query', + // name: '@tanstack/react-query', }, { // exportFromIndex: true, diff --git a/packages/openapi-ts-tests/test/spec/3.1.x/parser-filters.yaml b/packages/openapi-ts-tests/test/spec/3.1.x/parser-filters.yaml new file mode 100644 index 000000000..1ab07c7d3 --- /dev/null +++ b/packages/openapi-ts-tests/test/spec/3.1.x/parser-filters.yaml @@ -0,0 +1,90 @@ +openapi: 3.1.1 +info: + title: OpenAPI 3.1.1 parser filters example + version: 1 +paths: + /foo: + post: + deprecated: true + tags: + - foo + - bar + requestBody: + $ref: '#/components/requestBodies/Foo' + responses: + '200': + content: + '*/*': + schema: + $ref: '#/components/schemas/Foo' + description: OK + put: + tags: + - bar + requestBody: + $ref: '#/components/requestBodies/Bar' + responses: + '200': + content: + '*/*': + schema: + $ref: '#/components/schemas/Baz' + description: OK + /bar: + post: + requestBody: + content: + 'application/json': + schema: + $ref: '#/components/schemas/Bar' + required: true + responses: + '200': + content: + '*/*': + schema: + $ref: '#/components/schemas/Bar' + description: OK +components: + requestBodies: + Foo: + required: true + description: POST /foo payload + content: + 'application/json': + schema: + type: object + properties: + foo: + $ref: '#/components/schemas/Bar' + Bar: + required: true + description: PUT /foo payload + content: + 'application/json': + schema: + type: object + properties: + foo: + $ref: '#/components/schemas/Foo' + schemas: + Foo: + type: object + properties: + foo: + $ref: '#/components/schemas/Bar' + Bar: + type: object + properties: + bar: + $ref: '#/components/schemas/Baz' + Baz: + type: object + properties: + baz: + type: string + Orphan: + type: object + properties: + orphan: + type: string diff --git a/packages/openapi-ts/src/openApi/2.0.x/parser/filter.ts b/packages/openapi-ts/src/openApi/2.0.x/parser/filter.ts new file mode 100644 index 000000000..ae6aaaaa9 --- /dev/null +++ b/packages/openapi-ts/src/openApi/2.0.x/parser/filter.ts @@ -0,0 +1,75 @@ +import { addNamespace, removeNamespace } from '../../shared/utils/graph'; +import { httpMethods } from '../../shared/utils/operation'; +import type { + OpenApiV2_0_X, + OperationObject, + PathItemObject, + PathsObject, +} from '../types/spec'; + +/** + * Replace source spec with filtered version. + */ +export const filterSpec = ({ + operations, + preserveOrder, + schemas, + spec, +}: { + operations: Set; + preserveOrder: boolean; + requestBodies: Set; + schemas: Set; + spec: OpenApiV2_0_X; +}) => { + if (spec.definitions) { + const filtered: typeof spec.definitions = {}; + + if (preserveOrder) { + for (const [name, source] of Object.entries(spec.definitions)) { + if (schemas.has(addNamespace('schema', name))) { + filtered[name] = source; + } + } + } else { + for (const key of schemas) { + const { name } = removeNamespace(key); + const source = spec.definitions[name]; + if (source) { + filtered[name] = source; + } + } + } + + spec.definitions = filtered; + } + + if (spec.paths) { + for (const entry of Object.entries(spec.paths)) { + const path = entry[0] as keyof PathsObject; + const pathItem = entry[1] as PathItemObject; + + for (const method of httpMethods) { + // @ts-expect-error + const operation = pathItem[method] as OperationObject; + if (!operation) { + continue; + } + + const key = addNamespace( + 'operation', + `${method.toUpperCase()} ${path}`, + ); + if (!operations.has(key)) { + // @ts-expect-error + delete pathItem[method]; + } + } + + // remove paths that have no operations left + if (!Object.keys(pathItem).length) { + delete spec.paths[path]; + } + } + } +}; diff --git a/packages/openapi-ts/src/openApi/2.0.x/parser/index.ts b/packages/openapi-ts/src/openApi/2.0.x/parser/index.ts index 43f40ab76..bbd05562d 100644 --- a/packages/openapi-ts/src/openApi/2.0.x/parser/index.ts +++ b/packages/openapi-ts/src/openApi/2.0.x/parser/index.ts @@ -1,6 +1,11 @@ import type { IR } from '../../../ir/types'; import type { State } from '../../shared/types/state'; -import { canProcessRef, createFilters } from '../../shared/utils/filter'; +import { + createFilteredDependencies, + createFilters, + hasFilters, +} from '../../shared/utils/filter'; +import { createGraph } from '../../shared/utils/graph'; import { mergeParametersObjects } from '../../shared/utils/parameter'; import type { OpenApiV2_0_X, @@ -9,6 +14,7 @@ import type { PathsObject, SecuritySchemeObject, } from '../types/spec'; +import { filterSpec } from './filter'; import { parseOperation } from './operation'; import { parametersArrayToObject } from './parameter'; import { parseSchema } from './schema'; @@ -18,23 +24,23 @@ type PathKeys = keyof T extends infer K ? (K extends `/${string}` ? K : never) : never; export const parseV2_0_X = (context: IR.Context) => { + if (hasFilters(context.config.input.filters)) { + const graph = createGraph(context.spec); + const filters = createFilters(context.config.input.filters); + const sets = createFilteredDependencies({ filters, graph }); + filterSpec({ + ...sets, + preserveOrder: filters.preserveOrder, + spec: context.spec, + }); + } + const state: State = { ids: new Map(), operationIds: new Map(), }; const securitySchemesMap = new Map(); - const excludeFilters = createFilters(context.config.input.exclude); - const includeFilters = createFilters(context.config.input.include); - - const shouldProcessRef = ($ref: string, schema: Record) => - canProcessRef({ - $ref, - excludeFilters, - includeFilters, - schema, - }); - for (const name in context.spec.securityDefinitions) { const securitySchemeObject = context.spec.securityDefinitions[name]!; securitySchemesMap.set(name, securitySchemeObject); @@ -45,10 +51,6 @@ export const parseV2_0_X = (context: IR.Context) => { const $ref = `#/definitions/${name}`; const schema = context.spec.definitions[name]!; - if (!shouldProcessRef($ref, schema)) { - continue; - } - parseSchema({ $ref, context, @@ -95,11 +97,7 @@ export const parseV2_0_X = (context: IR.Context) => { state, }; - const $refDelete = `#/paths${path}/delete`; - if ( - finalPathItem.delete && - shouldProcessRef($refDelete, finalPathItem.delete) - ) { + if (finalPathItem.delete) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -119,8 +117,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refGet = `#/paths${path}/get`; - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { + if (finalPathItem.get) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -140,8 +137,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refHead = `#/paths${path}/head`; - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { + if (finalPathItem.head) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -161,11 +157,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refOptions = `#/paths${path}/options`; - if ( - finalPathItem.options && - shouldProcessRef($refOptions, finalPathItem.options) - ) { + if (finalPathItem.options) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -185,11 +177,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refPatch = `#/paths${path}/patch`; - if ( - finalPathItem.patch && - shouldProcessRef($refPatch, finalPathItem.patch) - ) { + if (finalPathItem.patch) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -209,8 +197,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refPost = `#/paths${path}/post`; - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { + if (finalPathItem.post) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, @@ -230,8 +217,7 @@ export const parseV2_0_X = (context: IR.Context) => { }); } - const $refPut = `#/paths${path}/put`; - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { + if (finalPathItem.put) { const parameters = mergeParametersObjects({ source: parametersArrayToObject({ context, diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/filter.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/filter.ts new file mode 100644 index 000000000..c1df8c459 --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/filter.ts @@ -0,0 +1,95 @@ +import { addNamespace, removeNamespace } from '../../shared/utils/graph'; +import { httpMethods } from '../../shared/utils/operation'; +import type { OpenApiV3_0_X, PathItemObject, PathsObject } from '../types/spec'; + +/** + * Replace source spec with filtered version. + */ +export const filterSpec = ({ + operations, + preserveOrder, + requestBodies, + schemas, + spec, +}: { + operations: Set; + preserveOrder: boolean; + requestBodies: Set; + schemas: Set; + spec: OpenApiV3_0_X; +}) => { + if (spec.components) { + if (spec.components.requestBodies) { + const filtered: typeof spec.components.requestBodies = {}; + + if (preserveOrder) { + for (const [name, source] of Object.entries( + spec.components.requestBodies, + )) { + if (requestBodies.has(addNamespace('body', name))) { + filtered[name] = source; + } + } + } else { + for (const key of requestBodies) { + const { name } = removeNamespace(key); + const source = spec.components.requestBodies[name]; + if (source) { + filtered[name] = source; + } + } + } + + spec.components.requestBodies = filtered; + } + + if (spec.components.schemas) { + const filtered: typeof spec.components.schemas = {}; + + if (preserveOrder) { + for (const [name, source] of Object.entries(spec.components.schemas)) { + if (schemas.has(addNamespace('schema', name))) { + filtered[name] = source; + } + } + } else { + for (const key of schemas) { + const { name } = removeNamespace(key); + const source = spec.components.schemas[name]; + if (source) { + filtered[name] = source; + } + } + } + + spec.components.schemas = filtered; + } + } + + if (spec.paths) { + for (const entry of Object.entries(spec.paths)) { + const path = entry[0] as keyof PathsObject; + const pathItem = entry[1] as PathItemObject; + + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const key = addNamespace( + 'operation', + `${method.toUpperCase()} ${path}`, + ); + if (!operations.has(key)) { + delete pathItem[method]; + } + } + + // remove paths that have no operations left + if (!Object.keys(pathItem).length) { + delete spec.paths[path]; + } + } + } +}; diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts index f842719c1..7e6eb47cf 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts @@ -1,6 +1,11 @@ import type { IR } from '../../../ir/types'; import type { State } from '../../shared/types/state'; -import { canProcessRef, createFilters } from '../../shared/utils/filter'; +import { + createFilteredDependencies, + createFilters, + hasFilters, +} from '../../shared/utils/filter'; +import { createGraph } from '../../shared/utils/graph'; import { mergeParametersObjects } from '../../shared/utils/parameter'; import type { OpenApiV3_0_X, @@ -10,6 +15,7 @@ import type { RequestBodyObject, SecuritySchemeObject, } from '../types/spec'; +import { filterSpec } from './filter'; import { parseOperation } from './operation'; import { parametersArrayToObject, parseParameter } from './parameter'; import { parseRequestBody } from './requestBody'; @@ -17,23 +23,23 @@ import { parseSchema } from './schema'; import { parseServers } from './server'; export const parseV3_0_X = (context: IR.Context) => { + if (hasFilters(context.config.input.filters)) { + const graph = createGraph(context.spec); + const filters = createFilters(context.config.input.filters); + const sets = createFilteredDependencies({ filters, graph }); + filterSpec({ + ...sets, + preserveOrder: filters.preserveOrder, + spec: context.spec, + }); + } + const state: State = { ids: new Map(), operationIds: new Map(), }; const securitySchemesMap = new Map(); - const excludeFilters = createFilters(context.config.input.exclude); - const includeFilters = createFilters(context.config.input.include); - - const shouldProcessRef = ($ref: string, schema: Record) => - canProcessRef({ - $ref, - excludeFilters, - includeFilters, - schema, - }); - // TODO: parser - handle more component types, old parser handles only parameters and schemas if (context.spec.components) { for (const name in context.spec.components.securitySchemes) { @@ -54,10 +60,6 @@ export const parseV3_0_X = (context: IR.Context) => { ? context.resolveRef(parameterOrReference.$ref) : parameterOrReference; - if (!shouldProcessRef($ref, parameter)) { - continue; - } - parseParameter({ $ref, context, @@ -74,10 +76,6 @@ export const parseV3_0_X = (context: IR.Context) => { ? context.resolveRef(requestBodyOrReference.$ref) : requestBodyOrReference; - if (!shouldProcessRef($ref, requestBody)) { - continue; - } - parseRequestBody({ $ref, context, @@ -89,10 +87,6 @@ export const parseV3_0_X = (context: IR.Context) => { const $ref = `#/components/schemas/${name}`; const schema = context.spec.components.schemas[name]!; - if (!shouldProcessRef($ref, schema)) { - continue; - } - parseSchema({ $ref, context, @@ -138,11 +132,7 @@ export const parseV3_0_X = (context: IR.Context) => { state, }; - const $refDelete = `#/paths${path}/delete`; - if ( - finalPathItem.delete && - shouldProcessRef($refDelete, finalPathItem.delete) - ) { + if (finalPathItem.delete) { parseOperation({ ...operationArgs, method: 'delete', @@ -160,8 +150,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refGet = `#/paths${path}/get`; - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { + if (finalPathItem.get) { parseOperation({ ...operationArgs, method: 'get', @@ -179,8 +168,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refHead = `#/paths${path}/head`; - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { + if (finalPathItem.head) { parseOperation({ ...operationArgs, method: 'head', @@ -198,11 +186,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refOptions = `#/paths${path}/options`; - if ( - finalPathItem.options && - shouldProcessRef($refOptions, finalPathItem.options) - ) { + if (finalPathItem.options) { parseOperation({ ...operationArgs, method: 'options', @@ -220,11 +204,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refPatch = `#/paths${path}/patch`; - if ( - finalPathItem.patch && - shouldProcessRef($refPatch, finalPathItem.patch) - ) { + if (finalPathItem.patch) { parseOperation({ ...operationArgs, method: 'patch', @@ -242,8 +222,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refPost = `#/paths${path}/post`; - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { + if (finalPathItem.post) { parseOperation({ ...operationArgs, method: 'post', @@ -261,8 +240,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refPut = `#/paths${path}/put`; - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { + if (finalPathItem.put) { parseOperation({ ...operationArgs, method: 'put', @@ -280,11 +258,7 @@ export const parseV3_0_X = (context: IR.Context) => { }); } - const $refTrace = `#/paths${path}/trace`; - if ( - finalPathItem.trace && - shouldProcessRef($refTrace, finalPathItem.trace) - ) { + if (finalPathItem.trace) { parseOperation({ ...operationArgs, method: 'trace', diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/filter.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/filter.ts new file mode 100644 index 000000000..6f0b69aaa --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/filter.ts @@ -0,0 +1,95 @@ +import { addNamespace, removeNamespace } from '../../shared/utils/graph'; +import { httpMethods } from '../../shared/utils/operation'; +import type { OpenApiV3_1_X, PathItemObject, PathsObject } from '../types/spec'; + +/** + * Replace source spec with filtered version. + */ +export const filterSpec = ({ + operations, + preserveOrder, + requestBodies, + schemas, + spec, +}: { + operations: Set; + preserveOrder: boolean; + requestBodies: Set; + schemas: Set; + spec: OpenApiV3_1_X; +}) => { + if (spec.components) { + if (spec.components.requestBodies) { + const filtered: typeof spec.components.requestBodies = {}; + + if (preserveOrder) { + for (const [name, source] of Object.entries( + spec.components.requestBodies, + )) { + if (requestBodies.has(addNamespace('body', name))) { + filtered[name] = source; + } + } + } else { + for (const key of requestBodies) { + const { name } = removeNamespace(key); + const source = spec.components.requestBodies[name]; + if (source) { + filtered[name] = source; + } + } + } + + spec.components.requestBodies = filtered; + } + + if (spec.components.schemas) { + const filtered: typeof spec.components.schemas = {}; + + if (preserveOrder) { + for (const [name, source] of Object.entries(spec.components.schemas)) { + if (schemas.has(addNamespace('schema', name))) { + filtered[name] = source; + } + } + } else { + for (const key of schemas) { + const { name } = removeNamespace(key); + const source = spec.components.schemas[name]; + if (source) { + filtered[name] = source; + } + } + } + + spec.components.schemas = filtered; + } + } + + if (spec.paths) { + for (const entry of Object.entries(spec.paths)) { + const path = entry[0] as keyof PathsObject; + const pathItem = entry[1] as PathItemObject; + + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const key = addNamespace( + 'operation', + `${method.toUpperCase()} ${path}`, + ); + if (!operations.has(key)) { + delete pathItem[method]; + } + } + + // remove paths that have no operations left + if (!Object.keys(pathItem).length) { + delete spec.paths[path]; + } + } + } +}; diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts index 260fb47f5..301f0be69 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts @@ -1,6 +1,11 @@ import type { IR } from '../../../ir/types'; import type { State } from '../../shared/types/state'; -import { canProcessRef, createFilters } from '../../shared/utils/filter'; +import { + createFilteredDependencies, + createFilters, + hasFilters, +} from '../../shared/utils/filter'; +import { createGraph } from '../../shared/utils/graph'; import { mergeParametersObjects } from '../../shared/utils/parameter'; import type { OpenApiV3_1_X, @@ -10,29 +15,31 @@ import type { RequestBodyObject, SecuritySchemeObject, } from '../types/spec'; +import { filterSpec } from './filter'; import { parseOperation } from './operation'; import { parametersArrayToObject, parseParameter } from './parameter'; import { parseRequestBody } from './requestBody'; import { parseSchema } from './schema'; import { parseServers } from './server'; + export const parseV3_1_X = (context: IR.Context) => { + if (hasFilters(context.config.input.filters)) { + const graph = createGraph(context.spec); + const filters = createFilters(context.config.input.filters); + const sets = createFilteredDependencies({ filters, graph }); + filterSpec({ + ...sets, + preserveOrder: filters.preserveOrder, + spec: context.spec, + }); + } + const state: State = { ids: new Map(), operationIds: new Map(), }; const securitySchemesMap = new Map(); - const excludeFilters = createFilters(context.config.input.exclude); - const includeFilters = createFilters(context.config.input.include); - - const shouldProcessRef = ($ref: string, schema: Record) => - canProcessRef({ - $ref, - excludeFilters, - includeFilters, - schema, - }); - // TODO: parser - handle more component types, old parser handles only parameters and schemas if (context.spec.components) { for (const name in context.spec.components.securitySchemes) { @@ -53,10 +60,6 @@ export const parseV3_1_X = (context: IR.Context) => { ? context.resolveRef(parameterOrReference.$ref) : parameterOrReference; - if (!shouldProcessRef($ref, parameter)) { - continue; - } - parseParameter({ $ref, context, @@ -73,10 +76,6 @@ export const parseV3_1_X = (context: IR.Context) => { ? context.resolveRef(requestBodyOrReference.$ref) : requestBodyOrReference; - if (!shouldProcessRef($ref, requestBody)) { - continue; - } - parseRequestBody({ $ref, context, @@ -88,10 +87,6 @@ export const parseV3_1_X = (context: IR.Context) => { const $ref = `#/components/schemas/${name}`; const schema = context.spec.components.schemas[name]!; - if (!shouldProcessRef($ref, schema)) { - continue; - } - parseSchema({ $ref, context, @@ -130,11 +125,7 @@ export const parseV3_1_X = (context: IR.Context) => { state, }; - const $refDelete = `#/paths${path}/delete`; - if ( - finalPathItem.delete && - shouldProcessRef($refDelete, finalPathItem.delete) - ) { + if (finalPathItem.delete) { parseOperation({ ...operationArgs, method: 'delete', @@ -152,8 +143,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refGet = `#/paths${path}/get`; - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { + if (finalPathItem.get) { parseOperation({ ...operationArgs, method: 'get', @@ -171,8 +161,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refHead = `#/paths${path}/head`; - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { + if (finalPathItem.head) { parseOperation({ ...operationArgs, method: 'head', @@ -190,11 +179,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refOptions = `#/paths${path}/options`; - if ( - finalPathItem.options && - shouldProcessRef($refOptions, finalPathItem.options) - ) { + if (finalPathItem.options) { parseOperation({ ...operationArgs, method: 'options', @@ -212,11 +197,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refPatch = `#/paths${path}/patch`; - if ( - finalPathItem.patch && - shouldProcessRef($refPatch, finalPathItem.patch) - ) { + if (finalPathItem.patch) { parseOperation({ ...operationArgs, method: 'patch', @@ -234,8 +215,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refPost = `#/paths${path}/post`; - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { + if (finalPathItem.post) { parseOperation({ ...operationArgs, method: 'post', @@ -253,8 +233,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refPut = `#/paths${path}/put`; - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { + if (finalPathItem.put) { parseOperation({ ...operationArgs, method: 'put', @@ -272,11 +251,7 @@ export const parseV3_1_X = (context: IR.Context) => { }); } - const $refTrace = `#/paths${path}/trace`; - if ( - finalPathItem.trace && - shouldProcessRef($refTrace, finalPathItem.trace) - ) { + if (finalPathItem.trace) { parseOperation({ ...operationArgs, method: 'trace', diff --git a/packages/openapi-ts/src/openApi/shared/utils/filter.ts b/packages/openapi-ts/src/openApi/shared/utils/filter.ts index d226aab8a..83c0d4048 100644 --- a/packages/openapi-ts/src/openApi/shared/utils/filter.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/filter.ts @@ -1,85 +1,460 @@ -type Filter = RegExp | ReadonlyArray; -type Filters = ReadonlyArray | undefined; +import type { Config } from '../../../types/config'; +import type { Graph } from './graph'; +import { addNamespace, removeNamespace } from './graph'; -const isFiltersMatch = ({ - $ref, +type FiltersConfigToState = { + [K in keyof T]-?: NonNullable extends ReadonlyArray + ? Set + : NonNullable extends object + ? FiltersConfigToState> + : T[K]; +}; + +export type Filters = FiltersConfigToState< + NonNullable +>; + +export const createFilters = (config: Config['input']['filters']): Filters => { + const filters: Filters = { + deprecated: config?.deprecated ?? true, + operations: { + exclude: new Set( + config?.operations?.exclude?.map((value) => + addNamespace('operation', value), + ), + ), + include: new Set( + config?.operations?.include?.map((value) => + addNamespace('operation', value), + ), + ), + }, + orphans: config?.orphans ?? false, + preserveOrder: config?.preserveOrder ?? false, + requestBodies: { + exclude: new Set( + config?.requestBodies?.exclude?.map((value) => + addNamespace('body', value), + ), + ), + include: new Set( + config?.requestBodies?.include?.map((value) => + addNamespace('body', value), + ), + ), + }, + schemas: { + exclude: new Set( + config?.schemas?.exclude?.map((value) => addNamespace('schema', value)), + ), + include: new Set( + config?.schemas?.include?.map((value) => addNamespace('schema', value)), + ), + }, + tags: { + exclude: new Set(config?.tags?.exclude), + include: new Set(config?.tags?.include), + }, + }; + return filters; +}; + +export const hasFilters = (config: Config['input']['filters']): boolean => { + if (!config) { + return false; + } + + // we explicitly want to strip orphans or deprecated + if (config.orphans === false || config.deprecated === false) { + return true; + } + + return Boolean( + config.operations?.exclude?.length || + config.operations?.include?.length || + config.requestBodies?.exclude?.length || + config.requestBodies?.include?.length || + config.schemas?.exclude?.length || + config.schemas?.include?.length || + config.tags?.exclude?.length || + config.tags?.include?.length, + ); +}; + +/** + * Collect operations that satisfy the include/exclude filters and schema dependencies. + */ +const collectOperations = ({ filters, - schema, + graph, + requestBodies, + schemas, }: { - $ref: string; - filters: NonNullable; - schema: Record; -}): boolean => { - for (const filter of filters) { - if (filter instanceof RegExp) { - filter.lastIndex = 0; - if (filter.test($ref)) { - return true; - } - } else { - const field = filter[0] || ''; - const value = filter[1]; - if (value === undefined) { - if (schema[field]) { - return true; + filters: Filters; + graph: Graph; + requestBodies: Set; + schemas: Set; +}): { + operations: Set; +} => { + const finalSet = new Set(); + const initialSet = filters.operations.include.size + ? filters.operations.include + : new Set(graph.operations.keys()); + const stack = [...initialSet]; + while (stack.length) { + const key = stack.pop()!; + + if (filters.operations.exclude.has(key) || finalSet.has(key)) { + continue; + } + + const node = graph.operations.get(key); + + if (!node) { + continue; + } + + if (!filters.deprecated && node.deprecated) { + continue; + } + + if ( + filters.tags.exclude.size && + node.tags.size && + [...filters.tags.exclude].some((tag) => node.tags.has(tag)) + ) { + continue; + } + + if ( + filters.tags.include.size && + !new Set([...filters.tags.include].filter((tag) => node.tags.has(tag))) + .size + ) { + continue; + } + + // skip operation if it references any component not included + if ( + [...node.dependencies].some((dependency) => { + const { namespace } = removeNamespace(dependency); + switch (namespace) { + case 'body': + return !requestBodies.has(dependency); + case 'schema': + return !schemas.has(dependency); + default: + return false; + } + }) + ) { + continue; + } + + finalSet.add(key); + } + return { operations: finalSet }; +}; + +/** + * Collect requestBodies that satisfy the include/exclude filters and schema dependencies. + */ +const collectRequestBodies = ({ + filters, + graph, + schemas, +}: { + filters: Filters; + graph: Graph; + schemas: Set; +}): { + requestBodies: Set; +} => { + const finalSet = new Set(); + const initialSet = filters.requestBodies.include.size + ? filters.requestBodies.include + : new Set(graph.requestBodies.keys()); + const stack = [...initialSet]; + while (stack.length) { + const key = stack.pop()!; + + if (filters.requestBodies.exclude.has(key) || finalSet.has(key)) { + continue; + } + + const node = graph.requestBodies.get(key); + + if (!node) { + continue; + } + + if (!filters.deprecated && node.deprecated) { + continue; + } + + finalSet.add(key); + + if (!node.dependencies.size) { + continue; + } + + for (const dependency of node.dependencies) { + const { namespace } = removeNamespace(dependency); + switch (namespace) { + case 'body': { + if (filters.requestBodies.exclude.has(dependency)) { + finalSet.delete(key); + } else if (!finalSet.has(dependency)) { + stack.push(dependency); + } + break; + } + case 'schema': { + if (filters.schemas.exclude.has(dependency)) { + finalSet.delete(key); + } else if (!schemas.has(dependency)) { + schemas.add(dependency); + } + break; } - } else if (schema[field] === value) { - return true; } } } + return { requestBodies: finalSet }; +}; - return false; +/** + * Collect schemas that satisfy the include/exclude filters. + */ +const collectSchemas = ({ + filters, + graph, +}: { + filters: Filters; + graph: Graph; +}): { + schemas: Set; +} => { + const finalSet = new Set(); + const initialSet = filters.schemas.include.size + ? filters.schemas.include + : new Set(graph.schemas.keys()); + const stack = [...initialSet]; + while (stack.length) { + const key = stack.pop()!; + + if (filters.schemas.exclude.has(key) || finalSet.has(key)) { + continue; + } + + const node = graph.schemas.get(key); + + if (!node) { + continue; + } + + if (!filters.deprecated && node.deprecated) { + continue; + } + + finalSet.add(key); + + if (!node.dependencies.size) { + continue; + } + + for (const dependency of node.dependencies) { + const { namespace } = removeNamespace(dependency); + switch (namespace) { + case 'schema': { + if ( + !finalSet.has(dependency) && + !filters.schemas.exclude.has(dependency) + ) { + stack.push(dependency); + } + break; + } + } + } + } + return { schemas: finalSet }; }; /** - * Exclude takes precedence over include. + * Drop request bodies that depend on already excluded request bodies. */ -export const canProcessRef = ({ - excludeFilters, - includeFilters, - ...state +const dropExcludedRequestBodies = ({ + filters, + graph, + requestBodies, }: { - $ref: string; - excludeFilters: Filters; - includeFilters: Filters; - schema: Record; -}): boolean => { - if (!excludeFilters && !includeFilters) { - return true; + filters: Filters; + graph: Graph; + requestBodies: Set; +}): void => { + if (!filters.requestBodies.exclude.size) { + return; } - if (excludeFilters) { - if (isFiltersMatch({ ...state, filters: excludeFilters })) { - return false; + for (const key of requestBodies) { + const node = graph.requestBodies.get(key); + + if (!node?.dependencies.size) { + continue; + } + + for (const excludedKey of filters.requestBodies.exclude) { + if (node.dependencies.has(excludedKey)) { + requestBodies.delete(key); + break; + } } } +}; - if (includeFilters) { - return isFiltersMatch({ ...state, filters: includeFilters }); +/** + * Drop schemas that depend on already excluded schemas. + */ +const dropExcludedSchemas = ({ + filters, + graph, + schemas, +}: { + filters: Filters; + graph: Graph; + schemas: Set; +}): void => { + if (!filters.schemas.exclude.size) { + return; } - return true; -}; + for (const key of schemas) { + const node = graph.schemas.get(key); -const createFilter = (matcher: string): Filter => { - if (matcher.startsWith('@')) { - return matcher.slice(1).split(':'); + if (!node?.dependencies.size) { + continue; + } + + for (const excludedKey of filters.schemas.exclude) { + if (node.dependencies.has(excludedKey)) { + schemas.delete(key); + break; + } + } } +}; - return new RegExp(matcher); +const dropOrphans = ({ + operationDependencies, + requestBodies, + schemas, +}: { + operationDependencies: Set; + requestBodies: Set; + schemas: Set; +}) => { + for (const key of schemas) { + if (!operationDependencies.has(key)) { + schemas.delete(key); + } + } + for (const key of requestBodies) { + if (!operationDependencies.has(key)) { + requestBodies.delete(key); + } + } }; -export const createFilters = ( - matchers: ReadonlyArray | string | undefined, -): Filters => { - if (!matchers) { - return; +const collectOperationDependencies = ({ + graph, + operations, +}: { + graph: Graph; + operations: Set; +}): { + operationDependencies: Set; +} => { + const finalSet = new Set(); + const initialSet = new Set( + [...operations].flatMap((key) => [ + ...(graph.operations.get(key)?.dependencies ?? []), + ]), + ); + const stack = [...initialSet]; + while (stack.length) { + const key = stack.pop()!; + + if (finalSet.has(key)) { + continue; + } + + finalSet.add(key); + + const { namespace } = removeNamespace(key); + let dependencies: Set | undefined; + if (namespace === 'body') { + dependencies = graph.requestBodies.get(key)?.dependencies; + } else if (namespace === 'operation') { + dependencies = graph.operations.get(key)?.dependencies; + } else if (namespace === 'schema') { + dependencies = graph.schemas.get(key)?.dependencies; + } + + if (!dependencies?.size) { + continue; + } + + for (const dependency of dependencies) { + if (!finalSet.has(dependency)) { + stack.push(dependency); + } + } } + return { operationDependencies: finalSet }; +}; + +export const createFilteredDependencies = ({ + filters, + graph, +}: { + filters: Filters; + graph: Graph; +}): { + operations: Set; + requestBodies: Set; + schemas: Set; +} => { + const { schemas } = collectSchemas({ filters, graph }); + const { requestBodies } = collectRequestBodies({ + filters, + graph, + schemas, + }); + + dropExcludedSchemas({ filters, graph, schemas }); + dropExcludedRequestBodies({ filters, graph, requestBodies }); + + // collect operations after dropping components + const { operations } = collectOperations({ + filters, + graph, + requestBodies, + schemas, + }); - if (typeof matchers === 'string') { - return [createFilter(matchers)]; + if (!filters.orphans) { + const { operationDependencies } = collectOperationDependencies({ + graph, + operations, + }); + dropOrphans({ operationDependencies, requestBodies, schemas }); } - return matchers.map((matcher) => createFilter(matcher)); + return { + operations, + requestBodies, + schemas, + }; }; diff --git a/packages/openapi-ts/src/openApi/shared/utils/graph.ts b/packages/openapi-ts/src/openApi/shared/utils/graph.ts new file mode 100644 index 000000000..1e5d3ed12 --- /dev/null +++ b/packages/openapi-ts/src/openApi/shared/utils/graph.ts @@ -0,0 +1,347 @@ +import type { SchemaObject as OpenApiV2_0_XSchemaObject } from '../../2.0.x/types/spec'; +import type { SchemaObject as OpenApiV3_0_XSchemaObject } from '../../3.0.x/types/spec'; +import type { + PathItemObject, + PathsObject, + SchemaObject as OpenApiV3_1_XSchemaObject, +} from '../../3.1.x/types/spec'; +import type { OpenApi } from '../../types'; +import { httpMethods } from './operation'; + +export type Graph = { + operations: Map< + string, + { + dependencies: Set; + deprecated: boolean; + tags: Set; + } + >; + // TODO: add parameters + requestBodies: Map< + string, + { + dependencies: Set; + deprecated: boolean; + } + >; + schemas: Map< + string, + { + dependencies: Set; + deprecated: boolean; + } + >; +}; + +type Type = 'body' | 'operation' | 'schema' | 'unknown'; + +/** + * Converts reference strings from OpenAPI $ref keywords into namespaces. + * @example '#/components/schemas/Foo' -> 'schema' + */ +export const stringToNamespace = (value: string): Type => { + switch (value) { + case 'requestBodies': + return 'body'; + case 'definitions': + case 'schemas': + return 'schema'; + default: + return 'unknown'; + } +}; + +export const addNamespace = (namespace: Type, value: string = ''): string => + `${namespace}/${value}`; + +export const removeNamespace = ( + key: string, +): { + name: string; + namespace: Type; +} => { + const [namespace, name] = key.split('/'); + return { + name: name!, + namespace: namespace! as Type, + }; +}; + +const collectSchemaDependencies = ( + schema: + | OpenApiV2_0_XSchemaObject + | OpenApiV3_0_XSchemaObject + | OpenApiV3_1_XSchemaObject, + dependencies: Set, +) => { + if ('$ref' in schema && schema.$ref) { + const parts = schema.$ref.split('/'); + const type = parts[parts.length - 2]; + const name = parts[parts.length - 1]; + if (type && name) { + dependencies.add(addNamespace(stringToNamespace(type), name)); + } + } + + if (schema.items && typeof schema.items === 'object') { + collectSchemaDependencies(schema.items, dependencies); + } + + if (schema.properties) { + for (const property of Object.values(schema.properties)) { + if (typeof property === 'object') { + collectSchemaDependencies(property, dependencies); + } + } + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' + ) { + collectSchemaDependencies(schema.additionalProperties, dependencies); + } + + if ('allOf' in schema && schema.allOf) { + for (const item of schema.allOf) { + collectSchemaDependencies(item, dependencies); + } + } + + if ('anyOf' in schema && schema.anyOf) { + for (const item of schema.anyOf) { + collectSchemaDependencies(item, dependencies); + } + } + + if ('contains' in schema && schema.contains) { + collectSchemaDependencies(schema.contains, dependencies); + } + + if ('not' in schema && schema.not) { + collectSchemaDependencies(schema.not, dependencies); + } + + if ('oneOf' in schema && schema.oneOf) { + for (const item of schema.oneOf) { + collectSchemaDependencies(item, dependencies); + } + } + + if ('prefixItems' in schema && schema.prefixItems) { + for (const item of schema.prefixItems) { + collectSchemaDependencies(item, dependencies); + } + } +}; + +const collectOpenApiV2Dependencies = (spec: OpenApi.V2_0_X, graph: Graph) => { + if (spec.definitions) { + for (const [key, schema] of Object.entries(spec.definitions)) { + const dependencies = new Set(); + collectSchemaDependencies(schema, dependencies); + graph.schemas.set(addNamespace('schema', key), { + dependencies, + deprecated: false, + }); + } + + // TODO: add parameters + } + + if (spec.paths) { + for (const entry of Object.entries(spec.paths)) { + const path = entry[0] as keyof PathsObject; + const pathItem = entry[1] as PathItemObject; + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const dependencies = new Set(); + + if (operation.requestBody) { + if ('$ref' in operation.requestBody) { + collectSchemaDependencies(operation.requestBody, dependencies); + } else { + for (const media of Object.values(operation.requestBody.content)) { + if (media.schema) { + collectSchemaDependencies(media.schema, dependencies); + } + } + } + } + + if (operation.responses) { + for (const response of Object.values(operation.responses)) { + if (!response) { + continue; + } + + if ('$ref' in response) { + collectSchemaDependencies(response, dependencies); + } else if (response.content) { + for (const media of Object.values(response.content)) { + if (media.schema) { + collectSchemaDependencies(media.schema, dependencies); + } + } + } + } + } + + if (operation.parameters) { + for (const parameter of operation.parameters) { + if ('$ref' in parameter) { + collectSchemaDependencies(parameter, dependencies); + } else if (parameter.schema) { + collectSchemaDependencies(parameter.schema, dependencies); + } + } + } + + graph.operations.set( + addNamespace('operation', `${method.toUpperCase()} ${path}`), + { + dependencies, + deprecated: Boolean(operation.deprecated), + tags: new Set(operation.tags), + }, + ); + } + } + } +}; + +const collectOpenApiV3Dependencies = ( + spec: OpenApi.V3_0_X | OpenApi.V3_1_X, + graph: Graph, +) => { + type ExtractedType = T extends Record ? V : never; + + if (spec.components) { + // TODO: add other components + if (spec.components.schemas) { + type Schema = ExtractedType; + for (const [key, value] of Object.entries(spec.components.schemas)) { + const schema = value as Schema; + const dependencies = new Set(); + collectSchemaDependencies(schema, dependencies); + graph.schemas.set(addNamespace('schema', key), { + dependencies, + deprecated: + 'deprecated' in schema ? Boolean(schema.deprecated) : false, + }); + } + } + + // TODO: add parameters + + if (spec.components.requestBodies) { + type RequestBody = ExtractedType; + for (const [key, value] of Object.entries( + spec.components.requestBodies, + )) { + const requestBody = value as RequestBody; + const dependencies = new Set(); + if ('$ref' in requestBody) { + collectSchemaDependencies(requestBody, dependencies); + } else { + for (const media of Object.values(requestBody.content)) { + if (media.schema) { + collectSchemaDependencies(media.schema, dependencies); + } + } + } + graph.requestBodies.set(addNamespace('body', key), { + dependencies, + deprecated: false, + }); + } + } + } + + if (spec.paths) { + for (const entry of Object.entries(spec.paths)) { + const path = entry[0] as keyof PathsObject; + const pathItem = entry[1] as PathItemObject; + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const dependencies = new Set(); + + if (operation.requestBody) { + if ('$ref' in operation.requestBody) { + collectSchemaDependencies(operation.requestBody, dependencies); + } else { + for (const media of Object.values(operation.requestBody.content)) { + if (media.schema) { + collectSchemaDependencies(media.schema, dependencies); + } + } + } + } + + if (operation.responses) { + for (const response of Object.values(operation.responses)) { + if (!response) { + continue; + } + + if ('$ref' in response) { + collectSchemaDependencies(response, dependencies); + } else if (response.content) { + for (const media of Object.values(response.content)) { + if (media.schema) { + collectSchemaDependencies(media.schema, dependencies); + } + } + } + } + } + + if (operation.parameters) { + for (const parameter of operation.parameters) { + if ('$ref' in parameter) { + collectSchemaDependencies(parameter, dependencies); + } else if (parameter.schema) { + collectSchemaDependencies(parameter.schema, dependencies); + } + } + } + + graph.operations.set( + addNamespace('operation', `${method.toUpperCase()} ${path}`), + { + dependencies, + deprecated: Boolean(operation.deprecated), + tags: new Set(operation.tags), + }, + ); + } + } + } +}; + +export const createGraph = ( + spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X, +): Graph => { + const graph: Graph = { + operations: new Map(), + requestBodies: new Map(), + schemas: new Map(), + }; + + if ('swagger' in spec) { + collectOpenApiV2Dependencies(spec, graph); + } else { + collectOpenApiV3Dependencies(spec, graph); + } + + return graph; +}; diff --git a/packages/openapi-ts/src/openApi/shared/utils/operation.ts b/packages/openapi-ts/src/openApi/shared/utils/operation.ts index e385345c2..fd8dd91ab 100644 --- a/packages/openapi-ts/src/openApi/shared/utils/operation.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/operation.ts @@ -3,6 +3,17 @@ import { stringCase } from '../../../utils/stringCase'; import { sanitizeNamespaceIdentifier } from '../../common/parser/sanitize'; import type { State } from '../types/state'; +export const httpMethods = [ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + 'trace', +] as const; + /** * Verifies that operation ID is unique. For now, we only warn when this isn't * true as people like to not follow this part of the specification. In the diff --git a/packages/openapi-ts/src/types/config.d.ts b/packages/openapi-ts/src/types/config.d.ts index 825b65bc3..c8b278217 100644 --- a/packages/openapi-ts/src/types/config.d.ts +++ b/packages/openapi-ts/src/types/config.d.ts @@ -38,35 +38,109 @@ interface Input { * This will always return the same file. */ commit_sha?: string; - /** - * Prevent parts matching the regular expression(s) from being processed. - * You can select both operations and components by reference within - * the bundled input. - * - * In case of conflicts, `exclude` takes precedence over `include`. - * - * @example - * operation: '^#/paths/api/v1/foo/get$' - * schema: '^#/components/schemas/Foo$' - * deprecated: '@deprecated' - */ - exclude?: ReadonlyArray | string; /** * You pass any valid Fetch API options to the request for fetching your * specification. This is useful if your file is behind auth for example. */ fetch?: RequestInit; /** - * Process only parts matching the regular expression(s). You can select both - * operations and components by reference within the bundled input. - * - * In case of conflicts, `exclude` takes precedence over `include`. - * - * @example - * operation: '^#/paths/api/v1/foo/get$' - * schema: '^#/components/schemas/Foo$' + * Filters can be used to select a subset of your input before it's processed + * by plugins. */ - include?: ReadonlyArray | string; + filters?: { + /** + * Include deprecated resources in the output? + * + * @default true + */ + deprecated?: boolean; + operations?: { + /** + * Prevent operations matching the `exclude` filters from being processed. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['GET /api/v1/foo'] + */ + exclude?: ReadonlyArray; + /** + * Process only operations matching the `include` filters. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['GET /api/v1/foo'] + */ + include?: ReadonlyArray; + }; + /** + * Keep reusable components without any references in the output? By + * default, we exclude orphaned resources. + * + * @default false + */ + orphans?: boolean; + /** + * Should we preserve the key order when overwriting your input? This + * option is disabled by default to improve performance. + * + * @default false + */ + preserveOrder?: boolean; + requestBodies?: { + /** + * Prevent request bodies matching the `exclude` filters from being processed. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['Foo'] + */ + exclude?: ReadonlyArray; + /** + * Process only request bodies matching the `include` filters. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['Foo'] + */ + include?: ReadonlyArray; + }; + schemas?: { + /** + * Prevent schemas matching the `exclude` filters from being processed. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['Foo'] + */ + exclude?: ReadonlyArray; + /** + * Process only schemas matching the `include` filters. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['Foo'] + */ + include?: ReadonlyArray; + }; + tags?: { + /** + * Prevent tags matching the `exclude` filters from being processed. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['foo'] + */ + exclude?: ReadonlyArray; + /** + * Process only tags matching the `include` filters. + * + * In case of conflicts, `exclude` takes precedence over `include`. + * + * @example ['foo'] + */ + include?: ReadonlyArray; + }; + }; /** * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** *