Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/true-pears-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"typed-openapi": patch
---

Allow transforming schema & endpoint names; automatically prevents generating reserved TS/JS keyords names

Fix https://github.com/astahmer/typed-openapi/issues/90
3 changes: 2 additions & 1 deletion packages/typed-openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"module": "dist/index.js",
"exports": {
".": "./dist/index.js",
"./node": "./dist/node.export.js"
"./node": "./dist/node.export.js",
"./pretty": "./dist/pretty.export.js"
},
"bin": {
"typed-openapi": "bin.js"
Expand Down
10 changes: 8 additions & 2 deletions packages/typed-openapi/src/generate-client-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { allowedRuntimes, generateFile } from "./generator.ts";
import { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts";
import { generateTanstackQueryFile } from "./tanstack-query.generator.ts";
import { prettify } from "./format.ts";
import type { NameTransformOptions } from "./types.ts";

const cwd = process.cwd();
const now = new Date();
Expand All @@ -26,17 +27,22 @@ export const optionsSchema = type({
schemasOnly: "boolean",
});

export async function generateClientFiles(input: string, options: typeof optionsSchema.infer) {
type GenerateClientFilesOptions = typeof optionsSchema.infer & {
nameTransform?: NameTransformOptions;
};

export async function generateClientFiles(input: string, options: GenerateClientFilesOptions) {
const openApiDoc = (await SwaggerParser.bundle(input)) as OpenAPIObject;

const ctx = mapOpenApiEndpoints(openApiDoc);
const ctx = mapOpenApiEndpoints(openApiDoc, options);
console.log(`Found ${ctx.endpointList.length} endpoints`);

const content = await prettify(
generateFile({
...ctx,
runtime: options.runtime,
schemasOnly: options.schemasOnly,
nameTransform: options.nameTransform,
}),
);
const outputPath = join(
Expand Down
2 changes: 2 additions & 0 deletions packages/typed-openapi/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import * as Codegen from "@sinclair/typebox-codegen";
import { match } from "ts-pattern";
import { type } from "arktype";
import { wrapWithQuotesIfNeeded } from "./string-utils.ts";
import type { NameTransformOptions } from "./types.ts";

type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints> & {
runtime?: "none" | keyof typeof runtimeValidationGenerator;
schemasOnly?: boolean;
nameTransform?: NameTransformOptions | undefined;
};
type GeneratorContext = Required<GeneratorOptions>;

Expand Down
21 changes: 15 additions & 6 deletions packages/typed-openapi/src/map-openapi-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { createRefResolver } from "./ref-resolver.ts";
import { tsFactory } from "./ts-factory.ts";
import { AnyBox, BoxRef, OpenapiSchemaConvertContext } from "./types.ts";
import { pathToVariableName } from "./string-utils.ts";
import { NameTransformOptions } from "./types.ts";
import { match, P } from "ts-pattern";
import { sanitizeName } from "./sanitize-name.ts";

const factory = tsFactory;

export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
export const mapOpenApiEndpoints = (doc: OpenAPIObject, options?: { nameTransform?: NameTransformOptions }) => {
const refs = createRefResolver(doc, factory);
const ctx: OpenapiSchemaConvertContext = { refs, factory };
const endpointList = [] as Array<Endpoint>;
Expand All @@ -22,14 +24,18 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
Object.entries(pathItem).forEach(([method, operation]) => {
if (operation.deprecated) return;

let alias = getAlias({ path, method, operation } as Endpoint);
if (options?.nameTransform?.transformEndpointName) {
alias = options.nameTransform.transformEndpointName({ alias, path, method: method as Method, operation });
}
const endpoint = {
operation,
method: method as Method,
path,
requestFormat: "json",
response: openApiSchemaToTs({ schema: {}, ctx }),
meta: {
alias: getAlias({ path, method, operation } as Endpoint),
alias,
areParametersRequired: false,
hasParameters: false,
},
Expand Down Expand Up @@ -84,7 +90,7 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {

if (matchingMediaType && content[matchingMediaType]) {
params.body = openApiSchemaToTs({
schema: content[matchingMediaType]?.schema ?? {} ?? {},
schema: content[matchingMediaType]?.schema ?? {},
ctx,
});
}
Expand Down Expand Up @@ -139,7 +145,7 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
const matchingMediaType = Object.keys(content).find(isResponseMediaType);
if (matchingMediaType && content[matchingMediaType]) {
endpoint.response = openApiSchemaToTs({
schema: content[matchingMediaType]?.schema ?? {} ?? {},
schema: content[matchingMediaType]?.schema ?? {},
ctx,
});
}
Expand Down Expand Up @@ -180,10 +186,13 @@ const isAllowedParamMediaTypes = (

const isResponseMediaType = (mediaType: string) => mediaType === "application/json";
const getAlias = ({ path, method, operation }: Endpoint) =>
(method + "_" + capitalize(operation.operationId ?? pathToVariableName(path))).replace(/-/g, "__");
sanitizeName(
(method + "_" + capitalize(operation.operationId ?? pathToVariableName(path))).replace(/-/g, "__"),
"endpoint",
);

type MutationMethod = "post" | "put" | "patch" | "delete";
type Method = "get" | "head" | "options" | MutationMethod;
export type Method = "get" | "head" | "options" | MutationMethod;

export type EndpointParameters = {
body?: Box<BoxRef>;
Expand Down
1 change: 0 additions & 1 deletion packages/typed-openapi/src/node.export.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export { prettify } from "./format.ts";
export { generateClientFiles } from "./generate-client-files.ts";
1 change: 1 addition & 0 deletions packages/typed-openapi/src/pretty.export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { prettify } from "./format.ts";
14 changes: 12 additions & 2 deletions packages/typed-openapi/src/ref-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { Box } from "./box.ts";
import { isReferenceObject } from "./is-reference-object.ts";
import { openApiSchemaToTs } from "./openapi-schema-to-ts.ts";
import { normalizeString } from "./string-utils.ts";
import { NameTransformOptions } from "./types.ts";
import { AnyBoxDef, GenericFactory, type LibSchemaObject } from "./types.ts";
import { topologicalSort } from "./topological-sort.ts";
import { sanitizeName } from "./sanitize-name.ts";

const autocorrectRef = (ref: string) => (ref[1] === "/" ? ref : "#/" + ref.slice(1));
const componentsWithSchemas = ["schemas", "responses", "parameters", "requestBodies", "headers"];
Expand All @@ -26,7 +28,11 @@ export type RefInfo = {
kind: "schemas" | "responses" | "parameters" | "requestBodies" | "headers";
};

export const createRefResolver = (doc: OpenAPIObject, factory: GenericFactory) => {
export const createRefResolver = (
doc: OpenAPIObject,
factory: GenericFactory,
nameTransform?: NameTransformOptions,
) => {
// both used for debugging purpose
const nameByRef = new Map<string, string>();
const refByName = new Map<string, string>();
Expand All @@ -48,7 +54,11 @@ export const createRefResolver = (doc: OpenAPIObject, factory: GenericFactory) =

// "#/components/schemas/Something.jsonld" -> "Something.jsonld"
const name = split[split.length - 1]!;
const normalized = normalizeString(name);
let normalized = normalizeString(name);
if (nameTransform?.transformSchemaName) {
normalized = nameTransform.transformSchemaName(normalized);
}
normalized = sanitizeName(normalized, "schema");

nameByRef.set(correctRef, normalized);
refByName.set(normalized, correctRef);
Expand Down
79 changes: 79 additions & 0 deletions packages/typed-openapi/src/sanitize-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const reservedWords = new Set([
// TS keywords and built-ins
"import",
"package",
"namespace",
"Record",
"Partial",
"Required",
"Readonly",
"Pick",
"Omit",
"String",
"Number",
"Boolean",
"Object",
"Array",
"Function",
"any",
"unknown",
"never",
"void",
"extends",
"super",
"class",
"interface",
"type",
"enum",
"const",
"let",
"var",
"if",
"else",
"for",
"while",
"do",
"switch",
"case",
"default",
"break",
"continue",
"return",
"try",
"catch",
"finally",
"throw",
"new",
"delete",
"in",
"instanceof",
"typeof",
"void",
"with",
"yield",
"await",
"static",
"public",
"private",
"protected",
"abstract",
"as",
"asserts",
"from",
"get",
"set",
"module",
"require",
"keyof",
"readonly",
"global",
"symbol",
"bigint",
]);

export function sanitizeName(name: string, type: "schema" | "endpoint") {
let n = name.replace(/[\W/]+/g, "_");
if (/^\d/.test(n)) n = "_" + n;
if (reservedWords.has(n)) n = (type === "schema" ? "Schema_" : "Endpoint_") + n;
return n;
}
15 changes: 14 additions & 1 deletion packages/typed-openapi/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ReferenceObject, SchemaObject } from "openapi3-ts/oas31";
import type { OperationObject, ReferenceObject, SchemaObject } from "openapi3-ts/oas31";
import type { SchemaObject as SchemaObject3 } from "openapi3-ts/oas30";

import type { RefResolver } from "./ref-resolver.ts";
import { Box } from "./box.ts";
import type { Method } from "./map-openapi-endpoints.ts";

export type LibSchemaObject = SchemaObject & SchemaObject3;

Expand Down Expand Up @@ -94,10 +95,22 @@ export type FactoryCreator = (
schema: SchemaObject | ReferenceObject,
ctx: OpenapiSchemaConvertContext,
) => GenericFactory;

export type NameTransformOptions = {
transformSchemaName?: (name: string) => string;
transformEndpointName?: (endpoint: {
alias: string;
operation: OperationObject;
method: Method;
path: string;
}) => string;
};

export type OpenapiSchemaConvertContext = {
factory: FactoryCreator | GenericFactory;
refs: RefResolver;
onBox?: (box: Box<AnyBoxDef>) => Box<AnyBoxDef>;
nameTransform?: NameTransformOptions;
};

export type StringOrBox = string | Box<AnyBoxDef>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mapOpenApiEndpoints } from "../src/map-openapi-endpoints.ts";
import { describe, it, expect } from "vitest";

const openApiDoc = {
openapi: "3.0.0",
info: { title: "Test", version: "1.0.0" },
paths: {
"/foo/bar": {
get: { operationId: "import", responses: { 200: { description: "ok" } } },
post: { operationId: "Record", responses: { 200: { description: "ok" } } },
},
"/user/profile": {
get: { operationId: "user/profile", responses: { 200: { description: "ok" } } },
},
"/import": {
get: { operationId: "import", responses: { 200: { description: "ok" } } },
},
"/set": {
get: { operationId: "set", responses: { 200: { description: "ok" } } },
},
"/normal": {
get: { operationId: "normal", responses: { 200: { description: "ok" } } },
},
},
} as any;

describe("mapOpenApiEndpoints with NameTransformOptions for endpoints", () => {
it("avoids reserved words and invalid chars", () => {
const ctx = mapOpenApiEndpoints(openApiDoc);
const aliases = ctx.endpointList.map((e) => e.meta.alias);
expect(aliases).toMatchInlineSnapshot(`
[
"get_Import",
"post_Record",
"get_User_profile",
"get_Import",
"get_Set",
"get_Normal",
]
`);
});

it("applies transformEndpointName and avoids reserved words", () => {
const ctx = mapOpenApiEndpoints(openApiDoc, {
nameTransform: { transformEndpointName: (endpoint) => `E_${endpoint.alias}_E` },
});
const aliases = ctx.endpointList.map((e) => e.meta.alias);
expect(aliases).toMatchInlineSnapshot(`
[
"E_get_Import_E",
"E_post_Record_E",
"E_get_User_profile_E",
"E_get_Import_E",
"E_get_Set_E",
"E_get_Normal_E",
]
`);
});
});
46 changes: 46 additions & 0 deletions packages/typed-openapi/tests/ref-resolver.name-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createRefResolver } from "../src/ref-resolver.ts";
import { describe, it, expect } from "vitest";
import { tsFactory } from "../src/ts-factory.ts";

const openApiDoc = {
openapi: "3.0.0",
info: { title: "Test", version: "1.0.0" },
components: {
schemas: {
"import": { type: "string" },
"set": { type: "object", properties: { id: { type: "string" } } },
"Record": { type: "number" },
"normal": { type: "boolean" },
},
},
} as any;

describe("createRefResolver with NameTransformOptions", () => {
it("applies transformSchemaName and avoids reserved words", () => {
const resolver = createRefResolver(openApiDoc, tsFactory, {
transformSchemaName: (name) => `X_${name}_X`,
});
const infos = Array.from(resolver.infos.values()).map(i => i.normalized);
expect(infos).toMatchInlineSnapshot(`
[
"X_import_X",
"X_set_X",
"X_Record_X",
"X_normal_X",
]
`);
});

it("applies no transform and still avoids reserved words and invalid chars", () => {
const resolver = createRefResolver(openApiDoc, tsFactory);
const infos = Array.from(resolver.infos.values()).map(i => i.normalized);
expect(infos).toMatchInlineSnapshot(`
[
"Schema_import",
"Schema_set",
"Schema_Record",
"normal",
]
`);
});
});
Loading