marianoguerra/atproto/xrpc does not have a README file

Transport

pub(open) trait Transport {
async fn send(Self, HttpRequest) -> HttpResponse
fn describe(Self) -> String
}

A thing that can send an XRPC request and return what came back.

Deliberately dumb. An implementation has exactly three obligations, and all three are things a hand-written one gets wrong:

  • Lower-case the response header names.
  • Do not forward content-length; most HTTP clients compute their own, and sending both risks two conflicting headers on the wire.
  • Do not let the underlying client's errors escape. Translate them, or a caller's catch sees a type from a library it never imported.

XrpcError

pub(all) suberror XrpcError {
RequestFailed(String)
Status(status~ : Int, error~ : String, message~ : String?, headers~ : Map[String, String])
RateLimited(retry_after_millis~ : Int64?, error~ : String, message~ : String?)
Decode(
DecodeError
)
Encode(String)
} derive(
Debug
)

impl Show for XrpcError

XrpcError::describe_error

fn XrpcError::describe_error(self : XrpcError) -> String

XrpcError::error_name

fn XrpcError::error_name(self : XrpcError) -> String

The machine-readable name: the error field of the body, or a name derived from the status when the server did not send one.

XrpcError::is_expired_token

fn XrpcError::is_expired_token(self : XrpcError) -> Bool

The access token has expired and a refresh should be attempted.

The condition is upstream's and it is not just "401": a PDS may answer 400 with ExpiredToken, and a client that only checked the status would log the user out instead of refreshing.

XrpcError::message

fn XrpcError::message(self : XrpcError) -> String?

XrpcError::retry_after_millis

fn XrpcError::retry_after_millis(self : XrpcError) -> Int64?

How long to wait before retrying, if the server said.

None means it did not, and the caller should pick its own backoff -- the reference implementation uses min(30s, 500ms * 2^attempt).

XrpcError::should_retry

fn XrpcError::should_retry(self : XrpcError) -> Bool

Whether the same request is worth sending again.

The status set is the reference implementation's RETRYABLE_HTTP_STATUS_CODES. A transport failure is retryable because it may never have reached the server; a 4xx other than these is not, because sending it again produces the same answer.

XrpcError::status

fn XrpcError::status(self : XrpcError) -> Int?

Body

pub(all) enum Body {
Empty
Json(
LexValue
)
Blob(bytes~ : Bytes, mime_type~ : String)
} derive(Eq,
Debug
)

What goes in the request body.

Credential

pub(all) enum Credential {
Anonymous
Bearer(String)
} derive(Eq,
Debug
)

How the caller proves who it is.

HttpRequest

pub(all) struct HttpRequest {
url : String
http_method : String
headers : Map[String, String]
body : Bytes
} derive(Eq,
Debug
)

One outbound call, already reduced to bytes.

HttpRequest::body_text

fn HttpRequest::body_text(self : HttpRequest) -> String

The body as text, for logging and tests. Lossy by design: a blob upload is not text and printing it should not fail.

HttpRequest::content_length

fn HttpRequest::content_length(self : HttpRequest) -> Int

The UTF-8 byte count, which is what Content-Length must be -- and is not the string length, because MoonBit strings are UTF-16.

HttpResponse

pub(all) struct HttpResponse {
status : Int
headers : Map[String, String]
body : Bytes
} derive(Eq,
Debug
)

HttpResponse::body_text

fn HttpResponse::body_text(self : HttpResponse) -> String

The body as text. Lossy, because a malformed body must still be reportable rather than turning into a second, less useful error.

HttpResponse::content_type

fn HttpResponse::content_type(self : HttpResponse) -> String?

The media type, without parameters -- application/json from application/json; charset=utf-8.

HttpResponse::header

fn HttpResponse::header(self : HttpResponse, name : String) -> String?

Method

pub(all) enum Method {
Query
Procedure
} derive(Eq,
Debug
)

A query reads and a procedure writes, and that is the only thing that decides the HTTP method.

Method::http_method

fn Method::http_method(self : Method) -> String

ParamValue

pub(all) enum ParamValue {
Str(String)
Int(Int64)
Bool(Bool)
Strs(Array[String])
Ints(Array[Int64])
Bools(Array[Bool])
} derive(Eq,
Debug
)

Params

pub(all) struct Params {
entries : Array[(String, ParamValue)]
} derive(Eq,
Debug
)

An ordered list rather than a map, because two entries may share a key -- that is how arrays are spelled -- and because a stable order makes a request comparable in a test.

Params::encode

fn Params::encode(self : Params) -> String

The query string, without a leading ?, or "" when there is nothing to send. An empty array contributes nothing at all, which matches the reference implementation -- ?tags= would mean a tag that is the empty string.

Params::is_empty

fn Params::is_empty(self : Params) -> Bool

Params::length

fn Params::length(self : Params) -> Int

Params::new

fn Params::new() -> Params

Params::of

fn Params::of(entries : Array[(String, ParamValue)]) -> Params

Params::put

fn Params::put(self : Params, key : String, value : ParamValue) -> Unit

Params::put_bool

fn Params::put_bool(self : Params, key : String, value : Bool?) -> Unit

Params::put_int

fn Params::put_int(self : Params, key : String, value : Int64?) -> Unit

Params::put_opt

fn Params::put_opt(self : Params, key : String, value : ParamValue?) -> Unit

The whole reason this type exists rather than a Map: an absent optional argument must vanish, not become an empty string. Every generated call is a column of these.

Params::put_string

fn Params::put_string(self : Params, key : String, value : String?) -> Unit

Params::put_strings

fn Params::put_strings(self : Params, key : String, value : Array[String]?) -> Unit

USER_AGENT

let USER_AGENT : String

build_request

fn build_request(service : String, nsid :
Nsid
, kind : Method, params? : Params, body? : Body, credential? : Credential, accept? : String, extra_headers? : Map[String, String]) -> HttpRequest

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/....

interpret

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.

interpret_bytes

fn interpret_bytes(response : HttpResponse) -> Bytes raise XrpcError

A 2xx whose body is not JSON: getBlob, getRepo, the video endpoints. Returns the bytes and lets the caller decide what they are.

percent_encode

fn percent_encode(text : String) -> String

RFC 3986 percent-encoding of everything outside the unreserved set.

Encoding goes through the UTF-8 bytes, not the string's UTF-16 code units. Getting that wrong is invisible until someone searches for a word with an accent in it, and then the query silently matches nothing.

status_error_name

fn status_error_name(status : Int) -> String

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.

to_xrpc_error

fn to_xrpc_error(e : Error) -> XrpcError

Narrows a caught Error back to this taxonomy.

Needed because variant patterns are the only way to match an error value -- the suberror TYPE name is not a pattern -- and they are only in scope inside this package. Anything from elsewhere becomes a transport failure, which is the honest reading: it came from the code that does the sending.