- Notifications
You must be signed in to change notification settings - Fork 0
Partition canonical api spec output#14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
17d996859e052284b7030d9d89480ab49585c08992ff37785f3bffb1dc6ab81File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -4,7 +4,6 @@ | ||
| "publishConfig": { | ||
| "access": "public" | ||
| }, | ||
| "type": "module", | ||
| "main": "./dist/index.cjs", | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| "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", | ||
| 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; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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}", | ||
| ); | ||
| }); | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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", | ||
| ); | ||
| }); | ||
| }); | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export type PartitionManifest = { | ||
| key: string; | ||
| hash: string; | ||
| }; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| export type Partition = { | ||
| hash: string; | ||
| operationKey: string; | ||
| }; | ||
| export type PartitionedSpec = { | ||
| metadata: Record<string, unknown>; | ||
| partitions: Map<string, Partition[]>; | ||
| }; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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"; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 }); | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }); | ||
| return { | ||
| metadata: { | ||
| ...spec.info, | ||
| ...spec.security, | ||
| }, | ||
| partitions, | ||
| }; | ||
| } | ||
| 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"; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| export const DEFAULT_TAG = "__DEFAULT__"; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const hash = createSHA256Hash(stableStringify(operation)); | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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, | ||
| }; | ||
| } | ||
| 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")); | ||
| }); | ||
| }); | ||
| }); |
| 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 }] })); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import crypto from "crypto"; | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| export function createSHA256Hash(data: string) { | ||
| return crypto.createHash("sha256").update(data).digest("hex"); | ||
| } | ||
toothlessdev marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.