├── .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 | 
8 | [](https://codecov.io/gh/FastifyResty/fastify-resty)
9 | [](https://snyk.io/test/github/FastifyResty/fastify-resty)
10 | [](https://depfu.com/github/FastifyResty/fastify-resty?project_id=17745)
11 |
12 |
13 |
14 |
15 |
16 | [](https://github.com/Naereen/StrapDown.js/blob/master/LICENSE)
17 | [](https://GitHub.com/FastifyResty/fastify-resty/graphs/commit-activity)
18 | [](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 | }
--------------------------------------------------------------------------------