Unified · Split
| @@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines | |||
| 54 | const createHttpCatalog = (input: { | 54 | const createHttpCatalog = (input: { |
| 55 | collideIds?: boolean; | 55 | collideIds?: boolean; |
| 56 | method?: string; | 56 | method?: string; |
| 57 | noContentSuccess?: boolean; | ||
| 57 | } = {}): CatalogV1 => { | 58 | } = {}): CatalogV1 => { |
| 58 | const catalog = createEmptyCatalogV1(); | 59 | const catalog = createEmptyCatalogV1(); |
| 59 | 60 | ||
| @@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines | |||
| 327 | id: responseId, | 328 | id: responseId, |
| 328 | kind: "response", | 329 | kind: "response", |
| 329 | docs: { | 330 | docs: { |
| 330 | summary: "Updated event", | 331 | summary: input.noContentSuccess ? "No content" : "Updated event", |
| 331 | }, | 332 | }, |
| 332 | contents: [ | 333 | ...(input.noContentSuccess |
| 333 | { | 334 | ? {} |
| 334 | mediaType: "application/json", | 335 | : { |
| 335 | shapeId: resultShapeId, | 336 | contents: [ |
| 336 | }, | 337 | { |
| 337 | ], | 338 | mediaType: "application/json", |
| 339 | shapeId: resultShapeId, | ||
| 340 | }, | ||
| 341 | ], | ||
| 342 | }), | ||
| 338 | synthetic: false, | 343 | synthetic: false, |
| 339 | provenance: baseProvenance("#/paths/~1events/responses/200"), | 344 | provenance: baseProvenance( |
| 345 | input.noContentSuccess ? "#/paths/~1events/responses/204" : "#/paths/~1events/responses/200", | ||
| 346 | ), | ||
| 340 | }); | 347 | }); |
| 341 | 348 | ||
| 342 | put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, { | 349 | put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, { |
| @@ -345,7 +352,7 @@ const createHttpCatalog = (input: { · 2 unmodified lines | |||
| 345 | { | 352 | { |
| 346 | match: { | 353 | match: { |
| 347 | kind: "exact", | 354 | kind: "exact", |
| 348 | status: 200, | 355 | status: input.noContentSuccess ? 204 : 200, |
| 349 | }, | 356 | }, |
| 350 | responseId, | 357 | responseId, |
| 351 | traits: ["success"], | 358 | traits: ["success"], |
| @@ -805,6 +812,27 @@ describe("IR catalog", () => { · 453 unmodified lines | |||
| 805 | expect(searchDoc.tags).toEqual(["calendar", "events"]); | 812 | expect(searchDoc.tags).toEqual(["calendar", "events"]); |
| 806 | }); | 813 | }); |
| 807 | 814 | ||
| 815 | it("projects no-content HTTP successes as null data", () => { | ||
| 816 | const projected = projectCatalogForAgentSdk({ | ||
| 817 | catalog: createHttpCatalog({ noContentSuccess: true }), | ||
| 818 | }); | ||
| 819 | |||
| 820 | const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")]; | ||
| 821 | const resultShape = descriptor.resultShapeId | ||
| 822 | ? projected.catalog.symbols[descriptor.resultShapeId] | ||
| 823 | : undefined; | ||
| 824 | if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | ||
| 825 | throw new Error("Expected projected HTTP result envelope"); | ||
| 826 | } | ||
| 827 | |||
| 828 | const dataShape = projected.catalog.symbols[resultShape.node.fields.data.shapeId]; | ||
| 829 | if (dataShape?.kind !== "shape" || dataShape.node.type !== "const") { | ||
| 830 | throw new Error("Expected no-content success data to project as null"); | ||
| 831 | } | ||
| 832 | |||
| 833 | expect(dataShape.node.value).toBeNull(); | ||
| 834 | }); | ||
| 835 | |||
| 808 | it("groups colliding HTTP parameter names into path/query containers", () => { | 836 | it("groups colliding HTTP parameter names into path/query containers", () => { |
| 809 | const projected = projectCatalogForAgentSdk({ | 837 | const projected = projectCatalogForAgentSdk({ |
| 810 | catalog: createHttpCatalog({ collideIds: true }), | 838 | catalog: createHttpCatalog({ collideIds: true }), |
| @@ -538,15 +538,30 @@ const projectResultShapeFromResponses = ( · 537 unmodified lines | |||
| 538 | catalog: CatalogV1, | 538 | catalog: CatalogV1, |
| 539 | capability: Capability, | 539 | capability: Capability, |
| 540 | responseSet: ResponseSet, | 540 | responseSet: ResponseSet, |
| 541 | ): ShapeSymbolId | undefined => | 541 | ): ShapeSymbolId | undefined => { |
| 542 | contentShapeIdFromResponseEntries( | 542 | const successEntries = responseVariantEntries(catalog, responseSet) |
| 543 | .filter(({ variant }) => isSuccessStatusMatch(variant.match)); | ||
| 544 | const contentShapeId = contentShapeIdFromResponseEntries( | ||
| 543 | catalog, | 545 | catalog, |
| 544 | capability, | 546 | capability, |
| 545 | `responseSet:${responseSet.id}`, | 547 | `responseSet:${responseSet.id}`, |
| 546 | `${capability.surface.title ?? capability.id} result`, | 548 | `${capability.surface.title ?? capability.id} result`, |
| 547 | responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)), | 549 | successEntries, |
| 548 | ); | 550 | ); |
| 549 | 551 | ||
| 552 | if (contentShapeId) { | ||
| 553 | return contentShapeId; | ||
| 554 | } | ||
| 555 | |||
| 556 | return successEntries.length > 0 | ||
| 557 | ? constNullShape( | ||
| 558 | catalog, | ||
| 559 | capability, | ||
| 560 | `responseSet:${responseSet.id}:success:null`, | ||
| 561 | ) | ||
| 562 | : undefined; | ||
| 563 | }; | ||
| 564 | |||
| 550 | const projectErrorShapeFromResponses = ( | 565 | const projectErrorShapeFromResponses = ( |
| 551 | catalog: CatalogV1, | 566 | catalog: CatalogV1, |
| 552 | capability: Capability, | 567 | capability: Capability, |
| @@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines | |||
| 622 | baseShapeId: ShapeSymbolId; | 637 | baseShapeId: ShapeSymbolId; |
| 623 | }, | 638 | }, |
| 624 | ): ShapeSymbolId => { | 639 | ): ShapeSymbolId => { |
| 640 | const baseShape = catalog.symbols[input.baseShapeId]; | ||
| 641 | if ( | ||
| 642 | baseShape?.kind === "shape" | ||
| 643 | && baseShape.node.type === "const" | ||
| 644 | && baseShape.node.value === null | ||
| 645 | ) { | ||
| 646 | return input.baseShapeId; | ||
| 647 | } | ||
| 648 | |||
| 625 | const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); | 649 | const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); |
| 626 | if (input.baseShapeId === nullShapeId) { | 650 | if (input.baseShapeId === nullShapeId) { |
| 627 | return nullShapeId; | 651 | return nullShapeId; |
| @@ -48,14 +48,65 @@ const vercelFixturePath = fileURLToPath( · 47 unmodified lines | |||
| 48 | new URL("../fixtures/vercel-openapi.json", import.meta.url), | 48 | new URL("../fixtures/vercel-openapi.json", import.meta.url), |
| 49 | ); | 49 | ); |
| 50 | 50 | ||
| 51 | const makeSource = (): Source => ({ | 51 | const noContentSuccessOpenApiFixture = JSON.stringify({ |
| 52 | id: "source_vercel_api", | 52 | openapi: "3.1.0", |
| 53 | info: { | ||
| 54 | title: "GitHub REST API", | ||
| 55 | version: "1.0.0", | ||
| 56 | }, | ||
| 57 | servers: [ | ||
| 58 | { | ||
| 59 | url: "https://api.github.com", | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | paths: { | ||
| 63 | "/user/starred/{owner}/{repo}": { | ||
| 64 | get: { | ||
| 65 | operationId: "checkRepoIsStarredByAuthenticatedUser", | ||
| 66 | summary: "Check if a repository is starred by the authenticated user", | ||
| 67 | parameters: [ | ||
| 68 | { | ||
| 69 | name: "owner", | ||
| 70 | in: "path", | ||
| 71 | required: true, | ||
| 72 | schema: { | ||
| 73 | type: "string", | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | name: "repo", | ||
| 78 | in: "path", | ||
| 79 | required: true, | ||
| 80 | schema: { | ||
| 81 | type: "string", | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | ], | ||
| 85 | responses: { | ||
| 86 | "204": { | ||
| 87 | description: "Response if this repository is starred by you", | ||
| 88 | }, | ||
| 89 | "404": { | ||
| 90 | description: "Not Found if this repository is not starred by you", | ||
| 91 | }, | ||
| 92 | }, | ||
| 93 | }, | ||
| 94 | }, | ||
| 95 | }, | ||
| 96 | }); | ||
| 97 | |||
| 98 | const makeSource = (input: { | ||
| 99 | id?: string; | ||
| 100 | name?: string; | ||
| 101 | namespace?: string; | ||
| 102 | } = {}): Source => ({ | ||
| 103 | id: input.id ?? "source_vercel_api", | ||
| 53 | scopeId: "scope_test", | 104 | scopeId: "scope_test", |
| 54 | name: "Vercel API", | 105 | name: input.name ?? "Vercel API", |
| 55 | kind: "openapi", | 106 | kind: "openapi", |
| 56 | status: "connected", | 107 | status: "connected", |
| 57 | enabled: true, | 108 | enabled: true, |
| 58 | namespace: "vercel-api", | 109 | namespace: input.namespace ?? "vercel-api", |
| 59 | createdAt: 0, | 110 | createdAt: 0, |
| 60 | updatedAt: 0, | 111 | updatedAt: 0, |
| 61 | }); | 112 | }); |
| @@ -167,4 +218,76 @@ describe("openapi type contracts", () => { · 105 unmodified lines | |||
| 167 | expect(declaration).not.toContain("email: unknown;"); | 218 | expect(declaration).not.toContain("email: unknown;"); |
| 168 | expect(declaration).not.toContain("country: unknown;"); | 219 | expect(declaration).not.toContain("country: unknown;"); |
| 169 | }).pipe(Effect.provide(NodeFileSystem.layer))); | 220 | }).pipe(Effect.provide(NodeFileSystem.layer))); |
| 221 | |||
| 222 | it.effect("renders null success data for bodyless OpenAPI responses", () => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const source = makeSource({ | ||
| 225 | id: "source_github_v3_rest_api", | ||
| 226 | name: "GitHub REST API", | ||
| 227 | namespace: "github-v3-rest-api", | ||
| 228 | }); | ||
| 229 | const manifest = yield* extractOpenApiManifest( | ||
| 230 | source.name, | ||
| 231 | noContentSuccessOpenApiFixture, | ||
| 232 | ); | ||
| 233 | const operations = compileOpenApiToolDefinitions(manifest).map( | ||
| 234 | openApiCatalogOperationFromDefinition, | ||
| 235 | ); | ||
| 236 | const fragment = createOpenApiCatalogFragment({ | ||
| 237 | source, | ||
| 238 | documents: [ | ||
| 239 | { | ||
| 240 | documentKind: "openapi", | ||
| 241 | documentKey: "https://example.com/github-rest-openapi.json", | ||
| 242 | fetchedAt: 0, | ||
| 243 | contentText: noContentSuccessOpenApiFixture, | ||
| 244 | }, | ||
| 245 | ], | ||
| 246 | operations, | ||
| 247 | }); | ||
| 248 | const snapshot = snapshotFromSourceCatalogSyncResult( | ||
| 249 | createSourceCatalogSyncResult({ | ||
| 250 | fragment, | ||
| 251 | importMetadata: createCatalogImportMetadata({ | ||
| 252 | source, | ||
| 253 | pluginKey: "openapi", | ||
| 254 | }), | ||
| 255 | sourceHash: manifest.sourceHash, | ||
| 256 | }), | ||
| 257 | ); | ||
| 258 | const projected = projectCatalogForAgentSdk({ | ||
| 259 | catalog: snapshot.catalog, | ||
| 260 | }); | ||
| 261 | const revision = makeRevision(); | ||
| 262 | const loadedCatalog: LoadedSourceCatalog = { | ||
| 263 | source, | ||
| 264 | sourceRecord: makeSourceRecord(source, revision), | ||
| 265 | revision, | ||
| 266 | snapshot, | ||
| 267 | catalog: snapshot.catalog, | ||
| 268 | projected, | ||
| 269 | typeProjector: createCatalogTypeProjector({ | ||
| 270 | catalog: projected.catalog, | ||
| 271 | roots: projectedCatalogTypeRoots(projected), | ||
| 272 | }), | ||
| 273 | importMetadata: snapshot.import, | ||
| 274 | }; | ||
| 275 | |||
| 276 | const tools = yield* expandCatalogTools({ | ||
| 277 | catalogs: [loadedCatalog], | ||
| 278 | includeSchemas: true, | ||
| 279 | includeTypePreviews: true, | ||
| 280 | }); | ||
| 281 | const tool = tools.find((candidate) => | ||
| 282 | candidate.path.endsWith(".checkRepoIsStarredByAuthenticatedUser") | ||
| 283 | ); | ||
| 284 | |||
| 285 | expect(tool).toBeDefined(); | ||
| 286 | |||
| 287 | const contract = yield* buildLoadedSourceCatalogToolContract(tool!); | ||
| 288 | |||
| 289 | expect(contract.output.typeDeclaration).toContain("data: null;"); | ||
| 290 | expect(contract.output.typeDeclaration).not.toContain("data: unknown"); | ||
| 291 | expect(contract.output.typePreview).toContain("data: null"); | ||
| 292 | }).pipe(Effect.provide(NodeFileSystem.layer))); | ||
| 170 | }); | 293 | }); |