Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

swap dropshot for poem_openapi

it's... nice here?

Changed files
+355 -289
slingshot
+200 -7
Cargo.lock
···
]
[[package]]
name = "derive_utils"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "hyper-util"
-
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
dependencies = [
"base64 0.22.1",
"bytes",
···
"libc",
"percent-encoding",
"pin-project-lite",
-
"socket2 0.5.9",
"system-configuration",
"tokio",
"tower-service",
···
"memchr",
"mime",
"spin",
"version_check",
]
···
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"wasi 0.11.0+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
···
dependencies = [
"hmac",
"subtle",
]
[[package]]
···
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"clap",
"ctrlc",
-
"dropshot",
"env_logger",
"foyer",
-
"http",
"jetstream",
"log",
"metrics",
"metrics-exporter-prometheus 0.17.2",
-
"schemars",
-
"semver",
"serde",
"serde_json",
"thiserror 2.0.12",
···
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unsigned-varint"
···
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
[[package]]
name = "winapi"
···
]
[[package]]
+
name = "derive_more"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
+
dependencies = [
+
"derive_more-impl",
+
]
+
+
[[package]]
+
name = "derive_more-impl"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.103",
+
"unicode-xid",
+
]
+
+
[[package]]
name = "derive_utils"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "hyper-util"
+
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
dependencies = [
"base64 0.22.1",
"bytes",
···
"libc",
"percent-encoding",
"pin-project-lite",
+
"socket2 0.6.0",
"system-configuration",
"tokio",
"tower-service",
···
"memchr",
"mime",
"spin",
+
"tokio",
"version_check",
]
···
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
+
name = "poem"
+
version = "3.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9f977080932c87287147dca052951c3e2696f8759863f6b4e4c0c9ffe7a4cc8b"
+
dependencies = [
+
"bytes",
+
"futures-util",
+
"headers",
+
"http",
+
"http-body-util",
+
"hyper",
+
"hyper-util",
+
"mime",
+
"multer",
+
"nix",
+
"parking_lot",
+
"percent-encoding",
+
"pin-project-lite",
+
"poem-derive",
+
"quick-xml",
+
"regex",
+
"rfc7239",
+
"serde",
+
"serde_json",
+
"serde_urlencoded",
+
"serde_yaml",
+
"smallvec",
+
"sync_wrapper",
+
"tempfile",
+
"thiserror 2.0.12",
+
"tokio",
+
"tokio-stream",
+
"tokio-util",
+
"tracing",
+
"wildmatch",
+
]
+
+
[[package]]
+
name = "poem-derive"
+
version = "3.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "056e2fea6de1cb240ffe23cfc4fc370b629f8be83b5f27e16b7acd5231a72de4"
+
dependencies = [
+
"proc-macro-crate",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.103",
+
]
+
+
[[package]]
+
name = "poem-openapi"
+
version = "5.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ccbcc395bf4dd03df1da32da351b6b6732e4074ce27ddec315650e52a2be44c"
+
dependencies = [
+
"base64 0.22.1",
+
"bytes",
+
"derive_more",
+
"futures-util",
+
"indexmap 2.9.0",
+
"itertools 0.14.0",
+
"mime",
+
"num-traits",
+
"poem",
+
"poem-openapi-derive",
+
"quick-xml",
+
"regex",
+
"serde",
+
"serde_json",
+
"serde_urlencoded",
+
"serde_yaml",
+
"thiserror 2.0.12",
+
"tokio",
+
]
+
+
[[package]]
+
name = "poem-openapi-derive"
+
version = "5.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "41273b691a3d467a8c44d05506afba9f7b6bd56c9cdf80123de13fe52d7ec587"
+
dependencies = [
+
"darling 0.20.11",
+
"http",
+
"indexmap 2.9.0",
+
"mime",
+
"proc-macro-crate",
+
"proc-macro2",
+
"quote",
+
"regex",
+
"syn 2.0.103",
+
"thiserror 2.0.12",
+
]
+
+
[[package]]
name = "portable-atomic"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "proc-macro-crate"
+
version = "3.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
+
dependencies = [
+
"toml_edit",
+
]
+
+
[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"wasi 0.11.0+wasi-snapshot-preview1",
"web-sys",
"winapi",
+
]
+
+
[[package]]
+
name = "quick-xml"
+
version = "0.36.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
+
dependencies = [
+
"memchr",
+
"serde",
]
[[package]]
···
dependencies = [
"hmac",
"subtle",
+
]
+
+
[[package]]
+
name = "rfc7239"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4a82f1d1e38e9a85bb58ffcfadf22ed6f2c94e8cd8581ec2b0f80a2a6858350f"
+
dependencies = [
+
"uncased",
]
[[package]]
···
]
[[package]]
+
name = "serde_yaml"
+
version = "0.9.34+deprecated"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+
dependencies = [
+
"indexmap 2.9.0",
+
"itoa",
+
"ryu",
+
"serde",
+
"unsafe-libyaml",
+
]
+
+
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"clap",
"ctrlc",
"env_logger",
"foyer",
"jetstream",
"log",
"metrics",
"metrics-exporter-prometheus 0.17.2",
+
"poem",
+
"poem-openapi",
"serde",
"serde_json",
"thiserror 2.0.12",
···
]
[[package]]
+
name = "tokio-stream"
+
version = "0.1.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+
dependencies = [
+
"futures-core",
+
"pin-project-lite",
+
"tokio",
+
]
+
+
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "uncased"
+
version = "0.9.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
+
dependencies = [
+
"version_check",
+
]
+
+
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+
[[package]]
+
name = "unicode-xid"
+
version = "0.2.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+
[[package]]
+
name = "unsafe-libyaml"
+
version = "0.2.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "unsigned-varint"
···
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
+
+
[[package]]
+
name = "wildmatch"
+
version = "2.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd"
[[package]]
name = "winapi"
+2 -4
slingshot/Cargo.toml
···
[dependencies]
clap = { version = "4.5.41", features = ["derive"] }
ctrlc = "3.4.7"
-
dropshot = "0.16.2"
env_logger = "0.11.8"
foyer = { version = "0.18.0", features = ["serde"] }
-
http = "1.3.1"
jetstream = { path = "../jetstream", features = ["metrics"] }
log = "0.4.27"
metrics = "0.24.2"
metrics-exporter-prometheus = { version = "0.17.1", features = ["http-listener"] }
-
schemars = { version = "0.8.22", features = ["raw_value"] }
-
semver = "1.0.26"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.141", features = ["raw_value"] }
thiserror = "2.0.12"
···
[dependencies]
clap = { version = "4.5.41", features = ["derive"] }
ctrlc = "3.4.7"
env_logger = "0.11.8"
foyer = { version = "0.18.0", features = ["serde"] }
jetstream = { path = "../jetstream", features = ["metrics"] }
log = "0.4.27"
metrics = "0.24.2"
metrics-exporter-prometheus = { version = "0.17.1", features = ["http-listener"] }
+
poem = "3.1.12"
+
poem-openapi = { version = "5.1.16", features = ["scalar"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.141", features = ["raw_value"] }
thiserror = "2.0.12"
-10
slingshot/src/error.rs
···
#[derive(Debug, Error)]
pub enum ServerError {
-
#[error("failed to configure server logger: {0}")]
-
ConfigLogError(std::io::Error),
-
#[error("failed to render json for openapi: {0}")]
-
OpenApiJsonFail(serde_json::Error),
-
#[error(transparent)]
-
FailedToBuildServer(#[from] dropshot::BuildError),
#[error("server exited: {0}")]
ServerExited(String),
-
#[error("server closed badly: {0}")]
-
BadClose(String),
-
#[error("blahhhahhahha")]
-
OhNo(String),
}
#[derive(Debug, Error)]
···
#[derive(Debug, Error)]
pub enum ServerError {
#[error("server exited: {0}")]
ServerExited(String),
}
#[derive(Debug, Error)]
+153 -268
slingshot/src/server.rs
···
-
use serde_json::value::RawValue;
-
use crate::CachedRecord;
use foyer::HybridCache;
-
use crate::error::ServerError;
-
use dropshot::{
-
ApiDescription, Body, ConfigDropshot, ConfigLogging,
-
ConfigLoggingLevel, HttpError, HttpResponse, Query, RequestContext,
-
ServerBuilder, ServerContext, endpoint,
-
ClientErrorStatusCode,
-
};
-
use http::{
-
Response, StatusCode,
-
header::{ORIGIN, USER_AGENT},
-
};
-
use metrics::{counter, histogram};
-
use std::sync::Arc;
-
-
use schemars::JsonSchema;
-
use serde::{Deserialize, Serialize};
-
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
-
const INDEX_HTML: &str = include_str!("../static/index.html");
-
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
-
-
pub async fn serve(
-
cache: HybridCache<String, CachedRecord>,
-
shutdown: CancellationToken,
-
) -> Result<(), ServerError> {
-
let config_logging = ConfigLogging::StderrTerminal {
-
level: ConfigLoggingLevel::Info,
-
};
-
-
let log = config_logging
-
.to_logger("example-basic")
-
.map_err(ServerError::ConfigLogError)?;
-
-
let mut api = ApiDescription::new();
-
api.register(index).unwrap();
-
api.register(favicon).unwrap();
-
api.register(openapi).unwrap();
-
api.register(get_record).unwrap();
-
-
// TODO: put spec in a once cell / lazy lock thing?
-
let spec = Arc::new(
-
api.openapi(
-
"Slingshot",
-
env!("CARGO_PKG_VERSION")
-
.parse()
-
.inspect_err(|e| {
-
eprintln!("failed to parse cargo package version for openapi: {e:?}")
-
})
-
.unwrap_or(semver::Version::new(0, 0, 1)),
-
)
-
.description("A fast edge cache for getRecord")
-
.contact_name("part of @microcosm.blue")
-
.contact_url("https://microcosm.blue")
-
.json()
-
.map_err(ServerError::OpenApiJsonFail)?,
-
);
-
let sub_shutdown = shutdown.clone();
-
let ctx = Context {
-
cache,
-
spec,
-
shutdown: sub_shutdown,
-
};
-
-
let server = ServerBuilder::new(api, ctx, log)
-
.config(ConfigDropshot {
-
bind_address: "0.0.0.0:9996".parse().unwrap(),
-
..Default::default()
-
})
-
.start()?;
-
-
tokio::select! {
-
s = server.wait_for_shutdown() => {
-
s.map_err(ServerError::ServerExited)?;
-
log::info!("server shut down normally.");
-
},
-
_ = shutdown.cancelled() => {
-
log::info!("shutting down: closing server");
-
server.close().await.map_err(ServerError::BadClose)?;
-
},
-
}
-
Ok(())
}
-
#[derive(Debug, Clone)]
-
struct Context {
-
pub cache: HybridCache<String, CachedRecord>,
-
pub spec: Arc<serde_json::Value>,
-
pub shutdown: CancellationToken,
}
-
-
async fn instrument_handler<T, H, R>(ctx: &RequestContext<T>, handler: H) -> Result<R, HttpError>
-
where
-
R: HttpResponse,
-
H: Future<Output = Result<R, HttpError>>,
-
T: ServerContext,
-
{
-
let start = Instant::now();
-
let result = handler.await;
-
let latency = start.elapsed();
-
let status_code = match &result {
-
Ok(response) => response.status_code(),
-
Err(e) => e.status_code.as_status(),
}
-
.as_str() // just the number (.to_string()'s Display does eg `200 OK`)
-
.to_string();
-
let endpoint = ctx.endpoint.operation_id.clone();
-
let headers = ctx.request.headers();
-
let origin = headers
-
.get(ORIGIN)
-
.and_then(|v| v.to_str().ok())
-
.unwrap_or("")
-
.to_string();
-
let ua = headers
-
.get(USER_AGENT)
-
.and_then(|v| v.to_str().ok())
-
.map(|ua| {
-
if ua.starts_with("Mozilla/5.0 ") {
-
"browser"
-
} else {
-
ua
-
}
-
})
-
.unwrap_or("")
-
.to_string();
-
counter!("server_requests_total",
-
"endpoint" => endpoint.clone(),
-
"origin" => origin,
-
"ua" => ua,
-
"status_code" => status_code,
-
)
-
.increment(1);
-
histogram!("server_handler_latency", "endpoint" => endpoint).record(latency.as_micros() as f64);
-
result
}
-
use dropshot::{HttpResponseHeaders, HttpResponseOk};
-
pub type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>;
-
-
/// Helper for constructing Ok responses: return OkCors(T).into()
-
/// (not happy with this yet)
-
pub struct OkCors<T: Serialize + JsonSchema + Send + Sync>(pub T);
-
-
impl<T> From<OkCors<T>> for OkCorsResponse<T>
-
where
-
T: Serialize + JsonSchema + Send + Sync,
-
{
-
fn from(ok: OkCors<T>) -> OkCorsResponse<T> {
-
let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0));
-
res.headers_mut()
-
.insert("access-control-allow-origin", "*".parse().unwrap());
-
Ok(res)
-
}
}
-
pub fn cors_err(e: HttpError) -> HttpError {
-
e.with_header("access-control-allow-origin", "*").unwrap()
}
-
-
-
// TODO: cors for HttpError
-
-
/// Serve index page as html
-
#[endpoint {
-
method = GET,
-
path = "/",
-
/*
-
* not useful to have this in openapi
-
*/
-
unpublished = true,
-
}]
-
async fn index(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> {
-
instrument_handler(&ctx, async {
-
Ok(Response::builder()
-
.status(StatusCode::OK)
-
.header(http::header::CONTENT_TYPE, "text/html")
-
.body(INDEX_HTML.into())?)
-
})
-
.await
}
-
/// Serve index page as html
-
#[endpoint {
-
method = GET,
-
path = "/favicon.ico",
-
/*
-
* not useful to have this in openapi
-
*/
-
unpublished = true,
-
}]
-
async fn favicon(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> {
-
instrument_handler(&ctx, async {
-
Ok(Response::builder()
-
.status(StatusCode::OK)
-
.header(http::header::CONTENT_TYPE, "image/x-icon")
-
.body(FAVICON.to_vec().into())?)
-
})
-
.await
}
-
/// Meta: get the openapi spec for this api
-
#[endpoint {
-
method = GET,
-
path = "/openapi",
-
/*
-
* not useful to have this in openapi
-
*/
-
unpublished = true,
-
}]
-
async fn openapi(ctx: RequestContext<Context>) -> OkCorsResponse<serde_json::Value> {
-
instrument_handler(&ctx, async {
-
let spec = (*ctx.context().spec).clone();
-
OkCors(spec).into()
-
})
-
.await
}
-
-
#[derive(Debug, Deserialize, JsonSchema)]
-
struct GetRecordQuery {
-
/// The DID of the repo
-
///
-
/// NOTE: handles should be accepted here but this is still TODO in slingshot
-
pub repo: String,
-
/// The NSID of the record collection
-
pub collection: String,
-
/// The Record key
-
pub rkey: String,
-
/// Optional: the CID of the version of the record.
///
-
/// If not specified, then return the most recent version.
///
-
/// If specified and a newer version of the record exists, returns 404 not
-
/// found. That is: slingshot only retains the most recent version of a
-
/// record.
-
#[serde(default)]
-
pub cid: Option<String>,
-
}
-
-
#[derive(Debug, Serialize, JsonSchema)]
-
struct GetRecordResponse {
-
pub uri: String,
-
pub cid: String,
-
pub value: Box<RawValue>,
-
}
-
-
/// com.atproto.repo.getRecord
-
///
-
/// Get a single record from a repository. Does not require auth.
-
///
-
/// See https://docs.bsky.app/docs/api/com-atproto-repo-get-record for the
-
/// canonical XRPC documentation that this endpoint aims to be compatible with.
-
#[endpoint {
-
method = GET,
-
path = "/xrpc/com.atproto.repo.getRecord",
-
}]
-
async fn get_record(
-
ctx: RequestContext<Context>,
-
query: Query<GetRecordQuery>,
-
) -> OkCorsResponse<GetRecordResponse> {
-
-
let Context { cache, .. } = ctx.context();
-
let GetRecordQuery { repo, collection, rkey, cid } = query.into_inner();
-
// TODO: yeah yeah
-
let at_uri = format!(
-
"at://{}/{}/{}",
-
&*repo, &*collection, &*rkey
-
);
-
-
instrument_handler(&ctx, async {
-
let entry = cache
.fetch(at_uri.clone(), || async move {
-
Err(foyer::Error::Other(Box::new(ServerError::OhNo("booo".to_string()))))
})
.await
.unwrap();
match *entry {
CachedRecord::Found(ref raw) => {
let (found_cid, raw_value) = raw.into();
let found_cid = found_cid.as_ref().to_string();
-
if cid.map(|c| c != found_cid).unwrap_or(false) {
-
Err(HttpError::for_not_found(None, "CID mismatch".to_string()))
-
.map_err(cors_err)?;
}
-
OkCors(GetRecordResponse {
uri: at_uri,
-
cid: found_cid,
-
value: raw_value,
-
}).into()
},
CachedRecord::Deleted => {
-
Err(HttpError::for_client_error_with_status(
-
Some("Gone".to_string()),
-
ClientErrorStatusCode::GONE,
-
)).map_err(cors_err)
}
}
-
})
-
.await
}
···
use foyer::HybridCache;
+
use crate::{error::ServerError, CachedRecord};
use tokio_util::sync::CancellationToken;
+
use poem::{listener::TcpListener, Route, Server};
+
use poem_openapi::{
+
payload::Json,
+
param::Query,
+
OpenApi, OpenApiService,
+
ApiResponse,
+
Object,
+
types::Example,
+
};
+
fn example_did() -> String {
+
"did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string()
+
}
+
fn example_collection() -> String {
+
"app.bsky.feed.like".to_string()
+
}
+
fn example_rkey() -> String {
+
"3lv4ouczo2b2a".to_string()
}
+
#[derive(Object)]
+
#[oai(example = true)]
+
struct XrpcErrorResponseObject {
+
/// Should correspond an error `name` in the lexicon errors array
+
error: String,
+
/// Human-readable description and possibly additonal context
+
message: String,
}
+
impl Example for XrpcErrorResponseObject {
+
fn example() -> Self {
+
Self {
+
error: "RecordNotFound".to_string(),
+
message: "This record was deleted".to_string(),
+
}
}
}
+
fn bad_request_handler(err: poem::Error) -> GetRecordResponse {
+
GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
+
error: "InvalidRequest".to_string(),
+
message: format!("Bad request, here's some info that maybe should not be exposed: {err}"),
+
}))
}
+
#[derive(Object)]
+
#[oai(example = true)]
+
struct FoundRecordResponseObject {
+
/// at-uri for this record
+
uri: String,
+
/// CID for this exact version of the record
+
///
+
/// Slingshot will always return the CID, despite it not being a required
+
/// response property in the official lexicon.
+
cid: Option<String>,
+
/// the record itself as JSON
+
value: serde_json::Value,
}
+
impl Example for FoundRecordResponseObject {
+
fn example() -> Self {
+
Self {
+
uri: format!("at://{}/{}/{}", example_did(), example_collection(), example_rkey()),
+
cid: Some("bafyreialv3mzvvxaoyrfrwoer3xmabbmdchvrbyhayd7bga47qjbycy74e".to_string()),
+
value: serde_json::json!({
+
"$type": "app.bsky.feed.like",
+
"createdAt": "2025-07-29T18:02:02.327Z",
+
"subject": {
+
"cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy",
+
"uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k"
+
}
+
}),
+
}
+
}
}
+
#[derive(ApiResponse)]
+
#[oai(bad_request_handler = "bad_request_handler")]
+
enum GetRecordResponse {
+
/// Record found
+
#[oai(status = 200)]
+
Ok(Json<FoundRecordResponseObject>),
+
/// Bad request or no record to return
+
///
+
/// The only error name in the repo.getRecord lexicon is `RecordNotFound`,
+
/// but the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)
+
/// also list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of
+
/// these, slingshot will only return `RecordNotFound` or `InvalidRequest`.
+
#[oai(status = 400)]
+
BadRequest(Json<XrpcErrorResponseObject>),
}
+
struct Xrpc {
+
cache: HybridCache<String, CachedRecord>,
}
+
#[OpenApi]
+
impl Xrpc {
+
/// com.atproto.repo.getRecord
///
+
/// Get a single record from a repository. Does not require auth.
///
+
/// See https://docs.bsky.app/docs/api/com-atproto-repo-get-record for the
+
/// canonical XRPC documentation that this endpoint aims to be compatible
+
/// with.
+
#[oai(path = "/com.atproto.repo.getRecord", method = "get")]
+
async fn get_record(
+
&self,
+
/// The DID of the repo
+
///
+
/// NOTE: handles should be accepted here but this is still TODO in slingshot
+
#[oai(example = "example_did")]
+
repo: Query<String>,
+
/// The NSID of the record collection
+
#[oai(example = "example_collection")]
+
collection: Query<String>,
+
/// The Record key
+
#[oai(example = "example_rkey")]
+
rkey: Query<String>,
+
/// Optional: the CID of the version of the record.
+
///
+
/// If not specified, then return the most recent version.
+
///
+
/// If specified and a newer version of the record exists, returns 404 not
+
/// found. That is: slingshot only retains the most recent version of a
+
/// record.
+
cid: Query<Option<String>>,
+
) -> GetRecordResponse {
+
// TODO: yeah yeah
+
let at_uri = format!(
+
"at://{}/{}/{}",
+
&*repo, &*collection, &*rkey
+
);
+
let entry = self.cache
.fetch(at_uri.clone(), || async move {
+
todo!()
})
.await
.unwrap();
+
+
// TODO: actual 404
match *entry {
CachedRecord::Found(ref raw) => {
let (found_cid, raw_value) = raw.into();
let found_cid = found_cid.as_ref().to_string();
+
if cid.clone().map(|c| c != found_cid).unwrap_or(false) {
+
return GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
+
error: "RecordNotFound".to_string(),
+
message: "A record was found but its CID did not match that requested".to_string(),
+
}));
}
+
// TODO: thank u stellz: https://gist.github.com/stella3d/51e679e55b264adff89d00a1e58d0272
+
let value = serde_json::from_str(raw_value.get()).expect("RawValue to be valid json");
+
GetRecordResponse::Ok(Json(FoundRecordResponseObject {
uri: at_uri,
+
cid: Some(found_cid),
+
value,
+
}))
},
CachedRecord::Deleted => {
+
GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
+
error: "RecordNotFound".to_string(),
+
message: "This record was deleted".to_string(),
+
}))
}
}
+
}
+
}
+
pub async fn serve(
+
cache: HybridCache<String, CachedRecord>,
+
_shutdown: CancellationToken,
+
) -> Result<(), ServerError> {
+
let api_service =
+
OpenApiService::new(Xrpc { cache }, "Slingshot", env!("CARGO_PKG_VERSION"))
+
.server("http://localhost:3000")
+
.url_prefix("/xrpc");
+
+
let app = Route::new()
+
.nest("/", api_service.scalar())
+
.nest("/openapi.json", api_service.spec_endpoint())
+
.nest("/xrpc/", api_service);
+
+
Server::new(TcpListener::bind("127.0.0.1:3000"))
+
.run(app)
+
.await
+
.map_err(|e| ServerError::ServerExited(format!("uh oh: {e:?}")))
}