├── .editorconfig ├── .github ├── COMMIT_CONVENTION.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── lock.yml ├── stale.yml └── workflows │ └── test.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── LICENSE.md ├── README.md ├── adonis-typings ├── http-context.ts ├── index.ts └── rmb.ts ├── bin ├── japaTypes.ts └── test.ts ├── examples └── index.ts ├── index.ts ├── instructions.md ├── package-lock.json ├── package.json ├── providers └── RmbProvider.ts ├── src ├── contracts │ └── index.ts ├── decorators │ └── bind.ts ├── exceptions │ └── missing_relationship.ts ├── middleware │ └── route_model_binding.ts ├── params_parser │ └── index.ts └── resource_loader │ └── index.ts ├── test_helpers └── index.ts ├── tests ├── bind.spec.ts ├── params_parser.spec.ts ├── resource_loader │ ├── model.spec.ts │ └── scope_model.spec.ts └── rmb_middleware.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/COMMIT_CONVENTION.md: -------------------------------------------------------------------------------- 1 | ## Git Commit Message Convention 2 | 3 | > This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). 4 | 5 | Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. 6 | 7 | ``` js 8 | /^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ 9 | ``` 10 | 11 | ## Commit Message Format 12 | A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: 13 | 14 | > The **scope** is optional 15 | 16 | ``` 17 | feat(router): add support for prefix 18 | 19 | Prefix makes it easier to append a path to a group of routes 20 | ``` 21 | 22 | 1. `feat` is type. 23 | 2. `router` is scope and is optional 24 | 3. `add support for prefix` is the subject 25 | 4. The **body** is followed by a blank line. 26 | 5. The optional **footer** can be added after the body, followed by a blank line. 27 | 28 | ## Types 29 | Only one type can be used at a time and only following types are allowed. 30 | 31 | - feat 32 | - fix 33 | - docs 34 | - style 35 | - refactor 36 | - perf 37 | - test 38 | - workflow 39 | - ci 40 | - chore 41 | - types 42 | - build 43 | 44 | If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. 45 | 46 | ### Revert 47 | If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. 48 | 49 | ## Scope 50 | The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. 51 | 52 | ## Subject 53 | The subject contains succinct description of the change: 54 | 55 | - use the imperative, present tense: "change" not "changed" nor "changes". 56 | - don't capitalize first letter 57 | - no dot (.) at the end 58 | 59 | ## Body 60 | 61 | Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". 62 | The body should include the motivation for the change and contrast this with previous behavior. 63 | 64 | ## Footer 65 | 66 | The footer should contain any information about **Breaking Changes** and is also the place to 67 | reference GitHub issues that this commit **Closes**. 68 | 69 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. 70 | 71 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | AdonisJS is a community driven project. You are free to contribute in any of the following ways. 4 | 5 | - [Coding style](coding-style) 6 | - [Fix bugs by creating PR's](fix-bugs-by-creating-prs) 7 | - [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) 8 | - [Report security issues](report-security-issues) 9 | - [Be a part of the community](be-a-part-of-community) 10 | 11 | ## Coding style 12 | 13 | Majority of AdonisJS core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. 14 | 15 | ## Fix bugs by creating PR's 16 | 17 | We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. 18 | 19 | Go through the following points, before creating a new PR. 20 | 21 | 1. Create an issue discussing the bug or short-coming in the framework. 22 | 2. Once approved, go ahead and fork the REPO. 23 | 3. Make sure to start from the `develop`, since this is the upto date branch. 24 | 4. Make sure to keep commits small and relevant. 25 | 5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. 26 | 6. Once done with all the changes, create a PR against the `develop` branch. 27 | 28 | ## Share an RFC for new features or big changes 29 | 30 | Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. 31 | 32 | ### What is an RFC? 33 | 34 | RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). 35 | 36 | In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. 37 | 38 | The RFC proposals are created as Pull Request on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. 39 | 40 | ## Report security issues 41 | 42 | All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. 43 | 44 | ## Be a part of community 45 | 46 | We welcome you to participate in [GitHub Discussion](https://github.com/adonisjs/core/discussions) and the AdonisJS [Discord Server](https://discord.gg/vDcEjq6). You are free to ask your questions and share your work or contributions made to AdonisJS eco-system. 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report identified bugs 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | - Lots of raised issues are directly not bugs but instead are design decisions taken by us. 13 | - Make use of our [GH discussions](https://github.com/adonisjs/core/discussions), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. 14 | - Ensure the issue isn't already reported. 15 | - Ensure you are reporting the bug in the correct repo. 16 | 17 | *Delete the above section and the instructions in the sections below before submitting* 18 | 19 | ## Package version 20 | 21 | 22 | ## Node.js and npm version 23 | 24 | 25 | ## Sample Code (to reproduce the issue) 26 | 27 | 28 | ## BONUS (a sample repo to reproduce the issue) 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Propose changes for adding a new feature 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | ## Consider an RFC 13 | 14 | Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if 15 | 16 | - Feature introduces a breaking change 17 | - Demands lots of time and changes in the current code base. 18 | 19 | *Delete the above section and the instructions in the sections below before submitting* 20 | 21 | ## Why this feature is required (specific use-cases will be appreciated)? 22 | 23 | 24 | ## Have you tried any other work arounds? 25 | 26 | 27 | ## Are you willing to work on it with little guidance? 28 | 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Proposed changes 4 | 5 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 6 | 7 | ## Types of changes 8 | 9 | What types of changes does your code introduce? 10 | 11 | _Put an `x` in the boxes that apply_ 12 | 13 | - [ ] Bugfix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | 17 | ## Checklist 18 | 19 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 20 | 21 | - [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/route-model-binding/blob/master/.github/CONTRIBUTING.md) doc 22 | - [ ] Lint and unit tests pass locally with my changes 23 | - [ ] I have added tests that prove my fix is effective or that my feature works. 24 | - [ ] I have added necessary documentation (if appropriate) 25 | 26 | ## Further comments 27 | 28 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 29 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: ['Type: Security'] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: false 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - "Type: Security" 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: "Status: Abandoned" 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | linux: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: 11 | - 16.13.1 12 | - 17.x 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Install 20 | run: npm install 21 | - name: Run tests 22 | run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test/__app 4 | .DS_STORE 5 | .nyc_output 6 | .idea 7 | .vscode/ 8 | *.sublime-project 9 | *.sublime-workspace 10 | *.log 11 | build 12 | dist 13 | shrinkwrap.yaml 14 | .env 15 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Route model binding 2 | > Bind the route parameters with Lucid models and automatically query the database 3 | 4 | Route model binding is a neat way to remove one-liner Lucid queries from your codebase and use conventions to query the database during HTTP requests. 5 | 6 | In the following example, we connect the route params `:post` and `:comments` with the arguments accepted by the `show` method. 7 | 8 | - The value of the first param from the URL will be used to query the first typed hinted model on the `show` method (i.e., Post). 9 | - Similarly, the value of the second param will be used to query the second typed hinted model (i.e., Comment). 10 | 11 | > **Note**: The params and models are connected using the order they appear and not the name. This is because TypeScript decorators have no way to know the names of the arguments accepted by a method. 12 | 13 | ```ts 14 | // Routes file 15 | Route.get('posts/:post/comments/:comment', 'PostsController.show') 16 | ``` 17 | 18 | ```ts 19 | // Controller 20 | import Post from 'App/Models/Post' 21 | import Comment from 'App/Models/Comment' 22 | import { bind } from '@adonisjs/route-model-binding' 23 | 24 | export default class PostsController { 25 | @bind() 26 | public async show({}, post: Post, comment: Comment) { 27 | return { post, comment } 28 | } 29 | } 30 | ``` 31 | 32 | > ▶️ : **Are you a visual learner**? Checkout [these screencasts](https://learn.adonisjs.com/series/route-model-binding/introduction) to learn about Route model binding, its setup and usage. 33 | 34 | ## Setup 35 | Install the package from the npm registry as follows. 36 | 37 | ```sh 38 | npm i @adonisjs/route-model-binding 39 | 40 | # yarn lovers 41 | yarn add @adonisjs/route-model-binding 42 | ``` 43 | 44 | Next, configure the package by running the following ace command. 45 | 46 | ```sh 47 | node ace configure @adonisjs/route-model-binding 48 | ``` 49 | 50 | The final step is to register the `RmbMiddleware` inside the `start/kernel.ts` file. 51 | 52 | ```ts 53 | Server.middleware.register([ 54 | // ...other middleware 55 | () => import('@ioc:Adonis/Addons/RmbMiddleware'), 56 | ]) 57 | ``` 58 | 59 | ## Basic usage 60 | Start with the most basic example and then tune up the complexity level to serve different use cases. 61 | 62 | In the following example, we will bind the `Post` model with the first parameter in the `posts/:id` route. 63 | 64 | ```ts 65 | Route.get('/posts/:id', 'PostsController.show') 66 | ``` 67 | 68 | ```ts 69 | import Post from 'App/Models/Post' 70 | import { bind } from '@adonisjs/route-model-binding' 71 | 72 | export default class PostsController { 73 | @bind() 74 | public async show({}, post: Post) { 75 | return { post } 76 | } 77 | } 78 | ``` 79 | 80 | The params and models are matched in the order they are defined. So the first param in the URL matches the first type-hinted model in the controller method. 81 | 82 | The match is not performed using the name of the controller method argument because TypeScript decorators cannot read them (so the technical limitation leaves us with the order-based matching only). 83 | 84 | ## Changing the lookup key 85 | By default, the model's primary key is used to find a matching row in the database. You can either change that globally or change it for just one specific route. 86 | 87 | ### Change lookup key globally via model 88 | After the following change, the post will be queried using the `slug` property and not the primary key. In a nutshell, the `Post.findByOrFail('slug', value)` query is executed. 89 | 90 | ```ts 91 | class Post extends BaseModel { 92 | public static routeLookupKey = 'slug' 93 | } 94 | ``` 95 | 96 | ### Change the lookup key for a single route. 97 | In the following example, we define the lookup key directly on the route enclosed with parenthesis. 98 | 99 | ```ts 100 | Route.get('/posts/:id(slug)', 'PostsController.show') 101 | ``` 102 | 103 | **Did you notice that our route now reads a bit funny?**\ 104 | The param is written as `:id(slug)`, which does not translate well. Therefore, with Route model binding, we recommend using the model name as the route param because we are not dealing with the `id` anymore. We are fetching model instances from the database. 105 | 106 | Following is the better way to write the same route. 107 | 108 | ```ts 109 | Route.get('/posts/:post(slug)', 'PostsController.show') 110 | ``` 111 | 112 | ## Change lookup logic 113 | You can change the lookup logic by defining a static `findForRequest` method on the model itself. The method receives the following parameters. 114 | 115 | - `ctx` - The HTTP context for the current request 116 | - `param` - The parsed parameter. The parameter has the following properties. 117 | - `param.name` - The normalized name of the parameter. 118 | - `param.param` - The original name of the parameter defined on the route. 119 | - `param.scoped` - If `true`, the parameter must be scoped to its parent model. 120 | - `param.lookupKey` - The lookup key defined on the route or the model. 121 | - `param.parent` - The name of the parent param. 122 | - `value` - The value of the param during the current request. 123 | 124 | In the following example, we query only published posts. Also, make sure that this method either returns an instance of the model or raises an exception. 125 | 126 | ```ts 127 | class Post extends BaseModel { 128 | public static findForRequest(ctx, param, value) { 129 | const lookupKey = param.lookupKey === '$primaryKey' ? 'id' : param.lookupKey 130 | 131 | return this 132 | .query() 133 | .where(lookupKey, value) 134 | .whereNotNull('publishedAt') 135 | .firstOrFail() 136 | } 137 | } 138 | ``` 139 | 140 | ## Scoped params 141 | When working with nested route resources, you might want to scope the second param as a relationship with the first param. 142 | 143 | A great example of this is finding a post comment by id and making sure that it is a child of the post mentioned within the same URL. 144 | 145 | The `posts/1/comments/2` should return 404 if the post id of the comment is not `1`. 146 | 147 | You can define scoped params using the `>` greater than a sign or famously known as the [breadcrumb sign](https://www.smashingmagazine.com/2009/03/breadcrumbs-in-web-design-examples-and-best-practices/#:~:text=You%20also%20see%20them%20in,the%20page%20links%20beside%20it.) 148 | 149 | ```ts 150 | Route.get('/posts/:post/comments/:>comment', 'PostsController.show') 151 | ``` 152 | 153 | ```ts 154 | import Post from 'App/Models/Post' 155 | import Comment from 'App/Models/Comment' 156 | import { bind } from '@adonisjs/route-model-binding' 157 | 158 | export default class PostsController { 159 | @bind() 160 | public async show({}, post: Post, comment: Comment) { 161 | return { post, comment } 162 | } 163 | } 164 | ``` 165 | 166 | For the above example to work, you will have to define the `comments` as a relationship on the `Post` model. The type of the relationship does not matter. 167 | 168 | ```ts 169 | class Post extends BaseModel { 170 | @hasMany(() => Comment) 171 | public comments: HasMany 172 | } 173 | ``` 174 | 175 | The name of the relationship is looked up, converting the param name to `camelCase`. We will use both plural and singular forms to find the relationship. 176 | 177 | ### Customizing relationship lookup 178 | By default, the relationship is fetched using the lookup key of the bound child model. Effectively the following query is executed. 179 | 180 | ```ts 181 | await parent 182 | .related('relationship') 183 | .query() 184 | .where(lookupKey, value) 185 | .firstOrFail() 186 | ``` 187 | 188 | However, you can customize the lookup by defining the `findRelatedForRequest` method on the model (note, this is not a static method). 189 | 190 | ```ts 191 | class Post extends BaseModel { 192 | public findRelatedForRequest(ctx, param, value) { 193 | /** 194 | * Have to do this weird dance because of 195 | * https://github.com/microsoft/TypeScript/issues/37778 196 | */ 197 | const self = this as unknown as Post 198 | const lookupKey = param.lookupKey === '$primaryKey' ? 'id' : param.lookupKey 199 | 200 | if (param.name === 'comment') { 201 | return self 202 | .related('comments') 203 | .query() 204 | .where(lookupKey, value) 205 | .firstOrFail() 206 | } 207 | } 208 | } 209 | ``` 210 | 211 | ## Unbound params 212 | You will often have parameters that are raw values and cannot be tied to a model. In the following example, the `version` is a regular string value and not backed using the database. 213 | 214 | ```ts 215 | Route.get( 216 | '/api/:version/posts/:post', 217 | 'PostsController.show' 218 | ) 219 | ``` 220 | 221 | You can represent the `version` as a string on the controller method, and we will perform no database lookup. For example: 222 | 223 | ```ts 224 | import Post from 'App/Models/Post' 225 | import { bind } from '@adonisjs/route-model-binding' 226 | 227 | class PostsController { 228 | @bind() 229 | public async show({}, version: string, post: Post) {} 230 | } 231 | ``` 232 | 233 | Since the route params and the controller method arguments are matched in the same order they are defined, you will always have to type-hint all the parameters. 234 | 235 |
236 |
237 | 238 | ![](https://cdn.jsdelivr.net/gh/thetutlage/static/sponsorkit/sponsors.png) 239 | -------------------------------------------------------------------------------- /adonis-typings/http-context.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Core/HttpContext' { 11 | interface HttpContextContract { 12 | resources: Record 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /adonis-typings/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | /// 12 | -------------------------------------------------------------------------------- /adonis-typings/rmb.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Addons/RmbMiddleware' { 11 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 12 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 13 | 14 | export interface RouteModelBindingMiddlewareContract { 15 | new (app: ApplicationContract): { 16 | handle(ctx: HttpContextContract, next: () => void): any 17 | } 18 | } 19 | 20 | const RouteModelBindingMiddleware: RouteModelBindingMiddlewareContract 21 | export default RouteModelBindingMiddleware 22 | } 23 | -------------------------------------------------------------------------------- /bin/japaTypes.ts: -------------------------------------------------------------------------------- 1 | import '@japa/runner' 2 | 3 | declare module '@japa/runner' { 4 | interface TestContext { 5 | // notify TypeScript about custom context properties 6 | } 7 | 8 | interface Test { 9 | // notify TypeScript about custom test properties 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { assert } from '@japa/assert' 3 | import { specReporter } from '@japa/spec-reporter' 4 | import { runFailedTests } from '@japa/run-failed-tests' 5 | import { processCliArgs, configure, run } from '@japa/runner' 6 | 7 | /* 8 | |-------------------------------------------------------------------------- 9 | | Configure tests 10 | |-------------------------------------------------------------------------- 11 | | 12 | | The configure method accepts the configuration to configure the Japa 13 | | tests runner. 14 | | 15 | | The first method call "processCliArgs" process the command line arguments 16 | | and turns them into a config object. Using this method is not mandatory. 17 | | 18 | | Please consult japa.dev/runner-config for the config docs. 19 | */ 20 | configure({ 21 | ...processCliArgs(process.argv.slice(2)), 22 | ...{ 23 | files: ['tests/**/*.spec.ts'], 24 | plugins: [assert(), runFailedTests()], 25 | reporters: [specReporter()], 26 | importer: (filePath) => import(filePath), 27 | forceExit: true, 28 | }, 29 | }) 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Run tests 34 | |-------------------------------------------------------------------------- 35 | | 36 | | The following "run" method is required to execute all the tests. 37 | | 38 | */ 39 | run() 40 | -------------------------------------------------------------------------------- /examples/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import 'reflect-metadata' 11 | import { bind } from '../src/decorators/bind' 12 | 13 | class User {} 14 | 15 | export class UsersController { 16 | @bind() 17 | public show(_, __: User) {} 18 | } 19 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { bind } from './src/decorators/bind' 11 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | The package has been configured successfully. 2 | 3 | In order for route model binding to work, you must register the following middleware inside the list of global middleware in `start/kernel.ts` file. 4 | 5 | ```ts 6 | Server.middleware.register([ 7 | // ...other middleware 8 | () => import('@ioc:Adonis/Addons/RmbMiddleware'), 9 | ]) 10 | ``` 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/route-model-binding", 3 | "version": "1.0.1", 4 | "description": "Route model binding for AdonisJS", 5 | "files": [ 6 | "build/adonis-typings", 7 | "build/src", 8 | "build/instructions.md", 9 | "build/providers", 10 | "build/index.d.ts", 11 | "build/index.js" 12 | ], 13 | "main": "build/index.js", 14 | "scripts": { 15 | "mrm": "mrm --preset=@adonisjs/mrm-preset", 16 | "pretest": "npm run lint", 17 | "test": "node -r @adonisjs/require-ts/build/register bin/test.ts", 18 | "clean": "del-cli build", 19 | "copyfiles": "copyfiles \"instructions.md\" build", 20 | "compile": "npm run lint && npm run clean && tsc", 21 | "build": "npm run compile && npm run copyfiles", 22 | "prepublishOnly": "npm run build", 23 | "lint": "eslint . --ext=.ts", 24 | "format": "prettier --write .", 25 | "commit": "git-cz", 26 | "release": "np --message=\"chore(release): %s\"", 27 | "version": "npm run build", 28 | "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json adonisjs/route-model-binding" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/adonisjs/route-model-binding.git" 33 | }, 34 | "keywords": [ 35 | "rmb", 36 | "route-model-binding", 37 | "route-model", 38 | "adonisjs" 39 | ], 40 | "author": "virk,adonisjs", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/adonisjs/route-model-binding/issues" 44 | }, 45 | "homepage": "https://github.com/adonisjs/route-model-binding#readme", 46 | "devDependencies": { 47 | "@adonisjs/core": "^5.8.3", 48 | "@adonisjs/lucid": "^18.0.1", 49 | "@adonisjs/mrm-preset": "^5.0.3", 50 | "@adonisjs/require-ts": "^2.0.12", 51 | "@japa/assert": "^1.3.4", 52 | "@japa/run-failed-tests": "^1.0.7", 53 | "@japa/runner": "^2.0.9", 54 | "@japa/spec-reporter": "^1.1.12", 55 | "@poppinss/dev-utils": "^2.0.3", 56 | "@types/node": "^17.0.42", 57 | "commitizen": "^4.2.4", 58 | "copyfiles": "^2.4.1", 59 | "cz-conventional-changelog": "^3.3.0", 60 | "del-cli": "^4.0.1", 61 | "eslint": "^8.17.0", 62 | "eslint-config-prettier": "^8.5.0", 63 | "eslint-plugin-adonis": "^2.1.0", 64 | "eslint-plugin-prettier": "^4.0.0", 65 | "github-label-sync": "^2.2.0", 66 | "husky": "^8.0.1", 67 | "mrm": "^4.0.0", 68 | "np": "^7.6.1", 69 | "prettier": "^2.6.2", 70 | "reflect-metadata": "^0.1.13", 71 | "sqlite3": "^5.0.8", 72 | "typescript": "^4.7.3" 73 | }, 74 | "mrmConfig": { 75 | "core": true, 76 | "license": "MIT", 77 | "services": [ 78 | "github-actions" 79 | ], 80 | "minNodeVersion": "16.13.1", 81 | "probotApps": [ 82 | "stale", 83 | "lock" 84 | ], 85 | "runGhActionsOnWindows": false 86 | }, 87 | "eslintConfig": { 88 | "extends": [ 89 | "plugin:adonis/typescriptPackage", 90 | "prettier" 91 | ], 92 | "plugins": [ 93 | "prettier" 94 | ], 95 | "rules": { 96 | "prettier/prettier": [ 97 | "error", 98 | { 99 | "endOfLine": "auto" 100 | } 101 | ] 102 | } 103 | }, 104 | "eslintIgnore": [ 105 | "build" 106 | ], 107 | "prettier": { 108 | "trailingComma": "es5", 109 | "semi": false, 110 | "singleQuote": true, 111 | "useTabs": false, 112 | "quoteProps": "consistent", 113 | "bracketSpacing": true, 114 | "arrowParens": "always", 115 | "printWidth": 100 116 | }, 117 | "config": { 118 | "commitizen": { 119 | "path": "cz-conventional-changelog" 120 | } 121 | }, 122 | "np": { 123 | "contents": ".", 124 | "anyBranch": false 125 | }, 126 | "dependencies": { 127 | "@poppinss/utils": "^4.0.4" 128 | }, 129 | "peerDependencies": { 130 | "@adonisjs/core": "^5.8.3", 131 | "@adonisjs/lucid": "^18.0.0" 132 | }, 133 | "publishConfig": { 134 | "access": "public", 135 | "tag": "latest" 136 | }, 137 | "adonisjs": { 138 | "instructionsMd": "./build/instructions.md", 139 | "types": "@adonisjs/route-model-binding/build/adonis-typings", 140 | "providers": [ 141 | "@adonisjs/route-model-binding/build/providers/RmbProvider" 142 | ] 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /providers/RmbProvider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { ApplicationContract } from '@ioc:Adonis/Core/Application' 11 | 12 | /** 13 | * AdonisJS provider for registering the middleware to the container 14 | */ 15 | export default class RmbProvider { 16 | constructor(protected app: ApplicationContract) {} 17 | 18 | public register() { 19 | this.app.container.bind('Adonis/Addons/RmbMiddleware', () => { 20 | const { RouteModelBindingMiddleware } = require('../src/middleware/route_model_binding') 21 | return new RouteModelBindingMiddleware(this.app) 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/contracts/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { LucidModel, LucidRow } from '@ioc:Adonis/Lucid/Orm' 11 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 12 | 13 | export type Param = { 14 | parent: null | string 15 | scoped: boolean 16 | name: string 17 | param: string 18 | lookupKey: string | '$primaryKey' 19 | } 20 | 21 | export interface RouteModel extends LucidModel { 22 | findForRequest?(ctx: HttpContextContract, param: Param, value: any): LucidRow | Promise 23 | routeLookupKey?: string 24 | 25 | new (): { 26 | findRelatedForRequest?( 27 | ctx: HttpContextContract, 28 | param: Param, 29 | value: any 30 | ): LucidRow | Promise 31 | } & LucidRow 32 | } 33 | -------------------------------------------------------------------------------- /src/decorators/bind.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 11 | 12 | /** 13 | * Automatically query Lucid models for the current HTTP 14 | * request. The decorator is meant to work only 15 | * with controllers 16 | */ 17 | export function bind() { 18 | return function (target: any, propertyKey: string) { 19 | const methodParams = Reflect.getMetadata('design:paramtypes', target, propertyKey) 20 | 21 | /** 22 | * Instantiate static bindings property on the controller class 23 | */ 24 | const parentBindings = target.constructor.bindings 25 | if (!target.constructor.hasOwnProperty('bindings')) { 26 | Object.defineProperty(target.constructor, 'bindings', { 27 | value: parentBindings ? Object.assign({}, parentBindings) : {}, 28 | }) 29 | 30 | Object.defineProperty(target, 'getHandlerArguments', { 31 | value: function (ctx: HttpContextContract) { 32 | const handler = ctx.route!.meta.resolvedHandler 33 | if (!handler || handler.type === 'function') { 34 | return [ctx] 35 | } 36 | 37 | const bindings = this.constructor.bindings[handler.method] 38 | if (!bindings) { 39 | return [ctx] 40 | } 41 | 42 | return bindings.reduce( 43 | (result: any[], _: any, index: number) => { 44 | const param = ctx.route!.meta.resolvedParams[index] 45 | if (param) { 46 | result.push(ctx.resources[param.name]) 47 | } 48 | return result 49 | }, 50 | [ctx] 51 | ) 52 | }, 53 | }) 54 | } 55 | 56 | target.constructor.bindings[propertyKey] = target.constructor.bindings[propertyKey] || [] 57 | methodParams.forEach((param: any, index: number) => { 58 | /** 59 | * The first method param is always the HTTP context 60 | */ 61 | if (index !== 0) { 62 | target.constructor.bindings[propertyKey].push(param) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/exceptions/missing_relationship.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Exception } from '@poppinss/utils' 11 | 12 | /** 13 | * Raised when trying to inject a primitive value like "StringConstructor" 14 | * to a class constructor or method 15 | */ 16 | export class MissingRelationshipException extends Exception { 17 | public static invoke(paramName: string, route: string, parentModel: string) { 18 | const errorMessage = `Cannot load "${paramName}" for route "${route}". Make sure to define it as a relationship on model "${parentModel}"` 19 | 20 | return new this(errorMessage, 500, 'E_MISSING_RELATIONSHIP') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware/route_model_binding.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { esmResolver } from '@poppinss/utils' 11 | import type { ApplicationContract } from '@ioc:Adonis/Core/Application' 12 | import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 13 | 14 | import { ResourceLoader } from '../resource_loader' 15 | 16 | export class RouteModelBindingMiddleware { 17 | constructor(private application: ApplicationContract) {} 18 | 19 | public async handle(ctx: HttpContextContract, next: () => Promise) { 20 | ctx.resources = {} 21 | 22 | /** 23 | * Ensure the route exists for which we want to load resources 24 | */ 25 | const route = ctx.route 26 | if (!route) { 27 | return next() 28 | } 29 | 30 | /** 31 | * Ensure the route has a controller.method handler. We do not load 32 | * resources for inline callbacks 33 | */ 34 | const handler = route.meta.resolvedHandler 35 | if (!handler || handler.type === 'function') { 36 | return next() 37 | } 38 | 39 | /** 40 | * Ensure the bindings for the given controller method are defined 41 | */ 42 | const controllerConstructor = esmResolver(this.application.container.use(handler)) 43 | if (!controllerConstructor['bindings'] || !controllerConstructor['bindings'][handler.method]) { 44 | return next() 45 | } 46 | 47 | /** 48 | * Load resources 49 | */ 50 | const resourceLoader = new ResourceLoader(ctx) 51 | await resourceLoader.load(controllerConstructor['bindings'][handler.method]) 52 | ctx.resources = resourceLoader.resources 53 | 54 | await next() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/params_parser/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Param } from '../contracts' 11 | 12 | /** 13 | * Parses the params on a route. The params parser should not rely on the 14 | * request runtime values, since the parsed parameters are later cached 15 | * directly on the route object. 16 | * 17 | * In other words, the params parser should be idempotent across request. 18 | */ 19 | export class ParamsParser { 20 | constructor(private params: string[], private routePattern: string) {} 21 | 22 | /** 23 | * A param can be one of the following 24 | * 25 | * post 26 | * post(slug) 27 | * >comment 28 | * >comment(slug) 29 | */ 30 | private parseParam(param: string): Param { 31 | let scoped = false 32 | let [name, lookupKey] = param.split('(') 33 | 34 | if (lookupKey) { 35 | lookupKey = lookupKey.slice(0, -1) 36 | } else { 37 | lookupKey = '$primaryKey' 38 | } 39 | 40 | if (name.startsWith('>')) { 41 | scoped = true 42 | name = name.substring(1) 43 | } 44 | 45 | return { 46 | scoped, 47 | name, 48 | param, 49 | lookupKey, 50 | parent: null, 51 | } 52 | } 53 | 54 | /** 55 | * Loop through the params and setup the parents 56 | */ 57 | private computeParamParents(params: Param[]) { 58 | params.forEach((param, index) => { 59 | if (param.scoped) { 60 | if (index === 0) { 61 | throw new Error(`The first parameter in route "${this.routePattern}" cannot be scoped`) 62 | } 63 | 64 | param.parent = params[index - 1].name 65 | } 66 | }) 67 | 68 | return params 69 | } 70 | 71 | /** 72 | * Parse route params for a given request 73 | */ 74 | public parse() { 75 | return this.computeParamParents(this.params.map((param) => this.parseParam(param))) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/resource_loader/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-bindings 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { string } from '@poppinss/utils/build/helpers' 11 | import { LucidModel, LucidRow } from '@ioc:Adonis/Lucid/Orm' 12 | import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' 13 | 14 | import { ParamsParser } from '../params_parser' 15 | import { Param, RouteModel } from '../contracts' 16 | import { MissingRelationshipException } from '../exceptions/missing_relationship' 17 | 18 | /** 19 | * Resource loader job is to query the Lucid models for the given 20 | * request using the route params. 21 | * 22 | * One can provide an array of models matching the position of params 23 | * inside a route to conventionally query the database using Lucid 24 | * models. For example: 25 | * 26 | * ``` 27 | * Route - /posts/:post_id/comments/:id 28 | * Models: [Post, Comment] 29 | * ``` 30 | * 31 | * The resource loader will return the following response. 32 | * 33 | * ```ts 34 | * { 35 | * post_id: Post.findOrFail(post_id), 36 | * id: Comment.findOrFail(id) 37 | * } 38 | * ``` 39 | * 40 | * The first model will be matched against the first param. We use the position, 41 | * since we plan to grab models using decorators and in TypeScript decorators 42 | * cannot get the name of the parameter on a method. 43 | * 44 | * One can customize the lookup logic in one of the few ways. 45 | * 46 | * - /posts/:post(slug) - The slug field will be used for the lookup 47 | * - /posts/:post/comments/:>comment - The comment will be loaded as the post relationship 48 | * - Post.routeLookupKey - Define the key to be used to lookup the route 49 | * - Post.findForRequest() - Define a custom static method to lookup Post for a given request 50 | * - Post.findRelatedForRequest() - Define a custom method to lookup Comment for a given post 51 | */ 52 | export class ResourceLoader { 53 | public resources: Record = {} 54 | constructor(public ctx: HttpContextContract) {} 55 | 56 | /** 57 | * Returns true when value is a resource model 58 | */ 59 | private isResourceModel(value: any): value is RouteModel { 60 | if (!value) { 61 | return false 62 | } 63 | 64 | return ( 65 | typeof value['findForRequest'] === 'function' || 66 | typeof value['findOrFail'] === 'function' || 67 | typeof value['findRelatedForRequest'] === 'function' 68 | ) 69 | } 70 | 71 | /** 72 | * Returns the relationship name for a scoped resource 73 | */ 74 | private getRelationshipName(param: Param, parentModel: LucidModel): string { 75 | /** 76 | * Search relationship by singular name 77 | */ 78 | let relationshipName = string.singularize(string.camelCase(param.name)) 79 | if (parentModel.$hasRelation(relationshipName)) { 80 | return relationshipName 81 | } 82 | 83 | /** 84 | * Search relationship by plural name 85 | */ 86 | relationshipName = string.pluralize(relationshipName) 87 | if (parentModel.$hasRelation(relationshipName)) { 88 | return relationshipName 89 | } 90 | 91 | throw MissingRelationshipException.invoke(param.name, this.ctx.route!.pattern, parentModel.name) 92 | } 93 | 94 | /** 95 | * Instantiate scoped model. The parent model instance is passed as 96 | * the first argument. 97 | */ 98 | private async instantiateScopedModel(model: RouteModel, param: Param, value: any) { 99 | const parentModel = this.resources[param.parent!] as LucidRow 100 | const parentModelConstructor = parentModel.constructor as RouteModel 101 | 102 | /** 103 | * The first priority is given to the model "findRelatedForRequest" method 104 | */ 105 | if (typeof parentModel['findRelatedForRequest'] === 'function') { 106 | return parentModel['findRelatedForRequest'](this.ctx, param, value) 107 | } 108 | 109 | const relatedQuery = parentModel 110 | .related(this.getRelationshipName(param, parentModelConstructor) as any) 111 | .query() 112 | 113 | /** 114 | * Next, we find the model by the custom key defined on the route 115 | */ 116 | if (param.lookupKey !== '$primaryKey') { 117 | return relatedQuery.where(param.lookupKey, value).firstOrFail() 118 | } 119 | 120 | /** 121 | * Next, we find the model by the custom key defined on the model 122 | */ 123 | if (model.routeLookupKey) { 124 | return relatedQuery.where(model.routeLookupKey, value).firstOrFail() 125 | } 126 | 127 | /** 128 | * Fallback to primaryKey lookup 129 | */ 130 | return relatedQuery.where(model.primaryKey, value).firstOrFail() 131 | } 132 | 133 | /** 134 | * Instantiate model 135 | */ 136 | private async instantiateModel(model: RouteModel, param: Param, value: any) { 137 | /** 138 | * The first priority is given to the model static "findForRequest" method 139 | */ 140 | if (typeof model.findForRequest === 'function') { 141 | return model.findForRequest(this.ctx, param, value) 142 | } 143 | 144 | /** 145 | * Next, we find the model by the custom key defined on the route 146 | */ 147 | if (param.lookupKey !== '$primaryKey') { 148 | return model.findByOrFail(param.lookupKey, value) 149 | } 150 | 151 | /** 152 | * Next, we find the model by the custom key defined on the model 153 | */ 154 | if (model.routeLookupKey) { 155 | return model.findByOrFail(model.routeLookupKey, value) 156 | } 157 | 158 | /** 159 | * Fallback to primaryKey lookup 160 | */ 161 | return model.findOrFail(value) 162 | } 163 | 164 | /** 165 | * Rewrite ctx.params to use normalized param names 166 | */ 167 | private rewriteParams(params: Param[]) { 168 | params.forEach((param) => { 169 | if (param.name !== param.param) { 170 | this.ctx.params[param.name] = this.ctx.params[param.param] 171 | delete this.ctx.params[param.param] 172 | } 173 | }) 174 | } 175 | 176 | /** 177 | * Load models based upon the current request route params 178 | */ 179 | public async load(models: any[]) { 180 | let index = 0 181 | if (!this.ctx.route!.meta.resolvedParams) { 182 | this.ctx.route!.meta.resolvedParams = new ParamsParser( 183 | this.ctx.route!.params, 184 | this.ctx.route!.pattern 185 | ).parse() 186 | } 187 | 188 | const routeParams = this.ctx.route!.meta.resolvedParams as Param[] 189 | 190 | /** 191 | * Loading models one by one, since some models can be scoped to the 192 | * parent 193 | */ 194 | for (let param of routeParams) { 195 | const model = models[index] 196 | const value = this.ctx.request.param(param.param) 197 | 198 | /** 199 | * Instantiate model if 200 | * 201 | * - Model for the given position is defined 202 | * - The param has values other than undefined and null. 203 | * - Param is undefined when it is not and not defined 204 | * - Param can be null when custom "cast" function sets it to null 205 | */ 206 | if (value !== undefined && value !== null) { 207 | if (this.isResourceModel(model)) { 208 | this.resources[param.name] = param.scoped 209 | ? await this.instantiateScopedModel(model, param, value) 210 | : await this.instantiateModel(model, param, value) 211 | } else { 212 | this.resources[param.name] = value 213 | } 214 | } 215 | 216 | index++ 217 | } 218 | 219 | this.rewriteParams(routeParams) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /test_helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'path' 11 | import { Filesystem } from '@poppinss/dev-utils' 12 | import { DatabaseContract } from '@ioc:Adonis/Lucid/Database' 13 | import { Application } from '@adonisjs/core/build/standalone' 14 | 15 | export const fs = new Filesystem(join(__dirname, '__app')) 16 | 17 | /** 18 | * Setup AdonisJS application 19 | */ 20 | export async function setup() { 21 | const application = new Application(fs.basePath, 'web', { 22 | providers: ['@adonisjs/core', '@adonisjs/lucid'], 23 | }) 24 | 25 | await fs.add( 26 | 'config/app.ts', 27 | ` 28 | export const profiler = { enabled: true } 29 | export const appKey = 'averylongrandomsecretkey' 30 | export const http = { 31 | trustProxy: () => {}, 32 | cookie: {} 33 | } 34 | ` 35 | ) 36 | 37 | await fs.add( 38 | 'config/database.ts', 39 | ` 40 | import { join } from 'path' 41 | 42 | export const connection = 'sqlite' 43 | export const connections = { 44 | sqlite: { 45 | client: 'sqlite3', 46 | connection: { 47 | filename: join(__dirname, '..', 'database', 'db.sqlite') 48 | } 49 | } 50 | } 51 | ` 52 | ) 53 | 54 | await application.setup() 55 | await application.registerProviders() 56 | await application.bootProviders() 57 | 58 | return application 59 | } 60 | 61 | /** 62 | * Migrate database 63 | */ 64 | export async function migrate(database: DatabaseContract) { 65 | await database.connection().schema.createTable('posts', (table) => { 66 | table.increments('id') 67 | table.string('title').notNullable() 68 | table.string('slug').notNullable() 69 | }) 70 | 71 | await database.connection().schema.createTable('comments', (table) => { 72 | table.increments('id') 73 | table.integer('post_id').notNullable().unsigned().references('id').inTable('posts') 74 | table.string('title').notNullable() 75 | table.string('slug').notNullable() 76 | }) 77 | } 78 | 79 | /** 80 | * Rollback database 81 | */ 82 | export async function rollback(database: DatabaseContract) { 83 | await database.connection().schema.dropTable('posts') 84 | await database.connection().schema.dropTable('comments') 85 | await database.manager.closeAll() 86 | } 87 | 88 | /** 89 | * Returns the context for a given route 90 | */ 91 | export function getContextForRoute(app: Application, route: string, url: string) { 92 | const HttpContext = app.container.resolveBinding('Adonis/Core/HttpContext') 93 | const Route = app.container.resolveBinding('Adonis/Core/Route') 94 | 95 | Route.get(route, () => {}) 96 | Route.commit() 97 | 98 | const matchingRoute = Route.match(url, 'GET')! 99 | 100 | const ctx = HttpContext.create(matchingRoute.route.pattern, matchingRoute.params) 101 | return ctx 102 | } 103 | -------------------------------------------------------------------------------- /tests/bind.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { bind } from '../src/decorators/bind' 12 | 13 | test.group('Bind decorator', () => { 14 | test('collect method parameter types and store on the controller', async ({ assert }) => { 15 | class User {} 16 | 17 | class UsersController { 18 | @bind() 19 | public async show(_, __: User) {} 20 | } 21 | 22 | assert.deepEqual(UsersController['bindings'], { show: [User] }) 23 | }) 24 | 25 | test('collect method parameter from the parent class', async ({ assert }) => { 26 | class User {} 27 | 28 | class BaseController { 29 | @bind() 30 | public async show(_, __: User) {} 31 | } 32 | 33 | class UsersController extends BaseController { 34 | @bind() 35 | public async update(_, __: User) {} 36 | } 37 | 38 | assert.deepEqual(BaseController['bindings'], { show: [User] }) 39 | assert.deepEqual(UsersController['bindings'], { show: [User], update: [User] }) 40 | }) 41 | 42 | test('collect method parameter from the multiple parent class', async ({ assert }) => { 43 | class User {} 44 | 45 | class BaseController { 46 | @bind() 47 | public async index(_, __: User) {} 48 | } 49 | 50 | class UserBaseController extends BaseController { 51 | @bind() 52 | public async show(_, __: User) {} 53 | } 54 | 55 | class UsersController extends UserBaseController { 56 | @bind() 57 | public async update(_, __: User) {} 58 | } 59 | 60 | assert.deepEqual(BaseController['bindings'], { index: [User] }) 61 | assert.deepEqual(UserBaseController['bindings'], { show: [User], index: [User] }) 62 | assert.deepEqual(UsersController['bindings'], { show: [User], update: [User], index: [User] }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /tests/params_parser.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { ParamsParser } from '../src/params_parser' 12 | 13 | test.group('Params Parser', () => { 14 | test('parse route params', async ({ assert }) => { 15 | const parser = new ParamsParser(['post'], '/:post') 16 | assert.deepEqual(parser.parse(), [ 17 | { 18 | name: 'post', 19 | param: 'post', 20 | lookupKey: '$primaryKey', 21 | scoped: false, 22 | parent: null, 23 | }, 24 | ]) 25 | }) 26 | 27 | test('parse nested route params', async ({ assert }) => { 28 | const parser = new ParamsParser(['post', 'comment'], '/:post/comments/:comment') 29 | assert.deepEqual(parser.parse(), [ 30 | { 31 | name: 'post', 32 | param: 'post', 33 | lookupKey: '$primaryKey', 34 | scoped: false, 35 | parent: null, 36 | }, 37 | { 38 | name: 'comment', 39 | param: 'comment', 40 | lookupKey: '$primaryKey', 41 | scoped: false, 42 | parent: null, 43 | }, 44 | ]) 45 | }) 46 | 47 | test('parse nested route scoped params', async ({ assert }) => { 48 | const parser = new ParamsParser(['post', '>comment'], '/:post/comments/:>comment') 49 | assert.deepEqual(parser.parse(), [ 50 | { 51 | name: 'post', 52 | param: 'post', 53 | lookupKey: '$primaryKey', 54 | scoped: false, 55 | parent: null, 56 | }, 57 | { 58 | name: 'comment', 59 | param: '>comment', 60 | lookupKey: '$primaryKey', 61 | scoped: true, 62 | parent: 'post', 63 | }, 64 | ]) 65 | }) 66 | 67 | test('disallow first param to be scoped', async ({ assert }) => { 68 | const parser = new ParamsParser(['>post', '>comment'], '/:>post/comments/:>comment') 69 | assert.throws( 70 | () => parser.parse(), 71 | 'The first parameter in route "/:>post/comments/:>comment" cannot be scoped' 72 | ) 73 | }) 74 | 75 | test('parse param with custom key', async ({ assert }) => { 76 | const parser = new ParamsParser(['post(slug)'], '/posts/:post(slug)') 77 | assert.deepEqual(parser.parse(), [ 78 | { 79 | name: 'post', 80 | param: 'post(slug)', 81 | lookupKey: 'slug', 82 | scoped: false, 83 | parent: null, 84 | }, 85 | ]) 86 | }) 87 | 88 | test('parse scoped param with custom key', async ({ assert }) => { 89 | const parser = new ParamsParser( 90 | ['post(slug)', '>comment(slug)'], 91 | '/posts/:post(slug)/comments/:>comment(slug)' 92 | ) 93 | assert.deepEqual(parser.parse(), [ 94 | { 95 | name: 'post', 96 | param: 'post(slug)', 97 | lookupKey: 'slug', 98 | scoped: false, 99 | parent: null, 100 | }, 101 | { 102 | name: 'comment', 103 | param: '>comment(slug)', 104 | lookupKey: 'slug', 105 | scoped: true, 106 | parent: 'post', 107 | }, 108 | ]) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /tests/resource_loader/model.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'path' 11 | import { test } from '@japa/runner' 12 | import type { HasMany } from '@ioc:Adonis/Lucid/Orm' 13 | import { ResourceLoader } from '../../src/resource_loader' 14 | import { setup, fs, getContextForRoute, migrate, rollback } from '../../test_helpers' 15 | 16 | test.group('Resource Loader', (group) => { 17 | group.each.setup(async () => { 18 | await fs.fsExtra.ensureDir(join(fs.basePath, 'database')) 19 | return () => fs.cleanup() 20 | }) 21 | 22 | test('load model by primary key', async ({ assert }) => { 23 | const app = await setup() 24 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 25 | 26 | const { BaseModel, column } = app.container.resolveBinding('Adonis/Lucid/Orm') 27 | 28 | class Post extends BaseModel { 29 | @column({ isPrimary: true }) 30 | public id: number 31 | 32 | @column() 33 | public title: string 34 | 35 | @column() 36 | public slug: string 37 | } 38 | 39 | await Post.createMany([ 40 | { 41 | title: 'Hello world', 42 | slug: 'hello-world', 43 | }, 44 | { 45 | title: 'Hello AdonisJS', 46 | slug: 'hello-adonisjs', 47 | }, 48 | ]) 49 | 50 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/1') 51 | const loader = new ResourceLoader(ctx) 52 | await loader.load([Post]) 53 | 54 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 55 | 56 | assert.property(loader.resources, 'post') 57 | assert.instanceOf(loader.resources.post, Post) 58 | assert.equal(loader.resources.post.id, 1) 59 | assert.equal(loader.resources.post.slug, 'hello-world') 60 | }) 61 | 62 | test('load model by custom model key', async ({ assert }) => { 63 | const app = await setup() 64 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 65 | 66 | const { BaseModel, column } = app.container.resolveBinding('Adonis/Lucid/Orm') 67 | 68 | class Post extends BaseModel { 69 | public static routeLookupKey = 'slug' 70 | 71 | @column({ isPrimary: true }) 72 | public id: number 73 | 74 | @column() 75 | public title: string 76 | 77 | @column() 78 | public slug: string 79 | } 80 | 81 | await Post.createMany([ 82 | { 83 | title: 'Hello world', 84 | slug: 'hello-world', 85 | }, 86 | { 87 | title: 'Hello AdonisJS', 88 | slug: 'hello-adonisjs', 89 | }, 90 | ]) 91 | 92 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/hello-adonisjs') 93 | const loader = new ResourceLoader(ctx) 94 | await loader.load([Post]) 95 | 96 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 97 | 98 | assert.property(loader.resources, 'post') 99 | assert.instanceOf(loader.resources.post, Post) 100 | assert.equal(loader.resources.post.id, 2) 101 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 102 | }) 103 | 104 | test('load model by custom route key', async ({ assert }) => { 105 | const app = await setup() 106 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 107 | 108 | const { BaseModel, column } = app.container.resolveBinding('Adonis/Lucid/Orm') 109 | 110 | class Post extends BaseModel { 111 | @column({ isPrimary: true }) 112 | public id: number 113 | 114 | @column() 115 | public title: string 116 | 117 | @column() 118 | public slug: string 119 | } 120 | 121 | await Post.createMany([ 122 | { 123 | title: 'Hello world', 124 | slug: 'hello-world', 125 | }, 126 | { 127 | title: 'Hello AdonisJS', 128 | slug: 'hello-adonisjs', 129 | }, 130 | ]) 131 | 132 | const ctx = getContextForRoute(app, 'posts/:post(slug)', 'posts/hello-adonisjs') 133 | const loader = new ResourceLoader(ctx) 134 | await loader.load([Post]) 135 | 136 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 137 | 138 | assert.deepEqual(ctx.params, { post: 'hello-adonisjs' }) 139 | assert.deepEqual(ctx.request.params(), { post: 'hello-adonisjs' }) 140 | assert.equal(ctx.request.param('post'), 'hello-adonisjs') 141 | 142 | assert.property(loader.resources, 'post') 143 | assert.instanceOf(loader.resources.post, Post) 144 | assert.equal(loader.resources.post.id, 2) 145 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 146 | }) 147 | 148 | test('load model by static "findForRequest" method', async ({ assert }) => { 149 | const app = await setup() 150 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 151 | 152 | const { BaseModel, column } = app.container.resolveBinding('Adonis/Lucid/Orm') 153 | 154 | class Post extends BaseModel { 155 | @column({ isPrimary: true }) 156 | public id: number 157 | 158 | @column() 159 | public title: string 160 | 161 | @column() 162 | public slug: string 163 | 164 | public static findForRequest(_, __, value: string) { 165 | return this.query().where('slug', value).firstOrFail() 166 | } 167 | } 168 | 169 | await Post.createMany([ 170 | { 171 | title: 'Hello world', 172 | slug: 'hello-world', 173 | }, 174 | { 175 | title: 'Hello AdonisJS', 176 | slug: 'hello-adonisjs', 177 | }, 178 | ]) 179 | 180 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/hello-adonisjs') 181 | const loader = new ResourceLoader(ctx) 182 | await loader.load([Post]) 183 | 184 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 185 | 186 | assert.property(loader.resources, 'post') 187 | assert.instanceOf(loader.resources.post, Post) 188 | assert.equal(loader.resources.post.id, 2) 189 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 190 | }) 191 | 192 | test('load nested independent resource', async ({ assert }) => { 193 | const app = await setup() 194 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 195 | 196 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 197 | 198 | class Post extends BaseModel { 199 | @column({ isPrimary: true }) 200 | public id: number 201 | 202 | @column() 203 | public title: string 204 | 205 | @column() 206 | public slug: string 207 | 208 | @hasMany(() => Comment) 209 | public comments: HasMany 210 | } 211 | 212 | class Comment extends BaseModel { 213 | @column({ isPrimary: true }) 214 | public id: number 215 | 216 | @column() 217 | public postId: number 218 | 219 | @column() 220 | public title: string 221 | 222 | @column() 223 | public slug: string 224 | } 225 | 226 | const [post1, post2] = await Post.createMany([ 227 | { 228 | title: 'Hello world', 229 | slug: 'hello-world', 230 | }, 231 | { 232 | title: 'Hello AdonisJS', 233 | slug: 'hello-adonisjs', 234 | }, 235 | ]) 236 | 237 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 238 | await post2.related('comments').create({ title: 'Awesome post', slug: 'awesome-post' }) 239 | 240 | const ctx = getContextForRoute(app, 'posts/:post/comments/:comment', 'posts/1/comments/1') 241 | const loader = new ResourceLoader(ctx) 242 | await loader.load([Post, Comment]) 243 | 244 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 245 | 246 | assert.property(loader.resources, 'post') 247 | assert.instanceOf(loader.resources.post, Post) 248 | assert.equal(loader.resources.post.id, 1) 249 | assert.equal(loader.resources.post.slug, 'hello-world') 250 | 251 | assert.property(loader.resources, 'comment') 252 | assert.instanceOf(loader.resources.comment, Comment) 253 | assert.equal(loader.resources.comment.id, 1) 254 | assert.equal(loader.resources.comment.slug, 'nice-post') 255 | }) 256 | 257 | test('do not load resource for optional missing param', async ({ assert }) => { 258 | const app = await setup() 259 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 260 | 261 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 262 | 263 | class Post extends BaseModel { 264 | @column({ isPrimary: true }) 265 | public id: number 266 | 267 | @column() 268 | public title: string 269 | 270 | @column() 271 | public slug: string 272 | 273 | @hasMany(() => Comment) 274 | public comments: HasMany 275 | } 276 | 277 | class Comment extends BaseModel { 278 | @column({ isPrimary: true }) 279 | public id: number 280 | 281 | @column() 282 | public postId: number 283 | 284 | @column() 285 | public title: string 286 | 287 | @column() 288 | public slug: string 289 | } 290 | 291 | const [post1, post2] = await Post.createMany([ 292 | { 293 | title: 'Hello world', 294 | slug: 'hello-world', 295 | }, 296 | { 297 | title: 'Hello AdonisJS', 298 | slug: 'hello-adonisjs', 299 | }, 300 | ]) 301 | 302 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 303 | await post2.related('comments').create({ title: 'Awesome post', slug: 'awesome-post' }) 304 | 305 | const ctx = getContextForRoute(app, 'posts/:post/comments/:comment?', 'posts/1/comments') 306 | const loader = new ResourceLoader(ctx) 307 | await loader.load([Post, Comment]) 308 | 309 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 310 | 311 | assert.property(loader.resources, 'post') 312 | assert.instanceOf(loader.resources.post, Post) 313 | assert.equal(loader.resources.post.id, 1) 314 | assert.equal(loader.resources.post.slug, 'hello-world') 315 | 316 | assert.notProperty(loader.resources, 'comment') 317 | }) 318 | 319 | test('disable model binding by setting param type to null', async ({ assert }) => { 320 | const app = await setup() 321 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 322 | 323 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 324 | 325 | class Post extends BaseModel { 326 | @column({ isPrimary: true }) 327 | public id: number 328 | 329 | @column() 330 | public title: string 331 | 332 | @column() 333 | public slug: string 334 | 335 | @hasMany(() => Comment) 336 | public comments: HasMany 337 | } 338 | 339 | class Comment extends BaseModel { 340 | @column({ isPrimary: true }) 341 | public id: number 342 | 343 | @column() 344 | public postId: number 345 | 346 | @column() 347 | public title: string 348 | 349 | @column() 350 | public slug: string 351 | } 352 | 353 | const [post1, post2] = await Post.createMany([ 354 | { 355 | title: 'Hello world', 356 | slug: 'hello-world', 357 | }, 358 | { 359 | title: 'Hello AdonisJS', 360 | slug: 'hello-adonisjs', 361 | }, 362 | ]) 363 | 364 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 365 | await post2.related('comments').create({ title: 'Awesome post', slug: 'awesome-post' }) 366 | 367 | const ctx = getContextForRoute(app, 'posts/:post/comments/:comment?', 'posts/1/comments/1') 368 | const loader = new ResourceLoader(ctx) 369 | await loader.load([null, Comment]) 370 | 371 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 372 | 373 | assert.property(loader.resources, 'post') 374 | assert.equal(loader.resources.post, '1') 375 | assert.property(loader.resources, 'comment') 376 | assert.instanceOf(loader.resources.comment, Comment) 377 | assert.equal(loader.resources.comment.id, 1) 378 | assert.equal(loader.resources.comment.slug, 'nice-post') 379 | }) 380 | }) 381 | -------------------------------------------------------------------------------- /tests/resource_loader/scope_model.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'path' 11 | import { test } from '@japa/runner' 12 | import type { HasMany } from '@ioc:Adonis/Lucid/Orm' 13 | import { ResourceLoader } from '../../src/resource_loader' 14 | import { setup, fs, getContextForRoute, migrate, rollback } from '../../test_helpers' 15 | 16 | test.group('Resource Loader | Scoped', (group) => { 17 | group.each.setup(async () => { 18 | await fs.fsExtra.ensureDir(join(fs.basePath, 'database')) 19 | return () => fs.cleanup() 20 | }) 21 | 22 | test('load nested scoped resource', async ({ assert }) => { 23 | const app = await setup() 24 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 25 | 26 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 27 | 28 | class Post extends BaseModel { 29 | @column({ isPrimary: true }) 30 | public id: number 31 | 32 | @column() 33 | public title: string 34 | 35 | @column() 36 | public slug: string 37 | 38 | @hasMany(() => Comment) 39 | public comments: HasMany 40 | } 41 | 42 | class Comment extends BaseModel { 43 | @column({ isPrimary: true }) 44 | public id: number 45 | 46 | @column() 47 | public postId: number 48 | 49 | @column() 50 | public title: string 51 | 52 | @column() 53 | public slug: string 54 | } 55 | 56 | const [post1, post2] = await Post.createMany([ 57 | { 58 | title: 'Hello world', 59 | slug: 'hello-world', 60 | }, 61 | { 62 | title: 'Hello AdonisJS', 63 | slug: 'hello-adonisjs', 64 | }, 65 | ]) 66 | 67 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 68 | await post2.related('comments').create({ title: 'Awesome post', slug: 'nice-post' }) 69 | 70 | const ctx = getContextForRoute(app, 'posts/:post/comments/:>comment', 'posts/2/comments/1') 71 | const loader = new ResourceLoader(ctx) 72 | await assert.rejects(() => loader.load([Post, Comment]), 'E_ROW_NOT_FOUND: Row not found') 73 | 74 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 75 | }) 76 | 77 | test('load nested scoped resource by custom route key', async ({ assert }) => { 78 | const app = await setup() 79 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 80 | 81 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 82 | 83 | class Post extends BaseModel { 84 | @column({ isPrimary: true }) 85 | public id: number 86 | 87 | @column() 88 | public title: string 89 | 90 | @column() 91 | public slug: string 92 | 93 | @hasMany(() => Comment) 94 | public comments: HasMany 95 | } 96 | 97 | class Comment extends BaseModel { 98 | @column({ isPrimary: true }) 99 | public id: number 100 | 101 | @column() 102 | public postId: number 103 | 104 | @column() 105 | public title: string 106 | 107 | @column() 108 | public slug: string 109 | } 110 | 111 | const [post1, post2] = await Post.createMany([ 112 | { 113 | title: 'Hello world', 114 | slug: 'hello-world', 115 | }, 116 | { 117 | title: 'Hello AdonisJS', 118 | slug: 'hello-adonisjs', 119 | }, 120 | ]) 121 | 122 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 123 | await post2.related('comments').create({ title: 'Awesome post', slug: 'nice-post' }) 124 | 125 | const ctx = getContextForRoute( 126 | app, 127 | 'posts/:post/comments/:>comment(slug)', 128 | 'posts/2/comments/nice-post' 129 | ) 130 | const loader = new ResourceLoader(ctx) 131 | await loader.load([Post, Comment]) 132 | 133 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 134 | 135 | assert.deepEqual(ctx.params, { post: '2', comment: 'nice-post' }) 136 | assert.deepEqual(ctx.request.params(), { post: '2', comment: 'nice-post' }) 137 | assert.equal(ctx.request.param('post'), '2') 138 | assert.equal(ctx.request.param('comment'), 'nice-post') 139 | 140 | assert.property(loader.resources, 'post') 141 | assert.instanceOf(loader.resources.post, Post) 142 | assert.equal(loader.resources.post.id, 2) 143 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 144 | 145 | assert.property(loader.resources, 'comment') 146 | assert.instanceOf(loader.resources.comment, Comment) 147 | assert.equal(loader.resources.comment.id, 2) 148 | assert.equal(loader.resources.comment.postId, 2) 149 | }) 150 | 151 | test('load nested scoped resource by custom model key', async ({ assert }) => { 152 | const app = await setup() 153 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 154 | 155 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 156 | 157 | class Post extends BaseModel { 158 | @column({ isPrimary: true }) 159 | public id: number 160 | 161 | @column() 162 | public title: string 163 | 164 | @column() 165 | public slug: string 166 | 167 | @hasMany(() => Comment) 168 | public comments: HasMany 169 | } 170 | 171 | class Comment extends BaseModel { 172 | public static routeLookupKey = 'slug' 173 | 174 | @column({ isPrimary: true }) 175 | public id: number 176 | 177 | @column() 178 | public postId: number 179 | 180 | @column() 181 | public title: string 182 | 183 | @column() 184 | public slug: string 185 | } 186 | 187 | const [post1, post2] = await Post.createMany([ 188 | { 189 | title: 'Hello world', 190 | slug: 'hello-world', 191 | }, 192 | { 193 | title: 'Hello AdonisJS', 194 | slug: 'hello-adonisjs', 195 | }, 196 | ]) 197 | 198 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 199 | await post2.related('comments').create({ title: 'Awesome post', slug: 'nice-post' }) 200 | 201 | const ctx = getContextForRoute( 202 | app, 203 | 'posts/:post/comments/:>comment', 204 | 'posts/2/comments/nice-post' 205 | ) 206 | const loader = new ResourceLoader(ctx) 207 | await loader.load([Post, Comment]) 208 | 209 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 210 | 211 | assert.deepEqual(ctx.params, { post: '2', comment: 'nice-post' }) 212 | assert.deepEqual(ctx.request.params(), { post: '2', comment: 'nice-post' }) 213 | assert.equal(ctx.request.param('post'), '2') 214 | assert.equal(ctx.request.param('comment'), 'nice-post') 215 | 216 | assert.property(loader.resources, 'post') 217 | assert.instanceOf(loader.resources.post, Post) 218 | assert.equal(loader.resources.post.id, 2) 219 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 220 | 221 | assert.property(loader.resources, 'comment') 222 | assert.instanceOf(loader.resources.comment, Comment) 223 | assert.equal(loader.resources.comment.id, 2) 224 | assert.equal(loader.resources.comment.postId, 2) 225 | }) 226 | 227 | test('load nested scoped resource by custom "findRelatedForRequest" method', async ({ 228 | assert, 229 | }) => { 230 | const app = await setup() 231 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 232 | 233 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 234 | 235 | class Post extends BaseModel { 236 | @column({ isPrimary: true }) 237 | public id: number 238 | 239 | @column() 240 | public title: string 241 | 242 | @column() 243 | public slug: string 244 | 245 | @hasMany(() => Comment) 246 | public comments: HasMany 247 | 248 | public findRelatedForRequest(_, param, value) { 249 | if (param.name === 'comment') { 250 | return this.related('comments' as any) 251 | .query() 252 | .where('slug', value) 253 | .firstOrFail() 254 | } 255 | } 256 | } 257 | 258 | class Comment extends BaseModel { 259 | @column({ isPrimary: true }) 260 | public id: number 261 | 262 | @column() 263 | public postId: number 264 | 265 | @column() 266 | public title: string 267 | 268 | @column() 269 | public slug: string 270 | } 271 | 272 | const [post1, post2] = await Post.createMany([ 273 | { 274 | title: 'Hello world', 275 | slug: 'hello-world', 276 | }, 277 | { 278 | title: 'Hello AdonisJS', 279 | slug: 'hello-adonisjs', 280 | }, 281 | ]) 282 | 283 | await post1.related('comments').create({ title: 'Nice post', slug: 'nice-post' }) 284 | await post2.related('comments').create({ title: 'Awesome post', slug: 'nice-post' }) 285 | 286 | const ctx = getContextForRoute( 287 | app, 288 | 'posts/:post/comments/:>comment', 289 | 'posts/2/comments/nice-post' 290 | ) 291 | const loader = new ResourceLoader(ctx) 292 | await loader.load([Post, Comment]) 293 | 294 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 295 | 296 | assert.deepEqual(ctx.params, { post: '2', comment: 'nice-post' }) 297 | assert.deepEqual(ctx.request.params(), { post: '2', comment: 'nice-post' }) 298 | assert.equal(ctx.request.param('post'), '2') 299 | assert.equal(ctx.request.param('comment'), 'nice-post') 300 | 301 | assert.property(loader.resources, 'post') 302 | assert.instanceOf(loader.resources.post, Post) 303 | assert.equal(loader.resources.post.id, 2) 304 | assert.equal(loader.resources.post.slug, 'hello-adonisjs') 305 | 306 | assert.property(loader.resources, 'comment') 307 | assert.instanceOf(loader.resources.comment, Comment) 308 | assert.equal(loader.resources.comment.id, 2) 309 | assert.equal(loader.resources.comment.postId, 2) 310 | }) 311 | 312 | test('raise exception when relationship for a scoped param is not defined', async ({ 313 | assert, 314 | }) => { 315 | const app = await setup() 316 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 317 | 318 | const { BaseModel, column } = app.container.resolveBinding('Adonis/Lucid/Orm') 319 | 320 | class Post extends BaseModel { 321 | @column({ isPrimary: true }) 322 | public id: number 323 | 324 | @column() 325 | public title: string 326 | 327 | @column() 328 | public slug: string 329 | } 330 | 331 | class Comment extends BaseModel { 332 | @column({ isPrimary: true }) 333 | public id: number 334 | 335 | @column() 336 | public postId: number 337 | 338 | @column() 339 | public title: string 340 | 341 | @column() 342 | public slug: string 343 | } 344 | 345 | await Post.createMany([ 346 | { 347 | title: 'Hello world', 348 | slug: 'hello-world', 349 | }, 350 | { 351 | title: 'Hello AdonisJS', 352 | slug: 'hello-adonisjs', 353 | }, 354 | ]) 355 | 356 | const ctx = getContextForRoute( 357 | app, 358 | 'posts/:post/comments/:>comment', 359 | 'posts/2/comments/nice-post' 360 | ) 361 | const loader = new ResourceLoader(ctx) 362 | await assert.rejects( 363 | () => loader.load([Post, Comment]), 364 | 'E_MISSING_RELATIONSHIP: Cannot load "comment" for route "/posts/:post/comments/:>comment". Make sure to define it as a relationship on model "Post"' 365 | ) 366 | 367 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 368 | }) 369 | }) 370 | -------------------------------------------------------------------------------- /tests/rmb_middleware.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/route-model-binding 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'path' 11 | import { test } from '@japa/runner' 12 | import type { HasMany } from '@ioc:Adonis/Lucid/Orm' 13 | 14 | import { setup, fs, getContextForRoute, migrate, rollback } from '../test_helpers' 15 | import { RouteModelBindingMiddleware } from '../src/middleware/route_model_binding' 16 | import { bind } from '../src/decorators/bind' 17 | 18 | test.group('Route model binding | middleware', (group) => { 19 | group.each.setup(async () => { 20 | await fs.fsExtra.ensureDir(join(fs.basePath, 'database')) 21 | return () => fs.cleanup() 22 | }) 23 | 24 | test('load resources for a given request', async ({ assert }) => { 25 | const app = await setup() 26 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 27 | 28 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 29 | 30 | class Post extends BaseModel { 31 | @column({ isPrimary: true }) 32 | public id: number 33 | 34 | @column() 35 | public title: string 36 | 37 | @column() 38 | public slug: string 39 | 40 | @hasMany(() => Comment) 41 | public comments: HasMany 42 | } 43 | 44 | class Comment extends BaseModel { 45 | @column({ isPrimary: true }) 46 | public id: number 47 | 48 | @column() 49 | public postId: number 50 | 51 | @column() 52 | public title: string 53 | 54 | @column() 55 | public slug: string 56 | } 57 | 58 | class PostsController { 59 | @bind() 60 | public show(_, __: Post) {} 61 | } 62 | 63 | await Post.createMany([ 64 | { 65 | title: 'Hello world', 66 | slug: 'hello-world', 67 | }, 68 | { 69 | title: 'Hello AdonisJS', 70 | slug: 'hello-adonisjs', 71 | }, 72 | ]) 73 | 74 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/2') 75 | ctx.route!.meta.resolvedHandler = { 76 | type: 'binding', 77 | namespace: 'PostsController', 78 | method: 'show', 79 | } 80 | 81 | app.container.bind('PostsController', () => PostsController) 82 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 83 | assert.property(ctx.resources, 'post') 84 | assert.instanceOf(ctx.resources.post, Post) 85 | assert.equal(ctx.resources.post.id, 2) 86 | assert.equal(ctx.resources.post.slug, 'hello-adonisjs') 87 | }) 88 | 89 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 90 | }) 91 | 92 | test('load resources with a custom route param', async ({ assert }) => { 93 | const app = await setup() 94 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 95 | 96 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 97 | 98 | class Post extends BaseModel { 99 | @column({ isPrimary: true }) 100 | public id: number 101 | 102 | @column() 103 | public title: string 104 | 105 | @column() 106 | public slug: string 107 | 108 | @hasMany(() => Comment) 109 | public comments: HasMany 110 | } 111 | 112 | class Comment extends BaseModel { 113 | @column({ isPrimary: true }) 114 | public id: number 115 | 116 | @column() 117 | public postId: number 118 | 119 | @column() 120 | public title: string 121 | 122 | @column() 123 | public slug: string 124 | } 125 | 126 | class PostsController { 127 | @bind() 128 | public show(_, __: Post) {} 129 | } 130 | 131 | await Post.createMany([ 132 | { 133 | title: 'Hello world', 134 | slug: 'hello-world', 135 | }, 136 | { 137 | title: 'Hello AdonisJS', 138 | slug: 'hello-adonisjs', 139 | }, 140 | ]) 141 | 142 | const ctx = getContextForRoute(app, 'posts/:post(slug)', 'posts/hello-adonisjs') 143 | ctx.route!.meta.resolvedHandler = { 144 | type: 'binding', 145 | namespace: 'PostsController', 146 | method: 'show', 147 | } 148 | 149 | app.container.bind('PostsController', () => PostsController) 150 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 151 | assert.property(ctx.resources, 'post') 152 | assert.instanceOf(ctx.resources.post, Post) 153 | assert.equal(ctx.resources.post.id, 2) 154 | assert.equal(ctx.resources.post.slug, 'hello-adonisjs') 155 | }) 156 | 157 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 158 | }) 159 | 160 | test("do not load resources when controller isn't using the bind decorator", async ({ 161 | assert, 162 | }) => { 163 | const app = await setup() 164 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 165 | 166 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 167 | 168 | class Post extends BaseModel { 169 | @column({ isPrimary: true }) 170 | public id: number 171 | 172 | @column() 173 | public title: string 174 | 175 | @column() 176 | public slug: string 177 | 178 | @hasMany(() => Comment) 179 | public comments: HasMany 180 | } 181 | 182 | class Comment extends BaseModel { 183 | @column({ isPrimary: true }) 184 | public id: number 185 | 186 | @column() 187 | public postId: number 188 | 189 | @column() 190 | public title: string 191 | 192 | @column() 193 | public slug: string 194 | } 195 | 196 | class PostsController { 197 | public show(_, __: Post) {} 198 | } 199 | 200 | await Post.createMany([ 201 | { 202 | title: 'Hello world', 203 | slug: 'hello-world', 204 | }, 205 | { 206 | title: 'Hello AdonisJS', 207 | slug: 'hello-adonisjs', 208 | }, 209 | ]) 210 | 211 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/2') 212 | ctx.route!.meta.resolvedHandler = { 213 | type: 'binding', 214 | namespace: 'PostsController', 215 | method: 'show', 216 | } 217 | 218 | app.container.bind('PostsController', () => PostsController) 219 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 220 | assert.deepEqual(ctx.resources, {}) 221 | }) 222 | 223 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 224 | }) 225 | 226 | test('do not load resources when route handler is a closure', async ({ assert }) => { 227 | const app = await setup() 228 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 229 | 230 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 231 | 232 | class Post extends BaseModel { 233 | @column({ isPrimary: true }) 234 | public id: number 235 | 236 | @column() 237 | public title: string 238 | 239 | @column() 240 | public slug: string 241 | 242 | @hasMany(() => Comment) 243 | public comments: HasMany 244 | } 245 | 246 | class Comment extends BaseModel { 247 | @column({ isPrimary: true }) 248 | public id: number 249 | 250 | @column() 251 | public postId: number 252 | 253 | @column() 254 | public title: string 255 | 256 | @column() 257 | public slug: string 258 | } 259 | 260 | class PostsController { 261 | public show(_, __: Post) {} 262 | } 263 | 264 | await Post.createMany([ 265 | { 266 | title: 'Hello world', 267 | slug: 'hello-world', 268 | }, 269 | { 270 | title: 'Hello AdonisJS', 271 | slug: 'hello-adonisjs', 272 | }, 273 | ]) 274 | 275 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/2') 276 | app.container.bind('PostsController', () => PostsController) 277 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 278 | assert.deepEqual(ctx.resources, {}) 279 | }) 280 | 281 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 282 | }) 283 | 284 | test('disable model binding by setting argument type to null', async ({ assert }) => { 285 | const app = await setup() 286 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 287 | 288 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 289 | 290 | class Post extends BaseModel { 291 | @column({ isPrimary: true }) 292 | public id: number 293 | 294 | @column() 295 | public title: string 296 | 297 | @column() 298 | public slug: string 299 | 300 | @hasMany(() => Comment) 301 | public comments: HasMany 302 | } 303 | 304 | class Comment extends BaseModel { 305 | @column({ isPrimary: true }) 306 | public id: number 307 | 308 | @column() 309 | public postId: number 310 | 311 | @column() 312 | public title: string 313 | 314 | @column() 315 | public slug: string 316 | } 317 | 318 | class PostsController { 319 | @bind() 320 | public show(_, __: null, ___: Post) {} 321 | } 322 | 323 | await Post.createMany([ 324 | { 325 | title: 'Hello world', 326 | slug: 'hello-world', 327 | }, 328 | { 329 | title: 'Hello AdonisJS', 330 | slug: 'hello-adonisjs', 331 | }, 332 | ]) 333 | 334 | const ctx = getContextForRoute(app, ':sessionId/posts/:post', '1/posts/2') 335 | ctx.route!.meta.resolvedHandler = { 336 | type: 'binding', 337 | namespace: 'PostsController', 338 | method: 'show', 339 | } 340 | 341 | app.container.bind('PostsController', () => PostsController) 342 | 343 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 344 | assert.property(ctx.resources, 'post') 345 | assert.instanceOf(ctx.resources.post, Post) 346 | assert.equal(ctx.resources.post.id, 2) 347 | assert.equal(ctx.resources.post.slug, 'hello-adonisjs') 348 | const postController = app.container.make(app.container.use('PostsController')) 349 | const injections = postController.getHandlerArguments(ctx) 350 | assert.deepEqual(injections, [ctx, '1', ctx.resources.post]) 351 | }) 352 | 353 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 354 | }) 355 | 356 | test('disable model binding by setting argument type to a primitive type', async ({ assert }) => { 357 | const app = await setup() 358 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 359 | 360 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 361 | 362 | class Post extends BaseModel { 363 | @column({ isPrimary: true }) 364 | public id: number 365 | 366 | @column() 367 | public title: string 368 | 369 | @column() 370 | public slug: string 371 | 372 | @hasMany(() => Comment) 373 | public comments: HasMany 374 | } 375 | 376 | class Comment extends BaseModel { 377 | @column({ isPrimary: true }) 378 | public id: number 379 | 380 | @column() 381 | public postId: number 382 | 383 | @column() 384 | public title: string 385 | 386 | @column() 387 | public slug: string 388 | } 389 | 390 | class PostsController { 391 | @bind() 392 | public show(_, __: string, ___: Post) {} 393 | } 394 | 395 | await Post.createMany([ 396 | { 397 | title: 'Hello world', 398 | slug: 'hello-world', 399 | }, 400 | { 401 | title: 'Hello AdonisJS', 402 | slug: 'hello-adonisjs', 403 | }, 404 | ]) 405 | 406 | const ctx = getContextForRoute(app, ':sessionId/posts/:post', '1/posts/2') 407 | ctx.route!.meta.resolvedHandler = { 408 | type: 'binding', 409 | namespace: 'PostsController', 410 | method: 'show', 411 | } 412 | 413 | app.container.bind('PostsController', () => PostsController) 414 | 415 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 416 | assert.property(ctx.resources, 'post') 417 | assert.instanceOf(ctx.resources.post, Post) 418 | assert.equal(ctx.resources.post.id, 2) 419 | assert.equal(ctx.resources.post.slug, 'hello-adonisjs') 420 | const postController = app.container.make(app.container.use('PostsController')) 421 | const injections = postController.getHandlerArguments(ctx) 422 | assert.deepEqual(injections, [ctx, '1', ctx.resources.post]) 423 | }) 424 | 425 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 426 | }) 427 | 428 | test('ignore bindings which have no parameters in route', async ({ assert }) => { 429 | const app = await setup() 430 | await migrate(app.container.resolveBinding('Adonis/Lucid/Database')) 431 | 432 | const { BaseModel, column, hasMany } = app.container.resolveBinding('Adonis/Lucid/Orm') 433 | 434 | class Post extends BaseModel { 435 | @column({ isPrimary: true }) 436 | public id: number 437 | 438 | @column() 439 | public title: string 440 | 441 | @column() 442 | public slug: string 443 | 444 | @hasMany(() => Comment) 445 | public comments: HasMany 446 | } 447 | 448 | class Comment extends BaseModel { 449 | @column({ isPrimary: true }) 450 | public id: number 451 | 452 | @column() 453 | public postId: number 454 | 455 | @column() 456 | public title: string 457 | 458 | @column() 459 | public slug: string 460 | } 461 | 462 | class PostsController { 463 | @bind() 464 | public show(_, __: Post, ___: Comment) {} 465 | } 466 | 467 | await Post.createMany([ 468 | { 469 | title: 'Hello world', 470 | slug: 'hello-world', 471 | }, 472 | { 473 | title: 'Hello AdonisJS', 474 | slug: 'hello-adonisjs', 475 | }, 476 | ]) 477 | 478 | const ctx = getContextForRoute(app, 'posts/:post', 'posts/2') 479 | ctx.route!.meta.resolvedHandler = { 480 | type: 'binding', 481 | namespace: 'PostsController', 482 | method: 'show', 483 | } 484 | 485 | app.container.bind('PostsController', () => PostsController) 486 | await new RouteModelBindingMiddleware(app).handle(ctx, async () => { 487 | assert.property(ctx.resources, 'post') 488 | assert.instanceOf(ctx.resources.post, Post) 489 | assert.equal(ctx.resources.post.id, 2) 490 | assert.equal(ctx.resources.post.slug, 'hello-adonisjs') 491 | 492 | const postController = app.container.make(app.container.use('PostsController')) 493 | const injections = postController.getHandlerArguments(ctx) 494 | assert.deepEqual(injections, [ctx, ctx.resources.post]) 495 | }) 496 | 497 | await rollback(app.container.resolveBinding('Adonis/Lucid/Database')) 498 | }) 499 | }) 500 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", 3 | "compilerOptions": { 4 | "types": ["@types/node", "@adonisjs/core", "@adonisjs/lucid", "reflect-metadata"], 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "skipLibCheck": true 8 | } 9 | } 10 | --------------------------------------------------------------------------------