├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── commitlint.config.js ├── docs ├── Application-Structure.md ├── Bootstrapping.md ├── Controllers.md ├── Dependency-Injection.md ├── Entity-Controllers.md ├── Model.md ├── Quickstart.md ├── README.md └── Services.md ├── examples ├── fastify-resty-blog │ ├── README.md │ ├── ormconfig.json │ ├── package.json │ ├── src │ │ ├── api │ │ │ ├── author │ │ │ │ ├── author.controller.ts │ │ │ │ └── author.entity.ts │ │ │ ├── generator │ │ │ │ ├── generator.controller.ts │ │ │ │ └── generator.service.ts │ │ │ └── post │ │ │ │ ├── post.controller.ts │ │ │ │ ├── post.entity.ts │ │ │ │ └── post.service.ts │ │ └── app.ts │ └── tsconfig.json └── fastify-resty-quickstart │ ├── README.md │ ├── package.json │ ├── src │ ├── app.ts │ ├── post.controller.ts │ └── post.entity.ts │ └── tsconfig.json ├── jest.config.base.js ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── bootstrap.ts │ │ ├── configurations.ts │ │ ├── decorators │ │ │ ├── controller.ts │ │ │ ├── entityController │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ ├── methods.ts │ │ │ │ ├── routes.ts │ │ │ │ └── schemaBuilder │ │ │ │ │ ├── baseSchema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schemaDefinitions.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ ├── model.ts │ │ │ ├── requestMethods.ts │ │ │ └── service.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── injector.ts │ │ ├── symbols.ts │ │ └── types.ts │ ├── tests │ │ ├── data │ │ │ ├── controllerMethodsDefinitions.ts │ │ │ ├── controllers │ │ │ │ ├── entitySample.controller.ts │ │ │ │ └── sample.controller.ts │ │ │ ├── injectables.ts │ │ │ └── schemaDefinitions.json │ │ ├── integration │ │ │ ├── bootstrap.test.ts │ │ │ ├── decorators │ │ │ │ ├── controller.test.ts │ │ │ │ └── entityController.test.ts │ │ │ └── injector.test.ts │ │ ├── jest.setup.ts │ │ ├── support │ │ │ ├── ModelMock.ts │ │ │ └── controllerFactory.ts │ │ └── unit │ │ │ ├── decorators │ │ │ └── entityController │ │ │ │ ├── entityMethods.test.ts │ │ │ │ └── schemaDefinitions.test.ts │ │ │ └── index.test.ts │ └── tsconfig.json └── typeorm │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── BaseModel.ts │ ├── bootstrap.ts │ ├── index.ts │ └── lib │ │ ├── mapProperty.ts │ │ └── queryBuilder │ │ ├── index.ts │ │ └── operations.ts │ ├── tests │ ├── data │ │ └── users.json │ ├── integration │ │ ├── BaseModel.test.ts │ │ └── mapProperty.test.ts │ └── unit │ │ ├── BaseModel.test.ts │ │ └── bootstrap.test.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | 9 | [*.{js,json,ts,tsx}] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [Makefile] 14 | indent_style = tab 15 | 16 | [*.{md,markdown}] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | 5 | *.js 6 | *.cjs -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "tsconfig.json", 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint/eslint-plugin"], 9 | "extends": [ 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "prettier/@typescript-eslint" 14 | ], 15 | "env": { 16 | "node": true 17 | }, 18 | "rules": { 19 | "@typescript-eslint/interface-name-prefix": "off", 20 | "@typescript-eslint/explicit-function-return-type": "off", 21 | "@typescript-eslint/no-explicit-any": "off", 22 | "@typescript-eslint/explicit-module-boundary-types": "off", 23 | "@typescript-eslint/no-unused-vars": "off", 24 | "@typescript-eslint/ban-types": "off" 25 | } 26 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Workflow 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | - 'examples/**' 8 | 9 | env: 10 | CI: true 11 | 12 | jobs: 13 | build_and_test: 14 | # ignore commits with `[skip ci]` 15 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} 16 | 17 | name: Build and test 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [10.x, 12.x, 14.x] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | 32 | - uses: actions/cache@v2 33 | with: 34 | path: '**/node_modules' 35 | key: ${{ runner.os }}-${{ matrix.node-version }}-modules-${{ hashFiles('**/yarn.lock') }} 36 | 37 | - name: Install dependencies 38 | run: yarn install --frozen-lock-file 39 | 40 | - name: Build packages and examples 41 | run: yarn build 42 | 43 | - name: Run tests 44 | run: yarn test 45 | 46 | - name: Upload coverage report 47 | run: bash <(curl -s https://codecov.io/bash) -t $CODECOV_TOKEN 48 | if: ${{ matrix.node-version == '12.x' }} 49 | 50 | publish: 51 | name: Publish 52 | runs-on: ubuntu-latest 53 | 54 | if: ${{ github.ref == 'refs/heads/main' }} 55 | needs: build_and_test 56 | 57 | steps: 58 | - uses: actions/checkout@v2 59 | with: 60 | token: ${{ secrets.GH_TOKEN }} 61 | # pulls all commits (needed for lerna to correctly version) 62 | fetch-depth: "0" 63 | 64 | # pulls all tags (needed for lerna to correctly version) 65 | - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 66 | 67 | - uses: actions/setup-node@v1 68 | with: 69 | node-version: 12.x 70 | 71 | - uses: actions/cache@v2 72 | with: 73 | path: '**/node_modules' 74 | key: ${{ runner.os }}-12.x-modules-${{ hashFiles('**/yarn.lock') }} 75 | 76 | - name: Install dependencies 77 | run: yarn install --frozen-lock-file --check-files 78 | 79 | - name: Build packages 80 | run: yarn build --scope @fastify-resty/* 81 | 82 | - name: Semantic version bump 83 | run: | 84 | git config --global user.email "demidovich.daniil@gmail.com" 85 | git config --global user.name "DanilaFadeev" 86 | yarn semantic-bump 87 | 88 | - name: Publish 89 | run: | 90 | echo -e "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}\nregistry=https://registry.npmjs.org/\nalways-auth=true\nscope=@fastify-resty" > .npmrc 91 | yarn release 92 | env: 93 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .npmrc 2 | node_modules/ 3 | .DS_Store 4 | *.log 5 | *.cache 6 | .idea/ 7 | .vscode/ 8 | coverage 9 | build/ 10 | dist/ 11 | tmp/ 12 | temp/ 13 | *.sql 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "avoid", 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2020 The Fastify Resty Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |
6 | 7 | ![Build Workflow](https://github.com/FastifyResty/fastify-resty/workflows/Build%20Workflow/badge.svg?branch=main) 8 | [![codecov](https://codecov.io/gh/FastifyResty/fastify-resty/branch/main/graph/badge.svg?token=R11QLZFPCJ)](https://codecov.io/gh/FastifyResty/fastify-resty) 9 | [![Known Vulnerabilities](https://snyk.io/test/github/FastifyResty/fastify-resty/badge.svg)](https://snyk.io/test/github/FastifyResty/fastify-resty) 10 | [![Depfu](https://badges.depfu.com/badges/c43ccc83fdcc48e031489f54ef8f4194/overview.svg)](https://depfu.com/github/FastifyResty/fastify-resty?project_id=17745) 11 | 12 |
13 | 14 |
15 | 16 | [![GitHub license](https://img.shields.io/github/license/Naereen/StrapDown.js.svg)](https://github.com/Naereen/StrapDown.js/blob/master/LICENSE) 17 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/FastifyResty/fastify-resty/graphs/commit-activity) 18 | [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/) 19 | 20 |
21 | 22 | > **Modern and declarative REST API framework for superfast and oversimplification backend development, build on top of Fastify and TypeScript.** 23 | 24 |
25 |

26 | If you find this useful, please don't forget to star :star:  the repo, as this will help to promote the project. 27 |

28 |
29 | 30 | ## Benefits :dart: 31 | 32 | - **Zero configuration** - Generates [RESTful API](https://restfulapi.net/) routes for your data out the box 33 | - **JSON Schema validation** - Build [JSON Schemas](https://json-schema.org/) to validate and speedup your requests and replies 34 | - **Highly customizable** - provides a lot of possible configurations for your application 35 | - **Purely TypeScript** - Written in [TypeScript](https://www.typescriptlang.org/) and comes with all the required typings 36 | - **Declarative interface** - Uses decorators for routes and models definitions 37 | - **Fastify compatible** - Built with [Fastify](https://www.fastify.io/) and supports all its features and plugins 38 | - **Built-in DI** - Provides simple Dependency Injection interface to bind your entries 39 | 40 | ## Install :pushpin: 41 | 42 | #### Core module 43 | 44 | ```sh 45 | $ npm install @fastify-resty/core fastify 46 | ``` 47 | 48 | #### TypeORM connector 49 | 50 | ```sh 51 | $ npm install @fastify-resty/typeorm typeorm 52 | ``` 53 | 54 | ## Usage :rocket: 55 | 56 | ##### TypeORM Entity (author.entity.ts): 57 | 58 | ```ts 59 | import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; 60 | 61 | @Entity() 62 | export default class Author { 63 | @PrimaryGeneratedColumn() 64 | id: number; 65 | 66 | @Column() 67 | firstname: string; 68 | 69 | @Column() 70 | lastname: string; 71 | 72 | @CreateDateColumn() 73 | created_at: Date; 74 | } 75 | ``` 76 | 77 | ##### Entity controller (author.controller.ts): 78 | 79 | ```ts 80 | import { EntityController } from '@fastify-resty/core'; 81 | import AuthorEntity from './author.entity'; 82 | 83 | @EntityController(AuthorEntity, '/authors') 84 | export default class AuthorController {} 85 | ``` 86 | 87 | ##### Bootstrap (app.ts): 88 | 89 | ```ts 90 | import fastify from 'fastify'; 91 | import { createConnection } from 'typeorm'; 92 | import { bootstrap } from '@fastify-resty/core'; 93 | import typeorm from '@fastify-resty/typeorm'; 94 | import AuthorController from './author.controller'; 95 | 96 | async function main() { 97 | const app = fastify(); 98 | const connection = await createConnection(); 99 | 100 | app.register(typeorm, { connection }); 101 | app.register(bootstrap, { controllers: [AuthorController] }); 102 | 103 | app.listen(8080, (err, address) => { 104 | console.log(app.printRoutes()); 105 | }); 106 | } 107 | 108 | main(); 109 | ``` 110 | 111 | ##### Generated routes: 112 | 113 | ``` 114 | └── / 115 | └── users (DELETE|GET|PATCH|POST|PUT) 116 | └── / (DELETE|GET|PATCH|POST|PUT) 117 | └── :id (DELETE) 118 | :id (GET) 119 | :id (PATCH) 120 | :id (PUT) 121 | ``` 122 | 123 | ## Documentation :books: 124 | 125 | - [Quickstart](./docs/Quickstart.md) :label: 126 | - [Application Structure](./docs/Application-Structure.md) :label: 127 | - [Bootstrapping](./docs/Bootstrapping.md) :label: 128 | - [Controllers](./docs/Controllers.md) :label: 129 | - [Entity Controllers](./docs/Entity-Controllers.md) :label: 130 | - [Model](./docs/Model.md) :label: 131 | - [Services](./docs/Services.md) :label: 132 | - [Dependency Injection](./docs/Dependency-Injection.md) :label: 133 | 134 | ## Packages :package: 135 | 136 | - [@fastify-resty/core](https://www.npmjs.com/package/@fastify-resty/core) - **Fastify Resty** core functionality 137 | - [@fastify-resty/typeorm](https://www.npmjs.com/package/@fastify-resty/typeorm) - **Fastify Resty** TypeORM connector 138 | 139 | ## Examples :microscope: 140 | 141 | - [Fastify Resty Quickstart](https://github.com/FastifyResty/fastify-resty/tree/main/examples/fastify-resty-quickstart) 142 | - [Fastify Resty Blog API](https://github.com/FastifyResty/fastify-resty/tree/main/examples/fastify-resty-blog) 143 | 144 | ## Issues and contributions :memo: 145 | 146 | Contributors are welcome, please fork and send pull requests! If you find a bug or have any ideas on how to improve this project please submit an issue. 147 | 148 | ## License 149 | [MIT License](https://github.com/FastifyResty/fastify-resty/blob/main/LICENSE.md) 150 | 151 | Icons made by Eucalyp from www.flaticon.com -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'release', 9 | 'build', 10 | 'chore', 11 | 'ci', 12 | 'docs', 13 | 'feat', 14 | 'fix', 15 | 'perf', 16 | 'refactor', 17 | 'revert', 18 | 'test' 19 | ], 20 | ], 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /docs/Application-Structure.md: -------------------------------------------------------------------------------- 1 | # Application Structure 2 | 3 | **Fastify Resty** do not dictate to you how to structure your project and you are free to use any structure you want. 4 | But when your codebase grows you end up having long handlers. This makes your code hard to understand and it contains potential bugs. 5 | 6 | The structure could be different depends on the goal you want to achieve, but in the general case we suggest to adhere to the following structure: 7 | 8 | ``` 9 | └── application/ 10 | └── dist # compiled javascript files 11 | └── src # typescript app sources 12 | └── api/ # API resources 13 | └── post/ # resource routes 14 | └── post.controller.ts # resource controller to handle endpoints routes 15 | └── post.entity.ts # resource entity data schema 16 | └── post.service.ts # resource-specific business logic (optional) 17 | └── libs/ # general services and utilities (optional) 18 | └── config/ # app configuration files (optional) 19 | └── app.ts # app main bootstrap 20 | └── tsconfig.ts # holds metadata and npm dependencies 21 | └── package.json # typescript compiler options 22 | ``` 23 | 24 | We highly recommend adding file type prefixes to resource files which help to identify what they actually contain and keep the autoload process simple. 25 | 26 | See the implementation of this application structure on our example [Fastify Resty Blog](https://github.com/FastifyResty/fastify-resty/tree/main/examples/fastify-resty-blog) API. 27 | -------------------------------------------------------------------------------- /docs/Bootstrapping.md: -------------------------------------------------------------------------------- 1 | # Bootstrapping 2 | 3 | **Fastify Resty** provides powerful tools like `Controller` and `EntityController` 4 | to define API routes in a clear and declarative way using TypeScript decorators 5 | and classes. 6 | 7 | ## Controllers loading 8 | 9 | To register our controllers we need to specify them on a **Fastify Resty** 10 | bootstrap plugin by direct passing or/and autoloading by filename pattern. 11 | 12 | ```ts 13 | import * as path from 'path'; 14 | import fastify from 'fastify'; 15 | import { bootstrap } from '@fastify-resty/core'; 16 | 17 | import UsersController from './users.controller'; 18 | import PostsController from './posts.controller'; 19 | 20 | async function main() { 21 | 22 | const app = fastify(); 23 | 24 | app.register(bootstrap, { 25 | // autoload controllers from "api" directory, using filename match pattern 26 | entry: path.resolve(__dirname, 'api'), 27 | // direct controllers declaration 28 | controllers: [UsersController, PostsController] 29 | }); 30 | 31 | app.listen(8080, (err, address) => { 32 | console.log(`Server is listening on ${address}`); 33 | }); 34 | } 35 | 36 | main(); 37 | ``` 38 | 39 | You also are able to specify your own pattern for controller filename match: 40 | 41 | ```ts 42 | app.register(bootstrap, { 43 | entry: path.resolve(__dirname, 'api'), 44 | pattern: /\.ctr\.ts$/ 45 | }); 46 | ``` 47 | 48 | ## Application options 49 | 50 | `EntityController` is able to have its own configuration, but by default, the global 51 | **Fastify Resty** configuration is used for all. We are able to overwrite it, so in this 52 | case, default and your configuration will be merged. 53 | 54 | ```ts 55 | app.register(bootstrap, { 56 | entry: path.resolve(__dirname, 'api'), 57 | defaults: { 58 | id: '_id', // change the name of entities primary field 59 | pagination: false // turn of the pagination 60 | } 61 | }); 62 | ``` 63 | 64 | ## Bootstrap configuration options 65 | 66 | | Option | Type | Default value | Description | 67 | | --- | --- | --- | --- | 68 | | `pattern` | `RegExp` | `/\.controller\.[jt]s$/` | Filename pattern to load controllers files | 69 | | `controllers` | `Array` | - | Array of controllers to be registred | 70 | | `entry` | `String` | - | Root path for controllers autoload | 71 | | `defaults.id` | `String` | "id" | The name of primary field in your database schemas | 72 | | `defaults.methods` | `Array` | - | The list of methods which will be created for controller endpoint. See the available methods on [EntityController Routes](./Entity-Controllers.md#entitycontroller-routes). If not specified, all the methods are registed | 73 | | `defaults.allowMulti` | `Boolean` | `true` | Defines if we need to register methods for handling mutly rows operations, like `PATCH` or `DELETE` multy rows | 74 | | `defaults.returning` | `Boolean` | `true` | The flag to return the result of completed action. Will cause an additinal request to get it. | 75 | | `defaults.pagination` | `Object / Boolean` | + | Set `false` to disable pagination or an object to configure it | 76 | | `defaults.pagination.limit` | `Number` | 20 | The default limit for returned query rows | 77 | | `defaults.pagination.total` | `Boolean` | `true` | Defines if we need to return total results count for `GET /` query | 78 | -------------------------------------------------------------------------------- /docs/Controllers.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | **Controllers** are decorated classes designed to handle incoming requests 4 | to its routes. See the [Bootstrapping](./Bootstrapping.md) section to get 5 | more information about their register in fastify application. 6 | 7 | ## Controller creation 8 | 9 | Firstly, we need to create our controller class and decorate it with the 10 | `@Controller` decorator which is provided by **Fastify Resty** core. 11 | 12 | ```ts 13 | import { Controller } from '@fastify-resty/core'; 14 | 15 | @Controller('/route') 16 | export default class MyController {} 17 | ``` 18 | 19 | > **Note!** Controller class has to be default exported to be picked-up by 20 | the autoloading mechanism. 21 | 22 | ## Controller decorator configuration 23 | 24 | The only optional property for the `Controller` decorator is the route URL, 25 | which will be the root path of our controller's endpoints. If not set, path 26 | `/` will be used for a controller router. 27 | 28 | Handles `/articles` path: 29 | 30 | ```ts 31 | @Controller('/articles') 32 | ``` 33 | 34 | Handles app root path: 35 | 36 | ```ts 37 | @Controller('/') 38 | // or 39 | @Controller() // root '/' route is default 40 | ``` 41 | 42 | ## Controller's requests methods 43 | 44 | Controller is able to handle different HTTP requests methods with different routes. 45 | For that, we need to declare a controller class method and decorate it with HTTP method decorator. 46 | 47 | Method handler receives [Request](https://www.fastify.io/docs/latest/Request/) and 48 | [Reply](https://www.fastify.io/docs/latest/Reply/) objects and works the same as **Fastify** route handler. 49 | 50 | ```ts 51 | import { Controller, GET, POST } from '@fastify-resty/core'; 52 | import type { FastifyRequest, FastifyResponse } from 'fastify'; 53 | 54 | @Controller('/route') 55 | export default class MyController { 56 | 57 | @POST('/') 58 | create(request: FastifyRequest, reply: FastifyReply) { 59 | // ... 60 | } 61 | 62 | @GET('/') 63 | async get(request: FastifyRequest) { 64 | // ... 65 | return []; 66 | } 67 | 68 | } 69 | ``` 70 | 71 | Here the list of available HTTP methods decorators: 72 | 73 | | Decorator | Arguments | 74 | | --- | --- | 75 | | **@GET** | `route`, `options?` | 76 | | **@HEAD** | `route`, `options?` | 77 | | **@PATCH** | `route`, `options?` | 78 | | **@POST** | `route`, `options?` | 79 | | **@PUT** | `route`, `options?` | 80 | | **@OPTIONS** | `route`, `options?` | 81 | | **@DELETE** | `route`, `options?` | 82 | | **@ALL** | `route`, `methods?`, `options?` | 83 | 84 | Each request method decorator has one required string `route` option which 85 | defines a route path. 86 | 87 | Another not mandatory parameter is `options` which allow all the **Fastify** 88 | route options, except the `url` and `method`. See them on [Fastify Routes Option](https://www.fastify.io/docs/latest/Routes/#routes-option) documentation. 89 | 90 | `@ALL` is a bit different than others because it handles all the HTTP methods, 91 | or just some of them if `methods` strings array was passed. 92 | 93 | ## Controller's hooks methods 94 | 95 | Following [Fastify Hooks](https://www.fastify.io/docs/latest/Hooks/) functionality 96 | you are able to implement them into your controller using a related hook decorator 97 | from **Fastify Resty** core. 98 | 99 | The declared hook will be applied to all the controller's routes. 100 | 101 | ```ts 102 | import { Controller, OnRequest } from '@fastify-resty/core'; 103 | import type { FastifyRequest } from 'fastify'; 104 | 105 | @Controller('/route') 106 | export default class MyController { 107 | 108 | @OnRequest 109 | async onRequest(request: FastifyRequest) { 110 | // ... 111 | } 112 | 113 | } 114 | ``` 115 | 116 | The list of hooks decorators: 117 | 118 | - **@onRequest()** 119 | - **@preParsing()** 120 | - **@preValidation()** 121 | - **@preHandler()** 122 | - **@preSerialization()** 123 | - **@onError()** 124 | - **@onSend()** 125 | - **@onResponse()** 126 | - **@onTimeout()** 127 | 128 | ## Controller's properties 129 | 130 | There might be some cases when you need to have an access to `fastify` instance inside 131 | the controller's methods. For that, you need to inject it into the controller using `@Inject` 132 | decorator with `FastifyToken` token. The same rule works for global application configuration 133 | that available with `GlobalConfig` token. 134 | 135 | ```ts 136 | import { Controller, Inject, FastifyToken, GlobalConfig, IApplicationConfig } from '@fastify-resty/core'; 137 | import type { FastifyInstance } from 'fastify'; 138 | 139 | @Controller('/route') 140 | export class MyController { 141 | 142 | @Inject(FastifyToken) 143 | instance: FastifyInstance; 144 | 145 | @Inject(GlobalConfig) 146 | globalConfig: IApplicationConfig; 147 | 148 | } 149 | ``` 150 | -------------------------------------------------------------------------------- /docs/Dependency-Injection.md: -------------------------------------------------------------------------------- 1 | # Dependency Injection 2 | 3 | ## Overview 4 | 5 | Dependency injection is a technique whereby one object (or static method) supplies the dependencies 6 | of another object. A dependency is an object that can be used (a service). 7 | 8 | Fastify Resty provides a built-in dependency injection mechanism to inject [Services](./Services.md), 9 | [Models](./Model.md) or Fastify [Decorated values](https://www.fastify.io/docs/latest/Decorators/) to 10 | your controllers. 11 | 12 | > There is not scope separation included and all the injectables values are stored at the global scope. 13 | 14 | > All the injectables values are constructed using Singleton pattern, so the same instance would be 15 | shared throw all the injects. 16 | 17 | ## Injectable classes 18 | 19 | You are able to create injectable classes decorated with `@Service` decorator to be used 20 | inside your controllers or each other. 21 | 22 | Example of `PostService` which has a method to generate random string: 23 | 24 | ```ts 25 | import { Service } from '@fastify-resty/core'; 26 | 27 | @Service() 28 | export default class PostService { 29 | 30 | public randomString(): string { 31 | return Math.random().toString(36).substring(7); 32 | } 33 | 34 | } 35 | ``` 36 | 37 | ## Inject functionality 38 | 39 | There are several ways to inject classes. They are working almost the same, so only you decide which 40 | one to choose from. 41 | 42 | ### Inject via the class constructor attributes 43 | 44 | The most simple way to inject some external class into the current one is to specify it on the class 45 | constructor with a related type. Make sure here that type needs to be a real physically existing class, 46 | not an interface or any other abstraction. 47 | 48 | ```ts 49 | import { Controller, GET } from '@fastify-resty/core'; 50 | import PostService from './post.service.ts'; 51 | 52 | @Controller('/post') 53 | export default class PostController { 54 | constructor(private _postService: PostService) {} 55 | 56 | @GET('/random') 57 | getRandomString() { 58 | const result = this._postService.randomString(); 59 | return { result }; 60 | } 61 | } 62 | ``` 63 | 64 | ### Inject via the class property 65 | 66 | Another way is to inject some class using `@Inject` decorator provided by **Fastify Resty** core. Let's 67 | inject the previously created model with it: 68 | 69 | ```ts 70 | import { Controller, GET, Inject } from '@fastify-resty/core'; 71 | import PostModel from './post.model.ts'; 72 | import type { FastifyRequest } from 'fastify'; 73 | 74 | @Controller('/post') 75 | export default class PostController { 76 | @Inject() 77 | private _postModel: PostModel; 78 | 79 | @GET('/by-name/:name') 80 | async getByName(request: FastifyRequest<{ Params: { name: string } }>) { 81 | const result = await this._postModel.getByName(request.params.name); 82 | return { result }; 83 | } 84 | } 85 | ``` 86 | 87 | The cool thing here that you are able to inject entries into static properties. The following part 88 | would work as well: 89 | 90 | ```ts 91 | @Controller('/post') 92 | export default class PostController { 93 | @Inject() 94 | static postModel: PostModel; 95 | } 96 | ``` 97 | 98 | ### Inject via tokens 99 | 100 | Services could have unique token key to be used for injection. This key needs to be set on `@Service` 101 | decorator as below: 102 | 103 | ```ts 104 | import { Service } from '@fastify-resty/core'; 105 | 106 | @Service('PostServiceToken') 107 | export default PostService {} 108 | ``` 109 | 110 | Then it could be injected with `@Inject` decorator into class constructor: 111 | 112 | ```ts 113 | import { Controller, Inject } from '@fastify-resty/core'; 114 | import PostService from './post.service.ts'; 115 | 116 | @Controller('/post') 117 | export default class PostController { 118 | postService: PostService; // declare property to assign in the constructor 119 | 120 | constructor(@Inject('PostServiceToken') postService: PostService) { 121 | this.postService = postService; 122 | } 123 | } 124 | ``` 125 | 126 | Or inject using a class property: 127 | 128 | ```ts 129 | import { Controller, Inject } from '@fastify-resty/core'; 130 | import PostService from './post.service.ts'; 131 | 132 | @Controller('/post') 133 | export default class PostController { 134 | @Inject('PostServiceToken') 135 | postService: PostService; 136 | } 137 | ``` 138 | 139 | ## Inject model instances 140 | 141 | **Fastify Resty** core provides `@Model` decorator which is pretty similar to `@Inject` one, but 142 | it would create and inject a new base model instance each time for the passed entity. 143 | 144 | ```ts 145 | import { Service, Model, IBaseModel } from '@fastify-resty/core'; 146 | import PostEntity from './post.entity.ts'; 147 | 148 | @Service() 149 | export default class PostService { 150 | @Model(PostEntity) 151 | postModel: IBaseModel; 152 | 153 | getAllPosts() { 154 | return this.postModel.find(); 155 | } 156 | } 157 | ``` 158 | 159 | To see more details about models check the [Model](./Model.md) documentation. 160 | 161 | ## Global injectable tokens 162 | 163 | A common case when you need to get access to the global Fastify instance or its decorated value inside 164 | a controller or service. That's could be achieved with global tokens provided by **Fastify Resty**. You 165 | are able to inject Fastify instance or any decorated value using `@Inject` decorator. 166 | 167 | ```ts 168 | import { Controller, Inject, FastifyToken } from '@fastify-resty/core'; 169 | import type { FastifyInstance } from 'fastify'; 170 | 171 | @Controller('/post') 172 | export default class PostController { 173 | @Inject(FastifyToken) 174 | instance: FastifyInstance; 175 | } 176 | ``` 177 | 178 | The same would work for class constructor parameter: 179 | 180 | ```ts 181 | import { Controller, Inject, FastifyToken } from '@fastify-resty/core'; 182 | import type { FastifyInstance } from 'fastify'; 183 | 184 | @Controller('/post') 185 | export default class PostController { 186 | constructor(@Inject(FastifyToken) instance: FastifyInstance) { 187 | console.log(instance); 188 | } 189 | } 190 | ``` 191 | 192 | One more example of injection for fastify decorated values. Let's imagine that you have decorated your name 193 | somewhere in the application with `fastify.decorate('username', 'Jhon');`. To get this value inside controller 194 | `FastifyToken` could be used, but also that's possible to inject this value directly by its name: 195 | 196 | ```ts 197 | import { Controller, Inject, FastifyToken } from '@fastify-resty/core'; 198 | import type { FastifyInstance } from 'fastify'; 199 | 200 | @Controller('/post') 201 | export default class PostController { 202 | @Inject('username') 203 | username: string; 204 | } 205 | ``` 206 | 207 | You could also use `GlobalConfig` token to get the global application configuration with basic controllers and 208 | model options. Explore available configuration options on [Bootstrapping](./Bootstrapping.md) reference. 209 | -------------------------------------------------------------------------------- /docs/Entity-Controllers.md: -------------------------------------------------------------------------------- 1 | # Entity Controllers 2 | 3 | ## Overview 4 | 5 | **Entity Controllers** is the most powerful and excited feature provided by **Fastify Resty**. It allows you to create REST API interface for your data models with zero-configuration. At the same time, all the methods will be high speed and reliable, because **Entity Controllers** generates [JSON Schema](https://json-schema.org/) to validate the routes and serialize their outputs. 6 | 7 | Automatically generated endpoints are happy to be extended or rewritten for some tricky cases. They also support some configuration to adjust the generate behavior. 8 | 9 | ## Basic usage 10 | 11 | All what we need to generate REST endpoint for your data model is just an empty controller, decorated with `@EntityController` decorator, and **Entity** class which has all the data schema definitions. 12 | 13 | ```ts 14 | import { EntityController } from '@fastify-resty/core'; 15 | import UserEntity from './user.entity'; 16 | 17 | @EntityController(UserEntity, '/users') 18 | export default class UserController {} 19 | ``` 20 | 21 | The example above will generate the following routes: 22 | 23 | ``` 24 | └── / 25 | └── users (DELETE|GET|PATCH|POST|PUT) 26 | └── / (DELETE|GET|PATCH|POST|PUT) 27 | └── :id (DELETE) 28 | :id (GET) 29 | :id (PATCH) 30 | :id (PUT) 31 | ``` 32 | 33 | > **Note!** `EntityController` class has to be default exported to be handled by 34 | the autoloading mechanism. 35 | 36 | ## EntityController routes 37 | 38 | | Route | HTTP Method | Controller method | Description | 39 | | --- | --- | --- | --- | 40 | | `/` | `GET` | `find` | Send a list of resources | 41 | | `/:id` | `GET` | `findOne` | Send a resource by `id` URL parameter | 42 | | `/` | `POST` | `create` | Create one and more new resources | 43 | | `/` | `PATCH` | `patch` | Update one or more fields on a queried resources | 44 | | `/:id` | `PATCH` | `patchOne` | Update one or more fields on a resource by `id` URL parameter | 45 | | `/` | `PUT` | `update` | Update/Replace queried resources | 46 | | `/:id` | `PUT` | `updateOne` | Update/Replace resource by `id` URL parameter | 47 | | `/` | `DELETE` | `remove` | Delete queried resources | 48 | | `/:id` | `DELETE` | `removeOne` | Delete resources by `id` URL parameter | 49 | 50 | ## Querying 51 | 52 | Generated **EntityControllers** provides a query search interface for direct data selection out the box. You are able to use all the available [Query Operators](./Model.md#query-operations) in your query strings for the endpoints. 53 | 54 | We recommend using [qs library](https://www.npmjs.com/package/qs) for query string generation. 55 | 56 | ### Query examples 57 | 58 | #### Example 1 59 | 60 | Model query: 61 | 62 | ```ts 63 | await model.find({ 64 | $limit: 20, 65 | $where: { 66 | title: 'Article', 67 | id: { $lt: 10 }, 68 | age: { $gte: 18, $lte: 65 }, 69 | $or: [ 70 | { name: 'Jhon' }, 71 | { lastname: { $in: ['Doe', 'Timbersaw'] } } 72 | ] 73 | } 74 | }); 75 | ``` 76 | 77 | URL query: 78 | 79 | > (`GET`) `/route/?$limit=20&$where[title]=Article&$where[id][$lt]=10&$where[age][$gte]=18&$where[age][$lte]=65&$where[$or][0][name]=Jhon&$where[$or][1][lastname][$in][]=Doe&$where[$or][1][lastname][$in][]=Timbersaw` 80 | 81 | #### Example 2 82 | 83 | Model query: 84 | 85 | ```ts 86 | await model.delete({ 87 | id: { $in: [10, 20, 30] } 88 | }); 89 | ``` 90 | 91 | URL query: 92 | 93 | > (`DELETE`) `/route?id[$in][0]=10&id[$in][1]=20&id[$in][2]=30` 94 | 95 | ## Customization and configuration 96 | 97 | ### Requests and hooks methods 98 | 99 | All the common controller hooks and methods are supported on **EntityController**. See the reference on [Controllers](./Controllers.md) documentation. 100 | 101 | ### Properties 102 | 103 | **EntityController** adds some helpful properties to each class that might be needed for custom additional logic or default methods overriding. 104 | 105 | | Property | Type | Description | 106 | | --- | --- | --- | 107 | | `config` | `IControllerConfig` | The controller-specific configuration, defined for the **EntityController** | 108 | | `model` | `IBaseModel` | [Model](./Model.md) instance for controller entity | 109 | 110 | Despite they already available on entity controller class, it would be better to declare them with related types, to have type suggestion, and avoid TypeScript compilation errors. 111 | 112 | ```ts 113 | import { EntityController, IControllerConfig, IBaseModel } from '@fastify-resty/core'; 114 | import UserEntity from './user.entity' 115 | import type { FastifyInstance } from 'fastify'; 116 | 117 | @EntityController(UserEntity, '/users') 118 | export default class UserController { 119 | 120 | config: IControllerConfig; 121 | 122 | model: IBaseModel; 123 | 124 | } 125 | ``` 126 | 127 | ### Configuration reference 128 | 129 | ```ts 130 | @EntityController(entity: Function, route: string, options?: IControllerConfig); 131 | ``` 132 | 133 | | Argument | Type | isRequired | Description | Default | 134 | | --- | --- | --- | --- | --- | 135 | | `entity` | `class` | yes | The entity class containing data schema definitions. | - | 136 | | `route` | `string` | no | Controller's endpoint root path. All the controller's routes will be starting with it. | `/` | 137 | | `options` | `object` (`IControllerConfig`) | no | Controller specific optional configuration. By default, application `default`* configuration is used. If controller options object is set, it will be merged with application config with rewrite specified fields. | `{ pagination: false, id: 'id', allowMulti: true, returning: true }` | 138 | 139 | `*` See the possible `defaults` options on [Bootstrap configuration options](./Bootstrapping.md#bootstrap-configuration-options) docs. 140 | -------------------------------------------------------------------------------- /docs/Model.md: -------------------------------------------------------------------------------- 1 | # Model 2 | 3 | **Fastify Resty** database adaptors, like `@fastify-resty/typeorm` provides extendable `BaseModel` 4 | class wrappers to create a simple interface for the interaction with your model entities. 5 | 6 | `EntityController` uses `BaseModel` inside to work with database entities, but you are able to use 7 | them independently for your needs. 8 | 9 | ## Bootstrap 10 | 11 | To register the connector we need to pass the **Type ORM** `connection` object to it 12 | as a fastify plugin option. **Fastify Resty TypeORM** will [decorate](https://www.fastify.io/docs/latest/Decorators/) 13 | it to `fastify` instance, so not needed to do this manually. 14 | 15 | With that, it also decorates `BaseModel` class which could be used to create an interactive 16 | instance based on your TypeORM Entity. 17 | 18 | ```ts 19 | import fastify from 'fastify'; 20 | import typeorm from '@fastify-resty/typeorm'; 21 | import { createConnection } from 'typeorm'; 22 | 23 | async function main() { 24 | const app = fastify(); 25 | 26 | // initialize typeorm connection 27 | const connection = await createConnection(); 28 | 29 | // register fastify-resty typeorm bootstraper 30 | app.register(typeorm, { connection }); 31 | } 32 | ``` 33 | 34 | ## Standalone usage 35 | 36 | Models instances are created and available on `EntityControllers`, but you also able to 37 | create and use them yourselves if needed. 38 | 39 | ```ts 40 | import { BaseModel } from '@fastify-resty/typeorm'; 41 | import { createConnection } from 'typeorm'; 42 | 43 | // initialize connection 44 | const connection = await createConnection(); 45 | 46 | // bootstrap static property 47 | BaseModel.connection = connection; 48 | 49 | // use 50 | const model = new BaseModel(Entity); 51 | ``` 52 | 53 | ## Injectable usage 54 | 55 | To create specific API logic working with data we might need basic `BaseModel` methods to achieve that. 56 | For model injection special `@Model` decorator provided that could be used on constructor parameters or 57 | class properties. It allowed being used on any injectable class. 58 | 59 | ```ts 60 | import { Controller, Model, IBaseModel } from '@fastify-resty/core'; 61 | import SampleEntity from './sample.entity.ts'; 62 | 63 | @Controller() 64 | export default class SampleClass { 65 | 66 | firstModel: IBaseModel; 67 | 68 | constructor(@Model(SampleEntity) model) { 69 | this.firstModel = model; // Case 1: initialize model with injected constructor parameter 70 | } 71 | 72 | @Model(SampleEntity) 73 | secondModel: IBaseModel; // Case 2: initialize model with injected property 74 | } 75 | ``` 76 | 77 | As the first request argument `@Model` decorator accepts data entity. You are also able to pass a 78 | model-specific configuration as the second options argument: 79 | 80 | ```ts 81 | @Model(SampleEntity, { id: '_id' }) 82 | ``` 83 | 84 | > Keep in mind that model injection will be creating a new model instance each time without a singleton 85 | pattern which is used by `@Inject` decorator. 86 | 87 | ## Basic Methods 88 | 89 | - ### Find 90 | 91 | ```ts 92 | find(query?: IFindQuery): Promise 93 | ``` 94 | 95 | Returns the array of items, according to passed `query`. 96 | 97 | ```ts 98 | const results = await model.find({ 99 | $select: ['id', 'title', 'name'], // select only specific fields 100 | $limit: 50, // return only the first 50 rows 101 | $sort: { age: 'DESC' }, // sort by "age" using a descending order 102 | $where: { // search conditions 103 | title: 'How are you', 104 | id: { $lt: 10 }, 105 | age: { $gte: 18, $lte: 65 }, 106 | $or: [{ name: 'Jhon' }, { lastname: { $in: ['Doe', 'Loe'] } }], 107 | } 108 | }); 109 | ``` 110 | 111 | - ### Total 112 | 113 | ```ts 114 | total(query?: IFindWhereQuery): Promise 115 | ``` 116 | 117 | Returns the total count of items (number), according to passed `query`. 118 | 119 | ```ts 120 | const count = await model.total({ id: { $nin: [10, 20, 30] } }); 121 | 122 | console.log(count); // 10 123 | ``` 124 | 125 | - ### Create 126 | 127 | ```ts 128 | create(data: E | E[]): Promise<{ identifiers: Identifier[] }> 129 | ``` 130 | 131 | Creates one or more rows and returns the array of created primary keys. 132 | 133 | ```ts 134 | const result = await model.create([ 135 | { name: 'Ms. Joanne Harris', age: 34 }, 136 | { name: 'Matilda Pouros', age: 26 } 137 | ]); 138 | 139 | console.log(result); // { identifiers: [1, 2] } 140 | ``` 141 | 142 | - ### Patch 143 | 144 | ```ts 145 | patch(query: IFindWhereQuery, raw: Partial): Promise 146 | ``` 147 | 148 | Updates one or more fields on the rows that match a `query`. The rest of the fields on 149 | updated rows will stay unchanged. Method execution returns the count of affected rows. 150 | 151 | ```ts 152 | const result = await model.patch( 153 | { id: { $in: [28, 29] } }, 154 | { title: 'Patched', views: 0 } 155 | ); 156 | 157 | console.log(result); // { affected: 2 } 158 | ``` 159 | 160 | - ### Update 161 | 162 | ```ts 163 | update(query: IFindWhereQuery, raw: E): Promise 164 | ``` 165 | 166 | Recreates rows that match a `query`. In more detail, it creates a new row and replaces 167 | matched `query` rows with it. Returns count of affected rows. 168 | 169 | ```ts 170 | const result = await model.update( 171 | { id: 10 }, 172 | { title: 'Updated', description: 'lorem', views: 0 } 173 | ); 174 | 175 | console.log(result); // { affected: 1 } 176 | ``` 177 | 178 | - ### Remove 179 | 180 | ```ts 181 | remove(query: IFindWhereQuery): Promise 182 | ``` 183 | 184 | Removes the rows that match `query` and return the count of affected rows. 185 | 186 | ```ts 187 | const result = await model.delete({ status: 'to_be_removed' }); 188 | 189 | console.log(result); // { affected: 6 } 190 | ``` 191 | 192 | ## Model Properties 193 | 194 | - `name` - model name, in most cases the name of the **Entity** 195 | 196 | - `jsonSchema` - entity's data schema converted to [JSON Schema](https://json-schema.org/) format. 197 | 198 | ## Model Quering 199 | 200 | ### Find options (`IFindQuery`) 201 | 202 | | Option | SQL | Example | 203 | | --- | --- | --- | 204 | | `$limit` | `LIMIT` | `{ $limit: 20 }` | 205 | | `$skip` | `OFFSET` | `{ $skip: 10 }` | 206 | | `$select` | `SELECT` | `{ $select: ['title', 'description'] }` | 207 | | `$sort` | `ORDER BY` | `{ $sort: 'age' }` / `{ $sort: ['age', 'name'] }` / `{ $sort: { age: 'ASC' } }` | 208 | | `$where` | `WHERE` | `{ $where: { id: 20 } }` | 209 | 210 | ### Query Operations 211 | 212 | | Operator | SQL | Example | 213 | | --- | --- | --- | 214 | | `$eq` | `=` | `{ name: { $eq: 'Jhon' } }`, or: `{ name: 'Jhon' }` | 215 | | `$neq` | `!=` | `{ name: { $neq: 'Dow' } }` | 216 | | `$gt` | `>` | `{ age: { $gt: 20 } }` | 217 | | `$gte` | `>=` | `{ age: { $gte: 20 } }` | 218 | | `$lt` | `<` | `{ age: { $lt: 20 } }` | 219 | | `$lte` | `<=` | `{ age: { $lte: 20 } }` | 220 | | `$like` | `LIKE` | `{ name: { $like: '%hon' } }` | 221 | | `$nlike` | `NOT LIKE` | `{ name: { $nlike: '%oe' } }` | 222 | | `$ilike` | `ILIKE` | `{ name: { $ilike: '%hon' } }` | 223 | | `$nilike` | `NOT ILIKE` | `{ name: { $nilike: '%oe' } }` | 224 | | `$regex` | `~` | `{ name: { $regex: '(b[^b]+)(b[^b]+)' } }` | 225 | | `$nregex` | `!~` | `{ name: { $nregex: '(bar)(beque)' } }` | 226 | | `$in` | `IN` | `{ id: { $in: [1, 2, 3] } }` | 227 | | `$nin` | `NOT IN` | `{ name: { $nin: [4, 5, 6] } }` | 228 | | `$between` | `BETWEEN` | `{ age: { $between: [6, 18] } }` | 229 | | `$nbetween` | `NOT BETWEEN` | `{ age: { $nbetween: [19, 21] } }` | 230 | 231 | > **Note:** You are able to achieve SQL `OR` operator with `$or` in your query object. 232 | -------------------------------------------------------------------------------- /docs/Quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start :running::dash: 2 | 3 | ## What is it about? 4 | 5 | In this quick start guide, we will create a basic API using `Fastify Resty` framework. 6 | The main goal here is to show how easily we can create all the requested data schema mappings 7 | using the generated CRUD API interface. 8 | 9 | ## What is used? 10 | 11 | - [NODE.JS](https://nodejs.org/en/about/) 12 | - [Fastify](https://www.fastify.io/) 13 | - [TypeORM](https://typeorm.io/#/) 14 | - [SQLite 3](https://www.sqlite.org/index.html) 15 | - [Yarn](https://yarnpkg.com/) 16 | 17 | > We'll use file-based database `SQLite 3` here to keep things simple, but that's fine if you proceed with any other database which is supported by `TypeORM`. 18 | 19 | ## Let's get in! :rocket: 20 | 21 | ### :one:  Project initialization: 22 | 23 | As the first step, we need to simply create the project folder and initialize `package.json` here. 24 | 25 | ```sh 26 | $ mkdir app & cd app # create app dir and go there 27 | $ mkdir src # directory where we will put our code 28 | $ yarn init # init package.json using Yarn 29 | ``` 30 | 31 | ### :two:  Install dependencies: 32 | 33 | First of all, we need to install `Fastify Resty` core functionality and `fastify`, because that is the foundation of our application and `Fastify Resty` is built on top of it. 34 | 35 | ```sh 36 | $ yarn add @fastify-resty/core fastify 37 | ``` 38 | 39 | Then, the packages to work with our data. To work with `TypeORM` entities we need to add it to our project and the related `Fastify Resty TypeORM` connector. 40 | 41 | ```sh 42 | $ yarn add @fastify-resty/typeorm typeorm sqlite3 43 | ``` 44 | 45 | To run and compile our `TypeScript` source code we need to install 46 | the following development dependencies: 47 | 48 | ```sh 49 | $ yarn add -D typescript ts-node 50 | ``` 51 | 52 | ### :three:  Add TypeScript configuration: 53 | 54 | `Fastify Resty` provides nice looking and declarative interface with decorators, so we need to 55 | create a `tsconfig.json` at the project root directory. 56 | 57 | `tsconfig.json`: 58 | 59 | ```json 60 | { 61 | "compilerOptions": { 62 | "target": "ES6", 63 | "module": "commonjs", 64 | "emitDecoratorMetadata": true, 65 | "experimentalDecorators": true, 66 | "outDir": "./dist" 67 | } 68 | } 69 | ``` 70 | 71 | That's it, we are done with the project setup. Now we could move to the important things and develop our API! :muscle: 72 | 73 | ### :four:  Create TypeORM Entity model: 74 | 75 | First thing first, we want to define our first data entity using `TypeORM`. It provides decorators for different data types. See them on the [TypeORM Entities](https://typeorm.io/#/entities) reference. 76 | 77 | Here, we will create a simple entity that will have a primary `id` field and `title` which has a string type. 78 | 79 | `src/post.entity.ts`: 80 | 81 | ```ts 82 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 83 | 84 | @Entity() 85 | export class PostEntity { 86 | @PrimaryGeneratedColumn() 87 | id: number; 88 | 89 | @Column() 90 | title: string; 91 | } 92 | ``` 93 | 94 | ### :five:  Create a controller to work with the entity: 95 | 96 | That's the most exciting part. 97 | 98 | To generate an API interface to interact with our created entity we need to just create a controller class and decorate it with `EnityController` decorator which is provided by `Fastify Resty`. 99 | 100 | As an argument, we'll pass our entity class and specify the route URL `/posts` to be the root path for our API endpoint. 101 | 102 | `src/post.controller.ts`: 103 | 104 | ```ts 105 | import { EntityController } from '@fastify-resty/core'; 106 | import { PostEntity } from './post.entity'; 107 | 108 | @EntityController(PostEntity, '/posts') 109 | export class PostController {} 110 | ``` 111 | 112 | It's all, right? Yeah :sunglasses: 113 | 114 | ### :six:  Setup Fastify server 115 | 116 | In the end, we need to setup your server and put all the things together. 117 | 118 | `src/app.ts`: 119 | 120 | ```ts 121 | import fastify from 'fastify'; 122 | import { createConnection } from 'typeorm'; 123 | import { bootstrap } from '@fastify-resty/core'; 124 | import typeorm from '@fastify-resty/typeorm'; 125 | 126 | import { PostController } from './post.controller'; 127 | 128 | async function main() { 129 | // 1. Create fastify server instance 130 | const app = fastify(); 131 | 132 | // 2. Initialize TypeORM connection using sqlite3 module 133 | const connection = await createConnection({ 134 | type: 'sqlite', // specify sqlite type 135 | database: './testDB.sql', // path to store sql db source 136 | entities: ['*.entity.ts'] // pattern to autoload entity files 137 | }); 138 | 139 | // 3. Register TypeORM module 140 | app.register(typeorm, { connection }); 141 | 142 | // 4. Register FastifyResty controller 143 | app.register(bootstrap, { controllers: [PostController] }); 144 | 145 | // 5. Start application server on port 8080 146 | app.listen(8080, (err, address) => { 147 | console.log(`Server is listening on ${address}`); 148 | console.log(app.printRoutes()); 149 | }); 150 | } 151 | 152 | main(); 153 | ``` 154 | 155 | Some notes about server setup steps at the code listing above: 156 | 157 | - `Steps 1 and 2` are the common setup of Fastify and TypeORM. You could see more details 158 | about them on the related documentation references. 159 | 160 | - `Step 3` - we register FastifyResty TypeORM wrapper and pass the created database 161 | connection in there. That's required for Fastify Resty `EntityController` to 162 | manage the data using the model interface, provided by Fastify Resty TypeORM connector. 163 | 164 | - `Step 4` is actual FastifyResty bootstrap with a minimal configuration where we pass 165 | our created controller to be registered. 166 | 167 | - `Step 5` runs fastify server listening and call the callback function after it's ready. 168 | 169 | ### :seven:  That's it! Run the server. 170 | 171 | Finally, we have our API application ready to be run and served. To do it 172 | go to the console command line again (make sure that you are in the project dir) 173 | and run the following command: 174 | 175 | ```sh 176 | $ yarn ts-node src/app.ts 177 | ``` 178 | 179 | If heaven favors you will see the message below, which says that 180 | our API server is running on `http://127.0.0.1:8080` address and ready 181 | to pick up and handle requests for the `http://127.0.0.1:8080/posts/` endpoint. 182 | 183 | ``` 184 | Server is listening on http://127.0.0.1:8080 185 | └── / 186 | └── posts (DELETE|GET|PATCH|POST|PUT) 187 | └── / (DELETE|GET|PATCH|POST|PUT) 188 | └── :id (DELETE) 189 | :id (GET) 190 | :id (PATCH) 191 | :id (PUT) 192 | ``` 193 | 194 | ## Let's play with it! :bouncing_ball_person: 195 | 196 | To make the requests to API with different HTTP request types we suggest to use [Postman](https://www.postman.com/downloads/) 197 | or [Insomnia](https://insomnia.rest/download/). They are both free and do their job very well. 198 | 199 | - First of all, let's check the list of posts. For that you need to send the following `GET` request: 200 | 201 | | Method | Url | 202 | | --- | --- | 203 | | `GET` | `http://127.0.0.1:8080/posts/` | 204 | 205 | 206 | The response would be `{ "total":0, "limit":20, "skip":0, "data": []}`. That's right, 207 | we don't have any posts yet, so `data` is empty array here. 208 | 209 | - We need to add a few posts to see them. For that let's send a request with `POST` 210 | method and post data in the body: 211 | 212 | | Method | Url | Body | 213 | | --- | --- | --- | 214 | | `POST` | `http://127.0.0.1:8080/posts/` | `{ name: "My first post" }` | 215 | 216 | Oops, something went wrong here. We forgot that we define the post name as `title` 217 | field on our TypeORM schema, and our API's response tells us about it: 218 | 219 | ```json 220 | { 221 | "statusCode": 400, 222 | "error": "Bad Request", 223 | "message": "body should have required property 'title'" 224 | } 225 | ``` 226 | 227 | That's fine, let's change the `name` to `title` and send a request one more time! As 228 | the result, we see `[1]`, so that's a signal that a new post was added! You could add a 229 | few more posts, but we go further. 230 | 231 | - Now we have the post added so can go back to the first step and ask for the posts list again: 232 | 233 | | Method | Url | 234 | | --- | --- | 235 | | `GET` | `http://127.0.0.1:8080/posts/` | 236 | 237 | Wow, now we have received our post: 238 | 239 | ```json 240 | { 241 | "total": 1, 242 | "limit": 20, 243 | "skip": 0, 244 | "data": [{ 245 | "id": 1, 246 | "title": "My first post" 247 | }] 248 | } 249 | ``` 250 | 251 | ## Afterwords :pray: 252 | 253 | In this guide, we have created our API using Fastify Resty framework. There 254 | are more possibilities and available things that not covered here, but you are ready for 255 | absorbing further documentation. 256 | 257 | The source code of this quickstart application is available on [Quickstart example](https://github.com/FastifyResty/fastify-resty/tree/main/examples/fastify-resty-quickstart), 258 | take a look if you have missed something! 259 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Fastify Resty Documentation :books: 2 | 3 | - [Quickstart](./Quickstart.md) :label: 4 | - [Application Structure](./Application-Structure.md) :label: 5 | - [Bootstrapping](./Bootstrapping.md) :label: 6 | - [Controllers](./Controllers.md) :label: 7 | - [Entity Controllers](./Entity-Controllers.md) :label: 8 | - [Model](./Model.md) :label: 9 | - [Services](./Services.md) :label: 10 | - [Dependency Injection](./Dependency-Injection.md) :label: 11 | -------------------------------------------------------------------------------- /docs/Services.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | ## Overview 4 | 5 | Services were designed to hide a massive business logic and reduce codebase of controllers. 6 | Service, generally, is a class with bunch of methods that solve some specific problems and 7 | could be shared with other controllers or services using [Dependency Injection](./Dependency-Injection.md) 8 | mechanism. 9 | 10 | Services could inject and use [Models](./Model.md) to work with actual data, stored in database. 11 | 12 | ## Service creation 13 | 14 | To specify class as injectable service we need to decorate it with `@Service` decorator provided by 15 | **Fastify Resty** core functionality. 16 | 17 | ```ts 18 | import { Service } from '@fastify-resty/core'; 19 | 20 | @Service() 21 | export default class CalculatorService { 22 | sum(a: number, b: number): number { 23 | return a + b; 24 | } 25 | 26 | multiply(a: number, b: number): number { 27 | return a * b; 28 | } 29 | } 30 | ``` 31 | 32 | As a single optional argument, the service token could be passed. The token is used to inject 33 | the service into other classes with `@Inject(token)` signature. Token must be of `string` or `symbol` type. 34 | 35 | Define token string: 36 | 37 | ```ts 38 | @Service('MyServiceToken') 39 | ``` 40 | 41 | Define token symbol: 42 | 43 | ```ts 44 | const MyServiceToken = Symbol('MyServiceToken'); 45 | 46 | @Service(MyServiceToken) 47 | ``` 48 | 49 | ### Usage 50 | 51 | The most common case is a controller-specific service which needs to be injected into it. 52 | 53 | ##### Service (sample.service.ts): 54 | 55 | ```ts 56 | import { Service } from '@fastify-resty/core'; 57 | 58 | @Service() 59 | export default class SampleService { 60 | sayHi(name: string): string { 61 | return `Hey, ${name}`; 62 | } 63 | } 64 | ``` 65 | 66 | ##### Controller (sample.controller.ts): 67 | 68 | ```ts 69 | import { Controller, GET } from '@fastify-resty/core'; 70 | import SampleService from './sample.service.ts'; 71 | 72 | @Controller('/sample') 73 | export default class SampleController { 74 | constructor(private _sampleService: SampleService) {} 75 | 76 | @GET('/say/hi/:name') 77 | getSayHi(request) { 78 | return _sampleService.sayHi(request.params.name); 79 | } 80 | } 81 | ``` 82 | 83 | See more service inject ways on [Dependency Injection](./Dependency-Injection.md) documentation. 84 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/README.md: -------------------------------------------------------------------------------- 1 | ## Fastify Resty API Blog example :memo: 2 | 3 | The current example application shows the key features of `Fastify Resty` for building fast and declarative API using TypeORM and PostgreSQL database. 4 | 5 | ### Description :link: 6 | 7 | There are 3 types of controllers implemented: 8 | 9 | - :wrench: Automatic zero-configuration controller `author.controller.ts` for TypeORM entity 10 | - :wrench: Custom controller `generator.controller.ts` without model entity binding using custom service `generator.service.ts` with logic 11 | - :wrench: Automatic controller `post.controller.ts` with additional custom route and hook, using service with model integration 12 | 13 | As the result we will have the following API routes structure: 14 | 15 | ``` 16 | └── / 17 | ├── authors (GET|POST|PATCH|PUT|DELETE) 18 | │ └── / (GET|POST|PATCH|PUT|DELETE) 19 | │ └── :id (GET) 20 | │ :id (PATCH) 21 | │ :id (PUT) 22 | │ :id (DELETE) 23 | ├── generate/ 24 | │ ├── author (GET) 25 | │ └── post (GET) 26 | └── posts (GET|POST|PATCH|PUT|DELETE) 27 | └── / (GET|POST|PATCH|PUT|DELETE) 28 | ├── :id (GET) 29 | :id (PATCH) 30 | :id (PUT) 31 | :id (DELETE) 32 | │ └── /author (GET) 33 | └── random (GET) 34 | ``` 35 | 36 | ### Requirements :white_check_mark: 37 | 38 | To run this application you need to have the following tools installed on your machine: 39 | 40 | - [Node.js](https://nodejs.org) (>8.15) 41 | - [PostgreSQL](https://www.postgresql.org/) (>9) (Check the notes at the bottom of this README to get additional help) 42 | 43 | ### Run :gear: 44 | 45 | To start the application using `ts-node` simply run: 46 | 47 | ```sh 48 | $ yarn start 49 | ``` 50 | 51 | ### Build :hammer: 52 | 53 | You are able to compile application typescript sources to native javascript and run it. 54 | To do it, you need to execute the following commands: 55 | 56 | ```sh 57 | $ yarn build 58 | $ cd dist 59 | $ node src/app.js 60 | ``` 61 | 62 | ### Helpful Notes :moyai: 63 | 64 | There are a few ways to up and run the Postgres database for the current API example. 65 | You could download and install it from the official [postgresql download page](https://www.postgresql.org/download/), or 66 | run it in a quicker way using following [Docker :whale:](https://www.docker.com/) CLI command: 67 | 68 | ```sh 69 | $ docker run --name postgres-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres 70 | ``` 71 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/ormconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "username": "postgres", 6 | "password": "postgres", 7 | "database": "postgres", 8 | "synchronize": true, 9 | "logging": false, 10 | "entities": [ 11 | "src/**/*.entity{.ts,.js}" 12 | ], 13 | "migrations": [ 14 | "src/migration/**/*{.ts,.js}" 15 | ], 16 | "subscribers": [ 17 | "src/subscriber/**/*{.ts,.js}" 18 | ], 19 | "cli": { 20 | "entitiesDir": "src/api/**/", 21 | "migrationsDir": "src/migration", 22 | "subscribersDir": "src/subscriber" 23 | } 24 | } -------------------------------------------------------------------------------- /examples/fastify-resty-blog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-resty-blog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Fastify Resty blog API example", 6 | "keywords": [ 7 | "fastify", 8 | "example", 9 | "decorators", 10 | "api", 11 | "rest", 12 | "controllers" 13 | ], 14 | "author": "Demidovich Daniil ", 15 | "license": "MIT", 16 | "scripts": { 17 | "start": "ts-node src/app.ts", 18 | "build": "tsc", 19 | "test": "echo \"Error: run tests from root\" && exit 1" 20 | }, 21 | "dependencies": { 22 | "@fastify-resty/core": "*", 23 | "@fastify-resty/typeorm": "*", 24 | "faker": "^5.1.0", 25 | "fastify": "^3.8.0", 26 | "pg": "^8.3.3", 27 | "typeorm": "^0.2.26" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^14.11.10", 31 | "ts-node": "^9.0.0", 32 | "typescript": "^4.0.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/author/author.controller.ts: -------------------------------------------------------------------------------- 1 | import { EntityController } from '@fastify-resty/core'; 2 | import AuthorEntity from './author.entity'; 3 | 4 | /* 5 | * Zero-configuration controller which will generate all the REST routes 6 | * for Author data entity 7 | */ 8 | @EntityController(AuthorEntity, '/authors') 9 | class AuthorController {} 10 | 11 | export default AuthorController; 12 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/author/author.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | import Post from '../post/post.entity'; 3 | 4 | @Entity() 5 | class Author { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | firstname: string; 11 | 12 | @Column() 13 | lastname: string; 14 | 15 | @OneToMany(type => Post, post => post.author) 16 | posts: Post[]; 17 | 18 | @CreateDateColumn() 19 | created_at: Date; 20 | 21 | @UpdateDateColumn() 22 | updated_at: Date; 23 | } 24 | 25 | export default Author; 26 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/generator/generator.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, GET } from '@fastify-resty/core'; 2 | import GeneratorService from './generator.service'; 3 | import type { FastifyRequest } from 'fastify'; 4 | 5 | const getAuthorSchema = { 6 | querystring: { 7 | multy: { type: 'number' } 8 | }, 9 | response: { 10 | 200: { 11 | type: 'object', 12 | properties: { 13 | data: { type: 'array' }, 14 | total: { type: 'number' }, 15 | firstname: { type: 'string' }, 16 | lastname: { type: 'string' } 17 | } 18 | } 19 | } 20 | }; 21 | 22 | /* 23 | * Custom controller without data routes generation. 24 | * Uses logic of GeneratorService injected with DI 25 | */ 26 | @Controller('/generate') 27 | export default class GeneratorController { 28 | constructor(private _generatorService: GeneratorService) {} 29 | 30 | @GET('/author', { schema: getAuthorSchema }) 31 | async getAuthor(request: FastifyRequest<{ Querystring: { multy?: number } }>) { 32 | const itemsCount = request.query.multy; 33 | 34 | if (itemsCount) { 35 | const authors = []; 36 | for (let i = 0; i < itemsCount; i++) authors.push(this._generatorService.generateAuthor()); 37 | return { total: itemsCount, data: authors }; 38 | } 39 | 40 | return this._generatorService.generateAuthor(); 41 | } 42 | 43 | @GET('/post') 44 | async getPost() { 45 | return this._generatorService.generatePost(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/generator/generator.service.ts: -------------------------------------------------------------------------------- 1 | import { Service } from '@fastify-resty/core'; 2 | import * as faker from 'faker'; 3 | 4 | /* 5 | * Simple injectable service provided some logical functionality 6 | */ 7 | @Service() 8 | export default class GeneratorService { 9 | generateAuthor() { 10 | return { 11 | firstname: faker.name.firstName(), 12 | lastname: faker.name.lastName() 13 | }; 14 | } 15 | 16 | generatePost() { 17 | return { 18 | title: faker.lorem.words(), 19 | description: faker.lorem.sentence(), 20 | image: faker.image.imageUrl(), 21 | content: faker.lorem.paragraph(), 22 | is_draft: faker.random.boolean() 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/post/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { EntityController, GET, OnRequest, Inject } from '@fastify-resty/core'; 2 | import PostEntity from './post.entity'; 3 | import PostService from './post.service'; 4 | import type { FastifyRequest } from 'fastify'; 5 | import type { Connection } from 'typeorm'; 6 | 7 | /* 8 | * Extended automatically REST generation controller with custom 9 | * hooks and methods. 10 | * Uses injected Fastify decorated property "connection" provided by 11 | * @fastify-resty/typeorm library and PostService service with some logic 12 | */ 13 | @EntityController(PostEntity, '/posts') 14 | export default class PostController { 15 | constructor(private postService: PostService) {} 16 | 17 | @Inject('connection') 18 | private connection: Connection; 19 | 20 | @GET('/:id/author', { 21 | schema: { 22 | params: { 23 | id: { type: 'number' } 24 | }, 25 | response: { 26 | 200: { 27 | firstname: { type: 'string' }, 28 | lastname: { type: 'string' } 29 | } 30 | } 31 | } 32 | }) 33 | async getPostAuthor(request: FastifyRequest<{ Params: { id: number } }>) { 34 | const typeormRepository = this.connection.getRepository(PostEntity); 35 | const result = await typeormRepository.find({ 36 | where: { id: request.params.id }, 37 | relations: ['author'] 38 | }); 39 | return result[0].author; 40 | } 41 | 42 | @GET('/random') 43 | async findRandomPost() { 44 | return this.postService.getRandomPost(); 45 | } 46 | 47 | @OnRequest 48 | async onRequests(request) { 49 | console.log(`Request #${request.id}`); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/post/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm'; 2 | import Author from '../author/author.entity'; 3 | 4 | @Entity() 5 | class Post { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | title: string; 11 | 12 | @Column() 13 | description: string; 14 | 15 | @Column() 16 | image: string; 17 | 18 | @Column('text') 19 | content: string; 20 | 21 | @Column({ default: true }) 22 | is_draft: boolean; 23 | 24 | @Column() 25 | authorId: number; 26 | 27 | @ManyToOne(type => Author, author => author.posts) 28 | author: Author; 29 | 30 | @CreateDateColumn() 31 | created_at: Date; 32 | 33 | @UpdateDateColumn() 34 | updated_at: Date; 35 | } 36 | 37 | export default Post; 38 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/api/post/post.service.ts: -------------------------------------------------------------------------------- 1 | import { IBaseModel, Model, Service } from '@fastify-resty/core'; 2 | import PostEntity from './post.entity'; 3 | 4 | /* 5 | * Service with logic functionality using injectable PostModel to work with 6 | * database stored data 7 | */ 8 | @Service() 9 | export default class PostService { 10 | @Model(PostEntity) 11 | postModel: IBaseModel; 12 | 13 | async getRandomPost() { 14 | const postsTotal = await this.postModel.total(); 15 | const posts = await this.postModel.find(); 16 | 17 | return posts[this.getRandomNumber(0, postsTotal - 1)]; 18 | } 19 | 20 | getRandomNumber(min: number, max: number): number { 21 | return Math.random() * (max - min) + min; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/src/app.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import fastify from 'fastify'; 3 | import { createConnection } from 'typeorm'; 4 | import { bootstrap } from '@fastify-resty/core'; 5 | import typeorm from '@fastify-resty/typeorm'; 6 | 7 | 8 | async function main() { 9 | // create fastify server instance 10 | const app = fastify(); 11 | 12 | // initialize typeorm connection 13 | const connection = await createConnection(); 14 | 15 | // register fastify-resty typeorm bootstraper 16 | app.register(typeorm, { connection }); 17 | 18 | // bootstrap fastify-resty controllers using autoloader 19 | app.register(bootstrap, { entry: path.resolve(__dirname, 'api') }); 20 | 21 | // start server listen on port 8080 22 | app.listen(8080, (err, address) => { 23 | if (err) { 24 | console.error(err); 25 | process.exit(1); 26 | } 27 | 28 | console.log(`Server is listening on ${address}`); 29 | console.log(app.printRoutes()); 30 | }); 31 | } 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /examples/fastify-resty-blog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "resolveJsonModule": true, 7 | "outDir": "./dist" 8 | }, 9 | "include": ["src/**/*", "ormconfig.json"] 10 | } -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/README.md: -------------------------------------------------------------------------------- 1 | # `fastify-resty-quickstart` 2 | 3 | > Fastify Resty Quickstart API application source 4 | 5 | ### The source code of the [Quickstart guide](https://github.com/FastifyResty/fastify-resty/blob/main/docs/Quickstart.md). 6 | 7 | ### Install and Run 8 | 9 | Using NPM: 10 | 11 | ```sh 12 | $ npm install 13 | $ npm run dev 14 | ``` 15 | 16 | Using Yarn: 17 | 18 | ```sh 19 | $ yarn 20 | $ yarn dev 21 | ``` 22 | 23 | ### Generated routes 24 | 25 | ``` 26 | Server is listening on http://127.0.0.1:8080 27 | └── / 28 | └── posts (GET|POST|PATCH|PUT|DELETE) 29 | └── / (GET|POST|PATCH|PUT|DELETE) 30 | └── :id (GET) 31 | :id (PATCH) 32 | :id (PUT) 33 | :id (DELETE) 34 | ``` -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-resty-quickstart", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "Fastify Resty quickstart API application", 6 | "author": "Demidovich Daniil ", 7 | "license": "MIT", 8 | "main": "src/app.ts", 9 | "scripts": { 10 | "build": "tsc", 11 | "dev": "ts-node src/app.ts", 12 | "start": "node dist/app.js", 13 | "test": "echo \"Error: run tests from root\" && exit 1" 14 | }, 15 | "dependencies": { 16 | "@fastify-resty/core": "*", 17 | "@fastify-resty/typeorm": "*", 18 | "fastify": "^3.7.0", 19 | "sqlite3": "^5.0.0", 20 | "typeorm": "^0.2.28" 21 | }, 22 | "devDependencies": { 23 | "ts-node": "^9.0.0", 24 | "typescript": "^4.0.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/src/app.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import fastify from 'fastify'; 3 | import { createConnection } from 'typeorm'; 4 | import { bootstrap } from '@fastify-resty/core'; 5 | import typeorm from '@fastify-resty/typeorm'; 6 | 7 | import { PostController } from './post.controller'; 8 | 9 | async function main() { 10 | // 1. Create fastify server instance 11 | const app = fastify(); 12 | 13 | // 2. Initialize TypeORM connection using sqlite3 module 14 | const connection = await createConnection({ 15 | type: 'sqlite', // specify sqlite type 16 | synchronize: true, // ask TypeORM to create db tables, if not exists 17 | database: './testDB.sql', // path to store sql db source 18 | entities: [path.resolve(__dirname, '*.entity{.ts,.js}')] // pattern to autoload entity files 19 | }); 20 | 21 | // 3. Register TypeORM module 22 | app.register(typeorm, { connection }); 23 | 24 | // 4. Register FastifyResty controller 25 | app.register(bootstrap, { controllers: [PostController] }); 26 | 27 | // 5. Start application server on port 8080 28 | app.listen(8080, (err, address) => { 29 | console.log(`Server is listening on ${address}`); 30 | console.log(app.printRoutes()); 31 | }); 32 | } 33 | 34 | main(); 35 | -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/src/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { EntityController } from '@fastify-resty/core'; 2 | import { PostEntity } from './post.entity'; 3 | 4 | @EntityController(PostEntity, '/posts') 5 | export class PostController {} 6 | -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/src/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 2 | 3 | @Entity() 4 | export class PostEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | title: string; 10 | } 11 | -------------------------------------------------------------------------------- /examples/fastify-resty-quickstart/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src/', '/tests/'], 5 | moduleFileExtensions: ['ts', 'js'], 6 | testPathIgnorePatterns: [ 7 | '/node_modules/', 8 | '/dist/' 9 | ], 10 | notify: true 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ['/packages/*'], 3 | collectCoverage: true, 4 | collectCoverageFrom: ['**/src/**'], 5 | coverageDirectory: '/coverage/' 6 | }; 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "independent", 6 | "command": { 7 | "version": { 8 | "allowBranch": "main", 9 | "message": "chore(release): publish :tada:\n\n[skip ci]" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-resty", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "workspaces": { 7 | "packages": [ 8 | "packages/*", 9 | "examples/*" 10 | ], 11 | "nohoist": [ 12 | "**/fastify-resty-blog", 13 | "**/fastify-resty-blog/**", 14 | "**/fastify-resty-quickstart", 15 | "**/fastify-resty-quickstart/**" 16 | ] 17 | }, 18 | "scripts": { 19 | "build": "lerna run build --ignore=@fastify-resty/core", 20 | "prebuild": "lerna run build --scope=@fastify-resty/core", 21 | "test": "jest", 22 | "lint": "eslint .", 23 | "semantic-bump": "lerna version --conventional-commits --no-private -y", 24 | "release": "lerna publish from-package -y" 25 | }, 26 | "devDependencies": { 27 | "@commitlint/cli": "^11.0.0", 28 | "@commitlint/config-conventional": "^11.0.0", 29 | "@types/http-errors": "^1.8.0", 30 | "@types/jest": "^26.0.14", 31 | "@types/json-schema": "^7.0.6", 32 | "@types/lodash": "^4.14.159", 33 | "@types/node": "^14.14.6", 34 | "@types/qs": "^6.9.4", 35 | "@typescript-eslint/eslint-plugin": "^4.6.0", 36 | "@typescript-eslint/parser": "^4.6.0", 37 | "eslint": "^7.5.0", 38 | "eslint-config-prettier": "^6.11.0", 39 | "eslint-plugin-import": "^2.22.0", 40 | "fastify": "^3.8.0", 41 | "husky": "^4.3.0", 42 | "jest": "^26.6.3", 43 | "lerna": "^3.22.1", 44 | "nodemon": "^2.0.4", 45 | "npm-run-all": "^4.1.5", 46 | "sqlite3": "^5.0.0", 47 | "supertest": "^6.0.0", 48 | "ts-jest": "^26.4.4", 49 | "ts-node": "^9.0.0", 50 | "ts-node-dev": "^1.0.0-pre.52", 51 | "typeorm": "^0.2.29", 52 | "typescript": "^4.0.5" 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "yarn lint", 57 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 58 | } 59 | }, 60 | "engines": { 61 | "node": ">= 10.16.0", 62 | "yarn": "^1.0.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [0.3.0](https://github.com/FastifyResty/fastify-resty/compare/@fastify-resty/core@0.2.0...@fastify-resty/core@0.3.0) (2020-11-18) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * added @Model decorator to construct model instances ([67bdb91](https://github.com/FastifyResty/fastify-resty/commit/67bdb91161278defa89be96078c76b5c9c15271c)) 12 | 13 | 14 | ### Features 15 | 16 | * **core:** inject by token, tests added ([be028bb](https://github.com/FastifyResty/fastify-resty/commit/be028bbb9af12353ccbc7fea9a31f6a38a78015a)) 17 | * added Service and Model injectable decorators ([d8ca15d](https://github.com/FastifyResty/fastify-resty/commit/d8ca15d64468b83f22eb71fcdc97b4033d7c383f)) 18 | * Inject decorator to inject properties and params by token ([806e2f5](https://github.com/FastifyResty/fastify-resty/commit/806e2f5c5878ef70782542185d8823998e78012f)) 19 | 20 | 21 | 22 | 23 | 24 | # [0.2.0](https://github.com/Fastify-Resty/fastify-resty/compare/@fastify-resty/core@0.1.1...@fastify-resty/core@0.2.0) (2020-11-15) 25 | 26 | 27 | ### Features 28 | 29 | * **core:** inject by token, tests added ([be028bb](https://github.com/Fastify-Resty/fastify-resty/commit/be028bbb9af12353ccbc7fea9a31f6a38a78015a)) 30 | * added Service and Model injectable decorators ([d8ca15d](https://github.com/Fastify-Resty/fastify-resty/commit/d8ca15d64468b83f22eb71fcdc97b4033d7c383f)) 31 | * Inject decorator to inject properties and params by token ([806e2f5](https://github.com/Fastify-Resty/fastify-resty/commit/806e2f5c5878ef70782542185d8823998e78012f)) 32 | 33 | 34 | 35 | 36 | 37 | ## [0.1.1](https://github.com/Fastify-Resty/fastify-resty/compare/@fastify-resty/core@0.1.0...@fastify-resty/core@0.1.1) (2020-11-01) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * lodash for typeorm, ES3 method decorators support ([e78f7c4](https://github.com/Fastify-Resty/fastify-resty/commit/e78f7c4d855b44845b1a381fe5154bd8fb284270)) 43 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @fastify-resty/core 2 | 3 | The core functionality of `fastify-resty` framework. 4 | 5 | ## Install 6 | 7 | Using npm: 8 | 9 | ```sh 10 | npm install @fastify-resty/core 11 | ``` 12 | 13 | or using yarn: 14 | 15 | ```sh 16 | yarn add @fastify-resty/core 17 | ``` 18 | -------------------------------------------------------------------------------- /packages/core/jest.config.js: -------------------------------------------------------------------------------- 1 | const configBase = require('../../jest.config.base.js'); 2 | 3 | module.exports = { 4 | ...configBase, 5 | displayName: { 6 | name: 'Core', 7 | color: 'magenta', 8 | }, 9 | setupFilesAfterEnv: ['/tests/jest.setup.ts'] 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify-resty/core", 3 | "version": "0.3.0", 4 | "description": "Fastify Resty API Framework core functionality", 5 | "keywords": [ 6 | "fastify", 7 | "rest", 8 | "json-schema", 9 | "decorators", 10 | "api", 11 | "framework" 12 | ], 13 | "author": { 14 | "name": "Demidovich Daniil", 15 | "email": "demidovich.daniil@gmail.com" 16 | }, 17 | "homepage": "https://github.com/FastifyResty/fastify-resty", 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "files": [ 22 | "dist/**/*", 23 | "CHANGELOG.md", 24 | "README.md" 25 | ], 26 | "engines": { 27 | "node": ">= 10.16.0" 28 | }, 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/FastifyResty/fastify-resty", 35 | "directory": "packages/core" 36 | }, 37 | "scripts": { 38 | "build": "tsc", 39 | "test": "jest --verbose", 40 | "coverage": "jest --collect-coverage" 41 | }, 42 | "dependencies": { 43 | "http-errors": "^1.8.0", 44 | "qs": "^6.9.4", 45 | "reflect-metadata": "^0.1.13" 46 | }, 47 | "peerDependencies": { 48 | "fastify": "3.x" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import qs from 'qs'; 4 | import Injector from './injector'; 5 | import { FastifyToken, GlobalConfig, Initialize } from './symbols'; 6 | import { createAppConfig } from './configurations'; 7 | import type { FastifyInstance } from 'fastify'; 8 | import type { Constructable, IApplicationOptions } from './types'; 9 | 10 | interface IBootstrapResult { 11 | controllers: any[]; // TODO specify controllers type 12 | } 13 | 14 | export async function bootstrap(fastifyInstance: FastifyInstance, options: IApplicationOptions): Promise { 15 | const config = createAppConfig(options); 16 | const controllers = new Set(); 17 | 18 | const injector = new Injector(); 19 | injector.registerInstance(FastifyToken, fastifyInstance); 20 | 21 | // decorate global config 22 | fastifyInstance.decorate(GlobalConfig, config); 23 | 24 | // add custom query parser for each request (qs) 25 | fastifyInstance.addHook('onRequest', async request => { 26 | request.query = qs.parse(request.raw.url.replace(/\?{2,}/, '?').split('?')[1] || ''); 27 | }); 28 | 29 | // controllers autoload 30 | if (config.entry) { 31 | function loadDirectory(directoryPath) { 32 | const files = fs.readdirSync(directoryPath); 33 | files.forEach(file => { 34 | const filePath = path.resolve(directoryPath, file); 35 | 36 | if (fs.lstatSync(filePath).isDirectory()) { 37 | return loadDirectory(path.resolve(directoryPath, file)); 38 | } 39 | 40 | if (config.pattern.test(filePath)) { 41 | // eslint-disable-next-line @typescript-eslint/no-var-requires 42 | controllers.add(require(filePath).default); 43 | } 44 | }); 45 | } 46 | 47 | loadDirectory(config.entry); 48 | } 49 | 50 | // add manual registered controllers 51 | if (config.controllers) { 52 | config.controllers.forEach(controller => controllers.add(controller)); 53 | } 54 | 55 | // initialize controllers 56 | const controllersInstances = Array.from(controllers) 57 | .filter(controller => controller?.prototype && Reflect.hasMetadata('fastify-resty:controller', controller.prototype)) 58 | .map(async controller => { 59 | let controllerInstance; 60 | const controllerMetadata = Reflect.getMetadata('fastify-resty:controller', controller.prototype); 61 | 62 | await fastifyInstance.register(async instance => { 63 | // resolve controller instance using DI 64 | controllerInstance = injector.getInstance(controller); 65 | 66 | // initialize built-in configuration 67 | if (typeof controllerInstance[Initialize] === 'function') { 68 | controllerInstance[Initialize](instance, config.defaults); 69 | } 70 | 71 | // add schema definitions to global scope 72 | if (Reflect.hasMetadata('fastify-resty:definitions', controllerInstance)) { 73 | const schemaDefinitions = Reflect.getMetadata('fastify-resty:definitions', controllerInstance); 74 | fastifyInstance.addSchema({ $id: `/${controllerInstance.model.name}.json`, ...schemaDefinitions }); 75 | } 76 | 77 | // register controller handlers 78 | const handlersKeys: Set = Reflect.getMetadata('fastify-resty:handlers', controller.prototype); 79 | if (handlersKeys) { 80 | handlersKeys.forEach(handlerKey => { 81 | const handlerOptions = Reflect.getMetadata('fastify-resty:handler', controller.prototype, handlerKey); 82 | instance.route({ ...handlerOptions, handler: controller.prototype[handlerKey].bind(controllerInstance) }); 83 | }); 84 | } 85 | 86 | // register controller hooks 87 | const hookKeys: Set = Reflect.getMetadata('fastify-resty:hooks', controller.prototype); 88 | if (hookKeys) { 89 | hookKeys.forEach(hookKey => { 90 | const hookOptions = Reflect.getMetadata('fastify-resty:hook', controller.prototype, hookKey); 91 | instance.addHook(hookOptions.event, controller.prototype[hookKey].bind(controllerInstance)); 92 | }); 93 | } 94 | 95 | }, { prefix: controllerMetadata?.route || '/' }); 96 | 97 | return controllerInstance; 98 | }); 99 | 100 | return { 101 | controllers: await Promise.all(controllersInstances) 102 | }; 103 | } 104 | 105 | bootstrap[Symbol.for('skip-override')] = true; 106 | -------------------------------------------------------------------------------- /packages/core/src/configurations.ts: -------------------------------------------------------------------------------- 1 | import { get } from './helpers'; 2 | import type { IApplicationConfig, IApplicationOptions, IControllerConfig, IControllerOptions, IBaseControllerMethods, IPagination } from './types'; 3 | 4 | const defaultPagination: IPagination = { limit: 20, total: true }; 5 | 6 | export const createAppConfig = (options: IApplicationOptions): IApplicationConfig => { 7 | const pagination = typeof options.defaults?.pagination === 'boolean' 8 | ? options.defaults?.pagination && defaultPagination 9 | : { ...defaultPagination, ...get(options.defaults?.pagination, {}) }; 10 | 11 | return { 12 | controllers: options.controllers, 13 | entry: options.entry, 14 | pattern: options.pattern || /\.controller\.[jt]s$/, 15 | defaults: { 16 | pagination, 17 | id: options.defaults?.id || 'id', 18 | softDelete: Boolean(options.defaults?.softDelete), 19 | methods: options.defaults?.methods, 20 | allowMulti: get(options.defaults?.allowMulti, true), 21 | returning: get(options.defaults?.returning, true) 22 | } 23 | }; 24 | } 25 | 26 | export const createControllerConfig = (options: IControllerOptions = {}, defaults: IControllerConfig): IControllerConfig => { 27 | let pagination; 28 | if (typeof options.pagination === 'boolean') { 29 | pagination = options.pagination && defaultPagination; 30 | } else if (typeof options.pagination === 'object') { 31 | pagination = { ...((defaults.pagination as false | object) || {}), ...options.pagination }; 32 | } else { 33 | pagination = defaults.pagination; 34 | } 35 | 36 | return Object.assign({}, defaults, options, { pagination }); 37 | } 38 | 39 | export const getAllowedMethods = (config: IControllerConfig): Array => { 40 | let allowedMethods: Array = ['find', 'findOne', 'create', 'patch', 'patchOne', 'update', 'updateOne', 'remove', 'removeOne']; 41 | 42 | // apply allowMulti 43 | if (!config.allowMulti) { 44 | const multiMethods = ['find', 'patch', 'update', 'remove']; 45 | allowedMethods = allowedMethods.filter(key => !multiMethods.includes(key)); 46 | } 47 | 48 | // apply specified methods 49 | if (config.methods) { 50 | allowedMethods = allowedMethods.filter(key => config.methods.includes(key)); 51 | } 52 | 53 | return allowedMethods; 54 | } 55 | -------------------------------------------------------------------------------- /packages/core/src/decorators/controller.ts: -------------------------------------------------------------------------------- 1 | import type { Constructable } from '../types'; 2 | 3 | export function Controller(route?: string): any { 4 | return function(target: T) { 5 | Reflect.defineMetadata('fastify-resty:controller', { route }, target.prototype); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/hooks.ts: -------------------------------------------------------------------------------- 1 | import createError from 'http-errors'; 2 | import { getAllowedMethods } from '../../configurations'; 3 | import type { FastifyRequest } from 'fastify'; 4 | 5 | export default { 6 | validateAllowedMethods: { 7 | event: 'onRequest', 8 | handler: function(routeOptions, route) { 9 | return async function(request: FastifyRequest): Promise { 10 | const allowedMethods = getAllowedMethods(this.config); 11 | const handlerKey = Object.keys(routeOptions).find( 12 | (key) => 13 | routeOptions[key].method === request.routerMethod && 14 | routeOptions[key].url === request.routerPath.replace(route || '/', '') 15 | ); 16 | 17 | if (handlerKey && !allowedMethods.includes(handlerKey as any)) { 18 | throw createError(405, `Method ${request.routerMethod} is not allowed here`); 19 | } 20 | }; 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/index.ts: -------------------------------------------------------------------------------- 1 | import hooks from './hooks'; 2 | import methods from './methods'; 3 | import getRoutes from './routes'; 4 | import { createControllerConfig } from '../../configurations'; 5 | import { baseSchema, schemaDefinitions } from './schemaBuilder'; 6 | import { Initialize } from '../../symbols'; 7 | import type { FastifyInstance } from 'fastify'; 8 | import type { IControllerConfig, IControllerOptions, IModelOptions, IBaseModel, Constructable } from '../../types'; 9 | 10 | 11 | export function EntityController(Entity: E, route?: string, options?: IControllerOptions & IModelOptions): any { 12 | return function(target: T) { 13 | const origin = target; 14 | 15 | const handlersSet = Reflect.getMetadata('fastify-resty:handlers', origin.prototype) || new Set(); 16 | const hooksSet = Reflect.getMetadata('fastify-resty:hooks', origin.prototype) || new Set(); 17 | 18 | (origin as any).prototype[Initialize] = function(fastifyInstance: FastifyInstance & { BaseModel?: new(...args) => IBaseModel }, defaultConfig: IControllerConfig) { 19 | if (!fastifyInstance.BaseModel && typeof fastifyInstance !== 'function') { 20 | throw new Error('Database connector is not bootstrapped! Missing Model class'); 21 | } 22 | 23 | this.config = createControllerConfig(options, defaultConfig); 24 | this.model = new fastifyInstance.BaseModel(Entity, this.config); 25 | 26 | const { jsonSchema } = this.model; 27 | 28 | const definitions = schemaDefinitions(jsonSchema); 29 | Reflect.defineMetadata('fastify-resty:definitions', definitions, origin.prototype); 30 | 31 | const routeSchemas = baseSchema(`/${this.model.name}.json`, jsonSchema); 32 | const routeOptions = getRoutes(routeSchemas); 33 | 34 | // register base entity methods 35 | Object.keys(methods).forEach(methodKey => { 36 | Reflect.defineProperty(origin.prototype, methodKey, { 37 | enumerable: true, 38 | value: methods[methodKey] 39 | }); 40 | 41 | handlersSet.add(methodKey); 42 | Reflect.defineMetadata('fastify-resty:handler', routeOptions[methodKey], origin.prototype, methodKey); 43 | }); 44 | 45 | // register base entity hooks 46 | if (!this.config.allowMulti || Array.isArray(this.config.methods)) { 47 | const hookHandlerKey = 'validateAllowedMethods'; 48 | 49 | Reflect.defineProperty(origin.prototype, hookHandlerKey, { 50 | enumerable: true, 51 | value: hooks[hookHandlerKey].handler(routeOptions, route) 52 | }); 53 | 54 | hooksSet.add(hookHandlerKey); 55 | Reflect.defineMetadata('fastify-resty:hook', { event: 'onRequest' }, origin.prototype, hookHandlerKey); 56 | } 57 | } 58 | 59 | Reflect.defineMetadata('fastify-resty:controller', { Entity, route }, origin.prototype); 60 | Reflect.defineMetadata('fastify-resty:handlers', handlersSet, origin.prototype); 61 | Reflect.defineMetadata('fastify-resty:hooks', hooksSet, origin.prototype); 62 | 63 | return origin; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/methods.ts: -------------------------------------------------------------------------------- 1 | import createError from 'http-errors'; 2 | import { get } from '../../helpers'; 3 | 4 | import type { FastifyRequest } from 'fastify'; 5 | import type { IFindQuery, IFindWhereQuery, Identifier, IControllerConfig, IPagination } from '../../types'; 6 | 7 | declare type Entity = any; 8 | 9 | const getPaginationConfig = (config: IControllerConfig): IPagination | null => typeof config.pagination === 'boolean' ? null : config.pagination; 10 | 11 | export default { 12 | async find(request: FastifyRequest<{ Querystring: IFindQuery }>) { 13 | const query = request.query; 14 | 15 | if (!this.config.pagination) { 16 | return { data: this.model.find(query) }; 17 | } 18 | 19 | const paginationConfig = getPaginationConfig(this.config); 20 | 21 | if (paginationConfig && query.$limit === undefined) { 22 | query.$limit = paginationConfig.limit; 23 | } 24 | 25 | return { 26 | data: await this.model.find(query), 27 | skip: query.$skip || 0, 28 | limit: query.$limit || paginationConfig.limit, 29 | total: paginationConfig.total && await this.model.total(query) 30 | }; 31 | }, 32 | async findOne(request: FastifyRequest<{ Params: { id: Identifier; } }>) { 33 | const results = await this.model.find({ $where: { [this.config.id]: request.params.id } }); 34 | 35 | if (results.length === 0) { 36 | throw createError(404, `${this.model.name} #${request.params.id} is not found`); 37 | } 38 | 39 | return results[0]; 40 | }, 41 | async create(request: FastifyRequest<{ Body: Entity, Querystring: { $results: boolean } }>) { 42 | const { identifiers } = await this.model.create(request.body); 43 | 44 | if (get(request.query.$results, this.config.returning)) { 45 | return await this.model.find({ $where: { [this.config.id]: { $in: identifiers } } }); 46 | } 47 | 48 | return identifiers; 49 | }, 50 | async patch(request: FastifyRequest<{ Body: Entity, Querystring: IFindWhereQuery & { $results?: boolean } }>) { 51 | const response: any = {}; 52 | 53 | let patchIds: number[]; 54 | if (get(request.query.$results, this.config.returning)) { 55 | const patchQuery = await this.model.find({ $select: [this.config.id], $where: request.query }); 56 | patchIds = patchQuery.map(row => row[this.config.id]); 57 | } 58 | 59 | const { affected } = await this.model.patch(request.query, request.body); 60 | response.affected = affected; 61 | 62 | if (get(request.query.$results, this.config.returning)) { 63 | response.data = await this.model.find({ $where: { [this.config.id]: { $in: patchIds } } }); 64 | } 65 | 66 | return response; 67 | }, 68 | async patchOne(request: FastifyRequest<{ Params: { id: Identifier; }, Body: Entity, Querystring: { $results: boolean } }>) { 69 | const { params: { id }, query: { $results } } = request; 70 | const { affected } = await this.model.patch({ [this.config.id]: id }, request.body); 71 | 72 | if (get($results, this.config.returning)) { 73 | return (await this.model.find({ $where: { [this.config.id]: id } }))[0]; 74 | } 75 | 76 | return { affected }; 77 | }, 78 | async update(request: FastifyRequest<{ Body: Entity, Querystring: IFindWhereQuery | { $results: boolean } }>) { 79 | const response: any = {}; 80 | 81 | let updateIds: number[]; 82 | if (get(request.query.$results, this.config.returning)) { 83 | const updateQuery = await this.model.find({ $select: [this.config.id], $where: request.query }); 84 | updateIds = updateQuery.map(row => row[this.config.id]); 85 | 86 | if (!updateIds.length) { 87 | return { affected: 0, data: [] }; 88 | } 89 | } 90 | 91 | const { affected } = await this.model.update(request.query, request.body); 92 | response.affected = affected; 93 | 94 | if (get(request.query.$results, this.config.returning)) { 95 | response.data = await this.model.find({ $where: { [this.config.id]: { $in: updateIds } } }); 96 | } 97 | 98 | return response; 99 | }, 100 | async updateOne(request: FastifyRequest<{ Params: { id: Identifier; }, Body: Entity, Querystring: { $results: boolean } }>) { 101 | const { params: { id }, query: { $results } } = request; 102 | const { affected } = await this.model.update({ [this.config.id]: id }, request.body); 103 | 104 | if (get($results, this.config.returning)) { 105 | const result = await this.model.find({ $where: { [this.config.id]: id } }); 106 | return result[0]; 107 | } 108 | 109 | return { affected }; 110 | }, 111 | async remove(request: FastifyRequest<{ Querystring: IFindWhereQuery | { $results: boolean } }>) { 112 | const response: any = {}; 113 | 114 | if (get(request.query.$results, this.config.returning)) { 115 | response.data = await this.model.find({ $where: request.query }); 116 | 117 | if (!response.data.length) { 118 | return { affected: 0, data: [] }; 119 | } 120 | } 121 | 122 | const { affected } = await this.model.remove(request.query); 123 | response.affected = affected; 124 | 125 | return response; 126 | }, 127 | async removeOne(request: FastifyRequest<{ Params: { id: Identifier; }, Querystring: { $results: boolean } }>) { 128 | const { params: { id }, query: { $results } } = request; 129 | let removedRow; 130 | 131 | if (get($results, this.config.returning)) { 132 | const results = await this.model.find({ $where: { [this.config.id]: id } }); 133 | if (results.length === 0) { 134 | throw createError(404, `${this.model.name} #${request.params.id} is not found to be removed`); 135 | } 136 | 137 | removedRow = results[0]; 138 | } 139 | 140 | const { affected } = await this.model.remove({ [this.config.id]: id }); 141 | 142 | if (get($results, this.config.returning)) { 143 | return removedRow; 144 | } 145 | 146 | return { affected }; 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/routes.ts: -------------------------------------------------------------------------------- 1 | import type { IControllerSchemas, IRouteOptions } from '../../types'; 2 | 3 | export default (routeSchemas: IControllerSchemas): IRouteOptions => ({ 4 | find: { 5 | method: 'GET', 6 | url: '/', 7 | schema: { 8 | ...routeSchemas.find 9 | } 10 | }, 11 | findOne: { 12 | method: 'GET', 13 | url: '/:id', 14 | schema: { 15 | ...routeSchemas.findOne 16 | } 17 | }, 18 | create: { 19 | method: 'POST', 20 | url: '/', 21 | schema: { 22 | ...routeSchemas.create 23 | } 24 | }, 25 | patch: { 26 | method: 'PATCH', 27 | url: '/', 28 | schema: { 29 | ...routeSchemas.patch 30 | } 31 | }, 32 | patchOne: { 33 | method: 'PATCH', 34 | url: '/:id', 35 | schema: { 36 | ...routeSchemas.patchOne 37 | } 38 | }, 39 | update: { 40 | method: 'PUT', 41 | url: '/', 42 | schema: { 43 | ...routeSchemas.update 44 | } 45 | }, 46 | updateOne: { 47 | method: 'PUT', 48 | url: '/:id', 49 | schema: { 50 | ...routeSchemas.updateOne 51 | } 52 | }, 53 | remove: { 54 | method: 'DELETE', 55 | url: '/', 56 | schema: { 57 | ...routeSchemas.remove 58 | } 59 | }, 60 | removeOne: { 61 | method: 'DELETE', 62 | url: '/:id', 63 | schema: { 64 | ...routeSchemas.removeOne 65 | } 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/schemaBuilder/baseSchema.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchema7 } from 'json-schema'; 2 | import type { IControllerSchemas, JSONSchema7Extended } from '../../../types'; 3 | 4 | const sortingEnum = ['ASC', 'DESC', 'asc', 'desc']; 5 | 6 | const partialSchemaProperties = (schemaId: string, schema: Record) => 7 | Object.keys(schema) 8 | .filter(key => !schema[key]._options?.hidden && !schema[key].readOnly) 9 | .reduce((props, key) => ({ 10 | ...props, 11 | [key]: { $ref: `${schemaId}#/definitions/entity/properties/${key}` } 12 | }), {}); 13 | 14 | const mergeRef = ($ref: string, properties): JSONSchema7 => ({ 15 | type: 'object', 16 | allOf: [{ $ref }, { properties }] 17 | }); 18 | 19 | const multiAffectedResponse = ($ref: string): JSONSchema7 => ({ 20 | type: 'object', 21 | properties: { 22 | affected: { type: 'number' }, 23 | data: { 24 | type: 'array', 25 | items: { $ref } 26 | } 27 | } 28 | }); 29 | 30 | const singleAffectedResponse = ($ref: string): JSONSchema7 => { 31 | const properties: Record = { affected: { type: 'number' } }; 32 | return { 33 | type: 'object', 34 | properties, 35 | if: { not: { properties } }, 36 | then: { $ref } 37 | }; 38 | }; 39 | 40 | export const baseSchema = (schemaId: string, schema: Record): IControllerSchemas => ({ 41 | find: { 42 | querystring: { 43 | $select: { 44 | type: ['string', 'array'], 45 | items: { 46 | type: 'string' 47 | } 48 | }, 49 | $sort: { 50 | type: ['string', 'array', 'object'], 51 | enum: sortingEnum, 52 | items: { 53 | type: 'string' 54 | }, 55 | properties: Object.keys(schema) 56 | .filter(key => !schema[key].writeOnly && !schema[key]._options?.hidden) 57 | .reduce((acc, key) => ({ 58 | ...acc, [key]: { type: 'string', enum: sortingEnum } 59 | }), {}) 60 | }, 61 | $limit: { type: 'number' }, 62 | $skip: { type: 'number' }, 63 | $where: { $ref: `${schemaId}#/definitions/query` } 64 | }, 65 | response: { 66 | 200: { 67 | type: 'object', 68 | properties: { 69 | total: { type: 'number' }, 70 | limit: { type: 'number' }, 71 | skip: { type: 'number' }, 72 | data: { 73 | type: 'array', 74 | items: { 75 | $ref: `${schemaId}#/definitions/entity` 76 | } 77 | } 78 | } 79 | } 80 | } 81 | }, 82 | findOne: { 83 | params: { 84 | id: { type: ['number', 'string'] } 85 | }, 86 | querystring: { 87 | $results: { type: 'boolean' } 88 | }, 89 | response: { 90 | 200: { $ref: `${schemaId}#/definitions/entity` } 91 | } 92 | }, 93 | create: { 94 | querystring: { 95 | $results: { type: 'boolean' } 96 | }, 97 | body: { 98 | type: ['array', 'object'], 99 | items: { $ref: `${schemaId}#/definitions/entity` }, 100 | 101 | if: { type: 'object' }, 102 | then: { $ref: `${schemaId}#/definitions/entity` } 103 | }, 104 | response: { 105 | // Not possible to support a few types. The issue: https://github.com/fastify/fast-json-stringify/issues/193 106 | 200: { 107 | type: 'array', 108 | items: { 109 | type: ['number', 'object'], 110 | if: { type: 'object' }, 111 | then: { $ref: `${schemaId}#/definitions/entity` } 112 | } 113 | } 114 | } 115 | }, 116 | patch: { 117 | querystring: mergeRef(`${schemaId}#/definitions/query`, { $results: { type: 'boolean' } }), 118 | body: { 119 | type: 'object', 120 | properties: partialSchemaProperties(schemaId, schema) 121 | }, 122 | response: { 123 | 200: multiAffectedResponse(`${schemaId}#/definitions/entity`) 124 | } 125 | }, 126 | patchOne: { 127 | params: { 128 | id: { type: ['number', 'string'] } 129 | }, 130 | querystring: { 131 | $results: { type: 'boolean' } 132 | }, 133 | body: { 134 | type: 'object', 135 | properties: partialSchemaProperties(schemaId, schema) 136 | }, 137 | response: { 138 | 200: singleAffectedResponse(`${schemaId}#/definitions/entity`) 139 | } 140 | }, 141 | update: { 142 | querystring: mergeRef(`${schemaId}#/definitions/query`, { $results: { type: 'boolean' } }), 143 | body: { 144 | $ref: `${schemaId}#/definitions/entity` 145 | }, 146 | response: { 147 | 200: multiAffectedResponse(`${schemaId}#/definitions/entity`) 148 | } 149 | }, 150 | updateOne: { 151 | params: { 152 | id: { type: ['number', 'string'] } 153 | }, 154 | querystring: { 155 | $results: { type: 'boolean' } 156 | }, 157 | body: { 158 | $ref: `${schemaId}#/definitions/entity` 159 | }, 160 | response: { 161 | 200: singleAffectedResponse(`${schemaId}#/definitions/entity`) 162 | } 163 | }, 164 | remove: { 165 | querystring: mergeRef(`${schemaId}#/definitions/query`, { $results: { type: 'boolean' } }), 166 | response: { 167 | 200: multiAffectedResponse(`${schemaId}#/definitions/entity`) 168 | } 169 | }, 170 | removeOne: { 171 | params: { 172 | id: { type: ['number', 'string'] } 173 | }, 174 | querystring: { 175 | $results: { type: 'boolean' } 176 | }, 177 | response: { 178 | 200: singleAffectedResponse(`${schemaId}#/definitions/entity`) 179 | } 180 | } 181 | }); 182 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/schemaBuilder/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schemaDefinitions'; 2 | export * from './baseSchema'; 3 | -------------------------------------------------------------------------------- /packages/core/src/decorators/entityController/schemaBuilder/schemaDefinitions.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchema7 } from 'json-schema'; 2 | import type { JSONSchema7Extended } from '../../../types'; 3 | 4 | const queryProperties = (type: 'number' | 'string' | ['number', 'string']): Record => ({ 5 | $eq: { type }, 6 | $neq: { type }, 7 | $gt: { type }, 8 | $gte: { type }, 9 | $lt: { type }, 10 | $lte: { type }, 11 | $like: { type: 'string' }, 12 | $nlike: { type: 'string' }, 13 | $ilike: { type: 'string' }, 14 | $nilike: { type: 'string' }, 15 | $regex: { type: 'string' }, 16 | $nregex: { type: 'string' }, 17 | $in: { type: 'array', items: { type } }, 18 | $nin: { type: 'array', items: { type } }, 19 | $between: { type: 'array', items: [{ type }, { type }] }, 20 | $nbetween: { type: 'array', items: [{ type }, { type }] } 21 | }); 22 | 23 | const isRequired = (property: JSONSchema7Extended) => !property.default && !property._options?.generated && !property._options?.nullable; 24 | 25 | export const schemaDefinitions = (schemaProperties: Record): JSONSchema7 => ({ 26 | definitions: { 27 | entity: { 28 | type: 'object', 29 | properties: Object.keys(schemaProperties) 30 | .filter(key => !schemaProperties[key]._options?.hidden) 31 | .reduce((props, key) => ({ ...props, [key]: schemaProperties[key] }), {}), 32 | required: Object.keys(schemaProperties) 33 | .reduce((required, key) => [...required, ...(isRequired(schemaProperties[key]) ? [key] : [])], []) 34 | }, 35 | query: { 36 | type: 'object', 37 | properties: Object.keys(schemaProperties) 38 | .filter(key => !schemaProperties[key].writeOnly && !schemaProperties[key]._options?.hidden) 39 | .reduce((acc, key) => ({ 40 | ...acc, 41 | [key]: { 42 | type: ['string', 'number', 'object'], 43 | properties: queryProperties( 44 | ['string', 'number'].includes(schemaProperties[key].type.toString()) 45 | ? schemaProperties[key].type as 'string' | 'number' 46 | : ['number', 'string'] 47 | ) 48 | } 49 | }), { 50 | $or: { 51 | type: 'array', 52 | items: { 53 | $ref: '#/definitions/query' 54 | } 55 | } 56 | }) 57 | } 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /packages/core/src/decorators/hooks.ts: -------------------------------------------------------------------------------- 1 | type ControllerHooks = 'onRequest' | 'preParsing' | 'preValidation' | 'preHandler' | 'preSerialization' | 'onError' | 'onSend' | 'onResponse' | 'onTimeout'; 2 | 3 | function hookFactory(event: ControllerHooks) { 4 | return function(target: object, propertyKey: string): void { 5 | Reflect.defineMetadata('fastify-resty:hook', { event }, target, propertyKey); 6 | 7 | if (Reflect.hasMetadata('fastify-resty:hooks', target)) { 8 | const hooks: Set = Reflect.getMetadata('fastify-resty:hooks', target); 9 | hooks.add(propertyKey); 10 | } else { 11 | Reflect.defineMetadata('fastify-resty:hooks', new Set([ propertyKey ]), target); 12 | } 13 | } 14 | } 15 | 16 | export const OnRequest = hookFactory('onRequest'); 17 | 18 | export const PreParsing = hookFactory('preParsing'); 19 | 20 | export const PreValidation = hookFactory('preValidation'); 21 | 22 | export const PreHandler = hookFactory('preHandler'); 23 | 24 | export const PreSerialization = hookFactory('preSerialization'); 25 | 26 | export const OnError = hookFactory('onError'); 27 | 28 | export const OnSend = hookFactory('onSend'); 29 | 30 | export const OnResponse = hookFactory('onResponse'); 31 | 32 | export const OnTimeout = hookFactory('onTimeout'); 33 | -------------------------------------------------------------------------------- /packages/core/src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller'; 2 | export * from './entityController'; 3 | export * from './requestMethods'; 4 | export * from './hooks'; 5 | export * from './model'; 6 | export * from './inject'; 7 | export * from './service'; 8 | -------------------------------------------------------------------------------- /packages/core/src/decorators/inject.ts: -------------------------------------------------------------------------------- 1 | import type { Constructable, IInjectToken } from '../types'; 2 | 3 | export function Inject(token?: IInjectToken) { 4 | return function(target, propertyKey: string, parameterIndex?: number): void { 5 | if (typeof propertyKey === 'undefined' && typeof parameterIndex === 'number') { 6 | const injectMap: Map = Reflect.getMetadata('fastify-resty:inject:constructor', target) || new Map(); 7 | injectMap.set(parameterIndex, token); 8 | 9 | Reflect.defineMetadata('fastify-resty:inject:constructor', injectMap, target); 10 | } else if (typeof propertyKey === 'string' && typeof parameterIndex === 'undefined') { 11 | const injectMap: Map = Reflect.getMetadata('fastify-resty:inject:properties', target) || new Map(); 12 | injectMap.set(propertyKey, token || Reflect.getMetadata('design:type', target, propertyKey)); 13 | 14 | Reflect.defineMetadata('fastify-resty:inject:properties', injectMap, target); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/decorators/model.ts: -------------------------------------------------------------------------------- 1 | import type { IModelOptions } from '../types'; 2 | 3 | export function Model(Entity, options?: IModelOptions) { 4 | return function (target, propertyKey: string, parameterIndex?: number): void { 5 | let metadataKey; 6 | if (typeof propertyKey === 'undefined' && typeof parameterIndex === 'number') { 7 | metadataKey = 'fastify-resty:inject:constructor:model'; 8 | } else if (typeof propertyKey === 'string' && typeof parameterIndex === 'undefined') { 9 | metadataKey = 'fastify-resty:inject:properties:model'; 10 | } 11 | 12 | if (metadataKey) { 13 | const injectMap: Map = Reflect.getMetadata(metadataKey, target) || new Map(); 14 | injectMap.set(propertyKey || parameterIndex, { Entity, options }); 15 | Reflect.defineMetadata(metadataKey, injectMap, target); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/decorators/requestMethods.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPMethods, RouteShorthandOptions } from 'fastify'; 2 | 3 | const httpMethods: HTTPMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']; 4 | 5 | function requestMethodFactory(methods: HTTPMethods | HTTPMethods[], url: string, options?: RouteShorthandOptions) { 6 | return function(target: object, propertyKey: string | symbol): void { 7 | // apply method if string or array of methods received, otherwise set all possible methods 8 | const method = typeof methods === 'string' || (Array.isArray(methods) && methods.length > 0) ? methods : httpMethods; 9 | 10 | Reflect.defineMetadata('fastify-resty:handler', { url, method, ...options }, target, propertyKey); 11 | 12 | if (Reflect.hasMetadata('fastify-resty:handlers', target)) { 13 | const handlers: Set = Reflect.getMetadata('fastify-resty:handlers', target); 14 | handlers.add(propertyKey.toString()); 15 | } else { 16 | Reflect.defineMetadata('fastify-resty:handlers', new Set([ propertyKey ]), target); 17 | } 18 | } 19 | } 20 | 21 | export function GET(route: string, options?: RouteShorthandOptions) { 22 | return requestMethodFactory('GET', route, options); 23 | } 24 | 25 | export function HEAD(route: string, options?: RouteShorthandOptions) { 26 | return requestMethodFactory('HEAD', route, options); 27 | } 28 | 29 | export function PATCH(route: string, options?: RouteShorthandOptions) { 30 | return requestMethodFactory('PATCH', route, options); 31 | } 32 | 33 | export function POST(route: string, options?: RouteShorthandOptions) { 34 | return requestMethodFactory('POST', route, options); 35 | } 36 | 37 | export function PUT(route: string, options?: RouteShorthandOptions) { 38 | return requestMethodFactory('PUT', route, options); 39 | } 40 | 41 | export function OPTIONS(route: string, options?: RouteShorthandOptions) { 42 | return requestMethodFactory('OPTIONS', route, options); 43 | } 44 | 45 | export function DELETE(route: string, options?: RouteShorthandOptions) { 46 | return requestMethodFactory('DELETE', route, options); 47 | } 48 | 49 | export function ALL(route: string, methods?: HTTPMethods[] | null, options?: RouteShorthandOptions) { 50 | return requestMethodFactory(methods, route, options); 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/src/decorators/service.ts: -------------------------------------------------------------------------------- 1 | import { serviceTokens } from '../injector'; 2 | 3 | export function Service(token?: string | Symbol) { 4 | return function(target): void { 5 | if (typeof token === 'string' || typeof token === 'symbol') { 6 | serviceTokens.set(token, target); 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns value if defined, otherwise default value. 3 | * @param {T} value - Primary value 4 | * @param {T} defaultValue - Default value for the case if value is undefined 5 | * @returns {T} 6 | */ 7 | export const get = (value: T, defaultValue: T): T => value !== undefined ? value : defaultValue; 8 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | export * from './types'; 4 | export * from './decorators'; 5 | export * from './bootstrap'; 6 | 7 | export { FastifyToken, GlobalConfig } from './symbols'; 8 | -------------------------------------------------------------------------------- /packages/core/src/injector.ts: -------------------------------------------------------------------------------- 1 | import { FastifyToken } from './symbols'; 2 | import type { Constructable, IInjectToken } from './types'; 3 | 4 | /* global service tokens map */ 5 | export const serviceTokens: Map = new Map(); 6 | 7 | export default class Injector { 8 | 9 | private readonly injectableMap: Map = new Map(); 10 | 11 | private resolve(constructor: Constructable | IInjectToken) { 12 | let currentInstance = this.injectableMap.get(constructor); 13 | if (currentInstance) return currentInstance; 14 | 15 | const fastifyInstance = this.injectableMap.get(FastifyToken); 16 | 17 | if (typeof constructor !== 'function') { // TODO check if constructable 18 | // service token 19 | if (serviceTokens.has(constructor)) { 20 | const serviceConstructor = serviceTokens.get(constructor); 21 | const serviceInstance = this.resolve(serviceConstructor); 22 | 23 | this.injectableMap.set(serviceConstructor, serviceInstance); 24 | this.injectableMap.set(constructor, serviceInstance); 25 | 26 | return serviceInstance; 27 | } 28 | 29 | // fastify decorated value 30 | return fastifyInstance[constructor]; 31 | } 32 | 33 | const paramTypes: Constructable[] = Reflect.getMetadata('design:paramtypes', constructor) || []; 34 | const injectedParams: Map = Reflect.getMetadata('fastify-resty:inject:constructor', constructor) || new Map(); 35 | const injectedModelParams: Map = Reflect.getMetadata('fastify-resty:inject:constructor:model', constructor) || new Map(); 36 | 37 | const constructorParams = paramTypes.map((param, index) => { 38 | if (injectedModelParams.has(index)) { 39 | const { Entity, options } = injectedModelParams.get(index) as any; 40 | return new fastifyInstance.BaseModel(Entity, options); 41 | } 42 | return this.resolve(injectedParams.get(index) || param) 43 | }); 44 | 45 | // Inject static properties 46 | const injectStaticMap: Map = Reflect.getMetadata('fastify-resty:inject:properties', constructor) || new Map(); 47 | for (const [property, token] of injectStaticMap.entries()) { 48 | constructor[property] = this.resolve(token); 49 | } 50 | 51 | // Inject static model properties 52 | const injectModelStaticMap: Map = Reflect.getMetadata('fastify-resty:inject:properties:model', constructor) || new Map(); 53 | for (const [property, { Entity, options }] of injectModelStaticMap.entries()) { 54 | constructor[property] = new fastifyInstance.BaseModel(Entity, options); 55 | } 56 | 57 | currentInstance = Reflect.construct(constructor, constructorParams); 58 | 59 | // Inject instance properties 60 | const injectPropsMap: Map = Reflect.getMetadata('fastify-resty:inject:properties', currentInstance) || new Map(); 61 | for (const [property, token] of injectPropsMap.entries()) { 62 | currentInstance[property] = this.resolve(token); 63 | } 64 | 65 | // Inject instance model properties 66 | const injectModelPropsMap: Map = Reflect.getMetadata('fastify-resty:inject:properties:model', currentInstance) || new Map(); 67 | for (const [property, { Entity, options }] of injectModelPropsMap.entries()) { 68 | currentInstance[property] = new fastifyInstance.BaseModel(Entity, options); 69 | } 70 | 71 | this.injectableMap.set(constructor, currentInstance); 72 | 73 | return currentInstance; 74 | } 75 | 76 | public getInstance(constructor: Constructable): T { 77 | return this.resolve(constructor); 78 | } 79 | 80 | public registerInstance(key: Constructable | IInjectToken, value: any): void { 81 | this.injectableMap.set(key, value); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/core/src/symbols.ts: -------------------------------------------------------------------------------- 1 | // public 2 | export const FastifyToken = Symbol('FastifyToken'); 3 | export const GlobalConfig = Symbol('GlobalConfig'); 4 | 5 | // internal 6 | export const Initialize = Symbol('Initialize'); 7 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { RouteOptions } from 'fastify'; 2 | import type { JSONSchema7 } from 'json-schema'; 3 | 4 | type Override = Omit & T2; 5 | 6 | export interface Constructable { 7 | new(...args): T; 8 | } 9 | 10 | export type JSONSchema7Extended = JSONSchema7 & { 11 | _options?: { 12 | generated?: boolean; 13 | nullable?: boolean; 14 | hidden?: boolean; 15 | } 16 | }; 17 | 18 | export interface IRequestSchema { 19 | body?: JSONSchema7; 20 | querystring?: JSONSchema7 | Record; 21 | params?: Record; 22 | response?: Record; 23 | headers?: Record; 24 | } 25 | 26 | export type IBaseControllerMethods = 'find' 27 | | 'findOne' 28 | | 'create' 29 | | 'patch' 30 | | 'patchOne' 31 | | 'update' 32 | | 'updateOne' 33 | | 'remove' 34 | | 'removeOne'; 35 | 36 | export type IControllerSchemas = { 37 | [key in IBaseControllerMethods]: IRequestSchema 38 | }; 39 | 40 | export type IRouteOptions = { 41 | [key in IBaseControllerMethods]: Partial 42 | }; 43 | 44 | export type Identifier = number | string; 45 | 46 | export type ModifyResponse = { affected: number }; 47 | 48 | export interface IFindResponse { 49 | data: E[]; 50 | total?: number; 51 | limit?: number; 52 | offset?: number; 53 | } 54 | 55 | export interface IFindQuery { 56 | $limit?: number; 57 | $skip?: number; 58 | $select?: string[]; 59 | $sort?: string | string[]; 60 | $where?: IFindWhereQuery; 61 | } 62 | 63 | export interface IFindWhereQuery { // todo specify 64 | $or?: { 65 | [key: string]: any; 66 | }; 67 | [key: string]: any; 68 | } 69 | 70 | export interface IBaseModel { 71 | readonly name: string; 72 | 73 | find (query?: IFindQuery): Promise; 74 | total (options?: IFindQuery): Promise; 75 | create (data: E | E[]): Promise<{ identifiers: Identifier[] }>; 76 | patch (query: IFindWhereQuery, raw: E): Promise; 77 | update (query: IFindWhereQuery, raw: E): Promise; 78 | remove (query: IFindWhereQuery): Promise; 79 | } 80 | 81 | export type IInjectToken = string | symbol; 82 | 83 | /* 84 | * Configuration types 85 | */ 86 | 87 | export interface IModelConfig { 88 | id: string; 89 | softDelete: boolean; 90 | } 91 | 92 | export type IModelOptions = Partial; 93 | 94 | export interface IPagination { 95 | limit: number; 96 | total: boolean; 97 | } 98 | 99 | export interface IControllerConfig { 100 | id: string; 101 | methods: IBaseControllerMethods[]; 102 | allowMulti: boolean; 103 | returning: boolean; 104 | pagination: boolean | IPagination; 105 | } 106 | 107 | export type IControllerOptions = Override, { pagination?: boolean | Partial }>; 108 | 109 | export interface ILoaderConfig { 110 | pattern: RegExp; 111 | controllers?: Constructable[]; 112 | entry?: string; 113 | } 114 | 115 | export type IApplicationConfig = ILoaderConfig & { 116 | defaults: IControllerConfig & IModelConfig 117 | }; 118 | 119 | export type IApplicationOptions = Partial & { 120 | defaults?: IControllerOptions & IModelOptions 121 | }; 122 | -------------------------------------------------------------------------------- /packages/core/tests/data/controllerMethodsDefinitions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | get: { 3 | schema: { 4 | querystring: { 5 | $limit: { type: 'number' }, 6 | $results: { type: 'boolean' }, 7 | }, 8 | params: { 9 | id: { type: 'number' }, 10 | }, 11 | response: { 12 | 200: { 13 | type: 'object', 14 | properties: { 15 | data: { 16 | type: 'array', 17 | items: { 18 | type: 'object', 19 | properties: { 20 | id: { type: 'number' }, 21 | title: { type: 'string' }, 22 | }, 23 | }, 24 | }, 25 | total: { type: 'number' }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | response: { 31 | data: [ 32 | { id: 1, title: 'Sample 1' }, 33 | { id: 2, title: 'Second Sample' }, 34 | ], 35 | total: 2, 36 | limit: 20, 37 | skip: 0, 38 | }, 39 | }, 40 | post: { 41 | response: { 42 | id: 1, 43 | createdAt: new Date(), 44 | }, 45 | body: { 46 | id: 1, 47 | }, 48 | schema: { 49 | query: { 50 | $results: { type: 'boolean' }, 51 | }, 52 | body: { 53 | id: { type: 'number' }, 54 | }, 55 | response: { 56 | 200: { 57 | type: 'object', 58 | properties: { 59 | createdAt: { 60 | type: 'string', 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | patch: { 68 | response: { 69 | affected: 1, 70 | }, 71 | body: { 72 | title: 'Sample data title', 73 | draft: true, 74 | }, 75 | schema: { 76 | querystring: { 77 | times: { type: 'number' }, 78 | }, 79 | params: { 80 | ref: { type: 'string' }, 81 | }, 82 | body: { 83 | title: { type: 'string' }, 84 | draft: { type: 'boolean' }, 85 | }, 86 | response: { 87 | 200: { 88 | type: 'object', 89 | properties: { 90 | affected: { type: 'number' }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /packages/core/tests/data/controllers/entitySample.controller.ts: -------------------------------------------------------------------------------- 1 | import { EntityController, GET, PreHandler } from '../../../src'; 2 | 3 | @EntityController({}, '/entity-sample') 4 | export default class EntitySampleController { 5 | @GET('/custom', { 6 | schema: { 7 | querystring: { 8 | flag: { type: 'boolean' } 9 | } 10 | } 11 | }) 12 | async getCustom() { 13 | return { status: 'complete' }; 14 | } 15 | 16 | @PreHandler 17 | async preHandlerHook(): Promise { 18 | return; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/tests/data/controllers/sample.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, GET, OnRequest } from '../../../src'; 2 | 3 | @Controller('/sample') 4 | export default class SampleController { 5 | @GET('/custom', { 6 | schema: { 7 | querystring: { 8 | flag: { type: 'boolean' } 9 | } 10 | } 11 | }) 12 | async getCustom() { 13 | return { status: 'complete' }; 14 | } 15 | 16 | @OnRequest 17 | async onRequestHook(): Promise { 18 | return; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/tests/data/injectables.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '../../src/decorators/inject'; 2 | import { Service } from '../../src/decorators/service'; 3 | import { Controller } from '../../src/decorators/controller'; 4 | import { EntityController } from '../../src/decorators/entityController'; 5 | import { Model } from '../../src/decorators/model'; 6 | import { FastifyToken } from '../../src/symbols'; 7 | import { IBaseModel } from '../../dist'; 8 | 9 | const TantoToken = Symbol('TantoToken'); 10 | const NunchakuToken = Symbol('NunchakuToken'); 11 | 12 | export const FastifyDecorated = Symbol('FastifyDecorated'); 13 | 14 | /****** Injectables ******/ 15 | 16 | @Service() 17 | export class Katana { 18 | hit() { return 'cut!'; } 19 | } 20 | 21 | @Service() 22 | export class Shuriken { 23 | cast() { return 'wjuuuh'; } 24 | } 25 | 26 | @Service() 27 | export class Naginata { 28 | size = 'large'; 29 | } 30 | 31 | @Service('Wakizashi') 32 | export class Wakizashi { 33 | fight() { return 'bang' }; 34 | } 35 | 36 | @Service(TantoToken) 37 | export class Tanto { 38 | use() { return 'battle!' } 39 | } 40 | 41 | @Service(NunchakuToken) 42 | export class Nunchaku { 43 | brandish() { return 'wjuuuh-wjuuuh-wjuuuh'; }; 44 | } 45 | 46 | export class BackpackEntity {} 47 | 48 | /****** Injectable ******/ 49 | 50 | @Controller() 51 | export class Samurai { 52 | constructor( 53 | public katana: Katana, 54 | @Inject('schema') schema, 55 | @Inject() naginata: Naginata, 56 | @Inject(NunchakuToken) nunchaku, 57 | @Model(BackpackEntity) backpackModel 58 | ) { 59 | this.schema = schema; 60 | this.naginata = naginata; 61 | this.nunchaku = nunchaku; 62 | this.backpackModel = backpackModel; 63 | } 64 | 65 | schema: any; 66 | naginata: Naginata; 67 | nunchaku: Nunchaku; 68 | backpackModel: IBaseModel; 69 | 70 | @Inject() 71 | shuriken: Shuriken; 72 | 73 | @Inject() 74 | static shuriken: Shuriken; 75 | 76 | @Inject('steel') 77 | steel: string; 78 | 79 | @Inject('steel') 80 | static steel: string; 81 | 82 | @Inject('Wakizashi') 83 | wakizashi: { fight: () => string }; 84 | 85 | @Inject('Wakizashi') 86 | static wakizashi: { fight: () => string }; 87 | 88 | @Inject(FastifyToken) 89 | fastifyInstance; 90 | 91 | @Inject(TantoToken) 92 | tanto: { use: () => string } 93 | 94 | @Inject(TantoToken) 95 | static tanto: { use: () => string } 96 | 97 | @Inject(FastifyDecorated) 98 | fastifyDecorated: string; 99 | 100 | @Model(BackpackEntity) 101 | backpack: IBaseModel; 102 | 103 | @Model(BackpackEntity) 104 | static backpack: IBaseModel; 105 | } 106 | 107 | @EntityController({}) 108 | export class Ninja { 109 | constructor( 110 | public katana: Katana, 111 | @Inject('schema') schema, 112 | @Inject() naginata: Naginata, 113 | @Inject(NunchakuToken) nunchaku, 114 | @Model(BackpackEntity) backpackModel 115 | ) { 116 | this.schema = schema; 117 | this.naginata = naginata; 118 | this.nunchaku = nunchaku; 119 | this.backpackModel = backpackModel; 120 | } 121 | 122 | schema: any; 123 | naginata: Naginata; 124 | nunchaku: Nunchaku; 125 | backpackModel: IBaseModel; 126 | 127 | @Inject() 128 | shuriken: Shuriken; 129 | 130 | @Inject() 131 | static shuriken: Shuriken; 132 | 133 | @Inject('steel') 134 | steel: string; 135 | 136 | @Inject('steel') 137 | static steel: string; 138 | 139 | @Inject('Wakizashi') 140 | wakizashi: { fight: () => string }; 141 | 142 | @Inject('Wakizashi') 143 | static wakizashi: { fight: () => string }; 144 | 145 | @Inject(FastifyToken) 146 | fastifyInstance; 147 | 148 | @Inject(TantoToken) 149 | tanto: { use: () => string } 150 | 151 | @Inject(TantoToken) 152 | static tanto: { use: () => string } 153 | 154 | @Inject(FastifyDecorated) 155 | fastifyDecorated: string; 156 | 157 | @Model(BackpackEntity) 158 | backpack: IBaseModel; 159 | 160 | @Model(BackpackEntity) 161 | static backpack: IBaseModel; 162 | } 163 | 164 | @Service() 165 | export class Weapon { 166 | constructor( 167 | public katana: Katana, 168 | @Inject('schema') schema, 169 | @Inject() naginata: Naginata, 170 | @Inject(NunchakuToken) nunchaku, 171 | @Model(BackpackEntity) backpackModel 172 | ) { 173 | this.schema = schema; 174 | this.naginata = naginata; 175 | this.nunchaku = nunchaku; 176 | this.backpackModel = backpackModel; 177 | } 178 | 179 | schema: any; 180 | naginata: Naginata; 181 | nunchaku: Nunchaku; 182 | backpackModel: IBaseModel; 183 | 184 | @Inject() 185 | shuriken: Shuriken; 186 | 187 | @Inject() 188 | static shuriken: Shuriken; 189 | 190 | @Inject('steel') 191 | steel: string; 192 | 193 | @Inject('steel') 194 | static steel: string; 195 | 196 | @Inject('Wakizashi') 197 | wakizashi: { fight: () => string }; 198 | 199 | @Inject('Wakizashi') 200 | static wakizashi: { fight: () => string }; 201 | 202 | @Inject(FastifyToken) 203 | fastifyInstance; 204 | 205 | @Inject(TantoToken) 206 | tanto: { use: () => string } 207 | 208 | @Inject(TantoToken) 209 | static tanto: { use: () => string } 210 | 211 | @Inject(FastifyDecorated) 212 | fastifyDecorated: string; 213 | 214 | @Model(BackpackEntity) 215 | backpack: IBaseModel; 216 | 217 | @Model(BackpackEntity) 218 | static backpack: IBaseModel; 219 | } 220 | -------------------------------------------------------------------------------- /packages/core/tests/data/schemaDefinitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": { 3 | "entity": { 4 | "type": "object", 5 | "properties": { 6 | "id": { 7 | "type": "number" 8 | }, 9 | "string": { 10 | "type": "string" 11 | }, 12 | "boolean": { 13 | "type": "boolean", 14 | "default": false 15 | } 16 | }, 17 | "required": [ 18 | "id", 19 | "string", 20 | "boolean" 21 | ] 22 | }, 23 | "query": { 24 | "type": "object", 25 | "properties": { 26 | "$or": { 27 | "type": "array", 28 | "items": { 29 | "$ref": "#/definitions/query" 30 | } 31 | }, 32 | "id": { 33 | "type": [ 34 | "string", 35 | "number", 36 | "object" 37 | ], 38 | "properties": { 39 | "$eq": { 40 | "type": "number" 41 | }, 42 | "$neq": { 43 | "type": "number" 44 | }, 45 | "$gt": { 46 | "type": "number" 47 | }, 48 | "$gte": { 49 | "type": "number" 50 | }, 51 | "$lt": { 52 | "type": "number" 53 | }, 54 | "$lte": { 55 | "type": "number" 56 | }, 57 | "$like": { 58 | "type": "string" 59 | }, 60 | "$nlike": { 61 | "type": "string" 62 | }, 63 | "$ilike": { 64 | "type": "string" 65 | }, 66 | "$nilike": { 67 | "type": "string" 68 | }, 69 | "$regex": { 70 | "type": "string" 71 | }, 72 | "$nregex": { 73 | "type": "string" 74 | }, 75 | "$in": { 76 | "type": "array", 77 | "items": { 78 | "type": "number" 79 | } 80 | }, 81 | "$nin": { 82 | "type": "array", 83 | "items": { 84 | "type": "number" 85 | } 86 | }, 87 | "$between": { 88 | "type": "array", 89 | "items": [ 90 | { 91 | "type": "number" 92 | }, 93 | { 94 | "type": "number" 95 | } 96 | ] 97 | }, 98 | "$nbetween": { 99 | "type": "array", 100 | "items": [ 101 | { 102 | "type": "number" 103 | }, 104 | { 105 | "type": "number" 106 | } 107 | ] 108 | } 109 | } 110 | }, 111 | "string": { 112 | "type": [ 113 | "string", 114 | "number", 115 | "object" 116 | ], 117 | "properties": { 118 | "$eq": { 119 | "type": "string" 120 | }, 121 | "$neq": { 122 | "type": "string" 123 | }, 124 | "$gt": { 125 | "type": "string" 126 | }, 127 | "$gte": { 128 | "type": "string" 129 | }, 130 | "$lt": { 131 | "type": "string" 132 | }, 133 | "$lte": { 134 | "type": "string" 135 | }, 136 | "$like": { 137 | "type": "string" 138 | }, 139 | "$nlike": { 140 | "type": "string" 141 | }, 142 | "$ilike": { 143 | "type": "string" 144 | }, 145 | "$nilike": { 146 | "type": "string" 147 | }, 148 | "$regex": { 149 | "type": "string" 150 | }, 151 | "$nregex": { 152 | "type": "string" 153 | }, 154 | "$in": { 155 | "type": "array", 156 | "items": { 157 | "type": "string" 158 | } 159 | }, 160 | "$nin": { 161 | "type": "array", 162 | "items": { 163 | "type": "string" 164 | } 165 | }, 166 | "$between": { 167 | "type": "array", 168 | "items": [ 169 | { 170 | "type": "string" 171 | }, 172 | { 173 | "type": "string" 174 | } 175 | ] 176 | }, 177 | "$nbetween": { 178 | "type": "array", 179 | "items": [ 180 | { 181 | "type": "string" 182 | }, 183 | { 184 | "type": "string" 185 | } 186 | ] 187 | } 188 | } 189 | }, 190 | "boolean": { 191 | "type": [ 192 | "string", 193 | "number", 194 | "object" 195 | ], 196 | "properties": { 197 | "$eq": { 198 | "type": [ 199 | "number", 200 | "string" 201 | ] 202 | }, 203 | "$neq": { 204 | "type": [ 205 | "number", 206 | "string" 207 | ] 208 | }, 209 | "$gt": { 210 | "type": [ 211 | "number", 212 | "string" 213 | ] 214 | }, 215 | "$gte": { 216 | "type": [ 217 | "number", 218 | "string" 219 | ] 220 | }, 221 | "$lt": { 222 | "type": [ 223 | "number", 224 | "string" 225 | ] 226 | }, 227 | "$lte": { 228 | "type": [ 229 | "number", 230 | "string" 231 | ] 232 | }, 233 | "$like": { 234 | "type": "string" 235 | }, 236 | "$nlike": { 237 | "type": "string" 238 | }, 239 | "$ilike": { 240 | "type": "string" 241 | }, 242 | "$nilike": { 243 | "type": "string" 244 | }, 245 | "$regex": { 246 | "type": "string" 247 | }, 248 | "$nregex": { 249 | "type": "string" 250 | }, 251 | "$in": { 252 | "type": "array", 253 | "items": { 254 | "type": [ 255 | "number", 256 | "string" 257 | ] 258 | } 259 | }, 260 | "$nin": { 261 | "type": "array", 262 | "items": { 263 | "type": [ 264 | "number", 265 | "string" 266 | ] 267 | } 268 | }, 269 | "$between": { 270 | "type": "array", 271 | "items": [ 272 | { 273 | "type": [ 274 | "number", 275 | "string" 276 | ] 277 | }, 278 | { 279 | "type": [ 280 | "number", 281 | "string" 282 | ] 283 | } 284 | ] 285 | }, 286 | "$nbetween": { 287 | "type": "array", 288 | "items": [ 289 | { 290 | "type": [ 291 | "number", 292 | "string" 293 | ] 294 | }, 295 | { 296 | "type": [ 297 | "number", 298 | "string" 299 | ] 300 | } 301 | ] 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /packages/core/tests/integration/bootstrap.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { bootstrap } from '../../src/bootstrap'; 3 | import { GlobalConfig } from '../../src/symbols'; 4 | import fastify, { FastifyInstance } from 'fastify'; 5 | import ModelMock, * as ModelMockMethods from '../support/ModelMock'; 6 | import SampleController from '../data/controllers/sample.controller'; 7 | import EntitySampleController from '../data/controllers/entitySample.controller'; 8 | 9 | describe('Bootstrap', () => { 10 | const spies: Record = {}; 11 | let server: FastifyInstance; 12 | 13 | beforeAll(() => { 14 | spies.sample_getCustom = jest.spyOn(SampleController.prototype, 'getCustom'); 15 | spies.sample_onRequestHook = jest.spyOn(SampleController.prototype, 'onRequestHook'); 16 | spies.entitySample_getCustom = jest.spyOn(EntitySampleController.prototype, 'getCustom'); 17 | spies.entitySample_preHandlerHook = jest.spyOn(EntitySampleController.prototype, 'preHandlerHook'); 18 | // TODO entity sample find method 19 | }); 20 | 21 | beforeEach(() => { server = fastify(); }); 22 | 23 | afterEach(() => { 24 | Object.values(spies).forEach(spy => spy.mockClear()); 25 | ModelMock.mockClear(); 26 | server.close(); 27 | }); 28 | 29 | test('Should apply nested query string parser', async () => { 30 | await bootstrap(server, {}); 31 | 32 | server.get('/', async ({ query }: any) => !!(query.$where?.title && query.$where?.age?.$gte)); 33 | 34 | const response = await server.inject({ 35 | method: 'GET', 36 | url: '/?$where[title]=Hello&$where[id][$lt]=10&$where[age][$gte]=18' 37 | }); 38 | 39 | expect(response.body).toBe('true'); 40 | }); 41 | 42 | test('Should decorate global configuration object', async () => { 43 | bootstrap(server, {}); 44 | expect(server[GlobalConfig]).toBeDefined(); 45 | }); 46 | 47 | test('Should use default configuration', async () => { 48 | await bootstrap(server, {}); 49 | const config = server[GlobalConfig]; 50 | 51 | expect(config.entry).toBeUndefined(); 52 | expect(config.pattern.test('mycontroller.controller.ts')).toBeTruthy(); 53 | expect(config.defaults).toMatchObject({ 54 | pagination: { limit: 20, total: true }, 55 | id: 'id', 56 | softDelete: false, 57 | methods: undefined, 58 | allowMulti: true, 59 | returning: true 60 | }); 61 | 62 | }); 63 | 64 | test('Should merge custom and default configuration', async () => { 65 | await bootstrap(server, { 66 | defaults: { 67 | pagination: false, 68 | allowMulti: false, 69 | softDelete: true 70 | } 71 | }); 72 | 73 | expect(server[GlobalConfig].defaults).toMatchObject({ 74 | pagination: false, 75 | id: 'id', 76 | softDelete: true, 77 | methods: undefined, 78 | allowMulti: false, 79 | returning: true 80 | }); 81 | }); 82 | 83 | test('Should register controllers by direct array', async () => { 84 | ModelMockMethods.find.mockResolvedValue([{ name: 'Sample' }]); 85 | ModelMockMethods.total.mockResolvedValue(1); 86 | 87 | server.decorate('BaseModel', ModelMock); 88 | await bootstrap(server, { controllers: [SampleController, EntitySampleController] }); 89 | 90 | const responseSample = await server.inject({ method: 'GET', url: '/sample/custom?flag=true' }); 91 | expect(responseSample).toHaveProperty('statusCode', 200); 92 | expect(responseSample).toHaveProperty('statusMessage', 'OK'); 93 | expect(responseSample).toHaveProperty('body', '{"status":"complete"}'); 94 | expect(spies.sample_getCustom).toHaveBeenCalledTimes(1); 95 | expect(spies.sample_onRequestHook).toHaveBeenCalledTimes(1); 96 | 97 | const responseEntity = await server.inject({ method: 'GET', url: '/entity-sample/' }); 98 | expect(JSON.parse(responseEntity.body)).toMatchObject({ total: 1, limit: 20, skip: 0, data: [{ name: 'Sample' }]}); 99 | expect(responseEntity).toHaveProperty('statusCode', 200); 100 | expect(responseEntity).toHaveProperty('statusMessage', 'OK'); 101 | expect(spies.entitySample_preHandlerHook).toHaveBeenCalledTimes(1); 102 | 103 | const responseEntityCustom = await server.inject({ method: 'GET', url: '/entity-sample/custom' }); 104 | expect(responseEntityCustom).toHaveProperty('statusCode', 200); 105 | expect(responseEntityCustom).toHaveProperty('statusMessage', 'OK'); 106 | expect(responseEntityCustom).toHaveProperty('body', '{"status":"complete"}'); 107 | expect(spies.entitySample_getCustom).toHaveBeenCalledTimes(1); 108 | expect(spies.entitySample_preHandlerHook).toHaveBeenCalledTimes(2); 109 | }); 110 | 111 | test('Should register controllers with autoload', async () => { 112 | ModelMockMethods.find.mockResolvedValue([{ name: 'Sample' }]); 113 | ModelMockMethods.total.mockResolvedValue(1); 114 | 115 | server.decorate('BaseModel', ModelMock); 116 | await bootstrap(server, { entry: path.join(__dirname, '../data/controllers') }); 117 | 118 | const responseSample = await server.inject({ method: 'GET', url: '/sample/custom?flag=true' }); 119 | expect(responseSample).toHaveProperty('statusCode', 200); 120 | expect(responseSample).toHaveProperty('statusMessage', 'OK'); 121 | expect(responseSample).toHaveProperty('body', '{"status":"complete"}'); 122 | expect(spies.sample_getCustom).toHaveBeenCalledTimes(1); 123 | expect(spies.sample_onRequestHook).toHaveBeenCalledTimes(1); 124 | 125 | const responseEntity = await server.inject({ method: 'GET', url: '/entity-sample/' }); 126 | expect(JSON.parse(responseEntity.body)).toMatchObject({ total: 1, limit: 20, skip: 0, data: [{ name: 'Sample' }]}); 127 | expect(responseEntity).toHaveProperty('statusCode', 200); 128 | expect(responseEntity).toHaveProperty('statusMessage', 'OK'); 129 | expect(spies.entitySample_preHandlerHook).toHaveBeenCalledTimes(1); 130 | 131 | const responseEntityCustom = await server.inject({ method: 'GET', url: '/entity-sample/custom' }); 132 | expect(responseEntityCustom).toHaveProperty('statusCode', 200); 133 | expect(responseEntityCustom).toHaveProperty('statusMessage', 'OK'); 134 | expect(responseEntityCustom).toHaveProperty('body', '{"status":"complete"}'); 135 | expect(spies.entitySample_getCustom).toHaveBeenCalledTimes(1); 136 | expect(spies.entitySample_preHandlerHook).toHaveBeenCalledTimes(2); 137 | }); 138 | 139 | }); 140 | -------------------------------------------------------------------------------- /packages/core/tests/integration/decorators/controller.test.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from '../../../src/bootstrap'; 2 | import fastify, { FastifyInstance } from 'fastify'; 3 | 4 | import { controllerFactory } from '../../support/controllerFactory'; 5 | import methodsDefinitions from '../../data/controllerMethodsDefinitions'; 6 | 7 | 8 | describe('@Controller decorator', () => { 9 | let server: FastifyInstance; 10 | 11 | beforeEach(async () => { server = fastify(); }); 12 | 13 | afterEach(() => { server.close(); }); 14 | 15 | describe('Controller register', () => { 16 | 17 | test('Should register controller on specific route', async () => { 18 | // arrange 19 | const TestController = controllerFactory('/example', [{ route: '/', method: 'GET', handler: () => [] }]); 20 | await bootstrap(server, { controllers: [TestController] }); 21 | 22 | // act 23 | await server.listen(4000); 24 | 25 | // assert 26 | expect(server.printRoutes()).toBe( 27 | '└── /example (GET)\n' + 28 | ' └── / (GET)\n' 29 | ); 30 | }); 31 | 32 | test('Should register controller without "route" option', async () => { 33 | // arrange 34 | const TestController = controllerFactory(undefined, [{ route: '/my-route', method: 'GET', handler: () => [] }]); 35 | await bootstrap(server, { controllers: [TestController] }); 36 | 37 | // act 38 | await server.listen(4000); 39 | 40 | // assert 41 | expect(server.printRoutes()).toBe('└── /my-route (GET)\n'); 42 | }); 43 | 44 | }); 45 | 46 | describe('Controller hooks', () => { 47 | 48 | test('Should handle @OnRequest hook', async () => { 49 | // arrange 50 | const hookHandler = jest.fn((request, reply, done) => done()); 51 | const routeHandler = jest.fn().mockResolvedValue([]); 52 | 53 | const TestController = controllerFactory( 54 | '/hooks-on-request', 55 | [{ route: '/', method: 'GET', handler: routeHandler }], 56 | [{ hook: 'OnRequest', handler: hookHandler }] 57 | ); 58 | await bootstrap(server, { controllers: [TestController] }); 59 | 60 | // act 61 | const response = await server.inject({ method: 'GET', url: '/hooks-on-request/' }); 62 | 63 | // assert 64 | expect(response.statusCode).toBe(200); 65 | expect(response.statusMessage).toBe('OK'); 66 | 67 | expect(hookHandler).toBeCalledTimes(1); 68 | }); 69 | 70 | test('Should handle @PreParsing hook', async () => { 71 | // arrange 72 | const hookHandler = jest.fn((request, reply, payload, done) => done(null, payload)); 73 | const routeHandler = jest.fn().mockResolvedValue([]); 74 | 75 | const TestController = controllerFactory( 76 | '/hooks-pre-parsing', 77 | [{ route: '/', method: 'POST', handler: routeHandler }], 78 | [{ hook: 'PreParsing', handler: hookHandler }] 79 | ); 80 | await bootstrap(server, { controllers: [TestController] }); 81 | 82 | // act 83 | const response = await server.inject({ method: 'POST', url: '/hooks-pre-parsing/', body: { title: 'Testing' } } as any); 84 | 85 | // assert 86 | expect(response.statusCode).toBe(200); 87 | expect(response.statusMessage).toBe('OK'); 88 | 89 | expect(hookHandler).toBeCalledTimes(1); 90 | }); 91 | 92 | test('Should handle @PreValidation hook', async () => { 93 | // arrange 94 | const hookHandler = jest.fn((request, reply, done) => done()); 95 | const routeHandler = jest.fn().mockResolvedValue([]); 96 | 97 | const TestController = controllerFactory( 98 | '/hooks-pre-validation', 99 | [{ route: '/', method: 'GET', handler: routeHandler }], 100 | [{ hook: 'PreValidation', handler: hookHandler }] 101 | ); 102 | await bootstrap(server, { controllers: [TestController] }); 103 | 104 | // act 105 | const response = await server.inject({ method: 'GET', url: '/hooks-pre-validation/' }); 106 | 107 | // assert 108 | expect(response.statusCode).toBe(200); 109 | expect(response.statusMessage).toBe('OK'); 110 | 111 | expect(hookHandler).toBeCalledTimes(1); 112 | }); 113 | 114 | // TODO: add 'preHandler', 'preSerialization', 'onError', 'onSend', 'onResponse', 'onTimeout'; 115 | // with the next pattern: 116 | // 117 | // test('Should handle @PreValidation hook', async () => {}); 118 | 119 | }); 120 | 121 | describe('Request decorators', () => { 122 | 123 | describe('@GET', () => { 124 | 125 | test('Should register @GET endpoint', async () => { 126 | // arrange 127 | const { response } = methodsDefinitions.get; 128 | const handler = jest.fn().mockResolvedValue(response); 129 | 130 | const TestController = controllerFactory('/test-get', [{ route: '/:id', method: 'GET', handler }]); 131 | await bootstrap(server, { controllers: [TestController] }); 132 | 133 | // act 134 | const result = await server.inject({ method: 'GET', url: '/test-get/965?$limit=20&$results=true' }); 135 | 136 | // assert 137 | expect(result.statusCode).toBe(200); 138 | expect(result.statusMessage).toBe('OK'); 139 | expect(result.body).toBe(JSON.stringify(response)); 140 | 141 | expect(handler).toHaveBeenCalledTimes(1); 142 | expect(handler.mock.calls[0].length).toBe(2); 143 | expect(handler.mock.calls[0][0].params.id).toBe('965'); 144 | expect(handler.mock.calls[0][0].query.$limit).toBe('20'); 145 | expect(handler.mock.calls[0][0].query.$results).toBe('true'); 146 | }); 147 | 148 | test('Should register @GET endpoint with schema and hooks', async () => { 149 | // arrange 150 | const { response, schema } = methodsDefinitions.get; 151 | 152 | const handler = jest.fn().mockResolvedValue(response); 153 | const preValidation = jest.fn().mockImplementation((request, reply, done) => done()); 154 | 155 | const TestController = controllerFactory('/test-get', [{ route: '/:id', method: 'GET', handler, options: { schema, preValidation } }]); 156 | await bootstrap(server, { controllers: [TestController] }); 157 | 158 | // act 159 | const result = await server.inject({ method: 'GET', url: '/test-get/965?$limit=20&$results=true' }); 160 | 161 | // assert 162 | expect(result.statusCode).toBe(200); 163 | expect(result.statusMessage).toBe('OK'); 164 | expect(result.body).toBe(JSON.stringify({ data: response.data, total: response.total })); 165 | 166 | expect(handler).toHaveBeenCalledTimes(1); 167 | expect(handler.mock.calls[0].length).toBe(2); 168 | expect(handler.mock.calls[0][0].params.id).toBe(965); 169 | expect(handler.mock.calls[0][0].query.$limit).toBe(20); 170 | expect(handler.mock.calls[0][0].query.$results).toBe(true); 171 | 172 | expect(preValidation).toBeCalledTimes(1); 173 | }); 174 | 175 | }); 176 | 177 | describe('@POST', () => { 178 | 179 | test('Should register @POST endpoint', async () => { 180 | // arrange 181 | const { response, body } = methodsDefinitions.post; 182 | const handler = jest.fn().mockResolvedValue(response); 183 | 184 | const TestController = controllerFactory('/test-post', [{ route: '/', method: 'POST', handler }]); 185 | await bootstrap(server, { controllers: [TestController] }); 186 | 187 | // act 188 | const result = await server.inject({ method: 'POST', url: '/test-post/?$results=true', body } as any); 189 | 190 | // assert 191 | expect(result.statusCode).toBe(200); 192 | expect(result.statusMessage).toBe('OK'); 193 | expect(result.body).toBe(JSON.stringify(response)); 194 | 195 | expect(handler).toHaveBeenCalledTimes(1); 196 | expect(handler.mock.calls[0].length).toBe(2); 197 | expect(handler.mock.calls[0][0].query.$results).toBe('true'); 198 | expect(handler.mock.calls[0][0].body).toMatchObject(body); 199 | }); 200 | 201 | test('Should register @POST endpoint with schema and hooks', async () => { 202 | // arrange 203 | const { response, body, schema } = methodsDefinitions.post; 204 | 205 | const handler = jest.fn().mockResolvedValue(response); 206 | const preParsing = jest.fn().mockImplementation((request, reply, payload, done) => done(null, payload)); 207 | 208 | const TestController = controllerFactory('/test-post', [{ route: '/', method: 'POST', handler, options: { schema, preParsing } }]); 209 | await bootstrap(server, { controllers: [TestController] }); 210 | 211 | // act 212 | const result = await server.inject({ method: 'POST', url: '/test-post/?$results=true', body } as any); 213 | 214 | // assert 215 | expect(result.statusCode).toBe(200); 216 | expect(result.statusMessage).toBe('OK'); 217 | expect(result.body).toBe(JSON.stringify({ createdAt: response.createdAt })); 218 | 219 | expect(handler).toHaveBeenCalledTimes(1); 220 | expect(handler.mock.calls[0].length).toBe(2); 221 | expect(handler.mock.calls[0][0].query.$results).toBe(true); 222 | expect(handler.mock.calls[0][0].body).toMatchObject(body); 223 | 224 | expect(preParsing).toBeCalledTimes(1); 225 | }); 226 | 227 | }); 228 | 229 | describe('@PATCH', () => { 230 | 231 | test('Should register @PATCH endpoint', async () => { 232 | // arrange 233 | const { response, body } = methodsDefinitions.patch; 234 | const handler = jest.fn().mockResolvedValue(response); 235 | 236 | const TestController = controllerFactory('/test-patch', [{ route: '/:ref', method: 'PATCH', handler }]); 237 | await bootstrap(server, { controllers: [TestController] }); 238 | 239 | // act 240 | const result = await server.inject({ method: 'PATCH', url: '/test-patch/sample?times=9', body } as any); 241 | 242 | // assert 243 | expect(result.statusCode).toBe(200); 244 | expect(result.statusMessage).toBe('OK'); 245 | expect(result.body).toBe(JSON.stringify(response)); 246 | 247 | expect(handler).toHaveBeenCalledTimes(1); 248 | expect(handler.mock.calls[0].length).toBe(2); 249 | expect(handler.mock.calls[0][0].params.ref).toBe('sample'); 250 | expect(handler.mock.calls[0][0].query.times).toBe('9'); 251 | expect(handler.mock.calls[0][0].body).toMatchObject(body); 252 | }); 253 | 254 | test('Should register @PATCH endpoint with schema and hooks', async () => { 255 | // arrange 256 | const { response, body, schema } = methodsDefinitions.patch; 257 | 258 | const handler = jest.fn().mockResolvedValue(response); 259 | const preHandler = jest.fn().mockImplementation((request, reply, done) => done()); 260 | 261 | const TestController = controllerFactory('/test-patch', [{ route: '/:ref', method: 'PATCH', handler, options: { schema, preHandler } }]); 262 | await bootstrap(server, { controllers: [TestController] }); 263 | 264 | // act 265 | const result = await server.inject({ method: 'PATCH', url: '/test-patch/sample?times=9', body } as any); 266 | 267 | // assert 268 | expect(result.statusCode).toBe(200); 269 | expect(result.statusMessage).toBe('OK'); 270 | expect(result.body).toBe(JSON.stringify(response)); 271 | 272 | expect(handler).toHaveBeenCalledTimes(1); 273 | expect(handler.mock.calls[0].length).toBe(2); 274 | expect(handler.mock.calls[0][0].params.ref).toBe('sample'); 275 | expect(handler.mock.calls[0][0].query.times).toBe(9); 276 | expect(handler.mock.calls[0][0].body).toMatchObject(body); 277 | 278 | expect(preHandler).toHaveBeenCalledTimes(1); 279 | }); 280 | 281 | }); 282 | 283 | // TODO: add UPDATE, DELETE, HEAD, PUT, OPTIONS, ALL (all + custom methods) 284 | // with the next pattern: 285 | // 286 | // describe('@POST', () => { 287 | // test('Should register @POST endpoint', async () => {}); 288 | // test('Should register @POST endpoint with schema and hooks', async () => {}); 289 | // }); 290 | 291 | }); 292 | 293 | }); 294 | -------------------------------------------------------------------------------- /packages/core/tests/integration/injector.test.ts: -------------------------------------------------------------------------------- 1 | import Injector from '../../src/injector'; 2 | import { FastifyToken } from '../../src/symbols'; 3 | import { Samurai, Ninja, Weapon, Katana, Shuriken, Naginata, Nunchaku, Tanto, Wakizashi, FastifyDecorated } from '../data/injectables'; 4 | import type { FastifyInstance } from 'fastify'; 5 | 6 | class BaseModel {} 7 | 8 | describe('Injector', () => { 9 | 10 | let injector: Injector; 11 | const fastifyInstance: FastifyInstance = { 12 | schema: { id: { type: 'number' } }, 13 | steel: 'real steel', 14 | [FastifyDecorated]: 'FastifyDecoratedValue', 15 | BaseModel 16 | } as any; 17 | 18 | beforeEach(() => { 19 | injector = new Injector(); 20 | injector.registerInstance(FastifyToken, fastifyInstance); 21 | }); 22 | 23 | it('Should use singleton instances', () => { 24 | const samuraiFirst = injector.getInstance(Samurai); 25 | const samuraiSecond = injector.getInstance(Samurai); 26 | expect(samuraiFirst).toBe(samuraiSecond); 27 | 28 | // Compare injects of two same class instances 29 | expect(samuraiFirst.katana).toBe(samuraiSecond.katana); 30 | expect(samuraiFirst.shuriken).toBe(samuraiSecond.shuriken); 31 | expect(samuraiFirst.naginata).toBe(samuraiSecond.naginata); 32 | expect(samuraiFirst.nunchaku).toBe(samuraiSecond.nunchaku); 33 | expect(samuraiFirst.tanto).toBe(samuraiSecond.tanto); 34 | expect(samuraiFirst.wakizashi).toBe(samuraiSecond.wakizashi); 35 | expect(samuraiFirst.fastifyInstance).toBe(samuraiSecond.fastifyInstance); 36 | 37 | // Compare injects of instance and static class property 38 | expect(samuraiFirst.shuriken).toBe(Samurai.shuriken); 39 | expect(samuraiFirst.shuriken).toBe(Samurai.shuriken); 40 | expect(samuraiFirst.wakizashi).toBe(Samurai.wakizashi); 41 | expect(samuraiFirst.wakizashi).toBe(Samurai.wakizashi); 42 | expect(samuraiFirst.tanto).toBe(Samurai.tanto); 43 | expect(samuraiFirst.tanto).toBe(Samurai.tanto); 44 | 45 | // Compare injects of instances and newly resolved instances 46 | const katana = injector.getInstance(Katana); 47 | expect(samuraiFirst.katana).toBe(katana); 48 | expect(samuraiSecond.katana).toBe(katana); 49 | 50 | const shuriken = injector.getInstance(Shuriken); 51 | expect(samuraiFirst.shuriken).toBe(shuriken); 52 | expect(samuraiSecond.shuriken).toBe(shuriken); 53 | 54 | const naginata = injector.getInstance(Naginata); 55 | expect(samuraiFirst.naginata).toBe(naginata); 56 | expect(samuraiSecond.naginata).toBe(naginata); 57 | 58 | const nunchaku = injector.getInstance(Nunchaku); 59 | expect(samuraiFirst.nunchaku).toBe(nunchaku); 60 | expect(samuraiSecond.nunchaku).toBe(nunchaku); 61 | 62 | const tanto = injector.getInstance(Tanto); 63 | expect(samuraiFirst.tanto).toBe(tanto); 64 | expect(samuraiSecond.tanto).toBe(tanto); 65 | 66 | const wakizashi = injector.getInstance(Wakizashi); 67 | expect(samuraiFirst.wakizashi).toBe(wakizashi); 68 | expect(samuraiSecond.wakizashi).toBe(wakizashi); 69 | }); 70 | 71 | describe('Resolve instance by type', () => { 72 | 73 | it('Should inject constructor parameter by type', () => { 74 | // @Controller 75 | const samurai = injector.getInstance(Samurai); 76 | expect(samurai.katana).toBeDefined(); 77 | expect(samurai.katana.hit).toBeDefined(); 78 | 79 | // @EntityController 80 | const ninja = injector.getInstance(Ninja); 81 | expect(ninja.katana).toBeDefined(); 82 | expect(ninja.katana.hit).toBeDefined(); 83 | 84 | // @Service 85 | const weapon = injector.getInstance(Weapon); 86 | expect(weapon.katana).toBeDefined(); 87 | expect(weapon.katana.hit).toBeDefined(); 88 | }); 89 | 90 | it('Should inject class property by type', () => { 91 | // @Controller 92 | const samurai = injector.getInstance(Samurai); 93 | expect(samurai.shuriken).toBeDefined(); 94 | expect(samurai.shuriken.cast).toBeDefined(); 95 | 96 | // @EntityController 97 | const ninja = injector.getInstance(Ninja); 98 | expect(ninja.shuriken).toBeDefined(); 99 | expect(ninja.shuriken.cast).toBeDefined(); 100 | 101 | // @Service 102 | const weapon = injector.getInstance(Weapon); 103 | expect(weapon.shuriken).toBeDefined(); 104 | expect(weapon.shuriken.cast).toBeDefined(); 105 | }); 106 | 107 | it('Should inject class static property by type', () => { 108 | // @Controller 109 | injector.getInstance(Samurai); 110 | expect(Samurai.shuriken).toBeDefined(); 111 | expect(Samurai.shuriken.cast).toBeDefined(); 112 | 113 | // @EntityController 114 | injector.getInstance(Ninja); 115 | expect(Ninja.shuriken).toBeDefined(); 116 | expect(Ninja.shuriken.cast).toBeDefined(); 117 | 118 | // @Service 119 | injector.getInstance(Weapon); 120 | expect(Weapon.shuriken).toBeDefined(); 121 | expect(Weapon.shuriken.cast).toBeDefined(); 122 | }); 123 | 124 | it('Should ignore empty token if type is defined', () => { 125 | // @Controller 126 | const samurai = injector.getInstance(Samurai); 127 | expect(samurai.naginata).toBeDefined(); 128 | expect(samurai.naginata.size).toBeDefined(); 129 | 130 | // @EntityController 131 | const ninja = injector.getInstance(Ninja); 132 | expect(ninja.naginata).toBeDefined(); 133 | expect(ninja.naginata.size).toBeDefined(); 134 | 135 | // @Service 136 | const weapon = injector.getInstance(Weapon); 137 | expect(weapon.naginata).toBeDefined(); 138 | expect(weapon.naginata.size).toBeDefined(); 139 | }); 140 | 141 | }); 142 | 143 | describe('Resolve instance by token', () => { 144 | 145 | describe('String token', () => { 146 | 147 | it('Should inject constructor parameter by string token', () => { 148 | // @Controller 149 | const samurai = injector.getInstance(Samurai); 150 | expect(samurai.schema).toBeDefined(); 151 | expect(samurai.schema).toEqual((fastifyInstance as any).schema); 152 | 153 | // @EntityController 154 | const ninja = injector.getInstance(Ninja); 155 | expect(ninja.schema).toBeDefined(); 156 | expect(ninja.schema).toEqual((fastifyInstance as any).schema); 157 | 158 | // @Service 159 | const weapon = injector.getInstance(Weapon); 160 | expect(weapon.schema).toBeDefined(); 161 | expect(weapon.schema).toEqual((fastifyInstance as any).schema); 162 | }); 163 | 164 | it('Should inject decorated value into class property by string token', () => { 165 | // @Controller 166 | const samurai = injector.getInstance(Samurai); 167 | expect(samurai.steel).toBeDefined(); 168 | expect(samurai.steel).toBe('real steel'); 169 | 170 | // @EntityController 171 | const ninja = injector.getInstance(Ninja); 172 | expect(ninja.steel).toBeDefined(); 173 | expect(ninja.steel).toBe('real steel'); 174 | 175 | // @Service 176 | const weapon = injector.getInstance(Weapon); 177 | expect(weapon.steel).toBeDefined(); 178 | expect(weapon.steel).toBe('real steel'); 179 | }); 180 | 181 | it('Should inject decorated value into class static property by string token', () => { 182 | // @Controller 183 | injector.getInstance(Samurai); 184 | expect(Samurai.steel).toBeDefined(); 185 | expect(Samurai.steel).toBe('real steel'); 186 | 187 | // @EntityController 188 | injector.getInstance(Ninja); 189 | expect(Ninja.steel).toBeDefined(); 190 | expect(Ninja.steel).toBe('real steel'); 191 | 192 | // @Service 193 | injector.getInstance(Weapon); 194 | expect(Weapon.steel).toBeDefined(); 195 | expect(Weapon.steel).toBe('real steel'); 196 | }); 197 | 198 | it('Should inject Service into class property by string token', () => { 199 | // @Controller 200 | const samurai = injector.getInstance(Samurai); 201 | expect(samurai.wakizashi).toBeDefined(); 202 | expect(samurai.wakizashi.fight).toBeDefined(); 203 | 204 | // @EntityController 205 | const ninja = injector.getInstance(Ninja); 206 | expect(ninja.wakizashi).toBeDefined(); 207 | expect(ninja.wakizashi.fight).toBeDefined(); 208 | 209 | // @Service 210 | const weapon = injector.getInstance(Weapon); 211 | expect(weapon.wakizashi).toBeDefined(); 212 | expect(weapon.wakizashi.fight).toBeDefined(); 213 | }); 214 | 215 | it('Should inject Service into class static property by string token', () => { 216 | // @Controller 217 | injector.getInstance(Samurai); 218 | expect(Samurai.wakizashi).toBeDefined(); 219 | expect(Samurai.wakizashi.fight).toBeDefined(); 220 | 221 | // @EntityController 222 | injector.getInstance(Ninja); 223 | expect(Ninja.wakizashi).toBeDefined(); 224 | expect(Ninja.wakizashi.fight).toBeDefined(); 225 | 226 | // @Service 227 | injector.getInstance(Weapon); 228 | expect(Weapon.wakizashi).toBeDefined(); 229 | expect(Weapon.wakizashi.fight).toBeDefined(); 230 | }); 231 | 232 | }); 233 | 234 | describe('Symbol token', () => { 235 | 236 | it('Should inject Fastify instance by token', () => { 237 | // @Controller 238 | const samurai = injector.getInstance(Samurai); 239 | expect(samurai.fastifyInstance).toBeDefined(); 240 | 241 | // @EntityController 242 | const ninja = injector.getInstance(Ninja); 243 | expect(ninja.fastifyInstance).toBeDefined(); 244 | 245 | // @Service 246 | const weapon = injector.getInstance(Weapon); 247 | expect(weapon.fastifyInstance).toBeDefined(); 248 | }); 249 | 250 | it('Should inject Fastify decorated symbol by token', () => { 251 | // @Controller 252 | const samurai = injector.getInstance(Samurai); 253 | expect(samurai.fastifyDecorated).toBe('FastifyDecoratedValue'); 254 | 255 | // @EntityController 256 | const ninja = injector.getInstance(Ninja); 257 | expect(ninja.fastifyDecorated).toBe('FastifyDecoratedValue'); 258 | 259 | // @Service 260 | const weapon = injector.getInstance(Weapon); 261 | expect(weapon.fastifyDecorated).toBe('FastifyDecoratedValue'); 262 | }); 263 | 264 | it('Should inject Service into class property by Symbol token', () => { 265 | // @Controller 266 | const samurai = injector.getInstance(Samurai); 267 | expect(samurai.tanto).toBeDefined(); 268 | expect(samurai.tanto.use).toBeDefined(); 269 | 270 | // @EntityController 271 | const ninja = injector.getInstance(Ninja); 272 | expect(ninja.tanto).toBeDefined(); 273 | expect(ninja.tanto.use).toBeDefined(); 274 | 275 | // @Service 276 | const weapon = injector.getInstance(Weapon); 277 | expect(weapon.tanto).toBeDefined(); 278 | expect(weapon.tanto.use).toBeDefined(); 279 | }); 280 | 281 | it('Should inject Service into class static property by Symbol token', () => { 282 | // @Controller 283 | injector.getInstance(Samurai); 284 | expect(Samurai.tanto).toBeDefined(); 285 | expect(Samurai.tanto.use).toBeDefined(); 286 | 287 | // @EntityController 288 | injector.getInstance(Ninja); 289 | expect(Ninja.tanto).toBeDefined(); 290 | expect(Ninja.tanto.use).toBeDefined(); 291 | 292 | // @Service 293 | injector.getInstance(Weapon); 294 | expect(Weapon.tanto).toBeDefined(); 295 | expect(Weapon.tanto.use).toBeDefined(); 296 | }); 297 | 298 | it('Should inject Service into constructor by Symbol token', () => { 299 | // @Controller 300 | const samurai = injector.getInstance(Samurai); 301 | expect(samurai.nunchaku).toBeDefined(); 302 | expect(samurai.nunchaku.brandish).toBeDefined(); 303 | 304 | // @EntityController 305 | const ninja = injector.getInstance(Ninja); 306 | expect(ninja.nunchaku).toBeDefined(); 307 | expect(ninja.nunchaku.brandish).toBeDefined(); 308 | 309 | // @Service 310 | const weapon = injector.getInstance(Weapon); 311 | expect(weapon.nunchaku).toBeDefined(); 312 | expect(weapon.nunchaku.brandish).toBeDefined(); 313 | }); 314 | 315 | }); 316 | 317 | }); 318 | 319 | describe('Construct injected model', () => { 320 | 321 | it('Should construct model from constructor parameter', () => { 322 | // @Controller 323 | const samurai = injector.getInstance(Samurai); 324 | expect(samurai.backpackModel).toBeDefined(); 325 | expect(samurai.backpackModel).toBeInstanceOf(BaseModel); 326 | 327 | // @EntityController 328 | const ninja = injector.getInstance(Ninja); 329 | expect(ninja.backpackModel).toBeDefined(); 330 | expect(ninja.backpackModel).toBeInstanceOf(BaseModel); 331 | 332 | // @Service 333 | const weapon = injector.getInstance(Weapon); 334 | expect(weapon.backpackModel).toBeDefined(); 335 | expect(weapon.backpackModel).toBeInstanceOf(BaseModel); 336 | }); 337 | 338 | it('Should construct model from class property', () => { 339 | // @Controller 340 | const samurai = injector.getInstance(Samurai); 341 | expect(samurai.backpack).toBeDefined(); 342 | expect(samurai.backpack).toBeInstanceOf(BaseModel); 343 | 344 | // @EntityController 345 | const ninja = injector.getInstance(Ninja); 346 | expect(ninja.backpack).toBeDefined(); 347 | expect(ninja.backpack).toBeInstanceOf(BaseModel); 348 | 349 | // @Service 350 | const weapon = injector.getInstance(Weapon); 351 | expect(weapon.backpack).toBeDefined(); 352 | expect(weapon.backpack).toBeInstanceOf(BaseModel); 353 | }); 354 | 355 | it('Should construct model from class static property', () => { 356 | // @Controller 357 | injector.getInstance(Samurai); 358 | expect(Samurai.backpack).toBeDefined(); 359 | expect(Samurai.backpack).toBeInstanceOf(BaseModel); 360 | 361 | // @EntityController 362 | injector.getInstance(Ninja); 363 | expect(Ninja.backpack).toBeDefined(); 364 | expect(Ninja.backpack).toBeInstanceOf(BaseModel); 365 | 366 | // @Service 367 | injector.getInstance(Weapon); 368 | expect(Weapon.backpack).toBeDefined(); 369 | expect(Weapon.backpack).toBeInstanceOf(BaseModel); 370 | }); 371 | 372 | it('Should construct different instances of same Entity', () => { 373 | const samurai = injector.getInstance(Samurai); 374 | expect(samurai.backpackModel).not.toBe(samurai.backpack); 375 | expect(samurai.backpack).not.toBe(Samurai.backpack); 376 | 377 | const ninja = injector.getInstance(Ninja); 378 | expect(ninja.backpackModel).not.toBe(ninja.backpack); 379 | expect(ninja.backpack).not.toBe(Ninja.backpack); 380 | 381 | const weapon = injector.getInstance(Weapon); 382 | expect(weapon.backpackModel).not.toBe(weapon.backpack); 383 | expect(weapon.backpack).not.toBe(Weapon.backpack); 384 | 385 | expect(samurai.backpackModel).not.toBe(ninja.backpackModel); 386 | expect(ninja.backpackModel).not.toBe(weapon.backpackModel); 387 | 388 | expect(samurai.backpack).not.toBe(ninja.backpack); 389 | expect(ninja.backpack).not.toBe(weapon.backpack); 390 | 391 | expect(Samurai.backpack).not.toBe(Ninja.backpack); 392 | expect(Ninja.backpack).not.toBe(Weapon.backpack); 393 | }); 394 | 395 | }); 396 | 397 | }); 398 | -------------------------------------------------------------------------------- /packages/core/tests/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | -------------------------------------------------------------------------------- /packages/core/tests/support/ModelMock.ts: -------------------------------------------------------------------------------- 1 | export const find = jest.fn(); 2 | export const total = jest.fn(); 3 | export const create = jest.fn(); 4 | export const patch = jest.fn(); 5 | export const update = jest.fn(); 6 | export const remove = jest.fn(); 7 | 8 | export default jest.fn().mockImplementation((EntityClass) => { 9 | return { 10 | get name() { 11 | return EntityClass.name; 12 | }, 13 | jsonSchema: { 14 | id: { type: 'number', _options: { generated: true } }, 15 | name: { type: 'string' } 16 | }, 17 | find, 18 | total, 19 | create, 20 | patch, 21 | update, 22 | remove 23 | }; 24 | }); 25 | -------------------------------------------------------------------------------- /packages/core/tests/support/controllerFactory.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '../../src/decorators/controller'; 2 | import * as METHODS from '../../src/decorators/requestMethods'; 3 | import * as HOOKS from '../../src/decorators/hooks'; 4 | 5 | import type { RouteShorthandOptions } from 'fastify'; 6 | 7 | export const controllerFactory = ( 8 | controllerRoute: string, 9 | methods: { 10 | method: keyof typeof METHODS, 11 | route: string, 12 | handler: (...args: any[]) => any, 13 | options?: RouteShorthandOptions 14 | }[] = [], 15 | hooks: { 16 | hook: keyof typeof HOOKS, 17 | handler: (...args: any[]) => any 18 | }[] = [] 19 | ) => { 20 | @Controller(controllerRoute) 21 | class TestController {} 22 | 23 | methods.forEach((methodConfig, index) => { 24 | TestController.prototype[`${methodConfig.method}_${index}`] = methodConfig.handler; 25 | METHODS[methodConfig.method] 26 | (methodConfig.route, methodConfig.options as any || {}) 27 | (TestController.prototype, `${methodConfig.method}_${index}`); 28 | }); 29 | 30 | hooks.forEach((hookConfig, index) => { 31 | TestController.prototype[`${hookConfig.hook}_${index}`] = hookConfig.handler; 32 | HOOKS[hookConfig.hook](TestController.prototype, `${hookConfig.hook}_${index}`); 33 | }); 34 | 35 | return TestController; 36 | } 37 | -------------------------------------------------------------------------------- /packages/core/tests/unit/decorators/entityController/entityMethods.test.ts: -------------------------------------------------------------------------------- 1 | import entityMethods from '../../../../src/decorators/entityController/methods'; 2 | 3 | describe('EntityController base methods', () => { 4 | let controllerContext; 5 | const mocks: Record = {}; 6 | 7 | beforeAll(() => { 8 | controllerContext = { 9 | model: { 10 | name: 'Entity', 11 | find: mocks.modelFind = jest.fn() 12 | }, 13 | config: { id: 'id' } 14 | }; 15 | }); 16 | 17 | afterEach(() => { 18 | Object.values(mocks).forEach(mock => mock.mockReset()); 19 | }); 20 | 21 | describe('#findOne', () => { 22 | 23 | test('Should handle 404 (NotFound) error', async () => { 24 | mocks.modelFind.mockResolvedValue([]); 25 | 26 | try { 27 | await entityMethods.findOne.call(controllerContext, ({ params: { id: 1 } } as any)); 28 | expect(false).toBeTruthy(); 29 | } catch (error) { 30 | expect(error.message).toBe('Entity #1 is not found'); 31 | expect(error.statusCode).toBe(404); 32 | expect(mocks.modelFind.mock.calls.length).toBe(1); 33 | expect(mocks.modelFind.mock.calls[0][0]).toMatchObject({ $where: { id: 1 } }); 34 | } 35 | }); 36 | 37 | }); 38 | }); -------------------------------------------------------------------------------- /packages/core/tests/unit/decorators/entityController/schemaDefinitions.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7 } from 'json-schema'; 2 | import { schemaDefinitions } from '../../../../src/decorators/entityController/schemaBuilder/schemaDefinitions'; 3 | import definitionsData from '../../../data/schemaDefinitions.json'; 4 | 5 | describe('schemaDefinitions', () => { 6 | 7 | test('Should be defined', () => { 8 | expect(schemaDefinitions).toBeDefined(); 9 | expect(typeof schemaDefinitions).toBe('function'); 10 | }); 11 | 12 | test('Should generate json schema definitions', () => { 13 | const schemaProperties: Record = { 14 | id: { type: 'number' }, 15 | string: { type: 'string' }, 16 | boolean: { type: 'boolean', default: false } 17 | }; 18 | 19 | const definitions = schemaDefinitions(schemaProperties); 20 | expect(definitions).toMatchObject(definitionsData); 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /packages/core/tests/unit/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from '../../src'; 2 | 3 | describe('Core package index exported', () => { 4 | 5 | it('Should export core decorators', () => { 6 | expect(core.Controller).toBeDefined(); 7 | expect(core.EntityController).toBeDefined(); 8 | expect(core.Service).toBeDefined(); 9 | expect(core.Model).toBeDefined(); 10 | }) 11 | 12 | it('Should export request method decorators', () => { 13 | expect(core.GET).toBeDefined(); 14 | expect(core.HEAD).toBeDefined(); 15 | expect(core.PATCH).toBeDefined(); 16 | expect(core.POST).toBeDefined(); 17 | expect(core.PUT).toBeDefined(); 18 | expect(core.OPTIONS).toBeDefined(); 19 | expect(core.DELETE).toBeDefined(); 20 | expect(core.ALL).toBeDefined(); 21 | }); 22 | 23 | it('Should export hooks decorators', () => { 24 | expect(core.OnRequest).toBeDefined(); 25 | expect(core.PreParsing).toBeDefined(); 26 | expect(core.PreValidation).toBeDefined(); 27 | expect(core.PreHandler).toBeDefined(); 28 | expect(core.PreSerialization).toBeDefined(); 29 | expect(core.OnError).toBeDefined(); 30 | expect(core.OnSend).toBeDefined(); 31 | expect(core.OnResponse).toBeDefined(); 32 | expect(core.OnTimeout).toBeDefined(); 33 | }); 34 | 35 | it('Should export global symbols', () => { 36 | expect(core.FastifyToken).toBeDefined(); 37 | expect(core.GlobalConfig).toBeDefined(); 38 | }); 39 | 40 | it('Should export DI decorators', () => { 41 | expect(core.Inject).toBeDefined(); 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "sourceMap": false, 6 | "strict": true 7 | }, 8 | "include": ["src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/typeorm/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [0.3.0](https://github.com/FastifyResty/fastify-resty/compare/@fastify-resty/typeorm@0.2.0...@fastify-resty/typeorm@0.3.0) (2020-11-18) 7 | 8 | 9 | ### Features 10 | 11 | * added Service and Model injectable decorators ([d8ca15d](https://github.com/FastifyResty/fastify-resty/commit/d8ca15d64468b83f22eb71fcdc97b4033d7c383f)) 12 | 13 | 14 | 15 | 16 | 17 | # [0.2.0](https://github.com/Fastify-Resty/fastify-resty/compare/@fastify-resty/typeorm@0.1.1...@fastify-resty/typeorm@0.2.0) (2020-11-15) 18 | 19 | 20 | ### Features 21 | 22 | * added Service and Model injectable decorators ([d8ca15d](https://github.com/Fastify-Resty/fastify-resty/commit/d8ca15d64468b83f22eb71fcdc97b4033d7c383f)) 23 | 24 | 25 | 26 | 27 | 28 | ## [0.1.1](https://github.com/Fastify-Resty/fastify-resty/compare/@fastify-resty/typeorm@0.1.0...@fastify-resty/typeorm@0.1.1) (2020-11-01) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * lodash for typeorm, ES3 method decorators support ([e78f7c4](https://github.com/Fastify-Resty/fastify-resty/commit/e78f7c4d855b44845b1a381fe5154bd8fb284270)) 34 | -------------------------------------------------------------------------------- /packages/typeorm/README.md: -------------------------------------------------------------------------------- 1 | # @fastify-resty/typeorm 2 | 3 | The `TypeORM` connector for `fastify-resty` framework. 4 | 5 | ## Install 6 | 7 | Using npm: 8 | 9 | ```sh 10 | npm install @fastify-resty/typeorm 11 | ``` 12 | 13 | or using yarn: 14 | 15 | ```sh 16 | yarn add @fastify-resty/typeorm 17 | ``` 18 | -------------------------------------------------------------------------------- /packages/typeorm/jest.config.js: -------------------------------------------------------------------------------- 1 | const configBase = require('../../jest.config.base.js'); 2 | 3 | module.exports = { 4 | ...configBase, 5 | displayName: { 6 | name: 'Typeorm', 7 | color: 'yellowBright', 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /packages/typeorm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify-resty/typeorm", 3 | "version": "0.3.0", 4 | "description": "Fastify Resty TypeORM connector", 5 | "keywords": [ 6 | "fastify", 7 | "typeorm", 8 | "database", 9 | "decorators", 10 | "json-schema" 11 | ], 12 | "author": { 13 | "name": "Demidovich Daniil", 14 | "email": "demidovich.daniil@gmail.com" 15 | }, 16 | "homepage": "https://github.com/FastifyResty/fastify-resty", 17 | "license": "MIT", 18 | "main": "dist/index.js", 19 | "types": "dist/index.d.ts", 20 | "files": [ 21 | "dist/**/*", 22 | "CHANGELOG.md", 23 | "README.md" 24 | ], 25 | "engines": { 26 | "node": ">= 10.16.0" 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/FastifyResty/fastify-resty", 34 | "directory": "packages/typeorm" 35 | }, 36 | "scripts": { 37 | "build": "tsc", 38 | "test": "jest --verbose", 39 | "coverage": "jest --collect-coverage" 40 | }, 41 | "dependencies": { 42 | "@fastify-resty/core": "^0.3.0", 43 | "lodash": "^4.17.20", 44 | "reflect-metadata": "^0.1.13" 45 | }, 46 | "peerDependencies": { 47 | "fastify": "3.x", 48 | "typeorm": "0.2.x" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/typeorm/src/BaseModel.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import mapProperty from './lib/mapProperty'; 3 | import { createSelectQueryBuilder, whereBuilder } from './lib/queryBuilder'; 4 | import type { IFindQuery, IFindWhereQuery, IBaseModel, Identifier, ModifyResponse, IModelConfig, IModelOptions } from '@fastify-resty/core'; 5 | import type { JSONSchema7 } from 'json-schema'; 6 | import type { ObjectType, Connection } from 'typeorm'; 7 | 8 | export class BaseModel implements IBaseModel { 9 | 10 | static connection: Connection; 11 | 12 | constructor(protected EntityClass: ObjectType, config?: IModelOptions) { 13 | this.config = { 14 | id: 'id', 15 | softDelete: false, 16 | ...(config || {}) 17 | }; 18 | } 19 | 20 | config: IModelConfig; 21 | 22 | get name() { 23 | return this.EntityClass.name; 24 | } 25 | 26 | get jsonSchema(): Record { 27 | const { columns } = BaseModel.connection.getMetadata(this.EntityClass); 28 | return columns.reduce((props, column) => ({ ...props, [column.propertyName]: mapProperty(column)}), {}); 29 | } 30 | 31 | async find(query?: IFindQuery): Promise { 32 | return createSelectQueryBuilder(BaseModel.connection, this.EntityClass, query).getMany(); 33 | } 34 | 35 | async total(query?: IFindWhereQuery): Promise { 36 | const _queryBuilder = BaseModel.connection 37 | .getRepository(this.EntityClass) 38 | .createQueryBuilder('entity'); 39 | 40 | if (query) { 41 | whereBuilder(_queryBuilder, query); 42 | } 43 | 44 | return _queryBuilder.getCount(); 45 | } 46 | 47 | async create(data: E | E[]): Promise<{ identifiers: Identifier[] }> { 48 | const result = await BaseModel.connection 49 | .createQueryBuilder() 50 | .insert() 51 | .into(this.EntityClass) 52 | .values(data) 53 | .execute(); 54 | 55 | return { identifiers: _.map(result.identifiers, this.config.id) }; 56 | } 57 | 58 | async patch(query: IFindWhereQuery, raw: Partial): Promise { 59 | const data = _.omit(raw, this.config.id) as E; 60 | 61 | const _queryBuilder = BaseModel.connection 62 | .createQueryBuilder() 63 | .update(this.EntityClass) 64 | .set(data); 65 | 66 | whereBuilder(_queryBuilder, query); 67 | 68 | return { affected: _.get(await _queryBuilder.execute(), 'affected') }; 69 | } 70 | 71 | async update(query: IFindWhereQuery, raw: E): Promise { 72 | const data = BaseModel.connection 73 | .getMetadata(this.EntityClass) 74 | .columns 75 | .reduce((data, column) => { 76 | // allow to not define primary fields 77 | if (column.isPrimary && raw[column.propertyName] === undefined) { 78 | return data; 79 | } 80 | // set new date for auto-generated date fields 81 | if ((column.isCreateDate || column.isUpdateDate || column.isDeleteDate) && raw[column.propertyName] === undefined) { 82 | return { ...data, [column.propertyName]: new Date() }; 83 | } 84 | return { ...data, [column.propertyName]: raw[column.propertyName] }; 85 | }, {}); 86 | 87 | const _queryBuilder = BaseModel.connection 88 | .createQueryBuilder() 89 | .update(this.EntityClass) 90 | .set(data); 91 | 92 | whereBuilder(_queryBuilder, query); 93 | 94 | return { affected: _.get(await _queryBuilder.execute(), 'affected') }; 95 | } 96 | 97 | async remove(query: IFindWhereQuery): Promise { 98 | const _queryBuilder = BaseModel.connection 99 | .createQueryBuilder() 100 | .delete() 101 | .from(this.EntityClass) 102 | 103 | whereBuilder(_queryBuilder, query); 104 | 105 | return { affected: _.get(await _queryBuilder.execute(), 'affected') }; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/typeorm/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel } from './BaseModel'; 2 | 3 | export function bootstrap(instance, { connection }, done) { 4 | BaseModel.connection = connection; 5 | 6 | instance.decorate('connection', connection); 7 | instance.decorate('BaseModel', BaseModel); 8 | 9 | done(); 10 | } 11 | 12 | bootstrap[Symbol.for('skip-override')] = true; 13 | -------------------------------------------------------------------------------- /packages/typeorm/src/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseModel } from './BaseModel'; 2 | export { bootstrap } from './bootstrap'; 3 | 4 | import { bootstrap } from './bootstrap'; 5 | export default bootstrap; 6 | 7 | /* Types Declarations */ 8 | 9 | import type { Connection } from 'typeorm'; 10 | import type { BaseModel } from './BaseModel'; 11 | 12 | declare module 'fastify' { 13 | interface FastifyInstance { 14 | BaseModel: BaseModel; 15 | connection: Connection; 16 | } 17 | } -------------------------------------------------------------------------------- /packages/typeorm/src/lib/mapProperty.ts: -------------------------------------------------------------------------------- 1 | import type { EntityMetadata } from 'typeorm'; 2 | import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema'; 3 | 4 | const cleanup = (obj: T): Partial => Object.keys(obj) 5 | .reduce((acc, key) => { 6 | if (obj[key]) acc[key] = obj[key]; 7 | return acc; 8 | }, {}); 9 | 10 | type ColumnMetadata = EntityMetadata['columns'][0]; 11 | 12 | const mapPropertyType = (type: any): JSONSchema7TypeName => { 13 | if (typeof type === 'function') { 14 | if (type === Number) return 'number'; 15 | if (type === String) return 'string'; 16 | if (type === Boolean) return 'boolean'; 17 | } 18 | return typeof type as any; 19 | }; 20 | 21 | export default (columnMetadata: ColumnMetadata): JSONSchema7 => { 22 | const { length, comment, propertyName, isNullable, isSelect, isUpdate, isGenerated } = columnMetadata; 23 | 24 | const schemaProperty: JSONSchema7 = cleanup({ 25 | type: mapPropertyType(columnMetadata.type), 26 | title: propertyName, 27 | description: comment, 28 | maxLength: length !== undefined ? parseInt(length, 10) : undefined, 29 | default: (typeof columnMetadata.default !== 'function') ? columnMetadata.default : undefined, 30 | enum: columnMetadata.enum, 31 | readOnly: isSelect && !isUpdate, 32 | writeOnly: !isSelect && isUpdate, 33 | format: ['timestamp', 'date'].includes(columnMetadata.type as string) && 'date-time', 34 | 35 | // custom schema options 36 | _options: { 37 | generated: typeof columnMetadata.default === 'function' || isGenerated, 38 | nullable: isNullable, 39 | hidden: !isSelect 40 | } 41 | }); 42 | 43 | return schemaProperty; 44 | } 45 | -------------------------------------------------------------------------------- /packages/typeorm/src/lib/queryBuilder/index.ts: -------------------------------------------------------------------------------- 1 | import { Brackets } from 'typeorm'; 2 | import operations from './operations'; 3 | import type { IFindQuery } from '@fastify-resty/core'; 4 | import type { ObjectType, SelectQueryBuilder, WhereExpression, Connection } from 'typeorm'; 5 | 6 | export const whereBuilder = (_query: SelectQueryBuilder | WhereExpression, _where: IFindQuery['$where']): void => { 7 | let isWhereUsed; 8 | const getWhere = (prefix: 'and' | 'or' = 'and') => isWhereUsed ? `${prefix}Where` : isWhereUsed = 'where'; 9 | const andWhere = (where, params?) => _query[getWhere()](where, params); 10 | 11 | Object.entries(_where) 12 | .filter(([key]) => key[0] !== '$') 13 | .forEach(([key, value]) => { 14 | if (typeof value === 'object') { 15 | Object.entries(value).forEach(([k, v]) => { 16 | andWhere(...operations[k](key, v as any)); 17 | }); 18 | } else { 19 | andWhere(...operations['$eq'](key, value)); 20 | } 21 | }); 22 | 23 | if (_where.$or) { 24 | andWhere(new Brackets(qb => { 25 | isWhereUsed = null; 26 | _where.$or.forEach(nestedWhere => { 27 | qb[getWhere('or')](new Brackets(orqb => { 28 | whereBuilder(orqb, nestedWhere); 29 | })); 30 | }); 31 | })); 32 | } 33 | } 34 | 35 | export const createSelectQueryBuilder = (connection: Connection, entityClass: ObjectType, query: IFindQuery = {}): SelectQueryBuilder => { 36 | const _query = connection 37 | .getRepository(entityClass) 38 | .createQueryBuilder('entity'); 39 | 40 | if (query.$select) { 41 | const select = Array.isArray(query.$select) ? query.$select : [query.$select]; 42 | _query.select(select.map(field => `entity.${field}`)); 43 | } 44 | 45 | if (query.$sort) { 46 | if (typeof query.$sort === 'string') { 47 | _query.orderBy(`entity.${query.$sort}`); 48 | } else if (Array.isArray(query.$sort)) { 49 | const orderOptions = query.$sort.reduce((acc, curr) => ({ ...acc, [`entity.${curr}`]: 'ASC' }), {}); 50 | _query.orderBy(orderOptions); 51 | } else if (typeof query.$sort === 'object') { 52 | _query.orderBy(query.$sort); 53 | } 54 | } 55 | 56 | // Notes: 57 | // $or - always an array! Specify on json schema!!! 58 | // $where - is object 59 | if (query.$where) { 60 | whereBuilder(_query, query.$where); 61 | } 62 | 63 | if (query.$skip) { 64 | _query.skip(query.$skip); 65 | } 66 | 67 | if (query.$limit !== null) { 68 | _query.take(query.$limit || 20); 69 | } 70 | 71 | return _query; 72 | } 73 | -------------------------------------------------------------------------------- /packages/typeorm/src/lib/queryBuilder/operations.ts: -------------------------------------------------------------------------------- 1 | type OperationFn = (key: string, value: string | string[]) => [string, object]; 2 | 3 | // TODO: handle those ones for PostgreSQL only 4 | // $iRegexp: '~*', 5 | // $notIRegexp: '!~*', 6 | // $contains: '@>', 7 | // $containsKey: '?', 8 | // $contained: '<@', 9 | // $any: '?|', 10 | // $all: '?&' 11 | 12 | const operations: Record = { 13 | $eq: (k, v) => [`${k} = :${k}`, { [k]: v }], 14 | $neq: (k, v) => [`${k} != :${k}`, { [k]: v }], 15 | $gt: (k, v) => [`${k} > :${k}`, { [k]: v }], 16 | $gte: (k, v) => [`${k} >= :${k}`, { [k]: v }], 17 | $lt: (k, v) => [`${k} < :${k}`, { [k]: v }], 18 | $lte: (k, v) => [`${k} <= :${k}`, { [k]: v }], 19 | $like: (k, v) => [`${k} LIKE :${k}`, { [k]: v }], 20 | $nlike: (k, v) => [`${k} NOT LIKE :${k}`, { [k]: v }], 21 | $ilike: (k, v) => [`${k} ILIKE :${k}`, { [k]: v }], 22 | $nilike: (k, v) => [`${k} NOT ILIKE :${k}`, { [k]: v }], 23 | $regex: (k, v) => [`${k} ~ :${k}`, { [k]: v }], 24 | $nregex: (k, v) => [`${k} !~ :${k}`, { [k]: v }], 25 | $in: (k, v) => [`${k} IN (:...${k})`, { [k]: Array.isArray(v) ? v : [v] }], 26 | $nin: (k, v) => [`${k} NOT IN (:...${k})`, { [k]: Array.isArray(v) ? v : [v] }], 27 | $between: (k, v) => [`${k} BETWEEN :${k}_from AND :${k}_to`, { [`${k}_from`]: v[0], [`${k}_to`]: v[1] }], 28 | $nbetween: (k, v) => [`${k} NOT BETWEEN :${k}_from AND :${k}_to`, { [`${k}_from`]: v[0], [`${k}_to`]: v[1] }] 29 | }; 30 | 31 | export default operations; 32 | -------------------------------------------------------------------------------- /packages/typeorm/tests/data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "age": 23, 6 | "username": "Bret", 7 | "email": "Sincere@april.biz", 8 | "address": { 9 | "street": "Kulas Light", 10 | "suite": "Apt. 556", 11 | "city": "Gwenborough", 12 | "zipcode": "92998-3874", 13 | "geo": { 14 | "lat": "-37.3159", 15 | "lng": "81.1496" 16 | } 17 | }, 18 | "phone": "1-770-736-8031 x56442", 19 | "website": "hildegard.org", 20 | "company": { 21 | "name": "Romaguera-Crona", 22 | "catchPhrase": "Multi-layered client-server neural-net", 23 | "bs": "harness real-time e-markets" 24 | } 25 | }, 26 | { 27 | "id": 2, 28 | "name": "Ervin Howell", 29 | "age": 26, 30 | "username": "Antonette", 31 | "email": "Shanna@melissa.tv", 32 | "address": { 33 | "street": "Victor Plains", 34 | "suite": "Suite 879", 35 | "city": "Wisokyburgh", 36 | "zipcode": "90566-7771", 37 | "geo": { 38 | "lat": "-43.9509", 39 | "lng": "-34.4618" 40 | } 41 | }, 42 | "phone": "010-692-6593 x09125", 43 | "website": "anastasia.net", 44 | "company": { 45 | "name": "Deckow-Crist", 46 | "catchPhrase": "Proactive didactic contingency", 47 | "bs": "synergize scalable supply-chains" 48 | } 49 | }, 50 | { 51 | "id": 3, 52 | "name": "Clementine Bauch", 53 | "age": 63, 54 | "username": "Samantha", 55 | "email": "Nathan@yesenia.net", 56 | "address": { 57 | "street": "Douglas Extension", 58 | "suite": "Suite 847", 59 | "city": "McKenziehaven", 60 | "zipcode": "59590-4157", 61 | "geo": { 62 | "lat": "-68.6102", 63 | "lng": "-47.0653" 64 | } 65 | }, 66 | "phone": "1-463-123-4447", 67 | "website": "ramiro.info", 68 | "company": { 69 | "name": "Romaguera-Jacobson", 70 | "catchPhrase": "Face to face bifurcated interface", 71 | "bs": "e-enable strategic applications" 72 | } 73 | }, 74 | { 75 | "id": 4, 76 | "name": "Patricia Lebsack", 77 | "age": 33, 78 | "username": "Karianne", 79 | "email": "Julianne.OConner@kory.org", 80 | "address": { 81 | "street": "Hoeger Mall", 82 | "suite": "Apt. 692", 83 | "city": "South Elvis", 84 | "zipcode": "53919-4257", 85 | "geo": { 86 | "lat": "29.4572", 87 | "lng": "-164.2990" 88 | } 89 | }, 90 | "phone": "493-170-9623 x156", 91 | "website": "kale.biz", 92 | "company": { 93 | "name": "Robel-Corkery", 94 | "catchPhrase": "Multi-tiered zero tolerance productivity", 95 | "bs": "transition cutting-edge web services" 96 | } 97 | }, 98 | { 99 | "id": 5, 100 | "name": "Chelsey Dietrich", 101 | "age": 33, 102 | "username": "Kamren", 103 | "email": "Lucio_Hettinger@annie.ca", 104 | "address": { 105 | "street": "Skiles Walks", 106 | "suite": "Suite 351", 107 | "city": "Roscoeview", 108 | "zipcode": "33263", 109 | "geo": { 110 | "lat": "-31.8129", 111 | "lng": "62.5342" 112 | } 113 | }, 114 | "phone": "(254)954-1289", 115 | "website": "demarco.info", 116 | "company": { 117 | "name": "Keebler LLC", 118 | "catchPhrase": "User-centric fault-tolerant solution", 119 | "bs": "revolutionize end-to-end systems" 120 | } 121 | }, 122 | { 123 | "id": 6, 124 | "name": "Mrs. Dennis Schulist", 125 | "age": 21, 126 | "username": "Leopoldo_Corkery", 127 | "email": "Karley_Dach@jasper.info", 128 | "address": { 129 | "street": "Norberto Crossing", 130 | "suite": "Apt. 950", 131 | "city": "South Christy", 132 | "zipcode": "23505-1337", 133 | "geo": { 134 | "lat": "-71.4197", 135 | "lng": "71.7478" 136 | } 137 | }, 138 | "phone": "1-477-935-8478 x6430", 139 | "website": "ola.org", 140 | "company": { 141 | "name": "Considine-Lockman", 142 | "catchPhrase": "Synchronised bottom-line interface", 143 | "bs": "e-enable innovative applications" 144 | } 145 | }, 146 | { 147 | "id": 7, 148 | "name": "Kurtis Weissnat", 149 | "age": 43, 150 | "username": "Elwyn.Skiles", 151 | "email": "Telly.Hoeger@billy.biz", 152 | "address": { 153 | "street": "Rex Trail", 154 | "suite": "Suite 280", 155 | "city": "Howemouth", 156 | "zipcode": "58804-1099", 157 | "geo": { 158 | "lat": "24.8918", 159 | "lng": "21.8984" 160 | } 161 | }, 162 | "phone": "210.067.6132", 163 | "website": "elvis.io", 164 | "company": { 165 | "name": "Johns Group", 166 | "catchPhrase": "Configurable multimedia task-force", 167 | "bs": "generate enterprise e-tailers" 168 | } 169 | }, 170 | { 171 | "id": 8, 172 | "name": "Nicholas Runolfsdottir V", 173 | "age": 20, 174 | "username": "Maxime_Nienow", 175 | "email": "Sherwood@rosamond.me", 176 | "address": { 177 | "street": "Ellsworth Summit", 178 | "suite": "Suite 729", 179 | "city": "Aliyaview", 180 | "zipcode": "45169", 181 | "geo": { 182 | "lat": "-14.3990", 183 | "lng": "-120.7677" 184 | } 185 | }, 186 | "phone": "586.493.6943 x140", 187 | "website": "jacynthe.com", 188 | "company": { 189 | "name": "Abernathy Group", 190 | "catchPhrase": "Implemented secondary concept", 191 | "bs": "e-enable extensible e-tailers" 192 | } 193 | }, 194 | { 195 | "id": 9, 196 | "name": "Glenna Reichert", 197 | "age": 33, 198 | "username": "Delphine", 199 | "email": "Chaim_McDermott@dana.io", 200 | "address": { 201 | "street": "Dayna Park", 202 | "suite": "Suite 449", 203 | "city": "Bartholomebury", 204 | "zipcode": "76495-3109", 205 | "geo": { 206 | "lat": "24.6463", 207 | "lng": "-168.8889" 208 | } 209 | }, 210 | "phone": "(775)976-6794 x41206", 211 | "website": "conrad.com", 212 | "company": { 213 | "name": "Yost and Sons", 214 | "catchPhrase": "Switchable contextually-based project", 215 | "bs": "aggregate real-time technologies" 216 | } 217 | }, 218 | { 219 | "id": 10, 220 | "name": "Clementina DuBuque", 221 | "age": 37, 222 | "username": "Moriah.Stanton", 223 | "email": "Rey.Padberg@karina.biz", 224 | "address": { 225 | "street": "Kattie Turnpike", 226 | "suite": "Suite 198", 227 | "city": "Lebsackbury", 228 | "zipcode": "31428-2261", 229 | "geo": { 230 | "lat": "-38.2386", 231 | "lng": "57.2232" 232 | } 233 | }, 234 | "phone": "024-648-3804", 235 | "website": "ambrose.net", 236 | "company": { 237 | "name": "Hoeger LLC", 238 | "catchPhrase": "Centralized empowering task-force", 239 | "bs": "target end-to-end models" 240 | } 241 | } 242 | ] -------------------------------------------------------------------------------- /packages/typeorm/tests/integration/BaseModel.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { BaseModel } from '../../src/BaseModel'; 4 | import { createConnection, Connection, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Repository } from 'typeorm'; 5 | 6 | import usersData from '../data/users.json'; 7 | 8 | async function seed(connection: Connection, Entity: any, data: any) { 9 | await connection.createQueryBuilder() 10 | .insert() 11 | .into(Entity) 12 | .values(data.map(item => ({ ...item }))) // to avoid the source data mutation 13 | .execute(); 14 | } 15 | 16 | async function cleanup(repository: Repository) { 17 | const { tableName } = repository.metadata; 18 | try { 19 | await repository.query(`DELETE FROM \`${tableName}\`;`); 20 | await repository.query(`DELETE FROM sqlite_sequence WHERE name = "${tableName}"`); 21 | } catch (err) { 22 | console.error('SQLite database error:', err); 23 | } 24 | } 25 | 26 | @Entity() 27 | class User { 28 | @PrimaryGeneratedColumn() 29 | id?: number; 30 | 31 | @Column() 32 | name: string; 33 | 34 | @Column() 35 | age: number; 36 | 37 | @CreateDateColumn() 38 | createdAt?: Date; 39 | 40 | @UpdateDateColumn() 41 | updatedAt?: Date; 42 | } 43 | 44 | describe('Model integration', () => { 45 | let connection: Connection; 46 | let model: BaseModel; 47 | let userRepository: Repository; 48 | 49 | beforeAll(async () => { 50 | connection = await createConnection({ 51 | type: 'sqlite', 52 | database: path.resolve(__dirname, 'testDB.sql'), 53 | entities: [User] 54 | }); 55 | 56 | await connection.synchronize(); 57 | 58 | BaseModel.connection = connection; 59 | model = new BaseModel(User); 60 | 61 | userRepository = connection.getRepository(User); 62 | }); 63 | 64 | afterAll(async () => { 65 | connection.close(); 66 | fs.unlinkSync(path.resolve(__dirname, 'testDB.sql')); 67 | }); 68 | 69 | // TODO break into find and queryBuilder sections 70 | describe('#find', () => { 71 | 72 | beforeAll(() => seed(connection, User, usersData)); 73 | 74 | afterAll(() => cleanup(userRepository)); 75 | 76 | describe('Paginate records using $skip and $limit query options', () => { 77 | 78 | test('Should find with $limit option', async () => { 79 | const results = await model.find({ $limit: 5 }); 80 | 81 | expect(results).toBeInstanceOf(Array); 82 | expect(results.length).toBe(5); 83 | }); 84 | 85 | test('Should find with $limit and $skip option', async () => { 86 | const results = await model.find({ $limit: 2, $skip: 4 }); 87 | 88 | expect(results).toBeInstanceOf(Array); 89 | expect(results.length).toBe(2); 90 | expect(results[0].id).toBe(5); 91 | expect(results[1].id).toBe(6); 92 | }); 93 | 94 | }); 95 | 96 | describe('Sorting records using $sort query option', () => { 97 | 98 | test('Should sort by string value with ASC order', async () => { 99 | const results = await model.find({ $sort: 'age' }); 100 | 101 | expect(results).toBeInstanceOf(Array); 102 | expect(results.length).toBe(10); 103 | expect(results[0].age).toBe(20); 104 | expect(results[9].age).toBe(63); 105 | }); 106 | 107 | test('Should sort by DESC order', async () => { 108 | const results = await model.find({ $sort: 'age' }); 109 | 110 | expect(results).toBeInstanceOf(Array); 111 | expect(results.length).toBe(10); 112 | expect(results[0].age).toBe(20); 113 | expect(results[9].age).toBe(63); 114 | }); 115 | 116 | test('Should sort by two string fields with ASC order', async () => { 117 | const results = await model.find({ $sort: ['age', 'id'] }); 118 | 119 | expect(results).toBeInstanceOf(Array); 120 | expect(results.length).toBe(10); 121 | expect(results[0].age).toBe(20); 122 | expect(results[9].age).toBe(63); 123 | expect(results[4].id).toBe(4); 124 | expect(results[5].id).toBe(5); 125 | expect(results[6].id).toBe(9); 126 | }); 127 | 128 | test.todo('Should sort by two fields with DESC order'); 129 | 130 | }); 131 | 132 | describe('Selecting fields using $select query option', () => { 133 | 134 | test('Should select only "name" fields', async () => { 135 | const results = await model.find({ $select: ['name'] }); 136 | 137 | const isSomeIncorrect = results.some(row => { 138 | const fields = Object.keys(row); 139 | return fields.length !== 1 || !fields.includes('name'); 140 | }) 141 | 142 | expect(isSomeIncorrect).toBeFalsy(); 143 | }); 144 | 145 | test('Should select only "id" and "age" fields', async () => { 146 | const results = await model.find({ $select: ['id', 'age'] }); 147 | 148 | const isSomeIncorrect = results.some(row => { 149 | const fields = Object.keys(row); 150 | return fields.length !== 2 || !fields.includes('id') || !fields.includes('age'); 151 | }) 152 | 153 | expect(isSomeIncorrect).toBeFalsy(); 154 | }); 155 | 156 | }); 157 | 158 | describe('Find records using $where filtering', () => { 159 | 160 | test('Should find rows with $not query', async () => { 161 | const results = await model.find({ $where: { name: { $neq: 'Ervin Howell' }} }); 162 | 163 | const filteredRowIndex = results.findIndex(row => row.name === 'Ervin Howell'); 164 | 165 | expect(results).toBeInstanceOf(Array); 166 | expect(results.length).toBe(9); 167 | expect(filteredRowIndex).toBe(-1); 168 | }); 169 | 170 | test.todo('Should find rows with $like query'); 171 | 172 | test('Should find rows with $in query', async () => { 173 | const results = await model.find({ $where: { id: { $in: [3, 6, 9] }} }); 174 | expect(results).toBeInstanceOf(Array); 175 | expect(results.length).toBe(3); 176 | }); 177 | 178 | }); 179 | }); 180 | 181 | describe('#total', () => { 182 | 183 | beforeAll(() => seed(connection, User, usersData)); 184 | 185 | afterAll(() => cleanup(userRepository)); 186 | 187 | test('Should return total rows count', async () => { 188 | const results = await model.total(); 189 | expect(results).toBe(10); 190 | }); 191 | 192 | test('Should return total rows count with search query', async () => { 193 | const results = await model.total({ age: { $gte: 30 } }); 194 | expect(results).toBe(6); 195 | }); 196 | 197 | }); 198 | 199 | describe('#create', () => { 200 | 201 | afterEach(() => cleanup(userRepository)); 202 | 203 | test('Should create new single row', async () => { 204 | const row = { name: 'Jhon Doe', age: 24 }; 205 | 206 | const results = await model.create(row); 207 | expect(results).toMatchObject({ identifiers: [1] }); 208 | 209 | const rows = await userRepository.find(); 210 | expect(rows).toHaveLength(1); 211 | expect(rows[0]).toMatchObject({ ...row, id: 1 }); 212 | }); 213 | 214 | test('Should create a few rows', async () => { 215 | const insertRows = [ 216 | { name: 'Jhon Doe', age: 24 }, 217 | { name: 'Clementina DuBuque', age: 31 }, 218 | { name: 'Nicholas Runolfsdottir V', age: 28 } 219 | ]; 220 | 221 | const results = await model.create(insertRows); 222 | expect(results).toMatchObject({ identifiers: [1, 2, 3] }); 223 | 224 | const rows = await userRepository.find(); 225 | expect(rows).toHaveLength(3); 226 | expect(rows[0]).toMatchObject({ ...insertRows[0], id: 1 }); 227 | expect(rows[1]).toMatchObject({ ...insertRows[1], id: 2 }); 228 | expect(rows[2]).toMatchObject({ ...insertRows[2], id: 3 }); 229 | }); 230 | 231 | }); 232 | 233 | describe('#patch', () => { 234 | 235 | beforeEach(() => seed(connection, User, usersData)); 236 | 237 | afterEach(() => cleanup(userRepository)); 238 | 239 | test('Should patch single row', async () => { 240 | const result = await model.patch({ id: 3 }, { age: 999 }); 241 | 242 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 243 | // expect(result).toMatchObject({ affected: 1 }); 244 | expect(result).toMatchObject({ affected: undefined }); 245 | 246 | const rows = await userRepository.find(); 247 | rows.forEach(row => { 248 | const sourceRow = usersData.find(user => user.id === row.id); 249 | expect(row).toHaveProperty('name', sourceRow.name); 250 | expect(row).toHaveProperty('age', row.id === 3 ? 999 : sourceRow.age); 251 | }); 252 | }); 253 | 254 | test('Should patch multi rows', async () => { 255 | const result = await model.patch({ age: { $in: [33, 37]} }, { name: 'PATCHED' }); 256 | 257 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 258 | // expect(result).toMatchObject({ affected: 4 }); 259 | expect(result).toMatchObject({ affected: undefined }); 260 | 261 | const rows = await userRepository.find(); 262 | rows.forEach(row => { 263 | const sourceRow = usersData.find(user => user.id === row.id); 264 | expect(row).toHaveProperty('name', [33, 37].includes(sourceRow.age) ? 'PATCHED' : sourceRow.name); 265 | expect(row).toHaveProperty('age', sourceRow.age); 266 | }); 267 | }); 268 | 269 | test('Should patch all rows', async () => { 270 | const result = await model.patch({}, { name: 'PATCHED', age: 0 }); 271 | 272 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 273 | // expect(result).toMatchObject({ affected: 10 }); 274 | expect(result).toMatchObject({ affected: undefined }); 275 | 276 | const rows = await userRepository.find(); 277 | rows.forEach(row => { 278 | expect(row).toHaveProperty('name', 'PATCHED'); 279 | expect(row).toHaveProperty('age', 0); 280 | }); 281 | }); 282 | 283 | test.todo('Should not patch the primary id'); 284 | 285 | }); 286 | 287 | describe('#update', () => { 288 | 289 | beforeEach(() => seed(connection, User, usersData)); 290 | 291 | afterEach(() => cleanup(userRepository)); 292 | 293 | test('Should update single row', async () => { 294 | const [sourceRow] = await userRepository.find({ id: 6 }); 295 | 296 | const result = await model.update({ id: 6 }, { id: 666, name: 'UPDATED', age: 999 }); 297 | 298 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 299 | // expect(result).toMatchObject({ affected: 1 }); 300 | expect(result).toMatchObject({ affected: undefined }); 301 | 302 | const [updatedRow] = await userRepository.find({ id: 666 }); 303 | expect(updatedRow).toBeDefined(); 304 | expect(updatedRow.name).toBe('UPDATED'); 305 | expect(updatedRow.age).toBe(999); 306 | expect(updatedRow.createdAt.getTime()).not.toBe(sourceRow.createdAt.getTime()); 307 | expect(updatedRow.updatedAt.getTime()).not.toBe(sourceRow.updatedAt.getTime()); 308 | }); 309 | 310 | test('Should update multi rows', async () => { 311 | // arrange 312 | const createdAt = new Date(); 313 | const sourceRows = await userRepository.find(); 314 | 315 | // act 316 | const result = await model.update({ id: { $in: [4, 5, 6, 7]} }, { name: 'UPDATED', age: 666, createdAt }); 317 | 318 | // assert 319 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 320 | // expect(result).toMatchObject({ affected: 4 }); 321 | expect(result).toMatchObject({ affected: undefined }); 322 | 323 | const rows = await userRepository.find(); 324 | rows.forEach(row => { 325 | const sourceRow = sourceRows.find(user => user.id === row.id); 326 | 327 | if ([4, 5, 6, 7].includes(row.id)) { 328 | expect(row).toHaveProperty('name', 'UPDATED'); 329 | expect(row).toHaveProperty('age', 666); 330 | expect(row.updatedAt.getTime()).not.toBe(sourceRow.updatedAt.getTime()); 331 | expect(row.createdAt.getTime()).toBe(createdAt.getTime()); 332 | } else { 333 | expect(row).toHaveProperty('name', sourceRow.name); 334 | expect(row).toHaveProperty('age', sourceRow.age); 335 | expect(row.updatedAt.getTime()).toBe(sourceRow.updatedAt.getTime()); 336 | expect(row.createdAt.getTime()).toBe(sourceRow.createdAt.getTime()); 337 | } 338 | }); 339 | }); 340 | 341 | test('Should update all rows', async () => { 342 | const updatedAt = new Date(); 343 | const result = await model.update({}, { name: 'UPDATED_ALL', age: 0, updatedAt }); 344 | 345 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 346 | // expect(result).toMatchObject({ affected: 10 }); 347 | expect(result).toMatchObject({ affected: undefined }); 348 | 349 | const rows = await userRepository.find(); 350 | rows.forEach(row => { 351 | expect(row).toHaveProperty('name', 'UPDATED_ALL'); 352 | expect(row).toHaveProperty('age', 0); 353 | expect(row.updatedAt.getTime()).toBe(updatedAt.getTime()); 354 | }); 355 | }); 356 | 357 | }); 358 | 359 | describe('#remove', () => { 360 | 361 | beforeEach(() => seed(connection, User, usersData)); 362 | 363 | afterEach(() => cleanup(userRepository)); 364 | 365 | test('Should remove single row', async () => { 366 | const result = await model.remove({ id: 5 }); 367 | 368 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 369 | // expect(result).toMatchObject({ affected: 1 }); 370 | expect(result).toMatchObject({ affected: undefined }); 371 | 372 | const rows = await userRepository.find(); 373 | expect(rows).toHaveLength(9); 374 | 375 | const removedRow = rows.find(row => row.id === 5); 376 | expect(removedRow).toBeUndefined(); 377 | }); 378 | 379 | test('Should delete multi rows', async () => { 380 | const result = await model.remove({ $or: [{ age: { $lte: 26 } }, { name: 'Chelsey Dietrich' }] }); 381 | 382 | // Note: working only with pg or mysql, because of RETURNING supported ✅ 383 | // expect(result).toMatchObject({ affected: 5 }); 384 | expect(result).toMatchObject({ affected: undefined }); 385 | 386 | const rows = await connection.getRepository(User).find(); 387 | expect(rows).toHaveLength(5); 388 | 389 | rows.forEach(row => { 390 | const isMatchDeleteQuery = row.age <= 26 || row.name === 'Chelsey Dietrich'; 391 | expect(isMatchDeleteQuery).toBeFalsy(); 392 | }); 393 | }); 394 | 395 | test('Should delete all rows', async () => { 396 | const result = await model.remove({}); 397 | 398 | // TODO: check on pg and mysql, test doesn't passed because of affected is undefined 399 | // expect(result).toMatchObject({ affected: 10 }); 400 | expect(result).toMatchObject({ affected: undefined }); 401 | 402 | const rows = await userRepository.find(); 403 | expect(rows).toHaveLength(0); 404 | }); 405 | 406 | }); 407 | 408 | }); 409 | -------------------------------------------------------------------------------- /packages/typeorm/tests/integration/mapProperty.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Column, Connection, createConnection, Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; 3 | import mapProperty from '../../src/lib/mapProperty'; 4 | import type { JSONSchema7 } from 'json-schema'; 5 | 6 | @Entity() 7 | class Example { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @Column() 12 | number: number; 13 | 14 | @Column() 15 | mediumint: number; 16 | 17 | @Column({ select: false }) 18 | hidden: string; 19 | 20 | @Column({ type: 'date' }) 21 | datetime: Date; 22 | 23 | @Column({ enum: ['value1', 'value2', 'value3'], update: false }) 24 | string: string; 25 | 26 | @Column({ 27 | type: 'varchar', 28 | name: 'my_varchar', 29 | length: 80, 30 | default: 'hey hey hey', 31 | unique: true, 32 | comment: 'This is my uniq string field' 33 | }) 34 | varchar: string; 35 | 36 | @Column({ nullable: true }) 37 | is_flag: boolean; 38 | 39 | @CreateDateColumn() 40 | created_at: Date; 41 | } 42 | 43 | 44 | describe('mapProperty', () => { 45 | let connection: Connection; 46 | let schema: Record; 47 | 48 | beforeAll(async () => { 49 | connection = await createConnection({ 50 | type: 'sqlite', 51 | database: './testDB.sql', 52 | entities: [Example] 53 | }); 54 | 55 | const { columns } = connection.getMetadata(Example); 56 | schema = columns.reduce((props, column) => ({ ...props, [column.propertyName]: mapProperty(column) }), {}); 57 | }); 58 | 59 | afterAll(async () => { 60 | connection.close(); 61 | fs.unlinkSync('./testDB.sql'); 62 | }); 63 | 64 | /** 65 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10 66 | */ 67 | test('Should add schema annotations', () => { 68 | const schemaProperty = schema.varchar; 69 | expect(schemaProperty.title).toBe('varchar'); 70 | expect(schemaProperty.description).toBe('This is my uniq string field'); 71 | expect(schemaProperty.default).toBe('hey hey hey'); 72 | 73 | expect(schema.string.readOnly).toBeTruthy(); 74 | expect(schema.hidden.writeOnly).toBeTruthy(); 75 | // expect(schemaProperty.examples) ? 76 | }); 77 | 78 | /** 79 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1 80 | */ 81 | test('Should add schema validation keywords for any instance type', () => { 82 | let schemaProperty = schema.string; 83 | expect(schemaProperty.type).toBe('string'); 84 | expect(schemaProperty.enum).toMatchObject(['value1', 'value2', 'value3']); 85 | 86 | schemaProperty = schema.is_flag; 87 | expect(schemaProperty.type).toBe('boolean'); 88 | // TODO add more type tests 89 | }); 90 | 91 | /** 92 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3 93 | */ 94 | test('Should add schema validation keywords for strings', () => { 95 | const schemaProperty = schema.varchar; 96 | expect(schemaProperty.maxLength).toBe(80); 97 | // minLength?: number; 98 | // pattern?: string; 99 | }); 100 | 101 | 102 | /** 103 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7 104 | */ 105 | test('Should add semantic validation With "format"', () => { 106 | const schemaProperty = schema.datetime; 107 | expect(schemaProperty.format).toBe('date-time'); 108 | }); 109 | 110 | }); 111 | -------------------------------------------------------------------------------- /packages/typeorm/tests/unit/BaseModel.test.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from 'typeorm'; 2 | import { BaseModel } from '../../src/BaseModel'; 3 | 4 | class Entity {} 5 | 6 | describe('Model', () => { 7 | const model = new BaseModel(Entity); 8 | const mocks: Record = {}; 9 | 10 | beforeAll(() => { 11 | mocks.getMetadataMock = jest.fn().mockReturnValue({ 12 | columns: [ 13 | { propertyName: 'id', type: Number }, 14 | { propertyName: 'name', type: String }, 15 | { propertyName: 'age', type: Number } 16 | ] 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | Object.values(mocks).forEach(mock => mock.mockClear()); 22 | }); 23 | 24 | test('Should have model base methods', () => { 25 | expect(model.find).toBeTruthy(); 26 | expect(model.create).toBeTruthy(); 27 | expect(model.patch).toBeTruthy(); 28 | expect(model.update).toBeTruthy(); 29 | expect(model.remove).toBeTruthy(); 30 | expect(model.total).toBeTruthy(); 31 | }); 32 | 33 | test('Should return model name', () => { 34 | expect(model.name).toBe('Entity'); 35 | }); 36 | 37 | test('Should return model name and jsonSchema', () => { 38 | BaseModel.connection = { getMetadata: mocks.getMetadataMock } as unknown as Connection; 39 | 40 | expect(model.jsonSchema).toBeDefined(); 41 | expect(mocks.getMetadataMock).toHaveBeenCalled(); 42 | expect(mocks.getMetadataMock).toHaveBeenCalledWith(Entity); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/typeorm/tests/unit/bootstrap.test.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from '../../src/bootstrap'; 2 | import { BaseModel } from '../../src/BaseModel'; 3 | 4 | describe('bootstrap', () => { 5 | 6 | test('Should decorate instance', () => { 7 | // arrange 8 | const fastifyInstance = { decorate: jest.fn() }; 9 | const connection = jest.fn(); 10 | const callback = jest.fn(); 11 | 12 | // act 13 | bootstrap(fastifyInstance, { connection }, callback); 14 | 15 | // assert 16 | expect(bootstrap).toBeDefined(); 17 | 18 | expect(fastifyInstance.decorate).toHaveBeenCalledTimes(2); 19 | expect(fastifyInstance.decorate).toHaveBeenCalledWith('connection', connection); 20 | expect(fastifyInstance.decorate).toHaveBeenCalledWith('BaseModel', BaseModel); 21 | 22 | expect(BaseModel.connection).toBe(connection); 23 | expect(callback).toBeCalledTimes(1); 24 | }); 25 | 26 | }); -------------------------------------------------------------------------------- /packages/typeorm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "sourceMap": false, 6 | "strict": true 7 | }, 8 | "include": ["src"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "allowJs": false, 8 | "alwaysStrict": true, 9 | "sourceMap": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": false, 14 | "noImplicitAny": false, 15 | "noImplicitThis": false, 16 | "strictNullChecks": false, 17 | "experimentalDecorators": true, 18 | "importHelpers": true, 19 | "declaration": true, 20 | "watch": false, 21 | "emitDecoratorMetadata": true, 22 | "esModuleInterop": true, 23 | "removeComments": true, 24 | "lib": ["ES2020"], 25 | "resolveJsonModule": true 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "dist", 30 | "**/*.spec.ts" 31 | ] 32 | } --------------------------------------------------------------------------------