Skip to content

Commit

Permalink
feat(cli): Add service command
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Apr 3, 2024
1 parent 7eaa641 commit e29accc
Show file tree
Hide file tree
Showing 11 changed files with 579 additions and 4 deletions.
5 changes: 5 additions & 0 deletions packages/cli/src/commands/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Command } from 'commander';
import { addCodegen } from './codegen/add-codegen';
import { addEntity } from './entity/add-entity';
import { createNewPlugin } from './plugin/create-new-plugin';
import { addService } from './service/add-service';
import { addUiExtensions } from './ui-extensions/add-ui-extensions';

const cancelledMessage = 'Add feature cancelled.';
Expand All @@ -18,6 +19,7 @@ export function registerAddCommand(program: Command) {
options: [
{ value: 'plugin', label: '[Plugin] Add a new plugin' },
{ value: 'entity', label: '[Plugin: Entity] Add a new entity to a plugin' },
{ value: 'service', label: '[Plugin: Service] Add a new service to a plugin' },
{ value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
{ value: 'codegen', label: '[Project: Codegen] Set up GraphQL code generation' },
],
Expand All @@ -39,6 +41,9 @@ export function registerAddCommand(program: Command) {
if (featureType === 'codegen') {
await addCodegen();
}
if (featureType === 'service') {
await addService();
}
} catch (e: any) {
log.error(e.message as string);
if (e.stack) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/add/entity/add-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { paramCase, pascalCase } from 'change-case';
import path from 'path';
import { ClassDeclaration } from 'ts-morph';

import { pascalCaseRegex } from '../../../constants';
import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
import { createFile } from '../../../utilities/ast-utils';
Expand Down Expand Up @@ -138,7 +139,6 @@ export async function getCustomEntityName(_cancelledMessage: string) {
if (!input) {
return 'The custom entity name cannot be empty';
}
const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/;
if (!pascalCaseRegex.test(input)) {
return 'The custom entity name must be in PascalCase, e.g. "ProductReview"';
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/add/plugin/create-new-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
import { addCodegen } from '../codegen/add-codegen';
import { addEntity } from '../entity/add-entity';
import { addService } from '../service/add-service';
import { addUiExtensions } from '../ui-extensions/add-ui-extensions';

import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
Expand Down Expand Up @@ -74,6 +75,7 @@ export async function createNewPlugin() {
options: [
{ value: 'no', label: "[Finish] No, I'm done!" },
{ value: 'entity', label: '[Plugin: Entity] Add a new entity to the plugin' },
{ value: 'service', label: '[Plugin: Service] Add a new service to the plugin' },
{ value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
{
value: 'codegen',
Expand All @@ -92,6 +94,8 @@ export async function createNewPlugin() {
await addUiExtensions(plugin);
} else if (featureType === 'codegen') {
await addCodegen(plugin);
} else if (featureType === 'service') {
await addService(plugin);
}
}

Expand Down
291 changes: 291 additions & 0 deletions packages/cli/src/commands/add/service/add-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { cancel, isCancel, outro, select, text } from '@clack/prompts';
import path from 'path';
import { ClassDeclaration, SourceFile } from 'ts-morph';

import { pascalCaseRegex } from '../../../constants';
import { EntityRef } from '../../../shared/entity-ref';
import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts';
import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils';

const cancelledMessage = 'Add service cancelled';

interface AddServiceTemplateContext {
type: 'basic' | 'entity';
serviceName: string;
entityRef?: EntityRef;
}

export async function addService(providedVendurePlugin?: VendurePluginRef) {
const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));

const type = await select({
message: 'What type of service would you like to add?',
options: [
{ value: 'basic', label: 'Basic empty service' },
{ value: 'entity', label: 'Service to perform CRUD operations on an entity' },
],
maxItems: 10,
});
if (isCancel(type)) {
cancel('Cancelled');
process.exit(0);
}
const context: AddServiceTemplateContext = {
type: type as AddServiceTemplateContext['type'],
serviceName: 'MyService',
};
if (type === 'entity') {
const entityRef = await selectEntity(vendurePlugin);
context.entityRef = entityRef;
context.serviceName = `${entityRef.name}Service`;
}

let serviceSourceFile: SourceFile;
if (context.type === 'basic') {
serviceSourceFile = createFile(project, path.join(__dirname, 'templates/basic-service.template.ts'));
const name = await text({
message: 'What is the name of the new service?',
initialValue: 'MyService',
validate: input => {
if (!input) {
return 'The service name cannot be empty';
}
if (!pascalCaseRegex.test(input)) {
return 'The service name must be in PascalCase, e.g. "MyService"';
}
},
});
if (isCancel(name)) {
cancel(cancelledMessage);
process.exit(0);
}
context.serviceName = name;
serviceSourceFile.getClass('BasicServiceTemplate')?.rename(context.serviceName);
} else {
serviceSourceFile = createFile(project, path.join(__dirname, 'templates/entity-service.template.ts'));
const serviceClassDeclaration = serviceSourceFile
.getClass('EntityServiceTemplate')
?.rename(context.serviceName);
if (!serviceClassDeclaration) {
throw new Error('Could not find service class declaration');
}
const entityRef = context.entityRef;
if (!entityRef) {
throw new Error('Entity class not found');
}
const templateEntityClass = serviceSourceFile.getClass('TemplateEntity');
if (templateEntityClass) {
templateEntityClass.rename(entityRef.name);
templateEntityClass.remove();
}
addImportsToFile(serviceClassDeclaration.getSourceFile(), {
moduleSpecifier: entityRef.classDeclaration.getSourceFile(),
namedImports: [entityRef.name],
});
const templateTranslationEntityClass = serviceSourceFile.getClass('TemplateEntityTranslation');
if (entityRef.isTranslatable()) {
const translationEntityClass = entityRef.getTranslationClass();
if (translationEntityClass && templateTranslationEntityClass) {
templateTranslationEntityClass.rename(translationEntityClass?.getName() as string);
templateTranslationEntityClass.remove();

addImportsToFile(serviceClassDeclaration.getSourceFile(), {
moduleSpecifier: translationEntityClass.getSourceFile(),
namedImports: [translationEntityClass.getName() as string],
});
}
} else {
templateTranslationEntityClass?.remove();
}
customizeInputInterfaces(serviceSourceFile, entityRef);
customizeFindOneMethod(serviceClassDeclaration, entityRef);
customizeFindAllMethod(serviceClassDeclaration, entityRef);
customizeCreateMethod(serviceClassDeclaration, entityRef);
customizeUpdateMethod(serviceClassDeclaration, entityRef);
removedUnusedConstructorArgs(serviceClassDeclaration, entityRef);
}

const serviceFileName = kebabize(context.serviceName).replace(/-service$/, '.service');
serviceSourceFile?.move(
path.join(vendurePlugin.getPluginDir().getPath(), 'services', `${serviceFileName}.ts`),
);

vendurePlugin.addProvider(context.serviceName);
addImportsToFile(vendurePlugin.classDeclaration.getSourceFile(), {
moduleSpecifier: serviceSourceFile,
namedImports: [context.serviceName],
});

serviceSourceFile.organizeImports();
await project.save();

if (!providedVendurePlugin) {
outro('✅ Done!');
}
}

function customizeFindOneMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const findOneMethod = serviceClassDeclaration.getMethod('findOne')!;
findOneMethod
.setBodyText(writer => {
writer.write(` return this.connection
.getRepository(ctx, ${entityRef.name})
.findOne({
where: { id },
relations,
})`);
if (entityRef.isTranslatable()) {
writer.write(`.then(entity => entity && this.translator.translate(entity, ctx));`);
} else {
writer.write(`;`);
}
})
.formatText();
if (!entityRef.isTranslatable()) {
findOneMethod.setReturnType(`Promise<${entityRef.name} | null>`);
}
}

function customizeFindAllMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const findAllMethod = serviceClassDeclaration.getMethod('findAll')!;
findAllMethod
.setBodyText(writer => {
writer.writeLine(`return this.listQueryBuilder`);
writer.write(`.build(${entityRef.name}, options,`).block(() => {
writer.writeLine('relations,');
writer.writeLine('ctx,');
writer.writeLine('channelId: ctx.channelId,');
});
writer.write(')');
writer.write('.getManyAndCount()');
writer.write('.then(([items, totalItems]) =>').block(() => {
writer.write('return').block(() => {
if (entityRef.isTranslatable()) {
writer.writeLine('items: items.map(item => this.translator.translate(item, ctx)),');
} else {
writer.writeLine('items,');
}
writer.writeLine('totalItems,');
});
});
writer.write(');');
})
.formatText();
if (!entityRef.isTranslatable()) {
findAllMethod.setReturnType(`Promise<PaginatedList<${entityRef.name}>>`);
}
}

function customizeCreateMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const createMethod = serviceClassDeclaration.getMethod('create')!;
createMethod
.setBodyText(writer => {
if (entityRef.isTranslatable()) {
writer.write(`const newEntity = await this.translatableSaver.create({
ctx,
input,
entityType: ${entityRef.name},
translationType: ${entityRef.getTranslationClass()?.getName() as string},
beforeSave: async f => {
// Any pre-save logic can go here
},
});`);
} else {
writer.writeLine(
`const newEntity = await this.connection.getRepository(ctx, ${entityRef.name}).save(input);`,
);
}
if (entityRef.hasCustomFields()) {
writer.writeLine(
`await this.customFieldRelationService.updateRelations(ctx, ${entityRef.name}, input, newEntity);`,
);
}
writer.writeLine(`return assertFound(this.findOne(ctx, newEntity.id));`);
})
.formatText();
if (!entityRef.isTranslatable()) {
createMethod.setReturnType(`Promise<${entityRef.name} | null>`);
}
}

function customizeUpdateMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const updateMethod = serviceClassDeclaration.getMethod('update')!;
updateMethod
.setBodyText(writer => {
if (entityRef.isTranslatable()) {
writer.write(`const updatedEntity = await this.translatableSaver.update({
ctx,
input,
entityType: ${entityRef.name},
translationType: ${entityRef.getTranslationClass()?.getName() as string},
beforeSave: async f => {
// Any pre-save logic can go here
},
});`);
} else {
writer.writeLine(
`const entity = await this.connection.getEntityOrThrow(ctx, ${entityRef.name}, input.id);`,
);
writer.writeLine(`const updatedEntity = patchEntity(entity, input);`);
writer.writeLine(
`await this.connection.getRepository(ctx, ${entityRef.name}).save(updatedEntity, { reload: false });`,
);
}
if (entityRef.hasCustomFields()) {
writer.writeLine(
`await this.customFieldRelationService.updateRelations(ctx, ${entityRef.name}, input, updatedEntity);`,
);
}
writer.writeLine(`return assertFound(this.findOne(ctx, updatedEntity.id));`);
})
.formatText();
if (!entityRef.isTranslatable()) {
updateMethod.setReturnType(`Promise<${entityRef.name} | null>`);
}
}

function customizeInputInterfaces(serviceSourceFile: SourceFile, entityRef: EntityRef) {
const createInputInterface = serviceSourceFile
.getInterface('CreateEntityInput')
?.rename(`Create${entityRef.name}Input`);
const updateInputInterface = serviceSourceFile
.getInterface('UpdateEntityInput')
?.rename(`Update${entityRef.name}Input`);
if (!entityRef.hasCustomFields()) {
createInputInterface?.getProperty('customFields')?.remove();
updateInputInterface?.getProperty('customFields')?.remove();
}
if (entityRef.isTranslatable()) {
createInputInterface
?.getProperty('translations')
?.setType(`Array<TranslationInput<${entityRef.name}>>`);
updateInputInterface
?.getProperty('translations')
?.setType(`Array<TranslationInput<${entityRef.name}>>`);
} else {
createInputInterface?.getProperty('translations')?.remove();
updateInputInterface?.getProperty('translations')?.remove();
}
}

function removedUnusedConstructorArgs(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
const isTranslatable = entityRef.isTranslatable();
const hasCustomFields = entityRef.hasCustomFields();
serviceClassDeclaration.getConstructors().forEach(constructor => {
constructor.getParameters().forEach(param => {
const paramName = param.getName();
if ((paramName === 'translatableSaver' || paramName === 'translator') && !isTranslatable) {
param.remove();
}
if (paramName === 'customFieldRelationService' && !hasCustomFields) {
param.remove();
}
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Ctx, Product, RequestContext, TransactionalConnection } from '@vendure/core';

@Injectable()
export class BasicServiceTemplate {
constructor(private connection: TransactionalConnection) {}

async exampleMethod(@Ctx() ctx: RequestContext) {
// Add your method logic here
const result = await this.connection.getRepository(ctx, Product).findOne({});
return result;
}
}
Loading

0 comments on commit e29accc

Please sign in to comment.