vite-plugin-openapi-codegen 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/dist/index.d.mts +89 -0
- package/dist/index.mjs +514 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# vite-plugin-openapi-codegen
|
|
2
|
+
|
|
3
|
+
Generate typed API clients and path builders from an OpenAPI document during Vite builds.
|
|
4
|
+
|
|
5
|
+
The plugin reads your OpenAPI spec, runs `openapi-typescript`, and emits four files into your target directory:
|
|
6
|
+
|
|
7
|
+
- `api-types.d.ts` for raw OpenAPI-derived types
|
|
8
|
+
- `types.ts` for schema aliases
|
|
9
|
+
- `api.ts` for path builder functions
|
|
10
|
+
- `client.ts` for typed request helpers
|
|
11
|
+
|
|
12
|
+
It also watches the input spec in dev mode and regenerates the files when the spec changes.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
This package is built for `vite-plus`.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
vp add -D vite-plugin-openapi-codegen vite-plus
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Add the plugin to your Vite config:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { defineConfig } from "vite-plus";
|
|
28
|
+
import { openapiCodegen } from "vite-plugin-openapi-codegen";
|
|
29
|
+
|
|
30
|
+
export default defineConfig({
|
|
31
|
+
plugins: [
|
|
32
|
+
openapiCodegen({
|
|
33
|
+
input: "openapi.json",
|
|
34
|
+
output: "src/generated",
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
When you run `vp dev` or `vp build`, the plugin generates:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
src/generated/
|
|
44
|
+
api-types.d.ts
|
|
45
|
+
types.ts
|
|
46
|
+
api.ts
|
|
47
|
+
client.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Runtime Contract
|
|
51
|
+
|
|
52
|
+
By default, generated clients import the following symbols from `#/integrations/http`:
|
|
53
|
+
|
|
54
|
+
- `requestJson`
|
|
55
|
+
- `requestVoid`
|
|
56
|
+
- `ApiRequestOptions`
|
|
57
|
+
|
|
58
|
+
The default runtime shape is designed for an app-level HTTP wrapper like this:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export interface ApiRequestOptions {
|
|
62
|
+
headers?: Record<string, string>;
|
|
63
|
+
json?: unknown;
|
|
64
|
+
method: string;
|
|
65
|
+
searchParams?: URLSearchParams;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function requestJson<T>(path: string, options: ApiRequestOptions): Promise<T> {
|
|
70
|
+
// Your app-specific HTTP implementation
|
|
71
|
+
throw new Error("Not implemented");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function requestVoid(path: string, options: ApiRequestOptions): Promise<void> {
|
|
75
|
+
// Your app-specific HTTP implementation
|
|
76
|
+
throw new Error("Not implemented");
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If your runtime uses different symbol names or a different module path, configure `httpClient`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { defineConfig } from "vite-plus";
|
|
84
|
+
import { openapiCodegen } from "vite-plugin-openapi-codegen";
|
|
85
|
+
|
|
86
|
+
export default defineConfig({
|
|
87
|
+
plugins: [
|
|
88
|
+
openapiCodegen({
|
|
89
|
+
input: "openapi.json",
|
|
90
|
+
output: "src/generated",
|
|
91
|
+
httpClient: {
|
|
92
|
+
module: "@app/http",
|
|
93
|
+
jsonFunction: "fetchJson",
|
|
94
|
+
voidFunction: "fetchVoid",
|
|
95
|
+
requestOptionsType: "RequestOptions",
|
|
96
|
+
omitKeys: ["json", "method", "signal"],
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Generated Output
|
|
104
|
+
|
|
105
|
+
Given a spec path like `/api/users/{user_id}`, the plugin generates a path builder:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
export function getUser(params: UserPath): string {
|
|
109
|
+
return `users/${params.user_id}`;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
And a typed client helper:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export interface GetUserOptions {
|
|
117
|
+
query?: never;
|
|
118
|
+
path: UserPath;
|
|
119
|
+
body?: never;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getUser(
|
|
124
|
+
options: GetUserOptions,
|
|
125
|
+
requestOptions: RuntimeRequestOptions = {},
|
|
126
|
+
): Promise<UserResponse> {
|
|
127
|
+
return requestJson<UserResponse>(buildGetUserPath(options.path), {
|
|
128
|
+
...requestOptions,
|
|
129
|
+
method: "GET",
|
|
130
|
+
signal: options.signal,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The generated client shape depends on the OpenAPI operation:
|
|
136
|
+
|
|
137
|
+
- path parameters become `options.path`
|
|
138
|
+
- query parameters become `options.query`
|
|
139
|
+
- JSON request bodies become `options.body`
|
|
140
|
+
- JSON responses become typed `Promise<T>`
|
|
141
|
+
- empty responses use the configured void request function
|
|
142
|
+
|
|
143
|
+
## Options
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
interface Options {
|
|
147
|
+
input: string;
|
|
148
|
+
output: string;
|
|
149
|
+
pathPrefix?: string;
|
|
150
|
+
stripPrefix?: boolean;
|
|
151
|
+
httpClient?: {
|
|
152
|
+
module?: string;
|
|
153
|
+
jsonFunction?: string;
|
|
154
|
+
voidFunction?: string;
|
|
155
|
+
requestOptionsType?: string;
|
|
156
|
+
omitKeys?: string[];
|
|
157
|
+
};
|
|
158
|
+
legacyAliases?: Record<string, string>;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### `input`
|
|
163
|
+
|
|
164
|
+
Path to the OpenAPI JSON file, relative to the Vite project root.
|
|
165
|
+
|
|
166
|
+
### `output`
|
|
167
|
+
|
|
168
|
+
Directory where generated files are written, relative to the Vite project root.
|
|
169
|
+
|
|
170
|
+
### `pathPrefix`
|
|
171
|
+
|
|
172
|
+
Only paths starting with this prefix are included. The default is `"/api/"`.
|
|
173
|
+
|
|
174
|
+
### `stripPrefix`
|
|
175
|
+
|
|
176
|
+
Controls whether the `pathPrefix` is removed from generated path builders. The default is `true`.
|
|
177
|
+
|
|
178
|
+
### `httpClient`
|
|
179
|
+
|
|
180
|
+
Overrides the runtime import path and symbol names used by generated clients.
|
|
181
|
+
|
|
182
|
+
### `legacyAliases`
|
|
183
|
+
|
|
184
|
+
Adds extra type aliases to `types.ts` so you can preserve older type names during migrations.
|
|
185
|
+
|
|
186
|
+
## Programmatic Usage
|
|
187
|
+
|
|
188
|
+
If you want to generate artifacts outside the Vite lifecycle, use `renderGeneratedArtifacts`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { readFileSync } from "node:fs";
|
|
192
|
+
import { renderGeneratedArtifacts } from "vite-plugin-openapi-codegen";
|
|
193
|
+
|
|
194
|
+
const spec = JSON.parse(readFileSync("openapi.json", "utf-8"));
|
|
195
|
+
|
|
196
|
+
const files = renderGeneratedArtifacts(spec, {
|
|
197
|
+
pathPrefix: "/api/",
|
|
198
|
+
stripPrefix: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log(files.api);
|
|
202
|
+
console.log(files.client);
|
|
203
|
+
console.log(files.types);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
vp install
|
|
210
|
+
vp test
|
|
211
|
+
vp check
|
|
212
|
+
vp pack
|
|
213
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Plugin } from "vite-plus";
|
|
2
|
+
|
|
3
|
+
//#region src/normalization.d.ts
|
|
4
|
+
declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "patch"];
|
|
5
|
+
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
6
|
+
type ParameterLocation = "path" | "query" | "header" | "cookie";
|
|
7
|
+
interface OpenAPIParameter {
|
|
8
|
+
in: ParameterLocation;
|
|
9
|
+
name: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
schema?: {
|
|
12
|
+
type?: string | string[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
interface OpenAPIContent {
|
|
16
|
+
schema?: unknown;
|
|
17
|
+
}
|
|
18
|
+
interface OpenAPIRequestBody {
|
|
19
|
+
content?: Record<string, OpenAPIContent>;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface OpenAPIResponse {
|
|
23
|
+
content?: Record<string, OpenAPIContent>;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
interface OpenAPIOperation {
|
|
27
|
+
operationId?: string;
|
|
28
|
+
parameters?: OpenAPIParameter[];
|
|
29
|
+
requestBody?: OpenAPIRequestBody;
|
|
30
|
+
responses?: Record<string, OpenAPIResponse>;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
}
|
|
33
|
+
type OpenAPIPathItem = Partial<Record<HttpMethod, OpenAPIOperation>>;
|
|
34
|
+
interface OpenAPISchema {
|
|
35
|
+
properties?: Record<string, unknown>;
|
|
36
|
+
type?: string;
|
|
37
|
+
}
|
|
38
|
+
interface OpenAPISpec {
|
|
39
|
+
paths?: Record<string, OpenAPIPathItem>;
|
|
40
|
+
components?: {
|
|
41
|
+
schemas?: Record<string, OpenAPISchema>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
interface OperationEntry {
|
|
45
|
+
apiPath: string;
|
|
46
|
+
funcName: string;
|
|
47
|
+
group: string;
|
|
48
|
+
method: HttpMethod;
|
|
49
|
+
operation: OpenAPIOperation;
|
|
50
|
+
operationId: string;
|
|
51
|
+
strippedPath: string;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/plugin.d.ts
|
|
55
|
+
interface HttpClientConfig {
|
|
56
|
+
/** HTTP module import path. Default: '#/integrations/http' */
|
|
57
|
+
module?: string;
|
|
58
|
+
/** Function name for JSON-returning requests. Default: 'requestJson' */
|
|
59
|
+
jsonFunction?: string;
|
|
60
|
+
/** Function name for void requests. Default: 'requestVoid' */
|
|
61
|
+
voidFunction?: string;
|
|
62
|
+
/** Type name for request options (type-only import). Default: 'ApiRequestOptions' */
|
|
63
|
+
requestOptionsType?: string;
|
|
64
|
+
/** Keys to Omit from requestOptionsType for RuntimeRequestOptions. Default: ['json','method','searchParams','signal'] */
|
|
65
|
+
omitKeys?: string[];
|
|
66
|
+
}
|
|
67
|
+
interface Options {
|
|
68
|
+
/** Path to openapi.json (relative to project root) */
|
|
69
|
+
input: string;
|
|
70
|
+
/** Output directory for generated files (relative to project root) */
|
|
71
|
+
output: string;
|
|
72
|
+
/** Path prefix for filtering and stripping. Default: '/api/' */
|
|
73
|
+
pathPrefix?: string;
|
|
74
|
+
/** Whether to strip pathPrefix from generated paths. Default: true */
|
|
75
|
+
stripPrefix?: boolean;
|
|
76
|
+
/** HTTP client configuration */
|
|
77
|
+
httpClient?: HttpClientConfig;
|
|
78
|
+
/** Legacy type aliases for types.ts: { OldName: 'NewName' } */
|
|
79
|
+
legacyAliases?: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
interface GeneratedArtifacts {
|
|
82
|
+
api: string;
|
|
83
|
+
client: string;
|
|
84
|
+
types: string;
|
|
85
|
+
}
|
|
86
|
+
declare function renderGeneratedArtifacts(spec: OpenAPISpec, options: Pick<Options, "httpClient" | "legacyAliases" | "pathPrefix" | "stripPrefix">, preCollectedOperations?: OperationEntry[]): GeneratedArtifacts;
|
|
87
|
+
declare function openapiCodegen(options: Options): Plugin;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { type HttpClientConfig, type OpenAPISpec, type OperationEntry, type Options, openapiCodegen, renderGeneratedArtifacts };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import * as ts from "typescript";
|
|
4
|
+
//#region src/ast.ts
|
|
5
|
+
const AST_PRINTER = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
|
|
6
|
+
function renderTypesSource(schemaNames, legacyAliases, generatedHeader) {
|
|
7
|
+
const statements = [createTypeOnlyImport(["components"], "./api-types")];
|
|
8
|
+
for (const name of schemaNames) statements.push(ts.factory.createTypeAliasDeclaration([createExportModifier()], ts.factory.createIdentifier(name), void 0, createIndexedAccessTypeNode("components", ["schemas", name])));
|
|
9
|
+
if (legacyAliases && Object.keys(legacyAliases).length > 0) Object.entries(legacyAliases).forEach(([alias, target], index) => {
|
|
10
|
+
const statement = ts.factory.createTypeAliasDeclaration([createExportModifier()], ts.factory.createIdentifier(alias), void 0, createTypeNodeFromText(target));
|
|
11
|
+
statements.push(index === 0 ? createGeneratedBannerComment(statement, "Legacy aliases") : statement);
|
|
12
|
+
});
|
|
13
|
+
return printGeneratedFile(statements, generatedHeader);
|
|
14
|
+
}
|
|
15
|
+
function renderApiSource(entries, generatedHeader) {
|
|
16
|
+
const statements = [];
|
|
17
|
+
const seenGroups = /* @__PURE__ */ new Set();
|
|
18
|
+
let needsOperationsImport = false;
|
|
19
|
+
const typeAliasImports = /* @__PURE__ */ new Set();
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const parameters = entry.pathTypeExpr == null ? [] : [ts.factory.createParameterDeclaration(void 0, void 0, ts.factory.createIdentifier("params"), void 0, createTypeNodeFromText(entry.pathTypeExpr))];
|
|
22
|
+
if (entry.pathTypeExpr) {
|
|
23
|
+
if (entry.pathTypeExpr.includes("operations[")) needsOperationsImport = true;
|
|
24
|
+
else if (/^[A-Z]\w*$/.test(entry.pathTypeExpr)) typeAliasImports.add(entry.pathTypeExpr);
|
|
25
|
+
}
|
|
26
|
+
const declaration = ts.factory.createFunctionDeclaration([createExportModifier()], void 0, ts.factory.createIdentifier(entry.funcName), void 0, parameters, ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), ts.factory.createBlock([ts.factory.createReturnStatement(entry.pathTypeExpr == null ? ts.factory.createStringLiteral(entry.strippedPath) : createPathExpression(entry.strippedPath))], true));
|
|
27
|
+
statements.push(seenGroups.has(entry.group) ? declaration : createGeneratedBannerComment(declaration, capitalize$1(entry.group)));
|
|
28
|
+
seenGroups.add(entry.group);
|
|
29
|
+
}
|
|
30
|
+
const sourceStatements = [];
|
|
31
|
+
if (needsOperationsImport) sourceStatements.push(createTypeOnlyImport(["operations"], "./api-types"));
|
|
32
|
+
if (typeAliasImports.size > 0) sourceStatements.push(createTypeOnlyImport([...typeAliasImports].sort(), "./types"));
|
|
33
|
+
sourceStatements.push(...statements);
|
|
34
|
+
return printGeneratedFile(sourceStatements, generatedHeader);
|
|
35
|
+
}
|
|
36
|
+
function renderClientSource(model, generatedHeader, httpClient) {
|
|
37
|
+
const statements = [
|
|
38
|
+
createTypeOnlyImport([httpClient.requestOptionsType], httpClient.module),
|
|
39
|
+
createValueImport([{ name: httpClient.jsonFunction }, { name: httpClient.voidFunction }], httpClient.module),
|
|
40
|
+
createTypeOnlyImport(["operations"], "./api-types")
|
|
41
|
+
];
|
|
42
|
+
if (model.typeImports.length > 0) statements.push(createTypeOnlyImport(model.typeImports, "./types"));
|
|
43
|
+
statements.push(createValueImport(model.operations.map((operation) => ({
|
|
44
|
+
alias: operation.builderAlias,
|
|
45
|
+
name: operation.funcName
|
|
46
|
+
})), "./api"));
|
|
47
|
+
const runtimeTypeExpr = httpClient.omitKeys.length > 0 ? `Omit<${httpClient.requestOptionsType}, ${httpClient.omitKeys.map((k) => `'${k}'`).join(" | ")}>` : httpClient.requestOptionsType;
|
|
48
|
+
statements.push(ts.factory.createTypeAliasDeclaration(void 0, ts.factory.createIdentifier("RuntimeRequestOptions"), void 0, createTypeNodeFromText(runtimeTypeExpr)));
|
|
49
|
+
if (model.needsSearchParamsHelper) statements.push(createBuildSearchParamsFunction());
|
|
50
|
+
const seenGroups = /* @__PURE__ */ new Set();
|
|
51
|
+
for (const operation of model.operations) {
|
|
52
|
+
const optionsInterface = createClientOptionsDeclaration(operation);
|
|
53
|
+
statements.push(seenGroups.has(operation.group) ? optionsInterface : createGeneratedBannerComment(optionsInterface, capitalize$1(operation.group)));
|
|
54
|
+
statements.push(createClientFunctionDeclaration(operation));
|
|
55
|
+
seenGroups.add(operation.group);
|
|
56
|
+
}
|
|
57
|
+
return printGeneratedFile(statements, generatedHeader);
|
|
58
|
+
}
|
|
59
|
+
function printGeneratedFile(statements, generatedHeader) {
|
|
60
|
+
const sourceFile = ts.factory.createSourceFile(statements, ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None);
|
|
61
|
+
const printed = AST_PRINTER.printFile(sourceFile).trim();
|
|
62
|
+
return printed.length > 0 ? `${generatedHeader.join("\n")}\n\n${printed}\n` : `${generatedHeader.join("\n")}\n`;
|
|
63
|
+
}
|
|
64
|
+
function parseExpression(sourceText) {
|
|
65
|
+
const statement = ts.createSourceFile("generated-expression.ts", `const value = ${sourceText}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS).statements[0];
|
|
66
|
+
if (!statement || !ts.isVariableStatement(statement)) throw new Error(`Failed to parse expression: ${sourceText}`);
|
|
67
|
+
const declaration = statement.declarationList.declarations[0];
|
|
68
|
+
if (!declaration?.initializer) throw new Error(`Missing parsed initializer for: ${sourceText}`);
|
|
69
|
+
return declaration.initializer;
|
|
70
|
+
}
|
|
71
|
+
function createTypeOnlyImport(names, moduleSpecifier) {
|
|
72
|
+
return ts.factory.createImportDeclaration(void 0, ts.factory.createImportClause(true, void 0, ts.factory.createNamedImports(names.map((name) => ts.factory.createImportSpecifier(false, void 0, ts.factory.createIdentifier(name))))), ts.factory.createStringLiteral(moduleSpecifier));
|
|
73
|
+
}
|
|
74
|
+
function createValueImport(specifiers, moduleSpecifier) {
|
|
75
|
+
return ts.factory.createImportDeclaration(void 0, ts.factory.createImportClause(false, void 0, ts.factory.createNamedImports(specifiers.map((specifier) => ts.factory.createImportSpecifier(false, specifier.alias ? ts.factory.createIdentifier(specifier.name) : void 0, ts.factory.createIdentifier(specifier.alias ?? specifier.name))))), ts.factory.createStringLiteral(moduleSpecifier));
|
|
76
|
+
}
|
|
77
|
+
function createExportModifier() {
|
|
78
|
+
return ts.factory.createModifier(ts.SyntaxKind.ExportKeyword);
|
|
79
|
+
}
|
|
80
|
+
function createGeneratedBannerComment(node, text) {
|
|
81
|
+
return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, ` ${text}`, true);
|
|
82
|
+
}
|
|
83
|
+
function createStringLiteralType(value) {
|
|
84
|
+
return ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value));
|
|
85
|
+
}
|
|
86
|
+
function createIndexedAccessTypeNode(rootName, indices) {
|
|
87
|
+
let current = ts.factory.createTypeReferenceNode(rootName);
|
|
88
|
+
for (const index of indices) current = ts.factory.createIndexedAccessTypeNode(current, createStringLiteralType(index));
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
function createPathExpression(strippedPath) {
|
|
92
|
+
const matches = [...strippedPath.matchAll(/\{(\w+)\}/g)];
|
|
93
|
+
if (matches.length === 0) return ts.factory.createStringLiteral(strippedPath);
|
|
94
|
+
const [firstMatch] = matches;
|
|
95
|
+
const spans = matches.map((match, index) => {
|
|
96
|
+
const nextMatch = matches[index + 1];
|
|
97
|
+
const literalText = strippedPath.slice(match.index + match[0].length, nextMatch?.index ?? strippedPath.length);
|
|
98
|
+
return ts.factory.createTemplateSpan(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("params"), match[1]), nextMatch ? ts.factory.createTemplateMiddle(literalText) : ts.factory.createTemplateTail(literalText));
|
|
99
|
+
});
|
|
100
|
+
return ts.factory.createTemplateExpression(ts.factory.createTemplateHead(strippedPath.slice(0, firstMatch.index)), spans);
|
|
101
|
+
}
|
|
102
|
+
function createTypeNodeFromText(sourceText) {
|
|
103
|
+
const stmt = ts.createSourceFile("__type__.ts", `type __T__ = ${sourceText}`, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS).statements[0];
|
|
104
|
+
if (!stmt || !ts.isTypeAliasDeclaration(stmt)) throw new Error(`Failed to parse type expression: ${sourceText}`);
|
|
105
|
+
return stmt.type;
|
|
106
|
+
}
|
|
107
|
+
function createClientOptionsDeclaration(operation) {
|
|
108
|
+
return ts.factory.createInterfaceDeclaration([createExportModifier()], ts.factory.createIdentifier(operation.optionTypeName), void 0, void 0, [
|
|
109
|
+
createClientChannelField("query", operation.queryChannel),
|
|
110
|
+
createClientChannelField("path", operation.pathChannel),
|
|
111
|
+
createClientChannelField("body", operation.bodyChannel),
|
|
112
|
+
ts.factory.createPropertySignature(void 0, ts.factory.createIdentifier("signal"), ts.factory.createToken(ts.SyntaxKind.QuestionToken), ts.factory.createTypeReferenceNode("AbortSignal"))
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
function createClientChannelField(key, channel) {
|
|
116
|
+
return ts.factory.createPropertySignature(void 0, ts.factory.createIdentifier(key), channel.present && channel.required ? void 0 : ts.factory.createToken(ts.SyntaxKind.QuestionToken), channel.present && channel.typeExpr ? createTypeNodeFromText(channel.typeExpr) : ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword));
|
|
117
|
+
}
|
|
118
|
+
function createClientFunctionDeclaration(operation) {
|
|
119
|
+
const requestProperties = [ts.factory.createSpreadAssignment(ts.factory.createIdentifier("requestOptions")), ts.factory.createPropertyAssignment(ts.factory.createIdentifier("method"), ts.factory.createStringLiteral(operation.methodUpper))];
|
|
120
|
+
if (operation.queryChannel.present) requestProperties.push(ts.factory.createPropertyAssignment(ts.factory.createIdentifier("searchParams"), ts.factory.createCallExpression(ts.factory.createIdentifier("buildSearchParams"), void 0, [ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("options"), "query")])));
|
|
121
|
+
if (operation.bodyChannel.present) requestProperties.push(ts.factory.createPropertyAssignment(ts.factory.createIdentifier("json"), ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("options"), "body")));
|
|
122
|
+
requestProperties.push(ts.factory.createPropertyAssignment(ts.factory.createIdentifier("signal"), ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("options"), "signal")));
|
|
123
|
+
const requestCall = ts.factory.createCallExpression(ts.factory.createIdentifier(operation.requestFunction), operation.responseTypeExpr ? [createTypeNodeFromText(operation.responseTypeExpr)] : void 0, [parseExpression(operation.pathInvocationExpr), ts.factory.createObjectLiteralExpression(requestProperties, true)]);
|
|
124
|
+
return ts.factory.createFunctionDeclaration([createExportModifier()], void 0, ts.factory.createIdentifier(operation.funcName), void 0, [ts.factory.createParameterDeclaration(void 0, void 0, ts.factory.createIdentifier("options"), void 0, ts.factory.createTypeReferenceNode(operation.optionTypeName)), ts.factory.createParameterDeclaration(void 0, void 0, ts.factory.createIdentifier("requestOptions"), void 0, ts.factory.createTypeReferenceNode("RuntimeRequestOptions"), ts.factory.createObjectLiteralExpression())], createTypeNodeFromText(operation.returnTypeExpr), ts.factory.createBlock([ts.factory.createReturnStatement(requestCall)], true));
|
|
125
|
+
}
|
|
126
|
+
function createBuildSearchParamsFunction() {
|
|
127
|
+
return ts.factory.createFunctionDeclaration(void 0, void 0, ts.factory.createIdentifier("buildSearchParams"), void 0, [ts.factory.createParameterDeclaration(void 0, void 0, ts.factory.createIdentifier("query"), void 0, createTypeNodeFromText("Record<string, unknown> | undefined"))], createTypeNodeFromText("URLSearchParams | undefined"), ts.factory.createBlock([
|
|
128
|
+
ts.factory.createIfStatement(ts.factory.createBinaryExpression(ts.factory.createIdentifier("query"), ts.factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), ts.factory.createIdentifier("undefined")), ts.factory.createReturnStatement(ts.factory.createIdentifier("undefined"))),
|
|
129
|
+
ts.factory.createVariableStatement(void 0, ts.factory.createVariableDeclarationList([ts.factory.createVariableDeclaration(ts.factory.createIdentifier("entries"), void 0, void 0, parseExpression("Object.entries(query).filter(([, value]) => value != null)"))], ts.NodeFlags.Const)),
|
|
130
|
+
ts.factory.createIfStatement(ts.factory.createBinaryExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("entries"), "length"), ts.factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), ts.factory.createNumericLiteral("0")), ts.factory.createReturnStatement(ts.factory.createIdentifier("undefined"))),
|
|
131
|
+
ts.factory.createVariableStatement(void 0, ts.factory.createVariableDeclarationList([ts.factory.createVariableDeclaration(ts.factory.createIdentifier("searchParams"), void 0, void 0, ts.factory.createNewExpression(ts.factory.createIdentifier("URLSearchParams"), void 0, []))], ts.NodeFlags.Const)),
|
|
132
|
+
ts.factory.createForOfStatement(void 0, ts.factory.createVariableDeclarationList([ts.factory.createVariableDeclaration(ts.factory.createArrayBindingPattern([ts.factory.createBindingElement(void 0, void 0, "key"), ts.factory.createBindingElement(void 0, void 0, "value")]))], ts.NodeFlags.Const), ts.factory.createIdentifier("entries"), ts.factory.createBlock([ts.factory.createExpressionStatement(ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("searchParams"), "set"), void 0, [ts.factory.createIdentifier("key"), ts.factory.createCallExpression(ts.factory.createIdentifier("String"), void 0, [ts.factory.createIdentifier("value")])]))], true)),
|
|
133
|
+
ts.factory.createReturnStatement(ts.factory.createIdentifier("searchParams"))
|
|
134
|
+
], true));
|
|
135
|
+
}
|
|
136
|
+
function capitalize$1(value) {
|
|
137
|
+
if (value.length === 0) return value;
|
|
138
|
+
return `${value[0].toUpperCase()}${value.slice(1)}`;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/normalization.ts
|
|
142
|
+
const HTTP_METHODS = [
|
|
143
|
+
"get",
|
|
144
|
+
"put",
|
|
145
|
+
"post",
|
|
146
|
+
"delete",
|
|
147
|
+
"patch"
|
|
148
|
+
];
|
|
149
|
+
function buildClientRenderModelFromOperations(operations, spec, requestFunctionNames = {
|
|
150
|
+
json: "requestJson",
|
|
151
|
+
void: "requestVoid"
|
|
152
|
+
}) {
|
|
153
|
+
const context = buildNormalizationContext(spec);
|
|
154
|
+
const typeImports = /* @__PURE__ */ new Set();
|
|
155
|
+
const normalized = operations.map((entry) => normalizeOperation(entry, context, typeImports, requestFunctionNames));
|
|
156
|
+
return {
|
|
157
|
+
operations: normalized,
|
|
158
|
+
needsSearchParamsHelper: normalized.some((operation) => operation.queryChannel.present),
|
|
159
|
+
typeImports: [...typeImports].sort()
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function collectOperations(spec, pathPrefix = "/api/", stripPrefix = true) {
|
|
163
|
+
const apiPaths = Object.keys(spec.paths ?? {}).filter((path) => path.startsWith(pathPrefix)).sort();
|
|
164
|
+
if (apiPaths.length === 0) throw new Error(`No paths matching prefix "${pathPrefix}" found in openapi.json`);
|
|
165
|
+
const entries = [];
|
|
166
|
+
for (const apiPath of apiPaths) {
|
|
167
|
+
const pathItem = spec.paths?.[apiPath];
|
|
168
|
+
if (!pathItem) continue;
|
|
169
|
+
for (const method of HTTP_METHODS) {
|
|
170
|
+
const operation = pathItem[method];
|
|
171
|
+
if (!operation?.operationId) continue;
|
|
172
|
+
const strippedPath = stripPrefix ? apiPath.replace(pathPrefix, "") : apiPath;
|
|
173
|
+
entries.push({
|
|
174
|
+
apiPath,
|
|
175
|
+
funcName: makeFuncName(method, apiPath, pathPrefix),
|
|
176
|
+
group: strippedPath.split("/")[0] ?? "misc",
|
|
177
|
+
method,
|
|
178
|
+
operation,
|
|
179
|
+
operationId: operation.operationId,
|
|
180
|
+
strippedPath
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return entries;
|
|
185
|
+
}
|
|
186
|
+
function getEffectiveParametersByLocation(entry, location) {
|
|
187
|
+
return (entry.operation.parameters ?? []).filter((parameter) => getEffectiveParameterLocation(entry.apiPath, parameter) === location);
|
|
188
|
+
}
|
|
189
|
+
function warnOnParameterLocationMismatch(operations) {
|
|
190
|
+
for (const entry of operations) for (const parameter of entry.operation.parameters ?? []) {
|
|
191
|
+
const effectiveLocation = getEffectiveParameterLocation(entry.apiPath, parameter);
|
|
192
|
+
if (effectiveLocation === parameter.in) continue;
|
|
193
|
+
console.warn(`[openapi-codegen] normalized parameter "${parameter.name}" for "${entry.operationId}" from ${parameter.in} to ${effectiveLocation}.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function buildNormalizationContext(spec) {
|
|
197
|
+
const schemaAliasIndex = /* @__PURE__ */ new Map();
|
|
198
|
+
const schemaNames = /* @__PURE__ */ new Set();
|
|
199
|
+
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
|
200
|
+
schemaNames.add(name);
|
|
201
|
+
const props = Object.keys(schema.properties ?? {}).sort().join(",");
|
|
202
|
+
if (!props) continue;
|
|
203
|
+
if (!schemaAliasIndex.has(props)) schemaAliasIndex.set(props, []);
|
|
204
|
+
schemaAliasIndex.get(props)?.push(name);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
schemaAliasIndex,
|
|
208
|
+
schemaNames
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function getParametersByLocation(operation, location) {
|
|
212
|
+
return (operation.parameters ?? []).filter((parameter) => parameter.in === location);
|
|
213
|
+
}
|
|
214
|
+
function hasRequiredChannel(parameters) {
|
|
215
|
+
return parameters.some((parameter) => parameter.required);
|
|
216
|
+
}
|
|
217
|
+
function getJsonRequestBody(operation) {
|
|
218
|
+
const requestBody = operation.requestBody;
|
|
219
|
+
if (!requestBody) return void 0;
|
|
220
|
+
const jsonBody = requestBody.content?.["application/json"];
|
|
221
|
+
if (!jsonBody) throw new Error(`Operation "${operation.operationId ?? "unknown"}" has a requestBody but no application/json content`);
|
|
222
|
+
return jsonBody;
|
|
223
|
+
}
|
|
224
|
+
function getSuccessResponseInfo(operation) {
|
|
225
|
+
const successResponses = Object.entries(operation.responses ?? {}).filter(([statusKey]) => isSuccessStatus(statusKey)).sort(([left], [right]) => Number(left) - Number(right));
|
|
226
|
+
if (successResponses.length === 0) throw new Error(`Operation "${operation.operationId ?? "unknown"}" has no 2xx success response`);
|
|
227
|
+
const withJson = successResponses.find(([, response]) => response.content?.["application/json"]);
|
|
228
|
+
if (withJson) return {
|
|
229
|
+
hasJsonBody: true,
|
|
230
|
+
statusKey: withJson[0]
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
hasJsonBody: false,
|
|
234
|
+
statusKey: successResponses[0][0]
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function isSuccessStatus(statusKey) {
|
|
238
|
+
const value = Number(statusKey);
|
|
239
|
+
return Number.isInteger(value) && value >= 200 && value < 300;
|
|
240
|
+
}
|
|
241
|
+
function formatStatusKey(statusKey) {
|
|
242
|
+
return Number.isInteger(Number(statusKey)) ? statusKey : `'${statusKey}'`;
|
|
243
|
+
}
|
|
244
|
+
function getBuilderAlias(funcName) {
|
|
245
|
+
return `build${capitalize(funcName)}Path`;
|
|
246
|
+
}
|
|
247
|
+
function getClientOptionTypeName(funcName) {
|
|
248
|
+
return `${capitalize(funcName)}Options`;
|
|
249
|
+
}
|
|
250
|
+
function makeFuncName(method, apiPath, pathPrefix = "/api/") {
|
|
251
|
+
const segments = apiPath.replace(pathPrefix, "").split("/");
|
|
252
|
+
const result = [];
|
|
253
|
+
for (const segment of segments) {
|
|
254
|
+
if (segment.startsWith("{")) {
|
|
255
|
+
const resource = segment.slice(1, -1).replace(/_id$/, "");
|
|
256
|
+
if (result.length > 0) result[result.length - 1] = resource;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
result.push(segment);
|
|
260
|
+
}
|
|
261
|
+
return `${method}${capitalize(result.map((segment, index) => {
|
|
262
|
+
const clean = segment.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
263
|
+
return index === 0 ? clean : `${clean[0].toUpperCase()}${clean.slice(1)}`;
|
|
264
|
+
}).join(""))}`;
|
|
265
|
+
}
|
|
266
|
+
function capitalize(value) {
|
|
267
|
+
if (value.length === 0) return value;
|
|
268
|
+
return `${value[0].toUpperCase()}${value.slice(1)}`;
|
|
269
|
+
}
|
|
270
|
+
function getEffectiveParameterLocation(apiPath, parameter) {
|
|
271
|
+
if (parameter.in !== "path") return parameter.in;
|
|
272
|
+
return getTemplateParameterNames(apiPath).has(parameter.name) ? "path" : "query";
|
|
273
|
+
}
|
|
274
|
+
function getTemplateParameterNames(apiPath) {
|
|
275
|
+
const matches = apiPath.match(/\{(\w+)\}/g) ?? [];
|
|
276
|
+
return new Set(matches.map((match) => match.slice(1, -1)));
|
|
277
|
+
}
|
|
278
|
+
function resolveParameterTypeExpression(entry, context, location, typeImports) {
|
|
279
|
+
const effectiveParameters = getEffectiveParametersByLocation(entry, location);
|
|
280
|
+
if (effectiveParameters.length === 0) return "never";
|
|
281
|
+
const alias = resolveAlias(context, effectiveParameters.map((parameter) => parameter.name), entry.operation.tags?.[0]);
|
|
282
|
+
if (alias) {
|
|
283
|
+
typeImports.add(alias);
|
|
284
|
+
return alias;
|
|
285
|
+
}
|
|
286
|
+
if (hasSameParameterNames(getParametersByLocation(entry.operation, location), effectiveParameters)) return `operations['${entry.operationId}']['parameters']['${location}']`;
|
|
287
|
+
return renderInlineParameterObject(effectiveParameters);
|
|
288
|
+
}
|
|
289
|
+
function normalizeOperation(entry, context, typeImports, requestFunctionNames) {
|
|
290
|
+
const successResponse = getSuccessResponseInfo(entry.operation);
|
|
291
|
+
const builderAlias = getBuilderAlias(entry.funcName);
|
|
292
|
+
const pathChannel = normalizeParameterChannel(entry, context, "path", typeImports);
|
|
293
|
+
const queryChannel = normalizeParameterChannel(entry, context, "query", typeImports);
|
|
294
|
+
const bodyChannel = normalizeBodyChannel(entry, context, typeImports);
|
|
295
|
+
const responseTypeExpr = resolveResponseTypeExpression(entry, context, typeImports, successResponse);
|
|
296
|
+
return {
|
|
297
|
+
bodyChannel,
|
|
298
|
+
builderAlias,
|
|
299
|
+
entry,
|
|
300
|
+
optionTypeName: getClientOptionTypeName(entry.funcName),
|
|
301
|
+
pathChannel,
|
|
302
|
+
pathInvocationExpr: pathChannel.present ? `${builderAlias}(options.path)` : `${builderAlias}()`,
|
|
303
|
+
queryChannel,
|
|
304
|
+
requestFunction: responseTypeExpr ? requestFunctionNames.json : requestFunctionNames.void,
|
|
305
|
+
responseTypeExpr,
|
|
306
|
+
returnTypeExpr: responseTypeExpr ? `Promise<${responseTypeExpr}>` : "Promise<void>"
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function normalizeParameterChannel(entry, context, location, typeImports) {
|
|
310
|
+
const parameters = getEffectiveParametersByLocation(entry, location);
|
|
311
|
+
if (parameters.length === 0) return {
|
|
312
|
+
present: false,
|
|
313
|
+
required: false,
|
|
314
|
+
typeExpr: null
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
present: true,
|
|
318
|
+
required: hasRequiredChannel(parameters),
|
|
319
|
+
typeExpr: resolveParameterTypeExpression(entry, context, location, typeImports)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function normalizeBodyChannel(entry, context, typeImports) {
|
|
323
|
+
const typeExpr = resolveRequestBodyTypeExpression(entry, context, typeImports);
|
|
324
|
+
if (!typeExpr) return {
|
|
325
|
+
present: false,
|
|
326
|
+
required: false,
|
|
327
|
+
typeExpr: null
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
present: true,
|
|
331
|
+
required: entry.operation.requestBody?.required !== false,
|
|
332
|
+
typeExpr
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function resolveRequestBodyTypeExpression(entry, context, typeImports) {
|
|
336
|
+
const jsonBody = getJsonRequestBody(entry.operation);
|
|
337
|
+
if (!jsonBody) return null;
|
|
338
|
+
const alias = resolveSchemaAlias(context, jsonBody.schema, typeImports);
|
|
339
|
+
if (alias) return alias;
|
|
340
|
+
return `operations['${entry.operationId}']['requestBody']['content']['application/json']`;
|
|
341
|
+
}
|
|
342
|
+
function resolveResponseTypeExpression(entry, context, typeImports, successResponse) {
|
|
343
|
+
if (!successResponse.hasJsonBody) return null;
|
|
344
|
+
const jsonContent = (entry.operation.responses?.[successResponse.statusKey])?.content?.["application/json"];
|
|
345
|
+
const alias = resolveSchemaAlias(context, jsonContent?.schema, typeImports);
|
|
346
|
+
if (alias) return alias;
|
|
347
|
+
return `operations['${entry.operationId}']['responses'][${formatStatusKey(successResponse.statusKey)}]['content']['application/json']`;
|
|
348
|
+
}
|
|
349
|
+
function hasSameParameterNames(left, right) {
|
|
350
|
+
if (left.length !== right.length) return false;
|
|
351
|
+
const leftNames = left.map((parameter) => parameter.name).sort();
|
|
352
|
+
const rightNames = right.map((parameter) => parameter.name).sort();
|
|
353
|
+
return leftNames.every((name, index) => name === rightNames[index]);
|
|
354
|
+
}
|
|
355
|
+
function resolveAlias(context, parameterNames, tag) {
|
|
356
|
+
const key = [...parameterNames].sort().join(",");
|
|
357
|
+
const candidates = context.schemaAliasIndex.get(key);
|
|
358
|
+
if (!candidates || candidates.length === 0) return;
|
|
359
|
+
if (candidates.length === 1) return candidates[0];
|
|
360
|
+
if (tag) {
|
|
361
|
+
const singularTag = tag.replace(/s$/, "");
|
|
362
|
+
const prefix = `${singularTag[0]?.toUpperCase() ?? ""}${singularTag.slice(1)}`;
|
|
363
|
+
const match = candidates.find((candidate) => candidate.startsWith(prefix));
|
|
364
|
+
if (match) return match;
|
|
365
|
+
}
|
|
366
|
+
return candidates[0];
|
|
367
|
+
}
|
|
368
|
+
function resolveSchemaAlias(context, schema, typeImports) {
|
|
369
|
+
const ref = readSchemaRef(schema);
|
|
370
|
+
if (!ref) return;
|
|
371
|
+
const schemaName = ref.split("/").pop();
|
|
372
|
+
if (!schemaName || !context.schemaNames.has(schemaName)) return;
|
|
373
|
+
typeImports.add(schemaName);
|
|
374
|
+
return schemaName;
|
|
375
|
+
}
|
|
376
|
+
function readSchemaRef(schema) {
|
|
377
|
+
if (!schema || typeof schema !== "object") return;
|
|
378
|
+
const maybeRef = schema.$ref;
|
|
379
|
+
return typeof maybeRef === "string" ? maybeRef : void 0;
|
|
380
|
+
}
|
|
381
|
+
function renderInlineParameterObject(parameters) {
|
|
382
|
+
return `{ ${parameters.map((parameter) => {
|
|
383
|
+
const optionalMarker = parameter.required ? "" : "?";
|
|
384
|
+
return `${parameter.name}${optionalMarker}: ${renderPrimitiveSchemaType(parameter.schema)}`;
|
|
385
|
+
}).join("; ")} }`;
|
|
386
|
+
}
|
|
387
|
+
function renderPrimitiveSchemaType(schema) {
|
|
388
|
+
const type = schema?.type;
|
|
389
|
+
if (!type) return "unknown";
|
|
390
|
+
if (Array.isArray(type)) return type.map((memberType) => mapPrimitiveType(memberType)).join(" | ");
|
|
391
|
+
return mapPrimitiveType(type);
|
|
392
|
+
}
|
|
393
|
+
function mapPrimitiveType(type) {
|
|
394
|
+
switch (type) {
|
|
395
|
+
case "integer":
|
|
396
|
+
case "number": return "number";
|
|
397
|
+
case "boolean": return "boolean";
|
|
398
|
+
case "null": return "null";
|
|
399
|
+
case "string": return "string";
|
|
400
|
+
default: return "unknown";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/plugin.ts
|
|
405
|
+
const HTTP_CLIENT_DEFAULTS = {
|
|
406
|
+
module: "#/integrations/http",
|
|
407
|
+
jsonFunction: "requestJson",
|
|
408
|
+
voidFunction: "requestVoid",
|
|
409
|
+
requestOptionsType: "ApiRequestOptions",
|
|
410
|
+
omitKeys: [
|
|
411
|
+
"json",
|
|
412
|
+
"method",
|
|
413
|
+
"searchParams",
|
|
414
|
+
"signal"
|
|
415
|
+
]
|
|
416
|
+
};
|
|
417
|
+
function resolveHttpClientConfig(config) {
|
|
418
|
+
return {
|
|
419
|
+
...HTTP_CLIENT_DEFAULTS,
|
|
420
|
+
...config
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const GENERATED_HEADER = ["// This file is auto-generated by vite-plugin-openapi-codegen.", "// Do not edit manually. Changes will be overwritten on next build."];
|
|
424
|
+
async function generateApiTypes(inputPath, outputDir) {
|
|
425
|
+
const { default: openapiTS, astToString } = await import("openapi-typescript");
|
|
426
|
+
const contents = astToString(await openapiTS(new URL(`file://${inputPath}`)));
|
|
427
|
+
writeFileSync(resolve(outputDir, "api-types.d.ts"), `${GENERATED_HEADER.join("\n")}\n\n${contents}`);
|
|
428
|
+
}
|
|
429
|
+
function renderTypesBarrel(spec, legacyAliases) {
|
|
430
|
+
const schemaNames = Object.keys(spec.components?.schemas ?? {}).sort();
|
|
431
|
+
if (schemaNames.length === 0) throw new Error("No schemas found in openapi.json");
|
|
432
|
+
return renderTypesSource(schemaNames, legacyAliases, GENERATED_HEADER);
|
|
433
|
+
}
|
|
434
|
+
function createApiEntries(normalizedOps) {
|
|
435
|
+
return normalizedOps.map((op) => ({
|
|
436
|
+
funcName: op.entry.funcName,
|
|
437
|
+
group: op.entry.group,
|
|
438
|
+
pathTypeExpr: op.pathChannel.present ? op.pathChannel.typeExpr : null,
|
|
439
|
+
strippedPath: op.entry.strippedPath
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
function createClientRenderModel(model) {
|
|
443
|
+
return {
|
|
444
|
+
needsSearchParamsHelper: model.needsSearchParamsHelper,
|
|
445
|
+
operations: model.operations.map((operation) => ({
|
|
446
|
+
bodyChannel: operation.bodyChannel,
|
|
447
|
+
builderAlias: operation.builderAlias,
|
|
448
|
+
funcName: operation.entry.funcName,
|
|
449
|
+
group: operation.entry.group,
|
|
450
|
+
methodUpper: operation.entry.method.toUpperCase(),
|
|
451
|
+
optionTypeName: operation.optionTypeName,
|
|
452
|
+
pathChannel: operation.pathChannel,
|
|
453
|
+
pathInvocationExpr: operation.pathInvocationExpr,
|
|
454
|
+
queryChannel: operation.queryChannel,
|
|
455
|
+
requestFunction: operation.requestFunction,
|
|
456
|
+
responseTypeExpr: operation.responseTypeExpr,
|
|
457
|
+
returnTypeExpr: operation.returnTypeExpr
|
|
458
|
+
})),
|
|
459
|
+
typeImports: model.typeImports
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function renderGeneratedArtifacts(spec, options, preCollectedOperations) {
|
|
463
|
+
const pathPrefix = options.pathPrefix ?? "/api/";
|
|
464
|
+
const stripPrefix = options.stripPrefix ?? true;
|
|
465
|
+
const httpClient = resolveHttpClientConfig(options.httpClient);
|
|
466
|
+
const clientModel = buildClientRenderModelFromOperations(preCollectedOperations ?? collectOperations(spec, pathPrefix, stripPrefix), spec, {
|
|
467
|
+
json: httpClient.jsonFunction,
|
|
468
|
+
void: httpClient.voidFunction
|
|
469
|
+
});
|
|
470
|
+
if (clientModel.operations.length === 0) throw new Error(`No paths matching prefix "${pathPrefix}" found in openapi.json`);
|
|
471
|
+
return {
|
|
472
|
+
api: renderApiSource(createApiEntries(clientModel.operations), GENERATED_HEADER),
|
|
473
|
+
client: renderClientSource(createClientRenderModel(clientModel), GENERATED_HEADER, httpClient),
|
|
474
|
+
types: renderTypesBarrel(spec, options.legacyAliases)
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async function generate(root, options) {
|
|
478
|
+
const inputPath = resolve(root, options.input);
|
|
479
|
+
const outputDir = resolve(root, options.output);
|
|
480
|
+
const spec = JSON.parse(readFileSync(inputPath, "utf-8"));
|
|
481
|
+
const operations = collectOperations(spec, options.pathPrefix ?? "/api/", options.stripPrefix ?? true);
|
|
482
|
+
const artifacts = renderGeneratedArtifacts(spec, options, operations);
|
|
483
|
+
warnOnParameterLocationMismatch(operations);
|
|
484
|
+
await generateApiTypes(inputPath, outputDir);
|
|
485
|
+
writeFileSync(resolve(outputDir, "types.ts"), artifacts.types);
|
|
486
|
+
writeFileSync(resolve(outputDir, "api.ts"), artifacts.api);
|
|
487
|
+
writeFileSync(resolve(outputDir, "client.ts"), artifacts.client);
|
|
488
|
+
}
|
|
489
|
+
function openapiCodegen(options) {
|
|
490
|
+
let root = process.cwd();
|
|
491
|
+
return {
|
|
492
|
+
name: "openapi-codegen",
|
|
493
|
+
enforce: "pre",
|
|
494
|
+
configResolved(config) {
|
|
495
|
+
root = config.root;
|
|
496
|
+
},
|
|
497
|
+
async buildStart() {
|
|
498
|
+
await generate(root, options);
|
|
499
|
+
},
|
|
500
|
+
configureServer(server) {
|
|
501
|
+
const inputPath = resolve(root, options.input);
|
|
502
|
+
server.watcher.add(inputPath);
|
|
503
|
+
server.watcher.on("change", async (path) => {
|
|
504
|
+
if (path === inputPath) {
|
|
505
|
+
console.log("[openapi-codegen] openapi.json changed, regenerating...");
|
|
506
|
+
await generate(root, options);
|
|
507
|
+
console.log("[openapi-codegen] regeneration complete.");
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
export { openapiCodegen, renderGeneratedArtifacts };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-openapi-codegen",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Vite plugin that generates typed API clients and route builders from OpenAPI specs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"api-client",
|
|
7
|
+
"codegen",
|
|
8
|
+
"openapi",
|
|
9
|
+
"openapi-typescript",
|
|
10
|
+
"route-builder",
|
|
11
|
+
"vite",
|
|
12
|
+
"vite-plugin",
|
|
13
|
+
"vite-plus"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/GGGLHHH/vite-plugin-openapi-codegen#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/GGGLHHH/vite-plugin-openapi-codegen/issues"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "ge Datou (https://github.com/GGGLHHH)",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/GGGLHHH/vite-plugin-openapi-codegen.git"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./dist/index.mjs",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "vp pack",
|
|
40
|
+
"dev": "vp pack --watch",
|
|
41
|
+
"test": "vp test",
|
|
42
|
+
"check": "vp check",
|
|
43
|
+
"release": "bumpp --no-verify",
|
|
44
|
+
"prepublishOnly": "vp run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"openapi-typescript": "^7.13.0",
|
|
48
|
+
"typescript": ">=5.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^25.5.0",
|
|
52
|
+
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
|
53
|
+
"bumpp": "^11.0.1",
|
|
54
|
+
"vite-plus": "^0.1.14"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"vite-plus": ">=0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"overrides": {
|
|
60
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
|
61
|
+
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
|
62
|
+
},
|
|
63
|
+
"packageManager": "npm@11.12.0"
|
|
64
|
+
}
|