Skip to content

Commit c0dca4c

Browse files
authored
Merge pull request #14 from toothlessdev/11-partition-canonical-api-spec-output
Partition canonical api spec output
2 parents 476934f + dc6ab81 commit c0dca4c

11 files changed

Lines changed: 387 additions & 1 deletion

File tree

packages/patchlogr-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"publishConfig": {
55
"access": "public"
66
},
7-
"type": "module",
87
"main": "./dist/index.cjs",
98
"module": "./dist/index.js",
109
"types": "./dist/index.d.ts",
@@ -31,6 +30,7 @@
3130
"@patchlogr/types": "workspace:^"
3231
},
3332
"devDependencies": {
33+
"@types/node": "^25.0.9",
3434
"esbuild": "^0.27.2",
3535
"openapi-types": "^12.1.3",
3636
"typescript": "^5.9.3",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { CanonicalSpec } from "@patchlogr/types";
2+
import { describe, expect, test } from "vitest";
3+
import { partitionByMethod } from "../partitionByMethod";
4+
5+
describe("partitionByMethod", () => {
6+
test("should group by HTTPMethod", () => {
7+
const spec: CanonicalSpec = {
8+
operations: {
9+
"GET /user": {
10+
key: "GET /user",
11+
doc: { tags: ["user"] },
12+
method: "GET",
13+
path: "/user",
14+
request: { params: [] },
15+
responses: {},
16+
},
17+
"GET /user/{userId}": {
18+
key: "GET /user/{userId}",
19+
doc: { tags: ["user"] },
20+
method: "GET",
21+
path: "/user/{userId}",
22+
request: { params: [] },
23+
responses: {},
24+
},
25+
},
26+
};
27+
28+
const partitions = partitionByMethod(spec).partitions;
29+
expect(partitions).toHaveLength(1);
30+
expect(partitions.get("GET")).toHaveLength(2);
31+
expect(partitions.get("GET")?.[0]?.operationKey).toBe("GET /user");
32+
expect(partitions.get("GET")?.[1]?.operationKey).toBe(
33+
"GET /user/{userId}",
34+
);
35+
});
36+
37+
test("should group by multiple HTTPMethods", () => {
38+
const spec: CanonicalSpec = {
39+
operations: {
40+
"GET /user": {
41+
key: "GET /user",
42+
doc: { tags: ["user"] },
43+
method: "GET",
44+
path: "/user",
45+
request: { params: [] },
46+
responses: {},
47+
},
48+
"POST /auth/login": {
49+
key: "POST /auth/login",
50+
doc: { tags: ["auth"] },
51+
method: "POST",
52+
path: "/auth/login",
53+
request: { params: [] },
54+
responses: {},
55+
},
56+
},
57+
};
58+
59+
const partitions = partitionByMethod(spec).partitions;
60+
61+
expect(partitions).toHaveLength(2);
62+
expect(partitions.get("GET")).toHaveLength(1);
63+
expect(partitions.get("POST")).toHaveLength(1);
64+
expect(partitions.get("GET")?.[0]?.operationKey).toBe("GET /user");
65+
expect(partitions.get("POST")?.[0]?.operationKey).toBe(
66+
"POST /auth/login",
67+
);
68+
});
69+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { CanonicalSpec } from "@patchlogr/types";
2+
import { describe, expect, test } from "vitest";
3+
import { DEFAULT_TAG, partitionByTag } from "../partitionByTag";
4+
5+
describe("partitionByTag", () => {
6+
test("should group by first tag", () => {
7+
const spec: CanonicalSpec = {
8+
operations: {
9+
"GET /user": {
10+
key: "GET /user",
11+
doc: { tags: ["user"] },
12+
method: "GET",
13+
path: "/user",
14+
request: { params: [] },
15+
responses: {},
16+
},
17+
"GET /user/{userId}": {
18+
key: "GET /user/{userId}",
19+
doc: { tags: ["user"] },
20+
method: "GET",
21+
path: "/user/{userId}",
22+
request: { params: [] },
23+
responses: {},
24+
},
25+
},
26+
};
27+
28+
const partitions = partitionByTag(spec).partitions;
29+
expect(partitions).toHaveLength(1);
30+
expect(partitions.get("user")).toHaveLength(2);
31+
expect(partitions.get("user")?.[0]?.operationKey).toBe("GET /user");
32+
expect(partitions.get("user")?.[1]?.operationKey).toBe(
33+
"GET /user/{userId}",
34+
);
35+
});
36+
37+
test("should group by multiple tags", () => {
38+
const spec: CanonicalSpec = {
39+
operations: {
40+
"GET /user": {
41+
key: "GET /user",
42+
doc: { tags: ["user"] },
43+
method: "GET",
44+
path: "/user",
45+
request: { params: [] },
46+
responses: {},
47+
},
48+
"POST /auth/login": {
49+
key: "POST /auth/login",
50+
doc: { tags: ["auth"] },
51+
method: "POST",
52+
path: "/auth/login",
53+
request: { params: [] },
54+
responses: {},
55+
},
56+
},
57+
};
58+
59+
const partitions = partitionByTag(spec).partitions;
60+
61+
expect(partitions).toHaveLength(2);
62+
expect(partitions.get("user")).toHaveLength(1);
63+
expect(partitions.get("auth")).toHaveLength(1);
64+
expect(partitions.get("user")?.[0]?.operationKey).toBe("GET /user");
65+
expect(partitions.get("auth")?.[0]?.operationKey).toBe(
66+
"POST /auth/login",
67+
);
68+
});
69+
70+
test("should group into default tag if tag not exists", () => {
71+
const spec: CanonicalSpec = {
72+
operations: {
73+
"GET /user": {
74+
key: "GET /user",
75+
doc: { tags: [] },
76+
method: "GET",
77+
path: "/user",
78+
request: { params: [] },
79+
responses: {},
80+
},
81+
},
82+
};
83+
84+
const partitions = partitionByTag(spec).partitions;
85+
86+
expect(partitions).toHaveLength(1);
87+
expect(partitions.get(DEFAULT_TAG)).toHaveLength(1);
88+
expect(partitions.get(DEFAULT_TAG)?.[0]?.operationKey).toBe(
89+
"GET /user",
90+
);
91+
});
92+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type PartitionManifest = {
2+
key: string;
3+
hash: string;
4+
};
5+
6+
export type Partition = {
7+
hash: string;
8+
operationKey: string;
9+
};
10+
11+
export type PartitionedSpec = {
12+
metadata: Record<string, unknown>;
13+
partitions: Map<string, Partition[]>;
14+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { CanonicalSpec, HTTPMethod } from "@patchlogr/types";
2+
import type { Partition, PartitionedSpec } from "./partition";
3+
4+
import { createSHA256Hash } from "../utils/createHash";
5+
import { stableStringify } from "../utils/stableStringify";
6+
7+
export function partitionByMethod(spec: CanonicalSpec): PartitionedSpec {
8+
const partitions = new Map<HTTPMethod, Partition[]>();
9+
10+
Object.entries(spec.operations).forEach(([key, operation]) => {
11+
const hash = createSHA256Hash(stableStringify(operation));
12+
13+
if (!partitions.has(operation.method))
14+
partitions.set(operation.method, [{ hash, operationKey: key }]);
15+
else
16+
partitions.get(operation.method)?.push({ hash, operationKey: key });
17+
});
18+
19+
return {
20+
metadata: {
21+
...spec.info,
22+
...spec.security,
23+
},
24+
partitions,
25+
};
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { CanonicalSpec } from "@patchlogr/types";
2+
import type { Partition, PartitionedSpec } from "./partition";
3+
4+
import { createSHA256Hash } from "../utils/createHash";
5+
import { stableStringify } from "../utils/stableStringify";
6+
7+
export const DEFAULT_TAG = "__DEFAULT__";
8+
9+
export function partitionByTag(spec: CanonicalSpec): PartitionedSpec {
10+
const partitions = new Map<string, Partition[]>();
11+
12+
Object.entries(spec.operations).forEach(([key, operation]) => {
13+
const tag = operation.doc?.tags?.[0] || DEFAULT_TAG;
14+
const hash = createSHA256Hash(stableStringify(operation));
15+
16+
if (!partitions.has(tag))
17+
partitions.set(tag, [{ hash, operationKey: key }]);
18+
else partitions.get(tag)?.push({ hash, operationKey: key });
19+
});
20+
21+
return {
22+
metadata: {
23+
...spec.info,
24+
...spec.security,
25+
},
26+
partitions,
27+
};
28+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, test, expect } from "vitest";
2+
import { createSHA256Hash } from "../createHash";
3+
4+
describe("createHash", () => {
5+
describe("createSHA256Hash", () => {
6+
test("sha256 must be deterministic", () => {
7+
const hash = createSHA256Hash("test");
8+
expect(hash).toBe(createSHA256Hash("test"));
9+
});
10+
});
11+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, test, expect } from "vitest";
2+
import { stableStringify } from "../stableStringify";
3+
4+
describe("stableStringify", () => {
5+
test("should stringify json", () => {
6+
expect(stableStringify({ a: 1, b: 2, c: 3 })).toBe(
7+
JSON.stringify({ a: 1, b: 2, c: 3 }),
8+
);
9+
});
10+
11+
test("should stringify json in a stable order", () => {
12+
const obj1 = { a: 1, b: 2 };
13+
const obj2 = { b: 2, a: 1 };
14+
15+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
16+
});
17+
18+
test("should stringify nested objects with stable key order", () => {
19+
const obj1 = { a: 1, nested: { x: 10, y: 20 } };
20+
const obj2 = { nested: { y: 20, x: 10 }, a: 1 };
21+
22+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
23+
});
24+
25+
test("should stringify deeply nested objects with stable key order", () => {
26+
const obj1 = {
27+
level1: {
28+
level2: {
29+
c: 3,
30+
b: 2,
31+
a: 1,
32+
},
33+
},
34+
};
35+
const obj2 = {
36+
level1: {
37+
level2: {
38+
a: 1,
39+
b: 2,
40+
c: 3,
41+
},
42+
},
43+
};
44+
45+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
46+
});
47+
48+
test("should stringify arrays containing objects with stable key order", () => {
49+
const obj1 = {
50+
items: [
51+
{ z: 3, y: 2, x: 1 },
52+
{ c: "c", b: "b", a: "a" },
53+
],
54+
};
55+
const obj2 = {
56+
items: [
57+
{ x: 1, y: 2, z: 3 },
58+
{ a: "a", b: "b", c: "c" },
59+
],
60+
};
61+
62+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
63+
});
64+
65+
test("should handle null and primitive values correctly", () => {
66+
const obj1 = { b: null, a: 1, c: "string", d: true };
67+
const obj2 = { d: true, c: "string", a: 1, b: null };
68+
69+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
70+
});
71+
72+
test("should produce deterministic output for canonical spec hashing", () => {
73+
const spec1 = {
74+
operationId: "getUser",
75+
responses: {
76+
"200": {
77+
schema: {
78+
type: "object",
79+
properties: { name: {}, id: {} },
80+
},
81+
},
82+
},
83+
parameters: [{ name: "id", in: "path", required: true }],
84+
};
85+
const spec2 = {
86+
parameters: [{ required: true, in: "path", name: "id" }],
87+
responses: {
88+
"200": {
89+
schema: {
90+
properties: { id: {}, name: {} },
91+
type: "object",
92+
},
93+
},
94+
},
95+
operationId: "getUser",
96+
};
97+
98+
expect(stableStringify(spec1)).toBe(stableStringify(spec2));
99+
});
100+
101+
test("should output nested object keys in sorted order", () => {
102+
const obj = { b: 2, a: { z: 1, y: 2 } };
103+
const result = stableStringify(obj);
104+
105+
expect(result).toBe(JSON.stringify({ a: { y: 2, z: 1 }, b: 2 }));
106+
});
107+
108+
test("should sort keys in arrays of objects", () => {
109+
const obj = { items: [{ b: 1, a: 2 }] };
110+
const result = stableStringify(obj);
111+
112+
expect(result).toBe(JSON.stringify({ items: [{ a: 2, b: 1 }] }));
113+
});
114+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import crypto from "crypto";
2+
3+
export function createSHA256Hash(data: string) {
4+
return crypto.createHash("sha256").update(data).digest("hex");
5+
}

0 commit comments

Comments
 (0)