├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── manual.yml │ └── test-on-pull.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── config.js ├── docs ├── assets │ ├── appy-api-screenshot.png │ └── swagger.png ├── associations.md ├── audit-logs.md ├── authentication.md ├── authorization.md ├── configuration.md ├── creating-endpoints.md ├── duplicate-fields.md ├── introduction.md ├── metadata.md ├── middleware.md ├── misc.md ├── model-generation.md ├── mongoose-wrapper-methods.md ├── policies.md ├── querying.md ├── questions.md ├── quick-start.md ├── route-customization.md ├── soft-delete.md ├── support.md ├── swagger-documentation.md ├── testing.md └── validation.md ├── globals.js ├── models └── audit-log.model.js ├── package-lock.json ├── package.json ├── policies ├── add-by-meta-data.js ├── add-document-scope.js ├── audit-log.js ├── authorize-document-creator.js ├── enforce-document-scope.js ├── populate-duplicate-fields.js └── track-duplicated-fields.js ├── rest-hapi-cli.js ├── rest-hapi.js ├── scripts ├── seed.js └── update-associations.js ├── seed ├── group.model.js ├── linking-models │ ├── group_permission.model.js │ ├── role_permission.model.js │ └── user_permission.model.js ├── permission.model.js ├── role.model.js └── user.model.js ├── tests ├── e2e │ ├── advance-assoc.tests.js │ ├── audit-log.tests.js │ ├── basic-crud.tests.js │ ├── basic-embed-rest.tests.js │ ├── basic-embed-wrapper.tests.js │ ├── basic-non-embed.tests.js │ ├── doc-auth.tests.js │ ├── duplicate-field.tests.js │ ├── end-to-end.tests.js │ ├── misc.tests.js │ └── test-scenarios │ │ ├── scenario-1 │ │ └── models │ │ │ └── role.model.js │ │ ├── scenario-2 │ │ └── models │ │ │ └── role.model.js │ │ ├── scenario-3 │ │ └── models │ │ │ ├── business.model.js │ │ │ ├── hashtag.model.js │ │ │ ├── linking-models │ │ │ └── user_permission.model.js │ │ │ ├── permission.model.js │ │ │ ├── role.model.js │ │ │ ├── user-profile.model.js │ │ │ └── user.model.js │ │ ├── scenario-4 │ │ └── models │ │ │ ├── building.model.js │ │ │ └── facility.model.js │ │ └── scenario-5 │ │ └── models │ │ ├── linking-models │ │ └── segment_tag.model.js │ │ ├── segment.model.js │ │ ├── tag.model.js │ │ └── video.model.js └── unit │ ├── enforce-document-scope.tests.js │ ├── handler-helper-factory.tests.js │ ├── handler-helper.tests.js │ ├── joi-mongoose-helper.tests.js │ ├── model-helper.tests.js │ ├── query-helper.tests.js │ └── rest-helper-factory.tests.js ├── utilities ├── api-generator.js ├── auth-helper.js ├── handler-helper-factory.js ├── handler-helper.js ├── joi-mongoose-helper.js ├── log-util.js ├── model-generator.js ├── model-helper.js ├── policy-generator.js ├── query-helper.js ├── rest-helper-factory.js ├── test-helper.js └── validation-helper.js └── website ├── blog ├── 2016-11-19-The-Problem-With-APIs.md ├── 2017-02-17-The-Problem-With-MongoDB.md └── 2018-06-26-How-To-Build-Powerful-APIs-Blazingly-Fast-With-Nodejs.md ├── core └── Footer.js ├── data └── users.js ├── i18n └── en.json ├── package-lock.json ├── package.json ├── pages └── en │ ├── demo.js │ ├── help.js │ ├── index.js │ ├── users.js │ └── versions.js ├── publish.sh ├── sidebars.json ├── siteConfig.js ├── static ├── css │ └── custom.css └── img │ ├── appy-api-screenshot.png │ ├── appy.png │ ├── docusaurus.svg │ ├── efficient_icon.png │ ├── favicon.png │ ├── favicon │ └── favicon.ico │ ├── flexible_icon.png │ ├── joi.png │ ├── oss_logo.png │ ├── powerful_icon.png │ ├── querying.png │ ├── rest-hapi-logo-alt.png │ └── rest-hapi-logo.png ├── versioned_docs ├── version-1.6.x │ ├── associations.md │ ├── audit-logs.md │ ├── authentication.md │ ├── authorization.md │ ├── configuration.md │ ├── creating-endpoints.md │ ├── duplicate-fields.md │ ├── introduction.md │ ├── metadata.md │ ├── middleware.md │ ├── misc.md │ ├── model-generation.md │ ├── mongoose-wrapper-methods.md │ ├── policies.md │ ├── querying.md │ ├── questions.md │ ├── quick-start.md │ ├── route-customization.md │ ├── soft-delete.md │ ├── support.md │ ├── swagger-documentation.md │ ├── testing.md │ └── validation.md ├── version-1.7.x │ ├── mongoose-wrapper-methods.md │ ├── policies.md │ └── validation.md ├── version-2.0.x │ ├── configuration.md │ ├── creating-endpoints.md │ ├── introduction.md │ ├── policies.md │ ├── querying.md │ ├── quick-start.md │ └── validation.md ├── version-2.2.x │ ├── associations.md │ ├── configuration.md │ ├── route-customization.md │ └── validation.md ├── version-2.3.x │ └── configuration.md ├── version-3.0.x │ └── quick-start.md ├── version-3.1.x │ └── configuration.md └── version-3.2.x │ └── middleware.md ├── versioned_sidebars ├── version-1.6.x-sidebars.json └── version-3.0.x-sidebars.json ├── versions.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | website 3 | docs -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module', 8 | }, 9 | env: { 10 | browser: true, 11 | }, 12 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 13 | extends: ['prettier-standard'], 14 | plugins: ['prettier'], 15 | // add your custom rules here 16 | rules: { 17 | // allow paren-less arrow functions 18 | 'arrow-parens': 0, 19 | // allow async-await 20 | 'generator-star-spacing': 0, 21 | // allow debugger during development 22 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 23 | 'prettier/prettier': 'error', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/manual.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow that is manually triggered 2 | 3 | # HOW TO USE 4 | # One of the challenges with testing NEW github workflows is that you can't trigger them without first 5 | # having the workflow existing in the main branch of the repo. 6 | # Once that workflow is there, you can update the workflow on any branch, and run that branch's version 7 | # of the workflow. So to use this and test a manually triggered workflow: 8 | # 1. Check out a new branch and modify this file 9 | # 2. Push your branch, then in your browser, navigate to the Github workflow 10 | # 3. Click on "Manual workflow" and trigger it, specifying your branch. 11 | 12 | name: Manual workflow 13 | 14 | # Controls when the action will run. Workflow runs when manually triggered using the UI 15 | # or API. 16 | on: 17 | workflow_dispatch: 18 | # Inputs the workflow accepts. 19 | inputs: 20 | name: 21 | # Friendly description to be shown in the UI instead of 'name' 22 | description: 'Person to greet' 23 | # Default value if no value is explicitly provided 24 | default: 'World' 25 | # Input has to be provided for the workflow to run 26 | required: true 27 | 28 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 29 | jobs: 30 | # This workflow contains a single job that can be modified on a branch to test behaviors 31 | scratch-job: 32 | # The type of runner that the job will run on 33 | runs-on: ubuntu-latest 34 | 35 | # Steps represent a sequence of tasks that will be executed as part of the job 36 | steps: 37 | # Runs a single command using the runners shell 38 | - name: Send greeting 39 | run: echo "Hello ${{ github.event.inputs.name }}" 40 | - name: Exit if failed match 41 | run: | 42 | echo $MATCH; 43 | echo ${#MATCH}; 44 | if [[ -z "$MATCH" ]]; then 45 | echo "this shouldn't print" 46 | fi 47 | env: 48 | MATCH: "asdf123" 49 | -------------------------------------------------------------------------------- /.github/workflows/test-on-pull.yml: -------------------------------------------------------------------------------- 1 | name: test-on-pull 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**.md' 9 | push: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**.md' 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [14.x, 15.x, 16.x] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | - run: npm ci 31 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #api/config.js 2 | api/node_modules 3 | *npm-debug.log 4 | .idea/ 5 | build 6 | node_modules 7 | .nyc_output 8 | coverage 9 | coverage.lcov 10 | 11 | # local uploads 12 | uploads/* 13 | 14 | # OSX files 15 | *.DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | _config.yml 3 | .idea 4 | tests 5 | .nyc_output 6 | coverage 7 | coverage.lcov 8 | docs 9 | website -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at resthapi@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to rest-hapi 2 | 3 | Hey there! We’re really excited that you are interested in contributing to 4 | rest-hapi. Before submitting your contribution though, please make sure to take 5 | a moment and read through the following guidelines. 6 | 7 | ## Code of Conduct 8 | 9 | Please note that this project has a [Code of Conduct](CODE_OF_CONDUCT.md). It's 10 | important that you review and enforce it. 11 | 12 | ## Code contributions 13 | 14 | Here is a quick guide to doing code contributions to the library. 15 | 16 | 1. Find some issue you're interested in, or a feature that you'd like to 17 | tackle. Also make sure that no one else is already working on it. If it's a 18 | feature you're requesting, make sure it's aligned with the direction of the 19 | project by creating an issue and discussing it with the core maintainers. We 20 | don't want you to be disappointed. 21 | 2. Fork the repo 22 | 3. Create a branch off of the *master* branch. Prefix your branch with either "feature/", "bugfix/", or something similar describing the type of update, and then add a descriptive name such as "bugfix/random\_files_erased". 23 | 4. Add your changes. 24 | * Please try to avoid monolithic commits. 25 | * Code must follow the [Javascript Standard Style](https://standardjs.com/). 26 | * Your code must pass eslint filters to be able to commit. 27 | 5. Make sure your master branch is [in sync/up-to-date with the original](https://help.github.com/articles/syncing-a-fork/). 28 | 6. Before submitting a pull request, merge in your synced master branch with your current branch and resolve any conflicts. 29 | 7. Once all conflicts are resolved, submit a pull request to the origin master branch. 30 | 8. Your pull request will be reviewed along with change requests or comments. 31 | 9. After all requests are complete, your pull request will be merged in. 32 | 10. Celebrate! :tada: 33 | 34 | **NOTE**: Don't forget to [update the docs](https://resthapi.com/docs/quick-start.html)! 😉 35 | 36 | ### This is my first Pull Request, where can I learn how to contribute? 37 | 38 | You can take this free course: 39 | [_How to Contribute to an Open Source Project on GitHub_](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Justin Headley 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 | -------------------------------------------------------------------------------- /docs/assets/appy-api-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JKHeadley/rest-hapi/708ecbcd4f5d3ec1d11c987a4741d505358e7de9/docs/assets/appy-api-screenshot.png -------------------------------------------------------------------------------- /docs/assets/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JKHeadley/rest-hapi/708ecbcd4f5d3ec1d11c987a4741d505358e7de9/docs/assets/swagger.png -------------------------------------------------------------------------------- /docs/audit-logs.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: audit-logs 3 | title: Audit Logs 4 | sidebar_label: Audit Logs 5 | --- 6 | 7 | By default, rest-hapi records all document-modifiying activities that occur within the [generated endpoints](creating-endpoints.md). Each event is stored as a document within the `auditLog` collection. The audit log documents can be set to expire by providing a value for `config.auditLogTTL`. The value can be specified in integer seconds or as a human-readable time period (Ex: 60 = 60 seconds, '1m' = 1 minute, or '1d' = 1 day). Audit logs can be disabled by setting `config.enableAuditLog` to `false`. Also, a [scope](authorization.md) can be added to the `auditLog` endpoints through `config.auditLogScope`, giving you control over who can access/create logs. Below is a list of the properties included in each auditLog document: 8 | 9 | - `date` 10 | * The date the action took place. 11 | * Used as the index for the expiration. 12 | - `method` 13 | * The http method used. 14 | * Must be one of `POST, PUT, DELETE, GET` 15 | * Can be null. 16 | - `action` 17 | * The type of action requested. 18 | * Typically one of `Create, Update, Delete, Add, Remove`. 19 | * Can be null. 20 | - `endpoint` 21 | * The relative path of the endpoint that was accessed. 22 | * Can be null. 23 | - `user` 24 | * If the endpoint is authenticated, this will be the \_id of the requesting user. 25 | * You can specify the user \_id path/key through [`config.userIdKey`](configuration.md#useridkey). 26 | * Can be null. 27 | - `collectionName` 28 | * The name of the primary/owner collection being modified. 29 | * Can be null. 30 | - `childCollectionName` 31 | * The name of the secondary/child collection being modified in the case of an association action. 32 | * Can be null. 33 | - `associationType` 34 | * The type of relationship between the two modified documents in an association action. 35 | * Must be one of `ONE_MANY, MANY_MANY, _MANY`. 36 | * Can be null. 37 | - `documents` 38 | * An array of \_ids of the documents being modified. 39 | * Can be null. 40 | - `payload` 41 | * The payload included in the request. 42 | * Can be null. 43 | - `params` 44 | * The params included in the request. 45 | * Can be null. 46 | - `result` 47 | * The response sent by the server. 48 | * Can be null. 49 | - `statusCode` 50 | * The status code of the server response. 51 | * Can be null. 52 | - `responseMessage` 53 | * The response message from the server. Typically for an error. 54 | * Can be null. 55 | - `isError` 56 | * A boolean value specifying whether the server responed with an error. 57 | - `ipAddress` 58 | * The ip address the request. 59 | * Can be null. 60 | - `notes` 61 | * Any additional notes. 62 | * Can be null. 63 | 64 | Below is an example of an `auditLog` document: 65 | 66 | ```javascript 67 | { 68 | "_id": "59eebc5f20cbfb49c6eae431", 69 | "notes": null, 70 | "ipAddress": "127.0.0.1", 71 | "method": "POST", 72 | "action": "Create", 73 | "endpoint": "/hashtag", 74 | "collectionName": "hashtag", 75 | "statusCode": 201, 76 | "isError": false, 77 | "responseMessage": null, 78 | "result": [ 79 | { 80 | "isDeleted": false, 81 | "createdAt": "2017-10-24T04:06:55.824Z", 82 | "text": "#coolhashtag", 83 | "_id": "59eebc5f20cbfb49c6eae42f" 84 | }, 85 | { 86 | "isDeleted": false, 87 | "createdAt": "2017-10-24T04:06:55.824Z", 88 | "text": "#notsocool", 89 | "_id": "59eebc5f20cbfb49c6eae430" 90 | } 91 | ], 92 | "params": null, 93 | "payload": [ 94 | { 95 | "text": "#coolhashtag" 96 | }, 97 | { 98 | "text": "#notsocool" 99 | } 100 | ], 101 | "documents": [ 102 | "59eebc5f20cbfb49c6eae42f", 103 | "59eebc5f20cbfb49c6eae430" 104 | ], 105 | "associationType": null, 106 | "childCollectionName": null, 107 | "user": "597242d4e14a710005d325b1", 108 | "date": "2017-10-24T01:17:43.177Z" 109 | } 110 | ``` 111 | 112 | Audit logs can be [queried against](querying.md) the same as any other generated endpoint. You can also create your own `auditLog` documents. 113 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: authentication 3 | title: Authentication 4 | sidebar_label: Authentication 5 | --- 6 | 7 | ## Route authentication 8 | Authentication for generated endpoints is configured through [`config.authStrategy`](configuration.md#authstrategy) property. If this property is set to a registered strategy, then that strategy is applied to all generated endpoints by default. For more details about authentication with hapi, see [the hapi docs](https://hapijs.com/tutorials/auth). For a working example of authentication with rest-hapi, see the [rest-hapi-demo-auth](https://github.com/JKHeadley/rest-hapi-demo/tree/feature/authentication) or [appy](https://github.com/JKHeadley/appy). 9 | 10 | You can disable authentication for generated CRUD endpoints by setting the correct property to ``false`` within the ``routeOptions`` object. Below is a list of properties and the endpoints they affect: 11 | 12 | Property | Affected endpoints when `false` 13 | --- | --- 14 | readAuth | ``GET /path`` and ``GET /path/{_id}`` endpoints 15 | createAuth | ``POST /path`` endpoint 16 | updateAuth | ``PUT /path/{_id}`` endpoint 17 | deleteAuth | ``DELETE /path`` and ``DELETE /path/{_id}`` endpoints 18 | 19 | Similarly, you can disable authentication for generated association endpoints through the following properties within each association object: 20 | 21 | Property | Affected endpoints when `false` 22 | --- | --- 23 | addAuth | ``POST /owner/{ownerId}/child`` and ``PUT /owner/{ownerId}/child/{childId}`` endpoints 24 | removeAuth | ``DELETE /owner/{ownerId}/child`` and ``DELETE /owner/{ownerId}/child/{childId}`` endpoints 25 | readAuth | ``GET /owner/{ownerId}/child`` endpoint 26 | 27 | For example, a routeOption object that disables authentication for creating objects and removing a specific association could look like this: 28 | 29 | ```javascript 30 | routeOptions: { 31 | createAuth: false, 32 | associations: { 33 | users: { 34 | type: "MANY_ONE", 35 | alias: "user", 36 | model: "user", 37 | removeAuth: false 38 | } 39 | } 40 | } 41 | ``` -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: introduction 3 | title: Introduction 4 | sidebar_label: Introduction 5 | --- 6 | 7 | ## Requirements 8 | 9 | You need [Node.js](https://nodejs.org/en/) ^12.14.1 installed and you'll need [MongoDB](https://docs.mongodb.com/manual/installation/) installed and running. 10 | 11 | ## Installation 12 | 13 | In your project directory, run: 14 | 15 | ```sh 16 | $ npm install rest-hapi 17 | ``` 18 | 19 | ## Using the plugin 20 | 21 | As rest-hapi is a hapi plugin, you'll need to set up a hapi server to generate API endpoints. You'll also need to set up a [mongoose](https://github.com/Automattic/mongoose) instance and include it in the plugin's options when you register. Create a new file ``api.js`` and add the following code to set up an API with rest-hapi: 22 | 23 | ```javascript 24 | // api.js 25 | let Hapi = require('@hapi/hapi') 26 | let mongoose = require('mongoose') 27 | let RestHapi = require('rest-hapi') 28 | 29 | async function api(){ 30 | try { 31 | let server = Hapi.Server({ port: 8080 }) 32 | 33 | let config = { 34 | appTitle: "My API", 35 | }; 36 | 37 | await server.register({ 38 | plugin: RestHapi, 39 | options: { 40 | mongoose, 41 | config 42 | } 43 | }) 44 | 45 | await server.start() 46 | 47 | console.log("Server ready", server.info) 48 | 49 | return server 50 | } catch (err) { 51 | console.log("Error starting server:", err); 52 | } 53 | } 54 | 55 | module.exports = api() 56 | ``` 57 | You can then run 58 | 59 | ```sh 60 | $ node api.js 61 | ``` 62 | 63 | and point your browser to [http://localhost:8080/](http://localhost:8080/) to view the swagger docs 64 | 65 | > **NOTE**: API endpoints will only be generated if you have provided models. See [Example Data](#example-data) or [Creating endpoints](creating-endpoints.md). 66 | 67 | 68 | ## Example Data 69 | 70 | **WARNING**: This will clear all data in the following MongoDB collections in the db defined in ``RestHapi.config`` (default ``mongodb://localhost:27017/rest_hapi``): ``users``, ``roles``. 71 | 72 | If you would like to seed your database with some demo models/data, run: 73 | 74 | ```sh 75 | $ ./node_modules/.bin/rest-hapi-cli seed 76 | ``` 77 | 78 | If you need a db different than the default, you can add the URI as an argument to the command: 79 | 80 | ```sh 81 | $ ./node_modules/.bin/rest-hapi-cli seed mongodb://localhost:27017/other_db 82 | ``` 83 | 84 | You can use these models as templates for your models or delete them later if you wish. 85 | 86 | For a ready-to-go demo project see [quick start](quick-start.md) 87 | -------------------------------------------------------------------------------- /docs/metadata.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: metadata 3 | title: Metadata 4 | sidebar_label: Metadata 5 | --- 6 | 7 | ## Timestamps 8 | rest-hapi supports the following optional timestamp metadata for documents: 9 | - createdAt (default enabled, activated via [`config.enableCreatedAt`](configuration.md#enablecreatedat)) 10 | - updatedAt (default enabled, activated via [`config.enableUpdatedAt`](configuration.md#enableupdatedat)) 11 | - deletedAt (default enabled, activated via [`config.enableDeletedAt`](configuration.md#enabledeletedat)) (see [Soft delete](soft-delete.md)) 12 | 13 | When enabled, these properties will automatically be populated during CRUD operations. For example, say I create a user with a payload of: 14 | 15 | ```json 16 | { 17 | "email": "test@email.com", 18 | "password": "1234" 19 | } 20 | ``` 21 | 22 | If I then query for this document I might get: 23 | 24 | ```json 25 | { 26 | "_id": "588077dfe8b75a830dc53e8b", 27 | "email": "test@email.com", 28 | "createdAt": "2017-01-19T08:25:03.577Z" 29 | } 30 | ``` 31 | 32 | If I later update that user's email then an additional query might return: 33 | 34 | ```json 35 | { 36 | "_id": "588077dfe8b75a830dc53e8b", 37 | "email": "test2@email.com", 38 | "createdAt": "2017-01-19T08:25:03.577Z", 39 | "updatedAt": "2017-01-19T08:30:46.676Z" 40 | } 41 | ``` 42 | 43 | The ``deletedAt`` property marks when a document was [soft deleted](soft-delete.md). 44 | 45 | > **NOTE**: Timestamp metadata properties are only set/updated if the document is created/modified using rest-hapi endpoints/methods. 46 | Ex: 47 | 48 | ``mongoose.model('user').findByIdAndUpdate(_id, payload)`` will not modify ``updatedAt`` whereas 49 | 50 | ``RestHapi.update(mongoose.model('user'), _id, payload)`` will. (see [Mongoose wrapper methods](mongoose-wrapper-methods.md)) 51 | 52 | ## User tags 53 | In addition to timestamps, the following user tag metadata can be added to a document: 54 | - createdBy (default disabled, activated via [`config.enableCreatedBy`](configuration.md#enablecreatedby)) 55 | - updatedBy (default disabled, activated via [`config.enableUpdatedBy`](configuration.md#enableupdatedby)) 56 | - deletedBy (default disabled, activated via [`config.enableDeletedBy`](configuration.md#enabledeletedby)) (see [Soft delete](soft-delete.md)) 57 | 58 | If enabled, these properties will record the `_id` of the user performing the corresponding action. 59 | 60 | This assumes that your authentication credentials (request.auth.credentials) will contain either a `user` object with a `_id` property, or the user's \_id stored in a property defined by [`config.userIdKey`](configuration.md#useridkey). 61 | 62 | > **NOTE**: Unlike timestamp metadata, user tag properties are only set/updated if the document is created/modified using rest-hapi endpoints, (not rest-hapi [methods](mongoose-wrapper-methods.md)). 63 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: middleware 3 | title: Middleware 4 | sidebar_label: Middleware 5 | --- 6 | 7 | ## CRUD 8 | Models can support middleware functions for CRUD operations. These exist under the ``routeOptions`` object. The following middleware functions are available: 9 | 10 | * list: 11 | - pre(query, request, Log) 12 | * returns: `query` 13 | - post(request, result, Log) 14 | * returns: `result` 15 | * find: 16 | - pre(\_id, query, request, Log) 17 | * returns: `query` 18 | - post(request, result, Log) 19 | * returns: `result` 20 | * create: 21 | - pre(payload, request, Log) 22 | * > **NOTE:** _For payloads with multiple documents, the pre function will be called for each document individually (passed in through the `payload` parameter) i.e. `request.payload` = array of documents, `payload` = single document_ 23 | 24 | * returns: `payload` 25 | - post(document, request, result, Log) 26 | * returns: `document` 27 | * update: 28 | - pre(\_id, payload, request, Log) 29 | * returns: `payload` 30 | - post(request, result, Log) 31 | * returns: `result` 32 | * delete: 33 | - pre(\_id, hardDelete, request, Log) 34 | * returns: `null` 35 | - post(hardDelete, deleted, request, Log) 36 | * returns: `null` 37 | 38 | For example, a ``create: pre`` function can be defined to encrypt a users password 39 | using a static method ``generatePasswordHash``. 40 | 41 | ```javascript 42 | // models/user.model.js 43 | let bcrypt = require('bcrypt'); 44 | 45 | module.exports = function (mongoose) { 46 | let modelName = "user"; 47 | let Types = mongoose.Schema.Types; 48 | let Schema = new mongoose.Schema({ 49 | email: { 50 | type: Types.String, 51 | unique: true 52 | }, 53 | password: { 54 | type: Types.String, 55 | required: true, 56 | exclude: true, 57 | allowOnUpdate: false 58 | } 59 | }); 60 | 61 | Schema.statics = { 62 | collectionName:modelName, 63 | routeOptions: { 64 | create: { 65 | pre: function (payload, request, Log) { 66 | let hashedPassword = mongoose.model('user').generatePasswordHash(payload.password); 67 | 68 | payload.password = hashedPassword; 69 | 70 | return payload; 71 | } 72 | } 73 | }, 74 | 75 | generatePasswordHash: function(password) { 76 | let salt = bcrypt.genSaltSync(10); 77 | let hash = bcrypt.hashSync(password, salt); 78 | return hash; 79 | } 80 | }; 81 | 82 | return Schema; 83 | }; 84 | ``` 85 | 86 | If a `Boom` error is thrown within a middleware function, that error will become the server response. Ex: 87 | 88 | ```javascript 89 | create: { 90 | pre: function (payload, request, Log) { 91 | throw Boom.badRequest("TEST ERROR") 92 | } 93 | } 94 | ``` 95 | 96 | will result in a response body of: 97 | 98 | ```javascript 99 | { 100 | "statusCode": 400, 101 | "error": "Bad Request", 102 | "message": "TEST ERROR" 103 | } 104 | ``` 105 | 106 | ## Association 107 | Support is being added for association middlware. Currently the following association middleware exist: 108 | 109 | * getAll: 110 | - post(request, result, Log) 111 | * returns: result 112 | * add: 113 | - pre(payload, request, Log) 114 | * returns: payload 115 | - post(payload, request, Log) 116 | * returns: void 117 | * remove: 118 | - pre(payload, request, Log) 119 | * returns: payload 120 | - post(payload, request, Log) 121 | * returns: void 122 | 123 | Association middleware is defined similar to CRUD middleware, with the only difference being the association name must be specified. See below for an example: 124 | 125 | ```javascript 126 | routeOptions: { 127 | associations: { 128 | groups: { 129 | type: "MANY_MANY", 130 | model: "group" 131 | } 132 | } 133 | }, 134 | getAll: { 135 | groups: { //<---this must match the association name 136 | post: function(request, result, Log) { 137 | /** modify and return result **/ 138 | } 139 | } 140 | } 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/misc.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: misc 3 | title: Miscellaneous 4 | sidebar_label: Miscellaneous 5 | --- 6 | 7 | ## Model generation 8 | In some situations models may be required before or without endpoint generation. For example some hapi plugins may require models to exist before the routes are registered. In these cases rest-hapi provides a ``generateModels`` function that can be called independently. 9 | 10 | > **NOTE:** See the [appy seed file](https://github.com/JKHeadley/appy/blob/master/gulp/seed.js) (or [scripts/seed.js](https://github.com/JKHeadley/rest-hapi/blob/master/scripts/seed.js)) for another example usage of ``generateModels``. 11 | 12 | 13 | ## Testing 14 | If you have downloaded the source you can run the tests with: 15 | ``` 16 | $ npm test 17 | ``` 18 | 19 | 20 | ## License 21 | MIT 22 | 23 | ## Questions? 24 | If you have any questions/issues/feature requests, please feel free to open an [issue](https://github.com/JKHeadley/rest-hapi/issues/new). We'd love to hear from you! 25 | 26 | ## Support 27 | Like this project? Please star it! 28 | 29 | ## Projects 30 | Building a project with rest-hapi? [Open a PR](https://github.com/JKHeadley/rest-hapi/blob/master/README.md) and list it here! 31 | 32 | - [appy](https://github.com/JKHeadley/appy) 33 | * A ready-to-go user system built on rest-hapi. 34 | - [rest-hapi-demo](https://github.com/JKHeadley/rest-hapi-demo) 35 | * A simple demo project implementing rest-hapi in a hapi server. 36 | 37 | ## Contributing 38 | Please reference the contributing doc: https://github.com/JKHeadley/rest-hapi/blob/master/CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/model-generation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: model-generation 3 | title: Model Generation 4 | sidebar_label: Model Generation 5 | --- 6 | 7 | In some situations models may be required before or without endpoint generation. For example some hapi plugins may require models to exist before the routes are registered. In these cases rest-hapi provides a ``generateModels`` function that can be called independently. 8 | 9 | > **NOTE:** See [scripts/seed.js](https://github.com/JKHeadley/rest-hapi/blob/master/scripts/seed.js) for an example usage of ``generateModels``. 10 | 11 | -------------------------------------------------------------------------------- /docs/querying.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: querying 3 | title: Querying 4 | sidebar_label: Querying 5 | --- 6 | 7 | Query parameters can be added to GET requests to filter responses. These parameters 8 | are structured and function similar to mongoose queries. Below is a list of currently 9 | supported parameters: 10 | 11 | * $skip 12 | - The number of records to skip in the database. This is typically used in pagination. 13 | 14 | * $page 15 | - The number of records to skip based on the $limit parameter. This is typically used in pagination. 16 | 17 | * $limit 18 | - The maximum number of records to return. This is typically used in pagination. 19 | 20 | * $select 21 | - A list of basic fields to be included in each resource. 22 | 23 | * $sort 24 | - A set of fields to sort by. Including field name indicates it should be sorted ascending, while prepending '-' indicates descending. The default sort direction is 'ascending' (lowest value to highest value). Listing multiple fields prioritizes the sort starting with the first field listed. 25 | 26 | * $text 27 | - A full text search parameter. Takes advantage of indexes for efficient searching. Also implements stemming with searches. Prefixing search terms with a "-" will exclude results that match that term. 28 | 29 | * $term 30 | - A regex search parameter. Slower than $text search but supports partial matches and doesn't require indexing. This can be refined using the $searchFields parameter. 31 | 32 | * $searchFields 33 | - A set of fields to apply the $term search parameter to. If this parameter is not included, the $term search parameter is applied to all searchable fields. 34 | 35 | * $embed 36 | - A set of associations to populate. 37 | 38 | * $flatten 39 | - Set to true to flatten embedded arrays, i.e. remove linking-model data. 40 | 41 | * $count 42 | - If set to true, only a count of the query results will be returned. 43 | 44 | * $where 45 | - An optional field for raw mongoose queries. 46 | - **!!WARNING!!**: This feature is meant for development ONLY and NOT in production as it 47 | provides direct query access to your database. See [the config docs](configuration.md#enablewherequeries) to enable. 48 | 49 | * (field "where" queries) 50 | - Ex: ``/user?email=test@user.com`` 51 | 52 | Query parameters can either be passed in as a single string, or an array of strings. 53 | 54 | ## Pagination 55 | For any GET query that returns multiple documents, pagination data is returned alongside the documents. The response object has the form: 56 | 57 | - docs - an array of documents. 58 | - pages - an object where: 59 | * current - a number indicating the current page. 60 | * prev - a number indicating the previous page. 61 | * hasPrev - a boolean indicating if there is a previous page. 62 | * next - a number indicating the next page. 63 | * hasNext - a boolean indicating if there is a next page. 64 | * total - a number indicating the total number of pages. 65 | - items - an object where: 66 | * limit - a number indicating the how many results should be returned. 67 | * begin - a number indicating what item number the results begin with. 68 | * end - a number indicating what item number the results end with. 69 | * total - a number indicating the total number of matching results. 70 | 71 | > **NOTE:** Pagination format borrowed from mongo-models [pagedFind](https://github.com/jedireza/mongo-models/blob/master/API.md#pagedfindfilter-fields-sort-limit-page-callback). 72 | 73 | ## Populate nested associations 74 | Associations can be populated through the ``$embed`` parameter. To populate nested associations, 75 | simply chain a parameter with ``.``. For example, consider the MANY_MANY group-user association 76 | from the example above. If we populate the users of a group with ``/group?$embed=users`` we might get a 77 | response like so: 78 | 79 | ```json 80 | { 81 | "_id": "58155f1a071468d3bda0fc6e", 82 | "name": "A-team", 83 | "users": [ 84 | { 85 | "user": { 86 | "_id": "580fc1a0e2d3308609470bc6", 87 | "email": "test@user.com", 88 | "title": "580fc1e2e2d3308609470bc8" 89 | }, 90 | "_id": "58155f6a071468d3bda0fc6f" 91 | }, 92 | { 93 | "user": { 94 | "_id": "5813ad3d0d4e5c822d2f05bd", 95 | "email": "test2@user.com", 96 | "title": "580fc1eee2d3308609470bc9" 97 | }, 98 | "_id": "58155f6a071468d3bda0fc71" 99 | } 100 | ] 101 | } 102 | ``` 103 | 104 | However we can further populate each user's ``title`` field with a nested ``$embed`` 105 | parameter: ``/group?$embed=users.title`` which could result in the following response: 106 | 107 | ```json 108 | { 109 | "_id": "58155f1a071468d3bda0fc6e", 110 | "name": "A-team", 111 | "users": [ 112 | { 113 | "user": { 114 | "_id": "580fc1a0e2d3308609470bc6", 115 | "email": "test@user.com", 116 | "title": { 117 | "_id": "580fc1e2e2d3308609470bc8", 118 | "name": "Admin" 119 | } 120 | }, 121 | "_id": "58155f6a071468d3bda0fc6f" 122 | }, 123 | { 124 | "user": { 125 | "_id": "5813ad3d0d4e5c822d2f05bd", 126 | "email": "test2@user.com", 127 | "title": { 128 | "_id": "580fc1eee2d3308609470bc9", 129 | "name": "SuperAdmin" 130 | } 131 | }, 132 | "_id": "58155f6a071468d3bda0fc71" 133 | } 134 | ] 135 | } 136 | ``` 137 | -------------------------------------------------------------------------------- /docs/questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: questions 3 | title: Questions 4 | sidebar_label: Questions 5 | --- 6 | 7 | If you have any questions/issues/feature requests, please feel free to open an [issue](https://github.com/JKHeadley/rest-hapi/issues/new). We'd love to hear from you! 8 | 9 | For more options please see the [help page](https://resthapi.com/help). -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quick-start 3 | title: Quick Start 4 | sidebar_label: Quick Start 5 | --- 6 | 7 | ## Requirements 8 | 9 | You need [Node.js](https://nodejs.org/en/) >= 14 installed and you'll need [MongoDB](https://docs.mongodb.com/manual/installation/) installed and running. 10 | 11 | ## Demo 12 | 13 | ![rest-hapi-demo-alt](https://user-images.githubusercontent.com/12631935/41813206-0d2298a0-76e6-11e8-95d4-9b1e521c179e.gif) 14 | 15 | The quickest way to get rest-hapi running on your machine is with the [rest-hapi-demo](https://github.com/JKHeadley/rest-hapi-demo) project: 16 | 17 | (**NOTE:** For an alternative quick start, check out his [awesome yeoman generator](https://github.com/vinaybedre/generator-resthapi) for rest-hapi.) 18 | 19 | 1) Clone the repo 20 | ```sh 21 | $ git clone https://github.com/JKHeadley/rest-hapi-demo.git 22 | $ cd rest-hapi-demo 23 | ``` 24 | 25 | 2) Install the dependencies 26 | ```sh 27 | $ npm install 28 | ``` 29 | 30 | 3) Seed the models 31 | ```sh 32 | $ ./node_modules/.bin/rest-hapi-cli seed 33 | ``` 34 | 35 | 4) Start the server 36 | ```sh 37 | $ npm start 38 | ``` 39 | 40 | 5) View the [API docs](swagger-documentation.md) at 41 | 42 | [http://localhost:8080/](http://localhost:8080/) 43 | 44 | ...have fun! 45 | -------------------------------------------------------------------------------- /docs/route-customization.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: route-customization 3 | title: Route Customization 4 | sidebar_label: Route Customization 5 | --- 6 | 7 | ## Custom path names 8 | By default route paths are constructed using model names, however aliases can be provided to customize the route paths. ``routeOptions.alias`` can be set to alter the base path name, and an ``alias`` property for an association can be set to alter the association path name. For example: 9 | 10 | ```javascript 11 | module.exports = function (mongoose) { 12 | let modelName = "user"; 13 | let Types = mongoose.Schema.Types; 14 | let Schema = new mongoose.Schema({ 15 | email: { 16 | type: Types.String, 17 | required: true, 18 | unique: true 19 | }, 20 | password: { 21 | type: Types.String, 22 | required: true, 23 | exclude: true, 24 | allowOnUpdate: false 25 | } 26 | }); 27 | 28 | Schema.statics = { 29 | collectionName: modelName 30 | routeOptions: { 31 | alias: "person" 32 | associations: { 33 | groups: { 34 | type: "MANY_MANY", 35 | model: "group", 36 | alias: "team" 37 | } 38 | } 39 | } 40 | }; 41 | 42 | return Schema; 43 | }; 44 | ``` 45 | 46 | will result in the following endpoints: 47 | 48 | ```javascript 49 | DELETE /person 50 | POST /person 51 | GET /person 52 | DELETE /person/{_id} 53 | GET /person/{_id} 54 | PUT /person/{_id} 55 | GET /person/{ownerId}/team 56 | DELETE /person/{ownerId}/team 57 | POST /person/{ownerId}/team 58 | DELETE /person/{ownerId}/team/{childId} 59 | PUT /person/{ownerId}/team/{childId} 60 | ``` 61 | 62 | ## Omitting routes 63 | 64 | You can prevent CRUD endpoints from generating by setting the correct property to ``false`` within the ``routeOptions`` object. Below is a list of properties and their effect: 65 | 66 | Property | Effect when false 67 | --- | --- 68 | allowList | omits ``GET /path`` endpoint 69 | allowRead | omits ``GET /path`` and ``GET /path/{_id}`` endpoints 70 | allowCreate | omits ``POST /path`` endpoint 71 | allowUpdate | omits ``PUT /path/{_id}`` endpoint 72 | allowDelete | omits ``DELETE /path`` and ``DELETE /path/{_id}`` endpoints 73 | 74 | Similarly, you can prevent association endpoints from generating through the following properties within each association object: 75 | 76 | Property | Effect when false 77 | --- | --- 78 | allowAdd | omits ``POST /owner/{ownerId}/child`` and ``PUT /owner/{ownerId}/child/{childId}`` endpoints 79 | allowRemove | omits ``DELETE /owner/{ownerId}/child`` and ``DELETE /owner/{ownerId}/child/{childId}`` endpoints 80 | allowRead | omits ``GET /owner/{ownerId}/child`` endpoint 81 | 82 | For example, a routeOption object that omits endpoints for creating objects and removing a specific association could look like this: 83 | 84 | ```javascript 85 | routeOptions: { 86 | allowCreate: false, 87 | associations: { 88 | users: { 89 | type: "MANY_ONE", 90 | alias: "user", 91 | model: "user", 92 | allowRemove: false 93 | } 94 | } 95 | } 96 | ``` -------------------------------------------------------------------------------- /docs/soft-delete.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: soft-delete 3 | title: Soft Delete 4 | sidebar_label: Soft Delete 5 | --- 6 | 7 | rest-hapi supports soft delete functionality for documents. When the [`config.enableSoftDelete`](configuration.md#enablesoftdelete) property is set to ``true``, documents will gain an ``isDeleted`` property when they are created that will be set to ``false``. Whenever that document is deleted (via a rest-hapi endpoint or method), the document will remain in the collection, its ``isDeleted`` property will be set to ``true``, and the ``deletedAt`` and ``deletedBy`` properties (if enabled) will be populated. 8 | 9 | "Hard" deletion is still possible when soft delete is enabled. In order to hard delete a document (i.e. remove a document from it's collection) via the api, a payload must be sent with the ``hardDelete`` property set to ``true``. 10 | 11 | The rest-hapi delete methods include a ``hardDelete`` flag as a parameter. The following is an example of a hard delete using a [rest-hapi method](mongoose-wrapper-methods.md): 12 | 13 | ``RestHapi.deleteOne(model, _id, true, Log);`` -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: support 3 | title: Support 4 | sidebar_label: Support 5 | --- 6 | 7 | Like this project? Please [star it](https://github.com/JKHeadley/rest-hapi)! 8 | 9 | ## Contributing 10 | 11 | We welcome contributions to rest-hapi! These are the many ways you can help: 12 | 13 | - Submit patches and features 14 | - Improve the documentation and website (created with [docusaurus](https://docusaurus.io/), check it out!) 15 | - Report bugs 16 | - Follow us on [Twitter](https://twitter.com/resthapi) 17 | - Participate in the [gitter community](https://gitter.im/rest-hapi/Lobby) 18 | - And [donate financially](https://opencollective.com/rest-hapi)! 19 | 20 | Please read our [contribution guide](https://github.com/JKHeadley/rest-hapi/blob/master/CONTRIBUTING.md) to get started. Also note 21 | that this project is released with a 22 | [Contributor Code of Conduct](https://github.com/JKHeadley/rest-hapi/blob/master/CODE_OF_CONDUCT.md), please make sure to review 23 | and follow it. 24 | 25 | ## Contributors 26 | 27 | Of course we want to thank all of our current contributors! 28 | 29 | 30 | 31 | ## Backers 32 | 33 | Support us with a monthly donation and help us continue our activities! 34 | [Become a backer](https://opencollective.com/rest-hapi#backers). 35 | 36 | 37 | 38 | 39 | ## License 40 | rest-hapi is licensed under a [MIT License](https://github.com/JKHeadley/rest-hapi/blob/master/LICENSE.txt). 41 | -------------------------------------------------------------------------------- /docs/swagger-documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: swagger-documentation 3 | title: Swagger Documentation 4 | sidebar_label: Swagger Documentation 5 | --- 6 | 7 | Swagger documentation (via [hapi-swagger](https://github.com/glennjones/hapi-swagger)) is automatically generated for all endpoints and can be viewed by pointing a browser at the server URL. If you use the [intro script](introduction.md#using-the-plugin) this will be [http://localhost:8080/](http://localhost:8080/). The swagger docs provide quick access to testing your endpoints along with model schema descriptions and query options. 8 | 9 | Below is an example from [demo.resthapi.com](https://demo.resthapi.com): 10 | 11 | ![swagger](https://user-images.githubusercontent.com/12631935/41813184-b31cac6a-76e5-11e8-84c3-881d98e6c65d.gif) -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: testing 3 | title: Testing 4 | sidebar_label: Testing 5 | --- 6 | 7 | If you have downloaded the source you can run the tests with: 8 | ``` 9 | $ npm test 10 | ``` 11 | -------------------------------------------------------------------------------- /globals.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | mongoose: {} 5 | } 6 | -------------------------------------------------------------------------------- /models/audit-log.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Config = require('../config') 4 | const _ = require('lodash') 5 | 6 | module.exports = function(mongoose) { 7 | const modelName = 'auditLog' 8 | const Types = mongoose.Schema.Types 9 | const dateField = { 10 | type: Types.Date, 11 | default: () => { 12 | return Date.now() 13 | } 14 | } 15 | if (Config.auditLogTTL) { 16 | dateField.expires = Config.auditLogTTL 17 | } 18 | 19 | const Schema = new mongoose.Schema( 20 | { 21 | date: dateField, 22 | method: { 23 | type: Types.String, 24 | enum: ['POST', 'PUT', 'DELETE', 'GET', null], 25 | allowNull: true, 26 | default: null 27 | }, 28 | action: { 29 | type: Types.String, 30 | allowNull: true, 31 | default: null 32 | }, 33 | endpoint: { 34 | type: Types.String, 35 | allowNull: true, 36 | default: null 37 | }, 38 | user: { 39 | type: Types.ObjectId, 40 | allowNull: true, 41 | default: null 42 | }, 43 | collectionName: { 44 | type: Types.String, 45 | allowNull: true, 46 | default: null 47 | }, 48 | childCollectionName: { 49 | type: Types.String, 50 | allowNull: true, 51 | default: null 52 | }, 53 | associationType: { 54 | type: Types.String, 55 | enum: ['ONE_MANY', 'MANY_MANY', '_MANY', null], 56 | allowNull: true, 57 | default: null 58 | }, 59 | documents: { 60 | type: [Types.ObjectId], 61 | allowNull: true, 62 | default: null 63 | }, 64 | payload: { 65 | type: Types.Object, 66 | allowNull: true, 67 | default: null 68 | }, 69 | params: { 70 | type: Types.Object, 71 | allowNull: true, 72 | default: null 73 | }, 74 | result: { 75 | type: Types.Object, 76 | allowNull: true, 77 | default: null 78 | }, 79 | statusCode: { 80 | type: Types.Number, 81 | allowNull: true, 82 | default: null 83 | }, 84 | responseMessage: { 85 | type: Types.String, 86 | allowNull: true, 87 | default: null 88 | }, 89 | isError: { 90 | type: Types.Boolean, 91 | default: false, 92 | required: true 93 | }, 94 | ipAddress: { 95 | type: Types.String, 96 | allowNull: true, 97 | default: null 98 | }, 99 | notes: { 100 | type: Types.String, 101 | allowNull: true, 102 | default: null 103 | } 104 | }, 105 | { collection: modelName } 106 | ) 107 | 108 | Schema.statics = { 109 | collectionName: modelName, 110 | routeOptions: { 111 | allowUpdate: false, 112 | allowDelete: false 113 | } 114 | } 115 | 116 | if (!_.isEmpty(Config.auditLogScope)) { 117 | Schema.statics.routeOptions.routeScope = { 118 | rootScope: Config.auditLogScope 119 | } 120 | } 121 | 122 | return Schema 123 | } 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-hapi", 3 | "version": "3.2.0", 4 | "description": "A RESTful API generator for hapi", 5 | "main": "rest-hapi.js", 6 | "bin": { 7 | "rest-hapi-cli": "./rest-hapi-cli.js" 8 | }, 9 | "engines": { 10 | "node": ">=16.13.2", 11 | "npm": ">=8.1.2" 12 | }, 13 | "directories": { 14 | "test": "tests" 15 | }, 16 | "scripts": { 17 | "test": "npm run cover", 18 | "posttest": "npm run report-coverage", 19 | "cover": "npm run cover:unit && npm run cover:e2e", 20 | "cover:unit": "nyc --reporter=lcov --silent npm run test-unit", 21 | "cover:e2e": "nyc --reporter=lcov --silent --clean=false npm run test-e2e", 22 | "test-all": "tape ./tests/unit/*.tests.js && tape ./tests/e2e/*.tests.js", 23 | "test-unit": "tape ./tests/unit/*.tests.js", 24 | "test-e2e": "tape ./tests/e2e/*.tests.js", 25 | "test-joi": "tape ./tests/unit/joi-mongoose-helper.tests.js", 26 | "test-rest-helper": "tape ./tests/unit/rest-helper-factory.tests.js", 27 | "report-coverage": "nyc report --reporter=html --reporter=text-lcov > coverage.lcov && codecov", 28 | "patch-release-git": "git add . && git commit -a -m 'patch release' && git push && npm version patch && npm publish", 29 | "patch-release": "npm version patch && npm publish", 30 | "lint": "eslint --fix **/*.js ./" 31 | }, 32 | "lint-staged": { 33 | "**/*.js": [ 34 | "eslint --fix", 35 | "git add" 36 | ] 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/JKHeadley/rest-hapi.git" 41 | }, 42 | "keywords": [ 43 | "hapi", 44 | "API", 45 | "RESTful", 46 | "mongoose", 47 | "generator" 48 | ], 49 | "author": { 50 | "name": "Justin Headley", 51 | "email": "headley.justin@gmail.com" 52 | }, 53 | "license": "MIT", 54 | "bugs": { 55 | "url": "https://github.com/JKHeadley/rest-hapi/issues/new", 56 | "email": "headley.justin@gmail.com" 57 | }, 58 | "homepage": "https://github.com/JKHeadley/rest-hapi#readme", 59 | "dependencies": { 60 | "@hapi/boom": "^9.1.0", 61 | "@hapi/hapi": "^20.2.2", 62 | "@hapi/hoek": "^9.0.4", 63 | "@hapi/inert": "^6.0.1", 64 | "@hapi/vision": "^6.0.0", 65 | "blue-tape": "^1.0.0", 66 | "chalk": "^4.0.0", 67 | "extend": "^3.0.2", 68 | "fs-extra": "^8.1.0", 69 | "hapi-swagger": "^14.5.5", 70 | "joi": "^17.6.0", 71 | "lodash": "~4.17.15", 72 | "loggin": "^3.0.2", 73 | "mongoose": "^6.4.6", 74 | "mrhorse": "^6.0.0", 75 | "prettier-config-standard": "^1.0.1", 76 | "query-string": "^6.8.3", 77 | "require-all": "^3.0.0", 78 | "tape": "latest" 79 | }, 80 | "devDependencies": { 81 | "babel-eslint": "^10.0.3", 82 | "clear-require": "^3.0.0", 83 | "codecov": "^3.7.0", 84 | "decache": "4.5.1", 85 | "eslint": "^6.4.0", 86 | "eslint-config-prettier": "^6.3.0", 87 | "eslint-config-prettier-standard": "^3.0.1", 88 | "eslint-config-standard": "^14.1.0", 89 | "eslint-plugin-import": "^2.18.2", 90 | "eslint-plugin-node": "^10.0.0", 91 | "eslint-plugin-prettier": "^3.1.0", 92 | "eslint-plugin-promise": "^4.2.1", 93 | "eslint-plugin-standard": "^4.0.1", 94 | "husky": "^3.0.5", 95 | "lint-staged": "^9.2.5", 96 | "mkdirp": "^1.0.3", 97 | "mongodb-memory-server": "^6.4.1", 98 | "nyc": "^15.0.0", 99 | "prettier": "1.18.2", 100 | "proxyquire": "^2.1.3", 101 | "q": "^1.5.1", 102 | "rewire": "^6.0.0", 103 | "sinon": "^7.0.0", 104 | "sinon-test": "^2.3.0" 105 | }, 106 | "husky": { 107 | "hooks": { 108 | "pre-push": "lint-staged" 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /policies/add-by-meta-data.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | const _ = require('lodash') 5 | const config = require('../config') 6 | 7 | const internals = {} 8 | 9 | /** 10 | * Policy to add the creating user's _id to the document's "createdBy" property. 11 | * @param model 12 | * @param logger 13 | * @returns {addCreatedByForModel} 14 | */ 15 | internals.addCreatedBy = function(model, logger) { 16 | const addCreatedByForModel = function addCreatedByForModel(request, h) { 17 | const Log = logger.bind('addCreatedBy') 18 | 19 | return internals.addMeta('create', request, h, Log) 20 | } 21 | 22 | addCreatedByForModel.applyPoint = 'onPreHandler' 23 | return addCreatedByForModel 24 | } 25 | internals.addCreatedBy.applyPoint = 'onPreHandler' 26 | 27 | /** 28 | * Policy to add the updating user's _id to the document's "updatedBy" property. 29 | * @param model 30 | * @param logger 31 | * @returns {addUpdatedByForModel} 32 | */ 33 | internals.addUpdatedBy = function(model, logger) { 34 | const addUpdatedByForModel = function addUpdatedByForModel(request, h) { 35 | const Log = logger.bind('addUpdatedBy') 36 | 37 | return internals.addMeta('update', request, h, Log) 38 | } 39 | 40 | addUpdatedByForModel.applyPoint = 'onPreHandler' 41 | return addUpdatedByForModel 42 | } 43 | internals.addUpdatedBy.applyPoint = 'onPreHandler' 44 | 45 | /** 46 | * Policy to add the deleting user's _id to the document's "deletedBy" property. 47 | * @param model 48 | * @param logger 49 | * @returns {addDeletedByForModel} 50 | */ 51 | internals.addDeletedBy = function(model, logger) { 52 | const addDeletedByForModel = function addDeletedByForModel(request, h) { 53 | const Log = logger.bind('addDeletedBy') 54 | 55 | if (_.isArray(request.payload)) { 56 | request.payload = request.payload.map(function(data) { 57 | if (_.isString(data)) { 58 | return { _id: data, hardDelete: false } 59 | } else { 60 | return data 61 | } 62 | }) 63 | } 64 | 65 | return internals.addMeta('delete', request, h, Log) 66 | } 67 | 68 | addDeletedByForModel.applyPoint = 'onPreHandler' 69 | return addDeletedByForModel 70 | } 71 | internals.addDeletedBy.applyPoint = 'onPreHandler' 72 | 73 | /** 74 | * Internal function to add the user's _id to a document's relevant meta property. 75 | * @param action 76 | * @param request 77 | * @param h 78 | * @param logger 79 | * @returns {*} 80 | */ 81 | internals.addMeta = function(action, request, h, logger) { 82 | const Log = logger.bind() 83 | 84 | try { 85 | let metaType = '' 86 | switch (action) { 87 | case 'create': 88 | metaType = 'createdBy' 89 | break 90 | case 'update': 91 | metaType = 'updatedBy' 92 | break 93 | case 'delete': 94 | metaType = 'deletedBy' 95 | break 96 | default: 97 | throw new Error('Invalid action.') 98 | } 99 | 100 | const userId = _.get(request.auth.credentials, config.userIdKey) 101 | 102 | if (!userId) { 103 | const message = 104 | 'User _id not found in auth credentials. Please specify the user _id path in "config.userIdKey"' 105 | Log.error(message) 106 | throw Boom.badRequest(message) 107 | } 108 | 109 | if (_.isArray(request.payload)) { 110 | request.payload.forEach(function(document) { 111 | document[metaType] = userId 112 | }) 113 | } else { 114 | request.payload = request.payload || {} 115 | request.payload[metaType] = userId 116 | } 117 | 118 | return h.continue 119 | } catch (err) { 120 | if (err.isBoom) { 121 | throw err 122 | } else { 123 | Log.error(err) 124 | throw Boom.badImplementation(err) 125 | } 126 | } 127 | } 128 | 129 | module.exports = { 130 | addCreatedBy: internals.addCreatedBy, 131 | addUpdatedBy: internals.addUpdatedBy, 132 | addDeletedBy: internals.addDeletedBy 133 | } 134 | -------------------------------------------------------------------------------- /policies/add-document-scope.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | const _ = require('lodash') 5 | 6 | const internals = {} 7 | 8 | /** 9 | * Policy to append any document scopes defined in the routeOptions to any existing scope. 10 | * @param model 11 | * @param logger 12 | * @returns {addDocumentScopeForModel} 13 | */ 14 | internals.addDocumentScope = function(model, logger) { 15 | const addDocumentScopeForModel = function addDocumentScopeForModel( 16 | request, 17 | h 18 | ) { 19 | const Log = logger.bind('addDocumentScope') 20 | try { 21 | const scope = model.routeOptions.documentScope 22 | 23 | if (scope) { 24 | for (const scopeType in scope) { 25 | if (_.isArray(request.payload)) { 26 | request.payload.forEach(function(document) { 27 | document.scope = document.scope || {} 28 | document.scope[scopeType] = document.scope[scopeType] || [] 29 | document.scope[scopeType] = document.scope[scopeType].concat( 30 | scope[scopeType] 31 | ) 32 | }) 33 | } else { 34 | request.payload.scope = request.payload.scope || {} 35 | request.payload.scope[scopeType] = 36 | request.payload.scope[scopeType] || [] 37 | request.payload.scope[scopeType] = request.payload.scope[ 38 | scopeType 39 | ].concat(scope[scopeType]) 40 | } 41 | } 42 | } 43 | 44 | return h.continue 45 | } catch (err) { 46 | Log.error('ERROR:', err) 47 | throw Boom.badImplementation(err) 48 | } 49 | } 50 | 51 | addDocumentScopeForModel.applyPoint = 'onPreHandler' 52 | 53 | return addDocumentScopeForModel 54 | } 55 | 56 | internals.addDocumentScope.applyPoint = 'onPreHandler' 57 | 58 | module.exports = { 59 | addDocumentScope: internals.addDocumentScope 60 | } 61 | -------------------------------------------------------------------------------- /policies/authorize-document-creator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | const _ = require('lodash') 5 | const config = require('../config') 6 | 7 | const internals = {} 8 | 9 | /** 10 | * Policy to authorize a document's creator to perform any action on the document. 11 | * @param model 12 | * @param logger 13 | * @returns {authorizeDocumentCreatorForModel} 14 | */ 15 | internals.authorizeDocumentCreator = function(model, logger) { 16 | const authorizeDocumentCreatorForModel = function authorizeDocumentCreatorForModel( 17 | request, 18 | h 19 | ) { 20 | const Log = logger.bind('authorizeDocumentCreator') 21 | 22 | return internals.addScope('root', request, h, Log) 23 | } 24 | 25 | authorizeDocumentCreatorForModel.applyPoint = 'onPreHandler' 26 | return authorizeDocumentCreatorForModel 27 | } 28 | internals.authorizeDocumentCreator.applyPoint = 'onPreHandler' 29 | 30 | /** 31 | * Policy to authorize a document's creator to perform read actions on the document. 32 | * @param model 33 | * @param logger 34 | * @returns {authorizeDocumentCreatorToReadForModel} 35 | */ 36 | internals.authorizeDocumentCreatorToRead = function(model, logger) { 37 | const authorizeDocumentCreatorToReadForModel = function authorizeDocumentCreatorToReadForModel( 38 | request, 39 | h 40 | ) { 41 | const Log = logger.bind('authorizeDocumentCreatorToRead') 42 | 43 | return internals.addScope('read', request, h, Log) 44 | } 45 | 46 | authorizeDocumentCreatorToReadForModel.applyPoint = 'onPreHandler' 47 | return authorizeDocumentCreatorToReadForModel 48 | } 49 | internals.authorizeDocumentCreatorToRead.applyPoint = 'onPreHandler' 50 | 51 | /** 52 | * Policy to authorize a document's creator to perform update actions on the document. 53 | * @param model 54 | * @param logger 55 | * @returns {authorizeDocumentCreatorToUpdateForModel} 56 | */ 57 | internals.authorizeDocumentCreatorToUpdate = function(model, logger) { 58 | const authorizeDocumentCreatorToUpdateForModel = function authorizeDocumentCreatorToUpdateForModel( 59 | request, 60 | h 61 | ) { 62 | const Log = logger.bind('authorizeDocumentCreatorToUpdate') 63 | 64 | return internals.addScope('update', request, h, Log) 65 | } 66 | 67 | authorizeDocumentCreatorToUpdateForModel.applyPoint = 'onPreHandler' 68 | return authorizeDocumentCreatorToUpdateForModel 69 | } 70 | internals.authorizeDocumentCreatorToUpdate.applyPoint = 'onPreHandler' 71 | 72 | /** 73 | * Policy to authorize a document's creator to perform delete actions on the document. 74 | * @param model 75 | * @param logger 76 | * @returns {authorizeDocumentCreatorToDeleteForModel} 77 | */ 78 | internals.authorizeDocumentCreatorToDelete = function(model, logger) { 79 | const authorizeDocumentCreatorToDeleteForModel = function authorizeDocumentCreatorToDeleteForModel( 80 | request, 81 | h 82 | ) { 83 | const Log = logger.bind('authorizeDocumentCreatorToDelete') 84 | 85 | return internals.addScope('delete', request, h, Log) 86 | } 87 | 88 | authorizeDocumentCreatorToDeleteForModel.applyPoint = 'onPreHandler' 89 | return authorizeDocumentCreatorToDeleteForModel 90 | } 91 | internals.authorizeDocumentCreatorToDelete.applyPoint = 'onPreHandler' 92 | 93 | /** 94 | * Policy to authorize a document's creator to perform associate actions on the document. 95 | * @param model 96 | * @param logger 97 | * @returns {authorizeDocumentCreatorToAssociateForModel} 98 | */ 99 | internals.authorizeDocumentCreatorToAssociate = function(model, logger) { 100 | const authorizeDocumentCreatorToAssociateForModel = function authorizeDocumentCreatorToAssociateForModel( 101 | request, 102 | h 103 | ) { 104 | const Log = logger.bind('authorizeDocumentCreatorToAssociate') 105 | 106 | return internals.addScope('associate', request, h, Log) 107 | } 108 | 109 | authorizeDocumentCreatorToAssociateForModel.applyPoint = 'onPreHandler' 110 | return authorizeDocumentCreatorToAssociateForModel 111 | } 112 | internals.authorizeDocumentCreatorToAssociate.applyPoint = 'onPreHandler' 113 | 114 | /** 115 | * Internal function to add the creating user's _id to a document's relevant action scope. 116 | * @param action 117 | * @param request 118 | * @param h 119 | * @param logger 120 | * @returns {*} 121 | */ 122 | internals.addScope = function(action, request, h, logger) { 123 | const Log = logger.bind() 124 | try { 125 | let scopeType = '' 126 | switch (action) { 127 | case 'root': 128 | scopeType = 'rootScope' 129 | break 130 | case 'read': 131 | scopeType = 'readScope' 132 | break 133 | case 'update': 134 | scopeType = 'updateScope' 135 | break 136 | case 'delete': 137 | scopeType = 'deleteScope' 138 | break 139 | case 'associate': 140 | scopeType = 'associateScope' 141 | break 142 | default: 143 | throw new Error('Invalid action.') 144 | } 145 | 146 | const userId = _.get(request.auth.credentials, config.userIdKey) 147 | 148 | if (!userId) { 149 | const message = 150 | 'User _id not found in auth credentials. Please specify the user _id path in "config.userIdKey"' 151 | Log.error(message) 152 | throw Boom.badRequest(message) 153 | } 154 | 155 | if (_.isArray(request.payload)) { 156 | request.payload.forEach(function(document) { 157 | const scope = {} 158 | scope[scopeType] = [] 159 | 160 | document.scope = document.scope || scope 161 | document.scope[scopeType] = document.scope[scopeType] || [] 162 | document.scope[scopeType].push('user-' + userId) 163 | }) 164 | } else { 165 | const scope = {} 166 | scope[scopeType] = [] 167 | 168 | request.payload.scope = request.payload.scope || scope 169 | request.payload.scope[scopeType] = request.payload.scope[scopeType] || [] 170 | request.payload.scope[scopeType].push('user-' + userId) 171 | } 172 | 173 | return h.continue 174 | } catch (err) { 175 | if (err.isBoom) { 176 | throw err 177 | } else { 178 | Log.error(err) 179 | throw Boom.badImplementation(err) 180 | } 181 | } 182 | } 183 | 184 | module.exports = { 185 | authorizeDocumentCreator: internals.authorizeDocumentCreator, 186 | authorizeDocumentCreatorToRead: internals.authorizeDocumentCreatorToRead, 187 | authorizeDocumentCreatorToUpdate: internals.authorizeDocumentCreatorToUpdate, 188 | authorizeDocumentCreatorToDelete: internals.authorizeDocumentCreatorToDelete, 189 | authorizeDocumentCreatorToAssociate: 190 | internals.authorizeDocumentCreatorToAssociate 191 | } 192 | -------------------------------------------------------------------------------- /policies/populate-duplicate-fields.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | const _ = require('lodash') 5 | 6 | const internals = {} 7 | 8 | /** 9 | * Policy to populate duplicate fields when an association is created or updated. 10 | * @param model 11 | * @param logger 12 | * @returns {populateDuplicateFields} 13 | */ 14 | internals.populateDuplicateFields = function(model, mongoose, logger) { 15 | const populateDuplicateFieldsForModel = async function addDocumentScopeForModel( 16 | request, 17 | h 18 | ) { 19 | const Log = logger.bind('populateDuplicateFields') 20 | try { 21 | let payload = request.payload 22 | if (!_.isArray(request.payload)) { 23 | payload = [request.payload] 24 | } 25 | 26 | const associations = model.schema.statics.routeOptions.associations 27 | if (associations) { 28 | const promises = [] 29 | for (const key in associations) { 30 | const association = associations[key] 31 | const duplicate = association.duplicate 32 | for (const doc of payload) { 33 | if ( 34 | duplicate && 35 | (association.type === 'MANY_ONE' || 36 | association.type === 'ONE_ONE') && 37 | doc[key] 38 | ) { 39 | const childModel = mongoose.model(association.model) 40 | 41 | const promise = childModel 42 | .findOne({ _id: doc[key] }) 43 | .then(function(result) { 44 | const docsToUpdate = payload.filter(function(docToFind) { 45 | return docToFind[key] === result._id.toString() 46 | }) 47 | // EXPL: Populate each duplicated field for this association. 48 | // NOTE: We are updating the original payload 49 | for (const prop of duplicate) { 50 | for (const docToUpdate of docsToUpdate) { 51 | docToUpdate[prop.as] = result[prop.field] 52 | } 53 | } 54 | }) 55 | promises.push(promise) 56 | } 57 | } 58 | } 59 | 60 | await Promise.all(promises) 61 | return h.continue 62 | } 63 | return h.continue 64 | } catch (err) { 65 | if (err.isBoom) { 66 | throw err 67 | } else { 68 | Log.error(err) 69 | throw Boom.badImplementation(err) 70 | } 71 | } 72 | } 73 | 74 | populateDuplicateFieldsForModel.applyPoint = 'onPreHandler' 75 | 76 | return populateDuplicateFieldsForModel 77 | } 78 | 79 | internals.populateDuplicateFields.applyPoint = 'onPreHandler' 80 | 81 | module.exports = { 82 | populateDuplicateFields: internals.populateDuplicateFields 83 | } 84 | -------------------------------------------------------------------------------- /policies/track-duplicated-fields.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | const _ = require('lodash') 5 | 6 | const internals = {} 7 | 8 | /** 9 | * Policy to update any duplicate fields when the original field changes. 10 | * @param model 11 | * @param logger 12 | * @returns {trackDuplicatedFields} 13 | */ 14 | internals.trackDuplicatedFields = function(model, mongoose, logger) { 15 | const trackDuplicatedFieldsForModel = async function addDocumentScopeForModel( 16 | request, 17 | h 18 | ) { 19 | const Log = logger.bind('trackDuplicatedFields') 20 | try { 21 | if (_.isError(request.response)) { 22 | return h.continue 23 | } 24 | await internals.trackFields( 25 | model, 26 | mongoose, 27 | request.payload, 28 | request.response.source, 29 | Log 30 | ) 31 | return h.continue 32 | } catch (err) { 33 | Log.error(err) 34 | throw Boom.badImplementation(err) 35 | } 36 | } 37 | 38 | trackDuplicatedFieldsForModel.applyPoint = 'onPostHandler' 39 | 40 | return trackDuplicatedFieldsForModel 41 | } 42 | 43 | internals.trackDuplicatedFields.applyPoint = 'onPostHandler' 44 | 45 | /** 46 | * Recursively updates all the duplicate fields. 47 | * @param model 48 | * @param mongoose 49 | * @param payload 50 | * @param result 51 | * @param logger 52 | * @returns {*} 53 | */ 54 | internals.trackFields = function(model, mongoose, payload, result, logger) { 55 | const Log = logger.bind('trackFields') 56 | const promises = [] 57 | for (const key in payload) { 58 | const field = model.schema.obj[key] 59 | // EXPL: Check each field that was updated. If the field has been duplicated, update each duplicate 60 | // field to match the new value. 61 | if (field && field.duplicated) { 62 | field.duplicated.forEach(function(duplicate) { 63 | const childModel = mongoose.model(duplicate.model) 64 | const newProp = {} 65 | newProp[duplicate.as] = result[key] 66 | const query = {} 67 | query[duplicate.association] = result._id 68 | 69 | promises.push( 70 | internals.findAndUpdate(mongoose, childModel, query, newProp, Log) 71 | ) 72 | }) 73 | } 74 | } 75 | 76 | return Promise.all(promises) 77 | } 78 | 79 | /** 80 | * Find the documents with duplicate fields and update. 81 | * @param mongoose 82 | * @param childModel 83 | * @param query 84 | * @param newProp 85 | * @param logger 86 | */ 87 | internals.findAndUpdate = async function( 88 | mongoose, 89 | childModel, 90 | query, 91 | newProp, 92 | logger 93 | ) { 94 | const result = await childModel.find(query) 95 | const promises = [] 96 | 97 | result.forEach(function(doc) { 98 | promises.push( 99 | internals.updateField(mongoose, childModel, doc._id, newProp, logger) 100 | ) 101 | }) 102 | 103 | return Promise.all(promises) 104 | } 105 | 106 | /** 107 | * Update a duplicate field for a single doc, then call 'trackDuplicateFields' in case any other docs are duplicating 108 | * the duplicate field. 109 | * @param mongoose 110 | * @param childModel 111 | * @param _id 112 | * @param newProp 113 | * @param logger 114 | */ 115 | internals.updateField = async function( 116 | mongoose, 117 | childModel, 118 | _id, 119 | newProp, 120 | logger 121 | ) { 122 | const result = await childModel.findByIdAndUpdate(_id, newProp, { new: true }) 123 | return internals.trackFields(childModel, mongoose, newProp, result, logger) 124 | } 125 | 126 | module.exports = { 127 | trackDuplicatedFields: internals.trackDuplicatedFields 128 | } 129 | -------------------------------------------------------------------------------- /rest-hapi-cli.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const userArgs = process.argv.slice(2) 4 | 5 | const command = userArgs[0] 6 | 7 | const args = userArgs 8 | 9 | args.shift() 10 | 11 | const exec = require('child_process').exec 12 | 13 | const isWindows = /^win/.test(process.platform) 14 | 15 | let cmdString = '$PWD/node_modules/rest-hapi/scripts/' 16 | 17 | if (isWindows) { 18 | cmdString = './node_modules/rest-hapi/scripts/' 19 | } 20 | 21 | switch (command) { 22 | case 'seed': 23 | exec('node ' + cmdString + 'seed.js ' + args, function( 24 | err, 25 | stdout, 26 | stderr 27 | ) { 28 | console.log(stdout) 29 | console.log(stderr) 30 | if (err) { 31 | throw err 32 | } 33 | }) 34 | break 35 | case 'test': 36 | exec('npm run test', function(err, stdout, stderr) { 37 | console.log(stdout) 38 | console.log(stderr) 39 | if (err) { 40 | throw err 41 | } 42 | }) 43 | break 44 | case 'update-associations': 45 | exec( 46 | 'node ' + 47 | cmdString + 48 | 'update-associations.js' + 49 | ' --options ' + 50 | args.join(' --options '), 51 | function(err, stdout, stderr) { 52 | console.log(stdout) 53 | console.log(stderr) 54 | if (err) { 55 | throw err 56 | } 57 | } 58 | ) 59 | break 60 | default: 61 | console.error('error, unknown command:', command) 62 | break 63 | } 64 | -------------------------------------------------------------------------------- /scripts/seed.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mongoose = require('mongoose') 4 | const config = require('../config') 5 | const RestHapi = require('../rest-hapi') 6 | const path = require('path') 7 | const fs = require('fs-extra') 8 | 9 | let mongoURI = process.argv.slice(2)[0] 10 | ;(async function seed() { 11 | RestHapi.config.loglevel = 'DEBUG' 12 | const Log = RestHapi.getLogger('seed') 13 | try { 14 | await moveModels() 15 | 16 | mongoose.Promise = Promise 17 | 18 | mongoURI = mongoURI || RestHapi.config.mongo.URI 19 | mongoose.connect(mongoURI, { 20 | useNewUrlParser: true, 21 | useUnifiedTopology: true 22 | }) 23 | 24 | const models = await RestHapi.generateModels(mongoose) 25 | 26 | const password = '1234' 27 | 28 | await dropCollections(models) 29 | 30 | Log.log('seeding roles') 31 | let roles = [ 32 | { 33 | name: 'Account', 34 | description: 'A standard user account.' 35 | }, 36 | { 37 | name: 'Admin', 38 | description: 'A user with advanced permissions.' 39 | }, 40 | { 41 | name: 'SuperAdmin', 42 | description: 'A user with full permissions.' 43 | } 44 | ] 45 | 46 | roles = await RestHapi.create(models.role, roles, Log) 47 | 48 | Log.log('seeding users') 49 | const users = [ 50 | { 51 | email: 'test@account.com', 52 | password: password, 53 | role: roles[0]._id 54 | }, 55 | { 56 | email: 'test@admin.com', 57 | password: password, 58 | role: roles[1]._id 59 | }, 60 | { 61 | email: 'test@superadmin.com', 62 | password: password, 63 | role: roles[2]._id 64 | } 65 | ] 66 | await RestHapi.create(models.user, users, Log) 67 | process.exit() 68 | } catch (err) { 69 | Log.error(err) 70 | process.exit() 71 | } 72 | })() 73 | 74 | function moveModels() { 75 | return new Promise((resolve, reject) => { 76 | fs.copy( 77 | path.join(__dirname, '../seed'), 78 | path.join(__dirname, '/../../../', config.modelPath), 79 | err => { 80 | if (err) { 81 | reject(err) 82 | } 83 | resolve() 84 | } 85 | ) 86 | }) 87 | } 88 | 89 | async function dropCollections(models) { 90 | RestHapi.config.loglevel = 'LOG' 91 | const Log = RestHapi.getLogger('unseed') 92 | try { 93 | await models.user.remove({}) 94 | Log.log('roles removed') 95 | await models.role.remove({}) 96 | Log.log('users removed') 97 | } catch (err) { 98 | Log.error(err) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /seed/group.model.js: -------------------------------------------------------------------------------- 1 | module.exports = function(mongoose) { 2 | const modelName = 'group' 3 | const Types = mongoose.Schema.Types 4 | const Schema = new mongoose.Schema({ 5 | name: { 6 | type: Types.String, 7 | required: true 8 | }, 9 | description: { 10 | type: Types.String 11 | } 12 | }) 13 | 14 | Schema.statics = { 15 | collectionName: modelName, 16 | routeOptions: { 17 | associations: { 18 | users: { 19 | type: 'MANY_MANY', 20 | alias: 'user', 21 | model: 'user' 22 | }, 23 | permissions: { 24 | type: 'MANY_MANY', 25 | alias: 'permission', 26 | model: 'permission', 27 | linkingModel: 'group_permission' 28 | } 29 | } 30 | } 31 | } 32 | 33 | return Schema 34 | } 35 | -------------------------------------------------------------------------------- /seed/linking-models/group_permission.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | module.exports = function() { 4 | const Types = mongoose.Schema.Types 5 | 6 | const Model = { 7 | Schema: { 8 | enabled: { 9 | type: Types.Boolean, 10 | default: true 11 | } 12 | }, 13 | modelName: 'group_permission' 14 | } 15 | 16 | return Model 17 | } 18 | -------------------------------------------------------------------------------- /seed/linking-models/role_permission.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | module.exports = function() { 4 | const Types = mongoose.Schema.Types 5 | 6 | const Model = { 7 | Schema: { 8 | enabled: { 9 | type: Types.Boolean, 10 | default: true 11 | } 12 | }, 13 | modelName: 'role_permission' 14 | } 15 | 16 | return Model 17 | } 18 | -------------------------------------------------------------------------------- /seed/linking-models/user_permission.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | module.exports = function() { 4 | const Types = mongoose.Schema.Types 5 | 6 | const Model = { 7 | Schema: { 8 | enabled: { 9 | type: Types.Boolean, 10 | default: true 11 | } 12 | }, 13 | modelName: 'user_permission' 14 | } 15 | 16 | return Model 17 | } 18 | -------------------------------------------------------------------------------- /seed/permission.model.js: -------------------------------------------------------------------------------- 1 | module.exports = function(mongoose) { 2 | const modelName = 'permission' 3 | const Types = mongoose.Schema.Types 4 | const Schema = new mongoose.Schema({ 5 | name: { 6 | type: Types.String, 7 | required: true 8 | }, 9 | description: { 10 | type: Types.String 11 | } 12 | }) 13 | Schema.statics = { 14 | collectionName: modelName, 15 | routeOptions: { 16 | associations: { 17 | users: { 18 | type: 'MANY_MANY', 19 | alias: 'user', 20 | model: 'user', 21 | linkingModel: 'user_permission' 22 | }, 23 | roles: { 24 | type: 'MANY_MANY', 25 | alias: 'role', 26 | model: 'role', 27 | linkingModel: 'role_permission' 28 | }, 29 | groups: { 30 | type: 'MANY_MANY', 31 | alias: 'group', 32 | model: 'group', 33 | linkingModel: 'group_permission' 34 | } 35 | } 36 | } 37 | } 38 | 39 | return Schema 40 | } 41 | -------------------------------------------------------------------------------- /seed/role.model.js: -------------------------------------------------------------------------------- 1 | module.exports = function(mongoose) { 2 | const modelName = 'role' 3 | const Types = mongoose.Schema.Types 4 | const Schema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: Types.String, 8 | enum: ['Account', 'Admin', 'SuperAdmin'], 9 | required: true 10 | }, 11 | description: { 12 | type: Types.String 13 | } 14 | }, 15 | { collection: modelName } 16 | ) 17 | 18 | Schema.statics = { 19 | collectionName: modelName, 20 | routeOptions: { 21 | associations: { 22 | users: { 23 | type: 'ONE_MANY', 24 | alias: 'user', 25 | foreignField: 'role', 26 | model: 'user' 27 | }, 28 | permissions: { 29 | type: 'MANY_MANY', 30 | alias: 'permission', 31 | model: 'permission', 32 | linkingModel: 'role_permission' 33 | } 34 | } 35 | } 36 | } 37 | 38 | return Schema 39 | } 40 | -------------------------------------------------------------------------------- /seed/user.model.js: -------------------------------------------------------------------------------- 1 | // NOTE: Install bcrypt then uncomment the line below 2 | // let bcrypt = require('bcryptjs') 3 | const RestHapi = require('rest-hapi') 4 | 5 | // TODO: assign a unique text index to email field 6 | 7 | module.exports = function(mongoose) { 8 | const modelName = 'user' 9 | const Types = mongoose.Schema.Types 10 | const Schema = new mongoose.Schema({ 11 | email: { 12 | type: Types.String, 13 | unique: true 14 | }, 15 | password: { 16 | type: Types.String, 17 | required: true, 18 | exclude: true, 19 | allowOnUpdate: false 20 | }, 21 | firstName: { 22 | type: Types.String 23 | }, 24 | lastName: { 25 | type: Types.String 26 | }, 27 | role: { 28 | type: Types.ObjectId, 29 | ref: 'role', 30 | required: true 31 | } 32 | }) 33 | 34 | Schema.statics = { 35 | collectionName: modelName, 36 | routeOptions: { 37 | associations: { 38 | role: { 39 | type: 'MANY_ONE', 40 | model: 'role' 41 | }, 42 | groups: { 43 | type: 'MANY_MANY', 44 | alias: 'group', 45 | model: 'group' 46 | }, 47 | permissions: { 48 | type: 'MANY_MANY', 49 | alias: 'permission', 50 | model: 'permission', 51 | linkingModel: 'user_permission' 52 | } 53 | }, 54 | extraEndpoints: [ 55 | // Password Update Endpoint 56 | function(server, model, options, logger) { 57 | const Log = logger.bind('Password Update') 58 | const Boom = require('@hapi/boom') 59 | 60 | const collectionName = model.collectionDisplayName || model.modelName 61 | 62 | Log.note('Generating Password Update endpoint for ' + collectionName) 63 | 64 | const handler = async function(request, h) { 65 | try { 66 | const hashedPassword = model.generatePasswordHash( 67 | request.payload.password 68 | ) 69 | 70 | await model.findByIdAndUpdate(request.params._id, { 71 | password: hashedPassword 72 | }) 73 | 74 | return h.response('Password updated.').code(200) 75 | } catch (err) { 76 | Log.error(err) 77 | throw Boom.badImplementation(err) 78 | } 79 | } 80 | 81 | server.route({ 82 | method: 'PUT', 83 | path: '/user/{_id}/password', 84 | config: { 85 | handler: handler, 86 | auth: null, 87 | description: "Update a user's password.", 88 | tags: ['api', 'User', 'Password'], 89 | validate: { 90 | params: { 91 | _id: RestHapi.joiHelper.joiObjectId().required() 92 | }, 93 | payload: { 94 | password: RestHapi.joi 95 | .string() 96 | .required() 97 | .description("The user's new password") 98 | } 99 | }, 100 | plugins: { 101 | 'hapi-swagger': { 102 | responseMessages: [ 103 | { code: 204, message: 'Success' }, 104 | { code: 400, message: 'Bad Request' }, 105 | { code: 404, message: 'Not Found' }, 106 | { code: 500, message: 'Internal Server Error' } 107 | ] 108 | } 109 | } 110 | } 111 | }) 112 | } 113 | ], 114 | create: { 115 | pre: function(payload, logger) { 116 | const hashedPassword = mongoose 117 | .model('user') 118 | .generatePasswordHash(payload.password) 119 | 120 | payload.password = hashedPassword 121 | 122 | return payload 123 | } 124 | } 125 | }, 126 | 127 | generatePasswordHash: function(password) { 128 | const hash = password 129 | // NOTE: Uncomment these two lines once bcrypt is installed 130 | // let salt = bcrypt.genSaltSync(10) 131 | // hash = bcrypt.hashSync(password, salt) 132 | return hash 133 | } 134 | } 135 | 136 | return Schema 137 | } 138 | -------------------------------------------------------------------------------- /tests/e2e/advance-assoc.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const TestHelper = require('../../utilities/test-helper') 5 | const Decache = require('decache') 6 | const Q = require('q') 7 | const Hapi = require('@hapi/hapi') 8 | 9 | module.exports = (t, Mongoose, internals, Log) => { 10 | return t.test('advanced association tests', function(t) { 11 | return ( 12 | Q.when() 13 | // implied association embeddings work 14 | // NOTE: implied associations are those listed with a 'ref' property but not listed under 'associations' 15 | // UPDATE: implied associations seem to have broken in the jump to v3.0 16 | .then(function() { 17 | return t.test('filler test', function(t) { 18 | return Promise.resolve("ok") 19 | }) 20 | // return t.test('implied associations work', function(t) { 21 | // // 22 | // const RestHapi = require('../../rest-hapi') 23 | // const server = new Hapi.Server() 24 | 25 | // const config = { 26 | // loglevel: 'ERROR', 27 | // absoluteModelPath: true, 28 | 29 | // modelPath: path.join( 30 | // __dirname, 31 | // '/test-scenarios/scenario-4/models' 32 | // ), 33 | // embedAssociations: false 34 | // } 35 | 36 | // RestHapi.config = config 37 | 38 | // let facilities = [] 39 | 40 | // const promises = [] 41 | 42 | // return ( 43 | // server 44 | // .register({ 45 | // plugin: RestHapi, 46 | // options: { 47 | // mongoose: Mongoose, 48 | // config: config 49 | // } 50 | // }) 51 | // .then(function() { 52 | // server.start() 53 | 54 | // const payload = [ 55 | // { 56 | // name: 'kitchen' 57 | // }, 58 | // { 59 | // name: 'study' 60 | // }, 61 | // { 62 | // name: 'office' 63 | // } 64 | // ] 65 | 66 | // const request = { 67 | // method: 'POST', 68 | // url: '/facility', 69 | // params: {}, 70 | // query: {}, 71 | // payload: payload, 72 | // credentials: {}, 73 | // headers: {} 74 | // } 75 | 76 | // const injectOptions = TestHelper.mockInjection(request) 77 | 78 | // return server.inject(injectOptions) 79 | // }) 80 | // .then(function(response) { 81 | // facilities = response.result 82 | 83 | // const payload = { 84 | // name: 'Big Building', 85 | // facilitiesPerFloor: [ 86 | // { _id: facilities[0]._id }, 87 | // { _id: facilities[1]._id }, 88 | // { _id: facilities[2]._id } 89 | // ] 90 | // } 91 | 92 | // const request = { 93 | // method: 'POST', 94 | // url: '/building', 95 | // params: {}, 96 | // query: {}, 97 | // payload: payload, 98 | // credentials: {}, 99 | // headers: {} 100 | // } 101 | 102 | // const injectOptions = TestHelper.mockInjection(request) 103 | 104 | // return server.inject(injectOptions) 105 | // }) 106 | // .then(function(response) { 107 | // const request = { 108 | // method: 'GET', 109 | // url: '/building', 110 | // params: {}, 111 | // query: { $embed: ['facilitiesPerFloor'] }, 112 | // payload: {}, 113 | // credentials: {}, 114 | // headers: {} 115 | // } 116 | 117 | // const injectOptions = TestHelper.mockInjection(request) 118 | 119 | // promises.push(server.inject(injectOptions)) 120 | // }) 121 | 122 | // // 123 | 124 | // // 125 | // .then(function(injectOptions) { 126 | // return Promise.all(promises) 127 | // }) 128 | // // 129 | 130 | // // 131 | // .then(function(response) { 132 | // const building = response[0].result.docs[0] 133 | // const kitchen = building.facilitiesPerFloor.find( 134 | // facility => facility.name === 'kitchen' 135 | // ) 136 | // const study = building.facilitiesPerFloor.find( 137 | // facility => facility.name === 'study' 138 | // ) 139 | // const office = building.facilitiesPerFloor.find( 140 | // facility => facility.name === 'office' 141 | // ) 142 | 143 | // t.ok(kitchen, 'kitchen embedded') 144 | // t.ok(study, 'study embedded') 145 | // t.ok(office, 'office embedded') 146 | // }) 147 | // // 148 | 149 | // // 150 | // .then(function() { 151 | // Decache('../../rest-hapi') 152 | 153 | // Decache('../config') 154 | // Object.keys(Mongoose.models).forEach(function(key) { 155 | // delete Mongoose.models[key] 156 | // }) 157 | // Object.keys(Mongoose.modelSchemas || []).forEach(function( 158 | // key 159 | // ) { 160 | // delete Mongoose?.modelSchemas[key] 161 | // }) 162 | // }) 163 | // ) 164 | // // 165 | // }) 166 | }) 167 | ) 168 | }) 169 | } 170 | -------------------------------------------------------------------------------- /tests/e2e/end-to-end.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Test = require('blue-tape') 4 | const Logging = require('loggin') 5 | const Q = require('q') 6 | const Decache = require('decache') 7 | 8 | // Import test groups 9 | const BasicCrudTests = require('./basic-crud.tests') 10 | const DocAuthTests = require('./doc-auth.tests') 11 | const BasicEmbedRestTests = require('./basic-embed-rest.tests') 12 | const BasicEmbedWrapperTests = require('./basic-embed-wrapper.tests') 13 | const BasicNonEmbedTests = require('./basic-non-embed.tests') 14 | const AuditLogTests = require('./audit-log.tests') 15 | const AdvanceAssocTests = require('./advance-assoc.tests') 16 | const DuplicateFieldTests = require('./duplicate-field.tests') 17 | const MiscTests = require('./misc.tests') 18 | 19 | const MongoMemoryServer = require('mongodb-memory-server').MongoMemoryServer 20 | const mongoServer = new MongoMemoryServer({ 21 | instance: { 22 | port: 27017, 23 | dbName: 'rest_hapi' 24 | } 25 | }) 26 | 27 | // TODO: Possibly require this in every test and decache it to avoid unexpected 28 | // errors between tests. 29 | const Mongoose = require('mongoose') 30 | Mongoose.Promise = Promise 31 | 32 | let Log = Logging.getLogger('tests') 33 | Log.logLevel = 'DEBUG' 34 | Log = Log.bind('end-to-end') 35 | 36 | const internals = { 37 | previous: {} 38 | } 39 | 40 | internals.onFinish = function() { 41 | process.exit() 42 | } 43 | 44 | Test.onFinish(internals.onFinish) 45 | 46 | process.on('unhandledRejection', error => { 47 | console.log('Unhandled error:', error.message) 48 | }) 49 | 50 | function restore(Mongoose) { 51 | Decache('../../rest-hapi') 52 | 53 | Decache('../config') 54 | Object.keys(Mongoose.models).forEach(function(key) { 55 | delete Mongoose.models[key] 56 | }) 57 | Object.keys(Mongoose.modelSchemas || []).forEach(function(key) { 58 | delete Mongoose?.modelSchemas[key] 59 | }) 60 | 61 | return Mongoose.connection.db.dropDatabase() 62 | } 63 | 64 | Test('end to end tests', function(t) { 65 | mongoServer 66 | .getConnectionString() 67 | .then(() => { 68 | return BasicCrudTests(t, Mongoose, internals, Log, restore) 69 | }) 70 | .then(function() { 71 | return DocAuthTests(t, Mongoose, internals, Log, restore) 72 | }) 73 | .then(function() { 74 | return BasicEmbedRestTests(t, Mongoose, internals, Log, restore) 75 | }) 76 | .then(function() { 77 | return BasicEmbedWrapperTests(t, Mongoose, internals, Log, restore) 78 | }) 79 | .then(function() { 80 | return BasicNonEmbedTests(t, Mongoose, internals, Log, restore) 81 | }) 82 | .then(function() { 83 | return AuditLogTests(t, Mongoose, internals, Log, restore) 84 | }) 85 | .then(function() { 86 | return AdvanceAssocTests(t, Mongoose, internals, Log, restore) 87 | }) 88 | .then(function() { 89 | return DuplicateFieldTests(t, Mongoose, internals, Log, restore) 90 | }) 91 | .then(function() { 92 | return MiscTests(t, Mongoose, internals, Log, restore) 93 | }) 94 | .then(function() { 95 | return t.test('clearing cache', function(t) { 96 | return Q.when().then(function() { 97 | Object.keys(require.cache).forEach(function(key) { 98 | delete require.cache[key] 99 | }) 100 | restore(Mongoose) 101 | 102 | t.ok(true, 'DONE') 103 | }) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-1/models/role.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // const _ = require('lodash'); 4 | // const Config = require('../config'); 5 | 6 | // const USER_ROLES = Config.get('/constants/USER_ROLES'); 7 | 8 | module.exports = function(mongoose) { 9 | const modelName = 'role' 10 | const Types = mongoose.Schema.Types 11 | const Schema = new mongoose.Schema( 12 | { 13 | name: { 14 | type: Types.String, 15 | // enum: _.values(USER_ROLES), 16 | required: true, 17 | unique: true 18 | }, 19 | description: { 20 | type: Types.String 21 | } 22 | }, 23 | { collection: modelName } 24 | ) 25 | 26 | Schema.statics = { 27 | collectionName: modelName, 28 | routeOptions: { 29 | // scope: { 30 | // scope: _.values(USER_ROLES), 31 | // }, 32 | // documentScope: { 33 | // scope: ['root'], 34 | // }, 35 | policies: { 36 | // policies: ['test'] 37 | } 38 | // authorizeDocumentCreatorToUpdate: true, 39 | // authorizeDocumentCreatorToRead: true, 40 | // associations: { 41 | // users: { 42 | // type: "ONE_MANY", 43 | // alias: "user", 44 | // foreignField: "role", 45 | // model: "user" 46 | // }, 47 | // permissions: { 48 | // type: "MANY_MANY", 49 | // alias: "permission", 50 | // model: "permission", 51 | // linkingModel: "role_permission", 52 | // } 53 | // } 54 | } 55 | } 56 | 57 | return Schema 58 | } 59 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-2/models/role.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'role' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String, 10 | required: true 11 | }, 12 | description: { 13 | type: Types.String 14 | } 15 | }, 16 | { collection: modelName } 17 | ) 18 | 19 | Schema.statics = { 20 | collectionName: modelName, 21 | routeOptions: { 22 | documentScope: { 23 | rootScope: ['root'], 24 | createScope: ['create'], 25 | updateScope: ['update'], 26 | deleteScope: ['delete'], 27 | associateScope: ['associate'] 28 | }, 29 | authorizeDocumentCreator: true 30 | } 31 | } 32 | 33 | return Schema 34 | } 35 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/business.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'business' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String, 10 | required: true 11 | }, 12 | description: { 13 | type: Types.String 14 | } 15 | }, 16 | { collection: modelName } 17 | ) 18 | 19 | Schema.statics = { 20 | collectionName: modelName, 21 | routeOptions: { 22 | associations: { 23 | roles: { 24 | type: 'ONE_MANY', 25 | alias: 'role', 26 | foreignField: 'company', 27 | model: 'role' 28 | } 29 | } 30 | } 31 | } 32 | 33 | return Schema 34 | } 35 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/hashtag.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'hashtag' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | text: { 9 | type: Types.String, 10 | required: true 11 | } 12 | }, 13 | { collection: modelName } 14 | ) 15 | 16 | Schema.statics = { 17 | collectionName: modelName, 18 | routeOptions: { 19 | associations: {} 20 | } 21 | } 22 | 23 | return Schema 24 | } 25 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/linking-models/user_permission.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mongoose = require('mongoose') 4 | 5 | module.exports = function() { 6 | const Types = mongoose.Schema.Types 7 | 8 | const Model = { 9 | Schema: { 10 | enabled: { 11 | type: Types.Boolean, 12 | default: true 13 | } 14 | }, 15 | modelName: 'user_permission' 16 | } 17 | 18 | return Model 19 | } 20 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/permission.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'permission' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String, 10 | required: true 11 | }, 12 | description: { 13 | type: Types.String 14 | } 15 | }, 16 | { collection: modelName } 17 | ) 18 | Schema.statics = { 19 | collectionName: modelName, 20 | routeOptions: { 21 | associations: { 22 | users: { 23 | type: 'MANY_MANY', 24 | alias: 'user', 25 | model: 'user', 26 | linkingModel: 'user_permission' 27 | }, 28 | roles: { 29 | type: 'MANY_MANY', 30 | alias: 'role', 31 | model: 'role' 32 | } 33 | } 34 | } 35 | } 36 | 37 | return Schema 38 | } 39 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/role.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'role' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String, 10 | enum: ['User', 'Admin', 'SuperAdmin'], 11 | required: true 12 | }, 13 | description: { 14 | type: Types.String 15 | }, 16 | company: { 17 | type: Types.ObjectId, 18 | ref: 'business' 19 | }, 20 | companyName: { 21 | type: Types.String 22 | } 23 | }, 24 | { collection: modelName } 25 | ) 26 | 27 | Schema.statics = { 28 | collectionName: modelName, 29 | routeOptions: { 30 | associations: { 31 | company: { 32 | type: 'MANY_ONE', 33 | model: 'business', 34 | duplicate: 'name' 35 | }, 36 | users: { 37 | type: 'ONE_MANY', 38 | alias: 'people', 39 | foreignField: 'title', 40 | model: 'user' 41 | }, 42 | permissions: { 43 | type: 'MANY_MANY', 44 | model: 'permission' 45 | } 46 | } 47 | } 48 | } 49 | 50 | return Schema 51 | } 52 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/user-profile.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'userProfile' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | status: { 9 | type: Types.String, 10 | required: true 11 | }, 12 | user: { 13 | type: Types.ObjectId, 14 | ref: 'user' 15 | } 16 | }, 17 | { collection: modelName } 18 | ) 19 | 20 | Schema.statics = { 21 | collectionName: modelName, 22 | routeOptions: { 23 | alias: 'user-profile', 24 | associations: { 25 | user: { 26 | type: 'ONE_ONE', 27 | model: 'user', 28 | duplicate: ['email'] 29 | } 30 | } 31 | } 32 | } 33 | 34 | return Schema 35 | } 36 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-3/models/user.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Boom = require('@hapi/boom') 4 | 5 | module.exports = function(mongoose) { 6 | const modelName = 'user' 7 | const Types = mongoose.Schema.Types 8 | const Schema = new mongoose.Schema( 9 | { 10 | email: { 11 | type: Types.String, 12 | unique: true 13 | }, 14 | password: { 15 | type: Types.String, 16 | required: true, 17 | exclude: true, 18 | allowOnUpdate: false 19 | }, 20 | firstName: { 21 | type: Types.String 22 | }, 23 | lastName: { 24 | type: Types.String 25 | }, 26 | title: { 27 | type: Types.ObjectId, 28 | ref: 'role' 29 | }, 30 | profile: { 31 | type: Types.ObjectId, 32 | ref: 'userProfile' 33 | } 34 | }, 35 | { collection: modelName } 36 | ) 37 | 38 | Schema.statics = { 39 | collectionName: modelName, 40 | routeOptions: { 41 | associations: { 42 | profile: { 43 | type: 'ONE_ONE', 44 | model: 'userProfile', 45 | duplicate: { 46 | field: 'status', 47 | as: 'state' 48 | } 49 | }, 50 | title: { 51 | type: 'MANY_ONE', 52 | model: 'role', 53 | duplicate: [ 54 | { 55 | field: 'name' 56 | }, 57 | { 58 | field: 'description', 59 | as: 'summary' 60 | }, 61 | { 62 | field: 'companyName', 63 | as: 'businessName' 64 | } 65 | ] 66 | }, 67 | permissions: { 68 | type: 'MANY_MANY', 69 | alias: 'permissions', 70 | model: 'permission', 71 | linkingModel: 'user_permission' 72 | }, 73 | tags: { 74 | type: '_MANY', 75 | model: 'hashtag' 76 | } 77 | }, 78 | update: { 79 | pre: function(_id, payload, request, Log) { 80 | if (payload.email === 'error@user.com') { 81 | throw Boom.badRequest('user error') 82 | } 83 | 84 | return payload 85 | } 86 | } 87 | } 88 | } 89 | 90 | return Schema 91 | } 92 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-4/models/building.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'building' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String 10 | }, 11 | facilitiesPerFloor: [ 12 | { 13 | type: Types.ObjectId, 14 | ref: 'facility' 15 | } 16 | ] 17 | }, 18 | { collection: modelName } 19 | ) 20 | 21 | Schema.statics = { 22 | collectionName: modelName, 23 | routeOptions: { 24 | associations: {} 25 | } 26 | } 27 | 28 | return Schema 29 | } 30 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-4/models/facility.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | const modelName = 'facility' 5 | const Types = mongoose.Schema.Types 6 | const Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String 10 | } 11 | }, 12 | { collection: modelName } 13 | ) 14 | 15 | Schema.statics = { 16 | collectionName: modelName, 17 | routeOptions: { 18 | associations: {} 19 | } 20 | } 21 | 22 | return Schema 23 | } 24 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-5/models/linking-models/segment_tag.model.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose') 2 | 3 | module.exports = function() { 4 | var Types = mongoose.Schema.Types 5 | 6 | var Model = { 7 | Schema: { 8 | rank: { 9 | type: Types.Number, 10 | required: true 11 | } 12 | }, 13 | modelName: 'segment_tag' 14 | } 15 | 16 | return Model 17 | } 18 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-5/models/segment.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | var modelName = 'segment' 5 | var Types = mongoose.Schema.Types 6 | var Schema = new mongoose.Schema( 7 | { 8 | title: { type: Types.String, required: true }, 9 | video: { type: Types.ObjectId, ref: 'video' } 10 | }, 11 | { collection: modelName } 12 | ) 13 | 14 | Schema.statics = { 15 | collectionName: modelName, 16 | routeOptions: { 17 | associations: { 18 | video: { 19 | type: 'MANY_ONE', 20 | model: 'video' 21 | }, 22 | tags: { 23 | type: 'MANY_MANY', 24 | alias: 'tag', 25 | model: 'tag', 26 | linkingModel: 'segment_tag' 27 | } 28 | } 29 | } 30 | } 31 | 32 | return Schema 33 | } 34 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-5/models/tag.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | var modelName = 'tag' 5 | var Types = mongoose.Schema.Types 6 | var Schema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: Types.String, 10 | required: true, 11 | unique: true 12 | } 13 | }, 14 | { collection: modelName } 15 | ) 16 | 17 | Schema.statics = { 18 | collectionName: modelName, 19 | routeOptions: { 20 | associations: { 21 | segments: { 22 | type: 'MANY_MANY', 23 | alias: 'segments', 24 | model: 'segment', 25 | embedAssociation: true, 26 | linkingModel: 'segment_tag' 27 | } 28 | } 29 | } 30 | } 31 | 32 | return Schema 33 | } 34 | -------------------------------------------------------------------------------- /tests/e2e/test-scenarios/scenario-5/models/video.model.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(mongoose) { 4 | var modelName = 'video' 5 | var Types = mongoose.Schema.Types 6 | var Schema = new mongoose.Schema( 7 | { 8 | title: { 9 | type: Types.String, 10 | description: 'Video title from YouTube' 11 | } 12 | }, 13 | { collection: modelName } 14 | ) 15 | 16 | Schema.statics = { 17 | collectionName: modelName, 18 | routeOptions: { 19 | associations: { 20 | segments: { 21 | type: 'ONE_MANY', 22 | alias: 'segment', 23 | foreignField: 'video', 24 | model: 'segment' 25 | } 26 | } 27 | } 28 | } 29 | 30 | return Schema 31 | } 32 | -------------------------------------------------------------------------------- /utilities/api-generator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | 6 | /** 7 | * This module reads in all the files that define additional endpoints and generates those endpoints. 8 | * @param server 9 | * @param mongoose 10 | * @param logger 11 | * @param config 12 | * @returns {*|promise} 13 | */ 14 | module.exports = async function(server, mongoose, logger, config) { 15 | const Log = logger.bind('api-generator') 16 | 17 | let apiPath = '' 18 | 19 | if (config.absoluteApiPath === true) { 20 | apiPath = config.apiPath 21 | } else { 22 | apiPath = path.join(__dirname, '/../../../', config.apiPath) 23 | } 24 | 25 | try { 26 | const files = fs.readdirSync(apiPath) 27 | 28 | for (const file of files) { 29 | const ext = path.extname(file) 30 | if (ext === '.js') { 31 | const fileName = path.basename(file, '.js') 32 | 33 | // EXPL: register all the additional endpoints 34 | require(apiPath + '/' + fileName)(server, mongoose, logger) 35 | } 36 | } 37 | } catch (err) { 38 | if (err.message.includes('no such file')) { 39 | if (config.absoluteApiPath === true) { 40 | Log.error(err) 41 | throw new Error( 42 | 'The api directory provided is either empty or does not exist. ' + 43 | "Try setting the 'apiPath' property of the config file." 44 | ) 45 | } 46 | } else { 47 | throw err 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /utilities/auth-helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const _ = require('lodash') 4 | 5 | module.exports = { 6 | /** 7 | * Generates the proper scope for an endpoint based on the model routeOptions 8 | * @param model: A mongoose model 9 | * @param type: The scope CRUD type. Valid values are 'create', 'read', 'update', 'delete', and 'associate'. 10 | * @param logger: A logging object 11 | * @returns {Array}: A list of authorization scopes for the endpoint. 12 | */ 13 | generateScopeForEndpoint: function(model, type, logger) { 14 | const routeScope = model.routeOptions.routeScope || {} 15 | const rootScope = routeScope.rootScope 16 | let scope = [] 17 | 18 | let additionalScope = null 19 | 20 | switch (type) { 21 | case 'create': 22 | additionalScope = routeScope.createScope 23 | break 24 | case 'read': 25 | additionalScope = routeScope.readScope 26 | break 27 | case 'update': 28 | additionalScope = routeScope.updateScope 29 | break 30 | case 'delete': 31 | additionalScope = routeScope.deleteScope 32 | break 33 | case 'associate': 34 | additionalScope = routeScope.associateScope 35 | break 36 | default: 37 | if (routeScope[type]) { 38 | scope = routeScope[type] 39 | if (!_.isArray(scope)) { 40 | scope = [scope] 41 | } 42 | } 43 | return scope 44 | } 45 | 46 | if (rootScope && _.isArray(rootScope)) { 47 | scope = scope.concat(rootScope) 48 | } else if (rootScope) { 49 | scope.push(rootScope) 50 | } 51 | 52 | if (additionalScope && _.isArray(additionalScope)) { 53 | scope = scope.concat(additionalScope) 54 | } else if (additionalScope) { 55 | scope.push(additionalScope) 56 | } 57 | 58 | return scope 59 | }, 60 | 61 | generateScopeForModel: function(model, logger) { 62 | const modelName = 63 | model.collectionName[0].toUpperCase() + model.collectionName.slice(1) 64 | 65 | const routeScope = model.routeOptions.routeScope || {} 66 | if (!routeScope.rootScope) { 67 | delete routeScope.rootScope 68 | } 69 | 70 | const scope = {} 71 | 72 | scope.rootScope = [ 73 | 'root', 74 | model.collectionName, 75 | '!-root', 76 | '!-' + model.collectionName 77 | ] 78 | scope.createScope = [ 79 | 'create', 80 | 'create' + modelName, 81 | '!-create', 82 | '!-create' + modelName 83 | ] 84 | scope.readScope = [ 85 | 'read', 86 | 'read' + modelName, 87 | '!-read', 88 | '!-read' + modelName 89 | ] 90 | scope.updateScope = [ 91 | 'update', 92 | 'update' + modelName, 93 | '!-update', 94 | '!-update' + modelName 95 | ] 96 | scope.deleteScope = [ 97 | 'delete', 98 | 'delete' + modelName, 99 | '!-delete', 100 | '!-delete' + modelName 101 | ] 102 | scope.associateScope = [ 103 | 'associate', 104 | 'associate' + modelName, 105 | '!-associate', 106 | '!-associate' + modelName 107 | ] 108 | 109 | const associations = model.routeOptions.associations 110 | 111 | for (const key in associations) { 112 | const associationName = key[0].toUpperCase() + key.slice(1) 113 | scope['add' + modelName + associationName + 'Scope'] = [ 114 | 'add' + modelName + associationName, 115 | '!-add' + modelName + associationName 116 | ] 117 | scope['remove' + modelName + associationName + 'Scope'] = [ 118 | 'remove' + modelName + associationName, 119 | '!-remove' + modelName + associationName 120 | ] 121 | scope['get' + modelName + associationName + 'Scope'] = [ 122 | 'get' + modelName + associationName, 123 | '!-get' + modelName + associationName 124 | ] 125 | } 126 | 127 | // Merge any existing scope fields with the generated scope 128 | for (const key in routeScope) { 129 | if (scope[key]) { 130 | if (!_.isArray(scope[key])) { 131 | scope[key] = [scope[key]] 132 | } 133 | if (routeScope[key] && _.isArray(routeScope[key])) { 134 | scope[key] = scope[key].concat(routeScope[key]) 135 | } else { 136 | scope[key].push(routeScope[key]) 137 | } 138 | } 139 | } 140 | 141 | model.routeOptions.routeScope = scope 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /utilities/log-util.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const chalk = require('chalk') 3 | 4 | /** 5 | * Function that truncates the properties of an object to a certain length. 6 | */ 7 | function truncatedProps(obj, truncateLength = 100) { 8 | const result = {} 9 | if (!_.isObject(obj)) return truncateProp(obj, truncateLength) 10 | for (const key in obj) { 11 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 12 | result[key] = truncateProp(obj[key], truncateLength) 13 | } 14 | } 15 | return result 16 | } 17 | 18 | function truncateProp(prop, truncateLength = 100) { 19 | let result = null 20 | let value = _.clone(prop) 21 | // if value is an array, truncate each element 22 | if (_.isArray(value)) { 23 | // First truncate the array to 10 elements 24 | if (value.length > 10) { 25 | value = value.slice(0, 10).concat(['.', '.', '.']) 26 | } 27 | result = value.map(v => truncatedProps(v, truncateLength)) 28 | } else if (_.isObject(value)) { 29 | result = truncatedProps(value, truncateLength) 30 | 31 | // if value is a string, truncate it to truncateLength characters 32 | } else if (typeof value === 'string') { 33 | if (value.length <= truncateLength) { 34 | result = value 35 | } else { 36 | result = value.substring(0, truncateLength) + '...' 37 | } 38 | 39 | // otherwise, just return it 40 | } else { 41 | result = value 42 | } 43 | 44 | return result 45 | } 46 | 47 | function truncatedStringify(obj, truncateLength = 100) { 48 | return JSON.stringify(truncatedProps(obj, truncateLength), null, 2) 49 | } 50 | 51 | module.exports = { 52 | truncatedProps, 53 | truncatedStringify, 54 | bindHelper: function(logger, name) { 55 | return logger.bind(chalk.gray(name)) 56 | }, 57 | logActionStart: function(logger, message, data) { 58 | if (data) { 59 | logger.log(chalk.blue(message) + chalk.white('...:')) 60 | _.forIn(data, function(value, key) { 61 | logger.log( 62 | chalk.gray('\t%s: `%s`'), 63 | chalk.magenta(key), 64 | chalk.cyan(value) 65 | ) 66 | }) 67 | } else { 68 | logger.log(chalk.blue(message) + chalk.white('...')) 69 | } 70 | }, 71 | logActionComplete: function(logger, message, data) { 72 | if (data) { 73 | logger.log(chalk.blue(message) + chalk.white(':')) 74 | _.forIn(data, function(value, key) { 75 | logger.log( 76 | chalk.gray('\t%s: `%s`'), 77 | chalk.magenta(key), 78 | chalk.cyan(value) 79 | ) 80 | }) 81 | } else { 82 | logger.log(chalk.blue(message)) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /utilities/model-generator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const modelHelper = require('./model-helper') 4 | const authHelper = require('./auth-helper') 5 | const fs = require('fs') 6 | const path = require('path') 7 | 8 | /** 9 | * This module reads in all the model files and generates the corresponding mongoose models. 10 | * @param mongoose 11 | * @param logger 12 | * @param config 13 | * @returns {*|promise} 14 | */ 15 | module.exports = function(mongoose, logger, config) { 16 | const Log = logger.bind('model-generator') 17 | 18 | const models = {} 19 | const schemas = {} 20 | let modelPath = '' 21 | 22 | if (config.absoluteModelPath === true) { 23 | modelPath = config.modelPath 24 | } else { 25 | modelPath = path.join(__dirname, '/../../../', config.modelPath) 26 | } 27 | 28 | return new Promise((resolve, reject) => { 29 | fs.readdir(modelPath, (err, files) => { 30 | if (err) { 31 | if (err.message.includes('no such file')) { 32 | Log.error(err) 33 | reject( 34 | new Error( 35 | 'The model directory provided is either empty or does not exist. ' + 36 | "Try setting the 'modelPath' property of the config file." 37 | ) 38 | ) 39 | } else { 40 | reject(err) 41 | } 42 | return 43 | } 44 | 45 | for (const file of files) { 46 | // EXPL: Import all the model schemas 47 | const ext = path.extname(file) 48 | if (ext === '.js') { 49 | const modelName = path.basename(file, '.js') 50 | const schema = require(modelPath + '/' + modelName)(mongoose) 51 | 52 | // EXPL: Add text index if enabled 53 | if (config.enableTextSearch) { 54 | schema.index({ '$**': 'text' }) 55 | } 56 | schemas[schema.statics.collectionName] = schema 57 | } 58 | } 59 | 60 | if (config.enableAuditLog) { 61 | const schema = require('../models/audit-log.model')(mongoose) 62 | schemas[schema.statics.collectionName] = schema 63 | } 64 | 65 | const extendedSchemas = {} 66 | 67 | for (const schemaKey in schemas) { 68 | const schema = schemas[schemaKey] 69 | extendedSchemas[schemaKey] = modelHelper.extendSchemaAssociations( 70 | schema, 71 | mongoose, 72 | modelPath 73 | ) 74 | } 75 | 76 | for (const schemaKey in extendedSchemas) { 77 | const schema = extendedSchemas[schemaKey] 78 | extendedSchemas[schemaKey] = modelHelper.addDuplicateFields( 79 | schema, 80 | schemas 81 | ) 82 | } 83 | 84 | for (const schemaKey in extendedSchemas) { 85 | // EXPL: Create models with final schemas 86 | const schema = extendedSchemas[schemaKey] 87 | models[schemaKey] = modelHelper.createModel(schema, mongoose) 88 | } 89 | 90 | for (const modelKey in models) { 91 | // EXPL: Populate internal model associations 92 | const model = models[modelKey] 93 | modelHelper.associateModels(model.schema, models) 94 | } 95 | 96 | for (const modelKey in models) { 97 | // EXPL: Generate scopes if enabled 98 | if (config.generateRouteScopes) { 99 | const model = models[modelKey] 100 | authHelper.generateScopeForModel(model, logger) 101 | } 102 | } 103 | 104 | resolve(models) 105 | }) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /utilities/policy-generator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | const internals = {} 6 | 7 | internals.policyObjects = require('require-all')( 8 | path.join(__dirname, '/../policies') 9 | ) 10 | 11 | internals.policies = {} 12 | 13 | for (const policyName in internals.policyObjects) { 14 | if (internals.policyObjects[policyName].applyPoint) { 15 | internals.policies[policyName] = internals.policyObjects[policyName] 16 | } else { 17 | const policyObject = internals.policyObjects[policyName] 18 | for (const policyName in policyObject) { 19 | internals.policies[policyName] = policyObject[policyName] 20 | } 21 | } 22 | } 23 | 24 | module.exports = internals.policies 25 | -------------------------------------------------------------------------------- /utilities/test-helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let test = require('tape') 4 | const _ = require('lodash') 5 | const QueryString = require('query-string') 6 | const config = require('../config') 7 | 8 | const internals = {} 9 | 10 | /** 11 | * Tests a given function (methodToTest) to verify that it's "model" parameter follows 12 | * the mongoose model format. 13 | * @param t: Parent test object. 14 | * @param methodToTest: The method being tested. 15 | * @param methodName: The name of the method being called. 16 | * @param parameters: A string array containing the parameter names for the method. 17 | * @param Log: A logging object. 18 | */ 19 | internals.testModelParameter = function( 20 | t, 21 | methodToTest, 22 | methodName, 23 | parameters, 24 | Log 25 | ) { 26 | if (t) { 27 | test = t.test 28 | } 29 | 30 | const modelIndex = 31 | parameters.indexOf('model') >= 0 32 | ? parameters.indexOf('model') 33 | : parameters.indexOf('ownerModel') 34 | const logIndex = 35 | parameters.indexOf('Log') >= 0 36 | ? parameters.indexOf('Log') 37 | : parameters.indexOf('logger') 38 | let paramCopy = {} 39 | 40 | parameters[logIndex] = Log 41 | 42 | test( 43 | methodName + " fails if model parameter isn't a mongoose model", 44 | function(t) { 45 | t.plan(10) 46 | 47 | let model = {} 48 | 49 | paramCopy = _.extend([], parameters) 50 | paramCopy[modelIndex] = model 51 | try { 52 | methodToTest.apply(null, paramCopy) 53 | t.fail('No error was thrown.') 54 | } catch (error) { 55 | t.ok(/^AssertionError/.test(error.name), 'error is an AssertionError') 56 | t.ok( 57 | error.message.indexOf('schema') > -1, 58 | "assertion message contains 'schema' text." 59 | ) 60 | } 61 | 62 | model = { 63 | schema: {} 64 | } 65 | 66 | paramCopy = _.extend([], parameters) 67 | paramCopy[modelIndex] = model 68 | try { 69 | methodToTest.apply(null, paramCopy) 70 | t.fail('No error was thrown.') 71 | } catch (error) { 72 | t.ok(/^AssertionError/.test(error.name), 'error is an AssertionError') 73 | t.ok( 74 | error.message.indexOf('schema.paths') > -1, 75 | "assertion message contains 'schema.paths' text." 76 | ) 77 | } 78 | 79 | model = { 80 | schema: { 81 | paths: {} 82 | } 83 | } 84 | 85 | paramCopy = _.extend([], parameters) 86 | paramCopy[modelIndex] = model 87 | try { 88 | methodToTest.apply(null, paramCopy) 89 | t.fail('No error was thrown.') 90 | } catch (error) { 91 | t.ok(/^AssertionError/.test(error.name), 'error is an AssertionError') 92 | t.ok( 93 | error.message.indexOf('schema.tree') > -1, 94 | "assertion message contains 'schema.tree' text." 95 | ) 96 | } 97 | 98 | model = { 99 | schema: { 100 | paths: {}, 101 | tree: {} 102 | } 103 | } 104 | 105 | paramCopy = _.extend([], parameters) 106 | paramCopy[modelIndex] = model 107 | try { 108 | methodToTest.apply(null, paramCopy) 109 | t.fail('No error was thrown.') 110 | } catch (error) { 111 | t.ok(/^AssertionError/.test(error.name), 'error is an AssertionError') 112 | t.ok( 113 | error.message.indexOf('routeOptions') > -1, 114 | "assertion message contains 'routeOptions' text." 115 | ) 116 | } 117 | 118 | model = { 119 | schema: { 120 | paths: { 121 | field1: {}, 122 | field2: {}, 123 | field3: {}, 124 | field4: {} 125 | }, 126 | tree: {} 127 | }, 128 | routeOptions: {} 129 | } 130 | 131 | paramCopy = _.extend([], parameters) 132 | paramCopy[modelIndex] = model 133 | try { 134 | methodToTest.apply(null, paramCopy) 135 | t.fail('No error was thrown.') 136 | } catch (error) { 137 | t.ok(/^AssertionError/.test(error.name), 'error is an AssertionError') 138 | t.ok( 139 | error.message.indexOf('options') > -1, 140 | "assertion message contains 'options' text." 141 | ) 142 | } 143 | } 144 | ) 145 | } 146 | 147 | /** 148 | * Mock hapi auth strategy for testing. 149 | * @param server 150 | * @param strategyName 151 | */ 152 | internals.mockStrategy = function(server, strategyName) { 153 | server.auth.scheme('mock', function(server, options) { 154 | return { 155 | authenticate: async function(request, h) { 156 | return h.authenticated() 157 | } 158 | } 159 | }) 160 | 161 | server.auth.strategy(strategyName, 'mock') 162 | } 163 | 164 | /** 165 | * Takes normal request properties and creates an options object for a server injection. 166 | * @param request: Mock request object. 167 | * @returns {{method: *, url: (string|string), payload: *, credentials: (request.credentials|{scope}|{user}|{}), headers: (request.headers|{authorization})}} 168 | */ 169 | internals.mockInjection = function(request) { 170 | let fullUrl = request.url 171 | for (const key in request.params) { 172 | fullUrl = fullUrl.replace('{' + key + '}', request.params[key]) 173 | } 174 | fullUrl = fullUrl + '?' + QueryString.stringify(request.query) 175 | 176 | const injectOptions = { 177 | method: request.method, 178 | url: fullUrl, 179 | payload: request.payload, 180 | auth: { 181 | credentials: request.credentials, 182 | strategy: config.authStrategy || 'default' 183 | }, 184 | headers: request.headers 185 | } 186 | 187 | return injectOptions 188 | } 189 | 190 | module.exports = { 191 | testModelParameter: internals.testModelParameter, 192 | 193 | mockStrategy: internals.mockStrategy, 194 | 195 | mockInjection: internals.mockInjection 196 | } 197 | -------------------------------------------------------------------------------- /utilities/validation-helper.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | // TODO: verify routeOptions exist 4 | 5 | module.exports = { 6 | /** 7 | * Assert that a given model follows the mongoose model format. 8 | * @param model 9 | * @param logger 10 | * @returns {boolean} 11 | */ 12 | validateModel: function(model, logger) { 13 | assert( 14 | model.schema, 15 | "model not mongoose format. 'schema' property required." 16 | ) 17 | assert( 18 | model.schema.paths, 19 | "model not mongoose format. 'schema.paths' property required." 20 | ) 21 | assert( 22 | model.schema.tree, 23 | "model not mongoose format. 'schema.tree' property required." 24 | ) 25 | 26 | const fields = model.schema.paths 27 | const fieldNames = Object.keys(fields) 28 | 29 | assert( 30 | model.routeOptions, 31 | "model not mongoose format. 'routeOptions' property required." 32 | ) 33 | 34 | for (let i = 0; i < fieldNames.length; i++) { 35 | const fieldName = fieldNames[i] 36 | assert( 37 | fields[fieldName].options, 38 | "field not mongoose format. 'options' parameter required." 39 | ) 40 | } 41 | 42 | return true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /website/blog/2016-11-19-The-Problem-With-APIs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Problem With APIs 3 | author: Justin Headley 4 | authorURL: http://twitter.com/JKHeadley 5 | authorFBID: 27403843 6 | --- 7 | 8 | > Original post can be found [here on Medium](https://hackernoon.com/the-problem-with-apis-331f08f7a39c) 9 | 10 |

api image

11 | 12 | These days, if you are a developer working on a web or mobile application, its likely you're going to need to communicate with a server for specific services or to access a database. This means implementing an API (usually a RESTful API) will be a critical part of developing your app. Unfortunately, RESTful APIs can take many different shapes and forms, even though most of them accomplish very similar functions. This is especially true in the world of javascript, where developers have free range to structure their code just about however they please. If you've worked on multiple API projects, its likely you've had the experience of writing the same API code a thousand different times and possibly a thousand different ways. There are some awesome tools out there that make this process a lot less painful, such as server frameworks like hapi and express or ODM/ORMs like mongoose and sequelize, however even with these tools there is a substantial amount of code involved with setting up even the most basic CRUD API endpoints specific to a project, especially if you plan on implementing standard features such as API documentation and validation. While all these tools, options, and features allow for great control over your API, they can become burdensome if not overwhelming, especially if you are trying to rapidly develop your API for a proof of concept/minimum viable product, and even more so if you are new to developing APIs. 13 | 14 | 15 | 16 |

wolf javascrpt image

17 | 18 | > Do any of us really? 19 | 20 | With this in mind I decided to create a framework for the purpose of rapid RESTful API development. My aim was to provide a tool that allows developers to quickly set up REST endpoints that mirror the structure of their database schema, even if they have little experience with APIs. The result was [rest-hapi](https://github.com/JKHeadley/rest-hapi), a RESTful API generator built around the [hapi](http://hapijs.com/) framework and [mongoose](http://mongoosejs.com/) ODM. rest-hapi automatically sets up CRUD endpoints based on mongoose models, which means all the developer has to do is set up their mongoose models and configure the server, and they're good to go! On top of this, rest-hapi has built-in validation (using [joi](https://github.com/hapijs/joi)) and documentation (via [hapi-swagger](https://github.com/glennjones/hapi-swagger)). Once the server is up and running, it can quickly and easily be tested and documented by viewing the swagger docs. 21 | 22 |

hapi image

23 |

mongoose image

24 | 25 | > hapi and mongoose are core tools used in rest-hapi 26 | 27 | The other major hurdle rest-hapi attempts to resolve is the never ending decision of whether to choose SQL vs NoSQL for a database. Generally speaking, developers choose SQL/relational databases for the structural advantages they provide, since most projects naturally contain some sort of relational structure within their data, while NoSQL databases are chosen due to their flexibility and scalability. rest-hapi attempts to combine the best of both worlds by using a NoSQL database ([MongoDB](https://www.mongodb.com/)) as its foundation, while also allowing relational structure to easily be defined within the model definitions. When model associations are defined, rest-hapi automatically generates association endpoints alongside the CRUD endpoints. 28 | 29 |

rest image

30 | 31 | > Standards, anyone? 32 | 33 | While rest-hapi doesn't provide an end-all solution to API development, I do believe it will be a great tool for developers that want to quickly set up an API to test their latest app idea. Right now the project is still in it's infancy, but eventually I hope it will reach a point where it could be used as a foundation for production level projects. Please take some time to check it out! If you have any feedback, feel free to open an issue in [GitHub](https://github.com/JKHeadley/rest-hapi/issues), or if you want to get in touch you can reach me at [Twitter](https://twitter.com/JKHeadley), [Facebook](https://www.facebook.com/justinkheadley), or [LinkedIn](https://www.linkedin.com/in/justinkheadley), or email me at headley.justin@gmail.com. Thanks for reading! 34 | 35 | (Props to [Zach Smith](https://github.com/zacharyclaysmith) for developing the API-generator that spawned rest-hapi, and [Scal.io](http://www.scal.io/) for being awesome) -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | /** 3 | * Copyright (c) 2017-present, Facebook, Inc. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | const React = require('react') 10 | 11 | const SocialFooter = props => ( 12 |
13 |
Social
14 |
15 | 26 | {props.config.projectName} 27 | 28 |
29 |
30 | 31 | rest-hapi tweet 32 | 33 |
34 | {props.config.twitterUsername && ( 35 |
36 | 39 | Follow @{props.config.twitterUsername} 40 | 41 |
42 | )} 43 | {props.config.facebookAppId && ( 44 |
45 |
53 |
54 | )} 55 |
56 | ); 57 | 58 | SocialFooter.propTypes = { 59 | config: PropTypes.object, 60 | }; 61 | 62 | class Footer extends React.Component { 63 | docUrl(doc, language) { 64 | const baseUrl = this.props.config.baseUrl 65 | return baseUrl + 'docs/' + (language ? language + '/' : '') + doc 66 | } 67 | 68 | pageUrl(doc, language) { 69 | const baseUrl = this.props.config.baseUrl 70 | return baseUrl + (language ? language + '/' : '') + doc 71 | } 72 | 73 | render() { 74 | const currentYear = new Date().getFullYear() 75 | return ( 76 | 117 | ) 118 | } 119 | } 120 | 121 | module.exports = Footer 122 | -------------------------------------------------------------------------------- /website/data/users.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | // Please add your logo in alphabetical order of caption. 3 | { 4 | caption: 'appy', 5 | image: '/img/appy.png', 6 | infoLink: 'https://www.appyapp.io', 7 | pinned: true 8 | } 9 | // Please add your logo in alphabetical order of caption. 10 | ] 11 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "^1.14.7" 13 | }, 14 | "version": "3.0.0" 15 | } 16 | -------------------------------------------------------------------------------- /website/pages/en/demo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react') 9 | 10 | class Demo extends React.Component { 11 | render() { 12 | return ( 13 |