src/http.bend

emerging-ezhttp@0.13.0.0 · 1,161 lines · 0 definitions

1# ezhttp/src/http: HTTP/1.1 message text for client and server — request and
2# response formatting/parsing — with no sockets. Semantics follow RFC 9110;
3# message syntax and framing follow RFC 9112. TLS is outside this module.
4import Base
5import emerging-ezhttp@0.13.0.0/src/body.bend as Body
6
7# a string divided at the first occurrence of a pattern, or not divided at all
8type Cut is Data:
9  NoCut{}
10  Cut{before: String, after: String}
11
12# one header field name and value (RFC 9110 §5 / RFC 9112 §5)
13type Header is Data:
14  H{name: String, value: String}
15
16# what a response turned out to be: status, headers, framed body — or why not
17type Reply is Data:
18  Torn{why: String}
19  Reply{status: U32, headers: List<&2, Header>, body: String}
20
21# the first of a list of pieces, "" when there are none
22def first(ps: List<&2, String>) -> String:
23  match ps:
24    case []:
25      ""
26    case h <> t:
27      h
28
29# the second of a list of pieces, "" when there is no second
30def second(ps: List<&2, String>) -> String:
31  match ps:
32    case []:
33      ""
34    case h <> t:
35      first(t)
36
37# one char of the divide, put back on the front of what the rest found
38def divide.push(h: Char, r: Cut) -> Cut:
39  match r:
40    case NoCut{}:
41      NoCut{}
42    case Cut{before, after}:
43      Cut{SCon{h, before}, after}
44
45# one step of the walk; rest is a thunk so Bool.pick does not force recursion
46def divide.step(here: Bool, h: Char, t: String, n: Nat,
47  rest: Unit -> Cut) -> Cut:
48  match here:
49    case True{}:
50      Cut{"", String.drop(SCon{h, t}, n)}
51    case False{}:
52      divide.push(h, rest(Unit{}))
53
54# the walk: stop at the first hit
55def divide.go(s: String, +pat: String, +n: Nat) -> Cut:
56  match s:
57    case SNil{}:
58      NoCut{}
59    case SCon{+h, +t}:
60      divide.step(String.starts_with(SCon{h, t}, pat), h, t, n,
61        _u => divide.go(t, pat, n))
62
63# a string in two halves around the first occurrence of a pattern
64def divide(s: String, +pat: String) -> Cut:
65  divide.go(s, pat, String.length(pat))
66
67# the value of one hex digit's code point, or 16 when it is not one
68def hex.of(+u: U32) -> U32:
69  Bool.pick(U32, Bool.and(U32.is_ge(u, 48), U32.is_le(u, 57)), U32.sub(u, 48),
70    Bool.pick(U32, Bool.and(U32.is_ge(u, 97), U32.is_le(u, 102)), U32.sub(u, 87),
71      Bool.pick(U32, Bool.and(U32.is_ge(u, 65), U32.is_le(u, 70)), U32.sub(u, 55),
72        16)))
73
74# the value of one hex digit, or 16 when the char is not one
75def hex.val(c: Char) -> U32:
76  hex.of(Char.to_u32(c))
77
78# one step of the hex read
79def hex.step(done: Bool, acc: Nat, rest: Unit -> Nat) -> Nat:
80  match done:
81    case True{}:
82      acc
83    case False{}:
84      rest(Unit{})
85
86# the hex digits at the front of a string, read until one is not a digit
87def hex.go(s: String, +acc: Nat) -> Nat:
88  match s:
89    case SNil{}:
90      acc
91    case SCon{+h, t}:
92      +v = hex.val(h)
93      hex.step(U32.is_ge(v, 16), acc,
94        _u => hex.go(t, Nat.add(Nat.mul(acc, 16n), U32.to_nat(v))))
95
96# chunk-size from a chunk header (RFC 9112 §7.1); extensions after `;` ignored
97def hexlen(s: String) -> Nat:
98  hex.go(String.trim(first(String.split(s, ';'))), 0n)
99
100# whether a character is a hex digit
101def hex.digit(c: Char) -> Bool:
102  U32.is_lt(hex.val(c), 16)
103
104# the rest of a hex token once the first digit is known
105def hex.rest(token: String, good: Bool) -> Bool:
106  match token good:
107    case _ False{}:
108      False{}
109    case SNil{} True{}:
110      True{}
111    case SCon{h, t} True{}:
112      hex.rest(t, hex.digit(h))
113
114# a chunk-size token is one or more hex digits (RFC 9112 §7.1)
115def hex.well(token: String) -> Bool:
116  match token:
117    case SNil{}:
118      False{}
119    case SCon{h, t}:
120      hex.rest(t, hex.digit(h))
121
122# the chunk-size token, extensions after `;` removed
123def chunks.token(before: String) -> String:
124  String.trim(first(String.split(before, ';')))
125
126# bytes one code point takes in UTF-8. Content-Length counts octets
127# (RFC 9110 §8.6); a Bend String counts code points.
128def utf8.width(+u: U32) -> Nat:
129  Bool.pick(Nat, U32.is_lt(u, 128), 1n,
130    Bool.pick(Nat, U32.is_lt(u, 2048), 2n,
131      Bool.pick(Nat, U32.is_lt(u, 65536), 3n, 4n)))
132
133# how many bytes of UTF-8 a string is
134def utf8.len(s: String) -> Nat:
135  match s:
136    case SNil{}:
137      0n
138    case SCon{h, t}:
139      Nat.add(utf8.width(Char.to_u32(h)), utf8.len(t))
140
141# one step of the take
142def utf8.take.step(short: Bool, h: Char, rest: Unit -> String) -> String:
143  match short:
144    case True{}:
145      SNil{}
146    case False{}:
147      SCon{h, rest(Unit{})}
148
149# the front of a string that is this many bytes of UTF-8
150def utf8.take(s: String, +n: Nat) -> String:
151  match s:
152    case SNil{}:
153      SNil{}
154    case SCon{+h, t}:
155      +w = utf8.width(Char.to_u32(h))
156      utf8.take.step(Nat.is_lt(n, w), h, _u => utf8.take(t, Nat.sub(n, w)))
157
158# one step of the drop
159def utf8.drop.step(short: Bool, h: Char, t: String,
160  rest: Unit -> String) -> String:
161  match short:
162    case True{}:
163      SCon{h, t}
164    case False{}:
165      rest(Unit{})
166
167# the rest of a string after this many bytes of UTF-8
168def utf8.drop(s: String, +n: Nat) -> String:
169  match s:
170    case SNil{}:
171      SNil{}
172    case SCon{+h, +t}:
173      +w = utf8.width(Char.to_u32(h))
174      utf8.drop.step(Nat.is_lt(n, w), h, t, _u => utf8.drop(t, Nat.sub(n, w)))
175
176# a decimal digit's value, or 10 when the character is not a digit
177def digits.val.of(+u: U32) -> U32:
178  Bool.pick(U32, Bool.and(U32.is_ge(u, 48), U32.is_le(u, 57)), U32.sub(u, 48), 10)
179
180# a decimal digit's value, or 10 when the character is not a digit
181def digits.val(c: Char) -> U32:
182  digits.val.of(Char.to_u32(c))
183
184# accept one digit and keep reading, or stop when it is not a digit
185def digits.step(ok: Bool, next: Unit -> Maybe<&2, Nat>) -> Maybe<&2, Nat>:
186  match ok:
187    case False{}:
188      None{}
189    case True{}:
190      next(Unit{})
191
192# read a decimal token without building Base's Nat.read ceiling
193def digits.go(s: String, +acc: Nat, left: Nat) -> Maybe<&2, Nat>:
194  match s left:
195    case _ 0n:
196      None{}
197    case SNil{} 1n+k:
198      Some{acc}
199    case SCon{+h, t} 1n+k:
200      +d = digits.val(h)
201      digits.step(U32.is_lt(d, 10),
202        _u => digits.go(t, Nat.add(Nat.mul(acc, 10n), U32.to_nat(d)), k))
203
204# a short decimal (Content-Length, max-age). Empty is none. At most 9 digits.
205def digits.read(s: String) -> Maybe<&2, Nat>:
206  match s:
207    case SNil{}:
208      None{}
209    case SCon{h, t}:
210      digits.go(SCon{h, t}, 0n, 9n)
211
212# a non-zero chunk has its data and the following CRLF
213def chunks.ready(+after: String, +n: Nat) -> Bool:
214  Bool.and(Bool.not(Nat.is_lt(utf8.len(after), Nat.add(n, 2n))),
215    String.starts_with(utf8.drop(after, n), "\r\n"))
216
217# the next scan step, or why this chunk header is not usable
218type Next is Data:
219  Stop{}
220  Go{cut: Cut}
221  Reject{why: String}
222
223# one chunk header: reject non-hex sizes, stop on the 0 chunk, else continue
224def chunks.next.of(zero: Bool, ready: Bool, +after: String, +n: Nat) -> Next:
225  match zero ready:
226    case True{} _:
227      Stop{}
228    case False{} False{}:
229      Reject{"the chunked body stopped short"}
230    case False{} True{}:
231      Go{divide(utf8.drop(after, Nat.add(n, 2n)), "\r\n")}
232
233# hex check, then the length
234def chunks.next.hex(+before: String, +after: String, ok: Bool) -> Next:
235  match ok:
236    case False{}:
237      Reject{"chunk size is not hexadecimal"}
238    case True{}:
239      +n = hexlen(before)
240      chunks.next.of(Nat.is_eq(n, 0n), chunks.ready(after, n), after, n)
241
242# classify one chunk header (RFC 9112 §7.1)
243def chunks.next(+before: String, +after: String) -> Next:
244  chunks.next.hex(before, after, hex.well(chunks.token(before)))
245
246# apply one scan step
247def chunks.scan.step(step: Next, rest: Cut -> Maybe<&2, String>)
248  -> Maybe<&2, String>:
249  match step:
250    case Stop{}:
251      None{}
252    case Reject{why}:
253      Some{why}
254    case Go{cut}:
255      rest(cut)
256
257# None when every chunk size is hex and the 0 chunk is reached
258def chunks.scan(fuel: Nat, c: Cut) -> Maybe<&2, String>:
259  match fuel c:
260    case 0n _:
261      Some{"the chunked body is truncated"}
262    case 1n+f NoCut{}:
263      None{}
264    case 1n+f Cut{before, +after}:
265      chunks.scan.step(chunks.next(before, after), cut => chunks.scan(f, cut))
266
267# a zero chunk ends the body; anything after it is a trailer (RFC 9112 §7.1)
268def chunks.more(+n: Nat, +after: String, rest: Unit -> String, zero: Bool)
269  -> String:
270  match zero:
271    case True{}:
272      ""
273    case False{}:
274      utf8.take(after, n)
275        ++ rest(Unit{})
276
277# a chunked body put back together (RFC 9112 §7.1)
278def chunks(fuel: Nat, c: Cut) -> String:
279  match fuel c:
280    case 0n _:
281      ""
282    case 1n+f NoCut{}:
283      ""
284    case 1n+f Cut{before, +after}:
285      +n = hexlen(before)
286      chunks.more(n, after,
287        _u => chunks(f, divide(utf8.drop(after, Nat.add(n, 2n)), "\r\n")),
288        Nat.is_eq(n, 0n))
289
290# a Transfer-Encoding: chunked body as the octets it stands for
291def dechunk(+s: String) -> String:
292  chunks(String.length(s), divide(s, "\r\n"))
293
294# whether a head line names this header (field names are case-insensitive:
295# RFC 9110 §5.1)
296def head.hit(h: String, +name: String) -> Bool:
297  String.starts_with(String.to_lower(h), name ++ ":")
298
299# this line's value when it is the header asked for
300def head.pick(hit: Bool, h: String, name: String, rest: String) -> String:
301  match hit:
302    case True{}:
303      String.trim(String.drop(h, Nat.add(String.length(name), 1n)))
304    case False{}:
305      rest
306
307# the value a head gave a header, "" when it gave none
308def head.go(ls: List<&2, String>, +name: String) -> String:
309  match ls:
310    case []:
311      ""
312    case +h <> t:
313      head.pick(head.hit(h, name), h, name, head.go(t, name))
314
315# this line's trimmed field value
316def head.value(h: String, name: String) -> String:
317  String.trim(String.drop(h, Nat.add(String.length(name), 1n)))
318
319# cons a value when the line is the header asked for
320def head.values.cons(hit: Bool, value: String, rest: Unit -> List<&2, String>)
321  -> List<&2, String>:
322  match hit:
323    case False{}:
324      rest(Unit{})
325    case True{}:
326      value <> rest(Unit{})
327
328# every value a head gave a header, in order (RFC 9112 §6.3 lists Content-Length)
329def head.values(ls: List<&2, String>, +name: String) -> List<&2, String>:
330  match ls:
331    case []:
332      []
333    case +h <> t:
334      head.values.cons(head.hit(h, name), head.value(h, name),
335        _u => head.values(t, name))
336
337# a field value still holding a line break is obs-fold, which we refuse
338def field.folded(+value: String) -> Bool:
339  Bool.or(String.contains(value, "\n"), String.contains(value, "\r"))
340
341# a trimmed field value, or none when it still contains obs-fold
342def field.keep(name: String, +value: String, folded: Bool) -> Maybe<&2, Header>:
343  match folded:
344    case True{}:
345      None{}
346    case False{}:
347      Some{H{name, value}}
348
349# name and trimmed value from a cut at the first colon
350def head.from_kept(before: String, +after: String) -> Maybe<&2, Header>:
351  +value = String.trim(after)
352  field.keep(before, value, field.folded(value))
353
354# one header line as a Header, or none when it has no colon
355def head.from_cut(c: Cut) -> Maybe<&2, Header>:
356  match c:
357    case NoCut{}:
358      None{}
359    case Cut{before, after}:
360      head.from_kept(before, after)
361
362# one header line as a Header, or none when it has no colon
363def head.line(h: String) -> Maybe<&2, Header>:
364  head.from_cut(divide(h, ":"))
365
366# prepend a parsed header when present
367def head.cons(m: Maybe<&2, Header>, rest: List<&2, Header>) -> List<&2, Header>:
368  match m:
369    case None{}:
370      rest
371    case Some{hdr}:
372      hdr <> rest
373
374# header lines after the status line as a list of fields
375def head.list(ls: List<&2, String>) -> List<&2, Header>:
376  match ls:
377    case []:
378      []
379    case h <> t:
380      head.cons(head.line(h), head.list(t))
381
382# one request header line
383def header.line(h: Header) -> String:
384  match h:
385    case H{name, value}:
386      name ++ ": " ++ value ++ "\r\n"
387
388# the value when this field is the one asked for
389def headers.find.pick(value: String, hit: Bool, rest: Unit -> String) -> String:
390  match hit:
391    case True{}:
392      value
393    case False{}:
394      rest(Unit{})
395
396# case-insensitive field lookup (RFC 9110 §5.1)
397def headers.find.go(hs: List<&2, Header>, +name: String) -> String:
398  match hs:
399    case []:
400      ""
401    case H{n, v} <> t:
402      headers.find.pick(v, String.eq(String.to_lower(n), name),
403        _u => headers.find.go(t, name))
404
405# the value of a field, "" when it is absent
406def headers.find(hs: List<&2, Header>, +name: String) -> String:
407  headers.find.go(hs, String.to_lower(name))
408
409# extra headers rendered in order
410def headers.lines(hs: List<&2, Header>) -> String:
411  match hs:
412    case []:
413      ""
414    case h <> t:
415      header.line(h) ++ headers.lines(t)
416
417# the Content-Length field when the body is not empty (RFC 9110 §8.6)
418def clen.field(+n: Nat, empty: Bool) -> List<&2, Header>:
419  match empty:
420    case True{}:
421      []
422    case False{}:
423      [H{"Content-Length", Nat.show(n)}]
424
425# Content-Length line when the body is not empty (RFC 9110 §8.6)
426def clen.line(+n: Nat, empty: Bool) -> String:
427  headers.lines(clen.field(n, empty))
428
429# the fields a request names: the ones ezhttp writes a default for, the ones
430# it always computes, and every other (SPEC.md HTTP-CLI-8)
431type Field is Data:
432  FHost{}
433  FAgent{}
434  FAccept{}
435  FConn{}
436  FClen{}
437  FOther{}
438
439# a field's place in the order above
440def field.rank(f: Field) -> U32:
441  match f:
442    case FHost{}:
443      0
444    case FAgent{}:
445      1
446    case FAccept{}:
447      2
448    case FConn{}:
449      3
450    case FClen{}:
451      4
452    case FOther{}:
453      5
454
455# whether two fields are the same one
456def field.same(a: Field, b: Field) -> Bool:
457  U32.is_eq(field.rank(a), field.rank(b))
458
459# a lower-case name that is not content-length
460def field.of.clen(hit: Bool) -> Field:
461  match hit:
462    case True{}:
463      FClen{}
464    case False{}:
465      FOther{}
466
467# a lower-case name that is not connection
468def field.of.conn(hit: Bool, +l: String) -> Field:
469  match hit:
470    case True{}:
471      FConn{}
472    case False{}:
473      field.of.clen(String.eq(l, "content-length"))
474
475# a lower-case name that is not accept
476def field.of.accept(hit: Bool, +l: String) -> Field:
477  match hit:
478    case True{}:
479      FAccept{}
480    case False{}:
481      field.of.conn(String.eq(l, "connection"), l)
482
483# a lower-case name that is not user-agent
484def field.of.agent(hit: Bool, +l: String) -> Field:
485  match hit:
486    case True{}:
487      FAgent{}
488    case False{}:
489      field.of.accept(String.eq(l, "accept"), l)
490
491# a lower-case name that is not host
492def field.of.host(hit: Bool, +l: String) -> Field:
493  match hit:
494    case True{}:
495      FHost{}
496    case False{}:
497      field.of.agent(String.eq(l, "user-agent"), l)
498
499# the field a lower-case name names
500def field.of(+l: String) -> Field:
501  field.of.host(String.eq(l, "host"), l)
502
503# the field a name names, compared case-insensitively (RFC 9110 §5.1)
504def field.name(name: String) -> Field:
505  field.of(String.to_lower(name))
506
507# the field a header line names
508def header.field(h: Header) -> Field:
509  match h:
510    case H{name, value}:
511      field.name(name)
512
513# keep a header in front of the rest, or not
514def fields.put(keep: Bool, h: Header, rest: List<&2, Header>)
515  -> List<&2, Header>:
516  match keep:
517    case True{}:
518      h <> rest
519    case False{}:
520      rest
521
522# the headers that name one field, in order
523def fields.of(+f: Field, hs: List<&2, Header>) -> List<&2, Header>:
524  match hs:
525    case []:
526      []
527    case +h <> t:
528      fields.put(field.same(f, header.field(h)), h, fields.of(f, t))
529
530# the caller's fields of a name, or the default when it gave none
531def fields.or(given: List<&2, Header>, dflt: List<&2, Header>)
532  -> List<&2, Header>:
533  match given:
534    case []:
535      dflt
536    case h <> t:
537      h <> t
538
539# a request's header fields: Host, User-Agent and Accept (the caller's, or
540# the default), Connection: close, the caller's other fields, Content-Length.
541# A caller's Connection or Content-Length is not sent: Connection: close lets
542# the connection end frame the body when no Content-Length / chunked framing
543# applies (RFC 9112 §9.6 / §6.3), and the body's length is computed.
544def request.fields(host: String, +hs: List<&2, Header>, +body: String)
545  -> List<&2, Header>:
546  +n = utf8.len(body)
547  List.append(&2, Header, fields.or(fields.of(FHost{}, hs), [H{"Host", host}]),
548    List.append(&2, Header,
549      fields.or(fields.of(FAgent{}, hs), [H{"User-Agent", "ezhttp"}]),
550      List.append(&2, Header,
551        fields.or(fields.of(FAccept{}, hs), [H{"Accept", "*/*"}]),
552        H{"Connection", "close"}
553          <> List.append(&2, Header, fields.of(FOther{}, hs),
554            clen.field(n, Nat.is_eq(n, 0n))))))
555
556# a request-line and headers (RFC 9112 §3, §5). Absolute-path request-target
557# (RFC 9112 §3.2.1); the fields are request.fields'.
558def request(method: String, host: String, path: String,
559  headers: List<&2, Header>, +body: String) -> String:
560  method ++ " " ++ path ++ " HTTP/1.1\r\n"
561    ++ headers.lines(request.fields(host, headers, body))
562    ++ "\r\n"
563    ++ body
564
565# the port a scheme implies: 443 for a secure one, 80 otherwise (RFC 9110
566# §4.2.1, §4.2.2)
567def port.dflt(secure: Bool) -> U32:
568  match secure:
569    case True{}:
570      443
571    case False{}:
572      80
573
574# the Host field value once it is known whether the port is the default
575def authority.of(host: String, +port: U32, dflt: Bool) -> String:
576  match dflt:
577    case True{}:
578      host
579    case False{}:
580      host ++ ":" ++ U32.show(port)
581
582# the Host field value: the host, and the port after a colon unless it is the
583# scheme's default (RFC 9110 §7.2)
584def authority(secure: Bool, host: String, +port: U32) -> String:
585  authority.of(host, port, U32.is_eq(port, port.dflt(secure)))
586
587# the request a client sends to host and port, over TLS when secure
588def request.at(method: String, secure: Bool, host: String, port: U32,
589  path: String, headers: List<&2, Header>, body: String) -> String:
590  request(method, authority(secure, host, port), path, headers, body)
591
592# GET with optional headers
593def get_req(host: String, path: String, headers: List<&2, Header>) -> String:
594  request("GET", host, path, headers, "")
595
596# HEAD with optional headers (RFC 9110 §9.3.2): same as GET, no body
597def head_req(host: String, path: String, headers: List<&2, Header>) -> String:
598  request("HEAD", host, path, headers, "")
599
600# POST with body and optional headers
601def post_req(host: String, path: String, headers: List<&2, Header>,
602  body: Body.Body) -> String:
603  request("POST", host, path, headers, Body.body.payload(body))
604
605# PUT with body and optional headers (RFC 9110 §9.3.4)
606def put_req(host: String, path: String, headers: List<&2, Header>,
607  body: Body.Body) -> String:
608  request("PUT", host, path, headers, Body.body.payload(body))
609
610# DELETE with body and optional headers (RFC 9110 §9.3.5)
611def delete_req(host: String, path: String, headers: List<&2, Header>,
612  body: Body.Body) -> String:
613  request("DELETE", host, path, headers, Body.body.payload(body))
614
615# OPTIONS with optional headers (RFC 9110 §9.3.7). No request body.
616def options_req(host: String, path: String, headers: List<&2, Header>) -> String:
617  request("OPTIONS", host, path, headers, "")
618
619# GET, HEAD, and OPTIONS are safe (RFC 9110 §9.2.1). Tokens are case-sensitive.
620def method.safe(+method: String) -> Bool:
621  Bool.or(String.eq(method, "GET"),
622    Bool.or(String.eq(method, "HEAD"), String.eq(method, "OPTIONS")))
623
624# safe methods, plus PUT and DELETE, are idempotent (RFC 9110 §9.2.2)
625def method.idempotent(+method: String) -> Bool:
626  Bool.or(method.safe(method),
627    Bool.or(String.eq(method, "PUT"), String.eq(method, "DELETE")))
628
629# the rest of a token once this character is known
630def token.ok.step(space: Bool, rest: Unit -> Bool) -> Bool:
631  match space:
632    case True{}:
633      False{}
634    case False{}:
635      rest(Unit{})
636
637# a bearer token has no whitespace (RFC 6750 §2.1)
638def token.ok(s: String) -> Bool:
639  match s:
640    case SNil{}:
641      True{}
642    case SCon{h, t}:
643      token.ok.step(Char.is_space(h), _u => token.ok(t))
644
645# origin-form request-target: absolute-path, optional query (RFC 9112 §3.2.1)
646def target.origin(path: String) -> Bool:
647  String.starts_with(path, "/")
648
649# asterisk-form is only `*` and only for OPTIONS (RFC 9112 §3.2.4)
650def target.asterisk(method: String, star: Bool) -> Bool:
651  match star:
652    case False{}:
653      False{}
654    case True{}:
655      String.eq(method, "OPTIONS")
656
657# origin-form, or OPTIONS asterisk-form
658def target.form(method: String, path: String, absolute: Bool) -> Bool:
659  match absolute:
660    case True{}:
661      True{}
662    case False{}:
663      target.asterisk(method, String.eq(path, "*"))
664
665# a body against the length its head promised
666def parse.short(status: U32, headers: List<&2, Header>, n: Nat, body: String,
667  short: Bool) -> Reply:
668  match short:
669    case True{}:
670      Torn{"the response body stopped before its Content-Length"}
671    case False{}:
672      Reply{status, headers, utf8.take(body, n)}
673
674# a body framed by Content-Length (RFC 9110 §8.6 / RFC 9112 §6.3)
675def parse.clen(status: U32, headers: List<&2, Header>, +n: Nat, +body: String)
676  -> Reply:
677  parse.short(status, headers, n, body, Nat.is_lt(utf8.len(body), n))
678
679# Content-Length when present; else the rest of the connection is the body
680def parse.len(status: U32, headers: List<&2, Header>, m: Maybe<&2, Nat>,
681  body: String) -> Reply:
682  match m:
683    case None{}:
684      Reply{status, headers, body}
685    case Some{n}:
686      parse.clen(status, headers, n, body)
687
688# every Content-Length text equals the first
689def clen.agree.step(same: Bool, rest: Unit -> Bool) -> Bool:
690  match same:
691    case False{}:
692      False{}
693    case True{}:
694      rest(Unit{})
695
696# the tail of a Content-Length list against the first value
697def clen.agree.go(rest: List<&2, String>, +h: String) -> Bool:
698  match rest:
699    case []:
700      True{}
701    case x <> r:
702      clen.agree.step(String.eq(x, h), _u => clen.agree.go(r, h))
703
704# duplicate Content-Length fields must be the same digits (RFC 9112 §6.3)
705def clen.agree(vs: List<&2, String>) -> Bool:
706  match vs:
707    case []:
708      True{}
709    case h <> t:
710      clen.agree.go(t, h)
711
712# whether any Content-Length field was present
713def clen.any(vs: List<&2, String>) -> Bool:
714  match vs:
715    case []:
716      False{}
717    case h <> t:
718      True{}
719
720# a chunked body, or why the chunk framing was rejected
721def parse.chunked(status: U32, headers: List<&2, Header>, body: String,
722  bad: Maybe<&2, String>) -> Reply:
723  match bad:
724    case None{}:
725      Reply{status, headers, dechunk(body)}
726    case Some{why}:
727      Torn{why}
728
729# scan chunk sizes, then decode when they are hex and closed by 0
730def parse.chunks(status: U32, headers: List<&2, Header>, +body: String) -> Reply:
731  parse.chunked(status, headers, body,
732    chunks.scan(String.length(body), divide(body, "\r\n")))
733
734# Transfer-Encoding together with Content-Length is a bad message
735def parse.te.clash(status: U32, headers: List<&2, Header>, body: String,
736  present: Bool) -> Reply:
737  match present:
738    case True{}:
739      Torn{"Content-Length and Transfer-Encoding conflict"}
740    case False{}:
741      parse.chunks(status, headers, body)
742
743# chunked framing, or Content-Length, or the rest of the connection
744def parse.te(status: U32, headers: List<&2, Header>, +lens: List<&2, String>,
745  +body: String, chunked: Bool) -> Reply:
746  match chunked:
747    case False{}:
748      parse.len(status, headers, digits.read(first(lens)), body)
749    case True{}:
750      parse.te.clash(status, headers, body, clen.any(lens))
751
752# disagreeing Content-Length values are torn before framing
753def parse.agree(status: U32, headers: List<&2, Header>, te: String,
754  +lens: List<&2, String>, body: String, ok: Bool) -> Reply:
755  match ok:
756    case False{}:
757      Torn{"Content-Length values disagree"}
758    case True{}:
759      parse.te(status, headers, lens, body,
760        String.contains(String.to_lower(te), "chunked"))
761
762# the headers that decide the framing (RFC 9112 §6.3)
763def parse.frame(status: U32, headers: List<&2, Header>, te: String,
764  +lens: List<&2, String>, body: String) -> Reply:
765  parse.agree(status, headers, te, lens, body, clen.agree(lens))
766
767# a head whose status line has been read (RFC 9112 §4)
768def parse.status(m: Maybe<&2, U32>, +ls: List<&2, String>, body: String)
769  -> Reply:
770  match m:
771    case None{}:
772      Torn{"the response has no status line"}
773    case Some{n}:
774      +hs = head.list(ls)
775      parse.frame(n, hs, head.go(ls, "transfer-encoding"),
776        head.values(ls, "content-length"), body)
777
778# the status code a head's first line names, if it names one (RFC 9112 §4)
779def status.of(ls: List<&2, String>) -> Maybe<&2, U32>:
780  U32.read(second(String.split(first(ls), ' ')))
781
782# the head's lines, with the status line read off the first
783def parse.head(+ls: List<&2, String>, body: String) -> Reply:
784  parse.status(status.of(ls), ls, body)
785
786# a response divided into its head and everything after the blank line
787# (RFC 9112 §2.1: header section ends at empty line)
788def parse.cut(c: Cut) -> Reply:
789  match c:
790    case NoCut{}:
791      Torn{"the response head has no blank line after it"}
792    case Cut{before, after}:
793      parse.head(String.lines(before), after)
794
795# the status, headers, and body of a raw response
796def parse(raw: String) -> Reply:
797  parse.cut(divide(raw, "\r\n\r\n"))
798
799# a parsed response on one line, for a test to read
800def show(r: Reply) -> String:
801  match r:
802    case Torn{why}:
803      "torn " ++ why
804    case Reply{status, headers, body}:
805      U32.show(status) ++ " " ++ body
806
807# --- inbound request (server) and outbound response (server) ---
808
809# a request that came apart, or the reason it would not (RFC 9112 §3)
810type Request is Data:
811  Bad{why: String}
812  Request{method: String, target: String, headers: List<&2, Header>,
813    body: String}
814
815# a status code and its reason-phrase
816type Phrase is Data:
817  P{code: U32, text: String}
818
819# the reason-phrases RFC 9110 §15 names (RFC 6585 §4 for 429)
820def reasons() -> List<&2, Phrase>:
821  [P{200, "OK"}, P{201, "Created"}, P{202, "Accepted"}, P{204, "No Content"},
822    P{301, "Moved Permanently"}, P{302, "Found"}, P{303, "See Other"},
823    P{304, "Not Modified"}, P{307, "Temporary Redirect"},
824    P{308, "Permanent Redirect"}, P{400, "Bad Request"},
825    P{401, "Unauthorized"}, P{403, "Forbidden"}, P{404, "Not Found"},
826    P{405, "Method Not Allowed"}, P{409, "Conflict"}, P{410, "Gone"},
827    P{413, "Content Too Large"}, P{415, "Unsupported Media Type"},
828    P{422, "Unprocessable Content"}, P{429, "Too Many Requests"},
829    P{500, "Internal Server Error"}, P{501, "Not Implemented"},
830    P{502, "Bad Gateway"}, P{503, "Service Unavailable"},
831    P{504, "Gateway Timeout"}]
832
833# this phrase when its code is the one asked for, else the rest's
834def reason.pick(text: String, hit: Bool, rest: Unit -> String) -> String:
835  match hit:
836    case True{}:
837      text
838    case False{}:
839      rest(Unit{})
840
841# the phrase a list gives a code, "" when it gives none
842def reason.in(ps: List<&2, Phrase>, +code: U32) -> String:
843  match ps:
844    case []:
845      ""
846    case P{c, text} <> t:
847      reason.pick(text, U32.is_eq(code, c), _u => reason.in(t, code))
848
849# the reason-phrase for a status code (RFC 9112 §4); "" for one not listed
850def reason(+code: U32) -> String:
851  reason.in(reasons(), code)
852
853# status-line + headers + body (RFC 9112 §4, §5). Connection: close ends the
854# exchange after one response.
855def respond(+status: U32, headers: List<&2, Header>, +body: String) -> String:
856  +n = utf8.len(body)
857  "HTTP/1.1 " ++ U32.show(status) ++ " " ++ reason(status) ++ "\r\n"
858    ++ "Connection: close\r\n"
859    ++ headers.lines(headers)
860    ++ clen.line(n, Nat.is_eq(n, 0n))
861    ++ "\r\n"
862    ++ body
863
864# 204 and 304 are terminated by the header section (RFC 9110 §15.3.5, §15.4.5)
865def status.nocontent(+code: U32) -> Bool:
866  Bool.or(U32.is_eq(code, 204), U32.is_eq(code, 304))
867
868# drop a 204/304 body; any other status keeps it
869def respond.of.body(+status: U32, headers: List<&2, Header>, body: String,
870  drop: Bool) -> String:
871  match drop:
872    case True{}:
873      respond(status, headers, "")
874    case False{}:
875      respond(status, headers, body)
876
877# format a structured Reply as a response message
878def respond.of(r: Reply) -> String:
879  match r:
880    case Torn{why}:
881      respond(500, [], why)
882    case Reply{+status, headers, body}:
883      respond.of.body(status, headers, body, status.nocontent(status))
884
885# HEAD carries no message body; any other method keeps the body (RFC 9110 §9.3.2)
886def reply.for.pick(reply: Reply, head: Bool) -> Reply:
887  match reply head:
888    case Torn{why} _:
889      Torn{why}
890    case Reply{status, headers, body} False{}:
891      Reply{status, headers, body}
892    case Reply{status, headers, body} True{}:
893      Reply{status, headers, ""}
894
895# drop a HEAD response body before it is written
896def reply.for(method: String, reply: Reply) -> Reply:
897  reply.for.pick(reply, String.eq(method, "HEAD"))
898
899# the method token, or "" when the request did not parse
900def method.of(req: Request) -> String:
901  match req:
902    case Bad{why}:
903      ""
904    case Request{method, target, headers, body}:
905      method
906
907# request body against Content-Length
908def ask.short(method: String, target: String, headers: List<&2, Header>,
909  n: Nat, body: String, short: Bool) -> Request:
910  match short:
911    case True{}:
912      Bad{"the request body stopped before its Content-Length"}
913    case False{}:
914      Request{method, target, headers, utf8.take(body, n)}
915
916# a request body framed by Content-Length (RFC 9110 §8.6 / RFC 9112 §6.3)
917def ask.clen(method: String, target: String, headers: List<&2, Header>,
918  +n: Nat, +body: String) -> Request:
919  ask.short(method, target, headers, n, body, Nat.is_lt(utf8.len(body), n))
920
921# Content-Length when present; else the rest of the connection is the body
922def ask.len(method: String, target: String, headers: List<&2, Header>,
923  m: Maybe<&2, Nat>, body: String) -> Request:
924  match m:
925    case None{}:
926      Request{method, target, headers, body}
927    case Some{n}:
928      ask.clen(method, target, headers, n, body)
929
930# a chunked request body, or why the chunk framing was rejected
931def ask.chunked(method: String, target: String, headers: List<&2, Header>,
932  body: String, bad: Maybe<&2, String>) -> Request:
933  match bad:
934    case None{}:
935      Request{method, target, headers, dechunk(body)}
936    case Some{why}:
937      Bad{why}
938
939# scan chunk sizes, then decode when they are hex and closed by 0
940def ask.chunks(method: String, target: String, headers: List<&2, Header>,
941  +body: String) -> Request:
942  ask.chunked(method, target, headers, body,
943    chunks.scan(String.length(body), divide(body, "\r\n")))
944
945# Transfer-Encoding together with Content-Length is a bad request
946def ask.te.clash(method: String, target: String, headers: List<&2, Header>,
947  body: String, present: Bool) -> Request:
948  match present:
949    case True{}:
950      Bad{"Content-Length and Transfer-Encoding conflict"}
951    case False{}:
952      ask.chunks(method, target, headers, body)
953
954# chunked framing, or Content-Length, or the rest of the connection
955def ask.te(method: String, target: String, headers: List<&2, Header>,
956  +lens: List<&2, String>, +body: String, chunked: Bool) -> Request:
957  match chunked:
958    case False{}:
959      ask.len(method, target, headers, digits.read(first(lens)), body)
960    case True{}:
961      ask.te.clash(method, target, headers, body, clen.any(lens))
962
963# disagreeing Content-Length values are bad before framing
964def ask.agree(method: String, target: String, headers: List<&2, Header>,
965  te: String, +lens: List<&2, String>, body: String, ok: Bool) -> Request:
966  match ok:
967    case False{}:
968      Bad{"Content-Length values disagree"}
969    case True{}:
970      ask.te(method, target, headers, lens, body,
971        String.contains(String.to_lower(te), "chunked"))
972
973# the headers that decide request framing (RFC 9112 §6.3)
974def ask.frame(method: String, target: String, headers: List<&2, Header>,
975  te: String, +lens: List<&2, String>, body: String) -> Request:
976  ask.agree(method, target, headers, te, lens, body, clen.agree(lens))
977
978# origin-form, or a bad request when the target is not an absolute-path
979def ask.origin(method: String, target: String, +ls: List<&2, String>,
980  body: String, ok: Bool) -> Request:
981  match ok:
982    case False{}:
983      Bad{"the request-target is not in origin-form"}
984    case True{}:
985      +hs = head.list(ls)
986      ask.frame(method, target, hs, head.go(ls, "transfer-encoding"),
987        head.values(ls, "content-length"), body)
988
989# request-line: method SP request-target SP HTTP-version (RFC 9112 §3)
990def ask.line(+method: String, +target: String, +ls: List<&2, String>,
991  body: String) -> Request:
992  ask.origin(method, target, ls, body,
993    target.form(method, target, target.origin(target)))
994
995# three fields of the request-line, or torn
996def ask.parts(ps: List<&2, String>, +ls: List<&2, String>, body: String)
997  -> Request:
998  match ps:
999    case []:
1000      Bad{"the request has no request line"}
1001    case m <> rest:
1002      match rest:
1003        case []:
1004          Bad{"the request line has no target"}
1005        case t <> _ver:
1006          ask.line(m, t, ls, body)
1007
1008# the head's lines, with the request-line read off the first
1009def ask.head(+ls: List<&2, String>, body: String) -> Request:
1010  match ls:
1011    case []:
1012      Bad{"the request has no request line"}
1013    case _:
1014      ask.parts(String.split(first(ls), ' '), ls, body)
1015
1016# a request divided into its head and everything after the blank line
1017def ask.cut(c: Cut) -> Request:
1018  match c:
1019    case NoCut{}:
1020      Bad{"the request head has no blank line after it"}
1021    case Cut{before, after}:
1022      ask.head(String.lines(before), after)
1023
1024# the method, target, headers, and body of a raw request
1025def ask(raw: String) -> Request:
1026  ask.cut(divide(raw, "\r\n\r\n"))
1027
1028# a parsed request on one line, for a test to read
1029def ask.show(r: Request) -> String:
1030  match r:
1031    case Bad{why}:
1032      "bad " ++ why
1033    case Request{method, target, headers, body}:
1034      method ++ " " ++ target ++ " " ++ body
1035
1036# --- when a response has all arrived (RFC 9112 §6.3) ---
1037
1038# how a response's body is framed
1039type Frame is Data:
1040  Bare{}
1041  Sized{n: Nat}
1042  Chunked{}
1043  Closed{}
1044
1045# 204 and 304 carry no body; a status line that did not parse is framed by
1046# its headers
1047def status.bare(m: Maybe<&2, U32>) -> Bool:
1048  match m:
1049    case None{}:
1050      False{}
1051    case Some{n}:
1052      status.nocontent(n)
1053
1054# a Content-Length that reads as a number, or the close when none does
1055def frame.sized(m: Maybe<&2, Nat>) -> Frame:
1056  match m:
1057    case None{}:
1058      Closed{}
1059    case Some{n}:
1060      Sized{n}
1061
1062# Content-Length values that agree frame the body; any others leave the close
1063def frame.agree(+lens: List<&2, String>, ok: Bool) -> Frame:
1064  match ok:
1065    case False{}:
1066      Closed{}
1067    case True{}:
1068      frame.sized(digits.read(first(lens)))
1069
1070# chunked framing wins over Content-Length (RFC 9112 §6.3 item 4)
1071def frame.te(+lens: List<&2, String>, chunked: Bool) -> Frame:
1072  match chunked:
1073    case True{}:
1074      Chunked{}
1075    case False{}:
1076      frame.agree(lens, clen.agree(lens))
1077
1078# the framing of a response: none when bare (a reply to HEAD, a 204 or a 304),
1079# else Transfer-Encoding chunked, else Content-Length, else the close
1080def frame(bare: Bool, te: String, +lens: List<&2, String>) -> Frame:
1081  match bare:
1082    case True{}:
1083      Bare{}
1084    case False{}:
1085      frame.te(lens, String.contains(String.to_lower(te), "chunked"))
1086
1087# a zero chunk's trailer section has ended: at once, or after its fields
1088def chunks.trailer(+after: String) -> Bool:
1089  Bool.or(String.starts_with(after, "\r\n"), String.contains(after, "\r\n\r\n"))
1090
1091# a chunk that has all arrived: on to the next chunk header, else not yet
1092def chunks.ended.data(+after: String, +n: Nat, ready: Bool,
1093  rest: Cut -> Bool) -> Bool:
1094  match ready:
1095    case False{}:
1096      False{}
1097    case True{}:
1098      rest(divide(utf8.drop(after, Nat.add(n, 2n)), "\r\n"))
1099
1100# a chunk of size n: the last one when n is 0
1101def chunks.ended.size(+after: String, +n: Nat, zero: Bool,
1102  rest: Cut -> Bool) -> Bool:
1103  match zero:
1104    case True{}:
1105      chunks.trailer(after)
1106    case False{}:
1107      chunks.ended.data(after, n, chunks.ready(after, n), rest)
1108
1109# a chunk header: a size that is not hex ends the read (the parse rejects it)
1110def chunks.ended.hex(+before: String, +after: String, ok: Bool,
1111  rest: Cut -> Bool) -> Bool:
1112  match ok:
1113    case False{}:
1114      True{}
1115    case True{}:
1116      +n = hexlen(before)
1117      chunks.ended.size(after, n, Nat.is_eq(n, 0n), rest)
1118
1119# a chunk header that has arrived, or not yet
1120def chunks.ended.at(c: Cut, rest: Cut -> Bool) -> Bool:
1121  match c:
1122    case NoCut{}:
1123      False{}
1124    case Cut{+before, +after}:
1125      chunks.ended.hex(before, after, hex.well(chunks.token(before)), rest)
1126
1127# whether a chunked body has reached the end of its last chunk
1128def chunks.ended(fuel: Nat, c: Cut) -> Bool:
1129  match fuel:
1130    case 0n:
1131      False{}
1132    case 1n+f:
1133      chunks.ended.at(c, cut => chunks.ended(f, cut))
1134
1135# whether a body has all arrived under its framing
1136def whole.of(f: Frame, +body: String) -> Bool:
1137  match f:
1138    case Bare{}:
1139      True{}
1140    case Sized{n}:
1141      Bool.not(Nat.is_lt(utf8.len(body), n))
1142    case Chunked{}:
1143      chunks.ended(String.length(body), divide(body, "\r\n"))
1144    case Closed{}:
1145      False{}
1146
1147# a response whose head has ended, or not yet
1148def whole.cut(asked: Bool, c: Cut) -> Bool:
1149  match c:
1150    case NoCut{}:
1151      False{}
1152    case Cut{before, after}:
1153      +ls = String.lines(before)
1154      whole.of(frame(Bool.or(asked, status.bare(status.of(ls))),
1155        head.go(ls, "transfer-encoding"), head.values(ls, "content-length")),
1156        after)
1157
1158# whether the text received so far is a whole response to a request with
1159# this method (RFC 9112 §6.3); a response framed by the close never is
1160def whole(+method: String, raw: String) -> Bool:
1161  whole.cut(String.eq(method, "HEAD"), divide(raw, "\r\n\r\n"))