Skip to main content

mdcast_client/
client.rs

1//! The client itself: one call per render, with the blob negotiation folded
2//! into it.
3//!
4//! A render is optimistic by default — post the request, and if the server
5//! answers `409` with digests it does not hold, upload exactly those and post
6//! the identical request again. **Once.** A second `409` for the same digests
7//! means eviction is racing the retry or the server is broken, and either way
8//! a loop would turn a bug into a hang, so it surfaces as
9//! [`Error::BlobsStillMissing`].
10//!
11//! The pessimistic strategy is [`Client::preflight`] (check → upload → render,
12//! zero `409`s), for callers that would rather pay a round trip than have a
13//! large upload discovered mid-render.
14
15use mdcast_api::wire::{
16    CapabilitiesResponse, MissingBlob, MissingBlobsError, RenderMarkdownRequest,
17    RenderTemplateRequest, TemplateFormat,
18};
19use reqwest::{Response, StatusCode};
20use serde::Serialize;
21use serde::de::DeserializeOwned;
22
23use crate::artifact::{self, Artifact};
24use crate::bundle::AssetBundle;
25use crate::error::{self, Error, Result};
26
27pub(crate) const RENDER: &str = "/v1/render";
28pub(crate) const RENDER_TEMPLATE: &str = "/v1/render/template";
29pub(crate) const BLOBS: &str = "/v1/blobs";
30pub(crate) const BLOBS_CHECK: &str = "/v1/blobs/check";
31pub(crate) const CAPABILITIES: &str = "/v1/capabilities";
32
33/// A handle on one `mdcast-server`. Cheap to clone — the underlying
34/// `reqwest::Client` is a shared connection pool.
35#[derive(Clone)]
36pub struct Client {
37    pub(crate) http: reqwest::Client,
38    base_url: String,
39    pub(crate) token: String,
40}
41
42/// Builder for a [`Client`]. `base_url` and `token` are required; the HTTP
43/// client is optional so a caller can supply its own pool, timeouts, or proxy
44/// configuration instead of taking `reqwest`'s defaults.
45#[derive(Clone, Default)]
46pub struct ClientBuilder {
47    base_url: String,
48    token: Option<String>,
49    http: Option<reqwest::Client>,
50}
51
52/// `Debug` is written out rather than derived on both types: the token is a
53/// shared secret, and a derived impl would print it into whatever log or panic
54/// message formats the client.
55impl std::fmt::Debug for Client {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Client")
58            .field("base_url", &self.base_url)
59            .field("token", &"<redacted>")
60            .finish_non_exhaustive()
61    }
62}
63
64impl std::fmt::Debug for ClientBuilder {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("ClientBuilder")
67            .field("base_url", &self.base_url)
68            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
69            .finish_non_exhaustive()
70    }
71}
72
73impl Client {
74    /// Start building a client for the server at `base_url`
75    /// (`http://mdcast.internal:8080`, with or without a trailing slash).
76    pub fn builder(base_url: impl Into<String>) -> ClientBuilder {
77        ClientBuilder {
78            base_url: base_url.into(),
79            ..ClientBuilder::default()
80        }
81    }
82
83    /// The normalised base URL, without its trailing slash.
84    pub fn base_url(&self) -> &str {
85        &self.base_url
86    }
87
88    /// Render markdown, negotiating any assets the server is missing.
89    ///
90    /// `request.assets` is filled from `bundle` when the caller left it empty,
91    /// and verified against it otherwise.
92    pub async fn render(
93        &self,
94        mut request: RenderMarkdownRequest,
95        bundle: &AssetBundle,
96    ) -> Result<Artifact> {
97        request.assets = bundle.bind(request.assets)?;
98        let fallback = format!("output.{}", request.target.extension());
99        self.negotiate(RENDER, &request, bundle, &fallback).await
100    }
101
102    /// Render a typst template over structured data — same negotiation, same
103    /// single retry.
104    pub async fn render_template(
105        &self,
106        mut request: RenderTemplateRequest,
107        bundle: &AssetBundle,
108    ) -> Result<Artifact> {
109        request.assets = bundle.bind(request.assets)?;
110        let fallback = match request.format {
111            TemplateFormat::Pdf => "output.pdf",
112            TemplateFormat::Html => "output.html",
113        };
114        self.negotiate(RENDER_TEMPLATE, &request, bundle, fallback)
115            .await
116    }
117
118    /// What this server can actually render right now — targets, template
119    /// formats, and its upload size ceiling. Unauthenticated server-side
120    /// (`GET /v1/capabilities` sits outside the bearer gate), so this is
121    /// useful for feature-gating a UI before a render is ever attempted.
122    pub async fn capabilities(&self) -> Result<CapabilitiesResponse> {
123        let response = self.http.get(self.url(CAPABILITIES)).send().await?;
124        self.json_response(response).await
125    }
126
127    /// Post `body`, upload what came back missing, post it again. Exactly one
128    /// retry — see the module docs.
129    async fn negotiate<B: Serialize>(
130        &self,
131        path: &str,
132        body: &B,
133        bundle: &AssetBundle,
134        fallback_name: &str,
135    ) -> Result<Artifact> {
136        let missing = match self.attempt(path, body, fallback_name).await? {
137            Attempt::Rendered(artifact) => return Ok(artifact),
138            Attempt::Missing(missing) => missing,
139        };
140
141        self.upload_missing(&missing, bundle).await?;
142
143        match self.attempt(path, body, fallback_name).await? {
144            Attempt::Rendered(artifact) => Ok(artifact),
145            Attempt::Missing(missing) => Err(Error::BlobsStillMissing { missing }),
146        }
147    }
148
149    async fn attempt<B: Serialize>(
150        &self,
151        path: &str,
152        body: &B,
153        fallback_name: &str,
154    ) -> Result<Attempt> {
155        let response = self.post_json(path, body).await?;
156        let status = response.status();
157        let headers = response.headers().clone();
158        let bytes = response.bytes().await?;
159
160        if status.is_success() {
161            return Ok(Attempt::Rendered(artifact::from_response(
162                &headers,
163                bytes,
164                fallback_name,
165            )));
166        }
167        if status == StatusCode::CONFLICT {
168            let body: MissingBlobsError = decode(status, &bytes)?;
169            return Ok(Attempt::Missing(body.missing));
170        }
171        Err(error::from_status(status.as_u16(), &bytes))
172    }
173
174    pub(crate) fn url(&self, path: &str) -> String {
175        format!("{}{path}", self.base_url)
176    }
177
178    pub(crate) async fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Response> {
179        Ok(self
180            .http
181            .post(self.url(path))
182            .bearer_auth(&self.token)
183            .json(body)
184            .send()
185            .await?)
186    }
187
188    /// Read a JSON response body, mapping any non-success status onto the
189    /// typed error first.
190    pub(crate) async fn json_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
191        let status = response.status();
192        let bytes = response.bytes().await?;
193        if !status.is_success() {
194            return Err(error::from_status(status.as_u16(), &bytes));
195        }
196        decode(status, &bytes)
197    }
198
199    /// A response whose only interesting property is that it succeeded.
200    pub(crate) async fn expect_success(&self, response: Response) -> Result<()> {
201        let status = response.status();
202        if status.is_success() {
203            return Ok(());
204        }
205        let bytes = response.bytes().await?;
206        Err(error::from_status(status.as_u16(), &bytes))
207    }
208}
209
210/// What one render POST produced: the artifact, or the negotiation signal.
211enum Attempt {
212    Rendered(Artifact),
213    Missing(Vec<MissingBlob>),
214}
215
216fn decode<T: DeserializeOwned>(status: StatusCode, bytes: &[u8]) -> Result<T> {
217    serde_json::from_slice(bytes).map_err(|e| Error::InvalidResponse {
218        status: status.as_u16(),
219        reason: e.to_string(),
220    })
221}
222
223impl ClientBuilder {
224    /// The shared bearer token every `/v1` route requires.
225    #[must_use]
226    pub fn token(mut self, token: impl Into<String>) -> Self {
227        self.token = Some(token.into());
228        self
229    }
230
231    /// Use this `reqwest::Client` — the caller's own connection pool, timeouts,
232    /// redirect policy or proxy — instead of a freshly built default one.
233    #[must_use]
234    pub fn http_client(mut self, http: reqwest::Client) -> Self {
235        self.http = Some(http);
236        self
237    }
238
239    pub fn build(self) -> Result<Client> {
240        let base_url = self.base_url.trim_end_matches('/').to_string();
241        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
242            return Err(Error::Config(format!(
243                "base URL must start with http:// or https://, got {:?}",
244                self.base_url
245            )));
246        }
247
248        let token = self.token.unwrap_or_default();
249        if token.is_empty() {
250            return Err(Error::Config(
251                "a bearer token is required — every /v1 route on mdcast-server is gated".into(),
252            ));
253        }
254
255        Ok(Client {
256            http: self.http.unwrap_or_default(),
257            base_url,
258            token,
259        })
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn client(base_url: &str) -> Client {
268        Client::builder(base_url)
269            .token("t")
270            .build()
271            .expect("a valid configuration")
272    }
273
274    #[test]
275    fn urls_are_joined_with_exactly_one_slash() {
276        for base in ["http://h:8080", "http://h:8080/", "http://h:8080///"] {
277            assert_eq!(client(base).url(RENDER), "http://h:8080/v1/render");
278        }
279        assert_eq!(client("https://h").base_url(), "https://h");
280    }
281
282    #[test]
283    fn a_base_url_without_a_scheme_is_a_configuration_error() {
284        for base in ["h:8080", "", "ftp://h", "//h"] {
285            match Client::builder(base).token("t").build() {
286                Err(Error::Config(message)) => assert!(message.contains("http://"), "{message}"),
287                other => panic!("accepted base URL {base:?}: {other:?}"),
288            }
289        }
290    }
291
292    #[test]
293    fn debug_never_prints_the_bearer_token() {
294        let secret = "correct-horse-battery-staple";
295        let builder = Client::builder("http://h").token(secret);
296        let client = builder.clone().build().unwrap();
297
298        for rendered in [format!("{builder:?}"), format!("{client:?}")] {
299            assert!(!rendered.contains(secret), "leaked the token: {rendered}");
300            assert!(rendered.contains("<redacted>"), "{rendered}");
301        }
302    }
303
304    #[test]
305    fn a_missing_or_empty_token_is_a_configuration_error() {
306        for builder in [
307            Client::builder("http://h"),
308            Client::builder("http://h").token(""),
309        ] {
310            match builder.build() {
311                Err(Error::Config(message)) => assert!(message.contains("token"), "{message}"),
312                other => panic!("built a client with no token: {other:?}"),
313            }
314        }
315    }
316}