Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505//! Same-origin Polymodel appview client for browser UI calls.//!//! Polymodel deliberately uses this thin client rather than Jacquard's//! credential/session `BasicClient` path: appview reads should target this//! server's `/xrpc/space.polymodel.*` routes and rely on browser-managed//! cookies for OAuth sessions. Callers send generated payload//! request values, for example `client.send(GetSession).await?.into_output()`.//! Identity resolution is delegated to Jacquard's resolver over the same//! injected HTTP transport; this keeps Polymodel's appview client thin without//! adopting a credential/session agent for read calls.
use std::fmt;use std::sync::{Arc, RwLock};
use jacquard::identity::JacquardResolver;use jacquard::identity::resolver::{ DidDocResponse, IdentityError, IdentityResolver, ResolverOptions,};use jacquard_common::BosStr;use jacquard_common::http_client::HttpClient;use jacquard_common::types::did::Did;use jacquard_common::types::string::Handle;use jacquard_common::xrpc::{CallOptions, XrpcClient, XrpcExt, XrpcRequest, XrpcResponse};use jacquard_common::{deps::fluent_uri::Uri, error::XrpcResult};
const DEFAULT_NATIVE_BASE_URI: &str = "http://127.0.0.1:8080";
#[derive(Clone)]pub struct PolymodelClient { http: reqwest::Client, resolver: JacquardResolver<reqwest::Client>, base_uri: Arc<RwLock<Uri<String>>>, opts: Arc<RwLock<CallOptions>>,}
impl fmt::Debug for PolymodelClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PolymodelClient") .field("base_uri", &self.current_base_uri().as_str()) .finish_non_exhaustive() }}
impl PolymodelClient { pub fn new() -> Result<Self, ClientError> { Self::with_base_uri(default_base_uri()?) }
pub fn with_base_uri(base_uri: Uri<String>) -> Result<Self, ClientError> { Self::with_http_and_base_uri(reqwest::Client::new(), base_uri) }
pub fn with_http_and_base_uri( http: reqwest::Client, base_uri: Uri<String>, ) -> Result<Self, ClientError> { let resolver = JacquardResolver::new(http.clone(), ResolverOptions::default()); Ok(Self { http, resolver, base_uri: Arc::new(RwLock::new(base_uri)), opts: Arc::new(RwLock::new(CallOptions::default())), }) }
pub fn parse_base_uri(value: &str) -> Result<Uri<String>, ClientError> { Uri::parse(value.to_string()).map_err(|_| ClientError::InvalidBaseUri(value.to_string())) }
pub fn current_base_uri(&self) -> Uri<String> { self.base_uri .read() .expect("Polymodel client base URI lock is not poisoned") .clone() }}
impl Default for PolymodelClient { fn default() -> Self { Self::new().expect("default Polymodel appview base URI is valid") }}
impl HttpClient for PolymodelClient { type Error = reqwest::Error;
async fn send_http( &self, request: http::Request<Vec<u8>>, ) -> Result<http::Response<Vec<u8>>, Self::Error> { self.http.send_http(request).await }}
impl XrpcClient for PolymodelClient { async fn base_uri(&self) -> Uri<String> { self.current_base_uri() }
async fn set_base_uri(&self, uri: Uri<String>) { *self .base_uri .write() .expect("Polymodel client base URI lock is not poisoned") = uri; }
async fn opts(&self) -> CallOptions { self.opts .read() .expect("Polymodel client XRPC options lock is not poisoned") .clone() }
async fn set_opts(&self, opts: CallOptions) { *self .opts .write() .expect("Polymodel client XRPC options lock is not poisoned") = opts; }
#[cfg(not(target_arch = "wasm32"))] async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> where R: XrpcRequest + Send + Sync + serde::Serialize, <R as XrpcRequest>::Response: Send + Sync, Self: Sync, { let base = self.base_uri().await; let opts = self.opts().await; let base_ref: Uri<&str> = base.borrow(); self.xrpc(base_ref).with_options(opts).send(&request).await }
#[cfg(target_arch = "wasm32")] async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> where R: XrpcRequest + Send + Sync + serde::Serialize, <R as XrpcRequest>::Response: Send + Sync, { let base = self.base_uri().await; let opts = self.opts().await; let base_ref: Uri<&str> = base.borrow(); self.xrpc(base_ref).with_options(opts).send(&request).await }
#[cfg(not(target_arch = "wasm32"))] async fn send_with_opts<R>(&self, request: R, opts: CallOptions) -> XrpcResult<XrpcResponse<R>> where R: XrpcRequest + Send + Sync + serde::Serialize, <R as XrpcRequest>::Response: Send + Sync, Self: Sync, { let base = self.base_uri().await; let base_ref: Uri<&str> = base.borrow(); self.xrpc(base_ref).with_options(opts).send(&request).await }
#[cfg(target_arch = "wasm32")] async fn send_with_opts<R>(&self, request: R, opts: CallOptions) -> XrpcResult<XrpcResponse<R>> where R: XrpcRequest + Send + Sync + serde::Serialize, <R as XrpcRequest>::Response: Send + Sync, { let base = self.base_uri().await; let base_ref: Uri<&str> = base.borrow(); self.xrpc(base_ref).with_options(opts).send(&request).await }}
impl IdentityResolver for PolymodelClient { fn options(&self) -> &ResolverOptions { self.resolver.options() }
#[cfg(not(target_arch = "wasm32"))] async fn resolve_handle<S: BosStr + Sync>( &self, handle: &Handle<S>, ) -> Result<Did, IdentityError> where Self: Sync, { self.resolver.resolve_handle(handle).await }
#[cfg(target_arch = "wasm32")] async fn resolve_handle<S: BosStr + Sync>( &self, handle: &Handle<S>, ) -> Result<Did, IdentityError> { self.resolver.resolve_handle(handle).await }
#[cfg(not(target_arch = "wasm32"))] async fn resolve_did_doc<S: BosStr + Sync>( &self, did: &Did<S>, ) -> Result<DidDocResponse, IdentityError> where Self: Sync, { self.resolver.resolve_did_doc(did).await }
#[cfg(target_arch = "wasm32")] async fn resolve_did_doc<S: BosStr + Sync>( &self, did: &Did<S>, ) -> Result<DidDocResponse, IdentityError> { self.resolver.resolve_did_doc(did).await }}
// ----------------------------------------------------------------------------// PM-43: raw same-origin app requests (drafts CRUD + raw-byte uploads).//// The typed `XrpcClient::send` path covers generated lexicon request/response// types. The publish wizard additionally needs (a) the app-internal `/app/*`// draft endpoints, which carry plain JSON bodies, and (b) raw-byte uploads// (`stageFile`, image upload), whose request body is the file bytes rather than// an XRPC query. Both ride the same same-origin transport and browser cookie// session as the read path, so authentication is automatic.// ----------------------------------------------------------------------------
/// Error from a raw same-origin app request.#[derive(Debug, thiserror::Error)]pub enum RawError { #[error("could not build request: {0}")] Build(String), #[error("transport error: {0}")] Transport(String), #[error("server returned status {status}: {message}")] Status { status: u16, message: String }, #[error("could not decode response: {0}")] Decode(String),}
/// Build a same-origin request against `base` for `path`, attaching an optional/// content type, extra headers, and a raw body. Pure (no I/O) so the request/// shape is unit-testable.fn build_raw_request( base: &str, method: http::Method, path: &str, content_type: Option<&str>, extra_headers: &[(&str, String)], body: Vec<u8>,) -> Result<http::Request<Vec<u8>>, RawError> { let url = format!("{}{}", base.trim_end_matches('/'), path); let mut builder = http::Request::builder().method(method).uri(url); if let Some(ct) = content_type { builder = builder.header(http::header::CONTENT_TYPE, ct); } for (name, value) in extra_headers { builder = builder.header(*name, value); } builder .body(body) .map_err(|e| RawError::Build(e.to_string()))}
/// Extract a human-readable message from an XRPC-style error body/// (`{"error":..,"message":..}`), falling back to the raw text.fn error_message(body: &[u8]) -> String { if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) { if let Some(message) = value.get("message").and_then(|m| m.as_str()) { return message.to_string(); } if let Some(error) = value.get("error").and_then(|e| e.as_str()) { return error.to_string(); } } String::from_utf8_lossy(body).trim().to_string()}
impl PolymodelClient { async fn send_raw( &self, method: http::Method, path: &str, content_type: Option<&str>, extra_headers: &[(&str, String)], body: Vec<u8>, ) -> Result<Vec<u8>, RawError> { let base = self.current_base_uri(); let request = build_raw_request( base.as_str(), method, path, content_type, extra_headers, body, )?; let response = self .send_http(request) .await .map_err(|e| RawError::Transport(e.to_string()))?; let status = response.status(); let body = response.into_body(); if status.is_success() { Ok(body) } else { Err(RawError::Status { status: status.as_u16(), message: error_message(&body), }) } }
fn decode<R: serde::de::DeserializeOwned>(body: &[u8]) -> Result<R, RawError> { serde_json::from_slice(body).map_err(|e| RawError::Decode(e.to_string())) }
/// Send a JSON-bodied (or bodyless) app request and decode the JSON response. pub async fn app_json<B, R>( &self, method: http::Method, path: &str, body: Option<&B>, ) -> Result<R, RawError> where B: serde::Serialize, R: serde::de::DeserializeOwned, { let (content_type, bytes) = match body { Some(value) => ( Some("application/json"), serde_json::to_vec(value).map_err(|e| RawError::Build(e.to_string()))?, ), None => (None, Vec::new()), }; let response = self .send_raw(method, path, content_type, &[], bytes) .await?; Self::decode(&response) }
/// POST raw bytes (a file/image upload) and decode the JSON response. pub async fn app_post_bytes<R>( &self, path: &str, content_type: &str, extra_headers: &[(&str, String)], body: Vec<u8>, ) -> Result<R, RawError> where R: serde::de::DeserializeOwned, { let response = self .send_raw( http::Method::POST, path, Some(content_type), extra_headers, body, ) .await?; Self::decode(&response) }}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]pub enum ClientError { #[error("invalid Polymodel appview base URI: {0}")] InvalidBaseUri(String),}
fn default_base_uri() -> Result<Uri<String>, ClientError> { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { let origin = web_sys::window() .and_then(|window| window.location().origin().ok()) .filter(|origin| !origin.is_empty()) .unwrap_or_else(|| DEFAULT_NATIVE_BASE_URI.to_string()); PolymodelClient::parse_base_uri(&origin) }
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] { PolymodelClient::parse_base_uri(DEFAULT_NATIVE_BASE_URI) }}
#[cfg(test)]mod tests { use super::*; use jacquard::ToSmolStr; use jacquard_common::xrpc::{CallOptions, build_http_request}; use polymodel_api::space_polymodel::actor::get_session::GetSession; use polymodel_api::space_polymodel::library::get_feed::GetFeed;
#[test] fn stores_base_uri_without_rewriting_it() { let client = PolymodelClient::with_base_uri( PolymodelClient::parse_base_uri("https://example.test/app").unwrap(), ) .unwrap();
assert_eq!( client.current_base_uri().as_str(), "https://example.test/app" ); }
#[test] fn get_feed_payload_builds_same_origin_xrpc_query() { let base = Uri::parse("https://example.test".to_string()).unwrap(); let base_ref = Uri::parse(base.as_str()).unwrap(); let request = GetFeed::new() .algorithm(Some("recent".to_smolstr())) .limit(24) .build(); let http = build_http_request(&base_ref, &request, &CallOptions::default()).unwrap();
assert_eq!(http.method(), http::Method::GET); assert_eq!( http.uri().to_string(), "https://example.test/xrpc/space.polymodel.library.getFeed?algorithm=recent&limit=24" ); }
#[test] fn get_session_payload_builds_no_param_query() { let base = Uri::parse("https://example.test".to_string()).unwrap(); let base_ref = Uri::parse(base.as_str()).unwrap(); let http = build_http_request(&base_ref, &GetSession, &CallOptions::default()).unwrap();
assert_eq!(http.method(), http::Method::GET); assert_eq!( http.uri().to_string(), "https://example.test/xrpc/space.polymodel.actor.getSession" ); }
#[test] fn polymodel_client_exposes_jacquard_identity_resolver_options() { let client = PolymodelClient::with_base_uri( PolymodelClient::parse_base_uri("https://example.test").unwrap(), ) .unwrap();
let _: &ResolverOptions = client.options(); }
#[test] fn client_error_formats_for_ui() { let error = PolymodelClient::parse_base_uri("not a uri").unwrap_err(); assert_eq!( error.to_string(), "invalid Polymodel appview base URI: not a uri" ); }
#[test] fn raw_request_builds_same_origin_upload() { let request = build_raw_request( "https://example.test/", http::Method::POST, "/xrpc/space.polymodel.library.stageFile", Some("model/stl"), &[("x-polymodel-filename", "tray.stl".to_string())], vec![1, 2, 3], ) .unwrap();
assert_eq!(request.method(), http::Method::POST); assert_eq!( request.uri().to_string(), "https://example.test/xrpc/space.polymodel.library.stageFile" ); assert_eq!( request.headers().get(http::header::CONTENT_TYPE).unwrap(), "model/stl" ); assert_eq!( request.headers().get("x-polymodel-filename").unwrap(), "tray.stl" ); assert_eq!(request.body(), &vec![1, 2, 3]); }
#[test] fn raw_request_targets_app_draft_path_without_double_slash() { let request = build_raw_request( "https://example.test", http::Method::PUT, "/app/drafts/abc123", Some("application/json"), &[], b"{}".to_vec(), ) .unwrap(); assert_eq!( request.uri().to_string(), "https://example.test/app/drafts/abc123" ); }
#[test] fn error_message_prefers_xrpc_message_field() { let body = br#"{"error":"InvalidRequest","message":"A license is required."}"#; assert_eq!(super::error_message(body), "A license is required."); }}