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",
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 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;
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 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;
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 number Diff line number Diff 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 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";

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 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";

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 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";

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