Showing the changes to packages/codemode-openapi/src (2 of 77 files). Show all
Unified · Split
| @@ -30,6 +30,7 @@ type TestServer = { · 29 unmodified lines | |||
| 30 | path: string; | 30 | path: string; |
| 31 | query: string; | 31 | query: string; |
| 32 | body: string; | 32 | body: string; |
| 33 | headers: Record<string, string>; | ||
| 33 | }>; | 34 | }>; |
| 34 | close: () => Promise<void>; | 35 | close: () => Promise<void>; |
| 35 | }; | 36 | }; |
| @@ -53,6 +54,7 @@ const makeEffectServerHandler = ( · 17 unmodified lines | |||
| 53 | path: `/repos/${path.owner}/${path.repo}`, | 54 | path: `/repos/${path.owner}/${path.repo}`, |
| 54 | query: include ? `?include=${encodeURIComponent(include)}` : "", | 55 | query: include ? `?include=${encodeURIComponent(include)}` : "", |
| 55 | body: "", | 56 | body: "", |
| 57 | headers: {}, | ||
| 56 | }); | 58 | }); |
| 57 | 59 | ||
| 58 | return { | 60 | return { |
| @@ -69,6 +71,7 @@ const makeEffectServerHandler = ( · 10 unmodified lines | |||
| 69 | path: `/repos/${path.owner}/${path.repo}/issues`, | 71 | path: `/repos/${path.owner}/${path.repo}/issues`, |
| 70 | query: "", | 72 | query: "", |
| 71 | body: bodyText, | 73 | body: bodyText, |
| 74 | headers: {}, | ||
| 72 | }); | 75 | }); |
| 73 | 76 | ||
| 74 | return { | 77 | return { |
| @@ -207,11 +210,21 @@ const makeTestServer = Effect.acquireRelease( · 132 unmodified lines | |||
| 207 | catch: (error: unknown) => | 210 | catch: (error: unknown) => |
| 208 | error instanceof Error ? error : new Error(String(error)), | 211 | error instanceof Error ? error : new Error(String(error)), |
| 209 | }).pipe(Effect.orDie), | 212 | }).pipe(Effect.orDie), |
| 210 | ); | 213 | ); |
| 211 | 214 | ||
| 212 | const ownerParam = HttpApiSchema.param("owner", Schema.String); | 215 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 213 | const repoParam = HttpApiSchema.param("repo", Schema.String); | 216 | const repoParam = HttpApiSchema.param("repo", Schema.String); |
| 214 | 217 | ||
| 218 | const requestHeadersFromNode = ( | ||
| 219 | request: IncomingMessage, | ||
| 220 | ): Record<string, string> => | ||
| 221 | Object.fromEntries( | ||
| 222 | Object.entries(request.headers).flatMap(([key, value]) => | ||
| 223 | value === undefined | ||
| 224 | ? [] | ||
| 225 | : [[key, Array.isArray(value) ? value.join(",") : value]]), | ||
| 226 | ); | ||
| 227 | |||
| 215 | class GeneratedReposApi extends HttpApiGroup.make("repos") | 228 | class GeneratedReposApi extends HttpApiGroup.make("repos") |
| 216 | .add( | 229 | .add( |
| 217 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` | 230 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` |
| @@ -475,6 +488,117 @@ describe("openapi-tools", () => { · 257 unmodified lines | |||
| 475 | }), | 488 | }), |
| 476 | ); | 489 | ); |
| 477 | 490 | ||
| 491 | it.scoped("applies credential placements for query params, cookies, and body values", () => | ||
| 492 | Effect.gen(function* () { | ||
| 493 | const requests: TestServer["requests"] = []; | ||
| 494 | const server = yield* Effect.acquireRelease( | ||
| 495 | Effect.promise<TestServer>( | ||
| 496 | () => | ||
| 497 | new Promise<TestServer>((resolve, reject) => { | ||
| 498 | const httpServer = createServer((request, response) => { | ||
| 499 | const url = new URL( | ||
| 500 | request.url ?? "/", | ||
| 501 | `http://${request.headers.host ?? "127.0.0.1"}`, | ||
| 502 | ); | ||
| 503 | let body = ""; | ||
| 504 | |||
| 505 | request.setEncoding("utf8"); | ||
| 506 | request.on("data", (chunk) => { | ||
| 507 | body += chunk; | ||
| 508 | }); | ||
| 509 | request.on("end", () => { | ||
| 510 | requests.push({ | ||
| 511 | method: request.method ?? "GET", | ||
| 512 | path: url.pathname, | ||
| 513 | query: url.search, | ||
| 514 | body, | ||
| 515 | headers: requestHeadersFromNode(request), | ||
| 516 | }); | ||
| 517 | response.statusCode = 200; | ||
| 518 | response.setHeader("content-type", "application/json"); | ||
| 519 | response.end(JSON.stringify({ ok: true })); | ||
| 520 | }); | ||
| 521 | }); | ||
| 522 | |||
| 523 | httpServer.once("error", reject); | ||
| 524 | httpServer.listen(0, "127.0.0.1", () => { | ||
| 525 | const address = httpServer.address(); | ||
| 526 | if (!address || typeof address === "string") { | ||
| 527 | reject(new Error("failed to resolve test server address")); | ||
| 528 | return; | ||
| 529 | } | ||
| 530 | |||
| 531 | resolve({ | ||
| 532 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 533 | requests, | ||
| 534 | close: async () => { | ||
| 535 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 536 | httpServer.close((error: Error | undefined) => { | ||
| 537 | if (error) { | ||
| 538 | closeReject(error); | ||
| 539 | return; | ||
| 540 | } | ||
| 541 | closeResolve(); | ||
| 542 | }); | ||
| 543 | }); | ||
| 544 | }, | ||
| 545 | }); | ||
| 546 | }); | ||
| 547 | }), | ||
| 548 | ), | ||
| 549 | (resource) => | ||
| 550 | Effect.tryPromise({ | ||
| 551 | try: () => resource.close(), | ||
| 552 | catch: (error: unknown) => | ||
| 553 | error instanceof Error ? error : new Error(String(error)), | ||
| 554 | }).pipe(Effect.orDie), | ||
| 555 | ); | ||
| 556 | const extracted = yield* createOpenApiToolsFromSpec({ | ||
| 557 | sourceName: "demo", | ||
| 558 | openApiSpec: generatedOpenApiSpec, | ||
| 559 | baseUrl: server.baseUrl, | ||
| 560 | namespace: "source.demo", | ||
| 561 | credentialPlacements: { | ||
| 562 | headers: { | ||
| 563 | authorization: "Bearer top-secret", | ||
| 564 | }, | ||
| 565 | queryParams: { | ||
| 566 | api_key: "secret-key", | ||
| 567 | }, | ||
| 568 | cookies: { | ||
| 569 | session: "abc123", | ||
| 570 | }, | ||
| 571 | bodyValues: { | ||
| 572 | "auth.token": "body-secret", | ||
| 573 | }, | ||
| 574 | }, | ||
| 575 | }); | ||
| 576 | |||
| 577 | const getRepo = resolveToolExecutor(extracted.tools, "source.demo.repos.getRepo"); | ||
| 578 | const createIssue = resolveToolExecutor(extracted.tools, "source.demo.repos.createIssue"); | ||
| 579 | |||
| 580 | yield* Effect.promise(() => getRepo({ owner: "octocat", repo: "hello-world" })); | ||
| 581 | yield* Effect.promise(() => | ||
| 582 | createIssue({ | ||
| 583 | owner: "octocat", | ||
| 584 | repo: "hello-world", | ||
| 585 | body: { title: "Bug report" }, | ||
| 586 | }), | ||
| 587 | ); | ||
| 588 | |||
| 589 | expect(server.requests).toHaveLength(2); | ||
| 590 | expect(server.requests[0]?.query).toContain("api_key=secret-key"); | ||
| 591 | expect(server.requests[0]?.headers.authorization).toBe("Bearer top-secret"); | ||
| 592 | expect(server.requests[0]?.headers.cookie).toContain("session=abc123"); | ||
| 593 | expect(JSON.parse(server.requests[1]?.body ?? "{}")).toEqual({ | ||
| 594 | title: "Bug report", | ||
| 595 | auth: { | ||
| 596 | token: "body-secret", | ||
| 597 | }, | ||
| 598 | }); | ||
| 599 | }), | ||
| 600 | ); | ||
| 601 | |||
| 478 | it.scoped("falls back to path-template args when extracted path parameters are missing", () => | 602 | it.scoped("falls back to path-template args when extracted path parameters are missing", () => |
| 479 | Effect.gen(function* () { | 603 | Effect.gen(function* () { |
| 480 | const server = yield* makeTestServer; | 604 | const server = yield* makeTestServer; |
| @@ -8,8 +8,12 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | |||
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | applyCookiePlacementsToHeaders, | ||
| 12 | applyHttpQueryPlacementsToUrl, | ||
| 13 | applyJsonBodyPlacements, | ||
| 11 | standardSchemaFromJsonSchema, | 14 | standardSchemaFromJsonSchema, |
| 12 | toTool, | 15 | toTool, |
| 16 | type HttpRequestPlacements, | ||
| 13 | type ToolMap, | 17 | type ToolMap, |
| 14 | unknownInputSchema, | 18 | unknownInputSchema, |
| 15 | } from "@executor/codemode-core"; | 19 | } from "@executor/codemode-core"; |
| @@ -288,6 +292,7 @@ export type CreateOpenApiToolsFromManifestInput = { · 272 unmodified lines | |||
| 288 | sourceKey?: string; | 292 | sourceKey?: string; |
| 289 | defaultHeaders?: Readonly<Record<string, string>>; | 293 | defaultHeaders?: Readonly<Record<string, string>>; |
| 290 | credentialHeaders?: Readonly<Record<string, string>>; | 294 | credentialHeaders?: Readonly<Record<string, string>>; |
| 295 | credentialPlacements?: HttpRequestPlacements; | ||
| 291 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 296 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 292 | }; | 297 | }; |
| 293 | 298 | ||
| @@ -317,7 +322,7 @@ const buildFetchRequest = (input: { · 23 unmodified lines | |||
| 317 | args: OpenApiToolArgs; | 322 | args: OpenApiToolArgs; |
| 318 | baseUrl: string; | 323 | baseUrl: string; |
| 319 | defaultHeaders: Readonly<Record<string, string>>; | 324 | defaultHeaders: Readonly<Record<string, string>>; |
| 320 | credentialHeaders: Readonly<Record<string, string>>; | 325 | credentialPlacements: HttpRequestPlacements; |
| 321 | }): { | 326 | }): { |
| 322 | url: URL; | 327 | url: URL; |
| 323 | method: string; | 328 | method: string; |
| @@ -369,9 +374,11 @@ const buildFetchRequest = (input: { · 45 unmodified lines | |||
| 369 | } | 374 | } |
| 370 | 375 | ||
| 371 | let body: string | undefined; | 376 | let body: string | undefined; |
| 377 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; | ||
| 378 | const hasCredentialBodyValues = Object.keys(bodyValues).length > 0; | ||
| 372 | 379 | ||
| 373 | if (input.payload.requestBody !== null) { | 380 | if (input.payload.requestBody !== null) { |
| 374 | if (!hasRequestBody(input.args)) { | 381 | if (!hasRequestBody(input.args) && !hasCredentialBodyValues) { |
| 375 | if (input.payload.requestBody.required) { | 382 | if (input.payload.requestBody.required) { |
| 376 | throw new OpenApiToolInvocationError({ | 383 | throw new OpenApiToolInvocationError({ |
| 377 | operation: "validate_args", | 384 | operation: "validate_args", |
| @@ -380,7 +387,13 @@ const buildFetchRequest = (input: { · 2 unmodified lines | |||
| 380 | }); | 387 | }); |
| 381 | } | 388 | } |
| 382 | } else { | 389 | } else { |
| 383 | body = JSON.stringify(input.args.body); | 390 | body = JSON.stringify( |
| 391 | applyJsonBodyPlacements({ | ||
| 392 | body: hasRequestBody(input.args) ? input.args.body : {}, | ||
| 393 | bodyValues, | ||
| 394 | label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`, | ||
| 395 | }), | ||
| 396 | ); | ||
| 384 | 397 | ||
| 385 | const preferredContentType = input.payload.requestBody.contentTypes[0]; | 398 | const preferredContentType = input.payload.requestBody.contentTypes[0]; |
| 386 | if (preferredContentType) { | 399 | if (preferredContentType) { |
| @@ -391,14 +404,23 @@ const buildFetchRequest = (input: { · 4 unmodified lines | |||
| 391 | } | 404 | } |
| 392 | } | 405 | } |
| 393 | 406 | ||
| 394 | for (const [key, value] of Object.entries(input.credentialHeaders)) { | 407 | const urlWithAuth = applyHttpQueryPlacementsToUrl({ |
| 395 | headers[key] = value; | 408 | url, |
| 409 | queryParams: input.credentialPlacements.queryParams, | ||
| 410 | }); | ||
| 411 | const headersWithAuthCookies = applyCookiePlacementsToHeaders({ | ||
| 412 | headers, | ||
| 413 | cookies: input.credentialPlacements.cookies, | ||
| 414 | }); | ||
| 415 | |||
| 416 | for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) { | ||
| 417 | headersWithAuthCookies[key] = value; | ||
| 396 | } | 418 | } |
| 397 | 419 | ||
| 398 | return { | 420 | return { |
| 399 | url, | 421 | url: urlWithAuth, |
| 400 | method: input.payload.method.toUpperCase(), | 422 | method: input.payload.method.toUpperCase(), |
| 401 | headers, | 423 | headers: headersWithAuthCookies, |
| 402 | body, | 424 | body, |
| 403 | }; | 425 | }; |
| 404 | }; | 426 | }; |
| @@ -413,15 +435,32 @@ export type CreateOpenApiToolFromDefinitionInput = { · 8 unmodified lines | |||
| 413 | baseUrl: string; | 435 | baseUrl: string; |
| 414 | defaultHeaders?: Readonly<Record<string, string>>; | 436 | defaultHeaders?: Readonly<Record<string, string>>; |
| 415 | credentialHeaders?: Readonly<Record<string, string>>; | 437 | credentialHeaders?: Readonly<Record<string, string>>; |
| 438 | credentialPlacements?: HttpRequestPlacements; | ||
| 416 | refHintTable?: Readonly<OpenApiRefHintTable>; | 439 | refHintTable?: Readonly<OpenApiRefHintTable>; |
| 417 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 440 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 418 | }; | 441 | }; |
| 419 | 442 | ||
| 443 | const normalizeCredentialPlacements = (input: { | ||
| 444 | credentialHeaders?: Readonly<Record<string, string>>; | ||
| 445 | credentialPlacements?: HttpRequestPlacements; | ||
| 446 | }): HttpRequestPlacements => ({ | ||
| 447 | headers: { | ||
| 448 | ...(input.credentialHeaders ?? {}), | ||
| 449 | ...(input.credentialPlacements?.headers ?? {}), | ||
| 450 | }, | ||
| 451 | queryParams: input.credentialPlacements?.queryParams, | ||
| 452 | cookies: input.credentialPlacements?.cookies, | ||
| 453 | bodyValues: input.credentialPlacements?.bodyValues, | ||
| 454 | }); | ||
| 455 | |||
| 420 | export const createOpenApiToolFromDefinition = ( | 456 | export const createOpenApiToolFromDefinition = ( |
| 421 | input: CreateOpenApiToolFromDefinitionInput, | 457 | input: CreateOpenApiToolFromDefinitionInput, |
| 422 | ) => { | 458 | ) => { |
| 423 | const defaultHeaders = input.defaultHeaders ?? {}; | 459 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 424 | const credentialHeaders = input.credentialHeaders ?? {}; | 460 | const credentialPlacements = normalizeCredentialPlacements({ |
| 461 | credentialHeaders: input.credentialHeaders, | ||
| 462 | credentialPlacements: input.credentialPlacements, | ||
| 463 | }); | ||
| 425 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | 464 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 426 | const presentation = buildOpenApiToolPresentation({ | 465 | const presentation = buildOpenApiToolPresentation({ |
| 427 | definition: input.definition, | 466 | definition: input.definition, |
| @@ -441,7 +480,7 @@ export const createOpenApiToolFromDefinition = ( · 13 unmodified lines | |||
| 441 | args: decodedArgs, | 480 | args: decodedArgs, |
| 442 | baseUrl: input.baseUrl, | 481 | baseUrl: input.baseUrl, |
| 443 | defaultHeaders, | 482 | defaultHeaders, |
| 444 | credentialHeaders, | 483 | credentialPlacements, |
| 445 | }); | 484 | }); |
| 446 | 485 | ||
| 447 | return Effect.runPromise( | 486 | return Effect.runPromise( |
| @@ -503,7 +542,6 @@ export const createOpenApiToolsFromManifest = ( · 55 unmodified lines | |||
| 503 | const baseUrl = normalizeHttpUrl(input.baseUrl); | 542 | const baseUrl = normalizeHttpUrl(input.baseUrl); |
| 504 | const sourceKey = input.sourceKey ?? "openapi.generated"; | 543 | const sourceKey = input.sourceKey ?? "openapi.generated"; |
| 505 | const defaultHeaders = input.defaultHeaders ?? {}; | 544 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 506 | const credentialHeaders = input.credentialHeaders ?? {}; | ||
| 507 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | 545 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 508 | 546 | ||
| 509 | const definitions = compileOpenApiToolDefinitions(input.manifest); | 547 | const definitions = compileOpenApiToolDefinitions(input.manifest); |
| @@ -517,7 +555,8 @@ export const createOpenApiToolsFromManifest = ( · 7 unmodified lines | |||
| 517 | sourceKey, | 555 | sourceKey, |
| 518 | baseUrl, | 556 | baseUrl, |
| 519 | defaultHeaders, | 557 | defaultHeaders, |
| 520 | credentialHeaders, | 558 | credentialHeaders: input.credentialHeaders, |
| 559 | credentialPlacements: input.credentialPlacements, | ||
| 521 | refHintTable: input.manifest.refHintTable, | 560 | refHintTable: input.manifest.refHintTable, |
| 522 | httpClientLayer, | 561 | httpClientLayer, |
| 523 | }); | 562 | }); |
| @@ -534,6 +573,7 @@ export const createOpenApiToolsFromSpec = (input: { · 10 unmodified lines | |||
| 534 | sourceKey?: string; | 573 | sourceKey?: string; |
| 535 | defaultHeaders?: Readonly<Record<string, string>>; | 574 | defaultHeaders?: Readonly<Record<string, string>>; |
| 536 | credentialHeaders?: Readonly<Record<string, string>>; | 575 | credentialHeaders?: Readonly<Record<string, string>>; |
| 576 | credentialPlacements?: HttpRequestPlacements; | ||
| 537 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 577 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 538 | }): Effect.Effect< | 578 | }): Effect.Effect< |
| 539 | { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap }, | 579 | { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap }, |
| @@ -551,6 +591,7 @@ export const createOpenApiToolsFromSpec = (input: { · 11 unmodified lines | |||
| 551 | sourceKey: input.sourceKey, | 591 | sourceKey: input.sourceKey, |
| 552 | defaultHeaders: input.defaultHeaders, | 592 | defaultHeaders: input.defaultHeaders, |
| 553 | credentialHeaders: input.credentialHeaders, | 593 | credentialHeaders: input.credentialHeaders, |
| 594 | credentialPlacements: input.credentialPlacements, | ||
| 554 | httpClientLayer: input.httpClientLayer, | 595 | httpClientLayer: input.httpClientLayer, |
| 555 | }), | 596 | }), |
| 556 | }), | 597 | }), |