├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── demo ├── front │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ └── index.html │ ├── sdk-generator.json │ ├── snowpack.config.mjs │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── ArticleCard.tsx │ │ │ ├── AuthorDropdown.tsx │ │ │ └── CategoryDropdown.tsx │ │ ├── index.tsx │ │ ├── pages │ │ │ ├── ArticlePage.tsx │ │ │ ├── AuthorsPage.tsx │ │ │ ├── CategoriesPage.tsx │ │ │ ├── HomePage.tsx │ │ │ └── NewArticlePage.tsx │ │ └── sdk-interface.ts │ └── tsconfig.json └── server │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ ├── app.module.ts │ ├── main.ts │ ├── mikro-orm.config.ts │ └── modules │ │ ├── article │ │ ├── article.controller.ts │ │ ├── article.entity.ts │ │ ├── article.module.ts │ │ ├── article.service.ts │ │ └── dtos │ │ │ ├── article-create.dto.ts │ │ │ └── article-update.dto.ts │ │ ├── author │ │ ├── author.controller.ts │ │ ├── author.entity.ts │ │ ├── author.module.ts │ │ ├── author.service.ts │ │ └── dtos │ │ │ ├── author-create.dto.ts │ │ │ └── author-update.dto.ts │ │ └── category │ │ ├── category.controller.ts │ │ ├── category.entity.ts │ │ ├── category.module.ts │ │ ├── category.service.ts │ │ └── dtos │ │ ├── category-create.dto.ts │ │ └── category-update.dto.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── src ├── analyzer │ ├── builtin.ts │ ├── classdeps.ts │ ├── controller.ts │ ├── controllers.ts │ ├── decorator.ts │ ├── extractor.ts │ ├── index.ts │ ├── methods.ts │ ├── module.ts │ ├── params.ts │ ├── route.ts │ └── typedeps.ts ├── bin.ts ├── config.ts ├── generator │ ├── genmodules.ts │ ├── gentypes.ts │ ├── index.ts │ ├── prettier.ts │ └── sdk-interface.ts ├── logging.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /_dev 2 | /_dev.old 3 | /build 4 | /out 5 | /test-module 6 | /node_modules 7 | sdk-generator.build.log 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | front/src/sdk -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "printWidth": 140 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2021 LoneStone SAS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nest-sdk-generator by [![](https://user-images.githubusercontent.com/73881870/130991041-a4a1f0f4-21f5-4a54-a085-974f80f56ed2.png)](https://lonestone.io/) 2 | 3 | [![](https://img.shields.io/npm/v/nest-sdk-generator)](https://www.npmjs.com/package/nest-sdk-generator) 4 | 5 | `nest-sdk-generator` is a tool that allows you to build automatically and in seconds a SDK for client applications to consume a NestJS server's API. 6 | 7 | The project is split in two parts: 8 | 9 | - The **analyzer** looks for all modules, controllers, methods and types in the NestJS server, and builds a JSON schema containing all informations 10 | - The **generator** takes this JSON schema and generates a directory containing the SDK 11 | 12 | The project has been created and is currently maintained by our developers at [Lonestone](https://lonestone.io/). 13 | 14 | For a quick glance at performances, check [here](#how-fast-is-it). 15 | 16 | **Table of contents:** 17 | 18 | - [What is nest-sdk-generator and why should I use it?](#what-is-nest-sdk-generator-and-why-should-i-use-it) 19 | - [Quick usage example](#quick-usage-example) 20 | - [How fast is it?](#how-fast-is-it) 21 | - [Features](#features) 22 | - [Limitations](#limitations) 23 | - [Using the SDK](#using-the-sdk) 24 | - [Architecture](#architecture) 25 | - [Step-by-step generation tutorial](#step-by-step-generation-tutorial) 26 | - [Recommandations](#recommandations) 27 | - [SDK usage](#sdk-usage) 28 | - [Importing API types](#importing-api-types) 29 | - [External files](#external-files) 30 | - [Configuration options](#configuration-options) 31 | - [Magic types](#magic-types) 32 | - [Frequently-asked questions](#frequently-asked-questions) 33 | - [Does this replace Swagger?](#does-this-replace-swagger) 34 | - [I have a GraphQL API, what can this project do for me?](#i-have-a-graphql-api-what-can-this-project-do-for-me) 35 | - [Does the SDK has any performance overhead?](#does-the-sdk-has-any-performance-overhead) 36 | - [How do I update the SDK once I change the API's source code?](#how-do-i-update-the-sdk-once-i-change-the-apis-source-code) 37 | - [Is the SDK documented?](#is-the-sdk-documented) 38 | - [Can I add header or other data on-the-fly when making requests?](#can-i-add-header-or-other-data-on-the-fly-when-making-requests) 39 | - [Is there a way to log the requests or responses somewhere?](#is-there-a-way-to-log-the-requests-or-responses-somewhere) 40 | - [Does the API server needs to be running to generate a SDK?](#does-the-api-server-needs-to-be-running-to-generate-a-sdk) 41 | - [Can I fork this project to make my own version of it?](#can-i-fork-this-project-to-make-my-own-version-of-it) 42 | - [Can I use this project in commercial software?](#can-i-use-this-project-in-commercial-software) 43 | - [License](#license) 44 | 45 | ## What is nest-sdk-generator and why should I use it? 46 | 47 | nest-sdk-generator is a tool that creates a client-side SDK based on a NestJS REST API. The SDK can be used to call the API's routes seamlessly without any friction, and also enforces type safety by typing all parameters and return values based on the API itself. 48 | 49 | This brings several advantages, including: 50 | 51 | - Missing or bad parameters and return types will be detected at compilation time 52 | - IDE autocompletion for parameters and return values without needing to look at the doc 53 | - All API routes are listed in a single block allowing to list all at once 54 | - Routes are split across controllers, themselves split across modules, keeping the same hierarchy as your API even if the routes don't 55 | - Global request and response handler acting as the main request actor, allowing to customize requests by providing additional values like headers if required 56 | - Simple & clean placeholders for server-side types that can't be ported to client-side (e.g. ORM dictionary types) 57 | 58 | The generator also allows you to (re-)generate a full SDK in seconds with a single command. And in case something goes wrong, you'll be able to pinpoint exactly what the problem is thanks to the colorful (can be disabled) verbose output. A log file can also be used to store all output informations in one place. 59 | 60 | ## Quick usage example 61 | 62 | The SDK generator only requires you to create a small JSON configuration file to indicate where you API is located at, and to run a simple `npx nest-sdk-generator ` command. This will generate a client-side SDK hierarchised in modules, controllers and methods that will allow you to call your API's routes without manually writing the URIs yourself. The query parameters and body content is also strictly typed and automatically converted to be sent to your API. 63 | 64 | Calling methods will look like this: 65 | 66 | ```typescript 67 | import { userController } from "../sdk/userModule" 68 | import { articleController } from "../sdk/articleModule" 69 | 70 | const user = await userController.profile({ username: "jack" }) 71 | 72 | const userArticles = await articleController.getAll({ author: user.id }) 73 | 74 | const user = await articleController.publish({}, { 75 | author: user.id, 76 | content: `${user.pseudo} has already published ${userArticles.length} articles!` 77 | }) 78 | ``` 79 | 80 | You can find a complete demonstration API in the [`demo/server`](demo/server) directory, as well as a frontend using a SDK based on this API in [`demo/front`](demo/front). The SDK configuration is located in [`demo/front/sdk-generator.json`](demo/front/sdk-generator.json). 81 | 82 | ## How fast is it? 83 | 84 | On our internal test project, with 20 controllers and 80 routes, the full process takes 3 seconds (from a cold start) on a Core i7-8750H (6-core laptop CPU from 2018) to generate 84 file with 2246 lines of code. 85 | 86 | The duration should be roughly proportional to the quantity of routes and DTOs you use in your application, although on very small applications in usually won't go below 2 seconds on such a CPU. 87 | 88 | ## Features 89 | 90 | - Full support for idiomatic NestJS modules and controllers 91 | - Recursive extraction of types controllers depend on, including types located in `node_modules` 92 | - Can extract classes, interfaces, enumerations and type aliases 93 | - Fully compatible with WSL, even if packages are installed using symbolic links from Windows 94 | - Compatible with alternative package managers like PNPM 95 | - Extremely detailed output by default for easier debugging in case of errors 96 | - Tree-shaking so the compiled code will only contain the methods you use from the generated SDK, no matter its size 97 | 98 | ## Limitations 99 | 100 | nest-sdk-generator comes with a set of limitations which can find below: 101 | 102 | 1. nest-sdk-generator does not check if the source files compile correctly. Therefore, if you try to use a type that doesn't exist, the generation may still succeed although compiling the code would fail. In such case, the resulting output type is `any`. 103 | 104 | 2. A current limitation of nest-sdk-generator is that it finds a controller's module by looking for a `.module.ts` file in the current directory, and parent directories if none is found in the controller's one. This means controller files must be put under a module's directory, and two module files cannot be put in the same directory. 105 | 106 | 3. Types belonging to namespaces are currently not supported and will result in error in the generated code 107 | 108 | 4. Due to limitations of the TypeScript compiler, importing from the project's root (e.g. `import { SomeType } from "src/file.ts"` instead of `import { SomeType } from "../file.ts"`) will result in an `any` type at generation, because such types are not recognized when manipulating types. If you want to avoid this behaviour in Visual Studio Code, you can add `"typescript.preferences.importModuleSpecifier": "relative"` to your settings. 109 | 110 | ## Using the SDK 111 | 112 | The SDK exposes several directories: 113 | 114 | - One directory for each module in your NestJS application, with inside one file for each controller belonging to this module 115 | - `_types`, containing the types used by the controllers, with the same directory structure than in your original project 116 | 117 | Each controller file exposes a simple record object with keys being your controller's methods' name. Each method takes the route's arguments (e.g. the identifier in `/users/get/:id`), the request's BODY (JSON) as well as the query parameters (e.g. `?something=...`). 118 | 119 | Methods return a typed `Promise<>` with the original method's return type. 120 | 121 | - If the requests succeeds and returns a valid JSON string, it is parsed and returned by the method 122 | - If the requests fail or cannot be decoded correctly, the promise fails 123 | 124 | ## Architecture 125 | 126 | nest-sdk-generator analyzes your NestJS API using a provided configuration file, and produces a client-side SDK. This SDK is made of modules containing route methods that all call a central handler with the corresponding URI and parameters. The handler makes the request to the server and transmits the response. This is where you can customize the data to send to the server, if they need normalization, encryption, hashing, parsing, authentication, or anything else. 127 | 128 | ## Step-by-step generation tutorial 129 | 130 | Generating a SDK is made in two steps: 131 | 132 | - Creating a configuration file for the generator 133 | - Performing the generation through `sdk-generator generate` 134 | 135 | Let's suppose we have a monorepo, with our server being in `apps/api` and running at `http://localhost:3000`, while our frontend is located in `apps/front`. We have the following structure: 136 | 137 | ``` 138 | . 139 | └── apps 140 | ├── api 141 | | └── src 142 | | └── index.ts 143 | └── front 144 | └── src 145 | └── index.ts 146 | ``` 147 | 148 | We want the SDK to be located in `apps/front/sdk`. 149 | 150 | First, we must create a configuration file. Let's put it in `front/sdk-generator.json`: 151 | 152 | ```json 153 | { 154 | "apiInputPath": "../server", 155 | "sdkOutput": "sdk", 156 | "sdkInterfacePath": "sdk-interface.ts" 157 | } 158 | ``` 159 | 160 | Let's now generate the SDK: 161 | 162 | ```shell 163 | sdk-generator apps/front/sdk-generator.json 164 | ``` 165 | 166 | We now have a `apps/front/sdk` directory with our SDK inside, and a default interface located in `apps/front/sdk-interface.ts`, which will handle the requests. It will only be generated if it doesn't exist, so you can edit it and commit in your repository without worrying about it being overwritten. 167 | 168 | **NOTE:** You can disable this generation by setting the `generateDefaultSdkInterface` setting to `false` in the `sdk-generator.json` file. 169 | 170 | #### Recommandations 171 | 172 | In most cases, you should add the SDK's output path to your `.gitignore` and perform the generation automatically in your CI. 173 | 174 | If you want to save time in the CI, you can save the generated SDK as an artifact and put it in the registry with the key being the hash of the server's source directory. This way, the SDK will only be rebuilt when the source directory changes. 175 | 176 | ## SDK usage 177 | 178 | Let's suppose the NestJS server has an `UserModule` module, containing an `UserController` controller with a `getOne(id: string): Promise` method. We can use it from the SDK: 179 | 180 | ```typescript 181 | import { userController } from '/userModule/userController' 182 | 183 | const user = await userController.getOne({ id: 'some_id' }) 184 | // typeof user == UserDTO 185 | ``` 186 | 187 | Each method takes three arguments: the parameters (`:xxx` in the original method's route), the body's content, and the query (`?xxx=yyy`). The query is always optional, while the body is only optional if nothing or an empty object is expected. The parameters are only optional if no parameter, nor body, is expected. 188 | 189 | #### Importing API types 190 | 191 | It's also possible to manually import types the API depends on. 192 | 193 | For instance, let's suppose `UserDTO` comes from a shared DTO package which is installed in our NestJS API's directory under `node_modules/@project/dtos/user.d.ts`. 194 | 195 | We can import it from the SDK like this: 196 | 197 | ```typescript 198 | import { UserDTO } from '/_types/node_modules/@project/dtos/user.d' 199 | ``` 200 | 201 | It can then be used normally: 202 | 203 | ```typescript 204 | import { userController } from '/userModule/userController' 205 | import { UserDTO } from '/_types/node_modules/@project/dtos/user.d' 206 | 207 | const user: UserDTO = await userController.getOne({ id: 'some_id' }) 208 | ``` 209 | 210 | ### External files 211 | 212 | In some situations, some of the types you are importing may belong to an external directory than the provided API source directory. For instance, if you use a mono-repository, you may have your API in `apps/api` but your DTOs built as a package in `node_modules/@apps/dtos`. In such case, the files will be placed in a special directory in `_types` called `_externalX` where `X` is the depth level (the farest the exernal file is, the larger this number will be). 213 | 214 | This will make no difference when using these types, but this is a special case where the types hierarchy isn't perfectly respected. 215 | 216 | ## Configuration options 217 | 218 | Here is the list of the configuration options you can use in the JSON file: 219 | 220 | | Option name | Default | Value type | Description | 221 | | ----------------------------- | ------- | ------------- | ------------------------------------------------------------------------------------- | 222 | | `apiInputPath` | - | `string` | Path to your API's source folder (e.g. `apps/api`) | 223 | | `sdkOutput` | - | `string` | Path to generate the SDK at (e.g. `apps/front/src/sdk`) | 224 | | `sdkInterfacePath` | - | `string` | Path to the SDK interface (e.g. `apps/front/src/sdk-interface.ts`) | 225 | | `magicTypes` | `[]` | `MagicType[]` | Magic types (see below) | 226 | | `jsonOutput` | `null` | `string` | Write the analyzer's output to a file | 227 | | `jsonPrettyOutput` | `null` | `boolean` | Prettify the output JSON if the option above is enabled | 228 | | `prettierConfig` | `null` | `string` | Use a specific Prettier config (otherwise will try to find one in a parent directory) | 229 | | `prettify` | `true` | `boolean` | Set to `false` to disable SDK files prettifying | 230 | | `tsconfigFile` | `tsconfig.json` | `string` | Use a custom tsconfig file | 231 | | `overwriteOldOutputDir` | `true` | `boolean` | Set to `false` to not remove the old SDK directory when re-generating it | 232 | | `generateDefaultSdkInterface` | `true` | `boolean` | Set to `false` to not generate a default SDK interface when it does not exist yet | 233 | | `generateTimestamps` | `true` | `boolean` | Set to `false` to not put the timestamp in the generated SDK files | 234 | | `verbose` | `false` | `boolean` | Display verbose informations | 235 | | `noColor` | `false` | `boolean` | Disable colored output | 236 | 237 | ## Magic types 238 | 239 | In some cases, there are types that may not be exportable using the generator. For instance: 240 | 241 | ```tsx 242 | // ... 243 | 244 | @Entity() 245 | export class Author { 246 | // ... 247 | 248 | @OneToMany(() => Article, (article) => article.author) 249 | articles = new Collection
(this) 250 | 251 | // ... 252 | } 253 | 254 | ``` 255 | 256 | Here, the `articles` field uses the `Collection` type of Mikro-ORM, which is a circular type, meaning we can't export it. Plus, it contains lots of methods and depends on multiple Mikro-ORM types, which we don't want in our SDK. 257 | 258 | To solve this problem, we can add a `magicTypes` entry in our configuration file: 259 | 260 | ```json 261 | { 262 | // ... 263 | "magicTypes": [ 264 | { 265 | "nodeModuleFilePath": "@mikro-orm/core/entity/Collection.d.ts", 266 | "typeName": "Collection", 267 | "placeholderContent": "export type Collection = Array;" 268 | } 269 | ] 270 | } 271 | 272 | ``` 273 | 274 | Now, when the SDK generator encounters the `Collection` type when it's imported from `@mikro-orm/core/entity/Collection.d.ts`, it will link to the `placeholderContent` we provided instead. 275 | 276 | Note that some magic types are already [built-in](src/analyzer/builtin.ts), such as the one we just saw. If you use a library very frequently and think others may benefit from a new magic type, feel free to [open a PR](https://github.com/lonestone/nest-sdk-generator/pulls)! 277 | 278 | ## Frequently-asked questions 279 | 280 | ### Does this replace Swagger? 281 | 282 | No, nest-sdk-generator only generates a client-side SDK to make requests more easily to the server ; it doesn't generate a documentation by itself. Although all routes are organized with their original name and split across controllers and modules the same way they were in the API, that doesn't make a full documentation in itself. 283 | 284 | ### I have a GraphQL API, what can this project do for me? 285 | 286 | Unfortunately, nest-sdk-generator isn't compatible with GraphQL API, and for a good reason: the whole point of this project is to bring structural typing to an API, but GraphQL already provides that. So there would be no point in this tool being compatible with GraphQL projects. 287 | 288 | ### Does the SDK has any performance overhead? 289 | 290 | Absolutely not. The generated SDK is only made of simple objets that will call the handler you provided through the interface script, and the said script will be in charge of making the request (with `fetch` by default). This means that no kind of data transformation/conversion happens behind-the-scenes. 291 | 292 | ### How do I update the SDK once I change the API's source code? 293 | 294 | You simply run the same shell command you used to generate the source code originally. There isn't a special subcommand for it, as it will simply delete the old SDK and replace it with the new one. 295 | 296 | ### Is the SDK documented? 297 | 298 | Each file in the SDK uses a generic documentation, including the exact route model for route methods. This means you will be able to check what route is called by which method each time. 299 | 300 | Here is a quick glance at a generated file sample: 301 | 302 | ```ts 303 | // ... 304 | /// Parent module: articleModule 305 | /// Controller: "articleController" registered as "article" (5 routes) 306 | 307 | // ... 308 | 309 | export default { 310 | // GET @ /article 311 | getAll(params: {} = {}, body: {} = {}, query: {} = {}): Promise { 312 | return request('GET', `/article`, body, query) 313 | }, 314 | 315 | // GET @ /article/:slug 316 | getOne( 317 | params: { slug: string }, 318 | body: {} = {}, 319 | query: {} = {}, 320 | ): Promise
{ 321 | return request('GET', `/article/${params.slug}`, body, query) 322 | }, 323 | 324 | // ... 325 | } 326 | ``` 327 | 328 | ### Can I add header or other data on-the-fly when making requests? 329 | 330 | All of the SDK's methods use a central handler which calls a function you provided, where you can absolutely everything you can. You are in charge of making the requests thanks to the provided URI and query/body parameters, which means you can add, edit or remove whatever data you want. 331 | 332 | ### Is there a way to log the requests or responses somewhere? 333 | 334 | All of the SDK's methods use a central handler which calls a function you provided, where you can do absolutely everything you can. You are in charge of making 335 | the requests thanks to the provided URI and query/body parameters, which means you can write the requests and responses to the local storage, send them to a log server, or anything else. 336 | 337 | ### Does the API server needs to be running to generate a SDK? 338 | 339 | No, the SDK is generated by analyzing the source code of your API, which means you don't need a server running for this. 340 | 341 | ### Can I fork this project to make my own version of it? 342 | 343 | Absolutely, that's what the MIT license is for. Although, if you could get in touch with us to indicate what changes you made to the generator it would be hugely helpful to improve it on our side ;) 344 | 345 | ### Can I use this project in commercial software? 346 | 347 | Absolutely, the MIT license allows you to do that without any royalties or particular constraint. 348 | 349 | If you use this project to produce any kind of program, be it paid or not, we'd love to hear about it! Feel free to get in touch with us at [contact@lonestone.studio](mailto:contact@lonestone.studio) to tell us about your projects, so we can improve our tool even more with additional real-use cases. 350 | 351 | ## License 352 | 353 | This project is published under the terms of the [MIT License](LICENSE.md). 354 | -------------------------------------------------------------------------------- /demo/front/.gitignore: -------------------------------------------------------------------------------- 1 | .snowpack 2 | build 3 | node_modules 4 | src/sdk -------------------------------------------------------------------------------- /demo/front/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /demo/front/README.md: -------------------------------------------------------------------------------- 1 | # nest-sdk-generator demo frontend 2 | 3 | ## Instructions 4 | 5 | ```shell 6 | # Install dependencies 7 | yarn 8 | 9 | # Generate the SDK 10 | yarn sdk:gen 11 | 12 | # Start the frontend 13 | yarn start 14 | ``` 15 | 16 | Don't forget to start the server located in [`demo/server`](../server/README.md). 17 | -------------------------------------------------------------------------------- /demo/front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "description": "A demo frontend for the SDK generator", 4 | "author": "Lonestone ", 5 | "private": true, 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "snowpack dev --port 5000", 9 | "build": "snowpack build", 10 | "sdk:gen": "nest-sdk-generator sdk-generator.json" 11 | }, 12 | "dependencies": { 13 | "react": "^17.0.2", 14 | "react-dom": "^17.0.2", 15 | "react-router-dom": "^5.2.1" 16 | }, 17 | "devDependencies": { 18 | "@snowpack/plugin-dotenv": "^2.2.0", 19 | "@snowpack/plugin-react-refresh": "^2.5.0", 20 | "@snowpack/plugin-typescript": "^1.2.1", 21 | "@testing-library/react": "^12.0.0", 22 | "@types/react": "^17.0.19", 23 | "@types/react-dom": "^17.0.9", 24 | "@types/react-router-dom": "^5.1.8", 25 | "@types/snowpack-env": "^2.3.4", 26 | "nest-sdk-generator": "^1.1.2", 27 | "prettier": "^2.3.2", 28 | "snowpack": "^3.8.8", 29 | "typescript": "^4.4.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/front/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | nest-sdk-generator Demo App 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/front/sdk-generator.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "apiInputPath": "../server", 4 | "sdkOutput": "src/sdk", 5 | "sdkInterfacePath": "src/sdk-interface.ts" 6 | } 7 | -------------------------------------------------------------------------------- /demo/front/snowpack.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("snowpack").SnowpackUserConfig } */ 2 | export default { 3 | mount: { 4 | public: { url: '/', static: true }, 5 | src: { url: '/dist' }, 6 | }, 7 | plugins: [ 8 | '@snowpack/plugin-react-refresh', 9 | '@snowpack/plugin-dotenv', 10 | [ 11 | '@snowpack/plugin-typescript', 12 | { 13 | /* Yarn PnP workaround: see https://www.npmjs.com/package/@snowpack/plugin-typescript */ 14 | ...(process.versions.pnp ? { tsc: 'yarn pnpify tsc' } : {}), 15 | }, 16 | ], 17 | ], 18 | routes: [ 19 | /* Enable an SPA Fallback in development: */ 20 | { match: 'routes', src: '.*', dest: '/index.html' }, 21 | ], 22 | optimize: { 23 | /* Example: Bundle your final build: */ 24 | // "bundle": true, 25 | }, 26 | packageOptions: { 27 | /* ... */ 28 | }, 29 | devOptions: { 30 | /* ... */ 31 | }, 32 | buildOptions: { 33 | /* ... */ 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /demo/front/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 3 | import { ArticlePage } from './pages/ArticlePage' 4 | import { AuthorsPage } from './pages/AuthorsPage' 5 | import { CategoriesPage } from './pages/CategoriesPage' 6 | import { HomePage } from './pages/HomePage' 7 | import { NewArticlePage } from './pages/NewArticlePage' 8 | 9 | export function App() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /demo/front/src/components/ArticleCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import type { Article } from '../sdk/_types/src/modules/article/article.entity' 4 | 5 | interface Props { 6 | article: Article 7 | } 8 | 9 | export function ArticleCard({ article }: Props) { 10 | return ( 11 |
12 |

{article.title}

13 |

14 | by {article.author.displayName} 15 |

16 |
17 |         {article.content.length < 100
18 |           ? article.content
19 |           : article.content.substr(0, 100) + '...'}
20 |       
21 | Read more 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /demo/front/src/components/AuthorDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { authorController } from '../sdk/authorModule' 4 | import type { Author } from '../sdk/_types/src/modules/author/author.entity' 5 | 6 | interface Props { 7 | value: string | undefined 8 | onChange: (id: string) => void 9 | } 10 | 11 | export function AuthorDropdown({ value, onChange }: Props) { 12 | const [authors, setAuthors] = useState(undefined) 13 | 14 | useEffect(() => { 15 | // Fetch all authors 16 | authorController.getAll().then(setAuthors, (error) => alert(error)) 17 | }, []) 18 | 19 | if (!authors) { 20 | return Loading... 21 | } 22 | 23 | return ( 24 | <> 25 | {authors.length === 0 ? ( 26 | No author available 27 | ) : ( 28 | 38 | )}{' '} 39 | (manage) 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /demo/front/src/components/CategoryDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { categoryController } from '../sdk/categoryModule' 4 | import type { Category } from '../sdk/_types/src/modules/category/category.entity' 5 | 6 | interface Props { 7 | value: string | undefined 8 | onChange: (id: string) => void 9 | } 10 | 11 | export function CategoryDropdown({ value, onChange }: Props) { 12 | const [categories, setCategories] = useState( 13 | undefined, 14 | ) 15 | 16 | useEffect(() => { 17 | // Fetch all categories 18 | categoryController.getAll().then(setCategories, (error) => alert(error)) 19 | }, []) 20 | 21 | if (!categories) { 22 | return Loading... 23 | } 24 | 25 | return ( 26 | <> 27 | {categories.length === 0 ? ( 28 | No category available 29 | ) : ( 30 | 40 | )}{' '} 41 | (manage) 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /demo/front/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { App } from './App' 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root'), 10 | ) 11 | 12 | // Hot Module Replacement (HMR) - Remove this snippet to remove HMR. 13 | // Learn more: https://snowpack.dev/concepts/hot-module-replacement 14 | if (import.meta.hot) { 15 | import.meta.hot.accept() 16 | } 17 | -------------------------------------------------------------------------------- /demo/front/src/pages/ArticlePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Link, useHistory, useParams } from 'react-router-dom' 3 | import { articleController } from '../sdk/articleModule' 4 | import type { Article } from '../sdk/_types/src/modules/article/article.entity' 5 | 6 | export function ArticlePage() { 7 | const { slug } = useParams<{ slug: string }>() 8 | const [article, setArticle] = useState
(null) 9 | const history = useHistory() 10 | 11 | useEffect(() => { 12 | // Fetch article 13 | articleController.getOne({ slug }).then(setArticle) 14 | }, []) 15 | 16 | const handleDelete = async () => { 17 | if (article && confirm('Do you really want to delete this article?')) { 18 | // Delete article 19 | await articleController.delete({ id: article.id }) 20 | alert('Article was succesfully removed!') 21 | history.push('/') 22 | } 23 | } 24 | 25 | if (article === null) { 26 | return ( 27 |

28 | Loading... 29 |

30 | ) 31 | } 32 | 33 | return ( 34 | <> 35 |

36 | {article.title}{' '} 37 | 38 | by {article.author.displayName} 39 | 40 |

41 |
42 |         {article.content}
43 |       
44 |

45 | 46 |

47 | <- Home page 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /demo/front/src/pages/AuthorsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { authorController } from '../sdk/authorModule' 3 | import type { Author } from '../sdk/_types/src/modules/author/author.entity' 4 | 5 | export function AuthorsPage() { 6 | const [authors, setAuthors] = useState(null) 7 | 8 | useEffect(() => { 9 | // Fetch all authors 10 | authorController.getAll().then(setAuthors, (error) => alert(error)) 11 | }, []) 12 | 13 | const handleCreate = async () => { 14 | const displayName = prompt("Author's name?") 15 | if (!displayName) return 16 | // Create new author 17 | const author = await authorController.create({}, { displayName }) 18 | setAuthors(authors ? [...authors, author] : [author]) 19 | alert('Success!') 20 | } 21 | 22 | return ( 23 | <> 24 |

Authors

25 | {authors === null ? ( 26 | Loading... 27 | ) : authors.length === 0 ? ( 28 | No author to display 29 | ) : ( 30 |
    31 | {authors.map((author) => ( 32 |
  • {author.displayName}
  • 33 | ))} 34 |
35 | )} 36 |

37 | 40 |

41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /demo/front/src/pages/CategoriesPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { categoryController } from '../sdk/categoryModule' 3 | import type { Category } from '../sdk/_types/src/modules/category/category.entity' 4 | 5 | export function CategoriesPage() { 6 | const [categories, setCategories] = useState(null) 7 | 8 | useEffect(() => { 9 | // Fetch all categories 10 | categoryController.getAll().then(setCategories, (error) => alert(error)) 11 | }, []) 12 | 13 | const handleCreate = async () => { 14 | const title = prompt("Category's name?") 15 | if (!title) return 16 | 17 | // Create category 18 | const category = await categoryController.create({}, { title }) 19 | setCategories(categories ? [...categories, category] : [category]) 20 | alert('Success!') 21 | } 22 | 23 | return ( 24 | <> 25 |

Categories

26 | {categories === null ? ( 27 | Loading... 28 | ) : categories.length === 0 ? ( 29 | No category to display 30 | ) : ( 31 |
    32 | {categories.map((category) => ( 33 |
  • {category.title}
  • 34 | ))} 35 |
36 | )} 37 |

38 | 41 |

42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /demo/front/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { ArticleCard } from '../components/ArticleCard' 4 | import { articleController } from '../sdk/articleModule' 5 | import type { Article } from '../sdk/_types/src/modules/article/article.entity' 6 | 7 | export function HomePage() { 8 | const [articles, setArticles] = useState(null) 9 | 10 | useEffect(() => { 11 | // Fetch all articles 12 | articleController.getAll().then(setArticles) 13 | }, []) 14 | 15 | return ( 16 | <> 17 |

Home page

18 | {!articles ? ( 19 | Loading... 20 | ) : articles.length === 0 ? ( 21 | No article to display 22 | ) : ( 23 | articles.map((article) => ( 24 | 25 | )) 26 | )} 27 | 28 | Create a new article 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /demo/front/src/pages/NewArticlePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import { AuthorDropdown } from '../components/AuthorDropdown' 4 | import { CategoryDropdown } from '../components/CategoryDropdown' 5 | import { articleController } from '../sdk/articleModule' 6 | 7 | export function NewArticlePage() { 8 | const [title, setTitle] = useState('') 9 | const [slug, setSlug] = useState('') 10 | const [content, setContent] = useState('') 11 | const [authorId, setAuthorId] = useState(undefined) 12 | const [categoryId, setCategoryId] = useState(undefined) 13 | 14 | const history = useHistory() 15 | 16 | const handleSubmit = async () => { 17 | if (!authorId) { 18 | return alert('Please select an author') 19 | } 20 | if (!categoryId) { 21 | return alert('Please select a category') 22 | } 23 | 24 | const article = await articleController.create( 25 | {}, 26 | { 27 | title, 28 | slug, 29 | content, 30 | authorId, 31 | categoryId, 32 | }, 33 | ) 34 | 35 | history.push(`/articles/${article.slug}`) 36 | } 37 | 38 | return ( 39 | <> 40 |

New article

41 |

42 | Author: 43 |

44 |

45 | Category:{' '} 46 | 47 |

48 |

49 | setSlug(e.target.value)} 54 | /> 55 |

56 |

57 | setTitle(e.target.value)} 62 | /> 63 |

64 | 71 |

72 | 75 |

76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /demo/front/src/sdk-interface.ts: -------------------------------------------------------------------------------- 1 | export async function request( 2 | method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', 3 | uri: string, 4 | body: unknown, 5 | query: Record, 6 | ): Promise { 7 | const url = new URL('http://localhost:3000' + uri) 8 | url.search = new URLSearchParams(query).toString() 9 | 10 | const params: RequestInit = { 11 | method, 12 | // Required for content to be correctly parsed by NestJS 13 | headers: { 'Content-Type': 'application/json' }, 14 | } 15 | 16 | // Setting a body is forbidden on GET requests 17 | if (method !== 'GET') { 18 | params.body = JSON.stringify(body) 19 | } 20 | 21 | return fetch(url.toString(), params).then((res) => { 22 | // Handle failed requests 23 | if (!res.ok) { 24 | throw Error(res.statusText) 25 | } 26 | 27 | return res.json() 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /demo/front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "baseUrl": "./", 9 | /* paths - import rewriting/resolving */ 10 | "paths": { 11 | // If you configured any Snowpack aliases, add them here. 12 | // Add this line to get types for streaming imports (packageOptions.source="remote"): 13 | // "*": [".snowpack/types/*"] 14 | // More info: https://www.snowpack.dev/guides/streaming-imports 15 | }, 16 | /* noEmit - Snowpack builds (emits) files, not tsc. */ 17 | "noEmit": true, 18 | /* Additional Options */ 19 | "strict": true, 20 | "strictPropertyInitialization": false, 21 | "skipLibCheck": true, 22 | "types": ["snowpack-env"], 23 | "forceConsistentCasingInFileNames": true, 24 | "resolveJsonModule": true, 25 | "allowSyntheticDefaultImports": true, 26 | "importsNotUsedAsValues": "error" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # demo database 37 | /demo-server-db.sqlite3 -------------------------------------------------------------------------------- /demo/server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "printWidth": 140 6 | } 7 | -------------------------------------------------------------------------------- /demo/server/README.md: -------------------------------------------------------------------------------- 1 | # nest-sdk-generator demo server 2 | 3 | To set up the demo server, simply run: 4 | 5 | ```shell 6 | # Install dependencies 7 | yarn 8 | 9 | # Create local database (SQLite, you don't need a database server running) 10 | yarn db:sync 11 | 12 | # Start the server 13 | yarn start:dev 14 | ``` 15 | -------------------------------------------------------------------------------- /demo/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "description": "A demo server for the SDK generator", 4 | "author": "Lonestone ", 5 | "private": true, 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "db:sync": "npx mikro-orm schema:update --run" 16 | }, 17 | "mikro-orm": { 18 | "useTsNode": true, 19 | "configPaths": [ 20 | "./src/mikro-orm.config.ts", 21 | "./dist/mikro-orm.config.js" 22 | ] 23 | }, 24 | "dependencies": { 25 | "@mikro-orm/core": "^4.5.9", 26 | "@mikro-orm/nestjs": "^4.3.0", 27 | "@mikro-orm/sqlite": "^4.5.9", 28 | "@nestjs/common": "^8.0.6", 29 | "@nestjs/core": "^8.0.6", 30 | "@nestjs/platform-express": "^8.0.6", 31 | "class-transformer": "^0.4.0", 32 | "class-validator": "^0.13.1", 33 | "reflect-metadata": "^0.1.13", 34 | "rimraf": "^3.0.2", 35 | "uuid": "^8.3.2" 36 | }, 37 | "devDependencies": { 38 | "@mikro-orm/cli": "^4.5.9", 39 | "@nestjs/cli": "^8.1.1", 40 | "@types/express": "^4.17.13", 41 | "@types/node": "^16.7.6", 42 | "@types/uuid": "^8.3.1", 43 | "prettier": "^2.3.2", 44 | "ts-loader": "^9.2.5", 45 | "ts-node": "^10.2.1", 46 | "tsconfig-paths": "^3.11.0", 47 | "typescript": "^4.4.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /demo/server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs' 2 | import { Module } from '@nestjs/common' 3 | import options from './mikro-orm.config' 4 | import { ArticleModule } from './modules/article/article.module' 5 | import { AuthorModule } from './modules/author/author.module' 6 | import { CategoryModule } from './modules/category/category.module' 7 | 8 | @Module({ 9 | imports: [MikroOrmModule.forRoot(options), AuthorModule, ArticleModule, CategoryModule], 10 | controllers: [], 11 | providers: [], 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /demo/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import { AppModule } from './app.module' 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule, { cors: true }) 7 | app.useGlobalPipes(new ValidationPipe()) 8 | await app.listen(3000) 9 | } 10 | 11 | bootstrap() 12 | -------------------------------------------------------------------------------- /demo/server/src/mikro-orm.config.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs' 2 | 3 | const options: MikroOrmModuleSyncOptions = { 4 | entities: ['./dist/**/*.entity.js'], 5 | entitiesTs: ['./src/**/*.entity.ts'], 6 | type: 'sqlite', 7 | dbName: 'demo-server-db.sqlite3', 8 | validate: true, 9 | strict: true, 10 | } 11 | 12 | export default options 13 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/article.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common' 2 | import { ArticleService } from './article.service' 3 | import { ArticleCreateDTO } from './dtos/article-create.dto' 4 | import { ArticleUpdateDTO } from './dtos/article-update.dto' 5 | 6 | @Controller('article') 7 | export class ArticleController { 8 | @Inject() 9 | private readonly articleService!: ArticleService 10 | 11 | @Get() 12 | getAll() { 13 | return this.articleService.getAll() 14 | } 15 | 16 | @Get(':slug') 17 | getOne(@Param('slug') slug: string) { 18 | return this.articleService.getBySlug(slug) 19 | } 20 | 21 | @Post() 22 | create(@Body() dto: ArticleCreateDTO) { 23 | return this.articleService.create(dto) 24 | } 25 | 26 | @Patch(':id') 27 | update(@Param('id') id: string, @Body() dto: ArticleUpdateDTO) { 28 | return this.articleService.update(id, dto) 29 | } 30 | 31 | @Delete(':id') 32 | delete(@Param('id') id: string) { 33 | return this.articleService.delete(id) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/article.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core' 2 | import { v4 } from 'uuid' 3 | import { Author } from '../author/author.entity' 4 | import { Category } from '../category/category.entity' 5 | 6 | @Entity() 7 | export class Article { 8 | @PrimaryKey({ type: 'string' }) 9 | id: string = v4() 10 | 11 | @Property() 12 | title!: string 13 | 14 | @Property({ unique: true }) 15 | slug!: string 16 | 17 | @Property() 18 | content!: string 19 | 20 | @ManyToOne(() => Author) 21 | author!: Author 22 | 23 | @ManyToOne(() => Category) 24 | category!: Category 25 | } 26 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/article.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs' 2 | import { Module } from '@nestjs/common' 3 | import { Author } from '../author/author.entity' 4 | import { Category } from '../category/category.entity' 5 | import { ArticleController } from './article.controller' 6 | import { Article } from './article.entity' 7 | import { ArticleService } from './article.service' 8 | 9 | @Module({ 10 | imports: [MikroOrmModule.forFeature([Article, Category, Author])], 11 | controllers: [ArticleController], 12 | providers: [ArticleService], 13 | }) 14 | export class ArticleModule {} 15 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/article.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/core' 2 | import { InjectRepository } from '@mikro-orm/nestjs' 3 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' 4 | import { Author } from '../author/author.entity' 5 | import { Category } from '../category/category.entity' 6 | import { Article } from './article.entity' 7 | import { ArticleCreateDTO } from './dtos/article-create.dto' 8 | import { ArticleUpdateDTO } from './dtos/article-update.dto' 9 | 10 | @Injectable() 11 | export class ArticleService { 12 | @InjectRepository(Article) 13 | private readonly articleRepo!: EntityRepository
14 | 15 | @InjectRepository(Category) 16 | private readonly categoryRepo!: EntityRepository 17 | 18 | @InjectRepository(Author) 19 | private readonly authorRepo!: EntityRepository 20 | 21 | private async getOrFail(id: string): Promise
{ 22 | const article = await this.articleRepo.findOne(id) 23 | 24 | if (!article) { 25 | throw new NotFoundException('Provided article ID was not found') 26 | } 27 | 28 | return article 29 | } 30 | 31 | async getAll(): Promise { 32 | return this.articleRepo.findAll({ populate: true }) 33 | } 34 | 35 | async getBySlug(slug: string): Promise
{ 36 | return this.articleRepo.findOne({ slug }, true) 37 | } 38 | 39 | async create(dto: ArticleCreateDTO): Promise
{ 40 | if (await this.getBySlug(dto.slug)) { 41 | throw new BadRequestException('An article with this slug already exists!') 42 | } 43 | 44 | const category = await this.categoryRepo.findOne(dto.categoryId) 45 | 46 | if (!category) { 47 | throw new BadRequestException('Category was not found') 48 | } 49 | 50 | const author = await this.authorRepo.findOne(dto.authorId) 51 | 52 | if (!author) { 53 | throw new BadRequestException('Author was not found') 54 | } 55 | 56 | const article = this.articleRepo.create({ 57 | ...dto, 58 | author, 59 | category, 60 | }) 61 | 62 | await this.articleRepo.persistAndFlush(article) 63 | 64 | return article 65 | } 66 | 67 | async update(id: string, dto: ArticleUpdateDTO): Promise
{ 68 | const article = await this.getOrFail(id) 69 | 70 | if (dto.title != null) { 71 | article.title = dto.title 72 | } 73 | 74 | if (dto.content != null) { 75 | article.content = dto.content 76 | } 77 | 78 | await this.articleRepo.persistAndFlush(article) 79 | 80 | return article 81 | } 82 | 83 | async delete(id: string) { 84 | const article = await this.getOrFail(id) 85 | await this.articleRepo.removeAndFlush(article) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/dtos/article-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class ArticleCreateDTO { 4 | @IsString() 5 | title!: string 6 | 7 | @IsString() 8 | slug!: string 9 | 10 | @IsString() 11 | content!: string 12 | 13 | @IsString() 14 | authorId!: string 15 | 16 | @IsString() 17 | categoryId!: string 18 | } 19 | -------------------------------------------------------------------------------- /demo/server/src/modules/article/dtos/article-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator' 2 | 3 | export class ArticleUpdateDTO { 4 | @IsString() 5 | @IsOptional() 6 | title?: string 7 | 8 | @IsString() 9 | @IsOptional() 10 | content?: string 11 | } 12 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/author.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common' 2 | import { Author } from './author.entity' 3 | import { AuthorService } from './author.service' 4 | import { AuthorCreateDTO } from './dtos/author-create.dto' 5 | import { AuthorUpdateDTO } from './dtos/author-update.dto' 6 | 7 | @Controller('author') 8 | export class AuthorController { 9 | @Inject() 10 | private readonly authorService!: AuthorService 11 | 12 | @Get() 13 | getAll() { 14 | return this.authorService.getAll() 15 | } 16 | 17 | @Get(':id/articles') 18 | articles(@Param('id') id: string) { 19 | return this.authorService.articles(id) 20 | } 21 | 22 | @Post() 23 | create(@Body() dto: AuthorCreateDTO) { 24 | return this.authorService.create(dto) 25 | } 26 | 27 | @Patch(':id') 28 | update(@Param('id') id: string, @Body() dto: AuthorUpdateDTO): Promise { 29 | return this.authorService.update(id, dto) 30 | } 31 | 32 | @Delete(':id') 33 | delete(@Param('id') id: string) { 34 | return this.authorService.delete(id) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/author.entity.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core' 2 | import { v4 } from 'uuid' 3 | import { Article } from '../article/article.entity' 4 | 5 | @Entity() 6 | export class Author { 7 | @PrimaryKey({ type: 'string' }) 8 | id: string = v4() 9 | 10 | @Property() 11 | displayName!: string 12 | 13 | @OneToMany(() => Article, (article) => article.author) 14 | articles = new Collection
(this) 15 | } 16 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/author.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs' 2 | import { Module } from '@nestjs/common' 3 | import { Article } from '../article/article.entity' 4 | import { AuthorController } from './author.controller' 5 | import { Author } from './author.entity' 6 | import { AuthorService } from './author.service' 7 | 8 | @Module({ 9 | imports: [MikroOrmModule.forFeature([Author, Article])], 10 | controllers: [AuthorController], 11 | providers: [AuthorService], 12 | }) 13 | export class AuthorModule {} 14 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/author.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/core' 2 | import { InjectRepository } from '@mikro-orm/nestjs' 3 | import { Injectable, NotFoundException } from '@nestjs/common' 4 | import { Article } from '../article/article.entity' 5 | import { Author } from './author.entity' 6 | import { AuthorCreateDTO } from './dtos/author-create.dto' 7 | import { AuthorUpdateDTO } from './dtos/author-update.dto' 8 | 9 | @Injectable() 10 | export class AuthorService { 11 | @InjectRepository(Author) 12 | private readonly authorRepo!: EntityRepository 13 | 14 | @InjectRepository(Article) 15 | private readonly articleRepo!: EntityRepository
16 | 17 | private async getOrFail(id: string): Promise { 18 | const author = await this.authorRepo.findOne(id) 19 | 20 | if (!author) { 21 | throw new NotFoundException('Provided author ID was not found') 22 | } 23 | 24 | return author 25 | } 26 | 27 | async getAll(): Promise { 28 | return this.authorRepo.findAll() 29 | } 30 | 31 | async articles(id: string): Promise { 32 | const author = await this.getOrFail(id) 33 | return this.articleRepo.find({ author }) 34 | } 35 | 36 | async create(dto: AuthorCreateDTO): Promise { 37 | const author = this.authorRepo.create(dto) 38 | 39 | await this.authorRepo.persistAndFlush(author) 40 | 41 | return author 42 | } 43 | 44 | async update(id: string, dto: AuthorUpdateDTO): Promise { 45 | const author = await this.getOrFail(id) 46 | 47 | if (dto.displayName != null) { 48 | author.displayName = dto.displayName 49 | } 50 | 51 | await this.authorRepo.persistAndFlush(author) 52 | 53 | return author 54 | } 55 | 56 | async delete(id: string) { 57 | const author = await this.getOrFail(id) 58 | 59 | await this.authorRepo.removeAndFlush(author) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/dtos/author-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class AuthorCreateDTO { 4 | @IsString() 5 | displayName!: string 6 | } 7 | -------------------------------------------------------------------------------- /demo/server/src/modules/author/dtos/author-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class AuthorUpdateDTO { 4 | @IsString() 5 | displayName?: string 6 | } 7 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/category.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common' 2 | import { CategoryService } from './category.service' 3 | import { CategoryCreateDTO } from './dtos/category-create.dto' 4 | import { CategoryUpdateDTO } from './dtos/category-update.dto' 5 | 6 | @Controller('category') 7 | export class CategoryController { 8 | @Inject() 9 | private readonly categoryService!: CategoryService 10 | 11 | @Get() 12 | getAll() { 13 | return this.categoryService.getAll() 14 | } 15 | 16 | @Get('by-title/:title') 17 | byTitle(@Param('title') title: string) { 18 | return this.categoryService.getByTitle(title) 19 | } 20 | 21 | @Get(':id/articles') 22 | articles(@Param('id') id: string) { 23 | return this.categoryService.articles(id) 24 | } 25 | 26 | @Post() 27 | create(@Body() dto: CategoryCreateDTO) { 28 | return this.categoryService.create(dto) 29 | } 30 | 31 | @Patch(':id') 32 | update(@Param('id') id: string, @Body() dto: CategoryUpdateDTO) { 33 | return this.categoryService.update(id, dto) 34 | } 35 | 36 | @Delete(':id') 37 | delete(@Param('id') id: string) { 38 | return this.categoryService.delete(id) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core' 2 | import { v4 } from 'uuid' 3 | import { Article } from '../article/article.entity' 4 | 5 | @Entity() 6 | export class Category { 7 | @PrimaryKey({ type: 'string' }) 8 | id: string = v4() 9 | 10 | @Property({ unique: true }) 11 | title!: string 12 | 13 | @OneToMany(() => Article, (article) => article.category) 14 | articles = new Collection(this) 15 | } 16 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/category.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs' 2 | import { Module } from '@nestjs/common' 3 | import { Article } from '../article/article.entity' 4 | import { CategoryController } from './category.controller' 5 | import { Category } from './category.entity' 6 | import { CategoryService } from './category.service' 7 | 8 | @Module({ 9 | imports: [MikroOrmModule.forFeature([Category, Article])], 10 | controllers: [CategoryController], 11 | providers: [CategoryService], 12 | }) 13 | export class CategoryModule {} 14 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/category.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/core' 2 | import { InjectRepository } from '@mikro-orm/nestjs' 3 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' 4 | import { Article } from '../article/article.entity' 5 | import { Category } from './category.entity' 6 | import { CategoryCreateDTO } from './dtos/category-create.dto' 7 | import { CategoryUpdateDTO } from './dtos/category-update.dto' 8 | 9 | @Injectable() 10 | export class CategoryService { 11 | @InjectRepository(Category) 12 | private readonly categoryRepo!: EntityRepository 13 | 14 | @InjectRepository(Article) 15 | private readonly articleRepo!: EntityRepository
16 | 17 | private async getOrFail(id: string): Promise { 18 | const category = await this.categoryRepo.findOne(id) 19 | 20 | if (!category) { 21 | throw new NotFoundException('Provided author ID was not found') 22 | } 23 | 24 | return category 25 | } 26 | 27 | async getAll(): Promise { 28 | return this.categoryRepo.findAll() 29 | } 30 | 31 | async getByTitle(title: string): Promise { 32 | return this.categoryRepo.findOne({ title }) 33 | } 34 | 35 | async articles(id: string): Promise { 36 | const category = await this.getOrFail(id) 37 | return this.articleRepo.find({ category }) 38 | } 39 | 40 | async create(dto: CategoryCreateDTO): Promise { 41 | if (await this.getByTitle(dto.title)) { 42 | throw new BadRequestException('A category with this title already exists!') 43 | } 44 | 45 | const category = this.categoryRepo.create(dto) 46 | 47 | await this.categoryRepo.persistAndFlush(category) 48 | 49 | return category 50 | } 51 | 52 | async update(id: string, dto: CategoryUpdateDTO): Promise { 53 | const category = await this.getOrFail(id) 54 | 55 | if (dto.title != null) { 56 | category.title = dto.title 57 | } 58 | 59 | await this.categoryRepo.persistAndFlush(category) 60 | 61 | return category 62 | } 63 | 64 | async delete(id: string) { 65 | const category = await this.getOrFail(id) 66 | await this.categoryRepo.removeAndFlush(category) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/dtos/category-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class CategoryCreateDTO { 4 | @IsString() 5 | title!: string 6 | } 7 | -------------------------------------------------------------------------------- /demo/server/src/modules/category/dtos/category-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator' 2 | 3 | export class CategoryUpdateDTO { 4 | @IsString() 5 | title?: string 6 | } 7 | -------------------------------------------------------------------------------- /demo/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /demo/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "noImplicitAny": true, 5 | "noImplicitThis": true, 6 | "noImplicitReturns": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "noUncheckedIndexedAccess": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "allowSyntheticDefaultImports": true, 16 | "module": "commonjs", 17 | "target": "es2017", 18 | "incremental": true, 19 | "declaration": true, 20 | "outDir": "dist" 21 | }, 22 | "include": ["src/**/*.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-sdk-generator", 3 | "author": "Lonestone ", 4 | "license": "MIT", 5 | "version": "1.1.3", 6 | "description": "A full-powered SDK generator for NestJS", 7 | "keywords": [ 8 | "nest", 9 | "nestjs", 10 | "sdk", 11 | "generator", 12 | "typescript", 13 | "frontend", 14 | "api" 15 | ], 16 | "homepage": "https://github.com/lonestone/nest-sdk-generator#readme", 17 | "bugs": { 18 | "url": "https://github.com/lonestone/nest-sdk-generator/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/lonestone/nest-sdk-generator.git" 23 | }, 24 | "main": "build/bin.js", 25 | "scripts": { 26 | "prepare": "npm run build", 27 | "build": "tsc -p tsconfig.json --outDir build", 28 | "start": "npm run build && node build/bin.js" 29 | }, 30 | "bin": "build/bin.js", 31 | "dependencies": { 32 | "chalk": "~4.1.2", 33 | "minimist": "~1.2.7", 34 | "prettier": "^2.7.1", 35 | "ts-morph": "~16.0.0" 36 | }, 37 | "devDependencies": { 38 | "@types/minimist": "^1.2.2", 39 | "@types/node": "~18.11.9", 40 | "@types/prettier": "^2.7.1", 41 | "typescript": "~4.8.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.4 2 | 3 | specifiers: 4 | '@types/minimist': ^1.2.2 5 | '@types/node': ~18.11.9 6 | '@types/prettier': ^2.7.1 7 | chalk: ~4.1.2 8 | minimist: ~1.2.7 9 | prettier: ^2.7.1 10 | ts-morph: ~16.0.0 11 | typescript: ~4.8.4 12 | 13 | dependencies: 14 | chalk: 4.1.2 15 | minimist: 1.2.7 16 | prettier: 2.7.1 17 | ts-morph: 16.0.0 18 | 19 | devDependencies: 20 | '@types/minimist': 1.2.2 21 | '@types/node': 18.11.9 22 | '@types/prettier': 2.7.1 23 | typescript: 4.8.4 24 | 25 | packages: 26 | 27 | /@nodelib/fs.scandir/2.1.5: 28 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 29 | engines: {node: '>= 8'} 30 | dependencies: 31 | '@nodelib/fs.stat': 2.0.5 32 | run-parallel: 1.2.0 33 | dev: false 34 | 35 | /@nodelib/fs.stat/2.0.5: 36 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} 37 | engines: {node: '>= 8'} 38 | dev: false 39 | 40 | /@nodelib/fs.walk/1.2.8: 41 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 42 | engines: {node: '>= 8'} 43 | dependencies: 44 | '@nodelib/fs.scandir': 2.1.5 45 | fastq: 1.13.0 46 | dev: false 47 | 48 | /@ts-morph/common/0.17.0: 49 | resolution: {integrity: sha512-RMSSvSfs9kb0VzkvQ2NWobwnj7TxCA9vI/IjR9bDHqgAyVbu2T0DN4wiKVqomyDWqO7dPr/tErSfq7urQ1Q37g==} 50 | dependencies: 51 | fast-glob: 3.2.12 52 | minimatch: 5.1.0 53 | mkdirp: 1.0.4 54 | path-browserify: 1.0.1 55 | dev: false 56 | 57 | /@types/minimist/1.2.2: 58 | resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} 59 | dev: true 60 | 61 | /@types/node/18.11.9: 62 | resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==} 63 | dev: true 64 | 65 | /@types/prettier/2.7.1: 66 | resolution: {integrity: sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==} 67 | dev: true 68 | 69 | /ansi-styles/4.3.0: 70 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 71 | engines: {node: '>=8'} 72 | dependencies: 73 | color-convert: 2.0.1 74 | dev: false 75 | 76 | /balanced-match/1.0.2: 77 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 78 | dev: false 79 | 80 | /brace-expansion/2.0.1: 81 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 82 | dependencies: 83 | balanced-match: 1.0.2 84 | dev: false 85 | 86 | /braces/3.0.2: 87 | resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} 88 | engines: {node: '>=8'} 89 | dependencies: 90 | fill-range: 7.0.1 91 | dev: false 92 | 93 | /chalk/4.1.2: 94 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 95 | engines: {node: '>=10'} 96 | dependencies: 97 | ansi-styles: 4.3.0 98 | supports-color: 7.2.0 99 | dev: false 100 | 101 | /code-block-writer/11.0.3: 102 | resolution: {integrity: sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw==} 103 | dev: false 104 | 105 | /color-convert/2.0.1: 106 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 107 | engines: {node: '>=7.0.0'} 108 | dependencies: 109 | color-name: 1.1.4 110 | dev: false 111 | 112 | /color-name/1.1.4: 113 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 114 | dev: false 115 | 116 | /fast-glob/3.2.12: 117 | resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} 118 | engines: {node: '>=8.6.0'} 119 | dependencies: 120 | '@nodelib/fs.stat': 2.0.5 121 | '@nodelib/fs.walk': 1.2.8 122 | glob-parent: 5.1.2 123 | merge2: 1.4.1 124 | micromatch: 4.0.5 125 | dev: false 126 | 127 | /fastq/1.13.0: 128 | resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} 129 | dependencies: 130 | reusify: 1.0.4 131 | dev: false 132 | 133 | /fill-range/7.0.1: 134 | resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} 135 | engines: {node: '>=8'} 136 | dependencies: 137 | to-regex-range: 5.0.1 138 | dev: false 139 | 140 | /glob-parent/5.1.2: 141 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 142 | engines: {node: '>= 6'} 143 | dependencies: 144 | is-glob: 4.0.3 145 | dev: false 146 | 147 | /has-flag/4.0.0: 148 | resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 149 | engines: {node: '>=8'} 150 | dev: false 151 | 152 | /is-extglob/2.1.1: 153 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 154 | engines: {node: '>=0.10.0'} 155 | dev: false 156 | 157 | /is-glob/4.0.3: 158 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 159 | engines: {node: '>=0.10.0'} 160 | dependencies: 161 | is-extglob: 2.1.1 162 | dev: false 163 | 164 | /is-number/7.0.0: 165 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 166 | engines: {node: '>=0.12.0'} 167 | dev: false 168 | 169 | /merge2/1.4.1: 170 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} 171 | engines: {node: '>= 8'} 172 | dev: false 173 | 174 | /micromatch/4.0.5: 175 | resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} 176 | engines: {node: '>=8.6'} 177 | dependencies: 178 | braces: 3.0.2 179 | picomatch: 2.3.1 180 | dev: false 181 | 182 | /minimatch/5.1.0: 183 | resolution: {integrity: sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==} 184 | engines: {node: '>=10'} 185 | dependencies: 186 | brace-expansion: 2.0.1 187 | dev: false 188 | 189 | /minimist/1.2.7: 190 | resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} 191 | dev: false 192 | 193 | /mkdirp/1.0.4: 194 | resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} 195 | engines: {node: '>=10'} 196 | hasBin: true 197 | dev: false 198 | 199 | /path-browserify/1.0.1: 200 | resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} 201 | dev: false 202 | 203 | /picomatch/2.3.1: 204 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 205 | engines: {node: '>=8.6'} 206 | dev: false 207 | 208 | /prettier/2.7.1: 209 | resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==} 210 | engines: {node: '>=10.13.0'} 211 | hasBin: true 212 | dev: false 213 | 214 | /queue-microtask/1.2.3: 215 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 216 | dev: false 217 | 218 | /reusify/1.0.4: 219 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} 220 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 221 | dev: false 222 | 223 | /run-parallel/1.2.0: 224 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 225 | dependencies: 226 | queue-microtask: 1.2.3 227 | dev: false 228 | 229 | /supports-color/7.2.0: 230 | resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 231 | engines: {node: '>=8'} 232 | dependencies: 233 | has-flag: 4.0.0 234 | dev: false 235 | 236 | /to-regex-range/5.0.1: 237 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 238 | engines: {node: '>=8.0'} 239 | dependencies: 240 | is-number: 7.0.0 241 | dev: false 242 | 243 | /ts-morph/16.0.0: 244 | resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} 245 | dependencies: 246 | '@ts-morph/common': 0.17.0 247 | code-block-writer: 11.0.3 248 | dev: false 249 | 250 | /typescript/4.8.4: 251 | resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} 252 | engines: {node: '>=4.2.0'} 253 | hasBin: true 254 | dev: true 255 | -------------------------------------------------------------------------------- /src/analyzer/builtin.ts: -------------------------------------------------------------------------------- 1 | import { MagicType } from '../config' 2 | 3 | export const builtinMagicTypes: MagicType[] = [ 4 | { 5 | nodeModuleFilePath: '@mikro-orm/core/entity/Collection.d.ts', 6 | typeName: 'Collection', 7 | placeholderContent: 'export type Collection = Array;', 8 | }, 9 | ] 10 | -------------------------------------------------------------------------------- /src/analyzer/classdeps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyze the dependencies of a class analyzed by the TypeScript compiler 3 | */ 4 | 5 | import * as path from 'path' 6 | import { ClassDeclaration, InterfaceDeclaration, ts, Type } from 'ts-morph' 7 | import { unreachable } from '../logging' 8 | import { ResolvedTypeDeps, resolveTypeDependencies } from './typedeps' 9 | 10 | export function analyzeClassDeps( 11 | decl: ClassDeclaration | InterfaceDeclaration, 12 | relativeFilePath: string, 13 | absoluteSrcPath: string 14 | ): ResolvedTypeDeps[] { 15 | if (path.isAbsolute(relativeFilePath)) { 16 | unreachable( 17 | 'Internal error: got absolute file path in class dependencies analyzer, when expecting a relative one (got {magentaBright})', 18 | relativeFilePath 19 | ) 20 | } 21 | 22 | const toLookup = new Array>() 23 | 24 | const superClasses = decl.getExtends() 25 | 26 | if (superClasses) { 27 | for (const sup of Array.isArray(superClasses) ? superClasses : [superClasses]) { 28 | toLookup.push(sup.getType()) 29 | } 30 | } 31 | 32 | for (const prop of decl.getProperties()) { 33 | toLookup.push(prop.getType()) 34 | } 35 | 36 | return toLookup.map((typeText) => resolveTypeDependencies(typeText, relativeFilePath, absoluteSrcPath)) 37 | } 38 | -------------------------------------------------------------------------------- /src/analyzer/controller.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's controllers (singles) 3 | */ 4 | 5 | import * as path from 'path' 6 | import { Node, Project } from 'ts-morph' 7 | import { debug, warn } from '../logging' 8 | import { analyzeMethods, SdkMethod } from './methods' 9 | 10 | /** 11 | * Convert a string to camel case 12 | * @param str 13 | */ 14 | function camelcase(str: string): string { 15 | return str 16 | .split(/[^a-zA-Z0-9_]/g) 17 | .map((p, i) => { 18 | const f = p.substr(0, 1) 19 | return (i === 0 ? f.toLocaleLowerCase() : f.toLocaleUpperCase()) + p.substr(1) 20 | }) 21 | .join('') 22 | } 23 | 24 | /** 25 | * SDK interface of a controller 26 | */ 27 | export interface SdkController { 28 | /** Original controller file's path */ 29 | readonly path: string 30 | /** Name of the controller's class, camel cased */ 31 | readonly camelClassName: string 32 | /** Name the controller is registered under */ 33 | readonly registrationName: string 34 | /** Controller's methods */ 35 | readonly methods: SdkMethod[] 36 | } 37 | 38 | /** 39 | * Generate a SDK interface for a controller 40 | * @param project TS-Morph project the controller is contained in 41 | * @param controllerPath Path to the controller's file 42 | * @param absoluteSrcPath Absolute path to the source directory 43 | * @returns The SDK interface of the provided controller 44 | */ 45 | export function analyzeController(project: Project, controllerPath: string, absoluteSrcPath: string): SdkController | null | Error { 46 | debug('Analyzing: {yellow}', controllerPath) 47 | 48 | // Prepare the source file to analyze 49 | const file = project.getSourceFileOrThrow(path.resolve(absoluteSrcPath, controllerPath)) 50 | 51 | // Find the controller class declaration 52 | const classDecl = file.forEachChildAsArray().find((node) => Node.isClassDeclaration(node)) 53 | 54 | if (!classDecl) { 55 | warn('No controller found in this file.') 56 | return null 57 | } 58 | 59 | if (!Node.isClassDeclaration(classDecl)) 60 | return new Error('Internal error: found class declaration statement which is not an instance of ClassDeclaration') 61 | 62 | const className = classDecl.getName() 63 | 64 | if (className === undefined) { 65 | return new Error('Internal error: failed to retrieve name of declared class') 66 | } 67 | 68 | // By default, a controller is registered under its class name 69 | // This is unless it provides an argument to its @Controller() decorator 70 | let registrationName = camelcase(className) 71 | let controllerUriPrefix: string | null = null 72 | 73 | debug('Found class declaration: {yellow}', className) 74 | 75 | // Get the @Controller() decorator 76 | const decorator = classDecl.getDecorators().find((dec) => dec.getName() === 'Controller') 77 | 78 | if (!decorator) { 79 | warn('Skipping this controller as it does not have a @Controller() decorator') 80 | return null 81 | } 82 | 83 | // Get the decorator's call expression 84 | const decCallExpr = decorator.getCallExpression() 85 | 86 | if (!decCallExpr) { 87 | warn('Skipping this controller as its @Controller() decorator is not called') 88 | return null 89 | } 90 | 91 | // Get the decorator's arguments 92 | const decExpr = decCallExpr.getArguments() 93 | 94 | if (decExpr.length > 1) { 95 | warn('Skipping this controller as its @Controller() decorator is called with more than 1 argument') 96 | return null 97 | } 98 | 99 | // Get the first argument, which is expected to be the controller's registration name 100 | // Example: `@Controller("SuperName")` will register the controller under the name "SuperName" 101 | if (decExpr[0]) { 102 | const nameArg = decExpr[0] 103 | 104 | // Variables are not supported 105 | if (!Node.isStringLiteral(nameArg)) { 106 | warn("Skipping this controller as its @Controller() decorator's argument is not a string literal") 107 | return null 108 | } 109 | 110 | // Update the registration name 111 | registrationName = camelcase(nameArg.getLiteralText()) 112 | controllerUriPrefix = registrationName 113 | debug('Registering controller {yellow} as {yellow} (as specified in @Controller())', className, registrationName) 114 | } else { 115 | // No argument was provided to the @Controller() decorator, so we stick with the original controller's name 116 | debug('@Controller() was called without argument, registering controller under name {yellow}', registrationName) 117 | } 118 | 119 | // Generate a SDK interface for the controller's methods 120 | const methods = analyzeMethods(classDecl, controllerUriPrefix, controllerPath, absoluteSrcPath) 121 | 122 | if (methods instanceof Error) { 123 | return methods 124 | } 125 | 126 | // Success! 127 | debug(`└─ Done for controller {yellow}`, controllerPath) 128 | 129 | return { 130 | path: controllerPath, 131 | camelClassName: camelcase(className), 132 | registrationName, 133 | methods, 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/analyzer/controllers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's controllers 3 | */ 4 | 5 | import * as os from 'os' 6 | import * as path from 'path' 7 | import { Project } from 'ts-morph' 8 | import { debug, format, panic, warn } from '../logging' 9 | import { findFileAbove } from '../utils' 10 | import { analyzeController, SdkController } from './controller' 11 | import { getModuleName } from './module' 12 | 13 | export type SdkModules = Map> 14 | 15 | export function analyzeControllers(controllers: string[], absoluteSrcPath: string, project: Project): SdkModules { 16 | /** Hierarchised SDK informations */ 17 | const collected = new Map>() 18 | 19 | /** 20 | * Modules cache: contains for a given directory the nearest module file's path and name 21 | * This allows to avoid having to analyze the whole directory structure for each controller 22 | */ 23 | const modulesCache = new Map() 24 | 25 | /** 26 | * Path of the declared modules 27 | * When a module is detected and put in the cache, its name is registered here along with its path 28 | * This allows to ensure there is no name clash between two different modules 29 | */ 30 | const declaredModulesPath = new Map() 31 | 32 | debug(`Analyzing {yellow} controllers...`, controllers.length) 33 | 34 | controllers.forEach((relativeControllerPath, i) => { 35 | const absoluteControllerPath = path.resolve(absoluteSrcPath, relativeControllerPath) 36 | 37 | debug( 38 | '\n{cyan} {yellow}: {magentaBright} {cyan}\n', 39 | '===== Analyzing controller', 40 | `${i + 1}/${controllers.length}`, 41 | relativeControllerPath, 42 | '=====' 43 | ) 44 | 45 | const basePath = path.dirname(absoluteControllerPath) 46 | 47 | let moduleName = modulesCache.get(basePath) 48 | 49 | // Check if the module's name is in cache 50 | if (!moduleName) { 51 | // Else, find the nearest module file 52 | const absoluteModulePath = findFileAbove(/^.*\.module\.ts$/, path.resolve(absoluteSrcPath, basePath)) 53 | 54 | if (absoluteModulePath === null) { 55 | panic('No module file was found for controller at path: {yellow}', absoluteControllerPath) 56 | } 57 | 58 | const relativeModulePath = path.relative(absoluteSrcPath, absoluteModulePath) 59 | 60 | // Get the module's name 61 | moduleName = getModuleName(project, relativeModulePath, absoluteSrcPath) 62 | 63 | debug('Discovered module: {yellow}', moduleName) 64 | 65 | // Ensure this module is unique 66 | const cachedModulePath = declaredModulesPath.get(moduleName) 67 | 68 | if (cachedModulePath) { 69 | panic( 70 | `Two modules were declared with the same name {yellow}:\n` + `- One in {yellow}\n` + `- One in {yellow}`, 71 | moduleName, 72 | cachedModulePath, 73 | relativeModulePath 74 | ) 75 | } 76 | 77 | modulesCache.set(basePath, moduleName) 78 | } 79 | 80 | if (moduleName in {}) { 81 | panic(`Detected module whose name {yellow} collides with a JavaScript's native object property`, moduleName) 82 | } 83 | 84 | let moduleSdkInfos = collected.get(moduleName) 85 | 86 | if (!moduleSdkInfos) { 87 | moduleSdkInfos = new Map() 88 | collected.set(moduleName, moduleSdkInfos) 89 | } 90 | 91 | if (i === 0) { 92 | if (process.platform === 'linux' && os.release().toLocaleLowerCase().includes('microsoft') && absoluteSrcPath.startsWith('/mnt/')) { 93 | warn("NOTE: On WSL, the first type analysis on a project located in Windows's filesystem may take a long time to complete.") 94 | warn('Please consider moving your project to WSL, or running this tool directly from Windows') 95 | } 96 | } 97 | 98 | const metadata = analyzeController(project, relativeControllerPath, absoluteSrcPath) 99 | 100 | if (metadata instanceof Error) { 101 | throw new Error(format('Failed to analyze controller at path {magenta}:\n{}', relativeControllerPath, metadata.message)) 102 | } 103 | 104 | if (metadata) { 105 | if (metadata.registrationName in {}) { 106 | panic( 107 | `Detected controller whose registration name {yellow} collides with a JavaScript's native object property`, 108 | metadata.registrationName 109 | ) 110 | } 111 | 112 | moduleSdkInfos.set(metadata.camelClassName, metadata) 113 | } 114 | }) 115 | 116 | return collected 117 | } 118 | -------------------------------------------------------------------------------- /src/analyzer/decorator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utilities for analyzing the source API's decorators 3 | */ 4 | 5 | import { Decorator, Node } from 'ts-morph' 6 | import { format } from '../logging' 7 | 8 | /** 9 | * Expect a decorator to have a single, string literal argument 10 | * @param dec The decorator 11 | * @returns Nothing if the decorator has no argument, a { lit: string } if the decorator has a string literal, an Error else 12 | */ 13 | export function expectSingleStrLitDecorator(dec: Decorator): string | null | Error { 14 | const args = dec.getArguments() 15 | 16 | if (args.length > 1) { 17 | return new Error(`Multiple (${args.length}) arguments were provided to the decorator`) 18 | } else if (args.length === 0) { 19 | return null 20 | } 21 | 22 | const [arg] = args 23 | 24 | if (!Node.isStringLiteral(arg)) { 25 | return new Error(format('The argument provided to the decorator is not a string literal:\n>>> {cyan}', arg.getText())) 26 | } 27 | 28 | return arg.getLiteralText() 29 | } 30 | -------------------------------------------------------------------------------- /src/analyzer/extractor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Types extractor to use in the generated SDK 3 | */ 4 | 5 | import * as path from 'path' 6 | import { Node, Project, SourceFile } from 'ts-morph' 7 | import { MagicType } from '../config' 8 | import { debug, format, panic, unreachable, warn } from '../logging' 9 | import { analyzeClassDeps } from './classdeps' 10 | import { SdkModules } from './controllers' 11 | import { getImportResolvedType, ResolvedTypeDeps, resolveTypeDependencies } from './typedeps' 12 | 13 | /** Valid extensions for TypeScript module files */ 14 | export const MODULE_EXTENSIONS = ['.ts', '.d.ts', '.tsx', '.d.tsx', '.js', '.jsx'] 15 | 16 | /** 17 | * Location of an imported type 18 | */ 19 | export interface TypeLocation { 20 | readonly typename: string 21 | readonly relativePathNoExt: string 22 | } 23 | 24 | /** 25 | * Location of an imported type after figuring out its extension 26 | */ 27 | export interface TypeLocationWithExt extends TypeLocation { 28 | readonly relativePath: string 29 | } 30 | 31 | /** 32 | * Extracted imported type 33 | */ 34 | export interface ExtractedType extends TypeLocationWithExt { 35 | /** Type's declaration */ 36 | readonly content: string 37 | 38 | /** Type parameters (e.g. ) */ 39 | readonly typeParams: string[] 40 | 41 | /** Types this one depends on */ 42 | readonly dependencies: TypeLocationWithExt[] 43 | } 44 | 45 | // Maps files to records mapping themselves type names to their declaration code 46 | export type TypesExtractorContent = Map> 47 | 48 | /** 49 | * Types extractor 50 | */ 51 | export class TypesExtractor { 52 | constructor( 53 | /** TS-Morph project */ 54 | public readonly project: Project, 55 | 56 | /** Absolute source path */ 57 | public readonly absoluteSrcPath: string, 58 | 59 | /** Magic types to replace non-portable types */ 60 | public readonly magicTypes: Array, 61 | 62 | /** Extracted types */ 63 | public readonly extracted: TypesExtractorContent = new Map() 64 | ) {} 65 | 66 | /** 67 | * Check if a type has already been extracted 68 | */ 69 | hasExtractedType(loc: TypeLocationWithExt): boolean { 70 | const files = this.extracted.get(loc.relativePath) 71 | return files ? files.has(loc.typename) : false 72 | } 73 | 74 | /** 75 | * Get a type that was previously extracted 76 | */ 77 | getExtractedType(loc: TypeLocationWithExt): ExtractedType | null { 78 | return this.extracted.get(loc.relativePath)?.get(loc.typename) ?? null 79 | } 80 | 81 | /** 82 | * Find the extension of a TypeScript module 83 | * @param loc 84 | * @returns 85 | */ 86 | guessExtractedTypeModuleFileExt(loc: TypeLocation): string | null { 87 | for (const ext of MODULE_EXTENSIONS) { 88 | if (this.extracted.has(loc.relativePathNoExt + ext)) { 89 | return loc.relativePathNoExt + ext 90 | } 91 | } 92 | 93 | return null 94 | } 95 | 96 | /** 97 | * Find if a type has previously been extracted, without providing its extension 98 | * @param loc 99 | * @returns 100 | */ 101 | findExtractedTypeWithoutExt(loc: TypeLocation): ExtractedType | null { 102 | for (const ext of MODULE_EXTENSIONS) { 103 | const typ = this.extracted.get(loc.relativePathNoExt + ext)?.get(loc.typename) 104 | 105 | if (typ) { 106 | return typ 107 | } 108 | } 109 | 110 | return null 111 | } 112 | 113 | /** 114 | * Memorize an extracted type so it can be reused later on 115 | * @param loc 116 | * @param extracted 117 | */ 118 | memorizeExtractedType(loc: TypeLocationWithExt, extracted: ExtractedType) { 119 | let files = this.extracted.get(loc.relativePath) 120 | 121 | if (!files) { 122 | files = new Map() 123 | this.extracted.set(loc.relativePath, files) 124 | } 125 | 126 | if (!files.has(loc.typename)) { 127 | files.set(loc.typename, extracted) 128 | } 129 | } 130 | 131 | /** 132 | * Find the relative file location of a type 133 | * @param loc 134 | * @returns 135 | */ 136 | findTypeRelativeFilePath(loc: TypeLocation): string | Error { 137 | if (path.isAbsolute(loc.relativePathNoExt)) { 138 | unreachable( 139 | 'Internal error: got absolute file path in types extractor, when expecting a relative one (got {magentaBright})', 140 | loc.relativePathNoExt 141 | ) 142 | } 143 | 144 | const absolutePathNoExt = path.resolve(this.absoluteSrcPath, loc.relativePathNoExt) 145 | 146 | const cached = this.findExtractedTypeWithoutExt(loc) 147 | 148 | if (cached) { 149 | return cached.relativePath 150 | } 151 | 152 | let relativeFilePath: string | null = null 153 | 154 | for (const ext of MODULE_EXTENSIONS) { 155 | try { 156 | this.project.getSourceFileOrThrow(absolutePathNoExt + ext) 157 | relativeFilePath = loc.relativePathNoExt + ext 158 | } catch (e) { 159 | continue 160 | } 161 | } 162 | 163 | return ( 164 | relativeFilePath ?? 165 | new Error( 166 | format('File {magenta} was not found (was expected to contain dependency type {yellow})', loc.relativePathNoExt, loc.typename) 167 | ) 168 | ) 169 | } 170 | 171 | /** 172 | * Extract a type 173 | * @param loc 174 | * @param typesPath 175 | * @returns 176 | */ 177 | extractType(loc: TypeLocation, typesPath: string[] = []): ExtractedType | Error { 178 | if (path.isAbsolute(loc.relativePathNoExt)) { 179 | unreachable( 180 | 'Internal error: got absolute file path in types extractor, when expecting a relative one (got {magentaBright})', 181 | loc.relativePathNoExt 182 | ) 183 | } 184 | 185 | // Get the absolute path of the type's parent file 186 | // We don't know its extension yet as imported file names in import statements don't have an extension 187 | const absolutePathNoExt = path.resolve(this.absoluteSrcPath, loc.relativePathNoExt) 188 | 189 | // If the type is already in cache, return it directly 190 | const cached = this.findExtractedTypeWithoutExt(loc) 191 | 192 | if (cached) { 193 | return cached 194 | } 195 | 196 | // Try to find the path with extension of the file and get it as a TS-Morph file 197 | let fileAndPath: [SourceFile, string] | null = null 198 | 199 | for (const ext of MODULE_EXTENSIONS) { 200 | try { 201 | fileAndPath = [this.project.getSourceFileOrThrow(absolutePathNoExt + ext), loc.relativePathNoExt + ext] 202 | } catch (e) { 203 | continue 204 | } 205 | } 206 | 207 | if (!fileAndPath) { 208 | return new Error( 209 | format('File {magenta} was not found (was expected to contain dependency type {yellow})', loc.relativePathNoExt, loc.typename) 210 | ) 211 | } 212 | 213 | const [file, relativeFilePath] = fileAndPath 214 | 215 | // Use magic types to replace non-portable types 216 | for (const magicType of this.magicTypes) { 217 | if (relativeFilePath.endsWith(`node_modules/${magicType.nodeModuleFilePath}`) && loc.typename === magicType.typeName) { 218 | debug( 219 | '-> '.repeat(typesPath.length + 1) + 220 | 'Found magic type {yellow} from external module file {magentaBright}, using provided placeholder.', 221 | loc.typename, 222 | relativeFilePath 223 | ) 224 | 225 | const extracted: ExtractedType = { 226 | content: '/** @file Magic placeholder from configuration file */\n\n' + magicType.placeholderContent, 227 | relativePath: relativeFilePath, 228 | relativePathNoExt: loc.relativePathNoExt, 229 | typename: loc.typename, 230 | typeParams: [], 231 | dependencies: [], 232 | } 233 | 234 | typesPath.pop() 235 | 236 | let types = this.extracted.get(relativeFilePath) 237 | 238 | if (!types) { 239 | types = new Map() 240 | this.extracted.set(relativeFilePath, types) 241 | } 242 | 243 | types.set(loc.typename, extracted) 244 | 245 | return extracted 246 | } 247 | } 248 | 249 | debug('-> '.repeat(typesPath.length + 1) + 'Extracting type {yellow} from file {magentaBright}...', loc.typename, relativeFilePath) 250 | 251 | // Analyze the type's declaration 252 | const decl = file.forEachChildAsArray().find((node) => { 253 | return ( 254 | (Node.isEnumDeclaration(node) || 255 | Node.isTypeAliasDeclaration(node) || 256 | Node.isInterfaceDeclaration(node) || 257 | Node.isClassDeclaration(node) || 258 | Node.isFunctionDeclaration(node)) && 259 | node.getName() === loc.typename 260 | ) 261 | }) 262 | 263 | if (!decl) { 264 | return new Error(format(`Type {yellow} was not found in file {magenta}`, loc.typename, relativeFilePath)) 265 | } 266 | 267 | // Handle a limitation of the tool: you can't import two types from two files at the same path with just two different extensions 268 | // Example: importing a type named "User" from two files in the same directory called "user.entity.ts" and "user.entity.js" 269 | const typ = this.findExtractedTypeWithoutExt(loc) 270 | 271 | if (typ && typ.relativePath !== relativeFilePath) { 272 | panic( 273 | 'Found two conflicting files at same path but with different extensions:\n> {magentaBright}\n> {magentaBright}', 274 | typ.relativePath, 275 | relativeFilePath 276 | ) 277 | } 278 | 279 | /** Resolved type's dependencies */ 280 | let resolvedDeps: ResolvedTypeDeps[] 281 | 282 | /** Type's parameters (e.g. ) */ 283 | let typeParams: string[] 284 | 285 | /** Type declaration */ 286 | let extractedDecl = decl.getText() 287 | 288 | // Handle enumerations 289 | if (Node.isEnumDeclaration(decl)) { 290 | resolvedDeps = [] 291 | typeParams = [] 292 | } 293 | 294 | // Handle type aliases 295 | else if (Node.isTypeAliasDeclaration(decl)) { 296 | resolvedDeps = [resolveTypeDependencies(decl.getType(), relativeFilePath, this.absoluteSrcPath)] 297 | typeParams = [] 298 | } 299 | 300 | // Handle interfaces 301 | else if (Node.isInterfaceDeclaration(decl)) { 302 | resolvedDeps = analyzeClassDeps(decl, relativeFilePath, this.absoluteSrcPath) 303 | typeParams = decl.getTypeParameters().map((tp) => tp.getText()) 304 | } 305 | 306 | // Handle classes 307 | // Methods are not handled because they shouldn't be used as DTOs and won't be decodable from JSON in all cases 308 | // This part is tricky as we remake the class from scratch using the informations we have on it, given we have to get rid 309 | // of methods as well as removing decorators 310 | else if (Node.isClassDeclaration(decl)) { 311 | const classHead = decl.getText().match(/\b(export[^{]+class[^{]+{)/) 312 | 313 | if (!classHead) { 314 | unreachable('Internal error: failed to match class head in declaration: {yellow}', decl.getText()) 315 | } 316 | 317 | extractedDecl = classHead[1] 318 | 319 | const index = decl.getType().getStringIndexType() ?? decl.getType().getNumberIndexType() 320 | 321 | if (index) { 322 | extractedDecl += '\npublic [input: string | number]: ' + getImportResolvedType(index) 323 | } 324 | 325 | // Export all members 326 | for (const member of decl.getMembers()) { 327 | if (!Node.isPropertyDeclaration(member)) { 328 | warn('Found non-property member in class {cyan}: {magenta}', decl.getName() ?? '', member.getText()) 329 | continue 330 | } 331 | 332 | const memberType = member.getType() 333 | 334 | extractedDecl += `\npublic ${member.getName()}${ 335 | member.getText().includes('?') ? '?' : member.getText().includes('!:') ? '!' : '' 336 | }: ${getImportResolvedType(memberType)};` 337 | } 338 | 339 | extractedDecl += '\n}' 340 | 341 | resolvedDeps = analyzeClassDeps(decl, relativeFilePath, this.absoluteSrcPath) 342 | typeParams = decl.getTypeParameters().map((tp) => tp.getText()) 343 | } 344 | 345 | // Handle functions 346 | else if (Node.isFunctionDeclaration(decl)) { 347 | resolvedDeps = [] 348 | typeParams = [] 349 | } 350 | 351 | // Handle unknown types 352 | else { 353 | panic('Unknown node type when extracting types: ' + decl.getKindName()) 354 | } 355 | 356 | /** Normalized dependencies */ 357 | const dependencies: TypeLocationWithExt[] = [] 358 | 359 | // Ensure we're not stuck in an infinite loop where we analyze a type A, then its dependency B, which itself depends on A, and so on 360 | if (typesPath.includes(loc.typename)) { 361 | unreachable(`Internal error: infinite loop detected in types extracted: {yellow}`, typesPath.join(' -> ')) 362 | } 363 | 364 | typesPath.push(loc.typename) 365 | 366 | // Analyze all dependencies 367 | for (const dependencyLoc of locateTypesFile(resolvedDeps)) { 368 | // If the "dependency" is one of the type's parameters (e.g. "T"), ignore this part 369 | if (typeParams.includes(dependencyLoc.typename)) { 370 | continue 371 | } 372 | 373 | // Find the dependency's relative path 374 | let relativePath: string | Error 375 | 376 | const cached = this.findExtractedTypeWithoutExt(dependencyLoc) 377 | 378 | if (cached) { 379 | relativePath = cached.relativePath 380 | } else if (typesPath.includes(dependencyLoc.typename)) { 381 | relativePath = this.findTypeRelativeFilePath(dependencyLoc) 382 | } else { 383 | const extracted = this.extractType(dependencyLoc, typesPath) 384 | relativePath = extracted instanceof Error ? extracted : extracted.relativePath 385 | } 386 | 387 | if (relativePath instanceof Error) { 388 | return new Error( 389 | format( 390 | '├─ Failed to extract type {yellow} due to an error in dependency type {yellow}\nfrom file {magenta} :\n{}', 391 | loc.typename, 392 | dependencyLoc.typename, 393 | relativeFilePath, 394 | relativePath.message.replace(/^/gm, ' ') 395 | ) 396 | ) 397 | } 398 | 399 | // Update dependencies 400 | dependencies.push({ ...dependencyLoc, relativePath }) 401 | } 402 | 403 | const extracted: ExtractedType = { 404 | ...loc, 405 | relativePath: relativeFilePath, 406 | typeParams, 407 | content: extractedDecl, 408 | dependencies, 409 | } 410 | 411 | typesPath.pop() 412 | 413 | let types = this.extracted.get(relativeFilePath) 414 | 415 | if (!types) { 416 | types = new Map() 417 | this.extracted.set(relativeFilePath, types) 418 | } 419 | 420 | // Save the dependency 421 | types.set(loc.typename, extracted) 422 | 423 | return extracted 424 | } 425 | } 426 | 427 | /** 428 | * Locate the files containing a list of a resolved types 429 | * @param resolvedTypes 430 | * @returns 431 | */ 432 | export function locateTypesFile(resolvedTypes: Array): TypeLocation[] { 433 | const out = new Array() 434 | 435 | for (const resolved of resolvedTypes) { 436 | for (const [file, types] of resolved.dependencies) { 437 | for (const typename of types) { 438 | if (!out.find((loc) => loc.typename === typename && loc.relativePathNoExt === file)) { 439 | out.push({ typename, relativePathNoExt: file }) 440 | } 441 | } 442 | } 443 | 444 | for (const typename of resolved.localTypes) { 445 | if (!out.find((loc) => loc.typename === typename && loc.relativePathNoExt === resolved.relativeFilePath)) { 446 | out.push({ typename, relativePathNoExt: resolved.relativeFilePath }) 447 | } 448 | } 449 | } 450 | 451 | return out 452 | } 453 | 454 | /** 455 | * Flatten a tree of resolved type dependencies 456 | * @param sdkModules 457 | * @returns 458 | */ 459 | export function flattenSdkResolvedTypes(sdkModules: SdkModules): ResolvedTypeDeps[] { 460 | const flattened = new Array() 461 | 462 | for (const module of sdkModules.values()) { 463 | for (const controller of module.values()) { 464 | for (const method of controller.methods.values()) { 465 | const { parameters: args, query, body } = method.params 466 | 467 | flattened.push(method.returnType) 468 | 469 | if (args) { 470 | flattened.push(...args.values()) 471 | } 472 | 473 | if (query) { 474 | flattened.push(...query.values()) 475 | } 476 | 477 | if (!body) { 478 | continue 479 | } 480 | 481 | if (body.full) { 482 | flattened.push(body.type) 483 | } else { 484 | flattened.push(...body.fields.values()) 485 | } 486 | } 487 | } 488 | } 489 | 490 | return flattened 491 | } 492 | -------------------------------------------------------------------------------- /src/analyzer/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Entrypoint of the source API analyzer, used to generate the final SDK 3 | */ 4 | 5 | import * as fs from 'fs' 6 | import * as path from 'path' 7 | import { Project } from 'ts-morph' 8 | import { Config } from '../config' 9 | import { debug, panic } from '../logging' 10 | import { builtinMagicTypes } from './builtin' 11 | import { analyzeControllers, SdkModules } from './controllers' 12 | import { flattenSdkResolvedTypes, locateTypesFile, TypesExtractor, TypesExtractorContent } from './extractor' 13 | 14 | export interface SdkContent { 15 | readonly modules: SdkModules 16 | readonly types: TypesExtractorContent 17 | } 18 | 19 | export interface MagicType { 20 | readonly package: string 21 | readonly typeName: string 22 | readonly content: string 23 | } 24 | 25 | export async function analyzerCli(config: Config): Promise { 26 | const started = Date.now() 27 | 28 | const sourcePath = path.resolve(process.cwd(), config.apiInputPath) 29 | 30 | if (!sourcePath) panic('Please provide a source directory') 31 | 32 | if (!fs.existsSync(sourcePath)) panic('Provided source path {magentaBright} does not exist', sourcePath) 33 | if (!fs.lstatSync(sourcePath).isDirectory()) panic('Provided source path is not a directory') 34 | 35 | debug(`Analyzing from source directory {yellow}`, sourcePath) 36 | 37 | if (config.jsonOutput) { 38 | const jsonOutputParentDir = path.dirname(path.resolve(process.cwd(), config.jsonOutput)) 39 | 40 | if (!fs.existsSync(jsonOutputParentDir)) { 41 | panic("Output file's parent directory {magentaBright} does not exist.", jsonOutputParentDir) 42 | } 43 | 44 | debug('Writing output to {yellow}', config.jsonOutput, config.jsonPrettyOutput ? 'beautified' : 'minified') 45 | } 46 | 47 | // ====== Find & parse 'tsconfig.json' ====== // 48 | if (config.tsconfigFile && config.tsconfigFile.includes('/')) { 49 | panic('Provided tsconfig file name contains slashes') 50 | } 51 | 52 | const tsConfigFileName = config.tsconfigFile ?? 'tsconfig.json' 53 | const tsConfigFilePath = path.join(sourcePath, tsConfigFileName) 54 | 55 | if (!fs.existsSync(tsConfigFilePath)) { 56 | panic('No {yellow} file found in provided source path {yellow}', tsConfigFileName, sourcePath) 57 | } 58 | 59 | // Create a 'ts-morph' project 60 | const project = new Project({ 61 | tsConfigFilePath, 62 | }) 63 | 64 | // Get the list of all TypeScript files in the source directory 65 | const sourceTSFiles = project.getSourceFiles().map((file) => path.relative(sourcePath, file.getFilePath())) 66 | debug(`Found {magentaBright} source files.`, sourceTSFiles.length) 67 | 68 | // Add them 69 | debug('\nAdding them to the source project...') 70 | 71 | let progressByTenth = 0 72 | let strLen = sourceTSFiles.length.toString().length 73 | 74 | const hasProgress = (filesTreated: number) => filesTreated / sourceTSFiles.length >= (progressByTenth + 1) / 10 75 | 76 | const controllers = [] 77 | 78 | for (let i = 0; i < sourceTSFiles.length; i++) { 79 | const file = sourceTSFiles[i] 80 | 81 | if (file.endsWith('.controller.ts')) { 82 | controllers.push(file) 83 | } 84 | 85 | if (hasProgress(i + 1)) { 86 | while (hasProgress(i + 1)) progressByTenth++ 87 | 88 | debug( 89 | '| Progress: {yellow} ({magentaBright} files) - {green} controller{} found', 90 | (progressByTenth * 10).toString().padStart(3, ' ') + '%', 91 | `${(i + 1).toString().padStart(strLen, ' ')} / ${sourceTSFiles.length}`, 92 | controllers.length.toString().padStart(strLen, ''), 93 | controllers.length > 1 ? 's' : '' 94 | ) 95 | } 96 | } 97 | 98 | debug('All files were added successfully.\n') 99 | 100 | const modules = analyzeControllers(controllers, sourcePath, project) 101 | 102 | // Builtin magic types are concatenated **after** the configuration's ones, as this allows users to 103 | // override the builtin ones if they want. Do **not** change the concatenation order! 104 | const magicTypes = (config.magicTypes ?? []).concat(builtinMagicTypes) 105 | 106 | const typesCache = new TypesExtractor(project, sourcePath, magicTypes) 107 | 108 | const typesToExtract = locateTypesFile(flattenSdkResolvedTypes(modules)) 109 | 110 | debug('\n==== Extracting {} type' + (typesToExtract.length > 1 ? 's' : '') + ' ====\n', typesToExtract.length) 111 | 112 | for (const loc of typesToExtract) { 113 | const result = typesCache.extractType(loc) 114 | 115 | if (result instanceof Error) { 116 | panic(result.message) 117 | } 118 | } 119 | 120 | const content: SdkContent = { 121 | modules, 122 | types: typesCache.extracted, 123 | } 124 | 125 | if (config.jsonOutput) { 126 | fs.writeFileSync( 127 | config.jsonOutput, 128 | JSON.stringify(content, (_, v) => (v instanceof Map ? Object.fromEntries(v) : v), config.jsonPrettyOutput ? 4 : 0), 129 | 'utf8' 130 | ) 131 | } 132 | 133 | debug('\n===== Done in {green}! ====', ((Date.now() - started) / 1000).toFixed(2) + 's') 134 | 135 | return content 136 | } 137 | -------------------------------------------------------------------------------- /src/analyzer/methods.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's methods 3 | */ 4 | 5 | import { blue } from 'chalk' 6 | import { ClassDeclaration, MethodDeclaration, Node } from 'ts-morph' 7 | import { debug, format } from '../logging' 8 | import { analyzeParams, SdkMethodParams } from './params' 9 | import { analyzeUri, debugUri, Route } from './route' 10 | import { ResolvedTypeDeps, resolveTypeDependencies } from './typedeps' 11 | 12 | /** 13 | * SDK interface for a single controller's method 14 | */ 15 | export interface SdkMethod { 16 | /** Method's name */ 17 | readonly name: string 18 | 19 | /** Method's HTTP method (e.g. GET / POST) */ 20 | readonly httpMethod: SdkHttpMethod 21 | 22 | /** Method's return type with resolved dependencies */ 23 | readonly returnType: ResolvedTypeDeps 24 | 25 | /** Method's parsed route */ 26 | readonly route: Route 27 | 28 | /** Method's URI path */ 29 | readonly uriPath: string 30 | 31 | /** Method's parameters */ 32 | readonly params: SdkMethodParams 33 | } 34 | 35 | /** 36 | * HTTP method of a controller's method 37 | */ 38 | export enum SdkHttpMethod { 39 | Get = 'GET', 40 | Post = 'POST', 41 | Put = 'PUT', 42 | Patch = 'PATCH', 43 | Delete = 'DELETE', 44 | } 45 | 46 | /** 47 | * Generate a SDK interface for a controller 48 | * @param controllerClass The class declaration of the controller 49 | * @param controllerUriPrefix Optional URI prefix for this controller (e.g. `@Controller("registrationName")`) 50 | * @param filePath Path to the controller's file 51 | * @param absoluteSrcPath Absolute path to the source directory 52 | */ 53 | export function analyzeMethods( 54 | controllerClass: ClassDeclaration, 55 | controllerUriPrefix: string | null, 56 | filePath: string, 57 | absoluteSrcPath: string 58 | ): SdkMethod[] | Error { 59 | // Output variable 60 | const collected = new Array() 61 | 62 | // Get the list of all methods 63 | const methods = controllerClass.forEachChildAsArray().filter((node) => node instanceof MethodDeclaration) as MethodDeclaration[] 64 | 65 | for (const method of methods) { 66 | const methodName = method.getName() 67 | 68 | debug('├─ Found method: {yellow}', methodName) 69 | 70 | // Get the HTTP decorator(s) of the method 71 | const decorators = method.getDecorators().filter((dec) => Object.keys(SdkHttpMethod).includes(dec.getName())) 72 | 73 | // We expect to have exactly one HTTP decorator 74 | if (decorators.length > 1) { 75 | // If there is more than one decorator, that's invalid, so we can't analyze the method 76 | return new Error( 77 | format('├─── Detected multiple HTTP decorators on method: {yellow}' + decorators.map((dec) => dec.getName()).join(',')) 78 | ) 79 | } else if (decorators.length === 0) { 80 | // If there isn't any HTTP decorator, this is simply not a method available from the outside and so we won't generate an interface for it 81 | debug('├─── Skipping this method as it does not have an HTTP decorator') 82 | continue 83 | } 84 | 85 | // Get the HTTP decorator 86 | const dec = decorators[0] 87 | 88 | // We need to put a '@ts-ignore' here because TypeScript doesn't like indexing an enumeration with a string key, although this works fine 89 | // @ts-ignore 90 | const httpMethod = SdkHttpMethod[dec.getName()] 91 | 92 | debug('├─── Detected HTTP method: {magentaBright}', httpMethod.toLocaleUpperCase()) 93 | 94 | // Get the arguments provided to the HTTP decorator (we expect one, the URI path) 95 | const decArgs = dec.getArguments() 96 | 97 | // The method's URI path 98 | let uriPath: string 99 | 100 | // We expect the decorator to have exactly one argument 101 | if (decArgs.length > 1) { 102 | // If we have more than one argument, that's invalid (or at least not supported here), so we can't analyze the method 103 | return new Error(`Multiple (${decArgs.length}) arguments were provided to the HTTP decorator`) 104 | } else if (decArgs.length === 0) { 105 | // If there is no argument, we take the method's name as the URI path 106 | debug('├─── No argument found for decorator, using base URI path.') 107 | uriPath = '' 108 | } else { 109 | // If we have exactly one argument, hurray! That's our URI path. 110 | const uriNameDec = decArgs[0] 111 | 112 | // Variables are not supported 113 | if (!Node.isStringLiteral(uriNameDec)) { 114 | return new Error( 115 | format('├─── The argument provided to the HTTP decorator is not a string literal:\n>> {cyan}', uriNameDec.getText()) 116 | ) 117 | } 118 | 119 | // Update the method's URI path 120 | uriPath = uriNameDec.getLiteralText() 121 | 122 | debug('├─── Detected argument in HTTP decorator, mapping this method to custom URI name') 123 | } 124 | 125 | debug('├─── Detected URI name: {yellow}', uriPath) 126 | 127 | // Analyze the method's URI 128 | const route = analyzeUri(controllerUriPrefix ? (uriPath ? `/${controllerUriPrefix}/${uriPath}` : `/` + controllerUriPrefix) : uriPath) 129 | 130 | if (route instanceof Error) { 131 | return new Error( 132 | '├─── Detected unsupported URI format:\n' + 133 | route.message 134 | .split('\n') 135 | .map((line) => '├───── ' + line) 136 | .join('\n') 137 | ) 138 | } 139 | 140 | debug('├─── Parsed URI name to route: {yellow}', debugUri(route, blue)) 141 | 142 | // Analyze the method's arguments 143 | debug('├─── Analyzing arguments...') 144 | const params = analyzeParams(httpMethod, route, method.getParameters(), filePath, absoluteSrcPath) 145 | 146 | if (params instanceof Error) return params 147 | 148 | // Get the method's return type 149 | debug('├─── Resolving return type...') 150 | const returnType = resolveTypeDependencies(method.getReturnType(), filePath, absoluteSrcPath) 151 | 152 | debug('├─── Detected return type: {cyan}', returnType.resolvedType) 153 | 154 | // Success! 155 | collected.push({ 156 | name: methodName, 157 | httpMethod, 158 | returnType, 159 | route, 160 | uriPath, 161 | params, 162 | }) 163 | } 164 | 165 | return collected 166 | } 167 | -------------------------------------------------------------------------------- /src/analyzer/module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's modules 3 | */ 4 | 5 | import * as path from 'path' 6 | import { Node, Project } from 'ts-morph' 7 | import { format, panic } from '../logging' 8 | 9 | /** 10 | * Get the name of a module 11 | * @param project TS-Morph project the module is contained in 12 | * @param modulePath Path to the module's file 13 | * @param sourcePath Path to the TypeScript root directory 14 | */ 15 | export function getModuleName(project: Project, modulePath: string, sourcePath: string): string { 16 | // Prepare the source file to analyze 17 | const file = project.getSourceFileOrThrow(path.resolve(sourcePath, modulePath)) 18 | 19 | // Find the module class declaration 20 | const classDecl = file.forEachChildAsArray().find((node) => Node.isClassDeclaration(node) && node.getDecorators().length > 0) 21 | 22 | if (!classDecl) { 23 | panic('No class declaration found in module at {yellow}', modulePath) 24 | } 25 | 26 | if (!Node.isClassDeclaration(classDecl)) 27 | panic('Internal error: found class declaration statement which is not an instance of ClassDeclaration') 28 | 29 | const moduleName = classDecl.getName() 30 | 31 | if (moduleName === undefined) { 32 | panic('Internal error: failed to retrieve name of declared class') 33 | } 34 | 35 | const decorators = classDecl.getDecorators() 36 | 37 | if (decorators.length > 1) { 38 | panic(`Found multiple decorators on module class {yellow} declared at {yellow}`, moduleName, modulePath) 39 | } 40 | 41 | const decName = decorators[0].getName() 42 | 43 | if (decName !== 'Module') { 44 | panic( 45 | format( 46 | `The decorator on module class {yellow} was expected to be a {yellow}, found an {yellow} instead\nModule path is: {yellow}`, 47 | moduleName, 48 | '@Module', 49 | '@' + decName, 50 | modulePath 51 | ) 52 | ) 53 | } 54 | 55 | return moduleName.substr(0, 1).toLocaleLowerCase() + moduleName.substr(1) 56 | } 57 | -------------------------------------------------------------------------------- /src/analyzer/params.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's controllers' methods' parameters 3 | */ 4 | 5 | import { ParameterDeclaration } from 'ts-morph' 6 | import { debug, format, warn } from '../logging' 7 | import { expectSingleStrLitDecorator } from './decorator' 8 | import { SdkHttpMethod } from './methods' 9 | import { paramsOfRoute, Route } from './route' 10 | import { ResolvedTypeDeps, resolveTypeDependencies } from './typedeps' 11 | 12 | /** 13 | * SDK interface for a controller's method's parameters 14 | */ 15 | export interface SdkMethodParams { 16 | /** Route parameters */ 17 | parameters: Map | null 18 | 19 | /** Query parameters */ 20 | query: Map | null 21 | 22 | /** Body parameters */ 23 | body: SdkMethodBodyParam | null 24 | } 25 | 26 | /** 27 | * Single body parameter in a SDK's method 28 | */ 29 | export type SdkMethodBodyParam = { full: true; type: ResolvedTypeDeps } | { full: false; fields: Map } 30 | 31 | /** 32 | * Generate a SDK interface for a controller's method's parameters 33 | * @param httpMethod The method's HTTP method 34 | * @param route The method's route 35 | * @param args The method's arguments 36 | * @param filePath Path to the controller's file 37 | * @param absoluteSrcPath Absolute path to the source directory 38 | * @returns A SDK interface for the method's parameters 39 | */ 40 | export function analyzeParams( 41 | httpMethod: SdkHttpMethod, 42 | route: Route, 43 | args: ParameterDeclaration[], 44 | filePath: string, 45 | absoluteSrcPath: string 46 | ): SdkMethodParams | Error { 47 | // The collected informations we will return 48 | const collected: SdkMethodParams = { 49 | parameters: null, 50 | query: null, 51 | body: null, 52 | } 53 | 54 | // Get the named parameters of the route 55 | const routeParams = paramsOfRoute(route) 56 | 57 | // Treat all arguments (to not confuse with the route's parameters) 58 | for (const arg of args) { 59 | const name = arg.getName() 60 | 61 | debug('├───── Detected argument: {yellow}', name) 62 | 63 | // Arguments are collected as soon as they have a decorator like @Query() or @Body() 64 | const decs = arg.getDecorators() 65 | 66 | if (decs.length === 0) { 67 | // If we have no argument, this is not an argument we are interested in, so we just skip it 68 | debug('├───── Skipping this argument as it does not have a decorator') 69 | continue 70 | } else if (decs.length > 1) { 71 | // If we have more than one decorator, this could mean we have for instance an @NotEmpty() @Query() or something like this, 72 | // which is currently not supported. 73 | return new Error('Skipping this argument as it has multiple decorators, which is currently not supported') 74 | } 75 | 76 | // Get the only decrator 77 | const dec = decs[0] 78 | const decName = dec.getName() 79 | 80 | // Treat the @Param() decorator 81 | if (decName === 'Param') { 82 | debug('├───── Detected decorator {blue}', '@Param') 83 | 84 | // We expect a single string argument for this decorator, 85 | // which is the route parameter's name 86 | const paramName = expectSingleStrLitDecorator(dec) 87 | 88 | if (paramName instanceof Error) return paramName 89 | 90 | // If there is no argument, this argument is a global receiver which maps the full set of parameters 91 | // We theorically *could* extract the type informations from this object type, but this would be insanely complex 92 | // So, we just skip it as it's a lot more simple, and is not commonly used anyway as it has a set of downsides 93 | if (paramName === null) { 94 | warn('├───── Skipping this argument as it is a generic parameters receiver, which is currently not supported') 95 | continue 96 | } 97 | 98 | // Ensure the specified parameter appears in the method's route 99 | if (!routeParams.includes(paramName)) return new Error(format('├───── Cannot map unknown parameter {yellow}', paramName)) 100 | 101 | debug('├───── Mapping argument to parameter: {yellow}', paramName) 102 | 103 | // Get the route parameter's type 104 | const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath) 105 | 106 | debug('├───── Detected parameter type: {yellow} ({magentaBright} dependencies)', typ.resolvedType, typ.dependencies.size) 107 | 108 | // Update the method's route parameters 109 | 110 | if (paramName in {}) { 111 | return new Error( 112 | format(`Detected @Param() field whose name {yellow} collides with a JavaScript's native object property`, paramName) 113 | ) 114 | } 115 | 116 | collected.parameters ??= new Map() 117 | collected.parameters.set(paramName, typ) 118 | } 119 | 120 | // Treat the @Query() decorator 121 | else if (decName === 'Query') { 122 | debug('├───── Detected decorator {blue}', '@Query') 123 | 124 | // We expect a single string argument for this decorator, 125 | // which is the query parameter's name 126 | const queryName = expectSingleStrLitDecorator(dec) 127 | 128 | if (queryName instanceof Error) return queryName 129 | 130 | // If there is no argument, this argument is a global receiver which maps the full set of parameters 131 | // We theorically *could* extract the type informations from this object type, but this would be insanely complex 132 | // So, we just skip it as it's a lot more simple, and is not commonly used anyway as it has a set of downsides 133 | if (queryName === null) { 134 | warn('├───── Skipping this argument as it is a generic query receiver') 135 | continue 136 | } 137 | 138 | debug('├───── Mapping argument to query: {yellow}', queryName) 139 | 140 | // Get the parameter's type 141 | const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath) 142 | 143 | debug(`├───── Detected query type: {yellow} ({magentaBright} dependencies)`, typ.resolvedType, typ.dependencies.size) 144 | 145 | // Update the method's query parameter 146 | 147 | if (queryName in {}) { 148 | return new Error( 149 | format(`Detected @Query() field whose name {yellow} collides with a JavaScript's native object property`, queryName) 150 | ) 151 | } 152 | 153 | collected.query ??= new Map() 154 | collected.query.set(queryName, typ) 155 | } 156 | 157 | // Treat the @Body() decorator 158 | else if (decName === 'Body') { 159 | debug('├───── Detected decorator {blue}', '@Body') 160 | 161 | // GET requests cannot have a BODY 162 | if (httpMethod === SdkHttpMethod.Get) { 163 | return new Error('GET requests cannot have a BODY!') 164 | } 165 | 166 | // We expect a single string argument for this decorator, 167 | // which is the body field's name 168 | const fieldName = expectSingleStrLitDecorator(dec) 169 | 170 | if (fieldName instanceof Error) return fieldName 171 | 172 | // Get the field's type 173 | const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath) 174 | 175 | const depsCount = typ.dependencies.size 176 | 177 | debug( 178 | `├───── Detected BODY type: {cyan} ({magentaBright} ${ 179 | depsCount === 0 ? 'no dependency' : depsCount > 1 ? 'dependencies' : 'dependency' 180 | })`, 181 | typ.resolvedType, 182 | depsCount 183 | ) 184 | 185 | // If there no name was provided to the decorator, then the decorator is a generic receiver which means it maps to the full body type 186 | // This also means we can map the BODY type to this argument's type 187 | if (fieldName === null) { 188 | const body = collected.body 189 | 190 | // If we previously had an @Body() decorator on another argument, we have an important risk of mistyping 191 | // => e.g. `@Body("a") a: string, @Body() body: { a: number }` is invalid as the type for the `a` field mismatches 192 | // => It's easy to make an error as the @Body() type is often hidden behind a DTO 193 | // But that's extremely complex to check automatically, so we just display a warning instead 194 | // Also, that's not the kind of thing we make on purpose very often, so it's more likely it's an error, which makes it even more important 195 | // to display a warning here. 196 | if (body?.full === false) 197 | warn('├───── Detected full @Body() decorator after a single parameter. This is considered a bad practice, avoid it if you can!') 198 | // Having two generic @Body() decorators is meaningless and will likey lead to errors, so we return a precise error here 199 | else if (body?.full) { 200 | return new Error( 201 | format( 202 | `Detected two @Body() decorators: found {yellow} previously, while method argument {yellow} indicates type {yellow}`, 203 | body.type.resolvedType, 204 | name, 205 | typ.resolvedType 206 | ) 207 | ) 208 | } 209 | 210 | debug("├───── Mapping argument to full request's body") 211 | 212 | // Update the whole BODY type 213 | collected.body = { full: true, type: typ } 214 | } else { 215 | // Here we have an @Body() decorator 216 | 217 | // If we previously had an @Body() decorator, this can lead to several types of errors (see the big comment above for more informations) 218 | if (collected.body?.full) { 219 | warn('├───── Detected single @Body() decorator after a full parameter. This is considered a bad practice, avoid it if you can!') 220 | } else { 221 | debug('├───── Mapping argument to BODY field: {yellow}', fieldName) 222 | 223 | // Update the BODY type by adding the current field to it 224 | 225 | if (fieldName in {}) { 226 | return new Error( 227 | format(`Detected @Body() field whose name {yellow} collides with a JavaScript's native object property`, fieldName) 228 | ) 229 | } 230 | 231 | collected.body ??= { full: false, fields: new Map() } 232 | 233 | collected.body.fields.set(fieldName, typ) 234 | } 235 | } 236 | } 237 | } 238 | 239 | // Success! 240 | return collected 241 | } 242 | -------------------------------------------------------------------------------- /src/analyzer/route.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Analyzer for the source API's routes in controller methods 3 | */ 4 | 5 | /** 6 | * A single URI part 7 | */ 8 | export type RoutePart = { readonly segment: string } | { readonly param: string } 9 | 10 | /** 11 | * A parsed URI path 12 | */ 13 | export interface Route { 14 | /** Is this an absolute route? (routes starting with a '/') */ 15 | readonly isRoot: boolean 16 | 17 | /** The route's parts */ 18 | readonly parts: RoutePart[] 19 | } 20 | 21 | /** 22 | * Analyze an URI path 23 | * @param uriPath The URI path to analyze 24 | */ 25 | export function analyzeUri(uriPath: string): Route | Error { 26 | // Split the URI path into "parts" 27 | const rawParts = uriPath.split('/') 28 | 29 | // The parsed parts we'll return 30 | const parts: RoutePart[] = [] 31 | 32 | // The current string offset in the URI path 33 | // This variable is used for error display 34 | let offset = 0 35 | 36 | /** 37 | * Find if a specific part of the URI path contains an invalid character 38 | * @param part The part to check 39 | * @param pattern The invalid characters to look for, as a regular expression 40 | * @returns An error message if an invalid character is found, nothing else 41 | */ 42 | function _findInvalid(part: string, pattern: RegExp): string | null { 43 | return part.match(pattern) ? uriPath + '\n' + ' '.repeat(offset) + '^' : null 44 | } 45 | 46 | // Treat all parts of the URI path 47 | for (let i = 0; i < rawParts.length; i++) { 48 | const part = rawParts[i] 49 | 50 | // Ignore empty parts (e.g. "/a///b" will be treated as "/a/b") 51 | if (part === '') continue 52 | 53 | // Ensure there is no generic character in the path as we don't support them 54 | const genericErr = _findInvalid(part, /[\*\+\?]/) 55 | 56 | if (genericErr) { 57 | return new Error( 58 | 'Generic symbols (* + ?) are not supported as they prevent from determining the right route to use. Found in URI:\n' + genericErr 59 | ) 60 | } 61 | 62 | // Check if this part is an URI parameter 63 | if (part.startsWith(':')) { 64 | // URI parameters must follow a strict naming 65 | const paramErr = _findInvalid(part, /[^a-zA-Z0-9_:]/) 66 | 67 | if (paramErr) { 68 | return new Error('Invalid character detected in named parameter in URI:\n' + paramErr) 69 | } 70 | 71 | // We got a parameter 72 | parts.push({ param: part.substr(1) }) 73 | } else { 74 | // We got a literal part 75 | parts.push({ segment: part }) 76 | } 77 | 78 | // Update the offset for error display 79 | offset += part.length + 1 80 | } 81 | 82 | // Success! 83 | return { 84 | isRoot: uriPath.startsWith('/'), 85 | parts, 86 | } 87 | } 88 | 89 | /** 90 | * Get the named parameters of a parsed route 91 | * @param route 92 | */ 93 | export function paramsOfRoute(route: Route): string[] { 94 | return route.parts.map((part) => ('param' in part ? part.param : null)).filter((e) => e !== null) as string[] 95 | } 96 | 97 | /** 98 | * Convert a route back to its original string 99 | * @param route 100 | */ 101 | export function unparseRoute(route: Route): string { 102 | return (route.isRoot ? '/' : '') + route.parts.map((part) => ('segment' in part ? part.segment : ':' + part.param)).join('/') 103 | } 104 | 105 | /** 106 | * Pretty-print a route 107 | */ 108 | export function debugUri(route: Route, color: (str: string) => string): string { 109 | return (route.isRoot ? '/' : '') + route.parts.map((part) => ('segment' in part ? part.segment : color(':' + part.param))).join('/') 110 | } 111 | 112 | /** 113 | * Resolve a route by providing its required parameters 114 | * @param route 115 | * @param params 116 | */ 117 | export function resolveRoute(route: Route, params: { [name: string]: string }): string | Error { 118 | let uri: string[] = [] 119 | 120 | for (const part of route.parts) { 121 | if ('segment' in part) { 122 | uri.push(part.segment) 123 | } else if (!params.hasOwnProperty(part.param)) { 124 | return new Error('Missing route parameter ' + part.param) 125 | } else { 126 | uri.push(params[part.param]) 127 | } 128 | } 129 | 130 | return (route.isRoot ? '/' : '') + uri.join('/') 131 | } 132 | 133 | /** 134 | * Resolve a route by providing its required parameters through a callback 135 | * @param route 136 | * @param paramsProvider 137 | */ 138 | export function resolveRouteWith(route: Route, paramsProvider: (param: string) => string | null): string | Error { 139 | let uri: string[] = [] 140 | 141 | for (const part of route.parts) { 142 | if ('segment' in part) { 143 | uri.push(part.segment) 144 | } else { 145 | const param = paramsProvider(part.param) 146 | 147 | if (param === null) { 148 | return new Error('Missing route parameter ' + part.param) 149 | } else { 150 | uri.push(param) 151 | } 152 | } 153 | } 154 | 155 | return (route.isRoot ? '/' : '') + uri.join('/') 156 | } 157 | -------------------------------------------------------------------------------- /src/analyzer/typedeps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Type dependencies builders from the source API 3 | */ 4 | 5 | import * as path from 'path' 6 | import { ts, Type } from 'ts-morph' 7 | import { unreachable } from '../logging' 8 | 9 | /** 10 | * Regex to match or replace imported types 11 | */ 12 | export const IMPORTED_TYPE_REGEX = /\bimport\(['"]([^'"]+)['"]\)\.([a-zA-Z0-9_]+)\b/g 13 | 14 | /** 15 | * Type with resolved level 1 dependencies (does not look for dependencies' own dependencies!) 16 | */ 17 | export interface ResolvedTypeDeps { 18 | /** The original raw type, with import("...") expressions */ 19 | readonly rawType: string 20 | 21 | /** The resolved type, without imports */ 22 | readonly resolvedType: string 23 | 24 | /** File from which the type originates */ 25 | readonly relativeFilePath: string 26 | 27 | /** 28 | * The type dependencies used by the resolved type 29 | * A collection of scripts' relative paths mapped with the list of types imported from them 30 | */ 31 | readonly dependencies: Map 32 | 33 | /** 34 | * Non-native types that are not imported 35 | * They may be either local types (declared in the same file than the one analyzed) or globally-defined types 36 | */ 37 | readonly localTypes: string[] 38 | } 39 | 40 | /** 41 | * Resolve the dependencies of a TS-Morph analyzed type 42 | * @param type 43 | * @param relativeFilePath 44 | * @param absoluteSrcPath 45 | * @returns 46 | */ 47 | export function resolveTypeDependencies(type: Type, relativeFilePath: string, absoluteSrcPath: string): ResolvedTypeDeps { 48 | /** Raw type's text (e.g. `Array`) */ 49 | const rawType = type.getText() 50 | 51 | if (path.isAbsolute(relativeFilePath)) { 52 | unreachable( 53 | 'Internal error: got absolute file path in type dependencies resolver, when expecting a relative one (got {magentaBright})', 54 | relativeFilePath 55 | ) 56 | } 57 | 58 | let dependencies: ResolvedTypeDeps['dependencies'] = new Map() 59 | let localTypes: ResolvedTypeDeps['localTypes'] = [] 60 | 61 | /** Resolved type (without import statements) */ 62 | const resolvedType: ResolvedTypeDeps['resolvedType'] = rawType.replace(IMPORTED_TYPE_REGEX, (_, matchedFilePath, type) => { 63 | const filePath = path.isAbsolute(matchedFilePath) ? path.relative(absoluteSrcPath, matchedFilePath) : matchedFilePath 64 | 65 | const deps = dependencies.get(filePath) 66 | 67 | if (deps) { 68 | if (!deps.includes(type)) { 69 | deps.push(type) 70 | } 71 | } else { 72 | dependencies.set(filePath, [type]) 73 | } 74 | 75 | return type 76 | }) 77 | 78 | if (resolvedType.includes('import(')) { 79 | unreachable('Internal error: resolved still contains an {magenta} statement: {green}', 'import(...)', resolvedType) 80 | } 81 | 82 | for (const depFile of dependencies.keys()) { 83 | if (path.isAbsolute(depFile)) { 84 | unreachable( 85 | 'Internal error: resolved absolute file path in type dependencies, when should have resolved a relative one\nIn type: {yellow}\nGot: {magentaBright}', 86 | type.getText(), 87 | depFile 88 | ) 89 | } 90 | } 91 | 92 | return { 93 | rawType, 94 | relativeFilePath, 95 | resolvedType, 96 | dependencies, 97 | localTypes, 98 | } 99 | } 100 | 101 | /** 102 | * Extract an import type's name from a TS-Morph type 103 | * @example "import('dir/file').TypeName" => "TypeName" 104 | * @param type 105 | * @returns 106 | */ 107 | export function getImportResolvedType(type: Type): string { 108 | return type.getText().replace(IMPORTED_TYPE_REGEX, (_, __, typename) => typename) 109 | } 110 | 111 | /** 112 | * Convert paths for external files 113 | * @param importedFilePath 114 | */ 115 | export function normalizeExternalFilePath(importedFilePath: string): string { 116 | importedFilePath = path.normalize(importedFilePath) 117 | 118 | if (!importedFilePath.startsWith('../')) { 119 | return importedFilePath 120 | } 121 | 122 | let level = 0 123 | 124 | while (importedFilePath.startsWith('../')) { 125 | level++ 126 | importedFilePath = importedFilePath.substr(3) 127 | } 128 | 129 | return `_external${level}/${importedFilePath}` 130 | } 131 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as path from 'path' 4 | import { analyzerCli } from './analyzer' 5 | import { config, configPath } from './config' 6 | import generatorCli from './generator' 7 | import { println } from './logging' 8 | 9 | async function main() { 10 | const started = Date.now() 11 | 12 | process.chdir(path.dirname(path.resolve(configPath))) 13 | 14 | switch (process.argv[3]) { 15 | case '--analyze': 16 | await analyzerCli(config) 17 | break 18 | 19 | case '--generate': 20 | case undefined: 21 | const sdkContent = await analyzerCli(config) 22 | await generatorCli(config, sdkContent) 23 | break 24 | 25 | default: 26 | console.error('ERROR: Unknown action provided (must be either "--analyze" or "--generate")') 27 | process.exit(1) 28 | } 29 | 30 | println('{green}', '@ Done in ' + ((Date.now() - started) / 1000).toFixed(2) + 's') 31 | } 32 | 33 | main() 34 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import chalk = require('chalk') 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | 5 | /** 6 | * The configuration file's content 7 | * For details on what these options do, see the project's README 8 | */ 9 | export interface Config { 10 | /** Enable verbose mode */ 11 | readonly verbose?: boolean 12 | 13 | /** Disable colored output */ 14 | readonly noColor?: boolean 15 | 16 | /** Path to the API's source directory */ 17 | readonly apiInputPath: string 18 | 19 | /** Path to generate the SDK at */ 20 | readonly sdkOutput: string 21 | 22 | /** Path to the SDK interface file */ 23 | readonly sdkInterfacePath: string 24 | 25 | /** List of magic types */ 26 | readonly magicTypes?: MagicType[] 27 | 28 | /** Show a JSON output */ 29 | readonly jsonOutput?: string 30 | 31 | /** Prettify the JSON output */ 32 | readonly jsonPrettyOutput?: boolean 33 | 34 | /** Prettify the generated files (enabled by default) */ 35 | readonly prettify?: boolean 36 | 37 | /** Path to Prettier's configuration file */ 38 | readonly prettierConfig?: string 39 | 40 | /** Path to custom tsconfig file */ 41 | readonly tsconfigFile?: string 42 | 43 | /** If the output directory already exists, overwrite it (enabled by default) */ 44 | readonly overwriteOldOutputDir?: boolean 45 | 46 | /** If the SDK interface file does not exist yet, create one automatically (enabled by default) */ 47 | readonly generateDefaultSdkInterface?: boolean 48 | 49 | /** Write generation timestamp in each TypeScript file (enabled by default) */ 50 | readonly generateTimestamps?: boolean 51 | } 52 | 53 | /** 54 | * Magic type used to replace a non-compatible type in the generated SDK 55 | */ 56 | export interface MagicType { 57 | readonly nodeModuleFilePath: string 58 | readonly typeName: string 59 | readonly placeholderContent: string 60 | } 61 | 62 | /** 63 | * Load an existing configuration file and decode it 64 | * @param configPath 65 | */ 66 | function loadConfigFile(configPath: string): Config { 67 | if (!fs.existsSync(configPath)) { 68 | console.error(chalk.red('Config file was not found at path: ' + chalk.yellow(path.resolve(configPath)))) 69 | process.exit(4) 70 | } 71 | 72 | const text = fs.readFileSync(configPath, 'utf8') 73 | 74 | try { 75 | return JSON.parse(text) 76 | } catch (e) { 77 | console.error(chalk.red('Failed to parse configuration file: ' + e)) 78 | process.exit(3) 79 | } 80 | } 81 | 82 | export const configPath = process.argv[2] 83 | 84 | if (!configPath) { 85 | console.error(chalk.red('Please provide a path to the configuration file')) 86 | process.exit(2) 87 | } 88 | 89 | export const config = loadConfigFile(configPath) 90 | -------------------------------------------------------------------------------- /src/generator/genmodules.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Generate SDK modules 3 | */ 4 | 5 | import * as path from 'path' 6 | import { SdkModules } from '../analyzer/controllers' 7 | import { SdkMethod } from '../analyzer/methods' 8 | import { SdkMethodParams } from '../analyzer/params' 9 | import { resolveRouteWith, unparseRoute } from '../analyzer/route' 10 | import { normalizeExternalFilePath, ResolvedTypeDeps } from '../analyzer/typedeps' 11 | import { panic } from '../logging' 12 | 13 | /** 14 | * Generate the SDK's module and controllers files 15 | * @param modules 16 | * @returns 17 | */ 18 | export function generateSdkModules(modules: SdkModules): Map { 19 | /** Generated module files */ 20 | const genFiles = new Map() 21 | 22 | // Iterate over each module 23 | for (const [moduleName, controllers] of modules) { 24 | // Iterate over each of the module's controllers 25 | for (const [controllerName, controller] of controllers) { 26 | /** Generated controller's content */ 27 | const out: string[] = [] 28 | 29 | out.push('/// Parent module: ' + moduleName) 30 | out.push(`/// Controller: "${controllerName}" registered as "${controller.registrationName}" (${controller.methods.length} routes)`) 31 | out.push('') 32 | out.push('import { request } from "../central";') 33 | 34 | const imports = new Map() 35 | 36 | const depsToImport = new Array() 37 | 38 | // Iterate over each controller 39 | for (const method of controller.methods.values()) { 40 | const { parameters: args, query, body } = method.params 41 | 42 | depsToImport.push(method.returnType) 43 | 44 | if (args) { 45 | depsToImport.push(...args.values()) 46 | } 47 | 48 | if (query) { 49 | depsToImport.push(...query.values()) 50 | } 51 | 52 | if (body) { 53 | if (body.full) { 54 | depsToImport.push(body.type) 55 | } else { 56 | depsToImport.push(...body.fields.values()) 57 | } 58 | } 59 | } 60 | 61 | // Build the imports list 62 | for (const dep of depsToImport) { 63 | for (const [file, types] of dep.dependencies) { 64 | let imported = imports.get(file) 65 | 66 | if (!imported) { 67 | imported = [] 68 | imports.set(file, imported) 69 | } 70 | 71 | for (const typ of types) { 72 | if (!imported.includes(typ)) { 73 | imported.push(typ) 74 | } 75 | } 76 | } 77 | } 78 | 79 | for (const [file, types] of imports) { 80 | out.push( 81 | `import type { ${types.join(', ')} } from "../_types/${normalizeExternalFilePath(file.replace(/\\/g, '/')).replace(/\\/g, '/')}";` 82 | ) 83 | } 84 | 85 | out.push('') 86 | out.push(`export default {`) 87 | 88 | for (const method of controller.methods) { 89 | const ret = method.returnType.resolvedType 90 | const promised = ret.startsWith('Promise<') ? ret : `Promise<${ret}>` 91 | 92 | out.push('') 93 | out.push(` // ${method.httpMethod} @ ${unparseRoute(method.route)}`) 94 | out.push(` ${method.name}(${generateSdkMethodParams(method.params)}): ${promised} {`) 95 | out.push(generateCentralRequest(method).replace(/^/gm, ' ')) 96 | out.push(' },') 97 | } 98 | 99 | out.push('') 100 | out.push('};') 101 | 102 | genFiles.set(path.join(moduleName, controller.camelClassName + '.ts'), out.join('\n')) 103 | } 104 | 105 | /** Generated module's content */ 106 | const moduleContent: string[] = [] 107 | 108 | moduleContent.push('/// Module name: ' + moduleName) 109 | moduleContent.push('') 110 | 111 | for (const controller of controllers.keys()) { 112 | moduleContent.push(`export { default as ${controller} } from "./${controller}";`) 113 | } 114 | 115 | // Generate the SDK module file 116 | genFiles.set(path.join(moduleName, 'index.ts'), moduleContent.join('\n')) 117 | } 118 | 119 | return genFiles 120 | } 121 | 122 | /** 123 | * Generate the method parameters for a given SDK method 124 | * @param params 125 | * @returns 126 | */ 127 | export function generateSdkMethodParams(params: SdkMethodParams): string { 128 | // List of parameters (e.g. `id` in `/get/:id`, analyzed from the usages of the `@Param` decorator) 129 | const parameters = params.parameters ? [...params.parameters].map(([name, type]) => `${name}: ${type.resolvedType}`) : [] 130 | 131 | // List of query values (e.g. `id` in `?id=xxx`, analyzed from the usages of the `@Query` decorator) 132 | const query = params.query ? [...params.query].map(([name, type]) => `${name}: ${type.resolvedType}`) : [] 133 | 134 | // Body's content (type used with the `@Body` decorator) 135 | const body = params.body 136 | ? params.body.full 137 | ? params.body.type.resolvedType 138 | : '{ ' + [...params.body.fields].map(([name, type]) => `${name}: ${type.resolvedType}`).join(', ') + ' }' 139 | : null 140 | 141 | // The ternary conditions below are made to eclipse useless parameters 142 | // For instance, if we're not expecting any query nor body, these two parameters can be omitted when calling the method 143 | return [ 144 | `params: {${' ' + parameters.join(', ') + ' '}}${parameters.length === 0 && !body && query.length === 0 ? ' = {}' : ''}`, 145 | `body: ${body ?? '{}'}${!body && query.length === 0 ? ' = {}' : ''}`, 146 | `query: {${' ' + query.join(', ') + ' '}}${query.length === 0 ? ' = {}' : ''}`, 147 | ].join(', ') 148 | } 149 | 150 | /** 151 | * Generate a request call to Central for the generated files 152 | * @param method 153 | * @returns 154 | */ 155 | export function generateCentralRequest(method: SdkMethod): string { 156 | const resolvedRoute = resolveRouteWith(method.route, (param) => '${params.' + param + '}') 157 | 158 | if (resolvedRoute instanceof Error) { 159 | panic('Internal error: failed to resolve route: ' + resolvedRoute.message) 160 | } 161 | 162 | return `return request('${method.httpMethod}', \`${resolvedRoute}\`, body, query) as any` 163 | } 164 | -------------------------------------------------------------------------------- /src/generator/gentypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Generate type files for the SDK 3 | */ 4 | 5 | import * as path from 'path' 6 | import { TypesExtractorContent } from '../analyzer/extractor' 7 | import { normalizeExternalFilePath } from '../analyzer/typedeps' 8 | 9 | /** 10 | * Generate the non-formatted type files which will be used by the SDK's route functions 11 | * @param sdkTypes 12 | * @returns 13 | */ 14 | export function generateSdkTypeFiles(sdkTypes: TypesExtractorContent): Map { 15 | /** Generated type files */ 16 | const genFiles = new Map() 17 | 18 | // While analyzing the controllers' content, all types used by their methods have been extracted 19 | // The list of these types is then provided to this function as the `sdkTypes` argument, allowing us 20 | // to establish the list of all dependencies. 21 | for (const [file, types] of sdkTypes) { 22 | const out = [] 23 | 24 | /** List of imports in the current file */ 25 | const imports = new Map() 26 | 27 | // Iterate over all types in the current `file` 28 | for (const extracted of types.values()) { 29 | // Iterate over all of the extracted type's dependencies 30 | for (const dep of extracted.dependencies) { 31 | // If the dependency is from the same file, then we have nothing to do 32 | if (dep.relativePath === file) { 33 | continue 34 | } 35 | 36 | // Push the typename to the list of imports 37 | let imported = imports.get(dep.relativePathNoExt) 38 | 39 | if (!imported) { 40 | imported = [dep.typename] 41 | imports.set(dep.relativePathNoExt, imported) 42 | } 43 | 44 | if (!imported.includes(dep.typename)) { 45 | imported.push(dep.typename) 46 | } 47 | } 48 | } 49 | 50 | // Generate an import statement for each imported type 51 | out.push( 52 | [...imports] 53 | .map(([depFile, types]) => { 54 | let depPath = path.relative(path.dirname(file), normalizeExternalFilePath(depFile)).replace(/\\/g, '/') 55 | if (!depPath.includes('/')) depPath = './' + depPath 56 | return `import type { ${types.join(', ')} } from "${ 57 | depPath.startsWith('./') || depPath.startsWith('../') ? depPath : './' + depPath 58 | }"` 59 | }) 60 | .join('\n') 61 | ) 62 | 63 | // Add the extracted types' declaration, indented 64 | for (const extracted of types.values()) { 65 | out.push(extracted.content.replace(/^/gm, ' ')) 66 | } 67 | 68 | // Generate the type file 69 | genFiles.set(file, out.join('\n')) 70 | } 71 | 72 | return genFiles 73 | } 74 | -------------------------------------------------------------------------------- /src/generator/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Entrypoint of the SDK generator 3 | */ 4 | 5 | import * as fs from 'fs' 6 | import * as path from 'path' 7 | import { SdkContent } from '../analyzer' 8 | import { normalizeExternalFilePath } from '../analyzer/typedeps' 9 | import { Config } from '../config' 10 | import { debug, panic, println } from '../logging' 11 | import { generateSdkModules } from './genmodules' 12 | import { generateSdkTypeFiles } from './gentypes' 13 | import { findPrettierConfig, prettify } from './prettier' 14 | import { defaultSdkInterface } from './sdk-interface' 15 | 16 | export default async function generatorCli(config: Config, sdkContent: SdkContent): Promise { 17 | const prettifyOutput = config.prettify !== false 18 | 19 | if (!prettifyOutput) { 20 | debug('NOTE: files will not be prettified with Prettier') 21 | } 22 | 23 | const output = path.resolve(process.cwd(), config.sdkOutput) 24 | 25 | if (fs.existsSync(output)) { 26 | if (config.overwriteOldOutputDir === false) { 27 | panic("Please provide an output directory that doesn't exist yet") 28 | } else { 29 | if (!fs.existsSync(path.join(output, 'central.ts'))) { 30 | panic("Provided output path exists but doesn't seem to contain an SDK output. Please check the output directory.") 31 | } else { 32 | fs.rmSync(output, { recursive: true }) 33 | } 34 | } 35 | } 36 | 37 | const outputParentDir = path.dirname(output) 38 | 39 | if (!fs.existsSync(outputParentDir)) { 40 | panic("Output directory's parent {magentaBright} does not exist.", outputParentDir) 41 | } 42 | 43 | fs.mkdirSync(output) 44 | 45 | const prettierConfig = prettifyOutput ? findPrettierConfig(config) : {} 46 | 47 | const writeScriptTo = (parentDir: null | string, file: string, utf8Content: string) => { 48 | if (file.endsWith('.ts')) { 49 | utf8Content = 50 | '/// Auto-generated file (nest-sdk-generator)\n' + 51 | '/// Please do not edit this file - re-generate the SDK using the generator instead.\n' + 52 | (config.generateTimestamps !== false ? '/// Generated on: ' + new Date().toUTCString() + '\n' : '') + 53 | '///\n' + 54 | utf8Content 55 | } 56 | 57 | const fullPath = path.resolve(output, parentDir ?? '', file) 58 | fs.mkdirSync(path.dirname(fullPath), { recursive: true }) 59 | fs.writeFileSync( 60 | fullPath, 61 | prettifyOutput ? prettify(utf8Content, prettierConfig, file.endsWith('.json') ? 'json' : 'typescript') : utf8Content, 62 | 'utf8' 63 | ) 64 | } 65 | 66 | println('> Generating type files...') 67 | 68 | for (const [file, content] of generateSdkTypeFiles(sdkContent.types)) { 69 | writeScriptTo('_types', normalizeExternalFilePath(file), content) 70 | } 71 | 72 | println('> Generating modules...') 73 | 74 | for (const [file, content] of generateSdkModules(sdkContent.modules)) { 75 | writeScriptTo(null, file, content) 76 | } 77 | 78 | const sdkInterfacePath = path.resolve(process.cwd(), config.sdkInterfacePath) 79 | 80 | const relativeSdkInterfacePath = path 81 | .relative(output, sdkInterfacePath) 82 | .replace(/\\/g, '/') 83 | .replace(/\.([jt]sx?)$/, '') 84 | 85 | writeScriptTo(null, 'central.ts', `export { request } from "${relativeSdkInterfacePath}"`) 86 | 87 | if (!fs.existsSync(sdkInterfacePath) && config.generateDefaultSdkInterface !== false) { 88 | println('├─ Generating default SDK interface...') 89 | 90 | fs.writeFileSync(sdkInterfacePath, defaultSdkInterface, 'utf8') 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/generator/prettier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Interface for prettifying generated files 3 | */ 4 | 5 | import * as fs from 'fs' 6 | import * as prettier from 'prettier' 7 | import { Config } from '../config' 8 | import { panic } from '../logging' 9 | import { findFileAbove } from '../utils' 10 | 11 | /** 12 | * Find a .prettierrc configuration file in the current directory or above 13 | */ 14 | export function findPrettierConfig(config: Config): object { 15 | let prettierConfigPath = config.prettierConfig ?? findFileAbove('.prettierrc', config.sdkOutput) 16 | 17 | if (!prettierConfigPath) { 18 | return {} 19 | } 20 | 21 | if (!fs.existsSync(prettierConfigPath)) { 22 | panic('Prettier configuration was not found at specified path {magenta}', prettierConfigPath) 23 | } 24 | 25 | const text = fs.readFileSync(prettierConfigPath, 'utf8') 26 | 27 | try { 28 | return JSON.parse(text) 29 | } catch (e) { 30 | throw new Error('Failed to parse Prettier configuration: ' + e) 31 | } 32 | } 33 | 34 | /** 35 | * Prettify a TypeScript or JSON input 36 | * @param source 37 | * @param config 38 | * @param parser 39 | * @returns 40 | */ 41 | export function prettify(source: string, config: object, parser: 'typescript' | 'json'): string { 42 | return prettier.format(source, { 43 | parser, 44 | ...config, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /src/generator/sdk-interface.ts: -------------------------------------------------------------------------------- 1 | export const defaultSdkInterface = ` 2 | export async function request( 3 | method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', 4 | uri: string, 5 | body: unknown, 6 | query: Record 7 | ): Promise { 8 | const url = new URL('http://localhost:3000' + uri) 9 | url.search = new URLSearchParams(query).toString() 10 | 11 | const params: RequestInit = { 12 | method, 13 | // Required for content to be correctly parsed by NestJS 14 | headers: { 'Content-Type': 'application/json' }, 15 | } 16 | 17 | // Setting a body is forbidden on GET requests 18 | if (method !== 'GET') { 19 | params.body = JSON.stringify(body) 20 | } 21 | 22 | return fetch(url.toString(), params).then((res) => { 23 | // Handle failed requests 24 | if (!res.ok) { 25 | throw Error(res.statusText) 26 | } 27 | 28 | return res.json() 29 | }) 30 | } 31 | 32 | `.trim() 33 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import * as chalk from 'chalk' 2 | import { config } from './config' 3 | 4 | export function format(message: string, ...params: Array): string { 5 | return message.replace( 6 | /\{(black|red|green|yellow|blue|magenta|cyan|white|gray|grey|blackBright|redBright|greenBright|yellowBright|blueBright|magentaBright|cyanBright|whiteBright|)\}/g, 7 | (match, color) => { 8 | const param = params.shift() ?? panic(`In message:\n> {}\nMissing parameter:\n> {}`, message, match) 9 | return color && config.noColor !== false ? (chalk as any)[color](param) : param 10 | } 11 | ) 12 | } 13 | 14 | export function panic(message: string, ...params: Array): never { 15 | console.error(chalk.redBright('ERROR: ' + format(message, ...params))) 16 | process.exit(1) 17 | } 18 | 19 | export function unreachable(message: string, ...params: Array): never { 20 | panic(message, ...params) 21 | } 22 | 23 | export function warn(message: string, ...params: Array) { 24 | console.warn(chalk.yellow(format(message, ...params))) 25 | } 26 | 27 | export function println(message: string, ...params: Array) { 28 | console.log(format(message, ...params)) 29 | } 30 | 31 | export function debug(message: string, ...params: Array) { 32 | if (config.verbose) { 33 | console.warn(chalk.cyan(format(message, ...params))) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | export function findFileAbove(pattern: string | RegExp, dir: string): string | null { 5 | if (!path.isAbsolute(dir)) { 6 | // Get an absolute path to allow getting its parent using path.dirname() 7 | dir = path.resolve(process.cwd(), dir) 8 | } 9 | 10 | // The previous directory 11 | // This is used to check if we reached the top-level directory ; for instance on Linux: 12 | // > path.dirname('/') === '/' 13 | // So if the previous directory and the current one are equal, this means we reached the top-level directory 14 | let prevDir = dir 15 | 16 | let items = [] 17 | 18 | // Search until we find the desired file 19 | while ((items = fs.readdirSync(dir).filter((item) => (pattern instanceof RegExp ? pattern.exec(item) : pattern === item))).length === 0) { 20 | // Get path to the parent directory 21 | dir = path.dirname(dir) 22 | 23 | // If the path is empty or equal to the previous path, we reached the top-level directory 24 | if (!dir || prevDir === dir) { 25 | return null 26 | } 27 | 28 | prevDir = dir 29 | } 30 | 31 | // Success! 32 | return path.resolve(dir, items[0]) 33 | } 34 | 35 | /** 36 | * Find all files matching a pattern, recursively 37 | * @param pattern The pattern to look for 38 | * @param dir The root directory to start searching from 39 | * @param relative Get relative paths instead of absolute paths (default: true) 40 | * @returns A list of paths 41 | */ 42 | export function findFilesRecursive(pattern: string | RegExp, dir: string, relative = true): string[] { 43 | const cwd = process.cwd() 44 | 45 | function find(pattern: string | RegExp, rootDir: string, currDir: string): string[] { 46 | return fs 47 | .readdirSync(currDir) 48 | .map((item) => { 49 | const fullPath = path.resolve(currDir, item) 50 | 51 | return fs.lstatSync(fullPath).isDirectory() 52 | ? find(pattern, rootDir, fullPath) 53 | : (typeof pattern === 'string' ? item === pattern : pattern.exec(item)) 54 | ? [relative ? path.relative(rootDir, fullPath) : path.resolve(cwd, currDir, item)] 55 | : [] 56 | }) 57 | .flat() 58 | } 59 | 60 | return find(pattern, dir, dir) 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noImplicitAny": true, 5 | "noImplicitOverride": true, 6 | "noImplicitReturns": true, 7 | "noImplicitThis": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "useUnknownInCatchVariables": true, 11 | 12 | "downlevelIteration": true, 13 | "experimentalDecorators": true, 14 | 15 | "declaration": true, 16 | "moduleResolution": "node", 17 | "module": "commonjs", 18 | "lib": ["esnext"], 19 | "target": "es6", 20 | 21 | "inlineSourceMap": true, 22 | "incremental": true 23 | }, 24 | 25 | "include": ["src"] 26 | } 27 | --------------------------------------------------------------------------------