diff --git a/packages/openapi-ts/src/compiler/classes.ts b/packages/openapi-ts/src/compiler/classes.ts index 44cf676aa..e322d49e5 100644 --- a/packages/openapi-ts/src/compiler/classes.ts +++ b/packages/openapi-ts/src/compiler/classes.ts @@ -95,7 +95,6 @@ export const createMethodDeclaration = ({ ts.factory.createModifier(ts.SyntaxKind.StaticKeyword), ]; } - const node = ts.factory.createMethodDeclaration( modifiers, undefined, @@ -131,10 +130,12 @@ export const createClassDeclaration = ({ decorator, members = [], name, + spaceBetweenMembers = true, }: { decorator?: ClassDecorator; members?: ts.ClassElement[]; name: string; + spaceBetweenMembers?: boolean; }) => { let modifiers: ts.ModifierLike[] = [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword), @@ -156,10 +157,14 @@ export const createClassDeclaration = ({ // Add newline between each class member. let m: ts.ClassElement[] = []; - members.forEach((member) => { - // @ts-ignore - m = [...m, member, createIdentifier({ text: '\n' })]; - }); + if (spaceBetweenMembers) { + members.forEach((member) => { + // @ts-ignore + m = [...m, member, createIdentifier({ text: '\n' })]; + }); + } else { + m = members; + } return ts.factory.createClassDeclaration( modifiers, @@ -169,3 +174,68 @@ export const createClassDeclaration = ({ m, ); }; + +/** + * Create a class property declaration. + * @param accessLevel - the access level of the constructor. + * @param comment - comment to add to function. + * @param isReadonly - if the property is readonly. + * @param name - name of the property. + * @param type - the type of the property. + * @param value - the value of the property. + * @returns ts.PropertyDeclaration + */ +export const createPropertyDeclaration = ({ + accessLevel, + comment, + isReadonly = false, + name, + type, + value, +}: { + accessLevel?: AccessLevel; + comment?: Comments; + isReadonly?: boolean; + name: string; + type?: string | ts.TypeNode; + value?: string | ts.Expression; +}) => { + let modifiers = toAccessLevelModifiers(accessLevel); + + if (isReadonly) { + modifiers = [ + ...modifiers, + ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword), + ]; + } + + const node = ts.factory.createPropertyDeclaration( + modifiers, + createIdentifier({ text: name }), + undefined, + type ? createTypeNode(type) : undefined, + value ? toExpression({ value }) : undefined, + ); + + addLeadingComments({ + comments: comment, + node, + }); + + return node; +}; + +/** + * Create a class new instance expression. + * @param name - name of the class. + * @returns ts.NewExpression + */ +export const newExpression = ({ + expression, + args, + types, +}: { + args?: ts.Expression[]; + expression: ts.Expression; + types?: ts.TypeNode[]; +}) => ts.factory.createNewExpression(expression, types, args); diff --git a/packages/openapi-ts/src/compiler/index.ts b/packages/openapi-ts/src/compiler/index.ts index a22222366..8485aef63 100644 --- a/packages/openapi-ts/src/compiler/index.ts +++ b/packages/openapi-ts/src/compiler/index.ts @@ -177,9 +177,11 @@ export const compiler = { conditionalExpression: types.createConditionalExpression, constVariable: module.createConstVariable, constructorDeclaration: classes.createConstructorDeclaration, + createPropertyDeclaration: classes.createPropertyDeclaration, elementAccessExpression: transform.createElementAccessExpression, enumDeclaration: types.createEnumDeclaration, exportAllDeclaration: module.createExportAllDeclaration, + exportDefaultDeclaration: module.createDefaultExportDeclaration, exportNamedDeclaration: module.createNamedExportDeclarations, expressionToStatement: convert.expressionToStatement, identifier: utils.createIdentifier, @@ -190,6 +192,7 @@ export const compiler = { methodDeclaration: classes.createMethodDeclaration, namedImportDeclarations: module.createNamedImportDeclarations, namespaceDeclaration: types.createNamespaceDeclaration, + newExpression: classes.newExpression, nodeToString: utils.tsNodeToString, objectExpression: types.createObjectType, ots: utils.ots, diff --git a/packages/openapi-ts/src/compiler/module.ts b/packages/openapi-ts/src/compiler/module.ts index 4cf32b2bf..de59552bd 100644 --- a/packages/openapi-ts/src/compiler/module.ts +++ b/packages/openapi-ts/src/compiler/module.ts @@ -9,6 +9,18 @@ import { ots, } from './utils'; +/** + * Create default export declaration. Example: `export default x`. + */ +export const createDefaultExportDeclaration = ({ + expression, +}: { + expression: ts.Expression; +}) => { + const statement = ts.factory.createExportDefault(expression); + return statement; +}; + /** * Create export all declaration. Example: `export * from './y'`. * @param module - module containing exports diff --git a/packages/openapi-ts/src/compiler/transform.ts b/packages/openapi-ts/src/compiler/transform.ts index 587f438a0..a4722b890 100644 --- a/packages/openapi-ts/src/compiler/transform.ts +++ b/packages/openapi-ts/src/compiler/transform.ts @@ -67,17 +67,19 @@ export const createBinaryExpression = ({ right, }: { left: ts.Expression; - operator?: '=' | '===' | 'in'; + operator?: '=' | '===' | 'in' | '??'; right: ts.Expression | string; }) => { const expression = ts.factory.createBinaryExpression( left, // TODO: add support for other tokens - operator === '=' - ? ts.SyntaxKind.EqualsToken - : operator === '===' - ? ts.SyntaxKind.EqualsEqualsEqualsToken - : ts.SyntaxKind.InKeyword, + operator === '??' + ? ts.SyntaxKind.QuestionQuestionToken + : operator === '=' + ? ts.SyntaxKind.EqualsToken + : operator === '===' + ? ts.SyntaxKind.EqualsEqualsEqualsToken + : ts.SyntaxKind.InKeyword, typeof right === 'string' ? createIdentifier({ text: right }) : right, ); return expression; diff --git a/packages/openapi-ts/src/generate/class.ts b/packages/openapi-ts/src/generate/class.ts index 5761f4f72..67e9769e1 100644 --- a/packages/openapi-ts/src/generate/class.ts +++ b/packages/openapi-ts/src/generate/class.ts @@ -1,15 +1,175 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; +import { ClassElement, compiler, TypeScriptFile } from '../compiler'; import type { OpenApi } from '../openApi'; import type { Client } from '../types/client'; +import { Config } from '../types/config'; +import { Files } from '../types/utils'; +import { camelCase } from '../utils/camelCase'; import { getConfig } from '../utils/config'; import { getHttpRequestName } from '../utils/getHttpRequestName'; import type { Templates } from '../utils/handlebars'; import { sortByName } from '../utils/sort'; +import { clientModulePath } from './client'; import { ensureDirSync } from './utils'; +const operationServiceName = (name: string): string => + `${camelCase({ + input: name, + pascalCase: true, + })}Service`; + +const operationVarName = (name: string): string => + `${camelCase({ + input: name, + pascalCase: false, + })}`; + +const sdkName = (name: Config['services']['sdk']): string => + name && typeof name === 'string' ? name : 'Sdk'; + +/** + * Generate the Full SDK class + */ +export const generateSDKClass = async ({ + client, + files, +}: { + client: Client; + files: Files; +}) => { + const config = getConfig(); + client; + if (!config.services.export || !config.services.sdk) { + return; + } + + files.sdk = new TypeScriptFile({ + dir: config.output.path, + name: 'sdk.ts', + }); + + // imports + files.sdk.import({ + module: clientModulePath(), + name: 'createClient', + }); + files.sdk.import({ + module: clientModulePath(), + name: 'createConfig', + }); + files.sdk.import({ + module: clientModulePath(), + name: 'Config', + }); + client.services.map((service) => { + files.sdk.import({ + // this detection could be done safer, but it shouldn't cause any issues + module: `./services.gen`, + name: operationServiceName(service.name), + }); + }); + + const instanceVars: ClassElement[] = client.services.map((service) => { + const node = compiler.createPropertyDeclaration({ + accessLevel: 'public', + isReadonly: true, + name: operationVarName(service.name), + type: operationServiceName(service.name), + }); + return node; + }); + + instanceVars.push( + compiler.createPropertyDeclaration({ + accessLevel: 'public', + isReadonly: true, + name: 'client', + type: getHttpRequestName(config.client), + }), + ); + + const serviceAssignments = client.services.map((service) => { + const node = compiler.expressionToStatement({ + expression: compiler.binaryExpression({ + left: compiler.propertyAccessExpression({ + expression: 'this', + name: operationVarName(service.name), + }), + right: compiler.newExpression({ + args: [ + compiler.propertyAccessExpression({ + expression: 'this', + name: 'client', + }), + ], + expression: compiler.identifier({ + text: operationServiceName(service.name), + }), + }), + }), + }); + return node; + }); + const clientAssignment = compiler.expressionToStatement({ + expression: compiler.binaryExpression({ + left: compiler.propertyAccessExpression({ + expression: 'this', + name: 'client', + }), + right: compiler.callExpression({ + functionName: 'createClient', + parameters: [ + compiler.binaryExpression({ + left: compiler.identifier({ text: 'config' }), + operator: '??', + right: compiler.callExpression({ + functionName: 'createConfig', + }), + }), + ], + }), + }), + }); + const constructor = compiler.constructorDeclaration({ + multiLine: true, + parameters: [ + { + isRequired: false, + name: 'config', + type: 'Config', + }, + ], + statements: [ + clientAssignment, + compiler.expressionToStatement({ + expression: compiler.identifier({ text: '\n' }), + }), + ...serviceAssignments, + ], + }); + + const statement = compiler.classDeclaration({ + decorator: + config.client.name === 'angular' + ? { args: [{ providedIn: 'root' }], name: 'Injectable' } + : undefined, + members: [...instanceVars, constructor], + name: sdkName(config.services.sdk), + spaceBetweenMembers: false, + }); + files.sdk.add(statement); + + const defaultExport = compiler.exportDefaultDeclaration({ + expression: compiler.identifier({ text: sdkName(config.services.sdk) }), + }); + + files.sdk.add(defaultExport); +}; + /** + * @deprecated * Generate the OpenAPI client index file using the Handlebar template and write it to disk. * The index file just contains all the exports you need to use the client as a standalone * library. But yuo can also import individual models and services directly. diff --git a/packages/openapi-ts/src/generate/core.ts b/packages/openapi-ts/src/generate/core.ts index 541479d47..f3b60e96d 100644 --- a/packages/openapi-ts/src/generate/core.ts +++ b/packages/openapi-ts/src/generate/core.ts @@ -13,6 +13,7 @@ import { getHttpRequestName } from '../utils/getHttpRequestName'; import type { Templates } from '../utils/handlebars'; /** + * @deprecated * Generate OpenAPI core files, this includes the basic boilerplate code to handle requests. * @param outputPath Directory to write the generated files to * @param client Client containing models, schemas, and services diff --git a/packages/openapi-ts/src/generate/output.ts b/packages/openapi-ts/src/generate/output.ts index ea08d814d..c9ef49745 100644 --- a/packages/openapi-ts/src/generate/output.ts +++ b/packages/openapi-ts/src/generate/output.ts @@ -5,7 +5,7 @@ import type { Client } from '../types/client'; import type { Files } from '../types/utils'; import { getConfig } from '../utils/config'; import type { Templates } from '../utils/handlebars'; -import { generateClientClass } from './class'; +import { generateClientClass, generateSDKClass } from './class'; import { generateClient } from './client'; import { generateCore } from './core'; import { generateIndexFile } from './indexFile'; @@ -72,6 +72,9 @@ export const generateOutput = async ( // services.gen.ts await generateServices({ client, files }); + // sdk.gen.ts + await generateSDKClass({ client, files }); + // deprecated files await generateClientClass(openApi, outputPath, client, templates); await generateCore( diff --git a/packages/openapi-ts/src/generate/services.ts b/packages/openapi-ts/src/generate/services.ts index 0e2681de1..2834baf00 100644 --- a/packages/openapi-ts/src/generate/services.ts +++ b/packages/openapi-ts/src/generate/services.ts @@ -497,7 +497,7 @@ const toOperationStatements = ( return [ compiler.returnFunctionCall({ args: [options], - name: `(options?.client ?? client).${operation.method.toLocaleLowerCase()}`, + name: `(options?.client ?? this.client).${operation.method.toLocaleLowerCase()}`, types: errorType && responseType ? [responseType, errorType, 'ThrowOnError'] @@ -636,7 +636,10 @@ const processService = ({ const node = compiler.methodDeclaration({ accessLevel: 'public', comment: toOperationComment(operation), - isStatic: config.name === undefined && config.client.name !== 'angular', + isStatic: + !config.services.sdk && + config.name === undefined && + config.client.name !== 'angular', name: toOperationName(operation, false), parameters: toOperationParamType(client, operation), returnType: isStandalone @@ -688,6 +691,21 @@ const processService = ({ }), ...members, ]; + } else if (config.services.sdk) { + members = [ + compiler.constructorDeclaration({ + multiLine: false, + parameters: [ + { + accessLevel: 'public', + isReadOnly: true, + name: 'client', + type: 'Client', + }, + ], + }), + ...members, + ]; } const statement = compiler.classDeclaration({ @@ -741,14 +759,25 @@ export const generateServices = async ({ // Import required packages and core files. if (isStandalone) { - files.services.import({ - module: clientModulePath(), - name: 'createClient', - }); - files.services.import({ - module: clientModulePath(), - name: 'createConfig', - }); + if (config.services.sdk) { + files.services.import({ + module: clientModulePath(), + name: 'Client', + }); + files.services.import({ + module: clientModulePath(), + name: 'RequestOptions', + }); + } else { + files.services.import({ + module: clientModulePath(), + name: 'createClient', + }); + files.services.import({ + module: clientModulePath(), + name: 'createConfig', + }); + } files.services.import({ asType: true, module: clientModulePath(), @@ -809,7 +838,7 @@ export const generateServices = async ({ } // define client first - if (isStandalone) { + if (isStandalone && !config.services.sdk) { const statement = compiler.constVariable({ exportConst: true, expression: compiler.callExpression({ diff --git a/packages/openapi-ts/src/index.ts b/packages/openapi-ts/src/index.ts index 5bedc35a5..2112e3311 100644 --- a/packages/openapi-ts/src/index.ts +++ b/packages/openapi-ts/src/index.ts @@ -165,7 +165,9 @@ const getServices = (userConfig: ClientConfig): Config['services'] => { name: '{{name}}Service', operationId: true, response: 'body', + sdk: false, }; + if (typeof userConfig.services === 'boolean') { services.export = userConfig.services; } else if (typeof userConfig.services === 'string') { @@ -176,6 +178,9 @@ const getServices = (userConfig: ClientConfig): Config['services'] => { ...userConfig.services, }; } + if (services.sdk) { + services.asClass = true; + } return services; }; diff --git a/packages/openapi-ts/src/types/config.ts b/packages/openapi-ts/src/types/config.ts index db5daf241..d56921279 100644 --- a/packages/openapi-ts/src/types/config.ts +++ b/packages/openapi-ts/src/types/config.ts @@ -181,6 +181,15 @@ export interface ClientConfig { * @deprecated */ response?: 'body' | 'response'; + /** + * Generate the service as a full SDK class? When enabled, it will override the `asClass` option + * to true and generate the serivce as service classes. + * + * When set to true, the main service class will be named "SDK" + * When set to a string, the main service class will bare the name of the provided string + * @default false + */ + sdk?: boolean | string; }; /** * Generate types?