fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(@hey-api/sdk): dedup classes

Lubos 5613ac0c 4689b684

+917 -494
+7 -12
dev/openapi-ts.config.ts
··· 277 277 // asClass: true, 278 278 // auth: false, 279 279 // classNameBuilder: '{{name}}', 280 - // classNameBuilder: '{{name}}Service', 280 + classNameBuilder: '{{name}}Service', 281 281 // classStructure: 'off', 282 282 // client: false, 283 283 // getSignature: ({ fields, signature, operation }) => { ··· 285 285 // fields.unwrap('path') 286 286 // }, 287 287 // include... 288 - instance: true, 288 + instance: 'Root', 289 + methodNameBuilder: '{{name}}Methods', 289 290 name: '@hey-api/sdk', 290 291 // operationId: false, 291 292 // paramsStructure: 'flat', ··· 304 305 symbols: { 305 306 // getFilePath: (symbol) => { 306 307 // if (symbol.name) { 307 - // return utils.stringCase({ 308 - // case: 'camelCase', 309 - // value: symbol.name, 310 - // }); 308 + // return utils.toCase(symbol.name, 'camelCase'); 311 309 // } 312 310 // return; 313 311 // }, ··· 351 349 // name: '{{name}}MO', 352 350 // name: 'options', 353 351 }, 354 - name: '@tanstack/react-query', 352 + // name: '@tanstack/react-query', 355 353 queryKeys: { 356 354 // name: '{{name}}QK', 357 355 // name: 'options', ··· 421 419 symbols: { 422 420 // getFilePath: (symbol) => { 423 421 // if (symbol.name) { 424 - // return utils.stringCase({ 425 - // case: 'camelCase', 426 - // value: symbol.name, 427 - // }); 422 + // return utils.toCase(symbol.name, 'camelCase'); 428 423 // } 429 424 // return; 430 425 // }, ··· 615 610 { 616 611 exportFromIndex: true, 617 612 // mutationOptions: '{{name}}Mutationssss', 618 - name: '@pinia/colada', 613 + // name: '@pinia/colada', 619 614 // queryOptions: { 620 615 // name: '{{name}}Queryyyyy', 621 616 // },
+1 -1
packages/codegen-core/src/files/file.ts
··· 5 5 import { debug } from '../debug'; 6 6 import type { Language } from '../languages/types'; 7 7 import type { INode } from '../nodes/node'; 8 - import type { NameScopes } from '../planner/types'; 8 + import type { NameScopes } from '../planner/scope'; 9 9 import type { IProject } from '../project/types'; 10 10 import type { Renderer } from '../renderer'; 11 11 import type { IFileIn } from './types';
+6 -10
packages/codegen-core/src/planner/analyzer.ts
··· 3 3 import { fromRef, isRef, ref } from '../refs/refs'; 4 4 import type { Ref } from '../refs/types'; 5 5 import type { Symbol } from '../symbols/symbol'; 6 - import type { IAnalysisContext, Input, NameScopes, Scope } from './types'; 7 - 8 - const createScope = (parent?: Scope): Scope => ({ 9 - children: [], 10 - localNames: new Map(), 11 - parent, 12 - symbols: [], 13 - }); 6 + import type { NameScopes, Scope } from './scope'; 7 + import { createScope } from './scope'; 8 + import type { IAnalysisContext, Input } from './types'; 14 9 15 10 export class AnalysisContext implements IAnalysisContext { 11 + scope: Scope; 16 12 scopes: Scope = createScope(); 17 13 symbol?: Symbol; 18 - scope: Scope = this.scopes; 19 14 20 15 constructor(symbol?: Symbol) { 16 + this.scope = this.scopes; 21 17 this.symbol = symbol; 22 18 } 23 19 ··· 62 58 } 63 59 64 60 pushScope(): void { 65 - const scope = createScope(this.scope); 61 + const scope = createScope({ parent: this.scope }); 66 62 this.scope.children.push(scope); 67 63 this.scope = scope; 68 64 }
+54 -31
packages/codegen-core/src/planner/planner.ts
··· 12 12 import type { SymbolKind } from '../symbols/types'; 13 13 import type { AnalysisContext } from './analyzer'; 14 14 import { Analyzer } from './analyzer'; 15 - import type { AssignOptions, NameScopes } from './types'; 15 + import type { AssignOptions, Scope } from './scope'; 16 + import { createScope } from './scope'; 16 17 17 18 const isTypeOnlyKind = (kind: SymbolKind) => 18 19 kind === 'type' || kind === 'interface'; ··· 76 77 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => { 77 78 const symbol = node.symbol; 78 79 if (!symbol) return; 79 - this.assignTopLevelName(symbol, ctx); 80 + this.assignTopLevelName({ ctx, symbol }); 80 81 }); 81 82 82 83 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => { ··· 86 87 const dep = fromRef(dependency); 87 88 // top-level or external symbol 88 89 if (dep.file) return; 89 - this.assignLocalName(dep, ctx, { 90 - scopesToUpdate: [file.allNames], 90 + this.assignLocalName({ 91 + ctx, 92 + file, 93 + scopesToUpdate: [createScope({ localNames: file.allNames })], 94 + symbol: dep, 91 95 }); 92 96 }); 93 97 }); ··· 166 170 }); 167 171 exp.setFile(target); 168 172 sourceFile.set(exp.id, file); 169 - this.assignTopLevelName(exp, ctx); 173 + this.assignTopLevelName({ ctx, symbol: exp }); 170 174 171 175 let entry = fileMap.get(exp.finalName); 172 176 if (!entry) { ··· 253 257 if (!dep.file || dep.file.id === file.id) return; 254 258 255 259 if (dep.external) { 256 - this.assignTopLevelName(dep, ctx); 260 + this.assignTopLevelName({ ctx, symbol: dep }); 257 261 } 258 262 259 263 const fromFileId = dep.file.id; ··· 272 276 name: dep.finalName, 273 277 }); 274 278 imp.setFile(file); 275 - this.assignTopLevelName(imp, ctx, { 276 - scope: imp.file!.allNames, 279 + this.assignTopLevelName({ 280 + ctx, 281 + scope: createScope({ localNames: imp.file!.allNames }), 282 + symbol: imp, 277 283 }); 278 284 entry = { 279 285 dep, ··· 334 340 * Supports optional overrides for the naming scope and scopes to update. 335 341 */ 336 342 private assignTopLevelName( 337 - symbol: Symbol, 338 - ctx: AnalysisContext, 339 - options?: Partial<AssignOptions>, 343 + args: Partial<AssignOptions> & { 344 + ctx: AnalysisContext; 345 + symbol: Symbol; 346 + }, 340 347 ): void { 341 - if (!symbol.file) return; 342 - this.assignSymbolName(symbol, { 343 - scope: options?.scope ?? symbol.file.topLevelNames, 348 + if (!args.symbol.file) return; 349 + this.assignSymbolName({ 350 + ...args, 351 + file: args.symbol.file, 352 + scope: 353 + args?.scope ?? 354 + createScope({ localNames: args.symbol.file.topLevelNames }), 344 355 scopesToUpdate: [ 345 - symbol.file.allNames, 346 - ctx.scopes.localNames, 347 - ...(options?.scopesToUpdate ?? []), 356 + createScope({ localNames: args.symbol.file.allNames }), 357 + args.ctx.scopes, 358 + ...(args?.scopesToUpdate ?? []), 348 359 ], 349 360 }); 350 361 } ··· 357 368 * Updates all provided name scopes accordingly. 358 369 */ 359 370 private assignLocalName( 360 - symbol: Symbol, 361 - ctx: AnalysisContext, 362 - options: Pick<Partial<AssignOptions>, 'scope'> & 363 - Pick<AssignOptions, 'scopesToUpdate'>, 371 + args: Pick<Partial<AssignOptions>, 'scope'> & 372 + Pick<AssignOptions, 'scopesToUpdate'> & { 373 + ctx: AnalysisContext; 374 + /** The file the symbol belongs to. */ 375 + file: File; 376 + symbol: Symbol; 377 + }, 364 378 ): void { 365 - this.assignSymbolName(symbol, { 366 - scope: options.scope ?? ctx.localNames(ctx.scope), 367 - scopesToUpdate: options.scopesToUpdate, 379 + this.assignSymbolName({ 380 + ...args, 381 + scope: args.scope ?? args.ctx.scope, 368 382 }); 369 383 } 370 384 ··· 375 389 * 376 390 * Updates all specified name scopes with the assigned final name. 377 391 */ 378 - private assignSymbolName(symbol: Symbol, options: AssignOptions): void { 392 + private assignSymbolName( 393 + args: AssignOptions & { 394 + ctx: AnalysisContext; 395 + /** The file the symbol belongs to. */ 396 + file: File; 397 + symbol: Symbol; 398 + }, 399 + ): void { 400 + const { ctx, file, scope, scopesToUpdate, symbol } = args; 379 401 if (this.cacheResolvedNames.has(symbol.id)) return; 380 402 381 403 const baseName = symbol.name; 382 404 let finalName = symbol.nameSanitizer?.(baseName) ?? baseName; 383 405 let attempt = 1; 384 406 407 + const localNames = ctx.localNames(scope); 385 408 while (true) { 386 - const kinds = [...(options.scope.get(finalName) ?? [])]; 409 + const kinds = [...(localNames.get(finalName) ?? [])]; 387 410 388 411 const ok = kinds.every((kind) => canShareName(symbol.kind, kind)); 389 412 if (ok) break; 390 413 391 - const language = symbol.node?.language || symbol.file?.language; 414 + const language = symbol.node?.language || file.language; 392 415 const resolver = 393 416 (language ? this.project.nameConflictResolvers[language] : undefined) ?? 394 417 this.project.defaultNameConflictResolver; ··· 403 426 404 427 symbol.setFinalName(finalName); 405 428 this.cacheResolvedNames.add(symbol.id); 406 - const updateScopes = [options.scope, ...options.scopesToUpdate]; 429 + const updateScopes = [scope, ...scopesToUpdate]; 407 430 for (const scope of updateScopes) { 408 431 this.updateScope(symbol, scope); 409 432 } ··· 414 437 * 415 438 * Ensures the name scope tracks all kinds associated with a given name. 416 439 */ 417 - private updateScope(symbol: Symbol, scope: NameScopes): void { 440 + private updateScope(symbol: Symbol, scope: Scope): void { 418 441 const name = symbol.finalName; 419 - const cache = scope.get(name) ?? new Set<SymbolKind>(); 442 + const cache = scope.localNames.get(name) ?? new Set(); 420 443 cache.add(symbol.kind); 421 - scope.set(name, cache); 444 + scope.localNames.set(name, cache); 422 445 } 423 446 424 447 private symbolToFileIn(symbol: Symbol): IFileIn {
+35
packages/codegen-core/src/planner/scope.ts
··· 1 + import type { Ref } from '../refs/types'; 2 + import type { Symbol } from '../symbols/symbol'; 3 + import type { SymbolKind } from '../symbols/types'; 4 + 5 + export type NameScopes = Map<string, Set<SymbolKind>>; 6 + 7 + export type Scope = { 8 + /** Child scopes. */ 9 + children: Array<Scope>; 10 + /** Resolved names in this scope. */ 11 + localNames: NameScopes; 12 + /** Parent scope, if any. */ 13 + parent?: Scope; 14 + /** Symbols registered in this scope. */ 15 + symbols: Array<Ref<Symbol>>; 16 + }; 17 + 18 + export type AssignOptions = { 19 + /** The primary scope in which to assign a symbol's final name. */ 20 + scope: Scope; 21 + /** Additional scopes to update as side effects when assigning a symbol's final name. */ 22 + scopesToUpdate: ReadonlyArray<Scope>; 23 + }; 24 + 25 + export const createScope = ( 26 + args: { 27 + localNames?: NameScopes; 28 + parent?: Scope; 29 + } = {}, 30 + ): Scope => ({ 31 + children: [], 32 + localNames: args.localNames || new Map(), 33 + parent: args.parent, 34 + symbols: [], 35 + });
+1 -21
packages/codegen-core/src/planner/types.d.ts
··· 1 1 import type { Ref } from '../refs/types'; 2 2 import type { Symbol } from '../symbols/symbol'; 3 - import type { SymbolKind } from '../symbols/types'; 4 - 5 - export type AssignOptions = { 6 - /** The primary scope in which to assign a symbol's final name. */ 7 - scope: NameScopes; 8 - /** Additional scopes to update as side effects when assigning a symbol's final name. */ 9 - scopesToUpdate: ReadonlyArray<NameScopes>; 10 - }; 3 + import type { NameScopes, Scope } from './scope'; 11 4 12 5 export type Input = Ref<object> | object | string | number | undefined; 13 - 14 - export type NameScopes = Map<string, Set<SymbolKind>>; 15 6 16 7 export type NameConflictResolver = (args: { 17 8 attempt: number; 18 9 baseName: string; 19 10 }) => string | null; 20 - 21 - export type Scope = { 22 - /** Child scopes. */ 23 - children: Array<Scope>; 24 - /** Resolved names in this scope. */ 25 - localNames: NameScopes; 26 - /** Parent scope, if any. */ 27 - parent?: Scope; 28 - /** Symbols registered in this scope. */ 29 - symbols: Array<Ref<Symbol>>; 30 - }; 31 11 32 12 export interface IAnalysisContext { 33 13 /** Register a dependency on another symbol. */
+5 -1
packages/codegen-core/src/symbols/symbol.ts
··· 337 337 * Returns a debug‑friendly string representation identifying the symbol. 338 338 */ 339 339 toString(): string { 340 - return `[Symbol ${this.name}#${this.id}]`; 340 + const canonical = this.canonical; 341 + if (canonical._finalName && canonical._finalName !== canonical._name) { 342 + return `[Symbol ${canonical._name} → ${canonical._finalName}#${canonical.id}]`; 343 + } 344 + return `[Symbol ${canonical._name}#${canonical.id}]`; 341 345 } 342 346 343 347 /**
+3 -4
packages/openapi-ts/src/openApi/shared/utils/name.ts
··· 1 - import { stringCase } from '~/utils/stringCase'; 2 - 3 - import type { StringCase, StringName } from '../../../types/case'; 1 + import type { StringCase, StringName } from '~/types/case'; 2 + import { toCase } from '~/utils/to-case'; 4 3 5 4 export const buildName = ({ 6 5 config, ··· 19 18 name = config.name.replace('{{name}}', `${separator}${name}${separator}`); 20 19 } 21 20 22 - return stringCase({ case: config.case, value: name }); 21 + return toCase(name, config.case); 23 22 };
+3 -9
packages/openapi-ts/src/openApi/shared/utils/operation.ts
··· 1 1 import type { Context } from '~/ir/context'; 2 2 import { createOperationKey } from '~/ir/operation'; 3 3 import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; 4 - import { stringCase } from '~/utils/stringCase'; 4 + import { toCase } from '~/utils/to-case'; 5 5 6 6 import type { State } from '../types/state'; 7 7 ··· 49 49 (!context.config.plugins['@hey-api/sdk'] || 50 50 context.config.plugins['@hey-api/sdk'].config.operationId) 51 51 ) { 52 - result = stringCase({ 53 - case: targetCase, 54 - value: sanitizeNamespaceIdentifier(id), 55 - }); 52 + result = toCase(sanitizeNamespaceIdentifier(id), targetCase); 56 53 } else { 57 54 const pathWithoutPlaceholders = path 58 55 .replace(/{(.*?)}/g, 'by-$1') 59 56 // replace slashes with hyphens for camelcase method at the end 60 57 .replace(/[/:+]/g, '-'); 61 58 62 - result = stringCase({ 63 - case: targetCase, 64 - value: `${method}-${pathWithoutPlaceholders}`, 65 - }); 59 + result = toCase(`${method}-${pathWithoutPlaceholders}`, targetCase); 66 60 } 67 61 68 62 if (count > 1) {
+2 -4
packages/openapi-ts/src/plugins/@angular/common/httpRequests.ts
··· 8 8 isOperationOptionsRequired, 9 9 } from '~/plugins/shared/utils/operation'; 10 10 import { $ } from '~/ts-dsl'; 11 - import { stringCase } from '~/utils/stringCase'; 11 + import { toCase } from '~/utils/to-case'; 12 12 13 13 import type { AngularCommonPlugin } from './types'; 14 14 ··· 107 107 generateClass(childClass); 108 108 109 109 currentClass.nodes.push( 110 - $.field( 111 - stringCase({ case: 'camelCase', value: childClass.className }), 112 - ).assign( 110 + $.field(toCase(childClass.className, 'camelCase')).assign( 113 111 $.new( 114 112 buildName({ 115 113 config: {
+3 -13
packages/openapi-ts/src/plugins/@angular/common/httpResources.ts
··· 8 8 isOperationOptionsRequired, 9 9 } from '~/plugins/shared/utils/operation'; 10 10 import { $ } from '~/ts-dsl'; 11 - import { stringCase } from '~/utils/stringCase'; 11 + import { toCase } from '~/utils/to-case'; 12 12 13 13 import type { AngularCommonPlugin } from './types'; 14 14 ··· 107 107 generateClass(childClass); 108 108 109 109 currentClass.nodes.push( 110 - $.field( 111 - stringCase({ 112 - case: 'camelCase', 113 - value: childClass.className, 114 - }), 115 - ).assign( 110 + $.field(toCase(childClass.className, 'camelCase')).assign( 116 111 $.new( 117 112 buildName({ 118 113 config: { ··· 237 232 for (let i = 1; i < firstEntry.path.length; i++) { 238 233 const className = firstEntry.path[i]; 239 234 if (className) { 240 - methodAccess = methodAccess.attr( 241 - stringCase({ 242 - case: 'camelCase', 243 - value: className, 244 - }), 245 - ); 235 + methodAccess = methodAccess.attr(toCase(className, 'camelCase')); 246 236 } 247 237 } 248 238
+1
packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts
··· 12 12 client: true, 13 13 exportFromIndex: true, 14 14 instance: '', 15 + methodNameBuilder: '{{name}}', 15 16 operationId: true, 16 17 paramsStructure: 'grouped', 17 18 response: 'body',
+312
packages/openapi-ts/src/plugins/@hey-api/sdk/model/class.ts
··· 1 + import type { IR } from '~/ir/types'; 2 + import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; 3 + import { 4 + createOperationComment, 5 + isOperationOptionsRequired, 6 + } from '~/plugins/shared/utils/operation'; 7 + import { $ } from '~/ts-dsl'; 8 + import { toCase } from '~/utils/to-case'; 9 + 10 + import { createClientClass, createRegistryClass } from '../shared/class'; 11 + import { nuxtTypeComposable, nuxtTypeDefault } from '../shared/constants'; 12 + import { 13 + operationClassName, 14 + operationMethodName, 15 + operationParameters, 16 + operationStatements, 17 + } from '../shared/operation'; 18 + import type { HeyApiSdkPlugin } from '../types'; 19 + 20 + /** 21 + * Represents a class in the SDK hierarchy. 22 + * 23 + * Classes can be nested (via children) and contain operations (methods). 24 + */ 25 + export class SdkClassModel { 26 + /** Nested classes within this class. */ 27 + children: Map<string, SdkClassModel> = new Map(); 28 + /** The name of this class (e.g., "Users", "Accounts"). */ 29 + name: string; 30 + /** Operations that will become methods in this class. */ 31 + operations: Array<IR.OperationObject> = []; 32 + /** Parent class in the hierarchy. Undefined if this is the root class. */ 33 + parent?: SdkClassModel; 34 + 35 + constructor(name: string, parent?: SdkClassModel) { 36 + this.name = name; 37 + this.parent = parent; 38 + } 39 + 40 + get isRoot(): boolean { 41 + return !this.parent; 42 + } 43 + 44 + /** 45 + * Adds an operation to this class. 46 + * 47 + * The operation will be converted to a method during code generation. 48 + */ 49 + addOperation(operation: IR.OperationObject): void { 50 + this.operations.push(operation); 51 + } 52 + 53 + /** 54 + * Gets or creates a child class. 55 + * 56 + * If the child doesn't exist, it's created automatically. 57 + * 58 + * @param name - The name of the child class 59 + * @returns The child class instance 60 + */ 61 + child(name: string): SdkClassModel { 62 + if (!this.children.has(name)) { 63 + this.children.set(name, new SdkClassModel(name, this)); 64 + } 65 + return this.children.get(name)!; 66 + } 67 + 68 + /** 69 + * Inserts an operation into the class tree. 70 + * 71 + * Parses the operation ID and creates the class hierarchy. 72 + */ 73 + insert( 74 + operation: IR.OperationObject, 75 + plugin: HeyApiSdkPlugin['Instance'], 76 + ): void { 77 + const classSegments = 78 + plugin.config.classStructure === 'auto' && operation.operationId 79 + ? operation.operationId.split(/[./]/).slice(0, -1) 80 + : []; 81 + 82 + // eslint-disable-next-line @typescript-eslint/no-this-alias 83 + let cursor: SdkClassModel = this; 84 + for (const segment of classSegments) { 85 + cursor = cursor.child(segment); 86 + } 87 + 88 + cursor.addOperation(operation); 89 + } 90 + 91 + /** 92 + * Converts this class group to a class node. 93 + */ 94 + toNode(plugin: HeyApiSdkPlugin['Instance']): { 95 + dependencies: Array<ReturnType<typeof $.class>>; 96 + node: ReturnType<typeof $.class>; 97 + } { 98 + const dependencies: Array<ReturnType<typeof $.class>> = []; 99 + 100 + const client = getClientPlugin(plugin.context.config); 101 + const isAngularClient = client.name === '@hey-api/client-angular'; 102 + const isNuxtClient = client.name === '@hey-api/client-nuxt'; 103 + 104 + const symbolClass = plugin.symbol( 105 + operationClassName({ plugin, value: this.name }), 106 + { 107 + meta: { 108 + category: 'utility', 109 + resource: 'class', 110 + resourceId: this.name, 111 + tool: 'sdk', 112 + }, 113 + }, 114 + ); 115 + const node = $.class(symbolClass) 116 + .export() 117 + .extends( 118 + plugin.referenceSymbol({ 119 + category: 'utility', 120 + resource: 'class', 121 + resourceId: 'HeyApiClient', 122 + tool: 'sdk', 123 + }), 124 + ) 125 + .$if(isAngularClient && this.isRoot, (c) => 126 + c.decorator( 127 + plugin.referenceSymbol({ 128 + category: 'external', 129 + resource: '@angular/core.Injectable', 130 + }), 131 + $.object().prop('providedIn', $.literal('root')), 132 + ), 133 + ); 134 + 135 + if (this.isRoot) { 136 + const symbolClient = plugin.symbol('HeyApiClient', { 137 + meta: { 138 + category: 'utility', 139 + resource: 'class', 140 + resourceId: 'HeyApiClient', 141 + tool: 'sdk', 142 + }, 143 + }); 144 + const clientNode = createClientClass({ plugin, symbol: symbolClient }); 145 + dependencies.push(clientNode); 146 + const symbolRegistry = plugin.symbol('HeyApiRegistry', { 147 + meta: { 148 + category: 'utility', 149 + resource: 'class', 150 + resourceId: 'HeyApiRegistry', 151 + tool: 'sdk', 152 + }, 153 + }); 154 + const registryNode = createRegistryClass({ 155 + plugin, 156 + sdkSymbol: symbolClass, 157 + symbol: symbolRegistry, 158 + }); 159 + dependencies.push(registryNode); 160 + node.field('__registry', (f) => 161 + f 162 + .public() 163 + .static() 164 + .readonly() 165 + .assign($.new(symbolRegistry).generic(symbolClass)), 166 + ); 167 + node.newline(); 168 + 169 + const symClient = plugin.getSymbol({ category: 'client' }); 170 + const isClientRequired = !plugin.config.client || !symClient; 171 + const symbolClientType = plugin.referenceSymbol({ 172 + category: 'external', 173 + resource: 'client.Client', 174 + }); 175 + node.init((i) => 176 + i 177 + .param('args', (p) => 178 + p.required(isClientRequired).type( 179 + $.type 180 + .object() 181 + .prop('client', (p) => 182 + p.required(isClientRequired).type(symbolClientType), 183 + ) 184 + .prop('key', (p) => p.optional().type('string')), 185 + ), 186 + ) 187 + .do( 188 + $('super').call('args'), 189 + $(symbolClass) 190 + .attr('__registry') 191 + .attr('set') 192 + .call('this', $('args').attr('key').required(isClientRequired)), 193 + ), 194 + ); 195 + } 196 + 197 + this.operations.forEach((operation, index) => { 198 + if (index > 0 || node.hasBody) node.newline(); 199 + const symbolMethod = plugin.symbol( 200 + operationMethodName({ 201 + operation, 202 + plugin, 203 + value: 204 + plugin.config.classStructure === 'auto' && operation.operationId 205 + ? toCase(operation.operationId.split(/[./]/).pop()!, 'camelCase') 206 + : operation.id, 207 + }), 208 + ); 209 + const isRequiredOptions = isOperationOptionsRequired({ 210 + context: plugin.context, 211 + operation, 212 + }); 213 + const opParameters = operationParameters({ 214 + isRequiredOptions, 215 + operation, 216 + plugin, 217 + }); 218 + const statements = operationStatements({ 219 + isRequiredOptions, 220 + opParameters, 221 + operation, 222 + plugin, 223 + }); 224 + node.method(symbolMethod, (m) => 225 + m 226 + .$if(createOperationComment(operation), (m, v) => m.doc(v)) 227 + .public() 228 + .static(!isAngularClient && !plugin.config.instance) 229 + .$if( 230 + isNuxtClient, 231 + (m) => 232 + m 233 + .generic(nuxtTypeComposable, (t) => 234 + t 235 + .extends( 236 + plugin.referenceSymbol({ 237 + category: 'external', 238 + resource: 'client.Composable', 239 + }), 240 + ) 241 + .default($.type.literal('$fetch')), 242 + ) 243 + .generic(nuxtTypeDefault, (t) => 244 + t.$if( 245 + plugin.querySymbol({ 246 + category: 'type', 247 + resource: 'operation', 248 + resourceId: operation.id, 249 + role: 'response', 250 + }), 251 + (t, s) => t.extends(s).default(s), 252 + ), 253 + ), 254 + (m) => 255 + m.generic('ThrowOnError', (t) => 256 + t 257 + .extends('boolean') 258 + .default( 259 + ('throwOnError' in client.config 260 + ? client.config.throwOnError 261 + : false) ?? false, 262 + ), 263 + ), 264 + ) 265 + .params(...opParameters.parameters) 266 + .do(...statements), 267 + ); 268 + }); 269 + 270 + for (const child of this.children.values()) { 271 + if (node.hasBody) node.newline(); 272 + const refChild = plugin.referenceSymbol({ 273 + category: 'utility', 274 + resource: 'class', 275 + resourceId: child.name, 276 + tool: 'sdk', 277 + }); 278 + const memberName = toCase(refChild.name, 'camelCase'); 279 + const privateName = plugin.symbol(`_${memberName}`); 280 + const getterName = plugin.symbol(memberName); 281 + node.field(privateName, (f) => f.private().optional().type(refChild)); 282 + node.do( 283 + $.getter(getterName, (g) => 284 + g.returns(refChild).do( 285 + $('this') 286 + .attr(privateName) 287 + .nullishAssign( 288 + $.new(refChild).args( 289 + $.object().prop('client', $('this').attr('client')), 290 + ), 291 + ) 292 + .return(), 293 + ), 294 + ), 295 + ); 296 + } 297 + 298 + return { dependencies, node }; 299 + } 300 + 301 + /** 302 + * Recursively walks the tree depth-first. 303 + * 304 + * Yields this node, then all descendants. 305 + */ 306 + *walk(): Generator<SdkClassModel> { 307 + for (const child of this.children.values()) { 308 + yield* child.walk(); 309 + } 310 + yield this; 311 + } 312 + }
+302 -265
packages/openapi-ts/src/plugins/@hey-api/sdk/shared/class.ts
··· 7 7 } from '~/plugins/shared/utils/operation'; 8 8 import type { TsDsl } from '~/ts-dsl'; 9 9 import { $ } from '~/ts-dsl'; 10 - import { stringCase } from '~/utils/stringCase'; 10 + import { toCase } from '~/utils/to-case'; 11 11 12 + import { SdkClassModel } from '../model/class'; 12 13 import type { HeyApiSdkPlugin } from '../types'; 13 14 import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; 14 15 import { ··· 46 47 47 48 export const registryName = '__registry'; 48 49 49 - const createRegistryClass = ({ 50 + export const createRegistryClass = ({ 50 51 plugin, 51 52 sdkSymbol, 52 53 symbol, ··· 54 55 plugin: HeyApiSdkPlugin['Instance']; 55 56 sdkSymbol: Symbol; 56 57 symbol: Symbol; 57 - }): TsDsl => { 58 + }): ReturnType<typeof $.class> => { 58 59 const symbolDefaultKey = plugin.symbol('defaultKey'); 59 60 const symbolInstances = plugin.symbol('instances'); 60 61 return $.class(symbol) ··· 107 108 ); 108 109 }; 109 110 110 - const createClientClass = ({ 111 + export const createClientClass = ({ 111 112 plugin, 112 113 symbol, 113 114 }: { 114 115 plugin: HeyApiSdkPlugin['Instance']; 115 116 symbol: Symbol; 116 - }): TsDsl => { 117 - const symClient = plugin.getSymbol({ 118 - category: 'client', 119 - }); 117 + }): ReturnType<typeof $.class> => { 118 + const symClient = plugin.getSymbol({ category: 'client' }); 120 119 const optionalClient = Boolean(plugin.config.client && symClient); 121 120 const symbolClient = plugin.referenceSymbol({ 122 121 category: 'external', ··· 165 164 */ 166 165 const generatedClasses = new Set<string>(); 167 166 167 + const sdkModel = plugin.config.instance 168 + ? new SdkClassModel(plugin.config.instance) 169 + : undefined; 170 + 168 171 plugin.forEach( 169 172 'operation', 170 173 ({ operation }) => { 171 - const isRequiredOptions = isOperationOptionsRequired({ 172 - context: plugin.context, 173 - operation, 174 - }); 175 - const symbolResponse = isNuxtClient 176 - ? plugin.querySymbol({ 177 - category: 'type', 178 - resource: 'operation', 179 - resourceId: operation.id, 180 - role: 'response', 181 - }) 182 - : undefined; 174 + if (sdkModel) { 175 + sdkModel.insert(operation, plugin); 176 + } else { 177 + const isRequiredOptions = isOperationOptionsRequired({ 178 + context: plugin.context, 179 + operation, 180 + }); 181 + const symbolResponse = isNuxtClient 182 + ? plugin.querySymbol({ 183 + category: 'type', 184 + resource: 'operation', 185 + resourceId: operation.id, 186 + role: 'response', 187 + }) 188 + : undefined; 183 189 184 - const classes = operationClasses({ operation, plugin }); 190 + const classes = operationClasses({ operation, plugin }); 185 191 186 - for (const entry of classes.values()) { 187 - entry.path.forEach((currentClassName, index) => { 188 - const symbolCurrentClass = plugin.referenceSymbol({ 189 - category: 'utility', 190 - resource: 'class', 191 - resourceId: currentClassName, 192 - tool: 'sdk', 193 - }); 194 - if (!sdkClasses.has(symbolCurrentClass.meta!.resourceId!)) { 195 - sdkClasses.set(symbolCurrentClass.meta!.resourceId!, { 196 - className: symbolCurrentClass.meta!.resourceId!, 197 - classes: new Set(), 198 - id: symbolCurrentClass.id, 199 - methods: new Set(), 200 - nodes: [], 201 - root: !index, 202 - }); 203 - } 204 - 205 - const parentClassName = entry.path[index - 1]; 206 - if (parentClassName) { 207 - const symbolParentClass = plugin.referenceSymbol({ 192 + for (const entry of classes.values()) { 193 + entry.path.forEach((currentClassName, index) => { 194 + const symbolCurrentClass = plugin.referenceSymbol({ 208 195 category: 'utility', 209 196 resource: 'class', 210 - resourceId: parentClassName, 197 + resourceId: currentClassName, 211 198 tool: 'sdk', 212 199 }); 213 - if ( 214 - symbolParentClass.meta?.resourceId !== 215 - symbolCurrentClass.meta?.resourceId 216 - ) { 217 - const parentClass = sdkClasses.get( 218 - symbolParentClass.meta!.resourceId!, 219 - )!; 220 - parentClass.classes.add(symbolCurrentClass.meta!.resourceId!); 221 - sdkClasses.set(symbolParentClass.meta!.resourceId!, parentClass); 200 + if (!sdkClasses.has(symbolCurrentClass.meta!.resourceId!)) { 201 + sdkClasses.set(symbolCurrentClass.meta!.resourceId!, { 202 + className: symbolCurrentClass.meta!.resourceId!, 203 + classes: new Set(), 204 + id: symbolCurrentClass.id, 205 + methods: new Set(), 206 + nodes: [], 207 + root: !index, 208 + }); 222 209 } 223 - } 224 210 225 - const isLast = entry.path.length === index + 1; 226 - // add methods only to the last class 227 - if (!isLast) { 228 - return; 229 - } 211 + const parentClassName = entry.path[index - 1]; 212 + if (parentClassName) { 213 + const symbolParentClass = plugin.referenceSymbol({ 214 + category: 'utility', 215 + resource: 'class', 216 + resourceId: parentClassName, 217 + tool: 'sdk', 218 + }); 219 + if ( 220 + symbolParentClass.meta?.resourceId !== 221 + symbolCurrentClass.meta?.resourceId 222 + ) { 223 + const parentClass = sdkClasses.get( 224 + symbolParentClass.meta!.resourceId!, 225 + )!; 226 + parentClass.classes.add(symbolCurrentClass.meta!.resourceId!); 227 + sdkClasses.set( 228 + symbolParentClass.meta!.resourceId!, 229 + parentClass, 230 + ); 231 + } 232 + } 230 233 231 - const currentClass = sdkClasses.get( 232 - symbolCurrentClass.meta!.resourceId!, 233 - )!; 234 + const isLast = entry.path.length === index + 1; 235 + // add methods only to the last class 236 + if (!isLast) { 237 + return; 238 + } 234 239 235 - const methodName = entry.methodName; 236 - if (currentClass.methods.has(methodName)) return; 237 - currentClass.methods.add(methodName); 240 + const currentClass = sdkClasses.get( 241 + symbolCurrentClass.meta!.resourceId!, 242 + )!; 238 243 239 - const opParameters = operationParameters({ 240 - isRequiredOptions, 241 - operation, 242 - plugin, 243 - }); 244 - const statements = operationStatements({ 245 - isRequiredOptions, 246 - opParameters, 247 - operation, 248 - plugin, 249 - }); 250 - const functionNode = $.method(methodName, (m) => 251 - m 252 - .$if(createOperationComment(operation), (m, v) => m.doc(v)) 253 - .public() 254 - .static(!isAngularClient && !plugin.config.instance) 255 - .$if( 256 - isNuxtClient, 257 - (m) => 258 - m 259 - .generic(nuxtTypeComposable, (t) => 244 + const methodName = entry.methodName; 245 + if (currentClass.methods.has(methodName)) return; 246 + currentClass.methods.add(methodName); 247 + 248 + const opParameters = operationParameters({ 249 + isRequiredOptions, 250 + operation, 251 + plugin, 252 + }); 253 + const statements = operationStatements({ 254 + isRequiredOptions, 255 + opParameters, 256 + operation, 257 + plugin, 258 + }); 259 + const functionNode = $.method(methodName, (m) => 260 + m 261 + .$if(createOperationComment(operation), (m, v) => m.doc(v)) 262 + .public() 263 + .static(!isAngularClient && !plugin.config.instance) 264 + .$if( 265 + isNuxtClient, 266 + (m) => 267 + m 268 + .generic(nuxtTypeComposable, (t) => 269 + t 270 + .extends( 271 + plugin.referenceSymbol({ 272 + category: 'external', 273 + resource: 'client.Composable', 274 + }), 275 + ) 276 + .default($.type.literal('$fetch')), 277 + ) 278 + .generic(nuxtTypeDefault, (t) => 279 + t.$if(symbolResponse, (t, s) => 280 + t.extends(s).default(s), 281 + ), 282 + ), 283 + (m) => 284 + m.generic('ThrowOnError', (t) => 260 285 t 261 - .extends( 262 - plugin.referenceSymbol({ 263 - category: 'external', 264 - resource: 'client.Composable', 265 - }), 266 - ) 267 - .default($.type.literal('$fetch')), 268 - ) 269 - .generic(nuxtTypeDefault, (t) => 270 - t.$if(symbolResponse, (t, s) => t.extends(s).default(s)), 286 + .extends('boolean') 287 + .default( 288 + ('throwOnError' in client.config 289 + ? client.config.throwOnError 290 + : false) ?? false, 291 + ), 271 292 ), 272 - (m) => 273 - m.generic('ThrowOnError', (t) => 274 - t 275 - .extends('boolean') 276 - .default( 277 - ('throwOnError' in client.config 278 - ? client.config.throwOnError 279 - : false) ?? false, 280 - ), 281 - ), 282 - ) 283 - .params(...opParameters.parameters) 284 - .do(...statements), 285 - ); 293 + ) 294 + .params(...opParameters.parameters) 295 + .do(...statements), 296 + ); 286 297 287 - if (!currentClass.nodes.length) { 288 - currentClass.nodes.push(functionNode); 289 - } else { 290 - currentClass.nodes.push($.newline(), functionNode); 291 - } 298 + if (!currentClass.nodes.length) { 299 + currentClass.nodes.push(functionNode); 300 + } else { 301 + currentClass.nodes.push($.newline(), functionNode); 302 + } 292 303 293 - sdkClasses.set(symbolCurrentClass.meta!.resourceId!, currentClass); 294 - }); 304 + sdkClasses.set(symbolCurrentClass.meta!.resourceId!, currentClass); 305 + }); 306 + } 295 307 } 296 308 }, 297 309 { ··· 299 311 }, 300 312 ); 301 313 302 - const clientIndex = plugin.config.instance ? plugin.node(null) : undefined; 303 - const symbolClient = 304 - clientIndex !== undefined 305 - ? plugin.symbol('HeyApiClient', { 306 - meta: { 307 - category: 'utility', 308 - resource: 'class', 309 - resourceId: 'HeyApiClient', 310 - tool: 'sdk', 311 - }, 312 - }) 314 + if (!sdkModel) { 315 + const clientIndex = plugin.config.instance ? plugin.node(null) : undefined; 316 + const symbolClient = 317 + clientIndex !== undefined 318 + ? plugin.symbol('HeyApiClient', { 319 + meta: { 320 + category: 'utility', 321 + resource: 'class', 322 + resourceId: 'HeyApiClient', 323 + tool: 'sdk', 324 + }, 325 + }) 326 + : undefined; 327 + const registryIndex = plugin.config.instance 328 + ? plugin.node(null) 313 329 : undefined; 314 - const registryIndex = plugin.config.instance ? plugin.node(null) : undefined; 315 330 316 - const generateClass = (currentClass: SdkClassEntry) => { 317 - const resourceId = currentClass.className; 331 + const generateClass = (currentClass: SdkClassEntry) => { 332 + const resourceId = currentClass.className; 318 333 319 - if (generatedClasses.has(resourceId)) return; 320 - generatedClasses.add(resourceId); 334 + if (generatedClasses.has(resourceId)) return; 335 + generatedClasses.add(resourceId); 321 336 322 - if (clientIndex !== undefined && symbolClient && !symbolClient.node) { 323 - const node = createClientClass({ plugin, symbol: symbolClient }); 324 - plugin.node(node, clientIndex); 325 - } 337 + if (clientIndex !== undefined && symbolClient && !symbolClient.node) { 338 + const node = createClientClass({ plugin, symbol: symbolClient }); 339 + plugin.node(node, clientIndex); 340 + } 326 341 327 - for (const childClassName of currentClass.classes) { 328 - const childClass = sdkClasses.get(childClassName)!; 329 - generateClass(childClass); 342 + for (const childClassName of currentClass.classes) { 343 + const childClass = sdkClasses.get(childClassName)!; 344 + generateClass(childClass); 330 345 331 - const refChildClass = plugin.referenceSymbol({ 332 - category: 'utility', 333 - resource: 'class', 334 - resourceId: childClass.className, 335 - tool: 'sdk', 336 - }); 346 + const refChildClass = plugin.referenceSymbol({ 347 + category: 'utility', 348 + resource: 'class', 349 + resourceId: childClass.className, 350 + tool: 'sdk', 351 + }); 337 352 338 - const originalMemberName = stringCase({ 339 - case: 'camelCase', 340 - value: refChildClass.meta!.resourceId!, 341 - }); 342 - // avoid collisions with existing method names 343 - let memberName = originalMemberName; 344 - if (currentClass.methods.has(memberName)) { 345 - let index = 2; 346 - let attempt = `${memberName}${index}`; 347 - while (currentClass.methods.has(attempt)) { 348 - attempt = `${memberName}${index++}`; 349 - } 350 - memberName = attempt; 351 - } 352 - currentClass.methods.add(memberName); 353 - 354 - if (currentClass.nodes.length > 0) { 355 - currentClass.nodes.push($.newline()); 356 - } 357 - 358 - if (plugin.config.instance) { 359 - const privateName = plugin.symbol(`_${memberName}`); 360 - const privateNode = $.field(privateName, (f) => 361 - f.private().optional().type(refChildClass), 353 + const originalMemberName = toCase( 354 + refChildClass.meta!.resourceId!, 355 + 'camelCase', 362 356 ); 363 - currentClass.nodes.push(privateNode); 364 - const getterNode = $.getter(memberName, (g) => 365 - g.returns(refChildClass).do( 366 - $('this') 367 - .attr(privateName) 368 - .nullishAssign( 369 - $.new(refChildClass).args( 370 - $.object().prop('client', $('this').attr('client')), 371 - ), 372 - ) 373 - .return(), 374 - ), 375 - ); 376 - currentClass.nodes.push(getterNode); 377 - } else { 378 - const subClassReferenceNode = plugin.isSymbolRegistered( 379 - refChildClass.id, 380 - ) 381 - ? $.field(memberName, (f) => f.static().assign($(refChildClass))) 382 - : $.getter(memberName, (g) => 383 - g.public().static().do($.return(refChildClass)), 384 - ); 385 - currentClass.nodes.push(subClassReferenceNode); 386 - } 387 - } 357 + // avoid collisions with existing method names 358 + let memberName = originalMemberName; 359 + if (currentClass.methods.has(memberName)) { 360 + let index = 2; 361 + let attempt = `${memberName}${index}`; 362 + while (currentClass.methods.has(attempt)) { 363 + attempt = `${memberName}${index++}`; 364 + } 365 + memberName = attempt; 366 + } 367 + currentClass.methods.add(memberName); 388 368 389 - const symbol = plugin.symbol(resourceId, { 390 - meta: { 391 - category: 'utility', 392 - resource: 'class', 393 - resourceId, 394 - tool: 'sdk', 395 - }, 396 - }); 369 + if (currentClass.nodes.length > 0) { 370 + currentClass.nodes.push($.newline()); 371 + } 397 372 398 - if (currentClass.root && registryIndex !== undefined) { 399 - const symClient = plugin.getSymbol({ category: 'client' }); 400 - const isClientRequired = !plugin.config.client || !symClient; 401 - const symbolClient = plugin.referenceSymbol({ 402 - category: 'external', 403 - resource: 'client.Client', 404 - }); 405 - const ctor = $.init((i) => 406 - i 407 - .param('args', (p) => 408 - p.required(isClientRequired).type( 409 - $.type 410 - .object() 411 - .prop('client', (p) => 412 - p.required(isClientRequired).type(symbolClient), 373 + if (plugin.config.instance) { 374 + const privateName = plugin.symbol(`_${memberName}`); 375 + const privateNode = $.field(privateName, (f) => 376 + f.private().optional().type(refChildClass), 377 + ); 378 + currentClass.nodes.push(privateNode); 379 + const getterNode = $.getter(memberName, (g) => 380 + g.returns(refChildClass).do( 381 + $('this') 382 + .attr(privateName) 383 + .nullishAssign( 384 + $.new(refChildClass).args( 385 + $.object().prop('client', $('this').attr('client')), 386 + ), 413 387 ) 414 - .prop('key', (p) => p.optional().type('string')), 388 + .return(), 415 389 ), 390 + ); 391 + currentClass.nodes.push(getterNode); 392 + } else { 393 + const subClassReferenceNode = plugin.isSymbolRegistered( 394 + refChildClass.id, 416 395 ) 417 - .do( 418 - $('super').call('args'), 419 - $(symbol) 420 - .attr(registryName) 421 - .attr('set') 422 - .call('this', $('args').attr('key').required(isClientRequired)), 423 - ), 424 - ); 425 - 426 - if (!currentClass.nodes.length) { 427 - currentClass.nodes.unshift(ctor); 428 - } else { 429 - currentClass.nodes.unshift(ctor, $.newline()); 396 + ? $.field(memberName, (f) => f.static().assign($(refChildClass))) 397 + : $.getter(memberName, (g) => 398 + g.public().static().do($.return(refChildClass)), 399 + ); 400 + currentClass.nodes.push(subClassReferenceNode); 401 + } 430 402 } 431 403 432 - const symbolRegistry = plugin.symbol('HeyApiRegistry', { 404 + const symbol = plugin.symbol(resourceId, { 433 405 meta: { 434 406 category: 'utility', 435 407 resource: 'class', 436 - resourceId: 'HeyApiRegistry', 408 + resourceId, 437 409 tool: 'sdk', 438 410 }, 439 411 }); 440 - const node = createRegistryClass({ 441 - plugin, 442 - sdkSymbol: symbol, 443 - symbol: symbolRegistry, 444 - }); 445 - plugin.node(node, registryIndex); 446 - const registryNode = $.field(registryName, (f) => 447 - f 448 - .public() 449 - .static() 450 - .readonly() 451 - .assign($.new(symbolRegistry).generic(symbol)), 452 - ); 453 - currentClass.nodes.unshift(registryNode, $.newline()); 412 + 413 + if (currentClass.root && registryIndex !== undefined) { 414 + const symClient = plugin.getSymbol({ category: 'client' }); 415 + const isClientRequired = !plugin.config.client || !symClient; 416 + const symbolClient = plugin.referenceSymbol({ 417 + category: 'external', 418 + resource: 'client.Client', 419 + }); 420 + const ctor = $.init((i) => 421 + i 422 + .param('args', (p) => 423 + p.required(isClientRequired).type( 424 + $.type 425 + .object() 426 + .prop('client', (p) => 427 + p.required(isClientRequired).type(symbolClient), 428 + ) 429 + .prop('key', (p) => p.optional().type('string')), 430 + ), 431 + ) 432 + .do( 433 + $('super').call('args'), 434 + $(symbol) 435 + .attr(registryName) 436 + .attr('set') 437 + .call('this', $('args').attr('key').required(isClientRequired)), 438 + ), 439 + ); 440 + 441 + if (!currentClass.nodes.length) { 442 + currentClass.nodes.unshift(ctor); 443 + } else { 444 + currentClass.nodes.unshift(ctor, $.newline()); 445 + } 446 + 447 + const symbolRegistry = plugin.symbol('HeyApiRegistry', { 448 + meta: { 449 + category: 'utility', 450 + resource: 'class', 451 + resourceId: 'HeyApiRegistry', 452 + tool: 'sdk', 453 + }, 454 + }); 455 + const node = createRegistryClass({ 456 + plugin, 457 + sdkSymbol: symbol, 458 + symbol: symbolRegistry, 459 + }); 460 + plugin.node(node, registryIndex); 461 + const registryNode = $.field(registryName, (f) => 462 + f 463 + .public() 464 + .static() 465 + .readonly() 466 + .assign($.new(symbolRegistry).generic(symbol)), 467 + ); 468 + currentClass.nodes.unshift(registryNode, $.newline()); 469 + } 470 + 471 + const node = $.class(symbol) 472 + .export() 473 + .extends(symbolClient) 474 + .$if(isAngularClient && currentClass.root, (c) => 475 + c.decorator( 476 + plugin.referenceSymbol({ 477 + category: 'external', 478 + resource: '@angular/core.Injectable', 479 + }), 480 + $.object().prop('providedIn', $.literal('root')), 481 + ), 482 + ) 483 + .do(...currentClass.nodes); 484 + plugin.node(node); 485 + }; 486 + 487 + for (const sdkClass of sdkClasses.values()) { 488 + generateClass(sdkClass); 454 489 } 490 + } else { 491 + const allDependencies: Array<ReturnType<typeof $.class>> = []; 492 + const allNodes: Array<ReturnType<typeof $.class>> = []; 455 493 456 - const node = $.class(symbol) 457 - .export() 458 - .extends(symbolClient) 459 - .$if(isAngularClient && currentClass.root, (c) => 460 - c.decorator( 461 - plugin.referenceSymbol({ 462 - category: 'external', 463 - resource: '@angular/core.Injectable', 464 - }), 465 - $.object().prop('providedIn', $.literal('root')), 466 - ), 467 - ) 468 - .do(...currentClass.nodes); 469 - plugin.node(node); 470 - }; 494 + for (const model of sdkModel.walk()) { 495 + const { dependencies, node } = model.toNode(plugin); 496 + allDependencies.push(...dependencies); 497 + allNodes.push(node); 498 + } 499 + 500 + const uniqueDeps = new Map<number, ReturnType<typeof $.class>>(); 501 + for (const dep of allDependencies) { 502 + if (dep.symbol) uniqueDeps.set(dep.symbol.id, dep); 503 + } 504 + for (const dep of uniqueDeps.values()) { 505 + plugin.node(dep); 506 + } 471 507 472 - for (const sdkClass of sdkClasses.values()) { 473 - generateClass(sdkClass); 508 + for (const node of allNodes) { 509 + plugin.node(node); 510 + } 474 511 } 475 512 };
+19 -8
packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts
··· 6 6 import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; 7 7 import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; 8 8 import { $ } from '~/ts-dsl'; 9 - import { stringCase } from '~/utils/stringCase'; 9 + import { toCase } from '~/utils/to-case'; 10 10 11 11 import type { Field, Fields } from '../../client-core/bundle/params'; 12 12 import type { HeyApiSdkPlugin } from '../types'; ··· 30 30 path: ReadonlyArray<string>; 31 31 } 32 32 33 - const operationClassName = ({ 33 + export const operationClassName = ({ 34 34 plugin, 35 35 value, 36 36 }: { 37 37 plugin: HeyApiSdkPlugin['Instance']; 38 38 value: string; 39 39 }) => { 40 - const name = stringCase({ case: 'PascalCase', value }); 40 + // TODO: expose casing option 41 + const name = toCase(value, 'PascalCase'); 41 42 return ( 42 43 (typeof plugin.config.classNameBuilder === 'string' 43 44 ? plugin.config.classNameBuilder.replace('{{name}}', name) ··· 48 49 export const operationMethodName = ({ 49 50 operation, 50 51 plugin, 52 + value, 51 53 }: { 52 54 operation: IR.OperationObject; 53 55 plugin: HeyApiSdkPlugin['Instance']; 54 - }) => plugin.config.methodNameBuilder?.(operation) || operation.id; 56 + value?: string; 57 + }) => { 58 + // TODO: expose casing option 59 + const name = toCase(value || operation.id, 'camelCase'); 60 + return ( 61 + (typeof plugin.config.methodNameBuilder === 'string' 62 + ? plugin.config.methodNameBuilder.replace('{{name}}', name) 63 + : plugin.config.methodNameBuilder?.(name, operation)) || name 64 + ); 65 + }; 55 66 56 67 /** 57 68 * Returns a list of classes where this operation appears in the generated SDK. ··· 73 84 classCandidates = operation.operationId.split(/[./]/).filter(Boolean); 74 85 if (classCandidates.length >= 2) { 75 86 const methodCandidate = classCandidates.pop()!; 76 - methodName = stringCase({ 77 - case: 'camelCase', 78 - value: sanitizeNamespaceIdentifier(methodCandidate), 79 - }); 87 + methodName = toCase( 88 + sanitizeNamespaceIdentifier(methodCandidate), 89 + 'camelCase', 90 + ); 80 91 className = classCandidates.pop()!; 81 92 } 82 93 }
+3 -3
packages/openapi-ts/src/plugins/@hey-api/sdk/shared/signature.ts
··· 1 1 import type { IR } from '~/ir/types'; 2 2 import type { PluginInstance } from '~/plugins/shared/utils/instance'; 3 3 import { refToName } from '~/utils/ref'; 4 - import { stringCase } from '~/utils/stringCase'; 4 + import { toCase } from '~/utils/to-case'; 5 5 6 6 import type { Field } from '../../client-core/bundle/params'; 7 7 ··· 83 83 } else if (operation.body.schema.$ref) { 84 84 // alias body for more ergonomic naming, e.g. user if the type is User 85 85 const name = refToName(operation.body.schema.$ref); 86 - const key = stringCase({ case: 'camelCase', value: name }); 86 + const key = toCase(name, 'camelCase'); 87 87 addParameter(key, 'body'); 88 88 } else { 89 89 addParameter('body', 'body'); ··· 157 157 } 158 158 } else if (operation.body.schema.$ref) { 159 159 const value = refToName(operation.body.schema.$ref); 160 - const originalName = stringCase({ case: 'camelCase', value }); 160 + const originalName = toCase(value, 'camelCase'); 161 161 const name = conflicts.has(originalName) 162 162 ? `${location}_${originalName}` 163 163 : originalName;
+8 -4
packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts
··· 75 75 instance?: string | boolean; 76 76 /** 77 77 * Customise the name of methods within the service. By default, 78 - * {@link IR.OperationObject.id} is used. 78 + * `operation.id` is used. 79 79 */ 80 - methodNameBuilder?: (operation: IR.OperationObject) => string; 80 + methodNameBuilder?: 81 + | string 82 + | ((name: string, operation: IR.OperationObject) => string); 81 83 // TODO: parser - rename operationId option to something like inferId?: boolean 82 84 /** 83 85 * Use operation ID to generate operation names? ··· 252 254 instance: string; 253 255 /** 254 256 * Customise the name of methods within the service. By default, 255 - * {@link IR.OperationObject.id} is used. 257 + * `operation.id` is used. 256 258 */ 257 - methodNameBuilder?: (operation: IR.OperationObject) => string; 259 + methodNameBuilder: 260 + | string 261 + | ((name: string, operation: IR.OperationObject) => string); 258 262 // TODO: parser - rename operationId option to something like inferId?: boolean 259 263 /** 260 264 * Use operation ID to generate operation names?
+2 -4
packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts
··· 6 6 import type { MaybeTsDsl, TypeTsDsl } from '~/ts-dsl'; 7 7 import { $, regexp } from '~/ts-dsl'; 8 8 import { pathToJsonPointer, refToName } from '~/utils/ref'; 9 - import { stringCase } from '~/utils/stringCase'; 9 + import { toCase } from '~/utils/to-case'; 10 10 11 11 import type { HeyApiTypeScriptPlugin } from '../types'; 12 12 import type { IrSchemaToAstOptions } from './types'; ··· 52 52 } 53 53 54 54 if (key) { 55 - key = stringCase({ 56 - case: plugin.config.enums.case, 55 + key = toCase(key, plugin.config.enums.case, { 57 56 stripLeadingSeparators: false, 58 - value: key, 59 57 }); 60 58 61 59 regexp.number.lastIndex = 0;
+2 -5
packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts
··· 3 3 import type { SchemaWithType } from '~/plugins'; 4 4 import type { TypeTsDsl } from '~/ts-dsl'; 5 5 import { $ } from '~/ts-dsl'; 6 - import { stringCase } from '~/utils/stringCase'; 6 + import { toCase } from '~/utils/to-case'; 7 7 8 8 import type { IrSchemaToAstOptions } from '../../shared/types'; 9 9 ··· 66 66 const symbolTypeId = plugin.referenceSymbol(queryTypeId); 67 67 const symbolTypeName = plugin.registerSymbol({ 68 68 meta: query, 69 - name: stringCase({ 70 - case: plugin.config.case, 71 - value: `${type}_id`, 72 - }), 69 + name: toCase(`${type}_id`, plugin.config.case), 73 70 }); 74 71 const node = $.type 75 72 .alias(symbolTypeName)
+2 -5
packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts
··· 1 1 import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; 2 2 import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; 3 3 import { $ } from '~/ts-dsl'; 4 - import { stringCase } from '~/utils/stringCase'; 4 + import { toCase } from '~/utils/to-case'; 5 5 6 6 import { createMutationOptions } from '../mutationOptions'; 7 7 import { createQueryOptions } from '../queryOptions'; ··· 76 76 e.attr(registryName).attr('get').call(), 77 77 ); 78 78 for (const className of entry.path.slice(1)) { 79 - const cls = stringCase({ 80 - case: 'camelCase', 81 - value: className, 82 - }); 79 + const cls = toCase(className, 'camelCase'); 83 80 queryFn = queryFn.attr(cls); 84 81 } 85 82 queryFn = queryFn.attr(entry.methodName);
+2 -5
packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts
··· 1 1 import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; 2 2 import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; 3 3 import { $ } from '~/ts-dsl'; 4 - import { stringCase } from '~/utils/stringCase'; 4 + import { toCase } from '~/utils/to-case'; 5 5 6 6 import type { PluginHandler } from '../types'; 7 7 import { createInfiniteQueryOptions } from './infiniteQueryOptions'; ··· 100 100 e.attr(registryName).attr('get').call(), 101 101 ); 102 102 for (const className of entry.path.slice(1)) { 103 - const cls = stringCase({ 104 - case: 'camelCase', 105 - value: className, 106 - }); 103 + const cls = toCase(className, 'camelCase'); 107 104 queryFn = queryFn.attr(cls); 108 105 } 109 106 queryFn = queryFn.attr(entry.methodName);
+2 -5
packages/openapi-ts/src/plugins/swr/v2/plugin.ts
··· 1 1 import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; 2 2 import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; 3 3 import { $ } from '~/ts-dsl'; 4 - import { stringCase } from '~/utils/stringCase'; 4 + import { toCase } from '~/utils/to-case'; 5 5 6 6 import type { SwrPlugin } from '../types'; 7 7 import { createUseSwr } from './useSwr'; ··· 41 41 e.attr(registryName).attr('get').call(), 42 42 ); 43 43 for (const className of entry.path.slice(1)) { 44 - const cls = stringCase({ 45 - case: 'camelCase', 46 - value: className, 47 - }); 44 + const cls = toCase(className, 'camelCase'); 48 45 queryFn = queryFn.attr(cls); 49 46 } 50 47 queryFn = queryFn.attr(entry.methodName);
+4 -4
packages/openapi-ts/src/ts-dsl/base.ts
··· 135 135 if (value instanceof Array) { 136 136 return value.map((item) => { 137 137 if (isRef(item)) item = fromRef(item); 138 - return this.unwrap(item, ctx); 138 + return this.unwrap(ctx, item); 139 139 }) as NodeOfMaybe<I>; 140 140 } 141 - return this.unwrap(value as any, ctx) as NodeOfMaybe<I>; 141 + return this.unwrap(ctx, value as any) as NodeOfMaybe<I>; 142 142 } 143 143 144 144 protected $type<I>( ··· 174 174 if (value instanceof Array) { 175 175 return value.map((item) => this.$type(ctx, item, args)) as TypeOfMaybe<I>; 176 176 } 177 - return this.unwrap(value as any, ctx) as TypeOfMaybe<I>; 177 + return this.unwrap(ctx, value as any) as TypeOfMaybe<I>; 178 178 } 179 179 180 180 /** Unwraps nested nodes into raw TypeScript AST. */ 181 181 private unwrap<I>( 182 - value: I, 183 182 ctx: AstContext, 183 + value: I, 184 184 ): I extends TsDsl<infer N> ? N : I { 185 185 return (isNode(value) ? value.toAst(ctx) : value) as I extends TsDsl< 186 186 infer N
+10 -3
packages/openapi-ts/src/ts-dsl/decl/class.ts
··· 18 18 import type { FieldName } from './field'; 19 19 import { FieldTsDsl } from './field'; 20 20 import { InitTsDsl } from './init'; 21 + import type { MethodName } from './method'; 21 22 import { MethodTsDsl } from './method'; 22 23 23 24 type Base = Symbol | string; ··· 63 64 } 64 65 } 65 66 67 + /** Returns true if the class has any members. */ 68 + get hasBody(): boolean { 69 + return this.body.length > 0; 70 + } 71 + 66 72 /** Adds one or more class members (fields, methods, etc.). */ 67 73 do(...items: Body): this { 68 74 this.body.push(...items); ··· 83 89 } 84 90 85 91 /** Adds a class constructor. */ 86 - init(fn?: (i: InitTsDsl) => void): this { 87 - const i = new InitTsDsl(fn); 92 + init(fn?: InitTsDsl | ((i: InitTsDsl) => void)): this { 93 + const i = 94 + typeof fn === 'function' ? new InitTsDsl(fn) : fn || new InitTsDsl(); 88 95 this.body.push(i); 89 96 return this; 90 97 } 91 98 92 99 /** Adds a class method. */ 93 - method(name: string, fn?: (m: MethodTsDsl) => void): this { 100 + method(name: MethodName, fn?: (m: MethodTsDsl) => void): this { 94 101 const m = new MethodTsDsl(name, fn); 95 102 this.body.push(m); 96 103 return this;
+5 -2
packages/openapi-ts/src/ts-dsl/decl/field.ts
··· 1 1 import type { 2 2 AnalysisContext, 3 3 AstContext, 4 + Ref, 4 5 Symbol, 5 6 } from '@hey-api/codegen-core'; 7 + import { ref } from '@hey-api/codegen-core'; 6 8 import ts from 'typescript'; 7 9 8 10 import { TsDsl, TypeTsDsl } from '../base'; ··· 43 45 export class FieldTsDsl extends Mixed { 44 46 readonly '~dsl' = 'FieldTsDsl'; 45 47 46 - protected name: FieldName; 48 + protected name: Ref<FieldName>; 47 49 protected _type?: TypeTsDsl; 48 50 49 51 constructor(name: FieldName, fn?: (f: FieldTsDsl) => void) { 50 52 super(); 51 - this.name = name; 53 + this.name = ref(name); 52 54 fn?.(this); 53 55 } 54 56 55 57 override analyze(ctx: AnalysisContext): void { 56 58 super.analyze(ctx); 59 + ctx.analyze(this.name); 57 60 ctx.analyze(this._type); 58 61 } 59 62
+13 -5
packages/openapi-ts/src/ts-dsl/decl/getter.ts
··· 1 - import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; 1 + import type { 2 + AnalysisContext, 3 + AstContext, 4 + Ref, 5 + Symbol, 6 + } from '@hey-api/codegen-core'; 7 + import { ref } from '@hey-api/codegen-core'; 2 8 import ts from 'typescript'; 3 9 4 10 import { TsDsl } from '../base'; ··· 17 23 import { TypeReturnsMixin } from '../mixins/type-returns'; 18 24 import { BlockTsDsl } from '../stmt/block'; 19 25 20 - export type GetterName = string | ts.PropertyName; 26 + export type GetterName = Symbol | string | ts.PropertyName; 21 27 22 28 const Mixed = AbstractMixin( 23 29 AsyncMixin( ··· 44 50 export class GetterTsDsl extends Mixed { 45 51 readonly '~dsl' = 'GetterTsDsl'; 46 52 47 - protected name: GetterName; 53 + protected name: Ref<GetterName>; 48 54 49 55 constructor(name: GetterName, fn?: (g: GetterTsDsl) => void) { 50 56 super(); 51 - this.name = name; 57 + this.name = ref(name); 52 58 fn?.(this); 53 59 } 54 60 55 61 override analyze(ctx: AnalysisContext): void { 62 + ctx.analyze(this.name); 63 + 56 64 ctx.pushScope(); 57 65 try { 58 66 super.analyze(ctx); ··· 64 72 override toAst(ctx: AstContext) { 65 73 const node = ts.factory.createGetAccessorDeclaration( 66 74 [...this.$decorators(ctx), ...this.modifiers], 67 - this.name, 75 + this.$node(ctx, this.name) as ts.PropertyName, 68 76 this.$params(ctx), 69 77 this.$returns(ctx), 70 78 this.$node(ctx, new BlockTsDsl(...this._do).pretty()),
+15 -5
packages/openapi-ts/src/ts-dsl/decl/method.ts
··· 1 - import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; 1 + import type { 2 + AnalysisContext, 3 + AstContext, 4 + Ref, 5 + Symbol, 6 + } from '@hey-api/codegen-core'; 7 + import { ref } from '@hey-api/codegen-core'; 2 8 import ts from 'typescript'; 3 9 4 10 import { TsDsl } from '../base'; ··· 19 25 import { TypeReturnsMixin } from '../mixins/type-returns'; 20 26 import { BlockTsDsl } from '../stmt/block'; 21 27 import { TokenTsDsl } from '../token'; 28 + 29 + export type MethodName = Symbol | string; 22 30 23 31 const Mixed = AbstractMixin( 24 32 AsyncMixin( ··· 49 57 export class MethodTsDsl extends Mixed { 50 58 readonly '~dsl' = 'MethodTsDsl'; 51 59 52 - protected name: string; 60 + protected name: Ref<MethodName>; 53 61 54 - constructor(name: string, fn?: (m: MethodTsDsl) => void) { 62 + constructor(name: MethodName, fn?: (m: MethodTsDsl) => void) { 55 63 super(); 56 - this.name = name; 64 + this.name = ref(name); 57 65 fn?.(this); 58 66 } 59 67 60 68 override analyze(ctx: AnalysisContext): void { 69 + ctx.analyze(this.name); 70 + 61 71 ctx.pushScope(); 62 72 try { 63 73 super.analyze(ctx); ··· 70 80 const node = ts.factory.createMethodDeclaration( 71 81 [...this.$decorators(ctx), ...this.modifiers], 72 82 undefined, 73 - this.name, 83 + this.$node(ctx, this.name) as ts.PropertyName, 74 84 this._optional ? this.$node(ctx, new TokenTsDsl().optional()) : undefined, 75 85 this.$generics(ctx), 76 86 this.$params(ctx),
+13 -5
packages/openapi-ts/src/ts-dsl/decl/setter.ts
··· 1 - import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; 1 + import type { 2 + AnalysisContext, 3 + AstContext, 4 + Ref, 5 + Symbol, 6 + } from '@hey-api/codegen-core'; 7 + import { ref } from '@hey-api/codegen-core'; 2 8 import ts from 'typescript'; 3 9 4 10 import { TsDsl } from '../base'; ··· 16 22 import { ParamMixin } from '../mixins/param'; 17 23 import { BlockTsDsl } from '../stmt/block'; 18 24 19 - export type SetterName = string | ts.PropertyName; 25 + export type SetterName = Symbol | string | ts.PropertyName; 20 26 21 27 const Mixed = AbstractMixin( 22 28 AsyncMixin( ··· 39 45 export class SetterTsDsl extends Mixed { 40 46 readonly '~dsl' = 'SetterTsDsl'; 41 47 42 - protected name: SetterName; 48 + protected name: Ref<SetterName>; 43 49 44 50 constructor(name: SetterName, fn?: (s: SetterTsDsl) => void) { 45 51 super(); 46 - this.name = name; 52 + this.name = ref(name); 47 53 fn?.(this); 48 54 } 49 55 50 56 override analyze(ctx: AnalysisContext): void { 57 + ctx.analyze(this.name); 58 + 51 59 ctx.pushScope(); 52 60 try { 53 61 super.analyze(ctx); ··· 59 67 override toAst(ctx: AstContext) { 60 68 const node = ts.factory.createSetAccessorDeclaration( 61 69 [...this.$decorators(ctx), ...this.modifiers], 62 - this.name, 70 + this.$node(ctx, this.name) as ts.PropertyName, 63 71 this.$params(ctx), 64 72 this.$node(ctx, new BlockTsDsl(...this._do).pretty()), 65 73 );
+16 -16
packages/openapi-ts/src/utils/__tests__/stringCase.test.ts packages/openapi-ts/src/utils/__tests__/to-case.test.ts
··· 2 2 3 3 import type { StringCase } from '~/types/case'; 4 4 5 - import { stringCase } from '../stringCase'; 5 + import { toCase } from '../to-case'; 6 6 7 7 const cases: ReadonlyArray<StringCase> = [ 8 8 'camelCase', ··· 178 178 }, 179 179 ]; 180 180 181 - describe('stringCase', () => { 182 - describe.each(cases)('%s', (style) => { 183 - switch (style) { 181 + describe('toCase', () => { 182 + describe.each(cases)('%s', (casing) => { 183 + switch (casing) { 184 184 case 'PascalCase': 185 185 it.each(scenarios)( 186 186 '$value -> $PascalCase', 187 187 ({ PascalCase, stripLeadingSeparators, value }) => { 188 - expect( 189 - stringCase({ case: style, stripLeadingSeparators, value }), 190 - ).toBe(PascalCase); 188 + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( 189 + PascalCase, 190 + ); 191 191 }, 192 192 ); 193 193 break; ··· 195 195 it.each(scenarios)( 196 196 '$value -> $camelCase', 197 197 ({ camelCase, stripLeadingSeparators, value }) => { 198 - expect( 199 - stringCase({ case: style, stripLeadingSeparators, value }), 200 - ).toBe(camelCase); 198 + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( 199 + camelCase, 200 + ); 201 201 }, 202 202 ); 203 203 break; ··· 205 205 it.each(scenarios)( 206 206 '$value -> $SCREAMING_SNAKE_CASE', 207 207 ({ SCREAMING_SNAKE_CASE, stripLeadingSeparators, value }) => { 208 - expect( 209 - stringCase({ case: style, stripLeadingSeparators, value }), 210 - ).toBe(SCREAMING_SNAKE_CASE); 208 + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( 209 + SCREAMING_SNAKE_CASE, 210 + ); 211 211 }, 212 212 ); 213 213 break; ··· 215 215 it.each(scenarios)( 216 216 '$value -> $snake_case', 217 217 ({ snake_case, stripLeadingSeparators, value }) => { 218 - expect( 219 - stringCase({ case: style, stripLeadingSeparators, value }), 220 - ).toBe(snake_case); 218 + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( 219 + snake_case, 220 + ); 221 221 }, 222 222 ); 223 223 break;
+28 -3
packages/openapi-ts/src/utils/exports.ts
··· 1 - import { stringCase } from './stringCase'; 1 + import type { StringCase } from '~/types/case'; 2 2 3 - // publicly exposed utils 3 + import { toCase } from './to-case'; 4 + 5 + /** 6 + * Utilities shared across the package. 7 + */ 4 8 export const utils = { 5 - stringCase, 9 + /** 10 + * @deprecated use `toCase` instead 11 + */ 12 + stringCase({ 13 + case: casing, 14 + stripLeadingSeparators, 15 + value, 16 + }: { 17 + readonly case: StringCase | undefined; 18 + /** 19 + * If leading separators have a semantic meaning, we might not want to 20 + * remove them. 21 + */ 22 + stripLeadingSeparators?: boolean; 23 + value: string; 24 + }) { 25 + return toCase(value, casing, { stripLeadingSeparators }); 26 + }, 27 + /** 28 + * Converts the given string to the specified casing. 29 + */ 30 + toCase, 6 31 };
+38 -41
packages/openapi-ts/src/utils/stringCase.ts packages/openapi-ts/src/utils/to-case.ts
··· 3 3 const uppercaseRegExp = /[\p{Lu}]/u; 4 4 const lowercaseRegExp = /[\p{Ll}]/u; 5 5 const identifierRegExp = /([\p{Alpha}\p{N}_]|$)/u; 6 - const separatorsRegExp = /[_.:\- `\\[\]{}\\/]+/; 6 + const separatorsRegExp = /[_.:\- `\\[\](){}\\/]+/; 7 7 8 8 const leadingSeparatorsRegExp = new RegExp(`^${separatorsRegExp.source}`); 9 9 const separatorsAndIdentifierRegExp = new RegExp( ··· 15 15 'gu', 16 16 ); 17 17 18 - const preserveCase = ({ 19 - case: _case, 20 - string, 21 - }: { 22 - readonly case: StringCase; 23 - string: string; 24 - }) => { 18 + const preserveCase = (value: string, casing: StringCase) => { 25 19 let isLastCharLower = false; 26 20 let isLastCharUpper = false; 27 21 let isLastLastCharUpper = false; 28 22 let isLastLastCharPreserved = false; 29 23 30 24 const separator = 31 - _case === 'snake_case' || _case === 'SCREAMING_SNAKE_CASE' ? '_' : '-'; 25 + casing === 'snake_case' || casing === 'SCREAMING_SNAKE_CASE' ? '_' : '-'; 32 26 33 - for (let index = 0; index < string.length; index++) { 34 - const character = string[index]!; 35 - isLastLastCharPreserved = 36 - index > 2 ? string[index - 3] === separator : true; 27 + for (let index = 0; index < value.length; index++) { 28 + const character = value[index]!; 29 + isLastLastCharPreserved = index > 2 ? value[index - 3] === separator : true; 37 30 38 31 let nextIndex = index + 1; 39 - let nextCharacter = string[nextIndex]; 32 + let nextCharacter = value[nextIndex]; 40 33 separatorsRegExp.lastIndex = 0; 41 34 while (nextCharacter && separatorsRegExp.test(nextCharacter)) { 42 35 nextIndex += 1; 43 - nextCharacter = string[nextIndex]; 36 + nextCharacter = value[nextIndex]; 44 37 } 45 38 const isSeparatorBeforeNextCharacter = nextIndex !== index + 1; 46 39 ··· 55 48 lowercaseRegExp.test(nextCharacter))) 56 49 ) { 57 50 // insert separator behind character 58 - string = `${string.slice(0, index)}${separator}${string.slice(index)}`; 51 + value = `${value.slice(0, index)}${separator}${value.slice(index)}`; 59 52 index++; 60 53 isLastLastCharUpper = isLastCharUpper; 61 54 isLastCharLower = false; ··· 72 65 ) 73 66 ) { 74 67 // insert separator 2 characters behind 75 - string = `${string.slice(0, index - 1)}${separator}${string.slice(index - 1)}`; 68 + value = `${value.slice(0, index - 1)}${separator}${value.slice(index - 1)}`; 76 69 isLastLastCharUpper = isLastCharUpper; 77 70 isLastCharLower = true; 78 71 isLastCharUpper = false; ··· 87 80 } 88 81 } 89 82 90 - return string; 83 + return value; 91 84 }; 92 85 93 - export const stringCase = ({ 94 - case: _case, 95 - stripLeadingSeparators = true, 96 - value, 97 - }: { 98 - readonly case: StringCase | undefined; 99 - /** 100 - * If leading separators have a semantic meaning, we might not want to 101 - * remove them. 102 - */ 103 - stripLeadingSeparators?: boolean; 104 - value: string; 105 - }): string => { 86 + /** 87 + * Converts the given string to the specified casing. 88 + * 89 + * @param value - The string to convert 90 + * @param casing - The target casing 91 + * @param options - Additional options 92 + * @returns The converted string 93 + */ 94 + export const toCase = ( 95 + value: string, 96 + casing: StringCase | undefined, 97 + options: { 98 + /** 99 + * If leading separators have a semantic meaning, we might not want to 100 + * remove them. 101 + */ 102 + stripLeadingSeparators?: boolean; 103 + } = {}, 104 + ) => { 105 + const stripLeadingSeparators = options.stripLeadingSeparators ?? true; 106 + 106 107 let result = value.trim(); 107 108 108 - if (!result.length) { 109 - return ''; 110 - } 111 - 112 - if (!_case || _case === 'preserve') { 109 + if (!result.length || !casing || casing === 'preserve') { 113 110 return result; 114 111 } 115 112 ··· 119 116 return ''; 120 117 } 121 118 122 - return _case === 'PascalCase' || _case === 'SCREAMING_SNAKE_CASE' 119 + return casing === 'PascalCase' || casing === 'SCREAMING_SNAKE_CASE' 123 120 ? result.toLocaleUpperCase() 124 121 : result.toLocaleLowerCase(); 125 122 } ··· 127 124 const hasUpperCase = result !== result.toLocaleLowerCase(); 128 125 129 126 if (hasUpperCase) { 130 - result = preserveCase({ case: _case, string: result }); 127 + result = preserveCase(result, casing); 131 128 } 132 129 133 130 if (stripLeadingSeparators || result[0] !== value[0]) { ··· 135 132 } 136 133 137 134 result = 138 - _case === 'SCREAMING_SNAKE_CASE' 135 + casing === 'SCREAMING_SNAKE_CASE' 139 136 ? result.toLocaleUpperCase() 140 137 : result.toLocaleLowerCase(); 141 138 142 - if (_case === 'PascalCase') { 139 + if (casing === 'PascalCase') { 143 140 result = `${result.charAt(0).toLocaleUpperCase()}${result.slice(1)}`; 144 141 } 145 142 146 - if (_case === 'snake_case' || _case === 'SCREAMING_SNAKE_CASE') { 143 + if (casing === 'snake_case' || casing === 'SCREAMING_SNAKE_CASE') { 147 144 result = result.replaceAll( 148 145 separatorsAndIdentifierRegExp, 149 146 (match, identifier, offset) => {