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

feat(client-angular): enhance Angular client with resource generation and injectable services

Max Scopp 69ce916b f5efa9fc

+662 -3
+4 -1
examples/openapi-ts-angular/openapi-ts.config.ts
··· 11 11 plugins: [ 12 12 '@hey-api/client-angular', 13 13 '@hey-api/schemas', 14 - '@hey-api/sdk', 14 + { 15 + asClass: false, 16 + name: '@hey-api/sdk', 17 + }, 15 18 { 16 19 enums: 'javascript', 17 20 name: '@hey-api/typescript',
+263
examples/openapi-ts-angular/src/client/client.resource.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { resource } from '@angular/core'; 4 + 5 + import type { 6 + AddPetData, 7 + CreateUserData, 8 + CreateUsersWithListInputData, 9 + DeleteOrderData, 10 + DeletePetData, 11 + DeleteUserData, 12 + FindPetsByStatusData, 13 + FindPetsByTagsData, 14 + GetInventoryData, 15 + GetOrderByIdData, 16 + GetPetByIdData, 17 + GetUserByNameData, 18 + LoginUserData, 19 + LogoutUserData, 20 + PlaceOrderData, 21 + UpdatePetData, 22 + UpdatePetWithFormData, 23 + UpdateUserData, 24 + UploadFileData, 25 + } from './types.gen'; 26 + 27 + /** 28 + * Add a new pet to the store. 29 + * Add a new pet to the store. 30 + */ 31 + export const addPetResource = (options: Omit<AddPetData, 'url'>) => 32 + resource({ 33 + loader: () => { 34 + throw new Error('Not implemented'); 35 + }, 36 + params: () => options, 37 + }); 38 + 39 + /** 40 + * Update an existing pet. 41 + * Update an existing pet by Id. 42 + */ 43 + export const updatePetResource = (options: Omit<UpdatePetData, 'url'>) => 44 + resource({ 45 + loader: () => { 46 + throw new Error('Not implemented'); 47 + }, 48 + params: () => options, 49 + }); 50 + 51 + /** 52 + * Finds Pets by status. 53 + * Multiple status values can be provided with comma separated strings. 54 + */ 55 + export const findPetsByStatusResource = ( 56 + options: Omit<FindPetsByStatusData, 'url'>, 57 + ) => 58 + resource({ 59 + loader: () => { 60 + throw new Error('Not implemented'); 61 + }, 62 + params: () => options, 63 + }); 64 + 65 + /** 66 + * Finds Pets by tags. 67 + * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. 68 + */ 69 + export const findPetsByTagsResource = ( 70 + options: Omit<FindPetsByTagsData, 'url'>, 71 + ) => 72 + resource({ 73 + loader: () => { 74 + throw new Error('Not implemented'); 75 + }, 76 + params: () => options, 77 + }); 78 + 79 + /** 80 + * Deletes a pet. 81 + * Delete a pet. 82 + */ 83 + export const deletePetResource = (options: Omit<DeletePetData, 'url'>) => 84 + resource({ 85 + loader: () => { 86 + throw new Error('Not implemented'); 87 + }, 88 + params: () => options, 89 + }); 90 + 91 + /** 92 + * Find pet by ID. 93 + * Returns a single pet. 94 + */ 95 + export const getPetByIdResource = (options: Omit<GetPetByIdData, 'url'>) => 96 + resource({ 97 + loader: () => { 98 + throw new Error('Not implemented'); 99 + }, 100 + params: () => options, 101 + }); 102 + 103 + /** 104 + * Updates a pet in the store with form data. 105 + * Updates a pet resource based on the form data. 106 + */ 107 + export const updatePetWithFormResource = ( 108 + options: Omit<UpdatePetWithFormData, 'url'>, 109 + ) => 110 + resource({ 111 + loader: () => { 112 + throw new Error('Not implemented'); 113 + }, 114 + params: () => options, 115 + }); 116 + 117 + /** 118 + * Uploads an image. 119 + * Upload image of the pet. 120 + */ 121 + export const uploadFileResource = (options: Omit<UploadFileData, 'url'>) => 122 + resource({ 123 + loader: () => { 124 + throw new Error('Not implemented'); 125 + }, 126 + params: () => options, 127 + }); 128 + 129 + /** 130 + * Returns pet inventories by status. 131 + * Returns a map of status codes to quantities. 132 + */ 133 + export const getInventoryResource = (options?: Omit<GetInventoryData, 'url'>) => 134 + resource({ 135 + loader: () => { 136 + throw new Error('Not implemented'); 137 + }, 138 + params: () => options, 139 + }); 140 + 141 + /** 142 + * Place an order for a pet. 143 + * Place a new order in the store. 144 + */ 145 + export const placeOrderResource = (options?: Omit<PlaceOrderData, 'url'>) => 146 + resource({ 147 + loader: () => { 148 + throw new Error('Not implemented'); 149 + }, 150 + params: () => options, 151 + }); 152 + 153 + /** 154 + * Delete purchase order by identifier. 155 + * For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. 156 + */ 157 + export const deleteOrderResource = (options: Omit<DeleteOrderData, 'url'>) => 158 + resource({ 159 + loader: () => { 160 + throw new Error('Not implemented'); 161 + }, 162 + params: () => options, 163 + }); 164 + 165 + /** 166 + * Find purchase order by ID. 167 + * For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. 168 + */ 169 + export const getOrderByIdResource = (options: Omit<GetOrderByIdData, 'url'>) => 170 + resource({ 171 + loader: () => { 172 + throw new Error('Not implemented'); 173 + }, 174 + params: () => options, 175 + }); 176 + 177 + /** 178 + * Create user. 179 + * This can only be done by the logged in user. 180 + */ 181 + export const createUserResource = (options?: Omit<CreateUserData, 'url'>) => 182 + resource({ 183 + loader: () => { 184 + throw new Error('Not implemented'); 185 + }, 186 + params: () => options, 187 + }); 188 + 189 + /** 190 + * Creates list of users with given input array. 191 + * Creates list of users with given input array. 192 + */ 193 + export const createUsersWithListInputResource = ( 194 + options?: Omit<CreateUsersWithListInputData, 'url'>, 195 + ) => 196 + resource({ 197 + loader: () => { 198 + throw new Error('Not implemented'); 199 + }, 200 + params: () => options, 201 + }); 202 + 203 + /** 204 + * Logs user into the system. 205 + * Log into the system. 206 + */ 207 + export const loginUserResource = (options?: Omit<LoginUserData, 'url'>) => 208 + resource({ 209 + loader: () => { 210 + throw new Error('Not implemented'); 211 + }, 212 + params: () => options, 213 + }); 214 + 215 + /** 216 + * Logs out current logged in user session. 217 + * Log user out of the system. 218 + */ 219 + export const logoutUserResource = (options?: Omit<LogoutUserData, 'url'>) => 220 + resource({ 221 + loader: () => { 222 + throw new Error('Not implemented'); 223 + }, 224 + params: () => options, 225 + }); 226 + 227 + /** 228 + * Delete user resource. 229 + * This can only be done by the logged in user. 230 + */ 231 + export const deleteUserResource = (options: Omit<DeleteUserData, 'url'>) => 232 + resource({ 233 + loader: () => { 234 + throw new Error('Not implemented'); 235 + }, 236 + params: () => options, 237 + }); 238 + 239 + /** 240 + * Get user by user name. 241 + * Get user detail based on username. 242 + */ 243 + export const getUserByNameResource = ( 244 + options: Omit<GetUserByNameData, 'url'>, 245 + ) => 246 + resource({ 247 + loader: () => { 248 + throw new Error('Not implemented'); 249 + }, 250 + params: () => options, 251 + }); 252 + 253 + /** 254 + * Update user resource. 255 + * This can only be done by the logged in user. 256 + */ 257 + export const updateUserResource = (options: Omit<UpdateUserData, 'url'>) => 258 + resource({ 259 + loader: () => { 260 + throw new Error('Not implemented'); 261 + }, 262 + params: () => options, 263 + });
+2 -2
packages/openapi-ts/src/plugins/@hey-api/client-angular/config.ts
··· 1 1 import { definePluginConfig } from '../../shared/utils/config'; 2 2 import { clientDefaultConfig, clientDefaultMeta } from '../client-core/config'; 3 - import { clientPluginHandler } from '../client-core/plugin'; 3 + import { angularClientPluginHandler } from './plugin'; 4 4 import type { HeyApiClientAngularPlugin } from './types'; 5 5 6 6 export const defaultConfig: HeyApiClientAngularPlugin['Config'] = { ··· 10 10 httpResource: false, 11 11 throwOnError: false, 12 12 }, 13 - handler: clientPluginHandler as HeyApiClientAngularPlugin['Handler'], 13 + handler: angularClientPluginHandler, 14 14 name: '@hey-api/client-angular', 15 15 }; 16 16
+373
packages/openapi-ts/src/plugins/@hey-api/client-angular/plugin.ts
··· 1 + import { tsc } from '../../../tsc'; 2 + import { stringCase } from '../../../utils/stringCase'; 3 + import { 4 + createOperationComment, 5 + isOperationOptionsRequired, 6 + } from '../../shared/utils/operation'; 7 + import { clientPluginHandler } from '../client-core/plugin'; 8 + import { operationClasses } from '../sdk/operation'; 9 + import { serviceFunctionIdentifier } from '../sdk/plugin-legacy'; 10 + import { typesId } from '../typescript/ref'; 11 + import type { HeyApiClientAngularPlugin } from './types'; 12 + 13 + export const angularClientPluginHandler: HeyApiClientAngularPlugin['Handler'] = 14 + (args) => { 15 + // First, run the standard client plugin handler to create/copy the client 16 + clientPluginHandler(args); 17 + 18 + const { plugin } = args; 19 + 20 + // Check if SDK plugin exists and if we should generate class-based services 21 + const sdkPlugin = plugin.getPlugin('@hey-api/sdk'); 22 + if (!sdkPlugin) { 23 + return; 24 + } 25 + 26 + // Now create our Angular-specific httpResource file 27 + const file = plugin.createFile({ 28 + id: 'httpResource', 29 + path: plugin.output + '.resource', 30 + }); 31 + 32 + // Import Angular core decorators and rxjs 33 + if (sdkPlugin.config.asClass) { 34 + file.import({ 35 + module: '@angular/core', 36 + name: 'Injectable', 37 + }); 38 + } 39 + 40 + file.import({ 41 + module: '@angular/core', 42 + name: 'resource', 43 + }); 44 + 45 + // Import types from the main types file if needed 46 + // const pluginTypeScript = plugin.getPlugin('@hey-api/typescript')!; 47 + // const fileTypeScript = plugin.context.file({ id: typesId })!;; 48 + 49 + if (sdkPlugin.config.asClass) { 50 + generateAngularClassServices({ file, plugin, sdkPlugin }); 51 + } else { 52 + generateAngularFunctionServices({ file, plugin, sdkPlugin }); 53 + } 54 + }; 55 + 56 + interface AngularServiceClassEntry { 57 + className: string; 58 + classes: Set<string>; 59 + methods: Set<string>; 60 + nodes: Array<any>; 61 + root: boolean; 62 + } 63 + 64 + const generateAngularClassServices = ({ 65 + file, 66 + plugin, 67 + sdkPlugin, 68 + }: { 69 + file: any; 70 + plugin: HeyApiClientAngularPlugin['Instance']; 71 + sdkPlugin: any; 72 + }) => { 73 + const serviceClasses = new Map<string, AngularServiceClassEntry>(); 74 + const generatedClasses = new Set<string>(); 75 + 76 + // Iterate through operations to build class structure 77 + plugin.forEach('operation', ({ operation }) => { 78 + const isRequiredOptions = isOperationOptionsRequired({ 79 + context: plugin.context, 80 + operation, 81 + }); 82 + 83 + const classes = operationClasses({ 84 + context: plugin.context, 85 + operation, 86 + plugin: sdkPlugin, 87 + }); 88 + 89 + for (const entry of classes.values()) { 90 + entry.path.forEach((currentClassName, index) => { 91 + if (!serviceClasses.has(currentClassName)) { 92 + serviceClasses.set(currentClassName, { 93 + className: currentClassName, 94 + classes: new Set(), 95 + methods: new Set(), 96 + nodes: [], 97 + root: !index, 98 + }); 99 + } 100 + 101 + const parentClassName = entry.path[index - 1]; 102 + if (parentClassName && parentClassName !== currentClassName) { 103 + const parentClass = serviceClasses.get(parentClassName)!; 104 + parentClass.classes.add(currentClassName); 105 + serviceClasses.set(parentClassName, parentClass); 106 + } 107 + 108 + const isLast = entry.path.length === index + 1; 109 + if (!isLast) { 110 + return; 111 + } 112 + 113 + const currentClass = serviceClasses.get(currentClassName)!; 114 + 115 + // Avoid duplicate methods 116 + if (currentClass.methods.has(entry.methodName)) { 117 + return; 118 + } 119 + 120 + // Generate Angular resource method 121 + const methodNode = generateAngularResourceMethod({ 122 + file, 123 + isRequiredOptions, 124 + methodName: entry.methodName, 125 + operation, 126 + plugin, 127 + }); 128 + 129 + if (!currentClass.nodes.length) { 130 + currentClass.nodes.push(methodNode); 131 + } else { 132 + currentClass.nodes.push(tsc.identifier({ text: '\n' }), methodNode); 133 + } 134 + 135 + currentClass.methods.add(entry.methodName); 136 + serviceClasses.set(currentClassName, currentClass); 137 + }); 138 + } 139 + }); 140 + 141 + // Generate classes 142 + const generateClass = (currentClass: AngularServiceClassEntry) => { 143 + if (generatedClasses.has(currentClass.className)) { 144 + return; 145 + } 146 + 147 + // Handle child classes 148 + if (currentClass.classes.size) { 149 + for (const childClassName of currentClass.classes) { 150 + const childClass = serviceClasses.get(childClassName)!; 151 + generateClass(childClass); 152 + 153 + currentClass.nodes.push( 154 + tsc.propertyDeclaration({ 155 + initializer: tsc.newExpression({ 156 + argumentsArray: [], 157 + expression: tsc.identifier({ 158 + text: `${childClass.className}Resource`, 159 + }), 160 + }), 161 + name: stringCase({ 162 + case: 'camelCase', 163 + value: childClass.className, 164 + }), 165 + }), 166 + ); 167 + } 168 + } 169 + 170 + const node = tsc.classDeclaration({ 171 + decorator: currentClass.root 172 + ? { 173 + args: [ 174 + { 175 + providedIn: 'root', 176 + }, 177 + ], 178 + name: 'Injectable', 179 + } 180 + : undefined, 181 + exportClass: currentClass.root, 182 + name: `${currentClass.className}Resource`, 183 + nodes: currentClass.nodes, 184 + }); 185 + 186 + file.add(node); 187 + generatedClasses.add(currentClass.className); 188 + }; 189 + 190 + for (const serviceClass of serviceClasses.values()) { 191 + generateClass(serviceClass); 192 + } 193 + }; 194 + 195 + const generateAngularFunctionServices = ({ 196 + file, 197 + plugin, 198 + // sdkPlugin, 199 + }: { 200 + file: any; 201 + plugin: HeyApiClientAngularPlugin['Instance']; 202 + sdkPlugin: any; 203 + }) => { 204 + plugin.forEach('operation', ({ operation }) => { 205 + const isRequiredOptions = isOperationOptionsRequired({ 206 + context: plugin.context, 207 + operation, 208 + }); 209 + 210 + const functionName = serviceFunctionIdentifier({ 211 + config: plugin.context.config, 212 + handleIllegal: true, 213 + id: operation.id, 214 + operation, 215 + }); 216 + 217 + const node = generateAngularResourceFunction({ 218 + file, 219 + functionName: `${functionName}Resource`, 220 + isRequiredOptions, 221 + operation, 222 + plugin, 223 + }); 224 + 225 + file.add(node); 226 + }); 227 + }; 228 + 229 + const generateResourceCallExpression = () => 230 + tsc.callExpression({ 231 + functionName: 'resource', 232 + parameters: [ 233 + tsc.objectExpression({ 234 + obj: [ 235 + { 236 + key: 'loader', 237 + value: tsc.arrowFunction({ 238 + parameters: [], 239 + statements: [ 240 + tsc.expressionToStatement({ 241 + expression: tsc.callExpression({ 242 + functionName: 'throw', 243 + parameters: [ 244 + tsc.newExpression({ 245 + argumentsArray: [ 246 + tsc.stringLiteral({ text: 'Not implemented' }), 247 + ], 248 + expression: tsc.identifier({ text: 'Error' }), 249 + }), 250 + ], 251 + }), 252 + }), 253 + ], 254 + }), 255 + }, 256 + { 257 + key: 'params', 258 + value: tsc.arrowFunction({ 259 + parameters: [], 260 + statements: [ 261 + tsc.returnStatement({ 262 + expression: tsc.identifier({ text: 'options' }), 263 + }), 264 + ], 265 + }), 266 + }, 267 + ], 268 + }), 269 + ], 270 + }); 271 + 272 + const generateAngularResourceMethod = ({ 273 + file, 274 + isRequiredOptions, 275 + methodName, 276 + operation, 277 + plugin, 278 + }: { 279 + file: any; 280 + isRequiredOptions: boolean; 281 + methodName: string; 282 + operation: any; 283 + plugin: any; 284 + }) => { 285 + // Import operation data type 286 + const pluginTypeScript = plugin.getPlugin('@hey-api/typescript')!; 287 + const fileTypeScript = plugin.context.file({ id: typesId })!; 288 + const dataType = file.import({ 289 + asType: true, 290 + module: file.relativePathToFile({ context: plugin.context, id: typesId }), 291 + name: fileTypeScript.getName( 292 + pluginTypeScript.api.getId({ operation, type: 'data' }), 293 + ), 294 + }); 295 + 296 + return tsc.methodDeclaration({ 297 + accessLevel: 'public', 298 + comment: createOperationComment({ operation }), 299 + isStatic: true, 300 + name: methodName, 301 + parameters: [ 302 + { 303 + isRequired: isRequiredOptions, 304 + name: 'options', 305 + type: dataType.name ? `Omit<${dataType.name}, 'url'>` : 'unknown', 306 + }, 307 + ], 308 + returnType: undefined, 309 + statements: [ 310 + tsc.returnStatement({ 311 + expression: generateResourceCallExpression(), 312 + }), 313 + ], 314 + types: [ 315 + { 316 + default: false, 317 + extends: 'boolean', 318 + name: 'ThrowOnError', 319 + }, 320 + ], 321 + }); 322 + }; 323 + 324 + const generateAngularResourceFunction = ({ 325 + file, 326 + functionName, 327 + isRequiredOptions, 328 + operation, 329 + plugin, 330 + }: { 331 + file: any; 332 + functionName: string; 333 + isRequiredOptions: boolean; 334 + operation: any; 335 + plugin: any; 336 + }) => { 337 + const pluginTypeScript = plugin.getPlugin('@hey-api/typescript')!; 338 + const fileTypeScript = plugin.context.file({ id: typesId })!; 339 + const dataType = file.import({ 340 + asType: true, 341 + module: file.relativePathToFile({ context: plugin.context, id: typesId }), 342 + name: fileTypeScript.getName( 343 + pluginTypeScript.api.getId({ operation, type: 'data' }), 344 + ), 345 + }); 346 + 347 + return tsc.constVariable({ 348 + comment: createOperationComment({ operation }), 349 + exportConst: true, 350 + expression: tsc.arrowFunction({ 351 + parameters: [ 352 + { 353 + isRequired: isRequiredOptions, 354 + name: 'options', 355 + type: dataType.name ? `Omit<${dataType.name}, 'url'>` : 'unknown', 356 + }, 357 + ], 358 + statements: [ 359 + tsc.returnStatement({ 360 + expression: generateResourceCallExpression(), 361 + }), 362 + ], 363 + types: [ 364 + { 365 + default: false, 366 + extends: 'boolean', 367 + name: 'ThrowOnError', 368 + }, 369 + ], 370 + }), 371 + name: functionName, 372 + }); 373 + };
+20
packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts
··· 115 115 }) => { 116 116 const client = getClientPlugin(plugin.context.config); 117 117 const isNuxtClient = client.name === '@hey-api/client-nuxt'; 118 + const isAngularClient = client.name === '@hey-api/client-angular'; 118 119 const file = plugin.context.file({ id: sdkId })!; 119 120 const sdkClasses = new Map<string, SdkClassEntry>(); 120 121 /** ··· 297 298 } 298 299 299 300 const node = tsc.classDeclaration({ 301 + decorator: 302 + currentClass.root && isAngularClient 303 + ? { 304 + args: [ 305 + { 306 + providedIn: 'root', 307 + }, 308 + ], 309 + name: 'Injectable', 310 + } 311 + : undefined, 300 312 exportClass: currentClass.root, 301 313 extendedClasses: plugin.config.instance ? ['_HeyApiClient'] : undefined, 302 314 name: currentClass.className, ··· 426 438 427 439 const client = getClientPlugin(plugin.context.config); 428 440 const isNuxtClient = client.name === '@hey-api/client-nuxt'; 441 + const isAngularClient = client.name === '@hey-api/client-angular'; 429 442 if (isNuxtClient) { 430 443 file.import({ 431 444 asType: true, 432 445 module: clientModule, 433 446 name: 'Composable', 447 + }); 448 + } 449 + 450 + if (isAngularClient && plugin.config.asClass) { 451 + file.import({ 452 + module: '@angular/core', 453 + name: 'Injectable', 434 454 }); 435 455 } 436 456