1use 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#[derive(Clone)]
36pub struct Client {
37 pub(crate) http: reqwest::Client,
38 base_url: String,
39 pub(crate) token: String,
40}
41
42#[derive(Clone, Default)]
46pub struct ClientBuilder {
47 base_url: String,
48 token: Option<String>,
49 http: Option<reqwest::Client>,
50}
51
52impl 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 pub fn builder(base_url: impl Into<String>) -> ClientBuilder {
77 ClientBuilder {
78 base_url: base_url.into(),
79 ..ClientBuilder::default()
80 }
81 }
82
83 pub fn base_url(&self) -> &str {
85 &self.base_url
86 }
87
88 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 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 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 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 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 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
210enum 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 #[must_use]
226 pub fn token(mut self, token: impl Into<String>) -> Self {
227 self.token = Some(token.into());
228 self
229 }
230
231 #[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}