Skip to content

Commit 8340950

Browse files
committed
fix(parser): prevent broken output by tracking dependencies when using filters
1 parent 33a417f commit 8340950

File tree

3 files changed

+250
-5
lines changed

3 files changed

+250
-5
lines changed

packages/openapi-ts-tests/test/openapi-ts.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export default defineConfig(() => {
2525
// 'x-foo': 'bar',
2626
// },
2727
// },
28-
// include:
29-
// '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$',
28+
include: '^(#/components/schemas/Foo)$',
29+
// '^(#/components/schemas/Foo|#/paths/api/v{api-version}/simple/options)$',
3030
// organization: 'hey-api',
3131
// path: {
3232
// components: {},
@@ -36,7 +36,7 @@ export default defineConfig(() => {
3636
// openapi: '3.1.0',
3737
// paths: {},
3838
// },
39-
path: path.resolve(__dirname, 'spec', '3.1.x', 'full.json'),
39+
path: path.resolve(__dirname, 'spec', '3.1.x', 'read-write-only.yaml'),
4040
// path: 'http://localhost:4000/',
4141
// path: 'https://get.heyapi.dev/',
4242
// path: 'https://get.heyapi.dev/hey-api/backend?branch=main&version=1.0.0',
@@ -115,7 +115,7 @@ export default defineConfig(() => {
115115
},
116116
{
117117
exportFromIndex: true,
118-
name: '@tanstack/react-query',
118+
// name: '@tanstack/react-query',
119119
},
120120
{
121121
// exportFromIndex: true,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import type {
2+
OpenApiV3_1_X,
3+
PathItemObject,
4+
PathsObject,
5+
SchemaObject,
6+
} from '../types/spec';
7+
8+
const collectSchemaDependencies = (
9+
schema: SchemaObject,
10+
dependencies: Set<string>,
11+
) => {
12+
// TODO: add more keywords, e.g. prefixItems
13+
14+
if (schema.$ref) {
15+
const refName = schema.$ref.split('/').pop();
16+
if (refName) {
17+
dependencies.add(refName);
18+
}
19+
}
20+
21+
if (schema.items && typeof schema.items === 'object') {
22+
collectSchemaDependencies(schema.items, dependencies);
23+
}
24+
25+
if (schema.properties) {
26+
for (const property of Object.values(schema.properties)) {
27+
if (typeof property === 'object') {
28+
collectSchemaDependencies(property, dependencies);
29+
}
30+
}
31+
}
32+
33+
if (
34+
schema.additionalProperties &&
35+
typeof schema.additionalProperties === 'object'
36+
) {
37+
collectSchemaDependencies(schema.additionalProperties, dependencies);
38+
}
39+
40+
for (const compositeKeyword of ['allOf', 'anyOf', 'oneOf'] as const) {
41+
if (schema[compositeKeyword]) {
42+
for (const item of schema[compositeKeyword]!) {
43+
collectSchemaDependencies(item, dependencies);
44+
}
45+
}
46+
}
47+
48+
if (schema.contains) {
49+
collectSchemaDependencies(schema.contains, dependencies);
50+
}
51+
52+
if (schema.not) {
53+
collectSchemaDependencies(schema.not, dependencies);
54+
}
55+
};
56+
57+
// TODO: references might reference other components besides schemas
58+
type DependencyGraphScope = Map<string, Set<string>>;
59+
type DependencyGraph = {
60+
operations: DependencyGraphScope;
61+
schemas: DependencyGraphScope;
62+
};
63+
64+
export const createDependencyGraph = (spec: OpenApiV3_1_X): DependencyGraph => {
65+
const dependencyGraph: DependencyGraph = {
66+
operations: new Map(),
67+
schemas: new Map(),
68+
};
69+
70+
if (spec.components) {
71+
// TODO: add other components
72+
if (spec.components.schemas) {
73+
for (const [schemaName, schema] of Object.entries(
74+
spec.components.schemas,
75+
)) {
76+
const dependencies = new Set<string>();
77+
collectSchemaDependencies(schema, dependencies);
78+
dependencyGraph.schemas.set(schemaName, dependencies);
79+
}
80+
}
81+
}
82+
83+
if (spec.paths) {
84+
const httpMethods = [
85+
'delete',
86+
'get',
87+
'head',
88+
'options',
89+
'patch',
90+
'post',
91+
'put',
92+
'trace',
93+
] as const;
94+
for (const entry of Object.entries(spec.paths)) {
95+
const path = entry[0] as keyof PathsObject;
96+
const pathItem = entry[1] as PathItemObject;
97+
for (const method of httpMethods) {
98+
const operation = pathItem[method];
99+
if (!operation) {
100+
continue;
101+
}
102+
103+
const operationKey = `${method.toUpperCase()} ${path}`;
104+
const dependencies = new Set<string>();
105+
106+
if (operation.requestBody) {
107+
if ('$ref' in operation.requestBody) {
108+
collectSchemaDependencies(operation.requestBody, dependencies);
109+
} else
110+
for (const media of Object.values(operation.requestBody.content)) {
111+
if (media.schema) {
112+
collectSchemaDependencies(media.schema, dependencies);
113+
}
114+
}
115+
}
116+
117+
if (operation.responses) {
118+
for (const response of Object.values(operation.responses)) {
119+
if (!response) {
120+
continue;
121+
}
122+
123+
if ('$ref' in response) {
124+
collectSchemaDependencies(response, dependencies);
125+
} else if (response.content) {
126+
for (const media of Object.values(response.content)) {
127+
if (media.schema) {
128+
collectSchemaDependencies(media.schema, dependencies);
129+
}
130+
}
131+
}
132+
}
133+
}
134+
135+
if (operation.parameters) {
136+
for (const parameter of operation.parameters) {
137+
if ('$ref' in parameter) {
138+
collectSchemaDependencies(parameter, dependencies);
139+
} else if (parameter.schema) {
140+
collectSchemaDependencies(parameter.schema, dependencies);
141+
}
142+
}
143+
}
144+
145+
dependencyGraph.operations.set(operationKey, dependencies);
146+
}
147+
}
148+
}
149+
150+
return dependencyGraph;
151+
};
152+
153+
// TODO: make generic to work with any spec version
154+
export const filterSpec = ({
155+
dependencyGraph,
156+
excludedSchemas,
157+
includedSchemas,
158+
spec,
159+
}: {
160+
dependencyGraph: DependencyGraph;
161+
excludedSchemas: Set<string>;
162+
includedSchemas: Set<string>;
163+
spec: OpenApiV3_1_X;
164+
}): OpenApiV3_1_X => {
165+
// pass 1: collect schemas that satisfy the include/exclude filters
166+
const allIncludedSchemas = new Set<string>();
167+
const initialSchemas = includedSchemas.size
168+
? includedSchemas
169+
: new Set(dependencyGraph.schemas.keys());
170+
const stack = [...initialSchemas];
171+
while (stack.length) {
172+
const schemaName = stack.pop()!;
173+
174+
if (excludedSchemas.has(schemaName) || allIncludedSchemas.has(schemaName)) {
175+
continue;
176+
}
177+
178+
allIncludedSchemas.add(schemaName);
179+
180+
const dependencies = dependencyGraph.schemas.get(schemaName);
181+
182+
if (!dependencies) {
183+
continue;
184+
}
185+
186+
for (const dependency of dependencies) {
187+
if (
188+
!allIncludedSchemas.has(dependency) &&
189+
!excludedSchemas.has(dependency)
190+
) {
191+
stack.push(dependency);
192+
}
193+
}
194+
}
195+
196+
// pass 2: drop schemas that depend on already excluded schemas
197+
for (const schemaName of allIncludedSchemas) {
198+
const dependencies = dependencyGraph.schemas.get(schemaName);
199+
200+
if (!dependencies) {
201+
continue;
202+
}
203+
204+
for (const excludedSchema of excludedSchemas) {
205+
if (dependencies.has(excludedSchema)) {
206+
allIncludedSchemas.delete(schemaName);
207+
break;
208+
}
209+
}
210+
}
211+
212+
// pass 3: replace source schemas with filtered schemas
213+
if (spec.components) {
214+
if (spec.components.schemas) {
215+
const schemasFiltered: typeof spec.components.schemas = {};
216+
for (const schemaName of allIncludedSchemas) {
217+
const schemaSource = spec.components.schemas[schemaName];
218+
if (schemaSource) {
219+
schemasFiltered[schemaName] = schemaSource;
220+
}
221+
}
222+
spec.components.schemas = schemasFiltered;
223+
}
224+
}
225+
226+
return spec;
227+
};

packages/openapi-ts/src/openApi/3.1.x/parser/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,29 @@ import type {
1010
RequestBodyObject,
1111
SecuritySchemeObject,
1212
} from '../types/spec';
13+
import { createDependencyGraph, filterSpec } from './dependencyGraph';
1314
import { parseOperation } from './operation';
1415
import { parametersArrayToObject, parseParameter } from './parameter';
1516
import { parseRequestBody } from './requestBody';
1617
import { parseSchema } from './schema';
1718
import { parseServers } from './server';
19+
1820
export const parseV3_1_X = (context: IR.Context<OpenApiV3_1_X>) => {
21+
// TODO: construct includedSchemas from config
22+
const excludedSchemas = new Set<string>();
23+
const includedSchemas = new Set<string>();
24+
excludedSchemas.add('Bar');
25+
includedSchemas.add('Foo');
26+
27+
// TODO: skip filtering if no filters are defined
28+
const dependencyGraph = createDependencyGraph(context.spec);
29+
// console.log(dependencyGraph)
30+
context.spec = filterSpec({
31+
dependencyGraph,
32+
excludedSchemas,
33+
includedSchemas,
34+
spec: context.spec,
35+
});
1936
const state: State = {
2037
ids: new Map(),
2138
operationIds: new Map(),
@@ -25,13 +42,14 @@ export const parseV3_1_X = (context: IR.Context<OpenApiV3_1_X>) => {
2542
const excludeFilters = createFilters(context.config.input.exclude);
2643
const includeFilters = createFilters(context.config.input.include);
2744

45+
// TODO: remove shouldProcessRef
2846
const shouldProcessRef = ($ref: string, schema: Record<string, any>) =>
2947
canProcessRef({
3048
$ref,
3149
excludeFilters,
3250
includeFilters,
3351
schema,
34-
});
52+
}) || true;
3553

3654
// TODO: parser - handle more component types, old parser handles only parameters and schemas
3755
if (context.spec.components) {

0 commit comments

Comments
 (0)