Skip to main content

mdcast_client/
lib.rs

1//! Async HTTP client for [`mdcast-server`], with the content-addressed asset
2//! negotiation made invisible.
3//!
4//! Hand it a document and a bag of assets. It hashes them, builds the
5//! [`AssetManifest`], posts the render, and — if the server answers `409` with
6//! blobs it does not hold — uploads exactly those and retries once. You never
7//! compute a digest, never build a manifest, and never write a retry loop.
8//!
9//! ```no_run
10//! use mdcast_client::{AssetBundle, Client, request};
11//! use mdcast_api::Target;
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let client = Client::builder("http://mdcast.internal:8080")
15//!     .token(std::env::var("MDCAST_TOKEN")?)
16//!     .build()?;
17//!
18//! let bundle = AssetBundle::new().with("img/chart.svg", std::fs::read("chart.svg")?);
19//! let artifact = client
20//!     .render(request::markdown("# Q3\n\n![](img/chart.svg)", Target::Pdf), &bundle)
21//!     .await?;
22//!
23//! std::fs::write(&artifact.filename, &artifact.bytes)?;
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! That is the whole cold-cache exchange: three requests (`409` → upload →
29//! `200`) behind one `await`. A warm cache costs one.
30//!
31//! ## Why this crate does not depend on `mdcast`
32//!
33//! Speaking the protocol needs a handful of serde structs, not a rendering
34//! engine — so this crate depends on [`mdcast_api`] and an HTTP client, and
35//! **never on `mdcast` itself**, not even with `default-features = false`.
36//! Depending on it would compile `rust-embed` (which pulls the whole vendored
37//! reveal.js tree and the reference OOXML documents in through a proc macro),
38//! `clap`, `tempfile`, `toml`, `regex`, `pulldown-cmark`, `futures`,
39//! `tracing-subscriber`, `typst` and pandoc's zip/xml stack into a process
40//! whose entire job is to POST some JSON. That cost is exactly what the
41//! workspace split exists to prevent (`PROJECT_PLAN.md` §7.1).
42//!
43//! ## Module map
44//!
45//! | Module | Contents |
46//! |---|---|
47//! | [`client`] | [`Client`] + [`ClientBuilder`] — render, with the `409` retry folded in |
48//! | [`blobs`] | [`Client::check`] / [`Client::upload`] / [`Client::preflight`] — the explicit, pessimistic path |
49//! | [`bundle`] | [`AssetBundle`] — `key → bytes`, and the hashing that turns it into a manifest |
50//! | [`artifact`] | [`Artifact`] — bytes, filename, content type |
51//! | [`request`] | constructors for the two wire request bodies |
52//! | [`error`] | [`Error`] — transport, `401`, `413`, a `409` that survived the retry, a server [`ErrorResponse`], a bundle that cannot satisfy its manifest |
53//!
54//! ## Two strategies
55//!
56//! [`Client::render`] is optimistic: assume the server has the assets, react
57//! to a `409` if it does not. That is one request in the steady state and
58//! three on a cold cache, and it is the default.
59//!
60//! A caller that would rather never discover a large upload mid-render calls
61//! [`Client::preflight`] first — `POST /v1/blobs/check`, upload what came
62//! back, then render — trading a guaranteed extra round trip for a render that
63//! cannot `409`.
64//!
65//! [`mdcast-server`]: https://github.com/xmiksay/mdcast#http-service-server-feature
66//! [`AssetManifest`]: mdcast_api::AssetManifest
67//! [`ErrorResponse`]: mdcast_api::wire::ErrorResponse
68
69pub mod artifact;
70pub mod blobs;
71pub mod bundle;
72pub mod client;
73pub mod error;
74pub mod request;
75
76pub use artifact::Artifact;
77pub use bundle::{AssetBundle, BlobFetchError, sha256};
78pub use client::{Client, ClientBuilder};
79pub use error::{Error, Result};
80
81/// The wire protocol itself, re-exported so a consumer needs exactly one
82/// dependency line: `Target`, `DocMeta`, `BrandSpec`, `AssetManifest`,
83/// `ErrorCode` and the request bodies all live here.
84pub use mdcast_api;