A cutesy HTTP client for Gleam

finalize

+727 -496
+21
.tangled/workflows/ci.yml
··· 1 + when: 2 + - event: 3 + - push 4 + branch: 5 + - uwu 6 + 7 + engine: nixery 8 + 9 + dependencies: 10 + nixpkgs/nixos-unstable: 11 + - gleam 12 + - erlang 13 + - beam28Packages.rebar3 14 + 15 + steps: 16 + - name: Checking code format 17 + command: gleam format --check 18 + - name: Check for errors 19 + command: gleam check 20 + - name: Run test unit 21 + command: gleam test
+15
.tangled/workflows/publish.yml
··· 1 + when: 2 + - event: 3 + - push 4 + tag: 5 + - v* 6 + 7 + engine: nixery 8 + 9 + dependencies: 10 + nixpkgs/nixos-unstable: 11 + - gleam 12 + 13 + steps: 14 + - name: Publish to Hex.pm 15 + command: gleam publish --yes
+5
.zed/settings.json
··· 1 + // Folder-specific settings 2 + // 3 + // For a full list of overridable settings, and general information on folder-specific settings, 4 + // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 + {}
+7 -2
README.md
··· 3 3 [![Package Version](https://img.shields.io/hexpm/v/requwu)](https://hex.pm/packages/requwu) 4 4 [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/requwu/) 5 5 6 + A cutesy HTTP client for Gleam. 7 + 6 8 ```sh 7 9 gleam add requwu@1 8 10 ``` 9 11 ```gleam 12 + import gleam/http/request 10 13 import requwu 11 14 12 - pub fn main() -> Nil { 13 - // TODO: An example of requwu in use 15 + pub fn main() { 16 + let assert Ok(req) = request.to("https://example.com") 17 + let assert Ok(response) = requwu.send(req) 18 + echo response 14 19 } 15 20 ``` 16 21
+1
flake.nix
··· 18 18 devShell.packages = pkgs: with pkgs; [ 19 19 erlang 20 20 gleam 21 + beam28Packages.rebar3 21 22 ]; 22 23 23 24 treefmtConfig =
+14 -1
gleam.toml
··· 1 1 name = "requwu" 2 - version = "1.0.0" 2 + version = "0.1.0" 3 3 4 4 description = "A cutesy HTTP client for Gleam" 5 5 target = "erlang" 6 6 7 7 licences = ["Apache-2.0"] 8 + 9 + internal_modules = [ 10 + "requwu/internal/*", 11 + "requwu_application" 12 + ] 8 13 9 14 [repository] 10 15 type = "tangled" ··· 20 25 gleam_otp = ">= 1.2.0 and < 2.0.0" 21 26 gleam_http = ">= 4.3.0 and < 5.0.0" 22 27 given = ">= 6.0.1 and < 7.0.0" 28 + 29 + [erlang] 30 + application_start_module = "requwu_application" 31 + 32 + [dev-dependencies] 33 + dream_mock_server = ">= 1.1.0 and < 2.0.0" 34 + gleeunit = ">= 1.9.0 and < 2.0.0" 23 35 gleam_json = ">= 3.1.0 and < 4.0.0" 36 + json_value = ">= 1.0.0 and < 2.0.0"
+20
manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "dream", version = "2.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], otp_app = "dream", source = "hex", outer_checksum = "CC80CF011F75FC467B3808D67D54F469691EA4B2A93956C840FF3CD8FF9C1275" }, 6 + { name = "dream_mock_server", version = "1.1.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], otp_app = "dream_mock_server", source = "hex", outer_checksum = "BC544037C41F436FFB20DF5261139D8A040DD3F7C21736DD0C2B9A78F6C75EF6" }, 7 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 8 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 5 9 { name = "given", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "given", source = "hex", outer_checksum = "7270B10BF75D2A24997DC412AC3031A5845C7093884FBD5D582C1CD475A97FF3" }, 10 + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 6 11 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 7 12 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 8 13 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 9 14 { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 10 15 { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, 16 + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 17 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 18 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 19 + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, 20 + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 21 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 22 + { name = "json_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "json_value", source = "hex", outer_checksum = "D784AA7A33EAF1349C58EE79E3587484B4086D22079C2C4960DED5DC46FB2899" }, 23 + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 24 + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 25 + { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, 11 26 { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], source = "git", repo = "https://github.com/arnu515/mug.git", commit = "a2767a2992dbf61c9450739ba98031e74072ea5c" }, 27 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 28 + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 12 29 ] 13 30 14 31 [requirements] 32 + dream_mock_server = { version = ">= 1.1.0 and < 2.0.0" } 15 33 given = { version = ">= 6.0.1 and < 7.0.0" } 16 34 gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 17 35 gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 18 36 gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 19 37 gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } 20 38 gleam_stdlib = { version = ">= 0.69.0 and < 1.0.0" } 39 + gleeunit = { version = ">= 1.9.0 and < 2.0.0" } 40 + json_value = { version = ">= 1.0.0 and < 2.0.0" } 21 41 mug = { git = "https://github.com/arnu515/mug.git", ref = "main" }
+226 -130
src/requwu.gleam
··· 1 + //// A cutesy HTTP client for Gleam 2 + //// 3 + //// ```gleam 4 + //// import gleam/http/request 5 + //// import requwu 6 + //// 7 + //// pub fn main() { 8 + //// let assert Ok(req) = request.to("https://example.com") 9 + //// let assert Ok(response) = requwu.send(req) 10 + //// echo response 11 + //// } 12 + //// ``` 13 + 1 14 import gleam/bit_array 15 + import gleam/bytes_tree.{type BytesTree} 2 16 import gleam/function 3 17 import gleam/http 4 - import gleam/http/response 5 - import gleam/json 18 + import gleam/http/request.{type Request, Request} 19 + import gleam/http/response.{type Response, Response} 20 + import gleam/int 6 21 import gleam/list 7 - import gleam/option 22 + import gleam/option.{type Option, None, Some} 8 23 import gleam/result 24 + import gleam/string 9 25 import gleam/uri 10 26 import mug 11 27 import requwu/errors 28 + import requwu/internal/helper 12 29 import requwu/internal/http_codec 13 - import requwu/request.{ 14 - type CompleteRequest, type RequestBuilder, CompleteRequest, 15 - } as requwu_request 30 + import requwu/internal/session_pool 31 + 32 + // TODO: add async feature 16 33 17 - // TODO: detect cyclic redirection 34 + const max_redirects = 10 18 35 19 - pub fn request(method method: http.Method, to uri: String) -> RequestBuilder { 20 - requwu_request.to(method, uri) 36 + /// Handles the behavior of request 37 + /// 38 + /// You should use this with client `send` variant if you are working with REST APIs 39 + pub type Client { 40 + Client( 41 + user_agent: Option(String), 42 + default_headers: List(#(String, String)), 43 + referer: Bool, 44 + ) 21 45 } 22 46 23 - pub const get: fn(String) -> RequestBuilder = requwu_request.get 24 - 25 - pub const head: fn(String) -> RequestBuilder = requwu_request.head 26 - 27 - pub const post: fn(String) -> RequestBuilder = requwu_request.post 28 - 29 - pub const put: fn(String) -> RequestBuilder = requwu_request.put 30 - 31 - pub const delete: fn(String) -> RequestBuilder = requwu_request.delete 32 - 33 - pub const connect: fn(String) -> RequestBuilder = requwu_request.connect 34 - 35 - pub const options: fn(String) -> RequestBuilder = requwu_request.options 36 - 37 - pub const trace: fn(String) -> RequestBuilder = requwu_request.trace 38 - 39 - pub const header: fn(RequestBuilder, String, String) -> RequestBuilder = requwu_request.header 40 - 41 - pub const headers: fn(RequestBuilder, List(#(String, String))) -> RequestBuilder = requwu_request.headers 42 - 43 - pub const body: fn(RequestBuilder, String) -> RequestBuilder = requwu_request.body 44 - 45 - pub const json: fn(RequestBuilder, json.Json) -> RequestBuilder = requwu_request.json 47 + /// Initialize an instance of client 48 + pub fn new_client() -> Client { 49 + Client(user_agent: None, default_headers: list.new(), referer: False) 50 + } 46 51 47 - pub const basic_auth: fn(RequestBuilder, String, option.Option(String)) -> 48 - RequestBuilder = requwu_request.basic_auth 52 + /// Set a user-agent for every request that's going to be made 53 + pub fn user_agent(client: Client, ua: String) -> Client { 54 + Client(..client, user_agent: Some(ua)) 55 + } 49 56 50 - pub const bearer_auth: fn(RequestBuilder, String) -> RequestBuilder = requwu_request.bearer_auth 51 - 52 - pub const auth: fn(RequestBuilder, String) -> RequestBuilder = requwu_request.auth 57 + /// Set default headers for every request that's going to be made 58 + pub fn default_headers( 59 + client: Client, 60 + default_headers: List(#(String, String)), 61 + ) -> Client { 62 + Client(..client, default_headers:) 63 + } 53 64 54 - pub const user_agent: fn(RequestBuilder, String) -> RequestBuilder = requwu_request.user_agent 65 + /// Whether to set `Referer` header for every redirects 66 + pub fn referer(client: Client, referer: Bool) -> Client { 67 + Client(..client, referer:) 68 + } 55 69 56 - pub const max_redirects: fn(RequestBuilder, Int) -> RequestBuilder = requwu_request.max_redirects 70 + /// Send a request with additional options to the request 71 + pub fn client_send( 72 + client: Client, 73 + for request: Request(String), 74 + timeout_msec timeout: Int, 75 + ) -> Result(Response(String), errors.SendError) { 76 + let request = Request(..request, body: bytes_tree.from_string(request.body)) 77 + use response <- result.try(client_send_bits(client, request, timeout)) 78 + use body <- result.try( 79 + bit_array.to_string(response.body) 80 + |> result.replace_error(errors.DecodeResponseError(errors.NonAsciiEncoding)), 81 + ) 82 + Ok(Response(..response, body:)) 83 + } 57 84 58 - pub const timeout: fn(RequestBuilder, Int) -> RequestBuilder = requwu_request.timeout 85 + /// Same as `client_send`, but this accepts a BytesTree body and returns a BitArray body 86 + pub fn client_send_bits( 87 + client: Client, 88 + for request: Request(BytesTree), 89 + timeout_msec timeout: Int, 90 + ) -> Result(Response(BitArray), errors.SendError) { 91 + let request = 92 + request 93 + |> list.fold(client.default_headers, _, fn(request, header) { 94 + let #(name, value) = header 95 + request.set_header(request, name, value) 96 + }) 97 + |> case bytes_tree.byte_size(request.body) { 98 + 0 -> function.identity 99 + len -> request.set_header(_, "content-length", int.to_string(len)) 100 + } 101 + |> case client.user_agent { 102 + Some(ua) -> request.set_header(_, "user-agent", ua) 103 + _ -> function.identity 104 + } 105 + |> request.set_header("host", { 106 + let #(host, port) = helper.request_to_host_port(request) 107 + host <> ":" <> int.to_string(port) 108 + }) 109 + use socket <- result.try(new_socket( 110 + client, 111 + for: request, 112 + timeout_msec: timeout, 113 + )) 114 + internal_request( 115 + client, 116 + socket:, 117 + for: request, 118 + timeout_msec: timeout, 119 + redirects: 0, 120 + ) 121 + } 59 122 60 - pub const query: fn(RequestBuilder, String) -> RequestBuilder = requwu_request.query 123 + // *** Implementation *** // 61 124 125 + /// Send a request 62 126 pub fn send( 63 - builder_request: RequestBuilder, 64 - ) -> Result(response.Response(String), errors.SendError) { 65 - use complete_request <- result.try( 66 - requwu_request.complete(builder_request) 67 - |> result.map_error(errors.EncodeRequestError), 68 - ) 69 - use socket <- result.try(new_socket(complete_request)) 127 + request: Request(String), 128 + timeout_msec timeout: Int, 129 + ) -> Result(Response(String), errors.SendError) { 130 + let client = new_client() 131 + client_send(client, for: request, timeout_msec: timeout) 132 + } 70 133 71 - internal_request(socket, complete_request, 0) 134 + /// Same as `send`, but this accepts a BytesTree body and returns a BitArray body 135 + pub fn send_bits( 136 + request: Request(BytesTree), 137 + timeout_msec timeout: Int, 138 + ) -> Result(Response(BitArray), errors.SendError) { 139 + let client = new_client() 140 + client_send_bits(client, for: request, timeout_msec: timeout) 72 141 } 73 142 74 143 fn new_socket( 75 - complete_request: CompleteRequest, 144 + _client: Client, 145 + for request: Request(a), 146 + timeout_msec timeout: Int, 76 147 ) -> Result(mug.Socket, errors.SendError) { 77 - use host <- result.try( 78 - complete_request.uri.host 79 - |> option.to_result(errors.EncodeRequestError(errors.NotAValidUri)), 80 - ) 81 - use scheme <- result.try( 82 - complete_request.uri.scheme 83 - |> option.to_result(errors.EncodeRequestError(errors.NotAValidUri)), 84 - ) 85 - use scheme <- result.try( 86 - http.scheme_from_string(scheme) 87 - |> result.replace_error(errors.EncodeRequestError(errors.NotAValidUri)), 88 - ) 89 - 90 - mug.new( 91 - host, 92 - complete_request.uri.port |> option.unwrap(scheme_to_port(scheme)), 93 - ) 94 - |> mug.timeout(complete_request.timeout) 95 - |> case scheme { 96 - http.Https -> mug.tls 97 - _ -> function.identity 148 + case session_pool.get_connection(request) { 149 + Ok(socket) -> 150 + // the only way to know a socket is closed is to check 151 + // if socket operation returns Error(Closed) 152 + case mug.receive_exact(socket, byte_size: 0, timeout_milliseconds: 1) { 153 + Error(mug.Closed) -> { 154 + session_pool.discard_connection(request) 155 + Error(Nil) 156 + } 157 + // we don't handle any error here, let the `internal_request` do it's job, 158 + // we're only giving back a socket and no more 159 + _ -> Ok(socket) 160 + } 161 + result -> result 98 162 } 99 - |> mug.connect 100 - |> result.map_error(errors.FailedToConnect) 163 + |> result.try_recover(fn(_) { 164 + let #(host, port) = helper.request_to_host_port(request) 165 + mug.new(host, port) 166 + |> mug.timeout(timeout) 167 + |> case request.scheme { 168 + http.Https -> mug.tls 169 + _ -> function.identity 170 + } 171 + |> mug.connect 172 + |> result.map_error(errors.FailedToConnect) 173 + }) 101 174 } 102 175 103 176 fn internal_request( 104 - socket: mug.Socket, 105 - complete_request: CompleteRequest, 106 - redirects: Int, 107 - ) -> Result(response.Response(String), errors.SendError) { 108 - let message_request = http_codec.encode_request(complete_request) 177 + client: Client, 178 + socket socket: mug.Socket, 179 + for request: Request(BytesTree), 180 + timeout_msec timeout: Int, 181 + redirects redirects: Int, 182 + ) -> Result(Response(BitArray), errors.SendError) { 183 + let message_request = http_codec.encode_request(request) 109 184 use _ <- result.try( 110 - mug.send(socket, message_request) 185 + mug.send_builder(socket, message_request) 111 186 |> result.map_error(errors.SocketSendError), 112 187 ) 113 188 114 - use response <- result.try(read_response(socket, complete_request)) 189 + use response <- result.try(read_response_loop( 190 + bytes_tree.new(), 191 + client:, 192 + request:, 193 + socket:, 194 + timeout_msec: timeout, 195 + )) 115 196 116 - case response.status, response.get_header(response, "location") { 117 - // TODO: support 300 multiple choice via location header 118 - 301, Ok(location) 119 - | 302, Ok(location) 120 - | 303, Ok(location) 121 - | 307, Ok(location) 122 - | 308, Ok(location) 123 - -> { 197 + case response.get_header(response, "connection") { 198 + Error(Nil) -> session_pool.save_connection(request, socket) 199 + Ok(value) -> 200 + case string.lowercase(value) { 201 + "keep-alive" -> session_pool.save_connection(request, socket) 202 + "close" -> session_pool.discard_connection(request) 203 + _ -> Nil 204 + } 205 + } 206 + 207 + let possibly_location = 208 + response.get_header(response, "location") |> option.from_result 209 + 210 + case response.status { 211 + 302 | 307 -> { 124 212 let redirects = redirects + 1 125 213 case 126 - redirects <= complete_request.max_redirects 127 - || complete_request.max_redirects < 0 214 + possibly_location, 215 + is_safe_method(request.method) && redirects <= max_redirects 128 216 { 129 - True -> { 130 - use uri <- result.try( 131 - uri.parse(location) 217 + Some(location), True -> { 218 + use location_request <- result.try( 219 + request.to(location) 132 220 |> result.replace_error(errors.EncodeRequestError( 133 - errors.FailedToParseUri, 221 + errors.NotAValidUri, 134 222 )), 135 223 ) 136 - use host <- result.try(option.to_result( 137 - uri.host, 138 - errors.EncodeRequestError(errors.FailedToParseUri), 139 - )) 140 224 let new_request = 141 - CompleteRequest( 142 - ..complete_request, 143 - uri:, 144 - headers: complete_request.headers 145 - |> list.key_set("host", host), 146 - ) 225 + // TODO: add referer header when redirecting via a client option 226 + request 227 + |> request.set_header("host", { 228 + let #(host, port) = helper.request_to_host_port(location_request) 229 + host <> ":" <> int.to_string(port) 230 + }) 231 + |> case client.referer { 232 + True -> request.set_header( 233 + _, 234 + "referer", 235 + request.to_uri(request) |> uri.to_string(), 236 + ) 237 + _ -> function.identity 238 + } 147 239 148 - let _ = mug.shutdown(socket) 149 - use new_socket <- result.try(new_socket(new_request)) 150 - 151 - internal_request(new_socket, new_request, redirects) 240 + use new_socket <- result.try(new_socket( 241 + client, 242 + for: new_request, 243 + timeout_msec: timeout, 244 + )) 245 + internal_request( 246 + client, 247 + socket: new_socket, 248 + for: new_request, 249 + timeout_msec: timeout, 250 + redirects:, 251 + ) 152 252 } 153 - False -> Ok(response) 253 + _, _ -> Ok(response) 154 254 } 155 255 } 156 - _, _ -> Ok(response) 256 + _ -> Ok(response) 157 257 } 158 258 } 159 259 160 - fn read_response( 161 - socket: mug.Socket, 162 - request: CompleteRequest, 163 - ) -> Result(response.Response(String), errors.SendError) { 164 - read_response_loop(<<>>, socket, request) 165 - } 166 - 167 260 fn read_response_loop( 168 - acc: BitArray, 169 - socket: mug.Socket, 170 - request: CompleteRequest, 171 - ) -> Result(response.Response(String), errors.SendError) { 261 + acc: BytesTree, 262 + client client: Client, 263 + request request: Request(a), 264 + socket socket: mug.Socket, 265 + timeout_msec timeout: Int, 266 + ) -> Result(Response(BitArray), errors.SendError) { 267 + let _ = client 172 268 use message_response <- result.try( 173 - mug.receive(socket, request.timeout) 174 - |> result.map_error(errors.SocketRecvError), 269 + mug.receive(socket, timeout) |> result.map_error(errors.SocketSendError), 175 270 ) 176 - let acc = bit_array.append(acc, message_response) 177 - case http_codec.decode_response(acc) { 271 + let acc = bytes_tree.append(acc, message_response) 272 + case http_codec.decode_response(request, bytes_tree.to_bit_array(acc)) { 178 273 Ok(response) -> Ok(response) 179 - Error(errors.IncompleteMessage) -> read_response_loop(acc, socket, request) 274 + Error(errors.IncompleteMessage) -> 275 + read_response_loop(acc, client:, request:, socket:, timeout_msec: timeout) 180 276 Error(error) -> Error(errors.DecodeResponseError(error)) 181 277 } 182 278 } 183 279 184 - fn scheme_to_port(scheme: http.Scheme) -> Int { 185 - case scheme { 186 - http.Http -> 80 187 - http.Https -> 443 280 + fn is_safe_method(method: http.Method) -> Bool { 281 + case method { 282 + http.Get | http.Head | http.Trace | http.Options -> True 283 + _ -> False 188 284 } 189 285 }
+4 -4
src/requwu/errors.gleam
··· 1 1 import mug 2 2 3 + /// An error representation of a failed request encode step 3 4 pub type EncodeRequestError { 4 - FailedToParseUri 5 5 NotAValidUri 6 6 } 7 7 8 + /// An error representation of a failed response decode step 8 9 pub type DecodeResponseError { 9 10 InvalidHttpVersion(BitArray) 10 11 IncompleteMessage 11 12 MalformedMessage 12 - MalformedHeaderTable 13 13 MalformedChunkedContent 14 - IntParseFailed 15 - BitArrayConversionFailed 14 + NonAsciiEncoding 16 15 } 17 16 17 + /// An error representation of a failed send operation 18 18 pub type SendError { 19 19 FailedToConnect(mug.ConnectError) 20 20 SocketSendError(mug.Error)
+29 -21
src/requwu/internal/ascii_util.gleam
··· 1 1 import gleam/bit_array 2 2 import gleam/list 3 3 4 - pub fn take_nth(bits: BitArray, n: Int) -> #(BitArray, BitArray) { 4 + pub type Taken { 5 + Taken(BitArray, BitArray) 6 + } 7 + 8 + pub fn take_nth(bits: BitArray, nth n: Int) -> Taken { 5 9 case n { 6 - 0 -> #(<<>>, bits) 10 + 0 -> Taken(<<>>, bits) 7 11 _ -> take_nth_loop(<<>>, bits, n) 8 12 } 9 13 } 10 14 11 - fn take_nth_loop(acc: BitArray, rest: BitArray, n: Int) -> #(BitArray, BitArray) { 15 + fn take_nth_loop(acc: BitArray, rest: BitArray, n: Int) -> Taken { 12 16 case n { 13 - 0 -> #(acc, rest) 17 + 0 -> Taken(acc, rest) 14 18 _ -> 15 19 case rest { 16 20 <<char:unsigned-int, rest:bits>> -> 17 21 take_nth_loop(<<acc:bits, char>>, rest, n - 1) 18 22 // return if rest = <<>> while n > 0 19 - _ -> #(acc, rest) 23 + _ -> Taken(acc, rest) 20 24 } 21 25 } 22 26 } 23 27 24 - pub fn take_till(bits: BitArray, needle: BitArray) -> #(BitArray, BitArray) { 25 - case needle { 26 - <<>> -> #(bits, <<>>) 27 - _ -> take_till_loop(<<>>, bits, needle) 28 + pub fn take_for(bits: BitArray, needle needle: BitArray) -> Result(Taken, Nil) { 29 + let Taken(match, rest) = take_nth(bits, bit_array.byte_size(needle)) 30 + case match == needle { 31 + True -> Ok(Taken(needle, rest)) 32 + False -> Error(Nil) 28 33 } 29 34 } 30 35 31 - fn take_till_loop( 32 - acc: BitArray, 33 - rest: BitArray, 34 - needle: BitArray, 35 - ) -> #(BitArray, BitArray) { 36 + pub fn take_till(bits: BitArray, found found: BitArray) -> Taken { 37 + case found { 38 + <<>> -> Taken(bits, <<>>) 39 + _ -> take_till_loop(<<>>, bits, found) 40 + } 41 + } 42 + 43 + fn take_till_loop(acc: BitArray, rest: BitArray, found: BitArray) -> Taken { 36 44 case rest { 37 - <<>> -> #(acc, <<>>) 45 + <<>> -> Taken(acc, <<>>) 38 46 _ -> { 39 47 // check if nth part of rest is actually the needle we need 40 - let #(match, _) = take_nth(rest, bit_array.byte_size(needle)) 48 + let match = take_for(rest, found) 41 49 case match { 42 - _ if match == needle -> #(acc, rest) 50 + Ok(_) -> Taken(acc, rest) 43 51 _ -> { 44 52 // trim by 1 if it's not because it needs accuracy 45 - let #(char, rest) = take_nth(rest, 1) 46 - take_till_loop(<<acc:bits, char:bits>>, rest, needle) 53 + let Taken(char, rest) = take_nth(rest, 1) 54 + take_till_loop(<<acc:bits, char:bits>>, rest, found) 47 55 } 48 56 } 49 57 } ··· 64 72 let match = 65 73 // check if the start of bitarray is actually one of the needles 66 74 list.any(needles, fn(needle) { 67 - let #(match, _) = take_nth(rest, bit_array.byte_size(needle)) 75 + let Taken(match, _) = take_nth(rest, bit_array.byte_size(needle)) 68 76 match == needle 69 77 }) 70 78 case match { 71 79 False -> { 72 80 // trim by 1 if it's not 73 - let #(_, rest) = take_nth(rest, 1) 81 + let Taken(_, rest) = take_nth(rest, 1) 74 82 contains_loop(rest, needles) 75 83 } 76 84 _ -> True
+14
src/requwu/internal/helper.gleam
··· 1 + import gleam/http 2 + import gleam/http/request.{type Request} 3 + import gleam/option 4 + 5 + pub fn scheme_to_port(scheme: http.Scheme) -> Int { 6 + case scheme { 7 + http.Http -> 80 8 + http.Https -> 443 9 + } 10 + } 11 + 12 + pub fn request_to_host_port(request: Request(a)) -> #(String, Int) { 13 + #(request.host, request.port |> option.unwrap(scheme_to_port(request.scheme))) 14 + }
+127 -123
src/requwu/internal/http_codec.gleam
··· 1 1 import given 2 2 import gleam/bit_array 3 - import gleam/bytes_tree as btree 3 + import gleam/bytes_tree.{type BytesTree} as btree 4 4 import gleam/http 5 - import gleam/http/response 5 + import gleam/http/request.{type Request} 6 + import gleam/http/response.{type Response, Response} 6 7 import gleam/int 7 8 import gleam/list 8 9 import gleam/option 9 10 import gleam/result 10 11 import gleam/string 11 12 import requwu/errors 12 - import requwu/internal/ascii_util 13 - import requwu/request 13 + import requwu/internal/ascii_util.{Taken} 14 14 15 15 const crlf: BitArray = <<"\r\n">> 16 16 17 17 const space: BitArray = <<" ">> 18 18 19 - pub fn encode_request(request: request.CompleteRequest) -> BitArray { 19 + pub fn encode_request(request: Request(BytesTree)) -> BytesTree { 20 20 let query = 21 - request.uri.query 21 + request.query 22 22 |> option.map(fn(query) { "?" <> query }) 23 23 |> option.unwrap("") 24 24 ··· 26 26 btree.new() 27 27 |> btree.append_string(http.method_to_string(request.method)) 28 28 |> btree.append(space) 29 - |> btree.append_string(normalise_path(request.uri.path) <> query) 29 + |> btree.append_string(normalise_path(request.path) <> query) 30 30 |> btree.append(space) 31 - |> btree.append_string(http_version_to_string(request.version)) 31 + |> btree.append_string("HTTP/1.1") 32 32 33 33 let headers = 34 - list.map(request.headers, fn(header) { 34 + btree.new() 35 + |> list.fold(request.headers, _, fn(tree, header) { 35 36 let #(name, value) = header 36 - name <> ": " <> value 37 + tree 38 + |> btree.append(<<name:utf8, ": ", value:utf8>>) 39 + |> btree.append(crlf) 37 40 }) 38 - |> string.join("\r\n") 39 - |> string.to_option 40 - |> option.map(fn(headers) { headers <> "\r\n" }) 41 - |> option.unwrap("") 42 41 43 42 btree.new() 44 43 |> btree.append_tree(request_line) 45 44 |> btree.append(crlf) 46 - |> btree.append_string(headers) 45 + |> btree.append_tree(headers) 47 46 |> btree.append(crlf) 48 - |> btree.append_string(request.body) 49 - |> btree.to_bit_array 47 + |> btree.append_tree(request.body) 50 48 } 51 49 52 50 pub fn decode_response( 51 + request: Request(a), 53 52 msg: BitArray, 54 - ) -> Result(response.Response(String), errors.DecodeResponseError) { 55 - // * ascii_util.take_nth(rest, 1) means that we throw away space 56 - // * ascii_util.take_nth(rest, 2) means that we throw away CRLF 57 - 58 - let #(http_version, rest) = ascii_util.take_till(msg, space) 53 + ) -> Result(Response(BitArray), errors.DecodeResponseError) { 54 + // parsing http version 55 + let Taken(http_version, rest) = ascii_util.take_till(msg, found: space) 59 56 use <- given.that(http_version == <<"HTTP/1.1">>, else_return: fn() { 60 57 Error(errors.InvalidHttpVersion(http_version)) 61 58 }) 62 59 63 - let #(_, rest) = ascii_util.take_nth(rest, 1) 64 - let #(status_code, rest) = ascii_util.take_till(rest, space) 65 - use status_code <- result.try( 66 - bit_array.to_string(status_code) 67 - |> result.replace_error(errors.BitArrayConversionFailed), 68 - ) 60 + // parsing status code 61 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 62 + rest, 63 + needle: space, 64 + )) 65 + let Taken(status_code, rest) = ascii_util.take_till(rest, found: space) 66 + use status_code <- guard_ascii_encoding(status_code) 69 67 use status <- result.try( 70 68 int.base_parse(status_code, 10) 71 - |> result.replace_error(errors.IntParseFailed), 69 + |> result.replace_error(errors.MalformedMessage), 72 70 ) 73 71 74 - let #(_, rest) = ascii_util.take_nth(rest, 1) 75 - let #(_reason, rest) = ascii_util.take_till(rest, crlf) 72 + // parsing reason 73 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 74 + rest, 75 + needle: space, 76 + )) 77 + let Taken(_reason, rest) = ascii_util.take_till(rest, found: crlf) 78 + 79 + // parsing headers 80 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 81 + rest, 82 + needle: crlf, 83 + )) 84 + use #(headers, rest) <- result.try(parse_header_loop(list.new(), rest)) 76 85 77 - let #(_, rest) = ascii_util.take_nth(rest, 2) 78 - use #(headers, rest) <- result.try(parse_header_loop([], rest)) 86 + // parsing body 87 + use Taken(_, body) <- guard_incomplete_message(ascii_util.take_for( 88 + rest, 89 + needle: crlf, 90 + )) 91 + use body <- result.try( 92 + // check for body's actual length 93 + case 94 + list.key_find(headers, "content-length"), 95 + list.key_find(headers, "transfer-encoding") 96 + |> result.unwrap("") 97 + |> string.ends_with("chunked"), 98 + request.method != http.Head 99 + { 100 + // content-length is found 101 + Ok(length), False, True -> { 102 + use length <- result.try( 103 + int.base_parse(length, 10) 104 + |> result.replace_error(errors.MalformedMessage), 105 + ) 79 106 80 - let #(_, body) = ascii_util.take_nth(rest, 2) 81 - case 82 - list.key_find(headers, "content-length"), 83 - list.key_find(headers, "transfer-encoding") 84 - |> result.unwrap("") 85 - |> string.contains("chunked") 86 - { 87 - Ok(length), False -> { 88 - use length <- result.try( 89 - int.base_parse(length, 10) 90 - |> result.replace_error(errors.IntParseFailed), 91 - ) 92 - case bit_array.byte_size(body) < length { 93 - True -> Error(errors.IncompleteMessage) 94 - False -> { 95 - use body <- result.try( 96 - bit_array.to_string(body) 97 - |> result.replace_error(errors.BitArrayConversionFailed), 98 - ) 99 - Ok(response.Response(status:, headers:, body:)) 107 + case bit_array.byte_size(body) < length { 108 + True -> Error(errors.IncompleteMessage) 109 + False -> Ok(body) 100 110 } 101 111 } 102 - } 103 - Error(Nil), True -> { 104 - // check if at the end of body is CRLF 105 - case bit_array.slice(body, bit_array.byte_size(body), -2) { 106 - Ok(<<"\r\n">>) -> { 107 - use body <- result.try(unwrap_chunked(body)) 108 - use body <- result.try( 109 - bit_array.to_string(body) 110 - |> result.replace_error(errors.BitArrayConversionFailed), 111 - ) 112 - Ok(response.Response(status:, headers:, body:)) 112 + // transfer-encoding has chunked at the end 113 + Error(Nil), True, True -> 114 + // check if at the end of body is last-chunk + CRLF 115 + case bit_array.slice(body, bit_array.byte_size(body), -5) { 116 + Ok(<<"0\r\n\r\n">>) -> unwrap_chunk_loop(btree.new(), body) 117 + _ -> Error(errors.IncompleteMessage) 113 118 } 114 - _ -> Error(errors.IncompleteMessage) 115 - } 116 - } 117 - _, _ -> Error(errors.MalformedMessage) 118 - } 119 - } 119 + _, _, False -> Ok(<<>>) 120 + _, _, _ -> Error(errors.MalformedMessage) 121 + }, 122 + ) 120 123 121 - fn http_version_to_string(version: request.HttpVersion) -> String { 122 - "HTTP/" 123 - <> case version { 124 - request.Http11 -> "1.1" 125 - } 124 + Ok(Response(status:, headers:, body:)) 126 125 } 127 126 128 127 fn normalise_path(path: String) { ··· 136 135 acc: List(#(String, String)), 137 136 rest: BitArray, 138 137 ) -> Result(#(List(#(String, String)), BitArray), errors.DecodeResponseError) { 139 - let #(match, _) = ascii_util.take_nth(rest, 2) 138 + let match = ascii_util.take_for(rest, needle: crlf) 140 139 case match { 141 - // if we found CRLF, that means we found an end 142 - <<"\r\n">> -> Ok(#(acc, rest)) 140 + Ok(_) -> Ok(#(acc, rest)) 143 141 _ -> { 144 - let #(name, rest) = ascii_util.take_till(rest, <<": ">>) 142 + let Taken(name, rest) = ascii_util.take_till(rest, found: <<": ">>) 145 143 use <- given.not(name == <<>>, else_return: fn() { 146 - Error(errors.MalformedHeaderTable) 144 + Error(errors.IncompleteMessage) 147 145 }) 148 146 149 - // throw ": " away 150 - let #(_, rest) = ascii_util.take_nth(rest, 2) 151 - let #(value, rest) = ascii_util.take_till(rest, crlf) 147 + use Taken(_, rest) <- guard_incomplete_message( 148 + ascii_util.take_for(rest, needle: <<": ">>), 149 + ) 150 + let Taken(value, rest) = ascii_util.take_till(rest, found: crlf) 152 151 153 - use name <- result.try( 154 - bit_array.to_string(name) 155 - |> result.replace_error(errors.BitArrayConversionFailed), 156 - ) 157 - use value <- result.try( 158 - bit_array.to_string(value) 159 - |> result.replace_error(errors.BitArrayConversionFailed), 160 - ) 152 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 153 + rest, 154 + needle: crlf, 155 + )) 156 + 157 + use name <- guard_ascii_encoding(name) 158 + use value <- guard_ascii_encoding(value) 161 159 let name = string.lowercase(name) 162 160 let value = string.lowercase(value) |> string.trim_end 163 161 164 - // throw CRLF away so the first case doesn't get messed up 165 - let #(_, rest) = ascii_util.take_nth(rest, 2) 166 - parse_header_loop([#(name, value), ..acc], rest) 162 + parse_header_loop(list.prepend(acc, #(name, value)), rest) 167 163 } 168 164 } 169 165 } 170 166 171 - pub fn unwrap_chunked( 172 - chunks: BitArray, 173 - ) -> Result(BitArray, errors.DecodeResponseError) { 174 - unwrap_chunk_loop(<<>>, chunks) 175 - } 176 - 177 167 fn unwrap_chunk_loop( 178 - acc: BitArray, 168 + acc: BytesTree, 179 169 chunks: BitArray, 180 170 ) -> Result(BitArray, errors.DecodeResponseError) { 181 171 use #(chunk, rest) <- result.try(unwrap_single_chunk(chunks)) 182 172 case chunk { 183 - <<>> -> Ok(acc) 184 - _ -> unwrap_chunk_loop(<<acc:bits, chunk:bits>>, rest) 173 + <<>> -> btree.to_bit_array(acc) |> Ok 174 + _ -> unwrap_chunk_loop(btree.append(acc, chunk), rest) 185 175 } 186 176 } 187 177 188 178 fn unwrap_single_chunk( 189 179 chunks: BitArray, 190 180 ) -> Result(#(BitArray, BitArray), errors.DecodeResponseError) { 191 - // assume we're after last chunk, take till CRLF 192 - let #(chunk_size, rest) = ascii_util.take_till(chunks, crlf) 181 + // assumed we're after last chunk, take till CRLF 182 + let Taken(chunk_size, rest) = ascii_util.take_till(chunks, found: crlf) 193 183 // chunk_ext might be exist, let's just ignore that 194 - let #(chunk_size, _chunk_ext) = ascii_util.take_till(chunk_size, <<";">>) 184 + let Taken(chunk_size, _chunk_ext) = 185 + ascii_util.take_till(chunk_size, found: <<";">>) 195 186 196 - // if both chunk_size and rest empty, it means that we're parsing incomplete message 197 - 198 - use chunk_size <- result.try( 199 - bit_array.to_string(chunk_size) 200 - |> result.replace_error(case chunk_size, rest { 201 - <<>>, <<>> -> errors.IncompleteMessage 202 - _, _ -> errors.MalformedChunkedContent 203 - }), 204 - ) 187 + use chunk_size <- guard_ascii_encoding(chunk_size) 205 188 use chunk_size <- result.try( 206 189 int.base_parse(chunk_size, 16) 207 - |> result.replace_error(case chunk_size, rest { 208 - "", <<>> -> errors.IncompleteMessage 209 - _, _ -> errors.MalformedChunkedContent 210 - }), 190 + |> result.replace_error(errors.MalformedChunkedContent), 211 191 ) 212 192 213 - let #(_, rest) = ascii_util.take_nth(rest, 2) 193 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 194 + rest, 195 + needle: crlf, 196 + )) 214 197 215 198 case chunk_size { 216 - // the "actual" chunk_data of last-chunk is trailers, let's discard that 217 199 0 -> Ok(#(<<>>, rest)) 218 200 _ -> { 219 - let #(chunk_data, rest) = ascii_util.take_till(rest, crlf) 201 + let Taken(chunk_data, rest) = ascii_util.take_till(rest, found: crlf) 220 202 221 203 use <- given.that( 222 204 // we check if the chunk_data's size is the same as the chunk_size ··· 224 206 else_return: fn() { Error(errors.MalformedChunkedContent) }, 225 207 ) 226 208 227 - let #(_, rest) = ascii_util.take_nth(rest, 2) 209 + use Taken(_, rest) <- guard_incomplete_message(ascii_util.take_for( 210 + rest, 211 + needle: crlf, 212 + )) 213 + 228 214 Ok(#(chunk_data, rest)) 229 215 } 230 216 } 231 217 } 218 + 219 + fn guard_incomplete_message( 220 + result: Result(a, e), 221 + apply: fn(a) -> Result(b, errors.DecodeResponseError), 222 + ) -> Result(b, errors.DecodeResponseError) { 223 + result.try(result |> result.replace_error(errors.IncompleteMessage), apply) 224 + } 225 + 226 + fn guard_ascii_encoding( 227 + bits: BitArray, 228 + apply: fn(String) -> Result(a, errors.DecodeResponseError), 229 + ) -> Result(a, errors.DecodeResponseError) { 230 + result.try( 231 + bit_array.to_string(bits) 232 + |> result.replace_error(errors.NonAsciiEncoding), 233 + apply, 234 + ) 235 + }
+87
src/requwu/internal/session_pool.gleam
··· 1 + import gleam/dict 2 + import gleam/erlang/atom 3 + import gleam/erlang/process 4 + import gleam/http/request.{type Request} 5 + import gleam/otp/actor 6 + import gleam/otp/static_supervisor 7 + import gleam/otp/supervision 8 + import mug 9 + import requwu/internal/helper 10 + import requwu/internal/type_util 11 + 12 + type HostPort = 13 + #(String, Int) 14 + 15 + type State = 16 + dict.Dict(HostPort, mug.Socket) 17 + 18 + type Message { 19 + Save(HostPort, mug.Socket) 20 + Discard(HostPort) 21 + Get(process.Subject(Result(mug.Socket, Nil)), HostPort) 22 + Stop 23 + } 24 + 25 + pub fn start_pool() -> process.Pid { 26 + let supervised_actor = 27 + supervision.worker(fn() { actor.start(actor_construct()) }) 28 + case process.named(actor_name()) { 29 + Error(_) -> { 30 + let assert Ok(supervisor) = 31 + static_supervisor.new(static_supervisor.OneForOne) 32 + |> static_supervisor.add(supervised_actor) 33 + |> static_supervisor.start 34 + as "failed to start supervisor for session pool" 35 + supervisor.pid 36 + } 37 + Ok(pid) -> pid 38 + } 39 + } 40 + 41 + fn actor_construct() -> actor.Builder(State, Message, process.Subject(Message)) { 42 + actor.new(dict.new()) 43 + |> actor.named(actor_name()) 44 + |> actor.on_message(handle_message) 45 + } 46 + 47 + fn handle_message(state: State, message: Message) -> actor.Next(State, Message) { 48 + case message { 49 + Save(host_port, socket) -> 50 + actor.continue(case dict.has_key(state, host_port) { 51 + True -> state 52 + False -> dict.insert(state, host_port, socket) 53 + }) 54 + Discard(host_port) -> actor.continue(dict.delete(state, host_port)) 55 + Get(caller, host_port) -> { 56 + let possibly_socket = dict.get(state, host_port) 57 + process.send(caller, possibly_socket) 58 + actor.continue(state) 59 + } 60 + Stop -> actor.stop() 61 + } 62 + } 63 + 64 + pub fn actor_name() -> process.Name(a) { 65 + atom.create("requwu@sessions") 66 + |> type_util.unsafe_cast 67 + } 68 + 69 + pub fn get_connection(request: Request(a)) -> Result(mug.Socket, Nil) { 70 + let host_port = helper.request_to_host_port(request) 71 + let subject = process.named_subject(actor_name()) 72 + process.call(subject, 100, fn(caller) { Get(caller, host_port) }) 73 + } 74 + 75 + pub fn save_connection(request: Request(a), socket: mug.Socket) -> Nil { 76 + let host_port = helper.request_to_host_port(request) 77 + let subject = process.named_subject(actor_name()) 78 + process.send(subject, Save(host_port, socket)) 79 + Nil 80 + } 81 + 82 + pub fn discard_connection(request: Request(a)) -> Nil { 83 + let host_port = helper.request_to_host_port(request) 84 + let subject = process.named_subject(actor_name()) 85 + process.send(subject, Discard(host_port)) 86 + Nil 87 + }
+2
src/requwu/internal/type_util.gleam
··· 1 + @external(erlang, "gleam@function", "identity") 2 + pub fn unsafe_cast(value: a) -> b
-215
src/requwu/request.gleam
··· 1 - import gleam/bit_array 2 - import gleam/function 3 - import gleam/http 4 - import gleam/http/request 5 - import gleam/int 6 - import gleam/json 7 - import gleam/list 8 - import gleam/option.{type Option, None, Some} 9 - import gleam/result 10 - import gleam/string 11 - import gleam/string_tree 12 - import gleam/uri 13 - import requwu/errors 14 - 15 - pub type RequestBuilder { 16 - RequestBuilder( 17 - timeout: Int, 18 - method: http.Method, 19 - uri: String, 20 - query: Option(String), 21 - version: HttpVersion, 22 - headers: List(#(String, String)), 23 - auth: Option(String), 24 - user_agent: Option(String), 25 - max_redirects: Int, 26 - body: String, 27 - ) 28 - } 29 - 30 - pub type HttpVersion { 31 - Http11 32 - } 33 - 34 - pub fn to(method: http.Method, to uri: String) -> RequestBuilder { 35 - let #(uri, query) = 36 - string.split_once(uri, on: "?") |> result.unwrap(#(uri, "")) 37 - 38 - RequestBuilder( 39 - timeout: 1000, 40 - method:, 41 - uri:, 42 - query: string.to_option(query), 43 - version: Http11, 44 - headers: [], 45 - auth: None, 46 - user_agent: None, 47 - max_redirects: -1, 48 - body: "", 49 - ) 50 - } 51 - 52 - pub fn get(uri: String) -> RequestBuilder { 53 - to(http.Get, uri) 54 - } 55 - 56 - pub fn head(uri: String) -> RequestBuilder { 57 - to(http.Head, uri) 58 - } 59 - 60 - pub fn post(uri: String) -> RequestBuilder { 61 - to(http.Post, uri) 62 - } 63 - 64 - pub fn put(uri: String) -> RequestBuilder { 65 - to(http.Put, uri) 66 - } 67 - 68 - pub fn delete(uri: String) -> RequestBuilder { 69 - to(http.Delete, uri) 70 - } 71 - 72 - pub fn connect(uri: String) -> RequestBuilder { 73 - to(http.Connect, uri) 74 - } 75 - 76 - pub fn options(uri: String) -> RequestBuilder { 77 - to(http.Options, uri) 78 - } 79 - 80 - pub fn trace(uri: String) -> RequestBuilder { 81 - to(http.Trace, uri) 82 - } 83 - 84 - pub fn header( 85 - builder: RequestBuilder, 86 - key: String, 87 - value: String, 88 - ) -> RequestBuilder { 89 - RequestBuilder(..builder, headers: [#(key, value), ..builder.headers]) 90 - } 91 - 92 - pub fn headers( 93 - builder: RequestBuilder, 94 - headers: List(#(String, String)), 95 - ) -> RequestBuilder { 96 - RequestBuilder(..builder, headers: list.append(builder.headers, headers)) 97 - } 98 - 99 - pub fn body(builder: RequestBuilder, body: String) -> RequestBuilder { 100 - RequestBuilder(..builder, body:) 101 - } 102 - 103 - pub fn json(builder: RequestBuilder, json: json.Json) -> RequestBuilder { 104 - RequestBuilder( 105 - ..builder, 106 - body: json.to_string_tree(json) |> string_tree.to_string, 107 - ) 108 - |> header("content-type", "application/json") 109 - } 110 - 111 - pub fn basic_auth( 112 - builder: RequestBuilder, 113 - user: String, 114 - password: Option(String), 115 - ) -> RequestBuilder { 116 - let basic_auth = 117 - bit_array.from_string(user <> ":" <> password |> option.unwrap("")) 118 - |> bit_array.base64_encode(True) 119 - 120 - RequestBuilder(..builder, auth: Some("Basic " <> basic_auth)) 121 - } 122 - 123 - pub fn bearer_auth(builder: RequestBuilder, token: String) -> RequestBuilder { 124 - RequestBuilder(..builder, auth: Some("Bearer " <> token)) 125 - } 126 - 127 - pub fn auth(builder: RequestBuilder, auth: String) -> RequestBuilder { 128 - RequestBuilder(..builder, auth: Some(auth)) 129 - } 130 - 131 - pub fn user_agent(builder: RequestBuilder, user_agent: String) -> RequestBuilder { 132 - RequestBuilder(..builder, user_agent: Some(user_agent)) 133 - } 134 - 135 - pub fn max_redirects(builder: RequestBuilder, redirects: Int) { 136 - RequestBuilder(..builder, max_redirects: redirects) 137 - } 138 - 139 - pub fn timeout( 140 - builder: RequestBuilder, 141 - miliseconds timeout: Int, 142 - ) -> RequestBuilder { 143 - RequestBuilder(..builder, timeout:) 144 - } 145 - 146 - pub fn query(builder: RequestBuilder, query: String) -> RequestBuilder { 147 - RequestBuilder(..builder, query: Some(query)) 148 - } 149 - 150 - pub fn from_request(req: request.Request(String)) -> RequestBuilder { 151 - let uri = request.to_uri(req) |> uri.to_string 152 - 153 - to(req.method, uri) 154 - |> headers(req.headers) 155 - |> case list.key_find(req.headers, "authorization") { 156 - Error(Nil) -> function.identity 157 - Ok(auth_str) -> auth(_, auth_str) 158 - } 159 - |> body(req.body) 160 - } 161 - 162 - @internal 163 - pub type CompleteRequest { 164 - CompleteRequest( 165 - timeout: Int, 166 - method: http.Method, 167 - uri: uri.Uri, 168 - version: HttpVersion, 169 - headers: List(#(String, String)), 170 - max_redirects: Int, 171 - body: String, 172 - ) 173 - } 174 - 175 - @internal 176 - pub fn complete( 177 - builder: RequestBuilder, 178 - ) -> Result(CompleteRequest, errors.EncodeRequestError) { 179 - let query = case builder.query { 180 - None -> "" 181 - Some(query) -> "?" <> query 182 - } 183 - let uri = builder.uri <> query 184 - use uri <- result.try( 185 - uri.parse(uri) |> result.replace_error(errors.FailedToParseUri), 186 - ) 187 - 188 - use host <- result.try(option.to_result(uri.host, errors.FailedToParseUri)) 189 - 190 - let headers = { 191 - case builder.auth { 192 - None -> [] 193 - Some(auth) -> [#("authorization", auth)] 194 - } 195 - |> list.append(builder.headers) 196 - |> list.prepend(#("host", host)) 197 - |> list.prepend(#( 198 - "content-length", 199 - bit_array.from_string(builder.body) 200 - |> bit_array.byte_size 201 - |> int.to_string, 202 - )) 203 - |> list.prepend(#("te", "chunked")) 204 - } 205 - 206 - Ok(CompleteRequest( 207 - timeout: builder.timeout, 208 - method: builder.method, 209 - uri:, 210 - version: builder.version, 211 - headers:, 212 - max_redirects: builder.max_redirects, 213 - body: builder.body, 214 - )) 215 - }
+20
src/requwu_application.gleam
··· 1 + import gleam/erlang/atom 2 + import gleam/erlang/process 3 + import requwu/internal/session_pool 4 + 5 + pub fn start(_type: typ, _args: args) -> Result(process.Pid, Nil) { 6 + let pid = session_pool.start_pool() 7 + let _ = process.link(pid) 8 + Ok(pid) 9 + } 10 + 11 + pub fn stop(_state: state) -> atom.Atom { 12 + case process.named(session_pool.actor_name()) { 13 + Ok(pid) -> { 14 + process.unlink(pid) 15 + process.send_exit(pid) 16 + } 17 + Error(_) -> Nil 18 + } 19 + atom.create("ok") 20 + }
test/.gitkeep

This is a binary file and will not be displayed.

+135
test/requwu_test.gleam
··· 1 + import dream_mock_server/server 2 + import gleam/dict 3 + import gleam/http 4 + import gleam/http/request 5 + import gleam/http/response 6 + import gleam/int 7 + import gleam/json 8 + import gleam/string_tree 9 + import gleeunit 10 + import json_value 11 + import requwu 12 + import requwu_application 13 + 14 + pub fn main() { 15 + let _ = requwu_application.start(Nil, Nil) 16 + let assert Ok(mock_server) = server.start(3535) 17 + 18 + gleeunit.main() 19 + 20 + let _ = requwu_application.stop(Nil) 21 + server.stop(mock_server) 22 + } 23 + 24 + fn json_structure( 25 + method method: String, 26 + path path: String, 27 + keys append: List(#(String, json_value.JsonValue)), 28 + ) { 29 + json_value.Object( 30 + dict.from_list([ 31 + #("method", json_value.String(method)), 32 + #("url", json_value.String(path)), 33 + ..append 34 + ]), 35 + ) 36 + } 37 + 38 + pub fn send_test() { 39 + let assert Ok(base_req) = request.to("http://localhost:3535") 40 + as "failed to construct base request" 41 + 42 + let assert Ok(response) = 43 + base_req |> request.set_path("/get") |> requwu.send(1000) 44 + as "/get: request send failed" 45 + assert response.status == 200 as "/get: status code is not 200" 46 + assert response |> response.get_header("content-type") 47 + == Ok("application/json; charset=utf-8") 48 + as "/get: content-type is not application/json" 49 + assert json.parse(response.body, json_value.decoder()) 50 + == Ok( 51 + json_structure(method: "GET", path: "/get", keys: [ 52 + #("headers", json_value.Array([])), 53 + ]), 54 + ) 55 + as "/get: assert failure for json" 56 + 57 + { 58 + let body = "foo bar, this has \u{000000} value" 59 + 60 + let assert Ok(response) = 61 + base_req 62 + |> request.set_method(http.Post) 63 + |> request.set_path("/post") 64 + |> request.set_body(body) 65 + |> requwu.send(1000) 66 + as "/post: request send failed" 67 + assert response.status == 201 as "/post: status code is not 201" 68 + assert response |> response.get_header("content-type") 69 + == Ok("application/json; charset=utf-8") 70 + as "/post: content-type is not application/json" 71 + assert json.parse(response.body, json_value.decoder()) 72 + == Ok( 73 + json_structure(method: "POST", path: "/post", keys: [ 74 + #("data", json_value.String(body)), 75 + ]), 76 + ) 77 + 78 + let assert Ok(response) = 79 + base_req 80 + |> request.set_method(http.Put) 81 + |> request.set_path("/put") 82 + |> request.set_body(body) 83 + |> requwu.send(1000) 84 + as "/put: request send failed" 85 + assert response.status == 200 as "/put: status code is not 200" 86 + assert response |> response.get_header("content-type") 87 + == Ok("application/json; charset=utf-8") 88 + as "/put: content-type is not application/json" 89 + assert json.parse(response.body, json_value.decoder()) 90 + == Ok( 91 + json_structure(method: "PUT", path: "/put", keys: [ 92 + #("data", json_value.String(body)), 93 + ]), 94 + ) 95 + } 96 + 97 + let assert Ok(response) = 98 + base_req 99 + |> request.set_method(http.Delete) 100 + |> request.set_path("/delete") 101 + |> requwu.send(1000) 102 + as "/delete: request send failed" 103 + assert response.status == 200 as "/delete: status code is not 200" 104 + assert response |> response.get_header("content-type") 105 + == Ok("application/json; charset=utf-8") 106 + as "/delete: content-type is not application/json" 107 + assert json.parse(response.body, json_value.decoder()) 108 + == Ok(json_structure(method: "DELETE", path: "/delete", keys: [])) 109 + 110 + let assert Ok(response) = 111 + base_req 112 + |> request.set_path("/stream/fast") 113 + |> requwu.send(10 * 100) 114 + as "fast stream: request send failed" 115 + assert response.status == 200 as "fast stream: status code is not 200" 116 + assert response.body 117 + == int.range(1, 11, with: string_tree.new(), run: fn(tree, i) { 118 + string_tree.append(tree, "Chunk " <> int.to_string(i) <> "\n") 119 + }) 120 + |> string_tree.to_string 121 + as "fast stream: unexpected body" 122 + 123 + let assert Ok(response) = 124 + base_req 125 + |> request.set_path("/stream/slow") 126 + |> requwu.send(5 * 2000) 127 + as "slow stream: request send failed" 128 + assert response.status == 200 as "slow stream: status code is not 200" 129 + assert response.body 130 + == int.range(1, 6, with: string_tree.new(), run: fn(tree, i) { 131 + string_tree.append(tree, "Chunk " <> int.to_string(i) <> "\n") 132 + }) 133 + |> string_tree.to_string 134 + as "slow stream: unexpected body" 135 + }