Current section

34 Versions

Jump to

Compare versions

13 files changed
+863 additions
-589 deletions
  @@ -1,8 +1,8 @@
1 1 name = "gleam_http"
2 - version = "4.1.1"
2 + version = "4.2.0"
3 3 licences = ["Apache-2.0"]
4 4 description = "Types and functions for Gleam HTTP clients and servers"
5 - gleam = ">= 1.4.0"
5 + gleam = ">= 1.11.0"
6 6
7 7 repository = { type = "github", user = "gleam-lang", repo = "http" }
8 8 links = [
  @@ -1,6 +1,6 @@
1 1 {<<"name">>, <<"gleam_http">>}.
2 2 {<<"app">>, <<"gleam_http">>}.
3 - {<<"version">>, <<"4.1.1">>}.
3 + {<<"version">>, <<"4.2.0">>}.
4 4 {<<"description">>, <<"Types and functions for Gleam HTTP clients and servers"/utf8>>}.
5 5 {<<"licenses">>, [<<"Apache-2.0">>]}.
6 6 {<<"build_tools">>, [<<"gleam">>]}.
  @@ -28,6 +28,25 @@ pub type Method {
28 28 Other(String)
29 29 }
30 30
31 + pub fn parse_method(method: String) -> Result(Method, Nil) {
32 + case method {
33 + "CONNECT" -> Ok(Connect)
34 + "DELETE" -> Ok(Delete)
35 + "GET" -> Ok(Get)
36 + "HEAD" -> Ok(Head)
37 + "OPTIONS" -> Ok(Options)
38 + "PATCH" -> Ok(Patch)
39 + "POST" -> Ok(Post)
40 + "PUT" -> Ok(Put)
41 + "TRACE" -> Ok(Trace)
42 + method ->
43 + case is_valid_token(method) {
44 + True -> Ok(Other(method))
45 + False -> Error(Nil)
46 + }
47 + }
48 + }
49 +
31 50 // A token is defined as:
32 51 //
33 52 // token = 1*tchar
  @@ -45,53 +64,101 @@ pub type Method {
45 64 //
46 65 // (From https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1)
47 66 //
48 - fn is_valid_token(s: String) -> Bool {
49 - bit_array.from_string(s)
50 - |> do_is_valid_token(True)
51 - }
52 -
53 - fn do_is_valid_token(bytes: BitArray, acc: Bool) {
54 - case bytes, acc {
55 - <<char, rest:bytes>>, True -> do_is_valid_token(rest, is_valid_tchar(char))
56 - _, _ -> acc
67 + fn is_valid_token(token: String) -> Bool {
68 + case token {
69 + // A token must have at least a single valid characther
70 + "" -> False
71 + _ -> is_valid_token_loop(token)
57 72 }
58 73 }
59 74
60 - fn is_valid_tchar(ch: Int) -> Bool {
61 - case ch {
62 - // "!" | "#" | "$" | "%" | "&" | "'" | "*" | "+" | "-"
63 - // | "." | "^" | "_" | "`" | "|" | "~"
64 - 33 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 45 | 46 | 94 | 95 | 96 | 124 | 126 ->
65 - True
66 - // DIGIT
67 - ch if ch >= 0x30 && ch <= 0x39 -> True
68 - // ALPHA
69 - ch if ch >= 0x41 && ch <= 0x5A || ch >= 0x61 && ch <= 0x7A -> True
75 + fn is_valid_token_loop(token: String) -> Bool {
76 + case token {
77 + "" -> True
78 + // SYMBOLS
79 + "!" <> rest
80 + | "#" <> rest
81 + | "$" <> rest
82 + | "%" <> rest
83 + | "&" <> rest
84 + | "'" <> rest
85 + | "*" <> rest
86 + | "+" <> rest
87 + | "-" <> rest
88 + | "." <> rest
89 + | "^" <> rest
90 + | "_" <> rest
91 + | "`" <> rest
92 + | "|" <> rest
93 + | "~" <> rest
94 + | // DIGITS
95 + "0" <> rest
96 + | "1" <> rest
97 + | "2" <> rest
98 + | "3" <> rest
99 + | "4" <> rest
100 + | "5" <> rest
101 + | "6" <> rest
102 + | "7" <> rest
103 + | "8" <> rest
104 + | "9" <> rest
105 + | // ALPHA
106 + "A" <> rest
107 + | "B" <> rest
108 + | "C" <> rest
109 + | "D" <> rest
110 + | "E" <> rest
111 + | "F" <> rest
112 + | "G" <> rest
113 + | "H" <> rest
114 + | "I" <> rest
115 + | "J" <> rest
116 + | "K" <> rest
117 + | "L" <> rest
118 + | "M" <> rest
119 + | "N" <> rest
120 + | "O" <> rest
121 + | "P" <> rest
122 + | "Q" <> rest
123 + | "R" <> rest
124 + | "S" <> rest
125 + | "T" <> rest
126 + | "U" <> rest
127 + | "V" <> rest
128 + | "W" <> rest
129 + | "X" <> rest
130 + | "Y" <> rest
131 + | "Z" <> rest
132 + | "a" <> rest
133 + | "b" <> rest
134 + | "c" <> rest
135 + | "d" <> rest
136 + | "e" <> rest
137 + | "f" <> rest
138 + | "g" <> rest
139 + | "h" <> rest
140 + | "i" <> rest
141 + | "j" <> rest
142 + | "k" <> rest
143 + | "l" <> rest
144 + | "m" <> rest
145 + | "n" <> rest
146 + | "o" <> rest
147 + | "p" <> rest
148 + | "q" <> rest
149 + | "r" <> rest
150 + | "s" <> rest
151 + | "t" <> rest
152 + | "u" <> rest
153 + | "v" <> rest
154 + | "w" <> rest
155 + | "x" <> rest
156 + | "y" <> rest
157 + | "z" <> rest -> is_valid_token_loop(rest)
70 158 _ -> False
71 159 }
72 160 }
73 161
74 - // TODO: check if the a is a valid HTTP method (i.e. it is a token, as per the
75 - // spec) and return Ok(Other(s)) if so.
76 - pub fn parse_method(s) -> Result(Method, Nil) {
77 - case s {
78 - "CONNECT" -> Ok(Connect)
79 - "DELETE" -> Ok(Delete)
80 - "GET" -> Ok(Get)
81 - "HEAD" -> Ok(Head)
82 - "OPTIONS" -> Ok(Options)
83 - "PATCH" -> Ok(Patch)
84 - "POST" -> Ok(Post)
85 - "PUT" -> Ok(Put)
86 - "TRACE" -> Ok(Trace)
87 - s ->
88 - case is_valid_token(s) {
89 - True -> Ok(Other(s))
90 - False -> Error(Nil)
91 - }
92 - }
93 - }
94 -
95 162 pub fn method_to_string(method: Method) -> String {
96 163 case method {
97 164 Connect -> "CONNECT"
  @@ -103,7 +170,7 @@ pub fn method_to_string(method: Method) -> String {
103 170 Post -> "POST"
104 171 Put -> "PUT"
105 172 Trace -> "TRACE"
106 - Other(s) -> s
173 + Other(method) -> method
107 174 }
108 175 }
109 176
  @@ -118,11 +185,10 @@ pub type Scheme {
118 185 ///
119 186 /// # Examples
120 187 ///
121 - /// > scheme_to_string(Http)
122 - /// "http"
123 - ///
124 - /// > scheme_to_string(Https)
125 - /// "https"
188 + /// ```gleam
189 + /// assert "http" == scheme_to_string(Http)
190 + /// assert "https" == scheme_to_string(Https)
191 + /// ```
126 192 ///
127 193 pub fn scheme_to_string(scheme: Scheme) -> String {
128 194 case scheme {
  @@ -135,11 +201,10 @@ pub fn scheme_to_string(scheme: Scheme) -> String {
135 201 ///
136 202 /// # Examples
137 203 ///
138 - /// > scheme_from_string("http")
139 - /// Ok(Http)
140 - ///
141 - /// > scheme_from_string("ftp")
142 - /// Error(Nil)
204 + /// ```gleam
205 + /// assert Ok(Http) == scheme_from_string("http")
206 + /// assert Error(Nil) == scheme_from_string("ftp")
207 + /// ```
143 208 ///
144 209 pub fn scheme_from_string(scheme: String) -> Result(Scheme, Nil) {
145 210 case string.lowercase(scheme) {
  @@ -1,7 +1,7 @@
1 1 import gleam/http.{type Scheme}
2 2 import gleam/int
3 3 import gleam/list
4 - import gleam/option.{type Option, Some}
4 + import gleam/option.{type Option}
5 5 import gleam/result
6 6 import gleam/string
7 7
  @@ -14,7 +14,7 @@ pub type SameSitePolicy {
14 14 None
15 15 }
16 16
17 - fn same_site_to_string(policy) {
17 + fn same_site_to_string(policy: SameSitePolicy) -> String {
18 18 case policy {
19 19 Lax -> "Lax"
20 20 Strict -> "Strict"
  @@ -37,64 +37,65 @@ pub type Attributes {
37 37 /// Helper to create sensible default attributes for a set cookie.
38 38 ///
39 39 /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Attributes
40 - pub fn defaults(scheme: Scheme) {
40 + pub fn defaults(scheme: Scheme) -> Attributes {
41 41 Attributes(
42 42 max_age: option.None,
43 43 domain: option.None,
44 44 path: option.Some("/"),
45 45 secure: scheme == http.Https,
46 46 http_only: True,
47 - same_site: Some(Lax),
47 + same_site: option.Some(Lax),
48 48 )
49 49 }
50 50
51 51 const epoch = "Expires=Thu, 01 Jan 1970 00:00:00 GMT"
52 52
53 - fn cookie_attributes_to_list(attributes) {
54 - let Attributes(
55 - max_age: max_age,
56 - domain: domain,
57 - path: path,
58 - secure: secure,
59 - http_only: http_only,
60 - same_site: same_site,
61 - ) = attributes
53 + fn cookie_attributes_to_list(attributes: Attributes) -> List(String) {
54 + let Attributes(max_age:, domain:, path:, secure:, http_only:, same_site:) =
55 + attributes
56 +
62 57 [
63 58 // Expires is a deprecated attribute for cookies, it has been replaced with MaxAge
64 59 // MaxAge is widely supported and so Expires values are not set.
65 60 // Only when deleting cookies is the exception made to use the old format,
66 61 // to ensure complete clearup of cookies if required by an application.
67 62 case max_age {
68 - option.Some(0) -> option.Some([epoch])
63 + option.Some(0) -> option.Some(epoch)
69 64 _ -> option.None
70 65 },
71 - option.map(max_age, fn(max_age) { ["Max-Age=", int.to_string(max_age)] }),
72 - option.map(domain, fn(domain) { ["Domain=", domain] }),
73 - option.map(path, fn(path) { ["Path=", path] }),
66 + option.map(max_age, fn(max_age) { "Max-Age=" <> int.to_string(max_age) }),
67 + option.map(domain, fn(domain) { "Domain=" <> domain }),
68 + option.map(path, fn(path) { "Path=" <> path }),
74 69 case secure {
75 - True -> option.Some(["Secure"])
70 + True -> option.Some("Secure")
76 71 False -> option.None
77 72 },
78 73 case http_only {
79 - True -> option.Some(["HttpOnly"])
74 + True -> option.Some("HttpOnly")
80 75 False -> option.None
81 76 },
82 77 option.map(same_site, fn(same_site) {
83 - ["SameSite=", same_site_to_string(same_site)]
78 + "SameSite=" <> same_site_to_string(same_site)
84 79 }),
85 80 ]
86 81 |> list.filter_map(option.to_result(_, Nil))
87 82 }
88 83
89 84 pub fn set_header(name: String, value: String, attributes: Attributes) -> String {
90 - [[name, "=", value], ..cookie_attributes_to_list(attributes)]
91 - |> list.map(string.join(_, ""))
85 + [name <> "=" <> value, ..cookie_attributes_to_list(attributes)]
92 86 |> string.join("; ")
93 87 }
94 88
95 89 /// Parse a list of cookies from a header string. Any malformed cookies will be
96 90 /// discarded.
97 91 ///
92 + /// ## Backwards compatibility
93 + ///
94 + /// RFC 6265 states that cookies in the cookie header should be separated by a
95 + /// `;`, however this function will also accept a `,` separator to remain
96 + /// compatible with the now-deprecated RFC 2965, and any older software
97 + /// following that specification.
98 + ///
98 99 pub fn parse(cookie_string: String) -> List(#(String, String)) {
99 100 cookie_string
100 101 |> string.split(";")
  @@ -104,8 +105,8 @@ pub fn parse(cookie_string: String) -> List(#(String, String)) {
104 105 Ok(#("", _)) -> Error(Nil)
105 106 Ok(#(key, value)) -> {
106 107 let key = string.trim(key)
107 - let value = string.trim(value)
108 108 use _ <- result.try(check_token(key))
109 + let value = string.trim(value)
109 110 use _ <- result.try(check_token(value))
110 111 Ok(#(key, value))
111 112 }
  @@ -115,13 +116,15 @@ pub fn parse(cookie_string: String) -> List(#(String, String)) {
115 116 }
116 117
117 118 fn check_token(token: String) -> Result(Nil, Nil) {
118 - case string.pop_grapheme(token) {
119 - Error(Nil) -> Ok(Nil)
120 - Ok(#(" ", _)) -> Error(Nil)
121 - Ok(#("\t", _)) -> Error(Nil)
122 - Ok(#("\r", _)) -> Error(Nil)
123 - Ok(#("\n", _)) -> Error(Nil)
124 - Ok(#("\f", _)) -> Error(Nil)
125 - Ok(#(_, rest)) -> check_token(rest)
119 + let contains_invalid_charachter =
120 + string.contains(token, " ")
121 + || string.contains(token, "\t")
122 + || string.contains(token, "\r")
123 + || string.contains(token, "\n")
124 + || string.contains(token, "\f")
125 +
126 + case contains_invalid_charachter {
127 + True -> Error(Nil)
128 + False -> Ok(Nil)
126 129 }
127 130 }
  @@ -27,7 +27,7 @@ pub type Request(body) {
27 27
28 28 /// Return the uri that a request was sent to.
29 29 ///
30 - pub fn to_uri(request: Request(a)) -> Uri {
30 + pub fn to_uri(request: Request(body)) -> Uri {
31 31 Uri(
32 32 scheme: option.Some(http.scheme_to_string(request.scheme)),
33 33 userinfo: option.None,
  @@ -56,8 +56,8 @@ pub fn from_uri(uri: Uri) -> Result(Request(String), Nil) {
56 56 method: Get,
57 57 headers: [],
58 58 body: "",
59 - scheme: scheme,
60 - host: host,
59 + scheme:,
60 + host:,
61 61 port: uri.port,
62 62 path: uri.path,
63 63 query: uri.query,
  @@ -89,7 +89,7 @@ pub fn set_header(
89 89 value: String,
90 90 ) -> Request(body) {
91 91 let headers = list.key_set(request.headers, string.lowercase(key), value)
92 - Request(..request, headers: headers)
92 + Request(..request, headers:)
93 93 }
94 94
95 95 /// Prepend the header with the given value under the given header key.
  @@ -106,33 +106,13 @@ pub fn prepend_header(
106 106 value: String,
107 107 ) -> Request(body) {
108 108 let headers = [#(string.lowercase(key), value), ..request.headers]
109 - Request(..request, headers: headers)
109 + Request(..request, headers:)
110 110 }
111 111
112 - // TODO: record update syntax, which can't be done currently as body type changes
113 112 /// Set the body of the request, overwriting any existing body.
114 113 ///
115 114 pub fn set_body(req: Request(old_body), body: new_body) -> Request(new_body) {
116 - let Request(
117 - method: method,
118 - headers: headers,
119 - scheme: scheme,
120 - host: host,
121 - port: port,
122 - path: path,
123 - query: query,
124 - ..,
125 - ) = req
126 - Request(
127 - method: method,
128 - headers: headers,
129 - body: body,
130 - scheme: scheme,
131 - host: host,
132 - port: port,
133 - path: path,
134 - query: query,
135 - )
115 + Request(..req, body:)
136 116 }
137 117
138 118 /// Update the body of a request using a given function.
  @@ -177,22 +157,21 @@ pub fn set_query(
177 157 req: Request(body),
178 158 query: List(#(String, String)),
179 159 ) -> Request(body) {
180 - let pair = fn(t: #(String, String)) {
181 - uri.percent_encode(t.0) <> "=" <> uri.percent_encode(t.1)
182 - }
183 160 let query =
184 - query
185 - |> list.map(pair)
186 - |> list.intersperse("&")
187 - |> string.concat
161 + list.map(query, fn(pair) {
162 + let #(key, value) = pair
163 + uri.percent_encode(key) <> "=" <> uri.percent_encode(value)
164 + })
165 + |> string.join(with: "&")
188 166 |> option.Some
189 - Request(..req, query: query)
167 +
168 + Request(..req, query:)
190 169 }
191 170
192 171 /// Set the method of the request.
193 172 ///
194 173 pub fn set_method(req: Request(body), method: Method) -> Request(body) {
195 - Request(..req, method: method)
174 + Request(..req, method:)
196 175 }
197 176
198 177 /// A request with commonly used default values. This request can be used as
  @@ -222,13 +201,13 @@ pub fn to(url: String) -> Result(Request(String), Nil) {
222 201 /// Set the scheme (protocol) of the request.
223 202 ///
224 203 pub fn set_scheme(req: Request(body), scheme: Scheme) -> Request(body) {
225 - Request(..req, scheme: scheme)
204 + Request(..req, scheme:)
226 205 }
227 206
228 207 /// Set the host of the request.
229 208 ///
230 209 pub fn set_host(req: Request(body), host: String) -> Request(body) {
231 - Request(..req, host: host)
210 + Request(..req, host:)
232 211 }
233 212
234 213 /// Set the port of the request.
  @@ -240,28 +219,29 @@ pub fn set_port(req: Request(body), port: Int) -> Request(body) {
240 219 /// Set the path of the request.
241 220 ///
242 221 pub fn set_path(req: Request(body), path: String) -> Request(body) {
243 - Request(..req, path: path)
222 + Request(..req, path:)
244 223 }
245 224
246 225 /// Set a cookie on a request, replacing any previous cookie with that name.
247 226 ///
248 - /// All cookies stored in a single header named `cookie`. There should be
249 - /// at most one header with the name `cookie`, otherwise this function cannot
250 - /// guarentee that previous cookies with the same name are replaced.
227 + /// All cookies should be stored in a single header named `cookie`.
228 + /// There should be at most one header with the name `cookie`, otherwise this
229 + /// function cannot guarentee that previous cookies with the same name are
230 + /// replaced.
251 231 ///
252 - pub fn set_cookie(req: Request(body), name: String, value: String) {
232 + pub fn set_cookie(
233 + req: Request(body),
234 + name: String,
235 + value: String,
236 + ) -> Request(body) {
253 237 // Get the cookies
254 238 let #(cookies, headers) =
255 239 list.key_pop(req.headers, "cookie") |> result.unwrap(#("", req.headers))
256 240
257 - // Parse them
258 - let cookies =
259 - string.split(cookies, ";")
260 - |> list.filter_map(fn(c) { string.trim_start(c) |> string.split_once("=") })
261 -
262 241 // Set the new cookie, replacing any previous one with the same name
263 242 let cookies =
264 - list.key_set(cookies, name, value)
243 + cookie.parse(cookies)
244 + |> list.key_set(name, value)
265 245 |> list.map(fn(pair) { pair.0 <> "=" <> pair.1 })
266 246 |> string.join("; ")
267 247
  @@ -272,38 +252,33 @@ pub fn set_cookie(req: Request(body), name: String, value: String) {
272 252 ///
273 253 /// Note badly formed cookie pairs will be ignored.
274 254 /// RFC6265 specifies that invalid cookie names/attributes should be ignored.
275 - pub fn get_cookies(req) -> List(#(String, String)) {
276 - let Request(headers: headers, ..) = req
255 + pub fn get_cookies(req: Request(body)) -> List(#(String, String)) {
256 + let Request(headers:, ..) = req
277 257
278 - headers
279 - |> list.filter_map(fn(header) {
280 - let #(name, value) = header
281 - case name {
282 - "cookie" -> Ok(cookie.parse(value))
283 - _ -> Error(Nil)
258 + list.flat_map(headers, fn(header) {
259 + case header {
260 + #("cookie", value) -> cookie.parse(value)
261 + _ -> []
284 262 }
285 263 })
286 - |> list.flatten()
287 264 }
288 265
289 266 /// Remove a cookie from a request
290 267 ///
291 - /// Remove a cookie from the request. If no cookie is found return the request unchanged.
292 - /// This will not remove the cookie from the client.
293 - pub fn remove_cookie(req: Request(body), name: String) {
268 + /// Remove a cookie from the request. If no cookie is found return the request
269 + /// unchanged. This will not remove the cookie from the client.
270 + pub fn remove_cookie(req: Request(body), name: String) -> Request(body) {
294 271 case list.key_pop(req.headers, "cookie") {
295 272 Ok(#(cookies_string, headers)) -> {
296 273 let new_cookies_string =
297 - string.split(cookies_string, ";")
298 - |> list.filter(fn(str) {
299 - string.trim(str)
300 - |> string.split_once("=")
301 - // Keep cookie if name does not match
302 - |> result.map(fn(tup) { tup.0 != name })
303 - // Don't do anything with malformed cookies
304 - |> result.unwrap(True)
274 + cookie.parse(cookies_string)
275 + |> list.filter_map(fn(cookie) {
276 + case cookie {
277 + #(cookie_name, _) if cookie_name == name -> Error(Nil)
278 + #(name, value) -> Ok(name <> "=" <> value)
279 + }
305 280 })
306 - |> string.join(";")
281 + |> string.join("; ")
307 282
308 283 Request(..req, headers: [#("cookie", new_cookies_string), ..headers])
309 284 }
Loading more files…