src/protocol.bend

bendonnect-rpc@0.1.0.0 · 583 lines · 93 definitions

1# NOTE: `Kind` is a reserved word in Bend, so the contract's `Kind` type is named `CallKind`.
2# Connect request classification (pure): turns an HTTP request line + headers into a Spec
3# or a Reject. https://connectrpc.com/docs/protocol/
4#
5# Choices (where the spec leaves room):
6# - `query` is the RAW query string without the leading '?' (e.g. "message=%7B%7D&encoding=json").
7#   Keys and values are matched/decoded here; unknown params are ignored.
8# - `prefix` is "" or a path such as "/api" (no trailing slash). Path = prefix "/" Service "/" Method.
9# - Check order: method (405) -> path (404) -> content-type / GET params (415 / 400)
10#   -> connect-protocol-version (400) -> timeout (InvalidArgument) -> encoding (Unimplemented).
11# - GET with missing/empty `encoding` or `message` is Status 400 (not 415).
12# - Unsupported codecs are NOT rejected here: unknown codecs become Custom{name} (415 is the
13#   caller's call once it knows its codec registry). An empty codec name is Status 415.
14# - GET also validates the connect-protocol-version header, if sent, like POST.
15# - `encoding`: lowercased/trimmed; empty or absent is "identity".
16# - `accept`: header present -> its comma list (trimmed, lowercased, ";q=" params and empties dropped);
17#   absent -> [encoding] (spec: client accepts the encoding it used; identity is implied last).
18# - Methods are matched case-sensitively ("POST", "GET"); anything else is Status 405.
19import Base
20import bendonnect-rpc@0.1.0.0/src/code.bend as Code
21import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
22import bend-kit-url@0.4.1.0/url.bend as Url
23
24type Codec is Data:
25  Json{}
26  Proto{}
27  Custom{name: String}
28
29type CallKind is Data:
30  Unary{}
31  UnaryGet{}
32  Stream{}
33
34type Spec is Type:
35  Spec{procedure: String, kind: CallKind, codec: Codec, timeout_ms: Maybe<&2, U32>,
36    encoding: String, accept: List<&2, String>, message: Bytes.Bytes}
37
38type Reject is Data:
39  Status{status: U32, message: String}
40  Connect{code: Code.Code, message: String}
41
42# A parsed Content-Type: stream = application/connect+<codec>.
43type Ct is Data:
44  Ct{stream: Bool, codec: Codec}
45
46def supported_encodings() -> List<&2, String>:
47  ["identity"]
48
49# ---------------------------------------------------------------- content types
50
51def codec_name(c: Codec) -> String:
52  match c:
53    case Json{}:
54      "json"
55    case Proto{}:
56      "proto"
57    case Custom{n}:
58      n
59
60def content_type(+stream: Bool, c: Codec) -> String:
61  match stream:
62    case True{}:
63      "application/connect+" ++ codec_name(c)
64    case False{}:
65      "application/" ++ codec_name(c)
66
67def unary_content_type(c: Codec) -> String:
68  content_type(False{}, c)
69
70def stream_content_type(c: Codec) -> String:
71  content_type(True{}, c)
72
73# json / proto / other (non-empty) -> Custom
74def codec_of(+name: String) -> Maybe<&2, Codec>:
75  Bool.pick(Maybe<&2, Codec>, String.eq(name, "json"), Some{Json{}},
76    Bool.pick(Maybe<&2, Codec>, String.eq(name, "proto"), Some{Proto{}},
77      Bool.pick(Maybe<&2, Codec>, String.is_empty(name), None{}, Some{Custom{name}})))
78
79def media.of(r: String & String) -> String:
80  (a, b) = r
81  a
82
83# text before the first ';'
84def media(s: String) -> String:
85  media.of(Url.take_to(s, 59))
86
87def ct_make(stream: Bool, mc: Maybe<&2, Codec>) -> Maybe<&2, Ct>:
88  match mc:
89    case None{}:
90      None{}
91    case Some{c}:
92      Some{Ct{stream, c}}
93
94def ct_split(stream: Bool, app: Bool, +m: String) -> Maybe<&2, Ct>:
95  match stream:
96    case True{}:
97      ct_make(True{}, codec_of(String.drop(m, 20n)))
98    case False{}:
99      match app:
100        case True{}:
101          ct_make(False{}, codec_of(String.drop(m, 12n)))
102        case False{}:
103          None{}
104
105# "application/json; charset=utf-8" -> Ct{False, Json}; case-insensitive; None if not application/*.
106def parse_content_type(+ct: String) -> Maybe<&2, Ct>:
107  +m = String.to_lower(String.trim(media(ct)))
108  ct_split(String.starts_with(m, "application/connect+"), String.starts_with(m, "application/"), m)
109
110def ct_stream(c: Ct) -> Bool:
111  match c:
112    case Ct{s, k}:
113      s
114
115def ct_codec(c: Ct) -> Codec:
116  match c:
117    case Ct{s, k}:
118      k
119
120def ct_kind(c: Ct) -> CallKind:
121  match c:
122    case Ct{s, k}:
123      match s:
124        case True{}:
125          Stream{}
126        case False{}:
127          Unary{}
128
129# ---------------------------------------------------------------- headers
130
131def hdr.of(r: Map<&2, List<&2, String>> & List<&2, String>) -> Maybe<&2, String>:
132  (m, v) = r
133  match v:
134    case Nil{}:
135      None{}
136    case Con{x, rest}:
137      Some{x}
138
139# First value of a (lowercase) header.
140def hdr(+h: Map<&2, List<&2, String>>, +k: String) -> Maybe<&2, String>:
141  hdr.of(Map.get(List<&2, String>, Nil{}, h, k))
142
143def hdr_all.of(r: Map<&2, List<&2, String>> & List<&2, String>) -> Maybe<&2, String>:
144  (m, v) = r
145  match v:
146    case Nil{}:
147      None{}
148    case Con{x, rest}:
149      Some{String.join(Con{x, rest}, ",")}
150
151# All values of a header joined with ",".
152def hdr_all(+h: Map<&2, List<&2, String>>, +k: String) -> Maybe<&2, String>:
153  hdr_all.of(Map.get(List<&2, String>, Nil{}, h, k))
154
155def bad_media() -> Result<&2, &1, Reject, Ct>:
156  Fail{Status{415, "unsupported content-type: want application/<codec> or application/connect+<codec>"}}
157
158def content_type_of.fin(m: Maybe<&2, Ct>) -> Result<&2, &1, Reject, Ct>:
159  match m:
160    case None{}:
161      bad_media()
162    case Some{c}:
163      Done{c}
164
165def content_type_of.of(m: Maybe<&2, String>) -> Result<&2, &1, Reject, Ct>:
166  match m:
167    case None{}:
168      bad_media()
169    case Some{s}:
170      content_type_of.fin(parse_content_type(s))
171
172def content_type_of(+h: Map<&2, List<&2, String>>) -> Result<&2, &1, Reject, Ct>:
173  content_type_of.of(hdr(h, "content-type"))
174
175def version_ok(ok: Bool) -> Result<&2, &1, Reject, Unit>:
176  match ok:
177    case True{}:
178      Done{Unit{}}
179    case False{}:
180      Fail{Status{400, "unsupported connect-protocol-version: want \"1\""}}
181
182def check_version.of(v: Maybe<&2, String>) -> Result<&2, &1, Reject, Unit>:
183  match v:
184    case None{}:
185      Done{Unit{}}
186    case Some{s}:
187      version_ok(String.eq(s, "1"))
188
189# connect-protocol-version, if present, must be "1".
190def check_version(+h: Map<&2, List<&2, String>>) -> Result<&2, &1, Reject, Unit>:
191  check_version.of(hdr(h, "connect-protocol-version"))
192
193# ---------------------------------------------------------------- timeout
194
195def digit(+c: U32) -> Bool:
196  Bool.and(U32.is_ge(c, 48), U32.is_le(c, 57))
197
198# acc*10 + d, clamped to U32 max.
199def sat(+acc: U32, +d: U32) -> U32:
200  Bool.pick(U32, U32.is_gt(acc, 429496729), 4294967295,
201    Bool.pick(U32, Bool.and(U32.is_eq(acc, 429496729), U32.is_gt(d, 5)), 4294967295,
202      (acc * 10 + d : U32)))
203
204def pt.pick(ok: Bool, rest: Unit -> Maybe<&2, U32>) -> Maybe<&2, U32>:
205  match ok:
206    case True{}:
207      rest(Unit{})
208    case False{}:
209      None{}
210
211def pt.go(s: String, +n: U32, +acc: U32) -> Maybe<&2, U32>:
212  match s:
213    case SNil{}:
214      Some{acc}
215    case SCon{Chr{+c}, t}:
216      pt.pick(Bool.and(digit(c), U32.is_lt(n, 10)), u => pt.go(t, (n + 1 : U32), sat(acc, (c - 48 : U32))))
217
218def pt.start(+s: String, empty: Bool) -> Maybe<&2, U32>:
219  match empty:
220    case True{}:
221      None{}
222    case False{}:
223      pt.go(s, 0, 0)
224
225# connect-timeout-ms value: 1..10 ASCII digits, clamped to U32 max.
226def parse_timeout(+s: String) -> Maybe<&2, U32>:
227  pt.start(s, String.is_empty(s))
228
229def timeout.chk(m: Maybe<&2, U32>, +s: String) -> Result<&2, &1, Reject, Maybe<&2, U32>>:
230  match m:
231    case None{}:
232      Fail{Connect{Code.InvalidArgument{}, "invalid connect-timeout-ms \"" ++ s ++ "\""}}
233    case Some{t}:
234      Done{Some{t}}
235
236def timeout.of(v: Maybe<&2, String>) -> Result<&2, &1, Reject, Maybe<&2, U32>>:
237  match v:
238    case None{}:
239      Done{None{}}
240    case Some{+s}:
241      timeout.chk(parse_timeout(s), s)
242
243def timeout_of(+h: Map<&2, List<&2, String>>) -> Result<&2, &1, Reject, Maybe<&2, U32>>:
244  timeout.of(hdr(h, "connect-timeout-ms"))
245
246# ---------------------------------------------------------------- encodings
247
248def norm.pick(empty: Bool, +e: String) -> String:
249  match empty:
250    case True{}:
251      "identity"
252    case False{}:
253      e
254
255def norm_enc(+s: String) -> String:
256  +e = String.to_lower(String.trim(s))
257  norm.pick(String.is_empty(e), e)
258
259def enc.fin(ok: Bool, +e: String) -> Result<&2, &1, Reject, String>:
260  match ok:
261    case True{}:
262      Done{e}
263    case False{}:
264      Fail{Connect{Code.Unimplemented{}, "unknown compression \"" ++ e ++ "\": supported encodings are " ++ String.join(supported_encodings(), ", ")}}
265
266# Normalizes an encoding name; Unimplemented unless it is identity.
267def check_enc(+raw: String) -> Result<&2, &1, Reject, String>:
268  +e = norm_enc(raw)
269  enc.fin(String.eq(e, "identity"), e)
270
271def get_enc.of(v: Maybe<&2, String>) -> Result<&2, &1, Reject, String>:
272  match v:
273    case None{}:
274      Done{"identity"}
275    case Some{s}:
276      check_enc(s)
277
278def get_enc(+h: Map<&2, List<&2, String>>, +k: String) -> Result<&2, &1, Reject, String>:
279  get_enc.of(hdr(h, k))
280
281def acc.item(+s: String) -> String:
282  +e = String.to_lower(String.trim(media(s)))
283  e
284
285def acc.cons(empty: Bool, x: String, r: List<&2, String>) -> List<&2, String>:
286  match empty:
287    case True{}:
288      r
289    case False{}:
290      x <> r
291
292def acc.item2(+x: String, r: List<&2, String>) -> List<&2, String>:
293  acc.cons(String.is_empty(x), x, r)
294
295def acc.go(xs: List<&2, String>) -> List<&2, String>:
296  match xs:
297    case Nil{}:
298      Nil{}
299    case Con{h, t}:
300      acc.item2(acc.item(h), acc.go(t))
301
302def accept_list(+s: String) -> List<&2, String>:
303  acc.go(String.split(s, ','))
304
305def accept.of(v: Maybe<&2, String>, +enc: String) -> List<&2, String>:
306  match v:
307    case None{}:
308      [enc]
309    case Some{s}:
310      accept_list(s)
311
312def accept_of(+h: Map<&2, List<&2, String>>, +k: String, +enc: String) -> List<&2, String>:
313  accept.of(hdr_all(h, k), enc)
314
315# ---------------------------------------------------------------- path
316
317def bad_path() -> Result<&2, &1, Reject, String>:
318  Fail{Status{404, "not found: want <prefix>/<package.Service>/<Method>"}}
319
320def proc.fin(ok: Bool, +p: String) -> Result<&2, &1, Reject, String>:
321  match ok:
322    case True{}:
323      Done{p}
324    case False{}:
325      bad_path()
326
327def proc.parts(xs: List<&2, String>, +p: String) -> Result<&2, &1, Reject, String>:
328  match xs:
329    case Con{+s, Con{+m, Nil{}}}:
330      proc.fin(Bool.and(Bool.not(String.is_empty(s)), Bool.not(String.is_empty(m))), p)
331    case _:
332      bad_path()
333
334def proc.split(+p: String) -> Result<&2, &1, Reject, String>:
335  proc.parts(String.split(p, '/'), p)
336
337def proc.slash(ok: Bool, +rest: String) -> Result<&2, &1, Reject, String>:
338  match ok:
339    case True{}:
340      proc.split(String.drop(rest, 1n))
341    case False{}:
342      bad_path()
343
344def proc.pre(ok: Bool, +path: String, +prefix: String) -> Result<&2, &1, Reject, String>:
345  match ok:
346    case True{}:
347      +rest = String.drop(path, String.length(prefix))
348      proc.slash(String.starts_with(rest, "/"), rest)
349    case False{}:
350      bad_path()
351
352# "<prefix>/pkg.Service/Method" -> "pkg.Service/Method"; else Status 404.
353def parse_proc(+path: String, +prefix: String) -> Result<&2, &1, Reject, String>:
354  proc.pre(String.starts_with(path, prefix), path, prefix)
355
356# ---------------------------------------------------------------- POST
357
358def post_enc_key(stream: Bool) -> String:
359  match stream:
360    case True{}:
361      "connect-content-encoding"
362    case False{}:
363      "content-encoding"
364
365def post_acc_key(stream: Bool) -> String:
366  match stream:
367    case True{}:
368      "connect-accept-encoding"
369    case False{}:
370      "accept-encoding"
371
372def post_req(+path: String, +headers: Map<&2, List<&2, String>>, +prefix: String) -> Result<&2, &1, Reject, Spec>:
373  do Result<&2, &1, Reject, Spec>:
374    proc : String <- parse_proc(path, prefix)
375    +ct : Ct <- content_type_of(headers)
376    ok : Unit <- check_version(headers)
377    t : Maybe<&2, U32> <- timeout_of(headers)
378    +enc : String <- get_enc(headers, post_enc_key(ct_stream(ct)))
379    return Spec{proc, ct_kind(ct), ct_codec(ct), t, enc, accept_of(headers, post_acc_key(ct_stream(ct)), enc), Bytes.new(0)}
380
381# ---------------------------------------------------------------- GET
382
383def qfind.pick(hit: Bool, v: String, rest: Unit -> Maybe<&2, String>) -> Maybe<&2, String>:
384  match hit:
385    case True{}:
386      Some{v}
387    case False{}:
388      rest(Unit{})
389
390def qfind.kv(r: String & String, +name: String, rest: Unit -> Maybe<&2, String>) -> Maybe<&2, String>:
391  (k, v) = r
392  qfind.pick(String.eq(k, name), v, rest)
393
394# First raw value of query param `name` in a list of "k=v" items.
395def qfind(xs: List<&2, String>, +name: String) -> Maybe<&2, String>:
396  match xs:
397    case Nil{}:
398      None{}
399    case Con{h, t}:
400      qfind.kv(Url.take_to(h, 61), name, u => qfind(t, name))
401
402def qparam(+query: String, +name: String) -> Maybe<&2, String>:
403  qfind(String.split(query, '&'), name)
404
405def need.fin(m: Maybe<&2, String>, +name: String) -> Result<&2, &1, Reject, String>:
406  match m:
407    case None{}:
408      Fail{Status{400, "missing query parameter \"" ++ name ++ "\""}}
409    case Some{v}:
410      Done{v}
411
412def need_param(+query: String, +name: String) -> Result<&2, &1, Reject, String>:
413  need.fin(qparam(query, name), name)
414
415def qversion.ok(ok: Bool) -> Result<&2, &1, Reject, Unit>:
416  match ok:
417    case True{}:
418      Done{Unit{}}
419    case False{}:
420      Fail{Status{400, "unsupported connect query parameter: want \"v1\""}}
421
422def qversion.of(m: Maybe<&2, String>) -> Result<&2, &1, Reject, Unit>:
423  match m:
424    case None{}:
425      Done{Unit{}}
426    case Some{s}:
427      qversion.ok(String.eq(s, "v1"))
428
429# connect=v1 if present, else 400.
430def check_qversion(+query: String) -> Result<&2, &1, Reject, Unit>:
431  qversion.of(qparam(query, "connect"))
432
433def b64flag.of(m: Maybe<&2, String>) -> Bool:
434  match m:
435    case None{}:
436      False{}
437    case Some{s}:
438      String.eq(s, "1")
439
440def codec.fin(m: Maybe<&2, Codec>) -> Result<&2, &1, Reject, Codec>:
441  match m:
442    case None{}:
443      Fail{Status{400, "empty encoding query parameter"}}
444    case Some{c}:
445      Done{c}
446
447def comp.of(m: Maybe<&2, String>) -> Result<&2, &1, Reject, String>:
448  match m:
449    case None{}:
450      Done{"identity"}
451    case Some{s}:
452      check_enc(s)
453
454def compression_of(+query: String) -> Result<&2, &1, Reject, String>:
455  comp.of(qparam(query, "compression"))
456
457# --- url-safe (and standard) base64, padded or not. Private; dedupe with b64.bend later.
458
459def b64val(+c: U32) -> Maybe<&2, U32>:
460  Bool.pick(Maybe<&2, U32>, Bool.and(U32.is_ge(c, 65), U32.is_le(c, 90)), Some{(c - 65 : U32)},
461  Bool.pick(Maybe<&2, U32>, Bool.and(U32.is_ge(c, 97), U32.is_le(c, 122)), Some{(c - 71 : U32)},
462  Bool.pick(Maybe<&2, U32>, Bool.and(U32.is_ge(c, 48), U32.is_le(c, 57)), Some{(c + 4 : U32)},
463  Bool.pick(Maybe<&2, U32>, Bool.or(U32.is_eq(c, 43), U32.is_eq(c, 45)), Some{62},
464  Bool.pick(Maybe<&2, U32>, Bool.or(U32.is_eq(c, 47), U32.is_eq(c, 95)), Some{63}, None{})))))
465
466def b64vals.cons(m: Maybe<&2, U32>, r: Maybe<&2, List<&2, U32>>) -> Maybe<&2, List<&2, U32>>:
467  match m:
468    case None{}:
469      None{}
470    case Some{v}:
471      match r:
472        case None{}:
473          None{}
474        case Some{l}:
475          Some{v <> l}
476
477def b64vals(s: String) -> Maybe<&2, List<&2, U32>>:
478  match s:
479    case SNil{}:
480      Some{Nil{}}
481    case SCon{Chr{+c}, t}:
482      b64vals.cons(b64val(c), b64vals(t))
483
484def b64count(xs: List<&2, U32>, +acc: U32) -> U32:
485  match xs:
486    case Nil{}:
487      acc
488    case Con{h, t}:
489      b64count(t, (acc + 1 : U32))
490
491def b64out(+n: U32) -> U32:
492  +r = (n % 4 : U32)
493  ((n / 4 : U32) * 3 + r - U32.min(r, 1) : U32)
494
495def b64set(out: Bytes.Bytes, +i: U32, +v: U32) -> Bytes.Bytes:
496  Bytes.set(out, i, (v .&. 255 : U32))
497
498# Fills out from the 6-bit values; None on a dangling single char.
499def b64go(vs: List<&2, U32>, out: Bytes.Bytes, +i: U32) -> Maybe<&1, Bytes.Bytes>:
500  match vs:
501    case Nil{}:
502      Some{out}
503    case Con{+a, Nil{}}:
504      None{}
505    case Con{+a, Con{+b, Nil{}}}:
506      Some{b64set(out, i, ((a << 2n) .|. (b >> 4n) : U32))}
507    case Con{+a, Con{+b, Con{+c, Nil{}}}}:
508      Some{b64set(b64set(out, i, ((a << 2n) .|. (b >> 4n) : U32)), (i + 1 : U32), (((b .&. 15 : U32) << 4n) .|. (c >> 2n) : U32))}
509    case Con{+a, Con{+b, Con{+c, Con{+d, rest}}}}:
510      b64go(rest, b64set(b64set(b64set(out, i, ((a << 2n) .|. (b >> 4n) : U32)), (i + 1 : U32), (((b .&. 15 : U32) << 4n) .|. (c >> 2n) : U32)), (i + 2 : U32), (((c .&. 3 : U32) << 6n) .|. d : U32)), (i + 3 : U32))
511
512def b64build(vs: List<&2, U32>, +n: U32) -> Maybe<&1, Bytes.Bytes>:
513  b64go(vs, Bytes.new(b64out(n)), 0)
514
515def b64run(m: Maybe<&2, List<&2, U32>>) -> Maybe<&1, Bytes.Bytes>:
516  match m:
517    case None{}:
518      None{}
519    case Some{+vs}:
520      b64build(vs, b64count(vs, 0))
521
522def b64strip(+s: String) -> String:
523  Bool.pick(String, String.ends_with(s, "=="), String.take(s, Nat.sub(String.length(s), 2n)),
524    Bool.pick(String, String.ends_with(s, "="), String.take(s, Nat.sub(String.length(s), 1n)), s))
525
526# Decodes RFC 4648 base64 (url-safe or standard alphabet), padding optional.
527def b64url_decode(+s: String) -> Maybe<&1, Bytes.Bytes>:
528  b64run(b64vals(b64strip(s)))
529
530def msg.bytes(m: Maybe<&1, Bytes.Bytes>) -> Result<&2, &1, Reject, Bytes.Bytes>:
531  match m:
532    case None{}:
533      Fail{Status{400, "invalid base64 in message query parameter"}}
534    case Some{b}:
535      Done{b}
536
537def msg.kind(b64: Bool, +s: String) -> Result<&2, &1, Reject, Bytes.Bytes>:
538  match b64:
539    case True{}:
540      msg.bytes(b64url_decode(s))
541    case False{}:
542      Done{Bytes.from_string(s)}
543
544def msg.of(m: Maybe<&2, String>, +b64: Bool) -> Result<&2, &1, Reject, Bytes.Bytes>:
545  match m:
546    case None{}:
547      Fail{Status{400, "invalid percent-encoding in message query parameter"}}
548    case Some{s}:
549      msg.kind(b64, s)
550
551# message param: percent-decoded, then base64-decoded if base64=1.
552def decode_message(+raw: String, +b64: Bool) -> Result<&2, &1, Reject, Bytes.Bytes>:
553  msg.of(Url.pct.decode(raw), b64)
554
555def get_req(+path: String, +query: String, +headers: Map<&2, List<&2, String>>, +prefix: String) -> Result<&2, &1, Reject, Spec>:
556  do Result<&2, &1, Reject, Spec>:
557    proc : String <- parse_proc(path, prefix)
558    ok : Unit <- check_version(headers)
559    ok2 : Unit <- check_qversion(query)
560    es : String <- need_param(query, "encoding")
561    codec : Codec <- codec.fin(codec_of(String.to_lower(es)))
562    raw : String <- need_param(query, "message")
563    msg : Bytes.Bytes <- decode_message(raw, b64flag.of(qparam(query, "base64")))
564    +comp : String <- compression_of(query)
565    t : Maybe<&2, U32> <- timeout_of(headers)
566    return Spec{proc, UnaryGet{}, codec, t, comp, accept_of(headers, "accept-encoding", comp), msg}
567
568# ---------------------------------------------------------------- entry point
569
570def route(post: Bool, get: Bool, +path: String, +query: String, +headers: Map<&2, List<&2, String>>, +prefix: String) -> Result<&2, &1, Reject, Spec>:
571  match post:
572    case True{}:
573      post_req(path, headers, prefix)
574    case False{}:
575      match get:
576        case True{}:
577          get_req(path, query, headers, prefix)
578        case False{}:
579          Fail{Status{405, "method not allowed: want POST or GET"}}
580
581# Classifies a request. method is "POST"/"GET"; query is the raw query string (no '?').
582def classify(+method: String, +path: String, +query: String, +headers: Map<&2, List<&2, String>>, +prefix: String) -> Result<&2, &1, Reject, Spec>:
583  route(String.eq(method, "POST"), String.eq(method, "GET"), path, query, headers, prefix)