Skip to content

Commit 4885b26

Browse files
authored
Merge branch 'main' into copilot/fix-admin-api-middleware-in-create-koa-app
2 parents 3b56164 + a7cbeab commit 4885b26

File tree

4 files changed

+207
-20
lines changed

4 files changed

+207
-20
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"counterfact": patch
3+
---
4+
5+
Refactor `openapiMiddleware` to accept an array of `{ path, baseUrl, id }` document descriptors. When the array contains a single entry the document is still served at `/counterfact/openapi` (backward-compatible). When multiple entries are provided each document is served at `/counterfact/openapi/{id}`.

src/server/create-koa-app.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ export function createKoaApp(
3434
const app = new Koa();
3535

3636
app.use(
37-
openapiMiddleware(
38-
config.openApiPath,
39-
`//localhost:${config.port}${config.routePrefix}`,
40-
),
37+
openapiMiddleware([
38+
{
39+
path: config.openApiPath,
40+
baseUrl: `//localhost:${config.port}${config.routePrefix}`,
41+
},
42+
]),
4143
);
4244

4345
app.use(

src/server/openapi-middleware.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,48 @@ import { bundle } from "@apidevtools/json-schema-ref-parser";
22
import { dump } from "js-yaml";
33
import type Koa from "koa";
44

5+
export interface OpenApiDocumentConfig {
6+
path: string;
7+
baseUrl: string;
8+
id?: string;
9+
}
10+
511
/**
6-
* Returns a Koa middleware that serves the bundled OpenAPI document as YAML at
7-
* `/counterfact/openapi`.
12+
* Returns a Koa middleware that serves bundled OpenAPI documents as YAML.
13+
*
14+
* When `documents` has exactly one entry the document is served at
15+
* `/counterfact/openapi` (backward-compatible behaviour).
16+
*
17+
* When `documents` has more than one entry each document is served at
18+
* `/counterfact/openapi/{id}` where `id` comes from the corresponding entry.
819
*
9-
* The document is augmented with a `servers` entry (OpenAPI 3.x) and a `host`
10-
* field (OpenAPI 2.x / Swagger) so that the Swagger UI can send requests to
11-
* the running Counterfact instance.
20+
* Every served document is augmented with a `servers` entry (OpenAPI 3.x) and
21+
* a `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
22+
* requests to the running Counterfact instance.
1223
*
13-
* @param openApiPath - Path or URL to the source OpenAPI document.
14-
* @param url - The base URL to inject (e.g. `"//localhost:3100/api"`).
24+
* @param documents - Array of document descriptors. Each entry must provide
25+
* `path` (file path or URL to the source OpenAPI document) and `baseUrl`
26+
* (the base URL to inject, e.g. `"//localhost:3100/api"`). An optional `id`
27+
* string is used to build the per-document URL when more than one document
28+
* is present.
1529
* @returns A Koa middleware function.
1630
*/
17-
export function openapiMiddleware(openApiPath: string, url: string) {
31+
export function openapiMiddleware(documents: OpenApiDocumentConfig[]) {
1832
return async (ctx: Koa.ExtendableContext, next: Koa.Next) => {
19-
if (ctx.URL.pathname === "/counterfact/openapi") {
20-
const openApiDocument = (await bundle(openApiPath)) as {
33+
let matched: OpenApiDocumentConfig | undefined;
34+
35+
if (documents.length === 1) {
36+
if (ctx.URL.pathname === "/counterfact/openapi") {
37+
matched = documents[0];
38+
}
39+
} else {
40+
matched = documents.find(
41+
(doc) => ctx.URL.pathname === `/counterfact/openapi/${doc.id}`,
42+
);
43+
}
44+
45+
if (matched) {
46+
const openApiDocument = (await bundle(matched.path)) as {
2147
host?: string;
2248
servers?: { description: string; url: string }[];
2349
};
@@ -26,11 +52,11 @@ export function openapiMiddleware(openApiPath: string, url: string) {
2652

2753
openApiDocument.servers.unshift({
2854
description: "Counterfact",
29-
url,
55+
url: matched.baseUrl,
3056
});
3157

3258
// OpenApi 2 support:
33-
openApiDocument.host = url;
59+
openApiDocument.host = matched.baseUrl;
3460

3561
ctx.body = dump(openApiDocument);
3662

test/server/openapi-middleware.test.ts

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ describe("openapiMiddleware", () => {
1616

1717
const app = new Koa();
1818

19-
app.use(openapiMiddleware($.path("openapi.yaml"), "//localhost:3100"));
19+
app.use(
20+
openapiMiddleware([
21+
{ path: $.path("openapi.yaml"), baseUrl: "//localhost:3100" },
22+
]),
23+
);
2024

2125
const response = await request(app.callback()).get(
2226
"/counterfact/openapi",
@@ -38,7 +42,11 @@ describe("openapiMiddleware", () => {
3842

3943
const app = new Koa();
4044

41-
app.use(openapiMiddleware($.path("openapi.yaml"), "//localhost:3100"));
45+
app.use(
46+
openapiMiddleware([
47+
{ path: $.path("openapi.yaml"), baseUrl: "//localhost:3100" },
48+
]),
49+
);
4250

4351
const response = await request(app.callback()).get(
4452
"/counterfact/openapi",
@@ -93,7 +101,11 @@ describe("openapiMiddleware", () => {
93101

94102
const app = new Koa();
95103

96-
app.use(openapiMiddleware($.path("openapi.yaml"), "//localhost:3100"));
104+
app.use(
105+
openapiMiddleware([
106+
{ path: $.path("openapi.yaml"), baseUrl: "//localhost:3100" },
107+
]),
108+
);
97109

98110
const response = await request(app.callback()).get(
99111
"/counterfact/openapi",
@@ -143,7 +155,11 @@ describe("openapiMiddleware", () => {
143155

144156
const app = new Koa();
145157

146-
app.use(openapiMiddleware($.path("openapi.yaml"), "//localhost:3100"));
158+
app.use(
159+
openapiMiddleware([
160+
{ path: $.path("openapi.yaml"), baseUrl: "//localhost:3100" },
161+
]),
162+
);
147163

148164
app.use((ctx) => {
149165
ctx.body = "fallthrough";
@@ -154,4 +170,142 @@ describe("openapiMiddleware", () => {
154170
expect(response.text).toBe("fallthrough");
155171
});
156172
});
173+
174+
describe("with multiple documents", () => {
175+
it("serves each document at /counterfact/openapi/{id}", async () => {
176+
await usingTemporaryFiles(async ($) => {
177+
await $.add(
178+
"spec-a.yaml",
179+
"openapi: '3.0.0'\ninfo:\n title: Spec A\n version: '1.0.0'\npaths: {}\n",
180+
);
181+
await $.add(
182+
"spec-b.yaml",
183+
"openapi: '3.0.0'\ninfo:\n title: Spec B\n version: '1.0.0'\npaths: {}\n",
184+
);
185+
186+
const app = new Koa();
187+
188+
app.use(
189+
openapiMiddleware([
190+
{
191+
path: $.path("spec-a.yaml"),
192+
baseUrl: "//localhost:3100/a",
193+
id: "spec-a",
194+
},
195+
{
196+
path: $.path("spec-b.yaml"),
197+
baseUrl: "//localhost:3100/b",
198+
id: "spec-b",
199+
},
200+
]),
201+
);
202+
203+
const responseA = await request(app.callback()).get(
204+
"/counterfact/openapi/spec-a",
205+
);
206+
expect(responseA.status).toBe(200);
207+
const docA = yaml.load(responseA.text) as { info: { title: string } };
208+
expect(docA.info.title).toBe("Spec A");
209+
210+
const responseB = await request(app.callback()).get(
211+
"/counterfact/openapi/spec-b",
212+
);
213+
expect(responseB.status).toBe(200);
214+
const docB = yaml.load(responseB.text) as { info: { title: string } };
215+
expect(docB.info.title).toBe("Spec B");
216+
});
217+
});
218+
219+
it("does not serve at /counterfact/openapi when there are multiple documents", async () => {
220+
await usingTemporaryFiles(async ($) => {
221+
await $.add(
222+
"spec-a.yaml",
223+
"openapi: '3.0.0'\ninfo:\n title: Spec A\n version: '1.0.0'\npaths: {}\n",
224+
);
225+
await $.add(
226+
"spec-b.yaml",
227+
"openapi: '3.0.0'\ninfo:\n title: Spec B\n version: '1.0.0'\npaths: {}\n",
228+
);
229+
230+
const app = new Koa();
231+
232+
app.use(
233+
openapiMiddleware([
234+
{
235+
path: $.path("spec-a.yaml"),
236+
baseUrl: "//localhost:3100/a",
237+
id: "spec-a",
238+
},
239+
{
240+
path: $.path("spec-b.yaml"),
241+
baseUrl: "//localhost:3100/b",
242+
id: "spec-b",
243+
},
244+
]),
245+
);
246+
247+
app.use((ctx) => {
248+
ctx.body = "fallthrough";
249+
});
250+
251+
const response = await request(app.callback()).get(
252+
"/counterfact/openapi",
253+
);
254+
255+
expect(response.text).toBe("fallthrough");
256+
});
257+
});
258+
259+
it("injects the correct baseUrl for each document", async () => {
260+
await usingTemporaryFiles(async ($) => {
261+
await $.add(
262+
"spec-a.yaml",
263+
"openapi: '3.0.0'\ninfo:\n title: Spec A\n version: '1.0.0'\npaths: {}\n",
264+
);
265+
await $.add(
266+
"spec-b.yaml",
267+
"openapi: '3.0.0'\ninfo:\n title: Spec B\n version: '1.0.0'\npaths: {}\n",
268+
);
269+
270+
const app = new Koa();
271+
272+
app.use(
273+
openapiMiddleware([
274+
{
275+
path: $.path("spec-a.yaml"),
276+
baseUrl: "//localhost:3100/a",
277+
id: "spec-a",
278+
},
279+
{
280+
path: $.path("spec-b.yaml"),
281+
baseUrl: "//localhost:3100/b",
282+
id: "spec-b",
283+
},
284+
]),
285+
);
286+
287+
const responseA = await request(app.callback()).get(
288+
"/counterfact/openapi/spec-a",
289+
);
290+
const docA = yaml.load(responseA.text) as {
291+
servers: { description: string; url: string }[];
292+
};
293+
expect(docA.servers[0]).toStrictEqual({
294+
description: "Counterfact",
295+
url: "//localhost:3100/a",
296+
});
297+
298+
const responseB = await request(app.callback()).get(
299+
"/counterfact/openapi/spec-b",
300+
);
301+
const docB = yaml.load(responseB.text) as {
302+
servers: { description: string; url: string }[];
303+
};
304+
expect(docB.servers[0]).toStrictEqual({
305+
description: "Counterfact",
306+
url: "//localhost:3100/b",
307+
});
308+
});
309+
});
310+
});
157311
});

0 commit comments

Comments
 (0)