Current section
Files
Jump to
Current section
Files
src/atproto/xrpc.gleam
//// Transport-agnostic XRPC plumbing. The `Client` wraps a `send` function so
//// the caller chooses the HTTP backend (erlang `httpc`, JS `fetch`, a test
//// stub), keeping this module free of any target-specific dependency. Bodies
//// are BitArrays so blobs ride the same client; the text helpers below keep
//// JSON call sites string-shaped.
import gleam/bit_array
import gleam/dynamic/decode
import gleam/http
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/json.{type Json}
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
pub type Client {
Client(
send: fn(Request(BitArray)) -> Result(Response(BitArray), TransportError),
)
}
/// A transport-level failure from the injected `send` function or from
/// request construction. Backends map their own error type onto these
/// variants; `Other` carries anything without a dedicated shape (including
/// DPoP signing failures surfaced through a wrapped client's `send`).
pub type TransportError {
InvalidUrl(url: String)
Timeout
ConnectionFailed(detail: String)
Other(detail: String)
}
/// A one-line human-readable rendering of a transport error.
pub fn transport_error_to_string(error: TransportError) -> String {
case error {
InvalidUrl(url) -> "invalid url: " <> url
Timeout -> "timed out"
ConnectionFailed(detail) -> "connection failed: " <> detail
Other(detail) -> detail
}
}
pub type XrpcError {
RequestFailed(TransportError)
/// A non-2xx response. atproto error bodies are JSON `{error, message}`; both
/// are parsed out (when present) so callers can branch on `error` (e.g.
/// `ExpiredToken`) instead of string-matching the raw body.
BadStatus(
status: Int,
error: Option(String),
message: Option(String),
body: String,
)
DecodeFailed(String)
}
/// A one-line human-readable rendering of an error, for CLI and log output.
pub fn describe(error: XrpcError) -> String {
case error {
RequestFailed(e) -> "request failed: " <> transport_error_to_string(e)
BadStatus(status, code, message, _) ->
"HTTP "
<> string.inspect(status)
<> option.map(code, fn(c) { " " <> c }) |> option.unwrap("")
<> option.map(message, fn(m) { ": " <> m }) |> option.unwrap("")
DecodeFailed(e) -> "decode failed: " <> e
}
}
/// Send a text request and decode the response body as text. The seam between
/// the string-shaped JSON world and the BitArray transport.
pub fn send_text(
client: Client,
req: Request(String),
) -> Result(Response(String), TransportError) {
use resp <- result.try(client.send(request.map(req, bit_array.from_string)))
case bit_array.to_string(resp.body) {
Ok(body) -> Ok(response.set_body(resp, body))
Error(_) -> Error(Other("response body is not valid utf-8"))
}
}
pub fn get(
client: Client,
url: String,
token: Option(String),
) -> Result(Response(String), XrpcError) {
use base <- result.try(base_request(url, token))
send_checked(client, base)
}
/// GET returning the raw bytes (e.g. blob or image downloads). The error body
/// on a bad status is decoded leniently for the message.
pub fn get_bits(
client: Client,
url: String,
token: Option(String),
) -> Result(Response(BitArray), XrpcError) {
use base <- result.try(base_request(url, token))
base
|> request.map(bit_array.from_string)
|> client.send
|> result.map_error(RequestFailed)
|> result.try(check_ok_bits)
}
pub fn post_json(
client: Client,
url: String,
token: Option(String),
body: Json,
) -> Result(Response(String), XrpcError) {
use base <- result.try(base_request(url, token))
base
|> request.set_method(http.Post)
|> request.set_header("content-type", "application/json")
|> request.set_body(json.to_string(body))
|> send_checked(client, _)
}
/// POST raw bytes (e.g. `uploadBlob`); the response is decoded as text (JSON).
pub fn post_bits(
client: Client,
url: String,
token: Option(String),
body: BitArray,
content_type: String,
) -> Result(Response(String), XrpcError) {
use base <- result.try(base_request(url, token))
use resp <- result.try(
base
|> request.set_method(http.Post)
|> request.set_header("content-type", content_type)
|> request.map(bit_array.from_string)
|> request.set_body(body)
|> client.send
|> result.map_error(RequestFailed),
)
case bit_array.to_string(resp.body) {
Ok(text) -> check_ok(response.set_body(resp, text))
Error(_) -> Error(DecodeFailed("response body is not valid utf-8"))
}
}
pub fn parse(body: String, decoder: decode.Decoder(a)) -> Result(a, XrpcError) {
json.parse(body, decoder)
|> result.map_error(fn(e) { DecodeFailed(string.inspect(e)) })
}
fn with_auth(req: Request(String), token: Option(String)) -> Request(String) {
case token {
Some(t) -> request.set_header(req, "authorization", "Bearer " <> t)
None -> req
}
}
fn base_request(
url: String,
token: Option(String),
) -> Result(Request(String), XrpcError) {
request.to(url)
|> result.replace_error(RequestFailed(InvalidUrl(url)))
|> result.map(with_auth(_, token))
}
fn send_checked(
client: Client,
req: Request(String),
) -> Result(Response(String), XrpcError) {
send_text(client, req)
|> result.map_error(RequestFailed)
|> result.try(check_ok)
}
fn check_ok_bits(
resp: Response(BitArray),
) -> Result(Response(BitArray), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let body =
bit_array.to_string(resp.body) |> result.unwrap("<binary body>")
let #(error, message) = parse_error(body)
Error(BadStatus(resp.status, error, message, body))
}
}
}
/// Turn a received response into a `Result`: `Ok` on 2xx, else a `BadStatus`
/// with the atproto `error`/`message` parsed out of the body when present.
pub fn check_ok(resp: Response(String)) -> Result(Response(String), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let #(error, message) = parse_error(resp.body)
Error(BadStatus(resp.status, error, message, resp.body))
}
}
}
fn parse_error(body: String) -> #(Option(String), Option(String)) {
let decoder = {
use error <- decode.optional_field(
"error",
None,
decode.optional(decode.string),
)
use message <- decode.optional_field(
"message",
None,
decode.optional(decode.string),
)
decode.success(#(error, message))
}
json.parse(body, decoder) |> result.unwrap(#(None, None))
}