Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/patchlogr-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -31,6 +30,7 @@
"@patchlogr/types": "workspace:^"
},
"devDependencies": {
"@types/node": "^25.0.9",
"esbuild": "^0.27.2",
"openapi-types": "^12.1.3",
"typescript": "^5.9.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { CanonicalSpec } from "@patchlogr/types";
import { describe, expect, test } from "vitest";
import { partitionByMethod } from "../partitionByMethod";

describe("partitionByMethod", () => {
test("should group by HTTPMethod", () => {
const spec: CanonicalSpec = {
operations: {
"GET /user": {
key: "GET /user",
doc: { tags: ["user"] },
method: "GET",
path: "/user",
request: { params: [] },
responses: {},
},
"GET /user/{userId}": {
key: "GET /user/{userId}",
doc: { tags: ["user"] },
method: "GET",
path: "/user/{userId}",
request: { params: [] },
responses: {},
},
},
};

const partitions = partitionByMethod(spec).partitions;
expect(partitions).toHaveLength(1);
expect(partitions.get("GET")).toHaveLength(2);
expect(partitions.get("GET")?.[0]?.operationKey).toBe("GET /user");
expect(partitions.get("GET")?.[1]?.operationKey).toBe(
"GET /user/{userId}",
);
});

test("should group by multiple HTTPMethods", () => {
const spec: CanonicalSpec = {
operations: {
"GET /user": {
key: "GET /user",
doc: { tags: ["user"] },
method: "GET",
path: "/user",
request: { params: [] },
responses: {},
},
"POST /auth/login": {
key: "POST /auth/login",
doc: { tags: ["auth"] },
method: "POST",
path: "/auth/login",
request: { params: [] },
responses: {},
},
},
};

const partitions = partitionByMethod(spec).partitions;

expect(partitions).toHaveLength(2);
expect(partitions.get("GET")).toHaveLength(1);
expect(partitions.get("POST")).toHaveLength(1);
expect(partitions.get("GET")?.[0]?.operationKey).toBe("GET /user");
expect(partitions.get("POST")?.[0]?.operationKey).toBe(
"POST /auth/login",
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { CanonicalSpec } from "@patchlogr/types";
import { describe, expect, test } from "vitest";
import { DEFAULT_TAG, partitionByTag } from "../partitionByTag";

describe("partitionByTag", () => {
test("should group by first tag", () => {
const spec: CanonicalSpec = {
operations: {
"GET /user": {
key: "GET /user",
doc: { tags: ["user"] },
method: "GET",
path: "/user",
request: { params: [] },
responses: {},
},
"GET /user/{userId}": {
key: "GET /user/{userId}",
doc: { tags: ["user"] },
method: "GET",
path: "/user/{userId}",
request: { params: [] },
responses: {},
},
},
};

const partitions = partitionByTag(spec).partitions;
expect(partitions).toHaveLength(1);
expect(partitions.get("user")).toHaveLength(2);
expect(partitions.get("user")?.[0]?.operationKey).toBe("GET /user");
expect(partitions.get("user")?.[1]?.operationKey).toBe(
"GET /user/{userId}",
);
});

test("should group by multiple tags", () => {
const spec: CanonicalSpec = {
operations: {
"GET /user": {
key: "GET /user",
doc: { tags: ["user"] },
method: "GET",
path: "/user",
request: { params: [] },
responses: {},
},
"POST /auth/login": {
key: "POST /auth/login",
doc: { tags: ["auth"] },
method: "POST",
path: "/auth/login",
request: { params: [] },
responses: {},
},
},
};

const partitions = partitionByTag(spec).partitions;

expect(partitions).toHaveLength(2);
expect(partitions.get("user")).toHaveLength(1);
expect(partitions.get("auth")).toHaveLength(1);
expect(partitions.get("user")?.[0]?.operationKey).toBe("GET /user");
expect(partitions.get("auth")?.[0]?.operationKey).toBe(
"POST /auth/login",
);
});

test("should group into default tag if tag not exists", () => {
const spec: CanonicalSpec = {
operations: {
"GET /user": {
key: "GET /user",
doc: { tags: [] },
method: "GET",
path: "/user",
request: { params: [] },
responses: {},
},
},
};

const partitions = partitionByTag(spec).partitions;

expect(partitions).toHaveLength(1);
expect(partitions.get(DEFAULT_TAG)).toHaveLength(1);
expect(partitions.get(DEFAULT_TAG)?.[0]?.operationKey).toBe(
"GET /user",
);
});
});
14 changes: 14 additions & 0 deletions packages/patchlogr-core/src/partition/partition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type PartitionManifest = {
key: string;
hash: string;
};

export type Partition = {
hash: string;
operationKey: string;
};

export type PartitionedSpec = {
metadata: Record<string, unknown>;
partitions: Map<string, Partition[]>;
};
26 changes: 26 additions & 0 deletions packages/patchlogr-core/src/partition/partitionByMethod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { CanonicalSpec, HTTPMethod } from "@patchlogr/types";
import type { Partition, PartitionedSpec } from "./partition";

import { createSHA256Hash } from "../utils/createHash";
import { stableStringify } from "../utils/stableStringify";

export function partitionByMethod(spec: CanonicalSpec): PartitionedSpec {
const partitions = new Map<HTTPMethod, Partition[]>();

Object.entries(spec.operations).forEach(([key, operation]) => {
const hash = createSHA256Hash(stableStringify(operation));

if (!partitions.has(operation.method))
partitions.set(operation.method, [{ hash, operationKey: key }]);
else
partitions.get(operation.method)?.push({ hash, operationKey: key });
});

return {
metadata: {
...spec.info,
...spec.security,
},
partitions,
};
}
28 changes: 28 additions & 0 deletions packages/patchlogr-core/src/partition/partitionByTag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { CanonicalSpec } from "@patchlogr/types";
import type { Partition, PartitionedSpec } from "./partition";

import { createSHA256Hash } from "../utils/createHash";
import { stableStringify } from "../utils/stableStringify";

export const DEFAULT_TAG = "__DEFAULT__";

export function partitionByTag(spec: CanonicalSpec): PartitionedSpec {
const partitions = new Map<string, Partition[]>();

Object.entries(spec.operations).forEach(([key, operation]) => {
const tag = operation.doc?.tags?.[0] || DEFAULT_TAG;
const hash = createSHA256Hash(stableStringify(operation));

if (!partitions.has(tag))
partitions.set(tag, [{ hash, operationKey: key }]);
else partitions.get(tag)?.push({ hash, operationKey: key });
});

return {
metadata: {
...spec.info,
...spec.security,
},
partitions,
};
}
11 changes: 11 additions & 0 deletions packages/patchlogr-core/src/utils/__tests__/createHash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, test, expect } from "vitest";
import { createSHA256Hash } from "../createHash";

describe("createHash", () => {
describe("createSHA256Hash", () => {
test("sha256 must be deterministic", () => {
const hash = createSHA256Hash("test");
expect(hash).toBe(createSHA256Hash("test"));
});
});
});
114 changes: 114 additions & 0 deletions packages/patchlogr-core/src/utils/__tests__/stableStringify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, test, expect } from "vitest";
import { stableStringify } from "../stableStringify";

describe("stableStringify", () => {
test("should stringify json", () => {
expect(stableStringify({ a: 1, b: 2, c: 3 })).toBe(
JSON.stringify({ a: 1, b: 2, c: 3 }),
);
});

test("should stringify json in a stable order", () => {
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };

expect(stableStringify(obj1)).toBe(stableStringify(obj2));
});

test("should stringify nested objects with stable key order", () => {
const obj1 = { a: 1, nested: { x: 10, y: 20 } };
const obj2 = { nested: { y: 20, x: 10 }, a: 1 };

expect(stableStringify(obj1)).toBe(stableStringify(obj2));
});

test("should stringify deeply nested objects with stable key order", () => {
const obj1 = {
level1: {
level2: {
c: 3,
b: 2,
a: 1,
},
},
};
const obj2 = {
level1: {
level2: {
a: 1,
b: 2,
c: 3,
},
},
};

expect(stableStringify(obj1)).toBe(stableStringify(obj2));
});

test("should stringify arrays containing objects with stable key order", () => {
const obj1 = {
items: [
{ z: 3, y: 2, x: 1 },
{ c: "c", b: "b", a: "a" },
],
};
const obj2 = {
items: [
{ x: 1, y: 2, z: 3 },
{ a: "a", b: "b", c: "c" },
],
};

expect(stableStringify(obj1)).toBe(stableStringify(obj2));
});

test("should handle null and primitive values correctly", () => {
const obj1 = { b: null, a: 1, c: "string", d: true };
const obj2 = { d: true, c: "string", a: 1, b: null };

expect(stableStringify(obj1)).toBe(stableStringify(obj2));
});

test("should produce deterministic output for canonical spec hashing", () => {
const spec1 = {
operationId: "getUser",
responses: {
"200": {
schema: {
type: "object",
properties: { name: {}, id: {} },
},
},
},
parameters: [{ name: "id", in: "path", required: true }],
};
const spec2 = {
parameters: [{ required: true, in: "path", name: "id" }],
responses: {
"200": {
schema: {
properties: { id: {}, name: {} },
type: "object",
},
},
},
operationId: "getUser",
};

expect(stableStringify(spec1)).toBe(stableStringify(spec2));
});

test("should output nested object keys in sorted order", () => {
const obj = { b: 2, a: { z: 1, y: 2 } };
const result = stableStringify(obj);

expect(result).toBe(JSON.stringify({ a: { y: 2, z: 1 }, b: 2 }));
});

test("should sort keys in arrays of objects", () => {
const obj = { items: [{ b: 1, a: 2 }] };
const result = stableStringify(obj);

expect(result).toBe(JSON.stringify({ items: [{ a: 2, b: 1 }] }));
});
});
5 changes: 5 additions & 0 deletions packages/patchlogr-core/src/utils/createHash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import crypto from "crypto";

export function createSHA256Hash(data: string) {
return crypto.createHash("sha256").update(data).digest("hex");
}
Loading
Loading