| @@ -436,6 +436,34 @@ function containsUnknownTypeToken(segment: string): boolean { · 435 unmodified lines |
| 436 | return /\bunknown\b/.test(withoutQuotedLiterals); | 436 | return /\bunknown\b/.test(withoutQuotedLiterals); |
| 437 | } | 437 | } |
| 438 | | 438 | |
| | 439 | function isSchemaRefOnly(schema: Record<string, unknown>): boolean { |
| | 440 | const keys = Object.keys(schema); |
| | 441 | return keys.length > 0 |
| | 442 | && typeof schema.$ref === "string" |
| | 443 | && keys.every((key) => key === "$ref"); |
| | 444 | } |
| | 445 | |
| | 446 | function resolveExpectedInputPrimitiveFromGeneratedSchema( |
| | 447 | inputSchema: Record<string, unknown>, |
| | 448 | fieldName: string, |
| | 449 | componentSchemas: Record<string, unknown>, |
| | 450 | ): PrimitiveParamType | null { |
| | 451 | const rootProps = toRecord(inputSchema.properties); |
| | 452 | const containerKeys = ["path", "query", "headers", "cookie", "body"] as const; |
| | 453 | |
| | 454 | const direct = resolveSchemaPrimitiveType(rootProps[fieldName], componentSchemas); |
| | 455 | if (direct) return direct; |
| | 456 | |
| | 457 | for (const container of containerKeys) { |
| | 458 | const containerSchema = toRecord(rootProps[container]); |
| | 459 | const containerProps = toRecord(containerSchema.properties); |
| | 460 | const resolved = resolveSchemaPrimitiveType(containerProps[fieldName], componentSchemas); |
| | 461 | if (resolved) return resolved; |
| | 462 | } |
| | 463 | |
| | 464 | return null; |
| | 465 | } |
| | 466 | |
| 439 | const SPECS: SpecFixture[] = [ | 467 | const SPECS: SpecFixture[] = [ |
| 440 | { | 468 | { |
| 441 | name: "jira", | 469 | name: "jira", |
| @@ -696,6 +724,113 @@ describe("real-world OpenAPI specs", () => { · 254 unmodified lines |
| 696 | 300_000, | 724 | 300_000, |
| 697 | ); | 725 | ); |
| 698 | | 726 | |
| | 727 | test( |
| | 728 | "github: enterprise code security configurations resolves array item schema in includeDts=false mode", |
| | 729 | async () => { |
| | 730 | const githubUrl = |
| | 731 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; |
| | 732 | |
| | 733 | const prepared = await prepareOpenApiSpec(githubUrl, "github", { |
| | 734 | includeDts: false, |
| | 735 | profile: "full", |
| | 736 | }); |
| | 737 | const tools = buildOpenApiToolsFromPrepared( |
| | 738 | { |
| | 739 | type: "openapi", |
| | 740 | name: "github", |
| | 741 | spec: githubUrl, |
| | 742 | baseUrl: prepared.servers[0] || "https://api.github.com", |
| | 743 | }, |
| | 744 | prepared, |
| | 745 | ); |
| | 746 | |
| | 747 | const tool = tools.find((t) => t.path === "github.code_security.get_configurations_for_enterprise"); |
| | 748 | |
| | 749 | expect(tool).toBeDefined(); |
| | 750 | const outputSchema = toRecord(tool!.typing?.outputSchema); |
| | 751 | expect(outputSchema.type).toBe("array"); |
| | 752 | |
| | 753 | const outputItems = toRecord(outputSchema.items); |
| | 754 | expect(typeof outputItems.$ref).not.toBe("string"); |
| | 755 | expect(outputItems.type).toBe("object"); |
| | 756 | |
| | 757 | const outputProps = toRecord(outputItems.properties); |
| | 758 | expect(toRecord(outputProps.id).type).toBe("integer"); |
| | 759 | expect(toRecord(outputProps.name).type).toBe("string"); |
| | 760 | }, |
| | 761 | 300_000, |
| | 762 | ); |
| | 763 | |
| | 764 | test( |
| | 765 | "github + stripe: root generated array schemas include concrete item definitions", |
| | 766 | async () => { |
| | 767 | const fixtures = [ |
| | 768 | { |
| | 769 | name: "github", |
| | 770 | url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", |
| | 771 | fallbackBaseUrl: "https://api.github.com", |
| | 772 | }, |
| | 773 | { |
| | 774 | name: "stripe", |
| | 775 | url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", |
| | 776 | fallbackBaseUrl: "https://api.stripe.com", |
| | 777 | }, |
| | 778 | ] as const; |
| | 779 | |
| | 780 | for (const fixture of fixtures) { |
| | 781 | const prepared = await prepareOpenApiSpec(fixture.url, fixture.name, { |
| | 782 | includeDts: false, |
| | 783 | profile: "full", |
| | 784 | }); |
| | 785 | const tools = buildOpenApiToolsFromPrepared( |
| | 786 | { |
| | 787 | type: "openapi", |
| | 788 | name: fixture.name, |
| | 789 | spec: fixture.url, |
| | 790 | baseUrl: prepared.servers[0] || fixture.fallbackBaseUrl, |
| | 791 | }, |
| | 792 | prepared, |
| | 793 | ); |
| | 794 | |
| | 795 | const issues: string[] = []; |
| | 796 | |
| | 797 | const validateArrayItems = (schema: Record<string, unknown>, location: string) => { |
| | 798 | const items = toRecord(schema.items); |
| | 799 | const itemKeys = Object.keys(items); |
| | 800 | if (itemKeys.length === 0) { |
| | 801 | issues.push(`${location}.items has empty items schema`); |
| | 802 | return; |
| | 803 | } |
| | 804 | |
| | 805 | const isRefOnly = typeof items.$ref === "string" && itemKeys.every((key) => key === "$ref"); |
| | 806 | if (isRefOnly) { |
| | 807 | issues.push(`${location}.items is ref-only (${String(items.$ref)})`); |
| | 808 | } |
| | 809 | }; |
| | 810 | |
| | 811 | const visitTopLevelSchema = (schemaValue: unknown, location: string) => { |
| | 812 | const schema = toRecord(schemaValue); |
| | 813 | if (Object.keys(schema).length === 0) return; |
| | 814 | |
| | 815 | if (schema.type === "array") { |
| | 816 | validateArrayItems(schema, `${location}:schema`); |
| | 817 | } |
| | 818 | }; |
| | 819 | |
| | 820 | for (const tool of tools) { |
| | 821 | visitTopLevelSchema(tool.typing?.inputSchema, `${tool.path}:input`); |
| | 822 | visitTopLevelSchema(tool.typing?.outputSchema, `${tool.path}:output`); |
| | 823 | } |
| | 824 | |
| | 825 | expect( |
| | 826 | issues, |
| | 827 | `${fixture.name} has array item schema regressions:\n${issues.slice(0, 20).join("\n")}`, |
| | 828 | ).toHaveLength(0); |
| | 829 | } |
| | 830 | }, |
| | 831 | 300_000, |
| | 832 | ); |
| | 833 | |
| 699 | test( | 834 | test( |
| 700 | "stripe: extraction keeps concrete schemas for forwarding + verification report list outputs", | 835 | "stripe: extraction keeps concrete schemas for forwarding + verification report list outputs", |
| 701 | async () => { | 836 | async () => { |
| @@ -1039,4 +1174,109 @@ describe("real-world OpenAPI specs", () => { · 337 unmodified lines |
| 1039 | }, | 1174 | }, |
| 1040 | 1_800_000, | 1175 | 1_800_000, |
| 1041 | ); | 1176 | ); |
| | 1177 | |
| | 1178 | test( |
| | 1179 | "full profile schemas validate every github/stripe operation against parser-derived expectations", |
| | 1180 | async () => { |
| | 1181 | const fixtures = SPECS.filter((fixture) => fixture.name === "github" || fixture.name === "stripe"); |
| | 1182 | expect(fixtures.length).toBe(2); |
| | 1183 | |
| | 1184 | for (const fixture of fixtures) { |
| | 1185 | const parsed = await SwaggerParser.parse(fixture.url); |
| | 1186 | const parsedSpec = parsed as Record<string, unknown>; |
| | 1187 | const componentSchemas = toRecord(toRecord(parsedSpec.components).schemas); |
| | 1188 | const operationExpectations = collectOperationTypeExpectations(parsedSpec); |
| | 1189 | expect(operationExpectations.size).toBeGreaterThan(0); |
| | 1190 | |
| | 1191 | const prepared = await prepareOpenApiSpec(parsedSpec, fixture.name, { |
| | 1192 | includeDts: false, |
| | 1193 | profile: "full", |
| | 1194 | }); |
| | 1195 | |
| | 1196 | const tools = buildOpenApiToolsFromPrepared( |
| | 1197 | { |
| | 1198 | type: "openapi", |
| | 1199 | name: fixture.name, |
| | 1200 | spec: fixture.url, |
| | 1201 | baseUrl: prepared.servers[0] || `https://${fixture.name}.example.com`, |
| | 1202 | }, |
| | 1203 | prepared, |
| | 1204 | ); |
| | 1205 | |
| | 1206 | const toolsByOperationId = new Map<string, (typeof tools)[number]>(); |
| | 1207 | for (const tool of tools) { |
| | 1208 | const operationId = tool.typing?.typedRef?.kind === "openapi_operation" |
| | 1209 | ? tool.typing.typedRef.operationId |
| | 1210 | : ""; |
| | 1211 | if (!operationId || toolsByOperationId.has(operationId)) continue; |
| | 1212 | toolsByOperationId.set(operationId, tool); |
| | 1213 | } |
| | 1214 | |
| | 1215 | expect(toolsByOperationId.size).toBe(operationExpectations.size); |
| | 1216 | |
| | 1217 | const issues: string[] = []; |
| | 1218 | |
| | 1219 | for (const [operationId, expectations] of operationExpectations.entries()) { |
| | 1220 | const tool = toolsByOperationId.get(operationId); |
| | 1221 | if (!tool) { |
| | 1222 | issues.push(`missing tool for operationId ${operationId}`); |
| | 1223 | continue; |
| | 1224 | } |
| | 1225 | |
| | 1226 | const inputSchema = toRecord(tool.typing?.inputSchema); |
| | 1227 | const outputSchema = toRecord(tool.typing?.outputSchema); |
| | 1228 | |
| | 1229 | if (expectations.expectsInput && Object.keys(inputSchema).length === 0) { |
| | 1230 | issues.push(`${tool.path}: expected input schema but got empty`); |
| | 1231 | } |
| | 1232 | |
| | 1233 | if (expectations.expectsKnownOutput && Object.keys(outputSchema).length === 0) { |
| | 1234 | issues.push(`${tool.path}: expected output schema but got empty`); |
| | 1235 | } |
| | 1236 | |
| | 1237 | if (expectations.expectsVoidOutput && Object.keys(outputSchema).length > 0) { |
| | 1238 | issues.push(`${tool.path}: expected void output but schema was present`); |
| | 1239 | } |
| | 1240 | |
| | 1241 | if (outputSchema.type === "array") { |
| | 1242 | const items = toRecord(outputSchema.items); |
| | 1243 | if (Object.keys(items).length === 0) { |
| | 1244 | issues.push(`${tool.path}: output array has empty items schema`); |
| | 1245 | } else if (isSchemaRefOnly(items)) { |
| | 1246 | issues.push(`${tool.path}: output array items are ref-only (${String(items.$ref)})`); |
| | 1247 | } |
| | 1248 | } |
| | 1249 | |
| | 1250 | for (const expected of expectations.expectedInputFields) { |
| | 1251 | const resolved = resolveExpectedInputPrimitiveFromGeneratedSchema(inputSchema, expected.name, componentSchemas); |
| | 1252 | if (!resolved) { |
| | 1253 | issues.push(`${tool.path}: missing input field ${expected.name}`); |
| | 1254 | continue; |
| | 1255 | } |
| | 1256 | if (resolved !== expected.type) { |
| | 1257 | issues.push(`${tool.path}: input field ${expected.name} expected ${expected.type}, got ${resolved}`); |
| | 1258 | } |
| | 1259 | } |
| | 1260 | |
| | 1261 | const outputProperties = toRecord(outputSchema.properties); |
| | 1262 | for (const expected of expectations.expectedOutputFields) { |
| | 1263 | const resolved = resolveSchemaPrimitiveType(outputProperties[expected.name], componentSchemas); |
| | 1264 | if (!resolved) { |
| | 1265 | issues.push(`${tool.path}: missing output field ${expected.name}`); |
| | 1266 | continue; |
| | 1267 | } |
| | 1268 | if (resolved !== expected.type) { |
| | 1269 | issues.push(`${tool.path}: output field ${expected.name} expected ${expected.type}, got ${resolved}`); |
| | 1270 | } |
| | 1271 | } |
| | 1272 | } |
| | 1273 | |
| | 1274 | expect( |
| | 1275 | issues, |
| | 1276 | `${fixture.name} full-profile schema regressions:\n${issues.slice(0, 30).join("\n")}`, |
| | 1277 | ).toHaveLength(0); |
| | 1278 | } |
| | 1279 | }, |
| | 1280 | 1_800_000, |
| | 1281 | ); |
| 1042 | }); | 1282 | }); |