atproto

AT Protocol (Bluesky) client: typed Lexicon records and responses, the atproto data model, XRPC, app-password sessions, and validators for DID, handle, NSID, AT-URI, TID and record keys. No dependencies; runs on every backend.

atproto
bluesky
xrpc
lexicon
client
did
Download zip
Version
0.1.1
License
Apache-2.0
Last updated
21 hours ago
Downloads
20

#marianoguerra/atproto

An AT Protocol (Bluesky) client for MoonBit.

  • Nothing is a bare String. A DID, a handle, an NSID, a record key and a TID are five different types, and the compiler knows which one a function wants.
  • No dependencies, so it builds on wasm, wasm-gc, js and native. Bringing the network is your job — or moon add marianoguerra/atproto-http.

moon add marianoguerra/atproto

Status: usable, not finished. The whole read/write surface is here — 197 typed calls, 47 paginating companions, and generated types for every com.atproto.* and app.bsky.* Lexicon. Not yet: an in-memory PDS for testing, OAuth, and anything needing DAG-CBOR.

#A call

// nocheck: needs a transport and a real account.
let client = @client.Client::new(@transport.HttpTransport::new())
client.login(identifier="alice.bsky.social", password=app_password) |> ignore

let feed = client.feed_get_author_feed(
actor=Handle(@syntax.Handle::parse("bob.test")),
limit=30,
)
for item in feed.feed {
// Typed all the way down: a `Handle`, an `AtUri`, a `Datetime`.
println("\{item.post.author.handle} \{item.post.indexed_at}")
}

Writing a record is the same shape, with the input as its own generated type:

// nocheck: needs a transport and a real account.
let post = @app_bsky.FeedPost::new(
text="hello",
created_at=@syntax.Datetime::from_epoch_millis(now_millis),
langs=[@syntax.Language::parse("en")],
)
let created = client.repo_create_record(
input=@com_atproto.RepoCreateRecordInput::new(
repo=Did(client.did().unwrap()),
collection=@syntax.Nsid::parse("app.bsky.feed.post"),
record=post.to_lex(),
),
)
println(created.uri) // at://did:plc:.../app.bsky.feed.post/3jzf...

Sessions refresh themselves. An expired access token is detected — HTTP 401 or a 400 whose error is ExpiredToken, because servers use both — the refresh token is exchanged, and the original call is retried once. Pass on_session to persist the result; resume_session picks it back up.

Anything cursor-paginated has an _all:

// nocheck: needs a transport.

///|
let everything = client.feed_get_author_feed_all(actor~, max_pages=5)

And the corpus moves faster than any generated client, so Client::call takes an NSID directly for an endpoint this version has never heard of.

#Identifiers

Every identifier parses or raises; there is no "probably fine" state, and no way to build one without saying which you meant.

///|
test {
let did = @syntax.Did::parse("did:plc:7iza6de2dwap2sbkpav7c6c6")
assert_eq(did.method_name(), "plc")

// Handles are domain names, so parsing lower-cases them. Two spellings of
// one handle are one value.
let handle = @syntax.Handle::parse("Alice.BSky.Social")
assert_eq(handle.to_string(), "alice.bsky.social")
assert_eq(handle, @syntax.Handle::parse("alice.bsky.social"))
}

An AtIdentifier is "a DID or a handle" — the type of every actor, repo and identifier parameter in the protocol. It is a sum type rather than a validated string, because the difference matters: a DID is stable and a handle is not, so anything that caches or compares needs to know which it is holding.

///|
test {
let who = @syntax.AtIdentifier::parse("did:plc:abc123")
assert_true(who.as_did() is Some(_))
assert_true(who.as_handle() is None)
}

#NSIDs

A Lexicon's name. The authority is a domain in reverse order, which is the one genuinely surprising thing about them:

///|
test {
let post = @syntax.Nsid::parse("app.bsky.feed.post")
assert_eq(post.authority(), "feed.bsky.app")
assert_eq(post.name(), "post")
}

The name part is stricter than the authority parts — no hyphens, no leading digit — which is the rule hand-written validators most often miss:

///|
test {
assert_true(@syntax.Nsid::is_valid("one.two.three.four-and.FiVe"))
assert_false(@syntax.Nsid::is_valid("a-0.b-1.c-3"))
}

#Record keys and TIDs

A TID is a timestamp identifier, and the format exists so that lexicographic order is chronological order. Sorting record keys sorts records by creation time, with no parsing and no clock:

///|
test {
let earlier = @syntax.Tid::from_time(1_700_000_000_000_000L, 42)
let later = @syntax.Tid::from_time(1_700_000_000_000_001L, 7)
assert_true(earlier < later)
assert_true(earlier.to_string() < later.to_string())
assert_eq(earlier.timestamp(), 1_700_000_000_000_000L)
}

Two records written in the same microsecond must not collide, so use a Ticker rather than from_time when minting keys. It is monotonic even when the clock stalls or goes backwards:

///|
test {
// The clock is an argument, not a call to `now()` -- which is what keeps this
// package free of a platform and therefore buildable on every backend.
let ticker = @syntax.Ticker::new(517)
let a = ticker.next(1_700_000_000_000_000L)
let b = ticker.next(1_700_000_000_000_000L) // same microsecond
let c = ticker.next(1_600_000_000_000_000L) // clock jumps backwards
assert_true(a < b)
assert_true(b < c)
}

Most record keys are TIDs, but the syntax is wider — self for singleton records like app.bsky.actor.profile, and much else besides. Record keys are case-sensitive, unlike handles, so nothing normalizes them.

///|
test {
assert_true(@syntax.RecordKey::is_valid("self"))
assert_true(@syntax.RecordKey::is_valid("3jzfcijpj2z2a"))
assert_true(@syntax.RecordKey::is_valid("..."))
// `.` and `..` are excluded for what they mean as path components.
assert_false(@syntax.RecordKey::is_valid("."))
assert_not_eq(
@syntax.RecordKey::parse("self"),
@syntax.RecordKey::parse("Self"),
)
}

#AT-URIs

at://authority/collection/rkey — how one record refers to another. The three shapes are an enum, so "a record key with no collection" is not a value this type can hold:

///|
test {
let uri = @syntax.AtUri::parse(
"at://did:plc:asdf123/app.bsky.feed.post/3jzfcijpj2z2a",
)
assert_eq(uri.collection().unwrap().to_string(), "app.bsky.feed.post")
assert_eq(uri.rkey().unwrap().to_string(), "3jzfcijpj2z2a")

// A bare repository has neither.
let repo = @syntax.AtUri::parse("at://alice.bsky.social")
assert_true(repo.path() is Repo)
assert_true(repo.collection() is None)
}

This is the strict grammar, the one Lexicons are held to — so no trailing slash, no query string, and the record key is really validated:

///|
test {
assert_false(@syntax.AtUri::is_valid("at://did:plc:asdf123/"))
assert_false(@syntax.AtUri::is_valid("at://did:plc:asdf123?foo=bar"))
assert_false(
@syntax.AtUri::is_valid("at://did:plc:asdf123/app.bsky.feed.post/."),
)
}

#Datetimes and CIDs

Both keep the exact bytes they were parsed from, and both do so for the same reason: a record's CID is the hash of its encoding, so a client that reads a record, changes one field and writes it back must not also rewrite its createdAt into a different precision.

///|
test {
let created = @syntax.Datetime::parse("1985-04-12T23:20:50.120000Z")
// Not normalized to milliseconds, not reformatted -- the bytes come back.
assert_eq(created.to_string(), "1985-04-12T23:20:50.120000Z")
assert_eq(created.parts().year, 1985)
assert_eq(created.to_epoch_seconds(), 482196050L)

// For a record you are creating yourself, the "preferred" spelling:
assert_eq(
@syntax.Datetime::from_epoch_millis(0L).to_string(),
"1970-01-01T00:00:00.000Z",
)
}

A Cid is parsed, not merely shape-checked, so the codec is readable — which is how a blob reference (raw bytes) is told from a record reference (DAG-CBOR):

///|
test {
let blob = @syntax.Cid::parse(
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku",
)
assert_true(blob.codec() is Raw)
let record = @syntax.Cid::parse(
"bafyreidfayvfuwqa2to2ujkxmgphvzz5xshbeezm2mfpvfhhzuq6f4sxrq",
)
assert_true(record.codec() is DagCbor)
}

#Languages have two levels

BCP 47 well-formedness and RFC 5646 validity are different questions, and the protocol's own corpus has a file of tags that pass one and fail the other. The Lexicon language format applies the looser one, so that is what a decoder must use — being stricter than the protocol means rejecting records other clients happily wrote.

///|
test {
// Well-formed, but not valid: the primary subtag must be lower-case.
assert_true(@syntax.Language::is_well_formed("JA"))
assert_false(@syntax.Language::is_valid("JA"))

// Valid, and so also well-formed.
assert_true(@syntax.Language::is_valid("en-GB"))
assert_true(@syntax.Language::is_valid("i-default")) // grandfathered
assert_true(@syntax.Language::is_valid("sl-rozaj-biske"))

// A repeated variant subtag is well-formed and not valid.
assert_false(@syntax.Language::is_valid("de-DE-1901-1901"))
}

#Errors

One error type, carrying which syntax failed, the input, and the reason. The reason is @atproto/syntax's own message, unchanged — which is a constraint the tests enforce, so a rejection here can be shown to happen for the same reason the reference implementation rejects, not merely at the same time.

///|
test {
try @syntax.Handle::parse("john..test") |> ignore catch {
e => {
assert_true(e.kind() is Handle)
assert_eq(e.reason(), "Handle parts can not be empty")
}
} noraise {
_ => fail("expected a raise")
}
}

Where a check is policy rather than syntax it is a separate question, because the answer changes over time and a handle that stops being allowed does not retroactively stop being well-formed:

///|
test {
let laptop = @syntax.Handle::parse("laptop.local")
assert_false(laptop.is_valid_tld())
assert_true(@syntax.Handle::parse("alice.bsky.social").is_valid_tld())
}

#The data model, not JSON

A record is not a Json. It is a value in the atproto data model, which has integers but no floats, and two byte-ish kinds JSON can only spell as tagged objects. @data.LexValue is that model, and it is what stands in for Json everywhere in this library — Json appears in no public signature at all.

///|
test {
let post = @data.LexValue::parse(
(
#|{"$type":"app.bsky.feed.post","text":"hello","langs":["en"]}
),
)
assert_eq(post.type_tag(), Some("app.bsky.feed.post"))
assert_eq(post.get("text").unwrap().as_string(), Some("hello"))

// `$link` and `$bytes` are decoded, so no caller ever sees them as maps.
let ref_ = @data.LexValue::parse(
(
#|{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}
),
)
assert_true(ref_.as_link() is Some(_))
}

The property everything above rests on: a record read and written back is byte for byte what arrived. Fields the library has never heard of survive, because Lexicons gain them continuously and a client that truncated records on read-modify-write would destroy data it did not know about.

///|
test {
let text =
#|{"$type":"app.bsky.feed.post","text":"hi","fieldFromNextYear":[1,2]}
assert_eq(@data.LexValue::parse(text).stringify(), text)
}

#DID documents

Where an account's PDS is written down — and a claim about its handle, which is only a claim until you check it in both directions.

///|
test {
let doc = @identity.DidDocument::from_lex(
@data.LexValue::parse(
(
#|{"id":"did:plc:7iza6de2dwap2sbkpav7c6c6",
#| "alsoKnownAs":["at://alice.bsky.social"],
#| "service":[{"id":"#atproto_pds",
#| "type":"AtprotoPersonalDataServer",
#| "serviceEndpoint":"https://shimeji.us-east.host.bsky.network"}]}
),
),
)
assert_eq(
doc.pds_endpoint(),
Some("https://shimeji.us-east.host.bsky.network"),
)
// The handle the document claims -- and whether it matches the one you asked
// about, which is the half that makes it mean anything.
assert_true(doc.verify_handle(@syntax.Handle::parse("alice.bsky.social")))
}

Service ids are matched in both the relative (#atproto_pds) and absolute (did:plc:xyz#atproto_pds) spellings, because both are in the wild. An endpoint that is not an http/https URL is not returned at all: a DID document comes from a third party and decides where this library would send an access token.

#Talking to a server

@xrpc is the wire protocol and nothing else: it builds a request and reads a response, and it never opens a socket. Transport is the seam.

///|
test {
let request = @xrpc.build_request(
"https://bsky.social",
@syntax.Nsid::parse("app.bsky.feed.getAuthorFeed"),
Query,
params={
let p = @xrpc.Params::new()
p.put_string("actor", Some("alice.bsky.social"))
p.put_int("limit", Some(30L))
p.put_string("cursor", None) // absent arguments vanish
p
},
)
assert_eq(
request.url,
"https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=alice.bsky.social&limit=30",
)
assert_eq(request.http_method, "GET")
}

Failures are typed, and the thing to branch on is the error NAME, which each Lexicon declares:

///|
test {
let response = @testing.error_response(
400,
"InvalidSwap",
message="swapCommit did not match",
)
try @xrpc.interpret(response) |> ignore catch {
e => {
assert_eq(e.error_name(), "InvalidSwap")
assert_false(e.should_retry())
}
} noraise {
_ => fail("expected a raise")
}

// A 429 is its own variant, because it is the one status you handle by
// waiting rather than by giving up.
try @xrpc.interpret(@testing.rate_limited(30)) |> ignore catch {
e => {
assert_true(e.should_retry())
assert_eq(e.retry_after_millis(), Some(30000L))
}
} noraise {
_ => fail("expected a raise")
}
}

Nothing here sleeps. should_retry and retry_after_millis compute; the caller waits. Sleeping needs a runtime, and depending on one would cost this package three backends.

#Bringing the network

moon add marianoguerra/atproto-http # native only

...or implement @xrpc.Transport over whatever HTTP client you already have. It is two methods. Three rules the library relies on, each documented at the point atproto-http obeys it: lower-case the response header names, do not forward content-length, and do not let your HTTP client's errors escape.

For tests, @testing.FakeTransport answers from a scripted list and records what went out — no socket, every backend.

#unchecked

Every identifier has one, and it means what it says: no validation, no normalization. It is for values that were checked at some earlier boundary — read back out of a database this library wrote, say — and for tests. If you are reaching for it on input from a user or a network, reach for parse instead.

#Not included

No DAG-CBOR, no CID computation, no CAR files, no firehose, no OAuth. A blob is bytes and stays bytes. See the repository README for what is planned.

#Licence

Apache-2.0. See LICENSE.

AtUri

A syntactically valid AT-URI.

AtUriPath

What an AT-URI points at. A record key without a collection is not a state this type can hold.

BlobRef

A reference to a blob, in whichever of the two encodings it arrived in.

Body

What goes in the request body.

Cid

A parsed CID, holding the string it came from.

As everywhere else in this package the original bytes are what re-serializes, which for a CID matters twice over: it is a content hash, so a re-encoding that differs is a reference to nothing.

CidCodec

Multicodec content types, the ones atproto uses.

Credential

How the caller proves who it is.

Datetime

A syntactically and semantically valid RFC 3339 datetime, holding the exact bytes it was parsed from.

DatetimeParts

The parsed pieces. Kept alongside the string rather than replacing it, so callers can do arithmetic without the value losing its original spelling.

Did

A syntactically valid DID.

Opaque: the only ways in are parse, which checks, and unchecked, which says in its name that it does not.

Handle

A syntactically valid handle. Always lower-case: parse normalizes, because handles are domain names and DNS is case-insensitive, so treating Alice.BSky.social and alice.bsky.social as different values would be a bug waiting to be written.

HttpRequest

One outbound call, already reduced to bytes.

Language

A well-formed BCP 47 language tag, holding the bytes it was parsed from. Case is meaningful to readers by convention but not to equality in the protocol; nothing here normalizes, because a record must round-trip.

LexValue

A value in the atproto data model.

Method

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

Nsid

A syntactically valid NSID.

Params

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.

RecordKey

A syntactically valid record key.

SyntaxKind

Which syntax a value failed to satisfy.

Ticker

Mints TIDs that increase even when the clock does not.

Two calls in the same microsecond -- or across a clock that has gone backwards -- must not produce the same key or a decreasing one, because a repository's records are ordered by it. So the ticker keeps the last value it issued and steps past it, which costs a microsecond of drift and buys monotonicity.

Tid

A syntactically valid TID.

Compare is derived from the string, which is correct precisely because of the sortable alphabet -- see the note above before changing it.

Transport

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.

Uri

A string shaped like a URI.

Source Files