├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── FAQ.md ├── LICENSE ├── README.md ├── assets ├── request_flow.svg └── response_flow.svg ├── bench └── middleware.exs ├── chronical.md ├── extensions ├── raxx_head_middleware │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── lib │ │ └── raxx │ │ │ └── head_middleware.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ │ ├── raxx │ │ └── head_middleware_test.exs │ │ └── test_helper.exs ├── raxx_logger │ ├── .formatter.exs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── lib │ │ └── raxx │ │ │ └── logger.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ │ ├── raxx │ │ └── logger_test.exs │ │ └── test_helper.exs ├── raxx_method_override │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── lib │ │ └── raxx │ │ │ └── method_override.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ │ ├── raxx │ │ └── method_override_test.exs │ │ └── test_helper.exs ├── raxx_session │ ├── .formatter.exs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── lib │ │ └── raxx │ │ │ ├── session.ex │ │ │ └── session │ │ │ ├── csrf_protection.ex │ │ │ ├── encrypted_session.ex │ │ │ └── signed_cookie.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ │ ├── raxx │ │ ├── session │ │ │ └── plug_compatibility_test.exs │ │ └── session_test.exs │ │ └── test_helper.exs └── raxx_view │ ├── .formatter.exs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── lib │ └── raxx │ │ ├── layout.ex │ │ ├── view.ex │ │ └── view │ │ └── layout.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ ├── raxx │ ├── other.html.eex │ ├── partial.html.eex │ ├── view │ │ ├── layout_test.exs │ │ ├── layout_test.html.eex │ │ └── layout_test_example.html.eex │ ├── view_test.exs │ ├── view_test.html.eex │ ├── view_test_invalid_layout.html.eex │ ├── view_test_layout.html.eex │ └── view_test_other.html.eex │ └── test_helper.exs ├── getting_started.md ├── lib ├── raxx.ex ├── raxx │ ├── context.ex │ ├── data.ex │ ├── http1.ex │ ├── middleware.ex │ ├── request.ex │ ├── response.ex │ ├── router.ex │ ├── server.ex │ ├── server │ │ └── return_error.ex │ ├── simple_server.ex │ ├── stack.ex │ └── tail.ex └── status.rfc7231 ├── mix.exs ├── mix.lock └── test ├── raxx ├── benchmarks_test.exs ├── context_test.exs ├── http1_test.exs ├── request_test.exs ├── router_test.exs ├── server_test.exs └── stack_test.exs ├── raxx_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /docs 5 | /doc 6 | erl_crash.dump 7 | *.ez 8 | .tool-versions 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | matrix: 3 | include: 4 | - otp_release: 20.3 5 | elixir: 1.7.4 6 | - otp_release: 21.3 7 | elixir: 1.8.1 8 | - otp_release: 22.0 9 | elixir: 1.8.2 10 | env: 11 | - MIX_ENV=test 12 | before_script: 13 | - mix local.hex --force 14 | - mix deps.get 15 | - for dir in ./extensions/*; do ( cd "$dir" && mix deps.get ); done 16 | script: 17 | - mix test --trace --include deprecations 18 | - mix format --check-formatted 19 | - mix dialyzer --halt-exit-status 20 | - for dir in ./extensions/*; do ( cd "$dir" && mix test ); done 21 | - for dir in ./extensions/*; do ( cd "$dir" && mix format --check-formatted ); done 22 | cache: 23 | directories: 24 | - _build 25 | before_cache: 26 | # should only keep the dialyzer artifacts 27 | - mix clean 28 | - mix deps.clean --all 29 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### Where is 422 `unprocessable_entity`? 4 | 5 | 422 Unprocessable Entity was never part of the official spec, but it was used because the official definition of 400 Bad Request in [RFC 2616](https://tools.ietf.org/html/rfc2616#page-65) was too strict and only covered cases when the client sent a request with malformed syntax. 6 | 7 | [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.5.1) expands the definition of 400 Bad Request to include anything perceived to be a client error, removing the need for a separate 422 response. 8 | 9 | ### Why are HTTP Headers not case-insensitive? 10 | 11 | AS much as possible we adher to [RFC 7540] (https://tools.ietf.org/html/rfc7540#section-8.1.2) 12 | > Just as in HTTP/1.x, header field names are strings of ASCII 13 | characters that are compared in a case-insensitive fashion. However, 14 | header field names MUST be converted to lowercase prior to their 15 | encoding in HTTP/2. A request or response containing uppercase 16 | header field names MUST be treated as malformed (Section 8.1.2.6). 17 | 18 | ### Why does Raxx not handle setting a Date header 19 | 20 | > An origin server MUST NOT send a Date header field if it does not 21 | have a clock capable of providing a reasonable approximation of the 22 | current instance in Coordinated Universal Time. An origin server MAY 23 | send a Date header field if the response is in the 1xx 24 | (Informational) or 5xx (Server Error) class of status codes. An 25 | origin server MUST send a Date header field in all other cases. 26 | 27 | https://tools.ietf.org/html/rfc7231#section-7.1.1.2 28 | 29 | Any server may be incapable of providing accurate time and date information. 30 | For this reason Raxx delegates handling the `Date` header to the server underneath it. 31 | -------------------------------------------------------------------------------- /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 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Raxx 2 | 3 | **Interface for HTTP webservers, frameworks and clients.** 4 | 5 | [![Hex pm](http://img.shields.io/hexpm/v/raxx.svg?style=flat)](https://hex.pm/packages/raxx) 6 | [![Build Status](https://secure.travis-ci.org/CrowdHailer/raxx.svg?branch=master 7 | "Build Status")](https://travis-ci.org/CrowdHailer/raxx) 8 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 9 | 10 | - [Install from hex.pm](https://hex.pm/packages/raxx) 11 | - [Documentation available on hexdoc](https://hexdocs.pm/raxx) 12 | - [Discuss on slack](https://elixir-lang.slack.com/messages/C56H3TBH8/) 13 | 14 | See [Raxx.Kit](https://github.com/CrowdHailer/raxx_kit) for a project generator that helps you set up 15 | a web project based on [Raxx](https://github.com/CrowdHailer/raxx)/[Ace](https://github.com/CrowdHailer/Ace). 16 | 17 | ## Simple server 18 | 19 | #### 1. Defining a server 20 | 21 | ```elixir 22 | defmodule MyServer do 23 | use Raxx.SimpleServer 24 | 25 | @impl Raxx.SimpleServer 26 | def handle_request(%{method: :GET, path: []}, _state) do 27 | response(:ok) 28 | |> set_header("content-type", "text/plain") 29 | |> set_body("Hello, World!") 30 | end 31 | 32 | def handle_request(%{method: :GET, path: _}, _state) do 33 | response(:not_found) 34 | |> set_header("content-type", "text/plain") 35 | |> set_body("Oops! Nothing here.") 36 | end 37 | end 38 | ``` 39 | 40 | - *A request's path is split into segments. 41 | A request to `GET /` has path `[]`.* 42 | 43 | #### 2. Running a server 44 | 45 | To start a Raxx server a compatible HTTP server is needed. 46 | This example uses [Ace](https://github.com/crowdhailer/ace) that can serve both HTTP/1 and HTTP/2. 47 | 48 | ```elixir 49 | raxx_server = {MyServer, nil} 50 | http_options = [port: 8080, cleartext: true] 51 | 52 | {:ok, pid} = Ace.HTTP.Service.start_link(raxx_server, http_options) 53 | ``` 54 | 55 | - *The second element in the Raxx server tuple is passed as the second argument to the `handle_request/2` callback. 56 | In this example it is unused and so set to nil.* 57 | 58 | Start your project and visit [http://localhost:8080](http://localhost:8080). 59 | 60 | ## HTTP streaming 61 | 62 | An HTTP exchange involves a client sending data to a server receiving a response. 63 | A simple view is to model this as a single message sent in each direction. 64 | *Working with this model corresponds to `Raxx.SimpleServer` callbacks.* 65 | 66 | ```txt 67 | request --> 68 | Client ============================================ Server 69 | <-- response 70 | ``` 71 | 72 | When the simple model is insufficient Raxx exposes a lower model. 73 | This consists of a series of messages in each direction. 74 | *Working with this model corresponds to `Raxx.Server` callbacks.* 75 | 76 | ```txt 77 | tail | data(1+) | head(request) --> 78 | Client ============================================ Server 79 | <-- head(response) | data(1+) | tail 80 | ``` 81 | 82 | - *The body of a request or a response, is the combination of all data parts sent.* 83 | 84 | #### Stateful server 85 | 86 | The `LongPoll` server is stateful. 87 | After receiving a complete request this server has to wait for extra input before sending a response to the client. 88 | 89 | ```elixir 90 | defmodule LongPoll do 91 | use Raxx.Server 92 | 93 | @impl Raxx.Server 94 | def handle_head(%{method: :GET, path: ["slow"]}, state) do 95 | Process.send_after(self(), :reply, 30_000) 96 | 97 | {[], state} 98 | end 99 | 100 | @impl Raxx.Server 101 | def handle_info(:reply, _state) do 102 | response(:ok) 103 | |> set_header("content-type", "text/plain") 104 | |> set_body("Hello, Thanks for waiting.") 105 | end 106 | end 107 | ``` 108 | - *A long lived server needs to return two things; the message parts to send, in this case nothing `[]`; 109 | and the new state of the server, in this case no change `state`.* 110 | - *The `initial_state` is configured when the server is started.* 111 | 112 | #### Server streaming 113 | 114 | The `SubscribeToMessages` server streams its response. 115 | The server will send the head of the response upon receiving the request. 116 | Data is sent to the client, as part of the body, when it becomes available. 117 | The response is completed when the chatroom sends a `:closed` message. 118 | 119 | ```elixir 120 | defmodule SubscribeToMessages do 121 | use Raxx.Server 122 | 123 | @impl Raxx.Server 124 | def handle_head(%{method: :GET, path: ["messages"]}, state) do 125 | {:ok, _} = ChatRoom.join() 126 | outbound = response(:ok) 127 | |> set_header("content-type", "text/plain") 128 | |> set_body(true) 129 | 130 | {[outbound], state} 131 | end 132 | 133 | @impl Raxx.Server 134 | def handle_info({ChatRoom, :closed}, state) do 135 | outbound = tail() 136 | 137 | {[outbound], state} 138 | end 139 | 140 | def handle_info({ChatRoom, data}, state) do 141 | outbound = data(data) 142 | 143 | {[outbound], state} 144 | end 145 | end 146 | ``` 147 | - *Using `set_body(true)` marks that the response has a body that it is not yet known.* 148 | - *A stream must have a tail to complete, metadata added here will be sent as trailers.* 149 | 150 | #### Client streaming 151 | 152 | The `Upload` server writes data to a file as it is received. 153 | Only once the complete request has been received is a response sent. 154 | 155 | ```elixir 156 | defmodule Upload do 157 | use Raxx.Server 158 | 159 | @impl Raxx.Server 160 | def handle_head(%{method: :PUT, path: ["upload"] body: true}, _state) do 161 | {:ok, io_device} = File.open("my/path") 162 | {[], {:file, device}} 163 | end 164 | 165 | @impl Raxx.Server 166 | def handle_data(data, state = {:file, device}) do 167 | IO.write(device, data) 168 | {[], state} 169 | end 170 | 171 | @impl Raxx.Server 172 | def handle_tail(_trailers, state) do 173 | response(:see_other) 174 | |> set_header("location", "/") 175 | end 176 | end 177 | ``` 178 | - *A body may arrive split by packets, chunks or frames. 179 | `handle_data` will be invoked as each part arrives. 180 | An application should never assume how a body will be broken into data parts.* 181 | 182 | #### Request/Response flow 183 | 184 | It is worth noting what guarantees are given on the request parts passed to the 185 | Server's `handle_*` functions. It depends on the Server type, 186 | `Raxx.Server` vs `Raxx.SimpleServer`: 187 | 188 | 189 | ![request flow](assets/request_flow.svg) 190 | 191 | So, for example, after a `%Raxx.Request{body: false}` is passed to a Server's `c:Raxx.Server.handle_head/2` 192 | callback, no further request parts will be passed to to the server (`c:Raxx.Server.handle_info/2` 193 | messages might be, though). 194 | 195 | Similarly, these are the valid sequences of the response parts returned from the Servers: 196 | 197 | 198 | ![response flow](assets/response_flow.svg) 199 | 200 | Any `Raxx.Middleware`s should follow the same logic. 201 | 202 | #### Router 203 | 204 | The `Raxx.Router` can be used to match requests to specific server modules. 205 | 206 | ```elixir 207 | defmodule MyApp do 208 | use Raxx.Server 209 | 210 | use Raxx.Router, [ 211 | {%{method: :GET, path: []}, HomePage}, 212 | {%{method: :GET, path: ["slow"]}, LongPoll}, 213 | {%{method: :GET, path: ["messages"]}, SubscribeToMessages}, 214 | {%{method: :PUT, path: ["upload"]}, Upload}, 215 | {_, NotFoundPage} 216 | ] 217 | end 218 | ``` 219 | -------------------------------------------------------------------------------- /assets/request_flow.svg: -------------------------------------------------------------------------------- 1 | 2 | %Request{body: false}the request has no bodydataa portion of the request body%Request{body: str}'str' is a binary - the full body%Request{body: true}the request body will followtailtrailers signalising the end of the request
Raxx.Server flow
[Not supported by viewer]

<div style="text-align: left"><br></div><div style="text-align: left"></div>
Raxx.SimpleServer flow
[Not supported by viewer]
-------------------------------------------------------------------------------- /bench/middleware.exs: -------------------------------------------------------------------------------- 1 | defmodule Macroware do 2 | defmacro __using__(_) do 3 | quote do 4 | defoverridable call: 1 5 | 6 | def call(value) do 7 | super(value + 1) 8 | end 9 | end 10 | end 11 | end 12 | 13 | defmodule MacroServer do 14 | def call(i) do 15 | i 16 | end 17 | 18 | for _ <- 1..1000 do 19 | use Macroware 20 | end 21 | end 22 | 23 | 1000 = MacroServer.call(0) 24 | 25 | defmodule Middleware do 26 | def call(value, [next | rest]) do 27 | next.call(value + 1, rest) 28 | end 29 | end 30 | 31 | defmodule Server do 32 | def call(value, []) do 33 | value 34 | end 35 | end 36 | 37 | stack = for(_ <- 1..999, do: Middleware) ++ [Server] 38 | 1000 = Middleware.call(0, stack) 39 | 40 | Benchee.run(%{ 41 | "macro" => fn -> MacroServer.call(0) end, 42 | "stack" => fn -> Middleware.call(0, stack) end 43 | }) 44 | -------------------------------------------------------------------------------- /chronical.md: -------------------------------------------------------------------------------- 1 | ## 2019-02-06 2 | 3 | Use the process dictionary for Raxx.Context. 4 | 5 | This was due to the forseen cost in upgrading every project to take one extra argument in callbacks, particularly in the cases where the context was not going to be used. 6 | 7 | ## 2019-02-05 8 | 9 | Don't implement default callbacks for behaviours, with debug information. 10 | 11 | It is easier to work with the compiler warnings for an unimplemented callback, 12 | than to define helper messages for each usecase. 13 | 14 | [See discussion](https://elixirforum.com/t/should-libraries-implement-default-callbacks-that-are-intended-to-always-be-overwritten/19915) 15 | 16 | ## 2019-01-30 17 | 18 | Choosing where code for extensions should reside. 19 | 20 | 1. Each in their own github repo 21 | 2. As a subdirectory in the Raxx project 22 | 23 | Advantages of 1. 24 | - CI with travis is simple, I don't know how to set up travis to only run tests on a subdirectory which changes, this option avoids this complexity. 25 | - Someone else can start a Raxx extension in their own repo, this is still possible with 2 but the ones in Raxx project may feel "blessed". 26 | 27 | Advantages of 2. 28 | - Simpler contribution for fixes that apply to multiple projects 29 | - One place for myself, and other contributors to go to look for open issues. 30 | - Easy first place to look for things you might expect like CORS or Sessions. 31 | - New features can be added to the core extensions in one PR 32 | - mix can still depend on projects from github using the sparse options. `{:raxx_static, github: "crowdhailer/raxx_kit", branch: "runtime-static-middleware", sparse: "extensions/raxx_static"}` 33 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | raxx_head_middleware-*.tar 24 | 25 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/README.md: -------------------------------------------------------------------------------- 1 | # Raxx.HeadMiddleware 2 | 3 | Convert HEAD requests to GET requests. 4 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/lib/raxx/head_middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.HeadMiddleware do 2 | @moduledoc """ 3 | An example router that allows you to handle HEAD requests with GET handlers 4 | """ 5 | 6 | alias Raxx.Server 7 | alias Raxx.Middleware 8 | 9 | @behaviour Middleware 10 | 11 | @impl Middleware 12 | def process_head(request = %{method: :HEAD}, _config, inner_server) do 13 | request = %{request | method: :GET} 14 | state = :engage 15 | {parts, inner_server} = Server.handle_head(inner_server, request) 16 | 17 | parts = modify_response_parts(parts, state) 18 | {parts, state, inner_server} 19 | end 20 | 21 | def process_head(request = %{method: _}, _config, inner_server) do 22 | {parts, inner_server} = Server.handle_head(inner_server, request) 23 | {parts, :disengage, inner_server} 24 | end 25 | 26 | @impl Middleware 27 | def process_data(data, state, inner_server) do 28 | {parts, inner_server} = Server.handle_data(inner_server, data) 29 | parts = modify_response_parts(parts, state) 30 | {parts, state, inner_server} 31 | end 32 | 33 | @impl Middleware 34 | def process_tail(tail, state, inner_server) do 35 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 36 | parts = modify_response_parts(parts, state) 37 | {parts, state, inner_server} 38 | end 39 | 40 | @impl Middleware 41 | def process_info(info, state, inner_server) do 42 | {parts, inner_server} = Server.handle_info(inner_server, info) 43 | parts = modify_response_parts(parts, state) 44 | {parts, state, inner_server} 45 | end 46 | 47 | defp modify_response_parts(parts, :disengage) do 48 | parts 49 | end 50 | 51 | defp modify_response_parts(parts, :engage) do 52 | Enum.flat_map(parts, &do_handle_response_part(&1)) 53 | end 54 | 55 | defp do_handle_response_part(response = %Raxx.Response{}) do 56 | # the content-length should remain the same 57 | [%Raxx.Response{response | body: false}] 58 | end 59 | 60 | defp do_handle_response_part(%Raxx.Data{}) do 61 | [] 62 | end 63 | 64 | defp do_handle_response_part(%Raxx.Tail{}) do 65 | [] 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxHeadMiddleware.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_head_middleware, 7 | version: "0.1.0", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | {:raxx, "~> 0.18.0 or ~> 1.0"}, 23 | {:ex_doc, ">= 0.0.0", only: :dev} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 3 | "eex_html": {:hex, :eex_html, "0.2.0", "7f9da3e8ec2ecda2658a0443c65321e161c7c89897f2011e3d8a70f4f68341cb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 9 | "raxx": {:hex, :raxx, "1.0.0", "e74f229340345a3ea94deaace6e4d78a0b9fd065010e1d0f80746760c728b2ee", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/test/raxx/head_middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.HeadMiddlewareTest do 2 | use ExUnit.Case 3 | import Raxx 4 | 5 | alias Raxx.Server 6 | 7 | defmodule SimpleApp do 8 | use Raxx.SimpleServer 9 | 10 | @impl Raxx.SimpleServer 11 | def handle_request(request, _) do 12 | send(self(), request) 13 | 14 | response(:ok) 15 | |> set_body("Hello, World!") 16 | end 17 | end 18 | 19 | setup do 20 | stack = Raxx.Stack.new([{Raxx.HeadMiddleware, nil}], {SimpleApp, nil}) 21 | {:ok, stack: stack} 22 | end 23 | 24 | test "The response to a GET request is unchanged", %{stack: stack} do 25 | request = request(:GET, "/") 26 | 27 | assert {[response], _} = Server.handle_head(stack, request) 28 | assert_receive %{method: :GET} 29 | assert response.body == "Hello, World!" 30 | end 31 | 32 | test "The response to a HEAD request is returned without body", %{stack: stack} do 33 | request = request(:HEAD, "/") 34 | 35 | assert {[response], _} = Server.handle_head(stack, request) 36 | assert_receive %{method: :GET} 37 | assert get_content_length(response) == 13 38 | assert response.body == false 39 | end 40 | 41 | test "A POST request is unchanged", %{stack: stack} do 42 | request = 43 | request(:POST, "/") 44 | |> set_body(true) 45 | 46 | assert {[], stack} = Server.handle_head(stack, request) 47 | assert {[], stack} = Server.handle_data(stack, "some data") 48 | assert {[response], _} = Server.handle_tail(stack, []) 49 | assert_receive %{method: :POST} 50 | assert response.body == "Hello, World!" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /extensions/raxx_head_middleware/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /extensions/raxx_logger/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /extensions/raxx_logger/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | raxx_logger-*.tar 24 | 25 | -------------------------------------------------------------------------------- /extensions/raxx_logger/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.2.2](https://github.com/CrowdHailer/raxx/tree/1.0.0) - 2019-04-16 8 | 9 | ### Added 10 | 11 | - Support for raxx 1.0.0. 12 | 13 | ## [0.2.1](https://github.com/CrowdHailer/raxx/tree/0.18.0) - 2019-02-07 14 | 15 | ### Added 16 | 17 | - Support for raxx 0.18.0. 18 | 19 | ## [0.2.0](#) - 2019-02-05 20 | 21 | ### Changed 22 | 23 | - Complete rewrite to use `Raxx.Middleware` behaviour. 24 | 25 | ## [0.1.0](https://github.com/CrowdHailer/raxx/tree/0.17.5) - 2019-02-04 26 | 27 | ### Added 28 | 29 | - Library extracted from Raxx package version `0.17.4`. 30 | -------------------------------------------------------------------------------- /extensions/raxx_logger/README.md: -------------------------------------------------------------------------------- 1 | # Raxx.Logger 2 | 3 | Middleware for basic logging in Raxx services. 4 | -------------------------------------------------------------------------------- /extensions/raxx_logger/lib/raxx/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Logger do 2 | @moduledoc """ 3 | Middleware for basic logging in the format: 4 | 5 | GET /index.html Sent 200 in 572ms 6 | 7 | ## Options 8 | 9 | - `:level` - The log level this middleware will use for request and response information. 10 | Default is `:info`. 11 | """ 12 | @behaviour Raxx.Middleware 13 | alias Raxx.Server 14 | 15 | require Logger 16 | 17 | @enforce_keys [:level, :start, :request] 18 | defstruct @enforce_keys 19 | 20 | def setup(options) do 21 | level = Keyword.fetch!(options, :level) 22 | %__MODULE__{level: level, start: nil, request: nil} 23 | end 24 | 25 | def process_head(request, options, next) when is_list(options) do 26 | process_head(request, setup(options), next) 27 | end 28 | 29 | def process_head(request, state = %__MODULE__{}, next) do 30 | state = %{state | start: System.monotonic_time(), request: request} 31 | 32 | if !Keyword.has_key?(Logger.metadata(), :"raxx.app") do 33 | {server, _} = Raxx.Stack.get_server(next) 34 | Logger.metadata("raxx.app": server) 35 | end 36 | 37 | Logger.metadata("raxx.scheme": request.scheme) 38 | Logger.metadata("raxx.authority": request.authority) 39 | Logger.metadata("raxx.method": request.method) 40 | Logger.metadata("raxx.path": inspect(request.path)) 41 | Logger.metadata("raxx.query": inspect(request.query)) 42 | 43 | {parts, next} = Server.handle_head(next, request) 44 | :ok = handle_parts(parts, state) 45 | {parts, state, next} 46 | end 47 | 48 | def process_data(data, state, next) do 49 | {parts, next} = Server.handle_data(next, data) 50 | :ok = handle_parts(parts, state) 51 | {parts, state, next} 52 | end 53 | 54 | def process_tail(tail, state, next) do 55 | {parts, next} = Server.handle_tail(next, tail) 56 | :ok = handle_parts(parts, state) 57 | {parts, state, next} 58 | end 59 | 60 | def process_info(info, state, next) do 61 | {parts, next} = Server.handle_info(next, info) 62 | :ok = handle_parts(parts, state) 63 | {parts, state, next} 64 | end 65 | 66 | defp handle_parts([response = %Raxx.Response{} | _], state) do 67 | Logger.log(state.level, fn -> 68 | stop = System.monotonic_time() 69 | diff = System.convert_time_unit(stop - state.start, :native, :microsecond) 70 | 71 | [ 72 | Atom.to_string(state.request.method), 73 | ?\s, 74 | Raxx.normalized_path(state.request), 75 | ?\s, 76 | response_type(response), 77 | ?\s, 78 | Integer.to_string(response.status), 79 | " in ", 80 | formatted_diff(diff) 81 | ] 82 | end) 83 | end 84 | 85 | defp handle_parts(parts, _state) when is_list(parts) do 86 | :ok 87 | end 88 | 89 | defp formatted_diff(diff) when diff > 1000, do: [diff |> div(1000) |> Integer.to_string(), "ms"] 90 | defp formatted_diff(diff), do: [Integer.to_string(diff), "µs"] 91 | 92 | defp response_type(%{body: true}), do: "Chunked" 93 | defp response_type(_), do: "Sent" 94 | end 95 | -------------------------------------------------------------------------------- /extensions/raxx_logger/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxLogger.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_logger, 7 | version: "0.2.2", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | extra_applications: [:logger] 19 | ] 20 | end 21 | 22 | defp deps do 23 | [ 24 | {:raxx, "~> 0.17.5 or ~> 0.18.0 or ~> 1.0"}, 25 | {:ex_doc, ">= 0.0.0", only: :dev} 26 | ] 27 | end 28 | 29 | defp description do 30 | """ 31 | Middleware for basic logging in Raxx services. 32 | """ 33 | end 34 | 35 | defp package do 36 | [ 37 | maintainers: ["Peter Saxton"], 38 | licenses: ["Apache 2.0"], 39 | links: %{ 40 | "GitHub" => "https://github.com/crowdhailer/raxx/tree/master/extensions/raxx_logger" 41 | } 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /extensions/raxx_logger/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cookie": {:hex, :cookie, "0.1.1", "89438362ee0f0ed400e9f076d617d630f82d682e3fbcf767072a46a6e1ed5781", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 4 | "eex_html": {:hex, :eex_html, "0.2.0", "7f9da3e8ec2ecda2658a0443c65321e161c7c89897f2011e3d8a70f4f68341cb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 10 | "raxx": {:hex, :raxx, "1.0.0", "e74f229340345a3ea94deaace6e4d78a0b9fd065010e1d0f80746760c728b2ee", [:mix], [], "hexpm"}, 11 | } 12 | -------------------------------------------------------------------------------- /extensions/raxx_logger/test/raxx/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.LoggerTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureLog 4 | 5 | defmodule DefaultServer do 6 | use Raxx.SimpleServer 7 | 8 | @impl Raxx.SimpleServer 9 | def handle_request(_request, _state) do 10 | response(:no_content) 11 | end 12 | end 13 | 14 | setup %{} do 15 | stack = 16 | Raxx.Stack.new( 17 | [ 18 | {Raxx.Logger, level: :info} 19 | ], 20 | {DefaultServer, nil} 21 | ) 22 | 23 | {:ok, stack: stack} 24 | end 25 | 26 | test "Request and response information is logged", %{stack: stack} do 27 | request = Raxx.request(:GET, "http://example.com:1234/foo?bar=value") 28 | 29 | log = 30 | capture_log(fn -> 31 | Raxx.Server.handle_head(stack, request) 32 | end) 33 | 34 | assert String.contains?(log, "GET /foo?bar=value") 35 | assert String.contains?(log, "Sent 204 in") 36 | end 37 | 38 | test "Request context is added to logger metadata", %{stack: stack} do 39 | request = Raxx.request(:GET, "http://example.com:1234/foo?bar=value") 40 | 41 | _log = 42 | capture_log(fn -> 43 | Raxx.Server.handle_head(stack, request) 44 | end) 45 | 46 | metadata = Logger.metadata() 47 | assert Raxx.LoggerTest.DefaultServer = Keyword.get(metadata, :"raxx.app") 48 | assert :http = Keyword.get(metadata, :"raxx.scheme") 49 | assert "example.com:1234" = Keyword.get(metadata, :"raxx.authority") 50 | assert :GET = Keyword.get(metadata, :"raxx.method") 51 | assert "[\"foo\"]" = Keyword.get(metadata, :"raxx.path") 52 | assert "\"bar=value\"" = Keyword.get(metadata, :"raxx.query") 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /extensions/raxx_logger/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | raxx_method_override-*.tar 24 | 25 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/README.md: -------------------------------------------------------------------------------- 1 | # Raxx.MethodOverride 2 | 3 | **Override a requests POST method with the method defined in the `_method` parameter.** 4 | 5 | [![Hex pm](http://img.shields.io/hexpm/v/raxx.svg?style=flat)](https://hex.pm/packages/raxx_method_override) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 7 | 8 | - [Install from hex.pm](https://hex.pm/packages/raxx_method_override) 9 | - [Documentation available on hexdoc](https://hexdocs.pm/raxx_method_override) 10 | - [Discuss on slack](https://elixir-lang.slack.com/messages/C56H3TBH8/) 11 | 12 | Allows browser to emulate using HTTP verbs other than `POST`. 13 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/lib/raxx/method_override.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.MethodOverride do 2 | @moduledoc """ 3 | Allows browser to emulate using HTTP verbs other than `POST`. 4 | 5 | Only the `POST` method will be overridden, 6 | It can be overridden to any of the listed HTTP verbs. 7 | - `PUT` 8 | - `PATCH` 9 | - `DELETE` 10 | 11 | The emulated method is is denoted by the `_method` parameter of the url query. 12 | 13 | ## Examples 14 | 15 | # override POST to PUT from query value 16 | iex> request(:POST, "/?_method=PUT") 17 | ...> |> override_method() 18 | ...> |> Map.get(:method) 19 | :PUT 20 | 21 | # override POST to PATCH from query value 22 | iex> request(:POST, "/?_method=PATCH") 23 | ...> |> override_method() 24 | ...> |> Map.get(:method) 25 | :PATCH 26 | 27 | # override POST to DELETE from query value 28 | iex> request(:POST, "/?_method=DELETE") 29 | ...> |> override_method() 30 | ...> |> Map.get(:method) 31 | :DELETE 32 | 33 | # overridding method removes the _method field from the query 34 | iex> request(:POST, "/?_method=PUT") 35 | ...> |> override_method() 36 | ...> |> Map.get(:query) 37 | %{} 38 | 39 | # override works with lowercase query value 40 | iex> request(:POST, "/?_method=delete") 41 | ...> |> override_method() 42 | ...> |> Map.get(:method) 43 | :DELETE 44 | 45 | # # at the moment breaks deleberatly due to unknown method 46 | # # does not allow unknown methods 47 | # iex> request(:POST, "/?_method=PARTY") 48 | # ...> |> override_method() 49 | # ...> |> Map.get(:method) 50 | # :POST 51 | 52 | # leaves non-POST requests unmodified, e.g. GET 53 | iex> request(:GET, "/?_method=DELETE") 54 | ...> |> override_method() 55 | ...> |> Map.get(:method) 56 | :GET 57 | 58 | # leaves non-POST requests unmodified, e.g. PUT 59 | # Not entirely sure of the logic here. 60 | iex> request(:PUT, "/?_method=DELETE") 61 | ...> |> override_method() 62 | ...> |> Map.get(:method) 63 | :PUT 64 | 65 | # queries with out a _method field are a no-op 66 | iex> request(:POST, "/?other=PUT") 67 | ...> |> override_method() 68 | ...> |> Map.get(:method) 69 | :POST 70 | """ 71 | 72 | use Raxx.Middleware 73 | 74 | @doc """ 75 | Modify a requests method based on query parameters 76 | """ 77 | def override_method(request = %{method: :POST}) do 78 | query = Raxx.get_query(request) 79 | {method, query} = Map.pop(query, "_method") 80 | 81 | case method && String.upcase(method) do 82 | nil -> 83 | request 84 | 85 | method when method in ["PUT", "PATCH", "DELETE"] -> 86 | method = String.to_existing_atom(method) 87 | %{request | method: method, query: query} 88 | end 89 | end 90 | 91 | def override_method(request) do 92 | request 93 | end 94 | 95 | @impl Raxx.Middleware 96 | def process_head(request, state, next) do 97 | request = override_method(request) 98 | {parts, next} = Raxx.Server.handle_head(next, request) 99 | {parts, state, next} 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxMethodOverride.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_method_override, 7 | version: "0.4.1", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | extra_applications: [:logger] 19 | ] 20 | end 21 | 22 | defp deps do 23 | [ 24 | {:raxx, "~> 0.18.0 or ~> 1.0"}, 25 | {:ex_doc, ">= 0.0.0", only: :dev} 26 | ] 27 | end 28 | 29 | defp description do 30 | """ 31 | Override a requests POST method with the method defined in the `_method` parameter. 32 | """ 33 | end 34 | 35 | defp package do 36 | [ 37 | maintainers: ["Peter Saxton"], 38 | licenses: ["Apache 2.0"], 39 | links: %{ 40 | "GitHub" => 41 | "https://github.com/crowdhailer/raxx/tree/master/extensions/raxx_method_override" 42 | } 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 3 | "eex_html": {:hex, :eex_html, "0.2.0", "7f9da3e8ec2ecda2658a0443c65321e161c7c89897f2011e3d8a70f4f68341cb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 9 | "raxx": {:hex, :raxx, "1.0.0", "e74f229340345a3ea94deaace6e4d78a0b9fd065010e1d0f80746760c728b2ee", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/test/raxx/method_override_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.MethodOverrideTest do 2 | use ExUnit.Case 3 | import Raxx 4 | import Raxx.MethodOverride 5 | doctest Raxx.MethodOverride 6 | 7 | alias Raxx.Server 8 | 9 | defmodule SimpleApp do 10 | use Raxx.SimpleServer 11 | 12 | @impl Raxx.SimpleServer 13 | def handle_request(request, _) do 14 | send(self(), request) 15 | 16 | response(:ok) 17 | |> set_body("Hello, World!") 18 | end 19 | end 20 | 21 | setup do 22 | stack = Raxx.Stack.new([{Raxx.MethodOverride, nil}], {SimpleApp, nil}) 23 | {:ok, stack: stack} 24 | end 25 | 26 | test "Query can be used to overwrite POST method", %{stack: stack} do 27 | request = request(:POST, "/?_method=PUT") 28 | 29 | assert {[response], _} = Server.handle_head(stack, request) 30 | assert_receive %{method: :PUT} 31 | assert response.body == "Hello, World!" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /extensions/raxx_method_override/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /extensions/raxx_session/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /extensions/raxx_session/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | raxx_session-*.tar 24 | 25 | -------------------------------------------------------------------------------- /extensions/raxx_session/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.2.5](#) - 2019-06-06 8 | 9 | ### Added 10 | 11 | - Better checks for csrf protection. 12 | 13 | ## [0.2.4](#) - 2019-06-03 14 | 15 | ### Added 16 | 17 | - new store `Raxx.Session.EncryptedCookie`, compatible with Plug sessions. 18 | 19 | ## [0.2.3](#) - 2019-05-09 20 | 21 | ### Added 22 | 23 | - Expose `Raxx.Session.unprotected_extract/2`. 24 | 25 | ## [0.2.2](#) - 2019-05-09 26 | 27 | ### Fixed 28 | 29 | - Empty session can be extracted from unsafe request without protection. 30 | -------------------------------------------------------------------------------- /extensions/raxx_session/README.md: -------------------------------------------------------------------------------- 1 | # Raxx.Session 2 | 3 | **Manage HTTP cookies and storage for persistent client sessions.** 4 | 5 | [![Hex pm](http://img.shields.io/hexpm/v/raxx_session.svg?style=flat)](https://hex.pm/packages/raxx_session) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 7 | 8 | - [Install from hex.pm](https://hex.pm/packages/raxx_session) 9 | - [Documentation available on hexdoc](https://hexdocs.pm/raxx_session) 10 | - [Discuss on slack](https://elixir-lang.slack.com/messages/C56H3TBH8/) 11 | 12 | ### Extract, Embed, Expire a session 13 | 14 | ```elixir 15 | defmodule MyApp.Action do 16 | use Raxx.SimpleServer 17 | alias Raxx.Session 18 | 19 | def handle_request(request, state) do 20 | {:ok, session} = Session.extract(request, state.session_config) 21 | updated_session = # ... processing 22 | 23 | response(:ok) 24 | |> Session.embed(updated_session, state.session_config) 25 | 26 | # ... something went wrong 27 | response(:forbidden) 28 | |> Session.expire(state.session_config) 29 | end 30 | end 31 | ``` 32 | 33 | Sessions are extract and embedded in their entirety from requests/responses. 34 | A session is just a map where any term can be saved, 35 | although large terms might not work with certain session stores. 36 | 37 | **If a session is updated it MUST be embedded in the response, 38 | otherwise the client will send the same previous session.** 39 | 40 | ### Flash 41 | 42 | *Flash protection used the `:_flash` key in a session, 43 | it should not be manipulated directly* 44 | 45 | A flash is information that should be shown to a user only once. 46 | `Raxx.Session` provides some simple helpers to working with flashes. 47 | 48 | ```elixir 49 | defmodule MyApp.Action do 50 | use Raxx.SimpleServer 51 | alias Raxx.Session 52 | 53 | def handle_request(%{path: ["set-flash"]}, state) do 54 | session = %{} 55 | |> Session.put_flash(:info, "Welcome to flash!") 56 | |> Session.put_flash(:error, "Welcome to flash!") 57 | redirect("/show-flash") 58 | |> Session.embed(session, state.session_config) 59 | end 60 | 61 | def handle_request(request = %{path: ["show-flash"]}, state) do 62 | {:ok, session} = Session.extract(request, state.session_config) 63 | 64 | {flashes, session} = Session.pop_flash(session) 65 | 66 | response(:ok) 67 | |> render(flashes) 68 | |> Session.embed(session, state.session_config) 69 | end 70 | end 71 | ``` 72 | 73 | ### CSRF Protection 74 | 75 | *CSRF protection used the `:_csrf_token` key in a session, 76 | it should not be manipulated directly* 77 | 78 | `Raxx.Session.extract/2` is protected against CSRF attacks. 79 | Sessions can be extracted from safe requests. 80 | These are `GET`, `HEAD` or `OPTIONS` requests and they should have no side effect. 81 | 82 | Requests with other methods must provide a `csrf_token`. 83 | By default this value is looked for in the `x-csrf-token` header. 84 | 85 | If the token is sent to the server in a different manner it can be explicitly passed by using `Raxx.Session.extract/3`. 86 | For example if it is passed in the body of a request 87 | 88 | ```elixir 89 | {%{"_csrf_token" => token}} = URI.decode(request.body) 90 | 91 | {:ok, session} = Raxx.Session.fetch(request, token, config) 92 | ``` 93 | 94 | **A CSRF token should not be sent back to the server as a query parameter.** 95 | 96 | ### Configuration 97 | 98 | ```elixir 99 | session_config = Raxx.Session.config( 100 | key: "my_session", 101 | store: Raxx.Session.SignedCookie, 102 | secret_key_base: String.duplicate("squirrel", 8), 103 | salt: "epsom" 104 | ) 105 | ``` 106 | 107 | For all configuration options see `Raxx.Session` or specific stores. 108 | 109 | #### Plug sessions 110 | 111 | Sessions from Plug applications can be verified in Raxx applications, and visa versa, 112 | if setup with the same configuration. 113 | -------------------------------------------------------------------------------- /extensions/raxx_session/lib/raxx/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Session do 2 | @moduledoc """ 3 | 4 | A session is extracted from a request using `extract/2`. 5 | An updated session is sent the the client using `embed/3` or a response 6 | To expire a session use `expire/2` on a response. 7 | 8 | ## Configuration 9 | 10 | All session functions take a configuration as a parameter. 11 | To check configuration only one it is best to configure a session, and it's store, at startup. 12 | 13 | When using the `SignedCookie` or `EncryptedCookie` store then sessions are compatible with sessions from plug applications. 14 | 15 | ## Options 16 | 17 | - `:store` - session store module (required) 18 | - `:key` - session cookie key (required) 19 | 20 | ### Cookie options 21 | 22 | Any option that can be passed as an option to `SetCookie.serialize/3` can be set as a session option. 23 | `:domain`, `:max_age`, `:path`, `:http_only`, `:secure`, `:extra` 24 | 25 | ### Store options 26 | 27 | Additional options may be required dependant on the store module being used. 28 | For example `SignedCookie` requires `secret_key_base` and `salt`. 29 | """ 30 | 31 | @enforce_keys [:key, :store, :cookie_options] 32 | defstruct @enforce_keys 33 | 34 | @cookie_options [:domain, :max_age, :path, :secure, :http_only, :extra] 35 | 36 | @doc """ 37 | Set up and check session configuration. 38 | 39 | See [options](#module-options) for details. 40 | """ 41 | def config(options) do 42 | {key, options} = Keyword.pop(options, :key) 43 | key || raise ArgumentError, "#{__MODULE__} requires the :key option to be set" 44 | {store, options} = Keyword.pop(options, :store) 45 | store || raise ArgumentError, "#{__MODULE__} requires the :store option to be set" 46 | 47 | cookie_options = Keyword.take(options, @cookie_options) 48 | store = store.config(options) 49 | 50 | %__MODULE__{key: key, store: store, cookie_options: cookie_options} 51 | end 52 | 53 | # get, extract that just expires error, not a deep API 54 | 55 | @doc """ 56 | Extract a session from a request. 57 | 58 | Returns `{:ok, nil}` if session cookie is not set. 59 | When session cookie is set but cannot be decoded or is tampered with an error will be returned. 60 | """ 61 | 62 | def extract(request, config = %__MODULE__{}) do 63 | extract(request, Raxx.get_header(request, "x-csrf-token"), config) 64 | end 65 | 66 | def extract(request, user_token, config = %__MODULE__{}) do 67 | case unprotected_extract(request, config) do 68 | {:ok, nil} -> 69 | {:ok, nil} 70 | 71 | {:ok, session} -> 72 | if __MODULE__.CSRFProtection.safe_request?(request) do 73 | {:ok, session} 74 | else 75 | __MODULE__.CSRFProtection.verify(session, user_token) 76 | end 77 | 78 | {:error, reason} -> 79 | {:error, reason} 80 | end 81 | end 82 | 83 | defdelegate get_csrf_token(session), to: __MODULE__.CSRFProtection 84 | 85 | # session works with any type 86 | @doc false 87 | def unprotected_extract(request, config = %__MODULE__{}) do 88 | case fetch_cookie(request, config.key) do 89 | # pass nil through, might want to set up an id 90 | {:ok, nil} -> 91 | {:ok, nil} 92 | 93 | {:ok, cookie} -> 94 | fetch_session(cookie, config.store) 95 | end 96 | end 97 | 98 | defp fetch_cookie(%{headers: headers}, key) do 99 | headers = :proplists.get_all_values("cookie", headers) 100 | 101 | cookies = for header <- headers, kv <- Cookie.parse(header), into: %{}, do: kv 102 | {:ok, Map.get(cookies, key)} 103 | end 104 | 105 | defp fetch_session(cookie, store = %store_mod{}) do 106 | store_mod.fetch(cookie, store) 107 | end 108 | 109 | # set update override 110 | # optional previous last argument to see if changed. 111 | @doc """ 112 | Overwrite a users session to a new value. 113 | 114 | The whole session object must be passed to this function. 115 | """ 116 | def embed(response, session, config = %__MODULE__{}) do 117 | store = %store_mod{} = config.store 118 | session_string = store_mod.put(session, store) 119 | 120 | Raxx.set_header( 121 | response, 122 | "set-cookie", 123 | SetCookie.serialize(config.key, session_string, config.cookie_options) 124 | ) 125 | end 126 | 127 | @doc """ 128 | Instruct a client to end a session. 129 | """ 130 | def expire(response, config = %__MODULE__{}) do 131 | # Needs to delete from store if we are doing that 132 | Raxx.set_header(response, "set-cookie", SetCookie.expire(config.key, config.cookie_options)) 133 | end 134 | 135 | @doc """ 136 | Add a message into a sessions flash. 137 | 138 | Any key can be used for the message, however `:info` and `:error` are common. 139 | """ 140 | def put_flash(session, key, message) when is_atom(key) and is_binary(message) do 141 | session = session || %{} 142 | flash = Map.get(session, :_flash, %{}) 143 | Map.put(session, :_flash, Map.put(flash, key, message)) 144 | end 145 | 146 | @doc """ 147 | Returns all the flash messages in a users session. 148 | 149 | This will be a map of `%{key => message}` set using `put_flash/3`. 150 | The returned session will have no flash messages. 151 | Remember this session must be embedded in the response otherwise flashes will be seen twice. 152 | """ 153 | def pop_flash(session) do 154 | session = session || %{} 155 | Map.pop(session, :_flash, %{}) 156 | end 157 | 158 | @doc """ 159 | Extract and discard all flash messages in a users session. 160 | """ 161 | def clear_flash(session) do 162 | {_flash, session} = pop_flash(session) 163 | session 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /extensions/raxx_session/lib/raxx/session/csrf_protection.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Session.CSRFProtection do 2 | @unprotected_methods [:HEAD, :GET, :OPTIONS] 3 | @token_size 16 4 | @encoded_token_size 24 5 | @double_encoded_token_size 32 6 | 7 | def safe_request?(%{method: method}) when method in @unprotected_methods, do: true 8 | def safe_request?(_), do: false 9 | 10 | def verify(_, nil), do: {:error, :csrf_missing} 11 | 12 | def verify(session, user_token) when is_binary(user_token) do 13 | case valid_csrf_token?(session_token(session), user_token) do 14 | {:ok, true} -> 15 | {:ok, session} 16 | 17 | {:ok, false} -> 18 | {:error, :csrf_check_failed} 19 | 20 | {:error, reason} -> 21 | {:error, reason} 22 | end 23 | end 24 | 25 | def get_csrf_token(session) do 26 | case session_token(session) do 27 | # If not the right size then _csrf_token field has been modified 28 | csrf_token when is_binary(csrf_token) and byte_size(csrf_token) == @encoded_token_size -> 29 | user_token = mask(csrf_token) 30 | {user_token, session} 31 | 32 | nil -> 33 | csrf_token = generate_token() 34 | session = Map.put(session || %{}, :_csrf_token, csrf_token) 35 | user_token = mask(csrf_token) 36 | {user_token, session} 37 | end 38 | end 39 | 40 | defp session_token(nil), do: nil 41 | defp session_token(session = %{}), do: Map.get(session, :_csrf_token) 42 | 43 | defp valid_csrf_token?( 44 | <>, 45 | <> 46 | ) do 47 | case Base.decode64(user_token) do 48 | {:ok, user_token} -> 49 | {:ok, Plug.Crypto.masked_compare(csrf_token, user_token, mask)} 50 | # Put as error not false 51 | # :error -> false 52 | end 53 | end 54 | 55 | defp valid_csrf_token?( 56 | <<_csrf_token::@encoded_token_size-binary>>, 57 | _ 58 | ) do 59 | {:error, :invalid_csrf_token} 60 | end 61 | 62 | defp valid_csrf_token?( 63 | nil, 64 | _ 65 | ) do 66 | {:error, :csrf_check_missing} 67 | end 68 | 69 | defp mask(token) do 70 | mask = generate_token() 71 | Base.encode64(Plug.Crypto.mask(token, mask)) <> mask 72 | end 73 | 74 | defp generate_token do 75 | Base.encode64(:crypto.strong_rand_bytes(@token_size)) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /extensions/raxx_session/lib/raxx/session/encrypted_session.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Session.EncryptedCookie do 2 | @moduledoc """ 3 | Store the session in an encrypted cookie. 4 | 5 | This module is not normally used directly. 6 | Instead see `Raxx.Session` and use this module as the `:store` option. 7 | 8 | ## Options 9 | 10 | - `:secret_key_base` - used to generate the signing key 11 | - `:signing_salt` - a salt used with `secret_key_base` to generate a signing_secret. 12 | - `:encryption_salt` - a salt used with `secret_key_base` to generate a encryption_secret. 13 | """ 14 | 15 | @enforce_keys [:secret_key_base, :encryption_salt, :signing_salt] 16 | defstruct @enforce_keys 17 | 18 | def config(options) do 19 | {secret_key_base, options} = Keyword.pop(options, :secret_key_base) 20 | 21 | secret_key_base || 22 | raise ArgumentError, "#{__MODULE__} requires the :secret_key_base option to be set" 23 | 24 | validate_secret_key_base(secret_key_base) 25 | 26 | {signing_salt, _options} = Keyword.pop(options, :signing_salt) 27 | 28 | signing_salt || 29 | raise ArgumentError, "#{__MODULE__} requires the :signing_salt option to be set" 30 | 31 | {encryption_salt, _options} = Keyword.pop(options, :encryption_salt) 32 | 33 | encryption_salt || 34 | raise ArgumentError, "#{__MODULE__} requires the :encryption_salt option to be set" 35 | 36 | %__MODULE__{ 37 | secret_key_base: secret_key_base, 38 | signing_salt: signing_salt, 39 | encryption_salt: encryption_salt 40 | } 41 | end 42 | 43 | defp validate_secret_key_base(secret_key_base) when byte_size(secret_key_base) < 64 do 44 | raise(ArgumentError, "#{__MODULE__} requires `secret_key_base` to be at least 64 bytes") 45 | end 46 | 47 | defp validate_secret_key_base(secret_key_base), do: secret_key_base 48 | 49 | def fetch(session_string, config = %__MODULE__{}) do 50 | case Plug.Crypto.MessageEncryptor.decrypt( 51 | session_string, 52 | encryption_secret(config), 53 | signing_secret(config) 54 | ) do 55 | {:ok, binary} -> 56 | {:ok, Plug.Crypto.safe_binary_to_term(binary)} 57 | 58 | :error -> 59 | {:error, :invalid_signature} 60 | end 61 | end 62 | 63 | def put(session, config) do 64 | binary = :erlang.term_to_binary(session) 65 | 66 | Plug.Crypto.MessageEncryptor.encrypt( 67 | binary, 68 | encryption_secret(config), 69 | signing_secret(config) 70 | ) 71 | end 72 | 73 | defp signing_secret(%__MODULE__{secret_key_base: secret_key_base, signing_salt: signing_salt}) do 74 | Plug.Crypto.KeyGenerator.generate(secret_key_base, signing_salt) 75 | end 76 | 77 | defp encryption_secret(%__MODULE__{ 78 | secret_key_base: secret_key_base, 79 | encryption_salt: encryption_salt 80 | }) do 81 | Plug.Crypto.KeyGenerator.generate(secret_key_base, encryption_salt) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /extensions/raxx_session/lib/raxx/session/signed_cookie.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Session.SignedCookie do 2 | @moduledoc """ 3 | Stores the session in a signed cookie. 4 | 5 | **Note: the contents of the cookie can be viewed by the client, 6 | an encrypted cookie store must be used to hide data from client. 7 | See `Raxx.Session.EncryptedCookie`.** 8 | 9 | This module is not normally used directly. 10 | Instead see `Raxx.Session` and use this module as the `:store` option. 11 | 12 | ## Options 13 | 14 | - `:secret_key_base` - used to generate the signing key 15 | - `:salt` - a salt used with `secret_key_base` to generate a key for signing/verifying a cookie. 16 | 17 | TODO support key generation options, currently sensible defaults are used. 18 | """ 19 | @enforce_keys [:secret_key_base, :salt] 20 | defstruct @enforce_keys 21 | 22 | def config(options) do 23 | {secret_key_base, options} = Keyword.pop(options, :secret_key_base) 24 | 25 | secret_key_base || 26 | raise ArgumentError, "#{__MODULE__} requires the :secret_key_base option to be set" 27 | 28 | validate_secret_key_base(secret_key_base) 29 | 30 | {salt, _options} = Keyword.pop(options, :salt) 31 | salt || raise ArgumentError, "#{__MODULE__} requires the :salt option to be set" 32 | 33 | %__MODULE__{secret_key_base: secret_key_base, salt: salt} 34 | end 35 | 36 | defp validate_secret_key_base(secret_key_base) when byte_size(secret_key_base) < 64 do 37 | raise(ArgumentError, "#{__MODULE__} requires `secret_key_base` to be at least 64 bytes") 38 | end 39 | 40 | defp validate_secret_key_base(secret_key_base), do: secret_key_base 41 | 42 | def fetch(session_string, config = %__MODULE__{}) do 43 | case Plug.Crypto.MessageVerifier.verify(session_string, key(config)) do 44 | {:ok, binary} -> 45 | {:ok, Plug.Crypto.safe_binary_to_term(binary)} 46 | 47 | :error -> 48 | {:error, :invalid_signature} 49 | end 50 | end 51 | 52 | def put(session, config = %__MODULE__{}) do 53 | binary = :erlang.term_to_binary(session) 54 | Plug.Crypto.MessageVerifier.sign(binary, key(config)) 55 | end 56 | 57 | defp key(%__MODULE__{secret_key_base: secret_key_base, salt: salt}) do 58 | Plug.Crypto.KeyGenerator.generate(secret_key_base, salt) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /extensions/raxx_session/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxSession.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_session, 7 | version: "0.2.5", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | docs: [extras: ["README.md"], main: "readme"], 13 | package: package() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:raxx, "~> 1.0"}, 26 | {:cookie, "~> 0.1.1"}, 27 | {:plug_crypto, "~> 1.0"}, 28 | {:plug, "~> 1.8", only: :test}, 29 | {:ex_doc, ">= 0.0.0", only: :dev} 30 | ] 31 | end 32 | 33 | defp description do 34 | """ 35 | Manage HTTP cookies and storage for persistent client sessions. 36 | """ 37 | end 38 | 39 | defp package do 40 | [ 41 | maintainers: ["Peter Saxton"], 42 | licenses: ["Apache 2.0"], 43 | links: %{ 44 | "GitHub" => "https://github.com/crowdhailer/raxx/tree/master/extensions/raxx_session" 45 | } 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /extensions/raxx_session/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cookie": {:hex, :cookie, "0.1.1", "89438362ee0f0ed400e9f076d617d630f82d682e3fbcf767072a46a6e1ed5781", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 9 | "plug": {:hex, :plug, "1.8.2", "0bcce1daa420f189a6491f3940cc77ea7fb1919761175c9c3b59800d897440fc", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, 10 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 11 | "raxx": {:hex, :raxx, "1.0.1", "8c51ec5227c85f999360fc844fc1d4e2e5a2adf2b0ce068eb56243ee6b2f65e3", [:mix], [], "hexpm"}, 12 | } 13 | -------------------------------------------------------------------------------- /extensions/raxx_session/test/raxx/session/plug_compatibility_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Session.PlugCompatibilityTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule RaxxSessionApp do 5 | use Raxx.SimpleServer 6 | alias Raxx.Session 7 | 8 | def handle_request(request = %{path: ["set", value]}, state) do 9 | {:ok, session} = Session.extract(request, state.session_config) 10 | session = session || %{} 11 | 12 | previous = Map.get(session, :value, "") 13 | session = Map.put(session, :value, value) 14 | 15 | response(:ok) 16 | |> Session.embed(session, state.session_config) 17 | |> set_body(previous) 18 | end 19 | end 20 | 21 | defmodule PlugSessionApp do 22 | alias Plug.Conn 23 | alias Plug.Session 24 | 25 | def call(conn, config) do 26 | conn 27 | |> set_secret(config.secret_key_base) 28 | |> Session.call(config.session_config) 29 | |> endpoint(nil) 30 | end 31 | 32 | def set_secret(conn, secret_key_base) do 33 | %{conn | secret_key_base: secret_key_base} 34 | end 35 | 36 | def endpoint(conn = %{path_info: ["set", value]}, _) do 37 | conn = 38 | conn 39 | |> Conn.fetch_session() 40 | 41 | # NOTE get session transparently casts keys to strings 42 | previous = patched_get_session(conn, :value) || "" 43 | 44 | conn 45 | |> Conn.put_session(:value, value) 46 | |> Conn.send_resp(200, previous) 47 | end 48 | 49 | defp patched_get_session(conn, key) do 50 | conn 51 | |> Conn.get_session() 52 | |> Map.get(key) 53 | end 54 | end 55 | 56 | test "Signed Sessions are interchangeable" do 57 | key = "my_shared_session" 58 | signing_salt = "sea" 59 | 60 | secret_key_base = random_string(64) 61 | 62 | plug_session_config = Plug.Session.init(store: :cookie, key: key, signing_salt: signing_salt) 63 | 64 | plug_config = %{secret_key_base: secret_key_base, session_config: plug_session_config} 65 | 66 | raxx_session_config = 67 | Raxx.Session.config( 68 | store: Raxx.Session.SignedCookie, 69 | secret_key_base: secret_key_base, 70 | key: key, 71 | salt: signing_salt 72 | ) 73 | 74 | raxx_config = %{session_config: raxx_session_config} 75 | do_test(plug_config, raxx_config) 76 | end 77 | 78 | test "Encrypted Sessions are interchangeable" do 79 | key = "my_shared_session" 80 | signing_salt = "sea" 81 | encryption_salt = "rock" 82 | 83 | secret_key_base = random_string(64) 84 | 85 | plug_session_config = 86 | Plug.Session.init( 87 | store: :cookie, 88 | key: key, 89 | encryption_salt: encryption_salt, 90 | signing_salt: signing_salt 91 | ) 92 | 93 | plug_config = %{secret_key_base: secret_key_base, session_config: plug_session_config} 94 | 95 | raxx_session_config = 96 | Raxx.Session.config( 97 | store: Raxx.Session.EncryptedCookie, 98 | secret_key_base: secret_key_base, 99 | key: key, 100 | encryption_salt: encryption_salt, 101 | signing_salt: signing_salt 102 | ) 103 | 104 | raxx_config = %{session_config: raxx_session_config} 105 | do_test(plug_config, raxx_config) 106 | end 107 | 108 | def do_test(plug_config, raxx_config) do 109 | first = random_string(10) 110 | second = random_string(10) 111 | 112 | first_conn = 113 | Plug.Test.conn(:get, "/set/#{first}") 114 | |> PlugSessionApp.call(plug_config) 115 | 116 | [first_set_cookie_string] = Plug.Conn.get_resp_header(first_conn, "set-cookie") 117 | first_set_cookie = SetCookie.parse(first_set_cookie_string) 118 | first_cookie_string = Cookie.serialize({first_set_cookie.key, first_set_cookie.value}) 119 | 120 | request = 121 | Raxx.request(:GET, "/set/#{second}") 122 | |> Raxx.set_header("cookie", first_cookie_string) 123 | 124 | response = RaxxSessionApp.handle_request(request, raxx_config) 125 | assert first = response.body 126 | 127 | second_set_cookie_string = Raxx.get_header(response, "set-cookie") 128 | second_set_cookie = SetCookie.parse(second_set_cookie_string) 129 | second_cookie_string = Cookie.serialize({second_set_cookie.key, second_set_cookie.value}) 130 | 131 | second_conn = 132 | Plug.Test.conn(:get, "/set/end") 133 | |> Plug.Conn.put_req_header("cookie", second_cookie_string) 134 | |> PlugSessionApp.call(plug_config) 135 | 136 | assert second_conn.resp_body == second 137 | end 138 | 139 | def random_string(length) do 140 | :crypto.strong_rand_bytes(length) |> Base.url_encode64() |> binary_part(0, length) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /extensions/raxx_session/test/raxx/session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.SessionTest do 2 | use ExUnit.Case, async: true 3 | doctest Raxx.Session 4 | 5 | describe "configuration" do 6 | test "configuration requires a key" do 7 | assert_raise(ArgumentError, ~r/:key/, fn -> 8 | Raxx.Session.config(store: Raxx.Session.SignedCookie) 9 | end) 10 | end 11 | 12 | test "configuration requires a store" do 13 | assert_raise(ArgumentError, ~r/:store/, fn -> 14 | Raxx.Session.config(key: "my_app_session") 15 | end) 16 | end 17 | 18 | test "valid store configuration is required" do 19 | assert_raise(ArgumentError, ~r/:secret_key_base/, fn -> 20 | Raxx.Session.config( 21 | key: "my_app_session", 22 | store: Raxx.Session.SignedCookie, 23 | salt: "smelling" 24 | ) 25 | end) 26 | end 27 | end 28 | 29 | describe "default configuration" do 30 | setup %{} do 31 | config = 32 | Raxx.Session.config( 33 | key: "my_app_session", 34 | store: Raxx.Session.SignedCookie, 35 | secret_key_base: String.duplicate("squirrel", 8), 36 | salt: "epsom" 37 | ) 38 | 39 | {:ok, config: config} 40 | end 41 | 42 | test "can extract an unprotected session from safe request", %{config: config} do 43 | session = %{"user" => "friend"} 44 | 45 | response = 46 | Raxx.response(:ok) 47 | |> Raxx.Session.embed(session, config) 48 | 49 | cookie_string = Raxx.get_header(response, "set-cookie") 50 | cookie = SetCookie.parse(cookie_string) 51 | assert cookie.key == "my_app_session" 52 | session_cookie = cookie.value 53 | 54 | assert map_size(cookie.attributes) == 2 55 | assert cookie.attributes.path == "/" 56 | assert cookie.attributes.http_only == true 57 | 58 | request = 59 | Raxx.request(:GET, "/") 60 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 61 | 62 | assert {:ok, ^session} = Raxx.Session.extract(request, config) 63 | end 64 | 65 | test "can extract a protected session from unsafe request", %{config: config} do 66 | session = %{"user" => "friend"} 67 | {token, session} = Raxx.Session.get_csrf_token(session) 68 | 69 | response = 70 | Raxx.response(:ok) 71 | |> Raxx.Session.embed(session, config) 72 | 73 | cookie_string = Raxx.get_header(response, "set-cookie") 74 | cookie = SetCookie.parse(cookie_string) 75 | assert cookie.key == "my_app_session" 76 | session_cookie = cookie.value 77 | 78 | assert map_size(cookie.attributes) == 2 79 | assert cookie.attributes.path == "/" 80 | assert cookie.attributes.http_only == true 81 | 82 | request = 83 | Raxx.request(:POST, "/") 84 | |> Raxx.set_header("x-csrf-token", token) 85 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 86 | 87 | assert {:ok, ^session} = Raxx.Session.extract(request, config) 88 | end 89 | 90 | test "cant extract an unprotected session from unsafe request", %{config: config} do 91 | session = %{"user" => "friend"} 92 | 93 | response = 94 | Raxx.response(:ok) 95 | |> Raxx.Session.embed(session, config) 96 | 97 | cookie_string = Raxx.get_header(response, "set-cookie") 98 | cookie = SetCookie.parse(cookie_string) 99 | assert cookie.key == "my_app_session" 100 | session_cookie = cookie.value 101 | 102 | assert map_size(cookie.attributes) == 2 103 | assert cookie.attributes.path == "/" 104 | assert cookie.attributes.http_only == true 105 | 106 | request = 107 | Raxx.request(:POST, "/") 108 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 109 | 110 | assert {:error, :csrf_missing} = Raxx.Session.extract(request, config) 111 | end 112 | 113 | test "can't extract session with invalid csrf token", %{config: config} do 114 | session = %{"user" => "friend"} 115 | {_token, session} = Raxx.Session.get_csrf_token(session) 116 | 117 | response = 118 | Raxx.response(:ok) 119 | |> Raxx.Session.embed(session, config) 120 | 121 | cookie_string = Raxx.get_header(response, "set-cookie") 122 | cookie = SetCookie.parse(cookie_string) 123 | assert cookie.key == "my_app_session" 124 | session_cookie = cookie.value 125 | 126 | assert map_size(cookie.attributes) == 2 127 | assert cookie.attributes.path == "/" 128 | assert cookie.attributes.http_only == true 129 | 130 | request = 131 | Raxx.request(:POST, "/") 132 | |> Raxx.set_header("x-csrf-token", "too-short") 133 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 134 | 135 | assert {:error, :invalid_csrf_token} = Raxx.Session.extract(request, config) 136 | end 137 | 138 | test "can't extract session with incorrect csrf token", %{config: config} do 139 | session = %{"user" => "friend"} 140 | {_token, session} = Raxx.Session.get_csrf_token(session) 141 | 142 | response = 143 | Raxx.response(:ok) 144 | |> Raxx.Session.embed(session, config) 145 | 146 | cookie_string = Raxx.get_header(response, "set-cookie") 147 | cookie = SetCookie.parse(cookie_string) 148 | assert cookie.key == "my_app_session" 149 | session_cookie = cookie.value 150 | 151 | assert map_size(cookie.attributes) == 2 152 | assert cookie.attributes.path == "/" 153 | assert cookie.attributes.http_only == true 154 | 155 | {incorrect_token, _other_session} = Raxx.Session.get_csrf_token(%{}) 156 | 157 | request = 158 | Raxx.request(:POST, "/") 159 | |> Raxx.set_header("x-csrf-token", incorrect_token) 160 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 161 | 162 | assert {:error, :csrf_check_failed} = Raxx.Session.extract(request, config) 163 | end 164 | 165 | test "can't extract session with incorrect csrf checkin in session", %{config: config} do 166 | session = %{"user" => "friend"} 167 | {token, _session} = Raxx.Session.get_csrf_token(session) 168 | 169 | response = 170 | Raxx.response(:ok) 171 | |> Raxx.Session.embed(session, config) 172 | 173 | cookie_string = Raxx.get_header(response, "set-cookie") 174 | cookie = SetCookie.parse(cookie_string) 175 | assert cookie.key == "my_app_session" 176 | session_cookie = cookie.value 177 | 178 | assert map_size(cookie.attributes) == 2 179 | assert cookie.attributes.path == "/" 180 | assert cookie.attributes.http_only == true 181 | 182 | request = 183 | Raxx.request(:POST, "/") 184 | |> Raxx.set_header("x-csrf-token", token) 185 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 186 | 187 | assert {:error, :csrf_check_missing} = Raxx.Session.extract(request, config) 188 | end 189 | 190 | test "protection is not checked when there is no session", %{config: config} do 191 | request = Raxx.request(:POST, "/") 192 | assert {:ok, nil} = Raxx.Session.extract(request, config) 193 | end 194 | 195 | test "can expire a session", %{config: config} do 196 | response = 197 | Raxx.response(:ok) 198 | |> Raxx.Session.expire(config) 199 | 200 | cookie_string = Raxx.get_header(response, "set-cookie") 201 | cookie = SetCookie.parse(cookie_string) 202 | assert cookie.key == "my_app_session" 203 | assert "" = cookie.value 204 | 205 | assert map_size(cookie.attributes) == 4 206 | assert cookie.attributes.path == "/" 207 | assert cookie.attributes.http_only == true 208 | assert cookie.attributes.expires == "Thu, 01 Jan 1970 00:00:00 GMT" 209 | assert cookie.attributes.max_age == "0" 210 | end 211 | 212 | test "request without cookies returns no session", %{config: config} do 213 | request = Raxx.request(:GET, "/") 214 | assert {:ok, nil} = Raxx.Session.extract(request, config) 215 | end 216 | 217 | test "request with other cookies returns no session", %{config: config} do 218 | request = 219 | Raxx.request(:GET, "/") 220 | |> Map.put(:headers, [{"cookie", "foo=1"}, {"cookie", "bar=2; baz=3"}]) 221 | 222 | assert {:ok, nil} = Raxx.Session.extract(request, config) 223 | end 224 | 225 | test "tampered with cookie, different key is an error", %{config: config} do 226 | session = %{"user" => "foe"} 227 | store_config = %store_mod{} = config.store 228 | session_cookie = store_mod.put(session, %{store_config | secret_key_base: "!!TAMPERED!!"}) 229 | 230 | request = 231 | Raxx.request(:GET, "/") 232 | |> Raxx.set_header("cookie", Cookie.serialize({"my_app_session", session_cookie})) 233 | 234 | assert {:error, _} = Raxx.Session.extract(request, config) 235 | end 236 | end 237 | 238 | describe "set cookie options" do 239 | setup %{} do 240 | config = 241 | Raxx.Session.config( 242 | key: "my_app_session", 243 | store: Raxx.Session.SignedCookie, 244 | secret_key_base: String.duplicate("squirrel", 8), 245 | salt: "epsom", 246 | domain: "other.example", 247 | max_age: 123_456_789, 248 | path: "some/path", 249 | secure: true, 250 | # Not sure it's possible to set http_only for false 251 | http_only: true, 252 | extra: "interesting" 253 | ) 254 | 255 | {:ok, config: config} 256 | end 257 | 258 | test "custom options are sent in the response", %{config: config} do 259 | response = 260 | Raxx.response(:ok) 261 | |> Raxx.Session.embed(%{"user" => "other"}, config) 262 | 263 | cookie_string = Raxx.get_header(response, "set-cookie") 264 | cookie = SetCookie.parse(cookie_string) 265 | 266 | assert map_size(cookie.attributes) == 7 267 | assert cookie.attributes.domain == "other.example" 268 | assert cookie.attributes.max_age == "123456789" 269 | assert cookie.attributes.path == "some/path" 270 | assert cookie.attributes.secure == true 271 | assert cookie.attributes.http_only == true 272 | assert cookie.attributes.extra == "interesting" 273 | end 274 | 275 | test "appropriate custom options are sent when expiring session", %{config: config} do 276 | response = 277 | Raxx.response(:ok) 278 | |> Raxx.Session.expire(config) 279 | 280 | cookie_string = Raxx.get_header(response, "set-cookie") 281 | cookie = SetCookie.parse(cookie_string) 282 | 283 | assert map_size(cookie.attributes) == 7 284 | assert cookie.attributes.domain == "other.example" 285 | assert cookie.attributes.expires == "Thu, 01 Jan 1970 00:00:00 GMT" 286 | assert cookie.attributes.max_age == "0" 287 | assert cookie.attributes.path == "some/path" 288 | assert cookie.attributes.secure == true 289 | assert cookie.attributes.http_only == true 290 | assert cookie.attributes.extra == "interesting" 291 | end 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /extensions/raxx_session/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /extensions/raxx_view/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | raxx_view-*.tar 24 | 25 | -------------------------------------------------------------------------------- /extensions/raxx_view/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.1.7](https://github.com/CrowdHailer/raxx/tree/1.0.0) - 2019-05-02 8 | 9 | ### Fixed 10 | 11 | - Escape values provided as optional variables in templates, so that non primitive values can be used. 12 | 13 | ## [0.1.6](https://github.com/CrowdHailer/raxx/tree/1.0.0) - 2019-05-02 14 | 15 | ### Fixed 16 | 17 | - Fix bug so that `:optional` argument doesn't have to be provided in layout. 18 | 19 | ## [0.1.5](https://github.com/CrowdHailer/raxx/tree/1.0.0) - 2019-05-02 20 | 21 | ### Added 22 | 23 | - Both `Raxx.View` and `Raxx.View.Layout` have `:optional` for variables with default values. 24 | 25 | ## [0.1.4](https://github.com/CrowdHailer/raxx/tree/1.0.0) - 2019-04-16 26 | 27 | ### Added 28 | 29 | - Support for raxx 1.0.0. 30 | - Support for eex_html 1.0.0. 31 | 32 | ## [0.1.3](https://github.com/CrowdHailer/raxx/tree/0.18.0) - 2019-03-03 33 | 34 | ### Added 35 | 36 | - Specific dependency on `eex_html`, needed because no longer a dependency of `raxx`. 37 | 38 | ## [0.1.2](https://github.com/CrowdHailer/raxx/tree/0.18.0) - 2019-02-07 39 | 40 | ### Deprecated 41 | 42 | - `Raxx.Layout` deprecated in favour of `Raxx.View.Layout`. 43 | 44 | ### Added 45 | 46 | - Support for raxx 0.18.0. 47 | 48 | ## [0.1.1](#) - 2019-02-06 49 | 50 | ### Added 51 | 52 | - `Raxx.View.partial/3` to generate helper functions from template files. 53 | 54 | ## [0.1.0](https://github.com/CrowdHailer/raxx/tree/0.17.6) - 2019-02-05 55 | 56 | ### Added 57 | 58 | - Library extracted from Raxx package version `0.17.5`. 59 | -------------------------------------------------------------------------------- /extensions/raxx_view/README.md: -------------------------------------------------------------------------------- 1 | # Raxx.View 2 | 3 | **Generate HTML views from `.eex` template files for Raxx web applications.** 4 | 5 | [![Hex pm](http://img.shields.io/hexpm/v/raxx_view.svg?style=flat)](https://hex.pm/packages/raxx_view) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 7 | 8 | - [Install from hex.pm](https://hex.pm/packages/raxx_view) 9 | - [Documentation available on hexdoc](https://hexdocs.pm/raxx_view) 10 | - [Discuss on slack](https://elixir-lang.slack.com/messages/C56H3TBH8/) 11 | 12 | ### Defining Views 13 | 14 | Common layout to use in multiple views. 15 | 16 | *lib/my_app/layout.html.eex* 17 | ```ex 18 |
19 |

<% title %>

20 | <%= __content__ %> 21 |
22 | ``` 23 | Template to show a list of users. 24 | 25 | *lib/my_app/list_users.html.eex* 26 | 27 | ```eex 28 | <%= for user <- users do %> 29 |
30 | <%= user.name %> 31 |

32 | Joined on <%= Timex.format!(user.registered_at, "{YYYY}-0{M}-0{D}") %> 33 |

34 |
35 | ``` 36 | 37 | View module that uses our templates. 38 | 39 | *lib/my_app/list_users_view.ex* 40 | 41 | ```elixir 42 | defmodule MyApp.ListUsersView do 43 | use Raxx.View, 44 | arguments: [:users], 45 | optional: [title: "My App"] 46 | template: "list_users.html.eex", 47 | layout: "layout.html.eex" 48 | end 49 | ``` 50 | 51 | - variables set in `:optional` have a default value that can be overwritten when using the view. 52 | - If `:template` is left unspecified the view will assume the template is in a file of the same name but with extension `.html.eex` in place of `.ex` or `.exs`. 53 | - An option for `:layout` can be omitted if all the content is in the view. 54 | - The `:arguments` can be set to `[:assigns]` if you prefer to use `@var` in you eex templates. 55 | This will not give you a compile time waring about unused arguments. 56 | 57 | ### Using views 58 | 59 | The `Raxx.View` macro generates a `render` function for adding a view to a request/response. 60 | 61 | ```elixir 62 | response = Raxx.response(:ok) 63 | MyApp.ListUsersView.render(response, user, title: "Users Page") 64 | ``` 65 | 66 | To work directly with the generated string an `html` function is also generated. 67 | 68 | ```elixir 69 | MyApp.ListUsersView.html(user, title: "Users Page") 70 | ``` 71 | 72 | ### Views in controllers/actions 73 | 74 | For simple usecases it is often more convenient to keep the controller and view code together. 75 | 76 | ```elixir 77 | defmodule MyApp.ListUsers do 78 | use Raxx.SimpleServer 79 | use Raxx.View, 80 | arguments: [:users], 81 | optional: [title: "My App"] 82 | template: "list_users.html.eex", 83 | layout: "layout.html.eex" 84 | 85 | @impl Raxx.SimpleServer 86 | def handle_request(_request, _state) do 87 | users = MyApp.fetch_users() 88 | 89 | response(:ok) 90 | |> render(users) 91 | end 92 | end 93 | ``` 94 | 95 | ### Helpers 96 | 97 | Helpers can be used to limit the amount of code written in a template. 98 | Functions defined in a view module, public or private, can be called in the template. 99 | 100 | *lib/my_app/list_users.ex* 101 | ```elixir 102 | # ... rest of module 103 | 104 | def display_date(datetime = %DateTime{}) do 105 | Timex.format!(datetime, "{YYYY}-0{M}-0{D}") 106 | end 107 | 108 | def user_page_link(user) do 109 | ~E""" 110 | <%= user.name %> 111 | """ 112 | end 113 | ``` 114 | 115 | Update the template to use the helpers. 116 | 117 | *lib/my_app/list_users.html.eex* 118 | 119 | ```eex 120 | <%= for user <- users do %> 121 |
122 | user_page_link(user) 123 |

124 | Joined on <%= display_date(user.registered_at) %> 125 |

126 |
127 | ``` 128 | 129 | ### Partials 130 | 131 | A partial is like any another helper function, but one that uses an EEx template file. 132 | 133 | *lib/my_app/list_users.ex* 134 | 135 | ```elixir 136 | # ... rest of module 137 | 138 | partial(:profile_card, [:user], template: "profile_card.html.eex") 139 | ``` 140 | 141 | - If `:template` is left unspecified the partial will assume the template is in a file with the same name as the partial with extension `.html.eex`. 142 | 143 | Update the template to make use of the profile_card helper 144 | 145 | *lib/my_app/list_users.html.eex* 146 | 147 | ```eex 148 | <%= for user <- users do %> 149 | profile_card(user) 150 | <% end %> 151 | ``` 152 | 153 | ## Reusable Layouts and Helpers 154 | 155 | Layouts can be used to define views that share layouts and possibly helpers. 156 | 157 | *lib/my_app/layout.ex* 158 | 159 | ```elixir 160 | defmodule MyApp.Layout do 161 | use Raxx.View.Layout, 162 | layout: "layout.html.eex", 163 | optional: [title: "My App"] 164 | 165 | def display_date(datetime = %DateTime{}) do 166 | Timex.format!(datetime, "{YYYY}-0{M}-0{D}") 167 | end 168 | 169 | def user_page_link(user) do 170 | ~E""" 171 | <%= user.name %> 172 | """ 173 | end 174 | 175 | partial(:profile_card, [:user], template: "profile_card.html.eex") 176 | end 177 | ``` 178 | 179 | - If `:layout` is left unspecified the layout will assume the template is in a file of the same name but with extension `.html.eex` in place of `.ex` or `.exs`. 180 | - All functions defined in a layout will be available in the derived views. 181 | 182 | The list users view can be derived from our layout and use the shared helpers. 183 | 184 | *lib/my_app/list_users.ex* 185 | 186 | ```elixir 187 | defmodule MyApp.ListUsersView do 188 | use MyApp.Layout, 189 | arguments: [:users], 190 | optional: [title: "List users - My App"] 191 | template: "list_users.html.eex", 192 | end 193 | ``` 194 | 195 | - Variables set in `:optionals` for the layout can have there default value overwritten by setting them again when using the layout. 196 | -------------------------------------------------------------------------------- /extensions/raxx_view/lib/raxx/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Layout do 2 | @moduledoc false 3 | 4 | defmacro __using__(options) do 5 | :elixir_errors.warn(__ENV__.line, __ENV__.file, """ 6 | The module `#{inspect(__MODULE__)}` is deprecated use `Raxx.View.Layout` instead. 7 | """) 8 | 9 | quote do 10 | use Raxx.View.Layout, unquote(options) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /extensions/raxx_view/lib/raxx/view.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.View do 2 | @moduledoc ~S""" 3 | Generate views from `.eex` template files. 4 | 5 | Using this module will add the functions `html` and `render` to a module. 6 | 7 | To create layouts that can be reused across multiple pages check out `Raxx.View.Layout`. 8 | 9 | ## Example 10 | 11 | # greet.html.eex 12 |

Hello, <%= name %>

13 | 14 | # layout.html.eex 15 |

Greetings

16 | <%= __content__ %> 17 | 18 | # greet.ex 19 | defmodule Greet do 20 | use Raxx.View, 21 | arguments: [:name], 22 | layout: "layout.html.eex" 23 | end 24 | 25 | # iex -S mix 26 | Greet.html("Alice") 27 | # => "

Greetings

\n

Hello, Alice

" 28 | 29 | Raxx.response(:ok) 30 | |> Greet.render("Bob") 31 | # => %Raxx.Response{ 32 | # status: 200, 33 | # headers: [{"content-type", "text/html"}], 34 | # body: "

Greetings

\n

Hello, Bob

" 35 | # } 36 | 37 | ## Options 38 | 39 | - **arguments:** A list of atoms for variables used in the template. 40 | This will be the argument list for the html function. 41 | The render function takes one additional argument to this list, 42 | a response struct. 43 | 44 | - **template (optional):** The eex file containing a main content template. 45 | If not given the template file will be generated from the file of the calling module. 46 | i.e. `path/to/file.ex` -> `path/to/file.html.eex` 47 | 48 | - **layout (optional):** An eex file containing a layout template. 49 | This template can use all the same variables as the main template. 50 | In addition it must include the content using `<%= __content__ %>` 51 | 52 | ## Safety 53 | 54 | ### [XSS (Cross Site Scripting) Prevention](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content) 55 | 56 | All content interpolated into a view is escaped. 57 | 58 | iex> Greet.html(" 77 | ``` 78 | 79 | Use `javascript_variables/1` for injecting variables into any JavaScript environment. 80 | """ 81 | defmacro __using__(options) do 82 | {options, []} = Module.eval_quoted(__CALLER__, options) 83 | 84 | {arguments, options} = Keyword.pop_first(options, :arguments, []) 85 | {optional_arguments, options} = Keyword.pop_first(options, :optional, []) 86 | 87 | {page_template, options} = 88 | Keyword.pop_first(options, :template, Raxx.View.template_for(__CALLER__.file)) 89 | 90 | page_template = Path.expand(page_template, Path.dirname(__CALLER__.file)) 91 | 92 | {layout_template, remaining_options} = Keyword.pop_first(options, :layout) 93 | 94 | if remaining_options != [] do 95 | keys = 96 | Keyword.keys(remaining_options) 97 | |> Enum.map(&inspect/1) 98 | |> Enum.join(", ") 99 | 100 | raise ArgumentError, "Unexpected options for #{inspect(unquote(__MODULE__))}: [#{keys}]" 101 | end 102 | 103 | layout_template = 104 | if layout_template do 105 | Path.expand(layout_template, Path.dirname(__CALLER__.file)) 106 | end 107 | 108 | arguments = Enum.map(arguments, fn a when is_atom(a) -> {a, [line: 1], nil} end) 109 | 110 | optional_bindings = 111 | for {arg, _value} when is_atom(arg) <- optional_arguments do 112 | {arg, {arg, [], nil}} 113 | end 114 | 115 | optional_bindings = {:%{}, [], optional_bindings} 116 | 117 | optional_values = 118 | for {arg, value} when is_atom(arg) <- optional_arguments do 119 | {arg, Macro.escape(value)} 120 | end 121 | 122 | optional_values = {:%{}, [], optional_values} 123 | 124 | compiled_page = EEx.compile_file(page_template, engine: EExHTML.Engine) 125 | 126 | # This step would not be necessary if the compiler could return a wrapped value. 127 | safe_compiled_page = 128 | quote do 129 | EExHTML.raw(unquote(compiled_page)) 130 | end 131 | 132 | compiled_layout = 133 | if layout_template do 134 | EEx.compile_file(layout_template, engine: EExHTML.Engine) 135 | else 136 | {:__content__, [], nil} 137 | end 138 | 139 | {compiled, has_page?} = 140 | Macro.prewalk(compiled_layout, false, fn 141 | {:__content__, _opts, nil}, _acc -> 142 | {safe_compiled_page, true} 143 | 144 | ast, acc -> 145 | {ast, acc} 146 | end) 147 | 148 | if !has_page? do 149 | raise ArgumentError, "Layout missing content, add `<%= __content__ %>` to template" 150 | end 151 | 152 | quote do 153 | import EExHTML 154 | import unquote(__MODULE__), only: [partial: 2, partial: 3] 155 | 156 | if unquote(layout_template) do 157 | @external_resource unquote(layout_template) 158 | @file unquote(layout_template) 159 | end 160 | 161 | @external_resource unquote(page_template) 162 | @file unquote(page_template) 163 | def render(request, unquote_splicing(arguments), optional \\ []) do 164 | request 165 | |> Raxx.set_header("content-type", "text/html") 166 | |> Raxx.set_body(html(unquote_splicing(arguments), optional).data) 167 | end 168 | 169 | def html(unquote_splicing(arguments), optional \\ []) do 170 | optional = 171 | case Keyword.split(optional, Map.keys(unquote(optional_values))) do 172 | {optional, []} -> 173 | optional 174 | 175 | {_, unexpected} -> 176 | raise ArgumentError, 177 | "Unexpect optional variables '#{Enum.join(Keyword.keys(unexpected), ", ")}'" 178 | end 179 | 180 | unquote(optional_bindings) = Enum.into(optional, unquote(optional_values)) 181 | # NOTE from eex_html >= 0.2.0 the content will already be wrapped as safe. 182 | EExHTML.raw(unquote(compiled)) 183 | end 184 | end 185 | end 186 | 187 | @doc """ 188 | Generate template partials from eex templates. 189 | """ 190 | defmacro partial(name, arguments, options \\ []) do 191 | {private, options} = Keyword.pop(options, :private, false) 192 | type = if private, do: :defp, else: :def 193 | file = Keyword.get(options, :template, "#{name}.html.eex") 194 | file = Path.expand(file, Path.dirname(__CALLER__.file)) 195 | {_, options} = Keyword.pop(options, :engine, false) 196 | options = options ++ [engine: EExHTML.Engine] 197 | 198 | quote do 199 | require EEx 200 | 201 | EEx.function_from_file( 202 | unquote(type), 203 | unquote(name), 204 | unquote(file), 205 | unquote(arguments), 206 | unquote(options) 207 | ) 208 | end 209 | end 210 | 211 | @doc false 212 | def template_for(file) do 213 | case String.split(file, ~r/\.ex(s)?$/) do 214 | [path_and_name, ""] -> 215 | path_and_name <> ".html.eex" 216 | 217 | _ -> 218 | raise "#{__MODULE__} needs to be used from a `.ex` or `.exs` file" 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /extensions/raxx_view/lib/raxx/view/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.View.Layout do 2 | @moduledoc """ 3 | Create a general template that can be reused by views. 4 | 5 | Using this module will create a module that can be used as a view. 6 | All functions created in the layout module will be available in the layout template 7 | and the content template 8 | 9 | ## Example 10 | 11 | ### Creating a new layout 12 | 13 | # www/layout.html.eex 14 |

My Site

15 | <%= __content__ %> 16 | 17 | # www/layout.ex 18 | defmodule WWW.Layout do 19 | use Raxx.View.Layout, 20 | layout: "layout.html.eex" 21 | 22 | def format_datetime(datetime) do 23 | DateTime.to_iso8601(datetime) 24 | end 25 | end 26 | 27 | ### Creating a view 28 | 29 | # www/show_user.html.eex 30 |

<%= user.username %>

31 |

signed up at <%= format_datetime(user.interted_at) %>

32 | 33 | # www/show_user.ex 34 | defmodule WWW.ShowUser do 35 | use Raxx.SimpleServer 36 | use WWW.Layout, 37 | template: "show_user.html.eex", 38 | arguments: [:user] 39 | 40 | @impl Raxx.Server 41 | def handle_request(_request, _state) do 42 | user = # fetch user somehow 43 | 44 | response(:ok) 45 | |> render(user) 46 | end 47 | end 48 | 49 | ## Options 50 | 51 | - **layout (optional):** The eex file containing the layout template. 52 | If not given the template file will be generated from the file of the calling module. 53 | i.e. `path/to/file.ex` -> `path/to/file.html.eex` 54 | 55 | - **imports (optional):** A list of modules to import into the template. 56 | The default behaviour is to import only the layout module into each view. 57 | Set this option to false to import no functions. 58 | """ 59 | defmacro __using__(options) do 60 | {options, []} = Module.eval_quoted(__CALLER__, options) 61 | {imports, options} = Keyword.pop_first(options, :imports) 62 | {layout_optional, options} = Keyword.pop_first(options, :optional, []) 63 | 64 | imports = 65 | case imports do 66 | nil -> 67 | [__CALLER__.module] 68 | 69 | false -> 70 | [] 71 | 72 | imports when is_list(imports) -> 73 | imports 74 | end 75 | 76 | {layout_template, remaining_options} = 77 | Keyword.pop_first(options, :layout, Raxx.View.template_for(__CALLER__.file)) 78 | 79 | if remaining_options != [] do 80 | keys = 81 | Keyword.keys(remaining_options) 82 | |> Enum.map(&inspect/1) 83 | |> Enum.join(", ") 84 | 85 | raise ArgumentError, "Unexpected options for #{inspect(unquote(__MODULE__))}: [#{keys}]" 86 | end 87 | 88 | layout_template = Path.expand(layout_template, Path.dirname(__CALLER__.file)) 89 | 90 | quote do 91 | import EExHTML 92 | import Raxx.View, only: [partial: 2, partial: 3] 93 | 94 | defmacro __using__(options) do 95 | imports = unquote(imports) 96 | layout_template = unquote(layout_template) 97 | layout_optional = unquote(Macro.escape(layout_optional)) 98 | 99 | imports = 100 | for i <- imports do 101 | quote do 102 | import unquote(i) 103 | end 104 | end 105 | 106 | {view_optional, options} = Keyword.pop(options, :optional, []) 107 | optional_arguments = Macro.escape(Keyword.merge(layout_optional, view_optional)) 108 | 109 | quote do 110 | unquote(imports) 111 | 112 | use Raxx.View, 113 | Keyword.merge( 114 | [ 115 | layout: unquote(layout_template), 116 | optional: unquote(optional_arguments) 117 | ], 118 | unquote(options) 119 | ) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /extensions/raxx_view/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxView.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_view, 7 | version: "0.1.7", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | docs: [extras: ["README.md"], main: "readme"], 13 | package: package() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:raxx, "~> 0.17.6 or ~> 0.18.0 or ~> 1.0"}, 26 | {:eex_html, "~> 0.2.1 or ~> 1.0"}, 27 | {:ex_doc, ">= 0.0.0", only: :dev} 28 | ] 29 | end 30 | 31 | defp description do 32 | """ 33 | Generate HTML views from `.eex` template files for Raxx web applications. 34 | """ 35 | end 36 | 37 | defp package do 38 | [ 39 | maintainers: ["Peter Saxton"], 40 | licenses: ["Apache 2.0"], 41 | links: %{ 42 | "GitHub" => "https://github.com/crowdhailer/raxx/tree/master/extensions/raxx_view" 43 | } 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /extensions/raxx_view/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cookie": {:hex, :cookie, "0.1.1", "89438362ee0f0ed400e9f076d617d630f82d682e3fbcf767072a46a6e1ed5781", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 4 | "eex_html": {:hex, :eex_html, "1.0.0", "c88020b584d5bfc48ef6c18176af2cfed558225fc5290b435e73119b8ce98ce0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 10 | "raxx": {:hex, :raxx, "1.0.1", "8c51ec5227c85f999360fc844fc1d4e2e5a2adf2b0ce068eb56243ee6b2f65e3", [:mix], [], "hexpm"}, 11 | } 12 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/other.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= private() %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/partial.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= private() %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.View.LayoutTest do 2 | use ExUnit.Case 3 | 4 | defmodule Helpers do 5 | def helper_function() do 6 | "helper_function" 7 | end 8 | end 9 | 10 | defmodule DefaultLayout do 11 | use Raxx.View.Layout, 12 | imports: [__MODULE__, Helpers], 13 | optional: [foo: "foo", bar: "foo", nested: %{inner: "inner"}] 14 | 15 | def layout_function() do 16 | "layout_function" 17 | end 18 | end 19 | 20 | defmodule DefaultLayoutExample do 21 | use DefaultLayout, 22 | arguments: [:x, :y], 23 | optional: [bar: "bar"], 24 | template: "layout_test_example.html.eex" 25 | end 26 | 27 | test "List of imports are available in template" do 28 | assert ["foobar", "inner", "7", "layout_function", "helper_function"] = 29 | lines("#{DefaultLayoutExample.html(3, 4)}") 30 | end 31 | 32 | # Inner checks that non primitive values can be set as defaults 33 | test "optional arguments can be overwritten in layout" do 34 | assert ["bazbaz", "inner", "7", "layout_function", "helper_function"] = 35 | lines("#{DefaultLayoutExample.html(3, 4, foo: "baz", bar: "baz")}") 36 | end 37 | 38 | defp lines(text) do 39 | String.split(text, ~r/\R/) 40 | |> Enum.reject(fn line -> line == "" end) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test.html.eex: -------------------------------------------------------------------------------- 1 | <%= foo %><%= bar %> 2 | <%= nested.inner %> 3 | <%= __content__ %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test_example.html.eex: -------------------------------------------------------------------------------- 1 | <%= x + y %> 2 | <%= layout_function() %> 3 | <%= helper_function() %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ViewTest do 2 | use ExUnit.Case 3 | 4 | defmodule DefaultTemplate do 5 | use Raxx.View, arguments: [:var], optional: [opt: "opt"] 6 | defp private(), do: "DefaultTemplate" 7 | end 8 | 9 | defmodule WithLayout do 10 | use Raxx.View, arguments: [:var], optional: [opt: "opt"], layout: "view_test_layout.html.eex" 11 | 12 | defp private(), do: "WithLayout" 13 | end 14 | 15 | defmodule AbsoluteTemplate do 16 | use Raxx.View, arguments: [:var], template: Path.join(__DIR__, "view_test_other.html.eex") 17 | end 18 | 19 | defmodule RelativeTemplate do 20 | use Raxx.View, arguments: [:var], template: "view_test_other.html.eex" 21 | end 22 | 23 | test "Arguments and private module functions are available in templated" do 24 | assert ["foo", "opt", "DefaultTemplate"] = lines("#{DefaultTemplate.html("foo")}") 25 | end 26 | 27 | test "Optional arguments can be overwritten" do 28 | assert ["foo", "overwrite", "DefaultTemplate"] = 29 | lines("#{DefaultTemplate.html("foo", opt: "overwrite")}") 30 | end 31 | 32 | test "Unexpected optional arguments are an error" do 33 | assert_raise ArgumentError, "Unexpect optional variables 'random'", fn -> 34 | DefaultTemplate.html("foo", random: "random") 35 | end 36 | end 37 | 38 | test "HTML content is escaped" do 39 | assert "<p>" = hd(lines("#{DefaultTemplate.html("

")}")) 40 | end 41 | 42 | test "Safe HTML content is not escaped" do 43 | assert "

" = hd(lines("#{DefaultTemplate.html(EExHTML.raw("

"))}")) 44 | end 45 | 46 | test "Render will set content-type and body" do 47 | response = 48 | Raxx.response(:ok) 49 | |> DefaultTemplate.render("bar", opt: "overwrite") 50 | 51 | assert ["bar", "overwrite", "DefaultTemplate"] = lines(response.body) 52 | assert [{"content-type", "text/html"}, {"content-length", "30"}] = response.headers 53 | end 54 | 55 | test "View can be rendered within a layout" do 56 | assert ["LAYOUT", "baz", "opt", "WithLayout"] = lines("#{WithLayout.html("baz")}") 57 | end 58 | 59 | test "Default template can changed" do 60 | assert ["OTHER", "5"] = lines("#{AbsoluteTemplate.html("5")}") 61 | end 62 | 63 | test "Template path can be relative to calling file" do 64 | assert ["OTHER", "5"] = lines("#{RelativeTemplate.html("5")}") 65 | end 66 | 67 | test "An layout missing space for content is invalid" do 68 | assert_raise ArgumentError, fn -> 69 | defmodule Tmp do 70 | use Raxx.View, layout: "view_test_invalid_layout.html.eex" 71 | end 72 | end 73 | end 74 | 75 | test "Unexpected options are an argument error" do 76 | assert_raise ArgumentError, fn -> 77 | defmodule Tmp do 78 | use Raxx.View, random: :foo 79 | end 80 | end 81 | end 82 | 83 | defp lines(text) do 84 | String.split("#{text}", ~r/\R/) 85 | |> Enum.reject(fn line -> line == "" end) 86 | end 87 | 88 | defmodule DefaultTemplatePartial do 89 | import Raxx.View 90 | 91 | partial(:partial, [:var]) 92 | 93 | defp private do 94 | "Default" 95 | end 96 | end 97 | 98 | defmodule RelativeTemplatePartial do 99 | import Raxx.View 100 | 101 | partial(:partial, [:var], template: "other.html.eex") 102 | 103 | defp private do 104 | "Relative" 105 | end 106 | end 107 | 108 | test "Arguments and private funcations are available in the partial template" do 109 | assert ["5", "Default"] = lines("#{DefaultTemplatePartial.partial("5")}") 110 | end 111 | 112 | test "HTML content in a partial is escaped" do 113 | assert ["5", "Default"] = lines("#{DefaultTemplatePartial.partial("5")}") 114 | end 115 | 116 | test "Partial template path can be relative to calling file" do 117 | assert ["5", "Relative"] = lines("#{RelativeTemplatePartial.partial("5")}") 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= opt %> 3 | <%= raw private() %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_invalid_layout.html.eex: -------------------------------------------------------------------------------- 1 | LAYOUT 2 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_layout.html.eex: -------------------------------------------------------------------------------- 1 | LAYOUT 2 | <%= __content__ %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_other.html.eex: -------------------------------------------------------------------------------- 1 | OTHER 2 | <%= var %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/raxx/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Context do 2 | @type section_name :: term() 3 | 4 | @typedoc """ 5 | An opaque type for the context snapshot data. 6 | """ 7 | @opaque snapshot :: map() 8 | 9 | @moduledoc """ 10 | `Raxx.Context` is a mechanism for simple sharing of state/information between 11 | `Raxx.Middleware`s and `Raxx.Server`s. 12 | 13 | It is designed to be flexible and to enable different middlewares to operate 14 | on it without conflicts. Each separate functionality using the context 15 | can be in a different "section", containing arbitrary data. 16 | 17 | Context is implicitly shared using the process dictionary and persists for the 18 | duration of a single request/response cycle. If you want to pass the context 19 | to a different process, you need to take its snapshot, pass it explicitly and 20 | "restore" it in the other process. See `Raxx.Context.get_snapshot/0` and 21 | `Raxx.Context.restore_snapshot/1` for details. 22 | """ 23 | 24 | @doc """ 25 | Sets the value of a context section. 26 | 27 | Returns the previous value of the section or `nil` if one was 28 | not set. 29 | """ 30 | @spec set(section_name, term) :: term | nil 31 | def set(section_name, value) do 32 | Process.put(tag(section_name), value) 33 | end 34 | 35 | @doc """ 36 | Deletes the section from the context. 37 | 38 | Returns the previous value of the section or `nil` if one was 39 | not set. 40 | """ 41 | @spec delete(section_name) :: term | nil 42 | def delete(section_name) do 43 | Process.delete(tag(section_name)) 44 | end 45 | 46 | @doc """ 47 | Retrieves the value of the context section. 48 | 49 | If the section wasn't set yet, it will return `nil`. 50 | """ 51 | @spec retrieve(section_name, default :: term) :: term 52 | def retrieve(section_name, default \\ nil) do 53 | Process.get(tag(section_name), default) 54 | end 55 | 56 | @doc """ 57 | Restores a previously created context snapshot. 58 | 59 | It will restore the implicit state of the context for the current 60 | process to what it was when the snapshot was created using 61 | `Raxx.Context.get_snapshot/0`. The current context values won't 62 | be persisted in any way. 63 | """ 64 | @spec restore_snapshot(snapshot()) :: :ok 65 | def restore_snapshot(context) when is_map(context) do 66 | new_context_tuples = 67 | context 68 | |> Enum.map(fn {k, v} -> {tag(k), v} end) 69 | 70 | current_context_keys = 71 | Process.get_keys() 72 | |> Enum.filter(&tagged_key?/1) 73 | 74 | new_keys = Enum.map(new_context_tuples, fn {k, _v} -> k end) 75 | keys_to_remove = current_context_keys -- new_keys 76 | 77 | Enum.each(keys_to_remove, &Process.delete/1) 78 | Enum.each(new_context_tuples, fn {k, v} -> Process.put(k, v) end) 79 | end 80 | 81 | @doc """ 82 | Creates a snapshot of the current process' context. 83 | 84 | The returned context data can be passed between processes and restored 85 | using `Raxx.Context.restore_snapshot/1` 86 | """ 87 | @spec get_snapshot() :: snapshot() 88 | def get_snapshot() do 89 | Process.get() 90 | |> Enum.filter(fn {k, _v} -> tagged_key?(k) end) 91 | |> Enum.map(fn {k, v} -> {strip_tag(k), v} end) 92 | |> Map.new() 93 | end 94 | 95 | defp tagged_key?({__MODULE__, _}) do 96 | true 97 | end 98 | 99 | defp tagged_key?(_) do 100 | false 101 | end 102 | 103 | defp strip_tag({__MODULE__, key}) do 104 | key 105 | end 106 | 107 | defp tag(key) do 108 | {__MODULE__, key} 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/raxx/data.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Data do 2 | @moduledoc """ 3 | A part of an HTTP messages body. 4 | 5 | *NOTE: There are no guarantees on how a message's body will be divided into data.* 6 | """ 7 | 8 | @typedoc """ 9 | Container for a section of an HTTP message. 10 | """ 11 | @type t :: %__MODULE__{ 12 | data: iodata 13 | } 14 | 15 | @enforce_keys [:data] 16 | defstruct @enforce_keys 17 | end 18 | -------------------------------------------------------------------------------- /lib/raxx/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Middleware do 2 | alias Raxx.Server 3 | 4 | @moduledoc """ 5 | A "middleware" is a component that sits between the HTTP server 6 | such as as [Ace](https://github.com/CrowdHailer/Ace) and a `Raxx.Server` controller. 7 | The middleware can modify requests request before giving it to the controller and 8 | modify the controllers response before it's given to the server. 9 | 10 | Oftentimes multiple middlewaress might be attached to a controller and 11 | function as a single `t:Raxx.Server.t/0` - see `Raxx.Stack` for details. 12 | 13 | The `Raxx.Middleware` provides a behaviour to be implemented by middlewares. 14 | 15 | ## Example 16 | 17 | Traditionally, middlewares are used for a variety of purposes: managing CORS, 18 | CSRF protection, logging, error handling, and many more. This example shows 19 | a middleware that given a HEAD request "translates" it to a GET one, hands 20 | it over to the controller and strips the response body transforms the 21 | response according to [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13) 22 | 23 | This way the controller doesn't heed to handle the HEAD case at all. 24 | 25 | defmodule Raxx.Middleware.Head do 26 | alias Raxx.Server 27 | alias Raxx.Middleware 28 | 29 | @behaviour Middleware 30 | 31 | @impl Middleware 32 | def process_head(request = %{method: :HEAD}, _config, inner_server) do 33 | request = %{request | method: :GET} 34 | state = :engage 35 | {parts, inner_server} = Server.handle_head(inner_server, request) 36 | 37 | parts = modify_response_parts(parts, state) 38 | {parts, state, inner_server} 39 | end 40 | 41 | def process_head(request = %{method: _}, _config, inner_server) do 42 | {parts, inner_server} = Server.handle_head(inner_server, request) 43 | {parts, :disengage, inner_server} 44 | end 45 | 46 | @impl Middleware 47 | def process_data(data, state, inner_server) do 48 | {parts, inner_server} = Server.handle_data(inner_server, data) 49 | parts = modify_response_parts(parts, state) 50 | {parts, state, inner_server} 51 | end 52 | 53 | @impl Middleware 54 | def process_tail(tail, state, inner_server) do 55 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 56 | parts = modify_response_parts(parts, state) 57 | {parts, state, inner_server} 58 | end 59 | 60 | @impl Middleware 61 | def process_info(info, state, inner_server) do 62 | {parts, inner_server} = Server.handle_info(inner_server, info) 63 | parts = modify_response_parts(parts, state) 64 | {parts, state, inner_server} 65 | end 66 | 67 | defp modify_response_parts(parts, :disengage) do 68 | parts 69 | end 70 | 71 | defp modify_response_parts(parts, :engage) do 72 | Enum.flat_map(parts, &do_handle_response_part(&1)) 73 | end 74 | 75 | defp do_handle_response_part(response = %Raxx.Response{}) do 76 | # the content-length will remain the same 77 | [%Raxx.Response{response | body: false}] 78 | end 79 | 80 | defp do_handle_response_part(%Raxx.Data{}) do 81 | [] 82 | end 83 | 84 | defp do_handle_response_part(%Raxx.Tail{}) do 85 | [] 86 | end 87 | end 88 | 89 | Within the callback implementations the middleware should call through 90 | to the "inner" server and make sure to return its updated state as part 91 | of the `t:Raxx.Middleware.next/0` tuple. 92 | 93 | In certain situations the middleware might want to short-circuit processing 94 | of the incoming messages, bypassing the server. In that case, it should not 95 | call through using `Raxx.Server`'s `handle_*` helper functions and return 96 | the `inner_server` unmodified. 97 | 98 | ## Gotchas 99 | 100 | ### Info messages forwarding 101 | 102 | As you can see in the above example, the middleware can even modify 103 | the `info` messages sent to the server and is responsible for forwarding them 104 | to the inner servers. 105 | 106 | ### Iodata contents 107 | 108 | While much of the time the request body, response body and data chunks will 109 | be represented with binaries, they can be represented 110 | as [`iodata`](https://hexdocs.pm/elixir/typespecs.html#built-in-types). 111 | 112 | A robust middleware should handle that. 113 | """ 114 | 115 | @typedoc """ 116 | The behaviour module and state/config of a raxx middleware 117 | """ 118 | @type t :: {module, state} 119 | 120 | @typedoc """ 121 | State of middleware. 122 | """ 123 | @type state :: any() 124 | 125 | @typedoc """ 126 | Values returned from the `process_*` callbacks 127 | """ 128 | @type next :: {[Raxx.part()], state, Server.t()} 129 | 130 | @doc """ 131 | Called once when a client starts a stream, 132 | 133 | The arguments a `Raxx.Request`, the middleware configuration and 134 | the "inner" server for the middleware to call through to. 135 | 136 | This callback can be relied upon to execute before any other callbacks 137 | """ 138 | @callback process_head(request :: Raxx.Request.t(), state(), inner_server :: Server.t()) :: 139 | next() 140 | 141 | @doc """ 142 | Called every time data from the request body is received. 143 | """ 144 | @callback process_data(binary(), state(), inner_server :: Server.t()) :: next() 145 | 146 | @doc """ 147 | Called once when a request finishes. 148 | 149 | This will be called with an empty list of headers is request is completed without trailers. 150 | 151 | Will not be called at all if the `t:Raxx.Request.t/0` passed to `c:process_head/3` had `body: false`. 152 | """ 153 | @callback process_tail(trailers :: [{binary(), binary()}], state(), inner_server :: Server.t()) :: 154 | next() 155 | 156 | @doc """ 157 | Called for all other messages the middleware may recieve. 158 | 159 | The middleware is responsible for forwarding them to the inner server. 160 | """ 161 | @callback process_info(any(), state(), inner_server :: Server.t()) :: next() 162 | 163 | defmacro __using__(_options) do 164 | quote do 165 | @behaviour unquote(__MODULE__) 166 | 167 | @impl unquote(__MODULE__) 168 | def process_head(request, state, inner_server) do 169 | {parts, inner_server} = Server.handle_head(inner_server, request) 170 | {parts, state, inner_server} 171 | end 172 | 173 | @impl unquote(__MODULE__) 174 | def process_data(data, state, inner_server) do 175 | {parts, inner_server} = Server.handle_data(inner_server, data) 176 | {parts, state, inner_server} 177 | end 178 | 179 | @impl unquote(__MODULE__) 180 | def process_tail(tail, state, inner_server) do 181 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 182 | {parts, state, inner_server} 183 | end 184 | 185 | @impl unquote(__MODULE__) 186 | def process_info(message, state, inner_server) do 187 | {parts, inner_server} = Server.handle_info(inner_server, message) 188 | {parts, state, inner_server} 189 | end 190 | 191 | defoverridable unquote(__MODULE__) 192 | end 193 | end 194 | 195 | @doc false 196 | @spec is_implemented?(module) :: boolean 197 | def is_implemented?(module) when is_atom(module) do 198 | # taken from Raxx.Server 199 | case Code.ensure_compiled(module) do 200 | {:module, module} -> 201 | module.module_info[:attributes] 202 | |> Keyword.get(:behaviour, []) 203 | |> Enum.member?(__MODULE__) 204 | _ -> false 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/raxx/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Request do 2 | @moduledoc """ 3 | HTTP requests to a Raxx application are encapsulated in a `Raxx.Request` struct. 4 | 5 | A request has all the properties of the url it was sent to. 6 | In addition it has optional content, in the body. 7 | As well as a variable number of headers that contain meta data. 8 | 9 | Where appropriate URI properties are named from this definition. 10 | 11 | > scheme:[//[user:password@]host[:port]][/]path[?query][#fragment] 12 | 13 | from [wikipedia](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax) 14 | 15 | The contents are itemised below: 16 | 17 | | **scheme** | `http` or `https`, depending on the transport used. | 18 | | **authority** | The location of the hosting server, as a binary. e.g. `www.example.com`. Plus an optional port number, separated from the hostname by a colon | 19 | | **method** | The HTTP request method, such as `:GET` or `:POST`, as an atom. This cannot ever be `nil`. It is always uppercase. | 20 | | **path** | The remainder of the request URL's “path”, split into segments. It designates the virtual “location” of the request's target within the application. This may be an empty array, if the requested URL targets the application root. | 21 | | **raw_path** | The request URL's "path" | 22 | | **query** | the URL query string. | 23 | | **headers** | The headers from the HTTP request as a proplist of strings. Note all headers will be downcased, e.g. `[{"content-type", "text/plain"}]` | 24 | | **body** | The body content sent with the request | 25 | 26 | """ 27 | 28 | @typedoc """ 29 | Method to indicate the desired action to be performed on the identified resource. 30 | """ 31 | @type method :: atom 32 | 33 | @typedoc """ 34 | Scheme describing protocol used. 35 | """ 36 | @type scheme :: :http | :https 37 | 38 | @typedoc """ 39 | Elixir representation for an HTTP request. 40 | """ 41 | @type t :: %__MODULE__{ 42 | scheme: scheme, 43 | authority: binary, 44 | method: method, 45 | path: [binary], 46 | raw_path: binary, 47 | query: binary | nil, 48 | headers: Raxx.headers(), 49 | body: Raxx.body() 50 | } 51 | 52 | defstruct scheme: nil, 53 | authority: nil, 54 | method: nil, 55 | path: [], 56 | raw_path: "", 57 | query: nil, 58 | headers: [], 59 | body: nil 60 | 61 | @default_ports %{ 62 | http: 80, 63 | https: 443 64 | } 65 | 66 | @doc """ 67 | Return the host value for the request. 68 | 69 | The `t:Raxx.Request.t/0` struct contains `authority` field, which 70 | may contain the port number. This function returns the host value which 71 | won't include the port number. 72 | """ 73 | def host(%__MODULE__{authority: authority}) do 74 | hd(String.split(authority, ":")) 75 | end 76 | 77 | @doc """ 78 | Return the port number used for the request. 79 | 80 | If no port number is explicitly specified in the request url, the 81 | default one for the scheme is used. 82 | """ 83 | @spec port(t, %{optional(atom) => :inet.port_number()}) :: :inet.port_number() 84 | def port(%__MODULE__{scheme: scheme, authority: authority}, default_ports \\ @default_ports) do 85 | case String.split(authority, ":") do 86 | [_host] -> 87 | Map.get(default_ports, scheme) 88 | 89 | [_host, port_string] -> 90 | case Integer.parse(port_string) do 91 | {port, _} when port in 0..65535 -> 92 | port 93 | end 94 | end 95 | end 96 | 97 | @doc """ 98 | Returns an `URI` struct corresponding to the url used in the provided request. 99 | 100 | **NOTE**: the `userinfo` field of the `URI` will always be `nil`, even if there 101 | is `Authorization` header basic auth information contained in the request. 102 | 103 | The `fragment` will also be `nil`, as the servers don't have access to it. 104 | """ 105 | @spec uri(t) :: URI.t() 106 | def uri(%__MODULE__{} = request) do 107 | scheme = 108 | case request.scheme do 109 | nil -> nil 110 | atom when is_atom(atom) -> Atom.to_string(atom) 111 | end 112 | 113 | %URI{ 114 | authority: request.authority, 115 | host: Raxx.request_host(request), 116 | path: request.raw_path, 117 | port: port(request), 118 | query: request.query, 119 | scheme: scheme, 120 | # you can't provide userinfo in a http request url (anymore) 121 | # pulling it out of Authorization headers would go against the 122 | # main use-case for this function 123 | userinfo: nil 124 | } 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/raxx/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Response do 2 | @moduledoc """ 3 | HTTP responses from a Raxx application are encapsulated in a `Raxx.Response` struct. 4 | 5 | The contents are itemised below: 6 | 7 | | **status** | The HTTP status code for the response: `1xx, 2xx, 3xx, 4xx, 5xx` | 8 | | **headers** | The response headers as a list: `[{"content-type", "text/plain"}` | 9 | | **body** | The response body, by default an empty string. | 10 | 11 | """ 12 | 13 | @typedoc """ 14 | Integer code for server response type 15 | """ 16 | @type status_code :: integer 17 | 18 | @typedoc """ 19 | Elixir representation for an HTTP response. 20 | """ 21 | @type t :: %__MODULE__{ 22 | status: status_code, 23 | headers: Raxx.headers(), 24 | body: Raxx.body() 25 | } 26 | 27 | @enforce_keys [:status, :headers, :body] 28 | defstruct @enforce_keys 29 | end 30 | -------------------------------------------------------------------------------- /lib/raxx/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Router do 2 | @moduledoc """ 3 | Routing for Raxx applications. 4 | 5 | Routes are defined as a match and an action module. 6 | Standard Elixir pattern matching is used to apply the match to an incoming request. 7 | An action module another implementation of `Raxx.Server` 8 | 9 | Sections group routes that all have the same middleware. 10 | Middleware in a section maybe defined as a list, 11 | this is useful when all configuration is known at compile-time. 12 | Alternativly an arity 1 function can be used. 13 | This can be used when middleware require runtime configuration. 14 | The argument passed to this function is server initial state. 15 | 16 | ## Examples 17 | 18 | defmodule MyRouter do 19 | use Raxx.Router 20 | 21 | section [{Raxx.Logger, level: :debug}], [ 22 | {%{method: :GET, path: ["ping"]}, Ping}, 23 | ] 24 | 25 | section &web/1, [ 26 | {%{method: :GET, path: []}, HomePage}, 27 | {%{method: :GET, path: ["users"]}, UsersPage}, 28 | {%{method: :GET, path: ["users", _id]}, UserPage}, 29 | {%{method: :POST, path: ["users"]}, CreateUser}, 30 | {_, NotFoundPage} 31 | ] 32 | 33 | def web(state) do 34 | [ 35 | {Raxx.Logger, level: state.log_level}, 36 | {MyMiddleware, foo: state.foo} 37 | ] 38 | end 39 | end 40 | *If the sections DSL does not work for an application it is possible to instead just implement a `route/2` function.* 41 | """ 42 | 43 | @callback route(Raxx.Request.t(), term) :: Raxx.Stack.t() 44 | 45 | @doc false 46 | defmacro __using__([]) do 47 | quote location: :keep do 48 | @behaviour Raxx.Server 49 | import unquote(__MODULE__) 50 | @behaviour unquote(__MODULE__) 51 | 52 | @impl Raxx.Server 53 | def handle_head(request, state) do 54 | stack = route(request, state) 55 | Raxx.Server.handle_head(stack, request) 56 | end 57 | 58 | @impl Raxx.Server 59 | def handle_data(data, stack) do 60 | Raxx.Server.handle_data(stack, data) 61 | end 62 | 63 | @impl Raxx.Server 64 | def handle_tail(trailers, stack) do 65 | Raxx.Server.handle_tail(stack, trailers) 66 | end 67 | 68 | @impl Raxx.Server 69 | def handle_info(message, stack) do 70 | Raxx.Server.handle_info(stack, message) 71 | end 72 | end 73 | end 74 | 75 | @doc """ 76 | Define a set of routes with a common set of middlewares applied to them. 77 | 78 | The first argument may be a list of middlewares; 79 | or a function that accepts one argument, the initial state, and returns a list of middleware. 80 | 81 | If all settings for a middleware can be decided at compile-time then a list is preferable. 82 | """ 83 | defmacro section(middlewares, routes) do 84 | state = quote do: state 85 | 86 | resolved_middlewares = 87 | case middlewares do 88 | middlewares when is_list(middlewares) -> 89 | middlewares 90 | 91 | _ -> 92 | quote do 93 | unquote(middlewares).(unquote(state)) 94 | end 95 | end 96 | 97 | for {match, action} <- routes do 98 | quote do 99 | def route(unquote(match), unquote(state)) do 100 | # Should this verify_implementation for the action/middlewares 101 | # Perhaps Stack.new should do it 102 | Raxx.Stack.new(unquote(resolved_middlewares), {unquote(action), unquote(state)}) 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/raxx/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Server do 2 | @moduledoc """ 3 | Interface to handle server side communication in an HTTP message exchange. 4 | 5 | If simple `request -> response` transformation is possible, try `Raxx.SimpleServer` 6 | 7 | *A module implementing `Raxx.Server` is run by an HTTP server. 8 | For example [Ace](https://github.com/CrowdHailer/Ace) 9 | can run such a module for both HTTP/1.x and HTTP/2 exchanges* 10 | 11 | ## Getting Started 12 | 13 | **Send complete response as soon as request headers are received.** 14 | 15 | defmodule HelloServer do 16 | use Raxx.Server 17 | 18 | def handle_head(%Raxx.Request{method: :GET, path: []}, _state) do 19 | response(:ok) 20 | |> set_header("content-type", "text/plain") 21 | |> set_body("Hello, World!") 22 | end 23 | end 24 | 25 | **Store data as it is available from a clients request** 26 | 27 | defmodule StreamingRequest do 28 | use Raxx.Server 29 | 30 | def handle_head(%Raxx.Request{method: :PUT, body: true}, _state) do 31 | {:ok, io_device} = File.open("my/path") 32 | {[], {:file, device}} 33 | end 34 | 35 | def handle_data(body_chunk, state = {:file, device}) do 36 | IO.write(device, body_chunk) 37 | {[], state} 38 | end 39 | 40 | def handle_tail(_trailers, state) do 41 | response(:see_other) 42 | |> set_header("location", "/") 43 | end 44 | end 45 | 46 | **Subscribe server to event source and forward notifications to client.** 47 | 48 | defmodule SubscribeToMessages do 49 | use Raxx.Server 50 | 51 | def handle_head(_request, _state) do 52 | {:ok, _} = ChatRoom.join() 53 | response(:ok) 54 | |> set_header("content-type", "text/event-stream") 55 | |> set_body(true) 56 | end 57 | 58 | def handle_info({ChatRoom, data}, state) do 59 | {[body(data)], state} 60 | end 61 | end 62 | 63 | ### Notes 64 | 65 | - `handle_head/2` will always be called with a request that has body as a boolean. 66 | For small requests where buffering the whole request is acceptable a simple middleware can be used. 67 | - Acceptable return values are the same for all callbacks; 68 | either a `Raxx.Response`, which must be complete or 69 | a list of message parts and a new state. 70 | 71 | ## Streaming 72 | 73 | `Raxx.Server` defines an interface to stream the body of request and responses. 74 | 75 | This has several advantages: 76 | 77 | - Large payloads do not need to be help in memory 78 | - Server can push information as it becomes available, using Server Sent Events. 79 | - If a request has invalid headers then a reply can be set without handling the body. 80 | - Content can be generated as requested using HTTP/2 flow control 81 | 82 | The body of a Raxx message (Raxx.Request or `Raxx.Response`) may be one of three types: 83 | 84 | - `iodata` - This is the complete body for the message. 85 | - `:false` - There **is no** body, for example `:GET` requests never have a body. 86 | - `:true` - There **is** a body, it can be processed as it is received 87 | 88 | ## Server Isolation 89 | 90 | To start an exchange a client sends a request. 91 | The server, upon receiving this message, sends a reply. 92 | A logical HTTP exchange consists of a single request and response. 93 | 94 | Methods such as [pipelining](https://en.wikipedia.org/wiki/HTTP_pipelining) 95 | and [multiplexing](http://qnimate.com/what-is-multiplexing-in-http2/) 96 | combine multiple logical exchanges onto a single connection. 97 | This is done to improve performance and is a detail not exposed a server. 98 | 99 | A Raxx server handles a single HTTP exchange. 100 | Therefore a single connection my have multiple servers each isolated in their own process. 101 | 102 | ## Termination 103 | 104 | An exchange can be stopped early by terminating the server process. 105 | Support for early termination is not consistent between versions of HTTP. 106 | 107 | - HTTP/2: server exit with reason `:normal`, stream reset with error `CANCEL`. 108 | - HTTP/2: server exit any other reason, stream reset with error `INTERNAL_ERROR`. 109 | - HTTP/1.x: server exit with any reason, connection is closed. 110 | 111 | `Raxx.Server` does not provide a terminate callback. 112 | Any cleanup that needs to be done from an aborted exchange should be handled by monitoring the server process. 113 | """ 114 | 115 | @typedoc """ 116 | The behaviour and state of a raxx server 117 | """ 118 | @type t :: {module, state} 119 | 120 | @typedoc """ 121 | State of application server. 122 | 123 | Original value is the configuration given when starting the raxx application. 124 | """ 125 | @type state :: any() 126 | 127 | @typedoc """ 128 | Possible return values instructing server to send client data and update state if appropriate. 129 | """ 130 | @type next :: {[Raxx.part()], state} | Raxx.Response.t() 131 | 132 | @doc """ 133 | Called once when a client starts a stream, 134 | 135 | Passed a `Raxx.Request` and server configuration. 136 | Note the value of the request body will be a boolean. 137 | 138 | This callback can be relied upon to execute before any other callbacks 139 | """ 140 | @callback handle_head(Raxx.Request.t(), state()) :: next 141 | 142 | @doc """ 143 | Called every time data from the request body is received 144 | """ 145 | @callback handle_data(binary(), state()) :: next 146 | 147 | @doc """ 148 | Called once when a request finishes. 149 | 150 | This will be called with an empty list of headers is request is completed without trailers. 151 | 152 | Will not be called at all if the `t:Raxx.Request.t/0` struct passed to `c:handle_head/2` had `body: false`. 153 | """ 154 | @callback handle_tail([{binary(), binary()}], state()) :: next 155 | 156 | @doc """ 157 | Called for all other messages the server may recieve 158 | """ 159 | @callback handle_info(any(), state()) :: next 160 | 161 | defmacro __using__(_options) do 162 | quote do 163 | @behaviour unquote(__MODULE__) 164 | import Raxx 165 | 166 | @impl unquote(__MODULE__) 167 | def handle_data(data, state) do 168 | import Logger 169 | Logger.warn("Received unexpected data: #{inspect(data)}") 170 | {[], state} 171 | end 172 | 173 | @impl unquote(__MODULE__) 174 | def handle_tail(trailers, state) do 175 | import Logger 176 | Logger.warn("Received unexpected trailers: #{inspect(trailers)}") 177 | {[], state} 178 | end 179 | 180 | @impl unquote(__MODULE__) 181 | def handle_info(message, state) do 182 | import Logger 183 | Logger.warn("Received unexpected message: #{inspect(message)}") 184 | {[], state} 185 | end 186 | 187 | defoverridable unquote(__MODULE__) 188 | end 189 | end 190 | 191 | @doc """ 192 | Execute a server module and current state in response to a new message 193 | """ 194 | @spec handle(t, term) :: {[Raxx.part()], state()} 195 | def handle({module, state}, request = %Raxx.Request{}) do 196 | normalize_reaction(module.handle_head(request, state), state) 197 | end 198 | 199 | def handle({module, state}, %Raxx.Data{data: data}) do 200 | normalize_reaction(module.handle_data(data, state), state) 201 | end 202 | 203 | def handle({module, state}, %Raxx.Tail{headers: headers}) do 204 | normalize_reaction(module.handle_tail(headers, state), state) 205 | end 206 | 207 | def handle({module, state}, other) do 208 | normalize_reaction(module.handle_info(other, state), state) 209 | end 210 | 211 | @doc """ 212 | Similar to `Raxx.Server.handle/2`, except it only accepts `t:Raxx.Request.t/0` 213 | and returns the whole server, not just its state. 214 | 215 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 216 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 217 | by callbacks. 218 | """ 219 | @spec handle_head(t(), Raxx.Request.t()) :: {[Raxx.part()], t()} 220 | def handle_head({module, state}, request = %Raxx.Request{}) do 221 | {parts, new_state} = normalize_reaction(module.handle_head(request, state), state) 222 | {parts, {module, new_state}} 223 | end 224 | 225 | @doc """ 226 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", binary data 227 | and returns the whole server, not just its state. 228 | 229 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 230 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 231 | by callbacks. 232 | """ 233 | @spec handle_data(t(), binary()) :: {[Raxx.part()], t()} 234 | def handle_data({module, state}, data) do 235 | {parts, new_state} = normalize_reaction(module.handle_data(data, state), state) 236 | {parts, {module, new_state}} 237 | end 238 | 239 | @doc """ 240 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", trailers 241 | and returns the whole server, not just its state. 242 | 243 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 244 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 245 | by callbacks. 246 | """ 247 | @spec handle_tail(t(), [{binary(), binary()}]) :: {[Raxx.part()], t()} 248 | def handle_tail({module, state}, tail) do 249 | {parts, new_state} = normalize_reaction(module.handle_tail(tail, state), state) 250 | {parts, {module, new_state}} 251 | end 252 | 253 | @doc """ 254 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", trailers 255 | and returns the whole server, not just its state. 256 | 257 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 258 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 259 | by callbacks. 260 | """ 261 | @spec handle_info(t(), any()) :: {[Raxx.part()], t()} 262 | def handle_info({module, state}, info) do 263 | {parts, new_state} = normalize_reaction(module.handle_info(info, state), state) 264 | {parts, {module, new_state}} 265 | end 266 | 267 | @doc false 268 | @spec normalize_reaction(next(), state() | module()) :: 269 | {[Raxx.part()], state() | module()} | no_return 270 | def normalize_reaction(response = %Raxx.Response{body: true}, _initial_state) do 271 | raise %ReturnError{return: response} 272 | end 273 | 274 | def normalize_reaction(response = %Raxx.Response{}, initial_state) do 275 | {[response], initial_state} 276 | end 277 | 278 | def normalize_reaction({parts, new_state}, _initial_state) when is_list(parts) do 279 | {parts, new_state} 280 | end 281 | 282 | def normalize_reaction(other, _initial_state) do 283 | raise %ReturnError{return: other} 284 | end 285 | 286 | @doc """ 287 | Verify server can be run? 288 | 289 | A runnable server consists of a tuple of server module and initial state. 290 | The server module must implement this modules behaviour. 291 | The initial state can be any term 292 | 293 | ## Examples 294 | 295 | # Could just call verify 296 | iex> Raxx.Server.verify_server({Raxx.ServerTest.DefaultServer, %{}}) 297 | {:ok, {Raxx.ServerTest.DefaultServer, %{}}} 298 | 299 | iex> Raxx.Server.verify_server({GenServer, %{}}) 300 | {:error, {:not_a_server_module, GenServer}} 301 | 302 | iex> Raxx.Server.verify_server({NotAModule, %{}}) 303 | {:error, {:not_a_module, NotAModule}} 304 | """ 305 | def verify_server({module, term}) do 306 | case verify_implementation(module) do 307 | {:ok, _} -> 308 | {:ok, {module, term}} 309 | 310 | {:error, reason} -> 311 | {:error, reason} 312 | end 313 | end 314 | 315 | @doc false 316 | def verify_implementation!(module) do 317 | case Raxx.Server.verify_implementation(module) do 318 | {:ok, _} -> 319 | :no_op 320 | 321 | {:error, {:not_a_server_module, module}} -> 322 | raise ArgumentError, "module `#{module}` does not implement `Raxx.Server` behaviour." 323 | 324 | {:error, {:not_a_module, module}} -> 325 | raise ArgumentError, "module `#{module}` could not be loaded." 326 | end 327 | end 328 | 329 | @doc false 330 | def verify_implementation(module) do 331 | case fetch_behaviours(module) do 332 | {:ok, behaviours} -> 333 | if Enum.member?(behaviours, __MODULE__) do 334 | {:ok, module} 335 | else 336 | {:error, {:not_a_server_module, module}} 337 | end 338 | 339 | {:error, reason} -> 340 | {:error, reason} 341 | end 342 | end 343 | 344 | defp fetch_behaviours(module) do 345 | case Code.ensure_compiled(module) do 346 | {:module, _module} -> 347 | behaviours = 348 | module.module_info[:attributes] 349 | |> Keyword.take([:behaviour]) 350 | |> Keyword.values() 351 | |> List.flatten() 352 | 353 | {:ok, behaviours} 354 | 355 | _ -> 356 | {:error, {:not_a_module, module}} 357 | end 358 | end 359 | end 360 | -------------------------------------------------------------------------------- /lib/raxx/server/return_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ReturnError do 2 | @moduledoc """ 3 | Raise when a server module returns an invalid reaction 4 | """ 5 | 6 | # DEBT could be improved by including server module in message and if it implements behaviour. 7 | defexception [:return] 8 | 9 | def message(%{return: return}) do 10 | """ 11 | Invalid reaction from server module. Response must be complete or include update server state 12 | 13 | e.g. 14 | \# Complete 15 | Raxx.response(:ok) 16 | |> Raxx.set_body("Hello, World!") 17 | 18 | \# New server state 19 | response = Raxx.response(:ok) 20 | |> Raxx.set_body(true) 21 | {[response], new_state} 22 | 23 | Actual value returned was 24 | #{inspect(return)} 25 | """ 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/raxx/simple_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.SimpleServer do 2 | @moduledoc """ 3 | Server interface for simple `request -> response` interactions. 4 | 5 | *Modules that use Raxx.SimpleServer implement the Raxx.Server behaviour. 6 | Default implementations are provided for the streaming interface to buffer the request before a single call to `handle_request/2`.* 7 | 8 | ## Example 9 | 10 | Echo the body of a request to the client 11 | 12 | defmodule EchoServer do 13 | use Raxx.SimpleServer, maximum_body_length: 12 * 1024 * 1024 14 | 15 | def handle_request(%Raxx.Request{method: :POST, path: [], body: body}, _state) do 16 | response(:ok) 17 | |> set_header("content-type", "text/plain") 18 | |> set_body(body) 19 | end 20 | end 21 | 22 | ## Options 23 | 24 | - **maximum_body_length** (default 8MB) the maximum sized body that will be automatically buffered. 25 | For large requests, e.g. file uploads, consider implementing a streaming server. 26 | 27 | """ 28 | 29 | @typedoc """ 30 | State of application server. 31 | 32 | Original value is the configuration given when starting the raxx application. 33 | """ 34 | @type state :: any() 35 | 36 | @doc """ 37 | Called with a complete request once all the data parts of a body are received. 38 | 39 | Passed a `Raxx.Request` and server configuration. 40 | Note the value of the request body will be a string. 41 | """ 42 | @callback handle_request(Raxx.Request.t(), state()) :: Raxx.Response.t() 43 | 44 | @eight_MB 8 * 1024 * 1024 45 | 46 | defmacro __using__(options) do 47 | {options, []} = Module.eval_quoted(__CALLER__, options) 48 | maximum_body_length = Keyword.get(options, :maximum_body_length, @eight_MB) 49 | 50 | quote do 51 | @behaviour unquote(__MODULE__) 52 | import Raxx 53 | 54 | @behaviour Raxx.Server 55 | 56 | def handle_head(request = %{body: false}, state) do 57 | response = __MODULE__.handle_request(%{request | body: ""}, state) 58 | 59 | case response do 60 | %{body: true} -> raise "Incomplete response" 61 | _ -> response 62 | end 63 | end 64 | 65 | def handle_head(request = %{body: true}, state) do 66 | {[], {request, [], state}} 67 | end 68 | 69 | def handle_data(data, {request, iodata_buffer, state}) do 70 | iodata_buffer = [data | iodata_buffer] 71 | 72 | if :erlang.iolist_size(iodata_buffer) <= unquote(maximum_body_length) do 73 | {[], {request, iodata_buffer, state}} 74 | else 75 | Raxx.error_response(:payload_too_large) 76 | end 77 | end 78 | 79 | def handle_tail([], {request, iodata_buffer, state}) do 80 | body = :erlang.iolist_to_binary(Enum.reverse(iodata_buffer)) 81 | response = __MODULE__.handle_request(%{request | body: body}, state) 82 | 83 | case response do 84 | %{body: true} -> raise "Incomplete response" 85 | _ -> response 86 | end 87 | end 88 | 89 | def handle_info(message, state) do 90 | require Logger 91 | 92 | Logger.warn( 93 | "#{inspect(self())} received unexpected message in handle_info/2: #{inspect(message)}" 94 | ) 95 | 96 | {[], state} 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/raxx/stack.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Stack do 2 | alias Raxx.Server 3 | alias Raxx.Middleware 4 | 5 | @behaviour Server 6 | 7 | @moduledoc """ 8 | A `Raxx.Stack` is a list of `Raxx.Middleware`s attached to a `Raxx.Server`. 9 | It implements the `Raxx.Server` interface itself so it can be used anywhere 10 | "normal" server can be. 11 | """ 12 | 13 | defmodule State do 14 | @moduledoc false 15 | 16 | @enforce_keys [:middlewares, :server] 17 | defstruct @enforce_keys 18 | 19 | # DEBT: compare struct t() performance to a (tagged) tuple implementation 20 | @type t :: %__MODULE__{ 21 | middlewares: [Middleware.t()], 22 | server: Server.t() 23 | } 24 | 25 | def new(middlewares \\ [], server) when is_list(middlewares) do 26 | %__MODULE__{ 27 | middlewares: middlewares, 28 | server: server 29 | } 30 | end 31 | 32 | def get_server(%__MODULE__{server: server}) do 33 | server 34 | end 35 | 36 | def set_server(state = %__MODULE__{}, {_, _} = server) do 37 | %__MODULE__{state | server: server} 38 | end 39 | 40 | def get_middlewares(%__MODULE__{middlewares: middlewares}) do 41 | middlewares 42 | end 43 | 44 | def set_middlewares(state = %__MODULE__{}, middlewares) when is_list(middlewares) do 45 | %__MODULE__{state | middlewares: middlewares} 46 | end 47 | 48 | @spec push_middleware(t(), Middleware.t()) :: t() 49 | def push_middleware(state = %__MODULE__{middlewares: middlewares}, middleware) do 50 | %__MODULE__{state | middlewares: [middleware | middlewares]} 51 | end 52 | 53 | @spec pop_middleware(t()) :: {Middleware.t() | nil, t()} 54 | def pop_middleware(state = %__MODULE__{middlewares: middlewares}) do 55 | case middlewares do 56 | [] -> 57 | {nil, state} 58 | 59 | [topmost | rest] -> 60 | {topmost, %__MODULE__{state | middlewares: rest}} 61 | end 62 | end 63 | end 64 | 65 | @typedoc """ 66 | The internal state of the `Raxx.Stack`. 67 | 68 | Its structure shouldn't be relied on, it is subject to change without warning. 69 | """ 70 | @opaque state :: State.t() 71 | 72 | @typedoc """ 73 | Represents a pipeline of middlewares attached to a server. 74 | 75 | Can be used exactly as any `t:Raxx.Server.t/0` could be. 76 | """ 77 | @type t :: {__MODULE__, state()} 78 | 79 | ## Public API 80 | 81 | @doc """ 82 | Creates a new stack from a list of middlewares and a server. 83 | """ 84 | @spec new([Middleware.t()], Server.t()) :: t() 85 | def new(middlewares \\ [], server) when is_list(middlewares) do 86 | {__MODULE__, State.new(middlewares, server)} 87 | end 88 | 89 | @doc """ 90 | Replaces the server in the stack. 91 | """ 92 | @spec set_server(t(), Server.t()) :: t() 93 | def set_server({__MODULE__, state}, server) do 94 | {__MODULE__, State.set_server(state, server)} 95 | end 96 | 97 | @doc """ 98 | Returns the server contained in the stack. 99 | """ 100 | @spec get_server(t()) :: Server.t() 101 | def get_server({__MODULE__, state}) do 102 | State.get_server(state) 103 | end 104 | 105 | @doc """ 106 | Replaces the middlewares in the stack. 107 | """ 108 | @spec set_middlewares(t(), [Middleware.t()]) :: t() 109 | def set_middlewares({__MODULE__, state}, middlewares) do 110 | {__MODULE__, State.set_middlewares(state, middlewares)} 111 | end 112 | 113 | @doc """ 114 | Returns the server contained in the stack. 115 | """ 116 | @spec get_middlewares(t()) :: [Middleware.t()] 117 | def get_middlewares({__MODULE__, state}) do 118 | State.get_middlewares(state) 119 | end 120 | 121 | ## Raxx.Server callbacks 122 | 123 | # NOTE those 4 can be rewritten using macros instead of apply for a minor performance increase 124 | @impl Server 125 | def handle_head(request, state) do 126 | handle_anything(request, state, :handle_head, :process_head) 127 | end 128 | 129 | @impl Server 130 | def handle_data(data, state) do 131 | handle_anything(data, state, :handle_data, :process_data) 132 | end 133 | 134 | @impl Server 135 | def handle_tail(tail, state) do 136 | handle_anything(tail, state, :handle_tail, :process_tail) 137 | end 138 | 139 | @impl Server 140 | def handle_info(message, state) do 141 | handle_anything(message, state, :handle_info, :process_info) 142 | end 143 | 144 | defp handle_anything(input, state, server_function, middleware_function) do 145 | case State.pop_middleware(state) do 146 | {nil, ^state} -> 147 | # time for the inner server to handle input 148 | server = State.get_server(state) 149 | {parts, new_server} = apply(Server, server_function, [server, input]) 150 | 151 | state = State.set_server(state, new_server) 152 | {parts, state} 153 | 154 | {middleware, state} -> 155 | # the top middleware was popped off the stack 156 | {middleware_module, middleware_state} = middleware 157 | 158 | {parts, middleware_state, {__MODULE__, state}} = 159 | apply(middleware_module, middleware_function, [ 160 | input, 161 | middleware_state, 162 | {__MODULE__, state} 163 | ]) 164 | 165 | state = State.push_middleware(state, {middleware_module, middleware_state}) 166 | {parts, state} 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/raxx/tail.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Tail do 2 | @moduledoc """ 3 | A trailer allows the sender to include additional fields at the end of a streamed message. 4 | """ 5 | @typedoc """ 6 | Container for optional trailers of an HTTP message. 7 | """ 8 | @type t :: %__MODULE__{ 9 | headers: [{String.t(), String.t()}] 10 | } 11 | 12 | @enforce_keys [:headers] 13 | defstruct @enforce_keys 14 | end 15 | -------------------------------------------------------------------------------- /lib/status.rfc7231: -------------------------------------------------------------------------------- 1 | 100 Continue 2 | 101 Switching Protocols 3 | 200 OK 4 | 201 Created 5 | 202 Accepted 6 | 203 Non-Authoritative Information 7 | 204 No Content 8 | 205 Reset Content 9 | 206 Partial Content 10 | 300 Multiple Choices 11 | 301 Moved Permanently 12 | 302 Found 13 | 303 See Other 14 | 304 Not Modified 15 | 305 Use Proxy 16 | 307 Temporary Redirect 17 | 400 Bad Request 18 | 401 Unauthorized 19 | 402 Payment Required 20 | 403 Forbidden 21 | 404 Not Found 22 | 405 Method Not Allowed 23 | 406 Not Acceptable 24 | 407 Proxy Authentication Required 25 | 408 Request Timeout 26 | 409 Conflict 27 | 410 Gone 28 | 411 Length Required 29 | 412 Precondition Failed 30 | 413 Payload Too Large 31 | 414 URI Too Long 32 | 415 Unsupported Media Type 33 | 416 Range Not Satisfiable 34 | 417 Expectation Failed 35 | 426 Upgrade Required 36 | 500 Internal Server Error 37 | 501 Not Implemented 38 | 502 Bad Gateway 39 | 503 Service Unavailable 40 | 504 Gateway Timeout 41 | 505 HTTP Version Not Supported 42 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx, 7 | version: "1.1.0", 8 | elixir: "~> 1.6", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | elixirc_options: [ 13 | warnings_as_errors: true 14 | ], 15 | description: description(), 16 | docs: [extras: ["README.md"], main: "readme", assets: ["assets"]], 17 | package: package(), 18 | aliases: aliases() 19 | ] 20 | end 21 | 22 | def application do 23 | [extra_applications: [:logger, :ssl, :eex]] 24 | end 25 | 26 | defp deps do 27 | [ 28 | {:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false}, 29 | {:ex_doc, ">= 0.0.0", only: :dev}, 30 | {:benchee, "~> 0.13.2", only: [:dev, :test]} 31 | ] 32 | end 33 | 34 | defp description do 35 | """ 36 | Interface for HTTP webservers, frameworks and clients. 37 | """ 38 | end 39 | 40 | defp package do 41 | [ 42 | maintainers: ["Peter Saxton"], 43 | licenses: ["Apache 2.0"], 44 | links: %{"GitHub" => "https://github.com/crowdhailer/raxx"} 45 | ] 46 | end 47 | 48 | defp aliases do 49 | [ 50 | test: ["test --exclude deprecations --exclude benchmarks"] 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "0.13.2", "30cd4ff5f593fdd218a9b26f3c24d580274f297d88ad43383afe525b1543b165", [:mix], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "deep_merge": {:hex, :deep_merge, "0.2.0", "c1050fa2edf4848b9f556fba1b75afc66608a4219659e3311d9c9427b5b680b3", [:mix], [], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.3.6", "ce1d0675e10a5bb46b007549362bd3f5f08908843957687d8484fe7f37466b19", [:mix], [], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/raxx/benchmarks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.BenchmarksTest do 2 | use ExUnit.Case 3 | 4 | @moduledoc """ 5 | To run the benchmarks do 6 | 7 | mix test --only benchmarks 8 | """ 9 | @moduletag :benchmarks 10 | 11 | # some benchmarks take more than the default timeout of minute 12 | @moduletag timeout: 10 * 60 * 1_000 13 | 14 | test "calling modules via functions" do 15 | list = Enum.to_list(1..10) 16 | 17 | enum = Enum 18 | lambda = fn l -> Enum.reverse(l) end 19 | 20 | Benchee.run(%{ 21 | "directly" => fn -> Enum.reverse(list) end, 22 | "module in a variable" => fn -> enum.reverse(list) end, 23 | "apply on a variable" => fn -> apply(enum, :reverse, [list]) end, 24 | "apply on a Module" => fn -> apply(Enum, :reverse, [list]) end, 25 | "lambda" => fn -> lambda.(list) end 26 | }) 27 | end 28 | 29 | test "passing state around" do 30 | big_data = %{ 31 | foo: [:bar, :baz, 1.5, "this is a medium size string"], 32 | bar: Enum.to_list(1..100) 33 | } 34 | 35 | inputs = %{ 36 | "1 item" => 1, 37 | "10 items" => 10, 38 | "100 items" => 100, 39 | "1000 items" => 1000 40 | } 41 | 42 | # updating state is here to make sure no smart optimisation kicks in 43 | update_state = fn state, value -> Map.put(state, :ban, value) end 44 | 45 | Benchee.run( 46 | %{ 47 | "directly" => fn count -> 48 | 1..count 49 | |> Enum.map(&update_state.(big_data, &1)) 50 | |> Enum.map(& &1) 51 | end, 52 | "sending messages to self" => fn count -> 53 | 1..count 54 | |> Enum.each(fn number -> 55 | send(self(), {:whoa, update_state.(big_data, number)}) 56 | end) 57 | 58 | 1..count 59 | |> Enum.map(fn _ -> 60 | receive do 61 | {:whoa, a} -> a 62 | after 63 | 0 -> raise "this shouldn't happen" 64 | end 65 | end) 66 | end, 67 | "passing through the process dictionary" => fn count -> 68 | 1..count 69 | |> Enum.each(fn number -> 70 | Process.put({:whoa, number}, update_state.(big_data, number)) 71 | end) 72 | 73 | 1..count 74 | |> Enum.map(fn number -> 75 | Process.get({:whoa, number}) 76 | end) 77 | end 78 | }, 79 | inputs: inputs 80 | ) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/raxx/context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ContextTest do 2 | use ExUnit.Case 3 | 4 | alias Raxx.Context 5 | 6 | @moduletag :context 7 | 8 | test "retrieve returns default values if the value is not present" do 9 | assert :default == Context.retrieve(:foo, :default) 10 | assert nil == Context.retrieve(:foo, nil) 11 | end 12 | 13 | test "retrieve returns nil as the default default value" do 14 | assert nil == Context.retrieve(:foo) 15 | end 16 | 17 | test "retrieve returns the most recently set section value" do 18 | Context.set(:foo, 1) 19 | assert 1 == Context.retrieve(:foo) 20 | Context.set(:foo, 2) 21 | assert 2 == Context.retrieve(:foo) 22 | end 23 | 24 | test "set returns the previous section value" do 25 | assert nil == Context.set(:foo, 1) 26 | assert 1 == Context.set(:foo, 2) 27 | end 28 | 29 | test "get_snapshot/0 gets all context values, but none of the other process dictionary values" do 30 | Process.put("this", "that") 31 | assert %{} == Context.get_snapshot() 32 | 33 | Context.set(:foo, 1) 34 | Context.set(:bar, 2) 35 | 36 | Process.put(:bar, 10) 37 | Process.put(:baz, 11) 38 | 39 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 40 | end 41 | 42 | test "restore_snapshot/1 doesn't affect 'normal' process dictionary values" do 43 | empty_snapshot = Context.get_snapshot() 44 | Process.put("this", "that") 45 | 46 | assert :ok = Context.restore_snapshot(empty_snapshot) 47 | assert "that" == Process.get("this") 48 | end 49 | 50 | test "delete/1 deletes the given section from the context (but nothing else)" do 51 | Context.set(:foo, 1) 52 | Context.set(:bar, 2) 53 | 54 | assert 1 == Context.delete(:foo) 55 | assert nil == Context.retrieve(:foo) 56 | 57 | # this makes sure the value wasn't just set to nil and the other values are untouched 58 | assert %{bar: 2} == Context.get_snapshot() 59 | end 60 | 61 | test "restore_snapshot/1 restores the snapshot to the process dictionary" do 62 | Context.set(:foo, 1) 63 | Context.set(:bar, 2) 64 | 65 | snapshot = Context.get_snapshot() 66 | 67 | Context.delete(:foo) 68 | Context.delete(:bar) 69 | 70 | assert %{} == Context.get_snapshot() 71 | 72 | Context.restore_snapshot(snapshot) 73 | 74 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 75 | end 76 | 77 | test "restore_snapshot/1 doesn't leave behind any section values from before the restore operation" do 78 | Context.set(:foo, 1) 79 | Context.set(:bar, 2) 80 | 81 | snapshot = Context.get_snapshot() 82 | 83 | Context.set(:bar, 22) 84 | Context.set(:baz, 3) 85 | 86 | assert :ok = Context.restore_snapshot(snapshot) 87 | 88 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/raxx/http1_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.HTTP1Test do 2 | use ExUnit.Case 3 | doctest Raxx.HTTP1 4 | end 5 | -------------------------------------------------------------------------------- /test/raxx/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.RequestTest do 2 | use ExUnit.Case 3 | alias Raxx.Request 4 | 5 | describe "uri/1" do 6 | test "handles a basic https case correctly" do 7 | request = Raxx.request(:GET, "https://example.com/") 8 | uri = Request.uri(request) 9 | 10 | assert %URI{ 11 | authority: "example.com", 12 | fragment: nil, 13 | host: "example.com", 14 | path: "/", 15 | port: 443, 16 | query: nil, 17 | scheme: "https", 18 | userinfo: nil 19 | } == uri 20 | end 21 | 22 | test "handles a basic http case correctly" do 23 | request = Raxx.request(:GET, "http://example.com/") 24 | uri = Request.uri(request) 25 | 26 | assert %URI{ 27 | authority: "example.com", 28 | fragment: nil, 29 | host: "example.com", 30 | path: "/", 31 | port: 80, 32 | query: nil, 33 | scheme: "http", 34 | userinfo: nil 35 | } == uri 36 | end 37 | 38 | test "handles the case with normal path" do 39 | request = Raxx.request(:GET, "https://example.com/foo/bar") 40 | uri = Request.uri(request) 41 | assert uri.path == "/foo/bar" 42 | end 43 | 44 | test "handles the case with duplicate slashes" do 45 | request = Raxx.request(:GET, "https://example.com/foo//bar") 46 | uri = Request.uri(request) 47 | assert uri.path == "/foo//bar" 48 | end 49 | 50 | test "passes through the query" do 51 | request = Raxx.request(:GET, "https://example.com?foo=bar&baz=ban") 52 | uri = Request.uri(request) 53 | assert uri.query == "foo=bar&baz=ban" 54 | end 55 | 56 | test "if there's a port number in the request, it is contained in the authority, but not the host" do 57 | url = "https://example.com:4321/foo/bar" 58 | request = Raxx.request(:GET, url) 59 | uri = Request.uri(request) 60 | assert uri.host == "example.com" 61 | assert uri.authority == "example.com:4321" 62 | assert uri.port == 4321 63 | end 64 | 65 | test "the uri won't contain userinfo" do 66 | url = "https://example.com/" 67 | 68 | request = 69 | Raxx.request(:GET, url) 70 | |> Raxx.set_header("authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l") 71 | 72 | uri = Request.uri(request) 73 | assert uri.userinfo == nil 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/raxx/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.RouterTest do 2 | use ExUnit.Case 3 | 4 | defmodule HomePage do 5 | use Raxx.SimpleServer 6 | 7 | @impl Raxx.SimpleServer 8 | def handle_request(_request, _state) do 9 | response(:ok) 10 | |> set_body("Home page") 11 | end 12 | end 13 | 14 | defmodule UsersPage do 15 | use Raxx.SimpleServer 16 | 17 | @impl Raxx.SimpleServer 18 | def handle_request(_request, _state) do 19 | response(:ok) 20 | |> set_body("Users page") 21 | end 22 | end 23 | 24 | defmodule UserPage do 25 | use Raxx.SimpleServer 26 | 27 | @impl Raxx.SimpleServer 28 | def handle_request(%{path: ["users", id]}, _state) do 29 | response(:ok) 30 | |> set_body("User page #{id}") 31 | end 32 | end 33 | 34 | defmodule CreateUser do 35 | use Raxx.SimpleServer 36 | 37 | @impl Raxx.SimpleServer 38 | def handle_request(%{body: body}, _state) do 39 | response(:created) 40 | |> set_body("User created #{body}") 41 | end 42 | end 43 | 44 | defmodule NotFoundPage do 45 | use Raxx.SimpleServer 46 | 47 | @impl Raxx.SimpleServer 48 | def handle_request(_request, _state) do 49 | response(:not_found) 50 | |> set_body("Not found") 51 | end 52 | end 53 | 54 | defmodule InvalidReturn do 55 | use Raxx.SimpleServer 56 | 57 | @impl Raxx.SimpleServer 58 | def handle_request(_request, _state) do 59 | :foo 60 | end 61 | end 62 | 63 | defmodule AuthorizationMiddleware do 64 | use Raxx.Middleware 65 | alias Raxx.Server 66 | 67 | @impl Raxx.Middleware 68 | def process_head(request, :pass, next) do 69 | {parts, next} = Server.handle_head(next, request) 70 | {parts, :pass, next} 71 | end 72 | 73 | def process_head(_request, :stop, next) do 74 | {[Raxx.response(:forbidden)], :stop, next} 75 | end 76 | end 77 | 78 | defmodule TestHeaderMiddleware do 79 | use Raxx.Middleware 80 | alias Raxx.Server 81 | 82 | @impl Raxx.Middleware 83 | def process_head(request, state, inner_server) do 84 | {parts, inner_server} = Server.handle_head(inner_server, request) 85 | parts = add_header(parts, state) 86 | {parts, state, inner_server} 87 | end 88 | 89 | @impl Raxx.Middleware 90 | def process_data(data, state, inner_server) do 91 | {parts, inner_server} = Server.handle_data(inner_server, data) 92 | parts = add_header(parts, state) 93 | {parts, state, inner_server} 94 | end 95 | 96 | @impl Raxx.Middleware 97 | def process_tail(tail, state, inner_server) do 98 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 99 | parts = add_header(parts, state) 100 | {parts, state, inner_server} 101 | end 102 | 103 | @impl Raxx.Middleware 104 | def process_info(message, state, inner_server) do 105 | {parts, inner_server} = Server.handle_info(inner_server, message) 106 | parts = add_header(parts, state) 107 | {parts, state, inner_server} 108 | end 109 | 110 | def add_header([response = %Raxx.Response{} | rest], state) do 111 | response = Raxx.set_header(response, "x-test", state) 112 | [response | rest] 113 | end 114 | 115 | def add_header(parts, _state) when is_list(parts) do 116 | parts 117 | end 118 | end 119 | 120 | describe "custom route function in router" do 121 | defmodule CustomRouter do 122 | use Raxx.Router 123 | 124 | @impl Raxx.Router 125 | def route(%{path: []}, config) do 126 | Raxx.Stack.new([{TestHeaderMiddleware, "run-time"}], {HomePage, config}) 127 | end 128 | 129 | def route(_request, _config) do 130 | {NotFound, :state} 131 | end 132 | end 133 | 134 | test "will route to homepage" do 135 | request = Raxx.request(:GET, "/") 136 | {[response], _state} = CustomRouter.handle_head(request, %{authorization: :pass}) 137 | assert "Home page" == response.body 138 | assert "run-time" == Raxx.get_header(response, "x-test") 139 | end 140 | end 141 | 142 | describe "new routing api with middleware" do 143 | defmodule SectionRouter do 144 | use Raxx.Router 145 | 146 | # Test with HEAD middleware 147 | section([{TestHeaderMiddleware, "compile-time"}], [ 148 | {%{method: :GET, path: []}, HomePage} 149 | ]) 150 | 151 | section(&private/1, [ 152 | {%{method: :GET, path: ["users"]}, UsersPage}, 153 | {%{method: :GET, path: ["users", _id]}, UserPage}, 154 | {%{method: :POST, path: ["users"]}, CreateUser}, 155 | {%{method: :GET, path: ["invalid"]}, InvalidReturn}, 156 | {%{method: :POST, path: ["invalid"]}, InvalidReturn}, 157 | {_, NotFoundPage} 158 | ]) 159 | 160 | def private(state) do 161 | send(self(), :i_just_ran) 162 | 163 | [ 164 | {TestHeaderMiddleware, "run-time"}, 165 | {AuthorizationMiddleware, state.authorization} 166 | ] 167 | end 168 | end 169 | 170 | test "will route to homepage" do 171 | request = Raxx.request(:GET, "/") 172 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 173 | assert "Home page" == response.body 174 | end 175 | 176 | test "will route to fixed segment" do 177 | request = Raxx.request(:GET, "/users") 178 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 179 | assert "Users page" == response.body 180 | end 181 | 182 | test "will route to variable segment path" do 183 | request = Raxx.request(:GET, "/users/34") 184 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 185 | assert "User page 34" == response.body 186 | end 187 | 188 | test "will route on method" do 189 | request = Raxx.request(:POST, "/users") 190 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 191 | assert "User created " == response.body 192 | end 193 | 194 | test "will forward whole request to controller" do 195 | request = 196 | Raxx.request(:POST, "/users") 197 | |> Raxx.set_body(true) 198 | 199 | {[], state} = SectionRouter.handle_head(request, %{authorization: :pass}) 200 | {[], state} = SectionRouter.handle_data("Bob", state) 201 | {[response], _state} = SectionRouter.handle_tail([], state) 202 | assert "User created Bob" == response.body 203 | end 204 | 205 | test "will route on catch all" do 206 | request = Raxx.request(:GET, "/random") 207 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 208 | assert "Not found" == response.body 209 | end 210 | 211 | test "will raise return error if fails to route simple request" do 212 | request = Raxx.request(:GET, "/invalid") 213 | 214 | assert_raise ReturnError, fn -> 215 | SectionRouter.handle_head(request, %{authorization: :pass}) 216 | end 217 | end 218 | 219 | test "will raise return error if fails to route streamed request" do 220 | request = 221 | Raxx.request(:POST, "/invalid") 222 | |> Raxx.set_body(true) 223 | 224 | {[], state} = SectionRouter.handle_head(request, %{authorization: :pass}) 225 | {[], state} = SectionRouter.handle_data("Bob", state) 226 | 227 | assert_raise ReturnError, fn -> 228 | SectionRouter.handle_tail([], state) 229 | end 230 | end 231 | 232 | test "middleware as list is applied" do 233 | request = Raxx.request(:GET, "/") 234 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 235 | assert "compile-time" == Raxx.get_header(response, "x-test") 236 | end 237 | 238 | test "middleware as function is applied" do 239 | request = Raxx.request(:GET, "/users") 240 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 241 | assert 200 == response.status 242 | assert "run-time" == Raxx.get_header(response, "x-test") 243 | assert_receive :i_just_ran 244 | 245 | request = Raxx.request(:GET, "/users") 246 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :stop}) 247 | assert 403 == response.status 248 | assert "run-time" == Raxx.get_header(response, "x-test") 249 | assert_receive :i_just_ran 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/raxx/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ServerTest do 2 | use ExUnit.Case 3 | doctest Raxx.Server 4 | import ExUnit.CaptureLog 5 | 6 | defmodule EchoServer do 7 | use Raxx.SimpleServer 8 | 9 | @impl Raxx.SimpleServer 10 | def handle_request(%{body: body}, _) do 11 | response(:ok) 12 | |> set_body(inspect(body)) 13 | end 14 | end 15 | 16 | test "body is concatenated to single string" do 17 | request = 18 | Raxx.request(:POST, "/") 19 | |> Raxx.set_body(true) 20 | 21 | state = %{} 22 | 23 | assert {[], state} = EchoServer.handle_head(request, state) 24 | assert {[], state} = EchoServer.handle_data("a", state) 25 | assert {[], state} = EchoServer.handle_data("b", state) 26 | assert {[], state} = EchoServer.handle_data("c", state) 27 | assert %{body: body} = EchoServer.handle_tail([], state) 28 | assert "\"abc\"" == body 29 | end 30 | 31 | defmodule DefaultServer do 32 | use Raxx.SimpleServer 33 | 34 | @impl Raxx.SimpleServer 35 | def handle_request(_, _) do 36 | response(:no_content) 37 | end 38 | end 39 | 40 | test "handle_info logs error" do 41 | logs = 42 | capture_log(fn -> 43 | DefaultServer.handle_info(:foo, :state) 44 | end) 45 | 46 | assert String.contains?(logs, "unexpected message") 47 | assert String.contains?(logs, ":foo") 48 | end 49 | 50 | test "default server will not buffer more than 8MB into one request" do 51 | request = 52 | Raxx.request(:POST, "/") 53 | |> Raxx.set_body(true) 54 | 55 | state = %{} 56 | 57 | assert {[], state} = DefaultServer.handle_head(request, state) 58 | four_Mb = String.duplicate("1234", round(:math.pow(2, 20))) 59 | assert {[], state} = DefaultServer.handle_data(four_Mb, state) 60 | assert {[], state} = DefaultServer.handle_data(four_Mb, state) 61 | assert response = %{status: 413} = DefaultServer.handle_data("straw", state) 62 | end 63 | 64 | defmodule BigServer do 65 | use Raxx.SimpleServer, maximum_body_length: 12 * 1024 * 1024 66 | 67 | @impl Raxx.SimpleServer 68 | def handle_request(_, _) do 69 | response(:no_content) 70 | end 71 | end 72 | 73 | test "Server max body size can be configured" do 74 | request = 75 | Raxx.request(:POST, "/") 76 | |> Raxx.set_body(true) 77 | 78 | state = %{} 79 | 80 | assert {[], state} = BigServer.handle_head(request, state) 81 | four_Mb = String.duplicate("1234", round(:math.pow(2, 20))) 82 | assert {[], state} = BigServer.handle_data(four_Mb, state) 83 | assert {[], state} = BigServer.handle_data(four_Mb, state) 84 | assert {[], state} = BigServer.handle_data(four_Mb, state) 85 | assert response = %{status: 413} = BigServer.handle_data("straw", state) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/raxx/stack_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.StackTest do 2 | use ExUnit.Case 3 | 4 | alias Raxx.Middleware 5 | alias Raxx.Stack 6 | alias Raxx.Server 7 | 8 | defmodule HomePage do 9 | use Raxx.SimpleServer 10 | 11 | @impl Raxx.SimpleServer 12 | def handle_request(_request, _state) do 13 | response(:ok) 14 | |> set_body("Home page") 15 | end 16 | end 17 | 18 | defmodule TrackStages do 19 | @behaviour Middleware 20 | 21 | @impl Middleware 22 | def process_head(request, config, inner_server) do 23 | {parts, inner_server} = Server.handle_head(inner_server, request) 24 | {parts, {config, :head}, inner_server} 25 | end 26 | 27 | @impl Middleware 28 | def process_data(data, {_, prev}, inner_server) do 29 | {parts, inner_server} = Server.handle_data(inner_server, data) 30 | {parts, {prev, :data}, inner_server} 31 | end 32 | 33 | @impl Middleware 34 | def process_tail(tail, {_, prev}, inner_server) do 35 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 36 | {parts, {prev, :tail}, inner_server} 37 | end 38 | 39 | @impl Middleware 40 | def process_info(message, {_, prev}, inner_server) do 41 | {parts, inner_server} = Server.handle_info(inner_server, message) 42 | {parts, {prev, :info}, inner_server} 43 | end 44 | end 45 | 46 | defmodule DefaultMiddleware do 47 | use Middleware 48 | end 49 | 50 | test "Default middleware callbacks leave the request and response unmodified" do 51 | middlewares = [{DefaultMiddleware, :irrelevant}, {DefaultMiddleware, 42}] 52 | stack = make_stack(middlewares, HomePage, :controller_initial) 53 | 54 | request = 55 | Raxx.request(:POST, "/") 56 | |> Raxx.set_content_length(3) 57 | |> Raxx.set_body(true) 58 | 59 | assert {[], stack} = Server.handle_head(stack, request) 60 | assert {[], stack} = Server.handle_data(stack, "abc") 61 | 62 | assert {[response], _stack} = Server.handle_tail(stack, []) 63 | 64 | assert %Raxx.Response{ 65 | body: "Home page", 66 | headers: [{"content-length", "9"}], 67 | status: 200 68 | } = response 69 | end 70 | 71 | defmodule Meddler do 72 | @behaviour Middleware 73 | @impl Middleware 74 | def process_head(request, config, inner_server) do 75 | request = 76 | case Keyword.get(config, :request_header) do 77 | nil -> 78 | request 79 | 80 | value -> 81 | request 82 | |> Raxx.delete_header("x-request-header") 83 | |> Raxx.set_header("x-request-header", value) 84 | end 85 | 86 | {parts, inner_server} = Server.handle_head(inner_server, request) 87 | parts = modify_parts(parts, config) 88 | {parts, config, inner_server} 89 | end 90 | 91 | @impl Middleware 92 | def process_data(data, config, inner_server) do 93 | {parts, inner_server} = Server.handle_data(inner_server, data) 94 | parts = modify_parts(parts, config) 95 | {parts, config, inner_server} 96 | end 97 | 98 | @impl Middleware 99 | def process_tail(tail, config, inner_server) do 100 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 101 | parts = modify_parts(parts, config) 102 | {parts, config, inner_server} 103 | end 104 | 105 | @impl Middleware 106 | def process_info(message, config, inner_server) do 107 | {parts, inner_server} = Server.handle_info(inner_server, message) 108 | parts = modify_parts(parts, config) 109 | {parts, config, inner_server} 110 | end 111 | 112 | defp modify_parts(parts, config) do 113 | Enum.map(parts, &modify_part(&1, config)) 114 | end 115 | 116 | defp modify_part(data = %Raxx.Data{data: contents}, config) do 117 | new_contents = modify_contents(contents, config) 118 | %Raxx.Data{data | data: new_contents} 119 | end 120 | 121 | defp modify_part(response = %Raxx.Response{body: contents}, config) 122 | when is_binary(contents) do 123 | new_contents = modify_contents(contents, config) 124 | %Raxx.Response{response | body: new_contents} 125 | end 126 | 127 | defp modify_part(part, _state) do 128 | part 129 | end 130 | 131 | defp modify_contents(contents, config) do 132 | case Keyword.get(config, :response_body) do 133 | nil -> 134 | contents 135 | 136 | replacement when is_binary(replacement) -> 137 | String.replace(contents, ~r/./, replacement) 138 | # make sure it's the same length 139 | |> String.slice(0, String.length(contents)) 140 | end 141 | end 142 | end 143 | 144 | defmodule SpyServer do 145 | use Raxx.Server 146 | # this server is deliberately weird to trip up any assumptions 147 | @impl Raxx.Server 148 | def handle_head(request = %{body: false}, state) do 149 | send(self(), {__MODULE__, :handle_head, request, state}) 150 | 151 | response = 152 | Raxx.response(:ok) |> Raxx.set_body("SpyServer responds to a request with no body") 153 | 154 | {[response], state} 155 | end 156 | 157 | def handle_head(request, state) do 158 | send(self(), {__MODULE__, :handle_head, request, state}) 159 | {[], 1} 160 | end 161 | 162 | def handle_data(data, state) do 163 | send(self(), {__MODULE__, :handle_data, data, state}) 164 | 165 | headers = 166 | response(:ok) 167 | |> set_content_length(10) 168 | |> set_body(true) 169 | 170 | {[headers], state + 1} 171 | end 172 | 173 | def handle_tail(tail, state) do 174 | send(self(), {__MODULE__, :handle_tail, tail, state}) 175 | {[data("spy server"), tail([{"x-response-trailer", "spy-trailer"}])], -1 * state} 176 | end 177 | end 178 | 179 | test "middlewares can modify the request" do 180 | middlewares = [{Meddler, [request_header: "foo"]}, {Meddler, [request_header: "bar"]}] 181 | stack = make_stack(middlewares, SpyServer, :controller_initial) 182 | 183 | request = 184 | Raxx.request(:POST, "/") 185 | |> Raxx.set_content_length(3) 186 | |> Raxx.set_body(true) 187 | 188 | assert {[], stack} = Server.handle_head(stack, request) 189 | 190 | assert_receive {SpyServer, :handle_head, server_request, :controller_initial} 191 | assert %Raxx.Request{} = server_request 192 | assert "bar" == Raxx.get_header(server_request, "x-request-header") 193 | assert 3 == Raxx.get_content_length(server_request) 194 | 195 | assert {[headers], stack} = Server.handle_data(stack, "abc") 196 | assert_receive {SpyServer, :handle_data, "abc", 1} 197 | assert %Raxx.Response{body: true, status: 200} = headers 198 | 199 | assert {[data, tail], stack} = Server.handle_tail(stack, []) 200 | assert_receive {SpyServer, :handle_tail, [], 2} 201 | assert %Raxx.Data{data: "spy server"} = data 202 | assert %Raxx.Tail{headers: [{"x-response-trailer", "spy-trailer"}]} == tail 203 | end 204 | 205 | test "middlewares can modify the response" do 206 | middlewares = [{Meddler, [response_body: "foo"]}, {Meddler, [response_body: "bar"]}] 207 | stack = make_stack(middlewares, SpyServer, :controller_initial) 208 | 209 | request = 210 | Raxx.request(:POST, "/") 211 | |> Raxx.set_content_length(3) 212 | |> Raxx.set_body(true) 213 | 214 | assert {[], stack} = Server.handle_head(stack, request) 215 | 216 | assert_receive {SpyServer, :handle_head, server_request, :controller_initial} 217 | assert %Raxx.Request{} = server_request 218 | assert nil == Raxx.get_header(server_request, "x-request-header") 219 | assert 3 == Raxx.get_content_length(server_request) 220 | 221 | assert {[headers], stack} = Server.handle_data(stack, "abc") 222 | assert_receive {SpyServer, :handle_data, "abc", 1} 223 | assert %Raxx.Response{body: true, status: 200} = headers 224 | 225 | assert {[data, tail], stack} = Server.handle_tail(stack, []) 226 | assert_receive {SpyServer, :handle_tail, [], 2} 227 | assert %Raxx.Data{data: "foofoofoof"} = data 228 | assert %Raxx.Tail{headers: [{"x-response-trailer", "spy-trailer"}]} == tail 229 | end 230 | 231 | test "middlewares' state is correctly updated" do 232 | middlewares = [{Meddler, [response_body: "foo"]}, {TrackStages, :config}] 233 | stack = make_stack(middlewares, SpyServer, :controller_initial) 234 | 235 | request = 236 | Raxx.request(:POST, "/") 237 | |> Raxx.set_content_length(3) 238 | |> Raxx.set_body(true) 239 | 240 | assert {_parts, stack} = Server.handle_head(stack, request) 241 | 242 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:config, :head}}] == 243 | Stack.get_middlewares(stack) 244 | 245 | assert {SpyServer, 1} == Stack.get_server(stack) 246 | 247 | {_parts, stack} = Server.handle_data(stack, "z") 248 | 249 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:head, :data}}] == 250 | Stack.get_middlewares(stack) 251 | 252 | assert {SpyServer, 2} == Stack.get_server(stack) 253 | 254 | {_parts, stack} = Server.handle_data(stack, "zz") 255 | 256 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:data, :data}}] == 257 | Stack.get_middlewares(stack) 258 | 259 | assert {SpyServer, 3} == Stack.get_server(stack) 260 | 261 | {_parts, stack} = Server.handle_tail(stack, [{"x-foo", "bar"}]) 262 | assert [{Meddler, _}, {TrackStages, {:data, :tail}}] = Stack.get_middlewares(stack) 263 | assert {SpyServer, -3} == Stack.get_server(stack) 264 | end 265 | 266 | test "a stack with no middlewares is functional" do 267 | stack = make_stack([], SpyServer, :controller_initial) 268 | 269 | request = 270 | Raxx.request(:POST, "/") 271 | |> Raxx.set_content_length(3) 272 | |> Raxx.set_body(true) 273 | 274 | {stack_result_1, stack} = Server.handle_head(stack, request) 275 | {stack_result_2, stack} = Server.handle_data(stack, "xxx") 276 | {stack_result_3, _stack} = Server.handle_tail(stack, []) 277 | 278 | {server_result_1, state} = SpyServer.handle_head(request, :controller_initial) 279 | {server_result_2, state} = SpyServer.handle_data("xxx", state) 280 | {server_result_3, _state} = SpyServer.handle_tail([], state) 281 | 282 | assert stack_result_1 == server_result_1 283 | assert stack_result_2 == server_result_2 284 | assert stack_result_3 == server_result_3 285 | end 286 | 287 | defmodule AlwaysForbidden do 288 | use Middleware 289 | 290 | @impl Middleware 291 | def process_head(_request, _config, inner_server) do 292 | response = 293 | Raxx.response(:forbidden) 294 | |> Raxx.set_body("Forbidden!") 295 | 296 | {[response], nil, inner_server} 297 | end 298 | end 299 | 300 | # This test also checks that the default callbacks from `use` macro can be overridden. 301 | test "middlewares can \"short circuit\" processing (not call through)" do 302 | middlewares = [{TrackStages, nil}, {AlwaysForbidden, nil}] 303 | stack = make_stack(middlewares, SpyServer, :whatever) 304 | request = Raxx.request(:GET, "/") 305 | 306 | assert {[response], _stack} = Server.handle_head(stack, request) 307 | assert %Raxx.Response{body: "Forbidden!"} = response 308 | 309 | refute_receive _ 310 | 311 | stack = make_stack([{TrackStages, nil}], SpyServer, :whatever) 312 | assert {[response], _stack} = Server.handle_head(stack, request) 313 | assert response.body =~ "SpyServer" 314 | 315 | assert_receive {SpyServer, _, _, _} 316 | end 317 | 318 | defmodule CustomReturn do 319 | use Raxx.Server 320 | @impl Raxx.Server 321 | def handle_head(_request, state) do 322 | {[:response], state} 323 | end 324 | 325 | def handle_data(_data, state) do 326 | {[], state} 327 | end 328 | 329 | def handle_tail(_tail, state) do 330 | {[], state} 331 | end 332 | end 333 | 334 | defmodule CustomReturnMiddleware do 335 | @behaviour Middleware 336 | 337 | @impl Middleware 338 | def process_head(request, _config, inner_server) do 339 | {parts, inner_server} = Server.handle_head(inner_server, request) 340 | {process_parts(parts), nil, inner_server} 341 | end 342 | 343 | @impl Middleware 344 | def process_data(data, _state, inner_server) do 345 | {parts, inner_server} = Server.handle_data(inner_server, data) 346 | {process_parts(parts), nil, inner_server} 347 | end 348 | 349 | @impl Middleware 350 | def process_tail(tail, _state, inner_server) do 351 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 352 | {process_parts(parts), nil, inner_server} 353 | end 354 | 355 | @impl Middleware 356 | def process_info(message, _state, inner_server) do 357 | {parts, inner_server} = Server.handle_info(inner_server, message) 358 | {process_parts(parts), nil, inner_server} 359 | end 360 | 361 | defp process_parts(parts) do 362 | parts 363 | |> Raxx.separate_parts() 364 | |> Enum.map(&process_part/1) 365 | end 366 | 367 | defp process_part(:response) do 368 | %Raxx.Response{ 369 | body: "custom", 370 | headers: [{"content-length", "6"}], 371 | status: 200 372 | } 373 | end 374 | 375 | defp process_part(other) do 376 | other 377 | end 378 | end 379 | 380 | test "servers can, in principle, return custom values to the middleware" do 381 | stack = make_stack([{CustomReturnMiddleware, nil}], CustomReturn, nil) 382 | request = Raxx.request(:GET, "/") 383 | assert {response, _stack} = Server.handle_head(stack, request) 384 | assert [%Raxx.Response{body: "custom"}] = response 385 | end 386 | 387 | defp make_stack(middlewares, server_module, server_state) do 388 | Stack.new(middlewares, {server_module, server_state}) 389 | end 390 | end 391 | -------------------------------------------------------------------------------- /test/raxx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxTest do 2 | use ExUnit.Case 3 | import Raxx 4 | doctest Raxx 5 | 6 | test "cannot set an uppercase header" do 7 | assert_raise ArgumentError, "Header keys must be lowercase", fn -> 8 | Raxx.response(:ok) 9 | |> Raxx.set_header("Foo", "Bar") 10 | end 11 | end 12 | 13 | test "header values cannot contain control feed charachter" do 14 | assert_raise ArgumentError, 15 | "Header values must not contain control feed (\\r) or newline (\\n)", 16 | fn -> 17 | Raxx.response(:ok) 18 | |> Raxx.set_header("foo", "Bar\r") 19 | end 20 | end 21 | 22 | test "header values cannot contain newline charachter" do 23 | assert_raise ArgumentError, 24 | "Header values must not contain control feed (\\r) or newline (\\n)", 25 | fn -> 26 | Raxx.response(:ok) 27 | |> Raxx.set_header("foo", "Bar\n") 28 | end 29 | end 30 | 31 | test "cannot set a host header" do 32 | assert_raise ArgumentError, 33 | "Cannot set host header, see documentation for details", 34 | fn -> 35 | Raxx.response(:ok) 36 | |> Raxx.set_header("host", "raxx.dev") 37 | end 38 | end 39 | 40 | test "cannot set a connection header" do 41 | assert_raise ArgumentError, 42 | "Cannot set a connection specific header, see documentation for details", 43 | fn -> 44 | Raxx.response(:ok) 45 | |> Raxx.set_header("connection", "keep-alive") 46 | end 47 | end 48 | 49 | test "cannot set a header twice" do 50 | assert_raise ArgumentError, "Headers should not be duplicated", fn -> 51 | Raxx.response(:ok) 52 | |> Raxx.set_header("x-foo", "one") 53 | |> Raxx.set_header("x-foo", "two") 54 | end 55 | end 56 | 57 | test "cannot get an uppercase header" do 58 | assert_raise ArgumentError, "Header keys must be lowercase", fn -> 59 | Raxx.response(:ok) 60 | |> Raxx.get_header("Foo") 61 | end 62 | end 63 | 64 | test "Cannot set the body of a GET request" do 65 | assert_raise ArgumentError, fn -> 66 | Raxx.request(:GET, "raxx.dev") 67 | |> Raxx.set_body("Hello, World!") 68 | end 69 | end 70 | 71 | test "Cannot set the body of a HEAD request" do 72 | assert_raise ArgumentError, fn -> 73 | Raxx.request(:HEAD, "raxx.dev") 74 | |> Raxx.set_body("Hello, World!") 75 | end 76 | end 77 | 78 | test "Cannot set the body of an informational (1xx) response" do 79 | assert_raise ArgumentError, fn -> 80 | Raxx.response(:continue) 81 | |> Raxx.set_body("Hello, World!") 82 | end 83 | end 84 | 85 | test "Cannot set the body of an no content response" do 86 | assert_raise ArgumentError, fn -> 87 | Raxx.response(:no_content) 88 | |> Raxx.set_body("Hello, World!") 89 | end 90 | end 91 | 92 | test "Cannot set the body of an not modified response" do 93 | assert_raise ArgumentError, fn -> 94 | Raxx.response(:not_modified) 95 | |> Raxx.set_body("Hello, World!") 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------