History

Treat bodyless OpenAPI success as null data

Rhys Sullivan committed 7 months ago

Commit 68c248c1 parent ecfde8f3 files · +191 −16Browse files

Unified · Split

3 files
  1. packages/kernel/ir/src/catalog.test.ts +37 −9
  2. packages/kernel/ir/src/catalog.ts +27 −3
  3. packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts +127 −4
packages/kernel/ir/src/catalog.test.ts+37−9
@@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines
5454 const createHttpCatalog = (input: {
5555 collideIds?: boolean;
5656 method?: string;
57+ noContentSuccess?: boolean;
5758 } = {}): CatalogV1 => {
5859 const catalog = createEmptyCatalogV1();
5960
@@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines
327328 id: responseId,
328329 kind: "response",
329330 docs: {
330− summary: "Updated event",
331+ summary: input.noContentSuccess ? "No content" : "Updated event",
331332 },
332− contents: [
333− {
334− mediaType: "application/json",
335− shapeId: resultShapeId,
336− },
337− ],
333+ ...(input.noContentSuccess
334+ ? {}
335+ : {
336+ contents: [
337+ {
338+ mediaType: "application/json",
339+ shapeId: resultShapeId,
340+ },
341+ ],
342+ }),
338343 synthetic: false,
339− provenance: baseProvenance("#/paths/~1events/responses/200"),
344+ provenance: baseProvenance(
345+ input.noContentSuccess ? "#/paths/~1events/responses/204" : "#/paths/~1events/responses/200",
346+ ),
340347 });
341348
342349 put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, {
@@ -345,7 +352,7 @@ const createHttpCatalog = (input: { · 2 unmodified lines
345352 {
346353 match: {
347354 kind: "exact",
348− status: 200,
355+ status: input.noContentSuccess ? 204 : 200,
349356 },
350357 responseId,
351358 traits: ["success"],
@@ -805,6 +812,27 @@ describe("IR catalog", () => { · 453 unmodified lines
805812 expect(searchDoc.tags).toEqual(["calendar", "events"]);
806813 });
807814
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+
808836 it("groups colliding HTTP parameter names into path/query containers", () => {
809837 const projected = projectCatalogForAgentSdk({
810838 catalog: createHttpCatalog({ collideIds: true }),
packages/kernel/ir/src/catalog.ts+27−3
@@ -538,15 +538,30 @@ const projectResultShapeFromResponses = ( · 537 unmodified lines
538538 catalog: CatalogV1,
539539 capability: Capability,
540540 responseSet: ResponseSet,
541−): ShapeSymbolId | undefined =>
542− contentShapeIdFromResponseEntries(
541+): ShapeSymbolId | undefined => {
542+ const successEntries = responseVariantEntries(catalog, responseSet)
543+ .filter(({ variant }) => isSuccessStatusMatch(variant.match));
544+ const contentShapeId = contentShapeIdFromResponseEntries(
543545 catalog,
544546 capability,
545547 `responseSet:${responseSet.id}`,
546548 `${capability.surface.title ?? capability.id} result`,
547− responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)),
549+ successEntries,
548550 );
549551
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+
550565 const projectErrorShapeFromResponses = (
551566 catalog: CatalogV1,
552567 capability: Capability,
@@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines
622637 baseShapeId: ShapeSymbolId;
623638 },
624639 ): 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+
625649 const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`);
626650 if (input.baseShapeId === nullShapeId) {
627651 return nullShapeId;
packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts+127−4
@@ -48,14 +48,65 @@ const vercelFixturePath = fileURLToPath( · 47 unmodified lines
4848 new URL("../fixtures/vercel-openapi.json", import.meta.url),
4949 );
5050
51−const makeSource = (): Source => ({
52− id: "source_vercel_api",
51+const noContentSuccessOpenApiFixture = JSON.stringify({
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",
53104 scopeId: "scope_test",
54− name: "Vercel API",
105+ name: input.name ?? "Vercel API",
55106 kind: "openapi",
56107 status: "connected",
57108 enabled: true,
58− namespace: "vercel-api",
109+ namespace: input.namespace ?? "vercel-api",
59110 createdAt: 0,
60111 updatedAt: 0,
61112 });
@@ -167,4 +218,76 @@ describe("openapi type contracts", () => { · 105 unmodified lines
167218 expect(declaration).not.toContain("email: unknown;");
168219 expect(declaration).not.toContain("country: unknown;");
169220 }).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)));
170293 });