// Building a request and reading a response.
//
// The XRPC wire format, in full:
//
// path /xrpc/, always
// method query -> GET, procedure -> POST
// params query string, on BOTH kinds
// input the request body, for procedures only
// output the response body
//
// That is the whole protocol. What makes a client non-trivial is not the shape
// but the edges: which encoding a body has, what a non-2xx means, and when a
// 400 is really an expired token.
///|
/// A query reads and a procedure writes, and that is the only thing that
/// decides the HTTP method.
pub(all) enum Method {
Query
Procedure
} derive(Eq, Debug)
///|
pub fn Method::http_method(self : Self) -> String {
match self {
Query => "GET"
Procedure => "POST"
}
}
///|
/// What goes in the request body.
pub(all) enum Body {
/// No body. Every query, and the procedures that take no input.
Empty
/// `application/json`, encoded through the lex codec so `$link` and `$bytes`
/// come out right.
Json(@data.LexValue)
/// Raw bytes with a declared media type: `uploadBlob`, and the video
/// endpoints. The mime type is the caller's, not guessed from the content.
Blob(bytes~ : Bytes, mime_type~ : String)
} derive(Eq, Debug)
///|
/// How the caller proves who it is.
pub(all) enum Credential {
Anonymous
/// `Authorization: Bearer `. Both the access and the refresh token are
/// sent this way -- `refreshSession` is the one call that uses the latter.
Bearer(String)
} derive(Eq, Debug)
///|
pub const USER_AGENT : String = "marianoguerra-atproto-mb"
///|
/// Assembles a request. Pure, and the reason almost all of this package can be
/// tested without a transport.
///
/// `service` is the origin to send to -- `https://bsky.social`, or the PDS from
/// the account's DID document. A trailing slash on it is tolerated, because
/// callers paste these from configuration and one showing up should not produce
/// `//xrpc/...`.
pub fn build_request(
service : String,
nsid : @syntax.Nsid,
// Spelled `kind` because `method` is a reserved word.
kind : Method,
params? : Params = Params::new(),
body? : Body = Empty,
credential? : Credential = Anonymous,
accept? : String,
extra_headers? : Map[String, String] = Map([]),
) -> HttpRequest {
let origin = if service.has_suffix("/") {
service[:service.length() - 1].to_owned()
} else {
service
}
let query = params.encode()
let url = if query == "" {
"\{origin}/xrpc/\{nsid}"
} else {
"\{origin}/xrpc/\{nsid}?\{query}"
}
let headers : Map[String, String] = Map([])
headers["user-agent"] = USER_AGENT
if accept is Some(value) {
headers["accept"] = value
}
let bytes = match body {
Empty => b""
Json(value) => {
headers["content-type"] = "application/json"
@utf8.encode(value.stringify())
}
Blob(bytes~, mime_type~) => {
headers["content-type"] = mime_type
bytes
}
}
// Set even though most transports recompute it, so a caller inspecting a
// request can see what it would send. `@xrpc.Transport` implementations are
// told to drop it rather than forward it.
if bytes.length() > 0 {
headers["content-length"] = bytes.length().to_string()
}
if credential is Bearer(token) {
headers["authorization"] = "Bearer \{token}"
}
for name, value in extra_headers {
headers[name.to_lower()] = value
}
{ url, http_method: kind.http_method(), headers, body: bytes }
}
///|
/// Turns a response into the decoded body, or into the right error.
///
/// The order matters. A 429 is a rate limit whatever its body says; a non-2xx
/// is an error even if the body happens to parse; and only then is a 2xx body
/// worth decoding.
pub fn interpret(response : HttpResponse) -> @data.LexValue raise XrpcError {
if response.status == 429 {
let (error, message) = error_body_of(response)
raise RateLimited(
retry_after_millis=retry_after_of(response.headers, None),
error=if error == "" { "RateLimitExceeded" } else { error },
message~,
)
}
if response.status < 200 || response.status >= 300 {
let (error, message) = error_body_of(response)
raise Status(
status=response.status,
error=if error == "" { status_error_name(response.status) } else { error },
message~,
headers=response.headers,
)
}
// A 2xx with an empty body is a successful call that returns nothing --
// `deleteSession`, `updateHandle`. Null is the honest value for it.
if response.body.length() == 0 {
return Null
}
@data.LexValue::parse(response.body_text()) catch {
e => raise Decode(e)
}
}
///|
/// A 2xx whose body is not JSON: `getBlob`, `getRepo`, the video endpoints.
/// Returns the bytes and lets the caller decide what they are.
pub fn interpret_bytes(response : HttpResponse) -> Bytes raise XrpcError {
if response.status == 429 {
let (error, message) = error_body_of(response)
raise RateLimited(
retry_after_millis=retry_after_of(response.headers, None),
error=if error == "" { "RateLimitExceeded" } else { error },
message~,
)
}
if response.status < 200 || response.status >= 300 {
let (error, message) = error_body_of(response)
raise Status(
status=response.status,
error=if error == "" { status_error_name(response.status) } else { error },
message~,
headers=response.headers,
)
}
response.body
}
///|
/// Reads `{"error": ..., "message": ...}` out of an error response.
///
/// Never raises. A server that is failing may also be returning HTML from a
/// proxy, and turning "502 with an nginx page" into a JSON parse error would
/// hide the status -- which is the only useful thing in it.
fn error_body_of(response : HttpResponse) -> (String, String?) {
if response.body.length() == 0 {
return ("", None)
}
let parsed = decode_or_none(response.body_text())
guard parsed is Some(value) else { return ("", None) }
let error = match value.get("error") {
Some(Str(name)) => name
_ => ""
}
let message = match value.get("message") {
Some(Str(text)) => Some(text)
_ => None
}
(error, message)
}
///|
fn decode_or_none(text : String) -> @data.LexValue? {
Some(@data.LexValue::parse(text)) catch {
_ => None
}
}
///|
/// The name to use when the server sent a status but no `error` field.
///
/// The table is the reference implementation's `ResponseType`. Anything
/// unlisted collapses to `InvalidRequest` below 500 and `InternalServerError`
/// at or above, which is what upstream's fallback does.
pub fn status_error_name(status : Int) -> String {
match status {
400 => "InvalidRequest"
401 => "AuthenticationRequired"
403 => "Forbidden"
404 => "XRPCNotSupported"
406 => "NotAcceptable"
413 => "PayloadTooLarge"
415 => "UnsupportedMediaType"
429 => "RateLimitExceeded"
500 => "InternalServerError"
501 => "MethodNotImplemented"
502 => "UpstreamFailure"
503 => "NotEnoughResources"
504 => "UpstreamTimeout"
_ => if status >= 500 { "InternalServerError" } else { "InvalidRequest" }
}
}