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
··· 1289 1289 ] 1290 1290 1291 1291 [[package]] 1292 + name = "derive_more" 1293 + version = "2.0.1" 1294 + source = "registry+https://github.com/rust-lang/crates.io-index" 1295 + checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 1296 + dependencies = [ 1297 + "derive_more-impl", 1298 + ] 1299 + 1300 + [[package]] 1301 + name = "derive_more-impl" 1302 + version = "2.0.1" 1303 + source = "registry+https://github.com/rust-lang/crates.io-index" 1304 + checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 1305 + dependencies = [ 1306 + "proc-macro2", 1307 + "quote", 1308 + "syn 2.0.103", 1309 + "unicode-xid", 1310 + ] 1311 + 1312 + [[package]] 1292 1313 name = "derive_utils" 1293 1314 version = "0.15.0" 1294 1315 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2283 2304 2284 2305 [[package]] 2285 2306 name = "hyper-util" 2286 - version = "0.1.14" 2307 + version = "0.1.16" 2287 2308 source = "registry+https://github.com/rust-lang/crates.io-index" 2288 - checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" 2309 + checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" 2289 2310 dependencies = [ 2290 2311 "base64 0.22.1", 2291 2312 "bytes", ··· 2299 2320 "libc", 2300 2321 "percent-encoding", 2301 2322 "pin-project-lite", 2302 - "socket2 0.5.9", 2323 + "socket2 0.6.0", 2303 2324 "system-configuration", 2304 2325 "tokio", 2305 2326 "tower-service", ··· 3234 3255 "memchr", 3235 3256 "mime", 3236 3257 "spin", 3258 + "tokio", 3237 3259 "version_check", 3238 3260 ] 3239 3261 ··· 3729 3751 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 3730 3752 3731 3753 [[package]] 3754 + name = "poem" 3755 + version = "3.1.12" 3756 + source = "registry+https://github.com/rust-lang/crates.io-index" 3757 + checksum = "9f977080932c87287147dca052951c3e2696f8759863f6b4e4c0c9ffe7a4cc8b" 3758 + dependencies = [ 3759 + "bytes", 3760 + "futures-util", 3761 + "headers", 3762 + "http", 3763 + "http-body-util", 3764 + "hyper", 3765 + "hyper-util", 3766 + "mime", 3767 + "multer", 3768 + "nix", 3769 + "parking_lot", 3770 + "percent-encoding", 3771 + "pin-project-lite", 3772 + "poem-derive", 3773 + "quick-xml", 3774 + "regex", 3775 + "rfc7239", 3776 + "serde", 3777 + "serde_json", 3778 + "serde_urlencoded", 3779 + "serde_yaml", 3780 + "smallvec", 3781 + "sync_wrapper", 3782 + "tempfile", 3783 + "thiserror 2.0.12", 3784 + "tokio", 3785 + "tokio-stream", 3786 + "tokio-util", 3787 + "tracing", 3788 + "wildmatch", 3789 + ] 3790 + 3791 + [[package]] 3792 + name = "poem-derive" 3793 + version = "3.1.12" 3794 + source = "registry+https://github.com/rust-lang/crates.io-index" 3795 + checksum = "056e2fea6de1cb240ffe23cfc4fc370b629f8be83b5f27e16b7acd5231a72de4" 3796 + dependencies = [ 3797 + "proc-macro-crate", 3798 + "proc-macro2", 3799 + "quote", 3800 + "syn 2.0.103", 3801 + ] 3802 + 3803 + [[package]] 3804 + name = "poem-openapi" 3805 + version = "5.1.16" 3806 + source = "registry+https://github.com/rust-lang/crates.io-index" 3807 + checksum = "1ccbcc395bf4dd03df1da32da351b6b6732e4074ce27ddec315650e52a2be44c" 3808 + dependencies = [ 3809 + "base64 0.22.1", 3810 + "bytes", 3811 + "derive_more", 3812 + "futures-util", 3813 + "indexmap 2.9.0", 3814 + "itertools 0.14.0", 3815 + "mime", 3816 + "num-traits", 3817 + "poem", 3818 + "poem-openapi-derive", 3819 + "quick-xml", 3820 + "regex", 3821 + "serde", 3822 + "serde_json", 3823 + "serde_urlencoded", 3824 + "serde_yaml", 3825 + "thiserror 2.0.12", 3826 + "tokio", 3827 + ] 3828 + 3829 + [[package]] 3830 + name = "poem-openapi-derive" 3831 + version = "5.1.16" 3832 + source = "registry+https://github.com/rust-lang/crates.io-index" 3833 + checksum = "41273b691a3d467a8c44d05506afba9f7b6bd56c9cdf80123de13fe52d7ec587" 3834 + dependencies = [ 3835 + "darling 0.20.11", 3836 + "http", 3837 + "indexmap 2.9.0", 3838 + "mime", 3839 + "proc-macro-crate", 3840 + "proc-macro2", 3841 + "quote", 3842 + "regex", 3843 + "syn 2.0.103", 3844 + "thiserror 2.0.12", 3845 + ] 3846 + 3847 + [[package]] 3732 3848 name = "portable-atomic" 3733 3849 version = "1.11.0" 3734 3850 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3778 3894 ] 3779 3895 3780 3896 [[package]] 3897 + name = "proc-macro-crate" 3898 + version = "3.3.0" 3899 + source = "registry+https://github.com/rust-lang/crates.io-index" 3900 + checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" 3901 + dependencies = [ 3902 + "toml_edit", 3903 + ] 3904 + 3905 + [[package]] 3781 3906 name = "proc-macro2" 3782 3907 version = "1.0.94" 3783 3908 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3821 3946 "wasi 0.11.0+wasi-snapshot-preview1", 3822 3947 "web-sys", 3823 3948 "winapi", 3949 + ] 3950 + 3951 + [[package]] 3952 + name = "quick-xml" 3953 + version = "0.36.2" 3954 + source = "registry+https://github.com/rust-lang/crates.io-index" 3955 + checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" 3956 + dependencies = [ 3957 + "memchr", 3958 + "serde", 3824 3959 ] 3825 3960 3826 3961 [[package]] ··· 4086 4221 dependencies = [ 4087 4222 "hmac", 4088 4223 "subtle", 4224 + ] 4225 + 4226 + [[package]] 4227 + name = "rfc7239" 4228 + version = "0.1.3" 4229 + source = "registry+https://github.com/rust-lang/crates.io-index" 4230 + checksum = "4a82f1d1e38e9a85bb58ffcfadf22ed6f2c94e8cd8581ec2b0f80a2a6858350f" 4231 + dependencies = [ 4232 + "uncased", 4089 4233 ] 4090 4234 4091 4235 [[package]] ··· 4554 4698 ] 4555 4699 4556 4700 [[package]] 4701 + name = "serde_yaml" 4702 + version = "0.9.34+deprecated" 4703 + source = "registry+https://github.com/rust-lang/crates.io-index" 4704 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 4705 + dependencies = [ 4706 + "indexmap 2.9.0", 4707 + "itoa", 4708 + "ryu", 4709 + "serde", 4710 + "unsafe-libyaml", 4711 + ] 4712 + 4713 + [[package]] 4557 4714 name = "sha1" 4558 4715 version = "0.10.6" 4559 4716 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4642 4799 dependencies = [ 4643 4800 "clap", 4644 4801 "ctrlc", 4645 - "dropshot", 4646 4802 "env_logger", 4647 4803 "foyer", 4648 - "http", 4649 4804 "jetstream", 4650 4805 "log", 4651 4806 "metrics", 4652 4807 "metrics-exporter-prometheus 0.17.2", 4653 - "schemars", 4654 - "semver", 4808 + "poem", 4809 + "poem-openapi", 4655 4810 "serde", 4656 4811 "serde_json", 4657 4812 "thiserror 2.0.12", ··· 5114 5269 ] 5115 5270 5116 5271 [[package]] 5272 + name = "tokio-stream" 5273 + version = "0.1.17" 5274 + source = "registry+https://github.com/rust-lang/crates.io-index" 5275 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 5276 + dependencies = [ 5277 + "futures-core", 5278 + "pin-project-lite", 5279 + "tokio", 5280 + ] 5281 + 5282 + [[package]] 5117 5283 name = "tokio-tungstenite" 5118 5284 version = "0.26.2" 5119 5285 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5422 5588 ] 5423 5589 5424 5590 [[package]] 5591 + name = "uncased" 5592 + version = "0.9.10" 5593 + source = "registry+https://github.com/rust-lang/crates.io-index" 5594 + checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" 5595 + dependencies = [ 5596 + "version_check", 5597 + ] 5598 + 5599 + [[package]] 5425 5600 name = "unicase" 5426 5601 version = "2.8.1" 5427 5602 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5432 5607 version = "1.0.18" 5433 5608 source = "registry+https://github.com/rust-lang/crates.io-index" 5434 5609 checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 5610 + 5611 + [[package]] 5612 + name = "unicode-xid" 5613 + version = "0.2.6" 5614 + source = "registry+https://github.com/rust-lang/crates.io-index" 5615 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 5616 + 5617 + [[package]] 5618 + name = "unsafe-libyaml" 5619 + version = "0.2.11" 5620 + source = "registry+https://github.com/rust-lang/crates.io-index" 5621 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 5435 5622 5436 5623 [[package]] 5437 5624 name = "unsigned-varint" ··· 5727 5914 version = "1.2.0" 5728 5915 source = "registry+https://github.com/rust-lang/crates.io-index" 5729 5916 checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 5917 + 5918 + [[package]] 5919 + name = "wildmatch" 5920 + version = "2.4.0" 5921 + source = "registry+https://github.com/rust-lang/crates.io-index" 5922 + checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" 5730 5923 5731 5924 [[package]] 5732 5925 name = "winapi"
+2 -4
slingshot/Cargo.toml
··· 6 6 [dependencies] 7 7 clap = { version = "4.5.41", features = ["derive"] } 8 8 ctrlc = "3.4.7" 9 - dropshot = "0.16.2" 10 9 env_logger = "0.11.8" 11 10 foyer = { version = "0.18.0", features = ["serde"] } 12 - http = "1.3.1" 13 11 jetstream = { path = "../jetstream", features = ["metrics"] } 14 12 log = "0.4.27" 15 13 metrics = "0.24.2" 16 14 metrics-exporter-prometheus = { version = "0.17.1", features = ["http-listener"] } 17 - schemars = { version = "0.8.22", features = ["raw_value"] } 18 - semver = "1.0.26" 15 + poem = "3.1.12" 16 + poem-openapi = { version = "5.1.16", features = ["scalar"] } 19 17 serde = { version = "1.0.219", features = ["derive"] } 20 18 serde_json = { version = "1.0.141", features = ["raw_value"] } 21 19 thiserror = "2.0.12"
-10
slingshot/src/error.rs
··· 14 14 15 15 #[derive(Debug, Error)] 16 16 pub enum ServerError { 17 - #[error("failed to configure server logger: {0}")] 18 - ConfigLogError(std::io::Error), 19 - #[error("failed to render json for openapi: {0}")] 20 - OpenApiJsonFail(serde_json::Error), 21 - #[error(transparent)] 22 - FailedToBuildServer(#[from] dropshot::BuildError), 23 17 #[error("server exited: {0}")] 24 18 ServerExited(String), 25 - #[error("server closed badly: {0}")] 26 - BadClose(String), 27 - #[error("blahhhahhahha")] 28 - OhNo(String), 29 19 } 30 20 31 21 #[derive(Debug, Error)]
+153 -268
slingshot/src/server.rs
··· 1 - use serde_json::value::RawValue; 2 - use crate::CachedRecord; 3 1 use foyer::HybridCache; 4 - use crate::error::ServerError; 5 - use dropshot::{ 6 - ApiDescription, Body, ConfigDropshot, ConfigLogging, 7 - ConfigLoggingLevel, HttpError, HttpResponse, Query, RequestContext, 8 - ServerBuilder, ServerContext, endpoint, 9 - ClientErrorStatusCode, 10 - }; 11 - use http::{ 12 - Response, StatusCode, 13 - header::{ORIGIN, USER_AGENT}, 14 - }; 15 - use metrics::{counter, histogram}; 16 - use std::sync::Arc; 17 - 18 - use schemars::JsonSchema; 19 - use serde::{Deserialize, Serialize}; 20 - use tokio::time::Instant; 2 + use crate::{error::ServerError, CachedRecord}; 21 3 use tokio_util::sync::CancellationToken; 22 4 23 - const INDEX_HTML: &str = include_str!("../static/index.html"); 24 - const FAVICON: &[u8] = include_bytes!("../static/favicon.ico"); 25 - 26 - pub async fn serve( 27 - cache: HybridCache<String, CachedRecord>, 28 - shutdown: CancellationToken, 29 - ) -> Result<(), ServerError> { 30 - let config_logging = ConfigLogging::StderrTerminal { 31 - level: ConfigLoggingLevel::Info, 32 - }; 33 - 34 - let log = config_logging 35 - .to_logger("example-basic") 36 - .map_err(ServerError::ConfigLogError)?; 37 - 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(); 43 - 44 - // TODO: put spec in a once cell / lazy lock thing? 45 - let spec = Arc::new( 46 - api.openapi( 47 - "Slingshot", 48 - env!("CARGO_PKG_VERSION") 49 - .parse() 50 - .inspect_err(|e| { 51 - eprintln!("failed to parse cargo package version for openapi: {e:?}") 52 - }) 53 - .unwrap_or(semver::Version::new(0, 0, 1)), 54 - ) 55 - .description("A fast edge cache for getRecord") 56 - .contact_name("part of @microcosm.blue") 57 - .contact_url("https://microcosm.blue") 58 - .json() 59 - .map_err(ServerError::OpenApiJsonFail)?, 60 - ); 5 + use poem::{listener::TcpListener, Route, Server}; 6 + use poem_openapi::{ 7 + payload::Json, 8 + param::Query, 9 + OpenApi, OpenApiService, 10 + ApiResponse, 11 + Object, 12 + types::Example, 13 + }; 61 14 62 - let sub_shutdown = shutdown.clone(); 63 - let ctx = Context { 64 - cache, 65 - spec, 66 - shutdown: sub_shutdown, 67 - }; 68 - 69 - let server = ServerBuilder::new(api, ctx, log) 70 - .config(ConfigDropshot { 71 - bind_address: "0.0.0.0:9996".parse().unwrap(), 72 - ..Default::default() 73 - }) 74 - .start()?; 75 - 76 - tokio::select! { 77 - s = server.wait_for_shutdown() => { 78 - s.map_err(ServerError::ServerExited)?; 79 - log::info!("server shut down normally."); 80 - }, 81 - _ = shutdown.cancelled() => { 82 - log::info!("shutting down: closing server"); 83 - server.close().await.map_err(ServerError::BadClose)?; 84 - }, 85 - } 86 - Ok(()) 15 + fn example_did() -> String { 16 + "did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string() 17 + } 18 + fn example_collection() -> String { 19 + "app.bsky.feed.like".to_string() 20 + } 21 + fn example_rkey() -> String { 22 + "3lv4ouczo2b2a".to_string() 87 23 } 88 24 89 - #[derive(Debug, Clone)] 90 - struct Context { 91 - pub cache: HybridCache<String, CachedRecord>, 92 - pub spec: Arc<serde_json::Value>, 93 - pub shutdown: CancellationToken, 25 + #[derive(Object)] 26 + #[oai(example = true)] 27 + struct XrpcErrorResponseObject { 28 + /// Should correspond an error `name` in the lexicon errors array 29 + error: String, 30 + /// Human-readable description and possibly additonal context 31 + message: String, 94 32 } 95 - 96 - async fn instrument_handler<T, H, R>(ctx: &RequestContext<T>, handler: H) -> Result<R, HttpError> 97 - where 98 - R: HttpResponse, 99 - H: Future<Output = Result<R, HttpError>>, 100 - T: ServerContext, 101 - { 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 { 35 + Self { 36 + error: "RecordNotFound".to_string(), 37 + message: "This record was deleted".to_string(), 38 + } 108 39 } 109 - .as_str() // just the number (.to_string()'s Display does eg `200 OK`) 110 - .to_string(); 111 - let endpoint = ctx.endpoint.operation_id.clone(); 112 - let headers = ctx.request.headers(); 113 - let origin = headers 114 - .get(ORIGIN) 115 - .and_then(|v| v.to_str().ok()) 116 - .unwrap_or("") 117 - .to_string(); 118 - let ua = headers 119 - .get(USER_AGENT) 120 - .and_then(|v| v.to_str().ok()) 121 - .map(|ua| { 122 - if ua.starts_with("Mozilla/5.0 ") { 123 - "browser" 124 - } else { 125 - ua 126 - } 127 - }) 128 - .unwrap_or("") 129 - .to_string(); 130 - counter!("server_requests_total", 131 - "endpoint" => endpoint.clone(), 132 - "origin" => origin, 133 - "ua" => ua, 134 - "status_code" => status_code, 135 - ) 136 - .increment(1); 137 - histogram!("server_handler_latency", "endpoint" => endpoint).record(latency.as_micros() as f64); 138 - result 139 40 } 140 41 141 - use dropshot::{HttpResponseHeaders, HttpResponseOk}; 142 42 143 - pub type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>; 144 - 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); 148 - 149 - impl<T> From<OkCors<T>> for OkCorsResponse<T> 150 - where 151 - T: Serialize + JsonSchema + Send + Sync, 152 - { 153 - fn from(ok: OkCors<T>) -> OkCorsResponse<T> { 154 - let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0)); 155 - res.headers_mut() 156 - .insert("access-control-allow-origin", "*".parse().unwrap()); 157 - Ok(res) 158 - } 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}"), 47 + })) 159 48 } 160 49 161 - pub fn cors_err(e: HttpError) -> HttpError { 162 - e.with_header("access-control-allow-origin", "*").unwrap() 50 + #[derive(Object)] 51 + #[oai(example = true)] 52 + struct FoundRecordResponseObject { 53 + /// at-uri for this record 54 + uri: String, 55 + /// CID for this exact version of the record 56 + /// 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, 163 62 } 164 - 165 - 166 - // TODO: cors for HttpError 167 - 168 - /// Serve index page as html 169 - #[endpoint { 170 - method = GET, 171 - path = "/", 172 - /* 173 - * not useful to have this in openapi 174 - */ 175 - unpublished = true, 176 - }] 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())?) 183 - }) 184 - .await 63 + impl Example for FoundRecordResponseObject { 64 + fn example() -> Self { 65 + 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", 71 + "subject": { 72 + "cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy", 73 + "uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k" 74 + } 75 + }), 76 + } 77 + } 185 78 } 186 79 187 - /// Serve index page as html 188 - #[endpoint { 189 - method = GET, 190 - path = "/favicon.ico", 191 - /* 192 - * not useful to have this in openapi 193 - */ 194 - unpublished = true, 195 - }] 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())?) 202 - }) 203 - .await 80 + #[derive(ApiResponse)] 81 + #[oai(bad_request_handler = "bad_request_handler")] 82 + enum GetRecordResponse { 83 + /// Record found 84 + #[oai(status = 200)] 85 + Ok(Json<FoundRecordResponseObject>), 86 + /// Bad request or no record to return 87 + /// 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>), 204 94 } 205 95 206 - /// Meta: get the openapi spec for this api 207 - #[endpoint { 208 - method = GET, 209 - path = "/openapi", 210 - /* 211 - * not useful to have this in openapi 212 - */ 213 - unpublished = true, 214 - }] 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() 219 - }) 220 - .await 96 + struct Xrpc { 97 + cache: HybridCache<String, CachedRecord>, 221 98 } 222 99 223 - 224 - #[derive(Debug, Deserialize, JsonSchema)] 225 - struct GetRecordQuery { 226 - /// The DID of the repo 227 - /// 228 - /// NOTE: handles should be accepted here but this is still TODO in slingshot 229 - pub repo: String, 230 - /// The NSID of the record collection 231 - pub collection: String, 232 - /// The Record key 233 - pub rkey: String, 234 - /// Optional: the CID of the version of the record. 100 + #[OpenApi] 101 + impl Xrpc { 102 + /// com.atproto.repo.getRecord 235 103 /// 236 - /// If not specified, then return the most recent version. 104 + /// Get a single record from a repository. Does not require auth. 237 105 /// 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 240 - /// record. 241 - #[serde(default)] 242 - pub cid: Option<String>, 243 - } 244 - 245 - #[derive(Debug, Serialize, JsonSchema)] 246 - struct GetRecordResponse { 247 - pub uri: String, 248 - pub cid: String, 249 - pub value: Box<RawValue>, 250 - } 251 - 252 - /// com.atproto.repo.getRecord 253 - /// 254 - /// Get a single record from a repository. Does not require auth. 255 - /// 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. 258 - #[endpoint { 259 - method = GET, 260 - path = "/xrpc/com.atproto.repo.getRecord", 261 - }] 262 - async fn get_record( 263 - ctx: RequestContext<Context>, 264 - query: Query<GetRecordQuery>, 265 - ) -> OkCorsResponse<GetRecordResponse> { 266 - 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 108 + /// with. 109 + #[oai(path = "/com.atproto.repo.getRecord", method = "get")] 110 + async fn get_record( 111 + &self, 112 + /// The DID of the repo 113 + /// 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>, 120 + /// The Record key 121 + #[oai(example = "example_rkey")] 122 + rkey: Query<String>, 123 + /// Optional: the CID of the version of the record. 124 + /// 125 + /// If not specified, then return the most recent version. 126 + /// 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 129 + /// record. 130 + cid: Query<Option<String>>, 131 + ) -> GetRecordResponse { 132 + // TODO: yeah yeah 133 + let at_uri = format!( 134 + "at://{}/{}/{}", 135 + &*repo, &*collection, &*rkey 136 + ); 269 137 270 - // TODO: yeah yeah 271 - let at_uri = format!( 272 - "at://{}/{}/{}", 273 - &*repo, &*collection, &*rkey 274 - ); 275 - 276 - instrument_handler(&ctx, async { 277 - let entry = cache 138 + let entry = self.cache 278 139 .fetch(at_uri.clone(), || async move { 279 - Err(foyer::Error::Other(Box::new(ServerError::OhNo("booo".to_string())))) 140 + todo!() 280 141 }) 281 142 .await 282 143 .unwrap(); 144 + 145 + // TODO: actual 404 283 146 284 147 match *entry { 285 148 CachedRecord::Found(ref raw) => { 286 149 let (found_cid, raw_value) = raw.into(); 287 150 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(), 155 + })); 291 156 } 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 { 293 160 uri: at_uri, 294 - cid: found_cid, 295 - value: raw_value, 296 - }).into() 161 + cid: Some(found_cid), 162 + value, 163 + })) 297 164 }, 298 165 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(), 169 + })) 303 170 } 304 171 } 305 - }) 306 - .await 172 + } 173 + } 307 174 175 + pub async fn serve( 176 + cache: HybridCache<String, CachedRecord>, 177 + _shutdown: CancellationToken, 178 + ) -> Result<(), ServerError> { 179 + let api_service = 180 + OpenApiService::new(Xrpc { cache }, "Slingshot", env!("CARGO_PKG_VERSION")) 181 + .server("http://localhost:3000") 182 + .url_prefix("/xrpc"); 183 + 184 + let app = Route::new() 185 + .nest("/", api_service.scalar()) 186 + .nest("/openapi.json", api_service.spec_endpoint()) 187 + .nest("/xrpc/", api_service); 188 + 189 + Server::new(TcpListener::bind("127.0.0.1:3000")) 190 + .run(app) 191 + .await 192 + .map_err(|e| ServerError::ServerExited(format!("uh oh: {e:?}"))) 308 193 }