From 1a01aa51c688e03324893e2385d317f99a492ab1 Mon Sep 17 00:00:00 2001 From: Lubos Date: Tue, 8 Apr 2025 16:19:29 +0100 Subject: [PATCH 1/4] feat: add sdk.params option --- .changeset/olive-buckets-doubt.md | 5 + .../test/openapi-ts.config.ts | 3 +- .../src/plugins/@hey-api/sdk/config.ts | 1 + .../src/plugins/@hey-api/sdk/params.ts | 110 ++++++++++++++++++ .../src/plugins/@hey-api/sdk/plugin.ts | 78 +++---------- .../src/plugins/@hey-api/sdk/typeOptions.ts | 40 +++++++ .../src/plugins/@hey-api/sdk/types.d.ts | 6 + .../src/plugins/@hey-api/typescript/plugin.ts | 3 + .../plugins/@tanstack/query-core/useType.ts | 2 +- 9 files changed, 185 insertions(+), 63 deletions(-) create mode 100644 .changeset/olive-buckets-doubt.md create mode 100644 packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts diff --git a/.changeset/olive-buckets-doubt.md b/.changeset/olive-buckets-doubt.md new file mode 100644 index 000000000..5eda12644 --- /dev/null +++ b/.changeset/olive-buckets-doubt.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix: make data types of never "required" so they don't accept undefined diff --git a/packages/openapi-ts-tests/test/openapi-ts.config.ts b/packages/openapi-ts-tests/test/openapi-ts.config.ts index 4667e3ba7..efb1dbe18 100644 --- a/packages/openapi-ts-tests/test/openapi-ts.config.ts +++ b/packages/openapi-ts-tests/test/openapi-ts.config.ts @@ -85,8 +85,9 @@ export default defineConfig(() => { // auth: false, // client: false, // include... - // name: '@hey-api/sdk', + name: '@hey-api/sdk', // operationId: false, + params: 'flattened', // serviceNameBuilder: '^Parameters', // throwOnError: true, // transformer: '@hey-api/transformers', diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts index 6ed457744..6618df0b5 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts @@ -46,6 +46,7 @@ export const defaultConfig: Plugin.Config = { name: '@hey-api/sdk', operationId: true, output: 'sdk', + params: 'namespaced', response: 'body', serviceNameBuilder: '{{name}}Service', }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts new file mode 100644 index 000000000..51672b64f --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts @@ -0,0 +1,110 @@ +import type { FunctionParameter } from '../../../compiler'; +import { clientApi } from '../../../generate/client'; +import type { TypeScriptFile } from '../../../generate/files'; +import { hasOperationDataRequired } from '../../../ir/operation'; +import type { IR } from '../../../ir/types'; +import type { Plugin } from '../../types'; +import { getClientPlugin } from '../client-core/utils'; +import { + importIdentifierData, + importIdentifierResponse, +} from '../typescript/ref'; +import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; +import type { Config } from './types'; + +export const operationOptionsType = ({ + context, + file, + operation, + throwOnError, + transformData, +}: { + context: IR.Context; + file: TypeScriptFile; + operation: IR.OperationObject; + throwOnError?: string; + transformData?: (name: string) => string; +}) => { + const identifierData = importIdentifierData({ context, file, operation }); + const identifierResponse = importIdentifierResponse({ + context, + file, + operation, + }); + + const optionsName = clientApi.Options.name; + + const finalData = (name: string) => + transformData ? transformData(name) : name; + + const client = getClientPlugin(context.config); + if (client.name === '@hey-api/client-nuxt') { + return `${optionsName}<${nuxtTypeComposable}, ${identifierData.name ? finalData(identifierData.name) : 'unknown'}, ${identifierResponse.name || 'unknown'}, ${nuxtTypeDefault}>`; + } + + // TODO: refactor this to be more generic, works for now + if (throwOnError) { + return `${optionsName}<${identifierData.name ? finalData(identifierData.name) : 'unknown'}, ${throwOnError}>`; + } + return identifierData.name + ? `${optionsName}<${finalData(identifierData.name)}>` + : optionsName; +}; + +export const createParameters = ({ + context, + file, + operation, + plugin, +}: { + context: IR.Context; + file: TypeScriptFile; + operation: IR.OperationObject; + plugin: Plugin.Instance; +}): Array | undefined => { + const client = getClientPlugin(context.config); + const isNuxtClient = client.name === '@hey-api/client-nuxt'; + + const parameters: Array = []; + + if (plugin.params === 'flattened') { + const identifierData = importIdentifierData({ context, file, operation }); + + if (identifierData.name) { + parameters.push({ + isRequired: hasOperationDataRequired(operation), + name: 'params', + type: `OmitNever>`, + }); + } + + parameters.push({ + isRequired: !plugin.client || isNuxtClient, + name: 'options', + type: operationOptionsType({ + context, + file, + operation, + throwOnError: isNuxtClient ? undefined : 'ThrowOnError', + transformData: (name) => `Pick<${name}, 'url'>`, + }), + }); + } + + if (plugin.params === 'namespaced') { + const isRequiredOptions = + !plugin.client || isNuxtClient || hasOperationDataRequired(operation); + parameters.push({ + isRequired: isRequiredOptions, + name: 'options', + type: operationOptionsType({ + context, + file, + operation, + throwOnError: isNuxtClient ? undefined : 'ThrowOnError', + }), + }); + } + + return parameters.length ? parameters : undefined; +}; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts index 20f972ec6..dcff31cde 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts @@ -3,7 +3,6 @@ import type ts from 'typescript'; import { compiler } from '../../../compiler'; import type { ObjectValue } from '../../../compiler/types'; import { clientApi, clientModulePath } from '../../../generate/client'; -import type { TypeScriptFile } from '../../../generate/files'; import { hasOperationDataRequired, statusCodeToGroup, @@ -21,13 +20,13 @@ import { transformersId, } from '../transformers/plugin'; import { - importIdentifierData, importIdentifierError, importIdentifierResponse, } from '../typescript/ref'; import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; +import { createParameters } from './params'; import { serviceFunctionIdentifier } from './plugin-legacy'; -import { createTypeOptions } from './typeOptions'; +import { createTypeOmitNever, createTypeOptions } from './typeOptions'; import type { Config } from './types'; // copy-pasted from @hey-api/client-core @@ -48,40 +47,6 @@ export interface Auth { type: 'apiKey' | 'http'; } -export const operationOptionsType = ({ - context, - file, - operation, - throwOnError, -}: { - context: IR.Context; - file: TypeScriptFile; - operation: IR.OperationObject; - throwOnError?: string; -}) => { - const identifierData = importIdentifierData({ context, file, operation }); - const identifierResponse = importIdentifierResponse({ - context, - file, - operation, - }); - - const optionsName = clientApi.Options.name; - - const client = getClientPlugin(context.config); - if (client.name === '@hey-api/client-nuxt') { - return `${optionsName}<${nuxtTypeComposable}, ${identifierData.name || 'unknown'}, ${identifierResponse.name || 'unknown'}, ${nuxtTypeDefault}>`; - } - - // TODO: refactor this to be more generic, works for now - if (throwOnError) { - return `${optionsName}<${identifierData.name || 'unknown'}, ${throwOnError}>`; - } - return identifierData.name - ? `${optionsName}<${identifierData.name}>` - : optionsName; -}; - export const sdkId = 'sdk'; /** @@ -552,18 +517,12 @@ const generateClassSdk = ({ id: operation.id, operation, }), - parameters: [ - { - isRequired: isRequiredOptions, - name: 'options', - type: operationOptionsType({ - context, - file, - operation, - throwOnError: isNuxtClient ? undefined : 'ThrowOnError', - }), - }, - ], + parameters: createParameters({ + context, + file, + operation, + plugin, + }), returnType: undefined, statements: operationStatements({ context, @@ -658,18 +617,12 @@ const generateFlatSdk = ({ ], exportConst: true, expression: compiler.arrowFunction({ - parameters: [ - { - isRequired: isRequiredOptions, - name: 'options', - type: operationOptionsType({ - context, - file, - operation, - throwOnError: isNuxtClient ? undefined : 'ThrowOnError', - }), - }, - ], + parameters: createParameters({ + context, + file, + operation, + plugin, + }), returnType: undefined, statements: operationStatements({ context, @@ -752,6 +705,9 @@ export const handler: Plugin.Handler = ({ context, plugin }) => { context, plugin, }); + createTypeOmitNever({ + context, + }); if (plugin.asClass) { generateClassSdk({ context, plugin }); diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts index 5bc20ace8..f0b25b043 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts @@ -1,3 +1,5 @@ +import ts from 'typescript'; + import { compiler } from '../../../compiler'; import { clientModulePath } from '../../../generate/client'; import type { FileImportResult } from '../../../generate/files'; @@ -131,3 +133,41 @@ export const createTypeOptions = ({ file.add(typeOptions); }; + +export const createTypeOmitNever = ({ context }: { context: IR.Context }) => { + const file = context.file({ id: sdkId })!; + + const neverType = compiler.keywordTypeNode({ keyword: 'never' }); + const kType = compiler.typeReferenceNode({ typeName: 'K' }); + const tType = compiler.typeReferenceNode({ typeName: 'T' }); + const kOfTType = compiler.indexedAccessTypeNode({ + indexType: kType, + objectType: tType, + }); + + const omitNeverTypeAlias = compiler.typeAliasDeclaration({ + exportType: true, + name: 'OmitNever', + type: ts.factory.createMappedTypeNode( + undefined, + ts.factory.createTypeParameterDeclaration( + undefined, + 'K', + ts.factory.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, tType), + undefined, + ), + ts.factory.createConditionalTypeNode( + kOfTType, + neverType, + neverType, + kType, + ), + undefined, + kOfTType, + undefined, + ), + typeParameters: [{ name: 'T' }], + }); + + file.add(omitNeverTypeAlias); +}; 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 bc39044d7..4e5d7658e 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 @@ -69,6 +69,12 @@ export interface Config extends Plugin.Name<'@hey-api/sdk'> { * @default 'sdk' */ output?: string; + /** + * TODO + * + * @default 'namespaced' + */ + params?: 'flattened' | 'namespaced'; /** * Customize the generated service class names. The name variable is * obtained from your OpenAPI specification tags. diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts index 2fad8c081..bf47505b9 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts @@ -852,6 +852,7 @@ const operationToDataType = ({ data.properties.body = { type: 'never', }; + dataRequired.push('body'); } // TODO: parser - handle cookie parameters @@ -879,6 +880,7 @@ const operationToDataType = ({ data.properties.path = { type: 'never', }; + dataRequired.push('path'); } if (operation.parameters?.query) { @@ -893,6 +895,7 @@ const operationToDataType = ({ data.properties.query = { type: 'never', }; + dataRequired.push('query'); } data.properties.url = { diff --git a/packages/openapi-ts/src/plugins/@tanstack/query-core/useType.ts b/packages/openapi-ts/src/plugins/@tanstack/query-core/useType.ts index 932a9a18d..cac055361 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/query-core/useType.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/query-core/useType.ts @@ -1,7 +1,7 @@ import type { ImportExportItemObject } from '../../../compiler/utils'; import type { IR } from '../../../ir/types'; import { getClientPlugin } from '../../@hey-api/client-core/utils'; -import { operationOptionsType } from '../../@hey-api/sdk/plugin'; +import { operationOptionsType } from '../../@hey-api/sdk/params'; import { importIdentifierError, importIdentifierResponse, From bd7185c44d5f80cad5ee7ebc0bcb02cc4898bd7d Mon Sep 17 00:00:00 2001 From: Lubos Date: Sat, 12 Apr 2025 16:16:21 +0100 Subject: [PATCH 2/4] feat: export buildClientParams function --- .changeset/twenty-numbers-talk.md | 10 + packages/client-axios/src/index.ts | 1 + .../client-core/src/__tests__/params.test.ts | 313 ++++++++++++++++++ packages/client-core/src/index.ts | 1 + packages/client-core/src/params.ts | 141 ++++++++ packages/client-custom/src/index.ts | 1 + packages/client-fetch/src/index.ts | 1 + packages/client-next/src/index.ts | 1 + packages/client-nuxt/src/index.ts | 1 + 9 files changed, 470 insertions(+) create mode 100644 .changeset/twenty-numbers-talk.md create mode 100644 packages/client-core/src/__tests__/params.test.ts create mode 100644 packages/client-core/src/params.ts diff --git a/.changeset/twenty-numbers-talk.md b/.changeset/twenty-numbers-talk.md new file mode 100644 index 000000000..5d50ae59d --- /dev/null +++ b/.changeset/twenty-numbers-talk.md @@ -0,0 +1,10 @@ +--- +'@hey-api/client-custom': minor +'@hey-api/client-axios': minor +'@hey-api/client-fetch': minor +'@hey-api/client-core': minor +'@hey-api/client-next': minor +'@hey-api/client-nuxt': minor +--- + +feat: export buildClientParams function diff --git a/packages/client-axios/src/index.ts b/packages/client-axios/src/index.ts index d871b459b..75a87064a 100644 --- a/packages/client-axios/src/index.ts +++ b/packages/client-axios/src/index.ts @@ -13,6 +13,7 @@ export type { export { createConfig } from './utils'; export type { Auth, QuerySerializerOptions } from '@hey-api/client-core'; export { + buildClientParams, formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, diff --git a/packages/client-core/src/__tests__/params.test.ts b/packages/client-core/src/__tests__/params.test.ts new file mode 100644 index 000000000..91a9b0c49 --- /dev/null +++ b/packages/client-core/src/__tests__/params.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; + +import type { Config } from '../params'; +import { buildClientParams } from '../params'; + +describe('buildClientParams', () => { + const scenarios: ReadonlyArray<{ + args: ReadonlyArray; + config: Config; + description: string; + params: Record; + }> = [ + { + args: [1, 2, 3, 4], + config: [ + { + in: 'path', + key: 'foo', + }, + { + in: 'query', + key: 'bar', + }, + { + in: 'headers', + key: 'baz', + }, + { + in: 'body', + key: 'qux', + }, + ], + description: 'positional arguments', + params: { + body: { + qux: 4, + }, + headers: { + baz: 3, + }, + path: { + foo: 1, + }, + query: { + bar: 2, + }, + }, + }, + { + args: [ + { + bar: 2, + baz: 3, + foo: 1, + qux: 4, + }, + ], + config: [ + { + args: [ + { + in: 'path', + key: 'foo', + }, + { + in: 'query', + key: 'bar', + }, + { + in: 'headers', + key: 'baz', + }, + { + in: 'body', + key: 'qux', + }, + ], + }, + ], + description: 'flat arguments', + params: { + body: { + qux: 4, + }, + headers: { + baz: 3, + }, + path: { + foo: 1, + }, + query: { + bar: 2, + }, + }, + }, + { + args: [ + 1, + 2, + { + baz: 3, + qux: 4, + }, + ], + config: [ + { + in: 'path', + key: 'foo', + }, + { + in: 'query', + key: 'bar', + }, + { + args: [ + { + in: 'headers', + key: 'baz', + }, + { + in: 'body', + key: 'qux', + }, + ], + }, + ], + description: 'mixed arguments', + params: { + body: { + qux: 4, + }, + headers: { + baz: 3, + }, + path: { + foo: 1, + }, + query: { + bar: 2, + }, + }, + }, + { + args: [1, 2, 3, 4], + config: [ + { + in: 'path', + key: 'foo', + map: 'f_o_o', + }, + { + in: 'query', + key: 'bar', + map: 'b_a_r', + }, + { + in: 'headers', + key: 'baz', + map: 'b_a_z', + }, + { + in: 'body', + key: 'qux', + map: 'q_u_x', + }, + ], + description: 'positional mapped arguments', + params: { + body: { + q_u_x: 4, + }, + headers: { + b_a_z: 3, + }, + path: { + f_o_o: 1, + }, + query: { + b_a_r: 2, + }, + }, + }, + { + args: [ + { + bar: 2, + baz: 3, + foo: 1, + qux: 4, + }, + ], + config: [ + { + args: [ + { + in: 'path', + key: 'foo', + map: 'f_o_o', + }, + { + in: 'query', + key: 'bar', + map: 'b_a_r', + }, + { + in: 'headers', + key: 'baz', + map: 'b_a_z', + }, + { + in: 'body', + key: 'qux', + map: 'q_u_x', + }, + ], + }, + ], + description: 'flat mapped arguments', + params: { + body: { + q_u_x: 4, + }, + headers: { + b_a_z: 3, + }, + path: { + f_o_o: 1, + }, + query: { + b_a_r: 2, + }, + }, + }, + { + args: [1], + config: [ + { + in: 'body', + }, + ], + description: 'positional primitive body', + params: { + body: 1, + }, + }, + { + args: [ + { + $body_qux: 4, + $headers_baz: 3, + $path_foo: 1, + $query_bar: 2, + }, + ], + config: [ + { + allowExtra: {}, + }, + ], + description: 'namespace extra arguments', + params: { + body: { + qux: 4, + }, + headers: { + baz: 3, + }, + path: { + foo: 1, + }, + query: { + bar: 2, + }, + }, + }, + { + args: [ + { + bar: 2, + baz: 3, + foo: 1, + qux: 4, + }, + ], + config: [ + { + allowExtra: { + query: true, + }, + }, + ], + description: 'slot extra arguments', + params: { + query: { + bar: 2, + baz: 3, + foo: 1, + qux: 4, + }, + }, + }, + { + args: [], + config: [], + description: 'strip empty slots', + params: {}, + }, + ]; + + it.each(scenarios)('$description', async ({ args, config, params }) => { + expect(buildClientParams(args, config)).toEqual(params); + }); +}); diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts index fb079d362..938a1fc8f 100644 --- a/packages/client-core/src/index.ts +++ b/packages/client-core/src/index.ts @@ -10,6 +10,7 @@ export { jsonBodySerializer, urlSearchParamsBodySerializer, } from './bodySerializer'; +export { buildClientParams } from './params'; export type { ArraySeparatorStyle, ArrayStyle, diff --git a/packages/client-core/src/params.ts b/packages/client-core/src/params.ts new file mode 100644 index 000000000..5910b6946 --- /dev/null +++ b/packages/client-core/src/params.ts @@ -0,0 +1,141 @@ +type Slot = 'body' | 'headers' | 'path' | 'query'; + +type Field = + | { + in: Exclude; + key: string; + map?: string; + } + | { + in: Extract; + key?: string; + map?: string; + }; + +interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type Config = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (configs: Config, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of configs) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + configs: Config, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(configs); + + let config: Config[number] | undefined; + + for (const [i, arg] of args.entries()) { + if (configs[i]) { + config = configs[i]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/packages/client-custom/src/index.ts b/packages/client-custom/src/index.ts index d871b459b..75a87064a 100644 --- a/packages/client-custom/src/index.ts +++ b/packages/client-custom/src/index.ts @@ -13,6 +13,7 @@ export type { export { createConfig } from './utils'; export type { Auth, QuerySerializerOptions } from '@hey-api/client-core'; export { + buildClientParams, formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, diff --git a/packages/client-fetch/src/index.ts b/packages/client-fetch/src/index.ts index d871b459b..75a87064a 100644 --- a/packages/client-fetch/src/index.ts +++ b/packages/client-fetch/src/index.ts @@ -13,6 +13,7 @@ export type { export { createConfig } from './utils'; export type { Auth, QuerySerializerOptions } from '@hey-api/client-core'; export { + buildClientParams, formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, diff --git a/packages/client-next/src/index.ts b/packages/client-next/src/index.ts index d871b459b..75a87064a 100644 --- a/packages/client-next/src/index.ts +++ b/packages/client-next/src/index.ts @@ -13,6 +13,7 @@ export type { export { createConfig } from './utils'; export type { Auth, QuerySerializerOptions } from '@hey-api/client-core'; export { + buildClientParams, formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, diff --git a/packages/client-nuxt/src/index.ts b/packages/client-nuxt/src/index.ts index 6f8f537b5..b58b46c33 100644 --- a/packages/client-nuxt/src/index.ts +++ b/packages/client-nuxt/src/index.ts @@ -14,6 +14,7 @@ export type { export { createConfig } from './utils'; export type { Auth, QuerySerializerOptions } from '@hey-api/client-core'; export { + buildClientParams, formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, From b345d9bf3a8cded8a509dcb46c573c0257bac113 Mon Sep 17 00:00:00 2001 From: Lubos Date: Sat, 12 Apr 2025 19:08:30 +0100 Subject: [PATCH 3/4] chore: separate params from options --- packages/client-core/src/params.ts | 6 +- packages/client-fetch/src/index.ts | 2 + packages/client-fetch/src/types.ts | 6 + packages/openapi-ts/package.json | 1 + .../src/plugins/@hey-api/sdk/constants.ts | 2 + .../src/plugins/@hey-api/sdk/params.ts | 6 +- .../src/plugins/@hey-api/sdk/plugin.ts | 523 +----------------- .../src/plugins/@hey-api/sdk/response.ts | 53 ++ .../src/plugins/@hey-api/sdk/statements.ts | 490 ++++++++++++++++ .../src/plugins/@hey-api/sdk/typeOptions.ts | 43 +- .../plugins/@tanstack/query-core/plugin.ts | 2 +- pnpm-lock.yaml | 19 +- 12 files changed, 601 insertions(+), 552 deletions(-) create mode 100644 packages/openapi-ts/src/plugins/@hey-api/sdk/response.ts create mode 100644 packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts diff --git a/packages/client-core/src/params.ts b/packages/client-core/src/params.ts index 5910b6946..b730952ee 100644 --- a/packages/client-core/src/params.ts +++ b/packages/client-core/src/params.ts @@ -86,9 +86,9 @@ export const buildClientParams = ( let config: Config[number] | undefined; - for (const [i, arg] of args.entries()) { - if (configs[i]) { - config = configs[i]; + for (const [index, arg] of args.entries()) { + if (configs[index]) { + config = configs[index]; } if (!config) { diff --git a/packages/client-fetch/src/index.ts b/packages/client-fetch/src/index.ts index 75a87064a..68835bd6f 100644 --- a/packages/client-fetch/src/index.ts +++ b/packages/client-fetch/src/index.ts @@ -4,8 +4,10 @@ export type { ClientOptions, Config, CreateClientConfig, + OmitNever, Options, OptionsLegacyParser, + Params, RequestOptions, RequestResult, TDataShape, diff --git a/packages/client-fetch/src/types.ts b/packages/client-fetch/src/types.ts index 60346fb89..2c3cae95c 100644 --- a/packages/client-fetch/src/types.ts +++ b/packages/client-fetch/src/types.ts @@ -164,3 +164,9 @@ export type OptionsLegacyParser< TData & Pick, 'body'> : OmitKeys, 'url'> & TData; + +export type OmitNever = { + [K in keyof T as T[K] extends never ? never : K]: T[K]; +}; + +export type Params = OmitNever>; diff --git a/packages/openapi-ts/package.json b/packages/openapi-ts/package.json index aa2e0fbdf..55bbe2db3 100644 --- a/packages/openapi-ts/package.json +++ b/packages/openapi-ts/package.json @@ -87,6 +87,7 @@ "typescript": "^5.5.3" }, "devDependencies": { + "@hey-api/client-core": "workspace:*", "@types/cross-spawn": "6.0.6", "@types/express": "4.17.21", "axios": "1.8.2", diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/constants.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/constants.ts index 5b5567560..e9da8dc83 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/constants.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/constants.ts @@ -1,3 +1,5 @@ export const nuxtTypeComposable = 'TComposable'; export const nuxtTypeDefault = 'DefaultT'; export const nuxtTypeResponse = 'ResT'; + +export const sdkId = 'sdk'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts index 51672b64f..05457b8c6 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts @@ -61,7 +61,7 @@ export const createParameters = ({ file: TypeScriptFile; operation: IR.OperationObject; plugin: Plugin.Instance; -}): Array | undefined => { +}): Array => { const client = getClientPlugin(context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; @@ -74,7 +74,7 @@ export const createParameters = ({ parameters.push({ isRequired: hasOperationDataRequired(operation), name: 'params', - type: `OmitNever>`, + type: `Params<${identifierData.name}>`, }); } @@ -106,5 +106,5 @@ export const createParameters = ({ }); } - return parameters.length ? parameters : undefined; + return parameters; }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts index dcff31cde..f4c36d835 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts @@ -1,488 +1,21 @@ import type ts from 'typescript'; import { compiler } from '../../../compiler'; -import type { ObjectValue } from '../../../compiler/types'; import { clientApi, clientModulePath } from '../../../generate/client'; -import { - hasOperationDataRequired, - statusCodeToGroup, -} from '../../../ir/operation'; import type { IR } from '../../../ir/types'; import { escapeComment } from '../../../utils/escape'; import { getServiceName } from '../../../utils/postprocess'; import { transformServiceName } from '../../../utils/transform'; -import { operationIrRef } from '../../shared/utils/ref'; import type { Plugin } from '../../types'; -import { zodId } from '../../zod/plugin'; -import { clientId, getClientPlugin } from '../client-core/utils'; -import { - operationTransformerIrRef, - transformersId, -} from '../transformers/plugin'; -import { - importIdentifierError, - importIdentifierResponse, -} from '../typescript/ref'; -import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; +import { getClientPlugin } from '../client-core/utils'; +import { importIdentifierResponse } from '../typescript/ref'; +import { nuxtTypeComposable, nuxtTypeDefault, sdkId } from './constants'; import { createParameters } from './params'; import { serviceFunctionIdentifier } from './plugin-legacy'; -import { createTypeOmitNever, createTypeOptions } from './typeOptions'; +import { createStatements } from './statements'; +import { createTypeOptions } from './typeOptions'; import type { Config } from './types'; -// copy-pasted from @hey-api/client-core -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const sdkId = 'sdk'; - -/** - * Infers `responseType` value from provided response content type. This is - * an adapted version of `getParseAs()` from the Fetch API client. - * - * From Axios documentation: - * `responseType` indicates the type of data that the server will respond with - * options are: 'arraybuffer', 'document', 'json', 'text', 'stream' - * browser only: 'blob' - */ -export const getResponseType = ( - contentType: string | null | undefined, -): - | 'arraybuffer' - | 'blob' - | 'document' - | 'json' - | 'stream' - | 'text' - | undefined => { - if (!contentType) { - return; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if ( - cleanContent.startsWith('application/json') || - cleanContent.endsWith('+json') - ) { - return 'json'; - } - - // Axios does not handle form data out of the box - // if (cleanContent === 'multipart/form-data') { - // return 'formData'; - // } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => - cleanContent.startsWith(type), - ) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } -}; - -// TODO: parser - handle more security types -const securitySchemeObjectToAuthObject = ({ - securitySchemeObject, -}: { - securitySchemeObject: IR.SecurityObject; -}): Auth | undefined => { - if (securitySchemeObject.type === 'openIdConnect') { - return { - scheme: 'bearer', - type: 'http', - }; - } - - if (securitySchemeObject.type === 'oauth2') { - if ( - securitySchemeObject.flows.password || - securitySchemeObject.flows.authorizationCode || - securitySchemeObject.flows.clientCredentials || - securitySchemeObject.flows.implicit - ) { - return { - scheme: 'bearer', - type: 'http', - }; - } - - return; - } - - if (securitySchemeObject.type === 'apiKey') { - if (securitySchemeObject.in === 'header') { - return { - name: securitySchemeObject.name, - type: 'apiKey', - }; - } - - if ( - securitySchemeObject.in === 'query' || - securitySchemeObject.in == 'cookie' - ) { - return { - in: securitySchemeObject.in, - name: securitySchemeObject.name, - type: 'apiKey', - }; - } - - return; - } - - if (securitySchemeObject.type === 'http') { - const scheme = securitySchemeObject.scheme.toLowerCase(); - if (scheme === 'bearer' || scheme === 'basic') { - return { - scheme: scheme as 'bearer' | 'basic', - type: 'http', - }; - } - - return; - } -}; - -const operationAuth = ({ - operation, - plugin, -}: { - context: IR.Context; - operation: IR.OperationObject; - plugin: Plugin.Instance; -}): Array => { - if (!operation.security || !plugin.auth) { - return []; - } - - const auth: Array = []; - - for (const securitySchemeObject of operation.security) { - const authObject = securitySchemeObjectToAuthObject({ - securitySchemeObject, - }); - if (authObject) { - auth.push(authObject); - } else { - console.warn( - `❗️ SDK warning: unsupported security scheme. Please open an issue if you'd like it added https://github.com/hey-api/openapi-ts/issues\n${JSON.stringify(securitySchemeObject, null, 2)}`, - ); - } - } - - return auth; -}; - -const operationStatements = ({ - context, - isRequiredOptions, - operation, - plugin, -}: { - context: IR.Context; - isRequiredOptions: boolean; - operation: IR.OperationObject; - plugin: Plugin.Instance; -}): Array => { - const file = context.file({ id: sdkId })!; - const sdkOutput = file.nameWithoutExtension(); - - const identifierError = importIdentifierError({ context, file, operation }); - const identifierResponse = importIdentifierResponse({ - context, - file, - operation, - }); - - // TODO: transform parameters - // const query = { - // BarBaz: options.query.bar_baz, - // qux_quux: options.query.qux_quux, - // fooBar: options.query.foo_bar, - // }; - - // if (operation.parameters) { - // for (const name in operation.parameters.query) { - // const parameter = operation.parameters.query[name] - // if (parameter.name !== fieldName({ context, name: parameter.name })) { - // console.warn(parameter.name) - // } - // } - // } - - const requestOptions: ObjectValue[] = []; - - if (operation.body) { - switch (operation.body.type) { - case 'form-data': - requestOptions.push({ spread: 'formDataBodySerializer' }); - file.import({ - module: clientModulePath({ - config: context.config, - sourceOutput: sdkOutput, - }), - name: 'formDataBodySerializer', - }); - break; - case 'json': - // jsonBodySerializer is the default, no need to specify - break; - case 'text': - // ensure we don't use any serializer by default - requestOptions.push({ - key: 'bodySerializer', - value: null, - }); - break; - case 'url-search-params': - requestOptions.push({ spread: 'urlSearchParamsBodySerializer' }); - file.import({ - module: clientModulePath({ - config: context.config, - sourceOutput: sdkOutput, - }), - name: 'urlSearchParamsBodySerializer', - }); - break; - } - } - - const client = getClientPlugin(context.config); - if (client.name === '@hey-api/client-axios') { - // try to infer `responseType` option for Axios. We don't need this in - // Fetch API client because it automatically detects the correct response - // during runtime. - for (const statusCode in operation.responses) { - // this doesn't handle default status code for now - if (statusCodeToGroup({ statusCode }) === '2XX') { - const response = operation.responses[statusCode]; - const responseType = getResponseType(response?.mediaType); - // json is the default, skip it - if (responseType && responseType !== 'json') { - requestOptions.push({ - key: 'responseType', - value: responseType, - }); - } - } - } - } - - // TODO: parser - set parseAs to skip inference if every response has the same - // content type. currently impossible because successes do not contain - // header information - - const auth = operationAuth({ context, operation, plugin }); - if (auth.length) { - requestOptions.push({ - key: 'security', - value: compiler.arrayLiteralExpression({ elements: auth }), - }); - } - - for (const name in operation.parameters?.query) { - const parameter = operation.parameters.query[name]!; - if ( - (parameter.schema.type === 'array' || - parameter.schema.type === 'tuple') && - (parameter.style !== 'form' || !parameter.explode) - ) { - // override the default settings for `querySerializer` - requestOptions.push({ - key: 'querySerializer', - value: [ - { - key: 'array', - value: [ - { - key: 'explode', - value: false, - }, - { - key: 'style', - value: 'form', - }, - ], - }, - ], - }); - break; - } - } - - if (plugin.transformer === '@hey-api/transformers') { - const identifierTransformer = context - .file({ id: transformersId })! - .identifier({ - $ref: operationTransformerIrRef({ id: operation.id, type: 'response' }), - namespace: 'value', - }); - - if (identifierTransformer.name) { - file.import({ - module: file.relativePathToFile({ - context, - id: transformersId, - }), - name: identifierTransformer.name, - }); - - requestOptions.push({ - key: 'responseTransformer', - value: identifierTransformer.name, - }); - } - } - - if (plugin.validator === 'zod') { - const identifierSchema = context.file({ id: zodId })!.identifier({ - $ref: operationIrRef({ - case: 'camelCase', - id: operation.id, - type: 'response', - }), - namespace: 'value', - }); - - if (identifierSchema.name) { - file.import({ - module: file.relativePathToFile({ - context, - id: zodId, - }), - name: identifierSchema.name, - }); - - requestOptions.push({ - key: 'responseValidator', - value: compiler.arrowFunction({ - async: true, - parameters: [ - { - name: 'data', - }, - ], - statements: [ - compiler.returnStatement({ - expression: compiler.awaitExpression({ - expression: compiler.callExpression({ - functionName: compiler.propertyAccessExpression({ - expression: compiler.identifier({ - text: identifierSchema.name, - }), - name: compiler.identifier({ text: 'parseAsync' }), - }), - parameters: [compiler.identifier({ text: 'data' })], - }), - }), - }), - ], - }), - }); - } - } - - requestOptions.push({ - key: 'url', - value: operation.path, - }); - - // options must go last to allow overriding parameters above - requestOptions.push({ spread: 'options' }); - if (operation.body) { - requestOptions.push({ - key: 'headers', - value: [ - { - key: 'Content-Type', - // form-data does not need Content-Type header, browser will set it automatically - value: - operation.body.type === 'form-data' - ? null - : operation.body.mediaType, - }, - { - spread: 'options?.headers', - }, - ], - }); - } - - const isNuxtClient = client.name === '@hey-api/client-nuxt'; - const responseType = identifierResponse.name || 'unknown'; - const errorType = identifierError.name || 'unknown'; - - const heyApiClient = plugin.client - ? file.import({ - alias: '_heyApiClient', - module: file.relativePathToFile({ - context, - id: clientId, - }), - name: 'client', - }) - : undefined; - - const optionsClient = compiler.propertyAccessExpression({ - expression: compiler.identifier({ text: 'options' }), - isOptional: !isRequiredOptions, - name: 'client', - }); - - return [ - compiler.returnFunctionCall({ - args: [ - compiler.objectExpression({ - identifiers: ['responseTransformer'], - obj: requestOptions, - }), - ], - name: compiler.propertyAccessExpression({ - expression: heyApiClient?.name - ? compiler.binaryExpression({ - left: optionsClient, - operator: '??', - right: compiler.identifier({ text: heyApiClient.name }), - }) - : optionsClient, - name: compiler.identifier({ text: operation.method }), - }), - types: isNuxtClient - ? [ - nuxtTypeComposable, - `${responseType} | ${nuxtTypeDefault}`, - errorType, - nuxtTypeDefault, - ] - : [responseType, errorType, 'ThrowOnError'], - }), - ]; -}; - const generateClassSdk = ({ context, plugin, @@ -496,13 +29,17 @@ const generateClassSdk = ({ const sdks = new Map>(); context.subscribe('operation', ({ operation }) => { - const isRequiredOptions = - !plugin.client || isNuxtClient || hasOperationDataRequired(operation); const identifierResponse = importIdentifierResponse({ context, file, operation, }); + const parameters = createParameters({ + context, + file, + operation, + plugin, + }); const node = compiler.methodDeclaration({ accessLevel: 'public', comment: [ @@ -517,17 +54,12 @@ const generateClassSdk = ({ id: operation.id, operation, }), - parameters: createParameters({ - context, - file, - operation, - plugin, - }), + parameters, returnType: undefined, - statements: operationStatements({ + statements: createStatements({ context, - isRequiredOptions, operation, + parameters, plugin, }), types: isNuxtClient @@ -602,13 +134,17 @@ const generateFlatSdk = ({ const file = context.file({ id: sdkId })!; context.subscribe('operation', ({ operation }) => { - const isRequiredOptions = - !plugin.client || isNuxtClient || hasOperationDataRequired(operation); const identifierResponse = importIdentifierResponse({ context, file, operation, }); + const parameters = createParameters({ + context, + file, + operation, + plugin, + }); const node = compiler.constVariable({ comment: [ operation.deprecated && '@deprecated', @@ -617,17 +153,12 @@ const generateFlatSdk = ({ ], exportConst: true, expression: compiler.arrowFunction({ - parameters: createParameters({ - context, - file, - operation, - plugin, - }), + parameters, returnType: undefined, - statements: operationStatements({ + statements: createStatements({ context, - isRequiredOptions, operation, + parameters, plugin, }), types: isNuxtClient @@ -689,6 +220,11 @@ export const handler: Plugin.Handler = ({ context, plugin }) => { alias: 'ClientOptions', module: clientModule, }); + file.import({ + asType: true, + module: clientModule, + name: 'Params', + }); const client = getClientPlugin(context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; @@ -705,9 +241,6 @@ export const handler: Plugin.Handler = ({ context, plugin }) => { context, plugin, }); - createTypeOmitNever({ - context, - }); if (plugin.asClass) { generateClassSdk({ context, plugin }); diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/response.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/response.ts new file mode 100644 index 000000000..624f32a81 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/response.ts @@ -0,0 +1,53 @@ +/** + * Infers `responseType` value from provided response content type. This is + * an adapted version of `getParseAs()` from the Fetch API client. + * + * From Axios documentation: + * `responseType` indicates the type of data that the server will respond with + * options are: 'arraybuffer', 'document', 'json', 'text', 'stream' + * browser only: 'blob' + */ +export const getResponseType = ( + contentType: string | null | undefined, +): + | 'arraybuffer' + | 'blob' + | 'document' + | 'json' + | 'stream' + | 'text' + | undefined => { + if (!contentType) { + return; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + // Axios does not handle form data out of the box + // if (cleanContent === 'multipart/form-data') { + // return 'formData'; + // } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } +}; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts new file mode 100644 index 000000000..a3040ae3e --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts @@ -0,0 +1,490 @@ +import type { Auth } from '@hey-api/client-core'; +import type ts from 'typescript'; + +import { compiler } from '../../../compiler'; +import type { FunctionParameter, ObjectValue } from '../../../compiler/types'; +import { clientModulePath } from '../../../generate/client'; +import { statusCodeToGroup } from '../../../ir/operation'; +import type { IR } from '../../../ir/types'; +import { operationIrRef } from '../../shared/utils/ref'; +import type { Plugin } from '../../types'; +import { zodId } from '../../zod/plugin'; +import { clientId, getClientPlugin } from '../client-core/utils'; +import { + operationTransformerIrRef, + transformersId, +} from '../transformers/plugin'; +import { + importIdentifierError, + importIdentifierResponse, +} from '../typescript/ref'; +import { nuxtTypeComposable, nuxtTypeDefault, sdkId } from './constants'; +import { getResponseType } from './response'; +import type { Config } from './types'; + +// TODO: parser - handle more security types +const securitySchemeObjectToAuthObject = ({ + securitySchemeObject, +}: { + securitySchemeObject: IR.SecurityObject; +}): Auth | undefined => { + if (securitySchemeObject.type === 'openIdConnect') { + return { + scheme: 'bearer', + type: 'http', + }; + } + + if (securitySchemeObject.type === 'oauth2') { + if ( + securitySchemeObject.flows.password || + securitySchemeObject.flows.authorizationCode || + securitySchemeObject.flows.clientCredentials || + securitySchemeObject.flows.implicit + ) { + return { + scheme: 'bearer', + type: 'http', + }; + } + + return; + } + + if (securitySchemeObject.type === 'apiKey') { + if (securitySchemeObject.in === 'header') { + return { + name: securitySchemeObject.name, + type: 'apiKey', + }; + } + + if ( + securitySchemeObject.in === 'query' || + securitySchemeObject.in == 'cookie' + ) { + return { + in: securitySchemeObject.in, + name: securitySchemeObject.name, + type: 'apiKey', + }; + } + + return; + } + + if (securitySchemeObject.type === 'http') { + const scheme = securitySchemeObject.scheme.toLowerCase(); + if (scheme === 'bearer' || scheme === 'basic') { + return { + scheme: scheme as 'bearer' | 'basic', + type: 'http', + }; + } + + return; + } +}; + +const operationAuth = ({ + operation, + plugin, +}: { + context: IR.Context; + operation: IR.OperationObject; + plugin: Plugin.Instance; +}): Array => { + if (!operation.security || !plugin.auth) { + return []; + } + + const auth: Array = []; + + for (const securitySchemeObject of operation.security) { + const authObject = securitySchemeObjectToAuthObject({ + securitySchemeObject, + }); + if (authObject) { + auth.push(authObject); + } else { + console.warn( + `❗️ SDK warning: unsupported security scheme. Please open an issue if you'd like it added https://github.com/hey-api/openapi-ts/issues\n${JSON.stringify(securitySchemeObject, null, 2)}`, + ); + } + } + + return auth; +}; + +export const createStatements = ({ + context, + operation, + parameters, + plugin, +}: { + context: IR.Context; + operation: IR.OperationObject; + parameters: Array; + plugin: Plugin.Instance; +}): Array => { + const file = context.file({ id: sdkId })!; + const sdkOutput = file.nameWithoutExtension(); + + const identifierError = importIdentifierError({ context, file, operation }); + const identifierResponse = importIdentifierResponse({ + context, + file, + operation, + }); + + // TODO: transform parameters + // const query = { + // BarBaz: options.query.bar_baz, + // qux_quux: options.query.qux_quux, + // fooBar: options.query.foo_bar, + // }; + + // if (operation.parameters) { + // for (const name in operation.parameters.query) { + // const parameter = operation.parameters.query[name] + // if (parameter.name !== fieldName({ context, name: parameter.name })) { + // console.warn(parameter.name) + // } + // } + // } + + const requestOptions: ObjectValue[] = []; + + if (operation.body) { + switch (operation.body.type) { + case 'form-data': + requestOptions.push({ spread: 'formDataBodySerializer' }); + file.import({ + module: clientModulePath({ + config: context.config, + sourceOutput: sdkOutput, + }), + name: 'formDataBodySerializer', + }); + break; + case 'json': + // jsonBodySerializer is the default, no need to specify + break; + case 'text': + // ensure we don't use any serializer by default + requestOptions.push({ + key: 'bodySerializer', + value: null, + }); + break; + case 'url-search-params': + requestOptions.push({ spread: 'urlSearchParamsBodySerializer' }); + file.import({ + module: clientModulePath({ + config: context.config, + sourceOutput: sdkOutput, + }), + name: 'urlSearchParamsBodySerializer', + }); + break; + } + } + + const client = getClientPlugin(context.config); + if (client.name === '@hey-api/client-axios') { + // try to infer `responseType` option for Axios. We don't need this in + // Fetch API client because it automatically detects the correct response + // during runtime. + for (const statusCode in operation.responses) { + // this doesn't handle default status code for now + if (statusCodeToGroup({ statusCode }) === '2XX') { + const response = operation.responses[statusCode]; + const responseType = getResponseType(response?.mediaType); + // json is the default, skip it + if (responseType && responseType !== 'json') { + requestOptions.push({ + key: 'responseType', + value: responseType, + }); + } + } + } + } + + // TODO: parser - set parseAs to skip inference if every response has the same + // content type. currently impossible because successes do not contain + // header information + + const auth = operationAuth({ context, operation, plugin }); + if (auth.length) { + requestOptions.push({ + key: 'security', + value: compiler.arrayLiteralExpression({ elements: auth }), + }); + } + + for (const name in operation.parameters?.query) { + const parameter = operation.parameters.query[name]!; + if ( + (parameter.schema.type === 'array' || + parameter.schema.type === 'tuple') && + (parameter.style !== 'form' || !parameter.explode) + ) { + // override the default settings for `querySerializer` + requestOptions.push({ + key: 'querySerializer', + value: [ + { + key: 'array', + value: [ + { + key: 'explode', + value: false, + }, + { + key: 'style', + value: 'form', + }, + ], + }, + ], + }); + break; + } + } + + if (plugin.transformer === '@hey-api/transformers') { + const identifierTransformer = context + .file({ id: transformersId })! + .identifier({ + $ref: operationTransformerIrRef({ id: operation.id, type: 'response' }), + namespace: 'value', + }); + + if (identifierTransformer.name) { + file.import({ + module: file.relativePathToFile({ + context, + id: transformersId, + }), + name: identifierTransformer.name, + }); + + requestOptions.push({ + key: 'responseTransformer', + value: identifierTransformer.name, + }); + } + } + + if (plugin.validator === 'zod') { + const identifierSchema = context.file({ id: zodId })!.identifier({ + $ref: operationIrRef({ + case: 'camelCase', + id: operation.id, + type: 'response', + }), + namespace: 'value', + }); + + if (identifierSchema.name) { + file.import({ + module: file.relativePathToFile({ + context, + id: zodId, + }), + name: identifierSchema.name, + }); + + requestOptions.push({ + key: 'responseValidator', + value: compiler.arrowFunction({ + async: true, + parameters: [ + { + name: 'data', + }, + ], + statements: [ + compiler.returnStatement({ + expression: compiler.awaitExpression({ + expression: compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression: compiler.identifier({ + text: identifierSchema.name, + }), + name: compiler.identifier({ text: 'parseAsync' }), + }), + parameters: [compiler.identifier({ text: 'data' })], + }), + }), + }), + ], + }), + }); + } + } + + requestOptions.push({ + key: 'url', + value: operation.path, + }); + + const clientParamsName = 'clientParams'; + const hasClientParams = plugin.params !== 'namespaced'; + + // options must go last to allow overriding parameters above + requestOptions.push({ spread: 'options' }); + if (parameters.length > 1) { + if (hasClientParams) { + requestOptions.push({ spread: clientParamsName }); + } else { + requestOptions.push({ spread: 'params' }); + } + } + + // TODO: add hasParams check, that would be true if we're using params and operation.parameters?.header + if (operation.body || hasClientParams) { + const value: Array = [ + { + spread: 'options?.headers', + }, + ]; + + if (operation.body) { + value.unshift({ + key: 'Content-Type', + // form-data does not need Content-Type header, browser will set it automatically + value: + operation.body.type === 'form-data' ? null : operation.body.mediaType, + }); + } + + if (hasClientParams) { + // TODO: clientParams, know when to use params and when to use clientParams + value.push({ + spread: `${clientParamsName}.headers`, + }); + } else if (operation.parameters?.header) { + value.push({ + spread: 'params?.headers', + }); + } + + requestOptions.push({ + key: 'headers', + value, + }); + } + + const isNuxtClient = client.name === '@hey-api/client-nuxt'; + const responseType = identifierResponse.name || 'unknown'; + const errorType = identifierError.name || 'unknown'; + + const heyApiClient = plugin.client + ? file.import({ + alias: '_heyApiClient', + module: file.relativePathToFile({ + context, + id: clientId, + }), + name: 'client', + }) + : undefined; + + const paramOptions = parameters.at(-1)!; + const optionsClient = compiler.propertyAccessExpression({ + expression: compiler.identifier({ text: 'options' }), + isOptional: 'name' in paramOptions ? !paramOptions.isRequired : true, + name: 'client', + }); + + const statements: Array = [ + compiler.returnFunctionCall({ + args: [ + compiler.objectExpression({ + identifiers: ['responseTransformer'], + obj: requestOptions, + }), + ], + name: compiler.propertyAccessExpression({ + expression: heyApiClient?.name + ? compiler.binaryExpression({ + left: optionsClient, + operator: '??', + right: compiler.identifier({ text: heyApiClient.name }), + }) + : optionsClient, + name: compiler.identifier({ text: operation.method }), + }), + types: isNuxtClient + ? [ + nuxtTypeComposable, + `${responseType} | ${nuxtTypeDefault}`, + errorType, + nuxtTypeDefault, + ] + : [responseType, errorType, 'ThrowOnError'], + }), + ]; + + if (hasClientParams) { + const buildClientParams = file.import({ + alias: '_buildClientParams', + module: clientModulePath({ + config: context.config, + sourceOutput: sdkOutput, + }), + name: 'buildClientParams', + }); + const args: Array = []; + for (const [index, parameter] of parameters.entries()) { + if ('name' in parameter && index !== parameters.length - 1) { + args.push(compiler.identifier({ text: parameter.name })); + } + } + const argsConfig: Array = []; + if (plugin.params === 'flattened') { + // TODO: construct config + // operation.body. + argsConfig.push( + compiler.objectExpression({ + obj: [ + { + key: 'args', + value: compiler.arrayLiteralExpression({ + elements: [ + compiler.objectExpression({ + obj: [ + { + key: 'in', + value: 'path', + }, + { + key: 'key', + value: 'params', + }, + ], + }), + ], + }), + }, + ], + }), + ); + } + const clientParamsNode = compiler.constVariable({ + expression: compiler.callExpression({ + functionName: buildClientParams.name, + parameters: [ + compiler.arrayLiteralExpression({ elements: args }), + compiler.arrayLiteralExpression({ elements: argsConfig }), + ], + }), + name: clientParamsName, + }); + statements.unshift(clientParamsNode); + } + + return statements; +}; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts index f0b25b043..2df5eb08c 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts @@ -1,13 +1,10 @@ -import ts from 'typescript'; - import { compiler } from '../../../compiler'; import { clientModulePath } from '../../../generate/client'; import type { FileImportResult } from '../../../generate/files'; import type { IR } from '../../../ir/types'; import type { Plugin } from '../../types'; import { getClientPlugin } from '../client-core/utils'; -import { nuxtTypeDefault, nuxtTypeResponse } from './constants'; -import { sdkId } from './plugin'; +import { nuxtTypeDefault, nuxtTypeResponse, sdkId } from './constants'; import type { Config } from './types'; export const createTypeOptions = ({ @@ -133,41 +130,3 @@ export const createTypeOptions = ({ file.add(typeOptions); }; - -export const createTypeOmitNever = ({ context }: { context: IR.Context }) => { - const file = context.file({ id: sdkId })!; - - const neverType = compiler.keywordTypeNode({ keyword: 'never' }); - const kType = compiler.typeReferenceNode({ typeName: 'K' }); - const tType = compiler.typeReferenceNode({ typeName: 'T' }); - const kOfTType = compiler.indexedAccessTypeNode({ - indexType: kType, - objectType: tType, - }); - - const omitNeverTypeAlias = compiler.typeAliasDeclaration({ - exportType: true, - name: 'OmitNever', - type: ts.factory.createMappedTypeNode( - undefined, - ts.factory.createTypeParameterDeclaration( - undefined, - 'K', - ts.factory.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, tType), - undefined, - ), - ts.factory.createConditionalTypeNode( - kOfTType, - neverType, - neverType, - kType, - ), - undefined, - kOfTType, - undefined, - ), - typeParameters: [{ name: 'T' }], - }); - - file.add(omitNeverTypeAlias); -}; diff --git a/packages/openapi-ts/src/plugins/@tanstack/query-core/plugin.ts b/packages/openapi-ts/src/plugins/@tanstack/query-core/plugin.ts index 524f5463b..be162741e 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/query-core/plugin.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/query-core/plugin.ts @@ -2,7 +2,7 @@ import { clientApi } from '../../../generate/client'; import { getServiceName } from '../../../utils/postprocess'; import { transformServiceName } from '../../../utils/transform'; import { clientId } from '../../@hey-api/client-core/utils'; -import { sdkId } from '../../@hey-api/sdk/plugin'; +import { sdkId } from '../../@hey-api/sdk/constants'; import { serviceFunctionIdentifier } from '../../@hey-api/sdk/plugin-legacy'; import { createInfiniteQueryOptions } from './infiniteQueryOptions'; import { createMutationOptions } from './mutationOptions'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc8776d4..c07016b11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,6 +843,9 @@ importers: specifier: 4.7.8 version: 4.7.8 devDependencies: + '@hey-api/client-core': + specifier: workspace:* + version: link:../client-core '@types/cross-spawn': specifier: 6.0.6 version: 6.0.6 @@ -18737,8 +18740,8 @@ snapshots: '@typescript-eslint/parser': 7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3) eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.2.0(eslint@9.17.0(jiti@2.4.2)) @@ -18761,7 +18764,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@9.4.0) @@ -18772,22 +18775,22 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3) eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -18798,7 +18801,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.15.0(eslint@9.17.0(jiti@2.4.2))(typescript@5.5.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From 656a72a737e45731662b1385cc490bdc489a07c4 Mon Sep 17 00:00:00 2001 From: Lubos Date: Thu, 17 Apr 2025 18:15:51 +0100 Subject: [PATCH 4/4] chore: start function for args config --- .../client-core/src/__tests__/params.test.ts | 4 +- packages/client-core/src/index.ts | 1 + packages/client-core/src/params.ts | 20 +-- packages/nuxt/src/module.ts | 10 +- packages/openapi-ts/src/compiler/classes.ts | 2 +- packages/openapi-ts/src/compiler/types.ts | 8 +- .../src/plugins/@hey-api/sdk/params.ts | 35 +++- .../src/plugins/@hey-api/sdk/statements.ts | 168 ++++++++++++------ 8 files changed, 170 insertions(+), 78 deletions(-) diff --git a/packages/client-core/src/__tests__/params.test.ts b/packages/client-core/src/__tests__/params.test.ts index 91a9b0c49..356061259 100644 --- a/packages/client-core/src/__tests__/params.test.ts +++ b/packages/client-core/src/__tests__/params.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { Config } from '../params'; +import type { FieldsConfig } from '../params'; import { buildClientParams } from '../params'; describe('buildClientParams', () => { const scenarios: ReadonlyArray<{ args: ReadonlyArray; - config: Config; + config: FieldsConfig; description: string; params: Record; }> = [ diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts index 938a1fc8f..014511206 100644 --- a/packages/client-core/src/index.ts +++ b/packages/client-core/src/index.ts @@ -10,6 +10,7 @@ export { jsonBodySerializer, urlSearchParamsBodySerializer, } from './bodySerializer'; +export type { Field, Fields, FieldsConfig } from './params'; export { buildClientParams } from './params'; export type { ArraySeparatorStyle, diff --git a/packages/client-core/src/params.ts b/packages/client-core/src/params.ts index b730952ee..7559bbb8c 100644 --- a/packages/client-core/src/params.ts +++ b/packages/client-core/src/params.ts @@ -1,6 +1,6 @@ type Slot = 'body' | 'headers' | 'path' | 'query'; -type Field = +export type Field = | { in: Exclude; key: string; @@ -12,12 +12,12 @@ type Field = map?: string; }; -interface Fields { +export interface Fields { allowExtra?: Partial>; args?: ReadonlyArray; } -export type Config = ReadonlyArray; +export type FieldsConfig = ReadonlyArray; const extraPrefixesMap: Record = { $body_: 'body', @@ -35,12 +35,12 @@ type KeyMap = Map< } >; -const buildKeyMap = (configs: Config, map?: KeyMap): KeyMap => { +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { if (!map) { map = new Map(); } - for (const config of configs) { + for (const config of fields) { if ('in' in config) { if (config.key) { map.set(config.key, { @@ -73,7 +73,7 @@ const stripEmptySlots = (params: Params) => { export const buildClientParams = ( args: ReadonlyArray, - configs: Config, + fields: FieldsConfig, ) => { const params: Params = { body: {}, @@ -82,13 +82,13 @@ export const buildClientParams = ( query: {}, }; - const map = buildKeyMap(configs); + const map = buildKeyMap(fields); - let config: Config[number] | undefined; + let config: FieldsConfig[number] | undefined; for (const [index, arg] of args.entries()) { - if (configs[index]) { - config = configs[index]; + if (fields[index]) { + config = fields[index]; } if (!config) { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 2d0ec9d21..64a7e17d4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -48,10 +48,12 @@ export default defineNuxtModule({ output: { path: path.join(nuxt.options.buildDir, 'client'), }, - plugins: (options.config.plugins || []).some((plugin: any) => { - const pluginName = typeof plugin === 'string' ? plugin : plugin.name; - return pluginName === '@hey-api/plugin-nuxt'; - }) + plugins: (options.config.plugins || []).some( + (plugin: Required['plugins'][number]) => { + const pluginName = typeof plugin === 'string' ? plugin : plugin.name; + return pluginName === '@hey-api/client-nuxt'; + }, + ) ? [] : ['@hey-api/client-nuxt'], } satisfies Partial) as UserConfig; diff --git a/packages/openapi-ts/src/compiler/classes.ts b/packages/openapi-ts/src/compiler/classes.ts index 96513ebff..6b2df7687 100644 --- a/packages/openapi-ts/src/compiler/classes.ts +++ b/packages/openapi-ts/src/compiler/classes.ts @@ -79,7 +79,7 @@ export const createMethodDeclaration = ({ isStatic?: boolean; multiLine?: boolean; name: string; - parameters?: FunctionParameter[]; + parameters?: ReadonlyArray; returnType?: string | ts.TypeNode; statements?: ts.Statement[]; types?: FunctionTypeParameter[]; diff --git a/packages/openapi-ts/src/compiler/types.ts b/packages/openapi-ts/src/compiler/types.ts index 0823b7139..a1aeaf08f 100644 --- a/packages/openapi-ts/src/compiler/types.ts +++ b/packages/openapi-ts/src/compiler/types.ts @@ -23,7 +23,7 @@ export type FunctionParameter = type?: any | ts.TypeNode; } | { - destructure: FunctionParameter[]; + destructure: ReadonlyArray; }; export interface FunctionTypeParameter { @@ -196,7 +196,9 @@ export const toAccessLevelModifiers = ( * @param parameters - the parameters to convert to declarations * @returns ts.ParameterDeclaration[] */ -export const toParameterDeclarations = (parameters: FunctionParameter[]) => +export const toParameterDeclarations = ( + parameters: ReadonlyArray, +) => parameters.map((parameter) => { if ('destructure' in parameter) { return createParameterDeclaration({ @@ -398,7 +400,7 @@ export const createArrowFunction = ({ async?: boolean; comment?: Comments; multiLine?: boolean; - parameters?: FunctionParameter[]; + parameters?: ReadonlyArray; returnType?: string | ts.TypeNode; statements?: ts.Statement[] | ts.Expression; types?: FunctionTypeParameter[]; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts index 05457b8c6..41d9bc73b 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts @@ -1,3 +1,5 @@ +import type { Field, FieldsConfig } from '@hey-api/client-core'; + import type { FunctionParameter } from '../../../compiler'; import { clientApi } from '../../../generate/client'; import type { TypeScriptFile } from '../../../generate/files'; @@ -51,6 +53,32 @@ export const operationOptionsType = ({ : optionsName; }; +export type SdkParameter = FunctionParameter & { + fields?: FieldsConfig; +}; + +const operationToFields = ({ + operation, +}: { + operation: IR.OperationObject; +}): ReadonlyArray => { + const fields: Array = []; + const args: Array = []; + + if (operation.body) { + args.push({ + in: 'body', + key: 'body', + }); + } + + if (args.length) { + fields.push({ args }); + } + + return fields; +}; + export const createParameters = ({ context, file, @@ -61,17 +89,20 @@ export const createParameters = ({ file: TypeScriptFile; operation: IR.OperationObject; plugin: Plugin.Instance; -}): Array => { +}): ReadonlyArray => { const client = getClientPlugin(context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; - const parameters: Array = []; + const parameters: Array = []; if (plugin.params === 'flattened') { const identifierData = importIdentifierData({ context, file, operation }); if (identifierData.name) { parameters.push({ + fields: operationToFields({ + operation, + }), isRequired: hasOperationDataRequired(operation), name: 'params', type: `Params<${identifierData.name}>`, diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts index a3040ae3e..185f145e9 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts @@ -2,7 +2,7 @@ import type { Auth } from '@hey-api/client-core'; import type ts from 'typescript'; import { compiler } from '../../../compiler'; -import type { FunctionParameter, ObjectValue } from '../../../compiler/types'; +import type { ObjectValue } from '../../../compiler/types'; import { clientModulePath } from '../../../generate/client'; import { statusCodeToGroup } from '../../../ir/operation'; import type { IR } from '../../../ir/types'; @@ -19,9 +19,12 @@ import { importIdentifierResponse, } from '../typescript/ref'; import { nuxtTypeComposable, nuxtTypeDefault, sdkId } from './constants'; +import type { SdkParameter } from './params'; import { getResponseType } from './response'; import type { Config } from './types'; +const clientParamsName = 'clientParams'; + // TODO: parser - handle more security types const securitySchemeObjectToAuthObject = ({ securitySchemeObject, @@ -116,6 +119,108 @@ const operationAuth = ({ return auth; }; +const fieldsToArgsConfig = ({ + argsConfig, + parameter, +}: { + argsConfig: Array; + parameter: SdkParameter; +}) => { + if (parameter.fields) { + for (const field of parameter.fields) { + if ('in' in field) { + // TODO: handle positional arguments + // field.in + } else if (field.args) { + const obj: Array = []; + + for (const config of field.args) { + obj.push({ + key: 'in', + value: config.in, + }); + + if (config.key) { + obj.push({ + key: 'key', + value: config.key, + }); + } + + if (config.map) { + obj.push({ + key: 'map', + value: config.map, + }); + } + } + + argsConfig.push( + compiler.objectExpression({ + obj: [ + { + key: 'args', + value: compiler.arrayLiteralExpression({ + elements: [compiler.objectExpression({ obj })], + }), + }, + ], + }), + ); + } + } + } +}; + +const buildClientParamsNode = ({ + context, + parameters, +}: { + context: IR.Context; + operation: IR.OperationObject; + parameters: ReadonlyArray; + plugin: Plugin.Instance; +}) => { + const file = context.file({ id: sdkId })!; + const sdkOutput = file.nameWithoutExtension(); + + const buildClientParams = file.import({ + alias: '_buildClientParams', + module: clientModulePath({ + config: context.config, + sourceOutput: sdkOutput, + }), + name: 'buildClientParams', + }); + + const args: Array = []; + const argsConfig: Array = []; + + for (const [index, parameter] of parameters.entries()) { + if ('name' in parameter && index !== parameters.length - 1) { + args.push(compiler.identifier({ text: parameter.name })); + } + + fieldsToArgsConfig({ + argsConfig, + parameter, + }); + } + + const clientParamsNode = compiler.constVariable({ + expression: compiler.callExpression({ + functionName: buildClientParams.name, + parameters: [ + compiler.arrayLiteralExpression({ elements: args }), + compiler.arrayLiteralExpression({ elements: argsConfig }), + ], + }), + name: clientParamsName, + }); + + return clientParamsNode; +}; + export const createStatements = ({ context, operation, @@ -124,7 +229,7 @@ export const createStatements = ({ }: { context: IR.Context; operation: IR.OperationObject; - parameters: Array; + parameters: ReadonlyArray; plugin: Plugin.Instance; }): Array => { const file = context.file({ id: sdkId })!; @@ -330,7 +435,6 @@ export const createStatements = ({ value: operation.path, }); - const clientParamsName = 'clientParams'; const hasClientParams = plugin.params !== 'namespaced'; // options must go last to allow overriding parameters above @@ -429,59 +533,11 @@ export const createStatements = ({ ]; if (hasClientParams) { - const buildClientParams = file.import({ - alias: '_buildClientParams', - module: clientModulePath({ - config: context.config, - sourceOutput: sdkOutput, - }), - name: 'buildClientParams', - }); - const args: Array = []; - for (const [index, parameter] of parameters.entries()) { - if ('name' in parameter && index !== parameters.length - 1) { - args.push(compiler.identifier({ text: parameter.name })); - } - } - const argsConfig: Array = []; - if (plugin.params === 'flattened') { - // TODO: construct config - // operation.body. - argsConfig.push( - compiler.objectExpression({ - obj: [ - { - key: 'args', - value: compiler.arrayLiteralExpression({ - elements: [ - compiler.objectExpression({ - obj: [ - { - key: 'in', - value: 'path', - }, - { - key: 'key', - value: 'params', - }, - ], - }), - ], - }), - }, - ], - }), - ); - } - const clientParamsNode = compiler.constVariable({ - expression: compiler.callExpression({ - functionName: buildClientParams.name, - parameters: [ - compiler.arrayLiteralExpression({ elements: args }), - compiler.arrayLiteralExpression({ elements: argsConfig }), - ], - }), - name: clientParamsName, + const clientParamsNode = buildClientParamsNode({ + context, + operation, + parameters, + plugin, }); statements.unshift(clientParamsNode); }