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/.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..356061259 --- /dev/null +++ b/packages/client-core/src/__tests__/params.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; + +import type { FieldsConfig } from '../params'; +import { buildClientParams } from '../params'; + +describe('buildClientParams', () => { + const scenarios: ReadonlyArray<{ + args: ReadonlyArray; + config: FieldsConfig; + 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..014511206 100644 --- a/packages/client-core/src/index.ts +++ b/packages/client-core/src/index.ts @@ -10,6 +10,8 @@ export { jsonBodySerializer, urlSearchParamsBodySerializer, } from './bodySerializer'; +export type { Field, Fields, FieldsConfig } from './params'; +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..7559bbb8c --- /dev/null +++ b/packages/client-core/src/params.ts @@ -0,0 +1,141 @@ +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + key: string; + map?: string; + } + | { + in: Extract; + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = 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 = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + 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, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + 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..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, @@ -13,6 +15,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/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/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, 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..41d9bc73b --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/params.ts @@ -0,0 +1,141 @@ +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'; +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 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, + operation, + plugin, +}: { + context: IR.Context; + file: TypeScriptFile; + operation: IR.OperationObject; + plugin: Plugin.Instance; +}): ReadonlyArray => { + 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({ + fields: operationToFields({ + operation, + }), + isRequired: hasOperationDataRequired(operation), + name: 'params', + type: `Params<${identifierData.name}>`, + }); + } + + 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; +}; 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 3067de8ef..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,524 +1,21 @@ 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, -} 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 { - importIdentifierData, - 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 { 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 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'; - -/** - * 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': - case 'octet-stream': - // 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, @@ -532,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: [ @@ -553,23 +54,12 @@ const generateClassSdk = ({ id: operation.id, operation, }), - parameters: [ - { - isRequired: isRequiredOptions, - name: 'options', - type: operationOptionsType({ - context, - file, - operation, - throwOnError: isNuxtClient ? undefined : 'ThrowOnError', - }), - }, - ], + parameters, returnType: undefined, - statements: operationStatements({ + statements: createStatements({ context, - isRequiredOptions, operation, + parameters, plugin, }), types: isNuxtClient @@ -644,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', @@ -659,23 +153,12 @@ const generateFlatSdk = ({ ], exportConst: true, expression: compiler.arrowFunction({ - parameters: [ - { - isRequired: isRequiredOptions, - name: 'options', - type: operationOptionsType({ - context, - file, - operation, - throwOnError: isNuxtClient ? undefined : 'ThrowOnError', - }), - }, - ], + parameters, returnType: undefined, - statements: operationStatements({ + statements: createStatements({ context, - isRequiredOptions, operation, + parameters, plugin, }), types: isNuxtClient @@ -737,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'; 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..185f145e9 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/statements.ts @@ -0,0 +1,546 @@ +import type { Auth } from '@hey-api/client-core'; +import type ts from 'typescript'; + +import { compiler } from '../../../compiler'; +import type { 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 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, +}: { + 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 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, + parameters, + plugin, +}: { + context: IR.Context; + operation: IR.OperationObject; + parameters: ReadonlyArray; + 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 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 clientParamsNode = buildClientParamsNode({ + context, + operation, + parameters, + plugin, + }); + statements.unshift(clientParamsNode); + } + + return statements; +}; 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,