Skip to content

Commit 1ec0e27

Browse files
committed
chore: add integration test for the generated runtime
1 parent 20f0ab7 commit 1ec0e27

File tree

8 files changed

+586
-7
lines changed

8 files changed

+586
-7
lines changed

.github/workflows/build-and-test.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,11 @@ jobs:
2323
- name: Build
2424
run: pnpm build
2525

26+
- name: Run integration test (MSW)
27+
run: pnpm test:runtime
28+
29+
- name: Type check generated client and integration test
30+
run: pnpm exec tsc --noEmit tmp/generated-client.ts tests/integration-runtime-msw.test.ts
31+
2632
- name: Test
2733
run: pnpm test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ styled-system
2525
styled-system-static
2626
.vercel
2727
*.tsbuildinfo
28+
29+
tmp

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"test": "cd packages/typed-openapi && pnpm run test"
1515
},
1616
"devDependencies": {
17-
"@changesets/cli": "^2.29.4"
17+
"@changesets/cli": "^2.29.4",
18+
"msw": "2.10.5"
1819
},
1920
"packageManager": "[email protected]+sha256.dae0f7e822c56b20979bb5965e3b73b8bdabb6b8b8ef121da6d857508599ca35"
2021
}

packages/typed-openapi/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"dev": "tsup --watch",
2323
"build": "tsup",
2424
"test": "vitest",
25+
"test:runtime": "pnpm run generate:runtime && pnpm run test:runtime:run",
26+
"generate:runtime": "node bin.js ./tests/samples/petstore.yaml --output ./tmp/generated-client.ts",
27+
"test:runtime:run": "vitest run tests/integration-runtime-msw.test.ts",
2528
"fmt": "prettier --write src",
2629
"typecheck": "tsc -b ./tsconfig.build.json"
2730
},
@@ -40,6 +43,7 @@
4043
"@changesets/cli": "^2.29.4",
4144
"@types/node": "^22.15.17",
4245
"@types/prettier": "3.0.0",
46+
"msw": "2.10.5",
4347
"tsup": "^8.4.0",
4448
"typescript": "^5.8.3",
4549
"vitest": "^3.1.3"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "Generate and test runtime client with MSW integration",
3+
"scripts": {
4+
"test:runtime": "pnpm run generate:runtime && pnpm run test:runtime:run",
5+
"generate:runtime": "pnpm exec tsx src/generate-client-files.ts tests/samples/petstore.yaml --output tmp/generated-client.ts --runtime none --schemasOnly true",
6+
"test:runtime:run": "vitest run tests/integration-runtime-msw.test.ts"
7+
}
8+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Generic API Client for typed-openapi generated code
3+
*
4+
* This is a simple, production-ready wrapper that you can copy and customize.
5+
* It handles:
6+
* - Path parameter replacement
7+
* - Query parameter serialization
8+
* - JSON request/response handling
9+
* - Basic error handling
10+
*
11+
* Usage:
12+
* 1. Replace './generated/api' with your actual generated file path
13+
* 2. Set your API_BASE_URL
14+
* 3. Customize error handling and headers as needed
15+
*/
16+
17+
import { type Fetcher, createApiClient } from "../tmp/generated-client.ts";
18+
19+
// Basic configuration
20+
const API_BASE_URL = process.env["API_BASE_URL"] || "https://api.example.com";
21+
22+
/**
23+
* Simple fetcher implementation without external dependencies
24+
*/
25+
const fetcher: Fetcher = async (method, apiUrl, params) => {
26+
const headers = new Headers();
27+
28+
// Replace path parameters (supports both {param} and :param formats)
29+
const actualUrl = replacePathParams(apiUrl, (params?.path ?? {}) as Record<string, string>);
30+
const url = new URL(actualUrl);
31+
32+
// Handle query parameters
33+
if (params?.query) {
34+
const searchParams = new URLSearchParams();
35+
Object.entries(params.query).forEach(([key, value]) => {
36+
if (value != null) {
37+
// Skip null/undefined values
38+
if (Array.isArray(value)) {
39+
value.forEach((val) => val != null && searchParams.append(key, String(val)));
40+
} else {
41+
searchParams.append(key, String(value));
42+
}
43+
}
44+
});
45+
url.search = searchParams.toString();
46+
}
47+
48+
// Handle request body for mutation methods
49+
const body = ["post", "put", "patch", "delete"].includes(method.toLowerCase())
50+
? JSON.stringify(params?.body)
51+
: undefined;
52+
53+
if (body) {
54+
headers.set("Content-Type", "application/json");
55+
}
56+
57+
// Add custom headers
58+
if (params?.header) {
59+
Object.entries(params.header).forEach(([key, value]) => {
60+
if (value != null) {
61+
headers.set(key, String(value));
62+
}
63+
});
64+
}
65+
66+
const response = await fetch(url, {
67+
method: method.toUpperCase(),
68+
...(body && { body }),
69+
headers,
70+
});
71+
72+
if (!response.ok) {
73+
// You can customize error handling here
74+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
75+
(error as any).response = response;
76+
(error as any).status = response.status;
77+
throw error;
78+
}
79+
80+
return response;
81+
};
82+
83+
/**
84+
* Replace path parameters in URL
85+
* Supports both OpenAPI format {param} and Express format :param
86+
*/
87+
function replacePathParams(url: string, params: Record<string, string>): string {
88+
return url
89+
.replace(/{(\w+)}/g, (_, key: string) => params[key] || `{${key}}`)
90+
.replace(/:([a-zA-Z0-9_]+)/g, (_, key: string) => params[key] || `:${key}`);
91+
}
92+
93+
export const api = createApiClient(fetcher, API_BASE_URL);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Integration test for generated query client using MSW
2+
// This test ensures the generated client (TS types only, no schema validation) has no runtime errors
3+
4+
import { http, HttpResponse } from "msw";
5+
import { setupServer } from "msw/node";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { api } from "./api-client.example.js";
8+
import { createApiClient } from "../tmp/generated-client.ts";
9+
10+
// Mock handler for a real endpoint from petstore.yaml
11+
const mockPets = [
12+
{
13+
id: 1,
14+
name: "Fluffy",
15+
photoUrls: [],
16+
status: "available",
17+
},
18+
];
19+
const server = setupServer(
20+
// GET with query
21+
http.get("http://localhost/pet/findByStatus", () => {
22+
return HttpResponse.json(mockPets);
23+
}),
24+
// GET with path
25+
http.get("http://localhost/pet/42", () => {
26+
return HttpResponse.json({ id: 42, name: "Spot", photoUrls: [], status: "sold" });
27+
}),
28+
// POST with body
29+
http.post("http://localhost/pet", async ({ request }) => {
30+
let body: any = await request.json();
31+
if (body == null || typeof body !== "object") body = {};
32+
return HttpResponse.json({ ...body, id: 99 });
33+
}),
34+
// POST with path and query (no body expected)
35+
http.post("http://localhost/pet/42", ({ request }) => {
36+
const url = new URL(request.url);
37+
const name = url.searchParams.get("name");
38+
const status = url.searchParams.get("status");
39+
return HttpResponse.json({ name, status });
40+
}),
41+
// DELETE with header
42+
http.delete("http://localhost/pet/42", ({ request }) => {
43+
if (request.headers.get("api_key") === "secret") {
44+
return HttpResponse.text("deleted");
45+
}
46+
return HttpResponse.text("forbidden", { status: 403 });
47+
}),
48+
);
49+
50+
beforeAll(() => server.listen());
51+
afterAll(() => server.close());
52+
53+
describe("minimalist test", () => {
54+
it("should fetch /pet/findByStatus and receive mocked pets", async () => {
55+
const fetcher = (method: string, url: string) => fetch(url, { method });
56+
const mini = createApiClient(fetcher, "http://localhost");
57+
const result = await mini.get("/pet/findByStatus", { query: {} });
58+
expect(result).toEqual(mockPets);
59+
});
60+
});
61+
62+
describe("Generated Query Client (runtime)", () => {
63+
beforeAll(() => {
64+
api.baseUrl = "http://localhost";
65+
});
66+
67+
it("should fetch /pet/findByStatus and receive mocked pets", async () => {
68+
const result = await api.get("/pet/findByStatus", { query: {} });
69+
expect(result).toEqual(mockPets);
70+
});
71+
72+
it("should fetch /pet/:petId with path param", async () => {
73+
const result = await api.get("/pet/{petId}", { path: { petId: 42 } });
74+
expect(result).toEqual({ id: 42, name: "Spot", photoUrls: [], status: "sold" });
75+
});
76+
77+
it("should post /pet with body param", async () => {
78+
const newPet = { name: "Rex", photoUrls: ["img.jpg"] };
79+
const result = await api.post("/pet", { body: newPet });
80+
expect(result).toMatchObject(newPet);
81+
expect(result.id).toBe(99);
82+
});
83+
84+
it("should post /pet/:petId with path and query params", async () => {
85+
const result = await api.post("/pet/{petId}", {
86+
path: { petId: 42 },
87+
query: { name: "Buddy", status: "pending" },
88+
});
89+
expect(result).toEqual({ name: "Buddy", status: "pending" });
90+
});
91+
92+
it("should delete /pet/:petId with header param", async () => {
93+
const result = await api.delete("/pet/{petId}", {
94+
path: { petId: 42 },
95+
header: { api_key: "secret" },
96+
});
97+
expect(result).toBe("deleted");
98+
});
99+
100+
it("should use .request to get a Response object", async () => {
101+
const res = await api.request("get", "/pet/findByStatus", { query: {} });
102+
expect(res.status).toBe(200);
103+
const data = await res.json();
104+
expect(data).toEqual(mockPets);
105+
});
106+
107+
it("should use .request to post and get a Response object", async () => {
108+
const newPet = { name: "Tiger", photoUrls: [] };
109+
const res = await api.request("post", "/pet", { body: newPet });
110+
expect(res.status).toBe(200);
111+
const data = await res.json();
112+
expect(data).toMatchObject(newPet);
113+
expect(data.id).toBe(99);
114+
});
115+
116+
it("should throw and handle error for forbidden delete", async () => {
117+
api.baseUrl = "http://localhost";
118+
await expect(
119+
api.delete("/pet/{petId}", {
120+
path: { petId: 42 },
121+
header: { api_key: "wrong" },
122+
}),
123+
).rejects.toMatchObject({
124+
message: expect.stringContaining("403"),
125+
status: 403,
126+
});
127+
});
128+
});

0 commit comments

Comments
 (0)