Skip to content

Commit 4d21f2e

Browse files
authored
feat: Allow transforming schema & endpoint names; automatically prevents generating reserved TS/JS keyords names (#95)
* feat: Allow transforming schema & endpoint names; automatically prevents generating reserved TS/JS keyords names * chore: split prettier export
1 parent 34dbd9a commit 4d21f2e

15 files changed

+312
-82
lines changed

.changeset/true-pears-beam.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"typed-openapi": patch
3+
---
4+
5+
Allow transforming schema & endpoint names; automatically prevents generating reserved TS/JS keyords names
6+
7+
Fix https://github.com/astahmer/typed-openapi/issues/90

packages/typed-openapi/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"module": "dist/index.js",
77
"exports": {
88
".": "./dist/index.js",
9-
"./node": "./dist/node.export.js"
9+
"./node": "./dist/node.export.js",
10+
"./pretty": "./dist/pretty.export.js"
1011
},
1112
"bin": {
1213
"typed-openapi": "bin.js"

packages/typed-openapi/src/generate-client-files.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { allowedRuntimes, generateFile } from "./generator.ts";
77
import { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts";
88
import { generateTanstackQueryFile } from "./tanstack-query.generator.ts";
99
import { prettify } from "./format.ts";
10+
import type { NameTransformOptions } from "./types.ts";
1011

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

29-
export async function generateClientFiles(input: string, options: typeof optionsSchema.infer) {
30+
type GenerateClientFilesOptions = typeof optionsSchema.infer & {
31+
nameTransform?: NameTransformOptions;
32+
};
33+
34+
export async function generateClientFiles(input: string, options: GenerateClientFilesOptions) {
3035
const openApiDoc = (await SwaggerParser.bundle(input)) as OpenAPIObject;
3136

32-
const ctx = mapOpenApiEndpoints(openApiDoc);
37+
const ctx = mapOpenApiEndpoints(openApiDoc, options);
3338
console.log(`Found ${ctx.endpointList.length} endpoints`);
3439

3540
const content = await prettify(
3641
generateFile({
3742
...ctx,
3843
runtime: options.runtime,
3944
schemasOnly: options.schemasOnly,
45+
nameTransform: options.nameTransform,
4046
}),
4147
);
4248
const outputPath = join(

packages/typed-openapi/src/generator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import * as Codegen from "@sinclair/typebox-codegen";
66
import { match } from "ts-pattern";
77
import { type } from "arktype";
88
import { wrapWithQuotesIfNeeded } from "./string-utils.ts";
9+
import type { NameTransformOptions } from "./types.ts";
910

1011
type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints> & {
1112
runtime?: "none" | keyof typeof runtimeValidationGenerator;
1213
schemasOnly?: boolean;
14+
nameTransform?: NameTransformOptions | undefined;
1315
};
1416
type GeneratorContext = Required<GeneratorOptions>;
1517

packages/typed-openapi/src/map-openapi-endpoints.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { createRefResolver } from "./ref-resolver.ts";
88
import { tsFactory } from "./ts-factory.ts";
99
import { AnyBox, BoxRef, OpenapiSchemaConvertContext } from "./types.ts";
1010
import { pathToVariableName } from "./string-utils.ts";
11+
import { NameTransformOptions } from "./types.ts";
1112
import { match, P } from "ts-pattern";
13+
import { sanitizeName } from "./sanitize-name.ts";
1214

1315
const factory = tsFactory;
1416

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

27+
let alias = getAlias({ path, method, operation } as Endpoint);
28+
if (options?.nameTransform?.transformEndpointName) {
29+
alias = options.nameTransform.transformEndpointName({ alias, path, method: method as Method, operation });
30+
}
2531
const endpoint = {
2632
operation,
2733
method: method as Method,
2834
path,
2935
requestFormat: "json",
3036
response: openApiSchemaToTs({ schema: {}, ctx }),
3137
meta: {
32-
alias: getAlias({ path, method, operation } as Endpoint),
38+
alias,
3339
areParametersRequired: false,
3440
hasParameters: false,
3541
},
@@ -84,7 +90,7 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
8490

8591
if (matchingMediaType && content[matchingMediaType]) {
8692
params.body = openApiSchemaToTs({
87-
schema: content[matchingMediaType]?.schema ?? {} ?? {},
93+
schema: content[matchingMediaType]?.schema ?? {},
8894
ctx,
8995
});
9096
}
@@ -139,7 +145,7 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
139145
const matchingMediaType = Object.keys(content).find(isResponseMediaType);
140146
if (matchingMediaType && content[matchingMediaType]) {
141147
endpoint.response = openApiSchemaToTs({
142-
schema: content[matchingMediaType]?.schema ?? {} ?? {},
148+
schema: content[matchingMediaType]?.schema ?? {},
143149
ctx,
144150
});
145151
}
@@ -180,10 +186,13 @@ const isAllowedParamMediaTypes = (
180186

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

185194
type MutationMethod = "post" | "put" | "patch" | "delete";
186-
type Method = "get" | "head" | "options" | MutationMethod;
195+
export type Method = "get" | "head" | "options" | MutationMethod;
187196

188197
export type EndpointParameters = {
189198
body?: Box<BoxRef>;
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
export { prettify } from "./format.ts";
21
export { generateClientFiles } from "./generate-client-files.ts";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { prettify } from "./format.ts";

packages/typed-openapi/src/ref-resolver.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Box } from "./box.ts";
55
import { isReferenceObject } from "./is-reference-object.ts";
66
import { openApiSchemaToTs } from "./openapi-schema-to-ts.ts";
77
import { normalizeString } from "./string-utils.ts";
8+
import { NameTransformOptions } from "./types.ts";
89
import { AnyBoxDef, GenericFactory, type LibSchemaObject } from "./types.ts";
910
import { topologicalSort } from "./topological-sort.ts";
11+
import { sanitizeName } from "./sanitize-name.ts";
1012

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

29-
export const createRefResolver = (doc: OpenAPIObject, factory: GenericFactory) => {
31+
export const createRefResolver = (
32+
doc: OpenAPIObject,
33+
factory: GenericFactory,
34+
nameTransform?: NameTransformOptions,
35+
) => {
3036
// both used for debugging purpose
3137
const nameByRef = new Map<string, string>();
3238
const refByName = new Map<string, string>();
@@ -48,7 +54,11 @@ export const createRefResolver = (doc: OpenAPIObject, factory: GenericFactory) =
4854

4955
// "#/components/schemas/Something.jsonld" -> "Something.jsonld"
5056
const name = split[split.length - 1]!;
51-
const normalized = normalizeString(name);
57+
let normalized = normalizeString(name);
58+
if (nameTransform?.transformSchemaName) {
59+
normalized = nameTransform.transformSchemaName(normalized);
60+
}
61+
normalized = sanitizeName(normalized, "schema");
5262

5363
nameByRef.set(correctRef, normalized);
5464
refByName.set(normalized, correctRef);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const reservedWords = new Set([
2+
// TS keywords and built-ins
3+
"import",
4+
"package",
5+
"namespace",
6+
"Record",
7+
"Partial",
8+
"Required",
9+
"Readonly",
10+
"Pick",
11+
"Omit",
12+
"String",
13+
"Number",
14+
"Boolean",
15+
"Object",
16+
"Array",
17+
"Function",
18+
"any",
19+
"unknown",
20+
"never",
21+
"void",
22+
"extends",
23+
"super",
24+
"class",
25+
"interface",
26+
"type",
27+
"enum",
28+
"const",
29+
"let",
30+
"var",
31+
"if",
32+
"else",
33+
"for",
34+
"while",
35+
"do",
36+
"switch",
37+
"case",
38+
"default",
39+
"break",
40+
"continue",
41+
"return",
42+
"try",
43+
"catch",
44+
"finally",
45+
"throw",
46+
"new",
47+
"delete",
48+
"in",
49+
"instanceof",
50+
"typeof",
51+
"void",
52+
"with",
53+
"yield",
54+
"await",
55+
"static",
56+
"public",
57+
"private",
58+
"protected",
59+
"abstract",
60+
"as",
61+
"asserts",
62+
"from",
63+
"get",
64+
"set",
65+
"module",
66+
"require",
67+
"keyof",
68+
"readonly",
69+
"global",
70+
"symbol",
71+
"bigint",
72+
]);
73+
74+
export function sanitizeName(name: string, type: "schema" | "endpoint") {
75+
let n = name.replace(/[\W/]+/g, "_");
76+
if (/^\d/.test(n)) n = "_" + n;
77+
if (reservedWords.has(n)) n = (type === "schema" ? "Schema_" : "Endpoint_") + n;
78+
return n;
79+
}

packages/typed-openapi/src/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { ReferenceObject, SchemaObject } from "openapi3-ts/oas31";
1+
import type { OperationObject, ReferenceObject, SchemaObject } from "openapi3-ts/oas31";
22
import type { SchemaObject as SchemaObject3 } from "openapi3-ts/oas30";
33

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

78
export type LibSchemaObject = SchemaObject & SchemaObject3;
89

@@ -94,10 +95,22 @@ export type FactoryCreator = (
9495
schema: SchemaObject | ReferenceObject,
9596
ctx: OpenapiSchemaConvertContext,
9697
) => GenericFactory;
98+
99+
export type NameTransformOptions = {
100+
transformSchemaName?: (name: string) => string;
101+
transformEndpointName?: (endpoint: {
102+
alias: string;
103+
operation: OperationObject;
104+
method: Method;
105+
path: string;
106+
}) => string;
107+
};
108+
97109
export type OpenapiSchemaConvertContext = {
98110
factory: FactoryCreator | GenericFactory;
99111
refs: RefResolver;
100112
onBox?: (box: Box<AnyBoxDef>) => Box<AnyBoxDef>;
113+
nameTransform?: NameTransformOptions;
101114
};
102115

103116
export type StringOrBox = string | Box<AnyBoxDef>;

0 commit comments

Comments
 (0)