openapi-remote-codegen 0.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/dist/parser.js ADDED
@@ -0,0 +1,236 @@
1
+ export function parseOpenApiSpec(spec) {
2
+ const operations = [];
3
+ const tagsSet = new Set();
4
+ for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
5
+ if (!pathItem)
6
+ continue;
7
+ const methods = ['get', 'post', 'put', 'patch', 'delete'];
8
+ for (const method of methods) {
9
+ const operation = pathItem[method];
10
+ if (!operation)
11
+ continue;
12
+ const remoteType = operation['x-remote-type'];
13
+ if (!remoteType)
14
+ continue;
15
+ const tag = operation.tags?.[0] ?? 'Default';
16
+ tagsSet.add(tag);
17
+ const invalidates = operation['x-remote-invalidates'] ?? [];
18
+ const parameters = parseParameters(operation.parameters ?? [], pathItem.parameters ?? [], spec.components);
19
+ const requestBodyResult = parseRequestBody(operation.requestBody);
20
+ const requestBodySchema = requestBodyResult?.schema;
21
+ const isArrayBody = requestBodyResult?.isArray ?? false;
22
+ // Check success response codes in priority order (200, 201, 202)
23
+ const responseSchema = parseResponse(operation.responses?.['200']) ??
24
+ parseResponse(operation.responses?.['201']) ??
25
+ parseResponse(operation.responses?.['202']);
26
+ // Detect void response: commands with no JSON response body (204 No Content, or 200 with no body)
27
+ const isVoidResponse = !responseSchema && (remoteType === 'command' || remoteType === 'form');
28
+ const isBatch = remoteType === 'query' && (operation['x-remote-batch'] === true);
29
+ operations.push({
30
+ operationId: operation.operationId ?? `${method}_${path}`,
31
+ tag,
32
+ method,
33
+ path,
34
+ remoteType,
35
+ invalidates,
36
+ parameters,
37
+ requestBodySchema,
38
+ requestBodyRequired: requestBodyResult?.required,
39
+ isArrayBody,
40
+ inlineRequestBody: requestBodyResult?.inline,
41
+ responseSchema,
42
+ isVoidResponse,
43
+ isBatch,
44
+ summary: operation.summary,
45
+ clientPropertyName: operation['x-client-property'],
46
+ });
47
+ }
48
+ }
49
+ return {
50
+ operations,
51
+ tags: Array.from(tagsSet),
52
+ };
53
+ }
54
+ function parseParameters(opParams, pathParams, components) {
55
+ const allParams = [...pathParams, ...opParams];
56
+ const result = [];
57
+ for (const param of allParams) {
58
+ if ('$ref' in param)
59
+ continue;
60
+ const schema = param.schema;
61
+ const resolved = resolveSchema(schema, components);
62
+ const paramInfo = {
63
+ name: param.name,
64
+ in: param.in,
65
+ required: param.required ?? false,
66
+ type: getSchemaType(resolved),
67
+ };
68
+ // Check if the resolved schema is an enum
69
+ if (resolved?.enum) {
70
+ const enumName = findEnumName(schema, components);
71
+ if (enumName) {
72
+ paramInfo.enumName = enumName;
73
+ }
74
+ }
75
+ // For array parameters, capture the item type
76
+ if (resolved?.type === 'array' && resolved.items) {
77
+ const itemSchema = resolveSchema(resolved.items, components);
78
+ paramInfo.itemType = getSchemaType(itemSchema);
79
+ }
80
+ result.push(paramInfo);
81
+ }
82
+ return result;
83
+ }
84
+ /**
85
+ * Resolve a schema through $ref and oneOf to get the underlying schema object.
86
+ */
87
+ function resolveSchema(schema, components) {
88
+ if (!schema)
89
+ return undefined;
90
+ if ('$ref' in schema) {
91
+ const refName = schema.$ref.split('/').pop();
92
+ if (refName && components?.schemas?.[refName]) {
93
+ return components.schemas[refName];
94
+ }
95
+ return undefined;
96
+ }
97
+ // Handle oneOf wrapping (NSwag nullable enums)
98
+ const schemaObj = schema;
99
+ if (schemaObj.oneOf) {
100
+ for (const item of schemaObj.oneOf) {
101
+ const resolved = resolveSchema(item, components);
102
+ if (resolved?.type || resolved?.enum)
103
+ return resolved;
104
+ }
105
+ }
106
+ return schemaObj;
107
+ }
108
+ /**
109
+ * Find the enum type name from a schema that may be wrapped in oneOf/$ref.
110
+ */
111
+ function findEnumName(schema, components) {
112
+ if (!schema)
113
+ return undefined;
114
+ if ('$ref' in schema) {
115
+ const refName = schema.$ref.split('/').pop();
116
+ if (refName && components?.schemas?.[refName]) {
117
+ const resolved = components.schemas[refName];
118
+ if (resolved.enum)
119
+ return refName;
120
+ }
121
+ return undefined;
122
+ }
123
+ const schemaObj = schema;
124
+ if (schemaObj.oneOf) {
125
+ for (const item of schemaObj.oneOf) {
126
+ const name = findEnumName(item, components);
127
+ if (name)
128
+ return name;
129
+ }
130
+ }
131
+ return undefined;
132
+ }
133
+ function parseRequestBody(body) {
134
+ if (!body?.content?.['application/json']?.schema)
135
+ return undefined;
136
+ const required = body.required ?? false;
137
+ const schema = body.content['application/json'].schema;
138
+ if ('$ref' in schema) {
139
+ const refName = schema.$ref.split('/').pop();
140
+ return refName ? { schema: `${refName}Schema`, isArray: false, required } : undefined;
141
+ }
142
+ // Handle array request bodies (e.g., Treatment[])
143
+ const schemaObj = schema;
144
+ if (schemaObj.type === 'array' && schemaObj.items) {
145
+ const items = schemaObj.items;
146
+ if ('$ref' in items) {
147
+ const itemName = items.$ref.split('/').pop();
148
+ return itemName ? { schema: `${itemName}Schema`, isArray: true, required } : undefined;
149
+ }
150
+ }
151
+ // Handle inline object schemas (e.g., Dictionary<string, string> -> { type: "object", additionalProperties: ... })
152
+ if (schemaObj.type === 'object' && schemaObj.additionalProperties) {
153
+ const inline = resolveInlineObjectSchema(schemaObj);
154
+ if (inline) {
155
+ return { schema: '', isArray: false, required, inline };
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+ /**
161
+ * Convert an inline OpenAPI object schema with additionalProperties to a Zod schema and TS type.
162
+ * Handles Dictionary<TKey, TValue> patterns from C#.
163
+ */
164
+ function resolveInlineObjectSchema(schema) {
165
+ const additionalProps = schema.additionalProperties;
166
+ if (!additionalProps || additionalProps === true) {
167
+ // additionalProperties: true or {} -> Record<string, unknown>
168
+ return {
169
+ zodSchema: 'z.record(z.string(), z.unknown())',
170
+ tsType: '{ [key: string]: any; }',
171
+ };
172
+ }
173
+ if (typeof additionalProps === 'object' && !('$ref' in additionalProps)) {
174
+ const valueSchema = additionalProps;
175
+ switch (valueSchema.type) {
176
+ case 'string':
177
+ return {
178
+ zodSchema: 'z.record(z.string(), z.string())',
179
+ tsType: '{ [key: string]: string; }',
180
+ };
181
+ case 'number':
182
+ case 'integer':
183
+ return {
184
+ zodSchema: 'z.record(z.string(), z.number())',
185
+ tsType: '{ [key: string]: number; }',
186
+ };
187
+ case 'boolean':
188
+ return {
189
+ zodSchema: 'z.record(z.string(), z.boolean())',
190
+ tsType: '{ [key: string]: boolean; }',
191
+ };
192
+ default:
193
+ return {
194
+ zodSchema: 'z.record(z.string(), z.unknown())',
195
+ tsType: '{ [key: string]: any; }',
196
+ };
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+ function parseResponse(response) {
202
+ if (!response?.content?.['application/json']?.schema)
203
+ return undefined;
204
+ const schema = response.content['application/json'].schema;
205
+ if ('$ref' in schema) {
206
+ return schema.$ref.split('/').pop();
207
+ }
208
+ // Handle array responses (e.g., TrackerDefinitionDto[])
209
+ const schemaObj = schema;
210
+ if (schemaObj.type === 'array' && schemaObj.items) {
211
+ const items = schemaObj.items;
212
+ if ('$ref' in items) {
213
+ const itemName = items.$ref.split('/').pop();
214
+ return itemName ? `${itemName}[]` : undefined;
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+ function getSchemaType(schema) {
220
+ if (!schema)
221
+ return 'unknown';
222
+ if (schema.type === 'string') {
223
+ if (schema.format === 'uuid')
224
+ return 'string';
225
+ if (schema.format === 'date-time')
226
+ return 'Date';
227
+ return 'string';
228
+ }
229
+ if (schema.type === 'integer' || schema.type === 'number')
230
+ return 'number';
231
+ if (schema.type === 'boolean')
232
+ return 'boolean';
233
+ if (schema.type === 'array')
234
+ return 'array';
235
+ return 'unknown';
236
+ }
@@ -0,0 +1,6 @@
1
+ export { defineConfig, resolveConfig } from './config.js';
2
+ export type { GeneratorConfig, UserConfig, ImportPaths, ErrorHandling } from './config.js';
3
+ export { parseOpenApiSpec } from './parser.js';
4
+ export type { ParsedSpec, OperationInfo, ParameterInfo, RemoteType, InlineRequestBody } from './types.js';
5
+ export { generateRemoteFunctions } from './generators/remote-functions.js';
6
+ export { generateApiClient } from './generators/api-client.js';
package/dist/public.js ADDED
@@ -0,0 +1,5 @@
1
+ // Public API for openapi-remote-codegen
2
+ export { defineConfig, resolveConfig } from './config.js';
3
+ export { parseOpenApiSpec } from './parser.js';
4
+ export { generateRemoteFunctions } from './generators/remote-functions.js';
5
+ export { generateApiClient } from './generators/api-client.js';
@@ -0,0 +1,44 @@
1
+ export type RemoteType = 'query' | 'command' | 'form';
2
+ export interface ParameterInfo {
3
+ name: string;
4
+ in: 'query' | 'path' | 'header';
5
+ required: boolean;
6
+ type: string;
7
+ enumName?: string;
8
+ /** For array-type parameters, the type of each item (e.g., 'string', 'number') */
9
+ itemType?: string;
10
+ }
11
+ /**
12
+ * Represents an inline request body that doesn't have a named $ref schema.
13
+ * Used for types like Dictionary<string, string> which become { type: "object", additionalProperties: ... }.
14
+ */
15
+ export interface InlineRequestBody {
16
+ /** Zod schema expression (e.g., "z.record(z.string(), z.string())") */
17
+ zodSchema: string;
18
+ /** TypeScript type for the NSwag client cast (e.g., "{ [key: string]: string; }") */
19
+ tsType: string;
20
+ }
21
+ export interface OperationInfo {
22
+ operationId: string;
23
+ tag: string;
24
+ method: string;
25
+ path: string;
26
+ remoteType: RemoteType;
27
+ invalidates: string[];
28
+ parameters: ParameterInfo[];
29
+ requestBodySchema?: string;
30
+ requestBodyRequired?: boolean;
31
+ isArrayBody?: boolean;
32
+ /** Inline request body for schemas that don't have a named $ref (e.g., Dictionary<string, string>) */
33
+ inlineRequestBody?: InlineRequestBody;
34
+ responseSchema?: string;
35
+ isVoidResponse: boolean;
36
+ /** Whether this query should use query.batch() for N+1 prevention */
37
+ isBatch?: boolean;
38
+ summary?: string;
39
+ clientPropertyName?: string;
40
+ }
41
+ export interface ParsedSpec {
42
+ operations: OperationInfo[];
43
+ tags: string[];
44
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Get the apiClient property name for an OpenAPI tag using camelCase fallback.
3
+ * Strips version prefixes (e.g., "V4 Trackers" -> "Trackers") and converts to camelCase.
4
+ * Used as fallback when no [ClientPropertyName] attribute is set on the controller.
5
+ */
6
+ export declare function getClientPropertyName(tag: string): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Get the apiClient property name for an OpenAPI tag using camelCase fallback.
3
+ * Strips version prefixes (e.g., "V4 Trackers" -> "Trackers") and converts to camelCase.
4
+ * Used as fallback when no [ClientPropertyName] attribute is set on the controller.
5
+ */
6
+ export function getClientPropertyName(tag) {
7
+ const cleaned = tag.replace(/^V\d+\s*/i, '').replace(/\s+/g, '');
8
+ return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
9
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Convert operationId to function name.
3
+ * "Note_GetNotes" -> "getNotes"
4
+ * "Note_CreateNote" -> "createNote"
5
+ * "ClockFaces_Delete" -> "remove" (reserved keyword handling)
6
+ */
7
+ export declare function operationIdToFunctionName(operationId: string): string;
8
+ /**
9
+ * Convert tag to camelCase file name with proper pluralization.
10
+ * "Note" -> "notes"
11
+ * "V4 Notes" -> "notes"
12
+ * "V4 Compression Lows" -> "compressionLows"
13
+ * "V1 Connector Status" -> "connectorStatus"
14
+ * "Battery" -> "batteries"
15
+ */
16
+ export declare function tagToFileName(tag: string): string;
17
+ /**
18
+ * Resolve invalidation targets.
19
+ * Short names resolve within same tag, full operationIds are used as-is.
20
+ * "GetNotes" with currentTag "Note" -> "getNotes"
21
+ * "Trackers_GetActiveInstances" -> "getActiveInstances" (from trackers)
22
+ */
23
+ export declare function resolveInvalidation(invalidate: string, currentTag: string): {
24
+ functionName: string;
25
+ fromTag: string;
26
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * JavaScript reserved keywords that cannot be used as export names.
3
+ * Maps known API method names to safe alternatives.
4
+ */
5
+ const RESERVED_KEYWORD_MAP = {
6
+ delete: 'remove',
7
+ };
8
+ const JS_RESERVED_KEYWORDS = new Set([
9
+ 'delete', 'new', 'return', 'switch', 'throw', 'try', 'catch', 'finally',
10
+ 'class', 'const', 'let', 'var', 'function', 'import', 'export', 'default',
11
+ 'void', 'typeof', 'instanceof', 'in', 'of', 'if', 'else', 'for', 'while',
12
+ 'do', 'break', 'continue', 'with', 'yield', 'await', 'async', 'super',
13
+ 'this', 'null', 'undefined', 'true', 'false', 'enum', 'implements',
14
+ 'interface', 'package', 'private', 'protected', 'public', 'static',
15
+ ]);
16
+ /**
17
+ * Ensure a function name is not a JavaScript reserved keyword.
18
+ * Uses explicit mapping for common cases (delete -> remove),
19
+ * falls back to underscore prefix for others.
20
+ */
21
+ function safeExportName(name) {
22
+ if (!JS_RESERVED_KEYWORDS.has(name))
23
+ return name;
24
+ return RESERVED_KEYWORD_MAP[name] ?? `_${name}`;
25
+ }
26
+ /**
27
+ * Convert operationId to function name.
28
+ * "Note_GetNotes" -> "getNotes"
29
+ * "Note_CreateNote" -> "createNote"
30
+ * "ClockFaces_Delete" -> "remove" (reserved keyword handling)
31
+ */
32
+ export function operationIdToFunctionName(operationId) {
33
+ const parts = operationId.split('_');
34
+ const name = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
35
+ const functionName = name.charAt(0).toLowerCase() + name.slice(1);
36
+ return safeExportName(functionName);
37
+ }
38
+ /**
39
+ * Pluralize a single word.
40
+ */
41
+ function pluralize(word) {
42
+ const lower = word.toLowerCase();
43
+ if (lower.endsWith('s'))
44
+ return lower;
45
+ if (lower.endsWith('y') && !/[aeiou]y$/.test(lower)) {
46
+ return lower.slice(0, -1) + 'ies';
47
+ }
48
+ if (/(?:sh|ch|x|z)$/.test(lower)) {
49
+ return lower + 'es';
50
+ }
51
+ return lower + 's';
52
+ }
53
+ /**
54
+ * Convert tag to camelCase file name with proper pluralization.
55
+ * "Note" -> "notes"
56
+ * "V4 Notes" -> "notes"
57
+ * "V4 Compression Lows" -> "compressionLows"
58
+ * "V1 Connector Status" -> "connectorStatus"
59
+ * "Battery" -> "batteries"
60
+ */
61
+ export function tagToFileName(tag) {
62
+ const cleaned = tag.replace(/^V\d+\s*/i, '').trim();
63
+ const words = cleaned.split(/\s+/);
64
+ // Pluralize the last word
65
+ const lastWord = pluralize(words[words.length - 1]);
66
+ const prefix = words.slice(0, -1);
67
+ // Build camelCase: first word lowercase, rest capitalized
68
+ const parts = [...prefix.map(w => w.toLowerCase()), lastWord];
69
+ return parts[0] + parts.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
70
+ }
71
+ /**
72
+ * Resolve invalidation targets.
73
+ * Short names resolve within same tag, full operationIds are used as-is.
74
+ * "GetNotes" with currentTag "Note" -> "getNotes"
75
+ * "Trackers_GetActiveInstances" -> "getActiveInstances" (from trackers)
76
+ */
77
+ export function resolveInvalidation(invalidate, currentTag) {
78
+ if (invalidate.includes('_')) {
79
+ const [tag, ...rest] = invalidate.split('_');
80
+ return {
81
+ functionName: operationIdToFunctionName(invalidate),
82
+ fromTag: tag,
83
+ };
84
+ }
85
+ return {
86
+ functionName: invalidate.charAt(0).toLowerCase() + invalidate.slice(1),
87
+ fromTag: currentTag,
88
+ };
89
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "openapi-remote-codegen",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Generate type-safe SvelteKit remote functions from OpenAPI specs with x-remote-* extensions",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "openapi-remote-codegen": "dist/index.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/public.d.ts",
14
+ "import": "./dist/public.js"
15
+ },
16
+ "./config": {
17
+ "types": "./dist/config.d.ts",
18
+ "import": "./dist/config.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "keywords": ["openapi", "sveltekit", "remote-functions", "codegen", "typescript"],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "openapi-types": "^12.1.3"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.0.0",
33
+ "tsx": "^4.19.0",
34
+ "typescript": "^5.8.3",
35
+ "vitest": "^3.0.0"
36
+ }
37
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getClientPropertyName } from '../utils/client-mapping.js';
3
+
4
+ describe('getClientPropertyName', () => {
5
+ it('maps standard tags to camelCase', () => {
6
+ expect(getClientPropertyName('Trackers')).toBe('trackers');
7
+ expect(getClientPropertyName('Entries')).toBe('entries');
8
+ expect(getClientPropertyName('Treatments')).toBe('treatments');
9
+ expect(getClientPropertyName('StateSpans')).toBe('stateSpans');
10
+ });
11
+
12
+ it('strips version prefix and camelCases', () => {
13
+ expect(getClientPropertyName('V4 Trackers')).toBe('trackers');
14
+ expect(getClientPropertyName('V2 Notifications')).toBe('notifications');
15
+ expect(getClientPropertyName('V1 Entries')).toBe('entries');
16
+ });
17
+
18
+ it('strips spaces from multi-word tags', () => {
19
+ expect(getClientPropertyName('Loop Notifications')).toBe('loopNotifications');
20
+ expect(getClientPropertyName('Chart Data')).toBe('chartData');
21
+ expect(getClientPropertyName('V4 Device Age')).toBe('deviceAge');
22
+ expect(getClientPropertyName('V4 Connector Settings')).toBe('connectorSettings');
23
+ });
24
+
25
+ it('applies pure camelCase without overrides', () => {
26
+ // These tags now rely on [ClientPropertyName] for non-standard names
27
+ expect(getClientPropertyName('Foods')).toBe('foods');
28
+ expect(getClientPropertyName('DData')).toBe('dData');
29
+ expect(getClientPropertyName('Prediction')).toBe('prediction');
30
+ expect(getClientPropertyName('IOB')).toBe('iOB');
31
+ expect(getClientPropertyName('UI Settings')).toBe('uISettings');
32
+ });
33
+
34
+ it('falls back to camelCase for unknown tags', () => {
35
+ expect(getClientPropertyName('NewFeature')).toBe('newFeature');
36
+ expect(getClientPropertyName('SomeThing')).toBe('someThing');
37
+ });
38
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defineConfig, resolveConfig } from '../config.js';
3
+
4
+ describe('defineConfig', () => {
5
+ it('returns the config object as-is (identity helper for type inference)', () => {
6
+ const config = defineConfig({ openApiPath: './spec.json' });
7
+ expect(config.openApiPath).toBe('./spec.json');
8
+ });
9
+ });
10
+
11
+ describe('resolveConfig', () => {
12
+ it('fills in all defaults when given empty partial', () => {
13
+ const config = resolveConfig({});
14
+ expect(config.openApiPath).toBe('./openapi.json');
15
+ expect(config.outputDir).toBe('./src/lib');
16
+ expect(config.remoteFunctionsOutput).toBe('api/generated');
17
+ expect(config.apiClientOutput).toBe('api/api-client.generated.ts');
18
+ expect(config.imports.server).toBe('$app/server');
19
+ expect(config.imports.kit).toBe('@sveltejs/kit');
20
+ expect(config.imports.schemas).toBe('$lib/api/generated/schemas');
21
+ expect(config.imports.apiTypes).toBe('$api');
22
+ expect(config.imports.zod).toBe('zod');
23
+ expect(config.clientAccess).toBe('getRequestEvent().locals.apiClient');
24
+ expect(config.nswagClientPath).toBe('./generated/api-client');
25
+ });
26
+
27
+ it('merges partial imports with defaults', () => {
28
+ const config = resolveConfig({
29
+ imports: { schemas: '$lib/schemas' },
30
+ });
31
+ expect(config.imports.schemas).toBe('$lib/schemas');
32
+ expect(config.imports.server).toBe('$app/server');
33
+ });
34
+
35
+ it('provides default error handling functions', () => {
36
+ const config = resolveConfig({});
37
+ expect(config.errorHandling.on401).toContain('redirect');
38
+ expect(config.errorHandling.on403).toContain('Forbidden');
39
+ expect(typeof config.errorHandling.on500).toBe('function');
40
+ expect(config.errorHandling.on500('doThing')).toContain('doThing');
41
+ });
42
+
43
+ it('allows overriding error handling', () => {
44
+ const config = resolveConfig({
45
+ errorHandling: {
46
+ on401: 'throw error(401, "Unauthorized")',
47
+ on403: 'throw error(403, "Nope")',
48
+ on500: (fn) => `throw error(500, "${fn} failed")`,
49
+ },
50
+ });
51
+ expect(config.errorHandling.on401).toBe('throw error(401, "Unauthorized")');
52
+ expect(config.errorHandling.on500('test')).toBe('throw error(500, "test failed")');
53
+ });
54
+
55
+ it('allows overriding nswagClientPath', () => {
56
+ const config = resolveConfig({ nswagClientPath: './generated/my-api' });
57
+ expect(config.nswagClientPath).toBe('./generated/my-api');
58
+ });
59
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { operationIdToFunctionName, tagToFileName, resolveInvalidation } from '../utils/naming.js';
3
+
4
+ describe('operationIdToFunctionName', () => {
5
+ it('strips tag prefix and camelCases', () => {
6
+ expect(operationIdToFunctionName('Trackers_GetDefinitions')).toBe('getDefinitions');
7
+ });
8
+
9
+ it('handles single-segment operationId', () => {
10
+ expect(operationIdToFunctionName('getDefinitions')).toBe('getDefinitions');
11
+ });
12
+
13
+ it('handles operationId starting with uppercase', () => {
14
+ expect(operationIdToFunctionName('CreateNote')).toBe('createNote');
15
+ });
16
+
17
+ it('handles multi-underscore operationId', () => {
18
+ expect(operationIdToFunctionName('Trackers_Get_All')).toBe('get_All');
19
+ });
20
+ });
21
+
22
+ describe('tagToFileName', () => {
23
+ it('lowercases already-plural tag', () => {
24
+ expect(tagToFileName('Trackers')).toBe('trackers');
25
+ });
26
+
27
+ it('pluralizes singular tag', () => {
28
+ expect(tagToFileName('Note')).toBe('notes');
29
+ });
30
+
31
+ it('strips version prefix', () => {
32
+ expect(tagToFileName('V4 Trackers')).toBe('trackers');
33
+ });
34
+
35
+ it('handles consonant + y -> ies', () => {
36
+ expect(tagToFileName('Battery')).toBe('batteries');
37
+ });
38
+
39
+ it('handles vowel + y -> ys', () => {
40
+ expect(tagToFileName('Key')).toBe('keys');
41
+ });
42
+
43
+ it('handles sibilant ending (ch)', () => {
44
+ expect(tagToFileName('Match')).toBe('matches');
45
+ });
46
+
47
+ it('preserves words already ending in s', () => {
48
+ expect(tagToFileName('Status')).toBe('status');
49
+ });
50
+
51
+ it('handles sibilant ending (x)', () => {
52
+ expect(tagToFileName('Box')).toBe('boxes');
53
+ });
54
+ });
55
+
56
+ describe('resolveInvalidation', () => {
57
+ it('resolves simple name within current tag', () => {
58
+ const result = resolveInvalidation('GetDefinitions', 'Trackers');
59
+ expect(result.functionName).toBe('getDefinitions');
60
+ expect(result.fromTag).toBe('Trackers');
61
+ });
62
+
63
+ it('resolves cross-tag operationId', () => {
64
+ const result = resolveInvalidation('Trackers_GetActiveInstances', 'Notes');
65
+ expect(result.functionName).toBe('getActiveInstances');
66
+ expect(result.fromTag).toBe('Trackers');
67
+ });
68
+ });