···
1
-
use serde_json::value::RawValue;
2
-
use crate::CachedRecord;
4
-
use crate::error::ServerError;
6
-
ApiDescription, Body, ConfigDropshot, ConfigLogging,
7
-
ConfigLoggingLevel, HttpError, HttpResponse, Query, RequestContext,
8
-
ServerBuilder, ServerContext, endpoint,
9
-
ClientErrorStatusCode,
12
-
Response, StatusCode,
13
-
header::{ORIGIN, USER_AGENT},
15
-
use metrics::{counter, histogram};
18
-
use schemars::JsonSchema;
19
-
use serde::{Deserialize, Serialize};
20
-
use tokio::time::Instant;
2
+
use crate::{error::ServerError, CachedRecord};
use tokio_util::sync::CancellationToken;
23
-
const INDEX_HTML: &str = include_str!("../static/index.html");
24
-
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
27
-
cache: HybridCache<String, CachedRecord>,
28
-
shutdown: CancellationToken,
29
-
) -> Result<(), ServerError> {
30
-
let config_logging = ConfigLogging::StderrTerminal {
31
-
level: ConfigLoggingLevel::Info,
34
-
let log = config_logging
35
-
.to_logger("example-basic")
36
-
.map_err(ServerError::ConfigLogError)?;
38
-
let mut api = ApiDescription::new();
39
-
api.register(index).unwrap();
40
-
api.register(favicon).unwrap();
41
-
api.register(openapi).unwrap();
42
-
api.register(get_record).unwrap();
44
-
// TODO: put spec in a once cell / lazy lock thing?
45
-
let spec = Arc::new(
48
-
env!("CARGO_PKG_VERSION")
51
-
eprintln!("failed to parse cargo package version for openapi: {e:?}")
53
-
.unwrap_or(semver::Version::new(0, 0, 1)),
55
-
.description("A fast edge cache for getRecord")
56
-
.contact_name("part of @microcosm.blue")
57
-
.contact_url("https://microcosm.blue")
59
-
.map_err(ServerError::OpenApiJsonFail)?,
5
+
use poem::{listener::TcpListener, Route, Server};
9
+
OpenApi, OpenApiService,
62
-
let sub_shutdown = shutdown.clone();
66
-
shutdown: sub_shutdown,
69
-
let server = ServerBuilder::new(api, ctx, log)
70
-
.config(ConfigDropshot {
71
-
bind_address: "0.0.0.0:9996".parse().unwrap(),
72
-
..Default::default()
77
-
s = server.wait_for_shutdown() => {
78
-
s.map_err(ServerError::ServerExited)?;
79
-
log::info!("server shut down normally.");
81
-
_ = shutdown.cancelled() => {
82
-
log::info!("shutting down: closing server");
83
-
server.close().await.map_err(ServerError::BadClose)?;
15
+
fn example_did() -> String {
16
+
"did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string()
18
+
fn example_collection() -> String {
19
+
"app.bsky.feed.like".to_string()
21
+
fn example_rkey() -> String {
22
+
"3lv4ouczo2b2a".to_string()
89
-
#[derive(Debug, Clone)]
91
-
pub cache: HybridCache<String, CachedRecord>,
92
-
pub spec: Arc<serde_json::Value>,
93
-
pub shutdown: CancellationToken,
26
+
#[oai(example = true)]
27
+
struct XrpcErrorResponseObject {
28
+
/// Should correspond an error `name` in the lexicon errors array
30
+
/// Human-readable description and possibly additonal context
96
-
async fn instrument_handler<T, H, R>(ctx: &RequestContext<T>, handler: H) -> Result<R, HttpError>
99
-
H: Future<Output = Result<R, HttpError>>,
102
-
let start = Instant::now();
103
-
let result = handler.await;
104
-
let latency = start.elapsed();
105
-
let status_code = match &result {
106
-
Ok(response) => response.status_code(),
107
-
Err(e) => e.status_code.as_status(),
33
+
impl Example for XrpcErrorResponseObject {
34
+
fn example() -> Self {
36
+
error: "RecordNotFound".to_string(),
37
+
message: "This record was deleted".to_string(),
109
-
.as_str() // just the number (.to_string()'s Display does eg `200 OK`)
111
-
let endpoint = ctx.endpoint.operation_id.clone();
112
-
let headers = ctx.request.headers();
113
-
let origin = headers
115
-
.and_then(|v| v.to_str().ok())
120
-
.and_then(|v| v.to_str().ok())
122
-
if ua.starts_with("Mozilla/5.0 ") {
130
-
counter!("server_requests_total",
131
-
"endpoint" => endpoint.clone(),
132
-
"origin" => origin,
134
-
"status_code" => status_code,
137
-
histogram!("server_handler_latency", "endpoint" => endpoint).record(latency.as_micros() as f64);
141
-
use dropshot::{HttpResponseHeaders, HttpResponseOk};
143
-
pub type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>;
145
-
/// Helper for constructing Ok responses: return OkCors(T).into()
146
-
/// (not happy with this yet)
147
-
pub struct OkCors<T: Serialize + JsonSchema + Send + Sync>(pub T);
149
-
impl<T> From<OkCors<T>> for OkCorsResponse<T>
151
-
T: Serialize + JsonSchema + Send + Sync,
153
-
fn from(ok: OkCors<T>) -> OkCorsResponse<T> {
154
-
let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0));
156
-
.insert("access-control-allow-origin", "*".parse().unwrap());
43
+
fn bad_request_handler(err: poem::Error) -> GetRecordResponse {
44
+
GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
45
+
error: "InvalidRequest".to_string(),
46
+
message: format!("Bad request, here's some info that maybe should not be exposed: {err}"),
161
-
pub fn cors_err(e: HttpError) -> HttpError {
162
-
e.with_header("access-control-allow-origin", "*").unwrap()
51
+
#[oai(example = true)]
52
+
struct FoundRecordResponseObject {
53
+
/// at-uri for this record
55
+
/// CID for this exact version of the record
57
+
/// Slingshot will always return the CID, despite it not being a required
58
+
/// response property in the official lexicon.
59
+
cid: Option<String>,
60
+
/// the record itself as JSON
61
+
value: serde_json::Value,
166
-
// TODO: cors for HttpError
168
-
/// Serve index page as html
173
-
* not useful to have this in openapi
175
-
unpublished = true,
177
-
async fn index(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> {
178
-
instrument_handler(&ctx, async {
179
-
Ok(Response::builder()
180
-
.status(StatusCode::OK)
181
-
.header(http::header::CONTENT_TYPE, "text/html")
182
-
.body(INDEX_HTML.into())?)
63
+
impl Example for FoundRecordResponseObject {
64
+
fn example() -> Self {
66
+
uri: format!("at://{}/{}/{}", example_did(), example_collection(), example_rkey()),
67
+
cid: Some("bafyreialv3mzvvxaoyrfrwoer3xmabbmdchvrbyhayd7bga47qjbycy74e".to_string()),
68
+
value: serde_json::json!({
69
+
"$type": "app.bsky.feed.like",
70
+
"createdAt": "2025-07-29T18:02:02.327Z",
72
+
"cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy",
73
+
"uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k"
187
-
/// Serve index page as html
190
-
path = "/favicon.ico",
192
-
* not useful to have this in openapi
194
-
unpublished = true,
196
-
async fn favicon(ctx: RequestContext<Context>) -> Result<Response<Body>, HttpError> {
197
-
instrument_handler(&ctx, async {
198
-
Ok(Response::builder()
199
-
.status(StatusCode::OK)
200
-
.header(http::header::CONTENT_TYPE, "image/x-icon")
201
-
.body(FAVICON.to_vec().into())?)
80
+
#[derive(ApiResponse)]
81
+
#[oai(bad_request_handler = "bad_request_handler")]
82
+
enum GetRecordResponse {
84
+
#[oai(status = 200)]
85
+
Ok(Json<FoundRecordResponseObject>),
86
+
/// Bad request or no record to return
88
+
/// The only error name in the repo.getRecord lexicon is `RecordNotFound`,
89
+
/// but the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)
90
+
/// also list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of
91
+
/// these, slingshot will only return `RecordNotFound` or `InvalidRequest`.
92
+
#[oai(status = 400)]
93
+
BadRequest(Json<XrpcErrorResponseObject>),
206
-
/// Meta: get the openapi spec for this api
211
-
* not useful to have this in openapi
213
-
unpublished = true,
215
-
async fn openapi(ctx: RequestContext<Context>) -> OkCorsResponse<serde_json::Value> {
216
-
instrument_handler(&ctx, async {
217
-
let spec = (*ctx.context().spec).clone();
218
-
OkCors(spec).into()
97
+
cache: HybridCache<String, CachedRecord>,
224
-
#[derive(Debug, Deserialize, JsonSchema)]
225
-
struct GetRecordQuery {
226
-
/// The DID of the repo
228
-
/// NOTE: handles should be accepted here but this is still TODO in slingshot
230
-
/// The NSID of the record collection
231
-
pub collection: String,
234
-
/// Optional: the CID of the version of the record.
102
+
/// com.atproto.repo.getRecord
236
-
/// If not specified, then return the most recent version.
104
+
/// Get a single record from a repository. Does not require auth.
238
-
/// If specified and a newer version of the record exists, returns 404 not
239
-
/// found. That is: slingshot only retains the most recent version of a
242
-
pub cid: Option<String>,
245
-
#[derive(Debug, Serialize, JsonSchema)]
246
-
struct GetRecordResponse {
249
-
pub value: Box<RawValue>,
252
-
/// com.atproto.repo.getRecord
254
-
/// Get a single record from a repository. Does not require auth.
256
-
/// See https://docs.bsky.app/docs/api/com-atproto-repo-get-record for the
257
-
/// canonical XRPC documentation that this endpoint aims to be compatible with.
260
-
path = "/xrpc/com.atproto.repo.getRecord",
262
-
async fn get_record(
263
-
ctx: RequestContext<Context>,
264
-
query: Query<GetRecordQuery>,
265
-
) -> OkCorsResponse<GetRecordResponse> {
267
-
let Context { cache, .. } = ctx.context();
268
-
let GetRecordQuery { repo, collection, rkey, cid } = query.into_inner();
106
+
/// See https://docs.bsky.app/docs/api/com-atproto-repo-get-record for the
107
+
/// canonical XRPC documentation that this endpoint aims to be compatible
109
+
#[oai(path = "/com.atproto.repo.getRecord", method = "get")]
110
+
async fn get_record(
112
+
/// The DID of the repo
114
+
/// NOTE: handles should be accepted here but this is still TODO in slingshot
115
+
#[oai(example = "example_did")]
116
+
repo: Query<String>,
117
+
/// The NSID of the record collection
118
+
#[oai(example = "example_collection")]
119
+
collection: Query<String>,
121
+
#[oai(example = "example_rkey")]
122
+
rkey: Query<String>,
123
+
/// Optional: the CID of the version of the record.
125
+
/// If not specified, then return the most recent version.
127
+
/// If specified and a newer version of the record exists, returns 404 not
128
+
/// found. That is: slingshot only retains the most recent version of a
130
+
cid: Query<Option<String>>,
131
+
) -> GetRecordResponse {
133
+
let at_uri = format!(
135
+
&*repo, &*collection, &*rkey
271
-
let at_uri = format!(
273
-
&*repo, &*collection, &*rkey
276
-
instrument_handler(&ctx, async {
138
+
let entry = self.cache
.fetch(at_uri.clone(), || async move {
279
-
Err(foyer::Error::Other(Box::new(ServerError::OhNo("booo".to_string()))))
145
+
// TODO: actual 404
CachedRecord::Found(ref raw) => {
let (found_cid, raw_value) = raw.into();
let found_cid = found_cid.as_ref().to_string();
288
-
if cid.map(|c| c != found_cid).unwrap_or(false) {
289
-
Err(HttpError::for_not_found(None, "CID mismatch".to_string()))
290
-
.map_err(cors_err)?;
151
+
if cid.clone().map(|c| c != found_cid).unwrap_or(false) {
152
+
return GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
153
+
error: "RecordNotFound".to_string(),
154
+
message: "A record was found but its CID did not match that requested".to_string(),
292
-
OkCors(GetRecordResponse {
157
+
// TODO: thank u stellz: https://gist.github.com/stella3d/51e679e55b264adff89d00a1e58d0272
158
+
let value = serde_json::from_str(raw_value.get()).expect("RawValue to be valid json");
159
+
GetRecordResponse::Ok(Json(FoundRecordResponseObject {
161
+
cid: Some(found_cid),
CachedRecord::Deleted => {
299
-
Err(HttpError::for_client_error_with_status(
300
-
Some("Gone".to_string()),
301
-
ClientErrorStatusCode::GONE,
302
-
)).map_err(cors_err)
166
+
GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject {
167
+
error: "RecordNotFound".to_string(),
168
+
message: "This record was deleted".to_string(),
175
+
pub async fn serve(
176
+
cache: HybridCache<String, CachedRecord>,
177
+
_shutdown: CancellationToken,
178
+
) -> Result<(), ServerError> {
180
+
OpenApiService::new(Xrpc { cache }, "Slingshot", env!("CARGO_PKG_VERSION"))
181
+
.server("http://localhost:3000")
182
+
.url_prefix("/xrpc");
184
+
let app = Route::new()
185
+
.nest("/", api_service.scalar())
186
+
.nest("/openapi.json", api_service.spec_endpoint())
187
+
.nest("/xrpc/", api_service);
189
+
Server::new(TcpListener::bind("127.0.0.1:3000"))
192
+
.map_err(|e| ServerError::ServerExited(format!("uh oh: {e:?}")))