diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..495aac0 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@crate/client", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "generate": "bun scripts/generate.ts", + "check": "bun scripts/generate.ts --check" + }, + "dependencies": { + "openapi-fetch": "0.17.0" + }, + "devDependencies": { + "@types/node": "26.4.0", + "openapi-typescript": "7.13.0", + "typescript": "6.0.3" + } +} diff --git a/packages/client/scripts/generate.test.ts b/packages/client/scripts/generate.test.ts new file mode 100644 index 0000000..47845ce --- /dev/null +++ b/packages/client/scripts/generate.test.ts @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process" +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { expect, it } from "vitest" + +it("generates typed binary requests and detects contract drift", async () => { + const directory = await mkdtemp(join(tmpdir(), "crate-typegen-test-")) + const schema = join(directory, "openapi.json") + const output = join(directory, "schema.ts") + await writeFile( + schema, + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Fixture", version: "1" }, + paths: { + "/upload": { + post: { + operationId: "upload", + requestBody: { + required: true, + content: { + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + }, + }, + responses: { "204": { description: "Stored" } }, + }, + }, + }, + }), + ) + const generator = fileURLToPath(new URL("./generate.ts", import.meta.url)) + const generate = (...args: string[]) => + spawnSync("bun", [generator, schema, output, ...args], { encoding: "utf8" }) + expect(generate().status).toBe(0) + expect(await readFile(output, "utf8")).toContain('"application/octet-stream": Blob') + expect(generate("--check").status).toBe(0) + await writeFile(output, "stale output") + const stale = generate("--check") + expect(stale.status).not.toBe(0) + expect(stale.stderr).toContain("client contract is stale") +}) diff --git a/packages/client/scripts/generate.ts b/packages/client/scripts/generate.ts new file mode 100644 index 0000000..5504bd0 --- /dev/null +++ b/packages/client/scripts/generate.ts @@ -0,0 +1,29 @@ +import { readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" +import openapiTS, { astToString } from "openapi-typescript" +import ts from "typescript" + +const arguments_ = process.argv.slice(2).filter((value) => value !== "--check") +const input = arguments_[0] + ? pathToFileURL(resolve(arguments_[0])) + : new URL("../openapi.json", import.meta.url) +const output = arguments_[1] + ? pathToFileURL(resolve(arguments_[1])) + : new URL("../src/schema.ts", import.meta.url) +const ast = await openapiTS(input, { + alphabetize: true, + transform(schema) { + if (schema.type === "string" && schema.format === "binary") { + return ts.factory.createTypeReferenceNode("Blob") + } + }, +}) +const generated = `// Generated from the Rust server's OpenAPI contract. Do not edit.\n${astToString(ast)}` +if (process.argv.includes("--check")) { + if ((await readFile(output, "utf8")) !== generated) { + throw new Error("TypeScript client contract is stale. Run bun run generate:api.") + } +} else { + await writeFile(output, generated) +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..2be06f5 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "scripts", "../../scripts/generate-api.ts"] +}