├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .travis.yml ├── fastify-mongoose-api.js ├── Gruntfile.js ├── eslint.config.mjs ├── .github └── workflows │ └── build.yml ├── src ├── LoadSchemasFromPath.js ├── API.js ├── DefaultSchemas.js ├── DefaultModelMethods.js └── APIRouter.js ├── LICENSE ├── package.json ├── test ├── extra_cases.test.js ├── BackwardWrapper.js ├── auth.test.js ├── list_handler.test.js ├── nested_paths.test.js ├── methods.test.js ├── prefix.test.js ├── default_v_key.test.js ├── boolean_fields.test.js ├── complex_where.test.js ├── disable_route.test.js ├── model_name_and_hide_vkey.test.js ├── schemas_path_filter.js ├── refs_in_array.test.js ├── create_or_update.test.js ├── schemas_from_path.js ├── validation_schemas.test.js ├── schemas_in_es.js ├── few_same_refs_populations.test.js └── api.test.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | package-lock.json 4 | .tap 5 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tap 2 | .vscode 3 | .eslintrc.json 4 | .prettierrc.json 5 | .github 6 | .travis.yml 7 | Gruntfile.js 8 | test -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "trailingComma": "none" 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_script: 3 | - sleep 15 4 | - mongo mydb_test --eval 'db.createUser({user:"travis",pwd:"test",roles:["readWrite"]});' 5 | services: 6 | - mongodb 7 | node_js: 8 | - "10" 9 | - "12" 10 | - "14" 11 | - "15" 12 | 13 | sudo: false -------------------------------------------------------------------------------- /fastify-mongoose-api.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin'); 2 | const API = require('./src/API.js'); 3 | const DefaultModelMethods = require('./src/DefaultModelMethods.js'); 4 | 5 | function initPlugin(fastify, options, next) { 6 | options = options || {}; 7 | options.fastify = fastify; 8 | 9 | const api = new API(options); 10 | fastify.decorate('mongooseAPI', api); 11 | 12 | next(); 13 | } 14 | 15 | const plugin = fp(initPlugin, { 16 | fastify: '^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0', 17 | name: 'fastify-mongoose-api', 18 | }); 19 | 20 | plugin.DefaultModelMethods = DefaultModelMethods; 21 | 22 | module.exports = plugin; 23 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.initConfig({ 4 | watch: { 5 | javascript: { 6 | files: ['test/**/*.js', 'src/**/*.js'], 7 | tasks: ['tape', 'eslint'] 8 | } 9 | }, 10 | env: { 11 | options: {}, 12 | test: { 13 | NODE_ENV: 'test' 14 | } 15 | }, 16 | tape: { 17 | options: { 18 | pretty: false 19 | }, 20 | files: ['test/**/*.js'] 21 | }, 22 | eslint: { 23 | options: { 24 | }, 25 | target: ['src/**/*.js'] 26 | } 27 | }); 28 | 29 | grunt.loadNpmTasks('grunt-contrib-watch'); 30 | grunt.loadNpmTasks('grunt-tape'); 31 | grunt.loadNpmTasks('grunt-env'); 32 | grunt.loadNpmTasks('grunt-eslint'); 33 | 34 | grunt.registerTask('default', ['tape']); 35 | grunt.registerTask('watchtests', ['env:test', 'watch:javascript']); 36 | 37 | }; -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import prettier from "eslint-plugin-prettier"; 3 | import globals from "globals"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all 15 | }); 16 | 17 | export default defineConfig([{ 18 | extends: compat.extends("eslint:recommended", "prettier"), 19 | 20 | plugins: { 21 | prettier, 22 | }, 23 | 24 | languageOptions: { 25 | globals: { 26 | ...globals.commonjs, 27 | ...globals.node, 28 | }, 29 | 30 | ecmaVersion: 12, 31 | sourceType: "commonjs", 32 | }, 33 | 34 | rules: { 35 | "prettier/prettier": "warn", 36 | }, 37 | }]); -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | workflow_dispatch: 10 | #on: [workflow_dispatch] # disabled for debug 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18] 17 | mongodb-version: [4.4, 5.0, 6.0] 18 | steps: 19 | - name: Git checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Start MongoDB ${{ matrix.mongodb-version }} 28 | uses: supercharge/mongodb-github-action@1.10.0 29 | with: 30 | mongodb-version: ${{ matrix.mongodb-version }} 31 | 32 | - name: Install dependencies 33 | run: npm install 34 | 35 | - name: Run tests 36 | run: npm run test 37 | env: 38 | DATABASE_URI: mongodb://localhost:27017/test -------------------------------------------------------------------------------- /src/LoadSchemasFromPath.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const importSync = require('import-sync'); 6 | 7 | const loadSchemasFromPath = (schemaDirPath, filterFn) => { 8 | const schemasFromPath = []; 9 | const schemaFiles = walkDir(schemaDirPath, filterFn); 10 | schemaFiles.forEach(file => { 11 | const schema = importSync(file); 12 | schemasFromPath.push(schema.default || schema); 13 | }); 14 | return schemasFromPath; 15 | }; 16 | 17 | const walkDir = (schemaDirPath, filterFn, fileList = []) => { 18 | const dir = fs.readdirSync(schemaDirPath); 19 | dir.forEach(file => { 20 | const pathFile = path.join(schemaDirPath, file); 21 | const stat = fs.statSync(pathFile); 22 | if (stat.isDirectory()) { 23 | fileList = walkDir(pathFile, filterFn, fileList); 24 | } else if (filterFn(schemaDirPath, file)) { 25 | fileList.push(pathFile); 26 | } 27 | }); 28 | return fileList; 29 | }; 30 | 31 | module.exports = loadSchemasFromPath; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jeka Kiselyov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-mongoose-api", 3 | "version": "1.2.28", 4 | "description": "API for Mongoose models in one line of code", 5 | "main": "fastify-mongoose-api.js", 6 | "scripts": { 7 | "test": "tap -j1 --allow-incomplete-coverage ./test/*.test.js", 8 | "coverage": "tap -j1 ./test/*.test.js", 9 | "lint": "eslint \"src/**/*.[jt]s?(x)\" \"test/**/*.?(c)[jt]s\"", 10 | "lint:fix": "npm run lint -- --fix" 11 | }, 12 | "repository": "https://github.com/jeka-kiselyov/fastify-mongoose-api.git", 13 | "keywords": [ 14 | "fastify", 15 | "mongoose", 16 | "mongodb", 17 | "mongo", 18 | "rest", 19 | "api", 20 | "crud" 21 | ], 22 | "author": "Jeka Kiselyov", 23 | "contributors": [ 24 | { 25 | "name": "Emiliano Bruni", 26 | "url": "https://github.com/EmilianoBruni", 27 | "author": true 28 | } 29 | ], 30 | "license": "MIT", 31 | "dependencies": { 32 | "fastify": "^5.2.1", 33 | "fastify-plugin": "^5.0.1", 34 | "import-sync": "^2.2.3", 35 | "mongoose": "^8.11.0" 36 | }, 37 | "devDependencies": { 38 | "@eslint/eslintrc": "^3.3.1", 39 | "@eslint/js": "^9.25.0", 40 | "eslint": "^9.21.0", 41 | "eslint-config-prettier": "^10.0.2", 42 | "eslint-plugin-prettier": "^5.1.3", 43 | "globals": "^16.0.0", 44 | "grunt": "^1.6.1", 45 | "grunt-contrib-watch": "^1.1.0", 46 | "grunt-env": "^1.0.1", 47 | "grunt-eslint": "^25.0.0", 48 | "grunt-tape": "^0.1.0", 49 | "prettier": "^3.5.2", 50 | "tap": "^21.1.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/extra_cases.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fastifyMongooseAPI = require('../fastify-mongoose-api.js'); 4 | 5 | const t = require('tap'); 6 | const { test } = t; 7 | 8 | const mongoose = require('mongoose'); 9 | const BackwardWrapper = require('./BackwardWrapper.js'); 10 | 11 | const bw = new BackwardWrapper(t); 12 | 13 | test('mongoose db initialization', async () => { 14 | await bw.createConnection(); 15 | }); 16 | 17 | test('schema initialization', async t => { 18 | const schema = mongoose.Schema({ 19 | name: String 20 | }); 21 | schema.methods.apiValues = function () { 22 | return { name: this.name }; 23 | }; 24 | schema.methods.apiPut = function () { 25 | return { name: this.name }; 26 | }; 27 | schema.methods.apiDelete = function () { 28 | return { name: this.name }; 29 | }; 30 | 31 | schema.statics.apiPost = function () { 32 | return { name: this.name }; 33 | }; 34 | schema.statics.apiSubRoutes = function () { 35 | return []; 36 | }; 37 | 38 | bw.conn.model('Test', schema); 39 | t.ok(bw.conn.models.Test); 40 | }); 41 | 42 | test('does not let initialize plugin class directly', async t => { 43 | t.throws(() => { 44 | new fastifyMongooseAPI(); 45 | }); 46 | t.throws(() => { 47 | new fastifyMongooseAPI({ fastify: 1 }); 48 | }); 49 | t.throws(() => { 50 | new fastifyMongooseAPI({ models: 3 }); 51 | }); 52 | }); 53 | 54 | test('initialization of API server', async () => { 55 | await bw.createServer({ 56 | models: bw.conn.models, 57 | setDefaults: false 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/BackwardWrapper.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | class BackwardWrapper { 4 | constructor(t) { 5 | this.MONGODB_URL = 6 | process.env.DATABASE_URI || 7 | 'mongodb://127.0.0.1/fastifymongooseapitest'; 8 | this.t = t; 9 | } 10 | 11 | async createServer(pluginConfig = {}) { 12 | const Fastify = require('fastify'); 13 | const fastifyMongooseAPI = require('../fastify-mongoose-api.js'); 14 | 15 | const fastify = Fastify(); 16 | fastify.register(fastifyMongooseAPI, pluginConfig); 17 | await fastify.ready(); 18 | 19 | this.t.ok(fastify.mongooseAPI, 'mongooseAPI decorator is available'); 20 | 21 | this.t.teardown(async () => { 22 | await fastify.close(); 23 | }); 24 | 25 | this.fastify = fastify; 26 | return fastify; 27 | } 28 | 29 | async createConnection() { 30 | this.conn = await mongoose 31 | .createConnection(this.MONGODB_URL) 32 | .asPromise(); 33 | 34 | this.t.ok(this.conn); 35 | this.t.equal(this.conn.readyState, 1, 'Ready state is connected(==1)'); /// connected 36 | 37 | this.t.teardown(async () => { 38 | await this.conn.close(); 39 | }); 40 | } 41 | 42 | async populateDoc(populated) { 43 | if (populated.execPopulate) { 44 | return await populated.execPopulate(); 45 | } else { 46 | return await populated; 47 | } 48 | } 49 | 50 | async inject(t, injectOptions, expectedStatusCode = 200, debug = false) { 51 | const response = await this.fastify.inject(injectOptions); 52 | 53 | if (debug) { 54 | console.error(response); 55 | } 56 | 57 | t.equal( 58 | response.statusCode, 59 | expectedStatusCode, 60 | `Status code is ${expectedStatusCode}` 61 | ); 62 | t.equal( 63 | response.headers['content-type'], 64 | 'application/json; charset=utf-8', 65 | 'Content-Type is correct' 66 | ); 67 | return response; 68 | } 69 | } 70 | 71 | module.exports = BackwardWrapper; 72 | -------------------------------------------------------------------------------- /test/auth.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | let isAuthedTestBoolean = false; 12 | 13 | test('mongoose db initialization', async () => { 14 | await bw.createConnection(); 15 | }); 16 | 17 | test('schema initialization', async t => { 18 | t.plan(2); 19 | 20 | const authorSchema = mongoose.Schema({ 21 | firstName: String, 22 | lastName: String, 23 | biography: String, 24 | created: { 25 | type: Date, 26 | default: Date.now 27 | } 28 | }); 29 | authorSchema.index({ 30 | firstName: 'text', 31 | lastName: 'text', 32 | biography: 'text' 33 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 34 | 35 | bw.conn.model('Author', authorSchema); 36 | 37 | const bookSchema = mongoose.Schema({ 38 | title: String, 39 | isbn: String, 40 | author: { 41 | type: mongoose.Schema.Types.ObjectId, 42 | ref: 'Author' 43 | }, 44 | created: { 45 | type: Date, 46 | default: Date.now 47 | } 48 | }); 49 | 50 | bw.conn.model('Book', bookSchema); 51 | 52 | t.ok(bw.conn.models.Author); 53 | t.ok(bw.conn.models.Book); 54 | }); 55 | 56 | test('clean up test collections', async () => { 57 | await bw.conn.models.Author.deleteMany({}).exec(); 58 | await bw.conn.models.Book.deleteMany({}).exec(); 59 | }); 60 | 61 | test('initialization of API server', async () => { 62 | await bw.createServer({ 63 | models: bw.conn.models, 64 | prefix: '/api/', 65 | setDefaults: true, 66 | checkAuth: async () => { 67 | if (!isAuthedTestBoolean) { 68 | const e = new Error('401, honey!'); 69 | e.statusCode = 401; 70 | throw e; 71 | } 72 | }, 73 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 74 | }); 75 | }); 76 | 77 | test('Test Auth (not)', async t => { 78 | let response = null; 79 | response = await bw.inject( 80 | t, 81 | { 82 | method: 'GET', 83 | url: '/api/books' 84 | }, 85 | 401 86 | ); 87 | 88 | t.same( 89 | response.json(), 90 | { statusCode: 401, error: 'Unauthorized', message: '401, honey!' }, 91 | 'There is error and no data response' 92 | ); 93 | 94 | response = await bw.inject( 95 | t, 96 | { 97 | method: 'GET', 98 | url: '/api/authors' 99 | }, 100 | 401 101 | ); 102 | 103 | t.same( 104 | response.json(), 105 | { statusCode: 401, error: 'Unauthorized', message: '401, honey!' }, 106 | 'There is error and no data response' 107 | ); 108 | }); 109 | 110 | test('Test Auth (authed)', async t => { 111 | //// sign in 112 | isAuthedTestBoolean = true; 113 | //// 114 | 115 | let response = null; 116 | response = await bw.inject(t, { 117 | method: 'GET', 118 | url: '/api/books' 119 | }); 120 | 121 | t.match(response.json(), { total: 0 }, 'There is response'); 122 | 123 | response = await bw.inject(t, { 124 | method: 'GET', 125 | url: '/api/authors' 126 | }); 127 | 128 | t.match(response.json(), { total: 0 }, 'There is response'); 129 | }); 130 | -------------------------------------------------------------------------------- /test/list_handler.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const schema = mongoose.Schema({ 17 | name: String, 18 | appleCount: Number, 19 | bananaCount: Number 20 | }); 21 | 22 | schema.statics.onListQuery = async function (query, request) { 23 | let evenBananas = request.query.even ? request.query.even : null; 24 | if (evenBananas) { 25 | query = query.and({ bananaCount: { $mod: [2, 0] } }); 26 | } 27 | 28 | // DO NOT RETURN THE QUERY 29 | }; 30 | 31 | bw.conn.model('WhereTest', schema); 32 | t.ok(bw.conn.models.WhereTest); 33 | }); 34 | 35 | test('clean up test collections', async () => { 36 | await bw.conn.models.WhereTest.deleteMany({}).exec(); 37 | }); 38 | 39 | test('initialization of API server', async t => { 40 | await bw.createServer({ 41 | models: bw.conn.models 42 | }); 43 | 44 | t.equal( 45 | bw.fastify.mongooseAPI.apiRouters.WhereTest.collectionName, 46 | 'wheretests', 47 | 'Collection name used in API path' 48 | ); 49 | }); 50 | 51 | test('POST item test', async t => { 52 | let response = null; 53 | response = await bw.inject(t, { 54 | method: 'POST', 55 | url: '/api/wheretests', 56 | payload: { name: 'Bob', appleCount: 1, bananaCount: 2 } 57 | }); 58 | 59 | t.match( 60 | JSON.parse(response.payload), 61 | { name: 'Bob', appleCount: 1, bananaCount: 2 }, 62 | 'POST api ok' 63 | ); 64 | 65 | t.match( 66 | response.json(), 67 | { name: 'Bob', appleCount: 1, bananaCount: 2 }, 68 | 'POST api ok' 69 | ); 70 | 71 | response = await bw.inject(t, { 72 | method: 'POST', 73 | url: '/api/wheretests', 74 | payload: { name: 'Rob', appleCount: 2, bananaCount: 3 } 75 | }); 76 | 77 | t.match( 78 | response.json(), 79 | { name: 'Rob', appleCount: 2, bananaCount: 3 }, 80 | 'POST api ok' 81 | ); 82 | 83 | response = await bw.inject(t, { 84 | method: 'POST', 85 | url: '/api/wheretests', 86 | payload: { name: 'Alice', appleCount: 50, bananaCount: 90 } 87 | }); 88 | 89 | t.match( 90 | response.json(), 91 | { name: 'Alice', appleCount: 50, bananaCount: 90 }, 92 | 'POST api ok' 93 | ); 94 | 95 | response = await bw.inject(t, { 96 | method: 'GET', 97 | url: '/api/wheretests' 98 | }); 99 | 100 | t.equal(response.json().total, 3, 'There re 3 banana holders'); 101 | }); 102 | 103 | test('GET collection onListQuery', async t => { 104 | let response = null; 105 | 106 | response = await bw.inject(t, { 107 | method: 'GET', 108 | url: '/api/wheretests' 109 | }); 110 | 111 | t.equal(response.json().total, 3, 'API returns total 3 items'); 112 | t.equal(response.json().items.length, 3, 'API returns total 3 items'); 113 | 114 | response = await bw.inject(t, { 115 | method: 'GET', 116 | url: '/api/wheretests', 117 | query: { even: true } // URL GET parameters 118 | }); 119 | 120 | t.equal(response.json().total, 2, 'API returns 2 filtered'); 121 | t.equal(response.json().items.length, 2, 'API returns 2 filtered'); /// banana == 2 and banana == 90 122 | }); 123 | -------------------------------------------------------------------------------- /test/nested_paths.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const authorSchema = mongoose.Schema({ 17 | firstName: String, 18 | lastName: String, 19 | biography: { description: String, born: Number }, 20 | created: { 21 | type: Date, 22 | default: Date.now 23 | } 24 | }); 25 | 26 | bw.conn.model('Author', authorSchema); 27 | 28 | t.ok(bw.conn.models.Author); 29 | }); 30 | 31 | test('clean up test collections', async () => { 32 | await bw.conn.models.Author.deleteMany({}).exec(); 33 | }); 34 | 35 | test('initialization of API server', async t => { 36 | await bw.createServer({ 37 | models: bw.conn.models, 38 | prefix: '/api/', 39 | setDefaults: true, 40 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 41 | }); 42 | 43 | t.equal( 44 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 45 | 'authors', 46 | 'Collection name used in API path' 47 | ); 48 | t.equal( 49 | bw.fastify.mongooseAPI.apiRouters.Author.path, 50 | '/api/authors', 51 | 'API path is composed with prefix + collectionName' 52 | ); 53 | }); 54 | 55 | test('POST item test', async t => { 56 | let response = null; 57 | response = await bw.inject(t, { 58 | method: 'POST', 59 | url: '/api/authors', 60 | payload: { 61 | firstName: 'Hutin', 62 | lastName: 'Puylo', 63 | 'biography.description': 'was good', 64 | 'biography.born': '1960' 65 | } 66 | }); 67 | 68 | t.match( 69 | JSON.parse(response.payload), 70 | { 71 | firstName: 'Hutin', 72 | lastName: 'Puylo', 73 | biography: { description: 'was good', born: 1960 } 74 | }, 75 | 'POST api response matches expected' 76 | ); 77 | 78 | t.match( 79 | response.json(), 80 | { 81 | firstName: 'Hutin', 82 | lastName: 'Puylo', 83 | biography: { description: 'was good', born: 1960 } 84 | }, 85 | 'POST api ok' 86 | ); 87 | 88 | response = await bw.inject(t, { 89 | method: 'GET', 90 | url: '/api/authors' 91 | }); 92 | 93 | t.match( 94 | response.json().items[0], 95 | { 96 | firstName: 'Hutin', 97 | lastName: 'Puylo', 98 | biography: { description: 'was good', born: 1960 } 99 | }, 100 | 'Listed same' 101 | ); 102 | t.equal(response.json().total, 1, 'There are author now'); 103 | }); 104 | 105 | test('PUT item test', async t => { 106 | let authorFromDb = await bw.conn.models.Author.findOne({ 107 | firstName: 'Hutin' 108 | }); 109 | // await BackwardWrapper.populateDoc(bookFromDb.populate('author')); 110 | 111 | const response = await bw.inject(t, { 112 | method: 'PUT', 113 | url: '/api/authors/' + authorFromDb.id, 114 | payload: { lastName: 'Chuvachello', 'biography.born': 1961 } 115 | }); 116 | 117 | t.match( 118 | response.json(), 119 | { 120 | firstName: 'Hutin', 121 | lastName: 'Chuvachello', 122 | biography: { description: 'was good', born: 1961 } 123 | }, 124 | 'PUT api ok' 125 | ); 126 | }); 127 | -------------------------------------------------------------------------------- /test/methods.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | let schema = { 12 | firstName: String, 13 | lastName: String 14 | }; 15 | let _id = null; 16 | 17 | test('mongoose db initialization', async () => { 18 | await bw.createConnection(); 19 | }); 20 | 21 | test('schema initialization', async t => { 22 | const authorSchema = mongoose.Schema(schema); 23 | 24 | bw.conn.model('Author', authorSchema); 25 | 26 | t.ok(bw.conn.models.Author); 27 | }); 28 | 29 | test('clean up test collections', async () => { 30 | await bw.conn.models.Author.deleteMany({}).exec(); 31 | }); 32 | 33 | test('initialization of API server', async t => { 34 | await bw.createServer({ 35 | models: bw.conn.models, 36 | setDefaults: true 37 | }); 38 | 39 | t.strictSame( 40 | bw.fastify.mongooseAPI._methods, 41 | ['list', 'get', 'post', 'patch', 'put', 'delete'], 42 | 'mongooseAPI defaults methods loaded' 43 | ); 44 | }); 45 | 46 | test('POST item test', async t => { 47 | let response = null; 48 | response = await bw.inject(t, { 49 | method: 'POST', 50 | url: '/api/authors', 51 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 52 | }); 53 | 54 | const responseBody = response.json(); 55 | t.equal(responseBody.firstName, 'Hutin'); 56 | t.equal(responseBody.lastName, 'Puylo'); 57 | t.match( 58 | responseBody, 59 | { firstName: 'Hutin', lastName: 'Puylo' }, 60 | 'POST api ok' 61 | ); 62 | _id = responseBody._id; 63 | t.ok(_id, '_id generated'); 64 | 65 | response = await bw.inject(t, { 66 | method: 'GET', 67 | url: '/api/authors' 68 | }); 69 | 70 | t.match( 71 | response.json().items[0], 72 | { firstName: 'Hutin', lastName: 'Puylo' }, 73 | 'Listed same' 74 | ); 75 | t.equal(response.json().total, 1, 'There are author now'); 76 | }); 77 | 78 | test('Shutdown API server', async () => { 79 | await bw.fastify.close(); 80 | }); 81 | 82 | test('initialization of API server with limited methods', async t => { 83 | await bw.createServer({ 84 | models: bw.conn.models, 85 | setDefaults: true, 86 | methods: ['list', 'get'] // read-only 87 | }); 88 | 89 | t.strictSame( 90 | bw.fastify.mongooseAPI._methods, 91 | ['list', 'get'], 92 | 'mongooseAPI custom methods loaded' 93 | ); 94 | }); 95 | 96 | test('POST item is invalid', async t => { 97 | const response = await bw.inject( 98 | t, 99 | { 100 | method: 'POST', 101 | url: '/api/authors', 102 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 103 | }, 104 | 404 105 | ); 106 | 107 | t.match( 108 | response.json().message, 109 | 'Route POST:/api/authors not found', 110 | 'POST denied' 111 | ); 112 | }); 113 | 114 | test('GET is valid', async t => { 115 | const response = await bw.inject(t, { 116 | method: 'GET', 117 | url: '/api/authors/' + _id 118 | }); 119 | 120 | t.equal(response.statusCode, 200, 'GET is valid'); 121 | t.equal( 122 | response.headers['content-type'], 123 | 'application/json; charset=utf-8', 124 | 'Content-Type is correct' 125 | ); 126 | 127 | t.match( 128 | response.json(), 129 | { firstName: 'Hutin', lastName: 'Puylo' }, 130 | 'Item found' 131 | ); 132 | }); 133 | 134 | test('LIST is valid', async t => { 135 | const response = await bw.inject(t, { 136 | method: 'GET', 137 | url: '/api/authors' 138 | }); 139 | 140 | t.match( 141 | response.json().items[0], 142 | { firstName: 'Hutin', lastName: 'Puylo' }, 143 | 'Listed same' 144 | ); 145 | t.equal(response.json().total, 1, 'There are author now'); 146 | }); 147 | -------------------------------------------------------------------------------- /test/prefix.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | } 26 | }); 27 | authorSchema.index({ 28 | firstName: 'text', 29 | lastName: 'text', 30 | biography: 'text' 31 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 32 | 33 | bw.conn.model('Author', authorSchema); 34 | 35 | const bookSchema = mongoose.Schema({ 36 | title: String, 37 | isbn: String, 38 | author: { 39 | type: mongoose.Schema.Types.ObjectId, 40 | ref: 'Author' 41 | }, 42 | created: { 43 | type: Date, 44 | default: Date.now 45 | } 46 | }); 47 | 48 | bw.conn.model('Book', bookSchema); 49 | 50 | t.ok(bw.conn.models.Author); 51 | t.ok(bw.conn.models.Book); 52 | }); 53 | 54 | test('clean up test collections', async () => { 55 | await bw.conn.models.Author.deleteMany({}).exec(); 56 | await bw.conn.models.Book.deleteMany({}).exec(); 57 | }); 58 | 59 | test('schema ok', async t => { 60 | let author = new bw.conn.models.Author(); 61 | author.firstName = 'Jay'; 62 | author.lastName = 'Kay'; 63 | author.biography = 'Lived. Died.'; 64 | 65 | await author.save(); 66 | 67 | let book = new bw.conn.models.Book(); 68 | book.title = 'The best book'; 69 | book.isbn = 'The best isbn'; 70 | book.author = author; 71 | 72 | await book.save(); 73 | 74 | let authorFromDb = await bw.conn.models.Author.findOne({ 75 | firstName: 'Jay' 76 | }); 77 | let bookFromDb = await bw.conn.models.Book.findOne({ 78 | title: 'The best book' 79 | }); 80 | 81 | t.ok(authorFromDb); 82 | t.ok(bookFromDb); 83 | 84 | await bw.populateDoc(bookFromDb.populate('author')); 85 | // await bookFromDb.populate('author').execPopulate(); 86 | 87 | t.equal('' + bookFromDb.author._id, '' + authorFromDb._id); 88 | }); 89 | 90 | test('initialization of API server', async t => { 91 | await bw.createServer({ 92 | models: bw.conn.models, 93 | prefix: '/someroute/', 94 | setDefaults: true, 95 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 96 | }); 97 | t.equal( 98 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 99 | 2, 100 | 'There are 2 APIRoutes, one for each model' 101 | ); 102 | 103 | t.equal( 104 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 105 | 'authors', 106 | 'Collection name used in API path' 107 | ); 108 | t.equal( 109 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 110 | 'books', 111 | 'Collection name used in API path' 112 | ); 113 | 114 | t.equal( 115 | bw.fastify.mongooseAPI.apiRouters.Author.path, 116 | '/someroute/authors', 117 | 'API path is composed with prefix + collectionName' 118 | ); 119 | t.equal( 120 | bw.fastify.mongooseAPI.apiRouters.Book.path, 121 | '/someroute/books', 122 | 'API path is composed with prefix + collectionName' 123 | ); 124 | }); 125 | 126 | test('GET collection endpoints', async t => { 127 | let response = null; 128 | response = await bw.inject(t, { 129 | method: 'GET', 130 | url: '/someroute/books' 131 | }); 132 | 133 | t.equal(response.json().total, 1, 'API returns 1 book'); 134 | 135 | response = await bw.inject(t, { 136 | method: 'GET', 137 | url: '/someroute/authors' 138 | }); 139 | 140 | t.equal(response.json().total, 1, 'API returns 1 author'); 141 | }); 142 | -------------------------------------------------------------------------------- /test/default_v_key.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | } 26 | }); 27 | authorSchema.index({ 28 | firstName: 'text', 29 | lastName: 'text', 30 | biography: 'text' 31 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 32 | 33 | bw.conn.model('Author', authorSchema); 34 | 35 | const bookSchema = mongoose.Schema({ 36 | title: String, 37 | isbn: String, 38 | author: { 39 | type: mongoose.Schema.Types.ObjectId, 40 | ref: 'Author' 41 | }, 42 | created: { 43 | type: Date, 44 | default: Date.now 45 | } 46 | }); 47 | 48 | bw.conn.model('Book', bookSchema); 49 | 50 | t.ok(bw.conn.models.Author); 51 | t.ok(bw.conn.models.Book); 52 | }); 53 | 54 | test('clean up test collections', async () => { 55 | await bw.conn.models.Author.deleteMany({}).exec(); 56 | await bw.conn.models.Book.deleteMany({}).exec(); 57 | }); 58 | 59 | test('schema ok', async t => { 60 | let author = new bw.conn.models.Author(); 61 | author.firstName = 'Jay'; 62 | author.lastName = 'Kay'; 63 | author.biography = 'Lived. Died.'; 64 | 65 | await author.save(); 66 | 67 | let book = new bw.conn.models.Book(); 68 | book.title = 'The best book'; 69 | book.isbn = 'The best isbn'; 70 | book.author = author; 71 | 72 | await book.save(); 73 | 74 | let authorFromDb = await bw.conn.models.Author.findOne({ 75 | firstName: 'Jay' 76 | }).exec(); 77 | let bookFromDb = await bw.conn.models.Book.findOne({ 78 | title: 'The best book' 79 | }).exec(); 80 | 81 | t.ok(authorFromDb); 82 | t.ok(bookFromDb); 83 | 84 | await bw.populateDoc(bookFromDb.populate('author')); 85 | 86 | t.equal('' + bookFromDb.author._id, '' + authorFromDb._id); 87 | }); 88 | 89 | test('initialization of API server', async t => { 90 | await bw.createServer({ 91 | models: bw.conn.models, 92 | prefix: '/api/', 93 | setDefaults: true, 94 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 95 | }); 96 | 97 | t.equal( 98 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 99 | 2, 100 | 'There are 2 APIRoutes, one for each model' 101 | ); 102 | 103 | t.equal( 104 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 105 | 'authors', 106 | 'Collection name used in API path' 107 | ); 108 | t.equal( 109 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 110 | 'books', 111 | 'Collection name used in API path' 112 | ); 113 | 114 | t.equal( 115 | bw.fastify.mongooseAPI.apiRouters.Author.path, 116 | '/api/authors', 117 | 'API path is composed with prefix + collectionName' 118 | ); 119 | t.equal( 120 | bw.fastify.mongooseAPI.apiRouters.Book.path, 121 | '/api/books', 122 | 'API path is composed with prefix + collectionName' 123 | ); 124 | }); 125 | 126 | test('GET collection endpoints', async t => { 127 | let response = null; 128 | response = await bw.inject(t, { 129 | method: 'GET', 130 | url: '/api/books' 131 | }); 132 | 133 | t.ok(response.json().items[0].__v !== undefined, 'version key is present'); 134 | t.has( 135 | response.json().items[0], 136 | { __modelName: undefined }, 137 | 'does not have __modelName field' 138 | ); 139 | 140 | response = await bw.inject(t, { 141 | method: 'GET', 142 | url: '/api/authors' 143 | }); 144 | 145 | t.equal(response.json().total, 1, 'API returns 1 author'); 146 | t.equal(response.json().items.length, 1, 'API returns 1 author'); 147 | 148 | t.ok(response.json().items[0].__v !== undefined, 'version key is present'); 149 | t.has( 150 | response.json().items[0], 151 | { __modelName: undefined }, 152 | 'does not have __modelName field' 153 | ); 154 | }); 155 | -------------------------------------------------------------------------------- /src/API.js: -------------------------------------------------------------------------------- 1 | const APIRouter = require('./APIRouter.js'); 2 | const DefaultModelMethods = require('./DefaultModelMethods'); 3 | const { responseSchema404, responseSchema500 } = require('./DefaultSchemas'); 4 | const loadSchemasFromPath = require('./LoadSchemasFromPath'); 5 | 6 | class API { 7 | constructor(params = {}) { 8 | this._models = params.models; 9 | this._fastify = params.fastify; 10 | 11 | if (!this._models || !this._fastify) { 12 | throw 'Please initialize fastify-mongoose-api with fastify.register() with required models parameter'; 13 | } 14 | 15 | this._checkAuth = params.checkAuth || null; 16 | this._defaultModelMethods = 17 | params.defaultModelMethods || DefaultModelMethods; 18 | 19 | this._exposeVersionKey = params.exposeVersionKey; // default = true 20 | if (this._exposeVersionKey === undefined) { 21 | this._exposeVersionKey = true; 22 | } 23 | 24 | this._exposeModelName = params.exposeModelName || false; // default = false 25 | 26 | this._methods = params.methods || [ 27 | 'list', 28 | 'get', 29 | 'post', 30 | 'patch', 31 | 'put', 32 | 'delete' 33 | ]; 34 | 35 | this._apiRouters = {}; 36 | 37 | this._registerReferencedSchemas(); 38 | 39 | this.schemas = params.schemas || []; 40 | if (params.schemaDirPath) { 41 | const schemaPathFilter = 42 | params.schemaPathFilter || 43 | ((pathFile, file) => file.endsWith('.js')); // Default filter 44 | this.schemas = [ 45 | ...this.schemas, 46 | ...loadSchemasFromPath(params.schemaDirPath, schemaPathFilter) 47 | ]; 48 | } 49 | 50 | for (let key of Object.keys(this._models)) { 51 | this.addModel(this._models[key], params); 52 | } 53 | } 54 | 55 | get apiRouters() { 56 | return this._apiRouters; 57 | } 58 | 59 | _registerReferencedSchemas() { 60 | this._fastify.addSchema(responseSchema404); 61 | this._fastify.addSchema(responseSchema500); 62 | } 63 | 64 | addModel(model, params = {}) { 65 | let setDefaults = true; 66 | if (params.setDefaults === false) { 67 | setDefaults = false; 68 | } 69 | 70 | let checkAuth = params.checkAuth ? params.checkAuth : null; 71 | let prefix = params.prefix ? params.prefix : null; 72 | if (model.schema) { 73 | if (setDefaults) { 74 | this.decorateModelWithDefaultAPIMethods(model); 75 | } 76 | 77 | if (model.prototype.apiValues) { 78 | //// if model has defined: 79 | //// schema.virtual('APIValues').get(function () { .... }) 80 | //// then expose it via API 81 | this._apiRouters[model.modelName] = new APIRouter({ 82 | models: this._models, 83 | model: model, 84 | methods: this._methods, 85 | checkAuth: checkAuth, 86 | prefix: prefix, 87 | fastify: this._fastify, 88 | schemas: this.schemas 89 | ? this.schemas.find( 90 | o => 91 | o.name.toLowerCase().replace(/s$/g, '') === 92 | model.prototype.collection.name 93 | .toLowerCase() 94 | .replace(/s$/g, '') 95 | ) 96 | : {} 97 | }); 98 | 99 | model.prototype.__api = this; 100 | } 101 | } 102 | } 103 | 104 | decorateModelWithDefaultAPIMethods(model) { 105 | if (model.schema) { 106 | if (!model.prototype['apiValues']) { 107 | model.prototype['apiValues'] = 108 | this._defaultModelMethods.prototype.apiValues; 109 | } 110 | if (!model.prototype['apiPut']) { 111 | model.prototype['apiPut'] = 112 | this._defaultModelMethods.prototype.apiPut; 113 | } 114 | if (!model.prototype['apiDelete']) { 115 | model.prototype['apiDelete'] = 116 | this._defaultModelMethods.prototype.apiDelete; 117 | } 118 | 119 | if (!model.apiPost) { 120 | model.apiPost = this._defaultModelMethods.apiPost; 121 | } 122 | if (!model.apiCoU) { 123 | model.apiCoU = this._defaultModelMethods.apiCoU; 124 | } 125 | if (!model.apiCoR) { 126 | model.apiCoR = this._defaultModelMethods.apiCoR; 127 | } 128 | if (!model.apiSubRoutes) { 129 | model.apiSubRoutes = this._defaultModelMethods.apiSubRoutes; 130 | } 131 | } 132 | } 133 | } 134 | 135 | module.exports = API; 136 | -------------------------------------------------------------------------------- /test/boolean_fields.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const schema = mongoose.Schema({ 17 | name: String, 18 | aGoodMan: Boolean 19 | }); 20 | 21 | bw.conn.model('BooleanTest', schema); 22 | t.ok(bw.conn.models.BooleanTest); 23 | }); 24 | 25 | test('clean up test collections', async () => { 26 | await bw.conn.models.BooleanTest.deleteMany({}).exec(); 27 | }); 28 | 29 | test('initialization of API server', async t => { 30 | await bw.createServer({ 31 | models: bw.conn.models 32 | }); 33 | 34 | t.equal( 35 | bw.fastify.mongooseAPI.apiRouters.BooleanTest.collectionName, 36 | 'booleantests', 37 | 'Collection name used in API path' 38 | ); 39 | }); 40 | 41 | test('POST item test', async t => { 42 | let response = null; 43 | response = await bw.inject(t, { 44 | method: 'POST', 45 | url: '/api/booleantests', 46 | payload: { name: 'Good', aGoodMan: true } 47 | }); 48 | 49 | t.match(response.json(), { name: 'Good', aGoodMan: true }, 'POST api ok'); 50 | 51 | response = await bw.inject(t, { 52 | method: 'GET', 53 | url: '/api/booleantests' 54 | }); 55 | 56 | t.equal(response.json().total, 1, 'There is one good man'); 57 | }); 58 | 59 | test('POST item false test', async t => { 60 | let response = null; 61 | 62 | response = await bw.inject(t, { 63 | method: 'POST', 64 | url: '/api/booleantests', 65 | body: { name: 'Bad', aGoodMan: false } 66 | }); 67 | 68 | t.match(response.json(), { name: 'Bad', aGoodMan: false }, 'POST api ok'); 69 | 70 | response = await bw.inject(t, { 71 | method: 'GET', 72 | url: '/api/booleantests' 73 | }); 74 | 75 | t.equal(response.json().total, 2, 'There are 2 men'); 76 | }); 77 | 78 | test('Update to false test', async t => { 79 | let response = null; 80 | 81 | response = await bw.inject(t, { 82 | method: 'GET', 83 | url: '/api/booleantests' 84 | }); 85 | 86 | t.equal(response.json().total, 2, 'There re 2 men'); 87 | 88 | let goodId = null; 89 | let foundGood = false; 90 | for (let item of response.json().items) { 91 | if (item.aGoodMan === true) { 92 | goodId = item._id; 93 | foundGood = true; 94 | } 95 | } 96 | 97 | t.ok(foundGood); 98 | 99 | response = await bw.inject(t, { 100 | method: 'PUT', 101 | url: '/api/booleantests/' + goodId, 102 | payload: { aGoodMan: false } 103 | }); 104 | 105 | t.match(response.json(), { name: 'Good', aGoodMan: false }, 'PUT api ok'); 106 | }); 107 | 108 | test('GET collection filtering', async t => { 109 | const response = await bw.inject(t, { 110 | method: 'GET', 111 | url: '/api/booleantests', 112 | query: { filter: 'aGoodMan=0' } 113 | }); 114 | 115 | t.equal(response.json().total, 2, 'API returns 2 filtered men'); 116 | t.equal(response.json().items.length, 2, 'API returns 2 filtered men'); 117 | }); 118 | 119 | test('And back to true', async t => { 120 | let response = null; 121 | 122 | response = await bw.inject(t, { 123 | method: 'GET', 124 | url: '/api/booleantests' 125 | }); 126 | 127 | t.equal(response.json().total, 2, 'There re 2 men'); 128 | 129 | let goodId = null; 130 | let foundGood = false; 131 | for (let item of response.json().items) { 132 | if (item.name === 'Good') { 133 | goodId = item._id; 134 | foundGood = true; 135 | } 136 | } 137 | 138 | t.ok(foundGood); 139 | 140 | response = await bw.inject(t, { 141 | method: 'PUT', 142 | url: '/api/booleantests/' + goodId, 143 | payload: { aGoodMan: true } 144 | }); 145 | 146 | t.match(response.json(), { name: 'Good', aGoodMan: true }, 'PUT api ok'); 147 | }); 148 | 149 | test('GET collection filtering', async t => { 150 | let response = null; 151 | 152 | response = await bw.inject(t, { 153 | method: 'GET', 154 | url: '/api/booleantests', 155 | query: { filter: 'aGoodMan=0' } 156 | }); 157 | 158 | t.equal(response.json().total, 1, 'API returns 1 filtered man'); 159 | t.equal(response.json().items.length, 1, 'API returns 1 filtered man'); 160 | t.match( 161 | response.json().items[0], 162 | { name: 'Bad', aGoodMan: false }, 163 | 'Filtered author' 164 | ); 165 | }); 166 | 167 | test('GET collection filtering', async t => { 168 | const response = await bw.inject(t, { 169 | method: 'GET', 170 | url: '/api/booleantests', 171 | query: { filter: 'aGoodMan=1' } 172 | }); 173 | 174 | t.equal(response.json().total, 1, 'API returns 1 filtered man'); 175 | t.equal(response.json().items.length, 1, 'API returns 1 filtered man'); 176 | t.match( 177 | response.json().items[0], 178 | { name: 'Good', aGoodMan: true }, 179 | 'Filtered author' 180 | ); 181 | }); 182 | -------------------------------------------------------------------------------- /test/complex_where.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const schema = mongoose.Schema({ 17 | name: String, 18 | appleCount: Number, 19 | bananaCount: Number 20 | }); 21 | 22 | bw.conn.model('WhereTest', schema); 23 | t.ok(bw.conn.models.WhereTest); 24 | }); 25 | 26 | test('clean up test collections', async () => { 27 | await bw.conn.models.WhereTest.deleteMany({}).exec(); 28 | }); 29 | 30 | test('initialization of API server', async t => { 31 | await bw.createServer({ 32 | models: bw.conn.models 33 | }); 34 | 35 | t.equal( 36 | bw.fastify.mongooseAPI.apiRouters.WhereTest.collectionName, 37 | 'wheretests', 38 | 'Collection name used in API path' 39 | ); 40 | }); 41 | 42 | const bob = { name: 'Bob', appleCount: 1, bananaCount: 2 }; 43 | const rob = { name: 'Rob', appleCount: 2, bananaCount: 3 }; 44 | const alice = { name: 'Alice', appleCount: 50, bananaCount: 90 }; 45 | 46 | test('POST item test', async t => { 47 | let response = null; 48 | 49 | response = await bw.inject(t, { 50 | method: 'POST', 51 | url: '/api/wheretests', 52 | payload: bob 53 | }); 54 | 55 | t.match(response.json(), bob, 'POST api ok'); 56 | 57 | response = await bw.inject(t, { 58 | method: 'POST', 59 | url: '/api/wheretests', 60 | payload: rob 61 | }); 62 | 63 | t.match(response.json(), rob, 'POST api ok'); 64 | 65 | response = await bw.inject(t, { 66 | method: 'POST', 67 | url: '/api/wheretests', 68 | payload: alice 69 | }); 70 | 71 | t.match(response.json(), alice, 'POST api ok'); 72 | 73 | response = await bw.inject(t, { 74 | method: 'GET', 75 | url: '/api/wheretests' 76 | }); 77 | 78 | t.equal(response.json().total, 3, 'There re 3 banana holders'); 79 | }); 80 | 81 | test('GET collection complex where', async t => { 82 | let response = null; 83 | 84 | response = await bw.inject(t, { 85 | method: 'GET', 86 | url: '/api/wheretests', 87 | query: { where: '{"bananaCount": 2}' } 88 | }); 89 | 90 | t.equal(response.json().total, 1, 'API returns 1 filtered'); 91 | t.equal(response.json().items.length, 1, 'API returns 1 filtered'); 92 | t.match(response.json().items[0], bob, 'Filtered'); 93 | 94 | response = await bw.inject(t, { 95 | method: 'GET', 96 | url: '/api/wheretests', 97 | query: { where: JSON.stringify({ appleCount: { $gt: 10 } }) } 98 | }); 99 | 100 | t.equal(response.json().total, 1, 'API returns 1 filtered'); 101 | t.equal(response.json().items.length, 1, 'API returns 1 filtered'); 102 | t.match(response.json().items[0], alice, 'Filtered'); 103 | 104 | response = await bw.inject(t, { 105 | method: 'GET', 106 | url: '/api/wheretests', 107 | query: { 108 | where: JSON.stringify({ 109 | $and: [{ appleCount: { $gt: 1 } }, { bananaCount: { $lt: 5 } }] 110 | }) 111 | } 112 | }); 113 | 114 | t.equal(response.json().total, 1, 'API returns 1 filtered'); 115 | t.equal(response.json().items.length, 1, 'API returns 1 filtered'); 116 | t.match(response.json().items[0], rob, 'Filtered'); 117 | 118 | // invalid where 119 | response = await bw.inject( 120 | t, 121 | { 122 | method: 'GET', 123 | url: '/api/wheretests', 124 | query: { 125 | where: JSON.stringify({ 126 | name: { $nonvalid: false } 127 | }) 128 | } 129 | }, 130 | 500 131 | ); 132 | 133 | // $regex 134 | response = await bw.inject(t, { 135 | method: 'GET', 136 | url: '/api/wheretests', 137 | query: { 138 | where: JSON.stringify({ 139 | name: { $regex: '^A' } 140 | }) 141 | } 142 | }); 143 | 144 | t.equal(response.json().total, 1, 'API returns 1 filtered'); 145 | t.equal(response.json().items.length, 1, 'API returns 1 filtered'); 146 | t.match(response.json().items[0], alice, 'Filtered'); 147 | 148 | response = await bw.inject(t, { 149 | method: 'GET', 150 | url: '/api/wheretests', 151 | query: { 152 | where: JSON.stringify({ 153 | name: { $regex: '^a' } 154 | }) 155 | } 156 | }); 157 | 158 | t.equal(response.json().total, 0, 'API returns 0 filtered'); 159 | 160 | response = await bw.inject(t, { 161 | method: 'GET', 162 | url: '/api/wheretests', 163 | query: { 164 | where: JSON.stringify({ 165 | name: { $regex: '^a', $options: 'i' } 166 | }) 167 | } 168 | }); 169 | 170 | t.equal(response.json().total, 1, 'API returns 1 filtered'); 171 | t.equal(response.json().items.length, 1, 'API returns 1 filtered'); 172 | t.match(response.json().items[0], alice, 'Filtered'); 173 | }); 174 | -------------------------------------------------------------------------------- /test/disable_route.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const schema = mongoose.Schema({ 17 | name: String, 18 | appleCount: Number, 19 | bananaCount: Number, 20 | fieldAvailableIfYouAskGood: { type: Number, default: 999 } 21 | }); 22 | 23 | schema.methods.apiValues = function (request) { 24 | if (!request.headers['givememoredataplease']) { 25 | return { 26 | name: this.name, 27 | appleCount: this.appleCount, 28 | bananaCount: this.bananaCount 29 | }; 30 | } 31 | 32 | // or return this.toObject(); 33 | // 34 | 35 | return { 36 | name: this.name, 37 | appleCount: this.appleCount, 38 | bananaCount: this.bananaCount, 39 | fieldAvailableIfYouAskGood: this.fieldAvailableIfYouAskGood 40 | }; 41 | }; 42 | 43 | schema.methods.apiPut = async function () { 44 | // disable the Put completely 45 | throw new Error('PUT is disabled for this route'); 46 | }; 47 | 48 | schema.statics.apiPost = async function (data, request) { 49 | // lets POST only with specific header 50 | // possible option is to check user rights here 51 | // if (!request.user.hasRightToPost()) { 52 | // throw new Error('Lol, you cant'); 53 | // } 54 | if (!request.headers['letmepostplease']) { 55 | throw new Error('POST is disabled for you!'); 56 | } 57 | 58 | let doc = new bw.conn.models.WhereTest(); 59 | 60 | bw.conn.models.WhereTest.schema.eachPath(pathname => { 61 | if (data[pathname] !== undefined) { 62 | doc[pathname] = data[pathname]; 63 | } 64 | }); 65 | 66 | await doc.save(); 67 | return doc; 68 | }; 69 | 70 | schema.methods.apiDelete = async function () { 71 | // disable the Put completely 72 | throw new Error('DELETE is disabled for this route'); 73 | }; 74 | 75 | bw.conn.model('WhereTest', schema); 76 | t.ok(bw.conn.models.WhereTest); 77 | }); 78 | 79 | test('clean up test collections', async () => { 80 | await bw.conn.models.WhereTest.deleteMany({}).exec(); 81 | }); 82 | 83 | test('initialization of API server', async t => { 84 | await bw.createServer({ 85 | models: bw.conn.models 86 | }); 87 | 88 | t.equal( 89 | bw.fastify.mongooseAPI.apiRouters.WhereTest.collectionName, 90 | 'wheretests', 91 | 'Collection name used in API path' 92 | ); 93 | }); 94 | 95 | test('Disabled POST item test', async t => { 96 | let response = null; 97 | response = await bw.inject( 98 | t, 99 | { 100 | method: 'POST', 101 | url: '/api/wheretests', 102 | payload: { name: 'Bob', appleCount: 1, bananaCount: 2 } 103 | }, 104 | 500 105 | ); 106 | 107 | t.equal( 108 | response.statusCode, 109 | 500, 110 | "doesn't let you post without extra header" 111 | ); 112 | 113 | response = await bw.inject(t, { 114 | method: 'POST', 115 | url: '/api/wheretests', 116 | headers: { letmepostplease: 'pleaaaaase' }, 117 | payload: { name: 'Bob', appleCount: 1, bananaCount: 2 } 118 | }); 119 | 120 | t.match( 121 | response.json(), 122 | { name: 'Bob', appleCount: 1, bananaCount: 2 }, 123 | 'POST with header ok' 124 | ); 125 | t.has( 126 | response.json(), 127 | { fieldAvailableIfYouAskGood: undefined }, 128 | 'does not have fieldAvailableIfYouAskGood field by default' 129 | ); 130 | }); 131 | 132 | test('Has Extra field in response by apiValues', async t => { 133 | const response = await bw.inject(t, { 134 | method: 'POST', 135 | url: '/api/wheretests', 136 | headers: { 137 | letmepostplease: 'pleaaaaase', 138 | givememoredataplease: 'pleaaaaase' 139 | }, 140 | payload: { name: 'Bob', appleCount: 1, bananaCount: 2 } 141 | }); 142 | 143 | t.match( 144 | response.json(), 145 | { name: 'Bob', appleCount: 1, bananaCount: 2 }, 146 | 'POST with header ok' 147 | ); 148 | 149 | t.ok( 150 | response.json().fieldAvailableIfYouAskGood !== undefined, 151 | 'fieldAvailableIfYouAskGood is present' 152 | ); 153 | }); 154 | 155 | test('Disabled PUT test', async t => { 156 | let itemFromDb = await bw.conn.models.WhereTest.findOne({}); 157 | 158 | await bw.inject( 159 | t, 160 | { 161 | method: 'PUT', 162 | url: '/api/wheretests/' + itemFromDb.id, 163 | payload: { name: 'Bob22', appleCount: 21, bananaCount: 22 } 164 | }, 165 | 500 166 | ); 167 | }); 168 | 169 | test('Disabled DELETE test', async t => { 170 | let itemFromDb = await bw.conn.models.WhereTest.findOne({}); 171 | 172 | await bw.inject( 173 | t, 174 | { 175 | method: 'DELETE', 176 | url: '/api/wheretests/' + itemFromDb.id 177 | }, 178 | 500 179 | ); 180 | }); 181 | -------------------------------------------------------------------------------- /test/model_name_and_hide_vkey.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | } 26 | }); 27 | authorSchema.index({ 28 | firstName: 'text', 29 | lastName: 'text', 30 | biography: 'text' 31 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 32 | 33 | bw.conn.model('Author', authorSchema); 34 | 35 | const bookSchema = mongoose.Schema({ 36 | title: String, 37 | isbn: String, 38 | author: { 39 | type: mongoose.Schema.Types.ObjectId, 40 | ref: 'Author' 41 | }, 42 | created: { 43 | type: Date, 44 | default: Date.now 45 | } 46 | }); 47 | 48 | bw.conn.model('Book', bookSchema); 49 | 50 | t.ok(bw.conn.models.Author); 51 | t.ok(bw.conn.models.Book); 52 | }); 53 | 54 | test('clean up test collections', async () => { 55 | await bw.conn.models.Author.deleteMany({}).exec(); 56 | await bw.conn.models.Book.deleteMany({}).exec(); 57 | }); 58 | 59 | test('schema ok', async t => { 60 | let author = new bw.conn.models.Author(); 61 | author.firstName = 'Jay'; 62 | author.lastName = 'Kay'; 63 | author.biography = 'Lived. Died.'; 64 | 65 | await author.save(); 66 | 67 | let book = new bw.conn.models.Book(); 68 | book.title = 'The best book'; 69 | book.isbn = 'The best isbn'; 70 | book.author = author; 71 | 72 | await book.save(); 73 | 74 | let authorFromDb = await bw.conn.models.Author.findOne({ 75 | firstName: 'Jay' 76 | }).exec(); 77 | let bookFromDb = await bw.conn.models.Book.findOne({ 78 | title: 'The best book' 79 | }).exec(); 80 | 81 | t.ok(authorFromDb); 82 | t.ok(bookFromDb); 83 | 84 | await bw.populateDoc(bookFromDb.populate('author')); 85 | 86 | t.equal('' + bookFromDb.author._id, '' + authorFromDb._id); 87 | }); 88 | 89 | test('initialization of API server', async t => { 90 | await bw.createServer({ 91 | models: bw.conn.models, 92 | exposeVersionKey: false, 93 | exposeModelName: true, 94 | prefix: '/api/', 95 | setDefaults: true, 96 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 97 | }); 98 | 99 | t.equal( 100 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 101 | 2, 102 | 'There are 2 APIRoutes, one for each model' 103 | ); 104 | 105 | t.equal( 106 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 107 | 'authors', 108 | 'Collection name used in API path' 109 | ); 110 | t.equal( 111 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 112 | 'books', 113 | 'Collection name used in API path' 114 | ); 115 | 116 | t.equal( 117 | bw.fastify.mongooseAPI.apiRouters.Author.path, 118 | '/api/authors', 119 | 'API path is composed with prefix + collectionName' 120 | ); 121 | t.equal( 122 | bw.fastify.mongooseAPI.apiRouters.Book.path, 123 | '/api/books', 124 | 'API path is composed with prefix + collectionName' 125 | ); 126 | }); 127 | 128 | test('GET collection endpoints', async t => { 129 | let response = null; 130 | response = await bw.inject(t, { 131 | method: 'GET', 132 | url: '/api/books' 133 | }); 134 | 135 | t.equal(response.json().total, 1, 'API returns 1 book'); 136 | t.equal( 137 | response.json().items[0].__modelName, 138 | 'Book', 139 | 'Model name is present in response' 140 | ); 141 | 142 | t.has( 143 | response.json().items[0], 144 | { __v: undefined }, 145 | 'does not have version field' 146 | ); 147 | 148 | response = await bw.inject(t, { 149 | method: 'GET', 150 | url: '/api/authors' 151 | }); 152 | 153 | t.equal(response.json().total, 1, 'API returns 1 author'); 154 | t.equal(response.json().items.length, 1, 'API returns 1 author'); 155 | t.equal( 156 | response.json().items[0].__modelName, 157 | 'Author', 158 | 'Model name is present in response' 159 | ); 160 | 161 | t.has( 162 | response.json().items[0], 163 | { __v: undefined }, 164 | 'does not have version field' 165 | ); 166 | }); 167 | 168 | test('ModelName on populated', async t => { 169 | let bookFromDb = await bw.conn.models.Book.findOne({ 170 | title: 'The best book' 171 | }); 172 | // await bookFromDb.populate('author').execPopulate(); 173 | await bw.populateDoc(bookFromDb.populate('author')); 174 | 175 | const response = await bw.inject(t, { 176 | method: 'GET', 177 | url: '/api/books/' + bookFromDb.id + '?populate=author' 178 | }); 179 | 180 | t.match( 181 | response.json(), 182 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 183 | 'Book is ok' 184 | ); 185 | t.equal( 186 | response.json().__modelName, 187 | 'Book', 188 | 'Model name is present in response' 189 | ); 190 | t.match( 191 | response.json().author, 192 | { 193 | firstName: bookFromDb.author.firstName, 194 | lastName: bookFromDb.author.lastName 195 | }, 196 | 'Populated author is ok' 197 | ); 198 | t.match( 199 | response.json().author, 200 | { _id: '' + bookFromDb.author.id }, 201 | 'Populated author id is ok' 202 | ); 203 | t.equal( 204 | response.json().author.__modelName, 205 | 'Author', 206 | 'Model name is present in response on populated objects' 207 | ); 208 | 209 | t.has( 210 | response.json().author, 211 | { __v: undefined }, 212 | 'populated does not have version field' 213 | ); 214 | }); 215 | -------------------------------------------------------------------------------- /test/schemas_path_filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const fs = require('fs'); 7 | const os = require('os'); 8 | const path = require('path'); 9 | 10 | const mongoose = require('mongoose'); 11 | const BackwardWrapper = require('./BackwardWrapper.js'); 12 | 13 | const bw = new BackwardWrapper(t); 14 | 15 | const schema_authors = { 16 | name: 'authors', 17 | schema: { 18 | firstName: 'String', 19 | lastName: 'String', 20 | birthday: 'Date' 21 | }, 22 | ref: [ 23 | { 24 | $id: 'authors', 25 | title: 'Authors - Model', 26 | properties: { 27 | firstName: { type: 'string' }, 28 | lastName: { type: 'string' }, 29 | birthday: { 30 | type: 'string', 31 | format: 'date' 32 | } 33 | }, 34 | required: ['firstName', 'lastName'] 35 | } 36 | ], 37 | routeGet: { 38 | response: { 39 | 200: { 40 | $ref: 'authors#' 41 | } 42 | } 43 | }, 44 | routePost: { 45 | body: { 46 | $ref: 'authors#' 47 | }, 48 | response: { 49 | 200: { 50 | $ref: 'authors#' 51 | } 52 | } 53 | } 54 | }; 55 | 56 | const schema_books = { 57 | name: 'books', 58 | schema: { 59 | title: 'String', 60 | isbn: 'String', 61 | created: { 62 | type: 'Date', 63 | default: Date.now 64 | } 65 | }, 66 | ref: [ 67 | { 68 | $id: 'books', 69 | title: 'Books - Model', 70 | properties: { 71 | title: { type: 'string' }, 72 | isbn: { type: 'string' }, 73 | created: { 74 | type: 'string', 75 | format: 'date' 76 | } 77 | }, 78 | required: ['title', 'isbn'] 79 | } 80 | ], 81 | routeGet: { 82 | response: { 83 | 200: { 84 | $ref: 'books#' 85 | } 86 | } 87 | }, 88 | routePost: { 89 | body: { 90 | $ref: 'books#' 91 | }, 92 | response: { 93 | 200: { 94 | $ref: 'books#' 95 | } 96 | } 97 | } 98 | }; 99 | 100 | const createSchemasInTmpPath = () => { 101 | const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), 'mfas-')); 102 | // authors.schema.js 103 | fs.writeFileSync( 104 | path.join(tmpPath, 'authors.schema.js'), 105 | 'module.exports=' + JSON.stringify(schema_authors) 106 | ); 107 | // books.schema.js.disabled 108 | fs.mkdirSync(path.join(tmpPath, 'subdir')); 109 | fs.writeFileSync( 110 | path.join(tmpPath, 'books.schema.js.disabled'), 111 | 'module.exports=' + JSON.stringify(schema_books) 112 | ); 113 | return tmpPath; 114 | }; 115 | 116 | const tmpPath = createSchemasInTmpPath(); 117 | 118 | test('valid schemaDir', async t => { 119 | t.not(tmpPath, undefined, 'schemaDir is valid'); 120 | }); 121 | 122 | test('mongoose db initialization', async () => { 123 | await bw.createConnection(); 124 | }); 125 | 126 | test('schema initialization', async t => { 127 | const authorSchema = mongoose.Schema(schema_authors.schema); 128 | bw.conn.model('Author', authorSchema); 129 | 130 | const bookSchema = mongoose.Schema(schema_books.schema); 131 | bw.conn.model('Book', bookSchema); 132 | 133 | t.ok(bw.conn.models.Author); 134 | t.ok(bw.conn.models.Book); 135 | }); 136 | 137 | test('clean up test collections', async () => { 138 | await bw.conn.models.Author.deleteMany({}).exec(); 139 | await bw.conn.models.Book.deleteMany({}).exec(); 140 | }); 141 | 142 | test('initialization of default API server', async () => { 143 | await bw.createServer({ 144 | models: bw.conn.models, 145 | setDefaults: true, 146 | schemaDirPath: tmpPath 147 | }); 148 | }); 149 | 150 | test('author schema exists', async t => { 151 | const response = await bw.inject( 152 | t, 153 | { 154 | method: 'POST', 155 | url: '/api/authors', 156 | payload: { firstName: 'Hutin' } 157 | }, 158 | 400 159 | ); 160 | 161 | t.match( 162 | response.json().message, 163 | "body must have required property 'lastName'", 164 | 'POST failed if required parameters not set' 165 | ); 166 | }); 167 | 168 | test('book schema does not exist (POST without ISBN)', async t => { 169 | await bw.inject(t, { 170 | method: 'POST', 171 | url: '/api/books', 172 | payload: { title: 'Critique of Practical Reason' } 173 | }); 174 | }); 175 | 176 | test('Shutdown API server', async () => { 177 | await bw.fastify.close(); 178 | }); 179 | 180 | test('reload API server changing default filter to include disabled schema', async () => { 181 | await bw.createServer({ 182 | models: bw.conn.models, 183 | setDefaults: true, 184 | schemaDirPath: tmpPath, 185 | schemaPathFilter: (filePath, fileName) => { 186 | // Filter to include .schema.js and .schema.js.disabled files 187 | return ( 188 | fileName.endsWith('.js') || fileName.endsWith('.js.disabled') 189 | ); 190 | } 191 | }); 192 | }); 193 | 194 | test('author schema exists too', async t => { 195 | const response = await bw.inject( 196 | t, 197 | { 198 | method: 'POST', 199 | url: '/api/authors', 200 | payload: { firstName: 'Hutin' } 201 | }, 202 | 400 203 | ); 204 | 205 | t.match( 206 | response.json().message, 207 | "body must have required property 'lastName'", 208 | 'POST failed if required parameters not set' 209 | ); 210 | }); 211 | 212 | test('book schema exists (POST without ISBN is not valid)', async t => { 213 | const response = await bw.inject( 214 | t, 215 | { 216 | method: 'POST', 217 | url: '/api/books', 218 | payload: { title: 'Critique of Practical Reason' } 219 | }, 220 | 400 221 | ); 222 | 223 | t.match( 224 | response.json().message, 225 | "body must have required property 'isbn'", 226 | 'POST failed if required parameters not set' 227 | ); 228 | }); 229 | 230 | test('teardown', async () => { 231 | fs.unlinkSync(path.join(tmpPath, 'authors.schema.js')); 232 | fs.unlinkSync(path.join(tmpPath, 'books.schema.js.disabled')); 233 | fs.rmdirSync(path.join(tmpPath, 'subdir')); 234 | fs.rmdirSync(tmpPath); 235 | }); 236 | -------------------------------------------------------------------------------- /src/DefaultSchemas.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const defaultSchemas = modelName => { 4 | return { 5 | routeGet: { 6 | summary: 'Get details of single ' + modelName, 7 | tags: [modelName], 8 | params: { 9 | type: 'object', 10 | properties: { 11 | id: { 12 | type: 'string', 13 | description: 'Unique identifier of ' + modelName 14 | } 15 | } 16 | }, 17 | querystring: { 18 | type: 'object', 19 | properties: { 20 | populate: { 21 | type: 'string', 22 | description: 'Population options of mongoose' 23 | } 24 | } 25 | }, 26 | response: { 27 | 404: { $ref: 'MongooseApiDefErrRespSchemas404#' }, 28 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 29 | } 30 | }, 31 | routePost: { 32 | summary: 'Create new ' + modelName, 33 | tags: [modelName], 34 | querystring: { 35 | type: 'object', 36 | properties: { 37 | populate: { 38 | type: 'string', 39 | description: 'Population options of mongoose' 40 | } 41 | } 42 | }, 43 | response: { 44 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 45 | } 46 | }, 47 | routeList: { 48 | summary: 'List ' + modelName, 49 | tags: [modelName], 50 | querystring: { 51 | type: 'object', 52 | properties: { 53 | offset: { 54 | type: 'number', 55 | description: 'Mongoose offset property' 56 | }, 57 | limit: { 58 | type: 'number', 59 | description: 'Mongoose limit property' 60 | }, 61 | sort: { 62 | type: 'string', 63 | description: 'Sort options of mongoose' 64 | }, 65 | filter: { 66 | type: 'string', 67 | description: 'Simple filtering by field value' 68 | }, 69 | where: { 70 | type: 'string', 71 | description: 'Mongoose where object' 72 | }, 73 | match: { 74 | type: 'string', 75 | description: 'Use it for pattern matching' 76 | }, 77 | search: { 78 | type: 'string', 79 | description: 80 | 'Performs search by full text mongodb indexes' 81 | }, 82 | projection: { 83 | type: 'string', 84 | description: 'Projection options of mongoose' 85 | }, 86 | populate: { 87 | type: 'string', 88 | description: 'Population options of mongoose' 89 | } 90 | } 91 | }, 92 | response: { 93 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 94 | } 95 | }, 96 | routePut: { 97 | summary: 'Replace existing ' + modelName, 98 | tags: [modelName], 99 | params: { 100 | type: 'object', 101 | properties: { 102 | id: { 103 | type: 'string', 104 | description: 'Unique identifier of ' + modelName 105 | } 106 | } 107 | }, 108 | querystring: { 109 | type: 'object', 110 | properties: { 111 | populate: { 112 | type: 'string', 113 | description: 'Population options of mongoose' 114 | } 115 | } 116 | }, 117 | response: { 118 | 404: { $ref: 'MongooseApiDefErrRespSchemas404#' }, 119 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 120 | } 121 | }, 122 | routePatch: { 123 | summary: 'Update existing ' + modelName, 124 | tags: [modelName], 125 | params: { 126 | type: 'object', 127 | properties: { 128 | id: { 129 | type: 'string', 130 | description: 'Unique identifier of ' + modelName 131 | } 132 | } 133 | }, 134 | querystring: { 135 | type: 'object', 136 | properties: { 137 | populate: { 138 | type: 'string', 139 | description: 'Population options of mongoose' 140 | } 141 | } 142 | }, 143 | response: { 144 | 404: { $ref: 'MongooseApiDefErrRespSchemas404#' }, 145 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 146 | } 147 | }, 148 | routeDelete: { 149 | summary: 'Delete existing ' + modelName, 150 | tags: [modelName], 151 | params: { 152 | type: 'object', 153 | properties: { 154 | id: { 155 | type: 'string', 156 | description: 'Unique identifier of ' + modelName 157 | } 158 | } 159 | }, 160 | response: { 161 | 200: { 162 | description: 'Success', 163 | type: 'object', 164 | properties: { 165 | acknowledged: { type: 'boolean' }, 166 | deletedCount: { type: 'number' } 167 | } 168 | }, 169 | 404: { $ref: 'MongooseApiDefErrRespSchemas404#' }, 170 | 500: { $ref: 'MongooseApiDefErrRespSchemas500#' } 171 | } 172 | } 173 | }; 174 | }; 175 | 176 | const responseSchema404 = { 177 | $id: 'MongooseApiDefErrRespSchemas404', 178 | description: 'Not found', 179 | type: 'object', 180 | properties: { 181 | error: { type: 'string', example: 'Route Not Found' }, 182 | message: { type: 'string', example: 'Not Found' }, 183 | statusCode: { type: 'integer', example: 404 } 184 | } 185 | }; 186 | 187 | const responseSchema500 = { 188 | $id: 'MongooseApiDefErrRespSchemas500', 189 | description: 'Server error', 190 | type: 'object', 191 | properties: { 192 | error: { type: 'string' }, 193 | message: { type: 'string' }, 194 | statusCode: { type: 'integer', example: 500 } 195 | } 196 | }; 197 | 198 | module.exports = { defaultSchemas, responseSchema404, responseSchema500 }; 199 | -------------------------------------------------------------------------------- /test/refs_in_array.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | }, 26 | inspired: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Book' }] // store books author was inpired by 27 | }); 28 | authorSchema.index({ 29 | firstName: 'text', 30 | lastName: 'text', 31 | biography: 'text' 32 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 33 | 34 | bw.conn.model('Author', authorSchema); 35 | 36 | const bookSchema = mongoose.Schema({ 37 | title: String, 38 | isbn: String, 39 | created: { 40 | type: Date, 41 | default: Date.now 42 | } 43 | }); 44 | 45 | // we defined apiValues response change to check if it works for refs response 46 | bookSchema.methods.apiValues = function () { 47 | const object = this.toObject({ depopulate: true }); 48 | object.isbn = 'hidden'; 49 | 50 | return object; 51 | }; 52 | bw.conn.model('Book', bookSchema); 53 | 54 | t.ok(bw.conn.models.Author); 55 | t.ok(bw.conn.models.Book); 56 | }); 57 | 58 | test('clean up test collections', async () => { 59 | await bw.conn.models.Author.deleteMany({}).exec(); 60 | await bw.conn.models.Book.deleteMany({}).exec(); 61 | }); 62 | 63 | test('schema ok', async t => { 64 | let book = new bw.conn.models.Book(); 65 | book.title = 'The best book'; 66 | book.isbn = 'The best isbn'; 67 | 68 | await book.save(); 69 | 70 | let book2 = new bw.conn.models.Book(); 71 | book2.title = 'The best book2'; 72 | book2.isbn = 'The best isbn2'; 73 | 74 | await book2.save(); 75 | 76 | let author = new bw.conn.models.Author(); 77 | author.firstName = 'Jay'; 78 | author.lastName = 'Kay'; 79 | author.biography = 'Lived. Died.'; 80 | author.inspired = [book, book2]; 81 | 82 | await author.save(); 83 | 84 | let authorFromDb = await bw.conn.models.Author.findOne({ 85 | firstName: 'Jay' 86 | }).exec(); 87 | let bookFromDb = await bw.conn.models.Book.findOne({ 88 | title: 'The best book' 89 | }).exec(); 90 | let book2FromDb = await bw.conn.models.Book.findOne({ 91 | title: 'The best book2' 92 | }).exec(); 93 | 94 | t.ok(authorFromDb); 95 | t.ok(bookFromDb); 96 | t.ok(book2FromDb); 97 | 98 | await bw.populateDoc(authorFromDb.populate('inspired')); 99 | 100 | t.equal('' + authorFromDb.inspired[0]._id, '' + book._id); 101 | t.equal('' + authorFromDb.inspired[1]._id, '' + book2._id); 102 | }); 103 | 104 | test('initialization of API server', async t => { 105 | await bw.createServer({ 106 | models: bw.conn.models, 107 | prefix: '/api/', 108 | setDefaults: true, 109 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 110 | }); 111 | t.equal( 112 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 113 | 2, 114 | 'There are 2 APIRoutes, one for each model' 115 | ); 116 | 117 | t.equal( 118 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 119 | 'authors', 120 | 'Collection name used in API path' 121 | ); 122 | t.equal( 123 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 124 | 'books', 125 | 'Collection name used in API path' 126 | ); 127 | 128 | t.equal( 129 | bw.fastify.mongooseAPI.apiRouters.Author.path, 130 | '/api/authors', 131 | 'API path is composed with prefix + collectionName' 132 | ); 133 | t.equal( 134 | bw.fastify.mongooseAPI.apiRouters.Book.path, 135 | '/api/books', 136 | 'API path is composed with prefix + collectionName' 137 | ); 138 | }); 139 | 140 | test('GET collection endpoints', async t => { 141 | let response = null; 142 | response = await bw.inject(t, { 143 | method: 'GET', 144 | url: '/api/books' 145 | }); 146 | 147 | t.equal(response.json().total, 2, 'API returns 2 books'); 148 | t.equal( 149 | response.json().items[0].isbn, 150 | 'hidden', 151 | 'apiValues model method works' 152 | ); 153 | t.equal( 154 | response.json().items[1].isbn, 155 | 'hidden', 156 | 'apiValues model method works' 157 | ); 158 | 159 | response = await bw.inject(t, { 160 | method: 'GET', 161 | url: '/api/authors' 162 | }); 163 | 164 | t.equal(response.json().total, 1, 'API returns 1 author'); 165 | t.equal(response.json().items.length, 1, 'API returns 1 author'); 166 | }); 167 | 168 | test('GET single item array Refs', async t => { 169 | let authorFromDb = await bw.conn.models.Author.findOne({ 170 | firstName: 'Jay' 171 | }).exec(); 172 | let bookFromDb = await bw.conn.models.Book.findOne({ 173 | title: 'The best book' 174 | }).exec(); 175 | let book2FromDb = await bw.conn.models.Book.findOne({ 176 | title: 'The best book2' 177 | }).exec(); 178 | await bw.populateDoc(authorFromDb.populate('inspired')); 179 | 180 | let response = null; 181 | 182 | response = await bw.inject(t, { 183 | method: 'GET', 184 | url: '/api/books/' + bookFromDb._id + '/authors' 185 | }); 186 | 187 | t.equal( 188 | response.json().total, 189 | 1, 190 | 'API returns 1 refed author, inspired by this book' 191 | ); 192 | t.equal(response.json().items.length, 1, 'API returns 1 refed author'); 193 | t.match(response.json().items[0], { firstName: 'Jay' }, 'Refed author'); 194 | 195 | response = await bw.inject(t, { 196 | method: 'GET', 197 | url: '/api/books/' + book2FromDb._id + '/authors' 198 | }); 199 | 200 | t.equal( 201 | response.json().total, 202 | 1, 203 | 'API returns 1 refed author, inspired by this book' 204 | ); 205 | t.equal(response.json().items.length, 1, 'API returns 1 refed author'); 206 | t.match(response.json().items[0], { firstName: 'Jay' }, 'Refed author'); 207 | }); 208 | 209 | test('GET single item with populated array field', async t => { 210 | let authorFromDb = await bw.conn.models.Author.findOne({ 211 | firstName: 'Jay' 212 | }).exec(); 213 | await bw.populateDoc(authorFromDb.populate('inspired')); 214 | 215 | const response = await bw.inject(t, { 216 | method: 'GET', 217 | url: '/api/authors/' + authorFromDb.id + '?populate=inspired' 218 | }); 219 | 220 | t.match(response.json(), { firstName: 'Jay' }, 'Single item data ok'); 221 | t.equal(response.json().inspired.length, 2, '2 items in ref array'); 222 | 223 | t.equal( 224 | response.json().inspired[0].isbn, 225 | 'hidden', 226 | 'apiValues model method works' 227 | ); 228 | t.equal( 229 | response.json().inspired[1].isbn, 230 | 'hidden', 231 | 'apiValues model method works' 232 | ); 233 | }); 234 | -------------------------------------------------------------------------------- /test/create_or_update.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | const authorSchema = mongoose.Schema({ 17 | firstName: String, 18 | lastName: String, 19 | biography: String 20 | }); 21 | 22 | const productSchema = mongoose.Schema( 23 | { 24 | _id: { type: String, required: true }, 25 | price: { type: Number, required: true } 26 | }, 27 | { 28 | timestamps: true 29 | } 30 | ); 31 | 32 | bw.conn.model('Author', authorSchema); 33 | bw.conn.model('Product', productSchema); 34 | 35 | t.ok(bw.conn.models.Author); 36 | t.ok(bw.conn.models.Product); 37 | }); 38 | 39 | test('clean up test collections', async () => { 40 | await bw.conn.models.Author.deleteMany({}).exec(); 41 | await bw.conn.models.Product.deleteMany({}).exec(); 42 | }); 43 | 44 | test('initialization of API server', async t => { 45 | await bw.createServer({ 46 | models: bw.conn.models, 47 | prefix: '/api/', 48 | setDefaults: true, 49 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 50 | }); 51 | t.equal( 52 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 53 | 2, 54 | 'There are 2 APIRoutes' 55 | ); 56 | 57 | t.equal( 58 | bw.fastify.mongooseAPI.apiRouters.Product.collectionName, 59 | 'products', 60 | 'Collection name used in API path' 61 | ); 62 | 63 | t.equal( 64 | bw.fastify.mongooseAPI.apiRouters.Product.path, 65 | '/api/products', 66 | 'API path is composed with prefix + collectionName' 67 | ); 68 | }); 69 | test('POST product test in std mode', async t => { 70 | let response = await bw.inject(t, { 71 | method: 'POST', 72 | url: '/api/products', 73 | payload: { 74 | _id: 'item 1', 75 | price: 10 76 | } 77 | }); 78 | 79 | t.match(response.json(), { _id: 'item 1', price: 10 }, 'POST api ok'); 80 | 81 | response = await bw.inject( 82 | t, 83 | { 84 | method: 'POST', 85 | url: '/api/products', 86 | payload: { 87 | _id: 'item 1', 88 | price: 20 89 | } 90 | }, 91 | 500 92 | ); 93 | t.match( 94 | response.json().message, 95 | /duplicate key error/, 96 | 'POST api conflict ok if CoU not set' 97 | ); 98 | }); 99 | 100 | test('POST products with CoU test', async t => { 101 | let response = await bw.inject(t, { 102 | method: 'POST', 103 | url: '/api/products', 104 | payload: { 105 | _id: 'item 1', 106 | price: 20 107 | }, 108 | headers: { 'x-http-method': 'cou' } 109 | }); 110 | t.match( 111 | response.json(), 112 | { _id: 'item 1', price: 20 }, 113 | 'POST api ok with CoU via header' 114 | ); 115 | 116 | response = await bw.inject(t, { 117 | method: 'POST', 118 | url: '/api/products', 119 | payload: { 120 | _id: 'item 1', 121 | price: 30 122 | }, 123 | headers: { 'X-HTTP-Method': 'CoU' } 124 | }); 125 | t.match( 126 | response.json(), 127 | { _id: 'item 1', price: 30 }, 128 | 'post api ok with cou via header case insensitive' 129 | ); 130 | 131 | response = await bw.inject(t, { 132 | method: 'GET', 133 | url: '/api/products/item 1' 134 | }); 135 | t.match( 136 | response.json(), 137 | { _id: 'item 1', price: 30 }, 138 | 'Record correctly updated' 139 | ); 140 | 141 | response = await bw.inject(t, { 142 | method: 'POST', 143 | url: '/api/products', 144 | payload: { 145 | _id: 'item 2', 146 | price: 69 147 | }, 148 | headers: { 'X-HTTP-Method': 'CoU' } 149 | }); 150 | t.match( 151 | response.json(), 152 | { _id: 'item 2', price: 69 }, 153 | 'new record standard saved also if CoU is set' 154 | ); 155 | 156 | response = await bw.inject(t, { 157 | method: 'GET', 158 | url: '/api/products' 159 | }); 160 | t.equal(response.json().total, 2, 'API returns 2 products'); 161 | t.equal(response.json().items.length, 2, 'API returns 2 products'); 162 | t.match( 163 | response.json().items[0], 164 | { _id: 'item 1', price: 30 }, 165 | 'Listed same' 166 | ); 167 | t.match( 168 | response.json().items[1], 169 | { _id: 'item 2', price: 69 }, 170 | 'Listed same' 171 | ); 172 | }); 173 | 174 | test('POST authors with CoU mode', async t => { 175 | let response = null; 176 | response = await bw.inject(t, { 177 | method: 'POST', 178 | url: '/api/authors', 179 | payload: { 180 | firstName: 'John', 181 | lastName: 'Doe' 182 | }, 183 | headers: { 'x-http-method': 'cou' } 184 | }); 185 | t.match( 186 | response.json(), 187 | { 188 | firstName: 'John', 189 | lastName: 'Doe' 190 | }, 191 | 'POST api ok with CoU via header' 192 | ); 193 | 194 | const _id = response.json()._id; 195 | 196 | response = await bw.inject(t, { 197 | method: 'GET', 198 | url: '/api/authors' 199 | }); 200 | t.equal(response.json().total, 1, 'API returns 1 author'); 201 | 202 | // update the author add biography 203 | response = await bw.inject(t, { 204 | method: 'POST', 205 | url: '/api/authors', 206 | payload: { 207 | _id, 208 | biography: 'Some biography' 209 | }, 210 | headers: { 'x-http-method': 'cou' } 211 | }); 212 | 213 | t.match( 214 | response.json(), 215 | { 216 | _id, 217 | firstName: 'John', 218 | lastName: 'Doe', 219 | biography: 'Some biography' 220 | }, 221 | 'POST api ok with CoU via header' 222 | ); 223 | 224 | // update by removing the biography and channge name 225 | response = await bw.inject(t, { 226 | method: 'POST', 227 | url: '/api/authors', 228 | payload: { 229 | _id, 230 | firstName: 'Jane', 231 | biography: null 232 | }, 233 | headers: { 'x-http-method': 'cou' } 234 | }); 235 | 236 | t.match( 237 | response.json(), 238 | { 239 | _id, 240 | firstName: 'Jane', 241 | lastName: 'Doe' 242 | }, 243 | 'POST api ok with CoU to update name and remove biography' 244 | ); 245 | 246 | // check biography not exists in document 247 | t.equal( 248 | response.json().biography, 249 | undefined, 250 | 'biography not exists in document' 251 | ); 252 | 253 | // replace the author in toto 254 | response = await bw.inject(t, { 255 | method: 'POST', 256 | url: '/api/authors', 257 | payload: { 258 | _id, 259 | firstName: 'Paul', 260 | biography: 'Some other biography' 261 | }, 262 | headers: { 'x-http-method': 'cor' } 263 | }); 264 | 265 | t.match( 266 | response.json(), 267 | { 268 | _id, 269 | firstName: 'Paul', 270 | biography: 'Some other biography' 271 | }, 272 | 'POST api ok in CoR-mode to replace the full document' 273 | ); 274 | 275 | // lastname not exists in document 276 | t.equal( 277 | response.json().lastName, 278 | undefined, 279 | 'and so lastName not exists in document' 280 | ); 281 | }); 282 | -------------------------------------------------------------------------------- /test/schemas_from_path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const fs = require('fs'); 7 | const os = require('os'); 8 | const path = require('path'); 9 | 10 | const mongoose = require('mongoose'); 11 | const BackwardWrapper = require('./BackwardWrapper.js'); 12 | 13 | const bw = new BackwardWrapper(t); 14 | 15 | const schema_authors = { 16 | name: 'authors', 17 | schema: { 18 | firstName: 'String', 19 | lastName: 'String', 20 | birthday: 'Date' 21 | }, 22 | ref: [ 23 | { 24 | $id: 'authors', 25 | title: 'Authors - Model', 26 | properties: { 27 | firstName: { type: 'string' }, 28 | lastName: { type: 'string' }, 29 | birthday: { 30 | type: 'string', 31 | format: 'date' 32 | } 33 | }, 34 | required: ['firstName', 'lastName'] 35 | } 36 | ], 37 | routeGet: { 38 | response: { 39 | 200: { 40 | $ref: 'authors#' 41 | } 42 | } 43 | }, 44 | routePost: { 45 | body: { 46 | $ref: 'authors#' 47 | }, 48 | response: { 49 | 200: { 50 | $ref: 'authors#' 51 | } 52 | } 53 | } 54 | }; 55 | 56 | const schema_books = { 57 | name: 'books', 58 | schema: { 59 | title: 'String', 60 | isbn: 'String', 61 | created: { 62 | type: 'Date', 63 | default: Date.now 64 | } 65 | }, 66 | ref: [ 67 | { 68 | $id: 'books', 69 | title: 'Books - Model', 70 | properties: { 71 | title: { type: 'string' }, 72 | isbn: { type: 'string' }, 73 | created: { 74 | type: 'string', 75 | format: 'date' 76 | } 77 | }, 78 | required: ['title', 'isbn'] 79 | } 80 | ], 81 | routeGet: { 82 | response: { 83 | 200: { 84 | $ref: 'books#' 85 | } 86 | } 87 | }, 88 | routePost: { 89 | body: { 90 | $ref: 'books#' 91 | }, 92 | response: { 93 | 200: { 94 | $ref: 'books#' 95 | } 96 | } 97 | } 98 | }; 99 | 100 | const createSchemasInTmpPath = () => { 101 | const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), 'mfas-')); 102 | // authors.schema.js 103 | fs.writeFileSync( 104 | path.join(tmpPath, 'authors.schema.js'), 105 | 'module.exports=' + JSON.stringify(schema_authors) 106 | ); 107 | // subdir/books.schema.js 108 | fs.mkdirSync(path.join(tmpPath, 'subdir')); 109 | fs.writeFileSync( 110 | path.join(tmpPath, 'subdir', 'books.schema.js'), 111 | 'module.exports=' + JSON.stringify(schema_books) 112 | ); 113 | return tmpPath; 114 | }; 115 | 116 | const tmpPath = createSchemasInTmpPath(); 117 | 118 | test('valid schemaDir', async t => { 119 | t.not(tmpPath, undefined, 'schemaDir is valid'); 120 | }); 121 | 122 | test('mongoose db initialization', async () => { 123 | await bw.createConnection(); 124 | }); 125 | 126 | test('schema initialization', async t => { 127 | const authorSchema = mongoose.Schema(schema_authors.schema); 128 | bw.conn.model('Author', authorSchema); 129 | 130 | const bookSchema = mongoose.Schema(schema_books.schema); 131 | bw.conn.model('Book', bookSchema); 132 | 133 | t.ok(bw.conn.models.Author); 134 | t.ok(bw.conn.models.Book); 135 | }); 136 | 137 | test('clean up test collections', async () => { 138 | await bw.conn.models.Author.deleteMany({}).exec(); 139 | await bw.conn.models.Book.deleteMany({}).exec(); 140 | }); 141 | 142 | test('initialization of API server', async () => { 143 | await bw.createServer({ 144 | models: bw.conn.models, 145 | setDefaults: true, 146 | schemaDirPath: tmpPath 147 | }); 148 | }); 149 | 150 | test('POST author item test', async t => { 151 | let response = null; 152 | response = await bw.inject(t, { 153 | method: 'POST', 154 | url: '/api/authors', 155 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 156 | }); 157 | 158 | t.match( 159 | response.json(), 160 | { firstName: 'Hutin', lastName: 'Puylo' }, 161 | 'POST api ok' 162 | ); 163 | 164 | response = await bw.inject(t, { 165 | method: 'GET', 166 | url: '/api/authors' 167 | }); 168 | 169 | t.match( 170 | response.json().items[0], 171 | { firstName: 'Hutin', lastName: 'Puylo' }, 172 | 'Listed same' 173 | ); 174 | t.equal(response.json().total, 1, 'There are author now'); 175 | }); 176 | 177 | test('POST only firstName', async t => { 178 | const response = await bw.inject( 179 | t, 180 | { 181 | method: 'POST', 182 | url: '/api/authors', 183 | payload: { firstName: 'Hutin' } 184 | }, 185 | 400 186 | ); 187 | 188 | t.match( 189 | response.json().message, 190 | "body must have required property 'lastName'", 191 | 'POST failed if required parameters not set' 192 | ); 193 | }); 194 | 195 | test('POST valid birthday', async t => { 196 | let response = null; 197 | response = await bw.inject(t, { 198 | method: 'POST', 199 | url: '/api/authors', 200 | payload: { 201 | firstName: 'Hutin', 202 | lastName: 'Puylo', 203 | birthday: '1969-07-06' 204 | } 205 | }); 206 | 207 | t.match( 208 | response.json(), 209 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 210 | 'POST api ok' 211 | ); 212 | 213 | response = await bw.inject(t, { 214 | method: 'GET', 215 | url: '/api/authors' 216 | }); 217 | 218 | t.match( 219 | response.json().items[1], 220 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 221 | 'Listed same' 222 | ); 223 | t.equal(response.json().total, 2, 'There are author now'); 224 | }); 225 | 226 | test('POST invalid birthday', async t => { 227 | const response = await bw.inject( 228 | t, 229 | { 230 | method: 'POST', 231 | url: '/api/authors', 232 | payload: { 233 | firstName: 'Hutin', 234 | lastName: 'Puylo', 235 | birthday: '1969-30-06' 236 | } 237 | }, 238 | 400 239 | ); 240 | 241 | t.match( 242 | response.json().message, 243 | 'body/birthday must match format "date"', 244 | 'POST api ok' 245 | ); 246 | }); 247 | 248 | test('POST book item test', async t => { 249 | const response = await bw.inject(t, { 250 | method: 'POST', 251 | url: '/api/books', 252 | payload: { title: 'Critique of Practical Reason', isbn: '1519394632' } 253 | }); 254 | 255 | t.match( 256 | response.json(), 257 | { title: 'Critique of Practical Reason', isbn: '1519394632' }, 258 | 'POST api ok' 259 | ); 260 | }); 261 | 262 | test('POST book without isbn', async t => { 263 | const response = await bw.inject( 264 | t, 265 | { 266 | method: 'POST', 267 | url: '/api/books', 268 | payload: { title: 'Critique of Practical Reason' } 269 | }, 270 | 400 271 | ); 272 | 273 | t.match( 274 | response.json().message, 275 | "body must have required property 'isbn'", 276 | 'POST failed if required parameters not set' 277 | ); 278 | }); 279 | 280 | test('Shutdown API server', async () => { 281 | await bw.fastify.close(); 282 | }); 283 | 284 | // now birthday is required too 285 | schema_authors.ref[0].required = ['firstName', 'lastName', 'birthday']; 286 | 287 | test('reload API server with schemas', async () => { 288 | await bw.createServer({ 289 | models: bw.conn.models, 290 | setDefaults: true, 291 | schemas: [schema_authors], // this replaces one loaded in dirPath 292 | schemaDirPath: tmpPath 293 | }); 294 | }); 295 | 296 | test('POST without birthday', async t => { 297 | const response = await bw.inject( 298 | t, 299 | { 300 | method: 'POST', 301 | url: '/api/authors', 302 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 303 | }, 304 | 400 305 | ); 306 | 307 | t.match( 308 | response.json().message, 309 | "body must have required property 'birthday'", 310 | 'POST failed if required parameters not set' 311 | ); 312 | }); 313 | 314 | test('POST with birthday', async t => { 315 | const response = await bw.inject(t, { 316 | method: 'POST', 317 | url: '/api/authors', 318 | payload: { 319 | firstName: 'Hutin', 320 | lastName: 'Puylo', 321 | birthday: '1969-07-06' 322 | } 323 | }); 324 | 325 | t.match( 326 | response.json(), 327 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 328 | 'POST api ok' 329 | ); 330 | }); 331 | 332 | test('teardown', async () => { 333 | fs.unlinkSync(path.join(tmpPath, 'authors.schema.js')); 334 | fs.unlinkSync(path.join(tmpPath, 'subdir', 'books.schema.js')); 335 | fs.rmdirSync(path.join(tmpPath, 'subdir')); 336 | fs.rmdirSync(tmpPath); 337 | }); 338 | -------------------------------------------------------------------------------- /test/validation_schemas.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | let collection = 'authors'; 12 | 13 | let schema_base = { 14 | name: collection, 15 | schema: { 16 | firstName: String, 17 | lastName: String, 18 | birthday: Date 19 | } 20 | }; 21 | 22 | let schema_empty = { 23 | ...schema_base, 24 | routeGet: {}, 25 | routePost: {}, 26 | routeList: {}, 27 | routePut: {}, 28 | routePatch: {}, 29 | routeDelete: {} 30 | }; 31 | 32 | let schema_with_ref = { 33 | ...schema_base, 34 | ref: [ 35 | { 36 | $id: collection, 37 | title: collection + ' - Model', 38 | properties: { 39 | firstName: { type: 'string' }, 40 | lastName: { type: 'string' }, 41 | birthday: { 42 | type: 'string', 43 | format: 'date' 44 | } 45 | }, 46 | required: ['firstName', 'lastName'] 47 | } 48 | ] 49 | }; 50 | 51 | let schema_full = { 52 | ...schema_with_ref, 53 | routeGet: { 54 | response: { 55 | 200: { 56 | $ref: `${collection}#` 57 | } 58 | } 59 | }, 60 | routePost: { 61 | body: { 62 | $ref: `${collection}#` 63 | }, 64 | response: { 65 | 200: { 66 | $ref: `${collection}#` 67 | } 68 | } 69 | }, 70 | 71 | routeList: { 72 | response: { 73 | 200: { 74 | type: 'object', 75 | properties: { 76 | total: { type: 'integer' }, 77 | items: { 78 | type: 'array', 79 | items: { $ref: `${collection}#` } 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | 86 | routePut: { 87 | body: { 88 | $ref: `${collection}#` 89 | }, 90 | response: { 91 | 200: { 92 | $ref: `${collection}#` 93 | } 94 | } 95 | }, 96 | 97 | routePatch: { 98 | body: { 99 | $ref: `${collection}#` 100 | }, 101 | response: { 102 | 200: { 103 | $ref: `${collection}#` 104 | } 105 | } 106 | }, 107 | 108 | routeDelete: {} 109 | }; 110 | 111 | test('mongoose db initialization', async () => { 112 | await bw.createConnection(); 113 | }); 114 | 115 | test('schema initialization', async t => { 116 | const authorSchema = mongoose.Schema(schema_base.schema); 117 | bw.conn.model('Author', authorSchema); 118 | t.ok(bw.conn.models.Author); 119 | }); 120 | 121 | // base, empty, with_ref should have old working mode 122 | 123 | //[schema_base, schema_empty, schema_with_ref].forEach(schema => { 124 | [schema_base, schema_empty, schema_with_ref].forEach(schema => { 125 | test('clean up test collections', async () => { 126 | await bw.conn.models.Author.deleteMany({}).exec(); 127 | }); 128 | 129 | test('initialization of API server', async () => { 130 | await bw.createServer({ 131 | models: bw.conn.models, 132 | setDefaults: true, 133 | schemas: [schema] 134 | }); 135 | }); 136 | 137 | test('POST item test', async t => { 138 | let response = null; 139 | response = await bw.inject(t, { 140 | method: 'POST', 141 | url: '/api/authors', 142 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 143 | }); 144 | 145 | t.match( 146 | response.json(), 147 | { firstName: 'Hutin', lastName: 'Puylo' }, 148 | 'POST api ok' 149 | ); 150 | 151 | response = await bw.inject(t, { 152 | method: 'GET', 153 | url: '/api/authors' 154 | }); 155 | 156 | t.match( 157 | response.json().items[0], 158 | { firstName: 'Hutin', lastName: 'Puylo' }, 159 | 'Listed same' 160 | ); 161 | t.equal(response.json().total, 1, 'There are author now'); 162 | }); 163 | 164 | test('NO validation so, also a field is valid', async t => { 165 | const response = await bw.inject(t, { 166 | method: 'POST', 167 | url: '/api/authors', 168 | payload: { 169 | firstName: 'Hutin' 170 | } 171 | }); 172 | 173 | t.match(response.json(), { firstName: 'Hutin' }, 'POST api ok'); 174 | }); 175 | 176 | test('POST valid birthday', async t => { 177 | let response = null; 178 | response = await bw.inject(t, { 179 | method: 'POST', 180 | url: '/api/authors', 181 | payload: { 182 | firstName: 'Hutin', 183 | lastName: 'Puylo', 184 | birthday: '1969-07-06' 185 | } 186 | }); 187 | 188 | t.match( 189 | response.json(), 190 | { 191 | firstName: 'Hutin', 192 | lastName: 'Puylo', 193 | birthday: '1969-07-06' 194 | }, 195 | 'POST api ok' 196 | ); 197 | 198 | response = await bw.inject(t, { 199 | method: 'GET', 200 | url: '/api/authors' 201 | }); 202 | 203 | t.match( 204 | response.json().items[2], 205 | { 206 | firstName: 'Hutin', 207 | lastName: 'Puylo', 208 | birthday: '1969-07-06' 209 | }, 210 | 'Listed same' 211 | ); 212 | t.equal(response.json().total, 3, 'There are author now'); 213 | }); 214 | 215 | test('POST invalid birthday', async t => { 216 | const response = await bw.inject( 217 | t, 218 | { 219 | method: 'POST', 220 | url: '/api/authors', 221 | payload: { 222 | firstName: 'Hutin', 223 | lastName: 'Puylo', 224 | birthday: '1969-30-06' 225 | } 226 | }, 227 | 500 228 | ); 229 | 230 | t.type(response.json().message, 'string'); 231 | // it may be a simple: 232 | // "Cast to date failed" 233 | // or something like: 234 | // "Author validation failed: birthday: Cast to Date failed for value \"1969-30-06\" at path \"birthday\"" 235 | // depending on Fastify ( ajv ? ) version. 236 | // So let's just check if there's "Cast to date failed" in the message 237 | t.match( 238 | response.json().message, 239 | /[\s\S]*Cast to [Dd]ate failed[\s\S]*/ 240 | ); 241 | }); 242 | 243 | test('Shutdown API server', async () => { 244 | await bw.fastify.close(); 245 | }); 246 | }); 247 | 248 | test('clean up test collections', async () => { 249 | await bw.conn.models.Author.deleteMany({}).exec(); 250 | }); 251 | 252 | test('initialization of API server with validation', async () => { 253 | await bw.createServer({ 254 | models: bw.conn.models, 255 | setDefaults: true, 256 | schemas: [schema_full] 257 | }); 258 | }); 259 | 260 | test('Check required validation', async t => { 261 | const response = await bw.inject( 262 | t, 263 | { 264 | method: 'POST', 265 | url: '/api/authors', 266 | payload: { firstName: 'Hutin' } 267 | }, 268 | 400 269 | ); 270 | 271 | t.match( 272 | response.json().message, 273 | "body must have required property 'lastName'", 274 | 'POST refused' 275 | ); 276 | }); 277 | 278 | test('POST valid birthday', async t => { 279 | let response = null; 280 | response = await bw.inject(t, { 281 | method: 'POST', 282 | url: '/api/authors', 283 | payload: { 284 | firstName: 'Hutin', 285 | lastName: 'Puylo', 286 | birthday: '1969-07-06' 287 | } 288 | }); 289 | 290 | t.match( 291 | response.json(), 292 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 293 | 'POST api ok' 294 | ); 295 | 296 | response = await bw.inject(t, { 297 | method: 'GET', 298 | url: '/api/authors' 299 | }); 300 | 301 | t.match( 302 | response.json().items[0], 303 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 304 | 'Listed same' 305 | ); 306 | t.equal(response.json().total, 1, 'There are author now'); 307 | }); 308 | 309 | test('POST invalid birthday', async t => { 310 | let response = null; 311 | response = await bw.inject( 312 | t, 313 | { 314 | method: 'POST', 315 | url: '/api/authors', 316 | payload: { 317 | firstName: 'Hutin', 318 | lastName: 'Puylo', 319 | birthday: '1969-30-06' 320 | } 321 | }, 322 | 400 323 | ); 324 | 325 | t.equal( 326 | response.headers['content-type'], 327 | 'application/json; charset=utf-8', 328 | 'Content-Type should be application/json; charset=utf-8' 329 | ); 330 | t.match( 331 | response.json().message, 332 | 'body/birthday must match format "date"', 333 | 'POST api ok' 334 | ); 335 | }); 336 | -------------------------------------------------------------------------------- /test/schemas_in_es.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const fs = require('fs'); 7 | const os = require('os'); 8 | const path = require('path'); 9 | 10 | const mongoose = require('mongoose'); 11 | const BackwardWrapper = require('./BackwardWrapper.js'); 12 | 13 | const bw = new BackwardWrapper(t); 14 | 15 | const schema_authors = { 16 | name: 'authors', 17 | schema: { 18 | firstName: 'String', 19 | lastName: 'String', 20 | birthday: 'Date' 21 | }, 22 | ref: [ 23 | { 24 | $id: 'authors', 25 | title: 'Authors - Model', 26 | properties: { 27 | firstName: { type: 'string' }, 28 | lastName: { type: 'string' }, 29 | birthday: { 30 | type: 'string', 31 | format: 'date' 32 | } 33 | }, 34 | required: ['firstName', 'lastName'] 35 | } 36 | ], 37 | routeGet: { 38 | response: { 39 | 200: { 40 | $ref: 'authors#' 41 | } 42 | } 43 | }, 44 | routePost: { 45 | body: { 46 | $ref: 'authors#' 47 | }, 48 | response: { 49 | 200: { 50 | $ref: 'authors#' 51 | } 52 | } 53 | } 54 | }; 55 | 56 | const schema_books = { 57 | name: 'books', 58 | schema: { 59 | title: 'String', 60 | isbn: 'String', 61 | created: { 62 | type: 'Date', 63 | default: Date.now 64 | } 65 | }, 66 | ref: [ 67 | { 68 | $id: 'books', 69 | title: 'Books - Model', 70 | properties: { 71 | title: { type: 'string' }, 72 | isbn: { type: 'string' }, 73 | created: { 74 | type: 'string', 75 | format: 'date' 76 | } 77 | }, 78 | required: ['title', 'isbn'] 79 | } 80 | ], 81 | routeGet: { 82 | response: { 83 | 200: { 84 | $ref: 'books#' 85 | } 86 | } 87 | }, 88 | routePost: { 89 | body: { 90 | $ref: 'books#' 91 | }, 92 | response: { 93 | 200: { 94 | $ref: 'books#' 95 | } 96 | } 97 | } 98 | }; 99 | 100 | const createSchemasInTmpPath = () => { 101 | const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), 'mfas-')); 102 | // authors.schema.js 103 | fs.writeFileSync( 104 | path.join(tmpPath, 'authors.schema.mjs'), 105 | 'export default ' + JSON.stringify(schema_authors) 106 | ); 107 | // subdir/books.schema.js 108 | fs.mkdirSync(path.join(tmpPath, 'subdir')); 109 | fs.writeFileSync( 110 | path.join(tmpPath, 'subdir', 'books.schema.mjs'), 111 | 'export default ' + JSON.stringify(schema_books) 112 | ); 113 | return tmpPath; 114 | }; 115 | 116 | const tmpPath = createSchemasInTmpPath(); 117 | 118 | test('valid schemaDir', async t => { 119 | t.not(tmpPath, undefined, 'schemaDir is valid'); 120 | }); 121 | 122 | test('mongoose db initialization', async () => { 123 | await bw.createConnection(); 124 | }); 125 | 126 | test('schema initialization', async t => { 127 | const authorSchema = mongoose.Schema(schema_authors.schema); 128 | bw.conn.model('Author', authorSchema); 129 | 130 | const bookSchema = mongoose.Schema(schema_books.schema); 131 | bw.conn.model('Book', bookSchema); 132 | 133 | t.ok(bw.conn.models.Author); 134 | t.ok(bw.conn.models.Book); 135 | }); 136 | 137 | test('clean up test collections', async () => { 138 | await bw.conn.models.Author.deleteMany({}).exec(); 139 | await bw.conn.models.Book.deleteMany({}).exec(); 140 | }); 141 | 142 | test('initialization of API server', async () => { 143 | await bw.createServer({ 144 | models: bw.conn.models, 145 | setDefaults: true, 146 | schemaDirPath: tmpPath, 147 | schemaPathFilter: (filePath, fileName) => { 148 | // Filter to include .mjs 149 | console.log(`filePath: ${filePath}, fileName: ${fileName}`); 150 | return fileName.endsWith('.mjs'); 151 | } 152 | }); 153 | }); 154 | 155 | test('POST author item test', async t => { 156 | let response = null; 157 | response = await bw.inject(t, { 158 | method: 'POST', 159 | url: '/api/authors', 160 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 161 | }); 162 | 163 | t.match( 164 | response.json(), 165 | { firstName: 'Hutin', lastName: 'Puylo' }, 166 | 'POST api ok' 167 | ); 168 | 169 | response = await bw.inject(t, { 170 | method: 'GET', 171 | url: '/api/authors' 172 | }); 173 | 174 | t.match( 175 | response.json().items[0], 176 | { firstName: 'Hutin', lastName: 'Puylo' }, 177 | 'Listed same' 178 | ); 179 | t.equal(response.json().total, 1, 'There is 1 author now'); 180 | }); 181 | 182 | test('POST only firstName', async t => { 183 | const response = await bw.inject( 184 | t, 185 | { 186 | method: 'POST', 187 | url: '/api/authors', 188 | payload: { firstName: 'Hutin' } 189 | }, 190 | 400 191 | ); 192 | 193 | t.match( 194 | response.json().message, 195 | "body must have required property 'lastName'", 196 | 'POST failed if required parameters not set' 197 | ); 198 | }); 199 | 200 | test('POST valid birthday', async t => { 201 | let response = null; 202 | response = await bw.inject(t, { 203 | method: 'POST', 204 | url: '/api/authors', 205 | payload: { 206 | firstName: 'Hutin', 207 | lastName: 'Puylo', 208 | birthday: '1969-07-06' 209 | } 210 | }); 211 | 212 | t.match( 213 | response.json(), 214 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 215 | 'POST api ok' 216 | ); 217 | 218 | response = await bw.inject(t, { 219 | method: 'GET', 220 | url: '/api/authors' 221 | }); 222 | 223 | t.match( 224 | response.json().items[1], 225 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 226 | 'Listed same' 227 | ); 228 | t.equal(response.json().total, 2, 'There are author now'); 229 | }); 230 | 231 | test('POST invalid birthday', async t => { 232 | const response = await bw.inject( 233 | t, 234 | { 235 | method: 'POST', 236 | url: '/api/authors', 237 | payload: { 238 | firstName: 'Hutin', 239 | lastName: 'Puylo', 240 | birthday: '1969-30-06' 241 | } 242 | }, 243 | 400 244 | ); 245 | 246 | t.match( 247 | response.json().message, 248 | 'body/birthday must match format "date"', 249 | 'POST api ok' 250 | ); 251 | }); 252 | 253 | test('POST book item test', async t => { 254 | const response = await bw.inject(t, { 255 | method: 'POST', 256 | url: '/api/books', 257 | payload: { title: 'Critique of Practical Reason', isbn: '1519394632' } 258 | }); 259 | 260 | t.match( 261 | response.json(), 262 | { title: 'Critique of Practical Reason', isbn: '1519394632' }, 263 | 'POST api ok' 264 | ); 265 | }); 266 | 267 | test('POST book without isbn', async t => { 268 | const response = await bw.inject( 269 | t, 270 | { 271 | method: 'POST', 272 | url: '/api/books', 273 | payload: { title: 'Critique of Practical Reason' } 274 | }, 275 | 400 276 | ); 277 | 278 | t.match( 279 | response.json().message, 280 | "body must have required property 'isbn'", 281 | 'POST failed if required parameters not set' 282 | ); 283 | }); 284 | 285 | test('Shutdown API server', async () => { 286 | await bw.fastify.close(); 287 | }); 288 | 289 | // now birthday is required too 290 | schema_authors.ref[0].required = ['firstName', 'lastName', 'birthday']; 291 | 292 | test('reload API server with schemas', async () => { 293 | await bw.createServer({ 294 | models: bw.conn.models, 295 | setDefaults: true, 296 | schemas: [schema_authors], // this replaces one loaded in dirPath 297 | schemaDirPath: tmpPath 298 | }); 299 | }); 300 | 301 | test('POST without birthday', async t => { 302 | const response = await bw.inject( 303 | t, 304 | { 305 | method: 'POST', 306 | url: '/api/authors', 307 | payload: { firstName: 'Hutin', lastName: 'Puylo' } 308 | }, 309 | 400 310 | ); 311 | 312 | t.match( 313 | response.json().message, 314 | "body must have required property 'birthday'", 315 | 'POST failed if required parameters not set' 316 | ); 317 | }); 318 | 319 | test('POST with birthday', async t => { 320 | const response = await bw.inject(t, { 321 | method: 'POST', 322 | url: '/api/authors', 323 | payload: { 324 | firstName: 'Hutin', 325 | lastName: 'Puylo', 326 | birthday: '1969-07-06' 327 | } 328 | }); 329 | 330 | t.match( 331 | response.json(), 332 | { firstName: 'Hutin', lastName: 'Puylo', birthday: '1969-07-06' }, 333 | 'POST api ok' 334 | ); 335 | }); 336 | 337 | test('teardown', async () => { 338 | fs.unlinkSync(path.join(tmpPath, 'authors.schema.mjs')); 339 | fs.unlinkSync(path.join(tmpPath, 'subdir', 'books.schema.mjs')); 340 | fs.rmdirSync(path.join(tmpPath, 'subdir')); 341 | fs.rmdirSync(tmpPath); 342 | }); 343 | -------------------------------------------------------------------------------- /src/DefaultModelMethods.js: -------------------------------------------------------------------------------- 1 | class DefaultModelMethods { 2 | /** 3 | * [apiSubRoutes description] 4 | * @return {[type]} [description] 5 | */ 6 | static apiSubRoutes() { 7 | /// this points to model (schema.statics.) 8 | const subRoutes = {}; 9 | 10 | let fPopulated = pathname => { 11 | return async doc => { 12 | const populate = doc.populate(pathname); 13 | if (populate.execPopulate) { 14 | await populate.execPopulate(); 15 | } else { 16 | await populate; 17 | } 18 | 19 | // await doc.populate(pathname).execPopulate(); 20 | return doc[pathname]; 21 | }; 22 | }; 23 | 24 | let fExternal = (externalModel, refKey) => { 25 | return doc => { 26 | const whereOptions = {}; 27 | whereOptions[refKey] = doc; 28 | 29 | return externalModel.find().where(whereOptions); 30 | }; 31 | }; 32 | 33 | this.schema.eachPath((pathname, schematype) => { 34 | if ( 35 | schematype && 36 | schematype.instance && 37 | schematype.instance.toLowerCase() == 'objectid' && 38 | schematype.options && 39 | schematype.options.ref 40 | ) { 41 | /// there is Ref ObjectId in this model 42 | subRoutes[pathname] = fPopulated(pathname); 43 | } 44 | }); 45 | 46 | for (let key of Object.keys(this.db.models)) { 47 | let model = this.db.models[key]; 48 | model.schema.eachPath((refKey, schematype) => { 49 | if ( 50 | schematype && 51 | schematype.instance && 52 | schematype.instance.toLowerCase() == 'objectid' && 53 | schematype.options && 54 | schematype.options.ref && 55 | schematype.options.ref == this.modelName 56 | ) { 57 | //// there is Ref to this model in other model 58 | let pathname = model.prototype.collection.name; 59 | if (!subRoutes[pathname]) { 60 | /// set up route as default name, /author/ID/books 61 | subRoutes[pathname] = fExternal(model, refKey); 62 | } else { 63 | /// if there're few refs to same model, as Author and co-Author in book, set up additional routes 64 | /// as /author/ID/books_as_coauthor 65 | /// keeping the first default one 66 | pathname += '_as_' + refKey; 67 | subRoutes[pathname] = fExternal(model, refKey); 68 | } 69 | } else if ( 70 | schematype && 71 | schematype.instance == 'Array' && 72 | schematype.options && 73 | schematype.options.type && 74 | schematype.options.type[0] && 75 | schematype.options.type[0].ref == this.modelName 76 | ) { 77 | //// Refs as the array of ObjectId 78 | //// like: 79 | //// inspired: [{ type : mongoose.Schema.Types.ObjectId, ref: 'Book' }], 80 | //// there is Ref to this model in other model 81 | let pathname = model.prototype.collection.name; 82 | if (!subRoutes[pathname]) { 83 | /// set up route as default name, /author/ID/books 84 | subRoutes[pathname] = fExternal(model, refKey); 85 | } else { 86 | /// if there're few refs to same model, as Author and co-Author in book, set up additional routes 87 | /// as /author/ID/books_as_coauthor 88 | /// keeping the first default one 89 | pathname += '_as_' + refKey; 90 | subRoutes[pathname] = fExternal(model, refKey); 91 | } 92 | } 93 | }); 94 | } 95 | 96 | return subRoutes; 97 | } 98 | 99 | /** 100 | * [apiPost description] 101 | * @param Object data [description] 102 | * @return Document [description] 103 | */ 104 | static async apiPost(data) { 105 | /// this points to model (schema.statics.) 106 | let doc = new this(); 107 | 108 | doc = assignDataToDocPostForReplace(this.schema, doc, data); 109 | 110 | await doc.save(); 111 | return doc; 112 | } 113 | 114 | /** 115 | * [apiCoU description] 116 | * @param Object data [description] 117 | * @return Document [description] 118 | */ 119 | static async apiCoU(data) { 120 | /// this points to model (schema.statics.) 121 | let doc = new this(); 122 | 123 | doc = assignDataToDocForUpdate(doc, data); 124 | await doc.validate(); 125 | 126 | // in save-mode, a field set to null is deleted, in update mode, it requires to use $unset 127 | // so we need to delete it from doc._doc and add it to $unset 128 | const keysToDelete = Object.keys(data).filter(key => doc[key] === null); 129 | keysToDelete.forEach(key => delete doc._doc[key]); 130 | 131 | doc = await this.findByIdAndUpdate( 132 | doc._id, 133 | { 134 | $set: doc, 135 | $unset: Object.fromEntries(keysToDelete.map(key => [key, ''])) 136 | }, 137 | { 138 | upsert: true, 139 | new: true, 140 | runValidators: true, 141 | setDefaultsOnInsert: true 142 | } 143 | ); 144 | 145 | return doc; 146 | } 147 | 148 | /** 149 | * [apiCoR description] 150 | * @param Object data [description] 151 | * @return Document [description] 152 | */ 153 | static async apiCoR(data) { 154 | /// this points to model (schema.statics.) 155 | let doc = new this(); 156 | 157 | doc = assignDataToDocPostForReplace(this.schema, doc, data); 158 | await doc.validate(); 159 | doc = await this.findOneAndReplace({ _id: doc._id }, doc, { 160 | upsert: true, 161 | new: true, 162 | runValidators: true, 163 | setDefaultsOnInsert: true 164 | }); 165 | 166 | return doc; 167 | } 168 | 169 | /** 170 | * [apiValues description] 171 | * @return {[type]} [description] 172 | */ 173 | async apiValues(request) { 174 | // complex logic below is to be sure deeper populated object are covered by their .apiValues() 175 | // method. 176 | // If you don't need this, just use simple: 177 | // return this.toObject({depopulate: true}); 178 | // instead 179 | // But please be sure you understand it doesn't proceed populated documents with apiValues() and 180 | // you may end showing off data you wanted to keep private. 181 | 182 | let areThereDeepObjects = false; 183 | let deepObjectsPromisesArray = []; 184 | const deepObjectsPromisesResults = {}; 185 | 186 | let versionKey = true; 187 | if (this.__api && this.__api._exposeVersionKey === false) { 188 | versionKey = false; 189 | } 190 | 191 | let modelNameField = null; 192 | if (this.__api && this.__api._exposeModelName) { 193 | if (typeof this.__api._exposeModelName == 'string') { 194 | modelNameField = this.__api._exposeModelName; 195 | } else { 196 | modelNameField = '__modelName'; 197 | } 198 | } 199 | 200 | // 2 steps 201 | // 1 - run regular toObject processing and check if there're deeper documents we may need to transform 202 | // 2 - run toObject processing updaing deeper objects with their .apiValues() results 203 | 204 | const transform = (doc, ret) => { 205 | if (doc === this) { 206 | if (modelNameField) { 207 | ret[modelNameField] = doc.constructor.modelName; 208 | } 209 | return ret; 210 | } else { 211 | areThereDeepObjects = true; 212 | 213 | const deeperApiValues = doc.apiValues(request); 214 | if (typeof deeperApiValues?.then === 'function') { 215 | deepObjectsPromisesArray.push( 216 | deeperApiValues.then(res => { 217 | if (modelNameField) { 218 | res[modelNameField] = doc.constructor.modelName; 219 | } 220 | deepObjectsPromisesResults[doc.id] = res; 221 | }) 222 | ); 223 | } else { 224 | if (modelNameField) { 225 | deeperApiValues[modelNameField] = 226 | doc.constructor.modelName; 227 | } 228 | deepObjectsPromisesResults[doc.id] = deeperApiValues; 229 | } 230 | return null; // to be covered later 231 | } 232 | }; 233 | 234 | const firstResult = this.toObject({ 235 | transform: transform, 236 | versionKey: versionKey 237 | }); 238 | 239 | if (!areThereDeepObjects) { 240 | // return data after 1st step if there's nothing deeper 241 | return firstResult; 242 | } 243 | 244 | // await for promises, if any 245 | await Promise.all(deepObjectsPromisesArray); 246 | 247 | const transformDeeper = (doc, ret) => { 248 | if (doc === this) { 249 | if (modelNameField) { 250 | ret[modelNameField] = doc.constructor.modelName; 251 | } 252 | return ret; 253 | } else { 254 | return deepObjectsPromisesResults[doc.id]; 255 | } 256 | }; 257 | 258 | return this.toObject({ 259 | transform: transformDeeper, 260 | versionKey: versionKey 261 | }); 262 | } 263 | 264 | /** 265 | * [apiPut description] 266 | * @param Object data [description] 267 | * @return Document [description] 268 | */ 269 | async apiPut(data) { 270 | //// this points to document (schema.methods.) 271 | const model = assignDataToDocForUpdate(this, data); 272 | await model.save(); 273 | } 274 | 275 | /** 276 | * [apiDelete description] 277 | * @return Boolean success 278 | */ 279 | async apiDelete() { 280 | //// this points to document (schema.methods.) 281 | await this.deleteOne(); 282 | } 283 | } 284 | 285 | /** 286 | * Assigns values from data to doc based on schema paths, handling nested paths. 287 | * @param {Schema} schema - Mongoose schema object 288 | * @param {Object} doc - Document to assign values to 289 | * @param {Object} data - Source data object 290 | */ 291 | const assignDataToDocPostForReplace = (schema, doc, data) => { 292 | schema.eachPath(pathname => { 293 | if (data[pathname] !== undefined) { 294 | if (pathname.includes('.')) { 295 | // nested document 296 | const keys = pathname.split('.'); 297 | const lastKey = keys.pop(); 298 | const lastObj = keys.reduce( 299 | (doc, key) => (doc[key] = doc[key] || {}), 300 | doc 301 | ); 302 | lastObj[lastKey] = data[pathname]; 303 | } else { 304 | doc[pathname] = data[pathname]; 305 | } 306 | } 307 | }); 308 | return doc; 309 | }; 310 | 311 | const assignDataToDocForUpdate = (model, data) => { 312 | model.schema.eachPath(pathname => { 313 | let newValue = undefined; 314 | let isChanged = false; 315 | if (data[pathname] !== undefined) { 316 | newValue = data[pathname]; 317 | isChanged = true; 318 | } else if (data[pathname] === null) { 319 | newValue = undefined; 320 | isChanged = true; 321 | } 322 | if (isChanged) { 323 | if (pathname.includes('.')) { 324 | let doc = model; 325 | // nested document 326 | const keys = pathname.split('.'); 327 | const lastKey = keys.pop(); 328 | const lastObj = keys.reduce( 329 | (doc, key) => (doc[key] = doc[key] || {}), 330 | doc 331 | ); 332 | lastObj[lastKey] = newValue; 333 | } else { 334 | model[pathname] = newValue; 335 | } 336 | } 337 | }); 338 | return model; 339 | }; 340 | module.exports = DefaultModelMethods; 341 | -------------------------------------------------------------------------------- /src/APIRouter.js: -------------------------------------------------------------------------------- 1 | const { defaultSchemas } = require('./DefaultSchemas'); 2 | 3 | const capFL = string => string.charAt(0).toUpperCase() + string.slice(1); 4 | 5 | class APIRouter { 6 | constructor(params = {}) { 7 | this._models = params.models || []; 8 | this._fastify = params.fastify || null; 9 | this._model = params.model || null; 10 | this._methods = params.methods; 11 | this._checkAuth = params.checkAuth || null; 12 | this._schemas = params.schemas || {}; 13 | this._registerReferencedSchemas(); 14 | 15 | this._modelName = this._model.modelName; 16 | 17 | this._prefix = params.prefix || '/api/'; 18 | 19 | this._collectionName = this._model.prototype.collection.name; 20 | 21 | this._path = this._prefix + this._collectionName; 22 | 23 | this._apiSubRoutesFunctions = {}; 24 | this._defaultSchemas = defaultSchemas(this._modelName); 25 | this.setUpRoutes(); 26 | } 27 | 28 | get collectionName() { 29 | return this._collectionName; 30 | } 31 | 32 | get path() { 33 | return this._path; 34 | } 35 | 36 | _registerReferencedSchemas() { 37 | const s = this._schemas; 38 | if (s.ref != undefined) { 39 | if (!Array.isArray(s.ref)) s.ref = [s.ref]; 40 | s.ref.forEach(item => this._fastify.addSchema(item)); 41 | } 42 | } 43 | 44 | setUpRoutes() { 45 | let path = this._path; 46 | this._methods.forEach(item => 47 | this._fastify[item === 'list' ? 'get' : item]( 48 | path + (item == 'list' || item == 'post' ? '' : '/:id'), 49 | this._populateSchema( 50 | 'route' + capFL(item), 51 | this._schemas['route' + capFL(item)] 52 | ), 53 | this.routeHandler('route' + capFL(item)) 54 | ) 55 | ); 56 | 57 | /// check if there's apiSubRoutes method on the model 58 | if (this._model['apiSubRoutes']) { 59 | this._apiSubRoutesFunctions = this._model['apiSubRoutes'](); 60 | 61 | for (let key of Object.keys(this._apiSubRoutesFunctions)) { 62 | this._fastify.get( 63 | path + '/:id/' + key, 64 | {}, 65 | this.routeHandler('routeSub', key) 66 | ); 67 | } 68 | } 69 | } 70 | 71 | _populateSchema(funcName, optSchema) { 72 | if (optSchema === undefined) return {}; 73 | // get default schema for funcName and merge with optSchema 74 | // merge response separately 75 | return { 76 | schema: { 77 | ...this._defaultSchemas[funcName], 78 | ...optSchema, 79 | response: { 80 | ...this._defaultSchemas[funcName].response, 81 | ...(optSchema.response || {}) 82 | } 83 | } 84 | }; 85 | } 86 | 87 | routeHandler(funcName, subRouteName = null) { 88 | return async (request, reply) => { 89 | if (typeof this._checkAuth === 'function') { 90 | await this._checkAuth(request, reply); 91 | } 92 | if (subRouteName) { 93 | return await this.routeSub(subRouteName, request, reply); 94 | } else { 95 | return await this[funcName](request, reply); 96 | } 97 | }; 98 | } 99 | 100 | async routeSub(routeName, request, reply) { 101 | let id = request.params.id || null; 102 | let doc = null; 103 | try { 104 | doc = await this._model.findById(id).exec(); 105 | } catch { 106 | doc = null; 107 | } 108 | 109 | if (!doc) { 110 | reply.callNotFound(); 111 | } else { 112 | let data = this._apiSubRoutesFunctions[routeName](doc); 113 | let ret = null; 114 | 115 | if ( 116 | Object.getPrototypeOf(data) && 117 | Object.getPrototypeOf(data).constructor.name == 'Query' 118 | ) { 119 | ret = await this.getListResponse(data, request, reply); 120 | } else { 121 | data = await Promise.resolve(data); 122 | if (Array.isArray(data)) { 123 | ret = {}; 124 | 125 | ret.items = await this.arrayOfDocsToAPIResponse( 126 | data, 127 | request 128 | ); 129 | ret.total = ret.items.length; 130 | } else { 131 | ret = await this.docToAPIResponse(data, request); 132 | } 133 | } 134 | 135 | reply.send(ret); 136 | } 137 | } 138 | 139 | async getListResponse(query, request) { 140 | let offset = request.query.offset 141 | ? parseInt(request.query.offset, 10) 142 | : 0; 143 | let limit = request.query.limit 144 | ? parseInt(request.query.limit, 10) 145 | : 100; 146 | let sort = request.query.sort ? request.query.sort : null; 147 | let filter = request.query.filter ? request.query.filter : null; 148 | let where = request.query.where ? request.query.where : null; 149 | let search = request.query.search ? request.query.search : null; 150 | let match = request.query.match ? request.query.match : null; 151 | let fields = request.query.fields ? request.query.fields : null; 152 | 153 | let populate = request.query['populate[]'] 154 | ? request.query['populate[]'] 155 | : request.query.populate 156 | ? request.query.populate 157 | : null; 158 | 159 | let ret = {}; 160 | 161 | if (search) { 162 | query = query 163 | .and( 164 | { $text: { $search: search } }, 165 | { score: { $meta: 'textScore' } } 166 | ) 167 | .sort({ score: { $meta: 'textScore' } }); 168 | } 169 | 170 | if (filter) { 171 | let splet = ('' + filter).split('='); 172 | if (splet.length < 2) { 173 | splet[1] = true; /// default value 174 | } 175 | 176 | query.where(splet[0]).equals(splet[1]); 177 | } 178 | 179 | if (where) { 180 | const allowedMethods = [ 181 | '$eq', 182 | '$gt', 183 | '$gte', 184 | '$in', 185 | '$lt', 186 | '$lte', 187 | '$ne', 188 | '$nin', 189 | '$and', 190 | '$not', 191 | '$nor', 192 | '$or', 193 | '$exists', 194 | '$regex', 195 | '$options' 196 | ]; 197 | const sanitize = function (v) { 198 | if (v instanceof Object) { 199 | for (var key in v) { 200 | if ( 201 | /^\$/.test(key) && 202 | allowedMethods.indexOf(key) === -1 203 | ) { 204 | throw new Error('Invalid where method: ' + key); 205 | } else { 206 | sanitize(v[key]); 207 | } 208 | } 209 | } 210 | return v; 211 | }; 212 | 213 | const whereAsObject = sanitize(JSON.parse(where)); 214 | 215 | query.and(whereAsObject); 216 | } 217 | 218 | if (match) { 219 | let splet = ('' + match).split('='); 220 | if (splet.length == 2) { 221 | const matchOptions = {}; 222 | matchOptions[splet[0]] = { $regex: splet[1] }; 223 | query = query.and(matchOptions); 224 | } 225 | } 226 | 227 | if (this._model.onListQuery) { 228 | await this._model.onListQuery(query, request); 229 | } 230 | 231 | if (query.clone) { 232 | // mongoose > 6.0 233 | ret.total = await query.clone().countDocuments(); /// @todo Use estimatedDocumentCount() if there're no filters? 234 | } else { 235 | ret.total = await query.countDocuments(); /// @todo Use estimatedDocumentCount() if there're no filters? 236 | } 237 | 238 | query.limit(limit); 239 | query.skip(offset); 240 | 241 | if (populate) { 242 | if (Array.isArray(populate)) { 243 | for (let pop of populate) { 244 | query.populate(pop); 245 | } 246 | } else { 247 | query.populate(populate); 248 | } 249 | } 250 | 251 | if (sort) { 252 | query.sort(sort); 253 | } 254 | 255 | if (fields) { 256 | query.select(fields.split(',')); 257 | } 258 | 259 | let docs = await query.find().exec(); 260 | ret.items = await this.arrayOfDocsToAPIResponse(docs, request); 261 | 262 | return ret; 263 | } 264 | 265 | async routeList(request, reply) { 266 | let query = this._model.find(); 267 | reply.send(await this.getListResponse(query, request, reply)); 268 | } 269 | 270 | async populateIfNeeded(request, doc) { 271 | let populate = request.query['populate[]'] 272 | ? request.query['populate[]'] 273 | : request.query.populate 274 | ? request.query.populate 275 | : null; 276 | if (populate) { 277 | let populated = null; 278 | if (Array.isArray(populate)) { 279 | populated = doc.populate(populate); 280 | // for (let pop of populate) { 281 | // doc.populate(pop); 282 | // } 283 | } else { 284 | populated = doc.populate(populate); 285 | } 286 | 287 | if (populated.execPopulate) { 288 | await populated.execPopulate(); 289 | } else { 290 | await populated; 291 | } 292 | 293 | // await doc.execPopulate(); 294 | } 295 | } 296 | 297 | async routePost(request, reply) { 298 | let doc; 299 | if (request.headers['x-http-method']) { 300 | const xhttpMethod = request.headers['x-http-method'].toLowerCase(); 301 | 302 | switch (xhttpMethod) { 303 | case 'cou': 304 | doc = await this._model.apiCoU(request.body, request); 305 | break; 306 | case 'cor': 307 | doc = await this._model.apiCoR(request.body, request); 308 | break; 309 | default: 310 | // error 311 | reply.status(405).send({ 312 | message: `Invalid HTTP method '${xhttpMethod}' for this endpoint` 313 | }); 314 | return; 315 | } 316 | } else { 317 | doc = await this._model.apiPost(request.body, request); 318 | } 319 | await this.populateIfNeeded(request, doc); 320 | 321 | reply.send(await this.docToAPIResponse(doc, request)); 322 | } 323 | 324 | async routeGet(request, reply) { 325 | let id = request.params.id || null; 326 | 327 | let doc = null; 328 | try { 329 | doc = await this._model.findById(id).exec(); 330 | } catch { 331 | doc = null; 332 | } 333 | 334 | if (!doc) { 335 | reply.callNotFound(); 336 | } else { 337 | await this.populateIfNeeded(request, doc); 338 | let ret = await this.docToAPIResponse(doc, request); 339 | reply.send(ret); 340 | } 341 | } 342 | 343 | async routePut(request, reply) { 344 | let id = request.params.id || null; 345 | 346 | let doc = null; 347 | try { 348 | doc = await this._model.findById(id).exec(); 349 | } catch { 350 | doc = null; 351 | } 352 | 353 | if (!doc) { 354 | reply.callNotFound(); 355 | } else { 356 | await doc.apiPut(request.body, request); 357 | await this.populateIfNeeded(request, doc); 358 | let ret = await this.docToAPIResponse(doc, request); 359 | reply.send(ret); 360 | } 361 | } 362 | 363 | async routePatch(request, reply) { 364 | await this.routePut(request, reply); 365 | } 366 | 367 | async routeDelete(request, reply) { 368 | let id = request.params.id || null; 369 | let doc = null; 370 | try { 371 | doc = await this._model.findById(id).exec(); 372 | } catch { 373 | doc = null; 374 | } 375 | 376 | if (!doc) { 377 | reply.callNotFound(); 378 | } else { 379 | await doc.apiDelete(request); 380 | reply.send({ success: true }); 381 | } 382 | } 383 | 384 | async arrayOfDocsToAPIResponse(docs, request) { 385 | const fn = doc => this.docToAPIResponse(doc, request); 386 | const promises = docs.map(fn); 387 | return await Promise.all(promises); 388 | } 389 | 390 | async docToAPIResponse(doc, request) { 391 | return doc ? (doc.apiValues ? doc.apiValues(request) : doc) : null; 392 | } 393 | } 394 | 395 | module.exports = APIRouter; 396 | -------------------------------------------------------------------------------- /test/few_same_refs_populations.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | } 26 | }); 27 | authorSchema.index({ 28 | firstName: 'text', 29 | lastName: 'text', 30 | biography: 'text' 31 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 32 | 33 | bw.conn.model('Author', authorSchema); 34 | 35 | const bookSchema = mongoose.Schema({ 36 | title: String, 37 | isbn: String, 38 | author: { 39 | type: mongoose.Schema.Types.ObjectId, 40 | ref: 'Author' 41 | }, 42 | coauthor: { 43 | /// testing multiple populate 44 | type: mongoose.Schema.Types.ObjectId, 45 | ref: 'Author' 46 | }, 47 | created: { 48 | type: Date, 49 | default: Date.now 50 | } 51 | }); 52 | 53 | bw.conn.model('Book', bookSchema); 54 | 55 | t.ok(bw.conn.models.Author); 56 | t.ok(bw.conn.models.Book); 57 | }); 58 | 59 | test('clean up test collections', async () => { 60 | await bw.conn.models.Author.deleteMany({}).exec(); 61 | await bw.conn.models.Book.deleteMany({}).exec(); 62 | }); 63 | 64 | test('schema ok', async t => { 65 | let author = new bw.conn.models.Author(); 66 | author.firstName = 'Jay'; 67 | author.lastName = 'Kay'; 68 | author.biography = 'Lived. Died.'; 69 | 70 | await author.save(); 71 | 72 | let coauthor = new bw.conn.models.Author(); 73 | coauthor.firstName = 'Co'; 74 | coauthor.lastName = 'Author'; 75 | coauthor.biography = 'Nothing special'; 76 | 77 | await coauthor.save(); 78 | 79 | let book = new bw.conn.models.Book(); 80 | book.title = 'The best book'; 81 | book.isbn = 'The best isbn'; 82 | book.author = author; 83 | book.coauthor = coauthor; 84 | 85 | await book.save(); 86 | 87 | let authorFromDb = await bw.conn.models.Author.findOne({ 88 | firstName: 'Jay' 89 | }).exec(); 90 | let coauthorFromDb = await bw.conn.models.Author.findOne({ 91 | firstName: 'Co' 92 | }).exec(); 93 | let bookFromDb = await bw.conn.models.Book.findOne({ 94 | title: 'The best book' 95 | }).exec(); 96 | 97 | t.ok(authorFromDb); 98 | t.ok(coauthorFromDb); 99 | t.ok(bookFromDb); 100 | 101 | await bw.populateDoc(bookFromDb.populate('author')); 102 | // await bookFromDb.populate('author').execPopulate(); 103 | 104 | t.equal('' + bookFromDb.author._id, '' + authorFromDb._id); 105 | 106 | await bw.populateDoc(bookFromDb.populate('coauthor')); 107 | // await bookFromDb.populate('coauthor').execPopulate(); 108 | 109 | t.equal('' + bookFromDb.coauthor._id, '' + coauthorFromDb._id); 110 | }); 111 | 112 | test('initialization of API server', async t => { 113 | await bw.createServer({ 114 | models: bw.conn.models, 115 | prefix: '/api/', 116 | setDefaults: true, 117 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 118 | }); 119 | 120 | t.equal( 121 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 122 | 2, 123 | 'There are 2 APIRoutes, one for each model' 124 | ); 125 | 126 | t.equal( 127 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 128 | 'authors', 129 | 'Collection name used in API path' 130 | ); 131 | t.equal( 132 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 133 | 'books', 134 | 'Collection name used in API path' 135 | ); 136 | 137 | t.equal( 138 | bw.fastify.mongooseAPI.apiRouters.Author.path, 139 | '/api/authors', 140 | 'API path is composed with prefix + collectionName' 141 | ); 142 | t.equal( 143 | bw.fastify.mongooseAPI.apiRouters.Book.path, 144 | '/api/books', 145 | 'API path is composed with prefix + collectionName' 146 | ); 147 | }); 148 | 149 | test('GET collection endpoints', async t => { 150 | let response = null; 151 | response = await bw.inject(t, { 152 | method: 'GET', 153 | url: '/api/books' 154 | }); 155 | 156 | t.equal(response.json().total, 1, 'API returns 1 book'); 157 | 158 | response = await bw.inject(t, { 159 | method: 'GET', 160 | url: '/api/authors' 161 | }); 162 | 163 | t.equal(response.json().total, 2, 'API returns 2 authors'); 164 | }); 165 | 166 | test('GET single item Refs', async t => { 167 | let bookFromDb = await bw.conn.models.Book.findOne({ 168 | title: 'The best book' 169 | }); 170 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 171 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 172 | 173 | let response = null; 174 | response = await bw.inject(t, { 175 | method: 'GET', 176 | url: '/api/books/' + bookFromDb.id + '/author' 177 | }); 178 | 179 | t.match( 180 | response.json(), 181 | { 182 | firstName: bookFromDb.author.firstName, 183 | lastName: bookFromDb.author.lastName 184 | }, 185 | 'Single item Ref ok' 186 | ); 187 | t.match( 188 | response.json(), 189 | { _id: '' + bookFromDb.author.id }, 190 | 'Single item id ok' 191 | ); 192 | 193 | response = null; 194 | response = await bw.inject(t, { 195 | method: 'GET', 196 | url: '/api/books/' + bookFromDb.id + '/coauthor' 197 | }); 198 | 199 | t.match( 200 | response.json(), 201 | { 202 | firstName: bookFromDb.coauthor.firstName, 203 | lastName: bookFromDb.coauthor.lastName 204 | }, 205 | 'Single item additional Ref ok' 206 | ); 207 | t.match( 208 | response.json(), 209 | { _id: '' + bookFromDb.coauthor.id }, 210 | 'Single item additional ref id ok' 211 | ); 212 | 213 | response = await bw.inject(t, { 214 | method: 'GET', 215 | url: '/api/authors/' + bookFromDb.author.id + '/books' 216 | }); 217 | 218 | t.equal(response.json().total, 1, 'API returns 1 refed book'); 219 | t.equal(response.json().items.length, 1, 'API returns 1 refed book'); 220 | t.match( 221 | response.json().items[0], 222 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 223 | 'Refed book' 224 | ); 225 | 226 | response = await bw.inject(t, { 227 | method: 'GET', 228 | url: '/api/authors/' + bookFromDb.coauthor.id + '/books' 229 | }); 230 | 231 | t.equal( 232 | response.json().total, 233 | 0, 234 | 'API returns no books where coauthor is author' 235 | ); 236 | t.equal( 237 | response.json().items.length, 238 | 0, 239 | 'API returns no books where coauthor is author' 240 | ); 241 | 242 | /// extra refs routes 243 | response = await bw.inject(t, { 244 | method: 'GET', 245 | url: '/api/authors/' + bookFromDb.coauthor.id + '/books_as_coauthor' 246 | }); 247 | 248 | t.equal(response.json().total, 1, 'API returns 1 refed book as coauthor'); 249 | t.equal( 250 | response.json().items.length, 251 | 1, 252 | 'API returns 1 refed book as coauthor' 253 | ); 254 | t.match( 255 | response.json().items[0], 256 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 257 | 'Refed book' 258 | ); 259 | }); 260 | 261 | test('GET single item with populated field', async t => { 262 | let bookFromDb = await bw.conn.models.Book.findOne({ 263 | title: 'The best book' 264 | }); 265 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 266 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 267 | 268 | let response = null; 269 | response = await bw.inject(t, { 270 | method: 'GET', 271 | url: '/api/books/' + bookFromDb.id + '?populate=author' 272 | }); 273 | 274 | t.match( 275 | response.json(), 276 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 277 | 'Book is ok' 278 | ); 279 | t.match( 280 | response.json().author, 281 | { 282 | firstName: bookFromDb.author.firstName, 283 | lastName: bookFromDb.author.lastName 284 | }, 285 | 'Populated author is ok' 286 | ); 287 | t.match( 288 | response.json().author, 289 | { _id: '' + bookFromDb.author.id }, 290 | 'Populated author id is ok' 291 | ); 292 | 293 | response = await bw.inject(t, { 294 | method: 'GET', 295 | url: '/api/books/' + bookFromDb.id + '?populate=coauthor' 296 | }); 297 | 298 | t.match( 299 | response.json(), 300 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 301 | 'Book is ok' 302 | ); 303 | t.match( 304 | response.json().coauthor, 305 | { 306 | firstName: bookFromDb.coauthor.firstName, 307 | lastName: bookFromDb.coauthor.lastName 308 | }, 309 | 'Populated coauthor is ok' 310 | ); 311 | t.match( 312 | response.json().coauthor, 313 | { _id: '' + bookFromDb.coauthor.id }, 314 | 'Populated coauthor id is ok' 315 | ); 316 | 317 | /// few populations 318 | response = await bw.inject(t, { 319 | method: 'GET', 320 | url: '/api/books/' + bookFromDb.id, 321 | query: 'populate[]=coauthor&populate[]=author' 322 | }); 323 | 324 | t.match( 325 | response.json(), 326 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 327 | 'Book is ok' 328 | ); 329 | t.match( 330 | response.json().coauthor, 331 | { 332 | firstName: bookFromDb.coauthor.firstName, 333 | lastName: bookFromDb.coauthor.lastName 334 | }, 335 | 'Populated coauthor is ok' 336 | ); 337 | t.match( 338 | response.json().coauthor, 339 | { _id: '' + bookFromDb.coauthor.id }, 340 | 'Populated coauthor id is ok' 341 | ); 342 | t.match( 343 | response.json().author, 344 | { 345 | firstName: bookFromDb.author.firstName, 346 | lastName: bookFromDb.author.lastName 347 | }, 348 | 'Populated author is ok' 349 | ); 350 | t.match( 351 | response.json().author, 352 | { _id: '' + bookFromDb.author.id }, 353 | 'Populated author id is ok' 354 | ); 355 | }); 356 | 357 | test('GET collection with few populated', async t => { 358 | let bookFromDb = await bw.conn.models.Book.findOne({ 359 | title: 'The best book' 360 | }); 361 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 362 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 363 | 364 | const response = await bw.inject(t, { 365 | method: 'GET', 366 | url: '/api/books', 367 | query: 'populate[]=coauthor&populate[]=author' 368 | }); 369 | 370 | t.match( 371 | response.json().items[0].coauthor, 372 | { _id: '' + bookFromDb.coauthor.id }, 373 | 'Populated coauthor id is ok' 374 | ); 375 | t.match( 376 | response.json().items[0].author, 377 | { 378 | firstName: bookFromDb.author.firstName, 379 | lastName: bookFromDb.author.lastName 380 | }, 381 | 'Populated author is ok' 382 | ); 383 | }); 384 | 385 | test('GET collection with single populated', async t => { 386 | let bookFromDb = await bw.conn.models.Book.findOne({ 387 | title: 'The best book' 388 | }); 389 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 390 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 391 | 392 | const response = await bw.inject(t, { 393 | method: 'GET', 394 | url: '/api/books?populate=coauthor' 395 | }); 396 | 397 | t.match( 398 | response.json().items[0].coauthor, 399 | { _id: '' + bookFromDb.coauthor.id }, 400 | 'Populated coauthor id is ok' 401 | ); 402 | t.equal( 403 | response.json().items[0].author, 404 | '' + bookFromDb.author.id, 405 | 'Author was not populated, there is just id' 406 | ); 407 | }); 408 | 409 | test('POST item with ref test', async t => { 410 | let bookFromDb = await bw.conn.models.Book.findOne({ 411 | title: 'The best book' 412 | }); 413 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 414 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 415 | 416 | let response = null; 417 | response = await bw.inject(t, { 418 | method: 'POST', 419 | url: '/api/books', 420 | payload: { 421 | title: 'Another One', 422 | isbn: 'isbn', 423 | author: '' + bookFromDb.author.id, 424 | coauthor: '' + bookFromDb.coauthor.id 425 | } 426 | }); 427 | t.match( 428 | response.json(), 429 | { 430 | title: 'Another One', 431 | isbn: 'isbn', 432 | author: '' + bookFromDb.author.id, 433 | coauthor: '' + bookFromDb.coauthor.id 434 | }, 435 | 'POST api ok' 436 | ); 437 | 438 | response = await bw.inject(t, { 439 | method: 'GET', 440 | url: '/api/authors/' + bookFromDb.author.id + '/books' 441 | }); 442 | 443 | t.equal(response.json().total, 2, 'API returns 2 refed books'); 444 | t.equal(response.json().items.length, 2, 'API returns 2 refed books'); 445 | 446 | response = await bw.inject(t, { 447 | method: 'GET', 448 | url: '/api/authors/' + bookFromDb.coauthor.id + '/books_as_coauthor' 449 | }); 450 | 451 | t.equal(response.json().total, 2, 'API returns 2 refed books as_coauthor'); 452 | t.equal( 453 | response.json().items.length, 454 | 2, 455 | 'API returns 2 refed books as_coauthor' 456 | ); 457 | }); 458 | 459 | test('PATCH item test', async t => { 460 | let bookFromDb = await bw.conn.models.Book.findOne({ 461 | title: 'The best book' 462 | }); 463 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 464 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 465 | 466 | const response = await bw.inject(t, { 467 | method: 'PATCH', 468 | url: '/api/books/' + bookFromDb.id, 469 | payload: { 470 | title: 'The best book patched', 471 | isbn: 'The best isbn patched' 472 | } 473 | }); 474 | 475 | t.match( 476 | response.json(), 477 | { 478 | title: 'The best book patched', 479 | isbn: 'The best isbn patched', 480 | author: '' + bookFromDb.author.id 481 | }, 482 | 'PUT api ok' 483 | ); 484 | t.match( 485 | response.json(), 486 | { author: '' + bookFromDb.author.id }, 487 | 'Author still refs to original' 488 | ); 489 | t.match( 490 | response.json(), 491 | { coauthor: '' + bookFromDb.coauthor.id }, 492 | 'coAuthor still refs to original' 493 | ); 494 | }); 495 | 496 | test('PUT item test', async t => { 497 | let bookFromDb = await bw.conn.models.Book.findOne({ 498 | title: 'The best book patched' 499 | }); 500 | // await bookFromDb.populate('author').execPopulate(); 501 | await bw.populateDoc(bookFromDb.populate('author')); 502 | 503 | const response = await bw.inject(t, { 504 | method: 'PUT', 505 | url: '/api/books/' + bookFromDb.id, 506 | payload: { 507 | title: 'The best book updated', 508 | isbn: 'The best isbn updated' 509 | } 510 | }); 511 | 512 | t.match( 513 | response.json(), 514 | { 515 | title: 'The best book updated', 516 | isbn: 'The best isbn updated', 517 | author: '' + bookFromDb.author.id 518 | }, 519 | 'PUT api ok' 520 | ); 521 | t.match( 522 | response.json(), 523 | { author: '' + bookFromDb.author.id }, 524 | 'Author still refs to original' 525 | ); 526 | }); 527 | 528 | test('DELETE item test', async t => { 529 | let bookFromDb = await bw.conn.models.Book.findOne({ 530 | title: 'The best book updated' 531 | }); 532 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 533 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 534 | bookFromDb.author; 535 | 536 | let response = null; 537 | response = await bw.inject(t, { 538 | method: 'DELETE', 539 | url: '/api/books/' + bookFromDb.id 540 | }); 541 | 542 | t.match(response.json(), { success: true }, 'DELETE api ok'); 543 | 544 | response = await bw.inject(t, { 545 | method: 'GET', 546 | url: '/api/authors/' + bookFromDb.author.id + '/books' 547 | }); 548 | 549 | t.equal(response.json().total, 1, 'API returns 1 refed books after delete'); 550 | t.equal( 551 | response.json().items.length, 552 | 1, 553 | 'API returns 1 refed books after delete' 554 | ); 555 | 556 | response = await bw.inject(t, { 557 | method: 'GET', 558 | url: '/api/authors/' + bookFromDb.coauthor.id + '/books_as_coauthor' 559 | }); 560 | 561 | t.equal( 562 | response.json().total, 563 | 1, 564 | 'API returns 1 refed books as_coauthor after delete' 565 | ); 566 | t.equal( 567 | response.json().items.length, 568 | 1, 569 | 'API returns 1 refed books as_coauthor after delete' 570 | ); 571 | }); 572 | 573 | test('POST item and return populated response test', async t => { 574 | let bookFromDb = await bw.conn.models.Book.findOne({ 575 | title: 'Another One' 576 | }); 577 | // await bookFromDb.populate('author').populate('coauthor').execPopulate(); 578 | await bw.populateDoc(bookFromDb.populate(['author', 'coauthor'])); 579 | 580 | const response = await bw.inject(t, { 581 | method: 'POST', 582 | url: '/api/books?populate[]=author&populate[]=coauthor', 583 | payload: { 584 | title: 'The populated book', 585 | isbn: 'isbn', 586 | author: '' + bookFromDb.author.id, 587 | coauthor: '' + bookFromDb.coauthor.id 588 | } 589 | }); 590 | 591 | t.match( 592 | response.json(), 593 | { title: 'The populated book', isbn: 'isbn' }, 594 | 'POST api ok' 595 | ); 596 | t.match( 597 | response.json().author, 598 | { 599 | firstName: bookFromDb.author.firstName, 600 | lastName: bookFromDb.author.lastName 601 | }, 602 | 'Populated author is ok' 603 | ); 604 | t.match( 605 | response.json().author, 606 | { _id: '' + bookFromDb.author.id }, 607 | 'Populated author id is ok' 608 | ); 609 | }); 610 | 611 | test('PUT item and return populated response test', async t => { 612 | let bookFromDb = await bw.conn.models.Book.findOne({ 613 | title: 'The populated book' 614 | }); 615 | // await bookFromDb.populate('author').execPopulate(); 616 | await bw.populateDoc(bookFromDb.populate('author')); 617 | 618 | const response = await bw.inject(t, { 619 | method: 'PUT', 620 | url: '/api/books/' + bookFromDb.id + '?populate=author', 621 | payload: { title: 'The populated book updated', isbn: 'isbn updated' } 622 | }); 623 | 624 | t.match( 625 | response.json(), 626 | { title: 'The populated book updated', isbn: 'isbn updated' }, 627 | 'PUT api ok' 628 | ); 629 | t.match( 630 | response.json().author, 631 | { 632 | firstName: bookFromDb.author.firstName, 633 | lastName: bookFromDb.author.lastName 634 | }, 635 | 'Populated author is ok' 636 | ); 637 | }); 638 | -------------------------------------------------------------------------------- /test/api.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tap'); 4 | const { test } = t; 5 | 6 | const mongoose = require('mongoose'); 7 | const BackwardWrapper = require('./BackwardWrapper.js'); 8 | 9 | const bw = new BackwardWrapper(t); 10 | 11 | test('mongoose db initialization', async () => { 12 | await bw.createConnection(); 13 | }); 14 | 15 | test('schema initialization', async t => { 16 | t.plan(2); 17 | 18 | const authorSchema = mongoose.Schema({ 19 | firstName: String, 20 | lastName: String, 21 | biography: String, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | } 26 | }); 27 | authorSchema.index({ 28 | firstName: 'text', 29 | lastName: 'text', 30 | biography: 'text' 31 | }); /// you can use wildcard here too: https://stackoverflow.com/a/28775709/1119169 32 | 33 | bw.conn.model('Author', authorSchema); 34 | 35 | const bookSchema = mongoose.Schema({ 36 | title: String, 37 | isbn: String, 38 | author: { 39 | type: mongoose.Schema.Types.ObjectId, 40 | ref: 'Author' 41 | }, 42 | created: { 43 | type: Date, 44 | default: Date.now 45 | } 46 | }); 47 | 48 | bw.conn.model('Book', bookSchema); 49 | 50 | t.ok(bw.conn.models.Author); 51 | t.ok(bw.conn.models.Book); 52 | }); 53 | 54 | test('clean up test collections', async () => { 55 | await bw.conn.models.Author.deleteMany({}).exec(); 56 | await bw.conn.models.Book.deleteMany({}).exec(); 57 | }); 58 | 59 | test('schema ok', async t => { 60 | let author = new bw.conn.models.Author(); 61 | author.firstName = 'Jay'; 62 | author.lastName = 'Kay'; 63 | author.biography = 'Lived. Died.'; 64 | 65 | await author.save(); 66 | 67 | let book = new bw.conn.models.Book(); 68 | book.title = 'The best book'; 69 | book.isbn = 'The best isbn'; 70 | book.author = author; 71 | 72 | await book.save(); 73 | 74 | let authorFromDb = await bw.conn.models.Author.findOne({ 75 | firstName: 'Jay' 76 | }).exec(); 77 | let bookFromDb = await bw.conn.models.Book.findOne({ 78 | title: 'The best book' 79 | }).exec(); 80 | 81 | t.ok(authorFromDb); 82 | t.ok(bookFromDb); 83 | 84 | await bw.populateDoc(bookFromDb.populate('author')); 85 | 86 | t.equal('' + bookFromDb.author._id, '' + authorFromDb._id); 87 | }); 88 | 89 | test('initialization of API server', async t => { 90 | await bw.createServer({ 91 | models: bw.conn.models, 92 | prefix: '/api/', 93 | setDefaults: true, 94 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 95 | }); 96 | t.equal( 97 | Object.keys(bw.fastify.mongooseAPI.apiRouters).length, 98 | 2, 99 | 'There are 2 APIRoutes, one for each model' 100 | ); 101 | 102 | t.equal( 103 | bw.fastify.mongooseAPI.apiRouters.Author.collectionName, 104 | 'authors', 105 | 'Collection name used in API path' 106 | ); 107 | t.equal( 108 | bw.fastify.mongooseAPI.apiRouters.Book.collectionName, 109 | 'books', 110 | 'Collection name used in API path' 111 | ); 112 | 113 | t.equal( 114 | bw.fastify.mongooseAPI.apiRouters.Author.path, 115 | '/api/authors', 116 | 'API path is composed with prefix + collectionName' 117 | ); 118 | t.equal( 119 | bw.fastify.mongooseAPI.apiRouters.Book.path, 120 | '/api/books', 121 | 'API path is composed with prefix + collectionName' 122 | ); 123 | }); 124 | 125 | test('GET collection endpoints', async t => { 126 | let response = null; 127 | response = await bw.inject(t, { 128 | method: 'GET', 129 | url: '/api/books' 130 | }); 131 | 132 | t.equal(response.json().total, 1, 'API returns 1 book'); 133 | 134 | response = await bw.inject(t, { 135 | method: 'GET', 136 | url: '/api/authors' 137 | }); 138 | 139 | t.equal(response.json().total, 1, 'API returns 1 author'); 140 | }); 141 | 142 | test('POST item test', async t => { 143 | let response = null; 144 | response = await bw.inject(t, { 145 | method: 'POST', 146 | url: '/api/authors', 147 | payload: { 148 | firstName: 'Hutin', 149 | lastName: 'Puylo', 150 | biography: 'The Little One' 151 | } 152 | }); 153 | 154 | t.match( 155 | response.json(), 156 | { firstName: 'Hutin', lastName: 'Puylo', biography: 'The Little One' }, 157 | 'POST api ok' 158 | ); 159 | 160 | response = await bw.inject(t, { 161 | method: 'GET', 162 | url: '/api/authors' 163 | }); 164 | 165 | t.equal(response.json().total, 2, 'There are two authors now'); 166 | }); 167 | 168 | test('GET collection filtering', async t => { 169 | const response = await bw.inject(t, { 170 | method: 'GET', 171 | url: '/api/authors', 172 | query: { filter: 'lastName=Puylo' } 173 | }); 174 | 175 | t.equal(response.json().total, 1, 'API returns 1 filtered author'); 176 | t.equal(response.json().items.length, 1, 'API returns 1 filtered author'); 177 | t.match( 178 | response.json().items[0], 179 | { firstName: 'Hutin', lastName: 'Puylo', biography: 'The Little One' }, 180 | 'Filtered author' 181 | ); 182 | }); 183 | 184 | test('GET collection search', async t => { 185 | const response = await bw.inject(t, { 186 | method: 'GET', 187 | url: '/api/authors', 188 | query: { search: 'One Little' } 189 | }); 190 | 191 | t.equal(response.json().total, 1, 'API returns 1 searched author'); 192 | t.equal(response.json().items.length, 1, 'API returns 1 searched author'); 193 | t.match( 194 | response.json().items[0], 195 | { firstName: 'Hutin', lastName: 'Puylo', biography: 'The Little One' }, 196 | 'Filtered author' 197 | ); 198 | }); 199 | 200 | test('GET collection regex match', async t => { 201 | const response = await bw.inject(t, { 202 | method: 'GET', 203 | url: '/api/authors', 204 | query: { match: 'lastName=Puy' } 205 | }); 206 | 207 | t.equal(response.json().total, 1, 'API returns 1 searched author'); 208 | t.equal(response.json().items.length, 1, 'API returns 1 searched author'); 209 | t.match( 210 | response.json().items[0], 211 | { firstName: 'Hutin', lastName: 'Puylo', biography: 'The Little One' }, 212 | 'Filtered author' 213 | ); 214 | }); 215 | 216 | test('GET collection case-insensitive regex match', async t => { 217 | const response = await bw.inject(t, { 218 | method: 'GET', 219 | url: '/api/authors', 220 | query: { match: 'lastName=(?i)puy' } 221 | }); 222 | 223 | t.equal(response.json().total, 1, 'API returns 1 searched author'); 224 | t.equal(response.json().items.length, 1, 'API returns 1 searched author'); 225 | t.match( 226 | response.json().items[0], 227 | { firstName: 'Hutin', lastName: 'Puylo', biography: 'The Little One' }, 228 | 'Filtered author' 229 | ); 230 | }); 231 | 232 | test('GET collection sorting', async t => { 233 | let response = null; 234 | 235 | response = await bw.inject(t, { 236 | method: 'GET', 237 | url: '/api/authors', 238 | query: { sort: 'created' } 239 | }); 240 | 241 | t.equal(response.json().total, 2, 'API returns 2 sorted authors'); 242 | t.equal(response.json().items.length, 2, 'API returns 2 sorted authors'); 243 | t.match(response.json().items[0], { firstName: 'Jay' }, 'The oldest first'); 244 | 245 | response = await bw.inject(t, { 246 | method: 'GET', 247 | url: '/api/authors', 248 | query: { sort: '-created' } 249 | }); 250 | 251 | t.equal(response.json().total, 2, 'API returns 2 sorted authors'); 252 | t.equal(response.json().items.length, 2, 'API returns 2 sorted authors'); 253 | t.match( 254 | response.json().items[0], 255 | { firstName: 'Hutin' }, 256 | 'Most recent first' 257 | ); 258 | }); 259 | 260 | test('GET collection pagination', async t => { 261 | let response = null; 262 | 263 | response = await bw.inject(t, { 264 | method: 'GET', 265 | url: '/api/authors', 266 | query: { limit: 1, offset: 0, sort: '-created' } 267 | }); 268 | 269 | t.equal(response.json().total, 2, 'Total is everything'); 270 | t.equal(response.json().items.length, 1, 'Returned is paginated'); 271 | t.match( 272 | response.json().items[0], 273 | { firstName: 'Hutin' }, 274 | 'Most recent is on the first page' 275 | ); 276 | 277 | response = await bw.inject(t, { 278 | method: 'GET', 279 | url: '/api/authors', 280 | query: { limit: 1, offset: 1, sort: '-created' } 281 | }); 282 | 283 | t.equal(response.json().total, 2, 'Total is everything'); 284 | t.equal(response.json().items.length, 1, 'Returned is paginated'); 285 | t.match( 286 | response.json().items[0], 287 | { firstName: 'Jay' }, 288 | 'Older is on the second page' 289 | ); 290 | }); 291 | 292 | test('GET collection projection', async t => { 293 | let response = null; 294 | 295 | response = await bw.inject(t, { 296 | method: 'GET', 297 | url: '/api/authors', 298 | query: { fields: 'firstName,lastName' } 299 | }); 300 | 301 | t.equal(response.json().total, 2, 'Total is everything'); 302 | t.equal(response.json().items.length, 2, 'API returns everything'); 303 | t.same( 304 | Object.keys(response.json().items[0]), 305 | ['_id', 'firstName', 'lastName'], 306 | 'Only contains projection and _id' 307 | ); 308 | t.same( 309 | Object.keys(response.json().items[1]), 310 | ['_id', 'firstName', 'lastName'], 311 | 'Only contains projection and _id' 312 | ); 313 | 314 | response = await bw.inject(t, { 315 | method: 'GET', 316 | url: '/api/authors', 317 | query: { fields: '-firstName,-lastName,-__v' } 318 | }); 319 | 320 | t.equal(response.json().total, 2, 'Total is everything'); 321 | t.equal(response.json().items.length, 2, 'API returns everything'); 322 | t.same( 323 | Object.keys(response.json().items[0]).sort(), 324 | ['_id', 'created', 'biography'].sort(), 325 | 'Exclude projection fields' 326 | ); 327 | t.same( 328 | Object.keys(response.json().items[1]).sort(), 329 | ['_id', 'created', 'biography'].sort(), 330 | 'Exclude projection fields' 331 | ); 332 | 333 | response = await bw.inject( 334 | t, 335 | { 336 | method: 'GET', 337 | url: '/api/authors', 338 | query: { fields: '-firstName,lastName' } 339 | }, 340 | 500 341 | ); 342 | }); 343 | 344 | test('GET single item', async t => { 345 | let authorFromDb = await bw.conn.models.Author.findOne({ 346 | firstName: 'Jay' 347 | }); 348 | let bookFromDb = await bw.conn.models.Book.findOne({ 349 | title: 'The best book' 350 | }); 351 | 352 | let response = null; 353 | response = await bw.inject(t, { 354 | method: 'GET', 355 | url: '/api/books/' + bookFromDb.id, 356 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 357 | }); 358 | 359 | t.match( 360 | response.json(), 361 | { title: 'The best book', isbn: 'The best isbn' }, 362 | 'Single item data ok' 363 | ); 364 | t.match(response.json(), { _id: bookFromDb.id }, 'Single item id ok'); 365 | 366 | response = await bw.inject(t, { 367 | method: 'GET', 368 | url: '/api/authors/' + authorFromDb.id, 369 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 370 | }); 371 | 372 | t.match(response.json(), { firstName: 'Jay' }, 'Single item data ok'); 373 | t.match(response.json(), { _id: authorFromDb.id }, 'Single item id ok'); 374 | }); 375 | 376 | test('GET single item 404', async t => { 377 | await bw.inject( 378 | t, 379 | { 380 | method: 'GET', 381 | url: '/api/books/SOMEWRONGID', 382 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 383 | }, 384 | 404 385 | ); 386 | }); 387 | 388 | test('GET single item Refs', async t => { 389 | let bookFromDb = await bw.conn.models.Book.findOne({ 390 | title: 'The best book' 391 | }); 392 | await bw.populateDoc(bookFromDb.populate('author')); 393 | 394 | let response = null; 395 | response = await bw.inject(t, { 396 | method: 'GET', 397 | url: '/api/books/' + bookFromDb.id + '/author', 398 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 399 | }); 400 | 401 | t.match( 402 | response.json(), 403 | { 404 | firstName: bookFromDb.author.firstName, 405 | lastName: bookFromDb.author.lastName 406 | }, 407 | 'Single item Ref ok' 408 | ); 409 | t.match( 410 | response.json(), 411 | { _id: '' + bookFromDb.author.id }, 412 | 'Single item id ok' 413 | ); 414 | 415 | response = await bw.inject(t, { 416 | method: 'GET', 417 | url: '/api/authors/' + bookFromDb.author.id + '/books', 418 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 419 | }); 420 | 421 | t.equal(response.json().total, 1, 'API returns 1 refed book'); 422 | t.equal(response.json().items.length, 1, 'API returns 1 refed book'); 423 | t.match( 424 | response.json().items[0], 425 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 426 | 'Refed book' 427 | ); 428 | }); 429 | 430 | test('GET single item with populated field', async t => { 431 | let bookFromDb = await bw.conn.models.Book.findOne({ 432 | title: 'The best book' 433 | }); 434 | await bw.populateDoc(bookFromDb.populate('author')); 435 | 436 | let response = null; 437 | response = await bw.inject(t, { 438 | method: 'GET', 439 | url: '/api/books/' + bookFromDb.id + '?populate=author', 440 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 441 | }); 442 | 443 | t.match( 444 | response.json(), 445 | { title: bookFromDb.title, isbn: bookFromDb.isbn }, 446 | 'Book is ok' 447 | ); 448 | t.match( 449 | response.json().author, 450 | { 451 | firstName: bookFromDb.author.firstName, 452 | lastName: bookFromDb.author.lastName 453 | }, 454 | 'Populated author is ok' 455 | ); 456 | t.match( 457 | response.json().author, 458 | { _id: '' + bookFromDb.author.id }, 459 | 'Populated author id is ok' 460 | ); 461 | }); 462 | 463 | test('POST item with ref test', async t => { 464 | let bookFromDb = await bw.conn.models.Book.findOne({ 465 | title: 'The best book' 466 | }); 467 | await bw.populateDoc(bookFromDb.populate('author')); 468 | 469 | let response = null; 470 | response = await bw.inject(t, { 471 | method: 'POST', 472 | url: '/api/books', 473 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 474 | payload: JSON.stringify({ 475 | title: 'Another One', 476 | isbn: 'isbn', 477 | author: '' + bookFromDb.author.id 478 | }) 479 | }); 480 | 481 | t.match( 482 | response.json(), 483 | { 484 | title: 'Another One', 485 | isbn: 'isbn', 486 | author: '' + bookFromDb.author.id 487 | }, 488 | 'POST api ok' 489 | ); 490 | 491 | response = await bw.inject(t, { 492 | method: 'GET', 493 | url: '/api/authors/' + bookFromDb.author.id + '/books', 494 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 495 | }); 496 | 497 | t.equal(response.json().total, 2, 'API returns 2 refed books'); 498 | t.equal(response.json().items.length, 2, 'API returns 2 refed books'); 499 | }); 500 | 501 | test('PATCH item test', async t => { 502 | let bookFromDb = await bw.conn.models.Book.findOne({ 503 | title: 'The best book' 504 | }); 505 | await bw.populateDoc(bookFromDb.populate('author')); 506 | 507 | const response = await bw.inject(t, { 508 | method: 'PATCH', 509 | url: '/api/books/' + bookFromDb.id, 510 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 511 | payload: JSON.stringify({ 512 | title: 'The best book patched', 513 | isbn: 'The best isbn patched' 514 | }) 515 | }); 516 | 517 | t.match( 518 | response.json(), 519 | { 520 | title: 'The best book patched', 521 | isbn: 'The best isbn patched', 522 | author: '' + bookFromDb.author.id 523 | }, 524 | 'PUT api ok' 525 | ); 526 | t.match( 527 | response.json(), 528 | { author: '' + bookFromDb.author.id }, 529 | 'Author still refs to original' 530 | ); 531 | }); 532 | 533 | test('PATCH single item 404', async t => { 534 | await bw.inject( 535 | t, 536 | { 537 | method: 'PATCH', 538 | url: '/api/books/SOMEWRONGID', 539 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 540 | body: { 541 | title: 'The best book patched', 542 | isbn: 'The best isbn patched' 543 | } 544 | }, 545 | 404 546 | ); 547 | }); 548 | 549 | test('PUT item test', async t => { 550 | let bookFromDb = await bw.conn.models.Book.findOne({ 551 | title: 'The best book patched' 552 | }); 553 | await bw.populateDoc(bookFromDb.populate('author')); 554 | 555 | const response = await bw.inject(t, { 556 | method: 'PUT', 557 | url: '/api/books/' + bookFromDb.id, 558 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 559 | payload: JSON.stringify({ 560 | title: 'The best book updated', 561 | isbn: 'The best isbn updated' 562 | }) 563 | }); 564 | 565 | t.match( 566 | response.json(), 567 | { 568 | title: 'The best book updated', 569 | isbn: 'The best isbn updated', 570 | author: '' + bookFromDb.author.id 571 | }, 572 | 'PUT api ok' 573 | ); 574 | t.match( 575 | response.json(), 576 | { author: '' + bookFromDb.author.id }, 577 | 'Author still refs to original' 578 | ); 579 | }); 580 | 581 | test('PUT single item 404', async t => { 582 | await bw.inject( 583 | t, 584 | { 585 | method: 'PUT', 586 | url: '/api/books/SOMEWRONGID', 587 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 588 | body: { 589 | title: 'The best book updated', 590 | isbn: 'The best isbn updated' 591 | } 592 | }, 593 | 404 594 | ); 595 | }); 596 | 597 | test('DELETE item test', async t => { 598 | let bookFromDb = await bw.conn.models.Book.findOne({ 599 | title: 'The best book updated' 600 | }); 601 | await bw.populateDoc(bookFromDb.populate('author')); 602 | bookFromDb.author; 603 | 604 | let response = null; 605 | response = await bw.inject(t, { 606 | method: 'DELETE', 607 | url: '/api/books/' + bookFromDb.id, 608 | body: { id: bookFromDb.id }, // https://github.com/fastify/fastify/pull/5419 609 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 610 | }); 611 | 612 | t.match(response.json(), { success: true }, 'DELETE api ok'); 613 | 614 | response = await bw.inject(t, { 615 | method: 'GET', 616 | url: '/api/authors/' + bookFromDb.author.id + '/books', 617 | body: { id: bookFromDb.id }, // https://github.com/fastify/fastify/pull/5419 618 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 619 | }); 620 | 621 | t.equal(response.json().total, 1, 'API returns 1 refed books after delete'); 622 | t.equal( 623 | response.json().items.length, 624 | 1, 625 | 'API returns 1 refed books after delete' 626 | ); 627 | }); 628 | 629 | test('DELETE single item 404', async t => { 630 | await bw.inject( 631 | t, 632 | { 633 | method: 'DELETE', 634 | url: '/api/books/SOMEWRONGID', 635 | body: { id: 'SOMEWRONGID' }, // https://github.com/fastify/fastify/pull/5419 636 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 637 | }, 638 | 404 639 | ); 640 | await bw.inject( 641 | t, 642 | { 643 | method: 'DELETE', 644 | url: '/api/books/SOMEWRONGID', 645 | body: {}, // https://github.com/fastify/fastify/pull/5419 646 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 647 | }, 648 | 404 649 | ); 650 | }); 651 | 652 | test('POST item and return populated response test', async t => { 653 | let bookFromDb = await bw.conn.models.Book.findOne({ 654 | title: 'Another One' 655 | }); 656 | await bw.populateDoc(bookFromDb.populate('author')); 657 | 658 | const response = await bw.inject(t, { 659 | method: 'POST', 660 | url: '/api/books?populate=author', 661 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 662 | payload: JSON.stringify({ 663 | title: 'The populated book', 664 | isbn: 'isbn', 665 | author: '' + bookFromDb.author.id 666 | }) 667 | }); 668 | 669 | t.match( 670 | response.json(), 671 | { title: 'The populated book', isbn: 'isbn' }, 672 | 'POST api ok' 673 | ); 674 | t.match( 675 | response.json().author, 676 | { 677 | firstName: bookFromDb.author.firstName, 678 | lastName: bookFromDb.author.lastName 679 | }, 680 | 'Populated author is ok' 681 | ); 682 | t.match( 683 | response.json().author, 684 | { _id: '' + bookFromDb.author.id }, 685 | 'Populated author id is ok' 686 | ); 687 | }); 688 | 689 | test('PUT item and return populated response test', async t => { 690 | let bookFromDb = await bw.conn.models.Book.findOne({ 691 | title: 'The populated book' 692 | }); 693 | await bw.populateDoc(bookFromDb.populate('author')); 694 | 695 | const response = await bw.inject(t, { 696 | method: 'PUT', 697 | url: '/api/books/' + bookFromDb.id + '?populate=author', 698 | headers: { 'Content-Type': 'application/json; charset=utf-8' }, 699 | payload: JSON.stringify({ 700 | title: 'The populated book updated', 701 | isbn: 'isbn updated' 702 | }) 703 | }); 704 | 705 | t.match( 706 | response.json(), 707 | { title: 'The populated book updated', isbn: 'isbn updated' }, 708 | 'PUT api ok' 709 | ); 710 | t.match( 711 | response.json().author, 712 | { 713 | firstName: bookFromDb.author.firstName, 714 | lastName: bookFromDb.author.lastName 715 | }, 716 | 'Populated author is ok' 717 | ); 718 | }); 719 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fastify plugin to expose API for Mongoose MongoDB models 2 | 3 | [![npm package](https://img.shields.io/npm/v/fastify-mongoose-api.svg)](http://npmjs.org/package/fastify-mongoose-api) 4 | [![Build workflow](https://github.com/jeka-kiselyov/fastify-mongoose-api/actions/workflows/build.yml/badge.svg)](https://github.com/jeka-kiselyov/fastify-mongoose-api/actions/workflows/build.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/jeka-kiselyov/fastify-mongoose-api/badge.svg?branch=master)](https://coveralls.io/github/jeka-kiselyov/fastify-mongoose-api?branch=master) 6 | ![Last Commit](https://img.shields.io/github/last-commit/jeka-kiselyov/fastify-mongoose-api) 7 | ![Dependencies](https://img.shields.io/librariesio/github/jeka-kiselyov/fastify-mongoose-api) 8 | ![Downloads](https://img.shields.io/npm/dt/fastify-mongoose-api) 9 | 10 | If you are using [Fastify](https://github.com/fastify/fastify) as your server and [Mongoose](https://github.com/Automattic/mongoose) as your ODM, **fastify-mongoose-api** is the easiest solution to run API server for your models. **fastify-mongoose-api** generates REST routes with refs subroutes like `/api/author/AUTHORID/books` and `/api/books/BOOKID/author` based on MongoDB Mongoose models definitions with few lines of code. 11 | 12 | ### As simple as: 13 | ```javascript 14 | const fastify = Fastify(); 15 | fastify.register(fastifyFormbody); /// need form body to accept API parameters, both fastify-formbody and @fastify/formbody would work here 16 | fastify.register(fastifyMongooseAPI, { /// here we are registering our plugin 17 | models: mongooseConnection.models, /// Mongoose connection models 18 | prefix: '/api/', /// URL prefix. e.g. http://localhost/api/... 19 | setDefaults: true, /// you can specify your own api methods on models, our trust our default ones, check em [here](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/src/DefaultModelMethods.js) 20 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] /// HTTP methods 21 | }); 22 | 23 | await fastify.ready(); /// waiting for plugins registration 24 | await fastify.listen(8080); /// running the server 25 | //// yep, right here we already have API server running on port 8080 with methods for all MongoDB models of your mongoose instance. 26 | ``` 27 | - [Installation](#installation) 28 | - [Initialization and parameters](#initialization) 29 | - Sample application ([Source code](https://github.com/jeka-kiselyov/sample-fastify-mongoose-api-app), [Live demo](https://fastify-mongoose-api-app.herokuapp.com/)) 30 | - [Auto generated method routes for sample application](#sample-application-generated-api-routes) 31 | - [Create or Update mode (CoU-mode) or Replace mode (CoR-mode)](#create-or-update-mode-cou-mode-or-replace-mode-cor-mode) 32 | - [POST/PUT on frontend samples](#postput-sample-on-frontend) 33 | - [LIST methods response](#list-method-response-sample) 34 | - [LIST methods options (pagination, projection, sorting, filtering, complex where, search, regex match, populate)](#list-method-options) 35 | - [Handle extra LIST cases, custom filtering etc](#handle-extra-cases) 36 | - [Validation and serialization](#validation-and-serialization) 37 | - [Disable/Limit some routes/methods](#disable-some-routesmethods) 38 | - [Populate on POST, PUT and single item GET methods)](#populate-on-post-put-and-single-item-get-methods) 39 | - [Subroutes when there're few refs to the same model)](#subroutes-when-therere-few-refs-to-the-same-model) 40 | - [How to hide document properties/fields in API response?](#how-to-hide-specific-fieldsproperties-in-api-response) 41 | - [How can I post/put nested paths?](#how-can-i-post-or-put-nested-paths-and-their-properties) 42 | - [How to enable CORS for cross-domain requests?](#cors) 43 | - [How to implement authorization?](#checkauth--function) 44 | - [Unit tests](#tests) 45 | 46 | ## Installation 47 | 48 | ```bash 49 | npm i fastify-mongoose-api -s 50 | ``` 51 | 52 | ## Initialization 53 | 54 | Register plugin on fastify instance: 55 | 56 | ```javascript 57 | const fastify = Fastify(); 58 | fastify.register(fastifyFormbody); // both fastify-formbody and @fastify/formbody would work 59 | fastify.register(fastifyMongooseAPI, options); 60 | ``` 61 | 62 | with following options: 63 | 64 | #### .models : array of mongoose models 65 | 66 | Required. Array of mongoose models. Usually you can get them from mongoose connection object like: 67 | ```javascript 68 | let connection = await mongoose.createConnection(this.config.database.database, options); 69 | /// ... register mongoose models 70 | connection.model('Author', schema); 71 | connection.model('Book', schema); 72 | /// ... connection models is ready for fastify-mongoose-api 73 | connection.models 74 | ``` 75 | 76 | #### .prefix : string (default: '/api/') 77 | 78 | Path prefix. Default is `/api/`. 79 | 80 | #### .setDefaults : boolean (default: true) 81 | 82 | Initialize api with default REST methods 83 | 84 | #### .exposeVersionKey : boolean (default: true) 85 | 86 | Show documents `__v` in API response 87 | 88 | #### .exposeModelName : boolean | string (default: false) 89 | 90 | Show mongoose Model Name property in API response. Default property name is `.__modelName` , specify exposeModelName as string to name this field as custom. 91 | 92 | If `true` it adds `__modelName` to all responses (get, list, post/put, populated too): 93 | 94 | ```javascript 95 | 96 | { total: 1, 97 | items: 98 | [ { _id: '5d2620aff4df8b3c4f4f03d6', 99 | created: '2019-07-10T17:30:23.486Z', 100 | firstName: 'Jay', 101 | lastName: 'Kay', 102 | biography: 'Lived. Died.', 103 | __modelName: 'Author' 104 | __v: 0 }, 105 | ] 106 | } 107 | ``` 108 | 109 | #### .methods : array of strings 110 | 111 | Methods to initialize, `['list', 'get', 'post', 'patch', 'put', 'delete', 'options']` is available. 112 | 113 | #### .checkAuth : function 114 | 115 | Function to run before any API request to check authorization permissions in. Just throw an error in it if user is now allowed to perform an action. 116 | 117 | ```javascript 118 | 119 | fastify.register(fastifyMongooseAPI, { 120 | models: this.db.connection.models, 121 | checkAuth: async (req, reply)=>{ 122 | let ac = await this.db.AuthCode.findOne({authCode: req.cookies.authCode}).populate('user').exec(); /// fastify-cookie plugin for req.cookie 123 | if (!ac || !ac.user) { 124 | throw new Error('You are not authorized to be here'); 125 | } 126 | } 127 | }); 128 | ``` 129 | 130 | #### .schemas: array of objects 131 | 132 | Enable support for fastify [validation and serialization](#validation-and-serialization). If `.schemaDirPath` is defined, these explicitly defined here have precedence. 133 | 134 | #### .schemaDirPath: string 135 | 136 | Directory where it's possible to define schemas for [validation and serialization](#validation-and-serialization) in separate files. The directory will be trasverse includes all subdirectories using .schemaPathFilter as filter function to determinate if a file must be included. 137 | 138 | #### .schemaPathFilter: function (default `(pathFile, file) => file.endsWith('.js')`) 139 | 140 | A boolean function called for every file in `schemaDirPath` to determinate if file must be included. As default, all `.js` files 141 | are added. 142 | 143 | * `pathFile` is the path of the file 144 | * `file` is the fileName with extension. 145 | 146 | ## Sample Application 147 | 148 | Sample application ([Source code](https://github.com/jeka-kiselyov/sample-fastify-mongoose-api-app), [Live demo](https://fastify-mongoose-api-app.herokuapp.com/)) with Vue.js UI, simple Auth integration, ready to run on Heroku. 149 | 150 | You can also check plugin's [unit test file](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/test/api.test.js). 151 | 152 | ### Sample models 153 | 154 | We are defining two classic models. Books and author with one to many relation between them. 155 | 156 | ``` javascript 157 | const mongoose = require('mongoose'); 158 | const mongooseConnection = await mongoose.createConnection(MONGODB_URL, { useNewUrlParser: true }); 159 | 160 | const authorSchema = mongoose.Schema({ 161 | firstName: String, 162 | lastName: String, 163 | biography: String, 164 | created: { type: Date, default: Date.now } 165 | }); 166 | 167 | const Author = mongooseConnection.model('Author', authorSchema); 168 | 169 | const bookSchema = mongoose.Schema({ 170 | title: String, 171 | isbn: String, 172 | author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' }, 173 | created: { type: Date, default: Date.now } 174 | }); 175 | 176 | const Book = mongooseConnection.model('Book', bookSchema); 177 | ``` 178 | ### Sample application server 179 | Should be easy here 180 | ```javascript 181 | const Fastify = require('fastify'); 182 | const fastifyMongooseAPI = require('fastify-mongoose-api'); 183 | const fastifyFormbody = require('fastify-formbody'); 184 | 185 | const fastify = Fastify(); 186 | fastify.register(fastifyFormbody); 187 | fastify.register(fastifyMongooseAPI, { 188 | models: mongooseConnection.models, 189 | prefix: '/api/', 190 | setDefaults: true, 191 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 192 | }); 193 | 194 | await fastify.ready(); 195 | await fastify.listen(8080); 196 | ``` 197 | ### Sample application generated API routes 198 | 199 | | | Method | URL | Info | 200 | | ------------- | ------------- | ----- | ----- | 201 | | List all authors | GET | /api/authors | Pagination, sorting, search and filtering [are ready](#list-method-options) | 202 | | List all books | GET | /api/books | Want to get populated refs in response? [You can](#populate) | 203 | | Create new author | POST | /api/authors | Send properties with post body [sample](https://github.com/jeka-kiselyov/sample-fastify-mongoose-api-app/blob/master/frontend/src/includes/api.js#L23) | 204 | | Create new book | POST | /api/books | | 205 | | Create or update a book | POST | /api/books | [CoU-mode](#create-or-update-mode-cou-mode-or-replace-mode-cor-mode) with `X-HTTP-Method: CoU` 206 | | Create or replace a book | POST | /api/books | [CoR-mode](#create-or-update-mode-cou-mode-or-replace-mode-cor-mode) with `X-HTTP-Method: CoR` 207 | | Get single author | GET | /api/authors/AUTHORID | | 208 | | Get author books | GET | /api/authors/AUTHORID/books | Plugin builds relations based on models definition | 209 | | Get book author | GET | /api/books/BOOKID/author | Same in reverse way | 210 | | Update author | PUT | /api/authors/AUTHORID | Send properties using post body | 211 | | Update book | PUT | /api/books/BOOKID | | 212 | | Delete book | DELETE | /api/books/BOOKID | Be careful | 213 | | Delete author | DELETE | /api/authors/AUTHORID | | 214 | 215 | ## Post/Put sample on frontend 216 | 217 | ```javascript 218 | await axios.post('/api/books', {title: 'The Book'}); 219 | await axios.put('/api/books/xxxxx', {title: 'The Book Updated'}); 220 | await axios.put('/api/books/xxxxx', {title: 'The Book Updated'}, {params: {populate: 'author'}}); 221 | 222 | ``` 223 | 224 | ## List method response sample 225 | 226 | Sample API response for `List all authors` method: 227 | 228 | ```javascript 229 | { total: 2, 230 | items: 231 | [ { _id: '5d2620aff4df8b3c4f4f03d6', 232 | created: '2019-07-10T17:30:23.486Z', 233 | firstName: 'Jay', 234 | lastName: 'Kay', 235 | biography: 'Lived. Died.', 236 | __v: 0 }, 237 | { _id: '5d2620aff4df8b3c4f4f03d8', 238 | created: '2019-07-10T17:30:23.566Z', 239 | firstName: 'Hutin', 240 | lastName: 'Puylo', 241 | biography: 'The Little One', 242 | __v: 0 } ] } 243 | ``` 244 | 245 | ## List method options 246 | 247 | Pass all options as URL GET parameters, e.g. /api/books?option=some&option2=better Works very same for other LIST routes, `/api/authors/AUTHORID/books` etc. 248 | 249 | ### Pagination 250 | 251 | | | Option Name | Default Value | 252 | | ------- | ----------- | ------------- | 253 | | Offset | offset | 0 | 254 | | Limit | limit | 100 | 255 | 256 | ### Sorting 257 | 258 | Pass sort option string as described in [Mongoose docs](https://mongoosejs.com/docs/api.html#query_Query-sort), e.g. 'name' for sorting by name field or '-name' for descending sort by it. 259 | 260 | | | Option Name | Default Value | 261 | | ------- | ----------- | ------------- | 262 | | Sort | sort | null | 263 | 264 | ### Filtering 265 | 266 | Simple filtering by field value is available. /api/books?filter=isbn%3Dsomeisbnval will return all books with isbn equals to 'someisbnval'. %3D here is urlencoded '=' symbol, so actual option value is 'isbn=someisbnval' 267 | 268 | | | Option Name | Default Value | 269 | | ------- | ----------- | ------------- | 270 | | Filter | filter | null | 271 | 272 | #### Filtering by Boolean property 273 | 274 | Though you pass property value directly as boolean to create new entity or update one: 275 | ```javascript 276 | await axios.post('/api/books', {title: 'Some Book', isGood: false}); 277 | ``` 278 | 279 | Filtering by that value may be implemented using number representation of boolean (0/1): 280 | ```javascript 281 | await axios.get('/api/books', {params: {filter: 'isGood=0'}}); 282 | ``` 283 | 284 | See [test case](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/test/boolean_fields.test.js) 285 | 286 | ### Complex Where Queries 287 | 288 | Pass mongo where object as `where` property JSON-encoded string and it will be added to list filters. 289 | `where: "{\"count\": 2}"` or `JSON.stringify({$and: [{appleCount: {$gt: 1}}, {bananaCount: {$lt: 5}}]})` 290 | 291 | Plugin uses simple sanitation, list of allowed operators: 292 | ```javascript 293 | '$eq', '$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin', '$and', '$not', '$nor', '$or', '$exists', '$regex', '$options' 294 | ``` 295 | For `$regex/$options` it's supported only the 296 | 297 | ```json 298 | { "": { "$regex": "pattern", "$options": "" } } 299 | ``` 300 | 301 | [syntax](https://www.mongodb.com/docs/manual/reference/operator/query/regex/#syntax). Using 302 | 303 | ```json 304 | { "": { "$regex": /pattern/, ... 305 | ``` 306 | syntax it's not supported and produce an error. 307 | 308 | See [Mongo operators docs](https://www.mongodb.com/docs/manual/reference/operator/query/#query-and-projection-operators) 309 | And plugin [test case](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/test/complex_where.test.js) 310 | for more info. 311 | 312 | 313 | | | Option Name | Default Value | 314 | | ------- | ----------- | ------------- | 315 | | Where | where | null | 316 | 317 | ### Regex match 318 | 319 | Use it for pattern matching. Useful for things like autocomplete etc. [Check mongodb docs](https://docs.mongodb.com/manual/reference/operator/query/regex/#pcre-vs-javascript) how to pass regex options in pattern string, e.g. `(?i)pattern` to turn case-insensitivity on. Pass param in the same way as for filtering, `/api/authors?match=lastName%3D(?i)vonnegut` 320 | 321 | | | Option Name | Default Value | 322 | | ------- | ----------- | ------------- | 323 | | Regex | match | null | 324 | 325 | ### Search 326 | 327 | Performs search by [full text mongodb indexes](https://docs.mongodb.com/manual/core/index-text/). First you have to [specify one or few text indexes](https://stackoverflow.com/a/28775709/1119169) in your model schema. You don't have to specify field name for this parameter, mongo will perform full text search on all available indexes. 328 | 329 | | | Option Name | Default Value | 330 | | ------- | ----------- | ------------- | 331 | | Search | search | null | 332 | 333 | ### Projection 334 | 335 | Projects the first element in an array that matches the field. `/api/authors?fields=firstName,lastName` will only return `_id, firstName, lastName`. You can also exclude fields by using `-`, i.e. `?fields=-firstName` which will return everything except the `firstName` field. 336 | 337 | | | Option Name | Default Value | 338 | | -------- | ----------- | ------------- | 339 | |Projection| fields | null | 340 | 341 | ### Populate 342 | 343 | If you want API response to include nested objects, just pass populate string in parameter, it will run `populate(param)` before sending response to client. To populate few fields, pass them as array, `?populate[]=author&populate[]=shop` 344 | 345 | | | Option Name | Default Value | 346 | | ------- | ----------- | ------------- | 347 | | Populate| populate | null | 348 | 349 | ### Handle extra cases 350 | 351 | You can create hook method on any model to handle its List requests. 352 | 353 | ```javascript 354 | schema.statics.onListQuery = async function(query, request) { 355 | let notSeen = request.query.notSeen ? request.query.notSeen : null; 356 | if (notSeen) { 357 | query = query.and({sawBy: {$ne: request.user._id}}); 358 | } 359 | } 360 | ``` 361 | query is Mongoose query object, so you can extend it by any [query object's methods](https://mongoosejs.com/docs/api.html#Query) depending on your state or request data. 362 | 363 | Note: **do not** return anything in this method. 364 | 365 | ## Validation and Serialization 366 | 367 | Generated API can support standard fastify validation and serialization via `.schemas` option. 368 | 369 | If you are not confidable with fastify validation and serialization logics, see [documentation](https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/). 370 | 371 | If you don't set some schemas, API works without validation (except, of course, that inherent in the db schema). 372 | 373 | If you wish to add a validation and/or a serialization schema for your api you should add an object to `.schemas` array or set a directory where automatically load schemas with `.schemaDirPath`: 374 | 375 | ```javascript 376 | 377 | fastify.register(fastifyMongooseAPI, { 378 | models: this.db.connection.models, 379 | schemas: [ 380 | { 381 | name: 'collection_name', 382 | routeGet: {}, 383 | routePost: {}, 384 | routeList: {}, 385 | routePut: {}, 386 | routePatch: {}, 387 | routeDelete: {}, 388 | }, 389 | { name: 'another_collection_name', 390 | ... 391 | }, 392 | ... 393 | ], 394 | schemaDirPath: '/path/to/your/schemas', 395 | 396 | ``` 397 | 398 | where `name` is the collection to which this schema will be applied and `route*` are the validation and/or serialization schemas for related restful http verbs. 399 | 400 | If you omit one of these, the related verbs will be generated *without* a schema. 401 | 402 | If you set to empty one, [these](src/DefaultSchemas.js) defaults will be added. 403 | 404 | If you set an not empty one, it will be merged with defaults, with, obviously, custom parameters with precedence. 405 | 406 | As an example, it declares author first and last name as required. We should implement this in `POST`, `PUT` and `PATCH` verbs. Do this for `POST` only 407 | 408 | ```javascript 409 | 410 | const schemas = { 411 | name: 'authors', 412 | routePost: { 413 | body: { 414 | properties: { 415 | firstName: { type: 'string' }, 416 | lastName: { type: 'string' }, 417 | biography: { type: 'string' } 418 | }, 419 | required: ['firstName', 'lastName'] 420 | } 421 | } 422 | }; 423 | 424 | fastify.register(fastifyMongooseAPI, { 425 | models: this.db.connection.models, 426 | schemas: schemas 427 | }); 428 | 429 | ``` 430 | 431 | Add a serialization to `POST` reply (errors (404/500) are managed by defaults). 432 | 433 | ```javascript 434 | 435 | const schemas = { 436 | name: 'authors', 437 | routePost: { 438 | body: { 439 | properties: { 440 | firstName: { type: 'string' }, 441 | lastName: { type: 'string' }, 442 | biography: { type: 'string' } 443 | }, 444 | required: ['firstName', 'lastName'] 445 | }, 446 | response: { 447 | 200: { 448 | properties: { 449 | firstName: { type: 'string' }, 450 | lastName: { type: 'string' }, 451 | biography: { type: 'string' } 452 | } 453 | } 454 | } 455 | } 456 | }; 457 | ``` 458 | 459 | As you can see taking a look to defaults, this plugin supports the URI [references](https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-01#section-8) `$ref` to other schemas. 460 | 461 | You can add manually these references through `fastify.addSchema(schema)` or automatically if your schema has a `ref` attribute. 462 | 463 | This attribute could be a single object or an array of objects if you wish to register more references at once. 464 | 465 | So it's possibile to simplify our example moving duplicated data into a reference 466 | 467 | ```javascript 468 | 469 | const schemas = { 470 | name: 'authors', 471 | ref: { 472 | $id: 'authorsModel', 473 | properties: { 474 | firstName: { type: 'string' }, 475 | lastName: { type: 'string' }, 476 | biography: { type: 'string' } 477 | }, 478 | required: ['firstName', 'lastName'] 479 | }, 480 | routePost: { 481 | body: { $ref: 'authorsModel#' }, 482 | response: { 483 | 200: { $ref: 'authorsModel#' } 484 | } 485 | } 486 | }; 487 | ``` 488 | If `.schemas` and `schemaDirPath` are used together, the schemas defined in `.schemas` have precedence to there loaded in `schemaDirPath`. 489 | 490 | To filter which files must be included in `schemaDirPath` a boolean function `schemaPathFilter` can be used. 491 | 492 | The generated validation and serialization is compatible with other plugins like [@fastify/swagger](https://github.com/fastify/fastify-swagger) and [@fastify/swagger-ui](https://github.com/fastify/fastify-swagger-ui) for automatically serving OpenAPI v2/v3 schemas 493 | 494 | It's obviously possibile to merge MongoDB schemas and validation schemas in the same object 495 | 496 | ```javascript 497 | 498 | const authorSchema = { 499 | name: 'authors', 500 | schema: { 501 | firstName: String, 502 | lastName: String, 503 | biography: String, 504 | created: { type: Date, default: Date.now } 505 | }, 506 | ref: { 507 | $id: 'authorsModel', 508 | properties: { 509 | firstName: { type: 'string' }, 510 | lastName: { type: 'string' }, 511 | biography: { type: 'string' } 512 | }, 513 | required: ['firstName', 'lastName'] 514 | }, 515 | routePost: { 516 | body: { $ref: 'authorsModel#' }, 517 | response: { 518 | 200: { $ref: 'authorsModel#' } 519 | } 520 | } 521 | }; 522 | 523 | const Author = mongooseConnection.model('Author', authorSchema.schema); 524 | 525 | fastify.register(fastifyMongooseAPI, { 526 | models: this.db.connection.models, 527 | schemas: [ authorSchema, ... ] 528 | }); 529 | 530 | ``` 531 | 532 | with the single caution, for newer avj versions, to disable strict mode so avj ignore the `schema` attribute 533 | 534 | 535 | ```javascript 536 | 537 | const fastify = Fastify({ 538 | ajv: { 539 | customOptions: { 540 | strictSchema: false, 541 | } 542 | } 543 | }); 544 | 545 | ``` 546 | 547 | ## Create or Update mode (CoU-mode) or Replace mode (CoR-mode) 548 | 549 | The "Create or Update" mode, also known as CoU-mode, and the "Create or Replace" mode also known as CoR-mode are convenient features provided by fastify-mongoose-api to simplify client-side logic when you want to either create a new document or update/replace an existing one using a single API endpoint. 550 | 551 | This is especially useful in scenarios where the client may not know if the document already exists, or when you want to minimize the number of API calls and code complexity. 552 | 553 | ### Why Use CoU/CoR Mode? 554 | 555 | - 🧩 **Simplifies client logic:** Instead of checking if a document exists and then deciding whether to send a POST (create) or PUT(replace)/PATCH(update) request, you can always send the same request and let the server handle it. 556 | - 📉 **Reduces API calls:** You avoid an extra round-trip to check for existence before creating or updating. 557 | - ⚛️ **Atomic operation:** The server ensures that the operation is performed atomically, reducing the risk of race conditions. 558 | 559 | ### How It Works the CoU-mode 560 | 561 | To use CoU-mode, you send a POST request to the collection endpoint (e.g., `/api/books`) with the data you want to create or update, and include the custom HTTP header `X-HTTP-Method: CoU`. The plugin will check if a document with the provided `_id` exists: 562 | - If it exists, it updates the document with the provided data. 563 | - If it does not exist, it creates a new document. 564 | 565 | If, in the update, you wish to remove a field, set it to `undefined` or `null`. 566 | 567 | This is handled internally by the `apiCoU` method on your model. 568 | 569 | The updated or created document will be returned. 570 | 571 | #### Example 1 for CoU-mode 572 | 573 | ```js 574 | // Create or update a book 575 | await axios.post('/api/books', { 576 | id: '5d62f39c20672b3cf2822ded', // If this _id exists, it will be updated; otherwise, a new document is created 577 | title: 'The best book', 578 | isbn: '1482663775', 579 | author: '5d62e5e4dab2ce6a1b958461' 580 | }, { 581 | headers: { 'X-HTTP-Method': 'CoU' } 582 | }); 583 | ``` 584 | 585 | #### Example 2 for CoU-mode 586 | 587 | ```js 588 | // Create or update a book (same _id above so update) 589 | await axios.post('/api/books', { 590 | _id: '5d62f39c20672b3cf2822ded', 591 | title: 'A new title', 592 | isbn: null, 593 | author: '5d62e5e4dab2ce6a1b958461' 594 | }, { 595 | headers: { 'X-HTTP-Method': 'CoU' } 596 | }); 597 | ``` 598 | In "standard" mode, this call raise a **duplicate key error** because the document _id is the same of example 1. 599 | 600 | In CoU-mode instead the document with this `_id` is changed. Its `title` is updated and `isbn` is removed (unset). 601 | 602 | ### How It Works the CoR-mode 603 | 604 | To use CoR-mode, you send a POST request to the collection endpoint (e.g., `/api/books`) with the data you want to create or replace, and include the custom HTTP header `X-HTTP-Method: CoR`. The plugin will check if a document with the provided `_id` exists: 605 | - If it exists, it replace the document with the provided data. 606 | - If it does not exist, it creates a new document. 607 | 608 | This is handled internally by the `apiCoR` method on your model. 609 | 610 | The replaced or created document will be returned. 611 | 612 | #### Example 1 for CoR-mode 613 | 614 | ```js 615 | // Create or replace a book (same _id above so replace) 616 | await axios.post('/api/books', { 617 | _id: '5d62f39c20672b3cf2822ded', 618 | title: 'A new title', 619 | author: '5d62e5e4dab2ce6a1b958461' 620 | }, { 621 | headers: { 'X-HTTP-Method': 'CoR' } 622 | }); 623 | ``` 624 | 625 | This has the same result of the example 2 for CoU-mode above but now, formally, the entire document is replaced with the one in data where `title` is different and `isbn` doesn't exist. 626 | 627 | ## Populate on POST, PUT and single item GET methods 628 | 629 | Works very same, just send your form(object) data in formBody and populate parameter in query string: 630 | 631 | ```javascript 632 | $.post('/api/books?populate=author', { 633 | title: 'The best book', 634 | isbn: '1482663775', 635 | author: '5d62e5e4dab2ce6a1b958461' 636 | }); 637 | ``` 638 | 639 | and get a response of: 640 | 641 | ```json 642 | { 643 | "_id":"5d62f39c20672b3cf2822ded", 644 | "title":"The best book", 645 | "isbn":"1482663775", 646 | "author":{ 647 | "_id":"5d62e5e4dab2ce6a1b958461", 648 | "firstName":"Jay", 649 | "lastName":"Holmes"} 650 | } 651 | ``` 652 | 653 | works very same, you can also pass `populate[]` array to populate few fields. 654 | 655 | ## Disable some routes/methods 656 | 657 | Plugin decorates every model with default methods for Post, Put and Delete, [apiPost](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/src/DefaultModelMethods.js#L91), [apiPut](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/src/DefaultModelMethods.js#L170) and [apiDelete](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/src/DefaultModelMethods.js#L187). 658 | 659 | ``` 660 | Post - schema.statics.apiPost = async(data, request) 661 | Put - schema.methods.apiPut = async(data, request) 662 | Delete - schema.methods.apiDelete = async(request) 663 | ``` 664 | 665 | But you can define your own methods on any model, so the simple one of: 666 | 667 | ```javascript 668 | schema.methods.apiPut = async function(data, request) { 669 | // disable the Put completely 670 | throw new Error('PUT is disabled for this route'); 671 | }; 672 | schema.methods.apiDelete = async function(request) { 673 | // disable the Put completely 674 | throw new Error('DELETE is disabled for this route'); 675 | }; 676 | ``` 677 | 678 | would disable the PUT and DELETE methods for model's API route, returing status of 500 with error message. 679 | 680 | You can also define any custom logic based on request's object (auth, user access levels etc) or data itself (disabling some fields upading etc): 681 | 682 | ```javascript 683 | schema.statics.apiPost = async function(data, request) { 684 | if (!request.headers['letmepostplease']) { 685 | throw new Error('POST is disabled for you!'); 686 | } 687 | 688 | let doc = new mongooseConnection.models.WhereTest; 689 | 690 | mongooseConnection.models.WhereTest.schema.eachPath((pathname) => { 691 | if (data[pathname] !== undefined) { 692 | doc[pathname] = data[pathname]; 693 | } 694 | }); 695 | 696 | await doc.save(); 697 | return doc; 698 | }; 699 | ``` 700 | 701 | Check out the [test case](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/test/disable_route.test.js) to see how it works in action. 702 | 703 | 704 | ## Subroutes when there're few refs to the same model 705 | 706 | By default, fastify-mongoose-api creates subroutes for external refs to your models, [sample](#sample-application-generated-api-routes). But what if there're few refs to the same model in your schema? Like: 707 | 708 | ```javascript 709 | const bookSchema = mongoose.Schema({ 710 | title: String, 711 | isbn: String, 712 | author: { 713 | type: mongoose.Schema.Types.ObjectId, 714 | ref: 'Author' 715 | }, 716 | coauthor: { 717 | type: mongoose.Schema.Types.ObjectId, 718 | ref: 'Author' 719 | }, 720 | created: { 721 | type: Date, 722 | default: Date.now 723 | } 724 | }); 725 | const Book = mongooseConnection.model('Book', bookSchema); 726 | ``` 727 | 728 | In this special case, it will create extra routes: 729 | 730 | `/api/author/AUTHORID/books` - to list books where AUTHORID is the author (the first ref defined) 731 | and 732 | `/api/author/AUTHORID/books_as_coauthor` - to list books where AUHTORID is the co-author (next ref to the same model) 733 | 734 | while keeping expected internal refs GET routes of `/api/books/BOOKID/author` and `/api/books/BOOKID/coauthor` 735 | 736 | ## How can I POST or PUT nested paths and their properties? 737 | 738 | Use dot notation. So `biography.description` or `biography.born` like: 739 | 740 | ```javascript 741 | await axios.post('/api/authors', { 742 | firstName: 'Some', 743 | firstName: 'Author', 744 | "biography.description": 'Had a happy live', 745 | "biography.born": 1960, 746 | }); 747 | ``` 748 | 749 | works for creating such schema: 750 | 751 | ```javascript 752 | const authorSchema = mongoose.Schema({ 753 | firstName: String, 754 | lastName: String, 755 | biography: { description: String, born: Number }, 756 | created: { 757 | type: Date, 758 | default: Date.now 759 | } 760 | }); 761 | ``` 762 | 763 | Thanks to [EmilianoBruni](https://github.com/EmilianoBruni) for implementation. 764 | 765 | ## How to hide specific fields/properties in API response 766 | 767 | fastify-mongoose-api adds [.apiValues(request)](https://github.com/jeka-kiselyov/fastify-mongoose-api/blob/master/src/DefaultModelMethods.js) method to every mongoose model without it. You can define your own: 768 | 769 | ```javascript 770 | const bookSchema = mongoose.Schema({ 771 | title: String, 772 | isbn: String, 773 | created: { 774 | type: Date, 775 | default: Date.now 776 | }, 777 | password: String, 778 | }); 779 | 780 | // we defined apiValues response change to check if it works for refs response 781 | bookSchema.methods.apiValues = function(request) { 782 | const object = this.toObject({depopulate: true}); 783 | object.isbn = 'hidden'; 784 | delete object.password; 785 | 786 | return object; 787 | }; 788 | ``` 789 | 790 | so it will always display `isbn` value as `hidden` in API response and never show anything for `password` field. 791 | 792 | As `request` is present, you can return different properties depending on request or your application state. Simpliest is: 793 | 794 | ```javascript 795 | 796 | schema.methods.apiValues = function (request) { 797 | if (!request.headers['givememoredataplease']) { 798 | return { 799 | name: this.name, 800 | }; 801 | } 802 | 803 | return this.toObject(); 804 | }; 805 | 806 | ``` 807 | 808 | will return the full object only if `givememoredataplease` HTTP header is present in the request. You can add some access level checking on your signed in 809 | user for more advanced flows: 810 | 811 | ```javascript 812 | 813 | schema.methods.apiValues = function (request) { 814 | if (!request.user.hasRightsToViewMoreFields()) { 815 | return { 816 | name: this.name, 817 | }; 818 | } 819 | return this.toObject(); 820 | }; 821 | 822 | ``` 823 | 824 | 825 | ## CORS 826 | 827 | How to enable CORS for cross-domain requests? [fastify-cors](https://github.com/fastify/fastify-cors) works just fine: 828 | 829 | ```javascript 830 | const fastify = Fastify(); 831 | fastify.register(fastifyFormbody); 832 | fastify.register(require('fastify-cors'), { 833 | // put your options here 834 | }); 835 | 836 | fastify.register(fastifyMongooseAPI, { 837 | models: this.db.connection.models, 838 | prefix: '/api/', 839 | setDefaults: true, 840 | methods: ['list', 'get', 'post', 'patch', 'put', 'delete', 'options'] 841 | }); 842 | 843 | await fastify.ready(); 844 | await fastify.listen(args.port); 845 | ``` 846 | 847 | 848 | ## Tests 849 | 850 | Clone fastify-mongoose-api, run `npm install` in its directory and run `grunt` or `npm test` to run [unit tests](https://github.com/jeka-kiselyov/fastify-mongoose-api/tree/master/test), or `grunt watchtests` to run unit tests on each file change (development mode). 851 | 852 | ## Coverage report 853 | 854 | Simply run `npm test` with the COVERALLS_REPO_TOKEN environment variable set and tap will automatically use nyc to report coverage to coveralls. 855 | 856 | ## License 857 | 858 | Licensed under [MIT](./LICENSE) 859 | --------------------------------------------------------------------------------