├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── gleam.toml ├── manifest.toml ├── src ├── gleam │ └── http │ │ └── elli.gleam └── gleam_elli_native.erl └── test ├── elli_logging_test_ffi.erl ├── gleam └── http │ ├── elli_logging_test.gleam │ └── elli_test.gleam └── gleam_elli_test.gleam /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: erlef/setup-beam@v1 15 | with: 16 | otp-version: "27.1" 17 | gleam-version: "1.10.0" 18 | rebar3-version: "3" 19 | - run: gleam test 20 | - run: gleam format --check src test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rebar3 2 | _* 3 | .eunit 4 | *.o 5 | *.beam 6 | *.plt 7 | *.swp 8 | *.swo 9 | .erlang.cookie 10 | ebin 11 | log 12 | erl_crash.dump 13 | .rebar 14 | logs 15 | _build 16 | .idea 17 | *.iml 18 | build 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.0.0 - 2025-06-01 4 | 5 | - Updated for `gleam_stdlib` v0.60.0. 6 | - Updated for `gleam_http` v4.0.0. 7 | 8 | ## v2.4.2 - 2024-11-19 9 | 10 | - Updated for `gleam_stdlib` v0.42.0. 11 | 12 | ## v2.4.1 - 2024-08-15 13 | 14 | - Warnings fixed. 15 | 16 | ## v2.4.0 - 2024-03-12 17 | 18 | - Updated for Gleam v1.0.0. 19 | 20 | ## v2.3.0 - 2023-11-06 21 | 22 | - Updated for Gleam v0.32.0. 23 | 24 | ## v2.2.0 - 2023-08-03 25 | 26 | - Updated for Gleam v0.30.0. 27 | 28 | ## v2.1.0 - 2023-02-14 29 | 30 | - Crashes in the request handler are now logged. 31 | 32 | ## v2.0.0 - 2022-09-10 33 | 34 | - Updated for `gleam_otp` v0.5.0. 35 | 36 | ## v1.1.1 - 2022-02-17 37 | 38 | - Fixed a bug where the request host field could have an incorrect value. 39 | - Fixed a bug where the request path field could also include the query. 40 | 41 | ## v1.1.0 - 2022-02-16 42 | 43 | - The `elli` module gains the `become` function. 44 | 45 | ## v1.0.0 - 2022-01-29 46 | 47 | - Updated for `gleam_http` v3.0.0. 48 | 49 | ## v0.5.0 - 2021-12-03 50 | 51 | - Converted to use the Gleam build tool. 52 | 53 | ## v0.4.0 - 2021-09-25 54 | 55 | - Updated for Gleam v0.17.0. 56 | 57 | ## v0.3.0 - 2021-08-14 58 | 59 | - The header keys are now converted to lowercase. 60 | - Updated to work with gleam/http v2.0. 61 | - Updated for Gleam v0.16 62 | 63 | ## v0.2.0 - 2020-11-06 64 | 65 | - The `start` function now returns a Gleam OTP `StartResult`. 66 | 67 | ## v0.1.1 - 2020-08-22 68 | 69 | - Include applications in rebar config. 70 | 71 | ## v0.1.0 - 2020-08-22 72 | 73 | - Initial release. 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2018, Louis Pilfold . 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elli 2 | 3 | A basic Gleam HTTP service adapter for the Elli web server. 4 | 5 | ``` 6 | gleam add gleam_elli gleam_http 7 | ``` 8 | ```gleam 9 | import gleam/http/elli 10 | import gleam/http/request.{type Request} 11 | import gleam/http/response.{type Response} 12 | import gleam/bytes_tree.{type BytesTree} 13 | 14 | // Define a HTTP service 15 | // 16 | pub fn my_service(req: Request(t)) -> Response(BytesBuilder) { 17 | let body = bytes_tree.from_string("Hello, world!") 18 | 19 | response.new(200) 20 | |> response.prepend_header("made-with", "Gleam") 21 | |> response.set_body(body) 22 | } 23 | 24 | // Start it on port 3000! 25 | // 26 | pub fn main() { 27 | elli.become(my_service, on_port: 3000) 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "gleam_elli" 2 | version = "3.0.0" 3 | gleam = ">= 1.10.0" 4 | 5 | licences = ["Apache-2.0"] 6 | description = "Run Gleam HTTP services with the Elli web server" 7 | 8 | repository = { type = "github", user = "gleam-lang", repo = "elli" } 9 | links = [ 10 | { title = "Website", href = "https://gleam.run" }, 11 | { title = "Sponsor", href = "https://github.com/sponsors/lpil" }, 12 | ] 13 | 14 | [dependencies] 15 | gleam_erlang = ">= 0.34.0 and < 1.0.0" 16 | gleam_otp = ">= 0.16.1 and < 1.0.0" 17 | gleam_stdlib = ">= 0.44.0 and < 2.0.0" 18 | gleam_http = ">= 4.0.0 and < 5.0.0" 19 | elli = "~> 3.0" 20 | 21 | [dev-dependencies] 22 | gleeunit = "~> 1.0" 23 | gleam_hackney = "~> 1.0" 24 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "certifi", version = "2.15.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60" }, 6 | { name = "elli", version = "3.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "elli", source = "hex", outer_checksum = "698B13B33D05661DB9FE7EFCBA41B84825A379CCE86E486CF6AFF9285BE0CCF8" }, 7 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 8 | { name = "gleam_hackney", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "hackney"], otp_app = "gleam_hackney", source = "hex", outer_checksum = "0449AADBEBF3E979509A4079EE34B92EEE4162C5A0DC94F3DA2787E4777F6B45" }, 9 | { name = "gleam_http", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0A62451FC85B98062E0907659D92E6A89F5F3C0FBE4AB8046C99936BF6F91DBC" }, 10 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 11 | { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" }, 12 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 13 | { name = "hackney", version = "1.24.1", build_tools = ["rebar3"], requirements = ["certifi", "idna", "metrics", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat"], otp_app = "hackney", source = "hex", outer_checksum = "F4A7392A0B53D8BBC3EB855BDCC919CD677358E65B2AFD3840B5B3690C4C8A39" }, 14 | { name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, 15 | { name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" }, 16 | { name = "mimerl", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144" }, 17 | { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, 18 | { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 19 | { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, 20 | ] 21 | 22 | [requirements] 23 | elli = { version = "~> 3.0" } 24 | gleam_erlang = { version = ">= 0.34.0 and < 1.0.0" } 25 | gleam_hackney = { version = "~> 1.0" } 26 | gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 27 | gleam_otp = { version = ">= 0.16.1 and < 1.0.0" } 28 | gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 29 | gleeunit = { version = "~> 1.0" } 30 | -------------------------------------------------------------------------------- /src/gleam/http/elli.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_tree.{type BytesTree} 2 | import gleam/dynamic.{type Dynamic} 3 | import gleam/dynamic/decode 4 | import gleam/erlang/atom.{type Atom} 5 | import gleam/erlang/process.{type Pid} 6 | import gleam/http 7 | import gleam/http/request.{type Request, Request} 8 | import gleam/http/response.{type Response, Response} 9 | import gleam/list 10 | import gleam/option 11 | import gleam/pair 12 | import gleam/result 13 | import gleam/string 14 | 15 | type ElliRequest 16 | 17 | type ElliResponse = 18 | #(Int, List(http.Header), BytesTree) 19 | 20 | type StartLinkOption { 21 | Callback(Atom) 22 | CallbackArgs(fn(ElliRequest) -> ElliResponse) 23 | Port(Int) 24 | } 25 | 26 | @external(erlang, "binary", "split") 27 | fn split(a: String, b: List(String)) -> List(String) 28 | 29 | @external(erlang, "elli", "start_link") 30 | fn erl_start_link(a: List(StartLinkOption)) -> Result(Pid, Dynamic) 31 | 32 | @external(erlang, "elli_request", "body") 33 | fn get_body(a: ElliRequest) -> BitArray 34 | 35 | @external(erlang, "elli_request", "headers") 36 | fn get_headers(a: ElliRequest) -> List(http.Header) 37 | 38 | @external(erlang, "gleam_elli_native", "get_host") 39 | fn get_host(a: ElliRequest) -> String 40 | 41 | @external(erlang, "gleam_elli_native", "get_method") 42 | fn get_method(a: ElliRequest) -> http.Method 43 | 44 | @external(erlang, "elli_request", "port") 45 | fn get_dynamic_port(a: ElliRequest) -> Dynamic 46 | 47 | fn get_port(req) { 48 | req 49 | |> get_dynamic_port 50 | |> decode.run(decode.int) 51 | |> option.from_result 52 | } 53 | 54 | @external(erlang, "elli_request", "scheme") 55 | fn get_dynamic_scheme(a: ElliRequest) -> Dynamic 56 | 57 | fn get_scheme(req) -> http.Scheme { 58 | let scheme = 59 | req 60 | |> get_dynamic_scheme 61 | |> decode.run(decode.string) 62 | |> result.unwrap("") 63 | |> string.lowercase 64 | case scheme { 65 | "https" -> http.Https 66 | _ -> http.Http 67 | } 68 | } 69 | 70 | // pub fn method_from_dynamic( 71 | // value: Dynamic, 72 | // ) -> Result(Method, List(decode.DecodeError)) { 73 | // case do_method_from_dynamic(value) { 74 | // Ok(method) -> Ok(method) 75 | // Error(_) -> 76 | // Error([decode.DecodeError("HTTP method", dynamic.classify(value), [])]) 77 | // } 78 | // } 79 | 80 | // @target(erlang) 81 | // @external(erlang, "gleam_http_native", "decode_method") 82 | // fn do_method_from_dynamic(a: Dynamic) -> Result(ElliMethod, nil) 83 | 84 | @external(erlang, "elli_request", "query_str") 85 | fn get_query(a: ElliRequest) -> String 86 | 87 | @external(erlang, "elli_request", "raw_path") 88 | fn get_raw_path(a: ElliRequest) -> String 89 | 90 | fn get_path(request: ElliRequest) -> String { 91 | let raw_path = get_raw_path(request) 92 | raw_path 93 | |> split(["#", "?"]) 94 | |> list.first 95 | |> result.unwrap(raw_path) 96 | } 97 | 98 | @external(erlang, "gleam_elli_native", "await_shutdown") 99 | fn await_shutdown(a: process.Pid) -> Nil 100 | 101 | fn convert_header_to_lowercase(header: http.Header) -> http.Header { 102 | pair.map_first(header, fn(key) { string.lowercase(key) }) 103 | } 104 | 105 | fn service_to_elli_handler( 106 | service: fn(Request(BitArray)) -> Response(BytesTree), 107 | ) -> fn(ElliRequest) -> ElliResponse { 108 | fn(req) { 109 | let resp = 110 | Request( 111 | scheme: get_scheme(req), 112 | method: get_method(req), 113 | host: get_host(req), 114 | port: get_port(req), 115 | path: get_path(req), 116 | query: option.Some(get_query(req)), 117 | headers: get_headers(req) 118 | |> list.map(convert_header_to_lowercase), 119 | body: get_body(req), 120 | ) 121 | |> service 122 | let Response(status, headers, body) = resp 123 | #(status, headers, body) 124 | } 125 | } 126 | 127 | /// Start a new Elli web server process which runs concurrently to the current 128 | /// process. 129 | /// 130 | /// If you want to run the web server but don't need to do anything else with 131 | /// the current process you may want to use the `become` function instead. 132 | /// 133 | pub fn start( 134 | service: fn(Request(BitArray)) -> Response(BytesTree), 135 | on_port number: Int, 136 | ) -> Result(Pid, Dynamic) { 137 | [ 138 | Port(number), 139 | Callback(atom.create_from_string("gleam_elli_native")), 140 | CallbackArgs(service_to_elli_handler(service)), 141 | ] 142 | |> erl_start_link 143 | } 144 | 145 | /// Start an Elli web server with the current process. 146 | /// 147 | /// This function returns if the Elli web server fails to start or if it was 148 | /// shut down after successfully starting. 149 | /// 150 | pub fn become( 151 | service: fn(Request(BitArray)) -> Response(BytesTree), 152 | on_port number: Int, 153 | ) -> Result(Nil, Dynamic) { 154 | service 155 | |> start(number) 156 | |> result.map(await_shutdown) 157 | } 158 | -------------------------------------------------------------------------------- /src/gleam_elli_native.erl: -------------------------------------------------------------------------------- 1 | -module(gleam_elli_native). 2 | 3 | -include_lib("kernel/include/logger.hrl"). 4 | -include_lib("elli/include/elli.hrl"). 5 | 6 | -export([handle/2, handle_event/3, await_shutdown/1, get_host/1, get_method/1]). 7 | 8 | handle(Req, Handler) -> 9 | Handler(Req). 10 | 11 | handle_event(request_error, [Request, Error, Stacktrace], _) -> 12 | ?LOG_ERROR(#{ 13 | message => <<"request handler had a runtime error">>, 14 | error => Error, 15 | method => method(Request), 16 | path => path(Request), 17 | stacktrace => Stacktrace 18 | }); 19 | handle_event(request_throw, [Request, Exception, Stacktrace], _) -> 20 | ?LOG_ERROR(#{ 21 | message => <<"request handler threw an exception">>, 22 | error => Exception, 23 | method => method(Request), 24 | path => path(Request), 25 | stacktrace => Stacktrace 26 | }); 27 | handle_event(request_exit, [Request, Exit, Stacktrace], _) -> 28 | ?LOG_ERROR(#{ 29 | message => <<"request handler exited">>, 30 | error => Exit, 31 | method => method(Request), 32 | path => path(Request), 33 | stacktrace => Stacktrace 34 | }); 35 | handle_event(_, _, _) -> 36 | ok. 37 | 38 | await_shutdown(Pid) when is_pid(Pid) -> 39 | Ref = erlang:monitor(process, Pid), 40 | receive 41 | {'DOWN', Ref, process, _, _} -> nil 42 | end. 43 | 44 | get_host(Request) -> 45 | case elli_request:host(Request) of 46 | undefined -> <<>>; 47 | Host when is_binary(Host) -> Host 48 | end. 49 | 50 | get_method(#req{method = Method}) -> 51 | case Method of 52 | 'CONNECT' -> connect; 53 | 'DELETE' -> delete; 54 | 'GET' -> get; 55 | 'HEAD' -> head; 56 | 'OPTIONS' -> options; 57 | 'PATCH' -> patch; 58 | 'POST' -> post; 59 | 'PUT' -> put; 60 | 'TRACE' -> trace; 61 | M when is_atom(M) -> {other, string:lowercase(erlang:atom_to_binary(M))}; 62 | M when is_binary(M) -> {other, string:lowercase(M)} 63 | end. 64 | 65 | path(#req{path = Path}) -> 66 | erlang:iolist_to_binary(["/"] ++ lists:join("/", Path)). 67 | 68 | method(#req{method = Method}) -> 69 | Method. 70 | -------------------------------------------------------------------------------- /test/elli_logging_test_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(elli_logging_test_ffi). 2 | 3 | -include_lib("elli/include/elli.hrl"). 4 | 5 | %% Test API 6 | -export([ 7 | bad_service/1, 8 | start_log_spy/1, 9 | silence_default_handler/0, 10 | get_spied_reports/1 11 | ]). 12 | 13 | %% Callbacks 14 | -export([ 15 | log/2, 16 | spy_loop/1 17 | ]). 18 | 19 | bad_service(Request) -> 20 | {request, _, _, _, _, _, _, Path, {some, <<"message=", Message/binary>>}} = Request, 21 | case Path of 22 | <<"/throw">> -> throw(Message); 23 | <<"/error">> -> error(Message); 24 | <<"/exit">> -> exit(Message) 25 | end. 26 | 27 | %% A spy seemed like the easiest way to test the logging, 28 | %% and implementing in Erlang was simpler than in Gleam. 29 | start_log_spy(IdBinary) -> 30 | HandlerId = binary_to_atom(IdBinary, utf8), 31 | register(HandlerId, spawn(?MODULE, spy_loop, [HandlerId])), 32 | logger:add_handler(HandlerId, ?MODULE, #{}). 33 | 34 | %% By default the logger will print stuff to the console, 35 | %% we don't need that in tests. 36 | silence_default_handler() -> 37 | logger:remove_handler(default). 38 | 39 | get_spied_reports(IdBinary)-> 40 | Id = binary_to_atom(IdBinary, utf8), 41 | Pid = whereis(Id), 42 | Pid ! {get_log, self()}, 43 | receive 44 | {log, LogEvents} -> 45 | lists:filtermap( 46 | fun 47 | (#{ level := Level, msg := {report, Report}}) -> 48 | %% massage to make it easier to use in tests 49 | {true, {atom_to_binary(Level), Report}}; 50 | (_) -> 51 | false 52 | end, 53 | LogEvents 54 | ) 55 | end. 56 | 57 | log(LogEvent, Config) -> 58 | Id = maps:get(id, Config), 59 | Pid = whereis(Id), 60 | Pid ! {log, LogEvent}. 61 | 62 | spy_loop(Id) -> 63 | spy_loop(Id, []). 64 | 65 | spy_loop(Id, LogEvents) -> 66 | receive 67 | {log, LogEvent} -> 68 | spy_loop(Id, [LogEvent | LogEvents]); 69 | {get_log, Pid} -> 70 | Pid ! {log, lists:reverse(LogEvents)}, 71 | spy_loop(Id, LogEvents) 72 | end. 73 | -------------------------------------------------------------------------------- /test/gleam/http/elli_logging_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_tree.{type BytesTree} 2 | import gleam/dict.{type Dict} 3 | import gleam/dynamic.{type Dynamic} 4 | import gleam/dynamic/decode.{type DecodeError} 5 | import gleam/erlang/atom.{type Atom} 6 | import gleam/hackney 7 | import gleam/http.{type Method, Get, Post, Put} 8 | import gleam/http/elli 9 | import gleam/http/request.{type Request} 10 | import gleam/http/response.{type Response} 11 | import gleam/list 12 | import gleam/result 13 | import gleeunit/should 14 | 15 | // Using FFI to make crashing in the request handler easy. 16 | @external(erlang, "elli_logging_test_ffi", "bad_service") 17 | fn bad_service(request request: Request(BitArray)) -> Response(BytesTree) 18 | 19 | pub fn log_throw_test() { 20 | let port = 4712 21 | let assert Ok(_) = elli.start(bad_service, on_port: port) 22 | 23 | let spy_name = "log_throw_test" 24 | start_log_spy(spy_name) 25 | silence_default_handler() 26 | 27 | let _ = 28 | make_request(port, Get, "/throw", "throw_value") 29 | |> hackney.send 30 | 31 | let assert [#(level, throw)] = get_spied_reports(spy_name) 32 | 33 | let assert "error" = level 34 | let assert Ok("request handler threw an exception") = 35 | get_string(throw, Message) 36 | let assert Ok("throw_value") = get_string(throw, Error) 37 | should.equal(get_method(throw, Method), atom_from_string("GET")) 38 | let assert Ok("/throw") = get_string(throw, Path) 39 | let assert Ok(throw_stack) = list_length(throw, Stacktrace) 40 | should.be_true(0 < throw_stack) 41 | } 42 | 43 | pub fn log_error_test() { 44 | let port = 4713 45 | let assert Ok(_) = elli.start(bad_service, on_port: port) 46 | 47 | let spy_name = "log_error_test" 48 | start_log_spy(spy_name) 49 | silence_default_handler() 50 | 51 | let _ = 52 | make_request(port, Post, "/error", "error_value") 53 | |> hackney.send 54 | 55 | let assert [#(level, err)] = get_spied_reports(spy_name) 56 | 57 | let assert "error" = level 58 | let assert Ok("request handler had a runtime error") = 59 | get_string(err, Message) 60 | let assert Ok("error_value") = get_string(err, Error) 61 | should.equal(get_method(err, Method), atom_from_string("POST")) 62 | let assert Ok("/error") = get_string(err, Path) 63 | let assert Ok(err_stack) = list_length(err, Stacktrace) 64 | should.be_true(0 < err_stack) 65 | } 66 | 67 | pub fn log_exit_test() { 68 | let port = 4714 69 | let assert Ok(_) = elli.start(bad_service, on_port: port) 70 | 71 | let spy_name = "log_exit_test" 72 | start_log_spy(spy_name) 73 | silence_default_handler() 74 | 75 | let _ = 76 | make_request(port, Put, "/exit", "exit_value") 77 | |> hackney.send 78 | 79 | let assert [#(level, exit)] = get_spied_reports(spy_name) 80 | 81 | let assert "error" = level 82 | let assert Ok("request handler exited") = get_string(exit, Message) 83 | let assert Ok("exit_value") = get_string(exit, Error) 84 | should.equal(get_method(exit, Method), atom_from_string("PUT")) 85 | let assert Ok("/exit") = get_string(exit, Path) 86 | let assert Ok(exit_stack) = list_length(exit, Stacktrace) 87 | should.be_true(0 < exit_stack) 88 | } 89 | 90 | @external(erlang, "elli_logging_test_ffi", "start_log_spy") 91 | fn start_log_spy(id id: String) -> Nil 92 | 93 | @external(erlang, "elli_logging_test_ffi", "silence_default_handler") 94 | fn silence_default_handler() -> Nil 95 | 96 | type ReportKey { 97 | Message 98 | Error 99 | Method 100 | Path 101 | Stacktrace 102 | } 103 | 104 | @external(erlang, "elli_logging_test_ffi", "get_spied_reports") 105 | fn get_spied_reports(id id: String) -> List(#(String, Dict(ReportKey, Dynamic))) 106 | 107 | fn make_request(port: Int, method: Method, path: String, message: String) { 108 | request.new() 109 | |> request.set_method(method) 110 | |> request.set_path(path) 111 | |> request.set_query([#("message", message)]) 112 | |> request.set_host("0.0.0.0") 113 | |> request.set_scheme(http.Http) 114 | |> request.set_port(port) 115 | |> request.set_body("SECRET DATA") 116 | } 117 | 118 | fn get_string( 119 | report: Dict(a, Dynamic), 120 | key: a, 121 | ) -> Result(String, List(DecodeError)) { 122 | dict.get(report, key) 123 | |> result.map_error(fn(_) { [] }) 124 | |> result.then(decode.run(_, decode.string)) 125 | } 126 | 127 | fn list_length( 128 | report: Dict(a, Dynamic), 129 | key: a, 130 | ) -> Result(Int, List(DecodeError)) { 131 | dict.get(report, key) 132 | |> result.map_error(fn(_) { [] }) 133 | |> result.then(decode.run(_, decode.list(decode.dynamic))) 134 | |> result.map(list.length) 135 | } 136 | 137 | fn get_method( 138 | report: Dict(a, Dynamic), 139 | key: a, 140 | ) -> Result(Atom, List(DecodeError)) { 141 | dict.get(report, key) 142 | |> result.map_error(fn(_) { [] }) 143 | // This only covers the methods we use in the tests, 144 | // notably not the binaries we get for unknown methods. 145 | |> result.then(atom.from_dynamic) 146 | } 147 | 148 | // a convenience wrapper for the test comparisons 149 | fn atom_from_string(s: String) -> Result(Atom, List(DecodeError)) { 150 | atom.from_string(s) 151 | |> result.map_error(fn(_) { [] }) 152 | } 153 | -------------------------------------------------------------------------------- /test/gleam/http/elli_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_tree.{type BytesTree} 2 | import gleam/hackney 3 | import gleam/http.{Get, Head, Post} 4 | import gleam/http/elli 5 | import gleam/http/request.{type Request} 6 | import gleam/http/response.{type Response} 7 | 8 | pub fn echo_service(request: Request(BitArray)) -> Response(BytesTree) { 9 | let body = case request.body { 10 | <<>> -> bytes_tree.from_string("Default body") 11 | x -> bytes_tree.from_bit_array(x) 12 | } 13 | response.new(200) 14 | |> response.prepend_header("made-with", "Gleam") 15 | |> response.set_body(body) 16 | } 17 | 18 | pub fn request_test() { 19 | // TODO: Assign these ports on random free ones aviable 20 | // TODO: Shut down server after test? 21 | let port = 3078 22 | let assert Ok(_) = elli.start(echo_service, on_port: port) 23 | 24 | let req = 25 | request.new() 26 | |> request.set_method(Get) 27 | |> request.set_host("0.0.0.0") 28 | |> request.set_scheme(http.Http) 29 | |> request.set_port(port) 30 | 31 | let assert Ok(resp) = hackney.send(req) 32 | let assert 200 = resp.status 33 | let assert Ok("Gleam") = response.get_header(resp, "made-with") 34 | let assert "Default body" = resp.body 35 | } 36 | 37 | pub fn get_request_does_not_discard_body_test() { 38 | let port = 3079 39 | let assert Ok(_) = elli.start(echo_service, on_port: port) 40 | 41 | let req = 42 | request.new() 43 | |> request.set_method(Get) 44 | |> request.set_host("0.0.0.0") 45 | |> request.set_scheme(http.Http) 46 | |> request.set_port(port) 47 | |> request.set_body("This does NOT get dropped") 48 | 49 | let assert Ok(resp) = hackney.send(req) 50 | let assert 200 = resp.status 51 | let assert Ok("Gleam") = response.get_header(resp, "made-with") 52 | let assert "This does NOT get dropped" = resp.body 53 | } 54 | 55 | pub fn head_request_discards_body_test() { 56 | let port = 3080 57 | let assert Ok(_) = elli.start(echo_service, on_port: port) 58 | 59 | let req = 60 | request.new() 61 | |> request.set_method(Head) 62 | |> request.set_host("0.0.0.0") 63 | |> request.set_scheme(http.Http) 64 | |> request.set_port(port) 65 | |> request.set_body("This gets dropped") 66 | 67 | let assert Ok(resp) = hackney.send(req) 68 | let assert 200 = resp.status 69 | let assert Ok("Gleam") = response.get_header(resp, "made-with") 70 | let assert "" = resp.body 71 | } 72 | 73 | pub fn body_is_echoed_on_post_test() { 74 | let port = 3081 75 | let assert Ok(_) = elli.start(echo_service, on_port: port) 76 | 77 | let req = 78 | request.new() 79 | |> request.set_method(Post) 80 | |> request.set_host("0.0.0.0") 81 | |> request.set_scheme(http.Http) 82 | |> request.set_port(port) 83 | |> request.set_body("Ping") 84 | 85 | let assert Ok(resp) = hackney.send(req) 86 | let assert 200 = resp.status 87 | let assert Ok("Gleam") = response.get_header(resp, "made-with") 88 | let assert "Ping" = resp.body 89 | } 90 | -------------------------------------------------------------------------------- /test/gleam_elli_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | --------------------------------------------------------------------------------