Skip to content

Commit

Permalink
add masking for reference schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
omermecitoglu committed Jun 14, 2024
1 parent ddd955f commit ef9128d
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 29 deletions.
36 changes: 18 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@omer-x/next-openapi-json-generator",
"version": "0.1.4",
"version": "0.2.0",
"description": "a Next.js plugin to generate OpenAPI documentation from route handlers",
"keywords": [
"next.js",
Expand Down Expand Up @@ -45,7 +45,7 @@
},
"devDependencies": {
"@omer-x/eslint-config": "^1.0.7",
"@omer-x/openapi-types": "^0.1.1",
"@omer-x/openapi-types": "^0.1.2",
"@types/node": "^20.14.2",
"clean-webpack-plugin": "^4.0.0",
"eslint": "^8.57.0",
Expand Down
74 changes: 74 additions & 0 deletions src/core/mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import zodToJsonSchema from "zod-to-json-schema";
import type { SchemaObject } from "@omer-x/openapi-types/schema";
import type { ZodType } from "zod";

function deepEqual(a: unknown, b: unknown): boolean {
if (typeof a !== typeof b) return false;
switch (typeof a) {
case "object": {
if (a === null) return a === b;
if (!b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b)) return false;
return a.every((item, index) => deepEqual(item, b[index]));
}
return Object.entries(a).every(([key, value]) => deepEqual(value, (b as Record<string, unknown>)[key]));
}
case "function":
case "symbol":
return false;
default:
return a === b;
}
}

export default function maskWithReference(
schema: SchemaObject,
storedSchemas: Record<string, ZodType>,
self: boolean
): SchemaObject {
if (self) {
for (const [schemaName, zodSchema] of Object.entries(storedSchemas)) {
if (deepEqual(schema, zodToJsonSchema(zodSchema))) {
return {
$ref: `#/components/schemas/${schemaName}`,
};
}
}
}
if ("$ref" in schema) return schema;
if (schema.oneOf) {
return {
...schema,
oneOf: schema.oneOf.map(i => maskWithReference(i, storedSchemas, true)),
};
}
if (schema.anyOf) {
return {
...schema,
anyOf: schema.anyOf.map(i => maskWithReference(i, storedSchemas, true)),
};
}
switch (schema.type) {
case "object":
return {
...schema,
properties: Object.entries(schema.properties).reduce((props, [propName, prop]) => ({
...props,
[propName]: maskWithReference(prop, storedSchemas, true),
}), {}),
};
case "array":
if (Array.isArray(schema.items)) {
return {
...schema,
items: schema.items.map(i => maskWithReference(i, storedSchemas, true)),
};
}
return {
...schema,
items: maskWithReference(schema.items, storedSchemas, true),
};
}
return schema;
}
3 changes: 2 additions & 1 deletion src/core/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { directoryExists } from "./dir";
import { transpile } from "./transpile";
import type { OperationObject } from "@omer-x/openapi-types/operation";

export async function findAppFolderPath() {
const inSrc = path.resolve(process.cwd(), "src", "app");
Expand All @@ -28,5 +29,5 @@ export async function getRouteExports(routePath: string, schemas: Record<string,
(global as Record<string, unknown>).schemas = schemas;
const result = eval(fixedCode);
delete (global as Record<string, unknown>).schemas;
return result as Record<string, { apiData?: unknown } | undefined>;
return result as Record<string, { apiData?: OperationObject } | undefined>;
}
57 changes: 57 additions & 0 deletions src/core/operation-mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import maskWithReference from "./mask";
import type { MediaTypeObject } from "@omer-x/openapi-types/media-type";
import type { OperationObject } from "@omer-x/openapi-types/operation";
import type { ParameterObject } from "@omer-x/openapi-types/parameter";
import type { ReferenceObject } from "@omer-x/openapi-types/reference";
import type { RequestBodyObject } from "@omer-x/openapi-types/request-body";
import type { ResponseObject, ResponsesObject } from "@omer-x/openapi-types/response";
import type { SchemaObject } from "@omer-x/openapi-types/schema";
import type { ZodType } from "zod";

function maskSchema(storedSchemas: Record<string, ZodType>, schema?: SchemaObject) {
if (!schema) return schema;
return maskWithReference(schema, storedSchemas, true);
}

function maskParameterSchema(param: ParameterObject | ReferenceObject, storedSchemas: Record<string, ZodType>) {
if ("$ref" in param) return param;
return { ...param, schema: maskSchema(storedSchemas, param.schema) } as ParameterObject;
}

function maskContentSchema(storedSchemas: Record<string, ZodType>, bodyContent?: Record<string, MediaTypeObject>) {
if (!bodyContent) return bodyContent;
return Object.entries(bodyContent).reduce((collection, [contentType, content]) => ({
...collection,
[contentType]: {
...content,
schema: maskSchema(storedSchemas, content.schema),
},
}), {} as Record<string, MediaTypeObject>);
}

function maskRequestBodySchema(storedSchemas: Record<string, ZodType>, body?: RequestBodyObject | ReferenceObject) {
if (!body || "$ref" in body) return body;
return { ...body, content: maskContentSchema(storedSchemas, body.content) } as RequestBodyObject;
}

function maskResponseSchema(storedSchemas: Record<string, ZodType>, response: ResponseObject | ReferenceObject) {
if ("$ref" in response) return response;
return { ...response, content: maskContentSchema(storedSchemas, response.content) };
}

function maskSchemasInResponses(storedSchemas: Record<string, ZodType>, responses?: ResponsesObject) {
if (!responses) return responses;
return Object.entries(responses).reduce((collection, [key, response]) => ({
...collection,
[key]: maskResponseSchema(storedSchemas, response),
}), {} as ResponsesObject);
}

export default function maskOperationSchemas(operation: OperationObject, storedSchemas: Record<string, ZodType>) {
return {
...operation,
parameters: operation.parameters?.map(p => maskParameterSchema(p, storedSchemas)),
requestBody: maskRequestBodySchema(storedSchemas, operation.requestBody),
responses: maskSchemasInResponses(storedSchemas, operation.responses),
} as OperationObject;
}
13 changes: 7 additions & 6 deletions src/core/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import maskOperationSchemas from "./operation-mask";
import type { OperationObject } from "@omer-x/openapi-types/operation";
import type { PathsObject } from "@omer-x/openapi-types/paths";
import type { ZodType } from "zod";

export type RouteRecord = {
method: string,
path: string,
apiData: object,
apiData: OperationObject,
};

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

export function createRouteRecord(method: string, filePath: string, rootPath: string, apiData: unknown) {
export function createRouteRecord(method: string, filePath: string, rootPath: string, apiData: OperationObject) {
return {
method: method.toLocaleLowerCase(),
path: getRoutePathName(filePath, rootPath),
apiData,
} as RouteRecord;
}

export function bundlePaths(source: RouteRecord[]) {
export function bundlePaths(source: RouteRecord[], storedSchemas: Record<string, ZodType>) {
source.sort((a, b) => a.path.localeCompare(b.path));
return source.reduce((collection, route) => ({
...collection,
[route.path]: {
...collection[route.path],
[route.method]: {
...route.apiData,
},
[route.method]: maskOperationSchemas(route.apiData, storedSchemas),
},
}), {} as PathsObject);
}
8 changes: 7 additions & 1 deletion src/core/schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import zodToJsonSchema from "zod-to-json-schema";
import maskWithReference from "./mask";
import type { SchemaObject } from "@omer-x/openapi-types/schema";
import type { ZodType } from "zod";

export function bundleSchemas(schemas: Record<string, ZodType>) {
return Object.keys(schemas).reduce((collection, schemaName) => {
const bundledSchemas = Object.keys(schemas).reduce((collection, schemaName) => {
return {
...collection,
[schemaName]: zodToJsonSchema(schemas[schemaName], {
target: "openApi3",
}),
} as Record<string, SchemaObject>;
}, {} as Record<string, SchemaObject>);

return Object.entries(bundledSchemas).reduce((bundle, [schemaName, schema]) => ({
...bundle,
[schemaName]: maskWithReference(schema, schemas, false),
}), {} as Record<string, SchemaObject>);
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default async function generateOpenApiSpec(schemas: Record<string, ZodTyp
title: metadata.serviceName,
version: metadata.version,
},
paths: bundlePaths(validRoutes),
paths: bundlePaths(validRoutes, schemas),
components: {
schemas: bundleSchemas(schemas),
},
Expand Down

0 comments on commit ef9128d

Please sign in to comment.