Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Full SDK generation #940

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 75 additions & 5 deletions packages/openapi-ts/src/compiler/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export const createMethodDeclaration = ({
ts.factory.createModifier(ts.SyntaxKind.StaticKeyword),
];
}

const node = ts.factory.createMethodDeclaration(
modifiers,
undefined,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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);
3 changes: 3 additions & 0 deletions packages/openapi-ts/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/openapi-ts/src/compiler/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions packages/openapi-ts/src/compiler/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
160 changes: 160 additions & 0 deletions packages/openapi-ts/src/generate/class.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-ts/src/generate/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/openapi-ts/src/generate/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Loading