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