1use 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#[derive(Clone)]
39pub struct Client {
40 pub(crate) http: reqwest::Client,
41 base_url: String,
42 pub(crate) token: String,
43}
44
45#[derive(Clone, Default)]
49pub struct ClientBuilder {
50 base_url: String,
51 token: Option<String>,
52 http: Option<reqwest::Client>,
53}
54
55impl 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 pub fn builder(base_url: impl Into<String>) -> ClientBuilder {
80 ClientBuilder {
81 base_url: base_url.into(),
82 ..ClientBuilder::default()
83 }
84 }
85
86 pub fn base_url(&self) -> &str {
88 &self.base_url
89 }
90
91 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 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 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 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 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 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
215enum Attempt {
217 Rendered(Artifact),
218 Missing(Vec<MissingBlob>),
219}
220
221async 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
230fn 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 #[must_use]
252 pub fn token(mut self, token: impl Into<String>) -> Self {
253 self.token = Some(token.into());
254 self
255 }
256
257 #[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}