diff --git a/packages/patchlogr-core/package.json b/packages/patchlogr-core/package.json index f3278a9..3e59ca5 100644 --- a/packages/patchlogr-core/package.json +++ b/packages/patchlogr-core/package.json @@ -4,7 +4,6 @@ "publishConfig": { "access": "public" }, - "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -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", diff --git a/packages/patchlogr-core/src/partition/__tests__/partitionByMethod.test.ts b/packages/patchlogr-core/src/partition/__tests__/partitionByMethod.test.ts new file mode 100644 index 0000000..294ebba --- /dev/null +++ b/packages/patchlogr-core/src/partition/__tests__/partitionByMethod.test.ts @@ -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", + ); + }); +}); diff --git a/packages/patchlogr-core/src/partition/__tests__/partitionByTag.test.ts b/packages/patchlogr-core/src/partition/__tests__/partitionByTag.test.ts new file mode 100644 index 0000000..d546346 --- /dev/null +++ b/packages/patchlogr-core/src/partition/__tests__/partitionByTag.test.ts @@ -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", + ); + }); +}); diff --git a/packages/patchlogr-core/src/partition/partition.ts b/packages/patchlogr-core/src/partition/partition.ts new file mode 100644 index 0000000..42b2b03 --- /dev/null +++ b/packages/patchlogr-core/src/partition/partition.ts @@ -0,0 +1,14 @@ +export type PartitionManifest = { + key: string; + hash: string; +}; + +export type Partition = { + hash: string; + operationKey: string; +}; + +export type PartitionedSpec = { + metadata: Record; + partitions: Map; +}; diff --git a/packages/patchlogr-core/src/partition/partitionByMethod.ts b/packages/patchlogr-core/src/partition/partitionByMethod.ts new file mode 100644 index 0000000..67524f6 --- /dev/null +++ b/packages/patchlogr-core/src/partition/partitionByMethod.ts @@ -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(); + + 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, + }; +} diff --git a/packages/patchlogr-core/src/partition/partitionByTag.ts b/packages/patchlogr-core/src/partition/partitionByTag.ts new file mode 100644 index 0000000..da5c6b4 --- /dev/null +++ b/packages/patchlogr-core/src/partition/partitionByTag.ts @@ -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(); + + 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, + }; +} diff --git a/packages/patchlogr-core/src/utils/__tests__/createHash.test.ts b/packages/patchlogr-core/src/utils/__tests__/createHash.test.ts new file mode 100644 index 0000000..de4fa42 --- /dev/null +++ b/packages/patchlogr-core/src/utils/__tests__/createHash.test.ts @@ -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")); + }); + }); +}); diff --git a/packages/patchlogr-core/src/utils/__tests__/stableStringify.test.ts b/packages/patchlogr-core/src/utils/__tests__/stableStringify.test.ts new file mode 100644 index 0000000..1395438 --- /dev/null +++ b/packages/patchlogr-core/src/utils/__tests__/stableStringify.test.ts @@ -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 }] })); + }); +}); diff --git a/packages/patchlogr-core/src/utils/createHash.ts b/packages/patchlogr-core/src/utils/createHash.ts new file mode 100644 index 0000000..ad7e27b --- /dev/null +++ b/packages/patchlogr-core/src/utils/createHash.ts @@ -0,0 +1,5 @@ +import crypto from "crypto"; + +export function createSHA256Hash(data: string) { + return crypto.createHash("sha256").update(data).digest("hex"); +} diff --git a/packages/patchlogr-core/src/utils/stableStringify.ts b/packages/patchlogr-core/src/utils/stableStringify.ts new file mode 100644 index 0000000..f9143b3 --- /dev/null +++ b/packages/patchlogr-core/src/utils/stableStringify.ts @@ -0,0 +1,26 @@ +export function stableStringify(obj: Record): string { + return JSON.stringify(sortObjectKeys(obj)); +} + +function sortObjectKeys(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + + if (typeof value === "object") { + const sortedObj: Record = {}; + const keys = Object.keys(value).sort(); + + for (const key of keys) { + sortedObj[key] = sortObjectKeys(value[key]); + } + + return sortedObj; + } + + return value; +} diff --git a/yarn.lock b/yarn.lock index 3f3c5a7..84eb7f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -424,6 +424,7 @@ __metadata: "@apidevtools/swagger-parser": "npm:^12.1.0" "@patchlogr/oas": "workspace:^" "@patchlogr/types": "workspace:^" + "@types/node": "npm:^25.0.9" esbuild: "npm:^0.27.2" openapi-types: "npm:^12.1.3" typescript: "npm:^5.9.3"