nuxt-openapi-hyperfetch 0.3.81-beta → 1.0.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.
Files changed (64) hide show
  1. package/README.md +218 -212
  2. package/dist/generators/components/connector-generator/templates.js +67 -17
  3. package/dist/generators/components/schema-analyzer/intent-detector.js +1 -12
  4. package/dist/generators/components/schema-analyzer/openapi-reader.js +10 -1
  5. package/dist/generators/components/schema-analyzer/resource-grouper.js +7 -0
  6. package/dist/generators/components/schema-analyzer/schema-field-mapper.js +1 -22
  7. package/dist/generators/components/schema-analyzer/types.d.ts +10 -0
  8. package/dist/generators/connectors/generator.d.ts +12 -0
  9. package/dist/generators/connectors/generator.js +115 -0
  10. package/dist/generators/connectors/runtime/connector-types.d.ts +147 -0
  11. package/dist/generators/connectors/runtime/connector-types.js +10 -0
  12. package/dist/generators/connectors/runtime/useCreateConnector.d.ts +26 -0
  13. package/dist/generators/connectors/runtime/useCreateConnector.js +156 -0
  14. package/dist/generators/connectors/runtime/useDeleteConnector.d.ts +30 -0
  15. package/dist/generators/connectors/runtime/useDeleteConnector.js +143 -0
  16. package/dist/generators/connectors/runtime/useGetAllConnector.d.ts +25 -0
  17. package/dist/generators/connectors/runtime/useGetAllConnector.js +127 -0
  18. package/dist/generators/connectors/runtime/useGetConnector.d.ts +15 -0
  19. package/dist/generators/connectors/runtime/useGetConnector.js +99 -0
  20. package/dist/generators/connectors/runtime/useUpdateConnector.d.ts +34 -0
  21. package/dist/generators/connectors/runtime/useUpdateConnector.js +211 -0
  22. package/dist/generators/connectors/runtime/zod-error-merger.d.ts +23 -0
  23. package/dist/generators/connectors/runtime/zod-error-merger.js +106 -0
  24. package/dist/generators/connectors/templates.d.ts +4 -0
  25. package/dist/generators/connectors/templates.js +376 -0
  26. package/dist/generators/connectors/types.d.ts +37 -0
  27. package/dist/generators/connectors/types.js +7 -0
  28. package/dist/generators/shared/runtime/useDeleteConnector.js +4 -2
  29. package/dist/generators/shared/runtime/useDetailConnector.d.ts +0 -1
  30. package/dist/generators/shared/runtime/useDetailConnector.js +9 -20
  31. package/dist/generators/shared/runtime/useFormConnector.js +4 -3
  32. package/dist/generators/use-async-data/runtime/useApiAsyncData.js +14 -5
  33. package/dist/generators/use-async-data/templates.js +20 -16
  34. package/dist/generators/use-fetch/templates.js +1 -1
  35. package/dist/index.js +1 -16
  36. package/dist/module/index.js +2 -3
  37. package/package.json +4 -3
  38. package/src/cli/prompts.ts +1 -7
  39. package/src/generators/components/connector-generator/templates.ts +97 -22
  40. package/src/generators/components/schema-analyzer/intent-detector.ts +1 -16
  41. package/src/generators/components/schema-analyzer/openapi-reader.ts +14 -1
  42. package/src/generators/components/schema-analyzer/resource-grouper.ts +9 -0
  43. package/src/generators/components/schema-analyzer/schema-field-mapper.ts +1 -26
  44. package/src/generators/components/schema-analyzer/types.ts +11 -0
  45. package/src/generators/connectors/generator.ts +137 -0
  46. package/src/generators/connectors/runtime/connector-types.ts +207 -0
  47. package/src/generators/connectors/runtime/useCreateConnector.ts +199 -0
  48. package/src/generators/connectors/runtime/useDeleteConnector.ts +179 -0
  49. package/src/generators/connectors/runtime/useGetAllConnector.ts +151 -0
  50. package/src/generators/connectors/runtime/useGetConnector.ts +120 -0
  51. package/src/generators/connectors/runtime/useUpdateConnector.ts +257 -0
  52. package/src/generators/connectors/runtime/zod-error-merger.ts +119 -0
  53. package/src/generators/connectors/templates.ts +481 -0
  54. package/src/generators/connectors/types.ts +39 -0
  55. package/src/generators/shared/runtime/useDeleteConnector.ts +4 -2
  56. package/src/generators/shared/runtime/useDetailConnector.ts +8 -19
  57. package/src/generators/shared/runtime/useFormConnector.ts +4 -3
  58. package/src/generators/use-async-data/runtime/useApiAsyncData.ts +16 -5
  59. package/src/generators/use-async-data/templates.ts +24 -16
  60. package/src/generators/use-fetch/templates.ts +1 -1
  61. package/src/index.ts +2 -19
  62. package/src/module/index.ts +2 -5
  63. package/docs/generated-components.md +0 -615
  64. package/docs/headless-composables-ui.md +0 -569
package/dist/index.js CHANGED
@@ -1,13 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import * as p from '@clack/prompts';
4
- import { existsSync } from 'fs';
5
- import * as path from 'path';
6
4
  import { generateOpenApiFiles, generateHeyApiFiles, checkJavaInstalled } from './generate.js';
7
5
  import { generateUseFetchComposables } from './generators/use-fetch/generator.js';
8
6
  import { generateUseAsyncDataComposables } from './generators/use-async-data/generator.js';
9
7
  import { generateNuxtServerRoutes } from './generators/nuxt-server/generator.js';
10
- import { generateConnectors } from './generators/components/connector-generator/generator.js';
8
+ import { generateConnectors } from './generators/connectors/generator.js';
11
9
  import { promptInitialInputs, promptInputPath, promptComposablesSelection, promptServerRoutePath, promptBffConfig, promptGeneratorBackend, promptConnectors, } from './cli/prompts.js';
12
10
  import { MESSAGES } from './cli/messages.js';
13
11
  import { displayLogo } from './cli/logo.js';
@@ -77,7 +75,6 @@ program
77
75
  : config.generator === 'heyapi'
78
76
  ? 'heyapi'
79
77
  : config.backend;
80
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
81
78
  const backend = await promptGeneratorBackend(resolvedBackend);
82
79
  // Check Java availability when official backend is selected
83
80
  if (backend === 'official' && !checkJavaInstalled()) {
@@ -158,7 +155,6 @@ program
158
155
  for (const composable of composables) {
159
156
  const spinner = p.spinner();
160
157
  spinner.start(`Generating ${composable}...`);
161
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
162
158
  const generateOptions = { baseUrl: config.baseUrl, backend };
163
159
  try {
164
160
  switch (composable) {
@@ -182,7 +178,6 @@ program
182
178
  break;
183
179
  case 'nuxtServer':
184
180
  if (!config.dryRun) {
185
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
186
181
  await generateNuxtServerRoutes(outputPath, serverRoutePath, { enableBff, backend });
187
182
  spinner.stop(`✓ Generated Nuxt server routes`);
188
183
  }
@@ -198,16 +193,6 @@ program
198
193
  }
199
194
  }
200
195
  // Generate headless connectors if requested (requires useAsyncData)
201
- if (generateConnectorsFlag) {
202
- // Check zod is available in the user's project by probing their node_modules,
203
- // not via import() which resolves from the CLI's own context (npx sandbox).
204
- const zodPath = path.resolve(process.cwd(), 'node_modules', 'zod');
205
- if (!existsSync(zodPath)) {
206
- p.log.warn('Skipping connectors: "zod" is not installed in this project.\n' +
207
- ' Run: npm install zod');
208
- generateConnectorsFlag = false;
209
- }
210
- }
211
196
  if (generateConnectorsFlag) {
212
197
  const spinner = p.spinner();
213
198
  spinner.start('Generating headless UI connectors...');
@@ -5,7 +5,7 @@ import { checkJavaInstalled } from '../generate.js';
5
5
  import { generateUseFetchComposables } from '../generators/use-fetch/generator.js';
6
6
  import { generateUseAsyncDataComposables } from '../generators/use-async-data/generator.js';
7
7
  import { generateNuxtServerRoutes } from '../generators/nuxt-server/generator.js';
8
- import { generateConnectors } from '../generators/components/connector-generator/generator.js';
8
+ import { generateConnectors } from '../generators/connectors/generator.js';
9
9
  import { createConsoleLogger } from '../cli/logger.js';
10
10
  export default defineNuxtModule({
11
11
  meta: {
@@ -67,8 +67,7 @@ export default defineNuxtModule({
67
67
  await generateNuxtServerRoutes(resolvedOutput, serverRoutePath, { enableBff: options.enableBff, backend }, logger);
68
68
  }
69
69
  // 3. Generate headless connectors if requested (requires useAsyncData)
70
- if (options.createUseAsyncDataConnectors &&
71
- selectedGenerators.includes('useAsyncData')) {
70
+ if (options.createUseAsyncDataConnectors && selectedGenerators.includes('useAsyncData')) {
72
71
  const connectorsOutputDir = path.join(composablesOutputDir, 'connectors');
73
72
  await generateConnectors({
74
73
  inputSpec: resolvedInput,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-openapi-hyperfetch",
3
- "version": "0.3.81-beta",
4
- "description": "Nuxt useFetch, useAsyncData and Nuxt server OpenAPI generator",
3
+ "version": "1.0.0",
4
+ "description": "Nuxt useFetch, useAsyncData, Nuxt server & Headless UI connectors OpenAPI generator",
5
5
  "type": "module",
6
6
  "author": "",
7
7
  "license": "Apache-2.0",
@@ -74,7 +74,8 @@
74
74
  "eslint-plugin-prettier": "^5.5.5",
75
75
  "ts-node": "^10.9.2",
76
76
  "typescript": "^5.9.3",
77
- "typescript-eslint": "^8.57.1"
77
+ "typescript-eslint": "^8.57.1",
78
+ "zod": "^4.3.6"
78
79
  },
79
80
  "dependencies": {
80
81
  "@clack/prompts": "^1.1.0",
@@ -5,13 +5,7 @@
5
5
  import * as p from '@clack/prompts';
6
6
  import { checkCancellation, validateNonEmpty } from './utils.js';
7
7
  import { MESSAGES, CHOICES, DEFAULTS } from './messages.js';
8
- import type {
9
- InitialInputs,
10
- ComposablesSelection,
11
- BffConfig,
12
- ComposableType,
13
- GeneratorBackend,
14
- } from './types.js';
8
+ import type { InitialInputs, ComposablesSelection, BffConfig, GeneratorBackend } from './types.js';
15
9
 
16
10
  /**
17
11
  * Ask which OpenAPI generator backend to use
@@ -29,8 +29,8 @@ function toAsyncDataName(operationId: string): string {
29
29
  }
30
30
 
31
31
  /**
32
- * composable name → PascalCase file name (without .ts), matching useAsyncData generator output.
33
- * 'useAsyncDataGetPets' → 'useAsyncDataGetPets'
32
+ * composable name → kebab-case file name (without .ts).
33
+ * 'useAsyncDataGetPets' → 'use-async-data-get-pets'
34
34
  */
35
35
  function toFileName(composableName: string): string {
36
36
  return composableName;
@@ -41,19 +41,32 @@ function toFileName(composableName: string): string {
41
41
  /**
42
42
  * Build all `import` lines for a resource connector.
43
43
  */
44
- function buildImports(resource: ResourceInfo, composablesRelDir: string, sdkRelDir: string, runtimeRelDir: string): string {
44
+ function buildImports(
45
+ resource: ResourceInfo,
46
+ composablesRelDir: string,
47
+ sdkRelDir: string,
48
+ runtimeRelDir: string
49
+ ): string {
45
50
  const lines: string[] = [];
46
51
 
52
+ // vue — shallowRef needed when form, delete, or detail endpoints exist
53
+ const needsShallowRef = !!(
54
+ resource.createEndpoint ||
55
+ resource.updateEndpoint ||
56
+ resource.deleteEndpoint ||
57
+ resource.detailEndpoint
58
+ );
59
+ const needsComputed = !!resource.detailEndpoint;
60
+ if (needsShallowRef) {
61
+ const vueImports = needsComputed ? `shallowRef, computed` : `shallowRef`;
62
+ lines.push(`import { ${vueImports} } from 'vue';`);
63
+ lines.push('');
64
+ }
65
+
47
66
  // zod
48
67
  lines.push(`import { z } from 'zod';`);
49
68
  lines.push('');
50
69
 
51
- // Vue — computed needed for the detail connector wrapper
52
- if (resource.detailEndpoint) {
53
- lines.push(`import { computed } from 'vue';`);
54
- lines.push('');
55
- }
56
-
57
70
  // connector-types — structural interfaces for return types
58
71
  const connectorTypeImports: string[] = ['ListConnectorReturn'];
59
72
  if (resource.detailEndpoint) {
@@ -65,7 +78,9 @@ function buildImports(resource: ResourceInfo, composablesRelDir: string, sdkRelD
65
78
  if (resource.deleteEndpoint) {
66
79
  connectorTypeImports.push('DeleteConnectorReturn');
67
80
  }
68
- lines.push(`import type { ${connectorTypeImports.join(', ')} } from '${runtimeRelDir}/connector-types';`);
81
+ lines.push(
82
+ `import type { ${connectorTypeImports.join(', ')} } from '${runtimeRelDir}/connector-types';`
83
+ );
69
84
  lines.push('');
70
85
 
71
86
  // SDK request/response types (for the params overload signature)
@@ -184,6 +199,12 @@ function buildOptionsInterface(resource: ResourceInfo): string {
184
199
  if (resource.updateEndpoint && resource.zodSchemas.update) {
185
200
  fields.push(` updateSchema?: z.ZodTypeAny | ((base: z.ZodTypeAny) => z.ZodTypeAny);`);
186
201
  }
202
+ if (resource.createEndpoint || resource.updateEndpoint || resource.deleteEndpoint) {
203
+ fields.push(` onRequest?: (ctx: any) => void | Promise<void>;`);
204
+ fields.push(` onSuccess?: (data: any) => void;`);
205
+ fields.push(` onError?: (err: any) => void;`);
206
+ fields.push(` onFinish?: () => void;`);
207
+ }
187
208
 
188
209
  if (fields.length === 0) {
189
210
  return `type ${typeName} = Record<string, never>;`;
@@ -213,11 +234,15 @@ function buildReturnType(resource: ResourceInfo): string {
213
234
  fields.push(` detail: DetailConnectorReturn<${pascal}>;`);
214
235
  }
215
236
  if (resource.createEndpoint) {
216
- const inputType = resource.zodSchemas.create ? `${pascal}CreateInput` : `Record<string, unknown>`;
237
+ const inputType = resource.zodSchemas.create
238
+ ? `${pascal}CreateInput`
239
+ : `Record<string, unknown>`;
217
240
  fields.push(` createForm: FormConnectorReturn<${inputType}>;`);
218
241
  }
219
242
  if (resource.updateEndpoint) {
220
- const inputType = resource.zodSchemas.update ? `${pascal}UpdateInput` : `Record<string, unknown>`;
243
+ const inputType = resource.zodSchemas.update
244
+ ? `${pascal}UpdateInput`
245
+ : `Record<string, unknown>`;
221
246
  fields.push(` updateForm: FormConnectorReturn<${inputType}>;`);
222
247
  }
223
248
  if (resource.deleteEndpoint) {
@@ -227,6 +252,18 @@ function buildReturnType(resource: ResourceInfo): string {
227
252
  return [`type ${typeName} = {`, ...fields, `};`].join('\n');
228
253
  }
229
254
 
255
+ /**
256
+ * Build the 3 generated lines for a composable that is keyed by a single path param
257
+ * (detail, delete). Uses computed(() => ({ param: ref.value })) so that p.value is
258
+ * always an object during setup — avoids `null.param` crash when Nuxt evaluates computedKey.
259
+ */
260
+ function buildPathParamComposableLines(prefix: string, fn: string, pathParam: string): string[] {
261
+ return [
262
+ ` const ${prefix}Ref = shallowRef(null);`,
263
+ ` const ${prefix}Composable = ${fn}(computed(() => ({ ${pathParam}: ${prefix}Ref.value })) as any, { immediate: false });`,
264
+ ];
265
+ }
266
+
230
267
  /**
231
268
  * Build the body of the exported connector function.
232
269
  */
@@ -255,6 +292,14 @@ function buildFunctionBody(resource: ResourceInfo): string {
255
292
  if (resource.updateEndpoint && resource.zodSchemas.update) {
256
293
  optionKeys.push('updateSchema');
257
294
  }
295
+ const hasMutations = !!(
296
+ resource.createEndpoint ||
297
+ resource.updateEndpoint ||
298
+ resource.deleteEndpoint
299
+ );
300
+ if (hasMutations) {
301
+ optionKeys.push('onRequest', 'onSuccess', 'onError', 'onFinish');
302
+ }
258
303
 
259
304
  const optionsDestructure =
260
305
  optionKeys.length > 0 ? ` const { ${optionKeys.join(', ')} } = options;\n` : '';
@@ -289,29 +334,36 @@ function buildFunctionBody(resource: ResourceInfo): string {
289
334
 
290
335
  if (resource.detailEndpoint) {
291
336
  const fn = toAsyncDataName(resource.detailEndpoint.operationId);
292
- // Wrap the composable to map the generic idRef to the actual path param name.
293
- // useDetailConnector passes a ref<id> and calls refresh() after updating it.
294
337
  const pathParam = resource.detailEndpoint.pathParams[0] ?? 'id';
338
+ subConnectors.push(...buildPathParamComposableLines('_detail', fn, pathParam));
295
339
  subConnectors.push(
296
- ` const detail = useDetailConnector(`,
297
- ` (idRef, opts) => ${fn}(computed(() => ({ ${pathParam}: idRef.value })) as any, opts),`,
298
- ` ) as unknown as DetailConnectorReturn<${pascal}>;`
340
+ ` const detail = useDetailConnector((id: any) => { _detailRef.value = id; return _detailComposable; }) as unknown as DetailConnectorReturn<${pascal}>;`
299
341
  );
300
342
  }
301
343
 
302
344
  if (resource.createEndpoint) {
303
345
  const fn = toAsyncDataName(resource.createEndpoint.operationId);
304
- const inputType = resource.zodSchemas.create ? `${pascal}CreateInput` : `Record<string, unknown>`;
346
+ const inputType = resource.zodSchemas.create
347
+ ? `${pascal}CreateInput`
348
+ : `Record<string, unknown>`;
305
349
  const schemaArg = resource.zodSchemas.create
306
350
  ? `{ schema: ${pascal}CreateSchema, schemaOverride: createSchema }`
307
351
  : '{}';
308
- subConnectors.push(` const createForm = useFormConnector(${fn}, ${schemaArg}) as unknown as FormConnectorReturn<${inputType}>;`);
352
+ subConnectors.push(` const _createRef = shallowRef({});`);
353
+ subConnectors.push(
354
+ ` const _createComposable = ${fn}(_createRef as any, { immediate: false, onRequest, onSuccess, onError, onFinish });`
355
+ );
356
+ subConnectors.push(
357
+ ` const createForm = useFormConnector((p: any) => { _createRef.value = p; return _createComposable; }, ${schemaArg}) as unknown as FormConnectorReturn<${inputType}>;`
358
+ );
309
359
  }
310
360
 
311
361
  if (resource.updateEndpoint) {
312
362
  const fn = toAsyncDataName(resource.updateEndpoint.operationId);
313
363
  const hasDetail = !!resource.detailEndpoint;
314
- const inputType = resource.zodSchemas.update ? `${pascal}UpdateInput` : `Record<string, unknown>`;
364
+ const inputType = resource.zodSchemas.update
365
+ ? `${pascal}UpdateInput`
366
+ : `Record<string, unknown>`;
315
367
 
316
368
  let schemaArg = '{}';
317
369
  if (resource.zodSchemas.update && hasDetail) {
@@ -321,12 +373,35 @@ function buildFunctionBody(resource: ResourceInfo): string {
321
373
  } else if (hasDetail) {
322
374
  schemaArg = `{ loadWith: detail }`;
323
375
  }
324
- subConnectors.push(` const updateForm = useFormConnector(${fn}, ${schemaArg}) as unknown as FormConnectorReturn<${inputType}>;`);
376
+ subConnectors.push(` const _updateRef = shallowRef({});`);
377
+ subConnectors.push(
378
+ ` const _updateComposable = ${fn}(_updateRef as any, { immediate: false, onRequest, onSuccess, onError, onFinish });`
379
+ );
380
+ subConnectors.push(
381
+ ` const updateForm = useFormConnector((p: any) => { _updateRef.value = p; return _updateComposable; }, ${schemaArg}) as unknown as FormConnectorReturn<${inputType}>;`
382
+ );
325
383
  }
326
384
 
327
385
  if (resource.deleteEndpoint) {
328
386
  const fn = toAsyncDataName(resource.deleteEndpoint.operationId);
329
- subConnectors.push(` const deleteAction = useDeleteConnector(${fn}) as unknown as DeleteConnectorReturn<${pascal}>;`);
387
+ const pathParam = resource.deleteEndpoint.pathParams[0];
388
+ if (pathParam) {
389
+ subConnectors.push(...buildPathParamComposableLines('_delete', fn, pathParam));
390
+ subConnectors.push(
391
+ ` const _deleteComposableWithHooks = ${fn}(computed(() => ({ ${pathParam}: _deleteRef.value })) as any, { immediate: false, onRequest, onSuccess, onError, onFinish });`
392
+ );
393
+ subConnectors.push(
394
+ ` const deleteAction = useDeleteConnector((item: any) => { _deleteRef.value = item?.${pathParam} ?? item?.id ?? item; return _deleteComposableWithHooks; }) as unknown as DeleteConnectorReturn<${pascal}>;`
395
+ );
396
+ } else {
397
+ subConnectors.push(` const _deleteRef = shallowRef({});`);
398
+ subConnectors.push(
399
+ ` const _deleteComposable = ${fn}(_deleteRef as any, { immediate: false, onRequest, onSuccess, onError, onFinish });`
400
+ );
401
+ subConnectors.push(
402
+ ` const deleteAction = useDeleteConnector((p: any) => { _deleteRef.value = p; return _deleteComposable; }) as unknown as DeleteConnectorReturn<${pascal}>;`
403
+ );
404
+ }
330
405
  }
331
406
 
332
407
  // Return object — always includes table (undefined when no list + no factory)
@@ -49,16 +49,6 @@ function isArraySchema(schema: OpenApiPropertySchema): boolean {
49
49
  return schema.type === 'array' || schema.items !== undefined;
50
50
  }
51
51
 
52
- /** True when schema is a primitive scalar (string, number, integer, boolean) — not a resource */
53
- function isPrimitiveSchema(schema: OpenApiPropertySchema): boolean {
54
- return (
55
- schema.type === 'string' ||
56
- schema.type === 'number' ||
57
- schema.type === 'integer' ||
58
- schema.type === 'boolean'
59
- );
60
- }
61
-
62
52
  // ─── Request body schema ──────────────────────────────────────────────────────
63
53
 
64
54
  function getRequestBodySchema(operation: OpenApiOperation): OpenApiPropertySchema | undefined {
@@ -123,12 +113,6 @@ export function detectIntent(
123
113
  return 'list';
124
114
  }
125
115
 
126
- // Primitive response (string, number, boolean) → not a CRUD resource
127
- // e.g. GET /user/login returns a string token — not a list or detail
128
- if (isPrimitiveSchema(responseSchema)) {
129
- return 'unknown';
130
- }
131
-
132
116
  // Object response — distinguish list vs detail by path structure:
133
117
  // GET /pets/{id} → has path param → detail (single item fetch)
134
118
  // GET /pets → no path param → list (likely paginated envelope: { data: [], total: n })
@@ -178,6 +162,7 @@ export function extractEndpoints(
178
162
  intent,
179
163
  hasPathParams: pathParams.length > 0,
180
164
  pathParams,
165
+ hasQueryParams: (operation.parameters ?? []).some((p) => p.in === 'query'),
181
166
  };
182
167
 
183
168
  // Attach response schema for GET intents
@@ -51,7 +51,20 @@ function resolveRefs(node: unknown, root: OpenApiSpec, visited = new Set<string>
51
51
  const resolved = resolvePointer(root, ref);
52
52
  const newVisited = new Set(visited);
53
53
  newVisited.add(ref);
54
- return resolveRefs(resolved, root, newVisited);
54
+ const resolvedFull = resolveRefs(resolved, root, newVisited);
55
+
56
+ // Annotate resolved schemas from #/components/schemas/Xxx with the original
57
+ // component name so downstream consumers can recover the model type name.
58
+ if (
59
+ typeof resolvedFull === 'object' &&
60
+ resolvedFull !== null &&
61
+ ref.startsWith('#/components/schemas/')
62
+ ) {
63
+ const refName = ref.split('/').pop()!;
64
+ return { ...resolvedFull, 'x-ref-name': refName };
65
+ }
66
+
67
+ return resolvedFull;
55
68
  }
56
69
 
57
70
  const result: Record<string, unknown> = {};
@@ -112,10 +112,19 @@ export function buildResourceMap(spec: OpenApiSpec): ResourceMap {
112
112
 
113
113
  const resourceName = pascalCase(tag);
114
114
 
115
+ // Infer the SDK model type name from the original $ref component name.
116
+ // Priority: detail response > list items > list response (may be envelope object).
117
+ const itemTypeName =
118
+ (detailEp?.responseSchema as any)?.['x-ref-name'] ??
119
+ (listEp?.responseSchema as any)?.items?.['x-ref-name'] ??
120
+ (listEp?.responseSchema as any)?.['x-ref-name'] ??
121
+ undefined;
122
+
115
123
  const info: ResourceInfo = {
116
124
  name: resourceName,
117
125
  tag,
118
126
  composableName: toConnectorName(tag),
127
+ itemTypeName,
119
128
  endpoints,
120
129
  listEndpoint: listEp,
121
130
  detailEndpoint: detailEp,
@@ -167,7 +167,7 @@ function baseZodExpr(prop: OpenApiPropertySchema): string {
167
167
  return arrayZodExpr(prop);
168
168
 
169
169
  case 'object':
170
- return objectZodExpr(prop);
170
+ return 'z.record(z.string(), z.unknown())';
171
171
 
172
172
  default:
173
173
  // $ref already resolved, unknown type → permissive
@@ -224,31 +224,6 @@ function numberZodExpr(prop: OpenApiPropertySchema): string {
224
224
  return expr;
225
225
  }
226
226
 
227
- function objectZodExpr(prop: OpenApiPropertySchema): string {
228
- const { additionalProperties } = prop;
229
-
230
- // additionalProperties: false or undefined → plain object
231
- if (!additionalProperties || additionalProperties === true) {
232
- return 'z.record(z.unknown())';
233
- }
234
-
235
- // additionalProperties has a known primitive type → typed record
236
- const valueExpr = additionalPropsZodExpr(additionalProperties);
237
- return `z.record(${valueExpr})`;
238
- }
239
-
240
- function additionalPropsZodExpr(schema: OpenApiPropertySchema): string {
241
- switch (schema.type) {
242
- case 'string': return stringZodExpr(schema);
243
- case 'integer': return integerZodExpr(schema);
244
- case 'number': return numberZodExpr(schema);
245
- case 'boolean': return 'z.boolean()';
246
- case 'array': return arrayZodExpr(schema);
247
- case 'object': return objectZodExpr(schema);
248
- default: return 'z.unknown()';
249
- }
250
- }
251
-
252
227
  function arrayZodExpr(prop: OpenApiPropertySchema): string {
253
228
  const itemExpr = prop.items ? baseZodExpr(prop.items) : 'z.unknown()';
254
229
  let expr = `z.array(${itemExpr})`;
@@ -33,6 +33,8 @@ export interface OpenApiPropertySchema {
33
33
  allOf?: OpenApiPropertySchema[];
34
34
  oneOf?: OpenApiPropertySchema[];
35
35
  anyOf?: OpenApiPropertySchema[];
36
+ /** Injected by the $ref resolver — original component schema name, e.g. 'Pet' */
37
+ 'x-ref-name'?: string;
36
38
  }
37
39
 
38
40
  export interface OpenApiSchema extends OpenApiPropertySchema {
@@ -103,6 +105,8 @@ export interface EndpointInfo {
103
105
  responseSchema?: OpenApiSchema;
104
106
  hasPathParams: boolean;
105
107
  pathParams: string[];
108
+ /** True when the operation has at least one query parameter */
109
+ hasQueryParams: boolean;
106
110
  }
107
111
 
108
112
  // ─── Form field definition ───────────────────────────────────────────────────
@@ -154,6 +158,13 @@ export interface ResourceInfo {
154
158
  /** The one endpoint detected as delete (DELETE) */
155
159
  deleteEndpoint?: EndpointInfo;
156
160
 
161
+ /**
162
+ * Inferred item model type name (e.g. 'Pet', 'Order') derived from the
163
+ * response schema's original $ref component name. Used for SDK type imports.
164
+ * Undefined when the response type is anonymous/primitive.
165
+ */
166
+ itemTypeName?: string;
167
+
157
168
  /** Columns inferred from the list/detail response schema */
158
169
  columns: ColumnDef[];
159
170
 
@@ -0,0 +1,137 @@
1
+ import * as path from 'path';
2
+ import fs from 'fs-extra';
3
+ import { fileURLToPath } from 'url';
4
+ import { format } from 'prettier';
5
+ import { analyzeSpec } from '../components/schema-analyzer/index.js';
6
+ import {
7
+ generateConnectorFile,
8
+ connectorFileName,
9
+ generateConnectorIndexFile,
10
+ } from './templates.js';
11
+ import type { ConnectorGeneratorOptions } from './types.js';
12
+ import { type Logger, createClackLogger } from '../../cli/logger.js';
13
+
14
+ // Runtime files that must be copied to the user's project
15
+ const RUNTIME_FILES = [
16
+ 'connector-types.ts',
17
+ 'useGetAllConnector.ts',
18
+ 'useGetConnector.ts',
19
+ 'useCreateConnector.ts',
20
+ 'useUpdateConnector.ts',
21
+ 'useDeleteConnector.ts',
22
+ 'zod-error-merger.ts',
23
+ ] as const;
24
+
25
+ /**
26
+ * Format TypeScript source with Prettier.
27
+ * Falls back to unformatted code on error.
28
+ */
29
+ async function formatCode(code: string, logger: Logger): Promise<string> {
30
+ try {
31
+ return await format(code, { parser: 'typescript' });
32
+ } catch (error) {
33
+ logger.log.warn(`Prettier formatting failed: ${String(error)}`);
34
+ return code;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Generate headless connector composables from an OpenAPI spec.
40
+ *
41
+ * Steps:
42
+ * 1. Analyze the spec → ResourceMap (Schema Analyzer)
43
+ * 2. For each resource: generate connector source, format, write
44
+ * 3. Write an index barrel file
45
+ * 4. Copy runtime helpers to the user's project
46
+ */
47
+ export async function generateConnectors(
48
+ options: ConnectorGeneratorOptions,
49
+ logger: Logger = createClackLogger()
50
+ ): Promise<void> {
51
+ const spinner = logger.spinner();
52
+
53
+ const outputDir = path.resolve(options.outputDir);
54
+ const composablesRelDir = options.composablesRelDir ?? '../use-async-data';
55
+ const runtimeRelDir = options.runtimeRelDir ?? '../runtime';
56
+
57
+ // ── 1. Analyze spec ───────────────────────────────────────────────────────
58
+ spinner.start('Analyzing OpenAPI spec');
59
+ const resourceMap = analyzeSpec(options.inputSpec);
60
+ spinner.stop(`Found ${resourceMap.size} resource(s)`);
61
+
62
+ if (resourceMap.size === 0) {
63
+ logger.log.warn('No resources found in spec — nothing to generate');
64
+ return;
65
+ }
66
+
67
+ // ── 2. Prepare output directory ───────────────────────────────────────────
68
+ // emptyDir (not ensureDir) so stale connectors from previous runs are removed.
69
+ spinner.start('Preparing output directory');
70
+ await fs.emptyDir(outputDir);
71
+ spinner.stop('Output directory ready');
72
+
73
+ // ── 3. Generate connector files ───────────────────────────────────────────
74
+ spinner.start('Generating connector composables');
75
+ let successCount = 0;
76
+ let errorCount = 0;
77
+ const generatedNames: string[] = [];
78
+
79
+ for (const resource of resourceMap.values()) {
80
+ try {
81
+ const code = generateConnectorFile(resource, composablesRelDir, '../..', runtimeRelDir);
82
+ const formatted = await formatCode(code, logger);
83
+ const fileName = connectorFileName(resource.composableName);
84
+ const filePath = path.join(outputDir, fileName);
85
+
86
+ await fs.writeFile(filePath, formatted, 'utf-8');
87
+ generatedNames.push(resource.composableName);
88
+ successCount++;
89
+ } catch (error) {
90
+ logger.log.error(`Error generating ${resource.composableName}: ${String(error)}`);
91
+ errorCount++;
92
+ }
93
+ }
94
+
95
+ spinner.stop(`Generated ${successCount} connector(s)`);
96
+
97
+ // ── 4. Write barrel index ─────────────────────────────────────────────────
98
+ if (generatedNames.length > 0) {
99
+ try {
100
+ const indexCode = generateConnectorIndexFile(generatedNames);
101
+ const formattedIndex = await formatCode(indexCode, logger);
102
+ await fs.writeFile(path.join(outputDir, 'index.ts'), formattedIndex, 'utf-8');
103
+ } catch (error) {
104
+ logger.log.warn(`Could not write connector index: ${String(error)}`);
105
+ }
106
+ }
107
+
108
+ // ── 5. Copy runtime helpers ───────────────────────────────────────────────
109
+ // Runtime files live in src/ and must be physical .ts files in the user's project
110
+ // so Nuxt/Vite can type-check them.
111
+ //
112
+ // Path resolution trick:
113
+ // • During development (ts-node / tsx): __dirname ≈ src/generators/connectors/
114
+ // • After `tsc` build: __dirname ≈ dist/generators/connectors/
115
+ //
116
+ // In both cases we step up 3 levels and re-enter src/ to find the runtime sources.
117
+ spinner.start('Copying runtime files');
118
+ const runtimeDir = path.resolve(outputDir, runtimeRelDir);
119
+ await fs.ensureDir(runtimeDir); // ensureDir — other runtime files may live here too
120
+
121
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
122
+ const runtimeSrcDir = path.resolve(__dirname, '../../../src/generators/connectors/runtime');
123
+
124
+ for (const file of RUNTIME_FILES) {
125
+ const src = path.join(runtimeSrcDir, file);
126
+ const dest = path.join(runtimeDir, file);
127
+ await fs.copyFile(src, dest);
128
+ }
129
+
130
+ spinner.stop('Runtime files copied');
131
+
132
+ // ── 6. Summary ────────────────────────────────────────────────────────────
133
+ if (errorCount > 0) {
134
+ logger.log.warn(`Completed with ${errorCount} error(s)`);
135
+ }
136
+ logger.log.success(`Generated ${successCount} connector(s) in ${outputDir}`);
137
+ }