Skip to content
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
11 changes: 7 additions & 4 deletions contracts/usuario.forge
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
contract Product {
contract User {
id: uuid
price: float
active: boolean
createdAt: datetime
name: string
}

contract Order {
id: uuid
total: decimal
}
34 changes: 32 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import fg from 'fast-glob';
import { generateAll, generateOpenApi } from '@forge/generators';
import { generateAll, generateOpenApi, generateNest, writeNestProject } from '@forge/generators';
import { parseForgeToSemanticModel, DiagnosticsError, type SemanticModel } from '@forge/language';

async function main(): Promise<void> {
Expand All @@ -26,6 +26,11 @@ async function main(): Promise<void> {
return;
}

if (command === 'generate' && process.argv[3] === 'nest') {
await generateNestCmd(process.cwd());
return;
}

printUsage();
process.exitCode = 1;
}
Expand Down Expand Up @@ -63,12 +68,18 @@ async function compile(root: string): Promise<void> {
}

const hasEmitOpenApi = process.argv.includes('--emit') && process.argv.includes('openapi');
const hasEmitNest = process.argv.includes('--emit') && process.argv.includes('nest');

if (hasEmitOpenApi) {
const openapiContent = generateOpenApi(model);
const target = path.join(root, 'dist', 'openapi.json');
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, openapiContent, 'utf8');
console.log(`Compiled ${files.length} Forge file(s), generated ${generatedFiles.length} file(s) and OpenAPI spec at dist/openapi.json.`);
} else if (hasEmitNest) {
const nestFiles = generateNest(model);
await writeNestProject(path.join(root, 'dist', 'backend'), nestFiles);
console.log(`Compiled ${files.length} Forge file(s), generated ${generatedFiles.length} file(s) and NestJS project at dist/backend.`);
} else {
console.log(`Compiled ${files.length} Forge file(s), generated ${generatedFiles.length} file(s).`);
}
Expand All @@ -95,6 +106,25 @@ async function generateOpenApiCmd(root: string): Promise<void> {
console.log(`Generated OpenAPI spec at dist/openapi.json.`);
}

async function generateNestCmd(root: string): Promise<void> {
const config = await readConfig(root);
const files = await fg(config.contracts, { cwd: root, absolute: true, onlyFiles: true });
const models: SemanticModel[] = [];

for (const file of files) {
const source = await readFile(file, 'utf8');
models.push(await parseForgeToSemanticModel(source, { uri: pathToFileUri(file) }));
}

const model: SemanticModel = {
contracts: models.flatMap(item => item.contracts)
};

const nestFiles = generateNest(model);
await writeNestProject(path.join(root, 'dist', 'backend'), nestFiles);
console.log(`Generated NestJS project at dist/backend.`);
}

async function readConfig(root: string): Promise<{ contracts: string; output: string }> {
const configPath = path.join(root, 'forge.config.json');
if (!existsSync(configPath)) {
Expand Down Expand Up @@ -123,7 +153,7 @@ function pathToFileUri(filePath: string): string {
}

function printUsage(): void {
console.log('Usage: forge <init|compile|generate openapi> [--emit openapi]');
console.log('Usage: forge <init|compile|generate openapi|generate nest> [--emit openapi|--emit nest]');
}

main().catch(error => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/generators/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ContractModel, FieldModel, SemanticModel } from '@forge/language';
import { generateOpenApi } from './openapi/generator.js';
import { generateNest } from './nest/generator.js';
import { writeNestProject } from './nest/writer.js';

export { generateOpenApi };
export { generateOpenApi, generateNest, writeNestProject };

const tsTypeMap: Record<string, string> = {
uuid: 'string',
Expand Down
172 changes: 172 additions & 0 deletions packages/generators/src/nest/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { SemanticModel } from '@forge/language';
import { renderModule } from './templates/module.template.js';
import { renderController } from './templates/controller.template.js';
import { renderService } from './templates/service.template.js';
import { renderUserDto, renderCreateDto, renderUpdateDto } from './templates/dto.template.js';

export interface GeneratedFile {
path: string;
content: string;
}

export function generateNest(model: SemanticModel): GeneratedFile[] {
const files: GeneratedFile[] = [];

// Grouped by contract, let's prepare plural forms, etc.
const contractInfo = model.contracts.map(contract => {
const name = contract.name;
const kebabSingular = name.toLowerCase();

// Simple pluralization logic
const kebabPlural = kebabSingular.endsWith('s') ? kebabSingular : `${kebabSingular}s`;
const pascalPlural = kebabPlural.charAt(0).toUpperCase() + kebabPlural.slice(1);
const camelPlural = kebabPlural;

return {
contract,
name,
kebabSingular,
kebabPlural,
pascalPlural,
camelPlural
};
});

// 1. Generate code for each contract
for (const info of contractInfo) {
const { contract, name, kebabSingular, kebabPlural, pascalPlural, camelPlural } = info;
const dir = `src/${kebabPlural}`;

// Module
files.push({
path: `${dir}/${kebabPlural}.module.ts`,
content: renderModule(name, kebabPlural, pascalPlural)
});

// Controller
files.push({
path: `${dir}/${kebabPlural}.controller.ts`,
content: renderController(name, kebabPlural, pascalPlural, camelPlural)
});

// Service
files.push({
path: `${dir}/${kebabPlural}.service.ts`,
content: renderService(name, kebabPlural, pascalPlural, camelPlural)
});

// DTOs
files.push({
path: `${dir}/dto/${kebabSingular}.dto.ts`,
content: renderUserDto(name, contract.fields)
});

files.push({
path: `${dir}/dto/create-${kebabSingular}.dto.ts`,
content: renderCreateDto(name, contract.fields)
});

files.push({
path: `${dir}/dto/update-${kebabSingular}.dto.ts`,
content: renderUpdateDto(name)
});
}

// 2. Generate root app.module.ts
const importsStr = contractInfo
.map(info => `import { ${info.pascalPlural}Module } from './${info.kebabPlural}/${info.kebabPlural}.module';`)
.join('\n');
const modulesList = contractInfo.map(info => `${info.pascalPlural}Module`).join(', ');

const appModuleContent = `import { Module } from '@nestjs/common';
${importsStr}

@Module({
imports: [${modulesList}],
})
export class AppModule {}
`;

files.push({
path: 'src/app.module.ts',
content: appModuleContent
});

// 3. Generate root main.ts
const mainContent = `import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
`;

files.push({
path: 'src/main.ts',
content: mainContent
});

// 4. Generate tsconfig.json
const tsconfigContent = `{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
`;

files.push({
path: 'tsconfig.json',
content: tsconfigContent
});

// 5. Generate package.json
const packageContent = `{
"name": "forge-nestjs-backend",
"version": "0.0.1",
"description": "Generated NestJS backend from Forge contract definition",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/mapped-types": "*",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@types/node": "^20.3.1",
"typescript": "^5.1.3"
}
}
`;

files.push({
path: 'package.json',
content: packageContent
});

return files;
}
18 changes: 18 additions & 0 deletions packages/generators/src/nest/mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function mapForgeTypeToTs(type: string): string {
switch (type) {
case 'string':
case 'uuid':
return 'string';
case 'int':
case 'float':
case 'decimal':
return 'number';
case 'boolean':
return 'boolean';
case 'date':
case 'datetime':
return 'Date';
default:
return 'any';
}
}
46 changes: 46 additions & 0 deletions packages/generators/src/nest/templates/controller.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function renderController(
className: string,
kebabPlural: string,
pascalPlural: string,
camelPlural: string
): string {
const kebabSingular = className.toLowerCase();

return `import { Controller, Get, Post, Put, Delete, Body, Param, HttpCode, HttpStatus } from '@nestjs/common';
import { ${pascalPlural}Service } from './${kebabPlural}.service';
import { Create${className}Dto } from './dto/create-${kebabSingular}.dto';
import { Update${className}Dto } from './dto/update-${kebabSingular}.dto';
import { ${className}Dto } from './dto/${kebabSingular}.dto';

@Controller('${kebabPlural}')
export class ${pascalPlural}Controller {
constructor(private readonly ${camelPlural}Service: ${pascalPlural}Service) {}

@Get()
findAll(): ${className}Dto[] {
return this.${camelPlural}Service.findAll();
}

@Get(':id')
findOne(@Param('id') id: string): ${className}Dto {
return this.${camelPlural}Service.findOne(id);
}

@Post()
create(@Body() dto: Create${className}Dto): ${className}Dto {
return this.${camelPlural}Service.create(dto);
}

@Put(':id')
update(@Param('id') id: string, @Body() dto: Update${className}Dto): ${className}Dto {
return this.${camelPlural}Service.update(id, dto);
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
delete(@Param('id') id: string): void {
this.${camelPlural}Service.delete(id);
}
}
`;
}
32 changes: 32 additions & 0 deletions packages/generators/src/nest/templates/dto.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { FieldModel } from '@forge/language';
import { mapForgeTypeToTs } from '../mapper.js';

export function renderUserDto(className: string, fields: FieldModel[]): string {
const renderedFields = fields
.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${mapForgeTypeToTs(f.type)};`)
.join('\n');
return `export class ${className}Dto {
${renderedFields}
}
`;
}

export function renderCreateDto(className: string, fields: FieldModel[]): string {
const renderedFields = fields
.filter(f => f.name !== 'id')
.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${mapForgeTypeToTs(f.type)};`)
.join('\n');
return `export class Create${className}Dto {
${renderedFields}
}
`;
}

export function renderUpdateDto(className: string): string {
const kebabSingular = className.toLowerCase();
return `import { PartialType } from '@nestjs/mapped-types';
import { Create${className}Dto } from './create-${kebabSingular}.dto';

export class Update${className}Dto extends PartialType(Create${className}Dto) {}
`;
}
12 changes: 12 additions & 0 deletions packages/generators/src/nest/templates/module.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function renderModule(className: string, kebabPlural: string, pascalPlural: string): string {
return `import { Module } from '@nestjs/common';
import { ${pascalPlural}Controller } from './${kebabPlural}.controller';
import { ${pascalPlural}Service } from './${kebabPlural}.service';

@Module({
controllers: [${pascalPlural}Controller],
providers: [${pascalPlural}Service],
})
export class ${pascalPlural}Module {}
`;
}
Loading