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
54const createHttpCatalog = (input: {54const 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 }),
packages/kernel/ir/src/catalog.ts+27−3
@@ -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
550const projectErrorShapeFromResponses = (565const 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;
packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts+127−4
@@ -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
51const makeSource = (): Source => ({51const 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
98const 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});