├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── renovate.json └── workflows │ ├── erlang.yml │ └── run_nra.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── guides ├── README.md ├── assets │ ├── controller_flow.png │ └── pubsub.png ├── books-and-links.md ├── building-releases.md ├── configuration.md ├── controllers.md ├── deprecations.md ├── handlers.md ├── multi-app.md ├── plugins.md ├── pubsub.md ├── quick-start.md ├── rebar3_nova.md ├── routing.md ├── sessions.md ├── views.md └── watchers.md ├── include ├── nova.hrl ├── nova_pubsub.hrl └── nova_router.hrl ├── priv └── static │ └── nova.png ├── rebar.config ├── rebar.lock └── src ├── controllers ├── nova_error_controller.erl └── nova_file_controller.erl ├── nova.app.src ├── nova.erl ├── nova_app.erl ├── nova_basic_handler.erl ├── nova_erlydtl_inventory.erl ├── nova_handler.erl ├── nova_handlers.erl ├── nova_jsonlogger.erl ├── nova_plugin.erl ├── nova_plugin_handler.erl ├── nova_pubsub.erl ├── nova_router.erl ├── nova_security_handler.erl ├── nova_session.erl ├── nova_session_ets.erl ├── nova_stream_h.erl ├── nova_sup.erl ├── nova_watcher.erl ├── nova_websocket.erl ├── nova_ws_handler.erl ├── plugins ├── nova_correlation_plugin.erl ├── nova_cors_plugin.erl └── nova_request_plugin.erl └── views ├── nova_error.dtl └── nova_file.dtl /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @burbas 2 | * @Taure -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: burbas, Taure 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **If this is a complex bug** 14 | Please prove a link to repository/zip that contains the code to reproduce the problem. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Desktop (please complete the following information):** 20 | - OS: [e.g. Mac OSX 12.1] 21 | - Erlang version [e.g 22] 22 | - Nova version [e.g. v0.9.1] 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQ.]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchFileNames": [ 9 | ".github/**/*.yml" 10 | ], 11 | "groupName": ".github/**/*.yml" 12 | }, 13 | { 14 | "matchPackagePrefixes": [ 15 | "localhost:", 16 | "tag" 17 | ], 18 | "enabled": false 19 | } 20 | ], 21 | "customManagers": [ 22 | { 23 | "description": "Match Hex.pm-based dependencies in rebar.config", 24 | "customType": "regex", 25 | "matchStrings": [ 26 | "{(?[^,]+), \"(?v?\\d+\\.\\d+(\\.\\d+)?)\"" 27 | ], 28 | "versioningTemplate": "semver" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-20.04 8 | name: Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | otp: ['25.1.1', '26.1', '27.0'] 13 | rebar3: ['3.23.0'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: erlef/setup-beam@v1 17 | with: 18 | otp-version: ${{matrix.otp}} 19 | rebar3-version: ${{matrix.rebar3}} 20 | version-type: strict 21 | - name: Compile 22 | run: rebar3 compile 23 | testing: 24 | runs-on: ubuntu-20.04 25 | name: Testing Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}} 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | otp: ['25.1.1', '26.1', '27.1'] 30 | rebar3: ['3.23.0'] 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{matrix.otp}} 36 | rebar3-version: ${{matrix.rebar3}} 37 | version-type: strict 38 | - name: Run dialyzer 39 | run: rebar3 dialyzer 40 | - name: Run xref 41 | run: rebar3 xref 42 | nova_request_app: 43 | if: github.ref != 'refs/heads/main' 44 | needs: [build] 45 | uses: ./.github/workflows/run_nra.yml 46 | with: 47 | branch: "${GITHUB_REF#refs/heads/}" 48 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/run_nra.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | branch: 5 | description: "Branch name" 6 | required: true 7 | type: string 8 | 9 | jobs: 10 | run_nra: 11 | if: github.ref != 'refs/heads/main' 12 | uses: novaframework/nova_request_app/.github/workflows/run_nra.yml@main 13 | with: 14 | nova_branch: "${{ inputs.branch }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | ebin/ 3 | rebar3.crashdump 4 | doc/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.2.0, available at https://www.contributor-covenant.org/version/1/2/0/code-of-conduct.html 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2019, Daniel Widgren . 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![nova logo](https://raw.githubusercontent.com/novaframework/nova/master/priv/static/nova.png) 2 | 3 | > ### Simple. Fault-tolerant. Distributed. 4 | > - Create a basic webpage in minutes 5 | > - Using Erlang OTP to achieve both fault-tolerance and distribution 6 | 7 | [http://www.novaframework.org](http://www.novaframework.org) 8 | 9 | ![Build status](https://github.com/novaframework/nova/actions/workflows/erlang.yml/badge.svg) 10 | 11 | 12 | ## Getting started 13 | 14 | Start by adding the `rebar3` template for Nova. This can be done by running the installation script; 15 | 16 | ```bash 17 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/novaframework/rebar3_nova/master/install.sh)" 18 | ``` 19 | 20 | #### Manually with rebar.config 21 | 22 | Add rebar3_nova to ~/.config/rebar3/rebar.config 23 | ```erlang 24 | {project_plugins, [rebar3_nova]} 25 | ``` 26 | 27 | After this is done use `rebar3` to generate a new project with Nova. 28 | 29 | ```bash 30 | rebar3 new nova my_first_nova 31 | ``` 32 | 33 | 34 | 35 | 36 | ## Supported Erlang versions 37 | 38 | Nova is supported with OTP 23 and above. 39 | 40 | ## Documentation 41 | 42 | Hex docs: https://hexdocs.pm/nova/ 43 | 44 | More on how things work can be read in the docs [Getting Started](https://hexdocs.pm/nova/quick-start.html#content). 45 | 46 | ### Articles 47 | 48 | * [Building an Erlang Web Api using Nova Framework and Redis](https://bercovici-adrian-simon.medium.com/building-an-erlang-web-api-using-nova-framework-and-redis-141edf170ef7) 49 | * [Gettings started with Nova](https://dev.to/taure/getting-started-with-nova-1ioo) 50 | 51 | ## Contributing 52 | 53 | Contribution is welcome to Nova. Check our [CODE OF CONDUCT](CODE_OF_CONDUCT.md) for more information. We will add features and bugs to the issues list. 54 | 55 | ### Generating a Nova project 56 | 57 | Start a new project with: 58 | 59 | ```bash 60 | rebar3 new nova my_first_nova 61 | ``` 62 | 63 | That will generate a Nova project for you. 64 | 65 | ```bash 66 | rebar3 nova serve 67 | ``` 68 | 69 | This will fetch all dependencies and compile. After the compilation it will start a shell that says which port it is running on and a few debug lines. 70 | 71 | When the shell is started, open a browser and go to localhost:8080 which will point to the `my_first_nova` server running Nova. 72 | 73 | ## Important links 74 | 75 | * [Erlang Slack channel][1] 76 | * [Issue tracker][2] 77 | * [Nova Forum (questions)][3] 78 | * Visit Nova's sponsors for expert Nova consulting: 79 | * [Burbas Consulting](http://burbasconsulting.com) 80 | * [Widgrens IT](https://widgrensit.com) 81 | 82 | [1]: https://erlef.org/slack-invite/erlanger 83 | [2]: https://github.com/novaframework/nova/issues 84 | [3]: https://erlangforums.com/c/erlang-frameworks/nova-forum/65 85 | -------------------------------------------------------------------------------- /guides/README.md: -------------------------------------------------------------------------------- 1 | # Nova 2 | 3 | Here can you find the documentation for the Nova framework. Nova is under contstant improvement and we will try to keep the documentation up to date. 4 | 5 | ## Contents 6 | 7 | ### Start of with Nova 8 | 9 | * [Quickstart](quick-start.md) 10 | * [Rebar3 plugin](rebar3_nova.md) 11 | 12 | ### Configuration and concepts 13 | 14 | * [Configuration](configuration.md) 15 | * [Routing](routing.md) 16 | * [Controllers](controllers.md) 17 | * [Views](views.md) 18 | * [Sessions](sessions.md) 19 | * [Watchers](watchers.md) 20 | * [Plugins](plugins.md) 21 | 22 | ### Deeper into Nova 23 | 24 | * [Handlers in Nova](handlers.md) 25 | * [Pubsub system](pubsub.md) 26 | 27 | ### Releases 28 | 29 | * [Building a release](building-releases.md) 30 | * [Include several nova applications in a release](multi-app.md) 31 | 32 | ### Further reading 33 | 34 | * [Books and links](books-and-links.md) 35 | -------------------------------------------------------------------------------- /guides/assets/controller_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novaframework/nova/809d1b3c3c7a0a3466bdbdddee937b76c2b4912f/guides/assets/controller_flow.png -------------------------------------------------------------------------------- /guides/assets/pubsub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novaframework/nova/809d1b3c3c7a0a3466bdbdddee937b76c2b4912f/guides/assets/pubsub.png -------------------------------------------------------------------------------- /guides/books-and-links.md: -------------------------------------------------------------------------------- 1 | # Books and links 2 | 3 | ## Online books 4 | 5 | * [Learn you some Erlang](https://learnyousomeerlang.com) 6 | 7 | ## Books 8 | 9 | * [Learn you some Erlang](https://learnyousomeerlang.com) 10 | * [Erlang OTP in action](https://www.manning.com/books/erlang-and-otp-in-action) 11 | 12 | ## Blogs & Articles 13 | 14 | * [Nova a web framework for Erlang - Daniel Widgren, Niclas Axelsson | Code BEAM V 2020](https://www.youtube.com/watch?v=rucxrxefZMA) 15 | * [Nova in Hello Erlang-podcast](https://www.youtube.com/watch?v=6svokoTcRes) 16 | * [Getting around with NOVA | Daniel Widgren, Niclas Axelsson | Code BEAM America 2022](https://www.youtube.com/watch?v=jVkhKrx4jcY) 17 | * [Fireside chat on Nova and Nitrogen | Daniel Widgren, Niclas Axelsson & Jesse Gumm | Code BEAM V EU21](https://www.youtube.com/watch?v=PgBzV1-E00k) 18 | -------------------------------------------------------------------------------- /guides/building-releases.md: -------------------------------------------------------------------------------- 1 | # Building releases 2 | 3 | Nova utilizes [relx](https://github.com/erlware/relx) for building releases and has a particular *profile* defined for creating such. In a standard Nova-project there will be two profiles defined; `dev`and `prod`. The `dev`-profile is used for local development and the `prod`-profile is used for building releases. 4 | 5 | So in order to build a release for production, you would run the following command: 6 | 7 | ``` 8 | $ rebar3 as prod tar 9 | ``` 10 | 11 | This builds a release and archives it into a tar-file. As default erts is included in the release, but this can be configured through the options in `rebar.config`. To read more about release handling and configuration options we recommend reading relx-manual. 12 | 13 | If you are inexperienced with building releases we recommend reading the [relx-manual](https://www.rebar3.org/docs/releases#section-relx). 14 | -------------------------------------------------------------------------------- /guides/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | There's a lot of parameters that can be configured in Nova. This document will try to explain them all. 4 | 5 | ## Cowboy configuration 6 | 7 | Nova uses Cowboy as the webserver. Cowboy is a very flexible webserver and Nova tries to expose as much of this flexibility as possible. This means that you can configure Cowboy in a lot of different ways. The configuration is done in the `nova`-application under `cowboy_configuration`-key in your *sys.config*. 8 | 9 | | Key | Description | Value | Default | 10 | |-----|-------------|-------|---------| 11 | | `stream_handlers` | Stream handlers are used to handle streaming requests. You can configure multiple stream handlers. Read more in the subsection *Stream handlers* | `list()` | `[nova_stream_h, cowboy_compress_h, cowboy_stream_h]` | 12 | | `middleware_handlers` | Middleware handlers are used to handle middleware requests. You can configure multiple middleware handlers. Read more in the subsection *Middleware handlers* | `list()` | `[nova_router, nova_plugin_handler, nova_security_handler, nova_handler, nova_plugin_handler]` | 13 | | `options` | Cowboy options. Read more in the subsection *Cowboy options* | `map()` | `#{compress => true}` | 14 | | `ip` | IP to bind to | `tuple` | `{0, 0, 0, 0}` | 15 | | `port` | Port to bind to | `integer()` | `8080` | 16 | | `use_ssl` | If SSL should be used | `boolean()` | `false` | 17 | | `ssl_options` | Transport options for SSL. Nova uses ranch_ssl module so read about available options on [their page](https://ninenines.eu/docs/en/ranch/2.0/manual/ranch_ssl/). | `ranch_ssl:opts()` | `#{cert => "/path/to/fullchain.pem", key => "/path/to/privkey.pem"}` | 18 | | `ssl_port` | Port to bind to when using SSL | `integer()` | `8443` | 19 | | `ca_cert` | Path to CA-cert *Deprecated since 0.10.3 - Read with `ssl_options`*| `string()` | `undefined` | 20 | | `cert` | Path to cert *Deprecated since 0.10.3 - Replaced with `ssl_options`*| `string()` | `undefined` | 21 | 22 | 23 | ## Nova specific configurations 24 | 25 | Following parameters should be defined under the `nova`-key in your *sys.config*. 26 | 27 | | Key | Description | Value | Default | 28 | |-----|-------------|-------|---------| 29 | | `use_persistent_term` | Use `persistent_term` module to store routing tree | `boolean()` | `true` | 30 | | `use_stacktrace` | If Nova should include stacktrace in error-pages | `boolean()` | `false` | 31 | | `render_error_pages` | If Nova should render error-pages for HTML-request | `boolean()` | `true` | 32 | | `use_sessions` | Turn off/on support for sessions | `boolean()` | `true` | 33 | | `session_manager` | Specifify a module to use as the session manager. Defaults to `nova_session_ets` | `atom()` | `nova_session_ets` | 34 | | `use_strict_routing` | If the routing module should work under the strict mode. Using strict mode will cause errors if non-deterministic paths are detected. This is a beta-function so use with caution. | `boolean()` | `false` | 35 | | `bootstrap_application` | Define which application to bootstrap with Nova. This should be the name of your application. | `atom()` | *Will crash if not defined* | 36 | | `cowboy_configuration` | If you need some additional configuration done to Cowboy this is the place. Check `nova_sup` module to learn which keys that can be defined. | `map()` | `#{}` | 37 | 38 | ## Application parameters 39 | 40 | These parameters can be specified in your *main* application (Eg the one you've specified in the `bootstrap`-section). 41 | 42 | | Key | Description | Value | 43 | |-----|-------------|-------| 44 | | `json_lib` | JSON lib to use. Read more in the subsection *Configure json lib* | `atom()` | 45 | | `watchers` | Watchers are external programs that will run together with Nova. Watchers are defined as list of tuples where the tuples is in format `{Command, ArgumentList}` (Like `[{my_app, "npm", ["run", "watch"], #{workdir => "priv/assets/js/my-app"}}]`) | `[{string(), string()}] | [{atom(), string(), map()}] | [{atom(), string(), list(), map()}]` | 46 | 47 | 48 | 49 | ### Configure json_lib 50 | 51 | One can configure which json library to use for encoding/decoding json structures. The module defined for this should expose two different functions: 52 | 53 | `encode(Structure) -> binary() | iolist()` 54 | 55 | `decode(JsonString) -> {ok, Structure}` 56 | 57 | 58 | ## Handling errors in Nova 59 | 60 | Nova will by default render a error page if an error occurs. This page will be rendered using the `nova_error`-template. This template can be overridden by defining a template with the same name in your application. 61 | By defauly Nova outputs a lot of information, including the stacktrace. This might not be a good approach in production. To turn off stacktraces in production you can add the following to your *sys.config*: 62 | 63 | ```erlang 64 | {nova, [{render_error_pages, false}]} 65 | ``` 66 | 67 | This will exclude stacktrace from the error page. 68 | 69 | *Note* Nova is aware about which `accept`-headers the request is sent with and will respond with the correct content-type. If the request is sent with `application/json` Nova will respond with a JSON-structure instead of a HTML-page. 70 | -------------------------------------------------------------------------------- /guides/controllers.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | Controllers are central in how Nova works. They are in charge of handling all user-implemented logic for a request. 4 | Controllers are located in the `/src/controllers/` directory of your Nova application. A controller is basically a regular Erlang module that exposes functions you've provided in the routing file. 5 | 6 | ![Request life-cycle](assets/controller_flow.png "Request life-cycle") 7 | 8 | ## Handlers 9 | 10 | Handlers are modules that interprets the resulting output from a controller and sends it to the requester. Handlers are identified by the first atom in the return of a controller, eg `{json, #{status => "ok"}}` calls the `json` handler. You can read about the handlers below and what their functions are. 11 | 12 | ### JSON structures 13 | 14 | #### Simple interface 15 | 16 | *Keyword*: `json` 17 | 18 | *Spec*: `{json, Structure :: map()}` 19 | 20 | *Example*: `{json, #{status => "ok"}}.` 21 | 22 | Converts the map (Second element in the resulting tuple) and sends it to the requester with *HTTP-status code 200*. 23 | 24 | #### Advanced interface 25 | 26 | *Keyword*: `json` 27 | 28 | *Spec*: `{json, StatusCode :: integer(), Headers :: map(), JSON :: map()}` 29 | 30 | *Example*: `{json, 201, #{"x-correlation-id", "EX123"}, #{some_response => true}}` 31 | 32 | Same as the simple interface but with two additional elements for specifying HTTP-status code and additional headers. 33 | 34 | ### HTML templates (Using Erlydtl) 35 | 36 | *Nova* renders templates using ErlyDTL ([https://github.com/erlydtl/erlydtl](https://github.com/erlydtl/erlydtl) which uses the *django template language* to express logic. To get a better overview for the functionality that comes with dtl check their [documentation page](https://django.readthedocs.io/en/1.6.x/ref/templates/builtins.html) 37 | 38 | #### Simple interface 39 | 40 | *Keyword*: `view` 41 | 42 | *Spec*: `{view, Variables :: map() | [{Key :: atom() | binary() | string(), Value :: any()}]}` 43 | 44 | *Example*: `{view, #{my_var => "123"}}` 45 | 46 | Renders the corresponding view with the variables attached. If a controller is named `my_simple_controller.erl` the view is named `my_simple_view.dtl`. 47 | 48 | #### Advanced interface 49 | 50 | *Keyword*: `view` 51 | 52 | *Spec*: `{view, Variables :: map() | [{Key :: atom() | binary() | string(), Value :: any()}], Options :: map()}` 53 | 54 | *Example*: `{view, #{my_var => "123"}, #{view => my_view_mod}}` 55 | 56 | Same as the simple interface but where you can define some options. Currently the only option for this interface is *view* which enables the user to specify a view other than the corresponding one based on controllers name. In the example above the dtl-file `my_view_mod.dtl` would be rendered. 57 | 58 | ### HTTP-status codes 59 | 60 | #### Simple interface 61 | 62 | *Keyword*: `status` 63 | 64 | *Spec*: `{status, StatusCode :: integer()}` 65 | 66 | *Example*: `{status, 200}` 67 | 68 | Returns a HTTP-code to the requester with an empty body. 69 | 70 | #### Medium interface 71 | 72 | *Keyword*: `status` 73 | 74 | *Spec*: `{status, StatusCode :: integer(), ExtraHeaders :: map()}` 75 | 76 | *Example*: `{status, 200, #{"content-type" => "application/json"}}` 77 | 78 | Same as the simple interface but with an additional field for specifying additional headers. 79 | 80 | #### Advanced interface 81 | 82 | *Keyword*: `status` 83 | 84 | *Spec*: `{status, Status :: integer(), ExtraHeaders :: map(), Body :: binary()}` 85 | 86 | *Example*: `{status, 200, #{"content-type" => "text/plain"}, "A plain text"}` 87 | 88 | Same as the medium interface but with an additional field for specifying a body. 89 | 90 | ### File transfers (Using cowboys sendfile functionality) 91 | 92 | *Keyword*: `sendfile` 93 | 94 | *Spec*: `{sendfile, StatusCode :: integer(), Headers :: map(), {Offset :: integer(), Length :: integer(), Path :: list()}, Mime :: binary()}` 95 | 96 | *Example*: `{sendfile, 200, #{}, {0, 12345, "path/to/logo.png"}, "image/png"}` 97 | 98 | Sends a file using sendfile. This uses cowboys sendfile functionality and more information about it can be found in the [cowboy manual on sendfile](https://ninenines.eu/docs/en/cowboy/2.9/guide/resp/#_sending_files) 99 | 100 | ### Redirecting user 101 | 102 | *Keyword*: `redirect` 103 | 104 | *Spec*: `{redirect, Route :: list() | binary()}` 105 | 106 | *Example*: `{redirect, "/my/other/path}` 107 | 108 | Sends a temporary redirect (HTTP status code 302) for the specified path to requester. 109 | 110 | 111 | ## Fallback controllers 112 | 113 | [Phoenix](https://www.phoenixframework.org) have a really useful feature which they call [action_fallback]( https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1). We thought it would be a good addition to Nova to include something similar and therefore the `fallback_controller` was introduced. If a controller returns an invalid/unhandled result the fallback controller gets invoked and can take action on the payload. It's good for separating error-handling from the controllers. A fallback controller is set by setting the `fallback_controller` attribute with the module name of the fallback controller. 114 | 115 | The following example shows how a controller defines a fallback 116 | 117 | ``` 118 | -module(my_main_controller). 119 | -export([ 120 | error_example/1 121 | ]). 122 | -fallback_controller(my_fallback_controller). 123 | 124 | error_example(_Req) -> 125 | %% Since {error, ...} is not a valid handler the fallback-controller will be invoked 126 | {error, example_error}. 127 | ``` 128 | 129 | A fallback controller exposes one function `resolve/2` which returns a handler (like for regular controllers) in order to return the response to client. If we take the previous example and try and build a fallback controller for it: 130 | 131 | ``` 132 | -module(my_fallback_controller). 133 | -export([ 134 | resolve/2 135 | ]). 136 | 137 | resolve(Req, {error, example_error}) -> 138 | {status, 400}. 139 | ``` 140 | 141 | 142 | 143 | 144 | ## Plugins 145 | 146 | Plugins are part of the Nova core and can be executed both before and after the execution of a controller. They can both terminate a request early (like the *request plugin* does) or transform data into another structure (*json plugin*). Plugins can be defined in two different places; *Global* plugins are defined in the *sys.config* file and will be executed for every incoming request. If a plugin should only be executed for a limited set of endpoints it can be defined in the router file for that specific application (we call there *local plugins*). 147 | 148 | ### Global plugins 149 | 150 | Global plugins are defined in the *sys.config* file and can have two different states: *pre* and *post* controller. They are exectued before or after a controller. Global plugins lives under the `nova` application, `plugins` key. An example of a sys.config file: 151 | 152 | ``` 153 | {nova, [ 154 | {plugins, [ 155 | {pre_request, [ 156 | {nova_request_plugin, #{decode_json_body => true}} 157 | ]}, 158 | {post_request, [ 159 | ]} 160 | ]} 161 | ]}. 162 | ``` 163 | 164 | In order to find the valid options one can call the `plugin_info/0` function of each plugin. Currently there are [four plugins](https://github.com/novaframework/nova/tree/master/src/plugins) shipped with Nova. 165 | 166 | 167 | > #### NOTE {: .tip} 168 | > 169 | > If you are creating an application that can be included in another Nova application all of your plugins should be defined in the router-file (Local plugins) in order to avoid being overwritten. 170 | 171 | 172 | ### Local plugins 173 | 174 | Local plugins works almost as the global ones but for a limited set of paths. Local plugins are therefore declared in the router-file for each *group* of endpoints. They are declared in the same way as the global ones. 175 | 176 | Example: 177 | ``` 178 | routes(_Env) -> 179 | [#{ 180 | prefix => "/api/json", 181 | security => false, 182 | plugins => [ 183 | {pre_request, [ 184 | {nova_request_plugin, #{decode_json_body => true}} 185 | ]} 186 | ], 187 | routes => [ 188 | {"/my_json_route", {my_app_json_controller, json}, #{methods => [get, post]}} 189 | ] 190 | }]. 191 | ``` 192 | 193 | It's recommended to use this method of specifying routes if you plan to use it as a component in another *Nova application*. 194 | 195 | ## Websockets 196 | 197 | *Coming soon* 198 | 199 | ### Callbacks 200 | 201 | *Coming soon* 202 | -------------------------------------------------------------------------------- /guides/deprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations in Nova 2 | 3 | We try to keep the Nova API as stable as possible, but sometimes we need to depreciate old functionality. 4 | This is a list of the things that are currently deprecated in Nova. 5 | 6 | 7 | ## 0.9.24 8 | 9 | - The old format with `{Module, Function}` is deprecated in favor of `fun Module:function/1`. This is a breaking change, 10 | but it is a good time to do it now before we release 1.0.0. The old format will be removed in 1.0.0. This goes for all occurrences 11 | of `{Module, Function}` in Nova. 12 | -------------------------------------------------------------------------------- /guides/handlers.md: -------------------------------------------------------------------------------- 1 | # Handlers 2 | 3 | ## Handlers 4 | 5 | Handlers are a nifty thing that is called on when the controller returns. For example if the controller returns 6 | `{json, #{hello => world}}` we would like Nova to create a proper JSON response to the requester. That means setting correct 7 | headers, encode the payload and send it out. This is what _handlers_ are for. They are (often short) functions that transforms something like 8 | `{json, Payload}` to proper output. 9 | -------------------------------------------------------------------------------- /guides/multi-app.md: -------------------------------------------------------------------------------- 1 | # Including other nova applications 2 | 3 | Nova is built to support inclusion of other applications built with Nova. To include an application you first need to include it in the `rebar.config` as a dependency. Then add the `nova_apps` option in your application-configuration. `nova_apps` should contain a list of atoms or two-tuples (For defining options). 4 | 5 | ## Configuration options 6 | 7 | There's currently two different options available and they works in the same way in the routing-module. 8 | 9 | | Key | Value | Description | 10 | |---|---|---| 11 | | prefix | string | Defines if the applications urls should be prefixed | 12 | | secure | false | {Mod, Fun} | Tells if the application should be secured | 13 | 14 | ## Example 15 | 16 | *rebar.config*: 17 | 18 | ``` 19 | ... 20 | {deps, [ 21 | {another_nova_app, "1.0.0"}, 22 | ] 23 | ... 24 | 25 | 26 | *sys.config* 27 | 28 | ``` 29 | ... 30 | {my_nova_app, [ 31 | {nova_apps, [{another_nova_app, #{prefix => "/another"}}]} 32 | ]} 33 | ... 34 | ``` 35 | -------------------------------------------------------------------------------- /guides/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Plugins are a bit like the handlers except they are run on request. There's currently two different type of plugins; `pre_request` and `post_request`. 4 | These can be used to create access logs, insert CORS headers or similar. 5 | 6 | Plugins are used to handle things before and/or after a request. They are applied on all requests of a specified protocol. 7 | 8 | This is an example: 9 | 10 | ```erlang 11 | -module(correlation_id). 12 | -behaviour(nova_plugin). 13 | -export([ 14 | pre_request/2, 15 | post_request/2, 16 | plugin_info/0 17 | ]). 18 | 19 | pre_request(Req, NovaState) -> 20 | UUID = uuid:uuid_to_string(uuid:get_v4()), 21 | {ok, cowboy_req:set_resp_header(<<"x-correlation-id">>, UUID, Req), NovaState}. 22 | 23 | post_request(Req, NovaState) -> 24 | {ok, Req, NovaState}. 25 | 26 | plugin_info() -> 27 | {<<"Correlation plugin">>, <<"1.0.0">>, <<"Niclas Axelsson ">>, 28 | <<"Example plugin for nova">>}. 29 | ``` 30 | 31 | This plugin injects a UUID into the headers. 32 | 33 | 34 | Adding a plugin 35 | 36 | Example: 37 | A good example of a very useful plugin is the `nova_request_plugin`. When we are developing a HTTP web api using json as the data format, we need the framework to 38 | decode our message so that we can process it. To do that we need to add `decode_json_body => true` into the options field in our `sys.config`. 39 | 40 | 41 | **sys.config** 42 | 43 | 44 | ```erlang 45 | {nova, [ 46 | {environment, dev}, 47 | {cowboy_configuration, #{ 48 | port => 8080 49 | }}, 50 | {dev_mode, true}, 51 | {bootstrap_application, chatapp}, 52 | {plugins, [ 53 | {pre_request, nova_request_plugin, #{parse_bindings => true, 54 | decode_json_body => true}} 55 | ]} 56 | ]} 57 | ``` 58 | We have added our plugin in the `plugins` section. As we can see this is a `pre_request` plugin since it processes and decodes the message to json format 59 | before we can actually use it in our nova application endpoints. 60 | 61 | Usage: 62 | 63 | **controller** 64 | 65 | ```erlang 66 | -module(test_controller). 67 | -export([increment/1]). 68 | 69 | increment(#{<<"json">> := #{<<"id">> := Id, <<"value">> := Value}})-> 70 | {json,200,#{},#{<<"id">> => Id , <<"received">> => Value, <<"increment">> => Value+1}}. 71 | 72 | ``` 73 | ## Nova plugins 74 | 75 | Nova has a couple of plugins for some general purposes. 76 | 77 | |Plugin|Description|Code| 78 | |------|-----------|----| 79 | |nova_correlation_plugin|This plugin will add a correlation id to header response but also add `#{correlation_id => CorrelationID}` to the request obj that is passed to the controller.|[nova_correlation_plugin](https://github.com/novaframework/nova/blob/master/src/plugins/nova_correlation_plugin.erl)| 80 | |nova_cors_plugin|This plugin will handle cors and add the cors headers into the request.|[nova_cors_plugin](https://github.com/novaframework/nova/blob/master/src/plugins/nova_cors_plugin.erl)| 81 | |nova_request_plugin|This plugin will handle incomming data like qs, form urlencoded and json|[nova_request_plugin](https://github.com/novaframework/nova/blob/master/src/plugins/nova_request_plugin.erl)| 82 | 83 | 84 | ### Nova correlation 85 | 86 | This plugin will generate a uuid v4 and set it as a response header as `X-Correlation-ID` if nothing is configuered. 87 | 88 | ```erlang 89 | {pre_request; nova_correlation_plugin, #{request_correlation_header => CorrelationHeader, 90 | logger_metadata_key => LoggerMetaDataKey}} 91 | ``` 92 | 93 | |Option|Description| 94 | |------|-----------| 95 | |request_correlation_header|This is if you want a different correlation header than the standard `X-Correlation-ID`| 96 | |logger_metadata_key| This is if you want to have a different metadata key then the standard `correaltion_id`| 97 | 98 | ### Nova cors 99 | 100 | This plugins will make it so that if we get method OPTIONS it will just return back the CORS headers. In this case you don't need a controller to handle it and the plugin stops after this. 101 | For other methods it will add the CORS headers to the request. 102 | 103 | ```erlang 104 | {pre_request; nova_cors_plugin, #{allow_origins => <<"*">>}} 105 | ``` 106 | 107 | |Option|Description| 108 | |------|-----------| 109 | |allow_origins|Specifies which origins to insert into Access-Control-Allow-Origin| 110 | 111 | ### Nova request 112 | 113 | This plugins handle incoming data and can transform them to erlang maps depending on what the options are. 114 | 115 | ```erlang 116 | {pre_request; nova_correlation_plugin, #{decode_json_body => true, 117 | read_urlencoded_body => true, 118 | parse_qs => true|list}} 119 | ``` 120 | 121 | 122 | |Option|Description|Req| 123 | |------|-----------|----| 124 | |decode_json_body|If header is application/json it will decode the body.| `Req#{json => Map}`| 125 | |read_urlencoded_body|If header is application/x-www-form-urlencoded it will decode it.| `Req#{params => Map}`| 126 | |parse_qs| If the path have qs in it we will get them.|`Req#{parsed_qs => Map or List}`| 127 | -------------------------------------------------------------------------------- /guides/pubsub.md: -------------------------------------------------------------------------------- 1 | # Nova Pubsub 2 | 3 | With version 0.9.4 we introduced the concept of a pubsub-like mechanism in Nova so that users can build distributed services. Internally it relies on Erlangs `pg2`-module which enables distributed named process groups. The concept is really simple and there's not much depth in the current implementation - it should only be used as a simple message bus. If you need more advanced features please take a look at [RabbitMQ](https://www.rabbitmq.com/) or [MQTT](https://mqtt.org). 4 | 5 | ## Basic concepts 6 | 7 | The idea is that a process subscribes to a *topic*. If there's any messages sent to this topic all the *subscribed* processes receivs that message. 8 | 9 | ![Processes](assets/pubsub.png) 10 | 11 | *This picture shows how two processes have subscribed to a topic (Or channel) "Messages". When another process then sends *"Hello"* on this topic the other two will receive it. 12 | 13 | ## Example 14 | ```erlang 15 | -module(test_module). 16 | -export([player1/0, 17 | player2/0, 18 | start_game/0]). 19 | 20 | player1() -> 21 | spawn(fun() -> 22 | nova_pubsub:join(game_of_pong), 23 | game_loop(1, "pong", "ping") 24 | end). 25 | 26 | player2() -> 27 | spawn(fun() -> 28 | nova_pubsub:join(game_of_pong), 29 | game_loop(2, "ping", "pong") 30 | end). 31 | 32 | game_loop(Player, ExpectedMessage, Smash) -> 33 | receive 34 | ExpectedMessage -> 35 | io:format("Player ~d received ~s and returning ~s~n", [Player, ExpectedMessage, Smash]), 36 | nova_pubsub:broadcast(game_of_pong, "match1", Smash), 37 | game_loop(Player, ExpectedMessage, Smash); 38 | _ -> 39 | game_loop(Player, ExpectedMessage, Smash) 40 | end. 41 | ``` 42 | Here we are subscribing to a topic *"game_of_pong"* which we later use to send either ping or pong to, depending on which player that are serving. 43 | This can ofcourse be extended to do so much more, but this is a taste of what one can use nova_pubsub to. 44 | 45 | 46 | ## Configuration 47 | 48 | It does not require any specific parameters set in order to work. Just remember to enable *distributed erlang*. 49 | -------------------------------------------------------------------------------- /guides/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Start your first project 4 | 5 | Nova provides a plugin to make working with the framework a lot easier. One can install it by either using 6 | an automated installation or include it manually in the global *rebar.config* file. 7 | 8 | > #### Note :bangbang: 9 | > 10 | > If you require help installing Erlang and/or rebar3 please check [https://adoptingerlang.org/docs/development/setup/](https://adoptingerlang.org/docs/development/setup/) 11 | 12 | ### Automated installation 13 | 14 | *Via Curl* 15 | 16 | ```bash 17 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/novaframework/rebar3_nova/master/install.sh)" 18 | ``` 19 | 20 | *Via wget* 21 | ```bash 22 | sh -c "$(wget -O- https://raw.githubusercontent.com/novaframework/rebar3_nova/master/install.sh)" 23 | ``` 24 | 25 | ### Manual installation 26 | 27 | Open your `rebar.config` file that should reside in `~/.config/rebar3/rebar.config*`. If the file does not exist you 28 | can just create it. Locate the `plugins` section of the file and include the nova plugin. The result should look something like 29 | the following: 30 | 31 | ``` 32 | {plugins,[{rebar3_nova,{git,"https://github.com/novaframework/rebar3_nova.git", 33 | {branch,"master"}}}]}. 34 | ``` 35 | 36 | 37 | ### Creating the skeleton 38 | 39 | After the installation of the Nova-plugin is done use rebar3 to generate a new project. 40 | 41 | ```bash 42 | rebar3 new nova my_first_nova 43 | ``` 44 | 45 | ``` 46 | $ rebar3 new nova my_first_app 47 | ===> Writing my_first_nova/config/dev_sys.config.src 48 | ===> Writing my_first_nova/config/prod_sys.config.src 49 | ===> Writing my_first_nova/src/my_first_nova.app.src 50 | ===> Writing my_first_nova/src/my_first_nova_app.erl 51 | ===> Writing my_first_nova/src/my_first_nova_sup.erl 52 | ===> Writing my_first_nova/src/my_first_nova_router.erl 53 | ===> Writing my_first_nova/src/controllers/my_first_nova_main_controller.erl 54 | ===> Writing my_first_nova/rebar.config 55 | ===> Writing my_first_nova/config/vm.args.src 56 | ===> Writing my_first_nova/src/views/my_first_nova_main.dtl 57 | ``` 58 | 59 | Now the skeleton have been created and you should be able to start it. Go into the newly created directory and run the `serve` command. 60 | 61 | > #### Note :bangbang: 62 | > 63 | > For the auto-compile/reload to work you need [inotify](https://github.com/massemanet/inotify) to be installed. 64 | 65 | ``` 66 | $ cd my_first_app 67 | $ rebar3 nova serve 68 | ===> Verifying dependencies... 69 | ===> Compiling my_first_app 70 | Erlang/OTP 20 [erts-9.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:0] [hipe] [kernel-poll:false] 71 | ... 72 | a lot of progress-reports 73 | ... 74 | ===> Booted my_first_app 75 | ... 76 | ``` 77 | 78 | If you take a look at [http://localhost:8080](http://localhost:8080) you should be greeted by a Nova-page. 79 | -------------------------------------------------------------------------------- /guides/rebar3_nova.md: -------------------------------------------------------------------------------- 1 | # Rebar3 Nova # 2 | 3 | If you have used the installation script, documented in the main README.md-file you should have a working installation of rebar3 nova. This gives you some handy commands to work with Nova; 4 | 5 | `rebar3 new nova ` - Creates a new Nova project in the directory `` 6 | 7 | `rebar3 nova serve` - Starts a local webserver on port given in *sys.config*, serving the current project. This means that if something is changed in the project, the server will automatically reload the changed files. 8 | 9 | `rebar3 nova routes` - Lists all routes in the current project (Includes included Nova applications aswell if they are properly configured) 10 | 11 | 12 | We are hoping to increase the amount of commands that is available from the rebar3 nova command in the future. 13 | -------------------------------------------------------------------------------- /guides/routing.md: -------------------------------------------------------------------------------- 1 | # Routing 2 | 3 | Each nova application have their own routes file. This file contains information about all the routes existing for this application. 4 | 5 | ## Basic components 6 | 7 | A simple route file could look something like this: 8 | 9 | ```erlang 10 | -module(my_app_router). 11 | 12 | -export([routes/1]). 13 | 14 | routes(_Environment) -> 15 | [#{prefix => "/admin", 16 | security => false, 17 | routes => [ 18 | {"/", fun my_controller:main/1, #{methods => [get]}} 19 | ] 20 | }]. 21 | ``` 22 | 23 | This will create a path for `/admin` which, when a user enters will call `my_controller:main/1`. The `_Environment` variable that is consumed by the `routes` function will have the value that `nova:get_environment/0` returns. The *environment* variable is an important thing cause it enables the devlopers to define different routes for different environments. To change the running environment edit the `sys.config` file to include an `{environment, Env}` tuple under the `nova` application where `Env` can be any erlang-term. 24 | 25 | ### The routing object 26 | 27 | The routing object consists of three or four fields. 28 | 29 | ### HTTP(S) Routing ### 30 | 31 | ``` 32 | {Route :: list(), ControllerCallback :: function(), Options :: map()} 33 | ``` 34 | 35 | As you saw in the initial example in the [Basic components](#basic-components) section, we defined a route for the root path `/`. 36 | 37 | ### Websocket routing ### 38 | 39 | ``` 40 | {Route :: list(), Controller :: atom(), Options :: map()} 41 | ``` 42 | 43 | > #### Important {: .tip} 44 | > 45 | > One needs to define `protocol => ws` in the options-map in order to enable websocket communications. 46 | 47 | 48 | ### Static files 49 | 50 | One can also define static files to be served by nova. This is done by adding a tuple to the route entries. The value of this tuple should be of size 2 or 3 where the first element is the url and the second element is the path to the file on the filesystem related from the apps `priv` directory. An additional third element can be added to the tuple to define options for this particular static file or directory. 51 | 52 | *Note*! The notation `[...]` can be used as a wildcard (Zero or many occurences) in the url-section. 53 | Valid options is; 54 | 55 | - `mimetype` - which mimetype the file should be served as. If not defined nova will try to guess the mimetype based on the file extension. 56 | - `index_files` - a list of filenames that can be used as an index. This is relevant if a directory is served. 57 | - `list_dir` - Set to true if allowing the requester to list the content of a directory (if such is served) 58 | 59 | Example: 60 | ```erlang 61 | {"/my/static/directory/[...]", "assets/a-local-dir", #{list_dir => true}}, 62 | {"/with-index/[...]", "assets/another-dir", #{index_files => ["index.html"]}} 63 | ``` 64 | 65 | ## How to create routes 66 | 67 | A route consists of three different components: 68 | 69 | * `Path` - This is the actual path to the endpoint. Eg `"/admin"` 70 | * `Method` - What method you want to support for this endpoint (get, post, update, delete). If you want to support all methods you can use the `'_'`-atom. 71 | * `ControllerCallback` - What erlang callback should be called on when the path gets called. This is defined as a function reference, eg `fun my_controller:main/1`. 72 | 73 | ## Using prefix 74 | 75 | You can group paths where you prefix them with a path. This is especially useful when having several different nova applications running. A very common example would be if you had an administration interface. Then the prefix would be `"/admin"`. Another example is versioned APIs. 76 | 77 | Prefix is defined at top level of a route-entry which can be seen in the [Basic components](#basic-components) section of this chapter. 78 | 79 | ## Secure routing 80 | 81 | When building web applications, ensuring the security of your routing layer is critical. Nova Framework’s routing system is designed with flexibility and security in mind, but developers must implement best practices to protect their applications from common vulnerabilities. This section outlines key considerations and features of Nova's routing system to help you achieve secure routing. 82 | 83 | ### Invoking security functions for a set of endpoints 84 | 85 | You can define a security function that will be called before the actual controller is called. This is useful if you want to check if the user is allowed to access the endpoint. The security function should return a boolean value (Or a special one - more about that later in this section). If the function returns `false` the request will be stopped and a `401` status code will be returned. 86 | 87 | The following code will invoke `security_controller:do_security/1` before calling the actual controller to do the security check. 88 | 89 | ```erlang 90 | -module(my_example_app_router). 91 | -behaviour(nova_router). 92 | 93 | -export([routes/1]). 94 | 95 | routes(_Environment) -> 96 | [#{prefix => "/admin", 97 | security => fun security_controller:do_security/1, 98 | routes => [ 99 | {"/", fun my_controller:main/1, #{methods => [get]}} 100 | ] 101 | }]. 102 | ``` 103 | 104 | ### The security-function callback 105 | 106 | The most simple way to implement a security function is to return a boolean value. If the function returns `false` the request will be stopped and a `401` status code will be returned. There's also an additional return value that can be used to store data that will be available to the controller. This is particularly useful if you want to pass data like user information, roles, etc. to the controller. 107 | 108 | #### Return values for the secure function 109 | 110 | | **Return value** | **Description** | 111 | | ---------------- | --------------- | 112 | | `true` | The request will continue to the controller. | 113 | | `{true, Data :: term()}` | The request will continue to the controller and the data will be available in the `Req` object under the `auth_data` key. | 114 | | `{redirect, Url :: binary()}` | The request will be redirected to the specified URL with HTTP status 302. | 115 | | `false` | The request will be stopped and a `401` status code will be returned. | 116 | | `{false, Headers :: map()}` | Same as above but also adds additional headers to the response. | 117 | | `{false, StatusCode :: integer(), Headers :: map()}` | Same as above but uses a custom status code. | 118 | | `{false, StatusCode :: integer(), Headers :: map(), Body :: iodata()}` | Same as above but also adds a custom body to the response. | 119 | 120 | *Note* If `false` is returned in any form the request will be stopped from executing the controller. 121 | 122 | 123 | ```erlang 124 | -module(security_controller). 125 | -export([do_security/1]). 126 | 127 | do_security(Req) -> 128 | case get_user(Req) of 129 | {ok, User} -> 130 | {true, #{user => User}}; 131 | _ -> 132 | false 133 | end. 134 | 135 | get_user(Req) -> 136 | ## Do some validatation and return the user 137 | {ok, #{name => "John Doe", role => "Admin"}}. 138 | ``` 139 | 140 | Once the security function has been called, the data will be available in the `Req` object under the `auth_data` key. 141 | 142 | ```erlang 143 | -module(my_controller). 144 | -export([main/1]). 145 | 146 | main(Req = #{auth_data := User}) -> 147 | io:format("User: ~p~n", [User]), 148 | {ok, Req, <<"Hello world!">>}. 149 | ``` 150 | 151 | ## Using plugins local to a set of endpoints 152 | 153 | It's possible to configure a small set of endpoints with a specific plugin. This is done by adding a `plugins` key to the route entry. The value of this key should be a list of plugins to use for this route entry. 154 | 155 | ```erlang 156 | #{prefix => "/admin", 157 | plugins => [ 158 | {pre_request, nova_json_schemas, #{render_errors => true}} 159 | ], 160 | routes => [ 161 | {"/", fun my_controller:main/1, #{methods => [get]}} 162 | ] 163 | } 164 | ``` 165 | 166 | In the example above we have enabled the *pre-request*-plugin `nova_json_schemas` for all routes under the `/admin` prefix. This will cause all requests to be validated against the JSON schema defined in the `nova_json_schemas` plugin. 167 | You can also include *post-request*-plugins in the same way. 168 | 169 | 170 | ## Adding routes programatically 171 | 172 | You can also add routes programatically by calling `nova_router:add_route/2`. This is useful if you want to add routes dynamically. The spec for it is: 173 | 174 | ```erlang 175 | %% nova_router:add_route/2 specification 176 | -spec add_route(App :: atom(), Routes :: map() | [map()]) -> ok. 177 | ``` 178 | 179 | First argument is the application you want to add the route to. The second argument is the route or a list of routes you want to add - it uses the same structure as in the regular routers. 180 | 181 | ```erlang 182 | nova_router:add_route(my_app, #{prefix => "/admin", routes => [{"/", fun my_controller:main/1, #{methods => [get]}}]}). 183 | ``` 184 | 185 | This will add the routes defined in the second argument to the `my_app` application. 186 | 187 | **Note**: If a route already exists it will be overwritten. 188 | -------------------------------------------------------------------------------- /guides/sessions.md: -------------------------------------------------------------------------------- 1 | # Sessions 2 | 3 | Sessions in Nova are handled by the `nova_session` module. This module is responsible for setting and getting session-values, as well as managing the session data. This means that you can store data in sessions without having to worry about session ids or anything like that - it's all handled for you. 4 | 5 | ## API 6 | 7 | ### `nova_session:set/3` 8 | 9 | ```erlang 10 | -spec set(Req :: cowboy_req:req(), Key :: binary(), Value :: binary()) -> 11 | ok | {error, Reason :: atom()} | no_return(). 12 | ``` 13 | 14 | This function is used to set a session value. It takes two arguments: the request object, the name of the session-key and the value. 15 | 16 | ```erlang 17 | nova_session:set(Req, "name", "John Doe"). 18 | ``` 19 | 20 | ### `nova_session:get/2` 21 | 22 | ```erlang 23 | -spec get(Req :: cowboy_req:req(), Key :: binary()) -> 24 | {ok, Value :: binary()} | {error, Reason :: atom()} | no_return(). 25 | ``` 26 | 27 | This function is used to get a session value. It takes two arguments: the request object and the name of the session-key. 28 | 29 | ```erlang 30 | nova_session:get(Req, "name"). 31 | ``` 32 | 33 | ### `nova_session:delete/2` 34 | 35 | ```erlang 36 | -spec delete(Req :: cowboy_req:req(), Key :: binary()) -> {ok, Req :: cowboy_req:req()} | 37 | {error, Reason :: atom()} | no_return(). 38 | ``` 39 | 40 | This function is used to delete a value. It takes two arguments: the request object and the name of the value. 41 | 42 | ```erlang 43 | nova_session:delete(Req, "name"). 44 | ``` 45 | 46 | ### `nova_session:delete/1` 47 | 48 | ```erlang 49 | -spec delete(Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()} | 50 | {error, Reason :: atom()} | no_return(). 51 | ``` 52 | 53 | This function is used to delete all values. It takes one argument: the request object. 54 | 55 | ```erlang 56 | nova_session:delete(Req). 57 | ``` 58 | 59 | ## Enabling Sessionss 60 | 61 | Sessions are enabled by default in Nova, but if you need to disable them, you can do so by setting the `use_sessions` configuration option to `false`. This will disable sessions for the entire site. 62 | It's also possible to set the backend for the session data. By default, the session data is stored in an *ETS* table in the Erlang VM but it's quite easy to implement a custom backend. 63 | 64 | ## Backend 65 | 66 | You can implement your own backend by creating a module that implements the `nova_session` behaviour. This behaviour has four functions that need to be implemented: `get/2`, `set/3`, `delete/1` and `delete/2`. 67 | 68 | ```erlang 69 | -module(my_session_backend). 70 | -behaviour(nova_session). 71 | 72 | -export([get_value/2, set_value/3, delete_value/1, delete_value/2]). 73 | 74 | get_value(Req, _Key) -> 75 | %% Your code here, 76 | {ok, Req}. 77 | 78 | set_value(Req, _Key, ) -> 79 | %% Your code here, 80 | {ok, Req}. 81 | 82 | delete_value(Req) -> 83 | %% Your code here, 84 | {ok, Req}. 85 | 86 | delete_value(Req, _Key) -> 87 | %% Your code here, 88 | {ok, Req}. 89 | ``` 90 | 91 | This module can then be set as the backend in the `sys.config` file under `nova`. 92 | 93 | ```erlang 94 | {session_manager, my_session_backend}. 95 | ``` 96 | -------------------------------------------------------------------------------- /guides/views.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | The views in Nova are powered by [erlydtl](https://github.com/erlydtl/erlydtl). They use the Django template language and compile down to Erlang. 4 | 5 | Please read the [erlydtl wiki](https://github.com/erlydtl/erlydtl/wiki) for more information regarding the templates. 6 | -------------------------------------------------------------------------------- /guides/watchers.md: -------------------------------------------------------------------------------- 1 | # Watchers 2 | 3 | Watchers is a concept that we directly ported from [Phoenix](https://www.phoenixframework.org/) and it enables the user to have running processes alongside the server. 4 | It read the configuration from the `nova` application under `watchers`-key. 5 | 6 | ```erlang 7 | {nova, [ 8 | {watchers, [ 9 | {my_application, "npm", ["run", "watch"], #{workdir => "priv/my_js_application"}} 10 | ]} 11 | ]} 12 | ``` 13 | 14 | The above example show how we invoke the `npm run watch` command in the `priv/my_js_application` directory. This will be a long-lived process and output will be forwarded to the erlang-console. Output from watchers will not be logged to log-files since it uses the `io:format/2` command to print messages. 15 | -------------------------------------------------------------------------------- /include/nova.hrl: -------------------------------------------------------------------------------- 1 | -define(LOG_DEPRECATED(SinceVersion, Msg), logger:warning("Deprecation warning. Since version: ~s, Message: ~s~nRead more about deprecations on https://github.com/novaframework/nova/blob/master/guides/deprecations.md", [SinceVersion, Msg])). 2 | -------------------------------------------------------------------------------- /include/nova_pubsub.hrl: -------------------------------------------------------------------------------- 1 | -record(nova_pubsub, { 2 | channel :: atom(), 3 | sender :: pid(), 4 | topic :: list() | binary(), 5 | payload :: any() 6 | }). 7 | -------------------------------------------------------------------------------- /include/nova_router.hrl: -------------------------------------------------------------------------------- 1 | %% Nova Routing value 2 | -record(nova_handler_value, { 3 | app :: atom(), 4 | module :: atom(), 5 | function :: atom(), 6 | callback :: function() | undefined, 7 | plugins = [] :: list(), 8 | secure = false :: false | {Mod :: atom(), Fun :: atom()}, 9 | extra_state :: any() 10 | }). 11 | 12 | -record(cowboy_handler_value, { 13 | app :: atom(), 14 | handler :: atom(), 15 | arguments :: any(), 16 | plugins = [] :: list(), 17 | secure = false :: false | {Mod :: atom(), Fun :: atom()} 18 | }). 19 | -------------------------------------------------------------------------------- /priv/static/nova.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/novaframework/nova/809d1b3c3c7a0a3466bdbdddee937b76c2b4912f/priv/static/nova.png -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*- 2 | 3 | {erl_opts, [debug_info]}. 4 | {src_dirs, ["src", "src/controllers"]}. 5 | {erlydtl_opts, [{doc_root, "src/views"}, 6 | {recursive, true}, 7 | {libraries, [ 8 | {nova_erlydtl_inventory, nova_erlydtl_inventory} 9 | ]}, 10 | {default_libraries, [nova_erlydtl_inventory]} 11 | ]}. 12 | 13 | {deps, [ 14 | {cowboy, "2.13.0"}, 15 | {erlydtl, "0.14.0"}, 16 | {jhn_stdlib, "5.3.3"}, 17 | {routing_tree, "1.0.9"}, 18 | {thoas, "1.2.1"} 19 | ]}. 20 | 21 | {profiles, [ 22 | {prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]} 23 | ]}. 24 | 25 | {dialyzer, [ 26 | {warnings, [ 27 | unknown 28 | ]}, 29 | {plt_apps, all_deps}, 30 | {plt_extra_apps, [jhn_stdlib, 31 | edoc, 32 | xmerl, 33 | cowboy, 34 | erlydtl, 35 | cowlib, 36 | routing_tree]} 37 | ]}. 38 | 39 | {xref_checks,[ 40 | undefined_function_calls, 41 | undefined_functions, 42 | locals_not_used, 43 | deprecated_function_calls, 44 | deprecated_functions 45 | ]}. 46 | 47 | 48 | {plugins, [rebar3_ex_doc, 49 | {rebar3_erlydtl_plugin, ".*", 50 | {git, "https://github.com/tsloughter/rebar3_erlydtl_plugin.git", {branch, "master"}}} 51 | ]}. 52 | 53 | 54 | {provider_hooks, [ 55 | {pre, [{compile, {erlydtl, compile}}]} 56 | ]}. 57 | 58 | {ex_doc, [{proglang, erlang}, 59 | {main, <<"nova">>}, 60 | {assets, #{<<"guides/assets">> => <<"guides/assets">>}}, 61 | {extras, [<<"guides/quick-start.md">>, 62 | <<"guides/configuration.md">>, 63 | <<"guides/routing.md">>, 64 | <<"guides/controllers.md">>, 65 | <<"guides/views.md">>, 66 | <<"guides/handlers.md">>, 67 | <<"guides/plugins.md">>, 68 | <<"guides/pubsub.md">>, 69 | <<"guides/building-releases.md">>, 70 | <<"guides/books-and-links.md">>, 71 | <<"guides/rebar3_nova.md">>]}, 72 | {source_url, <<"https://github.com/novaframework/nova">>}, 73 | {homepage_url, <<"https://novaframework.org">>}, 74 | {skip_undefined_reference_warnings_on, [<<"guides/configuration.md">>, 75 | <<"guides/controllers.md">>, 76 | <<"guides/routing.md">>]} 77 | ] 78 | }. 79 | 80 | {hex, [ 81 | {doc, #{provider => ex_doc}} 82 | ]}. 83 | 84 | {overrides, [{override, cowboy, [{deps, [{cowlib, "< 3.0.0"}, {ranch, "< 3.0.0"}]}]}]}. -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.2.0", 2 | [{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},0}, 3 | {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},1}, 4 | {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0}, 5 | {<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.3.3">>},0}, 6 | {<<"ranch">>,{pkg,<<"ranch">>,<<"2.1.0">>},1}, 7 | {<<"routing_tree">>,{pkg,<<"routing_tree">>,<<"1.0.9">>},0}, 8 | {<<"thoas">>,{pkg,<<"thoas">>,<<"1.2.1">>},0}]}. 9 | [ 10 | {pkg_hash,[ 11 | {<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>}, 12 | {<<"cowlib">>, <<"DB8F7505D8332D98EF50A3EF34B34C1AFDDEC7506E4EE4DD4A3A266285D282CA">>}, 13 | {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>}, 14 | {<<"jhn_stdlib">>, <<"3E50C560334A85EE5B6C645D2E1BCD35E7BE667E5FCB62AA364F00737C2ADC61">>}, 15 | {<<"ranch">>, <<"2261F9ED9574DCFCC444106B9F6DA155E6E540B2F82BA3D42B339B93673B72A3">>}, 16 | {<<"routing_tree">>, <<"F7B95CF21CAEF1F184948C547780BAB4EC2B2CB0CD75B8CB2BE49F86F0DFD523">>}, 17 | {<<"thoas">>, <<"19A25F31177A17E74004D4840F66D791D4298C5738790FA2CC73731EB911F195">>}]}, 18 | {pkg_hash_ext,[ 19 | {<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>}, 20 | {<<"cowlib">>, <<"E1E1284DC3FC030A64B1AD0D8382AE7E99DA46C3246B815318A4B848873800A4">>}, 21 | {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>}, 22 | {<<"jhn_stdlib">>, <<"2CB184C505397B62A842AB3DE13F21B83ADF62364BD35A572191629E30E0258E">>}, 23 | {<<"ranch">>, <<"244EE3FA2A6175270D8E1FC59024FD9DBC76294A321057DE8F803B1479E76916">>}, 24 | {<<"routing_tree">>, <<"FB9A05EA0F3A8B77CC39CED3952B98391C617BA448AF63DDD5908F8557D8C029">>}, 25 | {<<"thoas">>, <<"E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A">>}]} 26 | ]. 27 | -------------------------------------------------------------------------------- /src/controllers/nova_error_controller.erl: -------------------------------------------------------------------------------- 1 | -module(nova_error_controller). 2 | -export([ 3 | not_found/1, 4 | server_error/1, 5 | status_code/1 6 | ]). 7 | 8 | 9 | status_code(Req) -> 10 | {status, maps:get(resp_status_code, Req, 200)}. 11 | 12 | not_found(Req) -> 13 | %% Check the accept-headers 14 | Accept = cowboy_req:header(<<"accept">>, Req), 15 | AcceptList = case Accept of 16 | undefined -> 17 | [<<"application/json">>]; 18 | _ -> 19 | binary:split(Accept, <<",">>, [global]) 20 | end, 21 | 22 | case {lists:member(<<"application/json">>, AcceptList), 23 | lists:member(<<"text/html">>, AcceptList)} of 24 | {true, _} -> 25 | %% Render a json response 26 | JsonLib = nova:get_env(json_lib, thoas), 27 | Json = erlang:apply(JsonLib, encode, [#{message => "Resource not found"}]), 28 | {status, 404, #{<<"content-type">> => <<"application/json">>}, Json}; 29 | {_, true} -> 30 | %% Just assume HTML 31 | Variables = #{status => "Could not find the page you were looking for", 32 | title => "404 Not found", 33 | message => "We could not find the page you were looking for"}, 34 | {ok, Body} = nova_error_dtl:render(Variables), 35 | {status, 404, #{<<"content-type">> => <<"text/html">>}, Body}; 36 | _ -> 37 | {status, 404, #{<<"content-type">> => <<"text/html">>}, <<>>} 38 | end. 39 | 40 | server_error(#{crash_info := #{status_code := StatusCode} = CrashInfo} = Req) -> 41 | Variables = #{status => maps:get(status, CrashInfo, undefined), 42 | title => maps:get(title, CrashInfo, undefined), 43 | message => maps:get(message, CrashInfo, undefined), 44 | extra_msg => maps:get(extra_msg, CrashInfo, undefined), 45 | stacktrace => format_stacktrace(maps:get(stacktrace, CrashInfo, []))}, 46 | case application:get_env(nova, render_error_pages, true) of 47 | true -> 48 | case cowboy_req:header(<<"accept">>, Req) of 49 | <<"application/json">> -> 50 | JsonLib = nova:get_env(json_lib, thoas), 51 | Json = erlang:apply(JsonLib, encode, [Variables]), 52 | {status, StatusCode, #{<<"content-type">> => <<"application/json">>}, Json}; 53 | <<"text/html">> -> 54 | {ok, Body} = nova_error_dtl:render(Variables), 55 | {status, StatusCode, #{<<"content-type">> => <<"text/html">>}, Body}; 56 | _ -> 57 | {status, StatusCode, #{<<"content-type">> => <<"text/html">>}, <<>>} 58 | end; 59 | _ -> 60 | {status, StatusCode} 61 | end; 62 | server_error(#{crash_info := #{class := Class, reason := Reason}} = Req) -> 63 | Stacktrace = maps:get(stacktrace, Req, []), 64 | Variables = #{status => "Internal Server Error", 65 | title => "500 Internal Server Error", 66 | message => "Something internal crashed. Please take a look!", 67 | extra_msg => io_lib:format("Class: ~p
Reason: ~p", [Class, Reason]), 68 | stacktrace => format_stacktrace(Stacktrace)}, 69 | 70 | case nova:get_environment() of 71 | dev -> 72 | %% We do show a proper error response 73 | case cowboy_req:header(<<"accept">>, Req) of 74 | <<"application/json">> -> 75 | JsonLib = nova:get_env(json_lib, thoas), 76 | Json = erlang:apply(JsonLib, encode, [Variables]), 77 | {status, 500, #{<<"content-type">> => <<"application/json">>}, Json}; 78 | <<"text/html">> -> 79 | {ok, Body} = nova_error_dtl:render(Variables), 80 | {status, 500, #{<<"content-type">> => <<"text/html">>}, Body}; 81 | _ -> 82 | {status, 500, #{<<"content-type">> => <<"text/html">>}, <<>>} 83 | end; 84 | _ -> 85 | {status, 500} 86 | end. 87 | 88 | 89 | format_stacktrace(not_enabled) -> 90 | logger:warning("Stacktrace disabled. If you want to enable stacktraces call nova:stracktrace(true) or update your sys.config - Read more in the docs"), 91 | []; 92 | format_stacktrace([]) -> []; 93 | format_stacktrace([{Mod, Func, Arity, TraceOpts}|Tl]) -> 94 | File = case proplists:get_value(file, TraceOpts) of 95 | undefined -> undefined; 96 | F -> list_to_binary(F) 97 | end, 98 | Line = proplists:get_value(line, TraceOpts), 99 | Formated = #{module => erlang:atom_to_binary(Mod, utf8), 100 | function => erlang:atom_to_binary(Func, utf8), 101 | arity => format_arity(Arity, []), 102 | file => File, 103 | line => Line}, 104 | [Formated|format_stacktrace(Tl)]; 105 | format_stacktrace([Hd|Tl]) -> 106 | logger:warning("Could not format stacktrace line: ~p", [Hd]), 107 | format_stacktrace(Tl). 108 | 109 | 110 | 111 | format_arity(Arity) when is_pid(Arity) -> list_to_binary(pid_to_list(Arity)); 112 | format_arity(Arity) when is_function(Arity) -> <<"fun">>; 113 | format_arity(Arity) -> Arity. 114 | 115 | format_arity([], Acc) -> 116 | logger:warning("Acc: ~p~n", [Acc]), 117 | Acc; 118 | format_arity([Head, Tail], Acc) -> 119 | Formated = format_arity(Head), 120 | format_arity(Tail, [Formated | Acc]); 121 | format_arity(Arity, _) when is_function(Arity)-> 122 | <<"fun">>; 123 | format_arity(Arity, _) -> 124 | Arity. 125 | -------------------------------------------------------------------------------- /src/controllers/nova_file_controller.erl: -------------------------------------------------------------------------------- 1 | -module(nova_file_controller). 2 | -export([ 3 | get_file/1, 4 | get_dir/1 5 | ]). 6 | 7 | -include_lib("kernel/include/file.hrl"). 8 | 9 | get_file(#{extra_state := #{static := File, options := Options}, headers := Headers}) -> 10 | Filepath = get_filepath(File), 11 | MimeType = 12 | case maps:get(mimetype, Options, undefined) of 13 | undefined -> 14 | {T, V, _} = cow_mimetypes:web(erlang:list_to_binary(Filepath)), 15 | <>; 16 | MType -> 17 | MType 18 | end, 19 | 20 | %% Fetch file size 21 | #{size := Size} = file_info("", Filepath), 22 | 23 | %% Check if Range header is present 24 | case maps:get(<<"range">>, Headers, undefined) of 25 | undefined -> 26 | %% No range header, return full file 27 | {sendfile, 200, #{}, {0, Size, Filepath}, MimeType}; 28 | 29 | <<"bytes=", RangeSpec/binary>> -> 30 | %% Handle Range Request 31 | case parse_range(RangeSpec, Size) of 32 | {ok, {Start, End}} when Start < Size, End < Size, Start =< End -> 33 | Length = End - Start + 1, 34 | RespHeaders = #{<<"content-range">> => <<"bytes ", (integer_to_binary(Start))/binary, "-", 35 | (integer_to_binary(End))/binary, "/", 36 | (integer_to_binary(Size))/binary>>, 37 | <<"content-length">> => integer_to_binary(Length)}, 38 | {sendfile, 206, RespHeaders, {Start, Length, Filepath}, MimeType}; 39 | _ -> 40 | %% Invalid range request 41 | {status, 416, #{<<"content-range">> => <<"bytes */", (integer_to_binary(Size))/binary>>}} 42 | end 43 | end; 44 | 45 | get_file(_Req) -> 46 | {status, 404}. 47 | 48 | get_dir(#{extra_state := #{pathinfo := Pathinfo, static := Dir, options := Options}} = Req) -> 49 | %% This case will be invoked if a directory was set with wildcard - pathinfo will then 50 | %% contain the segments of the wildcard value 51 | Filepath = get_filepath(Dir), 52 | Filepath0 = lists:foldl(fun(F, Acc) -> filename:join(Acc, binary_to_list(F)) end, Filepath, Pathinfo), 53 | case filelib:is_dir(Filepath0) of 54 | false -> 55 | %% Check if it's a file 56 | case filelib:is_file(Filepath0) of 57 | true -> 58 | %% It's a file 59 | get_file(Req#{extra_state => #{static => {file, Filepath0}, options => Options}}); 60 | false -> 61 | {status, 404} 62 | end; 63 | true -> 64 | get_dir(Req#{extra_state => #{static => {dir, Filepath0}, options => Options}}) 65 | end; 66 | get_dir(#{path := Path, extra_state := #{static := Dir, options := Options}}) -> 67 | Filepath = get_filepath(Dir), 68 | {ok, Files} = file:list_dir(Filepath), 69 | case get_index_file(Files, maps:get(index_files, Options, ["index.html"])) of 70 | {ok, IndexFile} -> 71 | get_file(#{extra_state => #{static => {file, filename:join(Filepath, IndexFile)}, options => Options}}); 72 | false -> 73 | case maps:get(list_dir, Options, false) of 74 | false -> 75 | %% We will not show the directory listing 76 | {status, 403}; 77 | true -> 78 | {ok, Files} = file:list_dir(Filepath), 79 | FileInfos = [file_info(Filepath, F) || F <- Files], 80 | ParentDir = case re:replace(Path , "/[^/]+/?$", "") of 81 | [[]] -> undefined; 82 | Parent -> Parent 83 | end, 84 | {ok, #{date => calendar:local_time(), parent_dir => ParentDir, path => Path, files => FileInfos}} 85 | end 86 | end; 87 | get_dir(_Req) -> 88 | {status, 404}. 89 | 90 | 91 | %%%%%%%%%%%%%%%%%%%%%%%% 92 | %% Internal functions %% 93 | %%%%%%%%%%%%%%%%%%%%%%%% 94 | 95 | parse_range(RangeSpec, FileSize) -> 96 | %% Parse the "bytes=" range header value 97 | case binary:split(RangeSpec, <<"-">>, [global]) of 98 | [StartBin, EndBin] when EndBin =/= <<>> -> 99 | case {binary_to_integer(StartBin), binary_to_integer(EndBin)} of 100 | {Start, End} when Start >= 0, End >= Start, End < FileSize -> 101 | {ok, {Start, End}}; 102 | _ -> 103 | error 104 | end; 105 | [StartBin] when StartBin =/= <<>> -> 106 | case binary_to_integer(StartBin) of 107 | Start when Start >= 0, Start < FileSize -> 108 | {ok, {Start, FileSize - 1}}; 109 | _ -> 110 | error 111 | end; 112 | _ -> 113 | error 114 | end. 115 | 116 | get_index_file([], _) -> false; 117 | get_index_file([File|Tl], IndexFiles) -> 118 | case lists:member(File, IndexFiles) of 119 | true -> 120 | {ok, File}; 121 | false -> 122 | get_index_file(Tl, IndexFiles) 123 | end. 124 | 125 | 126 | get_filepath({file, LocalFile}) -> 127 | LocalFile; 128 | get_filepath({priv_file, App, PrivFile}) -> 129 | filename:join(code:priv_dir(App), PrivFile); 130 | get_filepath({dir, LocalPath}) -> 131 | LocalPath; 132 | get_filepath({priv_dir, App, LocalPath}) -> 133 | filename:join(code:priv_dir(App), LocalPath). 134 | 135 | 136 | file_info(Filepath, Filename) -> 137 | case file:read_file_info(filename:join(Filepath, Filename)) of 138 | {ok, #file_info{type = Type, size = Size, mtime = LastModified}} -> 139 | #{type => Type, size => Size, 140 | last_modified => LastModified, filename => Filename}; 141 | _ -> 142 | undefined 143 | end. 144 | -------------------------------------------------------------------------------- /src/nova.app.src: -------------------------------------------------------------------------------- 1 | {application,nova, 2 | [{description,"Nova is a web application framework"}, 3 | {vsn, git}, 4 | {registered,[]}, 5 | {mod,{nova_app,[]}}, 6 | {included_applications,[]}, 7 | {applications,[ 8 | kernel, 9 | stdlib, 10 | sasl, 11 | ranch, 12 | cowboy, 13 | compiler, 14 | erlydtl, 15 | jhn_stdlib, 16 | routing_tree, 17 | thoas 18 | ]}, 19 | {env,[]}, 20 | {modules,[nova]}, 21 | {doc, "doc"}, 22 | {licenses,["Apache 2.0"]}, 23 | {links,[{"GitHub","https://github.com/novaframework/nova"}]}]}. 24 | -------------------------------------------------------------------------------- /src/nova.erl: -------------------------------------------------------------------------------- 1 | %%% @author Niclas Axelsson 2 | %%% @doc 3 | %%% Interface module for nova 4 | %%% @end 5 | 6 | -module(nova). 7 | 8 | -export([ 9 | get_main_app/0, 10 | get_apps/0, 11 | get_environment/0, 12 | get_env/2, 13 | set_env/2, 14 | use_stacktrace/1 15 | ]). 16 | 17 | -type state() :: any(). 18 | -export_type([state/0]). 19 | 20 | %%-------------------------------------------------------------------- 21 | %% @doc 22 | %% Returns the name of the main bw-application (The one that started 23 | %% everything) 24 | %% 25 | %% @end 26 | %%-------------------------------------------------------------------- 27 | -spec get_main_app() -> {ok, Application :: atom()} | undefined. 28 | get_main_app() -> 29 | application:get_env(nova, bootstrap_application). 30 | 31 | 32 | %%-------------------------------------------------------------------- 33 | %% @doc 34 | %% Returns a proplist with all nova applications and their prefix. This 35 | %% function is useful when calulating dynamic routes. 36 | %% 37 | %% @end 38 | %%-------------------------------------------------------------------- 39 | -spec get_apps() -> [{App :: atom(), Prefix :: list()}]. 40 | get_apps() -> 41 | nova_router:compiled_apps(). 42 | 43 | %%-------------------------------------------------------------------- 44 | %% @doc 45 | %% Returns which environment nova is started in. This fetches the 46 | %% environment-variable in sys.config for Nova and returns the value found. 47 | %% Defaults to: dev 48 | %% 49 | %% @end 50 | %%-------------------------------------------------------------------- 51 | -spec get_environment() -> any(). 52 | get_environment() -> 53 | application:get_env(nova, environment, dev). 54 | 55 | %%-------------------------------------------------------------------- 56 | %% @doc 57 | %% Works as the regular application:get_env/3 but instead of giving 58 | %% a specific application we target the application that started 59 | %% nova. 60 | %% 61 | %% @end 62 | %%-------------------------------------------------------------------- 63 | -spec get_env(Parameter :: atom(), Default :: any()) -> term() | undefined. 64 | get_env(Parameter, Default) -> 65 | case get_main_app() of 66 | {ok, App} -> 67 | application:get_env(App, Parameter, Default); 68 | _ -> 69 | Default 70 | end. 71 | 72 | 73 | %%-------------------------------------------------------------------- 74 | %% @doc 75 | %% Sets an environment variable for the main application. 76 | %% 77 | %% @end 78 | %%-------------------------------------------------------------------- 79 | -spec set_env(Key :: atom(), Value :: any()) -> ok | {error, main_app_not_found}. 80 | set_env(Key, Value) -> 81 | case get_main_app() of 82 | {ok, App} -> 83 | application:set_env(App, Key, Value); 84 | _ -> 85 | {error, main_app_not_found} 86 | end. 87 | 88 | 89 | %%-------------------------------------------------------------------- 90 | %% @doc 91 | %% Enables or disables stacktraces. This is a global setting and 92 | %% affects all requests. If stacktraces are enabled, nova will 93 | %% try to print a stacktrace when an exception is thrown. 94 | %% @end 95 | %%-------------------------------------------------------------------- 96 | -spec use_stacktrace(Enable :: boolean()) -> ok. 97 | use_stacktrace(true) -> 98 | persistent_term:put(nova_use_stacktrace, true); 99 | use_stacktrace(_) -> 100 | persistent_term:erase(nova_use_stacktrace). 101 | -------------------------------------------------------------------------------- /src/nova_app.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %% @doc 3 | %% Nova application behaviour callback (Not used) 4 | %% @end 5 | %%%------------------------------------------------------------------- 6 | 7 | -module(nova_app). 8 | 9 | -behaviour(application). 10 | 11 | %% Application callbacks 12 | -export([start/2, stop/1]). 13 | 14 | %%==================================================================== 15 | %% API 16 | %%==================================================================== 17 | 18 | start(_StartType, _StartArgs) -> 19 | nova_sup:start_link(). 20 | 21 | %%-------------------------------------------------------------------- 22 | stop(_State) -> 23 | ok. 24 | 25 | %%==================================================================== 26 | %% Internal functions 27 | %%==================================================================== 28 | -------------------------------------------------------------------------------- /src/nova_basic_handler.erl: -------------------------------------------------------------------------------- 1 | -module(nova_basic_handler). 2 | -export([ 3 | handle_json/3, 4 | handle_ok/3, 5 | handle_view/3, 6 | handle_status/3, 7 | handle_redirect/3, 8 | handle_sendfile/3, 9 | handle_websocket/3, 10 | handle_ws/2 11 | ]). 12 | 13 | -include_lib("kernel/include/logger.hrl"). 14 | 15 | -type erlydtl_vars() :: map() | [{Key :: atom() | binary() | string(), Value :: any()}]. 16 | 17 | 18 | 19 | %%-------------------------------------------------------------------- 20 | %% @doc 21 | %% Handler for JSON. It can take one of three different return objects: 22 | %% 23 | %% {json, JSON :: map()} returns the JSON encoded to the user. 24 | %% If the operation was a POST the HTTP-status code will be 201, otherwise 25 | %% 200. 26 | %% 27 | %% {json, StatusCode :: integer(), Headers :: map(), JSON :: map()} Same 28 | %% operation as the above except you can set custom status code and custom 29 | %% headers. 30 | %% 31 | %% {json, StatusCode :: integer(), Headers :: map(), Req0 :: cowboy_req:req(), JSON :: map()} 32 | %% This is the same as the above but you can also return the request object. This is particularly 33 | %% useful if you want to set cookies or other headers that are not supported by the handler. 34 | %% @end 35 | %%-------------------------------------------------------------------- 36 | -spec handle_json({json, JSON :: map()} | {json, StatusCode :: integer(), Headers :: map(), JSON :: map()} | 37 | {json, StatusCode :: integer(), Headers :: map(), JSON :: map(), Req0 :: cowboy_req:req()}, 38 | Callback :: function(), Req :: cowboy_req:req()) -> {ok, State :: cowboy_req:req()}. 39 | handle_json({json, StatusCode, Headers, Req0, JSON}, Callback, _Req) -> 40 | handle_json({json, StatusCode, Headers, JSON}, Callback, Req0); 41 | handle_json({json, StatusCode, Headers, JSON}, _Callback, Req) -> 42 | JsonLib = nova:get_env(json_lib, thoas), 43 | EncodedJSON = JsonLib:encode(JSON), 44 | Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, Headers), 45 | Req0 = cowboy_req:set_resp_headers(Headers0, Req), 46 | Req1 = cowboy_req:set_resp_body(EncodedJSON, Req0), 47 | Req2 = Req1#{resp_status_code => StatusCode}, 48 | {ok, Req2}; 49 | handle_json({json, JSON}, Callback, Req = #{method := Method}) -> 50 | case Method of 51 | <<"POST">> -> 52 | handle_json({json, 201, #{}, JSON}, Callback, Req); 53 | _ -> 54 | handle_json({json, 200, #{}, JSON}, Callback, Req) 55 | end. 56 | 57 | 58 | 59 | %%-------------------------------------------------------------------- 60 | %% @doc 61 | %% Handler for regular views. This will render a template with given variables. 62 | %% If not another view is specified in options a view that corresponds to the controller will be 63 | %% rendered. The first element of the returned tuple could be either ok or view - they are 64 | %% identical in their functionality. 65 | %% 66 | %% 67 | %% -module(my_first_controller). 68 | %% -compile(export_all). 69 | %% 70 | %% my_function(_Req) -> 71 | %% {ok, []}. 72 | %% 73 | %% The example above will then render the view named 'app_main.dtl' 74 | %% 75 | %% The tuple can have three different forms: 76 | %% 77 | %% {ok, Variables} - This will render the default view with the given variables 78 | %% 79 | %% {ok, Variables, Options} - This will render the default view with the given variables and options 80 | %% Options can be specified as follows: 81 | %% 82 | %% - view - Specifies if another view should be rendered instead of default one 83 | %% 84 | %% - headers - Custom headers 85 | %% 86 | %% {ok, Variables, Options, Req0} - This will render the default view with the given variables and options 87 | %% but also takes a request object. This is useful if you want to set cookies or other headers that are not 88 | %% supported by the handler. 89 | %% @end 90 | %%-------------------------------------------------------------------- 91 | -spec handle_ok({ok, Variables :: erlydtl_vars()} | {ok, Variables :: erlydtl_vars(), Options :: map()}, 92 | Callback :: function(), Req :: cowboy_req:req()) -> {ok, cowboy_req:req()}. 93 | handle_ok({ok, Variables}, Callback, Req) -> 94 | %% Derive the view from module 95 | {module, Module} = erlang:fun_info(Callback, module), 96 | ViewNameAtom = get_view_name(Module), 97 | handle_view(ViewNameAtom, Variables, #{}, Req); 98 | handle_ok({ok, Variables, Options}, Callback, Req) -> 99 | {module, Module} = erlang:fun_info(Callback, module), 100 | View = 101 | case maps:get(view, Options, undefined) of 102 | undefined -> 103 | get_view_name(Module); 104 | CustomView when is_atom(CustomView) -> 105 | ViewName = atom_to_list(CustomView) ++ "_dtl", 106 | list_to_atom(ViewName); 107 | CustomView -> 108 | list_to_atom(CustomView ++ "_dtl") 109 | end, 110 | handle_view(View, Variables, Options, Req); 111 | handle_ok({ok, Variables, Options, Req0}, Callback, _Req) -> 112 | handle_ok({ok, Variables, Options}, Callback, Req0). 113 | 114 | 115 | 116 | %%-------------------------------------------------------------------- 117 | %% @doc 118 | %% Handler for regular views and uses the ok-handler. For more info see 119 | %% handle_ok/3. 120 | %% @end 121 | %%-------------------------------------------------------------------- 122 | handle_view({view, Variables}, Callback, Req) -> 123 | handle_ok({ok, Variables}, Callback, Req); 124 | handle_view({view, Variables, Options}, Callback, Req) -> 125 | handle_ok({ok, Variables, Options}, Callback, Req); 126 | handle_view({view, Variables, Options, Req0}, Callback, _Req) -> 127 | handle_ok({ok, Variables, Options}, Callback, Req0). 128 | 129 | %%-------------------------------------------------------------------- 130 | %% @doc 131 | %% Handler for returning http status codes. There's three different ways one can 132 | %% return status code. The most basic case is {status, Status} where Status is 133 | %% the code that should be returned. 134 | %% 135 | %% If there's a need for additional headers to be sent along with the http code one can specify 136 | %% a third argument that is a map with header-fields. 137 | %% 138 | %% One can also send in a body as a fourth argument in the tuple. It can either be a binary or 139 | %% a map. If it's a map it will be considered a JSON-structure and encoded. 140 | %% 141 | %% And if you want to modulate the request object you can send it in as the fifth argument. 142 | %% 143 | %% @end 144 | %%-------------------------------------------------------------------- 145 | -spec handle_status({status, StatusCode :: integer()} | 146 | {status, StatusCode :: integer(), ExtraHeaders :: map()} | 147 | {status, StatusCode :: integer(), ExtraHeaders :: map(), Body :: binary() | map()}, 148 | Callback :: function(), Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()}. 149 | handle_status({status, Status, ExtraHeaders, JSON, Req0}, Callback, _Req) -> 150 | handle_status({status, Status, ExtraHeaders, JSON}, Callback, Req0); 151 | handle_status({status, Status, ExtraHeaders, JSON}, _Callback, Req) when is_map(JSON) -> 152 | %% We do not need to render a status page since we just return a JSON structure 153 | JsonLib = nova:get_env(json_lib, thoas), 154 | Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders), 155 | Req0 = cowboy_req:set_resp_headers(Headers0, Req), 156 | Req1 = Req0#{resp_status_code => Status}, 157 | JSONStr = JsonLib:encode(JSON), 158 | Req2 = cowboy_req:set_resp_body(JSONStr, Req1), 159 | {ok, Req2}; 160 | handle_status({status, Status, ExtraHeaders, Body}, _Callback, Req) -> 161 | %% Body is a binary - just send it out 162 | Req0 = cowboy_req:set_resp_headers(ExtraHeaders, Req), 163 | Req1 = Req0#{resp_status_code => Status}, 164 | Req2 = cowboy_req:set_resp_body(Body, Req1), 165 | {ok, Req2}; 166 | handle_status({status, Status, ExtraHeaders}, _Callback, Req) -> 167 | Req0 = cowboy_req:set_resp_headers(ExtraHeaders, Req), 168 | Req1 = Req0#{resp_status_code => Status}, 169 | {ok, Req2, _Env} = nova_router:render_status_page(Status, #{}, Req1), 170 | {ok, Req2}; 171 | handle_status({status, Status}, Callback, State) when is_integer(Status) -> 172 | handle_status({status, Status, #{}}, Callback, State). 173 | 174 | 175 | %%-------------------------------------------------------------------- 176 | %% @doc 177 | %% Handles redirects. This will return a 302-status code with a location given 178 | %% by the user. Something like {redirect, "/login"} will send a 179 | %% 302 with location set to "/login" 180 | %% 181 | %% Optionally you can attach the request object as the third argument. This is useful 182 | %% if you want to set cookies or other headers that are not supported by the handler. 183 | %% @end 184 | %%----------------------------------------------------------------- 185 | -spec handle_redirect({redirect, Route :: list()|binary()}, Callback :: function(), 186 | Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()}. 187 | handle_redirect({redirect, Route, Req0}, Callback, _Req) -> 188 | handle_redirect({redirect, Route}, Callback, Req0); 189 | handle_redirect({redirect, Route}, Callback, Req) when is_list(Route) -> 190 | handle_redirect({redirect, list_to_binary(Route)}, Callback, Req); 191 | handle_redirect({redirect, Route}, _Callback, Req) -> 192 | Headers = #{<<"location">> => Route}, 193 | Req0 = cowboy_req:set_resp_headers(Headers, Req), 194 | Req1 = Req0#{resp_status_code => 302}, 195 | {ok, Req1}. 196 | 197 | %%-------------------------------------------------------------------- 198 | %% @doc 199 | %% Handles sendfile. 200 | %% 201 | %% The tuple have the following structure: 202 | %% {sendfile, StatusCode, Headers, {Offset, Length, Path}, Mime} 203 | %% 204 | %% - sendfile is the atom that tells the handler to send a file. 205 | %% 206 | %% - StatusCode is the HTTP status code that should be returned. 207 | %% 208 | %% - Headers is a map with headers that should be sent along with the file. 209 | %% 210 | %% - Offset is the offset in the file where the sending should start. 211 | %% 212 | %% - Length is the length of the file that should be sent. 213 | %% 214 | %% - Path is the path to the file that should be sent. 215 | %% 216 | %% - Mime is the mime-type of the file. 217 | %% 218 | %% 219 | %% Optionally a sixth element can be added to the tuple. This is the request object. This is useful 220 | %% if you want to set cookies or other headers that are not supported by the handler. 221 | %% @end 222 | %%----------------------------------------------------------------- 223 | -spec handle_sendfile({sendfile, StatusCode :: integer(), Headers :: map(), {Offset :: integer(), 224 | Length :: integer(), 225 | Path :: list()}, Mime :: binary()}, 226 | Callback :: function(), Req) -> {ok, Req} when Req :: cowboy_req:req(). 227 | handle_sendfile({sendfile, StatusCode, Headers, FileInfo, Mime, Req0}, Callback, _Req) -> 228 | handle_sendfile({sendfile, StatusCode, Headers, FileInfo, Mime}, Callback, Req0); 229 | handle_sendfile({sendfile, StatusCode, Headers, {Offset, Length, Path}, Mime}, _Callback, Req) -> 230 | Headers0 = maps:merge(#{<<"content-type">> => Mime}, Headers), 231 | Req0 = cowboy_req:set_resp_headers(Headers0, Req), 232 | Req1 = cowboy_req:set_resp_body({sendfile, Offset, Length, Path}, Req0), 233 | Req2 = Req1#{resp_status_code => StatusCode}, 234 | {ok, Req2}. 235 | 236 | 237 | %%-------------------------------------------------------------------- 238 | %% @doc 239 | %% Handles upgrading to websocket. This is a special handler in regards to 240 | %% arguments. The tuple-object for websocket only takes two arguments; What the initial state 241 | %% of the websocket handler should be and the request object. 242 | %% @end 243 | %%----------------------------------------------------------------- 244 | -spec handle_websocket({websocket, ControllerData :: any()}, Callback :: function(), Req :: cowboy_req:req()) -> 245 | {ok, Req :: cowboy_req:req()}. 246 | handle_websocket({websocket, ControllerData, Req0}, Callback, _Req) -> 247 | handle_websocket({websocket, ControllerData}, Callback, Req0); 248 | handle_websocket({websocket, ControllerData}, Callback, Req) -> 249 | {module, Module} = erlang:fun_info(Callback, module), 250 | case Module:init(ControllerData) of 251 | {ok, NewControllerData} -> 252 | {cowboy_websocket, Req#{controller_data => NewControllerData}, #{}}; 253 | Error -> 254 | ?LOG_ERROR(#{msg => <<"Handler returned unsupported result">>, handler => Module, return_obj => Error}), 255 | %% Render 500 256 | {ok, Req} 257 | end. 258 | 259 | 260 | %%-------------------------------------------------------------------- 261 | %% @doc 262 | %% Handles basic websocket operations. This is a special handler in regards to 263 | %% arguments. Handlers for websocket only takes two arguments; What the controller 264 | %% returned and the state. And the handler should return what cowboy expects. 265 | %% 266 | %% Example of a valid return value is {reply, Frame, State} 267 | %% @end 268 | %%----------------------------------------------------------------- 269 | handle_ws({reply, Frame, NewControllerData}, State = #{commands := Commands}) -> 270 | State#{controller_data => NewControllerData, 271 | commands => [Frame|Commands]}; 272 | handle_ws({reply, Frame, NewControllerData, hibernate}, State = #{commands := Commands}) -> 273 | State#{controller_data => NewControllerData, 274 | commands => [Frame|Commands], 275 | hibernate => true}; 276 | handle_ws({ok, NewControllerData}, State) -> 277 | State#{controller_data => NewControllerData}; 278 | handle_ws({ok, NewControllerData, hibernate}, State) -> 279 | State#{controller_data => NewControllerData, 280 | hibernate => true}; 281 | handle_ws({stop, NewControllerData}, State) -> 282 | {stop, State#{controller_data => NewControllerData}}; 283 | handle_ws(ok, State) -> 284 | State. 285 | 286 | 287 | %%%=================================================================== 288 | %%% Internal functions 289 | %%%=================================================================== 290 | 291 | handle_view(View, Variables, Options, Req) -> 292 | {ok, HTML} = render_dtl(View, Variables, []), 293 | Headers = 294 | case maps:get(headers, Options, undefined) of 295 | undefined -> 296 | #{<<"content-type">> => <<"text/html">>}; 297 | UserHeaders -> 298 | UserHeaders 299 | end, 300 | StatusCode = maps:get(status_code, Options, 200), 301 | Req0 = cowboy_req:set_resp_headers(Headers, Req), 302 | Req1 = cowboy_req:set_resp_body(HTML, Req0), 303 | Req2 = Req1#{resp_status_code => StatusCode}, 304 | {ok, Req2}. 305 | 306 | render_dtl(View, Variables, Options) -> 307 | case code:is_loaded(View) of 308 | false -> 309 | case code:load_file(View) of 310 | {error, Reason} -> 311 | %% Cast a warning since the module could not be found 312 | ?LOG_ERROR(#{msg => <<"Nova could not render template">>, template => View, reason => Reason}), 313 | throw({404, {template_not_found, View}}); 314 | _ -> 315 | View:render(Variables, Options) 316 | end; 317 | _ -> 318 | View:render(Variables, Options) 319 | end. 320 | 321 | 322 | get_view_name(Mod) when is_atom(Mod) -> 323 | StrName = get_view_name(erlang:atom_to_list(Mod)), 324 | erlang:list_to_atom(StrName); 325 | get_view_name([$_, $c, $o, $n, $t, $r, $o, $l, $l, $e, $r]) -> 326 | "_dtl"; 327 | get_view_name([H|T]) -> 328 | [H|get_view_name(T)]. 329 | -------------------------------------------------------------------------------- /src/nova_erlydtl_inventory.erl: -------------------------------------------------------------------------------- 1 | -module(nova_erlydtl_inventory). 2 | -behaviour(erlydtl_library). 3 | 4 | -include_lib("kernel/include/logger.hrl"). 5 | 6 | -export([ 7 | version/0, 8 | inventory/1, 9 | url/2 10 | ]). 11 | 12 | version() -> 1. 13 | 14 | inventory(filters) -> []; 15 | inventory(tags) -> [url]. 16 | 17 | url([Url|Variables], _Options) -> 18 | App = proplists:get_value(application, Variables), 19 | Apps = nova:get_apps(), 20 | case proplists:get_value(binary_to_atom(App, utf8), Apps) of 21 | undefined -> 22 | ?LOG_WARNING(#{msg => <<"Template could not find application">>, application => App}), 23 | <<"#">>; 24 | Prefix -> 25 | PrefixBin = list_to_binary(Prefix), 26 | << PrefixBin/binary, Url/binary >> 27 | end. 28 | -------------------------------------------------------------------------------- /src/nova_handler.erl: -------------------------------------------------------------------------------- 1 | -module(nova_handler). 2 | -behaviour(cowboy_middleware). 3 | 4 | %% Callbacks 5 | -export([ 6 | execute/2, 7 | terminate/3 8 | ]). 9 | 10 | -include_lib("kernel/include/logger.hrl"). 11 | -include("../include/nova_router.hrl"). 12 | 13 | -callback init(Req, any()) -> {ok | module(), Req, any()} 14 | | {module(), Req, any(), any()} 15 | when Req::cowboy_req:req(). 16 | 17 | -callback terminate(any(), map(), any()) -> ok. 18 | -optional_callbacks([terminate/3]). 19 | 20 | -spec execute(Req, Env) -> {ok, Req, Env} 21 | when Req::cowboy_req:req(), Env::cowboy_middleware:env(). 22 | execute(Req, Env = #{cowboy_handler := Handler, arguments := Arguments}) -> 23 | UseStacktrace = persistent_term:get(nova_use_stacktrace, false), 24 | try Handler:init(Req, Arguments) of 25 | {ok, Req2, _State} -> 26 | Result = terminate(normal, Req2, fun Handler:dummy/1), 27 | {ok, Req2, Env#{result => Result}}; 28 | {Mod, Req2, State} -> 29 | Mod:upgrade(Req2, Env, Handler, State); 30 | {Mod, Req2, State, Opts} -> 31 | Mod:upgrade(Req2, Env, Handler, State, Opts) 32 | catch 33 | Class:Reason:Stacktrace when UseStacktrace == true -> 34 | Payload = #{status_code => 404, 35 | stacktrace => Stacktrace, 36 | class => Class, 37 | reason => Reason}, 38 | ?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}), 39 | render_response(Req#{crash_info => Payload}, maps:remove(cowboy_handler, Env), 404); 40 | Class:Reason -> 41 | Payload = #{status_code => 404, 42 | class => Class, 43 | reason => Reason}, 44 | ?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class, reason => Reason}), 45 | render_response(Req#{crash_info => Payload}, maps:remove(cowboy_handler, Env), 404) 46 | end; 47 | execute(Req, Env = #{callback := Callback}) -> 48 | %% Ensure that the module exists and have the correct function exported 49 | UseStacktrace = persistent_term:get(nova_use_stacktrace, false), 50 | try RetObj = Callback(Req), 51 | call_handler(Callback, Req, RetObj, Env, false) 52 | of 53 | HandlerReturn -> 54 | HandlerReturn 55 | catch 56 | Class:{Status, Reason} when is_integer(Status) -> 57 | %% This makes it so that we don't need to fetch the stacktrace 58 | ?LOG_ERROR(#{msg => <<"Controller threw an exception">>, 59 | class => Class, 60 | reason => Reason}), 61 | render_response(Req#{crash_info => Reason}, Env, Status); 62 | Class:Reason:Stacktrace when UseStacktrace == true -> 63 | ?LOG_ERROR(#{msg => <<"Controller crashed">>, 64 | class => Class, 65 | reason => Reason, 66 | stacktrace => Stacktrace}), 67 | terminate(Reason, Req, Callback), 68 | %% Build the payload object 69 | Payload = #{status_code => 500, 70 | stacktrace => Stacktrace, 71 | class => Class, 72 | reason => Reason}, 73 | render_response(Req#{crash_info => Payload}, Env, 500); 74 | Class:Reason -> 75 | ?LOG_ERROR(#{msg => <<"Controller crashed">>, 76 | class => Class, 77 | reason => Reason}), 78 | terminate(Reason, Req, Callback), 79 | %% Build the payload object 80 | Payload = #{status_code => 500, 81 | class => Class, 82 | reason => Reason}, 83 | render_response(Req#{crash_info => Payload}, Env, 500) 84 | end. 85 | 86 | -spec terminate(any(), Req :: cowboy_req:req() | undefined, function()) -> ok. 87 | terminate(Reason, Req, Callback) -> 88 | {module, Module} = erlang:fun_info(Callback, module), 89 | case function_exported(Module, terminate, 3) of 90 | true -> 91 | Module:terminate(Reason, Req); 92 | false -> 93 | ok 94 | end. 95 | 96 | 97 | %%%%%%%%%%%%%%%%%%%%%%%% 98 | %% INTERNAL FUNCTIONS %% 99 | %%%%%%%%%%%%%%%%%%%%%%%% 100 | -spec execute_fallback(Callback :: function(), Req :: cowboy_req:req(), Response :: any(), Env :: map()) -> 101 | {ok, Req0 :: cowboy_req:req(), Env :: map()}. 102 | execute_fallback(Callback, Req, Response, Env) -> 103 | {module, Module} = erlang:fun_info(Callback, module), 104 | Attributes = Module:module_info(attributes), 105 | case proplists:get_value(fallback_controller, Attributes, undefined) of 106 | undefined -> 107 | %% No fallback - render a crash-page 108 | Payload = #{status_code => 500, 109 | status => <<"Problems with controller">>, 110 | stacktrace => [{nova_handler, execute_fallback, 4}], 111 | title => <<"Controller returned unsupported data">>, 112 | extra_msg => list_to_binary(io_lib:format("Controller returned unsupported data: ~p", 113 | [Response]))}, 114 | render_response(Req#{crash_info => Payload}, Env, 500); 115 | [FallbackModule] -> 116 | case function_exported(FallbackModule, resolve, 2) of 117 | true -> 118 | RetObj = FallbackModule:resolve(Req, Response), 119 | call_handler(fun FallbackModule:resolve/1, Req, RetObj, Env, true); 120 | _ -> 121 | Payload = #{status_code => 500, 122 | status => <<"Problems with fallback-controller">>, 123 | title => <<"Fallback controller does not have a valid resolve/2 function">>, 124 | extra_msg => list_to_binary(io_lib:format("Fallback controller ~s does not have " 125 | ++ "a valid resolve/2 function", 126 | [FallbackModule]))}, 127 | render_response(Req#{crash_info => Payload}, Env, 500) 128 | end 129 | end. 130 | 131 | -spec call_handler(Callback :: function(), Req :: cowboy_req:req(), 132 | RetObj :: any(), Env :: map(), IsFallbackController :: boolean()) -> 133 | {ok, Req0 :: cowboy_req:req(), Env :: map()}. 134 | call_handler(Callback, Req, RetObj, Env, IsFallbackController) -> 135 | case nova_handlers:get_handler(element(1, RetObj)) of 136 | {ok, HandlerCallback} -> 137 | {ok, Req0} = HandlerCallback(RetObj, Callback, Req), 138 | render_response(Req0, Env); 139 | {error, not_found} -> 140 | case IsFallbackController of 141 | true -> 142 | {module, Module} = erlang:fun_info(Callback, module), 143 | {name, Function} = erlang:fun_info(Callback, name), 144 | ?LOG_ERROR(#{msg => <<"Controller returned unsupported result">>, controller => Module, 145 | function => Function, return => RetObj}), 146 | Payload = #{status_code => 500, 147 | status => <<"Controller returned unsupported result">>, 148 | title => <<"Error in controller">>, 149 | extra_msg => list_to_binary(io_lib:format("Controller ~s:~s/1 returned a " ++ 150 | "non-valid result ~s", 151 | [Module, Function, RetObj]))}, 152 | render_response(Req#{crash_info => Payload}, Env, 500); 153 | _ -> 154 | execute_fallback(Callback, Req, RetObj, Env) 155 | end 156 | end. 157 | 158 | -spec render_response(Req :: cowboy_req:req(), Env :: map()) -> {ok, Req :: cowboy_req:req(), State :: map()}. 159 | render_response(Req = #{resp_status_code := StatusCode}, Env) -> 160 | Req0 = cowboy_req:reply(StatusCode, Req), 161 | {ok, Req0, Env}; 162 | render_response(Req, Env) -> 163 | Req0 = cowboy_req:reply(200, Req), 164 | {ok, Req0, Env}. 165 | 166 | 167 | render_response(Req, Env, StatusCode) -> 168 | case application:get_env(nova, render_error_pages, true) of 169 | true -> 170 | case nova_router:lookup_url(StatusCode) of 171 | {error, _} -> 172 | %% Render the internal view of nova 173 | {ok, Req0} = nova_basic_handler:handle_status({status, StatusCode}, fun erlang:date/0, Req), 174 | render_response(Req0, Env); 175 | {ok, _Bindings, #nova_handler_value{callback = Callback}} -> 176 | %% Recurse to the execute function to render error page 177 | execute(Req, Env#{app => nova, callback => Callback}) 178 | end; 179 | false -> 180 | {ok, Req0} = nova_basic_handler:handle_status({status, StatusCode}, fun(_) -> ok end, Req), 181 | render_response(Req0, Env) 182 | end. 183 | 184 | 185 | function_exported(Module, Function, Arity) -> 186 | Exports = Module:module_info(exports), 187 | case proplists:get_value(Function, Exports) of 188 | A when A == Arity -> 189 | true; 190 | _ -> 191 | false 192 | end. 193 | -------------------------------------------------------------------------------- /src/nova_handlers.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @copyright (C) 2020, Niclas Axelsson 4 | %%% @doc 5 | %%% This module is responsible for all the different return types a controller have. Nova is constructed 6 | %%% in such way that it's really easy to extend it by using handlers. A handler is basically a module consisting 7 | %%% of a function of arity 4. We will show an example of this. 8 | %%% 9 | %%% If you implement the following module: 10 | %%% 11 | %%% -module(my_handler). 12 | %%% -export([init/0, 13 | %%% handle_console]). 14 | %%% 15 | %%% init() -> 16 | %%% nova_handlers:register_handler(console, {my_handler, handle_console}). 17 | %%% 18 | %%% handle_console({console, Format, Args}, {Module, Function}, State) -> 19 | %%% io:format("~n=====================~n", []). 20 | %%% io:format("~p:~p was called.~n", []), 21 | %%% io:format("State: ~p~n", [State]), 22 | %%% io:format(Format, Args), 23 | %%% io:format("~n=====================~n", []), 24 | %%% {ok, 200, #{}, EmptyBinary}. 25 | %%% 26 | %%% The init/0 should be invoked from your applications supervisor and will register the module 27 | %%% my_handler as handler of the return type {console, Format, Args}. This means that you 28 | %%% can return this tuple in a controller which invokes my_handler:handle_console/4. 29 | %%% 30 | %%% A handler can return two different types 31 | %%% 32 | %%% {ok, StatusCode, Headers, Body} - This will return a proper reply to the requester. 33 | %%% 34 | %%% {error, Reason} - This will render a 500 page to the user. 35 | %%% @end 36 | %%% Created : 12 Feb 2020 by Niclas Axelsson 37 | %%%------------------------------------------------------------------- 38 | -module(nova_handlers). 39 | 40 | -behaviour(gen_server). 41 | 42 | %% API 43 | -export([ 44 | start_link/0, 45 | register_handler/2, 46 | unregister_handler/1, 47 | get_handler/1 48 | ]). 49 | 50 | %% gen_server callbacks 51 | -export([ 52 | init/1, 53 | handle_call/3, 54 | handle_cast/2, 55 | handle_info/2, 56 | terminate/2, 57 | code_change/3, 58 | format_status/1 59 | ]). 60 | 61 | -include_lib("kernel/include/logger.hrl"). 62 | 63 | -define(SERVER, ?MODULE). 64 | 65 | -define(HANDLERS_TABLE, nova_handlers_table). 66 | 67 | -type handler_return() :: {ok, State2 :: nova:state()} | 68 | {Module :: atom(), State :: nova:state()} | 69 | {error, Reason :: any()}. 70 | 71 | -export_type([handler_return/0]). 72 | 73 | -type handler_callback() :: {Module :: atom(), Function :: atom()} | 74 | fun((...) -> handler_return()). 75 | 76 | -record(state, { 77 | 78 | }). 79 | 80 | %%%=================================================================== 81 | %%% API 82 | %%%=================================================================== 83 | 84 | %%-------------------------------------------------------------------- 85 | %% @doc 86 | %% Starts the server 87 | %% @hidden 88 | %% @end 89 | %%-------------------------------------------------------------------- 90 | -spec start_link() -> {ok, Pid :: pid()} | 91 | {error, Error :: {already_started, pid()}} | 92 | {error, Error :: term()} | 93 | ignore. 94 | start_link() -> 95 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 96 | 97 | 98 | %%-------------------------------------------------------------------- 99 | %% @doc 100 | %% Registers a new handler. This can then be used in a nova controller 101 | %% by returning a tuple where the first element is the name of the handler. 102 | %% @end 103 | %%-------------------------------------------------------------------- 104 | -spec register_handler(Handle :: atom(), Callback :: handler_callback()) -> 105 | ok | {error, Reason :: atom()}. 106 | register_handler(Handle, Callback) -> 107 | gen_server:cast(?SERVER, {register_handler, Handle, Callback}). 108 | 109 | %%-------------------------------------------------------------------- 110 | %% @doc 111 | %% Unregisters a handler and makes it unavailable for all controllers. 112 | %% @end 113 | %%-------------------------------------------------------------------- 114 | -spec unregister_handler(Handle :: atom()) -> ok. 115 | unregister_handler(Handle) -> 116 | gen_server:call(?SERVER, {unregister_handler, Handle}). 117 | 118 | %%-------------------------------------------------------------------- 119 | %% @doc 120 | %% Fetches the handler identified with 'Handle' and returns the callback 121 | %% function for it. 122 | %% @end 123 | %%-------------------------------------------------------------------- 124 | -spec get_handler(Handle :: atom()) -> {ok, Callback :: handler_callback()} | 125 | {error, not_found}. 126 | get_handler(Handle) -> 127 | case ets:lookup(?HANDLERS_TABLE, Handle) of 128 | [] -> 129 | {error, not_found}; 130 | [{Handle, Callback}] -> 131 | {ok, Callback} 132 | end. 133 | 134 | %%%=================================================================== 135 | %%% gen_server callbacks 136 | %%%=================================================================== 137 | 138 | %%-------------------------------------------------------------------- 139 | %% @private 140 | %% @doc 141 | %% Initializes the server 142 | %% @end 143 | %%-------------------------------------------------------------------- 144 | -spec init(Args :: term()) -> {ok, State :: term()} | 145 | {ok, State :: term(), Timeout :: timeout()} | 146 | {ok, State :: term(), hibernate} | 147 | {stop, Reason :: term()} | 148 | ignore. 149 | init([]) -> 150 | process_flag(trap_exit, true), 151 | ets:new(?HANDLERS_TABLE, [named_table, set, protected]), 152 | register_handler(json, fun nova_basic_handler:handle_json/3), 153 | register_handler(ok, fun nova_basic_handler:handle_ok/3), 154 | register_handler(status, fun nova_basic_handler:handle_status/3), 155 | register_handler(redirect, fun nova_basic_handler:handle_redirect/3), 156 | register_handler(sendfile, fun nova_basic_handler:handle_sendfile/3), 157 | register_handler(ws, fun nova_basic_handler:handle_ws/2), 158 | register_handler(view, fun nova_basic_handler:handle_view/3), 159 | {ok, #state{}}. 160 | 161 | %%-------------------------------------------------------------------- 162 | %% @private 163 | %% @doc 164 | %% Handling call messages 165 | %% @end 166 | %%-------------------------------------------------------------------- 167 | -spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) -> 168 | {reply, Reply :: term(), NewState :: term()} | 169 | {reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} | 170 | {reply, Reply :: term(), NewState :: term(), hibernate} | 171 | {noreply, NewState :: term()} | 172 | {noreply, NewState :: term(), Timeout :: timeout()} | 173 | {noreply, NewState :: term(), hibernate} | 174 | {stop, Reason :: term(), Reply :: term(), NewState :: term()} | 175 | {stop, Reason :: term(), NewState :: term()}. 176 | handle_call({unregister_handler, Handle}, _From, State) -> 177 | ets:delete(?HANDLERS_TABLE, Handle), 178 | ?LOG_DEBUG(#{action => <<"Removed handler">>, handler => Handle}), 179 | {reply, ok, State}; 180 | 181 | handle_call(_Request, _From, State) -> 182 | Reply = ok, 183 | {reply, Reply, State}. 184 | 185 | %%-------------------------------------------------------------------- 186 | %% @private 187 | %% @doc 188 | %% Handling cast messages 189 | %% @end 190 | %%-------------------------------------------------------------------- 191 | -spec handle_cast(Request :: term(), State :: term()) -> 192 | {noreply, NewState :: term()} | 193 | {noreply, NewState :: term(), Timeout :: timeout()} | 194 | {noreply, NewState :: term(), hibernate} | 195 | {stop, Reason :: term(), NewState :: term()}. 196 | handle_cast({register_handler, Handle, Callback}, State) -> 197 | Callback0 = 198 | case Callback of 199 | Callback when is_function(Callback) -> Callback; 200 | {Module, Function} -> fun Module:Function/4 201 | end, 202 | case ets:lookup(?HANDLERS_TABLE, Handle) of 203 | [] -> 204 | ?LOG_DEBUG(#{action => <<"Registered handler">>, handler => Handle}), 205 | ets:insert(?HANDLERS_TABLE, {Handle, Callback0}), 206 | {noreply, State}; 207 | _ -> 208 | ?LOG_ERROR(#{msg => <<"Another handler is already registered on that name">>, handler => Handle}), 209 | {noreply, State} 210 | end; 211 | handle_cast(_Request, State) -> 212 | {noreply, State}. 213 | 214 | %%-------------------------------------------------------------------- 215 | %% @private 216 | %% @doc 217 | %% Handling all non call/cast messages 218 | %% @end 219 | %%-------------------------------------------------------------------- 220 | -spec handle_info(Info :: timeout() | term(), State :: term()) -> 221 | {noreply, NewState :: term()} | 222 | {noreply, NewState :: term(), Timeout :: timeout()} | 223 | {noreply, NewState :: term(), hibernate} | 224 | {stop, Reason :: normal | term(), NewState :: term()}. 225 | handle_info(_Info, State) -> 226 | {noreply, State}. 227 | 228 | %%-------------------------------------------------------------------- 229 | %% @private 230 | %% @doc 231 | %% This function is called by a gen_server when it is about to 232 | %% terminate. It should be the opposite of Module:init/1 and do any 233 | %% necessary cleaning up. When it returns, the gen_server terminates 234 | %% with Reason. The return value is ignored. 235 | %% @end 236 | %%-------------------------------------------------------------------- 237 | -spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(), 238 | State :: term()) -> any(). 239 | terminate(_Reason, _State) -> 240 | ok. 241 | 242 | %%-------------------------------------------------------------------- 243 | %% @private 244 | %% @doc 245 | %% Convert process state when code is changed 246 | %% @end 247 | %%-------------------------------------------------------------------- 248 | -spec code_change(OldVsn :: term() | {down, term()}, 249 | State :: term(), 250 | Extra :: term()) -> {ok, NewState :: term()} | 251 | {error, Reason :: term()}. 252 | code_change(_OldVsn, State, _Extra) -> 253 | {ok, State}. 254 | 255 | %%-------------------------------------------------------------------- 256 | %% @private 257 | %% @doc 258 | %% This function is called for changing the form and appearance 259 | %% of gen_server status when it is returned from sys:get_status/1,2 260 | %% or when it appears in termination error logs. 261 | %% @end 262 | %%-------------------------------------------------------------------- 263 | -spec format_status(Status) -> NewStatus when Status :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}, 264 | NewStatus :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}. 265 | format_status(Status) -> 266 | Status. 267 | 268 | %%%=================================================================== 269 | %%% Internal functions 270 | %%%=================================================================== 271 | -------------------------------------------------------------------------------- /src/nova_jsonlogger.erl: -------------------------------------------------------------------------------- 1 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 2 | %%% This module is copied from https://github.com/kivra/jsonformat 3 | %%% 4 | %%% What is changed is that we don't use jsx but the json library in Nova 5 | %%% 6 | %%% @doc Custom formatter for the Erlang OTP logger application which 7 | %%% outputs single-line JSON formatted data 8 | %%% @end 9 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 10 | 11 | 12 | -module(nova_jsonlogger). 13 | %%%_* Exports ========================================================== 14 | -export([format/2, 15 | system_time_to_iso8601/1, 16 | system_time_to_iso8601_nano/1]). 17 | 18 | %%%_* Types ============================================================ 19 | -type config() :: #{ 20 | new_line => boolean(), 21 | new_line_type => nl | crlf | cr | unix | windows | macos9, 22 | key_mapping => #{atom() => atom()}, 23 | format_funs => #{atom() => fun((_) -> _)} 24 | }. 25 | 26 | -export_type([config/0]). 27 | 28 | %%%_* Macros =========================================================== 29 | %%%_* Options ---------------------------------------------------------- 30 | -define(NEW_LINE, false). 31 | 32 | %%%_* Code ============================================================= 33 | %%%_ * API ------------------------------------------------------------- 34 | -spec format(logger:log_event(), config()) -> unicode:chardata(). 35 | format( 36 | #{msg := {report, #{format := Format, args := Args, label := {error_logger, _}}}} = Map, Config 37 | ) -> 38 | Report = #{text => io_lib:format(Format, Args)}, 39 | format(Map#{msg := {report, Report}}, Config); 40 | format(#{level := Level, msg := {report, Msg}, meta := Meta}, Config) when is_map(Msg) -> 41 | Data0 = merge_meta(Msg, Meta#{level => Level}, Config), 42 | Data1 = apply_key_mapping(Data0, Config), 43 | Data2 = apply_format_funs(Data1, Config), 44 | encode(pre_encode(Data2, Config), Config); 45 | format(Map = #{msg := {report, KeyVal}}, Config) when is_list(KeyVal) -> 46 | format(Map#{msg := {report, maps:from_list(KeyVal)}}, Config); 47 | format(Map = #{msg := {string, String}}, Config) -> 48 | Report = #{text => unicode:characters_to_binary(String)}, 49 | format(Map#{msg := {report, Report}}, Config); 50 | format(Map = #{msg := {Format, Terms}}, Config) -> 51 | format(Map#{msg := {string, io_lib:format(Format, Terms)}}, Config). 52 | 53 | %%% Useful for converting logger:timestamp() to a readable timestamp. 54 | -spec system_time_to_iso8601(integer()) -> binary(). 55 | system_time_to_iso8601(Epoch) -> 56 | system_time_to_iso8601(Epoch, microsecond). 57 | 58 | -spec system_time_to_iso8601_nano(integer()) -> binary(). 59 | system_time_to_iso8601_nano(Epoch) -> 60 | system_time_to_iso8601(1000 * Epoch, nanosecond). 61 | 62 | -spec system_time_to_iso8601(integer(), erlang:time_unit()) -> binary(). 63 | system_time_to_iso8601(Epoch, Unit) -> 64 | binary:list_to_bin(calendar:system_time_to_rfc3339(Epoch, [{unit, Unit}, {offset, "Z"}])). 65 | 66 | %%%_* Private functions ================================================ 67 | pre_encode(Data, Config) -> 68 | maps:fold( 69 | fun 70 | (K, V, Acc) when is_map(V) -> 71 | maps:put(jsonify(K), pre_encode(V, Config), Acc); 72 | % assume list of maps 73 | (K, Vs, Acc) when is_list(Vs), is_map(hd(Vs)) -> 74 | maps:put(jsonify(K), [pre_encode(V, Config) || V <- Vs, is_map(V)], Acc); 75 | (K, V, Acc) -> 76 | maps:put(jsonify(K), jsonify(V), Acc) 77 | end, 78 | maps:new(), 79 | Data 80 | ). 81 | 82 | merge_meta(Msg, Meta0, Config) -> 83 | Meta1 = meta_without(Meta0, Config), 84 | Meta2 = meta_with(Meta1, Config), 85 | maps:merge(Msg, Meta2). 86 | 87 | encode(Data, Config) -> 88 | JsonLib = nova:get_env(json_lib, thoas), 89 | Json = JsonLib:encode(Data), 90 | case new_line(Config) of 91 | true -> [Json, new_line_type(Config)]; 92 | false -> Json 93 | end. 94 | 95 | jsonify(A) when is_atom(A) -> A; 96 | jsonify(B) when is_binary(B) -> B; 97 | jsonify(I) when is_integer(I) -> I; 98 | jsonify(F) when is_float(F) -> F; 99 | jsonify(B) when is_boolean(B) -> B; 100 | jsonify(P) when is_pid(P) -> jsonify(pid_to_list(P)); 101 | jsonify(P) when is_port(P) -> jsonify(port_to_list(P)); 102 | jsonify(F) when is_function(F) -> jsonify(erlang:fun_to_list(F)); 103 | jsonify(L) when is_list(L) -> 104 | try list_to_binary(L) of 105 | S -> S 106 | catch 107 | error:badarg -> 108 | unicode:characters_to_binary(io_lib:format("~0p", [L])) 109 | end; 110 | jsonify({M, F, A}) when is_atom(M), is_atom(F), is_integer(A) -> 111 | <<(atom_to_binary(M, utf8))/binary, $:, (atom_to_binary(F, utf8))/binary, $/, (integer_to_binary(A))/binary>>; 112 | jsonify(Any) -> 113 | unicode:characters_to_binary(io_lib:format("~0p", [Any])). 114 | 115 | apply_format_funs(Data, #{format_funs := Callbacks}) -> 116 | maps:fold( 117 | fun 118 | (K, Fun, Acc) when is_map_key(K, Data) -> maps:update_with(K, Fun, Acc); 119 | (_, _, Acc) -> Acc 120 | end, 121 | Data, 122 | Callbacks 123 | ); 124 | apply_format_funs(Data, _) -> 125 | Data. 126 | 127 | apply_key_mapping(Data, #{key_mapping := Mapping}) -> 128 | DataOnlyMapped = 129 | maps:fold( 130 | fun 131 | (K, V, Acc) when is_map_key(K, Data) -> Acc#{V => maps:get(K, Data)}; 132 | (_, _, Acc) -> Acc 133 | end, 134 | #{}, 135 | Mapping 136 | ), 137 | DataNoMapped = maps:without(maps:keys(Mapping), Data), 138 | maps:merge(DataNoMapped, DataOnlyMapped); 139 | apply_key_mapping(Data, _) -> 140 | Data. 141 | 142 | new_line(Config) -> maps:get(new_line, Config, ?NEW_LINE). 143 | 144 | new_line_type(#{new_line_type := nl}) -> <<"\n">>; 145 | new_line_type(#{new_line_type := unix}) -> <<"\n">>; 146 | new_line_type(#{new_line_type := crlf}) -> <<"\r\n">>; 147 | new_line_type(#{new_line_type := windows}) -> <<"\r\n">>; 148 | new_line_type(#{new_line_type := cr}) -> <<"\r">>; 149 | new_line_type(#{new_line_type := macos9}) -> <<"\r">>; 150 | new_line_type(_Default) -> <<"\n">>. 151 | 152 | meta_without(Meta, Config) -> 153 | maps:without(maps:get(meta_without, Config, [report_cb]), Meta). 154 | 155 | meta_with(Meta, #{meta_with := Ks}) -> 156 | maps:with(Ks, Meta); 157 | meta_with(Meta, _ConfigNotPresent) -> 158 | Meta. 159 | 160 | %%%_* Tests ============================================================ 161 | -ifdef(TEST). 162 | -include_lib("eunit/include/eunit.hrl"). 163 | 164 | -define(assertJSONEqual(Expected, Actual), 165 | ?assertEqual(thoas:decode(Expected), thoas:decode(Actual)) 166 | ). 167 | 168 | format_test() -> 169 | ?assertJSONEqual( 170 | <<"{\"level\":\"alert\",\"text\":\"derp\"}">>, 171 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, #{}) 172 | ), 173 | ?assertJSONEqual( 174 | <<"{\"herp\":\"derp\",\"level\":\"alert\"}">>, 175 | format(#{level => alert, msg => {report, #{herp => derp}}, meta => #{}}, #{}) 176 | ). 177 | 178 | format_funs_test() -> 179 | Config1 = #{ 180 | format_funs => #{ 181 | time => fun(Epoch) -> Epoch + 1 end, 182 | level => fun(alert) -> info end 183 | } 184 | }, 185 | ?assertJSONEqual( 186 | <<"{\"level\":\"info\",\"text\":\"derp\",\"time\":2}">>, 187 | format(#{level => alert, msg => {string, "derp"}, meta => #{time => 1}}, Config1) 188 | ), 189 | 190 | Config2 = #{ 191 | format_funs => #{ 192 | time => fun(Epoch) -> Epoch + 1 end, 193 | foobar => fun(alert) -> info end 194 | } 195 | }, 196 | ?assertJSONEqual( 197 | <<"{\"level\":\"alert\",\"text\":\"derp\",\"time\":2}">>, 198 | format(#{level => alert, msg => {string, "derp"}, meta => #{time => 1}}, Config2) 199 | ). 200 | 201 | key_mapping_test() -> 202 | Config1 = #{ 203 | key_mapping => #{ 204 | level => lvl, 205 | text => message 206 | } 207 | }, 208 | ?assertJSONEqual( 209 | <<"{\"lvl\":\"alert\",\"message\":\"derp\"}">>, 210 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, Config1) 211 | ), 212 | 213 | Config2 = #{ 214 | key_mapping => #{ 215 | level => lvl, 216 | text => level 217 | } 218 | }, 219 | ?assertJSONEqual( 220 | <<"{\"level\":\"derp\",\"lvl\":\"alert\"}">>, 221 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, Config2) 222 | ), 223 | 224 | Config3 = #{ 225 | key_mapping => #{ 226 | level => lvl, 227 | foobar => level 228 | } 229 | }, 230 | ?assertJSONEqual( 231 | <<"{\"lvl\":\"alert\",\"text\":\"derp\"}">>, 232 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, Config3) 233 | ), 234 | 235 | Config4 = #{ 236 | key_mapping => #{time => timestamp}, 237 | format_funs => #{timestamp => fun(T) -> T + 1 end} 238 | }, 239 | ?assertJSONEqual( 240 | <<"{\"level\":\"alert\",\"text\":\"derp\",\"timestamp\":2}">>, 241 | format(#{level => alert, msg => {string, "derp"}, meta => #{time => 1}}, Config4) 242 | ). 243 | 244 | list_format_test() -> 245 | ErrorReport = 246 | #{ 247 | level => error, 248 | meta => #{time => 1}, 249 | msg => {report, #{report => [{hej, "hopp"}]}} 250 | }, 251 | ?assertJSONEqual( 252 | <<"{\"level\":\"error\",\"report\":\"[{hej,\\\"hopp\\\"}]\",\"time\":1}">>, 253 | format(ErrorReport, #{}) 254 | ). 255 | 256 | meta_without_test() -> 257 | Error = #{ 258 | level => info, 259 | msg => {report, #{answer => 42}}, 260 | meta => #{secret => xyz} 261 | }, 262 | ?assertEqual( 263 | {ok, #{ 264 | <<"answer">> => 42, 265 | <<"level">> => <<"info">>, 266 | <<"secret">> => <<"xyz">> 267 | }}, 268 | thoas:decode(format(Error, #{})) 269 | ), 270 | Config2 = #{meta_without => [secret]}, 271 | ?assertEqual( 272 | {ok, #{ 273 | <<"answer">> => 42, 274 | <<"level">> => <<"info">> 275 | }}, 276 | thoas:decode(format(Error, Config2)) 277 | ), 278 | ok. 279 | 280 | meta_with_test() -> 281 | Error = #{ 282 | level => info, 283 | msg => {report, #{answer => 42}}, 284 | meta => #{secret => xyz} 285 | }, 286 | ?assertEqual( 287 | {ok, #{ 288 | <<"answer">> => 42, 289 | <<"level">> => <<"info">>, 290 | <<"secret">> => <<"xyz">> 291 | }}, 292 | thoas:decode(format(Error, #{})) 293 | ), 294 | Config2 = #{meta_with => [level]}, 295 | ?assertEqual( 296 | {ok, #{ 297 | <<"answer">> => 42, 298 | <<"level">> => <<"info">> 299 | }}, 300 | thoas:decode(format(Error, Config2)) 301 | ), 302 | ok. 303 | 304 | newline_test() -> 305 | ConfigDefault = #{new_line => true}, 306 | ?assertEqual( 307 | [<<"{\"level\":\"alert\",\"text\":\"derp\"}">>, <<"\n">>], 308 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigDefault) 309 | ), 310 | ConfigCRLF = #{ 311 | new_line_type => crlf, 312 | new_line => true 313 | }, 314 | ?assertEqual( 315 | [<<"{\"level\":\"alert\",\"text\":\"derp\"}">>, <<"\r\n">>], 316 | format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigCRLF) 317 | ). 318 | 319 | -endif. -------------------------------------------------------------------------------- /src/nova_plugin.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @copyright (C) 2020, Niclas Axelsson 4 | %%% @doc 5 | %%% Plugins can be run at two different times; either in the beginning or at 6 | %%% the end of a request. They can modify both the actual request or the nova-state. 7 | %%% A plugin is implemented with the nova_plugin behaviour 8 | %%% and needs to implement three different functions: pre_request/2, 9 | %%% post_request/2 and plugin_info/0. 10 | %%% 11 | %%% A plugin can return either {ok, NewState}, {break, NewState}, 12 | %%% {stop, NewState}, {error, Reason}. 13 | %%% 14 | %%% {ok, NewState} will continue the normal execution with the NewState. 15 | %%% 16 | %%% {break, NewState} breaks the execution of the current plugin-chain. This means that 17 | %%% if a pre_request-plugin returns the break-statement the rest of the plugins in that chain will be skipped. 18 | %%% 19 | %%% {stop, NewState} stops the execution. The plugin is responsible in this case for returning 20 | %%% a proper response to the client. 21 | %%% 22 | %%% {error, Reason} will stop the execution and call the nova_error plugin, resulting in a 500 response back 23 | %%% to the user. If debug mode is enabled the reason will be returned in the 500-response. 24 | %%% 25 | %%% @end 26 | %%% Created : 12 Feb 2020 by Niclas Axelsson 27 | %%%------------------------------------------------------------------- 28 | -module(nova_plugin). 29 | 30 | -type request_type() :: pre_request | post_request. 31 | -export_type([request_type/0]). 32 | 33 | %% Define the callback functions for HTTP-plugins 34 | -callback pre_request(State :: nova:state(), Options :: map()) -> 35 | {ok, State0 :: nova:state()} | 36 | {break, State0 :: nova:state()} | 37 | {stop, State0 :: nova:state()} | 38 | {error, Reason :: term()}. 39 | -optional_callbacks([pre_request/2]). 40 | 41 | -callback post_request(State :: nova:state(), Options :: map()) -> 42 | {ok, State0 :: nova:state()} | 43 | {break, State0 :: nova:state()} | 44 | {stop, State0 :: nova:state()} | 45 | {error, Reason :: term()}. 46 | -optional_callbacks([post_request/2]). 47 | 48 | -callback plugin_info() -> {Title :: binary(), 49 | Version :: binary(), 50 | Author :: binary(), 51 | Description :: binary(), 52 | Options :: [{Key :: atom(), OptionDescription :: binary()}]}. 53 | -------------------------------------------------------------------------------- /src/nova_plugin_handler.erl: -------------------------------------------------------------------------------- 1 | -module(nova_plugin_handler). 2 | -behaviour(cowboy_middleware). 3 | 4 | -export([ 5 | execute/2 6 | ]). 7 | 8 | -include_lib("kernel/include/logger.hrl"). 9 | 10 | execute(Req = #{plugins := Plugins}, Env = #{plugin_state := pre_request}) -> 11 | %% This is a post plugin 12 | PostPlugins = proplists:get_value(post_request, Plugins, []), 13 | run_plugins(PostPlugins, post_request, Req, Env); 14 | execute(Req = #{plugins := Plugins}, Env) -> 15 | %% Determine which pre-plugin this is 16 | PrePlugins = proplists:get_value(pre_request, Plugins, []), 17 | run_plugins(PrePlugins, pre_request, Req, Env); 18 | execute(Req, Env) -> 19 | %% The router could not find any match for us 20 | {ok, Req, Env}. 21 | 22 | 23 | run_plugins([], Callback, Req, Env) -> 24 | {ok, Req, Env#{plugin_state => Callback}}; 25 | run_plugins([{Module, Options}|Tl], Callback, Req, Env) -> 26 | Args = case proplists:get_value(Callback, Module:module_info(exports)) of 27 | 2 -> [Req, Options]; 28 | 3 -> [Req, Env, Options]; 29 | _ -> {throw, bad_callback} 30 | end, 31 | try erlang:apply(Module, Callback, Args) of 32 | {ok, Req0} -> 33 | run_plugins(Tl, Callback, Req0, Env); 34 | {ok, Reply, Req0} -> 35 | Req1 = handle_reply(Reply, Req0), 36 | run_plugins(Tl, Callback, Req1, Env); 37 | {break, Req0} -> 38 | {ok, Req0}; 39 | {break, Reply, Req0} -> 40 | Req1 = handle_reply(Reply, Req0), 41 | {ok, Req1}; 42 | {stop, Req0} -> 43 | {stop, Req0}; 44 | {stop, Reply, Req0} -> 45 | Req1 = handle_reply(Reply, Req0), 46 | {stop, Req1} 47 | catch 48 | Class:Reason:Stacktrace -> 49 | ?LOG_ERROR(#{msg => <<"Plugin crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}), 50 | Req0 = Req#{crash_info => #{class => Class, 51 | reason => Reason, 52 | stacktrace => Stacktrace}}, 53 | nova_router:render_status_page('_', 500, #{}, Req0, Env) 54 | end. 55 | 56 | handle_reply({reply, Body}, Req) -> 57 | handle_reply({reply, 200, #{}, Body}, Req); 58 | handle_reply({reply, Status, Body}, Req) -> 59 | handle_reply({reply, Status, #{}, Body}, Req); 60 | handle_reply({reply, Status, Headers, Body}, Req) -> 61 | Req0 = cowboy_req:set_resp_headers(Headers, Req), 62 | Req1 = cowboy_req:set_resp_body(Body, Req0), 63 | Req1#{resp_status_code => Status}; 64 | handle_reply(_, Req) -> 65 | Req. 66 | -------------------------------------------------------------------------------- /src/nova_pubsub.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @doc 4 | %%% Pubsub system for Nova. It uses the pg/pg2 module. 5 | %%% 6 | %%% Pubsub subsystem is started with Nova and does not need any additional 7 | %%% configuration. It uses the pg/pg2 module depending on the version of OTP. 8 | %%% It provides a simple way of distributing messages to a large set of 9 | %%% receivers and exposes a simple set of functions for doing that. 10 | %%% 11 | %%% 12 | %%% A simple example of how to use pubsub in a ping/pong inspired game engine: 13 | %%% 14 | %%% -module(test_module). 15 | %%% -export([player1/0, 16 | %%% player2/0, 17 | %%% start_game/0]). 18 | %%% 19 | %%% player1() -> 20 | %%% spawn(fun() -> 21 | %%% nova_pubsub:join(game_of_pong), 22 | %%% game_loop(1, "pong", "ping"). 23 | %%% 24 | %%% player2() -> 25 | %%% spawn(fun() -> 26 | %%% nova_pubsub:join(game_of_pong), 27 | %%% game_loop(2, "ping", "pong"). 28 | %%% 29 | %%% game_loop(Player, ExpectedMessage, Smash) -> 30 | %%% receive 31 | %%% ExpectedMessage -> 32 | %%% io:format("Player ~d received ~s and returning ~s~n", [Player, ExpectedMessage, Smash]), 33 | %%% nova_pubsub:broadcast(game_of_pong, "match1", Smash), 34 | %%% game_loop(Player, ExpectedMessage, Smash); 35 | %%% _ -> 36 | %%% game_loop(Player, ExpectedMessage, Smash) 37 | %%% end. 38 | %%% 39 | %%% @end 40 | %%% Created : 8 Apr 2022 by Niclas Axelsson 41 | %%%------------------------------------------------------------------- 42 | -module(nova_pubsub). 43 | -export([ 44 | start/0, 45 | join/1, 46 | join/2, 47 | leave/1, 48 | leave/2, 49 | broadcast/3, 50 | local_broadcast/3, 51 | get_members/1, 52 | get_local_members/1 53 | ]). 54 | 55 | -define(SCOPE, nova_scope). 56 | 57 | -include("../include/nova_pubsub.hrl"). 58 | 59 | %%-------------------------------------------------------------------- 60 | %% @doc 61 | %% Starts the pubsub subsystem. Only used by Nova internal supervisor! 62 | %% @hidden 63 | %% @end 64 | %%-------------------------------------------------------------------- 65 | -spec start() -> ok. 66 | start() -> 67 | pg:start(?SCOPE), 68 | ok. 69 | 70 | %%-------------------------------------------------------------------- 71 | %% @doc 72 | %% Joining a channel with the calling process. Always returns ok 73 | %% @end 74 | %%-------------------------------------------------------------------- 75 | -spec join(Channel :: atom()) -> ok. 76 | join(Channel) -> 77 | join(Channel, self()). 78 | 79 | %%-------------------------------------------------------------------- 80 | %% @doc 81 | %% Leaves a channnel. Will return ok on success and not_joined if the 82 | %% calling process were not part of the channel. 83 | %% @end 84 | %%-------------------------------------------------------------------- 85 | -spec leave(Channel :: atom()) -> ok | not_joined. 86 | leave(Channel) -> 87 | leave(Channel, self()). 88 | 89 | 90 | %%-------------------------------------------------------------------- 91 | %% @doc 92 | %% Same as join/1 but with a specified process. 93 | %% @end 94 | %%-------------------------------------------------------------------- 95 | -spec join(Channel :: atom(), Pid :: pid()) -> ok. 96 | join(Channel, Pid) when is_pid(Pid) -> 97 | pg:join(?SCOPE, Channel, Pid). 98 | 99 | %%-------------------------------------------------------------------- 100 | %% @doc 101 | %% Same as leave/1 but with a specified process. 102 | %% @end 103 | %%-------------------------------------------------------------------- 104 | -spec leave(Channel :: atom(), Pid :: pid()) -> ok | not_joined. 105 | leave(Channel, Pid) -> 106 | pg:leave(?SCOPE, Channel, Pid). 107 | 108 | %%-------------------------------------------------------------------- 109 | %% @doc 110 | %% Broadcasts a message to all members of a channel. Topic is specified 111 | %% to differentiate messages within the same channel. 112 | %% @end 113 | %%-------------------------------------------------------------------- 114 | -spec broadcast(Channel :: atom(), Topic :: list() | binary(), Message :: any()) -> ok. 115 | broadcast(Channel, Topic, Message) -> 116 | Members = get_members(Channel), 117 | Envelope = create_envelope(Channel, self(), Topic, Message), 118 | [ Receiver ! Envelope || Receiver <- Members ], 119 | ok. 120 | 121 | 122 | %%-------------------------------------------------------------------- 123 | %% @doc 124 | %% Works in the same way as broadcast/3 but only for members in the same 125 | %% node. 126 | %% @end 127 | %%-------------------------------------------------------------------- 128 | -spec local_broadcast(Channel :: atom(), Topic :: list() | binary(), Message :: any()) -> ok. 129 | local_broadcast(Channel, Topic, Message) -> 130 | Members = get_local_members(Channel), 131 | Envelope = create_envelope(Channel, self(), Topic, Message), 132 | [ Receiver ! Envelope || Receiver <- Members ], 133 | ok. 134 | 135 | 136 | %%-------------------------------------------------------------------- 137 | %% @doc 138 | %% Returns all members for a given channel 139 | %% @end 140 | %%-------------------------------------------------------------------- 141 | -spec get_members(Channel :: atom()) -> [pid()]. 142 | get_members(Channel) -> 143 | pg:get_members(?SCOPE, Channel). 144 | 145 | %%-------------------------------------------------------------------- 146 | %% @doc 147 | %% Works the same way as get_members/1 but returns only members on the 148 | %% same node. 149 | %% @end 150 | %%-------------------------------------------------------------------- 151 | -spec get_local_members(Channel :: atom()) -> [pid()]. 152 | get_local_members(Channel) -> 153 | pg:get_local_members(?SCOPE, Channel). 154 | 155 | create_envelope(Channel, Sender, Topic, Payload) -> 156 | #nova_pubsub{channel = Channel, 157 | sender = Sender, 158 | topic = Topic, 159 | payload = Payload}. 160 | -------------------------------------------------------------------------------- /src/nova_router.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @doc 4 | %%% 5 | %%% @end 6 | %%%------------------------------------------------------------------- 7 | -module(nova_router). 8 | -behaviour(cowboy_middleware). 9 | 10 | %% Cowboy middleware-callbacks 11 | -export([ 12 | execute/2 13 | ]). 14 | 15 | %% API 16 | -export([ 17 | compiled_apps/0, 18 | compile/1, 19 | lookup_url/1, 20 | lookup_url/2, 21 | lookup_url/3, 22 | render_status_page/2, 23 | render_status_page/3, 24 | render_status_page/5, 25 | 26 | %% Expose the router-callback 27 | routes/1, 28 | 29 | %% Modulates the routes-table 30 | add_routes/2 31 | ]). 32 | 33 | -include_lib("routing_tree/include/routing_tree.hrl"). 34 | -include_lib("kernel/include/logger.hrl"). 35 | -include("../include/nova_router.hrl"). 36 | -include("../include/nova.hrl"). 37 | 38 | -type bindings() :: #{binary() := binary()}. 39 | -export_type([bindings/0]). 40 | 41 | %% This module is also exposing callbacks for routers 42 | -callback routes(Env :: atom()) -> Routes :: [map()]. 43 | 44 | 45 | -define(NOVA_APPS, nova_apps). 46 | 47 | -spec compiled_apps() -> [{App :: atom(), Prefix :: list()}]. 48 | compiled_apps() -> 49 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 50 | StorageBackend:get(?NOVA_APPS, []). 51 | 52 | -spec compile(Apps :: [atom() | {atom(), map()}]) -> host_tree(). 53 | compile(Apps) -> 54 | UseStrict = application:get_env(nova, use_strict_routing, false), 55 | Dispatch = compile(Apps, routing_tree:new(#{use_strict => UseStrict, convert_to_binary => true}), #{}), 56 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 57 | StorageBackend:put(nova_dispatch, Dispatch), 58 | Dispatch. 59 | 60 | -spec execute(Req, Env :: cowboy_middleware:env()) -> {ok, Req, Env0} | {stop, Req} 61 | when Req::cowboy_req:req(), 62 | Env0::cowboy_middleware:env(). 63 | execute(Req = #{host := Host, path := Path, method := Method}, Env) -> 64 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 65 | Dispatch = StorageBackend:get(nova_dispatch), 66 | case routing_tree:lookup(Host, Path, Method, Dispatch) of 67 | {error, not_found} -> render_status_page('_', 404, #{error => "Not found in path"}, Req, Env); 68 | {error, comparator_not_found} -> render_status_page('_', 405, #{error => "Method not allowed"}, Req, Env); 69 | {ok, Bindings, #nova_handler_value{app = App, callback = Callback, secure = Secure, plugins = Plugins, 70 | extra_state = ExtraState}} -> 71 | {ok, 72 | Req#{plugins => Plugins, 73 | extra_state => ExtraState, 74 | bindings => Bindings}, 75 | Env#{app => App, 76 | callback => Callback, 77 | secure => Secure, 78 | controller_data => #{} 79 | } 80 | }; 81 | {ok, Bindings, #nova_handler_value{app = App, callback = Callback, 82 | secure = Secure, plugins = Plugins, extra_state = ExtraState}, Pathinfo} -> 83 | {ok, 84 | Req#{plugins => Plugins, 85 | extra_state => ExtraState#{pathinfo => Pathinfo}, 86 | bindings => Bindings}, 87 | Env#{app => App, 88 | callback => Callback, 89 | secure => Secure, 90 | controller_data => #{} 91 | } 92 | }; 93 | {ok, Bindings, #cowboy_handler_value{app = App, handler = Handler, arguments = Args, 94 | plugins = Plugins, secure = Secure}} -> 95 | {ok, 96 | Req#{plugins => Plugins, 97 | bindings => Bindings}, 98 | Env#{app => App, 99 | cowboy_handler => Handler, 100 | arguments => Args, 101 | secure => Secure 102 | } 103 | }; 104 | Error -> 105 | ?LOG_ERROR(#{reason => <<"Unexpected return from routing_tree:lookup/4">>, 106 | return_object => Error}), 107 | render_status_page(Host, 404, #{error => Error}, Req, Env) 108 | end. 109 | 110 | lookup_url(Path) -> 111 | lookup_url('_', Path). 112 | 113 | lookup_url(Host, Path) -> 114 | lookup_url(Host, Path, '_'). 115 | 116 | lookup_url(Host, Path, Method) -> 117 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 118 | Dispatch = StorageBackend:get(nova_dispatch), 119 | lookup_url(Host, Path, Method, Dispatch). 120 | 121 | lookup_url(Host, Path, Method, Dispatch) -> 122 | routing_tree:lookup(Host, Path, Method, Dispatch). 123 | 124 | %%-------------------------------------------------------------------- 125 | %% @doc 126 | %% Add routes to the dispatch-table for the given app. The routes 127 | %% can be either a list of maps or a map. It use the same structure as 128 | %% the routes-callback in the router-module. 129 | %% @end 130 | %%-------------------------------------------------------------------- 131 | -spec add_routes(App :: atom(), Routes :: [map()] | map()) -> ok. 132 | add_routes(_App, []) -> ok; 133 | add_routes(App, [Routes|Tl]) when is_list(Routes) -> 134 | Options = #{}, 135 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 136 | Dispatch = StorageBackend:get(nova_dispatch), 137 | 138 | %% Take out the prefix for the app and store it in the persistent store 139 | CompiledApps = StorageBackend:get(?NOVA_APPS, []), 140 | CompiledApps0 = 141 | case lists:keyfind(App, 1, CompiledApps) of 142 | false -> 143 | [{App, maps:get(prefix, Options, "/")}|CompiledApps]; 144 | _StoredApp -> 145 | CompiledApps 146 | end, 147 | 148 | Options1 = Options#{app => App}, 149 | 150 | {ok, Dispatch1, _Options2} = compile_paths(Routes, Dispatch, Options1), 151 | 152 | StorageBackend:put(?NOVA_APPS, CompiledApps0), 153 | StorageBackend:put(nova_dispatch, Dispatch1), 154 | 155 | add_routes(App, Tl); 156 | add_routes(App, Routes) -> 157 | ?LOG_ERROR(#{reason => <<"Invalid routes structure">>, app => App, routes => Routes}), 158 | throw({error, {invalid_routes, App, Routes}}). 159 | 160 | 161 | %%%%%%%%%%%%%%%%%%%%%%%% 162 | %% INTERNAL FUNCTIONS %% 163 | %%%%%%%%%%%%%%%%%%%%%%%% 164 | 165 | -spec compile(Apps :: [atom() | {atom(), map()}], Dispatch :: host_tree(), Options :: map()) -> host_tree(). 166 | compile([], Dispatch, _Options) -> Dispatch; 167 | compile([{App, Options}|Tl], Dispatch, GlobalOptions) -> 168 | compile([App|Tl], Dispatch, maps:merge(Options, GlobalOptions)); 169 | compile([App|Tl], Dispatch, Options) -> 170 | %% Fetch the router-module for this application 171 | Router = erlang:list_to_atom(io_lib:format("~s_router", [App])), 172 | Env = nova:get_environment(), 173 | %% Call the router 174 | Routes = Router:routes(Env), 175 | Options1 = Options#{app => App}, 176 | 177 | {ok, Dispatch1, Options2} = compile_paths(Routes, Dispatch, Options1), 178 | 179 | %% Take out the prefix for the app and store it in the persistent store 180 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 181 | CompiledApps = StorageBackend:get(?NOVA_APPS, []), 182 | CompiledApps0 = [{App, maps:get(prefix, Options, "/")}|CompiledApps], 183 | 184 | StorageBackend:put(?NOVA_APPS, CompiledApps0), 185 | 186 | compile(Tl, Dispatch1, Options2). 187 | 188 | compile_paths([], Dispatch, Options) -> {ok, Dispatch, Options}; 189 | compile_paths([RouteInfo|Tl], Dispatch, Options) -> 190 | App = maps:get(app, Options), 191 | %% Fetch the global plugins 192 | GlobalPlugins = application:get_env(nova, plugins, []), 193 | Plugins = maps:get(plugins, RouteInfo, GlobalPlugins), 194 | 195 | Secure = 196 | case maps:get(secure, Options, maps:get(security, RouteInfo, false)) of 197 | false -> 198 | false; 199 | {SMod, SFun} -> 200 | ?LOG_DEPRECATED("v0.9.24", "The {Mod,Fun} format have been deprecated. Use the new format for routes."), 201 | fun SMod:SFun/1; 202 | SCallback -> 203 | SCallback 204 | end, 205 | 206 | Value = #nova_handler_value{secure = Secure, app = App, plugins = normalize_plugins(Plugins), 207 | extra_state = maps:get(extra_state, RouteInfo, #{})}, 208 | 209 | Prefix = concat_strings(maps:get(prefix, Options, ""), maps:get(prefix, RouteInfo, "")), 210 | Host = maps:get(host, RouteInfo, '_'), 211 | SubApps = maps:get(apps, RouteInfo, []), 212 | %% We need to add this app info to nova-env 213 | NovaEnv = nova:get_env(apps, []), 214 | NovaEnv0 = [{App, #{prefix => Prefix}} | NovaEnv], 215 | nova:set_env(apps, NovaEnv0), 216 | 217 | {ok, Dispatch1} = parse_url(Host, maps:get(routes, RouteInfo, []), Prefix, Value, Dispatch), 218 | 219 | Dispatch2 = compile(SubApps, Dispatch1, Options#{value => Value, prefix => Prefix}), 220 | 221 | compile_paths(Tl, Dispatch2, Options). 222 | 223 | parse_url(_Host, [], _Prefix, _Value, Tree) -> {ok, Tree}; 224 | parse_url(Host, [{StatusCode, Callback, Options}|Tl], Prefix, Value, Tree) when is_integer(StatusCode) andalso 225 | is_function(Callback) -> 226 | Value0 = Value#nova_handler_value{callback = Callback}, 227 | Res = lists:foldl(fun(Method, Tree0) -> 228 | insert(Host, StatusCode, Method, Value0, Tree0) 229 | end, Tree, maps:get(methods, Options, ['_'])), 230 | parse_url(Host, Tl, Prefix, Value, Res); 231 | parse_url(Host, 232 | [{RemotePath, LocalPath}|Tl], 233 | Prefix, Value = #nova_handler_value{}, Tree) 234 | when is_list(RemotePath), is_list(LocalPath) -> 235 | parse_url(Host, [{RemotePath, LocalPath, #{}}|Tl], Prefix, Value, Tree); 236 | parse_url(Host, 237 | [{RemotePath, LocalPath, Options}|Tl], 238 | Prefix, Value = #nova_handler_value{app = App, secure = Secure}, 239 | Tree) when is_list(RemotePath), is_list(LocalPath) -> 240 | 241 | %% Static assets - check that the path exists 242 | PrivPath = filename:join(code:priv_dir(App), LocalPath), 243 | 244 | Payload = 245 | case {filelib:is_dir(LocalPath), filelib:is_dir(PrivPath)} of 246 | {false, false} -> 247 | %% No directory - check if it's a file 248 | case {filelib:is_file(LocalPath), filelib:is_file(PrivPath)} of 249 | {false, false} -> 250 | %% No dir nor file 251 | ?LOG_WARNING(#{reason => <<"Could not find local path for the given resource">>, 252 | local_path => LocalPath, 253 | remote_path => RemotePath}), 254 | not_found; 255 | {true, false} -> 256 | {file, LocalPath}; 257 | {_, true} -> 258 | {priv_file, App, LocalPath} 259 | end; 260 | {true, false} -> 261 | {dir, LocalPath}; 262 | {_, true} -> 263 | {priv_dir, App, LocalPath} 264 | end, 265 | 266 | TargetFun = case Payload of 267 | {file, _} -> get_file; 268 | {priv_file, _, _} -> get_file; 269 | {dir, _} -> get_dir; 270 | {priv_dir, _, _} -> get_dir 271 | end, 272 | 273 | Value0 = #nova_handler_value{ 274 | app = App, 275 | callback = fun nova_file_controller:TargetFun/1, 276 | extra_state = #{static => Payload, options => Options}, 277 | plugins = Value#nova_handler_value.plugins, 278 | secure = Secure 279 | }, 280 | Tree0 = insert(Host, string:concat(Prefix, RemotePath), '_', Value0, Tree), 281 | parse_url(Host, Tl, Prefix, Value, Tree0); 282 | parse_url(Host, [{Path, {Mod, Func}, Options}|Tl], Prefix, 283 | Value = #nova_handler_value{app = _App, secure = _Secure}, Tree) -> 284 | ?LOG_DEPRECATED(<<"v0.9.24">>, <<"The {Mod,Fun} format have been deprecated. Use the new format for routes.">>), 285 | parse_url(Host, [{Path, fun Mod:Func/1, Options}|Tl], Prefix, Value, Tree); 286 | parse_url(Host, [{Path, Callback}|Tl], Prefix, Value, Tree) when is_function(Callback) -> 287 | %% Recurse with same args but with added options 288 | parse_url(Host, [{Path, Callback, #{}}|Tl], Prefix, Value, Tree); 289 | parse_url(Host, [{Path, Callback, Options}|Tl], Prefix, Value = #nova_handler_value{app = App}, Tree) 290 | when is_function(Callback) -> 291 | case maps:get(protocol, Options, http) of 292 | http -> 293 | %% Transform the path to a string format 294 | RealPath = concat_strings(Prefix, Path), 295 | 296 | Methods = maps:get(methods, Options, ['_']), 297 | 298 | ExtraState = maps:get(extra_state, Options, undefined), 299 | Value0 = Value#nova_handler_value{extra_state = ExtraState}, 300 | 301 | CompiledPaths = 302 | lists:foldl( 303 | fun(Method, Tree0) -> 304 | BinMethod = method_to_binary(Method), 305 | Value1 = Value0#nova_handler_value{ 306 | callback = Callback 307 | }, 308 | ?LOG_DEBUG(#{action => <<"Adding route">>, route => RealPath, app => App, method => Method}), 309 | insert(Host, RealPath, BinMethod, Value1, Tree0) 310 | end, Tree, Methods), 311 | parse_url(Host, Tl, Prefix, Value, CompiledPaths); 312 | OtherProtocol -> 313 | ?LOG_ERROR(#{reason => <<"Unknown protocol">>, protocol => OtherProtocol}), 314 | parse_url(Host, Tl, Prefix, Value, Tree) 315 | end; 316 | parse_url(Host, 317 | [{Path, Mod, #{protocol := ws}} | Tl], 318 | Prefix, #nova_handler_value{app = App, secure = Secure} = Value, 319 | Tree) when is_atom(Mod) -> 320 | Value0 = #cowboy_handler_value{ 321 | app = App, 322 | handler = nova_ws_handler, 323 | arguments = #{module => Mod}, 324 | plugins = Value#nova_handler_value.plugins, 325 | secure = Secure}, 326 | 327 | ?LOG_DEBUG(#{action => <<"Adding route">>, protocol => <<"ws">>, route => Path, app => App}), 328 | RealPath = concat_strings(Prefix, Path), 329 | CompiledPaths = insert(Host, RealPath, '_', Value0, Tree), 330 | parse_url(Host, Tl, Prefix, Value, CompiledPaths). 331 | 332 | 333 | -spec render_status_page(StatusCode :: integer(), Req :: cowboy_req:req()) -> 334 | {ok, Req0 :: cowboy_req:req(), Env :: map()}. 335 | render_status_page(StatusCode, Req) -> 336 | render_status_page(StatusCode, #{}, Req). 337 | 338 | -spec render_status_page(StatusCode :: integer(), Data :: map(), Req :: cowboy_req:req()) -> 339 | {ok, Req0 :: cowboy_req:req(), Env :: map()}. 340 | render_status_page(StatusCode, Data, Req) -> 341 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 342 | Dispatch = StorageBackend:get(nova_dispatch), 343 | render_status_page('_', StatusCode, Data, Req, #{dispatch => Dispatch}). 344 | 345 | -spec render_status_page(Host :: binary() | atom(), 346 | StatusCode :: integer(), 347 | Data :: map(), 348 | Req :: cowboy_req:req(), 349 | Env :: map()) -> {ok, Req0 :: cowboy_req:req(), Env :: map()}. 350 | render_status_page(Host, StatusCode, Data, Req, Env) -> 351 | StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), 352 | Dispatch = StorageBackend:get(nova_dispatch), 353 | {Req0, Env0} = 354 | case routing_tree:lookup(Host, StatusCode, '_', Dispatch) of 355 | {error, _} -> 356 | %% Render nova page if exists - We need to determine where to find this path? 357 | {Req, Env#{app => nova, 358 | callback => fun nova_error_controller:status_code/1, 359 | secure => false, 360 | controller_data => #{status => StatusCode, data => Data}}}; 361 | {ok, Bindings, #nova_handler_value{app = App, 362 | callback = Callback, 363 | secure = Secure, 364 | extra_state = ExtraState}} -> 365 | { 366 | Req#{extra_state => ExtraState, bindings => Bindings, resp_status_code => StatusCode}, 367 | Env#{app => App, 368 | callback => Callback, 369 | secure => Secure, 370 | controller_data => #{status => StatusCode, data => Data}, 371 | bindings => Bindings} 372 | } 373 | end, 374 | {ok, Req0#{resp_status_code => StatusCode}, Env0}. 375 | 376 | 377 | insert(Host, Path, Combinator, Value, Tree) -> 378 | try routing_tree:insert(Host, Path, Combinator, Value, Tree) of 379 | Tree0 -> Tree0 380 | catch 381 | throw:Exception -> 382 | ?LOG_ERROR(#{reason => <<"Error when inserting route">>, route => Path, combinator => Combinator}), 383 | throw(Exception); 384 | Type:Exception -> 385 | ?LOG_ERROR(#{reason => <<"Unexpected exit">>, type => Type, exception => Exception}), 386 | throw(Exception) 387 | end. 388 | 389 | 390 | normalize_plugins(Plugins) -> 391 | NormalizedPlugins = normalize_plugins(Plugins, []), 392 | [{Type, lists:reverse(TypePlugins)} || {Type, TypePlugins} <- NormalizedPlugins]. 393 | 394 | normalize_plugins([], Ack) -> Ack; 395 | normalize_plugins([{Type, PluginName, Options}|Tl], Ack) -> 396 | ExistingPlugins = proplists:get_value(Type, Ack, []), 397 | normalize_plugins(Tl, [{Type, [{PluginName, Options}|ExistingPlugins]}|proplists:delete(Type, Ack)]). 398 | 399 | method_to_binary(get) -> <<"GET">>; 400 | method_to_binary(post) -> <<"POST">>; 401 | method_to_binary(put) -> <<"PUT">>; 402 | method_to_binary(delete) -> <<"DELETE">>; 403 | method_to_binary(options) -> <<"OPTIONS">>; 404 | method_to_binary(head) -> <<"HEAD">>; 405 | method_to_binary(connect) -> <<"CONNECT">>; 406 | method_to_binary(trace) -> <<"TRACE">>; 407 | method_to_binary(patch) -> <<"PATCH">>; 408 | method_to_binary(_) -> '_'. 409 | 410 | 411 | concat_strings(Path1, Path2) when is_binary(Path1) -> 412 | concat_strings(binary_to_list(Path1), Path2); 413 | concat_strings(Path1, Path2) when is_binary(Path2) -> 414 | concat_strings(Path1, binary_to_list(Path2)); 415 | concat_strings(_Path1, Path2) when is_integer(Path2) -> 416 | Path2; 417 | concat_strings(Path1, Path2) when is_list(Path1), is_list(Path2) -> 418 | string:concat(Path1, Path2). 419 | 420 | -spec routes(Env :: atom()) -> [map()]. 421 | routes(_) -> 422 | [#{ 423 | routes => [ 424 | {404, { nova_error_controller, not_found }, #{}}, 425 | {500, { nova_error_controller, server_error }, #{}} 426 | ] 427 | }]. 428 | 429 | -ifdef(TEST). 430 | -compile(export_all). %% Export all functions for testing purpose 431 | -include_lib("eunit/include/eunit.hrl"). 432 | 433 | 434 | 435 | -endif. 436 | -------------------------------------------------------------------------------- /src/nova_security_handler.erl: -------------------------------------------------------------------------------- 1 | -module(nova_security_handler). 2 | -behaviour(cowboy_middleware). 3 | 4 | -export([ 5 | execute/2 6 | ]). 7 | 8 | -include_lib("kernel/include/logger.hrl"). 9 | -include("../include/nova.hrl"). 10 | 11 | execute(Req, Env = #{secure := false}) -> 12 | {ok, Req, Env}; 13 | execute(Req = #{host := Host}, Env = #{secure := Callback}) -> 14 | UseStacktrace = persistent_term:get(nova_use_stacktrace, false), 15 | try Callback(Req) of 16 | Result -> 17 | handle_response(Result, Req, Env) 18 | catch 19 | Class:Reason:Stacktrace when UseStacktrace == true -> 20 | ?LOG_ERROR(#{msg => <<"Security handler crashed">>, 21 | class => Class, 22 | reason => Reason, 23 | stacktrace => Stacktrace}), 24 | Payload = #{status_code => 500, 25 | stacktrace => Stacktrace, 26 | class => Class, 27 | reason => Reason}, 28 | {ok, Req0, _Env} = nova_router:render_status_page(Host, 500, #{}, Req#{crash_info => Payload}, Env), 29 | Req1 = cowboy_req:reply(500, Req0), 30 | {stop, Req1}; 31 | Class:Reason -> 32 | ?LOG_ERROR(#{msg => <<"Security handler crashed">>, 33 | class => Class, 34 | reason => Reason}), 35 | Payload = #{status_code => 500, 36 | class => Class, 37 | reason => Reason}, 38 | {ok, Req0, _Env} = nova_router:render_status_page(Host, 500, #{}, Req#{crash_info => Payload}, Env), 39 | Req1 = cowboy_req:reply(500, Req0), 40 | {stop, Req1} 41 | end. 42 | 43 | 44 | 45 | handle_response({true, AuthData}, Req, Env) -> 46 | case maps:get(cowboy_handler, Env, undefined) of 47 | nova_ws_handler -> 48 | Args = maps:get(arguments, Env, #{}), 49 | {ok, Req, Env#{arguments => Args#{controller_data => #{auth_data => AuthData}}}}; 50 | _ -> 51 | {ok, Req#{auth_data => AuthData}, Env} 52 | end; 53 | handle_response(true, Req, Env) -> 54 | {ok, Req, Env}; 55 | handle_response({false, Headers}, Req, Env) -> 56 | handle_response({false, 401, Headers, false}, Req, Env); 57 | handle_response({false, StatusCode, Headers}, Req, Env) -> 58 | handle_response({false, StatusCode, Headers, false}, Req, Env); 59 | handle_response({false, StatusCode, Headers, Body}, Req = #{host := Host}, Env) -> 60 | {ok, Req1, _Env1} = 61 | case Body of 62 | false -> 63 | {ok, _Req0, _Env0} = nova_router:render_status_page(Host, StatusCode, #{}, Req, Env); 64 | _ -> 65 | Req0 = cowboy_req:set_resp_body(Body, Req), 66 | {ok, Req0, Env} 67 | end, 68 | Req2 = cowboy_req:set_resp_headers(Headers, Req1), 69 | Req3 = cowboy_req:reply(StatusCode, Req2), 70 | {stop, Req3}; 71 | handle_response({redirect, Route}, Req, _Env) -> 72 | Req0 = cowboy_req:set_resp_headers(#{<<"location">> => list_to_binary(Route)}, Req), 73 | Req1 = cowboy_req:reply(302, Req0), 74 | {stop, Req1}; 75 | handle_response(_, Req = #{host := Host}, Env) -> 76 | {ok, Req0, _Env0} = nova_router:render_status_page(Host, 401, #{}, Req, Env), 77 | Req1 = cowboy_req:reply(401, Req0), 78 | {stop, Req1}. 79 | -------------------------------------------------------------------------------- /src/nova_session.erl: -------------------------------------------------------------------------------- 1 | %%% @author Niclas Axelsson 2 | %%% @doc 3 | %%% All kind of operations on sessions is handled by the nova_session-module. The module also 4 | %%% presents a behaviour that can be used to create customized backends for the session-data. 5 | %%% 6 | %%% @end 7 | -module(nova_session). 8 | -export([ 9 | get/2, 10 | set/3, 11 | delete/1, 12 | delete/2, 13 | generate_session_id/0 14 | ]). 15 | 16 | -include_lib("kernel/include/logger.hrl"). 17 | 18 | %%%=================================================================== 19 | %%% Callbacks 20 | %%%=================================================================== 21 | 22 | %% Get a value from a given session_id 23 | -callback get_value(SessionId, Key) -> 24 | {ok, Value :: any()} | 25 | {error, Reason :: atom()} 26 | when SessionId :: binary(), 27 | Key :: binary(). 28 | 29 | %% Set a value 30 | -callback set_value(SessionId, Key, Value) -> 31 | ok | 32 | {error, Reason :: atom()} 33 | when SessionId :: binary(), 34 | Key :: binary(), 35 | Value :: binary(). 36 | 37 | %% Deletes a whole session 38 | -callback delete_value(SessionId) -> 39 | ok | 40 | {error, Reason :: atom()} 41 | when SessionId :: binary(). 42 | 43 | %% Deletes a specific key of a session 44 | -callback delete_value(SessionId, Key) -> 45 | ok | 46 | {error, Reason :: atom()} 47 | when SessionId :: binary(), 48 | Key :: binary(). 49 | 50 | %%%=================================================================== 51 | %%% Public functions 52 | %%%=================================================================== 53 | -spec get(Req :: cowboy_req:req(), Key :: binary()) -> 54 | {ok, Value :: binary()} | {error, Reason :: atom()} | no_return(). 55 | get(Req, Key) -> 56 | case get_session_id(Req) of 57 | {ok, SessionId} -> 58 | Mod = get_session_module(), 59 | Mod:get_value(SessionId, Key); 60 | _ -> 61 | {error, not_found} 62 | end. 63 | 64 | -spec set(Req :: cowboy_req:req(), Key :: binary(), Value :: binary()) -> 65 | ok | {error, Reason :: atom()} | no_return(). 66 | set(Req, Key, Value) -> 67 | case get_session_id(Req) of 68 | {ok, SessionId} -> 69 | Mod = get_session_module(), 70 | Mod:set_value(SessionId, Key, Value); 71 | _ -> 72 | {error, session_id_not_set} 73 | end. 74 | 75 | -spec delete(Req :: cowboy_req:req()) -> {ok, Req :: cowboy_req:req()} | 76 | {error, Reason :: atom()}. 77 | delete(Req) -> 78 | case get_session_id(Req) of 79 | {ok, SessionId} -> 80 | Mod = get_session_module(), 81 | Mod:delete_value(SessionId), 82 | Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, 83 | #{max_age => 0}), 84 | {ok, Req1}; 85 | _ -> 86 | %% Session not found 87 | {ok, Req} 88 | end. 89 | 90 | -spec delete(Req :: cowboy_req:req(), Key :: binary()) -> {ok, Req :: cowboy_req:req()} | 91 | {error, Reason :: atom()} | no_return(). 92 | delete(Req, Key) -> 93 | case get_session_id(Req) of 94 | {ok, SessionId} -> 95 | Mod = get_session_module(), 96 | Mod:delete_value(SessionId, Key), 97 | {ok, Req}; 98 | _ -> 99 | %% Session not found 100 | {ok, Req} 101 | end. 102 | 103 | 104 | %%%=================================================================== 105 | %%% Private functions 106 | %%%=================================================================== 107 | get_session_module() -> 108 | application:get_env(nova, session_manager, nova_session_ets). 109 | 110 | get_session_id(Req) -> 111 | case nova:get_env(use_sessions, true) of 112 | true -> 113 | #{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req), 114 | case SessionId of 115 | undefined -> 116 | {error, not_found}; 117 | _ -> 118 | {ok, SessionId} 119 | end; 120 | _ -> 121 | ?LOG_ERROR(#{msg => <<"Session called but 'use_session' option is set to false">>}), 122 | throw({nova_session, unsupported_session_used}) 123 | end. 124 | 125 | generate_session_id() -> 126 | SessionId = 127 | << <> || 128 | X <- [ rand:uniform(255) || _ <- lists:seq(0, 31) ] >>, 129 | {ok, base64:encode(SessionId)}. 130 | -------------------------------------------------------------------------------- /src/nova_session_ets.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @copyright (C) 2019, Niclas Axelsson 4 | %%% @doc 5 | %%% 6 | %%% @end 7 | %%% Created : 8 Dec 2019 by Niclas Axelsson 8 | %%%------------------------------------------------------------------- 9 | -module(nova_session_ets). 10 | 11 | -behaviour(gen_server). 12 | 13 | %% API 14 | -export([ 15 | start_link/0, 16 | get_value/2, 17 | set_value/3, 18 | delete_value/1, 19 | delete_value/2 20 | ]). 21 | 22 | %% gen_server callbacks 23 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 24 | terminate/2, code_change/3, format_status/1]). 25 | 26 | -define(SERVER, ?MODULE). 27 | -define(TABLE, nova_session_ets_entries). 28 | -define(CHANNEL, '__sessions'). 29 | 30 | -include("../include/nova_pubsub.hrl"). 31 | 32 | -record(state, {}). 33 | 34 | %%%=================================================================== 35 | %%% API 36 | %%%=================================================================== 37 | 38 | %%-------------------------------------------------------------------- 39 | %% @doc 40 | %% Starts the server 41 | %% @end 42 | %%-------------------------------------------------------------------- 43 | -spec start_link() -> {ok, Pid :: pid()} | 44 | {error, Error :: {already_started, pid()}} | 45 | {error, Error :: term()} | 46 | ignore. 47 | start_link() -> 48 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 49 | 50 | 51 | -spec get_value(SessionId :: binary(), Key :: binary()) -> {ok, Value :: binary()} | {error, not_found}. 52 | get_value(SessionId, Key) -> 53 | gen_server:call(?SERVER, {get_value, SessionId, Key}). 54 | 55 | -spec set_value(SessionId :: binary(), Key :: binary(), Value :: binary()) -> ok | {error, Reason :: term()}. 56 | set_value(SessionId, Key, Value) -> 57 | nova_pubsub:broadcast(?CHANNEL, "set_value", {SessionId, Key, Value}). 58 | 59 | -spec delete_value(SessionId :: binary()) -> ok | {error, Reason :: term()}. 60 | delete_value(SessionId) -> 61 | nova_pubsub:broadcast(?CHANNEL, "delete_value", SessionId). 62 | 63 | -spec delete_value(SessionId :: binary(), Key :: binary()) -> ok | {error, Reason :: term()}. 64 | delete_value(SessionId, Key) -> 65 | nova_pubsub:broadcast(?CHANNEL, "delete_value", {SessionId, Key}). 66 | 67 | %%%=================================================================== 68 | %%% gen_server callbacks 69 | %%%=================================================================== 70 | 71 | %%-------------------------------------------------------------------- 72 | %% @private 73 | %% @doc 74 | %% Initializes the server 75 | %% @end 76 | %%-------------------------------------------------------------------- 77 | -spec init(Args :: term()) -> {ok, State :: term()} | 78 | {ok, State :: term(), Timeout :: timeout()} | 79 | {ok, State :: term(), hibernate} | 80 | {stop, Reason :: term()} | 81 | ignore. 82 | init([]) -> 83 | process_flag(trap_exit, true), 84 | ets:new(?TABLE, [set, named_table]), 85 | nova_pubsub:join(?CHANNEL), 86 | {ok, #state{}}. 87 | 88 | %%-------------------------------------------------------------------- 89 | %% @private 90 | %% @doc 91 | %% Handling call messages 92 | %% @end 93 | %%-------------------------------------------------------------------- 94 | -spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) -> 95 | {reply, Reply :: term(), NewState :: term()} | 96 | {reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} | 97 | {reply, Reply :: term(), NewState :: term(), hibernate} | 98 | {noreply, NewState :: term()} | 99 | {noreply, NewState :: term(), Timeout :: timeout()} | 100 | {noreply, NewState :: term(), hibernate} | 101 | {stop, Reason :: term(), Reply :: term(), NewState :: term()} | 102 | {stop, Reason :: term(), NewState :: term()}. 103 | handle_call({get_value, SessionId, Key}, _From, State) -> 104 | case ets:lookup(?TABLE, SessionId) of 105 | [] -> 106 | {reply, {error, not_found}, State}; 107 | [{SessionId, Session}|_] -> 108 | case maps:get(Key, Session, undefined) of 109 | undefined -> 110 | {reply, {error, not_found}, State}; 111 | Value -> 112 | {reply, {ok, Value}, State} 113 | end 114 | end; 115 | handle_call(_Request, _From, State) -> 116 | Reply = ok, 117 | {reply, Reply, State}. 118 | 119 | %%-------------------------------------------------------------------- 120 | %% @private 121 | %% @doc 122 | %% Handling cast messages 123 | %% @end 124 | %%-------------------------------------------------------------------- 125 | -spec handle_cast(Request :: term(), State :: term()) -> 126 | {noreply, NewState :: term()} | 127 | {noreply, NewState :: term(), Timeout :: timeout()} | 128 | {noreply, NewState :: term(), hibernate} | 129 | {stop, Reason :: term(), NewState :: term()}. 130 | handle_cast(_Request, State) -> 131 | {noreply, State}. 132 | 133 | %%-------------------------------------------------------------------- 134 | %% @private 135 | %% @doc 136 | %% Handling all non call/cast messages 137 | %% @end 138 | %%-------------------------------------------------------------------- 139 | -spec handle_info(Info :: timeout() | term(), State :: term()) -> 140 | {noreply, NewState :: term()} | 141 | {noreply, NewState :: term(), Timeout :: timeout()} | 142 | {noreply, NewState :: term(), hibernate} | 143 | {stop, Reason :: normal | term(), NewState :: term()}. 144 | handle_info(#nova_pubsub{topic = "set_value", payload = {SessionId, Key, Value}}, State) -> 145 | case ets:lookup(?TABLE, SessionId) of 146 | [] -> 147 | ets:insert(?TABLE, {SessionId, #{Key => Value}}); 148 | [{_, Session}|_] -> 149 | ets:insert(?TABLE, {SessionId, Session#{Key => Value}}) 150 | end, 151 | {noreply, State}; 152 | handle_info(#nova_pubsub{topic = "delete_value", payload = {SessionId, Key}}, State) -> 153 | case ets:lookup(?TABLE, SessionId) of 154 | [] -> 155 | ok; 156 | [{SessionId, Session}|_] -> 157 | ets:insert(?TABLE, {SessionId, maps:remove(Key, Session)}) 158 | end, 159 | {noreply, State}; 160 | handle_info(#nova_pubsub{topic = "delete_value", payload = SessionId}, State) -> 161 | ets:delete(?TABLE, SessionId), 162 | {noreply, State}; 163 | handle_info(_Info, State) -> 164 | {noreply, State}. 165 | 166 | %%-------------------------------------------------------------------- 167 | %% @private 168 | %% @doc 169 | %% This function is called by a gen_server when it is about to 170 | %% terminate. It should be the opposite of Module:init/1 and do any 171 | %% necessary cleaning up. When it returns, the gen_server terminates 172 | %% with Reason. The return value is ignored. 173 | %% @end 174 | %%-------------------------------------------------------------------- 175 | -spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(), 176 | State :: term()) -> any(). 177 | terminate(_Reason, _State) -> 178 | ok. 179 | 180 | %%-------------------------------------------------------------------- 181 | %% @private 182 | %% @doc 183 | %% Convert process state when code is changed 184 | %% @end 185 | %%-------------------------------------------------------------------- 186 | -spec code_change(OldVsn :: term() | {down, term()}, 187 | State :: term(), 188 | Extra :: term()) -> {ok, NewState :: term()} | 189 | {error, Reason :: term()}. 190 | code_change(_OldVsn, State, _Extra) -> 191 | {ok, State}. 192 | 193 | %%-------------------------------------------------------------------- 194 | %% @private 195 | %% @doc 196 | %% This function is called for changing the form and appearance 197 | %% of gen_server status when it is returned from sys:get_status/1,2 198 | %% or when it appears in termination error logs. 199 | %% @end 200 | %%-------------------------------------------------------------------- 201 | -spec format_status(Status) -> NewStatus when Status :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}, 202 | NewStatus :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}. 203 | format_status(Status) -> 204 | Status. 205 | 206 | %%%=================================================================== 207 | %%% Internal functions 208 | %%%=================================================================== 209 | -------------------------------------------------------------------------------- /src/nova_stream_h.erl: -------------------------------------------------------------------------------- 1 | -module(nova_stream_h). 2 | -behavior(cowboy_stream). 3 | 4 | -export([ 5 | init/3, 6 | data/4, 7 | info/3, 8 | terminate/3, 9 | early_error/5 10 | ]). 11 | 12 | -record(state, { 13 | next :: any(), 14 | req 15 | }). 16 | 17 | -type state() :: #state{}. 18 | 19 | -spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts()) 20 | -> {cowboy_stream:commands(), state()}. 21 | init(StreamID, Req, Opts) -> 22 | Req0 = 23 | case nova:get_env(use_sessions, true) of 24 | true -> 25 | Cookies = cowboy_req:parse_cookies(Req), 26 | case lists:keyfind(<<"session_id">>, 1, Cookies) of 27 | {_, _} -> Req; 28 | _ -> 29 | {ok, SessionId} = nova_session:generate_session_id(), 30 | cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req) 31 | end; 32 | _ -> 33 | Req 34 | end, 35 | %% Set the correct server-header information 36 | Req1 = cowboy_req:set_resp_header(<<"server">>, <<"Cowboy/Nova">>, Req0), 37 | {Commands, Next} = cowboy_stream:init(StreamID, Req1, Opts), 38 | {Commands, #state{req = Req0, next = Next}}. 39 | 40 | -spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State) 41 | -> {cowboy_stream:commands(), State} when State::state(). 42 | data(StreamID, IsFin, Data, State = #state{next = Next}) -> 43 | {Commands, Next0} = cowboy_stream:data(StreamID, IsFin, Data, Next), 44 | {Commands, State#state{next = Next0}}. 45 | 46 | -spec info(cowboy_stream:streamid(), any(), State) 47 | -> {cowboy_stream:commands(), State} when State::state(). 48 | info(StreamID, {response, Code, _Headers, _Body} = Info, State = #state{next = Next}) 49 | when is_integer(Code) -> 50 | {Commands, Next0} = cowboy_stream:info(StreamID, Info, Next), 51 | {Commands, State#state{next = Next0}}; 52 | info(StreamID, Info, State = #state{next = Next}) -> 53 | {Commands, Next0} = cowboy_stream:info(StreamID, Info, Next), 54 | {Commands, State#state{next = Next0}}. 55 | 56 | -spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), state()) -> any(). 57 | terminate(StreamID, Reason, #state{next = Next}) -> 58 | cowboy_stream:terminate(StreamID, Reason, Next). 59 | 60 | -spec early_error(cowboy_stream:streamid(), cowboy_stream:reason(), 61 | cowboy_stream:partial_req(), Resp, cowboy:opts()) 62 | -> Resp 63 | when Resp::cowboy_stream:resp_command(). 64 | early_error(StreamID, Reason, PartialReq, {_, _Status, _Headers, _} = Resp, Opts) -> 65 | cowboy_stream:early_error(StreamID, Reason, PartialReq, Resp, Opts). 66 | -------------------------------------------------------------------------------- /src/nova_sup.erl: -------------------------------------------------------------------------------- 1 | %%% @author Niclas Axelsson 2 | %%% @doc 3 | %%% Nova supervisor 4 | %%% @end 5 | 6 | -module(nova_sup). 7 | 8 | -behaviour(supervisor). 9 | 10 | %% API 11 | -export([start_link/0]). 12 | 13 | %% Supervisor callbacks 14 | -export([init/1]). 15 | 16 | -include_lib("kernel/include/logger.hrl"). 17 | -include("nova.hrl"). 18 | 19 | -define(SERVER, ?MODULE). 20 | -define(NOVA_LISTENER, nova_listener). 21 | -define(NOVA_STD_PORT, 8080). 22 | -define(NOVA_STD_SSL_PORT, 8443). 23 | 24 | 25 | %%%=================================================================== 26 | %%% API functions 27 | %%%=================================================================== 28 | 29 | %%-------------------------------------------------------------------- 30 | %% @doc 31 | %% Starts the supervisor 32 | %% 33 | %% @end 34 | %%-------------------------------------------------------------------- 35 | -spec start_link() -> {ok, Pid :: pid()} | ignore | {error, Error :: any()}. 36 | start_link() -> 37 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 38 | 39 | %%%=================================================================== 40 | %%% Supervisor callbacks 41 | %%%=================================================================== 42 | 43 | %%-------------------------------------------------------------------- 44 | %% @private 45 | %% @doc 46 | %% Whenever a supervisor is started using supervisor:start_link/[2,3], 47 | %% this function is called by the new process to find out about 48 | %% restart strategy, maximum restart intensity, and child 49 | %% specifications. 50 | %% 51 | %% @end 52 | %%-------------------------------------------------------------------- 53 | init([]) -> 54 | %% This is a bit ugly, but we need to do this anyhow(?) 55 | SupFlags = #{strategy => one_for_one, 56 | intensity => 1, 57 | period => 5}, 58 | 59 | Environment = nova:get_environment(), 60 | 61 | nova_pubsub:start(), 62 | 63 | ?LOG_NOTICE(#{msg => <<"Starting nova">>, environment => Environment}), 64 | 65 | Configuration = application:get_env(nova, cowboy_configuration, #{}), 66 | 67 | SessionManager = application:get_env(nova, session_manager, nova_session_ets), 68 | 69 | Children = [ 70 | child(nova_handlers, nova_handlers), 71 | child(SessionManager, SessionManager), 72 | child(nova_watcher, nova_watcher) 73 | ], 74 | 75 | setup_cowboy(Configuration), 76 | 77 | 78 | {ok, {SupFlags, Children}}. 79 | 80 | 81 | 82 | 83 | %%%=================================================================== 84 | %%% Internal functions 85 | %%%=================================================================== 86 | child(Id, Type, Mod, Args) -> 87 | #{id => Id, 88 | start => {Mod, start_link, Args}, 89 | restart => permanent, 90 | shutdown => 5000, 91 | type => Type, 92 | modules => [Mod]}. 93 | 94 | child(Id, Type, Mod) -> 95 | child(Id, Type, Mod, []). 96 | 97 | child(Id, Mod) -> 98 | child(Id, worker, Mod). 99 | 100 | setup_cowboy(Configuration) -> 101 | case start_cowboy(Configuration) of 102 | {ok, App, Host, Port} -> 103 | Host0 = inet:ntoa(Host), 104 | CowboyVersion = get_version(cowboy), 105 | NovaVersion = get_version(nova), 106 | UseStacktrace = application:get_env(nova, use_stacktrace, false), 107 | persistent_term:put(nova_use_stacktrace, UseStacktrace), 108 | ?LOG_NOTICE(#{msg => <<"Nova is running">>, 109 | url => unicode:characters_to_list(io_lib:format("http://~s:~B", [Host0, Port])), 110 | cowboy_version => CowboyVersion, nova_version => NovaVersion, app => App}); 111 | {error, Error} -> 112 | ?LOG_ERROR(#{msg => <<"Cowboy could not start">>, reason => Error}) 113 | end. 114 | 115 | -spec start_cowboy(Configuration :: map()) -> 116 | {ok, BootstrapApp :: atom(), Host :: string() | {integer(), integer(), integer(), integer()}, 117 | Port :: integer()} | {error, Reason :: any()}. 118 | start_cowboy(Configuration) -> 119 | Middlewares = [ 120 | nova_router, %% Lookup routes 121 | nova_plugin_handler, %% Handle pre-request plugins 122 | nova_security_handler, %% Handle security 123 | nova_handler, %% Controller 124 | nova_plugin_handler %% Handle post-request plugins 125 | ], 126 | StreamH = [nova_stream_h, 127 | cowboy_compress_h, 128 | cowboy_stream_h], 129 | StreamHandlers = maps:get(stream_handlers, Configuration, StreamH), 130 | MiddlewareHandlers = maps:get(middleware_handlers, Configuration, Middlewares), 131 | Options = maps:get(options, Configuration, #{compress => true}), 132 | 133 | %% Build the options map 134 | CowboyOptions1 = Options#{middlewares => MiddlewareHandlers, 135 | stream_handlers => StreamHandlers}, 136 | 137 | BootstrapApp = application:get_env(nova, bootstrap_application, undefined), 138 | 139 | %% Compile the routes 140 | Dispatch = 141 | case BootstrapApp of 142 | undefined -> 143 | ?LOG_ERROR(#{msg => <<"You need to define bootstrap_application option in configuration">>}), 144 | throw({error, no_nova_app_defined}); 145 | App -> 146 | ExtraApps = application:get_env(App, nova_apps, []), 147 | nova_router:compile([nova|[App|ExtraApps]]) 148 | end, 149 | 150 | CowboyOptions2 = 151 | case application:get_env(nova, use_persistent_term, true) of 152 | true -> 153 | CowboyOptions1; 154 | _ -> 155 | CowboyOptions1#{env => #{dispatch => Dispatch}} 156 | end, 157 | 158 | Host = maps:get(ip, Configuration, { 0, 0, 0, 0}), 159 | 160 | case maps:get(use_ssl, Configuration, false) of 161 | false -> 162 | Port = maps:get(port, Configuration, ?NOVA_STD_PORT), 163 | case cowboy:start_clear( 164 | ?NOVA_LISTENER, 165 | [{port, Port}, 166 | {ip, Host}], 167 | CowboyOptions2) of 168 | {ok, _Pid} -> 169 | {ok, BootstrapApp, Host, Port}; 170 | Error -> 171 | Error 172 | end; 173 | _ -> 174 | case maps:get(ca_cert, Configuration, undefined) of 175 | undefined -> 176 | Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), 177 | SSLOptions = maps:get(ssl_options, Configuration, #{}), 178 | TransportOpts = maps:put(port, Port, SSLOptions), 179 | TransportOpts1 = maps:put(ip, Host, TransportOpts), 180 | 181 | case cowboy:start_tls( 182 | ?NOVA_LISTENER, maps:to_list(TransportOpts1), CowboyOptions2) of 183 | {ok, _Pid} -> 184 | ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), 185 | {ok, BootstrapApp, Host, Port}; 186 | Error -> 187 | ?LOG_ERROR(#{msg => <<"Could not start cowboy with SSL">>, reason => Error}), 188 | Error 189 | end; 190 | CACert -> 191 | Cert = maps:get(cert, Configuration), 192 | Port = maps:get(ssl_port, Configuration, ?NOVA_STD_SSL_PORT), 193 | ?LOG_DEPRECATED(<<"0.10.3">>, <<"Use of use_ssl is deprecated, use ssl instead">>), 194 | case cowboy:start_tls( 195 | ?NOVA_LISTENER, [ 196 | {port, Port}, 197 | {ip, Host}, 198 | {certfile, Cert}, 199 | {cacertfile, CACert} 200 | ], 201 | CowboyOptions2) of 202 | {ok, _Pid} -> 203 | ?LOG_NOTICE(#{msg => <<"Nova starting SSL">>, port => Port}), 204 | {ok, BootstrapApp, Host, Port}; 205 | Error -> 206 | Error 207 | end 208 | end 209 | end. 210 | 211 | 212 | 213 | get_version(Application) -> 214 | case lists:keyfind(Application, 1, application:loaded_applications()) of 215 | {_, _, Version} -> 216 | Version; 217 | false -> 218 | not_found 219 | end. 220 | -------------------------------------------------------------------------------- /src/nova_watcher.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Niclas Axelsson 3 | %%% @doc 4 | %%% 5 | %%% @end 6 | %%% Created : 3 Mar 2021 by Niclas Axelsson 7 | %%%------------------------------------------------------------------- 8 | -module(nova_watcher). 9 | 10 | -behaviour(gen_server). 11 | 12 | %% API 13 | -export([ 14 | start_link/0, 15 | async_cast/4, 16 | async_cast/3, 17 | async_cast/2, 18 | async_cast/1, 19 | stop/0 20 | ]). 21 | 22 | %% gen_server callbacks 23 | -export([ 24 | init/1, 25 | handle_call/3, 26 | handle_cast/2, 27 | handle_info/2, 28 | terminate/2, 29 | code_change/3, 30 | format_status/1 31 | ]). 32 | 33 | -include_lib("kernel/include/logger.hrl"). 34 | 35 | -define(SERVER, ?MODULE). 36 | 37 | -record(state, { 38 | process_refs = [] :: [pid()] 39 | }). 40 | 41 | %%%=================================================================== 42 | %%% API 43 | %%%=================================================================== 44 | 45 | %%-------------------------------------------------------------------- 46 | %% @doc 47 | %% Starts the server 48 | %% @end 49 | %%-------------------------------------------------------------------- 50 | -spec start_link() -> {ok, Pid :: pid()} | 51 | {error, Error :: {already_started, pid()}} | 52 | {error, Error :: term()} | 53 | ignore. 54 | start_link() -> 55 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 56 | 57 | async_cast(Application, Cmd, Args, Options) -> 58 | gen_server:cast(?SERVER, {async, Application, Cmd, Args, Options}). 59 | 60 | async_cast(Application, Cmd, Options) -> 61 | async_cast(Application, Cmd, [], Options). 62 | 63 | async_cast(Cmd, Options) -> 64 | async_cast(nova:get_main_app(), Cmd, Options). 65 | 66 | async_cast(Cmd) -> 67 | async_cast(Cmd, #{}). 68 | 69 | stop() -> 70 | gen_server:call(?SERVER, stop). 71 | 72 | %%%=================================================================== 73 | %%% gen_server callbacks 74 | %%%=================================================================== 75 | 76 | %%-------------------------------------------------------------------- 77 | %% @private 78 | %% @doc 79 | %% Initializes the server 80 | %% @end 81 | %%-------------------------------------------------------------------- 82 | -spec init(Args :: term()) -> {ok, State :: term()} | 83 | {ok, State :: term(), Timeout :: timeout()} | 84 | {ok, State :: term(), hibernate} | 85 | {stop, Reason :: term()} | 86 | ignore. 87 | init([]) -> 88 | process_flag(trap_exit, true), 89 | CmdList = nova:get_env(watchers, []), 90 | [ erlang:apply(?MODULE, async_cast, tuple_to_list(X)) || X <- CmdList ], 91 | {ok, #state{}}. 92 | 93 | %%-------------------------------------------------------------------- 94 | %% @private 95 | %% @doc 96 | %% Handling call messages 97 | %% @end 98 | %%-------------------------------------------------------------------- 99 | -spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) -> 100 | {reply, Reply :: term(), NewState :: term()} | 101 | {reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} | 102 | {reply, Reply :: term(), NewState :: term(), hibernate} | 103 | {noreply, NewState :: term()} | 104 | {noreply, NewState :: term(), Timeout :: timeout()} | 105 | {noreply, NewState :: term(), hibernate} | 106 | {stop, Reason :: term(), Reply :: term(), NewState :: term()} | 107 | {stop, Reason :: term(), NewState :: term()}. 108 | handle_call(stop, _From, State) -> 109 | {stop, normal, ok, State}; 110 | handle_call(Request, From, State) -> 111 | ?LOG_ERROR(#{msg => <<"Unknown call">>, request => Request, from => From}), 112 | {reply, ok, State}. 113 | 114 | %%-------------------------------------------------------------------- 115 | %% @private 116 | %% @doc 117 | %% Handling cast messages 118 | %% @end 119 | %%-------------------------------------------------------------------- 120 | -spec handle_cast(Request :: term(), State :: term()) -> 121 | {noreply, NewState :: term()} | 122 | {noreply, NewState :: term(), Timeout :: timeout()} | 123 | {noreply, NewState :: term(), hibernate} | 124 | {stop, Reason :: term(), NewState :: term()}. 125 | handle_cast({async, Application, Cmd, Args, Options}, State = #state{process_refs = ProcessRefs}) -> 126 | LibDir = code:lib_dir(Application), 127 | Workdir = 128 | case maps:get(workdir, Options, undefined) of 129 | undefined -> 130 | LibDir; 131 | Subdir -> 132 | filename:join([LibDir, Subdir]) 133 | end, 134 | %% Set working directory 135 | file:set_cwd(Workdir), 136 | ArgList = string:join(Args, " "), 137 | Port = erlang:open_port({spawn, Cmd ++ " " ++ ArgList}, [use_stdio, {line, 1024}, stderr_to_stdout]), 138 | ?LOG_NOTICE(#{action => <<"Started async command">>, command => Cmd, arguments => ArgList}), 139 | {noreply, State#state{process_refs = [Port|ProcessRefs]}}; 140 | handle_cast(_Request, State) -> 141 | {noreply, State}. 142 | 143 | %%-------------------------------------------------------------------- 144 | %% @private 145 | %% @doc 146 | %% Handling all non call/cast messages 147 | %% @end 148 | %%-------------------------------------------------------------------- 149 | -spec handle_info(Info :: timeout() | term(), State :: term()) -> 150 | {noreply, NewState :: term()} | 151 | {noreply, NewState :: term(), Timeout :: timeout()} | 152 | {noreply, NewState :: term(), hibernate} | 153 | {stop, Reason :: normal | term(), NewState :: term()}. 154 | handle_info({_ProcessRef, {data, Data}}, State) -> 155 | Msg = case Data of 156 | {eol, Text} -> Text; 157 | _ -> Data 158 | end, 159 | case nova:get_environment() of 160 | dev -> io:format(user, "~s~n", [Msg]); 161 | _ -> ok %% Ignore the output 162 | end, 163 | {noreply, State}; 164 | handle_info({'EXIT', Ref, Reason}, State = #state{process_refs = Refs}) -> 165 | %% Remove the port from our list 166 | Refs2 = lists:delete(Ref, Refs), 167 | case Reason of 168 | normal -> 169 | ok; 170 | _ -> 171 | ?LOG_WARNING(#{action => <<"Process exited unexpectedly">>, reason => Reason}) 172 | end, 173 | {noreply, State#state{process_refs = Refs2}}; 174 | handle_info(_Info, State) -> 175 | {noreply, State}. 176 | 177 | %%-------------------------------------------------------------------- 178 | %% @private 179 | %% @doc 180 | %% This function is called by a gen_server when it is about to 181 | %% terminate. It should be the opposite of Module:init/1 and do any 182 | %% necessary cleaning up. When it returns, the gen_server terminates 183 | %% with Reason. The return value is ignored. 184 | %% @end 185 | %%-------------------------------------------------------------------- 186 | -spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(), 187 | State :: term()) -> any(). 188 | terminate(_Reason, #state{process_refs = Refs}) -> 189 | %% Clean up the ports 190 | lists:foreach(fun(PortRef) -> 191 | erlang:port_close(PortRef) 192 | end, Refs), 193 | ok. 194 | 195 | %%-------------------------------------------------------------------- 196 | %% @private 197 | %% @doc 198 | %% Convert process state when code is changed 199 | %% @end 200 | %%-------------------------------------------------------------------- 201 | -spec code_change(OldVsn :: term() | {down, term()}, 202 | State :: term(), 203 | Extra :: term()) -> {ok, NewState :: term()} | 204 | {error, Reason :: term()}. 205 | code_change(_OldVsn, State, _Extra) -> 206 | {ok, State}. 207 | 208 | %%-------------------------------------------------------------------- 209 | %% @private 210 | %% @doc 211 | %% This function is called for changing the form and appearance 212 | %% of gen_server status when it is returned from sys:get_status/1,2 213 | %% or when it appears in termination error logs. 214 | %% @end 215 | %%-------------------------------------------------------------------- 216 | -spec format_status(Status) -> NewStatus when Status :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}, 217 | NewStatus :: #{'log'=>[any()], 'message'=>_, 'reason'=>_, 'state'=>_}. 218 | format_status(Status) -> 219 | Status. 220 | 221 | %%%=================================================================== 222 | %%% Internal functions 223 | %%%=================================================================== 224 | 225 | 226 | 227 | 228 | 229 | 230 | %%%=================================================================== 231 | %%% Tests 232 | %%%=================================================================== 233 | 234 | -ifdef(TEST). 235 | -include_lib("eunit/include/eunit.hrl"). 236 | 237 | simple_ls_test_() -> 238 | {setup, 239 | fun() -> 240 | ?MODULE:start_link() 241 | end, 242 | fun(_) -> 243 | ?MODULE:stop() 244 | end, 245 | fun(_) -> 246 | [?_assertEqual(?MODULE:async_cast("ls"), ok)] 247 | end}. 248 | 249 | -endif. 250 | -------------------------------------------------------------------------------- /src/nova_websocket.erl: -------------------------------------------------------------------------------- 1 | -module(nova_websocket). 2 | 3 | -type call_result() :: {ok, State :: map()} | 4 | {ok, State :: map(), hibernate} | 5 | {reply, OutFrame :: cow_ws:frame() | [OutFrame :: cow_ws:frame()], 6 | State :: map()} | 7 | {reply, OutFrame :: cow_ws:frame() | [OutFrame :: cow_ws:frame()], 8 | State :: map(), hibernate} | 9 | {stop, State :: map()}. 10 | -export_type([call_result/0]). 11 | 12 | -type in_frame() :: ping | pong | {text | binary | ping | pong, binary()}. 13 | -export_type([in_frame/0]). 14 | 15 | -type reason() :: normal | stop | timeout | remote | 16 | {remote, cow_ws:close_code(), binary()} | 17 | {error, badencoding | badframe | closed | atom()} | 18 | {crash, error | exit | throw, any()}. 19 | -export_type([reason/0]). 20 | 21 | -callback init(State :: map()) -> {ok, State0 :: map()} | any(). 22 | -callback websocket_init(State :: map()) -> Result :: call_result(). 23 | -optional_callbacks([websocket_init/1]). 24 | 25 | -callback websocket_handle(Frame :: in_frame(), State :: map()) -> call_result(). 26 | -callback websocket_info(Info :: any(), State :: map()) -> call_result(). 27 | -callback terminate(Reason :: reason(), PartialReq :: map(), State :: map()) -> ok. 28 | -optional_callbacks([terminate/3]). 29 | -------------------------------------------------------------------------------- /src/nova_ws_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @author Niclas Axelsson 2 | %%% @doc 3 | %%% Callback controller for handling websockets 4 | %%% @end 5 | 6 | -module(nova_ws_handler). 7 | 8 | -export([ 9 | init/2, 10 | terminate/3, 11 | websocket_init/1, 12 | websocket_handle/2, 13 | websocket_info/2 14 | ]). 15 | 16 | -include_lib("kernel/include/logger.hrl"). 17 | 18 | -type nova_ws_state() :: #{controller_data := map(), 19 | mod := atom(), 20 | _ := _}. 21 | 22 | -export_type([nova_ws_state/0]). 23 | 24 | %%%%%%%%%%%%%%%%%%%%%%%%%%% 25 | %% Public functions %% 26 | %%%%%%%%%%%%%%%%%%%%%%%%%%% 27 | init(Req = #{method := _Method, plugins := Plugins}, State = #{module := Module}) -> 28 | %% Call the http-handler in order to correctly handle potential plugins for the http-request 29 | ControllerData = maps:get(controller_data, State, #{}), 30 | State0 = State#{mod => Module, 31 | plugins => Plugins}, 32 | ControllerData2 = ControllerData#{req => Req}, 33 | upgrade_ws(Module, Req, State0, ControllerData2). 34 | 35 | 36 | upgrade_ws(Module, Req, State, ControllerData) -> 37 | case Module:init(ControllerData) of 38 | {ok, NewControllerData} -> 39 | {cowboy_websocket, Req, State#{controller_data => NewControllerData}}; 40 | Error -> 41 | ?LOG_ERROR(#{msg => <<"Websocket handler returned unknown result">>, handler => Module, returned => Error}), 42 | nova_router:render_status_page(500, Req) 43 | end. 44 | 45 | websocket_init(State = #{mod := Mod}) -> 46 | %% Inject the websocket process into the state 47 | ControllerData = maps:get(controller_data, State, #{}), 48 | NewState = State#{controller_data => ControllerData#{ws_handler_process => self()}}, 49 | 50 | case erlang:function_exported(Mod, websocket_init, 1) of 51 | true -> 52 | handle_ws(Mod, websocket_init, [], NewState); 53 | _ -> 54 | {ok, NewState} 55 | end. 56 | 57 | websocket_handle(Frame, State = #{mod := Mod}) -> 58 | handle_ws(Mod, websocket_handle, [Frame], State). 59 | 60 | websocket_info(Msg, State = #{mod := Mod}) -> 61 | handle_ws(Mod, websocket_info, [Msg], State). 62 | 63 | terminate(Reason, PartialReq, State = #{controller_data := ControllerData, mod := Mod, plugins := Plugins}) -> 64 | case erlang:function_exported(Mod, terminate, 3) of 65 | true -> 66 | erlang:apply(Mod, terminate, [Reason, PartialReq, ControllerData]), 67 | %% Call post_ws_connection-plugins 68 | TerminatePlugins = proplists:get_value(post_ws_connection, Plugins, []), 69 | ControllerState = #{module => Mod, 70 | function => terminate, 71 | arguments => [Reason, PartialReq, State]}, 72 | run_plugins(TerminatePlugins, post_ws_connection, ControllerState, State); 73 | _ -> 74 | ok 75 | end; 76 | terminate(Reason, PartialReq, State) -> 77 | ?LOG_ERROR(#{msg => <<"Terminate called">>, reason => Reason, partial_req => PartialReq, state => State}), 78 | ok. 79 | 80 | 81 | handle_ws(Mod, Func, Args, State = #{controller_data := _ControllerData, plugins := Plugins}) -> 82 | PrePlugins = proplists:get_value(pre_ws_request, Plugins, []), 83 | ControllerState = #{module => Mod, 84 | function => Func, 85 | arguments => Args}, 86 | %% First run the pre-plugins and if everything goes alright we continue 87 | State0 = State#{commands => []}, 88 | case run_plugins(PrePlugins, pre_ws_request, ControllerState, State0) of 89 | {ok, State1} -> 90 | case invoke_controller(Mod, Func, Args, State1) of 91 | {stop, _StopReason} = S -> 92 | S; 93 | State2 -> 94 | %% Run the post-plugins 95 | PostPlugins = proplists:get_value(post_ws_request, Plugins, []), 96 | {ok, State3 = #{commands := Cmds}} = run_plugins(PostPlugins, post_ws_request, 97 | ControllerState, State2), 98 | %% Remove the commands from the map 99 | State4 = maps:remove(commands, State3), 100 | 101 | %% Check if we are hibernating 102 | case maps:get(hibernate, State4, false) of 103 | false -> 104 | {Cmds, State4}; 105 | _ -> 106 | {Cmds, maps:remove(hibernate, State4), hibernate} 107 | end 108 | end; 109 | {stop, _} = Stop -> 110 | ?LOG_WARNING(#{msg => <<"Got stop signal">>, signal => Stop}), 111 | Stop 112 | end; 113 | handle_ws(Mod, Func, Args, State) -> 114 | handle_ws(Mod, Func, Args, State#{controller_data => #{}}). 115 | 116 | %%%%%%%%%%%%%%%%%%%%%%%%%%%% 117 | %% Private functions %% 118 | %%%%%%%%%%%%%%%%%%%%%%%%%%%% 119 | 120 | 121 | invoke_controller(Mod, Func, Args, State = #{controller_data := ControllerData}) -> 122 | try erlang:apply(Mod, Func, Args ++ [ControllerData]) of 123 | RetObj -> 124 | case nova_handlers:get_handler(ws) of 125 | {ok, Callback} -> 126 | Callback(RetObj, State); 127 | {error, not_found} -> 128 | ?LOG_ERROR(#{msg => <<"Websocket handler not found. Check that a handler is 129 | registered on handle 'ws'">>, 130 | controller => Mod, function => Func, return => RetObj}), 131 | {stop, State} 132 | end 133 | catch 134 | Class:Reason:Stacktrace -> 135 | ?LOG_ERROR(#{msg => <<"Controller crashed">>, class => Class, 136 | reason => Reason, stacktrace => Stacktrace}), 137 | {stop, State} 138 | end. 139 | 140 | 141 | run_plugins([], _Callback, #{controller_result := {stop, _} = Signal}, _State) -> 142 | Signal; 143 | run_plugins([], _Callback, _ControllerState, State) -> 144 | {ok, State}; 145 | run_plugins([{Module, Options}|Tl], Callback, ControllerState, State) -> 146 | try erlang:apply(Module, Callback, [ControllerState, State, Options]) of 147 | {ok, ControllerState0, State0} -> 148 | run_plugins(Tl, Callback, ControllerState0, State0); 149 | %% Stop indicates that we want the entire pipe of plugins/controller to be stopped. 150 | {stop, State0} -> 151 | {stop, State0}; 152 | %% Break is used to signal that we are stopping further executing of plugins within the same Callback 153 | {break, State0} -> 154 | {ok, State0}; 155 | {error, Reason} -> 156 | ?LOG_ERROR(#{msg => <<"Plugin returned error">>, plugin => Module, function => Callback, reason => Reason}), 157 | {stop, State} 158 | catch 159 | Class:Reason:Stacktrace -> 160 | ?LOG_ERROR(#{msg => <<"Plugin crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}), 161 | {stop, State} 162 | end. 163 | 164 | 165 | 166 | -ifdef(TEST). 167 | -include_lib("eunit/include/eunit.hrl"). 168 | 169 | -endif. 170 | -------------------------------------------------------------------------------- /src/plugins/nova_correlation_plugin.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @doc 3 | %%%

Plugin Configuration

4 | %%% 5 | %%% To not break backwards compatibility in a minor release, some behavior is behind configuration items. 6 | %%% 7 | %%% 8 | %%%

Request Correlation Header Name

9 | %%% 10 | %%% Set request_correlation_header in the plugin config to read the correlation ID from the request headers. 11 | %%% 12 | %%% Notice: Cowboy request headers are always in lowercase. 13 | %%% 14 | %%%

Default Correlation ID Generation

15 | %%% 16 | %%% If the header name is not defined or the request lacks a correlation ID header, then the plugin generates 17 | %%% a v4 UUID automatically. 18 | %%% 19 | %%%

Logger Metadata Key Override

20 | %%% 21 | %%% Use logger_metadata_key to customize the correlation ID key in OTP logger process metadata. By default it is set to <<"correlation-id">>. 22 | %%% 23 | %%%

Correlation ID in Request Object

24 | %%% 25 | %%% The plugin defines a field called correlation_id in the request object for controller use if it makes further requests that it want to pass on the correlation id to. 26 | %%% 27 | %%%

Example configuration

28 | %%%
 <<"x-correlation-id">>,
32 | %%%             logger_metadata_key => correlation_id
33 | %%%         }}
34 | %%%     ]}
35 | %%% ]]>
36 | %%% 37 | %%% @end 38 | %%%------------------------------------------------------------------- 39 | -module(nova_correlation_plugin). 40 | -behaviour(nova_plugin). 41 | 42 | -export([pre_request/2, 43 | post_request/2, 44 | plugin_info/0]). 45 | 46 | -include_lib("kernel/include/logger.hrl"). 47 | 48 | %%-------------------------------------------------------------------- 49 | %% @doc 50 | %% Pre-request callback to either pick up correlation id from request headers 51 | %% or generate a new uuid correlation id. 52 | %% @end 53 | %%-------------------------------------------------------------------- 54 | 55 | pre_request(Req0, Opts) -> 56 | CorrId = get_correlation_id(Req0, Opts), 57 | %% Update the loggers metadata with correlation-id 58 | ok = update_logger_metadata(CorrId, Opts), 59 | Req1 = cowboy_req:set_resp_header(<<"x-correlation-id">>, CorrId, Req0), 60 | Req = Req1#{correlation_id => CorrId}, 61 | {ok, Req}. 62 | 63 | post_request(Req, _) -> 64 | {ok, Req}. 65 | 66 | plugin_info() -> 67 | { 68 | <<"nova_correlation_plugin">>, 69 | <<"0.2.0">>, 70 | <<"Nova team >, 71 | <<"Add X-Correlation-ID headers to response">>, 72 | [] 73 | }. 74 | 75 | get_correlation_id(Req, #{ request_correlation_header := CorrelationHeader }) -> 76 | CorrelationHeaderLower = jhn_bstring:to_lower(CorrelationHeader), 77 | case cowboy_req:header(CorrelationHeaderLower, Req) of 78 | undefined -> 79 | uuid(); 80 | CorrId -> 81 | CorrId 82 | end; 83 | get_correlation_id(_Req, _Opts) -> 84 | uuid(). 85 | 86 | uuid() -> 87 | jhn_uuid:gen(v4, [binary]). 88 | 89 | update_logger_metadata(CorrId, Opts) -> 90 | LoggerKey = maps:get(logger_metadata_key, Opts, <<"correlation-id">>), 91 | logger:update_process_metadata(#{LoggerKey => CorrId}). 92 | -------------------------------------------------------------------------------- /src/plugins/nova_cors_plugin.erl: -------------------------------------------------------------------------------- 1 | -module(nova_cors_plugin). 2 | -behaviour(nova_plugin). 3 | 4 | -export([ 5 | pre_request/2, 6 | post_request/2, 7 | plugin_info/0 8 | ]). 9 | 10 | %%-------------------------------------------------------------------- 11 | %% @doc 12 | %% Pre-request callback 13 | %% @end 14 | %%-------------------------------------------------------------------- 15 | -spec pre_request(Req :: cowboy_req:req(), Options :: map()) -> 16 | {ok, Req0 :: cowboy_req:req()}. 17 | pre_request(Req, #{allow_origins := Origins}) -> 18 | ReqWithOptions = add_cors_headers(Req, Origins), 19 | continue(ReqWithOptions). 20 | 21 | %%-------------------------------------------------------------------- 22 | %% @doc 23 | %% Post-request callback 24 | %% @end 25 | %%-------------------------------------------------------------------- 26 | -spec post_request(Req :: cowboy_req:req(), Options :: map()) -> 27 | {ok, Req0 :: cowboy_req:req()}. 28 | post_request(Req, _) -> 29 | {ok, Req}. 30 | 31 | %%-------------------------------------------------------------------- 32 | %% @doc 33 | %% nova_plugin callback. Returns information about the plugin. 34 | %% @end 35 | %%-------------------------------------------------------------------- 36 | -spec plugin_info() -> {Title :: binary(), 37 | Version :: binary(), 38 | Author :: binary(), 39 | Description :: binary(), 40 | Options :: [{Key :: atom(), OptionDescription :: binary()}]}. 41 | plugin_info() -> 42 | { 43 | <<"nova_cors_plugin">>, 44 | <<"0.2.0">>, 45 | <<"Nova team >, 46 | <<"Add CORS headers to request">>, 47 | [ 48 | { 49 | allow_origins, 50 | <<"Specifies which origins to insert into Access-Control-Allow-Origin">> 51 | } 52 | ]}. 53 | 54 | %%%%%%%%%%%%%%%%%%%%%% 55 | %% Private functions 56 | %%%%%%%%%%%%%%%%%%%%%% 57 | 58 | continue(#{method := <<"OPTIONS">>} = Req) -> 59 | Reply = cowboy_req:reply(200, Req), 60 | {stop, Reply}; 61 | continue(Req) -> 62 | {ok, Req}. 63 | add_cors_headers(Req, Origins) -> 64 | OriginsReq = cowboy_req:set_resp_header( 65 | <<"Access-Control-Allow-Origin">>, Origins, Req), 66 | HeadersReq = cowboy_req:set_resp_header( 67 | <<"Access-Control-Allow-Headers">>, <<"*">>, OriginsReq), 68 | cowboy_req:set_resp_header( 69 | <<"Access-Control-Allow-Methods">>, <<"*">>, HeadersReq). 70 | -------------------------------------------------------------------------------- /src/plugins/nova_request_plugin.erl: -------------------------------------------------------------------------------- 1 | -module(nova_request_plugin). 2 | -behaviour(nova_plugin). 3 | 4 | -export([ 5 | pre_request/2, 6 | post_request/2, 7 | plugin_info/0 8 | ]). 9 | 10 | %%-------------------------------------------------------------------- 11 | %% @doc 12 | %% Pre-request callback 13 | %% @end 14 | %%-------------------------------------------------------------------- 15 | -spec pre_request(Req :: cowboy_req:req(), Options :: map()) -> 16 | {ok, Req0 :: cowboy_req:req()}. 17 | pre_request(Req, Options) -> 18 | ListOptions = maps:to_list(Options), 19 | %% Read the body and put it into the Req object 20 | BodyReq = case should_read_body(ListOptions) andalso cowboy_req:has_body(Req) of 21 | true -> 22 | read_body(Req, <<>>); 23 | false -> 24 | Req#{body => <<>>} 25 | end, 26 | modulate_state(BodyReq, ListOptions). 27 | 28 | %%-------------------------------------------------------------------- 29 | %% @doc 30 | %% Post-request callback 31 | %% @end 32 | %%-------------------------------------------------------------------- 33 | -spec post_request(Req :: cowboy_req:req(), Options :: map()) -> 34 | {ok, Req0 :: cowboy_req:req()}. 35 | post_request(Req, _Options) -> 36 | {ok, Req}. 37 | 38 | 39 | %%-------------------------------------------------------------------- 40 | %% @doc 41 | %% nova_plugin callback. Returns information about the plugin. 42 | %% @end 43 | %%-------------------------------------------------------------------- 44 | -spec plugin_info() -> {Title :: binary(), 45 | Version :: binary(), 46 | Author :: binary(), 47 | Description :: binary(), 48 | Options :: [{Key :: atom(), OptionDescription :: binary()}]}. 49 | plugin_info() -> 50 | {<<"Nova body plugin">>, 51 | <<"0.0.1">>, 52 | <<"Nova team >, 53 | <<"This plugin modulates the body of a request.">>, 54 | [ 55 | {decode_json_body, <<"Decodes the body as JSON and puts it under `json`">>}, 56 | {read_urlencoded_body, <<"Used to parse body as query-string and put them in state under `qs` key">>} 57 | ]}. 58 | 59 | 60 | %%%%%%%%%%%%%%%%%%%%%% 61 | %% Private functions 62 | %%%%%%%%%%%%%%%%%%%%%% 63 | 64 | modulate_state(Req, []) -> 65 | {ok, Req}; 66 | 67 | modulate_state( Req = #{method := Method}, [{decode_json_body, true}|Tail]) when Method =:= <<"GET">>; Method =:= <<"DELETE">> -> 68 | modulate_state(Req, Tail); 69 | modulate_state(Req = #{headers := #{<<"content-type">> := <<"application/json", _/binary>>}, body := <<>>}, [{decode_json_body, true}|_Tl]) -> 70 | Req400 = cowboy_req:reply(400, Req), 71 | logger:warning(#{status_code => 400, 72 | msg => "Failed to decode json.", 73 | error => "No body to decode."}), 74 | {stop, Req400}; 75 | modulate_state(Req = #{headers := #{<<"content-type">> := <<"application/json", _/binary>>}, body := Body}, [{decode_json_body, true}|Tl]) -> 76 | %% Decode the data 77 | JsonLib = nova:get_env(json_lib, thoas), 78 | case erlang:apply(JsonLib, decode, [Body]) of 79 | {ok, JSON} -> 80 | modulate_state(Req#{json => JSON}, Tl); 81 | Error -> 82 | Req400 = cowboy_req:reply(400, Req), 83 | logger:warning(#{status_code => 400, 84 | msg => "Failed to decode json.", 85 | error => Error}), 86 | {stop, Req400} 87 | end; 88 | modulate_state(#{headers := #{<<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>}, body := Body} = Req, 89 | [{read_urlencoded_body, true}|Tl]) -> 90 | Data = cow_qs:parse_qs(Body), 91 | %% First read in the body 92 | Params = maps:from_list(Data), 93 | modulate_state(Req#{params => Params}, Tl); 94 | modulate_state(Req, [{parse_qs, Type}|T1]) -> 95 | Qs = cowboy_req:parse_qs(Req), 96 | case Type of 97 | true -> MapQs = maps:from_list(Qs), 98 | modulate_state(Req#{parsed_qs => MapQs}, T1); 99 | list -> modulate_state(Req#{parsed_qs => Qs}, T1) 100 | end; 101 | modulate_state(State, [_|Tl]) -> 102 | modulate_state(State, Tl). 103 | 104 | read_body(Req, Acc) -> 105 | case cowboy_req:read_body(Req) of 106 | {ok, Data, Req0} -> Req0#{body => <>}; 107 | {more, Data, Req0} -> read_body(Req0, <>) 108 | end. 109 | 110 | should_read_body([]) -> false; 111 | should_read_body([{decode_json_body, true}|_Tl]) -> true; 112 | should_read_body([{read_urlencoded_body, true}|_Tl]) -> true; 113 | should_read_body([_|Tl]) -> should_read_body(Tl). 114 | -------------------------------------------------------------------------------- /src/views/nova_file.dtl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Listing of {{path}} 4 | 5 | 6 | 7 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% if parent_dir %} 42 | 43 | 44 | 45 | {% endif %} 46 | 47 | {% for file in files %} 48 | {% if file %} 49 | 50 | 51 | 52 | 53 | 54 | {% endif %} 55 | {% endfor %} 56 | 57 | 60 | 61 |

{{path}}

NameSizeLast Modified
.. Parent directory
{% if file.type == "directory" %}{% else %}{% endif %} {{file.filename}}{{file.size|filesizeformat}}{{file.last_modified|date:"c"}}
58 | Generated at {{date|date:"c"}} by Nova Framework 59 |
62 | 63 | 64 | 65 | --------------------------------------------------------------------------------