├── .gitignore ├── .prettierrc.json ├── README.md ├── package.json ├── src ├── api.gen.ts ├── config.interface.ts ├── config.ts ├── i18n.gen.ts └── index.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | dist 4 | lib/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 130 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nest-client-generator 2 | 3 | Generate client api directly from [nest](https://nestjs.com/) controller. 4 | 5 | ### Install 6 | 7 | npm i nest-client-generator 8 | 9 | ### Use 10 | 11 | create generator file: 12 | 13 | ```typescript 14 | import { generateClientApi } from 'nest-client-generator'; 15 | generateClientApi({ clientPath, decorators, httpServiceTemplate, serverPath }); 16 | ``` 17 | 18 | add to scripts: 19 | `"gen-client": "ts-node ./generator && prettier --write \"./client/src/api/**/*.ts\"",` 20 | 21 | run 22 | `npm run gen-client` 23 | 24 | ### Example (angular) 25 | 26 | server controller : 27 | 28 | ```typescript 29 | import { Controller, Post, Get, Body, Req, UseInterceptors } from '@nestjs/common'; 30 | import { LoginRequest, User, signinRequest } from 'shared'; 31 | import { UserService } from 'services/user.service'; 32 | import { LoginInterceptor, GetUserAuthenticatedInterceptor } from '../middlewares/login.middleware'; 33 | import { ReqUser } from 'decorators/user.decorator'; 34 | 35 | @Controller('rest/auth') 36 | export class AuthController { 37 | constructor(private readonly authService: UserService) {} 38 | 39 | @Post('login') 40 | @UseInterceptors(LoginInterceptor) 41 | async login(@Body() user: LoginRequest): Promise<{ status: number }> { 42 | return this.authService.validateUser(user.email, user.password) as any; 43 | } 44 | 45 | @Post('signin') 46 | async signin(@Body() user: signinRequest): Promise { 47 | return this.authService.changePassword(user); 48 | } 49 | 50 | @UseInterceptors(GetUserAuthenticatedInterceptor) 51 | @Get('getUserAuthenticated') 52 | async getUserAuthenticated(@ReqUser() user: User): Promise { 53 | return user; 54 | } 55 | } 56 | ``` 57 | 58 | client generated file: 59 | 60 | ```typescript 61 | import { Injectable } from '@angular/core'; 62 | import { LoginRequest, signinRequest, User } from 'shared'; 63 | import { APIService } from './http.service'; 64 | 65 | @Injectable() 66 | export class AuthController { 67 | async login(user: LoginRequest): Promise<{ status: number }> { 68 | return new Promise(resolve => { 69 | this.api.post('rest/auth/login', user).subscribe((data: any) => resolve(data)); 70 | }); 71 | } 72 | 73 | async signin(user: signinRequest): Promise { 74 | return new Promise(resolve => { 75 | this.api.post('rest/auth/signin', user).subscribe((data: any) => resolve(data)); 76 | }); 77 | } 78 | 79 | async getUserAuthenticated(): Promise { 80 | return new Promise(resolve => { 81 | this.api.get('rest/auth/getUserAuthenticated').subscribe((data: any) => resolve(new User(data))); 82 | }); 83 | } 84 | 85 | constructor(private readonly api: APIService) {} 86 | } 87 | 88 | } 89 | 90 | ``` 91 | 92 | To using the same path for your models, add it to tsconfig: 93 | 94 | ``` 95 | "baseUrl": "./src", 96 | "paths": { 97 | "shared": ["../../shared"], 98 | "shared/*": ["../../shared/*"] 99 | } 100 | ``` 101 | 102 | [working example](https://github.com/yantrab/nest-angular/blob/master/generator/index.ts) 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-client-generator", 3 | "version": "1.1.4", 4 | "description": "generate client api diractly from controller files, using ts-morph .", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": {}, 8 | "keywords": [ 9 | "nest", 10 | "express", 11 | "client generator" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/yantrab/nest-client-generator" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/yantrab/nest-client-generator/issues" 19 | }, 20 | "author": "Yaniv Trabelsi ", 21 | "license": "ISC", 22 | "dependencies": { 23 | "json-ts": "^1.6.4", 24 | "ts-morph": "^1.0.0", 25 | "@types/node": "^10.12.24", 26 | "ts-node": "^8.0.2", 27 | "typescript": "^3.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/api.gen.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync, writeFileSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | import { Project, Scope, SyntaxKind } from 'ts-morph'; 4 | import * as defualtConfig from './config'; 5 | import { Config } from './config.interface'; 6 | export const startGenerateClientApi = (config: Config = defualtConfig) => { 7 | const clientPath = resolve(config.clientPath); 8 | const serverPath = resolve(config.serverPath); 9 | mkdirSync(clientPath, { recursive: true }); 10 | writeFileSync(clientPath + '/http.service.ts', config.httpServiceTemplate); 11 | 12 | const project = new Project(); 13 | const files = project.addExistingSourceFiles(serverPath + '/**/*controller.ts'); 14 | 15 | files.forEach(file => { 16 | const c = file.getClasses()[0]; 17 | const basePath = c 18 | .getDecorator('Controller') 19 | .getArguments()[0] 20 | .compilerNode.getText() 21 | .replace(/'/g, ''); 22 | 23 | // Remove all class Decorators & add Indectable decorator 24 | c.getDecorators().forEach(d => d.remove()); 25 | c.addDecorator({ name: 'Injectable', arguments: [] }); 26 | 27 | // Remove class constrctors & add one with di injectable 28 | c.getConstructors().forEach(constructor => constructor.remove()); 29 | c.addConstructor({ 30 | parameters: [ 31 | { 32 | isReadonly: true, 33 | type: 'APIService', 34 | name: 'api', 35 | scope: Scope.Private, 36 | }, 37 | ], 38 | }); 39 | 40 | // Add necessary imports 41 | file.addImportDeclaration({ 42 | namedImports: ['APIService'], 43 | moduleSpecifier: './http.service', 44 | }); 45 | file.addImportDeclaration({ 46 | namedImports: ['Injectable'], 47 | moduleSpecifier: '@angular/core', 48 | }); 49 | file.getImportStringLiterals()[0].getText(); 50 | const methods = c.getMethods(); 51 | methods.forEach(method => { 52 | let replacment = ''; 53 | const retrunType = method.getReturnType(); 54 | const returnTypeNode = method.getReturnTypeNode(); 55 | let resolver = 'resolve(data)'; 56 | 57 | if (!returnTypeNode) { 58 | method.setReturnType(retrunType.getText()); 59 | } else { 60 | const type = method 61 | .getReturnTypeNode() 62 | .getText() 63 | .replace('Promise<', '') 64 | .replace('>', ''); 65 | method.setReturnType(`Promise<${type}>`); 66 | 67 | if (type !== 'any' && !type.includes('{')) { 68 | const isArray = type.includes('[]'); 69 | if (isArray) { 70 | const arrayType = type.replace('[]', ''); 71 | resolver = `resolve(data.map(d => new ${arrayType}(d)))`; 72 | } else { 73 | resolver = `resolve(new ${type}(data))`; 74 | } 75 | } 76 | } 77 | method.getDecorators().forEach(d => { 78 | const name = d.getName(); 79 | if (!config.decorators[name]) { 80 | return d.remove(); 81 | } 82 | const args = d.getArguments(); 83 | const methodPath = args[0] 84 | ? args[0].compilerNode 85 | .getText() 86 | .replace(/'/g, '') 87 | .split(':')[0] 88 | : ''; 89 | const body = method 90 | .getParameters() 91 | .filter(p => p.getDecorators().find(d => d.getName() === 'Body')) 92 | .map(p => p.compilerNode.name.getText()) 93 | .join(', '); 94 | const param = method 95 | .getParameters() 96 | .filter(p => p.getDecorators().find(d => d.getName() === 'Param')) 97 | .map(p => p.compilerNode.name.getText()) 98 | .join(', '); 99 | 100 | replacment = config.decorators[name] 101 | .replace('{url}', basePath + (methodPath ? '/' + methodPath : '') + (param ? `' + ${param}+'` : '')) 102 | .replace('{body}', body ? ', ' + body : ''); 103 | d.remove(); 104 | }); 105 | 106 | method.getParameters().forEach(p => { 107 | const bodyDecorator = p.getDecorators().find(d => d.getName() === 'Body' || d.getName() === 'Param'); 108 | if (!bodyDecorator) { 109 | return p.remove(); 110 | } 111 | p.getDecorators().forEach(d => d.remove()); 112 | }); 113 | 114 | const implementation = method.getImplementation(); 115 | 116 | replacment = replacment.replace('{resolve}', resolver); 117 | implementation.setBodyText(replacment); 118 | }); 119 | for (const parameter of file.getDescendantsOfKind(SyntaxKind.Parameter)) { 120 | if (parameter.findReferencesAsNodes().length === 0) { 121 | parameter.remove(); 122 | } 123 | } 124 | file.fixMissingImports() 125 | .organizeImports() 126 | .formatText(); 127 | writeFileSync('client/src/api/' + file.getBaseName(), file.getText()); 128 | }); 129 | }; 130 | -------------------------------------------------------------------------------- /src/config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Config{ 2 | clientPath:string; 3 | serverPath:string; 4 | decorators:{Get:string, Post:string, [key: string]: string;}; 5 | httpServiceTemplate:string; 6 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const clientPath = './client/src/api/' 2 | export const serverPath = './server/src' 3 | export const decorators = { 4 | Get: "return new Promise((resolve) => this.api.get('{url}').subscribe((data:any) => {resolve}))", 5 | Post: "return new Promise((resolve) => this.api.post('{url}',{body}).subscribe((data:any) => {resolve}))" 6 | } 7 | export const httpServiceTemplate = ` 8 | import { Injectable } from "@angular/core"; 9 | import { HttpClient } from "@angular/common/http"; 10 | @Injectable() 11 | export class APIService { 12 | constructor(private httpClient: HttpClient) { } 13 | get(url) { return this.httpClient.get(url); } 14 | post(url, body) { return this.httpClient.post(url, body); } 15 | } 16 | ` -------------------------------------------------------------------------------- /src/i18n.gen.ts: -------------------------------------------------------------------------------- 1 | import { json2ts } from 'json-ts'; 2 | import { readdirSync, readFile } from 'fs' 3 | import { dirname } from 'path' 4 | const checkKeys = (a, b, path = '') => { 5 | const aData = a.data; 6 | const bData = b.data; 7 | if (!aData) console.warn('missing key :' + path + ' in file ' + a.name); 8 | if (!bData) console.warn('missing key :' + path + ' in file ' + b.name); 9 | else if (typeof (aData) == 'object') 10 | return Object.keys(aData).forEach(key => checkKeys({ name: a.name, data: aData[key] }, { name: b.name, data: bData[key] }, path + '.' + key)) 11 | 12 | } 13 | 14 | import { writeFileSync, mkdirSync } from 'fs' 15 | export const startGenerateInterfaces = (sourceFolder, dest) => { 16 | const fileNames = readdirSync(sourceFolder) 17 | Promise.all(fileNames.map(name => new Promise((resolve) => readFile(`${sourceFolder}/${name}`, 'utf8', (err, data) => resolve(data))))) 18 | .then((all: string[]) => { 19 | const jsons = all.map(f => JSON.parse(f as any)) 20 | 21 | // validate keys 22 | for (let i = 1; i < jsons.length; i++) { 23 | checkKeys({ name: fileNames[0], data: jsons[0] }, { name: fileNames[i], data: jsons[i] }); 24 | checkKeys({ name: fileNames[i], data: jsons[i] }, { name: fileNames[0], data: jsons[0] }); 25 | } 26 | 27 | // generate interfaces 28 | mkdirSync(dirname(dest), { recursive: true }); 29 | writeFileSync(dest, json2ts(all[0], { prefix: 'I18n' }).replace(/interface/g, 'export interface')) 30 | }) 31 | 32 | 33 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {startGenerateClientApi as generateClientApi} from './api.gen' 2 | export {startGenerateInterfaces as generateClientInterfaces} from './i18n.gen' -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": false, 10 | "outDir": "./lib", 11 | "lib": ["es2018"], 12 | }, 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "jsRules": { 4 | "no-unused-expression": true 5 | }, 6 | "rules": { 7 | "variable-name": { 8 | "enabled": false 9 | }, 10 | "quotemark": [true, "single"], 11 | "member-access": [false], 12 | "ordered-imports": [false], 13 | "max-line-length": [false], 14 | "member-ordering": [false], 15 | "interface-name": [false], 16 | "arrow-parens": false, 17 | "Semicolon": false, 18 | "object-literal-sort-keys": false, 19 | "max-classes-per-file": false, 20 | "variable-name": { 21 | "options": ["ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"] 22 | } 23 | }, 24 | "rulesDirectory": [] 25 | } 26 | --------------------------------------------------------------------------------