1538 lines · 54.8 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Dispatch tests for non-JSON request bodies.//// Each case spins up an Effect HttpApi-backed test server, derives the// OpenAPI spec from that API, and asserts both the wire-level content type// and body shape the plugin actually sent.//// The scenarios mirror what real specs commonly carry — multipart uploads// (files + scalar fields), XML bodies declared as pre-serialized strings,// text/plain payloads, and raw octet-stream byte uploads.//// v2: tools are produced per-connection, so each case adds the integration via// `addSpec` AND creates a connection before executing the full tool address.// --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest";import { Cause, Effect, Exit, Schema } from "effect";import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";import {  HttpApi,  HttpApiBuilder,  HttpApiEndpoint,  HttpApiGroup,  HttpApiSchema,} from "effect/unstable/httpapi"; import { createExecutor } from "@executor-js/sdk";import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";import {  addOpenApiTestConnection,  serveOpenApiHttpApiTestServer,} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; const JsonNameBody = Schema.fromJsonString(  Schema.Struct({    name: Schema.String,  }),);const decodeJsonNameBody = Schema.decodeUnknownSync(JsonNameBody); const JsonAttachmentBody = Schema.fromJsonString(  Schema.Struct({    attachment: Schema.String,    name: Schema.String,  }),);const decodeJsonAttachmentBody = Schema.decodeUnknownSync(JsonAttachmentBody); const testPlugins = () =>  [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; type Captured = {  contentType: string;  body: Buffer;}; const Ok = Schema.Struct({ ok: Schema.Boolean }); const startEchoServer = (options: {  readonly name?: string;  readonly path?: `/${string}`;  readonly payload: Schema.Top | readonly Schema.Top[];  readonly transformSpec?: (spec: Record<string, unknown>) => Record<string, unknown>;}) =>  Effect.gen(function* () {    const captured: Captured = { contentType: "", body: Buffer.alloc(0) };    const endpointName = options.name ?? "submit";    const path = options.path ?? "/submit";    const group = HttpApiGroup.make("body").add(      HttpApiEndpoint.post(endpointName, path, {        payload: options.payload,        success: Ok,      }),    );    const api = HttpApi.make(`bodyTest_${endpointName}`).add(group);    const handlersLayer = HttpApiBuilder.group(api, "body", (handlers) =>      handlers.handleRaw(endpointName, () =>        Effect.gen(function* () {          const request = yield* HttpServerRequest.HttpServerRequest;          captured.contentType = request.headers["content-type"] ?? "";          const body = yield* request.arrayBuffer.pipe(            Effect.catch(() => Effect.succeed(new ArrayBuffer(0))),          );          captured.body = Buffer.from(body);          return HttpServerResponse.jsonUnsafe({ ok: true });        }),      ),    );    const server = yield* serveOpenApiHttpApiTestServer({      api,      handlersLayer,      transformSpec: options.transformSpec,    });    return { server, captured };  }); const ObjectBody = Schema.Struct({  name: Schema.optional(Schema.String),  flag: Schema.optional(Schema.Boolean),  count: Schema.optional(Schema.Number),}); const JsonNameObject = Schema.Struct({ name: Schema.String }); const contentFor = (contentType: string) => ({  [contentType]: {    schema: { type: "object" },  },}); const replaceResponseContent =  (path: string, operation: string, content: Record<string, unknown>) =>  (spec: Record<string, unknown>): Record<string, unknown> => {    const paths = { ...(spec.paths as Record<string, unknown>) };    const pathItem = { ...(paths[path] as Record<string, unknown>) };    const operationSpec = { ...(pathItem[operation] as Record<string, unknown>) };    const responses = { ...(operationSpec.responses as Record<string, unknown>) };    const ok = { ...(responses["200"] as Record<string, unknown>) };    responses["200"] = { ...ok, content };    pathItem[operation] = { ...operationSpec, responses };    paths[path] = pathItem;    return { ...spec, paths };  }; const replaceOperationServers =  (path: string, operation: string, servers: readonly Record<string, unknown>[]) =>  (spec: Record<string, unknown>): Record<string, unknown> => {    const paths = { ...(spec.paths as Record<string, unknown>) };    const pathItem = { ...(paths[path] as Record<string, unknown>) };    const operationSpec = { ...(pathItem[operation] as Record<string, unknown>) };    pathItem[operation] = { ...operationSpec, servers };    paths[path] = pathItem;    return { ...spec, paths };  }; const replaceRequestBodyContent =  (    path: string,    operation: string,    content: Record<string, unknown>,    encoding?: Record<string, unknown>,  ) =>  (spec: Record<string, unknown>): Record<string, unknown> => {    const paths = { ...(spec.paths as Record<string, unknown>) };    const pathItem = { ...(paths[path] as Record<string, unknown>) };    const operationSpec = { ...(pathItem[operation] as Record<string, unknown>) };    const requestBody = { ...(operationSpec.requestBody as Record<string, unknown>) };    pathItem[operation] = {      ...operationSpec,      requestBody: {        ...requestBody,        content: encoding          ? Object.fromEntries(              Object.entries(content).map(([key, value]) => [                key,                { ...(value as Record<string, unknown>), encoding },              ]),            )          : content,      },    };    paths[path] = pathItem;    return { ...spec, paths };  }; describe("OpenAPI non-JSON request body dispatch", () => {  it.effect("multipart/form-data: object body is encoded as real multipart", () =>    Effect.gen(function* () {      const { server, captured } = yield* startEchoServer({        payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));       const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mp" });       yield* executor.execute(conn.address("body.submit"), {        body: { name: "Acme", flag: true, count: 7 },      });       expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/);      const body = captured.body.toString("utf8");      expect(body).toContain('name="name"');      expect(body).toContain("Acme");      expect(body).toContain('name="flag"');      expect(body).toContain("true");      expect(body).toContain('name="count"');      expect(body).toContain("7");      // Regression guard: never ship [object Object] over multipart.      expect(body).not.toContain("[object Object]");    }),  );   it.effect("multipart/form-data: binary file fields use ToolFile and real file parts", () =>    Effect.gen(function* () {      const { server, captured } = yield* startEchoServer({        name: "upload",        path: "/upload",        payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),        transformSpec: replaceRequestBodyContent(          "/upload",          "post",          {            "multipart/form-data": {              schema: {                type: "object",                properties: {                  document: {                    type: "string",                    format: "binary",                    description: "PDF document to upload.",                  },                  title: { type: "string" },                },                required: ["document"],              },            },          },          { document: { contentType: "application/pdf" } },        ),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" });       const schema = yield* executor.tools.schema(conn.address("body.upload"));      expect(schema?.inputSchema).toMatchObject({        properties: {          body: {            properties: {              document: {                properties: {                  _tag: { enum: ["ToolFile"] },                  data: { contentEncoding: "base64" },                },              },            },          },        },      });       const pdfBytes = Buffer.from("%PDF-1.4\nexecutor upload test\n");      yield* executor.execute(conn.address("body.upload"), {        body: {          document: {            _tag: "ToolFile",            name: "invoice.pdf",            mimeType: "application/pdf",            encoding: "base64",            data: pdfBytes.toString("base64"),            byteLength: pdfBytes.byteLength,          },          title: "Invoice",        },      });       expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/);      const body = captured.body.toString("utf8");      expect(body).toContain('name="document"; filename="invoice.pdf"');      expect(body).toMatch(        /name="document"; filename="invoice\.pdf"[\s\S]*?Content-Type: application\/pdf/,      );      expect(body).toContain("%PDF-1.4");      expect(body).toContain('name="title"');      expect(body).toContain("Invoice");      expect(body).not.toContain("[object Object]");    }),  );   it.effect("multipart/form-data: file arrays become file parts with the encoding type", () =>    Effect.gen(function* () {      const { server, captured } = yield* startEchoServer({        name: "uploadPages",        path: "/upload-pages",        payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),        transformSpec: replaceRequestBodyContent(          "/upload-pages",          "post",          {            "multipart/form-data": {              schema: {                type: "object",                properties: {                  pages: {                    type: "array",                    items: { type: "string", format: "binary" },                  },                },                required: ["pages"],              },            },          },          // A per-part content type on a file array describes each file part,          // not a JSON serialization of the array.          { pages: { contentType: "image/png" } },        ),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" });       const schema = yield* executor.tools.schema(conn.address("body.uploadPages"));      expect(schema?.inputSchema).toMatchObject({        properties: {          body: {            properties: {              pages: {                type: "array",                items: {                  properties: {                    _tag: { enum: ["ToolFile"] },                    data: { contentEncoding: "base64" },                  },                },              },            },          },        },      });       const first = Buffer.from("page-one-bytes");      const second = Buffer.from("page-two-bytes");      yield* executor.execute(conn.address("body.uploadPages"), {        body: {          pages: [            {              _tag: "ToolFile",              name: "one.png",              mimeType: "application/octet-stream",              encoding: "base64",              data: first.toString("base64"),              byteLength: first.byteLength,            },            {              _tag: "ToolFile",              name: "two.png",              mimeType: "application/octet-stream",              encoding: "base64",              data: second.toString("base64"),              byteLength: second.byteLength,            },          ],        },      });       expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/);      const body = captured.body.toString("utf8");      expect(body).toContain('name="pages"; filename="one.png"');      expect(body).toContain('name="pages"; filename="two.png"');      // The encoding contentType overrides each file's own mime type.      expect(body.match(/Content-Type: image\/png/g)).toHaveLength(2);      expect(body).toContain("page-one-bytes");      expect(body).toContain("page-two-bytes");      // Regression guard for the branch-ordering bug: the array must not be      // JSON-stringified into a single part.      expect(body).not.toContain("_tag");      expect(body).not.toContain(first.toString("base64"));      expect(body).not.toContain("[object Object]");    }),  );   it.effect("multipart/form-data: a file whose base64 does not decode fails before dispatch", () =>    Effect.gen(function* () {      const { server, captured } = yield* startEchoServer({        name: "uploadBroken",        path: "/upload-broken",        payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),        transformSpec: replaceRequestBodyContent("/upload-broken", "post", {          "multipart/form-data": {            schema: {              type: "object",              properties: { document: { type: "string", format: "binary" } },              required: ["document"],            },          },        }),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" });       const exit = yield* executor        .execute(conn.address("body.uploadBroken"), {          body: {            document: {              _tag: "ToolFile",              name: "broken.pdf",              mimeType: "application/pdf",              encoding: "base64",              data: "@@@@not base64@@@@",              byteLength: 12,            },          },        })        .pipe(Effect.exit);       // Never silently JSON.stringify the file envelope into the form body.      expect(Exit.isFailure(exit)).toBe(true);      const failure = Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : "<succeeded>";      expect(failure).toContain("`document`");      expect(captured.contentType).toBe("");      expect(captured.body.length).toBe(0);    }),  );   it.effect("application/json: binary string properties are left untouched", () =>    Effect.gen(function* () {      const { server, captured } = yield* startEchoServer({        name: "createNote",        path: "/notes",        payload: JsonNameObject,        transformSpec: replaceRequestBodyContent("/notes", "post", {          "application/json": {            schema: {              type: "object",              properties: {                attachment: { type: "string", format: "byte" },                name: { type: "string" },              },            },          },        }),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" });       // The rewrite is scoped to multipart bodies: a JSON body keeps its      // declared base64 string field.      const schema = yield* executor.tools.schema(conn.address("body.createNote"));      expect(schema?.inputSchema).toMatchObject({        properties: { body: { properties: { attachment: { type: "string", format: "byte" } } } },      });      expect(JSON.stringify(schema?.inputSchema)).not.toContain("ToolFile");       yield* executor.execute(conn.address("body.createNote"), {        body: { attachment: "aGVsbG8=", name: "Acme" },      });       expect(captured.contentType).toBe("application/json");      expect(decodeJsonAttachmentBody(captured.body.toString("utf8"))).toEqual({        attachment: "aGVsbG8=",        name: "Acme",      });    }),  );   it.effect("multipart/form-data: only encoder-supported file shapes are advertised", () =>    Effect.gen(function* () {      const { server } = yield* startEchoServer({        name: "uploadMixed",        path: "/upload-mixed",        payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),        transformSpec: replaceRequestBodyContent("/upload-mixed", "post", {          "multipart/form-data": {            schema: {              type: "object",              properties: {                document: {                  type: "string",                  format: "binary",                  title: "Document",                  description: "PDF document to upload.",                },                optionalDocument: { type: ["string", "null"], format: "binary" },                // Nested files are a documented limitation: the form encoder                // only builds parts from top-level properties and direct                // array items, so this stays a plain binary string.                metadata: {                  type: "object",                  properties: { thumbnail: { type: "string", format: "binary" } },                },                // Scoping guard: a non-schema keyword that happens to look                // like a binary string schema is never rewritten.                title: { type: "string", default: { type: "string", format: "binary" } },              },            },          },        }),      });       const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));      const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" });       const schema = yield* executor.tools.schema(conn.address("body.uploadMixed"));      expect(schema?.inputSchema).toMatchObject({        properties: {          body: {            properties: {              // Annotations survive the rewrite.              document: {                title: "Document",                description: "PDF document to upload.",                properties: { _tag: { enum: ["ToolFile"] } },              },              // Nullability survives as an anyOf branch, since the file              // schema is an object and cannot carry a "null" type entry.              optionalDocument: {

Lines 1–500 of 1538.