Skip to content

Commit ef9128d

Browse files
committed
add masking for reference schemas
1 parent ddd955f commit ef9128d

File tree

8 files changed

+168
-29
lines changed

8 files changed

+168
-29
lines changed

package-lock.json

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@omer-x/next-openapi-json-generator",
3-
"version": "0.1.4",
3+
"version": "0.2.0",
44
"description": "a Next.js plugin to generate OpenAPI documentation from route handlers",
55
"keywords": [
66
"next.js",
@@ -45,7 +45,7 @@
4545
},
4646
"devDependencies": {
4747
"@omer-x/eslint-config": "^1.0.7",
48-
"@omer-x/openapi-types": "^0.1.1",
48+
"@omer-x/openapi-types": "^0.1.2",
4949
"@types/node": "^20.14.2",
5050
"clean-webpack-plugin": "^4.0.0",
5151
"eslint": "^8.57.0",

src/core/mask.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import zodToJsonSchema from "zod-to-json-schema";
2+
import type { SchemaObject } from "@omer-x/openapi-types/schema";
3+
import type { ZodType } from "zod";
4+
5+
function deepEqual(a: unknown, b: unknown): boolean {
6+
if (typeof a !== typeof b) return false;
7+
switch (typeof a) {
8+
case "object": {
9+
if (a === null) return a === b;
10+
if (!b) return false;
11+
if (Array.isArray(a)) {
12+
if (!Array.isArray(b)) return false;
13+
return a.every((item, index) => deepEqual(item, b[index]));
14+
}
15+
return Object.entries(a).every(([key, value]) => deepEqual(value, (b as Record<string, unknown>)[key]));
16+
}
17+
case "function":
18+
case "symbol":
19+
return false;
20+
default:
21+
return a === b;
22+
}
23+
}
24+
25+
export default function maskWithReference(
26+
schema: SchemaObject,
27+
storedSchemas: Record<string, ZodType>,
28+
self: boolean
29+
): SchemaObject {
30+
if (self) {
31+
for (const [schemaName, zodSchema] of Object.entries(storedSchemas)) {
32+
if (deepEqual(schema, zodToJsonSchema(zodSchema))) {
33+
return {
34+
$ref: `#/components/schemas/${schemaName}`,
35+
};
36+
}
37+
}
38+
}
39+
if ("$ref" in schema) return schema;
40+
if (schema.oneOf) {
41+
return {
42+
...schema,
43+
oneOf: schema.oneOf.map(i => maskWithReference(i, storedSchemas, true)),
44+
};
45+
}
46+
if (schema.anyOf) {
47+
return {
48+
...schema,
49+
anyOf: schema.anyOf.map(i => maskWithReference(i, storedSchemas, true)),
50+
};
51+
}
52+
switch (schema.type) {
53+
case "object":
54+
return {
55+
...schema,
56+
properties: Object.entries(schema.properties).reduce((props, [propName, prop]) => ({
57+
...props,
58+
[propName]: maskWithReference(prop, storedSchemas, true),
59+
}), {}),
60+
};
61+
case "array":
62+
if (Array.isArray(schema.items)) {
63+
return {
64+
...schema,
65+
items: schema.items.map(i => maskWithReference(i, storedSchemas, true)),
66+
};
67+
}
68+
return {
69+
...schema,
70+
items: maskWithReference(schema.items, storedSchemas, true),
71+
};
72+
}
73+
return schema;
74+
}

src/core/next.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { directoryExists } from "./dir";
44
import { transpile } from "./transpile";
5+
import type { OperationObject } from "@omer-x/openapi-types/operation";
56

67
export async function findAppFolderPath() {
78
const inSrc = path.resolve(process.cwd(), "src", "app");
@@ -28,5 +29,5 @@ export async function getRouteExports(routePath: string, schemas: Record<string,
2829
(global as Record<string, unknown>).schemas = schemas;
2930
const result = eval(fixedCode);
3031
delete (global as Record<string, unknown>).schemas;
31-
return result as Record<string, { apiData?: unknown } | undefined>;
32+
return result as Record<string, { apiData?: OperationObject } | undefined>;
3233
}

src/core/operation-mask.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import maskWithReference from "./mask";
2+
import type { MediaTypeObject } from "@omer-x/openapi-types/media-type";
3+
import type { OperationObject } from "@omer-x/openapi-types/operation";
4+
import type { ParameterObject } from "@omer-x/openapi-types/parameter";
5+
import type { ReferenceObject } from "@omer-x/openapi-types/reference";
6+
import type { RequestBodyObject } from "@omer-x/openapi-types/request-body";
7+
import type { ResponseObject, ResponsesObject } from "@omer-x/openapi-types/response";
8+
import type { SchemaObject } from "@omer-x/openapi-types/schema";
9+
import type { ZodType } from "zod";
10+
11+
function maskSchema(storedSchemas: Record<string, ZodType>, schema?: SchemaObject) {
12+
if (!schema) return schema;
13+
return maskWithReference(schema, storedSchemas, true);
14+
}
15+
16+
function maskParameterSchema(param: ParameterObject | ReferenceObject, storedSchemas: Record<string, ZodType>) {
17+
if ("$ref" in param) return param;
18+
return { ...param, schema: maskSchema(storedSchemas, param.schema) } as ParameterObject;
19+
}
20+
21+
function maskContentSchema(storedSchemas: Record<string, ZodType>, bodyContent?: Record<string, MediaTypeObject>) {
22+
if (!bodyContent) return bodyContent;
23+
return Object.entries(bodyContent).reduce((collection, [contentType, content]) => ({
24+
...collection,
25+
[contentType]: {
26+
...content,
27+
schema: maskSchema(storedSchemas, content.schema),
28+
},
29+
}), {} as Record<string, MediaTypeObject>);
30+
}
31+
32+
function maskRequestBodySchema(storedSchemas: Record<string, ZodType>, body?: RequestBodyObject | ReferenceObject) {
33+
if (!body || "$ref" in body) return body;
34+
return { ...body, content: maskContentSchema(storedSchemas, body.content) } as RequestBodyObject;
35+
}
36+
37+
function maskResponseSchema(storedSchemas: Record<string, ZodType>, response: ResponseObject | ReferenceObject) {
38+
if ("$ref" in response) return response;
39+
return { ...response, content: maskContentSchema(storedSchemas, response.content) };
40+
}
41+
42+
function maskSchemasInResponses(storedSchemas: Record<string, ZodType>, responses?: ResponsesObject) {
43+
if (!responses) return responses;
44+
return Object.entries(responses).reduce((collection, [key, response]) => ({
45+
...collection,
46+
[key]: maskResponseSchema(storedSchemas, response),
47+
}), {} as ResponsesObject);
48+
}
49+
50+
export default function maskOperationSchemas(operation: OperationObject, storedSchemas: Record<string, ZodType>) {
51+
return {
52+
...operation,
53+
parameters: operation.parameters?.map(p => maskParameterSchema(p, storedSchemas)),
54+
requestBody: maskRequestBodySchema(storedSchemas, operation.requestBody),
55+
responses: maskSchemasInResponses(storedSchemas, operation.responses),
56+
} as OperationObject;
57+
}

src/core/route.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import maskOperationSchemas from "./operation-mask";
2+
import type { OperationObject } from "@omer-x/openapi-types/operation";
13
import type { PathsObject } from "@omer-x/openapi-types/paths";
4+
import type { ZodType } from "zod";
25

36
export type RouteRecord = {
47
method: string,
58
path: string,
6-
apiData: object,
9+
apiData: OperationObject,
710
};
811

912
function getRoutePathName(filePath: string, rootPath: string) {
@@ -14,23 +17,21 @@ function getRoutePathName(filePath: string, rootPath: string) {
1417
.replace("/route.ts", "");
1518
}
1619

17-
export function createRouteRecord(method: string, filePath: string, rootPath: string, apiData: unknown) {
20+
export function createRouteRecord(method: string, filePath: string, rootPath: string, apiData: OperationObject) {
1821
return {
1922
method: method.toLocaleLowerCase(),
2023
path: getRoutePathName(filePath, rootPath),
2124
apiData,
2225
} as RouteRecord;
2326
}
2427

25-
export function bundlePaths(source: RouteRecord[]) {
28+
export function bundlePaths(source: RouteRecord[], storedSchemas: Record<string, ZodType>) {
2629
source.sort((a, b) => a.path.localeCompare(b.path));
2730
return source.reduce((collection, route) => ({
2831
...collection,
2932
[route.path]: {
3033
...collection[route.path],
31-
[route.method]: {
32-
...route.apiData,
33-
},
34+
[route.method]: maskOperationSchemas(route.apiData, storedSchemas),
3435
},
3536
}), {} as PathsObject);
3637
}

src/core/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import zodToJsonSchema from "zod-to-json-schema";
2+
import maskWithReference from "./mask";
23
import type { SchemaObject } from "@omer-x/openapi-types/schema";
34
import type { ZodType } from "zod";
45

56
export function bundleSchemas(schemas: Record<string, ZodType>) {
6-
return Object.keys(schemas).reduce((collection, schemaName) => {
7+
const bundledSchemas = Object.keys(schemas).reduce((collection, schemaName) => {
78
return {
89
...collection,
910
[schemaName]: zodToJsonSchema(schemas[schemaName], {
1011
target: "openApi3",
1112
}),
1213
} as Record<string, SchemaObject>;
1314
}, {} as Record<string, SchemaObject>);
15+
16+
return Object.entries(bundledSchemas).reduce((bundle, [schemaName, schema]) => ({
17+
...bundle,
18+
[schemaName]: maskWithReference(schema, schemas, false),
19+
}), {} as Record<string, SchemaObject>);
1420
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default async function generateOpenApiSpec(schemas: Record<string, ZodTyp
3131
title: metadata.serviceName,
3232
version: metadata.version,
3333
},
34-
paths: bundlePaths(validRoutes),
34+
paths: bundlePaths(validRoutes, schemas),
3535
components: {
3636
schemas: bundleSchemas(schemas),
3737
},

0 commit comments

Comments
 (0)