vite-plugin-openapi-codegen 1.0.0 → 1.1.0
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 +53 -37
- package/dist/index.d.mts +2 -5
- package/dist/index.mjs +106 -79
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# vite-plugin-openapi-codegen
|
|
2
2
|
|
|
3
|
-
Generate typed API clients and path builders from
|
|
3
|
+
Generate typed API clients and path builders from a local or online OpenAPI document during Vite dev runs.
|
|
4
4
|
|
|
5
|
-
The plugin reads your OpenAPI spec, runs `openapi-typescript`, and emits
|
|
5
|
+
The plugin reads your OpenAPI JSON or YAML spec, runs `openapi-typescript`, and emits three files into your target directory:
|
|
6
6
|
|
|
7
7
|
- `api-types.d.ts` for raw OpenAPI-derived types
|
|
8
|
-
- `types.ts` for schema aliases
|
|
9
8
|
- `api.ts` for path builder functions
|
|
10
9
|
- `client.ts` for typed request helpers
|
|
11
10
|
|
|
12
|
-
It also watches
|
|
11
|
+
It also watches local input specs in dev mode and regenerates the files when the spec changes. In dev mode, generation runs in the background so Vite can finish starting even if the remote spec is temporarily unavailable. Online `http://` and `https://` inputs are fetched once when Vite starts. Build mode skips the plugin entirely.
|
|
13
12
|
|
|
14
13
|
## Installation
|
|
15
14
|
|
|
@@ -37,50 +36,66 @@ export default defineConfig({
|
|
|
37
36
|
});
|
|
38
37
|
```
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
Online YAML inputs use the same option:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { defineConfig } from "vite-plus";
|
|
43
|
+
import { openapiCodegen } from "vite-plugin-openapi-codegen";
|
|
44
|
+
|
|
45
|
+
export default defineConfig({
|
|
46
|
+
plugins: [
|
|
47
|
+
openapiCodegen({
|
|
48
|
+
input: "https://example.com/openapi.yaml",
|
|
49
|
+
output: "src/generated",
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
When you run `vp dev`, the plugin generates:
|
|
41
56
|
|
|
42
57
|
```text
|
|
43
58
|
src/generated/
|
|
44
59
|
api-types.d.ts
|
|
45
|
-
types.ts
|
|
46
60
|
api.ts
|
|
47
61
|
client.ts
|
|
48
62
|
```
|
|
49
63
|
|
|
50
|
-
##
|
|
64
|
+
## Example Projects
|
|
51
65
|
|
|
52
|
-
This repository includes
|
|
53
|
-
automatic generation from `vite.config.ts`.
|
|
66
|
+
This repository includes two minimal Vite demos under `example/`:
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
- `example/local` demonstrates generation from the shared local file `example/openapi.json`
|
|
69
|
+
- `example/online` demonstrates generation from `http://localhost:8080/openapi.yaml`
|
|
70
|
+
- Both demos reuse the shared runtime helper in `example/src/http.ts`
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
- `example/openapi.json` is the input spec
|
|
59
|
-
- `example/src/http.ts` provides the runtime symbols used by generated clients
|
|
60
|
-
- `example/src/generated/*` is generated during build/dev and is gitignored
|
|
72
|
+
Run the local demo in dev mode:
|
|
61
73
|
|
|
62
|
-
|
|
74
|
+
```bash
|
|
75
|
+
vp dev example/local --config ./example/local/vite.config.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Run the online demo in dev mode:
|
|
63
79
|
|
|
64
80
|
```bash
|
|
65
|
-
vp
|
|
81
|
+
vp dev example/online --config ./example/online/vite.config.ts
|
|
66
82
|
```
|
|
67
83
|
|
|
68
|
-
|
|
84
|
+
Start both demos together with the shared mock OpenAPI server:
|
|
69
85
|
|
|
70
86
|
```bash
|
|
71
|
-
vp
|
|
87
|
+
vp run dev:examples
|
|
72
88
|
```
|
|
73
89
|
|
|
74
|
-
|
|
90
|
+
Build either demo shell without running code generation:
|
|
75
91
|
|
|
76
|
-
```
|
|
77
|
-
example/
|
|
78
|
-
|
|
79
|
-
types.ts
|
|
80
|
-
api.ts
|
|
81
|
-
client.ts
|
|
92
|
+
```bash
|
|
93
|
+
vp build example/local --config ./example/local/vite.config.ts
|
|
94
|
+
vp build example/online --config ./example/online/vite.config.ts
|
|
82
95
|
```
|
|
83
96
|
|
|
97
|
+
When you run the dev server, generated files are written to `example/<demo>/src/generated/` and are ignored by git.
|
|
98
|
+
|
|
84
99
|
## Runtime Contract
|
|
85
100
|
|
|
86
101
|
By default, generated clients import the following symbols from `#/integrations/http`:
|
|
@@ -139,7 +154,9 @@ export default defineConfig({
|
|
|
139
154
|
Given a spec path like `/api/users/{user_id}`, the plugin generates a path builder:
|
|
140
155
|
|
|
141
156
|
```ts
|
|
142
|
-
|
|
157
|
+
import type { components } from "./api-types";
|
|
158
|
+
|
|
159
|
+
export function getUser(params: components["schemas"]["UserPath"]): string {
|
|
143
160
|
return `users/${params.user_id}`;
|
|
144
161
|
}
|
|
145
162
|
```
|
|
@@ -147,9 +164,11 @@ export function getUser(params: UserPath): string {
|
|
|
147
164
|
And a typed client helper:
|
|
148
165
|
|
|
149
166
|
```ts
|
|
167
|
+
import type { components } from "./api-types";
|
|
168
|
+
|
|
150
169
|
export interface GetUserOptions {
|
|
151
170
|
query?: never;
|
|
152
|
-
path: UserPath;
|
|
171
|
+
path: components["schemas"]["UserPath"];
|
|
153
172
|
body?: never;
|
|
154
173
|
signal?: AbortSignal;
|
|
155
174
|
}
|
|
@@ -157,8 +176,8 @@ export interface GetUserOptions {
|
|
|
157
176
|
export function getUser(
|
|
158
177
|
options: GetUserOptions,
|
|
159
178
|
requestOptions: RuntimeRequestOptions = {},
|
|
160
|
-
): Promise<UserResponse> {
|
|
161
|
-
return requestJson<UserResponse>(buildGetUserPath(options.path), {
|
|
179
|
+
): Promise<components["schemas"]["UserResponse"]> {
|
|
180
|
+
return requestJson<components["schemas"]["UserResponse"]>(buildGetUserPath(options.path), {
|
|
162
181
|
...requestOptions,
|
|
163
182
|
method: "GET",
|
|
164
183
|
signal: options.signal,
|
|
@@ -189,13 +208,14 @@ interface Options {
|
|
|
189
208
|
requestOptionsType?: string;
|
|
190
209
|
omitKeys?: string[];
|
|
191
210
|
};
|
|
192
|
-
legacyAliases?: Record<string, string>;
|
|
193
211
|
}
|
|
194
212
|
```
|
|
195
213
|
|
|
196
214
|
### `input`
|
|
197
215
|
|
|
198
|
-
Path to the OpenAPI JSON
|
|
216
|
+
Path or URL to the OpenAPI JSON/YAML document. Local paths are resolved relative to the Vite project root and watched in dev mode. Online `http://` and `https://` URLs are fetched once at startup and are not watched.
|
|
217
|
+
|
|
218
|
+
In dev mode, generation errors are logged and do not stop Vite from starting. In build mode, the plugin is skipped entirely.
|
|
199
219
|
|
|
200
220
|
### `output`
|
|
201
221
|
|
|
@@ -213,10 +233,6 @@ Controls whether the `pathPrefix` is removed from generated path builders. The d
|
|
|
213
233
|
|
|
214
234
|
Overrides the runtime import path and symbol names used by generated clients.
|
|
215
235
|
|
|
216
|
-
### `legacyAliases`
|
|
217
|
-
|
|
218
|
-
Adds extra type aliases to `types.ts` so you can preserve older type names during migrations.
|
|
219
|
-
|
|
220
236
|
## Programmatic Usage
|
|
221
237
|
|
|
222
238
|
If you want to generate artifacts outside the Vite lifecycle, use `renderGeneratedArtifacts`:
|
|
@@ -234,7 +250,6 @@ const files = renderGeneratedArtifacts(spec, {
|
|
|
234
250
|
|
|
235
251
|
console.log(files.api);
|
|
236
252
|
console.log(files.client);
|
|
237
|
-
console.log(files.types);
|
|
238
253
|
```
|
|
239
254
|
|
|
240
255
|
## Development
|
|
@@ -249,5 +264,6 @@ vp pack
|
|
|
249
264
|
To validate the real example project as part of local development:
|
|
250
265
|
|
|
251
266
|
```bash
|
|
252
|
-
vp build example --config ./example/vite.config.ts
|
|
267
|
+
vp build example/local --config ./example/local/vite.config.ts
|
|
268
|
+
vp build example/online --config ./example/online/vite.config.ts
|
|
253
269
|
```
|
package/dist/index.d.mts
CHANGED
|
@@ -65,7 +65,7 @@ interface HttpClientConfig {
|
|
|
65
65
|
omitKeys?: string[];
|
|
66
66
|
}
|
|
67
67
|
interface Options {
|
|
68
|
-
/** Path
|
|
68
|
+
/** Path or HTTP(S) URL to an OpenAPI JSON/YAML document */
|
|
69
69
|
input: string;
|
|
70
70
|
/** Output directory for generated files (relative to project root) */
|
|
71
71
|
output: string;
|
|
@@ -75,15 +75,12 @@ interface Options {
|
|
|
75
75
|
stripPrefix?: boolean;
|
|
76
76
|
/** HTTP client configuration */
|
|
77
77
|
httpClient?: HttpClientConfig;
|
|
78
|
-
/** Legacy type aliases for types.ts: { OldName: 'NewName' } */
|
|
79
|
-
legacyAliases?: Record<string, string>;
|
|
80
78
|
}
|
|
81
79
|
interface GeneratedArtifacts {
|
|
82
80
|
api: string;
|
|
83
81
|
client: string;
|
|
84
|
-
types: string;
|
|
85
82
|
}
|
|
86
|
-
declare function renderGeneratedArtifacts(spec: OpenAPISpec, options: Pick<Options, "httpClient" | "
|
|
83
|
+
declare function renderGeneratedArtifacts(spec: OpenAPISpec, options: Pick<Options, "httpClient" | "pathPrefix" | "stripPrefix">, preCollectedOperations?: OperationEntry[]): GeneratedArtifacts;
|
|
87
84
|
declare function openapiCodegen(options: Options): Plugin;
|
|
88
85
|
//#endregion
|
|
89
86
|
export { type HttpClientConfig, type OpenAPISpec, type OperationEntry, type Options, openapiCodegen, renderGeneratedArtifacts };
|
package/dist/index.mjs
CHANGED
|
@@ -1,45 +1,35 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { parse } from "yaml";
|
|
3
5
|
import * as ts from "typescript";
|
|
4
6
|
//#region src/ast.ts
|
|
5
7
|
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
8
|
function renderApiSource(entries, generatedHeader) {
|
|
16
9
|
const statements = [];
|
|
17
10
|
const seenGroups = /* @__PURE__ */ new Set();
|
|
18
|
-
let needsOperationsImport = false;
|
|
19
|
-
const typeAliasImports = /* @__PURE__ */ new Set();
|
|
20
11
|
for (const entry of entries) {
|
|
21
12
|
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
13
|
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
14
|
statements.push(seenGroups.has(entry.group) ? declaration : createGeneratedBannerComment(declaration, capitalize$1(entry.group)));
|
|
28
15
|
seenGroups.add(entry.group);
|
|
29
16
|
}
|
|
30
17
|
const sourceStatements = [];
|
|
31
|
-
|
|
32
|
-
if (
|
|
18
|
+
const apiTypeImports = collectApiTypeImports(entries.map((entry) => entry.pathTypeExpr));
|
|
19
|
+
if (apiTypeImports.length > 0) sourceStatements.push(createTypeOnlyImport(apiTypeImports, "./api-types"));
|
|
33
20
|
sourceStatements.push(...statements);
|
|
34
21
|
return printGeneratedFile(sourceStatements, generatedHeader);
|
|
35
22
|
}
|
|
36
23
|
function renderClientSource(model, generatedHeader, httpClient) {
|
|
37
|
-
const statements = [
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
24
|
+
const statements = [createTypeOnlyImport([httpClient.requestOptionsType], httpClient.module), createValueImport([{ name: httpClient.jsonFunction }, { name: httpClient.voidFunction }], httpClient.module)];
|
|
25
|
+
const apiTypeImports = collectApiTypeImports(model.operations.flatMap((operation) => [
|
|
26
|
+
operation.bodyChannel.typeExpr,
|
|
27
|
+
operation.pathChannel.typeExpr,
|
|
28
|
+
operation.queryChannel.typeExpr,
|
|
29
|
+
operation.responseTypeExpr,
|
|
30
|
+
operation.returnTypeExpr
|
|
31
|
+
]));
|
|
32
|
+
if (apiTypeImports.length > 0) statements.push(createTypeOnlyImport(apiTypeImports, "./api-types"));
|
|
43
33
|
statements.push(createValueImport(model.operations.map((operation) => ({
|
|
44
34
|
alias: operation.builderAlias,
|
|
45
35
|
name: operation.funcName
|
|
@@ -80,13 +70,14 @@ function createExportModifier() {
|
|
|
80
70
|
function createGeneratedBannerComment(node, text) {
|
|
81
71
|
return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, ` ${text}`, true);
|
|
82
72
|
}
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
function collectApiTypeImports(typeExprs) {
|
|
74
|
+
const names = /* @__PURE__ */ new Set();
|
|
75
|
+
for (const typeExpr of typeExprs) {
|
|
76
|
+
if (!typeExpr) continue;
|
|
77
|
+
if (typeExpr.includes("components[")) names.add("components");
|
|
78
|
+
if (typeExpr.includes("operations[")) names.add("operations");
|
|
79
|
+
}
|
|
80
|
+
return [...names].sort();
|
|
90
81
|
}
|
|
91
82
|
function createPathExpression(strippedPath) {
|
|
92
83
|
const matches = [...strippedPath.matchAll(/\{(\w+)\}/g)];
|
|
@@ -151,12 +142,10 @@ function buildClientRenderModelFromOperations(operations, spec, requestFunctionN
|
|
|
151
142
|
void: "requestVoid"
|
|
152
143
|
}) {
|
|
153
144
|
const context = buildNormalizationContext(spec);
|
|
154
|
-
const
|
|
155
|
-
const normalized = operations.map((entry) => normalizeOperation(entry, context, typeImports, requestFunctionNames));
|
|
145
|
+
const normalized = operations.map((entry) => normalizeOperation(entry, context, requestFunctionNames));
|
|
156
146
|
return {
|
|
157
147
|
operations: normalized,
|
|
158
|
-
needsSearchParamsHelper: normalized.some((operation) => operation.queryChannel.present)
|
|
159
|
-
typeImports: [...typeImports].sort()
|
|
148
|
+
needsSearchParamsHelper: normalized.some((operation) => operation.queryChannel.present)
|
|
160
149
|
};
|
|
161
150
|
}
|
|
162
151
|
function collectOperations(spec, pathPrefix = "/api/", stripPrefix = true) {
|
|
@@ -275,24 +264,21 @@ function getTemplateParameterNames(apiPath) {
|
|
|
275
264
|
const matches = apiPath.match(/\{(\w+)\}/g) ?? [];
|
|
276
265
|
return new Set(matches.map((match) => match.slice(1, -1)));
|
|
277
266
|
}
|
|
278
|
-
function resolveParameterTypeExpression(entry, context, location
|
|
267
|
+
function resolveParameterTypeExpression(entry, context, location) {
|
|
279
268
|
const effectiveParameters = getEffectiveParametersByLocation(entry, location);
|
|
280
269
|
if (effectiveParameters.length === 0) return "never";
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
typeImports.add(alias);
|
|
284
|
-
return alias;
|
|
285
|
-
}
|
|
270
|
+
const schemaTypeExpr = resolveAlias(context, effectiveParameters.map((parameter) => parameter.name), entry.operation.tags?.[0]);
|
|
271
|
+
if (schemaTypeExpr) return schemaTypeExpr;
|
|
286
272
|
if (hasSameParameterNames(getParametersByLocation(entry.operation, location), effectiveParameters)) return `operations['${entry.operationId}']['parameters']['${location}']`;
|
|
287
273
|
return renderInlineParameterObject(effectiveParameters);
|
|
288
274
|
}
|
|
289
|
-
function normalizeOperation(entry, context,
|
|
275
|
+
function normalizeOperation(entry, context, requestFunctionNames) {
|
|
290
276
|
const successResponse = getSuccessResponseInfo(entry.operation);
|
|
291
277
|
const builderAlias = getBuilderAlias(entry.funcName);
|
|
292
|
-
const pathChannel = normalizeParameterChannel(entry, context, "path"
|
|
293
|
-
const queryChannel = normalizeParameterChannel(entry, context, "query"
|
|
294
|
-
const bodyChannel = normalizeBodyChannel(entry, context
|
|
295
|
-
const responseTypeExpr = resolveResponseTypeExpression(entry, context,
|
|
278
|
+
const pathChannel = normalizeParameterChannel(entry, context, "path");
|
|
279
|
+
const queryChannel = normalizeParameterChannel(entry, context, "query");
|
|
280
|
+
const bodyChannel = normalizeBodyChannel(entry, context);
|
|
281
|
+
const responseTypeExpr = resolveResponseTypeExpression(entry, context, successResponse);
|
|
296
282
|
return {
|
|
297
283
|
bodyChannel,
|
|
298
284
|
builderAlias,
|
|
@@ -306,7 +292,7 @@ function normalizeOperation(entry, context, typeImports, requestFunctionNames) {
|
|
|
306
292
|
returnTypeExpr: responseTypeExpr ? `Promise<${responseTypeExpr}>` : "Promise<void>"
|
|
307
293
|
};
|
|
308
294
|
}
|
|
309
|
-
function normalizeParameterChannel(entry, context, location
|
|
295
|
+
function normalizeParameterChannel(entry, context, location) {
|
|
310
296
|
const parameters = getEffectiveParametersByLocation(entry, location);
|
|
311
297
|
if (parameters.length === 0) return {
|
|
312
298
|
present: false,
|
|
@@ -316,11 +302,11 @@ function normalizeParameterChannel(entry, context, location, typeImports) {
|
|
|
316
302
|
return {
|
|
317
303
|
present: true,
|
|
318
304
|
required: hasRequiredChannel(parameters),
|
|
319
|
-
typeExpr: resolveParameterTypeExpression(entry, context, location
|
|
305
|
+
typeExpr: resolveParameterTypeExpression(entry, context, location)
|
|
320
306
|
};
|
|
321
307
|
}
|
|
322
|
-
function normalizeBodyChannel(entry, context
|
|
323
|
-
const typeExpr = resolveRequestBodyTypeExpression(entry, context
|
|
308
|
+
function normalizeBodyChannel(entry, context) {
|
|
309
|
+
const typeExpr = resolveRequestBodyTypeExpression(entry, context);
|
|
324
310
|
if (!typeExpr) return {
|
|
325
311
|
present: false,
|
|
326
312
|
required: false,
|
|
@@ -332,18 +318,18 @@ function normalizeBodyChannel(entry, context, typeImports) {
|
|
|
332
318
|
typeExpr
|
|
333
319
|
};
|
|
334
320
|
}
|
|
335
|
-
function resolveRequestBodyTypeExpression(entry, context
|
|
321
|
+
function resolveRequestBodyTypeExpression(entry, context) {
|
|
336
322
|
const jsonBody = getJsonRequestBody(entry.operation);
|
|
337
323
|
if (!jsonBody) return null;
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
324
|
+
const schemaTypeExpr = resolveSchemaTypeExpression(context, jsonBody.schema);
|
|
325
|
+
if (schemaTypeExpr) return schemaTypeExpr;
|
|
340
326
|
return `operations['${entry.operationId}']['requestBody']['content']['application/json']`;
|
|
341
327
|
}
|
|
342
|
-
function resolveResponseTypeExpression(entry, context,
|
|
328
|
+
function resolveResponseTypeExpression(entry, context, successResponse) {
|
|
343
329
|
if (!successResponse.hasJsonBody) return null;
|
|
344
330
|
const jsonContent = (entry.operation.responses?.[successResponse.statusKey])?.content?.["application/json"];
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
331
|
+
const schemaTypeExpr = resolveSchemaTypeExpression(context, jsonContent?.schema);
|
|
332
|
+
if (schemaTypeExpr) return schemaTypeExpr;
|
|
347
333
|
return `operations['${entry.operationId}']['responses'][${formatStatusKey(successResponse.statusKey)}]['content']['application/json']`;
|
|
348
334
|
}
|
|
349
335
|
function hasSameParameterNames(left, right) {
|
|
@@ -356,22 +342,24 @@ function resolveAlias(context, parameterNames, tag) {
|
|
|
356
342
|
const key = [...parameterNames].sort().join(",");
|
|
357
343
|
const candidates = context.schemaAliasIndex.get(key);
|
|
358
344
|
if (!candidates || candidates.length === 0) return;
|
|
359
|
-
if (candidates.length === 1) return candidates[0];
|
|
345
|
+
if (candidates.length === 1) return createSchemaTypeExpression(candidates[0]);
|
|
360
346
|
if (tag) {
|
|
361
347
|
const singularTag = tag.replace(/s$/, "");
|
|
362
348
|
const prefix = `${singularTag[0]?.toUpperCase() ?? ""}${singularTag.slice(1)}`;
|
|
363
349
|
const match = candidates.find((candidate) => candidate.startsWith(prefix));
|
|
364
|
-
if (match) return match;
|
|
350
|
+
if (match) return createSchemaTypeExpression(match);
|
|
365
351
|
}
|
|
366
|
-
return candidates[0];
|
|
352
|
+
return createSchemaTypeExpression(candidates[0]);
|
|
367
353
|
}
|
|
368
|
-
function
|
|
354
|
+
function resolveSchemaTypeExpression(context, schema) {
|
|
369
355
|
const ref = readSchemaRef(schema);
|
|
370
356
|
if (!ref) return;
|
|
371
357
|
const schemaName = ref.split("/").pop();
|
|
372
358
|
if (!schemaName || !context.schemaNames.has(schemaName)) return;
|
|
373
|
-
|
|
374
|
-
|
|
359
|
+
return createSchemaTypeExpression(schemaName);
|
|
360
|
+
}
|
|
361
|
+
function createSchemaTypeExpression(schemaName) {
|
|
362
|
+
return `components['schemas']['${schemaName}']`;
|
|
375
363
|
}
|
|
376
364
|
function readSchemaRef(schema) {
|
|
377
365
|
if (!schema || typeof schema !== "object") return;
|
|
@@ -421,16 +409,11 @@ function resolveHttpClientConfig(config) {
|
|
|
421
409
|
};
|
|
422
410
|
}
|
|
423
411
|
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(
|
|
412
|
+
async function generateApiTypes(source, outputDir) {
|
|
425
413
|
const { default: openapiTS, astToString } = await import("openapi-typescript");
|
|
426
|
-
const contents = astToString(await openapiTS(
|
|
414
|
+
const contents = astToString(await openapiTS(source));
|
|
427
415
|
writeFileSync(resolve(outputDir, "api-types.d.ts"), `${GENERATED_HEADER.join("\n")}\n\n${contents}`);
|
|
428
416
|
}
|
|
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
417
|
function createApiEntries(normalizedOps) {
|
|
435
418
|
return normalizedOps.map((op) => ({
|
|
436
419
|
funcName: op.entry.funcName,
|
|
@@ -455,8 +438,7 @@ function createClientRenderModel(model) {
|
|
|
455
438
|
requestFunction: operation.requestFunction,
|
|
456
439
|
responseTypeExpr: operation.responseTypeExpr,
|
|
457
440
|
returnTypeExpr: operation.returnTypeExpr
|
|
458
|
-
}))
|
|
459
|
-
typeImports: model.typeImports
|
|
441
|
+
}))
|
|
460
442
|
};
|
|
461
443
|
}
|
|
462
444
|
function renderGeneratedArtifacts(spec, options, preCollectedOperations) {
|
|
@@ -470,46 +452,91 @@ function renderGeneratedArtifacts(spec, options, preCollectedOperations) {
|
|
|
470
452
|
if (clientModel.operations.length === 0) throw new Error(`No paths matching prefix "${pathPrefix}" found in openapi.json`);
|
|
471
453
|
return {
|
|
472
454
|
api: renderApiSource(createApiEntries(clientModel.operations), GENERATED_HEADER),
|
|
473
|
-
client: renderClientSource(createClientRenderModel(clientModel), GENERATED_HEADER, httpClient)
|
|
474
|
-
types: renderTypesBarrel(spec, options.legacyAliases)
|
|
455
|
+
client: renderClientSource(createClientRenderModel(clientModel), GENERATED_HEADER, httpClient)
|
|
475
456
|
};
|
|
476
457
|
}
|
|
458
|
+
async function loadOpenAPIInput(root, input) {
|
|
459
|
+
if (isHttpUrl(input)) {
|
|
460
|
+
const sourceText = await fetchRemoteOpenAPIInput(input);
|
|
461
|
+
return {
|
|
462
|
+
apiTypesSource: sourceText,
|
|
463
|
+
spec: parseOpenAPISpec(sourceText, input)
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const inputPath = resolve(root, input);
|
|
467
|
+
const sourceText = readFileSync(inputPath, "utf-8");
|
|
468
|
+
return {
|
|
469
|
+
apiTypesSource: pathToFileURL(inputPath),
|
|
470
|
+
spec: parseOpenAPISpec(sourceText, inputPath)
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function isHttpUrl(value) {
|
|
474
|
+
try {
|
|
475
|
+
const url = new URL(value);
|
|
476
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function fetchRemoteOpenAPIInput(input) {
|
|
482
|
+
const response = await fetch(input);
|
|
483
|
+
if (!response.ok) throw new Error(`[openapi-codegen] Failed to fetch OpenAPI document from ${input}: ${response.status} ${response.statusText}`);
|
|
484
|
+
return response.text();
|
|
485
|
+
}
|
|
486
|
+
function parseOpenAPISpec(sourceText, inputLabel) {
|
|
487
|
+
const parsed = parse(sourceText);
|
|
488
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`[openapi-codegen] Expected OpenAPI document object from ${inputLabel}`);
|
|
489
|
+
return parsed;
|
|
490
|
+
}
|
|
477
491
|
async function generate(root, options) {
|
|
478
|
-
const inputPath = resolve(root, options.input);
|
|
479
492
|
const outputDir = resolve(root, options.output);
|
|
480
493
|
mkdirSync(outputDir, { recursive: true });
|
|
481
|
-
const spec =
|
|
494
|
+
const { apiTypesSource, spec } = await loadOpenAPIInput(root, options.input);
|
|
482
495
|
const operations = collectOperations(spec, options.pathPrefix ?? "/api/", options.stripPrefix ?? true);
|
|
483
496
|
const artifacts = renderGeneratedArtifacts(spec, options, operations);
|
|
484
497
|
warnOnParameterLocationMismatch(operations);
|
|
485
|
-
await generateApiTypes(
|
|
486
|
-
writeFileSync(resolve(outputDir, "types.ts"), artifacts.types);
|
|
498
|
+
await generateApiTypes(apiTypesSource, outputDir);
|
|
487
499
|
writeFileSync(resolve(outputDir, "api.ts"), artifacts.api);
|
|
488
500
|
writeFileSync(resolve(outputDir, "client.ts"), artifacts.client);
|
|
489
501
|
}
|
|
490
502
|
function openapiCodegen(options) {
|
|
491
503
|
let root = process.cwd();
|
|
504
|
+
let command = "build";
|
|
492
505
|
return {
|
|
493
506
|
name: "openapi-codegen",
|
|
494
507
|
enforce: "pre",
|
|
495
508
|
configResolved(config) {
|
|
496
509
|
root = config.root;
|
|
510
|
+
command = config.command;
|
|
497
511
|
},
|
|
498
512
|
async buildStart() {
|
|
499
|
-
|
|
513
|
+
if (command === "serve") {
|
|
514
|
+
runDevelopmentGeneration(root, options);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
500
517
|
},
|
|
501
518
|
configureServer(server) {
|
|
519
|
+
if (isHttpUrl(options.input)) return;
|
|
502
520
|
const inputPath = resolve(root, options.input);
|
|
503
521
|
server.watcher.add(inputPath);
|
|
504
522
|
server.watcher.on("change", async (path) => {
|
|
505
523
|
if (path === inputPath) {
|
|
506
524
|
console.log("[openapi-codegen] openapi.json changed, regenerating...");
|
|
507
|
-
|
|
508
|
-
|
|
525
|
+
runDevelopmentGeneration(root, options, { onSuccess: () => {
|
|
526
|
+
console.log("[openapi-codegen] regeneration complete.");
|
|
527
|
+
} });
|
|
509
528
|
}
|
|
510
529
|
});
|
|
511
530
|
}
|
|
512
531
|
};
|
|
513
532
|
}
|
|
533
|
+
async function runDevelopmentGeneration(root, options, handlers) {
|
|
534
|
+
try {
|
|
535
|
+
await generate(root, options);
|
|
536
|
+
handlers?.onSuccess?.();
|
|
537
|
+
} catch (error) {
|
|
538
|
+
console.error("[openapi-codegen] generation failed during dev mode.", error);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
514
541
|
//#endregion
|
|
515
542
|
export { openapiCodegen, renderGeneratedArtifacts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-openapi-codegen",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Vite plugin that generates typed API clients and route builders from OpenAPI specs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-client",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "vp pack",
|
|
40
40
|
"dev": "vp pack --watch",
|
|
41
|
+
"dev:examples": "node scripts/dev-examples.mjs",
|
|
41
42
|
"test": "vp test",
|
|
42
43
|
"check": "vp check",
|
|
43
44
|
"release": "bumpp --no-verify",
|
|
@@ -45,7 +46,8 @@
|
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"openapi-typescript": "^7.13.0",
|
|
48
|
-
"typescript": ">=5.0.0"
|
|
49
|
+
"typescript": ">=5.0.0",
|
|
50
|
+
"yaml": "^2.9.0"
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@types/node": "^25.5.0",
|