//! 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 } } #[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" ); } }