Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/patchlogr-core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,6 @@
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./dist/index.cjs",
Comment thread
toothlessdev marked this conversation as resolved.
Comment thread
toothlessdev marked this conversation as resolved.
"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 numberDiff line numberDiff 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;
Comment thread
toothlessdev marked this conversation as resolved.
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 numberDiff line numberDiff 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;
Comment thread
toothlessdev marked this conversation as resolved.
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}",
);
});
Comment thread
toothlessdev marked this conversation as resolved.

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",
);
});
});
Comment thread
toothlessdev marked this conversation as resolved.
14 changes: 14 additions & 0 deletions packages/patchlogr-core/src/partition/partition.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
export type PartitionManifest = {
key: string;
hash: string;
};
Comment thread
toothlessdev marked this conversation as resolved.

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

export type PartitionedSpec = {
metadata: Record<string, unknown>;
partitions: Map<string, Partition[]>;
};
Comment thread
toothlessdev marked this conversation as resolved.
26 changes: 26 additions & 0 deletions packages/patchlogr-core/src/partition/partitionByMethod.ts
Original file line numberDiff line numberDiff 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";

Comment thread
toothlessdev marked this conversation as resolved.
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 });
Comment thread
toothlessdev marked this conversation as resolved.
});

return {
metadata: {
...spec.info,
...spec.security,
},
partitions,
};
}
28 changes: 28 additions & 0 deletions packages/patchlogr-core/src/partition/partitionByTag.ts
Original file line numberDiff line numberDiff 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";

Comment thread
toothlessdev marked this conversation as resolved.
export const DEFAULT_TAG = "__DEFAULT__";

Comment thread
toothlessdev marked this conversation as resolved.
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;
Comment thread
toothlessdev marked this conversation as resolved.
const hash = createSHA256Hash(stableStringify(operation));

Comment thread
toothlessdev marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import crypto from "crypto";

Comment thread
toothlessdev marked this conversation as resolved.
export function createSHA256Hash(data: string) {
return crypto.createHash("sha256").update(data).digest("hex");
}
Comment thread
toothlessdev marked this conversation as resolved.
Loading
Loading