elysia-openapi-codegen 0.1.5 → 0.1.7

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.
Files changed (2) hide show
  1. package/dist/index.js +142 -112
  2. package/package.json +19 -19
package/dist/index.js CHANGED
@@ -3,31 +3,27 @@
3
3
  // index.ts
4
4
  import fs from "fs";
5
5
  import path from "path";
6
- import https from "https";
7
- import http from "http";
8
6
  async function fetchSpec(source) {
9
7
  if (source.startsWith("http://") || source.startsWith("https://")) {
10
- return new Promise((resolve, reject) => {
11
- const client = source.startsWith("https://") ? https : http;
12
- client.get(source, (res) => {
13
- let data = "";
14
- res.on("data", (chunk) => data += chunk);
15
- res.on("end", () => {
16
- try {
17
- resolve(JSON.parse(data));
18
- } catch (e) {
19
- reject(new Error(`Failed to parse OpenAPI spec: ${e.message}`));
20
- }
21
- });
22
- res.on("error", reject);
23
- });
24
- });
8
+ const res = await fetch(source);
9
+ if (!res.ok)
10
+ throw new Error(`Failed to fetch spec: ${res.status} ${res.statusText}`);
11
+ return res.json();
25
12
  }
26
13
  return JSON.parse(fs.readFileSync(source, "utf8"));
27
14
  }
15
+ var TYPE_MAP = {
16
+ string: "string",
17
+ number: "number",
18
+ integer: "number",
19
+ boolean: "boolean",
20
+ null: "null"
21
+ };
28
22
  function resolveType(schema) {
29
23
  if (!schema)
30
24
  return "any";
25
+ if (schema.format === "binary")
26
+ return "File | Blob";
31
27
  if (schema.$ref) {
32
28
  return schema.$ref.split("/").pop() || "any";
33
29
  }
@@ -38,8 +34,7 @@ function resolveType(schema) {
38
34
  if (!schema.properties)
39
35
  return "Record<string, any>";
40
36
  const props = Object.entries(schema.properties).map(([key, prop]) => {
41
- const isRequired = schema.required?.includes(key);
42
- const optional = isRequired ? "" : "?";
37
+ const optional = schema.required?.includes(key) ? "" : "?";
43
38
  return ` ${key}${optional}: ${resolveType(prop)};`;
44
39
  });
45
40
  return `{
@@ -56,111 +51,141 @@ ${props.join(`
56
51
  if (schema.nullable) {
57
52
  return `${resolveType({ ...schema, nullable: false })} | null`;
58
53
  }
59
- const typeMap = {
60
- string: "string",
61
- number: "number",
62
- integer: "number",
63
- boolean: "boolean",
64
- null: "null"
65
- };
66
- return schema.type && typeMap[schema.type] || "any";
54
+ return schema.type && TYPE_MAP[schema.type] || "any";
55
+ }
56
+ function capitalize(str) {
57
+ return str.charAt(0).toUpperCase() + str.slice(1);
58
+ }
59
+ function toSafeIdentifier(id) {
60
+ return id.replace(/[^a-zA-Z0-9_-]/g, "").replace(/[-_]+(.?)/g, (_, c) => c ? c.toUpperCase() : "");
61
+ }
62
+ function getPathGroup(pathUrl) {
63
+ const segment = pathUrl.split("/").filter(Boolean)[0] || "root";
64
+ return toSafeIdentifier(segment);
67
65
  }
68
- function generateTypes(spec) {
69
- const definitions = [];
66
+ function generateGroups(spec) {
67
+ const groups = {};
68
+ const getGroup = (name) => {
69
+ if (!groups[name])
70
+ groups[name] = { types: [], hooks: [] };
71
+ return groups[name];
72
+ };
70
73
  if (spec.components?.schemas) {
74
+ const g = getGroup("schemas");
71
75
  for (const [name, schema] of Object.entries(spec.components.schemas)) {
72
- definitions.push(`export type ${name} = ${resolveType(schema)};`);
76
+ g.types.push(`export type ${name} = ${resolveType(schema)};`);
73
77
  }
74
78
  }
75
79
  for (const [pathUrl, pathItem] of Object.entries(spec.paths)) {
80
+ const g = getGroup(getPathGroup(pathUrl));
76
81
  for (const [method, operation] of Object.entries(pathItem)) {
77
82
  if (!["get", "post", "put", "patch", "delete"].includes(method))
78
83
  continue;
79
84
  const op = operation;
80
- const opId = op.operationId || `${method}${pathUrl.replace(/[^a-zA-Z0-9]/g, "")}`;
85
+ const opId = toSafeIdentifier(op.operationId || `${method}${pathUrl.replace(/[^a-zA-Z0-9]/g, "")}`);
81
86
  const response = op.responses?.["200"];
82
87
  if (response?.content?.["application/json"]?.schema) {
83
- const responseType = resolveType(response.content["application/json"].schema);
84
- definitions.push(`export type ${capitalize(opId)}Response = ${responseType};`);
88
+ g.types.push(`export type ${capitalize(opId)}Response = ${resolveType(response.content["application/json"].schema)};`);
85
89
  }
86
90
  const params = op.parameters || [];
87
91
  if (params.length > 0) {
88
92
  const paramProps = params.map((param) => {
89
- const required = param.required ? "" : "?";
90
- return ` ${param.name}${required}: ${resolveType(param.schema)};`;
93
+ const optional = param.in === "path" || param.required ? "" : "?";
94
+ return ` ${param.name}${optional}: ${resolveType(param.schema)};`;
91
95
  });
92
- definitions.push(`export type ${capitalize(opId)}Params = {
96
+ g.types.push(`export type ${capitalize(opId)}Params = {
93
97
  ${paramProps.join(`
94
98
  `)}
95
99
  };`);
96
100
  }
97
- if (op.requestBody?.content?.["application/json"]?.schema) {
98
- const bodyType = resolveType(op.requestBody.content["application/json"].schema);
99
- definitions.push(`export type ${capitalize(opId)}Body = ${bodyType};`);
101
+ const isMultipart = !op.requestBody?.content?.["application/json"] && !!op.requestBody?.content?.["multipart/form-data"];
102
+ const bodySchema = op.requestBody?.content?.["application/json"]?.schema ?? op.requestBody?.content?.["multipart/form-data"]?.schema;
103
+ if (bodySchema) {
104
+ g.types.push(`export type ${capitalize(opId)}Body = ${resolveType(bodySchema)};`);
100
105
  }
101
- }
102
- }
103
- return definitions.join(`
104
-
105
- `);
106
- }
107
- function generateHooks(spec) {
108
- const hooks = [];
109
- const baseUrl = spec.servers?.[0]?.url || "";
110
- for (const [pathUrl, pathItem] of Object.entries(spec.paths)) {
111
- for (const [method, operation] of Object.entries(pathItem)) {
112
- if (!["get", "post", "put", "patch", "delete"].includes(method))
113
- continue;
114
- const op = operation;
115
- const opId = op.operationId || `${method}${pathUrl.replace(/[^a-zA-Z0-9]/g, "")}`;
116
- const hasResponse = !!op.responses?.["200"]?.content?.["application/json"]?.schema;
106
+ const hasResponse = !!response?.content?.["application/json"]?.schema;
117
107
  const responseType = hasResponse ? `${capitalize(opId)}Response` : "any";
118
- const params = op.parameters || [];
119
108
  const hasParams = params.length > 0;
120
109
  const paramsType = hasParams ? `${capitalize(opId)}Params` : "void";
121
- const hasBody = !!op.requestBody?.content?.["application/json"]?.schema;
110
+ const hasBody = !!bodySchema;
122
111
  const bodyType = hasBody ? `${capitalize(opId)}Body` : "void";
123
112
  if (method === "get") {
124
- const queryParams = params.map((p) => p.name);
125
- const queryString = queryParams.length > 0 ? `?${queryParams.map((p) => `\${params?.${p} !== undefined ? '${p}=' + params.${p} : ''}`).join("&")}` : "";
126
- hooks.push(`
113
+ const pathParamsList = params.filter((p) => p.in === "path");
114
+ const queryParamsList = params.filter((p) => p.in === "query");
115
+ let urlPath = pathUrl;
116
+ for (const p of pathParamsList) {
117
+ urlPath = urlPath.replace(`{${p.name}}`, `\${params.${p.name}}`);
118
+ }
119
+ const qsLines = (indent) => queryParamsList.map((p) => `${indent}if (params?.${p.name} !== undefined) _qs.set('${p.name}', String(params.${p.name}));`).join(`
120
+ `);
121
+ if (!hasResponse) {
122
+ const body = queryParamsList.length > 0 ? ` const _qs = new URLSearchParams();
123
+ ${qsLines(" ")}
124
+ const _qstr = _qs.toString();
125
+ return \`\${baseUrl}${urlPath}\${_qstr ? '?' + _qstr : ''}\`;` : ` return \`\${baseUrl}${urlPath}\`;`;
126
+ g.hooks.push(`
127
+ export const ${opId}Url = (params${hasParams ? "" : "?"}: ${paramsType}): string => {
128
+ ${body}
129
+ };`);
130
+ continue;
131
+ }
132
+ const fetchStatement = queryParamsList.length > 0 ? ` const _qs = new URLSearchParams();
133
+ ${qsLines(" ")}
134
+ const _qstr = _qs.toString();
135
+ const res = await fetch(\`\${baseUrl}${urlPath}\${_qstr ? '?' + _qstr : ''}\`);` : ` const res = await fetch(\`\${baseUrl}${urlPath}\`);`;
136
+ const enabledGuard = pathParamsList.length > 0 ? `
137
+ enabled: ${pathParamsList.map((p) => `params.${p.name} != null`).join(" && ")},` : "";
138
+ g.hooks.push(`
127
139
  export const use${capitalize(opId)} = (
128
140
  params${hasParams ? "" : "?"}: ${paramsType},
129
141
  options?: Omit<UseQueryOptions<${responseType}>, 'queryKey' | 'queryFn'>
130
142
  ) => {
131
143
  return useQuery<${responseType}>({
132
- queryKey: ['${opId}', params],
144
+ queryKey: ['${opId}', params],${enabledGuard}
133
145
  queryFn: async () => {
134
- const res = await fetch(\`\${baseUrl}${pathUrl}${queryString}\`);
135
- if (!res.ok) throw new Error('API Error');
146
+ ${fetchStatement}
147
+ if (!res.ok) throw new Error(\`\${res.status}: \${await res.text()}\`);
136
148
  return res.json();
137
149
  },
138
150
  ...options,
139
151
  });
140
152
  };`);
141
153
  } else {
142
- let inputType = "void";
143
- let inputArg = "";
144
- if (hasBody) {
154
+ const pathParamsList = params.filter((p) => p.in === "path");
155
+ const hasPathParams = pathParamsList.length > 0;
156
+ let urlPath = pathUrl;
157
+ for (const p of pathParamsList) {
158
+ urlPath = urlPath.replace(`{${p.name}}`, `\${${p.name}}`);
159
+ }
160
+ let inputType;
161
+ let mutationArg;
162
+ if (hasBody && hasPathParams) {
163
+ inputType = `${capitalize(opId)}Params & ${capitalize(opId)}Body`;
164
+ mutationArg = `{ ${pathParamsList.map((p) => p.name).join(", ")}, ...body }`;
165
+ } else if (hasBody) {
145
166
  inputType = bodyType;
146
- inputArg = "body";
147
- } else if (hasParams) {
167
+ mutationArg = "body";
168
+ } else if (hasPathParams) {
148
169
  inputType = paramsType;
149
- inputArg = "params";
170
+ mutationArg = `{ ${pathParamsList.map((p) => p.name).join(", ")} }`;
171
+ } else {
172
+ inputType = "void";
173
+ mutationArg = "";
150
174
  }
151
- const hasInput = inputType !== "void";
152
- hooks.push(`
175
+ const fetchBodyLines = hasBody ? isMultipart ? `
176
+ body: Object.entries(body as Record<string, unknown>).reduce((f, [k, v]) => { f.append(k, v as any); return f; }, new FormData()),` : `
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify(body),` : "";
179
+ g.hooks.push(`
153
180
  export const use${capitalize(opId)} = (
154
181
  options?: UseMutationOptions<${responseType}, Error, ${inputType}>
155
182
  ) => {
156
183
  return useMutation<${responseType}, Error, ${inputType}>({
157
- mutationFn: async (${hasInput ? inputArg : ""}) => {
158
- const res = await fetch(\`\${baseUrl}${pathUrl}\`, {
159
- method: '${method.toUpperCase()}',
160
- headers: { 'Content-Type': 'application/json' },
161
- body: JSON.stringify(${hasInput ? inputArg : "{}"}),
184
+ mutationFn: async (${mutationArg}) => {
185
+ const res = await fetch(\`\${baseUrl}${urlPath}\`, {
186
+ method: '${method.toUpperCase()}',${fetchBodyLines}
162
187
  });
163
- if (!res.ok) throw new Error('API Error');
188
+ if (!res.ok) throw new Error(\`\${res.status}: \${await res.text()}\`);
164
189
  return res.json();
165
190
  },
166
191
  ...options,
@@ -169,18 +194,11 @@ export const use${capitalize(opId)} = (
169
194
  }
170
195
  }
171
196
  }
172
- return hooks.join(`
173
- `);
174
- }
175
- function capitalize(str) {
176
- return str.charAt(0).toUpperCase() + str.slice(1);
197
+ return groups;
177
198
  }
178
- async function parseArgs() {
199
+ function parseArgs() {
179
200
  const args = process.argv.slice(2);
180
- const config = {
181
- input: "",
182
- output: ""
183
- };
201
+ const config = { input: "", output: "" };
184
202
  for (let i = 0;i < args.length; i++) {
185
203
  const arg = args[i];
186
204
  if (!arg)
@@ -223,12 +241,7 @@ Arguments:
223
241
  -o, --output <output> Output directory for generated files
224
242
 
225
243
  Examples:
226
- # Using flags
227
244
  elysia-codegen -i https://api.example.com/openapi.json -o ./src/api
228
- elysia-codegen --input ./openapi.json --output ./generated
229
-
230
- # Using positional arguments
231
- elysia-codegen https://api.example.com/openapi.json ./src/api
232
245
  elysia-codegen ./openapi.json ./generated
233
246
  `);
234
247
  }
@@ -238,7 +251,7 @@ async function main() {
238
251
  showHelp();
239
252
  process.exit(0);
240
253
  }
241
- const { input, output } = await parseArgs();
254
+ const { input, output } = parseArgs();
242
255
  if (!input || !output) {
243
256
  console.error(`Error: Both input source and output directory are required.
244
257
  `);
@@ -248,28 +261,45 @@ async function main() {
248
261
  console.log("Fetching OpenAPI spec...");
249
262
  try {
250
263
  const spec = await fetchSpec(input);
251
- const types = generateTypes(spec);
252
- const hooks = generateHooks(spec);
253
- const baseUrl = spec.servers?.[0]?.url || "";
254
- const fileContent = `/* eslint-disable */
255
- /**
256
- * Auto-generated by Elysia OpenAPI Codegen
257
- */
258
-
259
- import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
260
-
261
- const baseUrl = '${baseUrl}';
262
-
263
- ${types}
264
-
265
- ${hooks}
266
- `;
264
+ const groups = generateGroups(spec);
265
+ const serverPath = spec.servers?.[0]?.url || "";
266
+ const baseUrl = input.startsWith("http://") || input.startsWith("https://") ? new URL(input).origin + serverPath : serverPath;
267
267
  if (!fs.existsSync(output)) {
268
268
  fs.mkdirSync(output, { recursive: true });
269
269
  }
270
- const outputPath = path.join(output, "generated.ts");
271
- fs.writeFileSync(outputPath, fileContent);
272
- console.log(`Successfully generated hooks at ${outputPath}`);
270
+ fs.writeFileSync(path.join(output, "base.ts"), `/* eslint-disable */
271
+ export const baseUrl = '${baseUrl}';
272
+ `);
273
+ const groupNames = [];
274
+ for (const [groupName, { types, hooks }] of Object.entries(groups)) {
275
+ if (types.length === 0 && hooks.length === 0)
276
+ continue;
277
+ const hasHooks = hooks.length > 0;
278
+ const sections = [`/* eslint-disable */`, `import { baseUrl } from './base';`];
279
+ if (hasHooks) {
280
+ sections.push(`import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';`);
281
+ }
282
+ if (types.length > 0)
283
+ sections.push(types.join(`
284
+
285
+ `));
286
+ if (hooks.length > 0)
287
+ sections.push(hooks.join(`
288
+ `));
289
+ fs.writeFileSync(path.join(output, `${groupName}.ts`), sections.join(`
290
+
291
+ `) + `
292
+ `);
293
+ groupNames.push(groupName);
294
+ console.log(` ↳ ${groupName}.ts (${types.length} types, ${hooks.length} hooks)`);
295
+ }
296
+ const indexContent = ["base", ...groupNames].map((f) => `export * from './${f}';`).join(`
297
+ `);
298
+ fs.writeFileSync(path.join(output, "index.ts"), `/* eslint-disable */
299
+ ${indexContent}
300
+ `);
301
+ console.log(`
302
+ Generated ${groupNames.length + 2} files at ${output}`);
273
303
  } catch (err) {
274
304
  console.error("Generation failed:", err instanceof Error ? err.message : String(err));
275
305
  process.exit(1);
package/package.json CHANGED
@@ -1,25 +1,30 @@
1
1
  {
2
2
  "name": "elysia-openapi-codegen",
3
- "version": "0.1.5",
4
- "description": "Generate React Query hooks and fully typed TypeScript interfaces from Elysia OpenAPI specs.",
5
- "module": "index.ts",
6
- "type": "module",
7
- "bin": {
8
- "elysia-codegen": "./dist/index.js"
9
- },
10
- "files": [
11
- "dist",
12
- "README.md"
13
- ],
3
+ "version": "0.1.7",
14
4
  "author": "Khantamir mkhantamir77@gmail.com",
15
- "license": "MIT",
16
5
  "repository": {
17
6
  "type": "git",
18
7
  "url": "https://github.com/mkhantamir/elysia-openapi-codegen.git"
19
8
  },
9
+ "main": "dist/index.js",
10
+ "module": "index.ts",
11
+ "devDependencies": {
12
+ "@types/bun": "latest"
13
+ },
14
+ "peerDependencies": {
15
+ "typescript": "^6.0.2"
16
+ },
17
+ "bin": {
18
+ "elysia-codegen": "./dist/index.js"
19
+ },
20
20
  "bugs": {
21
21
  "url": "https://github.com/mkhantamir/elysia-openapi-codegen/issues"
22
22
  },
23
+ "description": "Generate React Query hooks and fully typed TypeScript interfaces from Elysia OpenAPI specs.",
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
23
28
  "homepage": "https://github.com/mkhantamir/elysia-openapi-codegen#readme",
24
29
  "keywords": [
25
30
  "elysia",
@@ -29,14 +34,9 @@
29
34
  "react-query",
30
35
  "tanstack-query"
31
36
  ],
32
- "devDependencies": {
33
- "@types/bun": "latest"
34
- },
35
- "peerDependencies": {
36
- "typescript": "^6.0.2"
37
- },
37
+ "license": "MIT",
38
38
  "scripts": {
39
39
  "build": "bun build index.ts --outfile dist/index.js --target node"
40
40
  },
41
- "main": "dist/index.js"
41
+ "type": "module"
42
42
  }