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
moon add marianoguerra/atproto@0.1.0
Download zip
Version
0.1.0
License
Apache-2.0
Last updated
4 days ago
Downloads
9
README

#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