Skip to content

Commit

Permalink
refactor: better schema resolver
Browse files Browse the repository at this point in the history
fix: compute dependency graphs early & always use the deep one
fix: prop names with special characters
fix: schema names with special characters
  • Loading branch information
astahmer committed Nov 17, 2022
1 parent 0a76a5c commit ba71bd7
Show file tree
Hide file tree
Showing 19 changed files with 450 additions and 160 deletions.
6 changes: 3 additions & 3 deletions lib/src/CodeMeta.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ReferenceObject, SchemaObject } from "openapi3-ts";

import { isReferenceObject } from "./isReferenceObject";
import type { DocumentResolver } from "./makeSchemaResolver";
import { getSchemaComplexity } from "./schema-complexity";
import { getRefName } from "./utils";

export type ConversionTypeContext = {
getSchemaByRef: ($ref: string) => SchemaObject;
resolver: DocumentResolver;
zodSchemaByName: Record<string, string>;
schemaByName: Record<string, string>;
};
Expand Down Expand Up @@ -49,7 +49,7 @@ export class CodeMeta {
get codeString(): string {
if (this.code) return this.code;

return getRefName(this.ref!);
return this.ctx ? this.ctx.resolver.resolveRef(this.ref!).normalized : this.ref!;
}

get complexity(): number {
Expand Down
16 changes: 8 additions & 8 deletions lib/src/generateZodClientFromOpenAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,14 +1489,6 @@ test("with optional, partial, all required objects", async () => {
const VeryDeeplyNested = z.enum(["aaa", "bbb", "ccc"]);
const DeeplyNested = z.array(VeryDeeplyNested);
const Nested2: z.ZodType<Nested2> = z.lazy(() =>
z.object({
nested_prop: z.boolean().optional(),
deeplyNested: DeeplyNested.optional(),
circularToRoot: Root2.optional(),
requiredProp: z.string(),
})
);
const PartialObject = z
.object({ something: z.string(), another: z.number() })
.partial();
Expand All @@ -1509,6 +1501,14 @@ test("with optional, partial, all required objects", async () => {
optionalProp: z.string().optional(),
})
);
const Nested2: z.ZodType<Nested2> = z.lazy(() =>
z.object({
nested_prop: z.boolean().optional(),
deeplyNested: DeeplyNested.optional(),
circularToRoot: Root2.optional(),
requiredProp: z.string(),
})
);
const endpoints = makeApi([
{
Expand Down
3 changes: 2 additions & 1 deletion lib/src/getOpenApiDependencyGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { get } from "pastable/server";
import { expect, test } from "vitest";
import { getOpenApiDependencyGraph } from "./getOpenApiDependencyGraph";
import { topologicalSort } from "./topologicalSort";
import { asComponentSchema } from "./utils";

test("petstore.yaml", async () => {
const openApiDoc = (await SwaggerParser.parse("./tests/petstore.yaml")) as OpenAPIObject;
const getSchemaByRef = (ref: string) => get(openApiDoc, ref.replace("#/", "").replaceAll("/", ".")) as SchemaObject;
const { refsDependencyGraph: result, deepDependencyGraph } = getOpenApiDependencyGraph(
Object.keys(openApiDoc.components?.schemas || {}).map((name) => `#/components/schemas/${name}`),
Object.keys(openApiDoc.components?.schemas || {}).map((name) => asComponentSchema(name)),
getSchemaByRef
);
expect(result).toMatchInlineSnapshot(`
Expand Down
24 changes: 20 additions & 4 deletions lib/src/getZodiosEndpointDefinitionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,16 @@ test("getZodiosEndpointDefinitionList /store/order", () => {
"response": "Order",
},
],
"getSchemaByRef": [Function],
"issues": {
"ignoredFallbackResponse": [],
"ignoredGenericError": [],
},
"refsDependencyGraph": {},
"resolver": {
"getSchemaByRef": [Function],
"resolveRef": [Function],
"resolveSchemaName": [Function],
},
"schemaByName": {},
"zodSchemaByName": {
"Order": "z.object({ id: z.number().int(), petId: z.number().int(), quantity: z.number().int(), shipDate: z.string(), status: z.enum(["placed", "approved", "delivered"]), complete: z.boolean() }).partial()",
Expand Down Expand Up @@ -266,7 +270,6 @@ test("getZodiosEndpointDefinitionList /pet", () => {
"response": "Pet",
},
],
"getSchemaByRef": [Function],
"issues": {
"ignoredFallbackResponse": [],
"ignoredGenericError": [],
Expand All @@ -277,6 +280,11 @@ test("getZodiosEndpointDefinitionList /pet", () => {
"#/components/schemas/Tag",
},
},
"resolver": {
"getSchemaByRef": [Function],
"resolveRef": [Function],
"resolveSchemaName": [Function],
},
"schemaByName": {},
"zodSchemaByName": {
"Category": "z.object({ id: z.number().int(), name: z.string() }).partial()",
Expand Down Expand Up @@ -457,7 +465,6 @@ test("getZodiosEndpointDefinitionList /pet/findXXX", () => {
"response": "z.array(Pet)",
},
],
"getSchemaByRef": [Function],
"issues": {
"ignoredFallbackResponse": [],
"ignoredGenericError": [],
Expand All @@ -468,6 +475,11 @@ test("getZodiosEndpointDefinitionList /pet/findXXX", () => {
"#/components/schemas/Tag",
},
},
"resolver": {
"getSchemaByRef": [Function],
"resolveRef": [Function],
"resolveSchemaName": [Function],
},
"schemaByName": {},
"zodSchemaByName": {
"Category": "z.object({ id: z.number().int(), name: z.string() }).partial()",
Expand Down Expand Up @@ -941,7 +953,6 @@ test("petstore.yaml", async () => {
"response": "",
},
],
"getSchemaByRef": [Function],
"issues": {
"ignoredFallbackResponse": [
"createUsersWithListInput",
Expand All @@ -961,6 +972,11 @@ test("petstore.yaml", async () => {
"#/components/schemas/Tag",
},
},
"resolver": {
"getSchemaByRef": [Function],
"resolveRef": [Function],
"resolveSchemaName": [Function],
},
"schemaByName": {},
"zodSchemaByName": {
"ApiResponse": "z.object({ code: z.number().int(), type: z.string(), message: z.string() }).partial()",
Expand Down
31 changes: 12 additions & 19 deletions lib/src/getZodiosEndpointDefinitionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,27 @@ import type {
ResponseObject,
SchemaObject,
} from "openapi3-ts";
import { get } from "pastable/server";
import { match } from "ts-pattern";
import { sync } from "whence";

import type { CodeMeta, ConversionTypeContext } from "./CodeMeta";
import { getOpenApiDependencyGraph } from "./getOpenApiDependencyGraph";
import { isReferenceObject } from "./isReferenceObject";
import { makeSchemaResolver } from "./makeSchemaResolver";
import { getZodChain, getZodSchema } from "./openApiToZod";
import { getSchemaComplexity } from "./schema-complexity";
import type { TemplateContext } from "./template-context";
import { getRefFromName, normalizeString, pathToVariableName } from "./utils";
import { asComponentSchema, normalizeString, pathToVariableName } from "./utils";

const voidSchema = "z.void()";

// eslint-disable-next-line sonarjs/cognitive-complexity
export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: TemplateContext["options"]) => {
const getSchemaByRef: ConversionTypeContext["getSchemaByRef"] = (ref: string) => {
const correctRef = ref[1] === "/" ? ref : "#/" + ref;
const split = correctRef.split("/");
const path = split.slice(1, split.length - 1).join("/")!;
const map = get(doc, path.replace("#/", "").replace("#", "").replaceAll("/", ".")) ?? ({} as any);
const name = split[split.length - 1]!;

return map[name] as SchemaObject;
};
const resolver = makeSchemaResolver(doc);
const graphs = getOpenApiDependencyGraph(
Object.keys(doc.components?.schemas ?? {}).map((name) => asComponentSchema(name)),
resolver.getSchemaByRef
);

const endpoints = [];

Expand Down Expand Up @@ -60,7 +56,7 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te
: options.isMediaTypeAllowed;
}

const ctx: ConversionTypeContext = { getSchemaByRef, zodSchemaByName: {}, schemaByName: {} };
const ctx: ConversionTypeContext = { resolver, zodSchemaByName: {}, schemaByName: {} };
const complexityThreshold = options?.complexityThreshold ?? 4;
const getZodVarName = (input: CodeMeta, fallbackName?: string) => {
const result = input.toString();
Expand Down Expand Up @@ -101,7 +97,7 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te

// result is a reference to another schema
if (input.ref && ctx.zodSchemaByName[result]) {
const complexity = getSchemaComplexity({ current: 0, schema: getSchemaByRef(input.ref) });
const complexity = getSchemaComplexity({ current: 0, schema: ctx.resolver.getSchemaByRef(input.ref) });

// ref result is simple enough that it doesn't need to be assigned to a variable
if (complexity < complexityThreshold) {
Expand Down Expand Up @@ -185,7 +181,9 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te
}

for (const param of parameters) {
const paramItem = (isReferenceObject(param) ? getSchemaByRef(param.$ref) : param) as ParameterObject;
const paramItem = (
isReferenceObject(param) ? ctx.resolver.getSchemaByRef(param.$ref) : param
) as ParameterObject;
if (allowedPathInValues.includes(paramItem.in)) {
const paramSchema = (isReferenceObject(param) ? param.$ref : param.schema) as SchemaObject;
const paramCode = getZodSchema({
Expand Down Expand Up @@ -309,11 +307,6 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te
}
}

const graphs = getOpenApiDependencyGraph(
Object.keys(doc.components?.schemas ?? {}).map((name) => getRefFromName(name)),
ctx.getSchemaByRef
);

return {
...(ctx as Required<ConversionTypeContext>),
...graphs,
Expand Down
51 changes: 51 additions & 0 deletions lib/src/makeSchemaResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { OpenAPIObject, SchemaObject } from "openapi3-ts";
import { get } from "pastable/server";
import { normalizeString } from "./utils";

const autocorrectRef = (ref: string) => (ref[1] === "/" ? ref : "#/" + ref.slice(1));

type RefInfo = {
ref: string;
name: string;
normalized: string;
};

export const makeSchemaResolver = (doc: OpenAPIObject) => {
const nameByRef = new Map<string, string>();
const refByName = new Map<string, string>();

const byRef = new Map<string, RefInfo>();
const byNormalized = new Map<string, RefInfo>();

const getSchemaByRef = (ref: string) => {
// #components -> #/components
const correctRef = autocorrectRef(ref);
const split = correctRef.split("/");

// "#/components/schemas/Something.jsonld" -> #/components/schemas
const path = split.slice(1, split.length - 1).join("/")!;
const map = get(doc, path.replace("#/", "").replace("#", "").replaceAll("/", ".")) ?? ({} as any);

// "#/components/schemas/Something.jsonld" -> "Something.jsonld"
const name = split[split.length - 1]!;
const normalized = normalizeString(name);

nameByRef.set(correctRef, normalized);
refByName.set(normalized, correctRef);

const infos = { ref: correctRef, name, normalized };
byRef.set(infos.ref, infos);
byNormalized.set(infos.normalized, infos);

// doc.components.schemas["Something.jsonld"]
return map[name] as SchemaObject;
};

return {
getSchemaByRef,
resolveRef: (ref: string) => byRef.get(autocorrectRef(ref))!,
resolveSchemaName: (normalized: string) => byNormalized.get(normalized)!,
};
};

export type DocumentResolver = ReturnType<typeof makeSchemaResolver>;
17 changes: 12 additions & 5 deletions lib/src/openApiToTypescript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { getTypescriptFromOpenApi, TsConversionContext } from "./openApiToTypesc
import type { SchemaObject, SchemasObject } from "openapi3-ts";
import { ts } from "tanu";
import { describe, expect, test } from "vitest";
import { makeSchemaResolver } from "./makeSchemaResolver";
import { asComponentSchema } from "./utils";

const makeSchema = (schema: SchemaObject) => schema;
const getSchemaAsTsString = (schema: SchemaObject, meta?: { name: string }) =>
Expand Down Expand Up @@ -297,8 +299,9 @@ describe("getSchemaAsTsString with context", () => {
const ctx: TsConversionContext = {
nodeByRef: {},
visitedsRefs: {},
getSchemaByRef: (ref) => schemas[ref.split("/").at(-1)!]!,
resolver: makeSchemaResolver({ components: { schemas } } as any),
};
Object.keys(schemas).forEach((key) => ctx.resolver.getSchemaByRef(asComponentSchema(key)));
expect(printTs(getTypescriptFromOpenApi({ schema: schemas["Root"]!, meta: { name: "Root" }, ctx }) as ts.Node))
.toMatchInlineSnapshot(`
"export type Root = Partial<{
Expand Down Expand Up @@ -339,8 +342,9 @@ describe("getSchemaAsTsString with context", () => {
const ctx: TsConversionContext = {
nodeByRef: {},
visitedsRefs: {},
getSchemaByRef: (ref) => schemas[ref.split("/").at(-1)!]!,
resolver: makeSchemaResolver({ components: { schemas } } as any),
};
Object.keys(schemas).forEach((key) => ctx.resolver.getSchemaByRef(asComponentSchema(key)));
expect(
printTs(getTypescriptFromOpenApi({ schema: schemas["Root2"]!, meta: { name: "Root2" }, ctx }) as ts.Node)
).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -375,8 +379,9 @@ describe("getSchemaAsTsString with context", () => {
const ctx: TsConversionContext = {
nodeByRef: {},
visitedsRefs: {},
getSchemaByRef: (ref) => schemas[ref.split("/").at(-1)!]!,
resolver: makeSchemaResolver({ components: { schemas } } as any),
};
Object.keys(schemas).forEach((key) => ctx.resolver.getSchemaByRef(asComponentSchema(key)));

expect(
printTs(
Expand Down Expand Up @@ -420,8 +425,9 @@ describe("getSchemaAsTsString with context", () => {
const ctx: TsConversionContext = {
nodeByRef: {},
visitedsRefs: {},
getSchemaByRef: (ref) => schemas[ref.split("/").at(-1)!]!,
resolver: makeSchemaResolver({ components: { schemas } } as any),
};
Object.keys(schemas).forEach((key) => ctx.resolver.getSchemaByRef(asComponentSchema(key)));
const result = getTypescriptFromOpenApi({
schema: schemas["Root4"]!,
meta: { name: "Root4", $ref: "#/components/schemas/Root4" },
Expand Down Expand Up @@ -468,8 +474,9 @@ describe("getSchemaAsTsString with context", () => {
const ctx: TsConversionContext = {
nodeByRef: {},
visitedsRefs: {},
getSchemaByRef: (ref) => schemas[ref.split("/").at(-1)!]!,
resolver: makeSchemaResolver({ components: { schemas } } as any),
};
Object.keys(schemas).forEach((key) => ctx.resolver.getSchemaByRef(asComponentSchema(key)));
const result = getTypescriptFromOpenApi({
schema: schemas["Root"]!,
meta: { name: "Root", $ref: "#/components/schemas/Root" },
Expand Down
Loading

0 comments on commit ba71bd7

Please sign in to comment.