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.
moon add marianoguerra/atprotoStatus: 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.
// 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}")
}// 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...// nocheck: needs a transport.
///|
let everything = client.feed_get_author_feed_all(actor~, max_pages=5)///|
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"))
}///|
test {
let who = @syntax.AtIdentifier::parse("did:plc:abc123")
assert_true(who.as_did() is Some(_))
assert_true(who.as_handle() is None)
}///|
test {
let post = @syntax.Nsid::parse("app.bsky.feed.post")
assert_eq(post.authority(), "feed.bsky.app")
assert_eq(post.name(), "post")
}///|
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"))
}///|
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)
}///|
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)
}///|
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"),
)
}///|
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)
}///|
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/."),
)
}///|
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",
)
}///|
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)
}///|
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"))
}///|
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")
}
}///|
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())
}///|
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(_))
}///|
test {
let text =
#|{"$type":"app.bsky.feed.post","text":"hi","fieldFromNextYear":[1,2]}
assert_eq(@data.LexValue::parse(text).stringify(), text)
}///|
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")))
}///|
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")
}///|
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")
}
}moon add marianoguerra/atproto-http # native onlyAT 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.