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. 19 import Base 20 import ./code.bend as Code 21 import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes 22 import bend-kit-url@0.4.1.0/url.bend as Url 23 24 type Codec is Data: 25 Json{} 26 Proto{} 27 Custom{name: String} 28 29 type CallKind is Data: 30 Unary{} 31 UnaryGet{} 32 Stream{} 33 34 type 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 38 type 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+. 43 type Ct is Data: 44 Ct{stream: Bool, codec: Codec} 45 46 def supported_encodings() -> List<&2, String>: 47 ["identity"] 48 49 # ---------------------------------------------------------------- content types 50 51 def 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 60 def 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 67 def unary_content_type(c: Codec) -> String: 68 content_type(False{}, c) 69 70 def stream_content_type(c: Codec) -> String: 71 content_type(True{}, c) 72 73 # json / proto / other (non-empty) -> Custom 74 def 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 79 def media.of(r: String & String) -> String: 80 (a, b) = r 81 a 82 83 # text before the first ';' 84 def media(s: String) -> String: 85 media.of(Url.take_to(s, 59)) 86 87 def 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 94 def 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/*. 106 def 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 110 def ct_stream(c: Ct) -> Bool: 111 match c: 112 case Ct{s, k}: 113 s 114 115 def ct_codec(c: Ct) -> Codec: 116 match c: 117 case Ct{s, k}: 118 k 119 120 def 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 131 def 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. 140 def hdr(+h: Map<&2, List<&2, String>>, +k: String) -> Maybe<&2, String>: 141 hdr.of(Map.get(List<&2, String>, Nil{}, h, k)) 142 143 def 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 ",". 152 def 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 155 def bad_media() -> Result<&2, &1, Reject, Ct>: 156 Fail{Status{415, "unsupported content-type: want application/ or application/connect+"}} 157 158 def 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 165 def 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 172 def content_type_of(+h: Map<&2, List<&2, String>>) -> Result<&2, &1, Reject, Ct>: 173 content_type_of.of(hdr(h, "content-type")) 174 175 def 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 182 def 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". 190 def 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 195 def 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. 199 def 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 204 def 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 211 def 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 218 def 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. 226 def parse_timeout(+s: String) -> Maybe<&2, U32>: 227 pt.start(s, String.is_empty(s)) 228 229 def 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 236 def 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 243 def 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 248 def norm.pick(empty: Bool, +e: String) -> String: 249 match empty: 250 case True{}: 251 "identity" 252 case False{}: 253 e 254 255 def norm_enc(+s: String) -> String: 256 +e = String.to_lower(String.trim(s)) 257 norm.pick(String.is_empty(e), e) 258 259 def 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. 267 def check_enc(+raw: String) -> Result<&2, &1, Reject, String>: 268 +e = norm_enc(raw) 269 enc.fin(String.eq(e, "identity"), e) 270 271 def 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 278 def get_enc(+h: Map<&2, List<&2, String>>, +k: String) -> Result<&2, &1, Reject, String>: 279 get_enc.of(hdr(h, k)) 280 281 def acc.item(+s: String) -> String: 282 +e = String.to_lower(String.trim(media(s))) 283 e 284 285 def 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 292 def acc.item2(+x: String, r: List<&2, String>) -> List<&2, String>: 293 acc.cons(String.is_empty(x), x, r) 294 295 def 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 302 def accept_list(+s: String) -> List<&2, String>: 303 acc.go(String.split(s, ',')) 304 305 def 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 312 def 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 317 def bad_path() -> Result<&2, &1, Reject, String>: 318 Fail{Status{404, "not found: want //"}} 319 320 def 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 327 def 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 334 def proc.split(+p: String) -> Result<&2, &1, Reject, String>: 335 proc.parts(String.split(p, '/'), p) 336 337 def 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 344 def 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 # "/pkg.Service/Method" -> "pkg.Service/Method"; else Status 404. 353 def 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 358 def post_enc_key(stream: Bool) -> String: 359 match stream: 360 case True{}: 361 "connect-content-encoding" 362 case False{}: 363 "content-encoding" 364 365 def post_acc_key(stream: Bool) -> String: 366 match stream: 367 case True{}: 368 "connect-accept-encoding" 369 case False{}: 370 "accept-encoding" 371 372 def 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 383 def 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 390 def 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. 395 def 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 402 def qparam(+query: String, +name: String) -> Maybe<&2, String>: 403 qfind(String.split(query, '&'), name) 404 405 def 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 412 def need_param(+query: String, +name: String) -> Result<&2, &1, Reject, String>: 413 need.fin(qparam(query, name), name) 414 415 def 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 422 def 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. 430 def check_qversion(+query: String) -> Result<&2, &1, Reject, Unit>: 431 qversion.of(qparam(query, "connect")) 432 433 def 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 440 def 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 447 def 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 454 def 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 459 def 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 466 def 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 477 def 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 484 def 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 491 def b64out(+n: U32) -> U32: 492 +r = (n % 4 : U32) 493 ((n / 4 : U32) * 3 + r - U32.min(r, 1) : U32) 494 495 def 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. 499 def 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 512 def b64build(vs: List<&2, U32>, +n: U32) -> Maybe<&1, Bytes.Bytes>: 513 b64go(vs, Bytes.new(b64out(n)), 0) 514 515 def 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 522 def 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. 527 def b64url_decode(+s: String) -> Maybe<&1, Bytes.Bytes>: 528 b64run(b64vals(b64strip(s))) 529 530 def 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 537 def 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 544 def 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. 552 def decode_message(+raw: String, +b64: Bool) -> Result<&2, &1, Reject, Bytes.Bytes>: 553 msg.of(Url.pct.decode(raw), b64) 554 555 def 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 570 def 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 '?'). 582 def 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)