From 206d5acac7ae873557b2765a0ab4d5292ce94ca9 Mon Sep 17 00:00:00 2001 From: Lubos Date: Mon, 29 Dec 2025 03:57:33 +0800 Subject: [PATCH] chore: angular plugin structure --- dev/openapi-ts.config.ts | 16 +- .../src/openApi/common/parser/sanitize.ts | 21 - .../src/openApi/shared/locations/index.ts | 1 + .../src/openApi/shared/locations/operation.ts | 6 + .../utils}/__tests__/sanitize.test.ts | 2 +- .../src/openApi/shared/utils/operation.ts | 23 +- .../src/plugins/@angular/common/config.ts | 39 +- .../@angular/common/httpRequests/config.ts | 68 +++ .../@angular/common/httpRequests/index.ts | 3 + .../@angular/common/httpRequests/resolve.ts | 50 ++ .../@angular/common/httpRequests/types.d.ts | 193 +++++++ .../@angular/common/httpResources/config.ts | 68 +++ .../@angular/common/httpResources/index.ts | 3 + .../@angular/common/httpResources/resolve.ts | 50 ++ .../@angular/common/httpResources/types.d.ts | 193 +++++++ .../src/plugins/@angular/common/plugin.ts | 82 ++- .../@angular/common/shared/httpRequests.ts | 298 ----------- .../@angular/common/shared/httpResources.ts | 362 ------------- .../plugins/@angular/common/shared/node.ts | 475 ++++++++++++++++++ .../src/plugins/@angular/common/types.d.ts | 120 +---- .../plugins/@hey-api/sdk/operations/config.ts | 15 +- .../plugins/@hey-api/sdk/operations/index.ts | 7 +- .../@hey-api/sdk/operations/types.d.ts | 24 +- .../plugins/@hey-api/sdk/shared/operation.ts | 125 +---- .../@hey-api/sdk/shared/typeOptions.ts | 2 +- .../src/plugins/@hey-api/sdk/types.d.ts | 7 +- .../src/plugins/@hey-api/sdk/v1/node.ts | 380 ++++++++++++++ .../src/plugins/@hey-api/sdk/v1/plugin.ts | 384 +------------- .../src/plugins/shared/utils/operation.ts | 2 +- .../openapi-ts/src/ts-dsl/utils/context.ts | 9 +- 30 files changed, 1654 insertions(+), 1374 deletions(-) delete mode 100644 packages/openapi-ts/src/openApi/common/parser/sanitize.ts rename packages/openapi-ts/src/openApi/{common/parser => shared/utils}/__tests__/sanitize.test.ts (89%) create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpRequests/config.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpRequests/index.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpRequests/resolve.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpRequests/types.d.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpResources/config.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpResources/index.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpResources/resolve.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/httpResources/types.d.ts delete mode 100644 packages/openapi-ts/src/plugins/@angular/common/shared/httpRequests.ts delete mode 100644 packages/openapi-ts/src/plugins/@angular/common/shared/httpResources.ts create mode 100644 packages/openapi-ts/src/plugins/@angular/common/shared/node.ts create mode 100644 packages/openapi-ts/src/plugins/@hey-api/sdk/v1/node.ts diff --git a/dev/openapi-ts.config.ts b/dev/openapi-ts.config.ts index 907e5d760..d0f3b808e 100644 --- a/dev/openapi-ts.config.ts +++ b/dev/openapi-ts.config.ts @@ -242,7 +242,7 @@ export default defineConfig(() => { { // baseUrl: false, // exportFromIndex: true, - // name: '@hey-api/client-angular', + name: '@hey-api/client-angular', // runtimeConfigPath: path.resolve(__dirname, 'hey-api.ts'), // runtimeConfigPath: './src/hey-api.ts', // strictBaseUrl: true, @@ -295,7 +295,7 @@ export default defineConfig(() => { // container: 'object', containerName: { // casing: 'snake_case', - // name: 'OpencodeClient' + name: 'OpencodeClient', }, methodName: { // casing: 'SCREAMING_SNAKE_CASE', @@ -304,8 +304,8 @@ export default defineConfig(() => { methods: 'static', nesting: 'id', segmentName: '{{name}}Seggy', - strategy: 'byTags', - strategyDefaultTag: 'DaxLikedThis', + strategy: 'flat', + // strategyDefaultTag: 'DaxLikedThis', }, // paramsStructure: 'flat', // responseStyle: 'data', @@ -618,12 +618,14 @@ export default defineConfig(() => { { exportFromIndex: true, httpRequests: { - // asClass: true, - enabled: false, + // enabled: false, + containerName: 'AngularTest', + segmentName: '{{name}}Seggy', + strategy: 'single', }, httpResources: { // asClass: true, - enabled: false, + // enabled: false, }, name: '@angular/common', }, diff --git a/packages/openapi-ts/src/openApi/common/parser/sanitize.ts b/packages/openapi-ts/src/openApi/common/parser/sanitize.ts deleted file mode 100644 index c8803f974..000000000 --- a/packages/openapi-ts/src/openApi/common/parser/sanitize.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Sanitizes namespace identifiers so they are valid TypeScript identifiers of a certain form. - * - * 1: Remove any leading characters that are illegal as starting character of a typescript identifier. - * 2: Replace illegal characters in remaining part of type name with hyphen (-). - * - * Step 1 should perhaps instead also replace illegal characters with underscore, or prefix with it, like sanitizeEnumName - * does. The way this is now one could perhaps end up removing all characters, if all are illegal start characters. It - * would be sort of a breaking change to do so, though, previously generated code might change then. - * - * JavaScript identifier regexp pattern retrieved from https://developer.mozilla.org/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers - * - * The output of this is expected to be converted to PascalCase - * - * @deprecated - */ -export const sanitizeNamespaceIdentifier = (name: string) => - name - .replace(/^[^\p{ID_Start}]+/u, '') - .replace(/[^$\u200c\u200d\p{ID_Continue}]/gu, '-') - .replace(/[$+]/g, '-'); diff --git a/packages/openapi-ts/src/openApi/shared/locations/index.ts b/packages/openapi-ts/src/openApi/shared/locations/index.ts index 8e6ed6e77..f687be48c 100644 --- a/packages/openapi-ts/src/openApi/shared/locations/index.ts +++ b/packages/openapi-ts/src/openApi/shared/locations/index.ts @@ -1,5 +1,6 @@ export type { OperationPathStrategy, + OperationsStrategy, OperationStructureStrategy, } from './operation'; export { OperationPath, OperationStrategies } from './operation'; diff --git a/packages/openapi-ts/src/openApi/shared/locations/operation.ts b/packages/openapi-ts/src/openApi/shared/locations/operation.ts index be2ca1eb7..ab4ae350f 100644 --- a/packages/openapi-ts/src/openApi/shared/locations/operation.ts +++ b/packages/openapi-ts/src/openApi/shared/locations/operation.ts @@ -20,6 +20,12 @@ export type OperationStructureStrategy = ( operation: IR.OperationObject, ) => ReadonlyArray; +export type OperationsStrategy = + | 'byTags' + | 'flat' + | 'single' + | OperationStructureStrategy; + /** * Built-in strategies for operations. */ diff --git a/packages/openapi-ts/src/openApi/common/parser/__tests__/sanitize.test.ts b/packages/openapi-ts/src/openApi/shared/utils/__tests__/sanitize.test.ts similarity index 89% rename from packages/openapi-ts/src/openApi/common/parser/__tests__/sanitize.test.ts rename to packages/openapi-ts/src/openApi/shared/utils/__tests__/sanitize.test.ts index 6d656b407..89a6c6245 100644 --- a/packages/openapi-ts/src/openApi/common/parser/__tests__/sanitize.test.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/__tests__/sanitize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sanitizeNamespaceIdentifier } from '../sanitize'; +import { sanitizeNamespaceIdentifier } from '../operation'; describe('sanitizeNamespaceIdentifier', () => { it.each([ diff --git a/packages/openapi-ts/src/openApi/shared/utils/operation.ts b/packages/openapi-ts/src/openApi/shared/utils/operation.ts index 2b8eb68f7..20587396a 100644 --- a/packages/openapi-ts/src/openApi/shared/utils/operation.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/operation.ts @@ -1,6 +1,5 @@ import type { Context } from '~/ir/context'; import { createOperationKey } from '~/ir/operation'; -import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; import { toCase } from '~/utils/naming'; import type { State } from '../types/state'; @@ -16,6 +15,28 @@ export const httpMethods = [ 'trace', ] as const; +/** + * Sanitizes namespace identifiers so they are valid TypeScript identifiers of a certain form. + * + * 1: Remove any leading characters that are illegal as starting character of a typescript identifier. + * 2: Replace illegal characters in remaining part of type name with hyphen (-). + * + * Step 1 should perhaps instead also replace illegal characters with underscore, or prefix with it, like sanitizeEnumName + * does. The way this is now one could perhaps end up removing all characters, if all are illegal start characters. It + * would be sort of a breaking change to do so, though, previously generated code might change then. + * + * JavaScript identifier regexp pattern retrieved from https://developer.mozilla.org/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers + * + * The output of this is expected to be converted to PascalCase + * + * @deprecated + */ +export const sanitizeNamespaceIdentifier = (name: string) => + name + .replace(/^[^\p{ID_Start}]+/u, '') + .replace(/[^$\u200c\u200d\p{ID_Continue}]/gu, '-') + .replace(/[$+]/g, '-'); + /** * Returns an operation ID to use across the application. By default, we try * to use the provided ID. If it's not provided or the SDK is configured diff --git a/packages/openapi-ts/src/plugins/@angular/common/config.ts b/packages/openapi-ts/src/plugins/@angular/common/config.ts index 66398724a..2fbb3c00a 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/config.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/config.ts @@ -1,5 +1,7 @@ import { definePluginConfig } from '~/plugins/shared/utils/config'; +import { resolveHttpRequests } from './httpRequests'; +import { resolveHttpResources } from './httpResources'; import { handler } from './plugin'; import type { AngularCommonPlugin } from './types'; @@ -11,41 +13,8 @@ export const defaultConfig: AngularCommonPlugin['Config'] = { handler, name: '@angular/common', resolveConfig: (plugin, context) => { - plugin.config.httpRequests = context.valueToObject({ - defaultValue: { - asClass: false, - classNameBuilder: '{{name}}Requests', - enabled: true, - }, - mappers: { - boolean: (enabled) => ({ enabled }), - }, - value: plugin.config.httpRequests, - }); - - if (!plugin.config.httpRequests.methodNameBuilder) { - const { asClass } = plugin.config.httpRequests; - plugin.config.httpRequests.methodNameBuilder = (operation) => - asClass ? `${operation.id}` : `${operation.id}Request`; - } - - plugin.config.httpResources = context.valueToObject({ - defaultValue: { - asClass: false, - classNameBuilder: '{{name}}Resources', - enabled: true, - }, - mappers: { - boolean: (enabled) => ({ enabled }), - }, - value: plugin.config.httpResources, - }); - - if (!plugin.config.httpResources.methodNameBuilder) { - const { asClass } = plugin.config.httpResources; - plugin.config.httpResources.methodNameBuilder = (operation) => - asClass ? `${operation.id}` : `${operation.id}Resource`; - } + plugin.config.httpRequests = resolveHttpRequests(plugin.config, context); + plugin.config.httpResources = resolveHttpResources(plugin.config, context); }, }; diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpRequests/config.ts b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/config.ts new file mode 100644 index 000000000..ab62aba99 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/config.ts @@ -0,0 +1,68 @@ +import type { PluginContext } from '~/plugins/types'; + +import type { UserConfig } from '../types'; +import type { HttpRequestsConfig, UserHttpRequestsConfig } from './types'; + +type Config = Omit; + +export function resolveHttpRequests( + config: Config, + context: PluginContext, +): HttpRequestsConfig { + let input = config.httpRequests; + if (typeof input === 'string' || typeof input === 'function') { + input = { strategy: input }; + } else if (typeof input === 'boolean' || !input) { + input = { enabled: Boolean(input) }; + } + + const strategy = input.strategy ?? 'flat'; + + return context.valueToObject({ + defaultValue: { + container: 'class', + enabled: true, + methods: 'static', + nesting: strategy === 'flat' ? 'id' : 'operationId', + nestingDelimiters: /[./]/, + strategy, + strategyDefaultTag: 'default', + }, + mappers: { + object(value) { + value.containerName = context.valueToObject({ + defaultValue: + strategy === 'single' + ? { casing: 'PascalCase', name: 'HttpRequests' } + : { casing: 'PascalCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.containerName, + }); + value.methodName = context.valueToObject({ + defaultValue: + strategy === 'flat' + ? { casing: 'camelCase', name: '{{name}}Request' } + : { casing: 'camelCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.methodName, + }); + value.segmentName = context.valueToObject({ + defaultValue: { casing: 'PascalCase', name: '{{name}}Requests' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.segmentName, + }); + return value; + }, + }, + value: input as UserHttpRequestsConfig, + }) as HttpRequestsConfig; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpRequests/index.ts b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/index.ts new file mode 100644 index 000000000..b9bc8730b --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/index.ts @@ -0,0 +1,3 @@ +export { resolveHttpRequests } from './config'; +export { resolveHttpRequestsStrategy } from './resolve'; +export type { HttpRequestsConfig, UserHttpRequestsConfig } from './types'; diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpRequests/resolve.ts b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/resolve.ts new file mode 100644 index 000000000..0cbd6cf06 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/resolve.ts @@ -0,0 +1,50 @@ +import type { + OperationPathStrategy, + OperationStructureStrategy, +} from '~/openApi/shared/locations'; +import { OperationPath, OperationStrategies } from '~/openApi/shared/locations'; + +import type { AngularCommonPlugin } from '../types'; + +function resolvePath( + plugin: AngularCommonPlugin['Instance'], +): OperationPathStrategy { + if (plugin.config.httpRequests.nesting === 'id') { + return OperationPath.id(); + } + + if (plugin.config.httpRequests.nesting === 'operationId') { + return OperationPath.fromOperationId({ + delimiters: plugin.config.httpRequests.nestingDelimiters, + }); + } + + return plugin.config.httpRequests.nesting; +} + +export function resolveHttpRequestsStrategy( + plugin: AngularCommonPlugin['Instance'], +): OperationStructureStrategy { + if (plugin.config.httpRequests.strategy === 'flat') { + return OperationStrategies.flat({ + path: resolvePath(plugin), + }); + } + + if (plugin.config.httpRequests.strategy === 'single') { + const root = plugin.config.httpRequests.containerName; + return OperationStrategies.single({ + path: resolvePath(plugin), + root: typeof root.name === 'string' ? root.name : (root.name?.('') ?? ''), + }); + } + + if (plugin.config.httpRequests.strategy === 'byTags') { + return OperationStrategies.byTags({ + fallback: plugin.config.httpRequests.strategyDefaultTag, + path: resolvePath(plugin), + }); + } + + return plugin.config.httpRequests.strategy; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpRequests/types.d.ts b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/types.d.ts new file mode 100644 index 000000000..7a3723387 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpRequests/types.d.ts @@ -0,0 +1,193 @@ +import type { + OperationPathStrategy, + OperationsStrategy, +} from '~/openApi/shared/locations'; +import type { NamingConfig, NamingRule } from '~/utils/naming'; + +export interface UserHttpRequestsConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + * + * @default 'class' + */ + container?: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', case: 'PascalCase' } + */ + containerName?: NamingRule; + /** + * Whether or not to create HTTP Request instances. + * + * @default true + */ + enabled?: boolean; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName?: NamingRule; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'static'` - Static methods, no instantiation required + * + * @default 'static' + */ + methods?: 'static'; + /** + * How to derive nesting structure from operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + * + * @default 'operationId' + */ + nesting?: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + * + * @default /[./]/ + */ + nestingDelimiters?: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName?: NamingRule; + /** + * Grouping strategy. + * + * - `'flat'` - Standalone functions, no grouping + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + * + * @default 'flat' + */ + strategy?: OperationsStrategy; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + * + * @default 'default' + */ + strategyDefaultTag?: string; +} + +export interface HttpRequestsConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + */ + container: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', case: 'PascalCase' } + */ + containerName: NamingConfig; + /** + * Whether or not to create HTTP Request instances. + */ + enabled: boolean; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName: NamingConfig; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'static'` - Static methods, no instantiation required + */ + methods: 'static'; + /** + * How to derive nesting structure from operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + */ + nesting: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + */ + nestingDelimiters: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName: NamingConfig; + /** + * Grouping strategy. + * + * - `'flat'` - Standalone functions, no grouping + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + */ + strategy: OperationsStrategy; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + */ + strategyDefaultTag: string; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpResources/config.ts b/packages/openapi-ts/src/plugins/@angular/common/httpResources/config.ts new file mode 100644 index 000000000..c3a570f81 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpResources/config.ts @@ -0,0 +1,68 @@ +import type { PluginContext } from '~/plugins/types'; + +import type { UserConfig } from '../types'; +import type { HttpResourcesConfig, UserHttpResourcesConfig } from './types'; + +type Config = Omit; + +export function resolveHttpResources( + config: Config, + context: PluginContext, +): HttpResourcesConfig { + let input = config.httpResources; + if (typeof input === 'string' || typeof input === 'function') { + input = { strategy: input }; + } else if (typeof input === 'boolean' || !input) { + input = { enabled: Boolean(input) }; + } + + const strategy = input.strategy ?? 'flat'; + + return context.valueToObject({ + defaultValue: { + container: 'class', + enabled: true, + methods: 'static', + nesting: strategy === 'flat' ? 'id' : 'operationId', + nestingDelimiters: /[./]/, + strategy, + strategyDefaultTag: 'default', + }, + mappers: { + object(value) { + value.containerName = context.valueToObject({ + defaultValue: + strategy === 'single' + ? { casing: 'PascalCase', name: 'HttpResources' } + : { casing: 'PascalCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.containerName, + }); + value.methodName = context.valueToObject({ + defaultValue: + strategy === 'flat' + ? { casing: 'camelCase', name: '{{name}}Resource' } + : { casing: 'camelCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.methodName, + }); + value.segmentName = context.valueToObject({ + defaultValue: { casing: 'PascalCase', name: '{{name}}Resources' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.segmentName, + }); + return value; + }, + }, + value: input as UserHttpResourcesConfig, + }) as HttpResourcesConfig; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpResources/index.ts b/packages/openapi-ts/src/plugins/@angular/common/httpResources/index.ts new file mode 100644 index 000000000..e99060061 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpResources/index.ts @@ -0,0 +1,3 @@ +export { resolveHttpResources } from './config'; +export { resolveHttpResourcesStrategy } from './resolve'; +export type { HttpResourcesConfig, UserHttpResourcesConfig } from './types'; diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpResources/resolve.ts b/packages/openapi-ts/src/plugins/@angular/common/httpResources/resolve.ts new file mode 100644 index 000000000..c1af5acf0 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpResources/resolve.ts @@ -0,0 +1,50 @@ +import type { + OperationPathStrategy, + OperationStructureStrategy, +} from '~/openApi/shared/locations'; +import { OperationPath, OperationStrategies } from '~/openApi/shared/locations'; + +import type { AngularCommonPlugin } from '../types'; + +function resolvePath( + plugin: AngularCommonPlugin['Instance'], +): OperationPathStrategy { + if (plugin.config.httpResources.nesting === 'id') { + return OperationPath.id(); + } + + if (plugin.config.httpResources.nesting === 'operationId') { + return OperationPath.fromOperationId({ + delimiters: plugin.config.httpResources.nestingDelimiters, + }); + } + + return plugin.config.httpResources.nesting; +} + +export function resolveHttpResourcesStrategy( + plugin: AngularCommonPlugin['Instance'], +): OperationStructureStrategy { + if (plugin.config.httpResources.strategy === 'flat') { + return OperationStrategies.flat({ + path: resolvePath(plugin), + }); + } + + if (plugin.config.httpResources.strategy === 'single') { + const root = plugin.config.httpResources.containerName; + return OperationStrategies.single({ + path: resolvePath(plugin), + root: typeof root.name === 'string' ? root.name : (root.name?.('') ?? ''), + }); + } + + if (plugin.config.httpResources.strategy === 'byTags') { + return OperationStrategies.byTags({ + fallback: plugin.config.httpResources.strategyDefaultTag, + path: resolvePath(plugin), + }); + } + + return plugin.config.httpResources.strategy; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpResources/types.d.ts b/packages/openapi-ts/src/plugins/@angular/common/httpResources/types.d.ts new file mode 100644 index 000000000..e10c244ad --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/httpResources/types.d.ts @@ -0,0 +1,193 @@ +import type { + OperationPathStrategy, + OperationsStrategy, +} from '~/openApi/shared/locations'; +import type { NamingConfig, NamingRule } from '~/utils/naming'; + +export interface UserHttpResourcesConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + * + * @default 'class' + */ + container?: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', case: 'PascalCase' } + */ + containerName?: NamingRule; + /** + * Whether or not to create HTTP resource APIs. + * + * @default true + */ + enabled?: boolean; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName?: NamingRule; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'static'` - Static methods, no instantiation required + * + * @default 'static' + */ + methods?: 'static'; + /** + * How to derive nesting structure from operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + * + * @default 'operationId' + */ + nesting?: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + * + * @default /[./]/ + */ + nestingDelimiters?: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName?: NamingRule; + /** + * Grouping strategy. + * + * - `'flat'` - Standalone functions, no grouping + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + * + * @default 'flat' + */ + strategy?: OperationsStrategy; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + * + * @default 'default' + */ + strategyDefaultTag?: string; +} + +export interface HttpResourcesConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + */ + container: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', case: 'PascalCase' } + */ + containerName: NamingConfig; + /** + * Whether or not to create HTTP resource APIs. + */ + enabled: boolean; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName: NamingConfig; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'static'` - Static methods, no instantiation required + */ + methods: 'static'; + /** + * How to derive nesting structure from operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + */ + nesting: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + */ + nestingDelimiters: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName: NamingConfig; + /** + * Grouping strategy. + * + * - `'flat'` - Standalone functions, no grouping + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + */ + strategy: OperationsStrategy; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + */ + strategyDefaultTag: string; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/plugin.ts b/packages/openapi-ts/src/plugins/@angular/common/plugin.ts index e44fc08b8..e971909a4 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/plugin.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/plugin.ts @@ -1,5 +1,17 @@ -import { createHttpRequests } from './shared/httpRequests'; -import { createHttpResources } from './shared/httpResources'; +import { StructureModel } from '@hey-api/codegen-core'; + +import type { $ } from '~/ts-dsl'; + +import { resolveHttpRequestsStrategy } from './httpRequests'; +import { resolveHttpResourcesStrategy } from './httpResources'; +import type { OperationItem } from './shared/node'; +import { + createHttpRequestShell, + createHttpResourceShell, + source, + toHttpRequestNode, + toHttpResourceNode, +} from './shared/node'; import type { AngularCommonPlugin } from './types'; export const handler: AngularCommonPlugin['Handler'] = ({ plugin }) => { @@ -33,11 +45,73 @@ export const handler: AngularCommonPlugin['Handler'] = ({ plugin }) => { }, }); + const httpRequestStructure = new StructureModel(); + const httpResourceStructure = new StructureModel(); + if (plugin.config.httpRequests.enabled) { - createHttpRequests({ plugin }); + const shell = createHttpRequestShell(plugin); + const strategy = resolveHttpRequestsStrategy(plugin); + + plugin.forEach( + 'operation', + ({ operation }) => { + httpRequestStructure.insert({ + data: { + operation, + } satisfies OperationItem, + locations: strategy(operation).map((path) => ({ path, shell })), + source, + }); + }, + { order: 'declarations' }, + ); } if (plugin.config.httpResources.enabled) { - createHttpResources({ plugin }); + const shell = createHttpResourceShell(plugin); + const strategy = resolveHttpResourcesStrategy(plugin); + + plugin.forEach( + 'operation', + ({ operation }) => { + httpResourceStructure.insert({ + data: { + operation, + } satisfies OperationItem, + locations: strategy(operation).map((path) => ({ path, shell })), + source, + }); + }, + { order: 'declarations' }, + ); + } + + const allDependencies: Array> = []; + const allNodes: Array> = []; + + for (const node of httpRequestStructure.walk()) { + const { dependencies, nodes } = toHttpRequestNode(node, plugin); + allDependencies.push(...(dependencies ?? [])); + allNodes.push(...nodes); + } + for (const node of httpResourceStructure.walk()) { + const { dependencies, nodes } = toHttpResourceNode(node, plugin); + allDependencies.push(...(dependencies ?? [])); + allNodes.push(...nodes); + } + + const uniqueDependencies = new Map< + number, + ReturnType + >(); + for (const dep of allDependencies) { + if (dep.symbol) uniqueDependencies.set(dep.symbol.id, dep); + } + for (const dep of uniqueDependencies.values()) { + plugin.node(dep); + } + + for (const node of allNodes) { + plugin.node(node); } }; diff --git a/packages/openapi-ts/src/plugins/@angular/common/shared/httpRequests.ts b/packages/openapi-ts/src/plugins/@angular/common/shared/httpRequests.ts deleted file mode 100644 index 438fe72b9..000000000 --- a/packages/openapi-ts/src/plugins/@angular/common/shared/httpRequests.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { IR } from '~/ir/types'; -import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; -import { - createOperationComment, - isOperationOptionsRequired, -} from '~/plugins/shared/utils/operation'; -import { $ } from '~/ts-dsl'; -import { applyNaming, toCase } from '~/utils/naming'; - -import type { AngularCommonPlugin } from '../types'; - -interface AngularRequestClassEntry { - className: string; - classes: Set; - methods: Set; - nodes: Array>; - root: boolean; -} - -const generateClassRequests = ({ - plugin, -}: { - plugin: AngularCommonPlugin['Instance']; -}) => { - const requestClasses = new Map(); - const generatedClasses = new Set(); - - const symbolHttpRequest = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/common/http.HttpRequest', - }); - const symbolOptions = plugin.referenceSymbol({ - category: 'type', - resource: 'client-options', - tool: 'sdk', - }); - const symbolInjectable = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/core.Injectable', - }); - - const sdkPlugin = plugin.getPluginOrThrow('@hey-api/sdk'); - - plugin.forEach( - 'operation', - ({ operation }) => { - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - - const classes = operationClasses({ operation, plugin: sdkPlugin }); - - for (const entry of classes.values()) { - entry.path.forEach((currentClassName, index) => { - if (!requestClasses.has(currentClassName)) { - requestClasses.set(currentClassName, { - className: currentClassName, - classes: new Set(), - methods: new Set(), - nodes: [], - root: !index, - }); - } - - const parentClassName = entry.path[index - 1]; - if (parentClassName && parentClassName !== currentClassName) { - const parentClass = requestClasses.get(parentClassName)!; - parentClass.classes.add(currentClassName); - requestClasses.set(parentClassName, parentClass); - } - - const isLast = entry.path.length === index + 1; - if (!isLast) { - return; - } - - const currentClass = requestClasses.get(currentClassName)!; - - const requestMethodName = - plugin.config.httpRequests.methodNameBuilder(operation); - - if (currentClass.methods.has(requestMethodName)) { - return; - } - - const symbolDataType = plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'typescript', - }); - - const methodNode = $.method(requestMethodName) - .public() - .$if(createOperationComment(operation), (c, v) => c.doc(v)) - .param('options', (p) => - p.required(isRequiredOptions).type( - $.type(symbolOptions) - .generic(symbolDataType ?? 'unknown') - .generic('ThrowOnError'), - ), - ) - .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) - .returns($.type(symbolHttpRequest).generic('unknown')) - .do( - $.return( - generateRequestCallExpression({ - operation, - plugin, - }), - ), - ); - - if (!currentClass.nodes.length) { - currentClass.nodes.push(methodNode); - } else { - currentClass.nodes.push($.newline(), methodNode); - } - - currentClass.methods.add(requestMethodName); - requestClasses.set(currentClassName, currentClass); - }); - } - }, - { - order: 'declarations', - }, - ); - - const generateClass = (currentClass: AngularRequestClassEntry) => { - if (generatedClasses.has(currentClass.className)) { - return; - } - - if (currentClass.classes.size) { - for (const childClassName of currentClass.classes) { - const childClass = requestClasses.get(childClassName)!; - generateClass(childClass); - - currentClass.nodes.push( - $.field(toCase(childClass.className, 'camelCase')).assign( - $.new( - applyNaming(childClass.className, { - case: 'preserve', - name: plugin.config.httpRequests.classNameBuilder, - }), - ), - ), - ); - } - } - - const symbolClass = plugin.symbol( - applyNaming(currentClass.className, { - case: 'preserve', - name: plugin.config.httpRequests.classNameBuilder, - }), - { - meta: { - category: 'utility', - resource: 'class', - resourceId: currentClass.className, - tool: 'angular', - }, - }, - ); - const node = $.class(symbolClass) - .export() - .$if(currentClass.root, (c) => - c.decorator( - symbolInjectable, - $.object().prop('providedIn', $.literal('root')), - ), - ) - .do(...currentClass.nodes); - plugin.node(node); - - generatedClasses.add(currentClass.className); - }; - - for (const requestClass of requestClasses.values()) { - generateClass(requestClass); - } -}; - -const generateFunctionRequests = ({ - plugin, -}: { - plugin: AngularCommonPlugin['Instance']; -}) => { - const symbolHttpRequest = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/common/http.HttpRequest', - }); - const symbolOptions = plugin.referenceSymbol({ - category: 'type', - resource: 'client-options', - tool: 'sdk', - }); - - plugin.forEach( - 'operation', - ({ operation }) => { - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - - const symbol = plugin.symbol( - plugin.config.httpRequests.methodNameBuilder(operation), - { - meta: { - category: 'utility', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'angular', - }, - }, - ); - - const symbolDataType = plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'typescript', - }); - - const node = $.const(symbol) - .export() - .$if(createOperationComment(operation), (c, v) => c.doc(v)) - .assign( - $.func() - .param('options', (p) => - p.required(isRequiredOptions).type( - $.type(symbolOptions) - .generic(symbolDataType ?? 'unknown') - .generic('ThrowOnError'), - ), - ) - .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) - .returns($.type(symbolHttpRequest).generic('unknown')) - .do( - $.return( - generateRequestCallExpression({ - operation, - plugin, - }), - ), - ), - ); - plugin.node(node); - }, - { - order: 'declarations', - }, - ); -}; - -const generateRequestCallExpression = ({ - operation, - plugin, -}: { - operation: IR.OperationObject; - plugin: AngularCommonPlugin['Instance']; -}) => { - const symbolClient = plugin.getSymbol({ - category: 'client', - }); - - const optionsClient = $('options') - .attr('client') - .optional() - .$if(symbolClient, (c, s) => c.coalesce(s)); - - return optionsClient - .attr('requestOptions') - .call( - $.object() - .prop('responseStyle', $.literal('data')) - .prop('method', $.literal(operation.method.toUpperCase())) - .prop('url', $.literal(operation.path)) - .spread('options'), - ); -}; - -export const createHttpRequests: AngularCommonPlugin['Handler'] = ({ - plugin, -}) => { - if (plugin.config.httpRequests.asClass) { - generateClassRequests({ plugin }); - } else { - generateFunctionRequests({ plugin }); - } -}; diff --git a/packages/openapi-ts/src/plugins/@angular/common/shared/httpResources.ts b/packages/openapi-ts/src/plugins/@angular/common/shared/httpResources.ts deleted file mode 100644 index a077c7816..000000000 --- a/packages/openapi-ts/src/plugins/@angular/common/shared/httpResources.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { IR } from '~/ir/types'; -import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; -import { - createOperationComment, - isOperationOptionsRequired, -} from '~/plugins/shared/utils/operation'; -import { $ } from '~/ts-dsl'; -import { applyNaming, toCase } from '~/utils/naming'; - -import type { AngularCommonPlugin } from '../types'; - -interface AngularServiceClassEntry { - className: string; - classes: Set; - methods: Set; - nodes: Array>; - root: boolean; -} - -const generateClassServices = ({ - plugin, -}: { - plugin: AngularCommonPlugin['Instance']; -}) => { - const serviceClasses = new Map(); - const generatedClasses = new Set(); - - const symbolInjectable = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/core.Injectable', - }); - const symbolOptions = plugin.referenceSymbol({ - category: 'type', - resource: 'client-options', - tool: 'sdk', - }); - - const sdkPlugin = plugin.getPluginOrThrow('@hey-api/sdk'); - - plugin.forEach( - 'operation', - ({ operation }) => { - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - - const classes = operationClasses({ operation, plugin: sdkPlugin }); - - for (const entry of classes.values()) { - entry.path.forEach((currentClassName, index) => { - if (!serviceClasses.has(currentClassName)) { - serviceClasses.set(currentClassName, { - className: currentClassName, - classes: new Set(), - methods: new Set(), - nodes: [], - root: !index, - }); - } - - const parentClassName = entry.path[index - 1]; - if (parentClassName && parentClassName !== currentClassName) { - const parentClass = serviceClasses.get(parentClassName)!; - parentClass.classes.add(currentClassName); - serviceClasses.set(parentClassName, parentClass); - } - - const isLast = entry.path.length === index + 1; - if (!isLast) { - return; - } - - const currentClass = serviceClasses.get(currentClassName)!; - - const resourceMethodName = - plugin.config.httpResources.methodNameBuilder(operation); - - if (currentClass.methods.has(resourceMethodName)) { - return; - } - - const symbolDataType = plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'typescript', - }); - - const methodNode = $.method(resourceMethodName) - .public() - .$if(createOperationComment(operation), (c, v) => c.doc(v)) - .param('options', (p) => - p.required(isRequiredOptions).type( - $.type.func().returns( - $.type.or( - $.type(symbolOptions) - .generic(symbolDataType ?? 'unknown') - .generic('ThrowOnError'), - $.type('undefined'), - ), - ), - ), - ) - .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) - .do( - $.return( - generateResourceCallExpression({ - operation, - plugin, - }), - ), - ); - - if (!currentClass.nodes.length) { - currentClass.nodes.push(methodNode); - } else { - currentClass.nodes.push($.newline(), methodNode); - } - - currentClass.methods.add(resourceMethodName); - serviceClasses.set(currentClassName, currentClass); - }); - } - }, - { - order: 'declarations', - }, - ); - - const generateClass = (currentClass: AngularServiceClassEntry) => { - if (generatedClasses.has(currentClass.className)) { - return; - } - - if (currentClass.classes.size) { - for (const childClassName of currentClass.classes) { - const childClass = serviceClasses.get(childClassName)!; - generateClass(childClass); - - currentClass.nodes.push( - $.field(toCase(childClass.className, 'camelCase')).assign( - $.new( - applyNaming(childClass.className, { - case: 'preserve', - name: plugin.config.httpResources.classNameBuilder, - }), - ), - ), - ); - } - } - - const symbolClass = plugin.symbol( - applyNaming(currentClass.className, { - case: 'preserve', - name: plugin.config.httpResources.classNameBuilder, - }), - ); - const node = $.class(symbolClass) - .export() - .$if(currentClass.root, (c) => - c.decorator( - symbolInjectable, - $.object().prop('providedIn', $.literal('root')), - ), - ) - .do(...currentClass.nodes); - plugin.node(node); - - generatedClasses.add(currentClass.className); - }; - - for (const serviceClass of serviceClasses.values()) { - generateClass(serviceClass); - } -}; - -const generateFunctionServices = ({ - plugin, -}: { - plugin: AngularCommonPlugin['Instance']; -}) => { - const symbolOptions = plugin.referenceSymbol({ - category: 'type', - resource: 'client-options', - tool: 'sdk', - }); - - plugin.forEach( - 'operation', - ({ operation }) => { - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - - const symbol = plugin.symbol( - plugin.config.httpResources.methodNameBuilder(operation), - ); - - const symbolDataType = plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'typescript', - }); - - const node = $.const(symbol) - .export() - .$if(createOperationComment(operation), (c, v) => c.doc(v)) - .assign( - $.func() - .param('options', (p) => - p.required(isRequiredOptions).type( - $.type.func().returns( - $.type.or( - $.type(symbolOptions) - .generic(symbolDataType ?? 'unknown') - .generic('ThrowOnError'), - $.type('undefined'), - ), - ), - ), - ) - .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) - .do( - $.return( - generateResourceCallExpression({ - operation, - plugin, - }), - ), - ), - ); - plugin.node(node); - }, - { - order: 'declarations', - }, - ); -}; - -const generateResourceCallExpression = ({ - operation, - plugin, -}: { - operation: IR.OperationObject; - plugin: AngularCommonPlugin['Instance']; -}) => { - const sdkPlugin = plugin.getPluginOrThrow('@hey-api/sdk'); - - const symbolHttpResource = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/common/http.httpResource', - }); - const symbolInject = plugin.referenceSymbol({ - category: 'external', - resource: '@angular/core.inject', - }); - - const symbolResponseType = plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'response', - }); - - if (plugin.config.httpRequests.asClass) { - // For class-based request methods, use inject and class hierarchy - const classes = operationClasses({ operation, plugin: sdkPlugin }); - - const firstEntry = Array.from(classes.values())[0]; - if (firstEntry) { - // Import the root class from HTTP requests - const rootClassName = firstEntry.path[0]!; - const symbolClass = plugin.referenceSymbol({ - category: 'utility', - resource: 'class', - resourceId: rootClassName, - tool: 'angular', - }); - - // Build the method access path using inject - let methodAccess: ReturnType = - $(symbolInject).call(symbolClass); - - // Navigate through the class hierarchy - for (let i = 1; i < firstEntry.path.length; i++) { - const className = firstEntry.path[i]; - if (className) { - methodAccess = methodAccess.attr(toCase(className, 'camelCase')); - } - } - - methodAccess = methodAccess.attr( - plugin.config.httpRequests.methodNameBuilder(operation), - ); - - return $(symbolHttpResource) - .call( - $.func().do( - $.const('opts').assign( - $.ternary('options') - .do($('options').call()) - .otherwise($.id('undefined')), - ), - $.return( - $.ternary('opts') - .do(methodAccess.call('opts')) - .otherwise($.id('undefined')), - ), - ), - ) - .generic(symbolResponseType ?? 'unknown'); - } - } else { - const symbolHttpRequest = plugin.referenceSymbol({ - category: 'utility', - resource: 'operation', - resourceId: operation.id, - role: 'data', - tool: 'angular', - }); - - return $(symbolHttpResource) - .call( - $.func().do( - $.const('opts').assign( - $.ternary('options') - .do($('options').call()) - .otherwise($.id('undefined')), - ), - $.return( - $.ternary('opts') - .do($(symbolHttpRequest).call('opts')) - .otherwise($.id('undefined')), - ), - ), - ) - .generic(symbolResponseType ?? 'unknown'); - } - - // Fallback return (should not reach here) - return $(symbolHttpResource).call( - $.func() - .do($.return($.id('undefined'))) - .generic(symbolResponseType ?? 'unknown'), - ); -}; - -export const createHttpResources: AngularCommonPlugin['Handler'] = ({ - plugin, -}) => { - if (plugin.config.httpResources.asClass) { - generateClassServices({ plugin }); - } else { - generateFunctionServices({ plugin }); - } -}; diff --git a/packages/openapi-ts/src/plugins/@angular/common/shared/node.ts b/packages/openapi-ts/src/plugins/@angular/common/shared/node.ts new file mode 100644 index 000000000..674910f00 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@angular/common/shared/node.ts @@ -0,0 +1,475 @@ +import type { + StructureItem, + StructureNode, + StructureShell, + Symbol, + SymbolMeta, +} from '@hey-api/codegen-core'; + +import type { IR } from '~/ir/types'; +import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; +import { + createOperationComment, + isOperationOptionsRequired, +} from '~/plugins/shared/utils/operation'; +import { $ } from '~/ts-dsl'; +import { applyNaming, toCase } from '~/utils/naming'; + +import type { AngularCommonPlugin } from '../types'; + +export interface OperationItem { + operation: IR.OperationObject; +} + +export const source = globalThis.Symbol('@angular/common'); + +function attachComment< + T extends ReturnType, +>(args: { node: T; operation: IR.OperationObject }): T { + const { node, operation } = args; + return node.$if(createOperationComment(operation), (n, v) => n.doc(v)) as T; +} + +function createHttpRequestFnMeta(operation: IR.OperationObject): SymbolMeta { + return { + category: 'utility', + resource: 'operation', + resourceId: operation.id, + role: 'request', + tool: 'angular', + }; +} + +function createHttpRequestShellMeta(node: StructureNode): SymbolMeta { + return { + category: 'utility', + resource: 'shell', + resourceId: node.getPath().join('.'), + role: 'request', + tool: 'angular', + }; +} + +function createHttpResourceFnMeta(operation: IR.OperationObject): SymbolMeta { + return { + category: 'utility', + resource: 'operation', + resourceId: operation.id, + role: 'resource', + tool: 'angular', + }; +} + +function createHttpResourceShellMeta(node: StructureNode): SymbolMeta { + return { + category: 'utility', + resource: 'shell', + resourceId: node.getPath().join('.'), + role: 'resource', + tool: 'angular', + }; +} + +function createHttpRequestFnSymbol( + plugin: AngularCommonPlugin['Instance'], + item: StructureItem & { data: OperationItem }, +): Symbol { + const { operation } = item.data; + const name = item.location[item.location.length - 1]!; + return plugin.symbol( + applyNaming(name, plugin.config.httpRequests.methodName), + { + meta: createHttpRequestFnMeta(operation), + }, + ); +} + +function createHttpResourceFnSymbol( + plugin: AngularCommonPlugin['Instance'], + item: StructureItem & { data: OperationItem }, +): Symbol { + const { operation } = item.data; + const name = item.location[item.location.length - 1]!; + return plugin.symbol( + applyNaming(name, plugin.config.httpResources.methodName), + { + meta: createHttpResourceFnMeta(operation), + }, + ); +} + +function childToHttpRequestNode( + resource: StructureNode, + plugin: AngularCommonPlugin['Instance'], +): ReadonlyArray> { + const refChild = plugin.referenceSymbol(createHttpRequestShellMeta(resource)); + const memberNameStr = toCase(refChild.name, 'camelCase'); + const memberName = plugin.symbol(memberNameStr); + if (plugin.isSymbolRegistered(refChild.id)) { + return [$.field(memberName, (f) => f.static().assign($(refChild)))]; + } + return [ + $.getter(memberName, (g) => g.public().static().do($.return(refChild))), + ]; +} + +function childToHttpResourceNode( + resource: StructureNode, + plugin: AngularCommonPlugin['Instance'], +): ReadonlyArray> { + const refChild = plugin.referenceSymbol( + createHttpResourceShellMeta(resource), + ); + const memberNameStr = toCase(refChild.name, 'camelCase'); + const memberName = plugin.symbol(memberNameStr); + if (plugin.isSymbolRegistered(refChild.id)) { + return [$.field(memberName, (f) => f.static().assign($(refChild)))]; + } + return [ + $.getter(memberName, (g) => g.public().static().do($.return(refChild))), + ]; +} + +export function createHttpRequestShell( + plugin: AngularCommonPlugin['Instance'], +): StructureShell { + const client = getClientPlugin(plugin.context.config); + const isAngularClient = client.name === '@hey-api/client-angular'; + + const symbolInjectable = plugin.external('@angular/core.Injectable'); + + return { + define: (node) => { + const symbol = plugin.symbol( + applyNaming( + node.name, + node.isRoot + ? plugin.config.httpRequests.containerName + : plugin.config.httpRequests.segmentName, + ), + { + meta: createHttpRequestShellMeta(node), + }, + ); + + const c = $.class(symbol) + .export() + .$if(isAngularClient && node.isRoot, (c) => + c.decorator( + symbolInjectable, + $.object().prop('providedIn', $.literal('root')), + ), + ); + + return { dependencies: [], node: c }; + }, + }; +} + +export function createHttpResourceShell( + plugin: AngularCommonPlugin['Instance'], +): StructureShell { + const symbolInjectable = plugin.external('@angular/core.Injectable'); + + return { + define: (node) => { + const symbol = plugin.symbol( + applyNaming( + node.name, + node.isRoot + ? plugin.config.httpResources.containerName + : plugin.config.httpResources.segmentName, + ), + { + meta: createHttpResourceShellMeta(node), + }, + ); + + const c = $.class(symbol) + .export() + .$if(node.isRoot, (c) => + c.decorator( + symbolInjectable, + $.object().prop('providedIn', $.literal('root')), + ), + ); + + return { dependencies: [], node: c }; + }, + }; +} + +function implementHttpRequestFn< + T extends ReturnType, +>(args: { + node: T; + operation: IR.OperationObject; + plugin: AngularCommonPlugin['Instance']; +}): T { + const { node, operation, plugin } = args; + const isRequiredOptions = isOperationOptionsRequired({ + context: plugin.context, + operation, + }); + + const symbolHttpRequest = plugin.external('@angular/common/http.HttpRequest'); + const symbolClient = plugin.getSymbol({ category: 'client' }); + const symbolOptions = plugin.referenceSymbol({ + category: 'type', + resource: 'client-options', + tool: 'sdk', + }); + const symbolDataType = plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'data', + tool: 'typescript', + }); + + return node + .param('options', (p) => + p.required(isRequiredOptions).type( + $.type(symbolOptions) + .generic(symbolDataType ?? 'unknown') + .generic('ThrowOnError'), + ), + ) + .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) + .returns($.type(symbolHttpRequest).generic('unknown')) + .do( + $.return( + $('options') + .attr('client') + .optional() + .$if(symbolClient, (c, s) => c.coalesce(s)) + .attr('requestOptions') + .call( + $.object() + .prop('responseStyle', $.literal('data')) + .prop('method', $.literal(operation.method.toUpperCase())) + .prop('url', $.literal(operation.path)) + .spread('options'), + ), + ), + ) as T; +} + +function implementHttpResourceFn< + T extends ReturnType, +>(args: { + node: T; + operation: IR.OperationObject; + plugin: AngularCommonPlugin['Instance']; +}): T { + const { node, operation, plugin } = args; + const isRequiredOptions = isOperationOptionsRequired({ + context: plugin.context, + operation, + }); + + const symbolHttpResource = plugin.external( + '@angular/common/http.httpResource', + ); + const symbolOptions = plugin.referenceSymbol({ + category: 'type', + resource: 'client-options', + tool: 'sdk', + }); + const symbolDataType = plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'data', + tool: 'typescript', + }); + const symbolResponseType = plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'response', + }); + + return node + .param('options', (p) => + p.required(isRequiredOptions).type( + $.type.func().returns( + $.type.or( + $.type(symbolOptions) + .generic(symbolDataType ?? 'unknown') + .generic('ThrowOnError'), + $.type('undefined'), + ), + ), + ), + ) + .generic('ThrowOnError', (g) => g.extends('boolean').default(false)) + .do( + $.return( + $(symbolHttpResource) + .call( + $.func().do( + $.const('opts').assign( + $.ternary('options') + .do($('options').call()) + .otherwise($.id('undefined')), + ), + $.return( + $.ternary('opts') + .do( + $.lazy((ctx) => + ctx + .access( + plugin.referenceSymbol( + createHttpRequestFnMeta(operation), + ), + ) + .call('opts'), + ), + ) + .otherwise($.id('undefined')), + ), + ), + ) + .generic(symbolResponseType ?? 'unknown'), + ), + ) as T; +} + +export function toHttpRequestNode( + model: StructureNode, + plugin: AngularCommonPlugin['Instance'], +): { + dependencies?: Array>; + nodes: ReadonlyArray>; +} { + if (model.virtual) { + const nodes: Array> = []; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + let node = $.const(createHttpRequestFnSymbol(plugin, item)) + .export() + .assign( + implementHttpRequestFn({ + node: $.func(), + operation, + plugin, + }), + ); + node = attachComment({ node, operation }); + nodes.push(node); + } + return { nodes }; + } + + if (!model.shell) { + return { nodes: [] }; + } + + const nodes: Array> = []; + + const shell = model.shell.define(model); + const node = shell.node as ReturnType; + + let index = 0; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + if (index > 0 || node.hasBody) node.newline(); + node.do( + implementHttpRequestFn({ + node: $.method(createHttpRequestFnSymbol(plugin, item), (m) => + attachComment({ + node: m, + operation, + }) + .public() + .static(), + ), + operation, + plugin, + }), + ); + index += 1; + } + + for (const child of model.children.values()) { + if (node.hasBody) node.newline(); + node.do(...childToHttpRequestNode(child, plugin)); + } + + nodes.push(node); + + return { + dependencies: shell.dependencies as Array>, + nodes, + }; +} + +export function toHttpResourceNode( + model: StructureNode, + plugin: AngularCommonPlugin['Instance'], +): { + dependencies?: Array>; + nodes: ReadonlyArray>; +} { + if (model.virtual) { + const nodes: Array> = []; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + let node = $.const(createHttpResourceFnSymbol(plugin, item)) + .export() + .assign( + implementHttpResourceFn({ + node: $.func(), + operation, + plugin, + }), + ); + node = attachComment({ node, operation }); + nodes.push(node); + } + return { nodes }; + } + + if (!model.shell) { + return { nodes: [] }; + } + + const nodes: Array> = []; + + const shell = model.shell.define(model); + const node = shell.node as ReturnType; + + let index = 0; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + if (index > 0 || node.hasBody) node.newline(); + node.do( + implementHttpResourceFn({ + node: $.method(createHttpResourceFnSymbol(plugin, item), (m) => + attachComment({ + node: m, + operation, + }) + .public() + .static(), + ), + operation, + plugin, + }), + ); + index += 1; + } + + for (const child of model.children.values()) { + if (node.hasBody) node.newline(); + node.do(...childToHttpResourceNode(child, plugin)); + } + + nodes.push(node); + + return { + dependencies: shell.dependencies as Array>, + nodes, + }; +} diff --git a/packages/openapi-ts/src/plugins/@angular/common/types.d.ts b/packages/openapi-ts/src/plugins/@angular/common/types.d.ts index d304523aa..d64dd666c 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/types.d.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/types.d.ts @@ -1,5 +1,14 @@ +import type { OperationsStrategy } from '~/openApi/shared/locations'; import type { DefinePlugin, Plugin } from '~/plugins'; -import type { NameTransformer } from '~/utils/naming'; + +import type { + HttpRequestsConfig, + UserHttpRequestsConfig, +} from './httpRequests'; +import type { + HttpResourcesConfig, + UserHttpResourcesConfig, +} from './httpResources'; export type UserConfig = Plugin.Name<'@angular/common'> & Plugin.Hooks & { @@ -13,66 +22,15 @@ export type UserConfig = Plugin.Name<'@angular/common'> & /** * Options for generating HTTP Request instances. * - * @default true + * @default 'flat' */ - httpRequests?: - | boolean - | { - /** - * Whether to generate the resource as a class. - * - * @default false - */ - asClass?: boolean; - /** - * Builds the class name for the generated resource. - * By default, the class name is suffixed with "Resources". - */ - classNameBuilder?: NameTransformer; - /** - * Whether or not to create HTTP Request instances. - * - * @default true - */ - enabled?: boolean; - /** - * Builds the method name for the generated resource. - * - * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource". - */ - methodNameBuilder?: (operation: IR.OperationObject) => string; - }; + httpRequests?: boolean | OperationsStrategy | UserHttpRequestsConfig; /** * Options for generating HTTP resource APIs. * - * @default true + * @default 'flat' */ - httpResources?: - | boolean - | { - /** - * Whether to generate the resource as a class. - * @default false - */ - asClass?: boolean; - /** - * Builds the class name for the generated resource. - * By default, the class name is suffixed with "Resources". - */ - classNameBuilder?: NameTransformer; - /** - * Whether or not to create HTTP resource APIs. - * - * @default true - */ - enabled?: boolean; - /** - * Builds the method name for the generated resource. - * - * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource". - */ - methodNameBuilder?: (operation: IR.OperationObject) => string; - }; + httpResources?: boolean | OperationsStrategy | UserHttpResourcesConfig; }; export type Config = Plugin.Name<'@angular/common'> & @@ -87,57 +45,11 @@ export type Config = Plugin.Name<'@angular/common'> & /** * Options for generating HTTP Request instances. */ - httpRequests: { - /** - * Whether to generate the resource as a class. - * - * @default false - */ - asClass: boolean; - /** - * Builds the class name for the generated resource. - * By default, the class name is suffixed with "Resources". - */ - classNameBuilder: NameTransformer; - /** - * Whether or not to create HTTP Request instances. - * - * @default true - */ - enabled: boolean; - /** - * Builds the method name for the generated resource. - * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource". - */ - methodNameBuilder: (operation: IR.OperationObject) => string; - }; + httpRequests: HttpRequestsConfig; /** * Options for generating HTTP resource APIs. */ - httpResources: { - /** - * Whether to generate the resource as a class. - * - * @default false - */ - asClass: boolean; - /** - * Builds the class name for the generated resource. - * By default, the class name is suffixed with "Resources". - */ - classNameBuilder: NameTransformer; - /** - * Whether or not to create HTTP resource APIs. - * - * @default true - */ - enabled: boolean; - /** - * Builds the method name for the generated resource. - * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource". - */ - methodNameBuilder: (operation: IR.OperationObject) => string; - }; + httpResources: HttpResourcesConfig; }; export type AngularCommonPlugin = DefinePlugin; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/config.ts index 1e711bc1a..fdaec7bc0 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/config.ts @@ -1,13 +1,10 @@ import { log } from '@hey-api/codegen-core'; +import type { OperationsStrategy } from '~/openApi/shared/locations'; import type { PluginContext } from '~/plugins/types'; import type { UserConfig } from '../types'; -import type { - OperationsConfig, - OperationsStrategy, - UserOperationsConfig, -} from './types'; +import type { OperationsConfig, UserOperationsConfig } from './types'; type Config = Omit; @@ -151,14 +148,6 @@ function normalizeConfig( } function mapLegacyToConfig(config: Config): Partial { - // TODO: refactor... - // Set default classNameBuilder based on client type - if (config.classNameBuilder === '{{name}}') { - if (config.client === '@hey-api/client-angular') { - config.classNameBuilder = '{{name}}Service'; - } - } - let strategy: OperationsConfig['strategy'] | undefined; if (config.instance) { strategy = 'single'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/index.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/index.ts index a9f588fd0..a472cd1ec 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/index.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/index.ts @@ -1,6 +1,3 @@ export { resolveOperations } from './config'; -export type { - OperationsConfig, - OperationsStrategy, - UserOperationsConfig, -} from './types'; +export { resolveStrategy } from './resolve'; +export type { OperationsConfig, UserOperationsConfig } from './types'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/types.d.ts index 76abe54ad..935f77b90 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/operations/types.d.ts @@ -1,15 +1,9 @@ import type { OperationPathStrategy, - OperationStructureStrategy, + OperationsStrategy, } from '~/openApi/shared/locations'; import type { NamingConfig, NamingRule } from '~/utils/naming'; -export type OperationsStrategy = - | 'byTags' - | 'flat' - | 'single' - | OperationStructureStrategy; - export interface UserOperationsConfig { /** * Type of container for grouped operations. @@ -17,13 +11,14 @@ export interface UserOperationsConfig { * Ignored when `strategy` is `'flat'`. * * - `'class'` - Class with methods - * - `'object'` - Plain object literal * * @default 'class' */ - container?: 'class' | 'object'; + container?: 'class'; + // * - `'object'` - Plain object literal + // container?: 'class' | 'object'; /** - * Customize container names (class or object variable names). + * Customize container names. * * For `'single'` strategy, this sets the root container name. * For `'byTags'` strategy, this transforms tag names. @@ -40,7 +35,7 @@ export interface UserOperationsConfig { * * @example * // With casing - * containerName: { name: '{{name}}Service', case: 'PascalCase' } + * containerName: { name: '{{name}}Service', casing: 'PascalCase' } */ containerName?: NamingRule; /** @@ -114,11 +109,12 @@ export interface OperationsConfig { * Ignored when `strategy` is `'flat'`. * * - `'class'` - Class with methods - * - `'object'` - Plain object literal */ - container: 'class' | 'object'; + container: 'class'; + // * - `'object'` - Plain object literal + // container: 'class' | 'object'; /** - * Customize container names (class or object variable names). + * Customize container names. * * For `'single'` strategy, this sets the root container name. * For `'byTags'` strategy, this transforms tag names. diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts index 19365f72d..b43f5a41a 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts @@ -3,140 +3,17 @@ import { refs } from '@hey-api/codegen-core'; import { statusCodeToGroup } from '~/ir/operation'; import type { IR } from '~/ir/types'; -import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; import { $ } from '~/ts-dsl'; -import { toCase } from '~/utils/naming'; import type { Field, Fields } from '../../client-core/bundle/params'; import type { HeyApiSdkPlugin } from '../types'; -import { isInstance } from '../v1/plugin'; +import { isInstance } from '../v1/node'; import { operationAuth } from './auth'; import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; import { getSignatureParameters } from './signature'; import { createRequestValidator, createResponseValidator } from './validator'; -interface ClassNameEntry { - /** - * Name of the class where this function appears. - */ - className: string; - /** - * Name of the function within the class. - */ - methodName: string; - /** - * JSONPath-like array to class location. - */ - path: ReadonlyArray; -} - -/** - * @deprecated - */ -const operationClassName = ({ - plugin, - value, -}: { - plugin: HeyApiSdkPlugin['Instance']; - value: string; -}) => { - // TODO: expose casing option - const name = toCase(value, 'PascalCase'); - return ( - // @ts-expect-error - (typeof plugin.config.classNameBuilder === 'string' - ? // @ts-expect-error - plugin.config.classNameBuilder.replace('{{name}}', name) - : // @ts-expect-error - plugin.config.classNameBuilder(name)) || name - ); -}; - -/** - * @deprecated - */ -const operationMethodName = ({ - operation, - plugin, - value, -}: { - operation: IR.OperationObject; - plugin: HeyApiSdkPlugin['Instance']; - value?: string; -}) => { - // TODO: expose casing option - const name = toCase(value || operation.id, 'camelCase'); - return ( - // @ts-expect-error - (typeof plugin.config.methodNameBuilder === 'string' - ? // @ts-expect-error - plugin.config.methodNameBuilder.replace('{{name}}', name) - : // @ts-expect-error TODO: remove - plugin.config.methodNameBuilder?.(name, operation)) || name - ); -}; - -/** - * Returns a list of classes where this operation appears in the generated SDK. - * - * @deprecated - */ -export const operationClasses = ({ - operation, - plugin, -}: { - operation: IR.OperationObject; - plugin: HeyApiSdkPlugin['Instance']; -}): Map => { - const classNames = new Map(); - - let className: string | undefined; - let methodName: string | undefined; - let classCandidates: Array = []; - - // @ts-expect-error - if (plugin.config.classStructure === 'auto' && operation.operationId) { - classCandidates = operation.operationId.split(/[./]/).filter(Boolean); - if (classCandidates.length >= 2) { - const methodCandidate = classCandidates.pop()!; - methodName = toCase( - sanitizeNamespaceIdentifier(methodCandidate), - 'camelCase', - ); - className = classCandidates.pop()!; - } - } - - // @ts-expect-error - const rootClasses = plugin.config.instance - ? // @ts-expect-error - [plugin.config.instance] - : (operation.tags ?? ['default']); - - for (const rootClass of rootClasses) { - // Default path - let path = [rootClass]; - if (className) { - // If root class is already within classCandidates or the same as className - // do not add it again as this will cause a recursion issue. - if (classCandidates.includes(rootClass) || rootClass === className) { - path = [...classCandidates, className]; - } else { - path = [rootClass, ...classCandidates, className]; - } - } - - classNames.set(rootClass, { - className: operationClassName({ plugin, value: className || rootClass }), - methodName: methodName || operationMethodName({ operation, plugin }), - path: path.map((value) => operationClassName({ plugin, value })), - }); - } - - return classNames; -}; - /** TODO: needs complete refactor */ export const operationOptionsType = ({ isDataAllowed = true, diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/typeOptions.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/typeOptions.ts index b7c20bc3e..034ed45bb 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/typeOptions.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/typeOptions.ts @@ -3,7 +3,7 @@ import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; import { $ } from '~/ts-dsl'; import type { HeyApiSdkPlugin } from '../types'; -import { isInstance } from '../v1/plugin'; +import { isInstance } from '../v1/node'; import { nuxtTypeDefault, nuxtTypeResponse } from './constants'; export const createTypeOptions = ({ diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts index 00504200e..00575088d 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts @@ -1,12 +1,9 @@ +import type { OperationsStrategy } from '~/openApi/shared/locations'; import type { DefinePlugin, Plugin } from '~/plugins'; import type { PluginClientNames, PluginValidatorNames } from '~/plugins/types'; import type { NameTransformer } from '~/utils/naming'; -import type { - OperationsConfig, - OperationsStrategy, - UserOperationsConfig, -} from './operations'; +import type { OperationsConfig, UserOperationsConfig } from './operations'; export type UserConfig = Plugin.Name<'@hey-api/sdk'> & Plugin.Hooks & { diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/node.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/node.ts new file mode 100644 index 000000000..fabbc0406 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/node.ts @@ -0,0 +1,380 @@ +import type { + StructureItem, + StructureNode, + StructureShell, + Symbol, + SymbolMeta, +} from '@hey-api/codegen-core'; + +import type { IR } from '~/ir/types'; +import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; +import { + createOperationComment, + isOperationOptionsRequired, +} from '~/plugins/shared/utils/operation'; +import { $ } from '~/ts-dsl'; +import { applyNaming, toCase } from '~/utils/naming'; + +import { createClientClass, createRegistryClass } from '../shared/class'; +import { nuxtTypeComposable, nuxtTypeDefault } from '../shared/constants'; +import { operationParameters, operationStatements } from '../shared/operation'; +import type { HeyApiSdkPlugin } from '../types'; + +export interface OperationItem { + operation: IR.OperationObject; + path: ReadonlyArray; + tags: ReadonlyArray | undefined; +} + +export const source = globalThis.Symbol('@hey-api/sdk'); + +export function isInstance(plugin: HeyApiSdkPlugin['Instance']): boolean { + const config = plugin.config.operations; + return ( + config.container === 'class' && + config.methods === 'instance' && + config.strategy !== 'flat' + ); +} + +function attachComment< + T extends ReturnType, +>(args: { node: T; operation: IR.OperationObject }): T { + const { node, operation } = args; + return node.$if(createOperationComment(operation), (n, v) => n.doc(v)) as T; +} + +function createShellMeta(node: StructureNode): SymbolMeta { + return { + category: 'utility', + resource: 'class', + resourceId: node.getPath().join('.'), + tool: 'sdk', + }; +} + +function createFnSymbol( + plugin: HeyApiSdkPlugin['Instance'], + item: StructureItem & { data: OperationItem }, +): Symbol { + const { operation, path, tags } = item.data; + const name = item.location[item.location.length - 1]!; + return plugin.symbol(applyNaming(name, plugin.config.operations.methodName), { + meta: { + category: 'sdk', + path, + resource: 'operation', + resourceId: operation.id, + tags, + tool: 'sdk', + }, + }); +} + +function childToNode( + resource: StructureNode, + plugin: HeyApiSdkPlugin['Instance'], +): ReadonlyArray> { + const refChild = plugin.referenceSymbol(createShellMeta(resource)); + const memberNameStr = toCase(refChild.name, 'camelCase'); + const memberName = plugin.symbol(memberNameStr); + if (isInstance(plugin)) { + const privateName = plugin.symbol(`_${memberNameStr}`); + return [ + $.field(privateName, (f) => f.private().optional().type(refChild)), + $.getter(memberName, (g) => + g.returns(refChild).do( + $('this') + .attr(privateName) + .nullishAssign( + $.new(refChild).args( + $.object().prop('client', $('this').attr('client')), + ), + ) + .return(), + ), + ), + ]; + } + if (plugin.isSymbolRegistered(refChild.id)) { + return [$.field(memberName, (f) => f.static().assign($(refChild)))]; + } + return [ + $.getter(memberName, (g) => g.public().static().do($.return(refChild))), + ]; +} + +export function createShell( + plugin: HeyApiSdkPlugin['Instance'], +): StructureShell { + const client = getClientPlugin(plugin.context.config); + const isAngularClient = client.name === '@hey-api/client-angular'; + return { + define: (node) => { + const symbol = plugin.symbol( + applyNaming( + node.name, + node.isRoot + ? plugin.config.operations.containerName + : plugin.config.operations.segmentName, + ), + { + meta: createShellMeta(node), + }, + ); + + // if (plugin.config.operations.container === 'object') { + // const o = $.const(symbol).export().assign($.object()); + // return { dependencies: [], node: o }; + // } + + const c = $.class(symbol) + .export() + .$if(isInstance(plugin), (c) => + c.extends( + plugin.referenceSymbol({ + category: 'utility', + resource: 'class', + resourceId: 'HeyApiClient', + tool: 'sdk', + }), + ), + ) + .$if(isAngularClient && node.isRoot, (c) => + c.decorator( + plugin.external('@angular/core.Injectable'), + $.object().prop('providedIn', $.literal('root')), + ), + ); + + const dependencies: Array> = []; + + if (node.isRoot && isInstance(plugin)) { + enrichRootClass({ + dependencies, + node: c, + plugin, + symbol, + }); + } + + return { dependencies, node: c }; + }, + }; +} + +function enrichRootClass(args: { + dependencies: Array>; + node: ReturnType; + plugin: HeyApiSdkPlugin['Instance']; + symbol: Symbol; +}): void { + const { dependencies, node, plugin, symbol } = args; + const symbolClient = plugin.symbol('HeyApiClient', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiClient', + tool: 'sdk', + }, + }); + dependencies.push(createClientClass({ plugin, symbol: symbolClient })); + const symbolRegistry = plugin.symbol('HeyApiRegistry', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiRegistry', + tool: 'sdk', + }, + }); + dependencies.push( + createRegistryClass({ + plugin, + sdkSymbol: symbol, + symbol: symbolRegistry, + }), + ); + const isClientRequired = + !plugin.config.client || !plugin.getSymbol({ category: 'client' }); + const registry = plugin.symbol('__registry'); + node.toAccessNode = (node, options) => { + if (options.context) return; + return $(node.name).attr(registry).attr('get').call(); + }; + node.do( + $.field(registry, (f) => + f + .public() + .static() + .readonly() + .assign($.new(symbolRegistry).generic(symbol)), + ), + $.newline(), + $.init((i) => + i + .param('args', (p) => + p.required(isClientRequired).type( + $.type + .object() + .prop('client', (p) => + p + .required(isClientRequired) + .type(plugin.external('client.Client')), + ) + .prop('key', (p) => p.optional().type('string')), + ), + ) + .do( + $('super').call('args'), + $(symbol) + .attr(registry) + .attr('set') + .call('this', $('args').attr('key').required(isClientRequired)), + ), + ), + ); +} + +function implementFn< + T extends ReturnType, +>(args: { + node: T; + operation: IR.OperationObject; + plugin: HeyApiSdkPlugin['Instance']; +}): T { + const { node, operation, plugin } = args; + const client = getClientPlugin(plugin.context.config); + const isNuxtClient = client.name === '@hey-api/client-nuxt'; + const isRequiredOptions = isOperationOptionsRequired({ + context: plugin.context, + operation, + }); + const opParameters = operationParameters({ + isRequiredOptions, + operation, + plugin, + }); + const statements = operationStatements({ + isRequiredOptions, + opParameters, + operation, + plugin, + }); + return node + .$if( + isNuxtClient, + (m) => + m + .generic(nuxtTypeComposable, (t) => + t + .extends(plugin.external('client.Composable')) + .default($.type.literal('$fetch')), + ) + .generic(nuxtTypeDefault, (t) => + t.$if( + plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'response', + }), + (t, s) => t.extends(s).default(s), + (t) => t.default('undefined'), + ), + ), + (m) => + m.generic('ThrowOnError', (t) => + t + .extends('boolean') + .default( + ('throwOnError' in client.config + ? client.config.throwOnError + : false) ?? false, + ), + ), + ) + .params(...opParameters.parameters) + .do(...statements) as T; +} + +export function toNode( + model: StructureNode, + plugin: HeyApiSdkPlugin['Instance'], +): { + dependencies?: Array>; + nodes: ReadonlyArray>; +} { + if (model.virtual) { + const nodes: Array> = []; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + let node = $.const(createFnSymbol(plugin, item)) + .export() + .assign( + implementFn({ + node: $.func(), + operation, + plugin, + }), + ); + node = attachComment({ node, operation }); + nodes.push(node); + } + return { nodes }; + } + + if (!model.shell) { + return { nodes: [] }; + } + + const nodes: Array> = []; + + const client = getClientPlugin(plugin.context.config); + const isAngularClient = client.name === '@hey-api/client-angular'; + + const shell = model.shell.define(model); + const node = shell.node as ReturnType; + + let index = 0; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + if (node['~dsl'] === 'VarTsDsl') { + // TODO: object + } else { + if (index > 0 || node.hasBody) node.newline(); + node.do( + implementFn({ + node: $.method(createFnSymbol(plugin, item), (m) => + attachComment({ + node: m, + operation, + }) + .public() + .static(!isAngularClient && !isInstance(plugin)), + ), + operation, + plugin, + }), + ); + } + index += 1; + } + + for (const child of model.children.values()) { + if (node['~dsl'] === 'VarTsDsl') { + // TODO: object + } else { + if (node.hasBody) node.newline(); + node.do(...childToNode(child, plugin)); + } + } + + nodes.push(node); + + return { + dependencies: shell.dependencies as Array< + ReturnType + >, + nodes, + }; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/plugin.ts index 967170cc4..6e9ca55a3 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/v1/plugin.ts @@ -1,383 +1,14 @@ -import type { - StructureItem, - StructureNode, - StructureShell, - Symbol, -} from '@hey-api/codegen-core'; import { StructureModel } from '@hey-api/codegen-core'; import { clientFolderAbsolutePath } from '~/generate/client'; -import type { IR } from '~/ir/types'; import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; -import { - createOperationComment, - isOperationOptionsRequired, -} from '~/plugins/shared/utils/operation'; -import { $ } from '~/ts-dsl'; -import { applyNaming, toCase } from '~/utils/naming'; +import type { $ } from '~/ts-dsl'; -import { resolveStrategy } from '../operations/resolve'; -import { createClientClass, createRegistryClass } from '../shared/class'; -import { nuxtTypeComposable, nuxtTypeDefault } from '../shared/constants'; -import { operationParameters, operationStatements } from '../shared/operation'; +import { resolveStrategy } from '../operations'; import { createTypeOptions } from '../shared/typeOptions'; import type { HeyApiSdkPlugin } from '../types'; - -interface OperationItem { - operation: IR.OperationObject; - path: ReadonlyArray; - tags: ReadonlyArray | undefined; -} - -const source = globalThis.Symbol('@hey-api/sdk'); - -function attachComment< - T extends ReturnType, ->(args: { node: T; operation: IR.OperationObject }): T { - const { node, operation } = args; - return node.$if(createOperationComment(operation), (m, v) => m.doc(v)) as T; -} - -function createFnSymbol( - plugin: HeyApiSdkPlugin['Instance'], - item: StructureItem & { data: OperationItem }, -): Symbol { - const { operation, path, tags } = item.data; - const name = item.location[item.location.length - 1]!; - return plugin.symbol(applyNaming(name, plugin.config.operations.methodName), { - meta: { - category: 'sdk', - path, - resource: 'operation', - resourceId: operation.id, - tags, - tool: 'sdk', - }, - }); -} - -function childToNode( - resource: StructureNode, - plugin: HeyApiSdkPlugin['Instance'], -): ReadonlyArray> { - const refChild = plugin.referenceSymbol({ - category: 'utility', - resource: 'class', - resourceId: resource.getPath().join('.'), - tool: 'sdk', - }); - const memberNameStr = toCase(refChild.name, 'camelCase'); - const memberName = plugin.symbol(memberNameStr); - if (isInstance(plugin)) { - const privateName = plugin.symbol(`_${memberNameStr}`); - return [ - $.field(privateName, (f) => f.private().optional().type(refChild)), - $.getter(memberName, (g) => - g.returns(refChild).do( - $('this') - .attr(privateName) - .nullishAssign( - $.new(refChild).args( - $.object().prop('client', $('this').attr('client')), - ), - ) - .return(), - ), - ), - ]; - } - if (plugin.isSymbolRegistered(refChild.id)) { - return [$.field(memberName, (f) => f.static().assign($(refChild)))]; - } - return [ - $.getter(memberName, (g) => g.public().static().do($.return(refChild))), - ]; -} - -function enrichRootClass(args: { - dependencies: Array>; - node: ReturnType; - plugin: HeyApiSdkPlugin['Instance']; - symbol: Symbol; -}): void { - const { dependencies, node, plugin, symbol } = args; - const symbolClient = plugin.symbol('HeyApiClient', { - meta: { - category: 'utility', - resource: 'class', - resourceId: 'HeyApiClient', - tool: 'sdk', - }, - }); - dependencies.push(createClientClass({ plugin, symbol: symbolClient })); - const symbolRegistry = plugin.symbol('HeyApiRegistry', { - meta: { - category: 'utility', - resource: 'class', - resourceId: 'HeyApiRegistry', - tool: 'sdk', - }, - }); - dependencies.push( - createRegistryClass({ - plugin, - sdkSymbol: symbol, - symbol: symbolRegistry, - }), - ); - const isClientRequired = - !plugin.config.client || !plugin.getSymbol({ category: 'client' }); - const registry = plugin.symbol('__registry'); - node.toAccessNode = (node, options) => { - if (options.context) return; - return $(node.name).attr(registry).attr('get').call(); - }; - node.do( - $.field(registry, (f) => - f - .public() - .static() - .readonly() - .assign($.new(symbolRegistry).generic(symbol)), - ), - $.newline(), - $.init((i) => - i - .param('args', (p) => - p.required(isClientRequired).type( - $.type - .object() - .prop('client', (p) => - p.required(isClientRequired).type( - plugin.referenceSymbol({ - category: 'external', - resource: 'client.Client', - }), - ), - ) - .prop('key', (p) => p.optional().type('string')), - ), - ) - .do( - $('super').call('args'), - $(symbol) - .attr(registry) - .attr('set') - .call('this', $('args').attr('key').required(isClientRequired)), - ), - ), - ); -} - -function implementFn< - T extends ReturnType, ->(args: { - node: T; - operation: IR.OperationObject; - plugin: HeyApiSdkPlugin['Instance']; -}): T { - const { node, operation, plugin } = args; - const client = getClientPlugin(plugin.context.config); - const isNuxtClient = client.name === '@hey-api/client-nuxt'; - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - const opParameters = operationParameters({ - isRequiredOptions, - operation, - plugin, - }); - const statements = operationStatements({ - isRequiredOptions, - opParameters, - operation, - plugin, - }); - return node - .$if( - isNuxtClient, - (m) => - m - .generic(nuxtTypeComposable, (t) => - t - .extends( - plugin.referenceSymbol({ - category: 'external', - resource: 'client.Composable', - }), - ) - .default($.type.literal('$fetch')), - ) - .generic(nuxtTypeDefault, (t) => - t.$if( - plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'response', - }), - (t, s) => t.extends(s).default(s), - (t) => t.default('undefined'), - ), - ), - (m) => - m.generic('ThrowOnError', (t) => - t - .extends('boolean') - .default( - ('throwOnError' in client.config - ? client.config.throwOnError - : false) ?? false, - ), - ), - ) - .params(...opParameters.parameters) - .do(...statements) as T; -} - -/** - * Converts this class group to a class node. - */ -function toNode( - model: StructureNode, - plugin: HeyApiSdkPlugin['Instance'], -): { - dependencies?: Array>; - nodes: ReadonlyArray>; -} { - if (model.virtual) { - const nodes: Array> = []; - for (const item of model.itemsFrom(source)) { - const { operation } = item.data; - let node = $.const(createFnSymbol(plugin, item)) - .export() - .assign( - implementFn({ - node: $.func(), - operation, - plugin, - }), - ); - node = attachComment({ node, operation }); - nodes.push(node); - } - return { nodes }; - } - - if (!model.shell) { - return { nodes: [] }; - } - - const nodes: Array> = []; - - const client = getClientPlugin(plugin.context.config); - const isAngularClient = client.name === '@hey-api/client-angular'; - - const shell = model.shell.define(model); - const node = shell.node as ReturnType; - - let index = 0; - for (const item of model.itemsFrom(source)) { - const { operation } = item.data; - if (index > 0 || node.hasBody) node.newline(); - node.do( - implementFn({ - node: $.method(createFnSymbol(plugin, item), (m) => - attachComment({ - node: m, - operation, - }) - .public() - .static(!isAngularClient && !isInstance(plugin)), - ), - operation, - plugin, - }), - ); - index += 1; - } - - for (const child of model.children.values()) { - if (node.hasBody) node.newline(); - node.do(...childToNode(child, plugin)); - } - - nodes.push(node); - - return { - dependencies: shell.dependencies as Array>, - nodes, - }; -} - -function createShell(plugin: HeyApiSdkPlugin['Instance']): StructureShell { - const client = getClientPlugin(plugin.context.config); - const isAngularClient = client.name === '@hey-api/client-angular'; - return { - define: (node) => { - const symbol = plugin.symbol( - applyNaming( - node.name, - node.isRoot - ? plugin.config.operations.containerName - : plugin.config.operations.segmentName, - ), - { - meta: { - category: 'utility', - resource: 'class', - resourceId: node.getPath().join('.'), - tool: 'sdk', - }, - }, - ); - const c = $.class(symbol) - .export() - .$if(isInstance(plugin), (c) => - c.extends( - plugin.referenceSymbol({ - category: 'utility', - resource: 'class', - resourceId: 'HeyApiClient', - tool: 'sdk', - }), - ), - ) - .$if(isAngularClient && node.isRoot, (c) => - c.decorator( - plugin.referenceSymbol({ - category: 'external', - resource: '@angular/core.Injectable', - }), - $.object().prop('providedIn', $.literal('root')), - ), - ); - - const dependencies: Array> = []; - - if (node.isRoot && isInstance(plugin)) { - enrichRootClass({ - dependencies, - node: c, - plugin, - symbol, - }); - } - - return { dependencies, node: c }; - }, - }; -} - -export function isInstance(plugin: HeyApiSdkPlugin['Instance']): boolean { - const config = plugin.config.operations; - return ( - config.container === 'class' && - config.methods === 'instance' && - config.strategy !== 'flat' - ); -} +import type { OperationItem } from './node'; +import { createShell, source, toNode } from './node'; export const handlerV1: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { const clientModule = clientFolderAbsolutePath(plugin.context.config); @@ -456,7 +87,7 @@ export const handlerV1: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { { order: 'declarations' }, ); - const allDependencies: Array> = []; + const allDependencies: Array> = []; const allNodes: Array> = []; for (const node of structure.walk()) { @@ -465,7 +96,10 @@ export const handlerV1: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { allNodes.push(...nodes); } - const uniqueDependencies = new Map>(); + const uniqueDependencies = new Map< + number, + ReturnType + >(); for (const dep of allDependencies) { if (dep.symbol) uniqueDependencies.set(dep.symbol.id, dep); } diff --git a/packages/openapi-ts/src/plugins/shared/utils/operation.ts b/packages/openapi-ts/src/plugins/shared/utils/operation.ts index cbb41c72f..d625c882c 100644 --- a/packages/openapi-ts/src/plugins/shared/utils/operation.ts +++ b/packages/openapi-ts/src/plugins/shared/utils/operation.ts @@ -3,7 +3,7 @@ import { hasOperationDataRequired } from '~/ir/operation'; import type { IR } from '~/ir/types'; import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; import type { HeyApiSdkPlugin } from '~/plugins/@hey-api/sdk'; -import { isInstance } from '~/plugins/@hey-api/sdk/v1/plugin'; +import { isInstance } from '~/plugins/@hey-api/sdk/v1/node'; import { escapeComment } from '~/utils/escape'; export const createOperationComment = ( diff --git a/packages/openapi-ts/src/ts-dsl/utils/context.ts b/packages/openapi-ts/src/ts-dsl/utils/context.ts index 5f31054e2..ebbd0e360 100644 --- a/packages/openapi-ts/src/ts-dsl/utils/context.ts +++ b/packages/openapi-ts/src/ts-dsl/utils/context.ts @@ -12,6 +12,8 @@ export type NodeChain = ReadonlyArray; export interface AccessOptions { /** The access context. */ context?: 'example'; + /** Enable debug mode. */ + debug?: boolean; } export type AccessResult = ReturnType< @@ -52,9 +54,10 @@ function getAccessChainForNode(node: TsDsl): NodeChain { const structuralChain = [...getStructuralChainForNode(node, new Set())]; const accessChain = structuralToAccessChain(structuralChain); if (accessChain.length === 0) { - throw new Error( - `Cannot build access chain for node ${node['~dsl']} (${node.name.toString()})`, - ); + // I _think_ this should not happen, but it does and this fix works for now. + // I assume this will cause issues with imports in some cases, investigate + // when it actually happens. + return [node.clone()]; } return accessChain.map((node) => node.clone()); } -- 2.54.0