nuxt-openapi-hyperfetch 1.0.2 → 1.0.4
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 +106 -51
- package/dist/cli/config.d.ts +15 -49
- package/dist/cli/config.js +42 -6
- package/dist/config/connectors.d.ts +6 -0
- package/dist/config/connectors.js +26 -0
- package/dist/config/types.d.ts +62 -0
- package/dist/config/types.js +1 -0
- package/dist/generators/connectors/config-resolver.d.ts +3 -0
- package/dist/generators/connectors/config-resolver.js +207 -0
- package/dist/generators/connectors/generator.js +3 -1
- package/dist/generators/connectors/templates.js +6 -2
- package/dist/generators/connectors/types.d.ts +3 -0
- package/dist/index.js +27 -10
- package/dist/module/index.js +11 -3
- package/dist/module/types.d.ts +4 -4
- package/docs/QUICK-START.md +44 -4
- package/package.json +22 -3
- package/src/cli/config.ts +61 -57
- package/src/config/connectors.ts +48 -0
- package/src/config/types.ts +67 -0
- package/src/generators/connectors/config-resolver.ts +303 -0
- package/src/generators/connectors/generator.ts +3 -1
- package/src/generators/connectors/templates.ts +5 -2
- package/src/generators/connectors/types.ts +4 -0
- package/src/index.ts +41 -12
- package/src/module/index.ts +18 -3
- package/src/module/types.ts +4 -4
package/src/cli/config.ts
CHANGED
|
@@ -1,66 +1,50 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { pathToFileURL } from 'url';
|
|
3
4
|
import * as p from '@clack/prompts';
|
|
4
|
-
import type {
|
|
5
|
+
import type { GeneratorConfig, GeneratorType } from '../config/types.js';
|
|
5
6
|
|
|
6
7
|
const { existsSync } = fs;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
serverRoutePath?: string;
|
|
36
|
-
/** Enable BFF pattern (for nuxtServer mode) */
|
|
37
|
-
enableBff?: boolean;
|
|
38
|
-
/** Generator backend: official (Java) or heyapi (Node.js) */
|
|
39
|
-
backend?: GeneratorBackend;
|
|
40
|
-
/**
|
|
41
|
-
* Generation engine to use.
|
|
42
|
-
* - 'openapi': @openapitools/openapi-generator-cli (requires Java 11+)
|
|
43
|
-
* - 'heyapi': @hey-api/openapi-ts (Node.js native, no Java required)
|
|
44
|
-
* When set, the CLI will not ask which engine to use.
|
|
45
|
-
*/
|
|
46
|
-
generator?: ConfigGenerator;
|
|
47
|
-
/**
|
|
48
|
-
* Generate headless UI connector composables on top of useAsyncData.
|
|
49
|
-
* Connectors provide ready-made logic for tables, pagination, forms and delete actions.
|
|
50
|
-
* Requires useAsyncData to also be generated.
|
|
51
|
-
* @default false
|
|
52
|
-
*/
|
|
53
|
-
createUseAsyncDataConnectors?: boolean;
|
|
9
|
+
export type ComposableGeneratorType = 'useFetch' | 'useAsyncData' | 'nuxtServer';
|
|
10
|
+
|
|
11
|
+
export interface NormalizedGenerators {
|
|
12
|
+
composables: ComposableGeneratorType[];
|
|
13
|
+
generateConnectors: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COMPOSABLE_GENERATOR_ORDER: readonly ComposableGeneratorType[] = [
|
|
17
|
+
'useFetch',
|
|
18
|
+
'useAsyncData',
|
|
19
|
+
'nuxtServer',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
async function importConfigModule(configPath: string): Promise<unknown> {
|
|
23
|
+
try {
|
|
24
|
+
const module = await import(pathToFileURL(configPath).href);
|
|
25
|
+
return module.default || module;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (!configPath.endsWith('.ts')) {
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { createJiti } = await import('jiti');
|
|
32
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
33
|
+
const module = jiti(configPath);
|
|
34
|
+
return module?.default || module;
|
|
35
|
+
}
|
|
54
36
|
}
|
|
55
37
|
|
|
56
38
|
/**
|
|
57
|
-
* Load configuration from nxh.config.js, nuxt-openapi-
|
|
39
|
+
* Load configuration from nxh.config.{ts,js,mjs}, nuxt-openapi-hyperfetch.{ts,js,mjs}, or package.json
|
|
58
40
|
*/
|
|
59
41
|
export async function loadConfig(cwd: string = process.cwd()): Promise<GeneratorConfig | null> {
|
|
60
42
|
// Try different config file names
|
|
61
43
|
const configFiles = [
|
|
44
|
+
'nxh.config.ts',
|
|
62
45
|
'nxh.config.js',
|
|
63
46
|
'nxh.config.mjs',
|
|
47
|
+
'nuxt-openapi-hyperfetch.ts',
|
|
64
48
|
'nuxt-openapi-hyperfetch.js',
|
|
65
49
|
'nuxt-openapi-hyperfetch.mjs',
|
|
66
50
|
];
|
|
@@ -69,11 +53,8 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<Generator
|
|
|
69
53
|
const configPath = join(cwd, configFile);
|
|
70
54
|
if (existsSync(configPath)) {
|
|
71
55
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
75
|
-
const exportedConfig = config.default || config;
|
|
76
|
-
return exportedConfig as GeneratorConfig;
|
|
56
|
+
const config = await importConfigModule(configPath);
|
|
57
|
+
return config as GeneratorConfig;
|
|
77
58
|
} catch (error) {
|
|
78
59
|
p.log.warn(`Failed to load config from ${configFile}: ${String(error)}`);
|
|
79
60
|
}
|
|
@@ -134,14 +115,37 @@ export function parseTags(tagsString?: string): string[] | undefined {
|
|
|
134
115
|
/**
|
|
135
116
|
* Parse generators string into array
|
|
136
117
|
*/
|
|
137
|
-
export function parseGenerators(
|
|
138
|
-
generatorsString?: string
|
|
139
|
-
): ('useFetch' | 'useAsyncData' | 'nuxtServer' | 'connectors')[] | undefined {
|
|
118
|
+
export function parseGenerators(generatorsString?: string): GeneratorType[] | undefined {
|
|
140
119
|
if (!generatorsString) {
|
|
141
120
|
return undefined;
|
|
142
121
|
}
|
|
143
122
|
const parts = generatorsString.split(',').map((g) => g.trim());
|
|
144
|
-
return parts.filter((g): g is
|
|
123
|
+
return parts.filter((g): g is GeneratorType =>
|
|
145
124
|
['useFetch', 'useAsyncData', 'nuxtServer', 'connectors'].includes(g)
|
|
146
125
|
);
|
|
147
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Normalize generator selection so connectors can be requested declaratively.
|
|
130
|
+
* Rules:
|
|
131
|
+
* - If `connectors` is selected, `useAsyncData` is added automatically.
|
|
132
|
+
* - If `createUseAsyncDataConnectors` is true, connectors are enabled and
|
|
133
|
+
* `useAsyncData` is added automatically.
|
|
134
|
+
*/
|
|
135
|
+
export function normalizeGenerators(
|
|
136
|
+
generators?: GeneratorType[],
|
|
137
|
+
createUseAsyncDataConnectors?: boolean
|
|
138
|
+
): NormalizedGenerators {
|
|
139
|
+
const requested = new Set(generators ?? []);
|
|
140
|
+
const generateConnectors = requested.has('connectors') || createUseAsyncDataConnectors === true;
|
|
141
|
+
|
|
142
|
+
const composables: ComposableGeneratorType[] = COMPOSABLE_GENERATOR_ORDER.filter((generator) =>
|
|
143
|
+
requested.has(generator)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (generateConnectors && !composables.includes('useAsyncData')) {
|
|
147
|
+
composables.push('useAsyncData');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { composables, generateConnectors };
|
|
151
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ConnectorsConfig, GeneratorConfig, GeneratorType } from './types.js';
|
|
2
|
+
|
|
3
|
+
export type RuntimeComposableGenerator = 'useFetch' | 'useAsyncData' | 'nuxtServer';
|
|
4
|
+
|
|
5
|
+
export function hasConnectorsConfig(connectors?: ConnectorsConfig): boolean {
|
|
6
|
+
if (!connectors) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (connectors.enabled === true) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (connectors.strategy !== undefined) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Object.keys(connectors.resources ?? {}).length > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isConnectorsRequested(
|
|
22
|
+
config: Pick<GeneratorConfig, 'generators' | 'createUseAsyncDataConnectors' | 'connectors'>
|
|
23
|
+
): boolean {
|
|
24
|
+
return (
|
|
25
|
+
config.createUseAsyncDataConnectors === true ||
|
|
26
|
+
(config.generators ?? []).includes('connectors') ||
|
|
27
|
+
hasConnectorsConfig(config.connectors)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toRuntimeComposableGenerators(
|
|
32
|
+
generators?: GeneratorType[]
|
|
33
|
+
): RuntimeComposableGenerator[] {
|
|
34
|
+
return (generators ?? []).filter(
|
|
35
|
+
(g): g is RuntimeComposableGenerator =>
|
|
36
|
+
g === 'useFetch' || g === 'useAsyncData' || g === 'nuxtServer'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ensureUseAsyncDataForConnectors(
|
|
41
|
+
composables: RuntimeComposableGenerator[],
|
|
42
|
+
connectorsRequested: boolean
|
|
43
|
+
): RuntimeComposableGenerator[] {
|
|
44
|
+
if (!connectorsRequested || composables.includes('useAsyncData')) {
|
|
45
|
+
return composables;
|
|
46
|
+
}
|
|
47
|
+
return [...composables, 'useAsyncData'];
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GeneratorBackend, ConfigGenerator } from '../cli/types.js';
|
|
2
|
+
|
|
3
|
+
export type GeneratorType = 'useFetch' | 'useAsyncData' | 'nuxtServer' | 'connectors';
|
|
4
|
+
export type ConnectorStrategy = 'manual' | 'hybrid';
|
|
5
|
+
export type ConnectorOperationName = 'getAll' | 'get' | 'create' | 'update' | 'delete';
|
|
6
|
+
|
|
7
|
+
export interface ConnectorOperationConfig {
|
|
8
|
+
operationId?: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ConnectorResourceConfig {
|
|
13
|
+
operations?: Partial<Record<ConnectorOperationName, ConnectorOperationConfig>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ConnectorsConfig {
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
strategy?: ConnectorStrategy;
|
|
19
|
+
resources?: Record<string, ConnectorResourceConfig>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared configuration contract used by both CLI (nxh.config.*) and Nuxt module (nuxt.config.ts).
|
|
24
|
+
*/
|
|
25
|
+
export interface GeneratorConfig {
|
|
26
|
+
/** Path or URL to OpenAPI specification */
|
|
27
|
+
input?: string;
|
|
28
|
+
/** Output directory for generated files */
|
|
29
|
+
output?: string;
|
|
30
|
+
/** Base URL for API requests */
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
/** Generation mode: client or server */
|
|
33
|
+
mode?: 'client' | 'server';
|
|
34
|
+
/** Generate only specific tags */
|
|
35
|
+
tags?: string[];
|
|
36
|
+
/** Exclude specific tags */
|
|
37
|
+
excludeTags?: string[];
|
|
38
|
+
/** Overwrite existing files without prompting */
|
|
39
|
+
overwrite?: boolean;
|
|
40
|
+
/** Preview changes without writing files */
|
|
41
|
+
dryRun?: boolean;
|
|
42
|
+
/** Enable verbose logging */
|
|
43
|
+
verbose?: boolean;
|
|
44
|
+
/** Watch mode - regenerate on file changes */
|
|
45
|
+
watch?: boolean;
|
|
46
|
+
/** Generator types to use */
|
|
47
|
+
generators?: GeneratorType[];
|
|
48
|
+
/** Server route path (for nuxtServer mode) */
|
|
49
|
+
serverRoutePath?: string;
|
|
50
|
+
/** Enable BFF pattern (for nuxtServer mode) */
|
|
51
|
+
enableBff?: boolean;
|
|
52
|
+
/** Generator backend: official (Java) or heyapi (Node.js) */
|
|
53
|
+
backend?: GeneratorBackend;
|
|
54
|
+
/**
|
|
55
|
+
* Generation engine to use.
|
|
56
|
+
* - 'openapi': @openapitools/openapi-generator-cli (requires Java 11+)
|
|
57
|
+
* - 'heyapi': @hey-api/openapi-ts (Node.js native, no Java required)
|
|
58
|
+
*/
|
|
59
|
+
generator?: ConfigGenerator;
|
|
60
|
+
/**
|
|
61
|
+
* Generate headless UI connector composables on top of useAsyncData.
|
|
62
|
+
* Requires useAsyncData to also be generated.
|
|
63
|
+
*/
|
|
64
|
+
createUseAsyncDataConnectors?: boolean;
|
|
65
|
+
/** Advanced connectors generation contract (manual/hybrid, custom resources, overloads). */
|
|
66
|
+
connectors?: ConnectorsConfig;
|
|
67
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { camelCase, pascalCase } from 'change-case';
|
|
2
|
+
import type {
|
|
3
|
+
ConnectorsConfig,
|
|
4
|
+
ConnectorOperationConfig,
|
|
5
|
+
ConnectorOperationName,
|
|
6
|
+
ConnectorResourceConfig,
|
|
7
|
+
} from '../../config/types.js';
|
|
8
|
+
import type {
|
|
9
|
+
EndpointInfo,
|
|
10
|
+
OpenApiSchema,
|
|
11
|
+
ResourceInfo,
|
|
12
|
+
ResourceMap,
|
|
13
|
+
} from '../components/schema-analyzer/types.js';
|
|
14
|
+
import {
|
|
15
|
+
mapColumnsFromSchema,
|
|
16
|
+
mapFieldsFromSchema,
|
|
17
|
+
buildZodSchema,
|
|
18
|
+
} from '../components/schema-analyzer/index.js';
|
|
19
|
+
|
|
20
|
+
function toConnectorName(tag: string): string {
|
|
21
|
+
const pascal = pascalCase(tag);
|
|
22
|
+
const plural = /(?:s|x|z|ch|sh)$/i.test(pascal) ? `${pascal}es` : `${pascal}s`;
|
|
23
|
+
return `use${plural}Connector`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cloneResource(resource: ResourceInfo): ResourceInfo {
|
|
27
|
+
return {
|
|
28
|
+
...resource,
|
|
29
|
+
endpoints: [...resource.endpoints],
|
|
30
|
+
columns: [...resource.columns],
|
|
31
|
+
formFields: {
|
|
32
|
+
...(resource.formFields.create ? { create: [...resource.formFields.create] } : {}),
|
|
33
|
+
...(resource.formFields.update ? { update: [...resource.formFields.update] } : {}),
|
|
34
|
+
},
|
|
35
|
+
zodSchemas: { ...resource.zodSchemas },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getRefNameFromSchema(schema?: OpenApiSchema): string | undefined {
|
|
40
|
+
if (!schema) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const directRef = schema['x-ref-name'];
|
|
45
|
+
if (typeof directRef === 'string') {
|
|
46
|
+
return directRef;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const items = schema.items;
|
|
50
|
+
if (items && typeof items === 'object') {
|
|
51
|
+
const itemRef = items['x-ref-name'];
|
|
52
|
+
if (typeof itemRef === 'string') {
|
|
53
|
+
return itemRef;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rebuildDerived(resource: ResourceInfo): void {
|
|
61
|
+
const schemaForColumns =
|
|
62
|
+
resource.listEndpoint?.responseSchema ?? resource.detailEndpoint?.responseSchema;
|
|
63
|
+
resource.columns = schemaForColumns ? mapColumnsFromSchema(schemaForColumns) : [];
|
|
64
|
+
|
|
65
|
+
resource.formFields = {
|
|
66
|
+
...(resource.createEndpoint?.requestBodySchema
|
|
67
|
+
? { create: mapFieldsFromSchema(resource.createEndpoint.requestBodySchema) }
|
|
68
|
+
: {}),
|
|
69
|
+
...(resource.updateEndpoint?.requestBodySchema
|
|
70
|
+
? { update: mapFieldsFromSchema(resource.updateEndpoint.requestBodySchema) }
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
resource.zodSchemas = {
|
|
75
|
+
...(resource.createEndpoint?.requestBodySchema
|
|
76
|
+
? { create: buildZodSchema(resource.createEndpoint.requestBodySchema) }
|
|
77
|
+
: {}),
|
|
78
|
+
...(resource.updateEndpoint?.requestBodySchema
|
|
79
|
+
? { update: buildZodSchema(resource.updateEndpoint.requestBodySchema) }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
resource.itemTypeName =
|
|
84
|
+
getRefNameFromSchema(resource.detailEndpoint?.responseSchema) ??
|
|
85
|
+
getRefNameFromSchema(resource.listEndpoint?.responseSchema) ??
|
|
86
|
+
undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function expectedMethodsForOperation(
|
|
90
|
+
operationName: ConnectorOperationName
|
|
91
|
+
): Array<EndpointInfo['method']> {
|
|
92
|
+
switch (operationName) {
|
|
93
|
+
case 'getAll':
|
|
94
|
+
case 'get':
|
|
95
|
+
return ['GET'];
|
|
96
|
+
case 'create':
|
|
97
|
+
return ['POST'];
|
|
98
|
+
case 'update':
|
|
99
|
+
return ['PUT', 'PATCH'];
|
|
100
|
+
case 'delete':
|
|
101
|
+
return ['DELETE'];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function selectByIntent(
|
|
106
|
+
operationName: ConnectorOperationName,
|
|
107
|
+
candidates: EndpointInfo[]
|
|
108
|
+
): EndpointInfo[] {
|
|
109
|
+
if (operationName === 'getAll') {
|
|
110
|
+
const preferred = candidates.filter((c) => c.intent === 'list');
|
|
111
|
+
return preferred.length > 0 ? preferred : candidates;
|
|
112
|
+
}
|
|
113
|
+
if (operationName === 'get') {
|
|
114
|
+
const preferred = candidates.filter((c) => c.intent === 'detail');
|
|
115
|
+
return preferred.length > 0 ? preferred : candidates;
|
|
116
|
+
}
|
|
117
|
+
if (operationName === 'update') {
|
|
118
|
+
const put = candidates.filter((c) => c.method === 'PUT');
|
|
119
|
+
return put.length > 0 ? put : candidates;
|
|
120
|
+
}
|
|
121
|
+
return candidates;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildEndpointIndex(resourceMap: ResourceMap): {
|
|
125
|
+
byOperationId: Map<string, EndpointInfo>;
|
|
126
|
+
byPath: Map<string, EndpointInfo[]>;
|
|
127
|
+
} {
|
|
128
|
+
const byOperationId = new Map<string, EndpointInfo>();
|
|
129
|
+
const byPath = new Map<string, EndpointInfo[]>();
|
|
130
|
+
|
|
131
|
+
for (const resource of resourceMap.values()) {
|
|
132
|
+
for (const endpoint of resource.endpoints) {
|
|
133
|
+
byOperationId.set(endpoint.operationId, endpoint);
|
|
134
|
+
const list = byPath.get(endpoint.path) ?? [];
|
|
135
|
+
list.push(endpoint);
|
|
136
|
+
byPath.set(endpoint.path, list);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { byOperationId, byPath };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveEndpoint(
|
|
144
|
+
operationName: ConnectorOperationName,
|
|
145
|
+
operationConfig: ConnectorOperationConfig,
|
|
146
|
+
endpointIndex: ReturnType<typeof buildEndpointIndex>,
|
|
147
|
+
resourceName: string
|
|
148
|
+
): EndpointInfo {
|
|
149
|
+
if (operationConfig.operationId && operationConfig.path) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`[connectors] Resource "${resourceName}" operation "${operationName}" cannot define both operationId and path`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!operationConfig.operationId && !operationConfig.path) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[connectors] Resource "${resourceName}" operation "${operationName}" must define operationId or path`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (operationConfig.operationId) {
|
|
162
|
+
const endpoint = endpointIndex.byOperationId.get(operationConfig.operationId);
|
|
163
|
+
if (!endpoint) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[connectors] Resource "${resourceName}" operation "${operationName}" references unknown operationId "${operationConfig.operationId}"`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return endpoint;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const candidates = endpointIndex.byPath.get(operationConfig.path!) ?? [];
|
|
172
|
+
if (candidates.length === 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`[connectors] Resource "${resourceName}" operation "${operationName}" references unknown path "${operationConfig.path}"`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const validMethods = expectedMethodsForOperation(operationName);
|
|
179
|
+
const byMethod = candidates.filter((c) => validMethods.includes(c.method));
|
|
180
|
+
if (byMethod.length === 0) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`[connectors] Resource "${resourceName}" operation "${operationName}" path "${operationConfig.path}" has no compatible method (${validMethods.join('/')})`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const prioritized = selectByIntent(operationName, byMethod);
|
|
187
|
+
return prioritized[0];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function setOperationEndpoint(
|
|
191
|
+
resource: ResourceInfo,
|
|
192
|
+
operationName: ConnectorOperationName,
|
|
193
|
+
endpoint: EndpointInfo
|
|
194
|
+
): void {
|
|
195
|
+
switch (operationName) {
|
|
196
|
+
case 'getAll':
|
|
197
|
+
resource.listEndpoint = endpoint;
|
|
198
|
+
break;
|
|
199
|
+
case 'get':
|
|
200
|
+
resource.detailEndpoint = endpoint;
|
|
201
|
+
break;
|
|
202
|
+
case 'create':
|
|
203
|
+
resource.createEndpoint = endpoint;
|
|
204
|
+
break;
|
|
205
|
+
case 'update':
|
|
206
|
+
resource.updateEndpoint = endpoint;
|
|
207
|
+
break;
|
|
208
|
+
case 'delete':
|
|
209
|
+
resource.deleteEndpoint = endpoint;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!resource.endpoints.some((ep) => ep.operationId === endpoint.operationId)) {
|
|
214
|
+
resource.endpoints.push(endpoint);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createResourceSkeleton(resourceKey: string): ResourceInfo {
|
|
219
|
+
return {
|
|
220
|
+
name: pascalCase(resourceKey),
|
|
221
|
+
tag: resourceKey,
|
|
222
|
+
composableName: toConnectorName(resourceKey),
|
|
223
|
+
endpoints: [],
|
|
224
|
+
columns: [],
|
|
225
|
+
formFields: {},
|
|
226
|
+
zodSchemas: {},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function applyResourceOverrides(
|
|
231
|
+
target: ResourceInfo,
|
|
232
|
+
resourceName: string,
|
|
233
|
+
config: ConnectorResourceConfig,
|
|
234
|
+
endpointIndex: ReturnType<typeof buildEndpointIndex>
|
|
235
|
+
): ResourceInfo {
|
|
236
|
+
const operations = config.operations ?? {};
|
|
237
|
+
|
|
238
|
+
for (const [operationName, operationConfig] of Object.entries(operations) as Array<
|
|
239
|
+
[ConnectorOperationName, ConnectorOperationConfig | undefined]
|
|
240
|
+
>) {
|
|
241
|
+
if (!operationConfig) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const endpoint = resolveEndpoint(operationName, operationConfig, endpointIndex, resourceName);
|
|
246
|
+
setOperationEndpoint(target, operationName, endpoint);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
rebuildDerived(target);
|
|
250
|
+
return target;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeConfig(
|
|
254
|
+
config?: ConnectorsConfig
|
|
255
|
+
): Required<Pick<ConnectorsConfig, 'strategy'>> & ConnectorsConfig {
|
|
256
|
+
return {
|
|
257
|
+
strategy: config?.strategy ?? 'hybrid',
|
|
258
|
+
...config,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function resolveConnectorResourceMap(
|
|
263
|
+
baseResourceMap: ResourceMap,
|
|
264
|
+
connectorsConfig?: ConnectorsConfig
|
|
265
|
+
): ResourceMap {
|
|
266
|
+
if (!connectorsConfig) {
|
|
267
|
+
return baseResourceMap;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const normalized = normalizeConfig(connectorsConfig);
|
|
271
|
+
const resourceConfigs = normalized.resources ?? {};
|
|
272
|
+
const endpointIndex = buildEndpointIndex(baseResourceMap);
|
|
273
|
+
|
|
274
|
+
if (normalized.strategy === 'manual') {
|
|
275
|
+
const manualMap: ResourceMap = new Map();
|
|
276
|
+
|
|
277
|
+
for (const [resourceName, resourceConfig] of Object.entries(resourceConfigs)) {
|
|
278
|
+
const resourceKey = camelCase(resourceName);
|
|
279
|
+
const baseResource = baseResourceMap.get(resourceKey);
|
|
280
|
+
const target = baseResource
|
|
281
|
+
? cloneResource(baseResource)
|
|
282
|
+
: createResourceSkeleton(resourceName);
|
|
283
|
+
|
|
284
|
+
applyResourceOverrides(target, resourceName, resourceConfig, endpointIndex);
|
|
285
|
+
manualMap.set(resourceKey, target);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return manualMap;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const hybridMap: ResourceMap = new Map(
|
|
292
|
+
[...baseResourceMap.entries()].map(([key, value]) => [key, cloneResource(value)])
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
for (const [resourceName, resourceConfig] of Object.entries(resourceConfigs)) {
|
|
296
|
+
const resourceKey = camelCase(resourceName);
|
|
297
|
+
const target = hybridMap.get(resourceKey) ?? createResourceSkeleton(resourceName);
|
|
298
|
+
applyResourceOverrides(target, resourceName, resourceConfig, endpointIndex);
|
|
299
|
+
hybridMap.set(resourceKey, target);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return hybridMap;
|
|
303
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateConnectorIndexFile,
|
|
10
10
|
} from './templates.js';
|
|
11
11
|
import type { ConnectorGeneratorOptions } from './types.js';
|
|
12
|
+
import { resolveConnectorResourceMap } from './config-resolver.js';
|
|
12
13
|
import { type Logger, createClackLogger } from '../../cli/logger.js';
|
|
13
14
|
|
|
14
15
|
// Runtime files that must be copied to the user's project
|
|
@@ -56,7 +57,8 @@ export async function generateConnectors(
|
|
|
56
57
|
|
|
57
58
|
// ── 1. Analyze spec ───────────────────────────────────────────────────────
|
|
58
59
|
spinner.start('Analyzing OpenAPI spec');
|
|
59
|
-
const
|
|
60
|
+
const baseResourceMap = analyzeSpec(options.inputSpec);
|
|
61
|
+
const resourceMap = resolveConnectorResourceMap(baseResourceMap, options.connectorsConfig);
|
|
60
62
|
spinner.stop(`Found ${resourceMap.size} resource(s)`);
|
|
61
63
|
|
|
62
64
|
if (resourceMap.size === 0) {
|
|
@@ -181,6 +181,7 @@ function buildOptionsInterface(resource: ResourceInfo): string {
|
|
|
181
181
|
if (resource.listEndpoint && hasColumns) {
|
|
182
182
|
fields.push(` columnLabels?: Record<string, string>;`);
|
|
183
183
|
fields.push(` columnLabel?: (key: string) => string;`);
|
|
184
|
+
fields.push(` getAllRequestOptions?: Record<string, unknown>;`);
|
|
184
185
|
}
|
|
185
186
|
if (resource.createEndpoint && resource.zodSchemas.create) {
|
|
186
187
|
const pascal = pascalCase(resource.name);
|
|
@@ -264,7 +265,9 @@ function buildFunctionBody(resource: ResourceInfo): string {
|
|
|
264
265
|
// Options destructure
|
|
265
266
|
const optionKeys: string[] = [];
|
|
266
267
|
if (resource.listEndpoint && hasColumns) {
|
|
267
|
-
optionKeys.push('columnLabels', 'columnLabel');
|
|
268
|
+
optionKeys.push('columnLabels', 'columnLabel', 'getAllRequestOptions');
|
|
269
|
+
} else if (resource.listEndpoint) {
|
|
270
|
+
optionKeys.push('getAllRequestOptions');
|
|
268
271
|
}
|
|
269
272
|
if (resource.createEndpoint && resource.zodSchemas.create) {
|
|
270
273
|
optionKeys.push('createSchema');
|
|
@@ -317,7 +320,7 @@ function buildFunctionBody(resource: ResourceInfo): string {
|
|
|
317
320
|
` const isFactory = typeof paramsOrSource === 'function';`,
|
|
318
321
|
` const listFactory = isFactory`,
|
|
319
322
|
` ? (paramsOrSource as () => unknown)`,
|
|
320
|
-
` : () => ${fn}((paramsOrSource ?? {}) as ${listRequestTypeName});`,
|
|
323
|
+
` : () => ${fn}((paramsOrSource ?? {}) as ${listRequestTypeName}, { paginated: true, ...(getAllRequestOptions ?? {}) });`,
|
|
321
324
|
` const getAll = useGetAllConnector(listFactory, ${opts}) as unknown as GetAllConnectorReturn<${pascal}>;`
|
|
322
325
|
);
|
|
323
326
|
} else {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* and writes one `use{Resource}Connector.ts` file per resource using $fetch for mutations.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { ConnectorsConfig } from '../../config/types.js';
|
|
9
|
+
|
|
8
10
|
export interface ConnectorGeneratorOptions {
|
|
9
11
|
/** Absolute or relative path to the OpenAPI YAML/JSON spec */
|
|
10
12
|
inputSpec: string;
|
|
@@ -25,6 +27,8 @@ export interface ConnectorGeneratorOptions {
|
|
|
25
27
|
* useRuntimeConfig().public.apiBaseUrl at runtime.
|
|
26
28
|
*/
|
|
27
29
|
baseUrl?: string;
|
|
30
|
+
/** Advanced connectors configuration (manual/hybrid, custom resources, overloads). */
|
|
31
|
+
connectorsConfig?: ConnectorsConfig;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface ConnectorFileInfo {
|