From 0800d4ba784f200e8b36e4e144a55b22879974f2 Mon Sep 17 00:00:00 2001 From: phil Date: Tue, 29 Jul 2025 16:40:42 -0400 Subject: [PATCH] swap dropshot for poem_openapi it's... nice here? --- Cargo.lock | 207 +++++++++++++++++++- slingshot/Cargo.toml | 6 +- slingshot/src/error.rs | 10 - slingshot/src/server.rs | 423 +++++++++++++++------------------------- 4 files changed, 356 insertions(+), 290 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1c36b9..0d49226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,6 +1288,27 @@ dependencies = [ "syn 2.0.103", ] +[[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" @@ -2283,9 +2304,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -2299,7 +2320,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -3234,6 +3255,7 @@ dependencies = [ "memchr", "mime", "spin", + "tokio", "version_check", ] @@ -3728,6 +3750,100 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -3777,6 +3893,15 @@ dependencies = [ "elliptic-curve", ] +[[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" @@ -3823,6 +3948,16 @@ dependencies = [ "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]] name = "quick_cache" version = "0.6.12" @@ -4088,6 +4223,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfc7239" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a82f1d1e38e9a85bb58ffcfadf22ed6f2c94e8cd8581ec2b0f80a2a6858350f" +dependencies = [ + "uncased", +] + [[package]] name = "ring" version = "0.17.14" @@ -4553,6 +4697,19 @@ dependencies = [ "syn 2.0.103", ] +[[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" @@ -4642,16 +4799,14 @@ version = "0.1.0" dependencies = [ "clap", "ctrlc", - "dropshot", "env_logger", "foyer", - "http", "jetstream", "log", "metrics", "metrics-exporter-prometheus 0.17.2", - "schemars", - "semver", + "poem", + "poem-openapi", "serde", "serde_json", "thiserror 2.0.12", @@ -5113,6 +5268,17 @@ dependencies = [ "tokio", ] +[[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" @@ -5421,6 +5587,15 @@ dependencies = [ "ufos", ] +[[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" @@ -5433,6 +5608,18 @@ 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 = "0.8.0" @@ -5728,6 +5915,12 @@ 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" version = "0.3.9" diff --git a/slingshot/Cargo.toml b/slingshot/Cargo.toml index d034716..6b70c8b 100644 --- a/slingshot/Cargo.toml +++ b/slingshot/Cargo.toml @@ -6,16 +6,14 @@ edition = "2024" [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" +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" diff --git a/slingshot/src/error.rs b/slingshot/src/error.rs index ba90399..dd6bde6 100644 --- a/slingshot/src/error.rs +++ b/slingshot/src/error.rs @@ -14,18 +14,8 @@ pub enum ConsumerError { #[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)] diff --git a/slingshot/src/server.rs b/slingshot/src/server.rs index b071fee..4f0e111 100644 --- a/slingshot/src/server.rs +++ b/slingshot/src/server.rs @@ -1,308 +1,193 @@ -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 crate::{error::ServerError, CachedRecord}; 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, - 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()?; +use poem::{listener::TcpListener, Route, Server}; +use poem_openapi::{ + payload::Json, + param::Query, + OpenApi, OpenApiService, + ApiResponse, + Object, + types::Example, +}; - 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(()) +fn example_did() -> String { + "did:plc:hdhoaan3xa3jiuq4fg4mefid".to_string() } - -#[derive(Debug, Clone)] -struct Context { - pub cache: HybridCache, - pub spec: Arc, - pub shutdown: CancellationToken, +fn example_collection() -> String { + "app.bsky.feed.like".to_string() } - -async fn instrument_handler(ctx: &RequestContext, handler: H) -> Result -where - R: HttpResponse, - H: Future>, - 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 +fn example_rkey() -> String { + "3lv4ouczo2b2a".to_string() } -use dropshot::{HttpResponseHeaders, HttpResponseOk}; - -pub type OkCorsResponse = Result>, HttpError>; - -/// Helper for constructing Ok responses: return OkCors(T).into() -/// (not happy with this yet) -pub struct OkCors(pub T); - -impl From> for OkCorsResponse -where - T: Serialize + JsonSchema + Send + Sync, -{ - fn from(ok: OkCors) -> OkCorsResponse { - let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0)); - res.headers_mut() - .insert("access-control-allow-origin", "*".parse().unwrap()); - Ok(res) - } +#[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, } - -pub fn cors_err(e: HttpError) -> HttpError { - e.with_header("access-control-allow-origin", "*").unwrap() +impl Example for XrpcErrorResponseObject { + fn example() -> Self { + Self { + error: "RecordNotFound".to_string(), + message: "This record was deleted".to_string(), + } + } } -// 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) -> Result, HttpError> { - instrument_handler(&ctx, async { - Ok(Response::builder() - .status(StatusCode::OK) - .header(http::header::CONTENT_TYPE, "text/html") - .body(INDEX_HTML.into())?) - }) - .await +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}"), + })) } -/// 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) -> Result, 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 +#[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, + /// the record itself as JSON + value: serde_json::Value, } - -/// 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) -> OkCorsResponse { - instrument_handler(&ctx, async { - let spec = (*ctx.context().spec).clone(); - OkCors(spec).into() - }) - .await +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(Debug, Deserialize, JsonSchema)] -struct GetRecordQuery { - /// The DID of the repo +#[derive(ApiResponse)] +#[oai(bad_request_handler = "bad_request_handler")] +enum GetRecordResponse { + /// Record found + #[oai(status = 200)] + Ok(Json), + /// Bad request or no record to return /// - /// 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, + /// 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), } -#[derive(Debug, Serialize, JsonSchema)] -struct GetRecordResponse { - pub uri: String, - pub cid: String, - pub value: Box, +struct Xrpc { + cache: HybridCache, } -/// 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, - query: Query, -) -> OkCorsResponse { - - 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 +#[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, + /// The NSID of the record collection + #[oai(example = "example_collection")] + collection: Query, + /// The Record key + #[oai(example = "example_rkey")] + rkey: Query, + /// 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>, + ) -> GetRecordResponse { + // TODO: yeah yeah + let at_uri = format!( + "at://{}/{}/{}", + &*repo, &*collection, &*rkey + ); + + let entry = self.cache .fetch(at_uri.clone(), || async move { - Err(foyer::Error::Other(Box::new(ServerError::OhNo("booo".to_string())))) + 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.map(|c| c != found_cid).unwrap_or(false) { - Err(HttpError::for_not_found(None, "CID mismatch".to_string())) - .map_err(cors_err)?; + 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(), + })); } - OkCors(GetRecordResponse { + // 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: found_cid, - value: raw_value, - }).into() + cid: Some(found_cid), + value, + })) }, CachedRecord::Deleted => { - Err(HttpError::for_client_error_with_status( - Some("Gone".to_string()), - ClientErrorStatusCode::GONE, - )).map_err(cors_err) + GetRecordResponse::BadRequest(Json(XrpcErrorResponseObject { + error: "RecordNotFound".to_string(), + message: "This record was deleted".to_string(), + })) } } - }) - .await + } +} +pub async fn serve( + cache: HybridCache, + _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:?}"))) } -- 2.51.2