Skip to main content

typed_openapi/
client.rs

1//! A typed client over a document, and the two steps every call is made of.
2//!
3//! [`Client`] holds one document and hands out [`Call`]s by `operationId`.
4//! `Call` knows what its answer deserialises into, so sending is one expression
5//! in each flavour and a caller never names a response type twice.
6//!
7//! Nothing here is specific to one API. A generated wrapper is the typed door
8//! onto [`Client::call`]: it names the `operationId`, names the arguments under
9//! the document's own names, and holds no path template, no query rule and no
10//! encoder.
11
12use std::fmt;
13use std::marker::PhantomData;
14
15use http::{StatusCode, Uri};
16use serde::Serialize;
17use serde::de::DeserializeOwned;
18use thiserror::Error;
19
20use crate::model::{Document, DocumentError, DriftError, Effect, Operation};
21use crate::request::{Invocation, ValueError};
22use crate::transport::{AsyncClient, HttpRequest, HttpResponse, SyncClient};
23use crate::values::Values;
24
25/// A response the document promises no body for.
26///
27/// It deserialises from anything, including an empty body, so an operation that
28/// answers `201` with nothing is not a special case at the call site.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct NoContent;
31
32impl<'de> serde::Deserialize<'de> for NoContent {
33    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
34        serde::de::IgnoredAny::deserialize(de).map(|_| Self)
35    }
36}
37
38/// Anything that stops a call short of a typed answer.
39#[derive(Debug, Error)]
40pub enum Error {
41    #[error("the document does not load: {0}")]
42    Document(#[from] DocumentError),
43    #[error(transparent)]
44    Drift(#[from] DriftError),
45    #[error(transparent)]
46    Values(#[from] ValueError),
47    #[error("cannot build the request: {0}")]
48    Request(#[from] http::Error),
49    #[error("cannot serialise the request body: {0}")]
50    Encode(#[source] serde_json::Error),
51    #[error("transport: {0}")]
52    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
53    #[error("{status}: {body}")]
54    Status { status: StatusCode, body: String },
55    #[error("the response is not the shape the document promises: {source}")]
56    Decode {
57        #[source]
58        source: serde_json::Error,
59        raw: String,
60    },
61}
62
63/// One document, one server, and a typed way to call anything in it.
64#[derive(Debug)]
65pub struct Client {
66    document: Document,
67    base: Uri,
68}
69
70impl Client {
71    /// The one constructor: a reduction that is already in hand.
72    ///
73    /// A shipped binary gets that reduction from `Document::from_blob`, which
74    /// is all it can do — the reader is a cargo feature and the binary does not
75    /// enable it. A bless step, which does, writes
76    /// `Client::over(Document::load(document, overlay)?)` and handles the load
77    /// failure under its own type, so no shape on this page depends on which
78    /// build a reader is looking at.
79    pub fn over(document: Document) -> Result<Self, Error> {
80        let base = document.base().clone();
81        Ok(Self { document, base })
82    }
83
84    /// Point at a different server than the document's first one.
85    #[must_use]
86    pub fn with_base(mut self, base: Uri) -> Self {
87        self.base = base;
88        self
89    }
90
91    #[must_use]
92    pub fn base(&self) -> &Uri {
93        &self.base
94    }
95
96    /// The document itself, for a caller that wants the command tree or an
97    /// operation no wrapper covers.
98    #[must_use]
99    pub fn document(&self) -> &Document {
100        &self.document
101    }
102
103    /// An operation and the values for it.
104    ///
105    /// The operation is a reference into a document rather than a name to look
106    /// up, so there is no "no such operation" to report here: the caller
107    /// already holds one.
108    pub fn call<'a, T>(&'a self, op: &'a Operation, values: Values) -> Result<Call<'a, T>, Error> {
109        Ok(Call {
110            invocation: Invocation::new(op, values)?,
111            base: &self.base,
112            response: PhantomData,
113        })
114    }
115}
116
117/// A request body that does not deserialise into the type the document
118/// describes for it.
119///
120/// This is what a `--json-body` file gets checked against. The source is
121/// serde's own message, which names the field that is missing or ill-typed.
122#[derive(Debug, Error)]
123#[error("{op}: the request body does not fit the schema the document declares")]
124pub struct BodyError {
125    pub op: String,
126    #[source]
127    pub source: serde_json::Error,
128}
129
130/// Does `body` deserialise into `T`?
131///
132/// Generated code calls this with the type its own wrapper takes for the
133/// operation, which is how a body assembled on a command line is held to the
134/// same schema as a body passed from Rust — before a request is built.
135pub fn fits<T: DeserializeOwned>(op: &str, body: &serde_json::Value) -> Result<(), BodyError> {
136    serde_json::from_value::<T>(body.clone())
137        .map(drop)
138        .map_err(|source| BodyError {
139            op: op.to_owned(),
140            source,
141        })
142}
143
144/// One request, built and waiting, that knows what its answer deserialises into.
145pub struct Call<'a, T> {
146    invocation: Invocation<'a>,
147    base: &'a Uri,
148    response: PhantomData<fn() -> T>,
149}
150
151impl<T> fmt::Debug for Call<'_, T> {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.debug_struct("Call")
154            .field("operation", &self.invocation.operation().id())
155            .field("response", &std::any::type_name::<T>())
156            .finish()
157    }
158}
159
160impl<T: DeserializeOwned> Call<'_, T> {
161    /// `Write` means a CLI must see `--commit` before this is sent. A Rust
162    /// caller is trusted and never asks.
163    #[must_use]
164    pub fn effect(&self) -> Effect {
165        self.invocation.effect()
166    }
167
168    /// The exact bytes that go on the wire. A CLI prints this for a dry run; a
169    /// test asserts on it.
170    pub fn request(&self) -> Result<HttpRequest, Error> {
171        Ok(self.invocation.request(self.base)?)
172    }
173
174    /// Send it and deserialise the answer.
175    pub fn send<C: SyncClient>(&self, client: &C) -> Result<T, Error> {
176        parse(&client.send(self.request()?).map_err(transport)?)
177    }
178
179    /// The same, inside a runtime.
180    pub async fn send_async<C: AsyncClient>(&self, client: &C) -> Result<T, Error> {
181        parse(&client.send(self.request()?).await.map_err(transport)?)
182    }
183}
184
185fn transport<E: std::error::Error + Send + Sync + 'static>(error: E) -> Error {
186    Error::Transport(Box::new(error))
187}
188
189/// Status first, then the body — never deserialise the success type out of an
190/// error response.
191fn parse<T: DeserializeOwned>(response: &HttpResponse) -> Result<T, Error> {
192    let body = response.body();
193    if !response.status().is_success() {
194        return Err(Error::Status {
195            status: response.status(),
196            body: String::from_utf8_lossy(body).into_owned(),
197        });
198    }
199    // An empty body is how "no content" arrives; `NoContent` accepts `null`.
200    let bytes = if body.is_empty() { b"null" } else { &body[..] };
201    serde_json::from_slice(bytes).map_err(|source| Error::Decode {
202        source,
203        raw: String::from_utf8_lossy(body).into_owned(),
204    })
205}
206
207/// The request body of a typed wrapper, as JSON.
208pub fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, Error> {
209    serde_json::to_value(value).map_err(Error::Encode)
210}