fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2072 from hey-api/feat/spec-dependencies

fix(parser): prevent broken output by tracking dependencies when using filters

authored by

Lubos and committed by
GitHub
e514a138 8ad48481

+1621 -235
+53
.changeset/hip-crabs-help.md
··· 1 + --- 2 + '@hey-api/openapi-ts': minor 3 + --- 4 + 5 + feat: upgraded input filters 6 + 7 + ### Upgraded input filters 8 + 9 + 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! 10 + 11 + ::: code-group 12 + 13 + ```js [include] 14 + export default { 15 + input: { 16 + // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path 17 + filters: { 18 + operations: { 19 + include: ['GET /api/v1/foo'], // [!code ++] 20 + }, 21 + schemas: { 22 + include: ['foo'], // [!code ++] 23 + }, 24 + }, 25 + include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] 26 + path: 'https://get.heyapi.dev/hey-api/backend', 27 + }, 28 + output: 'src/client', 29 + plugins: ['@hey-api/client-fetch'], 30 + }; 31 + ``` 32 + 33 + ```js [exclude] 34 + export default { 35 + input: { 36 + // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path 37 + exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] 38 + filters: { 39 + operations: { 40 + exclude: ['GET /api/v1/foo'], // [!code ++] 41 + }, 42 + schemas: { 43 + exclude: ['foo'], // [!code ++] 44 + }, 45 + }, 46 + path: 'https://get.heyapi.dev/hey-api/backend', 47 + }, 48 + output: 'src/client', 49 + plugins: ['@hey-api/client-fetch'], 50 + }; 51 + ``` 52 + 53 + :::
+180 -5
docs/openapi-ts/configuration.md
··· 193 193 194 194 ## Filters 195 195 196 - 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`. 196 + If you work with large specifications and want to generate output from their subset, you can use `input.filters` to select the relevant resources. 197 + 198 + ### Operations 199 + 200 + 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`. 201 + 202 + ::: code-group 203 + 204 + ```js [include] 205 + export default { 206 + input: { 207 + filters: { 208 + operations: { 209 + include: ['GET /api/v1/foo'], // [!code ++] 210 + }, 211 + }, 212 + path: 'https://get.heyapi.dev/hey-api/backend', 213 + }, 214 + output: 'src/client', 215 + plugins: ['@hey-api/client-fetch'], 216 + }; 217 + ``` 218 + 219 + ```js [exclude] 220 + export default { 221 + input: { 222 + filters: { 223 + operations: { 224 + exclude: ['GET /api/v1/foo'], // [!code ++] 225 + }, 226 + }, 227 + path: 'https://get.heyapi.dev/hey-api/backend', 228 + }, 229 + output: 'src/client', 230 + plugins: ['@hey-api/client-fetch'], 231 + }; 232 + ``` 233 + 234 + ::: 235 + 236 + ### Tags 237 + 238 + 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`. 239 + 240 + ::: code-group 241 + 242 + ```js [include] 243 + export default { 244 + input: { 245 + filters: { 246 + tags: { 247 + include: ['v2'], // [!code ++] 248 + }, 249 + }, 250 + path: 'https://get.heyapi.dev/hey-api/backend', 251 + }, 252 + output: 'src/client', 253 + plugins: ['@hey-api/client-fetch'], 254 + }; 255 + ``` 256 + 257 + ```js [exclude] 258 + export default { 259 + input: { 260 + filters: { 261 + tags: { 262 + exclude: ['v1'], // [!code ++] 263 + }, 264 + }, 265 + path: 'https://get.heyapi.dev/hey-api/backend', 266 + }, 267 + output: 'src/client', 268 + plugins: ['@hey-api/client-fetch'], 269 + }; 270 + ``` 271 + 272 + ::: 273 + 274 + ### Deprecated 275 + 276 + You can filter out deprecated resources by setting `deprecated` to `false`. 277 + 278 + ```js 279 + export default { 280 + input: { 281 + filters: { 282 + deprecated: false, // [!code ++] 283 + }, 284 + path: 'https://get.heyapi.dev/hey-api/backend', 285 + }, 286 + output: 'src/client', 287 + plugins: ['@hey-api/client-fetch'], 288 + }; 289 + ``` 290 + 291 + ### Schemas 292 + 293 + 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`. 197 294 198 295 ::: code-group 199 296 200 297 ```js [include] 201 298 export default { 202 299 input: { 203 - // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path // [!code ++] 204 - include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code ++] 300 + filters: { 301 + schemas: { 302 + include: ['Foo'], // [!code ++] 303 + }, 304 + }, 205 305 path: 'https://get.heyapi.dev/hey-api/backend', 206 306 }, 207 307 output: 'src/client', ··· 212 312 ```js [exclude] 213 313 export default { 214 314 input: { 215 - // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path // [!code ++] 216 - exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code ++] 315 + filters: { 316 + schemas: { 317 + exclude: ['Foo'], // [!code ++] 318 + }, 319 + }, 217 320 path: 'https://get.heyapi.dev/hey-api/backend', 218 321 }, 219 322 output: 'src/client', ··· 222 325 ``` 223 326 224 327 ::: 328 + 329 + ### Request Bodies 330 + 331 + 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`. 332 + 333 + ::: code-group 334 + 335 + ```js [include] 336 + export default { 337 + input: { 338 + filters: { 339 + requestBodies: { 340 + include: ['Payload'], // [!code ++] 341 + }, 342 + }, 343 + path: 'https://get.heyapi.dev/hey-api/backend', 344 + }, 345 + output: 'src/client', 346 + plugins: ['@hey-api/client-fetch'], 347 + }; 348 + ``` 349 + 350 + ```js [exclude] 351 + export default { 352 + input: { 353 + filters: { 354 + requestBodies: { 355 + exclude: ['Payload'], // [!code ++] 356 + }, 357 + }, 358 + path: 'https://get.heyapi.dev/hey-api/backend', 359 + }, 360 + output: 'src/client', 361 + plugins: ['@hey-api/client-fetch'], 362 + }; 363 + ``` 364 + 365 + ::: 366 + 367 + ### Orphaned resources 368 + 369 + 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. 370 + 371 + ```js 372 + export default { 373 + input: { 374 + filters: { 375 + orphans: false, // [!code ++] 376 + }, 377 + path: 'https://get.heyapi.dev/hey-api/backend', 378 + }, 379 + output: 'src/client', 380 + plugins: ['@hey-api/client-fetch'], 381 + }; 382 + ``` 383 + 384 + ### Order 385 + 386 + 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`. 387 + 388 + ```js 389 + export default { 390 + input: { 391 + filters: { 392 + preserveOrder: true, // [!code ++] 393 + }, 394 + path: 'https://get.heyapi.dev/hey-api/backend', 395 + }, 396 + output: 'src/client', 397 + plugins: ['@hey-api/client-fetch'], 398 + }; 399 + ``` 225 400 226 401 ## Watch Mode 227 402
+50
docs/openapi-ts/migrating.md
··· 27 27 28 28 This config option is deprecated and will be removed. 29 29 30 + ## v0.68.0 31 + 32 + ### Upgraded input filters 33 + 34 + 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! 35 + 36 + ::: code-group 37 + 38 + ```js [include] 39 + export default { 40 + input: { 41 + // match only the schema named `foo` and `GET` operation for the `/api/v1/foo` path 42 + filters: { 43 + operations: { 44 + include: ['GET /api/v1/foo'], // [!code ++] 45 + }, 46 + schemas: { 47 + include: ['foo'], // [!code ++] 48 + }, 49 + }, 50 + include: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] 51 + path: 'https://get.heyapi.dev/hey-api/backend', 52 + }, 53 + output: 'src/client', 54 + plugins: ['@hey-api/client-fetch'], 55 + }; 56 + ``` 57 + 58 + ```js [exclude] 59 + export default { 60 + input: { 61 + // match everything except for the schema named `foo` and `GET` operation for the `/api/v1/foo` path 62 + exclude: '^(#/components/schemas/foo|#/paths/api/v1/foo/get)$', // [!code --] 63 + filters: { 64 + operations: { 65 + exclude: ['GET /api/v1/foo'], // [!code ++] 66 + }, 67 + schemas: { 68 + exclude: ['foo'], // [!code ++] 69 + }, 70 + }, 71 + path: 'https://get.heyapi.dev/hey-api/backend', 72 + }, 73 + output: 'src/client', 74 + plugins: ['@hey-api/client-fetch'], 75 + }; 76 + ``` 77 + 78 + ::: 79 + 30 80 ## v0.67.0 31 81 32 82 ### Respecting `moduleResolution` value in `tsconfig.json`
+3 -1
packages/openapi-ts-tests/test/2.0.x.test.ts
··· 224 224 { 225 225 config: createConfig({ 226 226 input: { 227 - exclude: ['@deprecated'], 227 + filters: { 228 + deprecated: false, 229 + }, 228 230 path: 'exclude-deprecated.yaml', 229 231 }, 230 232 output: 'exclude-deprecated',
+3 -1
packages/openapi-ts-tests/test/3.0.x.test.ts
··· 426 426 { 427 427 config: createConfig({ 428 428 input: { 429 - exclude: ['@deprecated'], 429 + filters: { 430 + deprecated: false, 431 + }, 430 432 path: 'exclude-deprecated.yaml', 431 433 }, 432 434 output: 'exclude-deprecated',
+3 -1
packages/openapi-ts-tests/test/3.1.x.test.ts
··· 440 440 { 441 441 config: createConfig({ 442 442 input: { 443 - exclude: ['@deprecated'], 443 + filters: { 444 + deprecated: false, 445 + }, 444 446 path: 'exclude-deprecated.yaml', 445 447 }, 446 448 output: 'exclude-deprecated',
+12 -7
packages/openapi-ts-tests/test/openapi-ts.config.ts
··· 16 16 // experimentalParser: false, 17 17 input: { 18 18 // branch: 'main', 19 - // exclude: [ 20 - // '^#/components/schemas/ModelWithCircularReference$', 21 - // '@deprecated', 22 - // ], 23 19 // fetch: { 24 20 // headers: { 25 21 // 'x-foo': 'bar', 26 22 // }, 27 23 // }, 28 - // include: 29 - // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', 24 + filters: { 25 + deprecated: false, 26 + // operations: { 27 + // include: ['POST /foo'], 28 + // }, 29 + orphans: false, 30 + // preserveOrder: true, 31 + // tags: { 32 + // exclude: ['bar'], 33 + // }, 34 + }, 30 35 // organization: 'hey-api', 31 36 // path: { 32 37 // components: {}, ··· 115 120 }, 116 121 { 117 122 exportFromIndex: true, 118 - name: '@tanstack/react-query', 123 + // name: '@tanstack/react-query', 119 124 }, 120 125 { 121 126 // exportFromIndex: true,
+90
packages/openapi-ts-tests/test/spec/3.1.x/parser-filters.yaml
··· 1 + openapi: 3.1.1 2 + info: 3 + title: OpenAPI 3.1.1 parser filters example 4 + version: 1 5 + paths: 6 + /foo: 7 + post: 8 + deprecated: true 9 + tags: 10 + - foo 11 + - bar 12 + requestBody: 13 + $ref: '#/components/requestBodies/Foo' 14 + responses: 15 + '200': 16 + content: 17 + '*/*': 18 + schema: 19 + $ref: '#/components/schemas/Foo' 20 + description: OK 21 + put: 22 + tags: 23 + - bar 24 + requestBody: 25 + $ref: '#/components/requestBodies/Bar' 26 + responses: 27 + '200': 28 + content: 29 + '*/*': 30 + schema: 31 + $ref: '#/components/schemas/Baz' 32 + description: OK 33 + /bar: 34 + post: 35 + requestBody: 36 + content: 37 + 'application/json': 38 + schema: 39 + $ref: '#/components/schemas/Bar' 40 + required: true 41 + responses: 42 + '200': 43 + content: 44 + '*/*': 45 + schema: 46 + $ref: '#/components/schemas/Bar' 47 + description: OK 48 + components: 49 + requestBodies: 50 + Foo: 51 + required: true 52 + description: POST /foo payload 53 + content: 54 + 'application/json': 55 + schema: 56 + type: object 57 + properties: 58 + foo: 59 + $ref: '#/components/schemas/Bar' 60 + Bar: 61 + required: true 62 + description: PUT /foo payload 63 + content: 64 + 'application/json': 65 + schema: 66 + type: object 67 + properties: 68 + foo: 69 + $ref: '#/components/schemas/Foo' 70 + schemas: 71 + Foo: 72 + type: object 73 + properties: 74 + foo: 75 + $ref: '#/components/schemas/Bar' 76 + Bar: 77 + type: object 78 + properties: 79 + bar: 80 + $ref: '#/components/schemas/Baz' 81 + Baz: 82 + type: object 83 + properties: 84 + baz: 85 + type: string 86 + Orphan: 87 + type: object 88 + properties: 89 + orphan: 90 + type: string
+75
packages/openapi-ts/src/openApi/2.0.x/parser/filter.ts
··· 1 + import { addNamespace, removeNamespace } from '../../shared/utils/graph'; 2 + import { httpMethods } from '../../shared/utils/operation'; 3 + import type { 4 + OpenApiV2_0_X, 5 + OperationObject, 6 + PathItemObject, 7 + PathsObject, 8 + } from '../types/spec'; 9 + 10 + /** 11 + * Replace source spec with filtered version. 12 + */ 13 + export const filterSpec = ({ 14 + operations, 15 + preserveOrder, 16 + schemas, 17 + spec, 18 + }: { 19 + operations: Set<string>; 20 + preserveOrder: boolean; 21 + requestBodies: Set<string>; 22 + schemas: Set<string>; 23 + spec: OpenApiV2_0_X; 24 + }) => { 25 + if (spec.definitions) { 26 + const filtered: typeof spec.definitions = {}; 27 + 28 + if (preserveOrder) { 29 + for (const [name, source] of Object.entries(spec.definitions)) { 30 + if (schemas.has(addNamespace('schema', name))) { 31 + filtered[name] = source; 32 + } 33 + } 34 + } else { 35 + for (const key of schemas) { 36 + const { name } = removeNamespace(key); 37 + const source = spec.definitions[name]; 38 + if (source) { 39 + filtered[name] = source; 40 + } 41 + } 42 + } 43 + 44 + spec.definitions = filtered; 45 + } 46 + 47 + if (spec.paths) { 48 + for (const entry of Object.entries(spec.paths)) { 49 + const path = entry[0] as keyof PathsObject; 50 + const pathItem = entry[1] as PathItemObject; 51 + 52 + for (const method of httpMethods) { 53 + // @ts-expect-error 54 + const operation = pathItem[method] as OperationObject; 55 + if (!operation) { 56 + continue; 57 + } 58 + 59 + const key = addNamespace( 60 + 'operation', 61 + `${method.toUpperCase()} ${path}`, 62 + ); 63 + if (!operations.has(key)) { 64 + // @ts-expect-error 65 + delete pathItem[method]; 66 + } 67 + } 68 + 69 + // remove paths that have no operations left 70 + if (!Object.keys(pathItem).length) { 71 + delete spec.paths[path]; 72 + } 73 + } 74 + } 75 + };
+25 -39
packages/openapi-ts/src/openApi/2.0.x/parser/index.ts
··· 1 1 import type { IR } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { canProcessRef, createFilters } from '../../shared/utils/filter'; 3 + import { 4 + createFilteredDependencies, 5 + createFilters, 6 + hasFilters, 7 + } from '../../shared/utils/filter'; 8 + import { createGraph } from '../../shared/utils/graph'; 4 9 import { mergeParametersObjects } from '../../shared/utils/parameter'; 5 10 import type { 6 11 OpenApiV2_0_X, ··· 9 14 PathsObject, 10 15 SecuritySchemeObject, 11 16 } from '../types/spec'; 17 + import { filterSpec } from './filter'; 12 18 import { parseOperation } from './operation'; 13 19 import { parametersArrayToObject } from './parameter'; 14 20 import { parseSchema } from './schema'; ··· 18 24 keyof T extends infer K ? (K extends `/${string}` ? K : never) : never; 19 25 20 26 export const parseV2_0_X = (context: IR.Context<OpenApiV2_0_X>) => { 27 + if (hasFilters(context.config.input.filters)) { 28 + const graph = createGraph(context.spec); 29 + const filters = createFilters(context.config.input.filters); 30 + const sets = createFilteredDependencies({ filters, graph }); 31 + filterSpec({ 32 + ...sets, 33 + preserveOrder: filters.preserveOrder, 34 + spec: context.spec, 35 + }); 36 + } 37 + 21 38 const state: State = { 22 39 ids: new Map(), 23 40 operationIds: new Map(), 24 41 }; 25 42 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 26 43 27 - const excludeFilters = createFilters(context.config.input.exclude); 28 - const includeFilters = createFilters(context.config.input.include); 29 - 30 - const shouldProcessRef = ($ref: string, schema: Record<string, any>) => 31 - canProcessRef({ 32 - $ref, 33 - excludeFilters, 34 - includeFilters, 35 - schema, 36 - }); 37 - 38 44 for (const name in context.spec.securityDefinitions) { 39 45 const securitySchemeObject = context.spec.securityDefinitions[name]!; 40 46 securitySchemesMap.set(name, securitySchemeObject); ··· 45 51 const $ref = `#/definitions/${name}`; 46 52 const schema = context.spec.definitions[name]!; 47 53 48 - if (!shouldProcessRef($ref, schema)) { 49 - continue; 50 - } 51 - 52 54 parseSchema({ 53 55 $ref, 54 56 context, ··· 95 97 state, 96 98 }; 97 99 98 - const $refDelete = `#/paths${path}/delete`; 99 - if ( 100 - finalPathItem.delete && 101 - shouldProcessRef($refDelete, finalPathItem.delete) 102 - ) { 100 + if (finalPathItem.delete) { 103 101 const parameters = mergeParametersObjects({ 104 102 source: parametersArrayToObject({ 105 103 context, ··· 119 117 }); 120 118 } 121 119 122 - const $refGet = `#/paths${path}/get`; 123 - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { 120 + if (finalPathItem.get) { 124 121 const parameters = mergeParametersObjects({ 125 122 source: parametersArrayToObject({ 126 123 context, ··· 140 137 }); 141 138 } 142 139 143 - const $refHead = `#/paths${path}/head`; 144 - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { 140 + if (finalPathItem.head) { 145 141 const parameters = mergeParametersObjects({ 146 142 source: parametersArrayToObject({ 147 143 context, ··· 161 157 }); 162 158 } 163 159 164 - const $refOptions = `#/paths${path}/options`; 165 - if ( 166 - finalPathItem.options && 167 - shouldProcessRef($refOptions, finalPathItem.options) 168 - ) { 160 + if (finalPathItem.options) { 169 161 const parameters = mergeParametersObjects({ 170 162 source: parametersArrayToObject({ 171 163 context, ··· 185 177 }); 186 178 } 187 179 188 - const $refPatch = `#/paths${path}/patch`; 189 - if ( 190 - finalPathItem.patch && 191 - shouldProcessRef($refPatch, finalPathItem.patch) 192 - ) { 180 + if (finalPathItem.patch) { 193 181 const parameters = mergeParametersObjects({ 194 182 source: parametersArrayToObject({ 195 183 context, ··· 209 197 }); 210 198 } 211 199 212 - const $refPost = `#/paths${path}/post`; 213 - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { 200 + if (finalPathItem.post) { 214 201 const parameters = mergeParametersObjects({ 215 202 source: parametersArrayToObject({ 216 203 context, ··· 230 217 }); 231 218 } 232 219 233 - const $refPut = `#/paths${path}/put`; 234 - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { 220 + if (finalPathItem.put) { 235 221 const parameters = mergeParametersObjects({ 236 222 source: parametersArrayToObject({ 237 223 context,
+95
packages/openapi-ts/src/openApi/3.0.x/parser/filter.ts
··· 1 + import { addNamespace, removeNamespace } from '../../shared/utils/graph'; 2 + import { httpMethods } from '../../shared/utils/operation'; 3 + import type { OpenApiV3_0_X, PathItemObject, PathsObject } from '../types/spec'; 4 + 5 + /** 6 + * Replace source spec with filtered version. 7 + */ 8 + export const filterSpec = ({ 9 + operations, 10 + preserveOrder, 11 + requestBodies, 12 + schemas, 13 + spec, 14 + }: { 15 + operations: Set<string>; 16 + preserveOrder: boolean; 17 + requestBodies: Set<string>; 18 + schemas: Set<string>; 19 + spec: OpenApiV3_0_X; 20 + }) => { 21 + if (spec.components) { 22 + if (spec.components.requestBodies) { 23 + const filtered: typeof spec.components.requestBodies = {}; 24 + 25 + if (preserveOrder) { 26 + for (const [name, source] of Object.entries( 27 + spec.components.requestBodies, 28 + )) { 29 + if (requestBodies.has(addNamespace('body', name))) { 30 + filtered[name] = source; 31 + } 32 + } 33 + } else { 34 + for (const key of requestBodies) { 35 + const { name } = removeNamespace(key); 36 + const source = spec.components.requestBodies[name]; 37 + if (source) { 38 + filtered[name] = source; 39 + } 40 + } 41 + } 42 + 43 + spec.components.requestBodies = filtered; 44 + } 45 + 46 + if (spec.components.schemas) { 47 + const filtered: typeof spec.components.schemas = {}; 48 + 49 + if (preserveOrder) { 50 + for (const [name, source] of Object.entries(spec.components.schemas)) { 51 + if (schemas.has(addNamespace('schema', name))) { 52 + filtered[name] = source; 53 + } 54 + } 55 + } else { 56 + for (const key of schemas) { 57 + const { name } = removeNamespace(key); 58 + const source = spec.components.schemas[name]; 59 + if (source) { 60 + filtered[name] = source; 61 + } 62 + } 63 + } 64 + 65 + spec.components.schemas = filtered; 66 + } 67 + } 68 + 69 + if (spec.paths) { 70 + for (const entry of Object.entries(spec.paths)) { 71 + const path = entry[0] as keyof PathsObject; 72 + const pathItem = entry[1] as PathItemObject; 73 + 74 + for (const method of httpMethods) { 75 + const operation = pathItem[method]; 76 + if (!operation) { 77 + continue; 78 + } 79 + 80 + const key = addNamespace( 81 + 'operation', 82 + `${method.toUpperCase()} ${path}`, 83 + ); 84 + if (!operations.has(key)) { 85 + delete pathItem[method]; 86 + } 87 + } 88 + 89 + // remove paths that have no operations left 90 + if (!Object.keys(pathItem).length) { 91 + delete spec.paths[path]; 92 + } 93 + } 94 + } 95 + };
+26 -52
packages/openapi-ts/src/openApi/3.0.x/parser/index.ts
··· 1 1 import type { IR } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { canProcessRef, createFilters } from '../../shared/utils/filter'; 3 + import { 4 + createFilteredDependencies, 5 + createFilters, 6 + hasFilters, 7 + } from '../../shared/utils/filter'; 8 + import { createGraph } from '../../shared/utils/graph'; 4 9 import { mergeParametersObjects } from '../../shared/utils/parameter'; 5 10 import type { 6 11 OpenApiV3_0_X, ··· 10 15 RequestBodyObject, 11 16 SecuritySchemeObject, 12 17 } from '../types/spec'; 18 + import { filterSpec } from './filter'; 13 19 import { parseOperation } from './operation'; 14 20 import { parametersArrayToObject, parseParameter } from './parameter'; 15 21 import { parseRequestBody } from './requestBody'; ··· 17 23 import { parseServers } from './server'; 18 24 19 25 export const parseV3_0_X = (context: IR.Context<OpenApiV3_0_X>) => { 26 + if (hasFilters(context.config.input.filters)) { 27 + const graph = createGraph(context.spec); 28 + const filters = createFilters(context.config.input.filters); 29 + const sets = createFilteredDependencies({ filters, graph }); 30 + filterSpec({ 31 + ...sets, 32 + preserveOrder: filters.preserveOrder, 33 + spec: context.spec, 34 + }); 35 + } 36 + 20 37 const state: State = { 21 38 ids: new Map(), 22 39 operationIds: new Map(), 23 40 }; 24 41 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 25 42 26 - const excludeFilters = createFilters(context.config.input.exclude); 27 - const includeFilters = createFilters(context.config.input.include); 28 - 29 - const shouldProcessRef = ($ref: string, schema: Record<string, any>) => 30 - canProcessRef({ 31 - $ref, 32 - excludeFilters, 33 - includeFilters, 34 - schema, 35 - }); 36 - 37 43 // TODO: parser - handle more component types, old parser handles only parameters and schemas 38 44 if (context.spec.components) { 39 45 for (const name in context.spec.components.securitySchemes) { ··· 54 60 ? context.resolveRef<ParameterObject>(parameterOrReference.$ref) 55 61 : parameterOrReference; 56 62 57 - if (!shouldProcessRef($ref, parameter)) { 58 - continue; 59 - } 60 - 61 63 parseParameter({ 62 64 $ref, 63 65 context, ··· 74 76 ? context.resolveRef<RequestBodyObject>(requestBodyOrReference.$ref) 75 77 : requestBodyOrReference; 76 78 77 - if (!shouldProcessRef($ref, requestBody)) { 78 - continue; 79 - } 80 - 81 79 parseRequestBody({ 82 80 $ref, 83 81 context, ··· 89 87 const $ref = `#/components/schemas/${name}`; 90 88 const schema = context.spec.components.schemas[name]!; 91 89 92 - if (!shouldProcessRef($ref, schema)) { 93 - continue; 94 - } 95 - 96 90 parseSchema({ 97 91 $ref, 98 92 context, ··· 138 132 state, 139 133 }; 140 134 141 - const $refDelete = `#/paths${path}/delete`; 142 - if ( 143 - finalPathItem.delete && 144 - shouldProcessRef($refDelete, finalPathItem.delete) 145 - ) { 135 + if (finalPathItem.delete) { 146 136 parseOperation({ 147 137 ...operationArgs, 148 138 method: 'delete', ··· 160 150 }); 161 151 } 162 152 163 - const $refGet = `#/paths${path}/get`; 164 - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { 153 + if (finalPathItem.get) { 165 154 parseOperation({ 166 155 ...operationArgs, 167 156 method: 'get', ··· 179 168 }); 180 169 } 181 170 182 - const $refHead = `#/paths${path}/head`; 183 - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { 171 + if (finalPathItem.head) { 184 172 parseOperation({ 185 173 ...operationArgs, 186 174 method: 'head', ··· 198 186 }); 199 187 } 200 188 201 - const $refOptions = `#/paths${path}/options`; 202 - if ( 203 - finalPathItem.options && 204 - shouldProcessRef($refOptions, finalPathItem.options) 205 - ) { 189 + if (finalPathItem.options) { 206 190 parseOperation({ 207 191 ...operationArgs, 208 192 method: 'options', ··· 220 204 }); 221 205 } 222 206 223 - const $refPatch = `#/paths${path}/patch`; 224 - if ( 225 - finalPathItem.patch && 226 - shouldProcessRef($refPatch, finalPathItem.patch) 227 - ) { 207 + if (finalPathItem.patch) { 228 208 parseOperation({ 229 209 ...operationArgs, 230 210 method: 'patch', ··· 242 222 }); 243 223 } 244 224 245 - const $refPost = `#/paths${path}/post`; 246 - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { 225 + if (finalPathItem.post) { 247 226 parseOperation({ 248 227 ...operationArgs, 249 228 method: 'post', ··· 261 240 }); 262 241 } 263 242 264 - const $refPut = `#/paths${path}/put`; 265 - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { 243 + if (finalPathItem.put) { 266 244 parseOperation({ 267 245 ...operationArgs, 268 246 method: 'put', ··· 280 258 }); 281 259 } 282 260 283 - const $refTrace = `#/paths${path}/trace`; 284 - if ( 285 - finalPathItem.trace && 286 - shouldProcessRef($refTrace, finalPathItem.trace) 287 - ) { 261 + if (finalPathItem.trace) { 288 262 parseOperation({ 289 263 ...operationArgs, 290 264 method: 'trace',
+95
packages/openapi-ts/src/openApi/3.1.x/parser/filter.ts
··· 1 + import { addNamespace, removeNamespace } from '../../shared/utils/graph'; 2 + import { httpMethods } from '../../shared/utils/operation'; 3 + import type { OpenApiV3_1_X, PathItemObject, PathsObject } from '../types/spec'; 4 + 5 + /** 6 + * Replace source spec with filtered version. 7 + */ 8 + export const filterSpec = ({ 9 + operations, 10 + preserveOrder, 11 + requestBodies, 12 + schemas, 13 + spec, 14 + }: { 15 + operations: Set<string>; 16 + preserveOrder: boolean; 17 + requestBodies: Set<string>; 18 + schemas: Set<string>; 19 + spec: OpenApiV3_1_X; 20 + }) => { 21 + if (spec.components) { 22 + if (spec.components.requestBodies) { 23 + const filtered: typeof spec.components.requestBodies = {}; 24 + 25 + if (preserveOrder) { 26 + for (const [name, source] of Object.entries( 27 + spec.components.requestBodies, 28 + )) { 29 + if (requestBodies.has(addNamespace('body', name))) { 30 + filtered[name] = source; 31 + } 32 + } 33 + } else { 34 + for (const key of requestBodies) { 35 + const { name } = removeNamespace(key); 36 + const source = spec.components.requestBodies[name]; 37 + if (source) { 38 + filtered[name] = source; 39 + } 40 + } 41 + } 42 + 43 + spec.components.requestBodies = filtered; 44 + } 45 + 46 + if (spec.components.schemas) { 47 + const filtered: typeof spec.components.schemas = {}; 48 + 49 + if (preserveOrder) { 50 + for (const [name, source] of Object.entries(spec.components.schemas)) { 51 + if (schemas.has(addNamespace('schema', name))) { 52 + filtered[name] = source; 53 + } 54 + } 55 + } else { 56 + for (const key of schemas) { 57 + const { name } = removeNamespace(key); 58 + const source = spec.components.schemas[name]; 59 + if (source) { 60 + filtered[name] = source; 61 + } 62 + } 63 + } 64 + 65 + spec.components.schemas = filtered; 66 + } 67 + } 68 + 69 + if (spec.paths) { 70 + for (const entry of Object.entries(spec.paths)) { 71 + const path = entry[0] as keyof PathsObject; 72 + const pathItem = entry[1] as PathItemObject; 73 + 74 + for (const method of httpMethods) { 75 + const operation = pathItem[method]; 76 + if (!operation) { 77 + continue; 78 + } 79 + 80 + const key = addNamespace( 81 + 'operation', 82 + `${method.toUpperCase()} ${path}`, 83 + ); 84 + if (!operations.has(key)) { 85 + delete pathItem[method]; 86 + } 87 + } 88 + 89 + // remove paths that have no operations left 90 + if (!Object.keys(pathItem).length) { 91 + delete spec.paths[path]; 92 + } 93 + } 94 + } 95 + };
+27 -52
packages/openapi-ts/src/openApi/3.1.x/parser/index.ts
··· 1 1 import type { IR } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { canProcessRef, createFilters } from '../../shared/utils/filter'; 3 + import { 4 + createFilteredDependencies, 5 + createFilters, 6 + hasFilters, 7 + } from '../../shared/utils/filter'; 8 + import { createGraph } from '../../shared/utils/graph'; 4 9 import { mergeParametersObjects } from '../../shared/utils/parameter'; 5 10 import type { 6 11 OpenApiV3_1_X, ··· 10 15 RequestBodyObject, 11 16 SecuritySchemeObject, 12 17 } from '../types/spec'; 18 + import { filterSpec } from './filter'; 13 19 import { parseOperation } from './operation'; 14 20 import { parametersArrayToObject, parseParameter } from './parameter'; 15 21 import { parseRequestBody } from './requestBody'; 16 22 import { parseSchema } from './schema'; 17 23 import { parseServers } from './server'; 24 + 18 25 export const parseV3_1_X = (context: IR.Context<OpenApiV3_1_X>) => { 26 + if (hasFilters(context.config.input.filters)) { 27 + const graph = createGraph(context.spec); 28 + const filters = createFilters(context.config.input.filters); 29 + const sets = createFilteredDependencies({ filters, graph }); 30 + filterSpec({ 31 + ...sets, 32 + preserveOrder: filters.preserveOrder, 33 + spec: context.spec, 34 + }); 35 + } 36 + 19 37 const state: State = { 20 38 ids: new Map(), 21 39 operationIds: new Map(), 22 40 }; 23 41 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 24 42 25 - const excludeFilters = createFilters(context.config.input.exclude); 26 - const includeFilters = createFilters(context.config.input.include); 27 - 28 - const shouldProcessRef = ($ref: string, schema: Record<string, any>) => 29 - canProcessRef({ 30 - $ref, 31 - excludeFilters, 32 - includeFilters, 33 - schema, 34 - }); 35 - 36 43 // TODO: parser - handle more component types, old parser handles only parameters and schemas 37 44 if (context.spec.components) { 38 45 for (const name in context.spec.components.securitySchemes) { ··· 52 59 '$ref' in parameterOrReference 53 60 ? context.resolveRef<ParameterObject>(parameterOrReference.$ref) 54 61 : parameterOrReference; 55 - 56 - if (!shouldProcessRef($ref, parameter)) { 57 - continue; 58 - } 59 62 60 63 parseParameter({ 61 64 $ref, ··· 73 76 ? context.resolveRef<RequestBodyObject>(requestBodyOrReference.$ref) 74 77 : requestBodyOrReference; 75 78 76 - if (!shouldProcessRef($ref, requestBody)) { 77 - continue; 78 - } 79 - 80 79 parseRequestBody({ 81 80 $ref, 82 81 context, ··· 88 87 const $ref = `#/components/schemas/${name}`; 89 88 const schema = context.spec.components.schemas[name]!; 90 89 91 - if (!shouldProcessRef($ref, schema)) { 92 - continue; 93 - } 94 - 95 90 parseSchema({ 96 91 $ref, 97 92 context, ··· 130 125 state, 131 126 }; 132 127 133 - const $refDelete = `#/paths${path}/delete`; 134 - if ( 135 - finalPathItem.delete && 136 - shouldProcessRef($refDelete, finalPathItem.delete) 137 - ) { 128 + if (finalPathItem.delete) { 138 129 parseOperation({ 139 130 ...operationArgs, 140 131 method: 'delete', ··· 152 143 }); 153 144 } 154 145 155 - const $refGet = `#/paths${path}/get`; 156 - if (finalPathItem.get && shouldProcessRef($refGet, finalPathItem.get)) { 146 + if (finalPathItem.get) { 157 147 parseOperation({ 158 148 ...operationArgs, 159 149 method: 'get', ··· 171 161 }); 172 162 } 173 163 174 - const $refHead = `#/paths${path}/head`; 175 - if (finalPathItem.head && shouldProcessRef($refHead, finalPathItem.head)) { 164 + if (finalPathItem.head) { 176 165 parseOperation({ 177 166 ...operationArgs, 178 167 method: 'head', ··· 190 179 }); 191 180 } 192 181 193 - const $refOptions = `#/paths${path}/options`; 194 - if ( 195 - finalPathItem.options && 196 - shouldProcessRef($refOptions, finalPathItem.options) 197 - ) { 182 + if (finalPathItem.options) { 198 183 parseOperation({ 199 184 ...operationArgs, 200 185 method: 'options', ··· 212 197 }); 213 198 } 214 199 215 - const $refPatch = `#/paths${path}/patch`; 216 - if ( 217 - finalPathItem.patch && 218 - shouldProcessRef($refPatch, finalPathItem.patch) 219 - ) { 200 + if (finalPathItem.patch) { 220 201 parseOperation({ 221 202 ...operationArgs, 222 203 method: 'patch', ··· 234 215 }); 235 216 } 236 217 237 - const $refPost = `#/paths${path}/post`; 238 - if (finalPathItem.post && shouldProcessRef($refPost, finalPathItem.post)) { 218 + if (finalPathItem.post) { 239 219 parseOperation({ 240 220 ...operationArgs, 241 221 method: 'post', ··· 253 233 }); 254 234 } 255 235 256 - const $refPut = `#/paths${path}/put`; 257 - if (finalPathItem.put && shouldProcessRef($refPut, finalPathItem.put)) { 236 + if (finalPathItem.put) { 258 237 parseOperation({ 259 238 ...operationArgs, 260 239 method: 'put', ··· 272 251 }); 273 252 } 274 253 275 - const $refTrace = `#/paths${path}/trace`; 276 - if ( 277 - finalPathItem.trace && 278 - shouldProcessRef($refTrace, finalPathItem.trace) 279 - ) { 254 + if (finalPathItem.trace) { 280 255 parseOperation({ 281 256 ...operationArgs, 282 257 method: 'trace',
+430 -55
packages/openapi-ts/src/openApi/shared/utils/filter.ts
··· 1 - type Filter = RegExp | ReadonlyArray<string>; 2 - type Filters = ReadonlyArray<Filter> | undefined; 1 + import type { Config } from '../../../types/config'; 2 + import type { Graph } from './graph'; 3 + import { addNamespace, removeNamespace } from './graph'; 4 + 5 + type FiltersConfigToState<T> = { 6 + [K in keyof T]-?: NonNullable<T[K]> extends ReadonlyArray<infer U> 7 + ? Set<U> 8 + : NonNullable<T[K]> extends object 9 + ? FiltersConfigToState<NonNullable<T[K]>> 10 + : T[K]; 11 + }; 12 + 13 + export type Filters = FiltersConfigToState< 14 + NonNullable<Config['input']['filters']> 15 + >; 16 + 17 + export const createFilters = (config: Config['input']['filters']): Filters => { 18 + const filters: Filters = { 19 + deprecated: config?.deprecated ?? true, 20 + operations: { 21 + exclude: new Set( 22 + config?.operations?.exclude?.map((value) => 23 + addNamespace('operation', value), 24 + ), 25 + ), 26 + include: new Set( 27 + config?.operations?.include?.map((value) => 28 + addNamespace('operation', value), 29 + ), 30 + ), 31 + }, 32 + orphans: config?.orphans ?? false, 33 + preserveOrder: config?.preserveOrder ?? false, 34 + requestBodies: { 35 + exclude: new Set( 36 + config?.requestBodies?.exclude?.map((value) => 37 + addNamespace('body', value), 38 + ), 39 + ), 40 + include: new Set( 41 + config?.requestBodies?.include?.map((value) => 42 + addNamespace('body', value), 43 + ), 44 + ), 45 + }, 46 + schemas: { 47 + exclude: new Set( 48 + config?.schemas?.exclude?.map((value) => addNamespace('schema', value)), 49 + ), 50 + include: new Set( 51 + config?.schemas?.include?.map((value) => addNamespace('schema', value)), 52 + ), 53 + }, 54 + tags: { 55 + exclude: new Set(config?.tags?.exclude), 56 + include: new Set(config?.tags?.include), 57 + }, 58 + }; 59 + return filters; 60 + }; 61 + 62 + export const hasFilters = (config: Config['input']['filters']): boolean => { 63 + if (!config) { 64 + return false; 65 + } 66 + 67 + // we explicitly want to strip orphans or deprecated 68 + if (config.orphans === false || config.deprecated === false) { 69 + return true; 70 + } 71 + 72 + return Boolean( 73 + config.operations?.exclude?.length || 74 + config.operations?.include?.length || 75 + config.requestBodies?.exclude?.length || 76 + config.requestBodies?.include?.length || 77 + config.schemas?.exclude?.length || 78 + config.schemas?.include?.length || 79 + config.tags?.exclude?.length || 80 + config.tags?.include?.length, 81 + ); 82 + }; 83 + 84 + /** 85 + * Collect operations that satisfy the include/exclude filters and schema dependencies. 86 + */ 87 + const collectOperations = ({ 88 + filters, 89 + graph, 90 + requestBodies, 91 + schemas, 92 + }: { 93 + filters: Filters; 94 + graph: Graph; 95 + requestBodies: Set<string>; 96 + schemas: Set<string>; 97 + }): { 98 + operations: Set<string>; 99 + } => { 100 + const finalSet = new Set<string>(); 101 + const initialSet = filters.operations.include.size 102 + ? filters.operations.include 103 + : new Set(graph.operations.keys()); 104 + const stack = [...initialSet]; 105 + while (stack.length) { 106 + const key = stack.pop()!; 107 + 108 + if (filters.operations.exclude.has(key) || finalSet.has(key)) { 109 + continue; 110 + } 111 + 112 + const node = graph.operations.get(key); 113 + 114 + if (!node) { 115 + continue; 116 + } 117 + 118 + if (!filters.deprecated && node.deprecated) { 119 + continue; 120 + } 121 + 122 + if ( 123 + filters.tags.exclude.size && 124 + node.tags.size && 125 + [...filters.tags.exclude].some((tag) => node.tags.has(tag)) 126 + ) { 127 + continue; 128 + } 129 + 130 + if ( 131 + filters.tags.include.size && 132 + !new Set([...filters.tags.include].filter((tag) => node.tags.has(tag))) 133 + .size 134 + ) { 135 + continue; 136 + } 137 + 138 + // skip operation if it references any component not included 139 + if ( 140 + [...node.dependencies].some((dependency) => { 141 + const { namespace } = removeNamespace(dependency); 142 + switch (namespace) { 143 + case 'body': 144 + return !requestBodies.has(dependency); 145 + case 'schema': 146 + return !schemas.has(dependency); 147 + default: 148 + return false; 149 + } 150 + }) 151 + ) { 152 + continue; 153 + } 3 154 4 - const isFiltersMatch = ({ 5 - $ref, 155 + finalSet.add(key); 156 + } 157 + return { operations: finalSet }; 158 + }; 159 + 160 + /** 161 + * Collect requestBodies that satisfy the include/exclude filters and schema dependencies. 162 + */ 163 + const collectRequestBodies = ({ 6 164 filters, 7 - schema, 165 + graph, 166 + schemas, 8 167 }: { 9 - $ref: string; 10 - filters: NonNullable<Filters>; 11 - schema: Record<string, unknown>; 12 - }): boolean => { 13 - for (const filter of filters) { 14 - if (filter instanceof RegExp) { 15 - filter.lastIndex = 0; 16 - if (filter.test($ref)) { 17 - return true; 168 + filters: Filters; 169 + graph: Graph; 170 + schemas: Set<string>; 171 + }): { 172 + requestBodies: Set<string>; 173 + } => { 174 + const finalSet = new Set<string>(); 175 + const initialSet = filters.requestBodies.include.size 176 + ? filters.requestBodies.include 177 + : new Set(graph.requestBodies.keys()); 178 + const stack = [...initialSet]; 179 + while (stack.length) { 180 + const key = stack.pop()!; 181 + 182 + if (filters.requestBodies.exclude.has(key) || finalSet.has(key)) { 183 + continue; 184 + } 185 + 186 + const node = graph.requestBodies.get(key); 187 + 188 + if (!node) { 189 + continue; 190 + } 191 + 192 + if (!filters.deprecated && node.deprecated) { 193 + continue; 194 + } 195 + 196 + finalSet.add(key); 197 + 198 + if (!node.dependencies.size) { 199 + continue; 200 + } 201 + 202 + for (const dependency of node.dependencies) { 203 + const { namespace } = removeNamespace(dependency); 204 + switch (namespace) { 205 + case 'body': { 206 + if (filters.requestBodies.exclude.has(dependency)) { 207 + finalSet.delete(key); 208 + } else if (!finalSet.has(dependency)) { 209 + stack.push(dependency); 210 + } 211 + break; 212 + } 213 + case 'schema': { 214 + if (filters.schemas.exclude.has(dependency)) { 215 + finalSet.delete(key); 216 + } else if (!schemas.has(dependency)) { 217 + schemas.add(dependency); 218 + } 219 + break; 220 + } 18 221 } 19 - } else { 20 - const field = filter[0] || ''; 21 - const value = filter[1]; 22 - if (value === undefined) { 23 - if (schema[field]) { 24 - return true; 222 + } 223 + } 224 + return { requestBodies: finalSet }; 225 + }; 226 + 227 + /** 228 + * Collect schemas that satisfy the include/exclude filters. 229 + */ 230 + const collectSchemas = ({ 231 + filters, 232 + graph, 233 + }: { 234 + filters: Filters; 235 + graph: Graph; 236 + }): { 237 + schemas: Set<string>; 238 + } => { 239 + const finalSet = new Set<string>(); 240 + const initialSet = filters.schemas.include.size 241 + ? filters.schemas.include 242 + : new Set(graph.schemas.keys()); 243 + const stack = [...initialSet]; 244 + while (stack.length) { 245 + const key = stack.pop()!; 246 + 247 + if (filters.schemas.exclude.has(key) || finalSet.has(key)) { 248 + continue; 249 + } 250 + 251 + const node = graph.schemas.get(key); 252 + 253 + if (!node) { 254 + continue; 255 + } 256 + 257 + if (!filters.deprecated && node.deprecated) { 258 + continue; 259 + } 260 + 261 + finalSet.add(key); 262 + 263 + if (!node.dependencies.size) { 264 + continue; 265 + } 266 + 267 + for (const dependency of node.dependencies) { 268 + const { namespace } = removeNamespace(dependency); 269 + switch (namespace) { 270 + case 'schema': { 271 + if ( 272 + !finalSet.has(dependency) && 273 + !filters.schemas.exclude.has(dependency) 274 + ) { 275 + stack.push(dependency); 276 + } 277 + break; 25 278 } 26 - } else if (schema[field] === value) { 27 - return true; 28 279 } 29 280 } 30 281 } 31 - 32 - return false; 282 + return { schemas: finalSet }; 33 283 }; 34 284 35 285 /** 36 - * Exclude takes precedence over include. 286 + * Drop request bodies that depend on already excluded request bodies. 37 287 */ 38 - export const canProcessRef = ({ 39 - excludeFilters, 40 - includeFilters, 41 - ...state 288 + const dropExcludedRequestBodies = ({ 289 + filters, 290 + graph, 291 + requestBodies, 42 292 }: { 43 - $ref: string; 44 - excludeFilters: Filters; 45 - includeFilters: Filters; 46 - schema: Record<string, unknown>; 47 - }): boolean => { 48 - if (!excludeFilters && !includeFilters) { 49 - return true; 293 + filters: Filters; 294 + graph: Graph; 295 + requestBodies: Set<string>; 296 + }): void => { 297 + if (!filters.requestBodies.exclude.size) { 298 + return; 50 299 } 51 300 52 - if (excludeFilters) { 53 - if (isFiltersMatch({ ...state, filters: excludeFilters })) { 54 - return false; 301 + for (const key of requestBodies) { 302 + const node = graph.requestBodies.get(key); 303 + 304 + if (!node?.dependencies.size) { 305 + continue; 306 + } 307 + 308 + for (const excludedKey of filters.requestBodies.exclude) { 309 + if (node.dependencies.has(excludedKey)) { 310 + requestBodies.delete(key); 311 + break; 312 + } 55 313 } 56 314 } 315 + }; 57 316 58 - if (includeFilters) { 59 - return isFiltersMatch({ ...state, filters: includeFilters }); 317 + /** 318 + * Drop schemas that depend on already excluded schemas. 319 + */ 320 + const dropExcludedSchemas = ({ 321 + filters, 322 + graph, 323 + schemas, 324 + }: { 325 + filters: Filters; 326 + graph: Graph; 327 + schemas: Set<string>; 328 + }): void => { 329 + if (!filters.schemas.exclude.size) { 330 + return; 60 331 } 61 332 62 - return true; 63 - }; 333 + for (const key of schemas) { 334 + const node = graph.schemas.get(key); 335 + 336 + if (!node?.dependencies.size) { 337 + continue; 338 + } 64 339 65 - const createFilter = (matcher: string): Filter => { 66 - if (matcher.startsWith('@')) { 67 - return matcher.slice(1).split(':'); 340 + for (const excludedKey of filters.schemas.exclude) { 341 + if (node.dependencies.has(excludedKey)) { 342 + schemas.delete(key); 343 + break; 344 + } 345 + } 68 346 } 347 + }; 69 348 70 - return new RegExp(matcher); 349 + const dropOrphans = ({ 350 + operationDependencies, 351 + requestBodies, 352 + schemas, 353 + }: { 354 + operationDependencies: Set<string>; 355 + requestBodies: Set<string>; 356 + schemas: Set<string>; 357 + }) => { 358 + for (const key of schemas) { 359 + if (!operationDependencies.has(key)) { 360 + schemas.delete(key); 361 + } 362 + } 363 + for (const key of requestBodies) { 364 + if (!operationDependencies.has(key)) { 365 + requestBodies.delete(key); 366 + } 367 + } 71 368 }; 72 369 73 - export const createFilters = ( 74 - matchers: ReadonlyArray<string> | string | undefined, 75 - ): Filters => { 76 - if (!matchers) { 77 - return; 370 + const collectOperationDependencies = ({ 371 + graph, 372 + operations, 373 + }: { 374 + graph: Graph; 375 + operations: Set<string>; 376 + }): { 377 + operationDependencies: Set<string>; 378 + } => { 379 + const finalSet = new Set<string>(); 380 + const initialSet = new Set( 381 + [...operations].flatMap((key) => [ 382 + ...(graph.operations.get(key)?.dependencies ?? []), 383 + ]), 384 + ); 385 + const stack = [...initialSet]; 386 + while (stack.length) { 387 + const key = stack.pop()!; 388 + 389 + if (finalSet.has(key)) { 390 + continue; 391 + } 392 + 393 + finalSet.add(key); 394 + 395 + const { namespace } = removeNamespace(key); 396 + let dependencies: Set<string> | undefined; 397 + if (namespace === 'body') { 398 + dependencies = graph.requestBodies.get(key)?.dependencies; 399 + } else if (namespace === 'operation') { 400 + dependencies = graph.operations.get(key)?.dependencies; 401 + } else if (namespace === 'schema') { 402 + dependencies = graph.schemas.get(key)?.dependencies; 403 + } 404 + 405 + if (!dependencies?.size) { 406 + continue; 407 + } 408 + 409 + for (const dependency of dependencies) { 410 + if (!finalSet.has(dependency)) { 411 + stack.push(dependency); 412 + } 413 + } 78 414 } 415 + return { operationDependencies: finalSet }; 416 + }; 79 417 80 - if (typeof matchers === 'string') { 81 - return [createFilter(matchers)]; 418 + export const createFilteredDependencies = ({ 419 + filters, 420 + graph, 421 + }: { 422 + filters: Filters; 423 + graph: Graph; 424 + }): { 425 + operations: Set<string>; 426 + requestBodies: Set<string>; 427 + schemas: Set<string>; 428 + } => { 429 + const { schemas } = collectSchemas({ filters, graph }); 430 + const { requestBodies } = collectRequestBodies({ 431 + filters, 432 + graph, 433 + schemas, 434 + }); 435 + 436 + dropExcludedSchemas({ filters, graph, schemas }); 437 + dropExcludedRequestBodies({ filters, graph, requestBodies }); 438 + 439 + // collect operations after dropping components 440 + const { operations } = collectOperations({ 441 + filters, 442 + graph, 443 + requestBodies, 444 + schemas, 445 + }); 446 + 447 + if (!filters.orphans) { 448 + const { operationDependencies } = collectOperationDependencies({ 449 + graph, 450 + operations, 451 + }); 452 + dropOrphans({ operationDependencies, requestBodies, schemas }); 82 453 } 83 454 84 - return matchers.map((matcher) => createFilter(matcher)); 455 + return { 456 + operations, 457 + requestBodies, 458 + schemas, 459 + }; 85 460 };
+347
packages/openapi-ts/src/openApi/shared/utils/graph.ts
··· 1 + import type { SchemaObject as OpenApiV2_0_XSchemaObject } from '../../2.0.x/types/spec'; 2 + import type { SchemaObject as OpenApiV3_0_XSchemaObject } from '../../3.0.x/types/spec'; 3 + import type { 4 + PathItemObject, 5 + PathsObject, 6 + SchemaObject as OpenApiV3_1_XSchemaObject, 7 + } from '../../3.1.x/types/spec'; 8 + import type { OpenApi } from '../../types'; 9 + import { httpMethods } from './operation'; 10 + 11 + export type Graph = { 12 + operations: Map< 13 + string, 14 + { 15 + dependencies: Set<string>; 16 + deprecated: boolean; 17 + tags: Set<string>; 18 + } 19 + >; 20 + // TODO: add parameters 21 + requestBodies: Map< 22 + string, 23 + { 24 + dependencies: Set<string>; 25 + deprecated: boolean; 26 + } 27 + >; 28 + schemas: Map< 29 + string, 30 + { 31 + dependencies: Set<string>; 32 + deprecated: boolean; 33 + } 34 + >; 35 + }; 36 + 37 + type Type = 'body' | 'operation' | 'schema' | 'unknown'; 38 + 39 + /** 40 + * Converts reference strings from OpenAPI $ref keywords into namespaces. 41 + * @example '#/components/schemas/Foo' -> 'schema' 42 + */ 43 + export const stringToNamespace = (value: string): Type => { 44 + switch (value) { 45 + case 'requestBodies': 46 + return 'body'; 47 + case 'definitions': 48 + case 'schemas': 49 + return 'schema'; 50 + default: 51 + return 'unknown'; 52 + } 53 + }; 54 + 55 + export const addNamespace = (namespace: Type, value: string = ''): string => 56 + `${namespace}/${value}`; 57 + 58 + export const removeNamespace = ( 59 + key: string, 60 + ): { 61 + name: string; 62 + namespace: Type; 63 + } => { 64 + const [namespace, name] = key.split('/'); 65 + return { 66 + name: name!, 67 + namespace: namespace! as Type, 68 + }; 69 + }; 70 + 71 + const collectSchemaDependencies = ( 72 + schema: 73 + | OpenApiV2_0_XSchemaObject 74 + | OpenApiV3_0_XSchemaObject 75 + | OpenApiV3_1_XSchemaObject, 76 + dependencies: Set<string>, 77 + ) => { 78 + if ('$ref' in schema && schema.$ref) { 79 + const parts = schema.$ref.split('/'); 80 + const type = parts[parts.length - 2]; 81 + const name = parts[parts.length - 1]; 82 + if (type && name) { 83 + dependencies.add(addNamespace(stringToNamespace(type), name)); 84 + } 85 + } 86 + 87 + if (schema.items && typeof schema.items === 'object') { 88 + collectSchemaDependencies(schema.items, dependencies); 89 + } 90 + 91 + if (schema.properties) { 92 + for (const property of Object.values(schema.properties)) { 93 + if (typeof property === 'object') { 94 + collectSchemaDependencies(property, dependencies); 95 + } 96 + } 97 + } 98 + 99 + if ( 100 + schema.additionalProperties && 101 + typeof schema.additionalProperties === 'object' 102 + ) { 103 + collectSchemaDependencies(schema.additionalProperties, dependencies); 104 + } 105 + 106 + if ('allOf' in schema && schema.allOf) { 107 + for (const item of schema.allOf) { 108 + collectSchemaDependencies(item, dependencies); 109 + } 110 + } 111 + 112 + if ('anyOf' in schema && schema.anyOf) { 113 + for (const item of schema.anyOf) { 114 + collectSchemaDependencies(item, dependencies); 115 + } 116 + } 117 + 118 + if ('contains' in schema && schema.contains) { 119 + collectSchemaDependencies(schema.contains, dependencies); 120 + } 121 + 122 + if ('not' in schema && schema.not) { 123 + collectSchemaDependencies(schema.not, dependencies); 124 + } 125 + 126 + if ('oneOf' in schema && schema.oneOf) { 127 + for (const item of schema.oneOf) { 128 + collectSchemaDependencies(item, dependencies); 129 + } 130 + } 131 + 132 + if ('prefixItems' in schema && schema.prefixItems) { 133 + for (const item of schema.prefixItems) { 134 + collectSchemaDependencies(item, dependencies); 135 + } 136 + } 137 + }; 138 + 139 + const collectOpenApiV2Dependencies = (spec: OpenApi.V2_0_X, graph: Graph) => { 140 + if (spec.definitions) { 141 + for (const [key, schema] of Object.entries(spec.definitions)) { 142 + const dependencies = new Set<string>(); 143 + collectSchemaDependencies(schema, dependencies); 144 + graph.schemas.set(addNamespace('schema', key), { 145 + dependencies, 146 + deprecated: false, 147 + }); 148 + } 149 + 150 + // TODO: add parameters 151 + } 152 + 153 + if (spec.paths) { 154 + for (const entry of Object.entries(spec.paths)) { 155 + const path = entry[0] as keyof PathsObject; 156 + const pathItem = entry[1] as PathItemObject; 157 + for (const method of httpMethods) { 158 + const operation = pathItem[method]; 159 + if (!operation) { 160 + continue; 161 + } 162 + 163 + const dependencies = new Set<string>(); 164 + 165 + if (operation.requestBody) { 166 + if ('$ref' in operation.requestBody) { 167 + collectSchemaDependencies(operation.requestBody, dependencies); 168 + } else { 169 + for (const media of Object.values(operation.requestBody.content)) { 170 + if (media.schema) { 171 + collectSchemaDependencies(media.schema, dependencies); 172 + } 173 + } 174 + } 175 + } 176 + 177 + if (operation.responses) { 178 + for (const response of Object.values(operation.responses)) { 179 + if (!response) { 180 + continue; 181 + } 182 + 183 + if ('$ref' in response) { 184 + collectSchemaDependencies(response, dependencies); 185 + } else if (response.content) { 186 + for (const media of Object.values(response.content)) { 187 + if (media.schema) { 188 + collectSchemaDependencies(media.schema, dependencies); 189 + } 190 + } 191 + } 192 + } 193 + } 194 + 195 + if (operation.parameters) { 196 + for (const parameter of operation.parameters) { 197 + if ('$ref' in parameter) { 198 + collectSchemaDependencies(parameter, dependencies); 199 + } else if (parameter.schema) { 200 + collectSchemaDependencies(parameter.schema, dependencies); 201 + } 202 + } 203 + } 204 + 205 + graph.operations.set( 206 + addNamespace('operation', `${method.toUpperCase()} ${path}`), 207 + { 208 + dependencies, 209 + deprecated: Boolean(operation.deprecated), 210 + tags: new Set(operation.tags), 211 + }, 212 + ); 213 + } 214 + } 215 + } 216 + }; 217 + 218 + const collectOpenApiV3Dependencies = ( 219 + spec: OpenApi.V3_0_X | OpenApi.V3_1_X, 220 + graph: Graph, 221 + ) => { 222 + type ExtractedType<T> = T extends Record<string, infer V> ? V : never; 223 + 224 + if (spec.components) { 225 + // TODO: add other components 226 + if (spec.components.schemas) { 227 + type Schema = ExtractedType<typeof spec.components.schemas>; 228 + for (const [key, value] of Object.entries(spec.components.schemas)) { 229 + const schema = value as Schema; 230 + const dependencies = new Set<string>(); 231 + collectSchemaDependencies(schema, dependencies); 232 + graph.schemas.set(addNamespace('schema', key), { 233 + dependencies, 234 + deprecated: 235 + 'deprecated' in schema ? Boolean(schema.deprecated) : false, 236 + }); 237 + } 238 + } 239 + 240 + // TODO: add parameters 241 + 242 + if (spec.components.requestBodies) { 243 + type RequestBody = ExtractedType<typeof spec.components.requestBodies>; 244 + for (const [key, value] of Object.entries( 245 + spec.components.requestBodies, 246 + )) { 247 + const requestBody = value as RequestBody; 248 + const dependencies = new Set<string>(); 249 + if ('$ref' in requestBody) { 250 + collectSchemaDependencies(requestBody, dependencies); 251 + } else { 252 + for (const media of Object.values(requestBody.content)) { 253 + if (media.schema) { 254 + collectSchemaDependencies(media.schema, dependencies); 255 + } 256 + } 257 + } 258 + graph.requestBodies.set(addNamespace('body', key), { 259 + dependencies, 260 + deprecated: false, 261 + }); 262 + } 263 + } 264 + } 265 + 266 + if (spec.paths) { 267 + for (const entry of Object.entries(spec.paths)) { 268 + const path = entry[0] as keyof PathsObject; 269 + const pathItem = entry[1] as PathItemObject; 270 + for (const method of httpMethods) { 271 + const operation = pathItem[method]; 272 + if (!operation) { 273 + continue; 274 + } 275 + 276 + const dependencies = new Set<string>(); 277 + 278 + if (operation.requestBody) { 279 + if ('$ref' in operation.requestBody) { 280 + collectSchemaDependencies(operation.requestBody, dependencies); 281 + } else { 282 + for (const media of Object.values(operation.requestBody.content)) { 283 + if (media.schema) { 284 + collectSchemaDependencies(media.schema, dependencies); 285 + } 286 + } 287 + } 288 + } 289 + 290 + if (operation.responses) { 291 + for (const response of Object.values(operation.responses)) { 292 + if (!response) { 293 + continue; 294 + } 295 + 296 + if ('$ref' in response) { 297 + collectSchemaDependencies(response, dependencies); 298 + } else if (response.content) { 299 + for (const media of Object.values(response.content)) { 300 + if (media.schema) { 301 + collectSchemaDependencies(media.schema, dependencies); 302 + } 303 + } 304 + } 305 + } 306 + } 307 + 308 + if (operation.parameters) { 309 + for (const parameter of operation.parameters) { 310 + if ('$ref' in parameter) { 311 + collectSchemaDependencies(parameter, dependencies); 312 + } else if (parameter.schema) { 313 + collectSchemaDependencies(parameter.schema, dependencies); 314 + } 315 + } 316 + } 317 + 318 + graph.operations.set( 319 + addNamespace('operation', `${method.toUpperCase()} ${path}`), 320 + { 321 + dependencies, 322 + deprecated: Boolean(operation.deprecated), 323 + tags: new Set(operation.tags), 324 + }, 325 + ); 326 + } 327 + } 328 + } 329 + }; 330 + 331 + export const createGraph = ( 332 + spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X, 333 + ): Graph => { 334 + const graph: Graph = { 335 + operations: new Map(), 336 + requestBodies: new Map(), 337 + schemas: new Map(), 338 + }; 339 + 340 + if ('swagger' in spec) { 341 + collectOpenApiV2Dependencies(spec, graph); 342 + } else { 343 + collectOpenApiV3Dependencies(spec, graph); 344 + } 345 + 346 + return graph; 347 + };
+11
packages/openapi-ts/src/openApi/shared/utils/operation.ts
··· 3 3 import { sanitizeNamespaceIdentifier } from '../../common/parser/sanitize'; 4 4 import type { State } from '../types/state'; 5 5 6 + export const httpMethods = [ 7 + 'delete', 8 + 'get', 9 + 'head', 10 + 'options', 11 + 'patch', 12 + 'post', 13 + 'put', 14 + 'trace', 15 + ] as const; 16 + 6 17 /** 7 18 * Verifies that operation ID is unique. For now, we only warn when this isn't 8 19 * true as people like to not follow this part of the specification. In the
+96 -22
packages/openapi-ts/src/types/config.d.ts
··· 39 39 */ 40 40 commit_sha?: string; 41 41 /** 42 - * Prevent parts matching the regular expression(s) from being processed. 43 - * You can select both operations and components by reference within 44 - * the bundled input. 45 - * 46 - * In case of conflicts, `exclude` takes precedence over `include`. 47 - * 48 - * @example 49 - * operation: '^#/paths/api/v1/foo/get$' 50 - * schema: '^#/components/schemas/Foo$' 51 - * deprecated: '@deprecated' 52 - */ 53 - exclude?: ReadonlyArray<string> | string; 54 - /** 55 42 * You pass any valid Fetch API options to the request for fetching your 56 43 * specification. This is useful if your file is behind auth for example. 57 44 */ 58 45 fetch?: RequestInit; 59 46 /** 60 - * Process only parts matching the regular expression(s). You can select both 61 - * operations and components by reference within the bundled input. 62 - * 63 - * In case of conflicts, `exclude` takes precedence over `include`. 64 - * 65 - * @example 66 - * operation: '^#/paths/api/v1/foo/get$' 67 - * schema: '^#/components/schemas/Foo$' 47 + * Filters can be used to select a subset of your input before it's processed 48 + * by plugins. 68 49 */ 69 - include?: ReadonlyArray<string> | string; 50 + filters?: { 51 + /** 52 + * Include deprecated resources in the output? 53 + * 54 + * @default true 55 + */ 56 + deprecated?: boolean; 57 + operations?: { 58 + /** 59 + * Prevent operations matching the `exclude` filters from being processed. 60 + * 61 + * In case of conflicts, `exclude` takes precedence over `include`. 62 + * 63 + * @example ['GET /api/v1/foo'] 64 + */ 65 + exclude?: ReadonlyArray<string>; 66 + /** 67 + * Process only operations matching the `include` filters. 68 + * 69 + * In case of conflicts, `exclude` takes precedence over `include`. 70 + * 71 + * @example ['GET /api/v1/foo'] 72 + */ 73 + include?: ReadonlyArray<string>; 74 + }; 75 + /** 76 + * Keep reusable components without any references in the output? By 77 + * default, we exclude orphaned resources. 78 + * 79 + * @default false 80 + */ 81 + orphans?: boolean; 82 + /** 83 + * Should we preserve the key order when overwriting your input? This 84 + * option is disabled by default to improve performance. 85 + * 86 + * @default false 87 + */ 88 + preserveOrder?: boolean; 89 + requestBodies?: { 90 + /** 91 + * Prevent request bodies matching the `exclude` filters from being processed. 92 + * 93 + * In case of conflicts, `exclude` takes precedence over `include`. 94 + * 95 + * @example ['Foo'] 96 + */ 97 + exclude?: ReadonlyArray<string>; 98 + /** 99 + * Process only request bodies matching the `include` filters. 100 + * 101 + * In case of conflicts, `exclude` takes precedence over `include`. 102 + * 103 + * @example ['Foo'] 104 + */ 105 + include?: ReadonlyArray<string>; 106 + }; 107 + schemas?: { 108 + /** 109 + * Prevent schemas matching the `exclude` filters from being processed. 110 + * 111 + * In case of conflicts, `exclude` takes precedence over `include`. 112 + * 113 + * @example ['Foo'] 114 + */ 115 + exclude?: ReadonlyArray<string>; 116 + /** 117 + * Process only schemas matching the `include` filters. 118 + * 119 + * In case of conflicts, `exclude` takes precedence over `include`. 120 + * 121 + * @example ['Foo'] 122 + */ 123 + include?: ReadonlyArray<string>; 124 + }; 125 + tags?: { 126 + /** 127 + * Prevent tags matching the `exclude` filters from being processed. 128 + * 129 + * In case of conflicts, `exclude` takes precedence over `include`. 130 + * 131 + * @example ['foo'] 132 + */ 133 + exclude?: ReadonlyArray<string>; 134 + /** 135 + * Process only tags matching the `include` filters. 136 + * 137 + * In case of conflicts, `exclude` takes precedence over `include`. 138 + * 139 + * @example ['foo'] 140 + */ 141 + include?: ReadonlyArray<string>; 142 + }; 143 + }; 70 144 /** 71 145 * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 72 146 *