[READ-ONLY] Mirror of https://github.com/metruzanca/atcrab. Rust AT Protocol libraries
atproto rust
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add authentication, write operations, and blob upload

- Session management via com.atproto.server.createSession
- createRecord, putRecord, deleteRecord write operations
- Blob upload support (uploadBlob)
- Auto-load credentials from .env via dotenvy
- Builder-style set_password().login() API
- New example: create_record (CRUD with dummy schema)
- New example: upload_blob (upload + inspect)
- Add features section to readme
- Fix: use POST instead of PUT for putRecord
- Fix: add skip_serializing_if to all fields
- Fix: return Error::Auth instead of panicking on missing password

Sam Zanca (Jul 4, 2026, 2:42 AM EDT) a30ac8c5 93a20c75

+340 -13
+1
.gitignore
··· 1 1 /target 2 2 3 3 references 4 + .env
+4
AGENTS.md
··· 1 + # Repository Rules 2 + - Keep things simple and avoid hasty abstractions. 3 + - When committing use conventional commits. 4 + - This is a library, don't panic instead return errors so the client can handle the errors
+7
Cargo.lock
··· 17 17 name = "atcrab" 18 18 version = "0.1.0" 19 19 dependencies = [ 20 + "dotenvy", 20 21 "hickory-resolver", 21 22 "reqwest", 22 23 "serde", ··· 93 94 "quote", 94 95 "syn", 95 96 ] 97 + 98 + [[package]] 99 + name = "dotenvy" 100 + version = "0.15.7" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 96 103 97 104 [[package]] 98 105 name = "enum-as-inner"
+1
Cargo.toml
··· 10 10 tokio = { version = "1", features = ["full"] } 11 11 thiserror = "2" 12 12 hickory-resolver = "0.24" 13 + dotenvy = "0.15"
+45
examples/create_record.rs
··· 1 + use atcrab::{Collection, CreateRecordOutput, Repo}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Serialize, Deserialize, Debug, Clone)] 5 + struct Note { 6 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 7 + r#type: Option<String>, 8 + text: String, 9 + #[serde(rename = "createdAt")] 10 + created_at: String, 11 + } 12 + 13 + impl Collection for Note { 14 + const NSID: &'static str = "net.example.test"; 15 + } 16 + 17 + #[tokio::main] 18 + async fn main() -> Result<(), Box<dyn std::error::Error>> { 19 + let mut repo = Repo::new("metru.dev").await?; 20 + repo.login().await?; 21 + 22 + let note = Note { 23 + r#type: Some(Note::NSID.into()), 24 + text: "hello from atcrab".into(), 25 + created_at: "2025-01-01T00:00:00.000Z".into(), 26 + }; 27 + 28 + let created: CreateRecordOutput = repo.create_record(Note::NSID, &note).await?; 29 + println!("Created: {}", created.uri); 30 + 31 + let record = Note { 32 + r#type: Some(Note::NSID.into()), 33 + text: "updated!".into(), 34 + created_at: "2025-01-01T00:00:00.000Z".into(), 35 + }; 36 + 37 + let rkey = created.uri.rsplit('/').next().unwrap(); 38 + let updated = repo.put_record(Note::NSID, rkey, &record, None).await?; 39 + println!("Updated: {}", updated.uri); 40 + 41 + repo.delete_record(Note::NSID, rkey).await?; 42 + println!("Deleted: {}", rkey); 43 + 44 + Ok(()) 45 + }
+17
examples/upload_blob.rs
··· 1 + use atcrab::Repo; 2 + 3 + #[tokio::main] 4 + async fn main() -> Result<(), Box<dyn std::error::Error>> { 5 + let mut repo = Repo::new("metru.dev").await?; 6 + repo.login().await?; 7 + 8 + let blob = repo 9 + .upload_blob(b"hello world".to_vec(), "text/plain") 10 + .await?; 11 + println!("Uploaded blob: {}", blob.blob_ref.link); 12 + println!(" mimeType: {}", blob.mime_type); 13 + println!(" size: {}", blob.size); 14 + 15 + // Unreferenced blobs are garbage collected after a time span. No worries here. 16 + Ok(()) 17 + }
+9
readme.md
··· 6 6 > 7 7 > Do not use this in production. See https://atproto.com/sdks for alternatives. 8 8 9 + ## Features 10 + 11 + - **Handle & DID resolution** - resolve any handle to a DID via DNS (`_atproto`) or `.well-known`, then resolve the PDS endpoint from PLC or `did:web` documents 12 + - **Read records** - fetch records from any collection with built-in pagination and automatic cursor handling 13 + - **Write records** - create, update, and delete records in your own PDS repository 14 + - **Auth** - login with app passwords, auto-loaded from `.env` or environment variables (`ATP_PASSWORD`) 15 + - **Blob upload** - upload images and other binary data to your PDS 16 + - **Built-in lexicon types** - ready-to-use types for [standard.site](https://standard.site) lexicons 17 + 9 18 ## Adding as a dependency 10 19 11 20 ```toml
+49
src/auth.rs
··· 1 + use serde::Deserialize; 2 + 3 + use crate::error::Error; 4 + 5 + #[derive(Debug, Clone)] 6 + pub struct Session { 7 + pub access_jwt: String, 8 + pub refresh_jwt: String, 9 + pub did: String, 10 + pub handle: String, 11 + } 12 + 13 + #[derive(Deserialize)] 14 + struct CreateSessionResponse { 15 + #[serde(rename = "accessJwt")] 16 + access_jwt: String, 17 + #[serde(rename = "refreshJwt")] 18 + refresh_jwt: String, 19 + did: String, 20 + handle: String, 21 + } 22 + 23 + pub async fn create_session( 24 + pds: &str, 25 + identifier: &str, 26 + password: &str, 27 + ) -> Result<Session, Error> { 28 + let url = format!("{}/xrpc/com.atproto.server.createSession", pds); 29 + let body = serde_json::json!({ 30 + "identifier": identifier, 31 + "password": password, 32 + }); 33 + let client = reqwest::Client::new(); 34 + let resp = client.post(&url).json(&body).send().await?; 35 + 36 + if !resp.status().is_success() { 37 + let status = resp.status(); 38 + let text = resp.text().await.unwrap_or_default(); 39 + return Err(Error::Auth(format!("createSession failed ({}): {}", status, text))); 40 + } 41 + 42 + let session: CreateSessionResponse = resp.json().await?; 43 + Ok(Session { 44 + access_jwt: session.access_jwt, 45 + refresh_jwt: session.refresh_jwt, 46 + did: session.did, 47 + handle: session.handle, 48 + }) 49 + }
+36
src/blob.rs
··· 1 + use reqwest::Client; 2 + use serde::Deserialize; 3 + 4 + use crate::error::Error; 5 + use crate::lexicons::types::Blob; 6 + 7 + #[derive(Deserialize)] 8 + struct UploadBlobResponse { 9 + blob: Blob, 10 + } 11 + 12 + pub async fn upload_blob( 13 + client: &Client, 14 + pds: &str, 15 + token: &str, 16 + data: Vec<u8>, 17 + mime_type: &str, 18 + ) -> Result<Blob, Error> { 19 + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds); 20 + let resp = client 21 + .post(&url) 22 + .header("Authorization", format!("Bearer {}", token)) 23 + .header("Content-Type", mime_type) 24 + .body(data) 25 + .send() 26 + .await?; 27 + 28 + let status = resp.status(); 29 + if !status.is_success() { 30 + let text = resp.text().await.unwrap_or_default(); 31 + return Err(Error::Status(status, text)); 32 + } 33 + 34 + let body: UploadBlobResponse = resp.json().await?; 35 + Ok(body.blob) 36 + }
+6
src/error.rs
··· 22 22 23 23 #[error("invalid handle: {0}")] 24 24 InvalidHandle(String), 25 + 26 + #[error("auth error: {0}")] 27 + Auth(String), 28 + 29 + #[error("HTTP {}: {}", .0, .1)] 30 + Status(reqwest::StatusCode, String), 25 31 }
+2 -2
src/lexicons/document.rs
··· 10 10 #[derive(Serialize, Deserialize, Debug, Clone)] 11 11 #[serde(rename_all = "camelCase")] 12 12 pub struct Contributor { 13 - #[serde(rename = "$type")] 13 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 14 14 pub r#type: Option<String>, 15 15 pub did: String, 16 16 pub role: Option<String>, ··· 21 21 #[derive(Serialize, Deserialize, Debug, Clone)] 22 22 #[serde(rename_all = "camelCase")] 23 23 pub struct Document { 24 - #[serde(rename = "$type")] 24 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 25 25 pub r#type: Option<String>, 26 26 pub site: String, 27 27 pub path: Option<String>,
+2 -2
src/lexicons/publication.rs
··· 11 11 #[derive(Serialize, Deserialize, Debug, Clone)] 12 12 #[serde(rename_all = "camelCase")] 13 13 pub struct Preferences { 14 - #[serde(rename = "$type")] 14 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 15 15 pub r#type: Option<String>, 16 16 #[serde(rename = "showInDiscover")] 17 17 #[serde(default = "default_true")] ··· 25 25 #[derive(Serialize, Deserialize, Debug, Clone)] 26 26 #[serde(rename_all = "camelCase")] 27 27 pub struct Publication { 28 - #[serde(rename = "$type")] 28 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 29 29 pub r#type: Option<String>, 30 30 pub url: String, 31 31 pub icon: Option<Blob>,
+1 -1
src/lexicons/recommend.rs
··· 9 9 #[derive(Serialize, Deserialize, Debug, Clone)] 10 10 #[serde(rename_all = "camelCase")] 11 11 pub struct Recommend { 12 - #[serde(rename = "$type")] 12 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 13 13 pub r#type: Option<String>, 14 14 pub document: String, 15 15 #[serde(rename = "createdAt")]
+1 -1
src/lexicons/subscription.rs
··· 9 9 #[derive(Serialize, Deserialize, Debug, Clone)] 10 10 #[serde(rename_all = "camelCase")] 11 11 pub struct Subscription { 12 - #[serde(rename = "$type")] 12 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 13 13 pub r#type: Option<String>, 14 14 pub publication: String, 15 15 #[serde(rename = "createdAt")]
+3 -3
src/lexicons/theme.rs
··· 3 3 #[derive(Serialize, Deserialize, Debug, Clone)] 4 4 #[serde(rename_all = "camelCase")] 5 5 pub struct Rgb { 6 - #[serde(rename = "$type")] 6 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 7 7 pub r#type: Option<String>, 8 8 pub r: u8, 9 9 pub g: u8, ··· 13 13 #[derive(Serialize, Deserialize, Debug, Clone)] 14 14 #[serde(rename_all = "camelCase")] 15 15 pub struct Rgba { 16 - #[serde(rename = "$type")] 16 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 17 17 pub r#type: Option<String>, 18 18 pub r: u8, 19 19 pub g: u8, ··· 24 24 #[derive(Serialize, Deserialize, Debug, Clone)] 25 25 #[serde(rename_all = "camelCase")] 26 26 pub struct BasicTheme { 27 - #[serde(rename = "$type")] 27 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 28 28 pub r#type: Option<String>, 29 29 pub background: Rgb, 30 30 pub foreground: Rgb,
+1 -1
src/lexicons/types.rs
··· 21 21 #[derive(Serialize, Deserialize, Debug, Clone)] 22 22 #[serde(rename_all = "camelCase")] 23 23 pub struct StrongRef { 24 - #[serde(rename = "$type")] 24 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 25 25 pub r#type: Option<String>, 26 26 pub uri: String, 27 27 pub cid: String,
+4 -1
src/lib.rs
··· 1 + pub mod auth; 2 + pub mod blob; 1 3 pub mod did; 2 4 pub mod error; 3 5 pub mod handle; ··· 5 7 pub mod repo; 6 8 pub mod types; 7 9 10 + pub use auth::Session; 8 11 pub use error::Error; 9 12 pub use lexicons::Collection; 10 13 pub use repo::Repo; 11 - pub use types::{ListRecords, Record}; 14 + pub use types::{CreateRecordOutput, ListRecords, PutRecordOutput, Record};
+127 -1
src/repo.rs
··· 1 1 use reqwest::Client; 2 2 use serde::de::DeserializeOwned; 3 + use serde::Serialize; 3 4 5 + use crate::auth::{self, Session}; 6 + use crate::blob; 4 7 use crate::did; 5 8 use crate::error::Error; 6 9 use crate::handle; 10 + use crate::lexicons::types::Blob; 7 11 use crate::lexicons::Collection; 8 - use crate::types::{ListRecords, Record}; 12 + use crate::types::{CreateRecordOutput, ListRecords, PutRecordOutput, Record}; 9 13 10 14 pub struct Repo { 11 15 pub did: String, 12 16 pub pds: String, 17 + pub session: Option<Session>, 18 + password: Option<String>, 13 19 client: Client, 14 20 } 15 21 16 22 impl Repo { 17 23 pub async fn new(handle: &str) -> Result<Self, Error> { 24 + dotenvy::dotenv().ok(); 18 25 let did = handle::resolve_handle(handle).await?; 19 26 let pds = did::resolve_pds(&did).await?; 20 27 21 28 Ok(Self { 22 29 did, 23 30 pds, 31 + session: None, 32 + password: None, 24 33 client: Client::new(), 25 34 }) 35 + } 36 + 37 + pub fn set_password(&mut self, password: &str) -> &mut Self { 38 + self.password = Some(password.to_string()); 39 + self 40 + } 41 + 42 + pub async fn login(&mut self) -> Result<(), Error> { 43 + let password = match &self.password { 44 + Some(p) => p.clone(), 45 + None => std::env::var("ATP_PASSWORD") 46 + .map_err(|_| Error::Auth("ATP_PASSWORD not set; call set_password() or set ATP_PASSWORD in .env".into()))?, 47 + }; 48 + let session = auth::create_session(&self.pds, &self.did, &password).await?; 49 + self.did = session.did.clone(); 50 + self.session = Some(session); 51 + Ok(()) 52 + } 53 + 54 + pub async fn create_record<T: Serialize>( 55 + &self, 56 + collection: &str, 57 + record: &T, 58 + ) -> Result<CreateRecordOutput, Error> { 59 + let session = self.session.as_ref().ok_or_else(|| Error::Auth("not logged in".into()))?; 60 + let url = format!("{}/xrpc/com.atproto.repo.createRecord", self.pds); 61 + let body = serde_json::json!({ 62 + "repo": self.did, 63 + "collection": collection, 64 + "record": record, 65 + }); 66 + 67 + let resp = self.client 68 + .post(&url) 69 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 70 + .json(&body) 71 + .send() 72 + .await?; 73 + 74 + let status = resp.status(); 75 + if !status.is_success() { 76 + let text = resp.text().await.unwrap_or_default(); 77 + return Err(Error::Status(status, text)); 78 + } 79 + 80 + Ok(resp.json().await?) 81 + } 82 + 83 + pub async fn put_record<T: Serialize>( 84 + &self, 85 + collection: &str, 86 + rkey: &str, 87 + record: &T, 88 + swap_record: Option<&str>, 89 + ) -> Result<PutRecordOutput, Error> { 90 + let session = self.session.as_ref().ok_or_else(|| Error::Auth("not logged in".into()))?; 91 + let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds); 92 + 93 + let mut body = serde_json::json!({ 94 + "repo": self.did, 95 + "collection": collection, 96 + "rkey": rkey, 97 + "record": record, 98 + }); 99 + 100 + if let Some(cid) = swap_record { 101 + body["swapRecord"] = serde_json::json!(cid); 102 + } 103 + 104 + let resp = self.client 105 + .post(&url) 106 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 107 + .json(&body) 108 + .send() 109 + .await?; 110 + 111 + let status = resp.status(); 112 + if !status.is_success() { 113 + let text = resp.text().await.unwrap_or_default(); 114 + return Err(Error::Status(status, text)); 115 + } 116 + 117 + Ok(resp.json().await?) 118 + } 119 + 120 + pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<(), Error> { 121 + let session = self.session.as_ref().ok_or_else(|| Error::Auth("not logged in".into()))?; 122 + let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds); 123 + let body = serde_json::json!({ 124 + "repo": self.did, 125 + "collection": collection, 126 + "rkey": rkey, 127 + }); 128 + 129 + let resp = self.client 130 + .post(&url) 131 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 132 + .json(&body) 133 + .send() 134 + .await?; 135 + 136 + let status = resp.status(); 137 + if !status.is_success() { 138 + let text = resp.text().await.unwrap_or_default(); 139 + return Err(Error::Status(status, text)); 140 + } 141 + 142 + Ok(()) 143 + } 144 + 145 + pub async fn upload_blob( 146 + &self, 147 + data: Vec<u8>, 148 + mime_type: &str, 149 + ) -> Result<Blob, Error> { 150 + let session = self.session.as_ref().ok_or_else(|| Error::Auth("not logged in".into()))?; 151 + blob::upload_blob(&self.client, &self.pds, &session.access_jwt, data, mime_type).await 26 152 } 27 153 28 154 fn list_records_url(&self, collection: &str, cursor: Option<&str>) -> String {
+24 -1
src/types.rs
··· 1 - use serde::Deserialize; 1 + use serde::{Deserialize, Serialize}; 2 2 3 3 #[derive(Deserialize, Debug)] 4 4 pub struct Record<T> { ··· 12 12 pub records: Vec<Record<T>>, 13 13 pub cursor: Option<String>, 14 14 } 15 + 16 + // --- Write types --- 17 + 18 + #[derive(Deserialize, Debug)] 19 + pub struct CreateRecordOutput { 20 + pub uri: String, 21 + pub cid: String, 22 + } 23 + 24 + #[derive(Deserialize, Debug)] 25 + pub struct PutRecordOutput { 26 + pub uri: String, 27 + pub cid: String, 28 + } 29 + 30 + #[derive(Serialize)] 31 + pub struct DeleteRecordInput { 32 + pub repo: String, 33 + pub collection: String, 34 + pub rkey: String, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub swap_record: Option<String>, 37 + }