Initial Commit
This commit is contained in:
233
packages/api-stack/api/api-openapi/src/merger/merger.test.ts
Normal file
233
packages/api-stack/api/api-openapi/src/merger/merger.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { OpenAPIDocument } from "../types/openapi.js";
|
||||
import {
|
||||
extractOpenAPIDoc,
|
||||
filterPathsByTag,
|
||||
mergeOpenAPIDocuments,
|
||||
prefixPaths,
|
||||
removeInternalRoutes,
|
||||
} from "./merger.js";
|
||||
|
||||
const baseDoc = (): OpenAPIDocument => ({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths: { "/a": { get: { responses: { "200": { description: "ok" } } } } },
|
||||
tags: [{ name: "alpha" }],
|
||||
});
|
||||
|
||||
describe("mergeOpenAPIDocuments", () => {
|
||||
it("merge composes paths", { timeout: 5000 }, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
paths: {
|
||||
"/b": { post: { responses: {} } },
|
||||
},
|
||||
});
|
||||
expect(Object.keys(merged.paths).sort()).toEqual(["/a", "/b"]);
|
||||
});
|
||||
|
||||
it("colliding paths union their methods", { timeout: 5000 }, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
paths: { "/a": { post: { responses: {} } } },
|
||||
});
|
||||
expect(merged.paths["/a"]?.get).toBeDefined();
|
||||
expect(merged.paths["/a"]?.post).toBeDefined();
|
||||
});
|
||||
|
||||
it("colliding methods — last write wins", { timeout: 5000 }, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
paths: {
|
||||
"/a": {
|
||||
get: { responses: { "200": { description: "B" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const responses = merged.paths["/a"]?.get?.responses as Record<
|
||||
string,
|
||||
{ description: string }
|
||||
>;
|
||||
expect(responses["200"]?.description).toBe("B");
|
||||
});
|
||||
|
||||
it("dedups tags by name", { timeout: 5000 }, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
tags: [{ name: "alpha" }, { name: "beta" }],
|
||||
});
|
||||
expect(merged.tags?.map((t) => t.name).sort()).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("spread-merges components", { timeout: 5000 }, () => {
|
||||
const base = {
|
||||
...baseDoc(),
|
||||
components: { schemas: { User: { type: "object" } } },
|
||||
};
|
||||
const merged = mergeOpenAPIDocuments(base, {
|
||||
components: {
|
||||
schemas: { Project: { type: "object" } },
|
||||
securitySchemes: { Bearer: { type: "http" } },
|
||||
parameters: { idParam: {} },
|
||||
responses: { default: {} },
|
||||
},
|
||||
});
|
||||
expect(merged.components?.schemas).toMatchObject({
|
||||
User: { type: "object" },
|
||||
Project: { type: "object" },
|
||||
});
|
||||
expect(merged.components?.securitySchemes).toMatchObject({
|
||||
Bearer: { type: "http" },
|
||||
});
|
||||
expect(merged.components?.parameters?.["idParam"]).toBeDefined();
|
||||
expect(merged.components?.responses?.["default"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("concats servers from add-on docs", { timeout: 5000 }, () => {
|
||||
const base = { ...baseDoc(), servers: [{ url: "https://a" }] };
|
||||
const merged = mergeOpenAPIDocuments(base, {
|
||||
servers: [{ url: "https://b" }],
|
||||
});
|
||||
expect(merged.servers?.map((s) => s.url).sort()).toEqual([
|
||||
"https://a",
|
||||
"https://b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("works without components on base", { timeout: 5000 }, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
components: { schemas: { X: {} } },
|
||||
});
|
||||
expect(merged.components?.schemas?.["X"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("doc.components with only securitySchemes (no other sub-keys)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
components: { securitySchemes: { Bearer: { type: "http" } } },
|
||||
});
|
||||
expect(merged.components?.securitySchemes).toMatchObject({
|
||||
Bearer: { type: "http" },
|
||||
});
|
||||
expect(merged.components?.schemas).toEqual({});
|
||||
});
|
||||
|
||||
it("doc.servers merges into a base without servers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const merged = mergeOpenAPIDocuments(baseDoc(), {
|
||||
servers: [{ url: "https://only" }],
|
||||
});
|
||||
expect(merged.servers?.map((s) => s.url)).toEqual(["https://only"]);
|
||||
});
|
||||
|
||||
it("merging with no docs returns a clone with identical paths/info/tags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = baseDoc();
|
||||
const m = mergeOpenAPIDocuments(b);
|
||||
expect(m).not.toBe(b);
|
||||
expect(m.paths).toEqual(b.paths);
|
||||
expect(m.info).toEqual(b.info);
|
||||
expect(m.tags).toEqual(b.tags);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefixPaths", () => {
|
||||
it("prefix collapses double slashes", { timeout: 5000 }, () => {
|
||||
const out = prefixPaths(baseDoc(), "/api/");
|
||||
expect(Object.keys(out.paths)).toEqual(["/api/a"]);
|
||||
});
|
||||
|
||||
it("prefix without trailing slash", { timeout: 5000 }, () => {
|
||||
const out = prefixPaths(baseDoc(), "/v1");
|
||||
expect(Object.keys(out.paths)).toEqual(["/v1/a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPathsByTag", () => {
|
||||
it("keeps tag-intersecting ops", { timeout: 5000 }, () => {
|
||||
const doc: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths: {
|
||||
"/a": {
|
||||
get: { responses: {}, tags: ["alpha"] },
|
||||
post: { responses: {}, tags: ["beta"] },
|
||||
},
|
||||
"/b": { get: { responses: {}, tags: ["beta"] } },
|
||||
},
|
||||
tags: [{ name: "alpha" }, { name: "beta" }],
|
||||
};
|
||||
const out = filterPathsByTag(doc, ["alpha"]);
|
||||
expect(out.paths["/a"]?.get).toBeDefined();
|
||||
expect(out.paths["/a"]?.post).toBeUndefined();
|
||||
expect(out.paths["/b"]).toBeUndefined();
|
||||
expect(out.tags?.map((t) => t.name)).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("drops the tags array gracefully when absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths: { "/a": { get: { responses: {}, tags: ["x"] } } },
|
||||
};
|
||||
const out = filterPathsByTag(doc, ["x"]);
|
||||
expect(out.paths["/a"]).toBeDefined();
|
||||
expect(out.tags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeInternalRoutes", () => {
|
||||
it("drops x-internal: true", { timeout: 5000 }, () => {
|
||||
const doc: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths: {
|
||||
"/pub": { get: { responses: {} } },
|
||||
"/int": {
|
||||
get: {
|
||||
responses: {},
|
||||
["x-internal" as `x-${string}`]: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const out = removeInternalRoutes(doc);
|
||||
expect(out.paths["/pub"]).toBeDefined();
|
||||
expect(out.paths["/int"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps non-internal methods even when sibling is internal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths: {
|
||||
"/x": {
|
||||
get: { responses: {} },
|
||||
post: {
|
||||
responses: {},
|
||||
["x-internal" as `x-${string}`]: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const out = removeInternalRoutes(doc);
|
||||
expect(out.paths["/x"]?.get).toBeDefined();
|
||||
expect(out.paths["/x"]?.post).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOpenAPIDoc", () => {
|
||||
it("extracts a doc from a Hono app", { timeout: 5000 }, () => {
|
||||
const app = new OpenAPIHono();
|
||||
const doc = extractOpenAPIDoc(app, { title: "X", version: "1.0.0" });
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
expect(doc.info.title).toBe("X");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user