//! 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, base_uri: Arc>>, opts: Arc>, } 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::with_base_uri(default_base_uri()?) } pub fn with_base_uri(base_uri: Uri) -> Result { Self::with_http_and_base_uri(reqwest::Client::new(), base_uri) } pub fn with_http_and_base_uri( http: reqwest::Client, base_uri: Uri, ) -> Result { 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, ClientError> { Uri::parse(value.to_string()).map_err(|_| ClientError::InvalidBaseUri(value.to_string())) } pub fn current_base_uri(&self) -> Uri { 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>, ) -> Result>, Self::Error> { self.http.send_http(request).await } } impl XrpcClient for PolymodelClient { async fn base_uri(&self) -> Uri { self.current_base_uri() } async fn set_base_uri(&self, uri: Uri) { *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(&self, request: R) -> XrpcResult> where R: XrpcRequest + Send + Sync + serde::Serialize, ::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(&self, request: R) -> XrpcResult> where R: XrpcRequest + Send + Sync + serde::Serialize, ::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(&self, request: R, opts: CallOptions) -> XrpcResult> where R: XrpcRequest + Send + Sync + serde::Serialize, ::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(&self, request: R, opts: CallOptions) -> XrpcResult> where R: XrpcRequest + Send + Sync + serde::Serialize, ::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( &self, handle: &Handle, ) -> Result where Self: Sync, { self.resolver.resolve_handle(handle).await } #[cfg(target_arch = "wasm32")] async fn resolve_handle( &self, handle: &Handle, ) -> Result { self.resolver.resolve_handle(handle).await } #[cfg(not(target_arch = "wasm32"))] async fn resolve_did_doc( &self, did: &Did, ) -> Result where Self: Sync, { self.resolver.resolve_did_doc(did).await } #[cfg(target_arch = "wasm32")] async fn resolve_did_doc( &self, did: &Did, ) -> Result { 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, ) -> Result>, 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::(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, ) -> Result, 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(body: &[u8]) -> Result { 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( &self, method: http::Method, path: &str, body: Option<&B>, ) -> Result 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( &self, path: &str, content_type: &str, extra_headers: &[(&str, String)], body: Vec, ) -> Result 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, 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."); } }