Skip to main content

better_fetch/
endpoint.rs

1//! Typed API routes via the [`Endpoint`] trait.
2//!
3//! Define routes as types, then use [`Client::call`](crate::Client::call) for a typed
4//! [`EndpointRequestBuilder`]. Path and query use [`.params()`](EndpointRequestBuilder::params)
5//! and [`.query()`](EndpointRequestBuilder::query) with structs — not string keys.
6//!
7//! For ad-hoc string paths, use [`Client::get`](crate::Client::get) instead (see [`RequestBuilder`](crate::RequestBuilder)).
8//!
9//! Helpers: [`endpoint!`], [`define_params!`], and (feature `macros`) `EndpointParamsDerive` /
10//! `EndpointQueryDerive`.
11
12use std::marker::PhantomData;
13
14use http::Method;
15use indexmap::IndexMap;
16
17use crate::request::RequestBuilder;
18use crate::url_build::QueryValue;
19
20#[cfg(feature = "json")]
21use serde::de::DeserializeOwned;
22
23/// Type-state: path parameters still required before send.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct NeedsParams;
26
27/// Type-state: ready to configure query/headers and send.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct Ready;
30
31/// Describes a typed API route.
32///
33/// Implement this trait (or use [`endpoint!`]) and call [`Client::call`](crate::Client::call).
34/// Path and query parameters are typed via [`EndpointParams`] and [`EndpointQuery`] structs;
35/// use [`.params()`](EndpointRequestBuilder::params) and [`.query()`](EndpointRequestBuilder::query).
36///
37/// # Examples
38///
39/// ```no_run
40/// # use better_fetch::{Client, Endpoint, EndpointParams, Result, define_params};
41/// # use http::Method;
42/// # use serde::Deserialize;
43/// define_params!(GetTodoParams for "/todos/:id" { id: u64 });
44///
45/// struct GetTodo;
46///
47/// impl Endpoint for GetTodo {
48///     const METHOD: Method = Method::GET;
49///     const PATH: &'static str = "/todos/:id";
50///     type Response = Todo;
51///     type Params = GetTodoParams;
52///     type Query = ();
53/// }
54///
55/// #[derive(Deserialize)]
56/// struct Todo { id: u64, title: String }
57///
58/// # #[tokio::main]
59/// # async fn main() -> Result<()> {
60/// let client = Client::new("https://jsonplaceholder.typicode.com")?;
61/// let todo: Todo = client
62///     .call::<GetTodo>()
63///     .params(GetTodoParams { id: 1 })
64///     .send_json()
65///     .await?;
66/// # Ok(())
67/// # }
68/// ```
69pub trait Endpoint {
70    /// HTTP method for this route.
71    const METHOD: Method;
72    /// Path template (may include `:param` segments).
73    const PATH: &'static str;
74
75    #[cfg(feature = "json")]
76    /// JSON response type for [`EndpointRequestBuilder::send_json`].
77    type Response: DeserializeOwned;
78
79    #[cfg(not(feature = "json"))]
80    /// Response type when the `json` feature is disabled.
81    type Response;
82
83    /// Path parameters applied via [`EndpointRequestBuilder::params`].
84    type Params: EndpointParams + Default;
85    /// Query parameters applied via [`EndpointRequestBuilder::query`].
86    type Query: EndpointQuery + Default;
87}
88
89/// Initial builder state for an endpoint's path parameters.
90pub type ParamsBuilderState<P> = <P as EndpointParams>::BuilderState;
91
92/// Creates the initial [`EndpointRequestBuilder`] for `client.call::<E>()`.
93pub trait EndpointParamsInitial<E: Endpoint>: EndpointParams {
94    fn initial(client: &crate::Client) -> EndpointRequestBuilder<'_, E, Self::BuilderState>;
95}
96
97impl<E: Endpoint> EndpointParamsInitial<E> for () {
98    fn initial(client: &crate::Client) -> EndpointRequestBuilder<'_, E, Ready> {
99        EndpointRequestBuilder::new_ready(client.request(E::METHOD, E::PATH))
100    }
101}
102
103impl<E: Endpoint, P: EndpointParams<BuilderState = NeedsParams>> EndpointParamsInitial<E> for P {
104    fn initial(client: &crate::Client) -> EndpointRequestBuilder<'_, E, NeedsParams> {
105        EndpointRequestBuilder::new_needs_params(client.request(E::METHOD, E::PATH))
106    }
107}
108
109/// Applies path parameters to a [`RequestBuilder`].
110pub trait EndpointParams: Default + Sized {
111    /// When [`NeedsParams`], [`.params()`](EndpointRequestBuilder::params) is required before send.
112    type BuilderState;
113    /// Applies this type's parameters to `builder`.
114    fn apply_params(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_>;
115}
116
117impl EndpointParams for () {
118    type BuilderState = Ready;
119
120    fn apply_params(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_> {
121        builder
122    }
123}
124
125impl EndpointParams for std::collections::HashMap<String, String> {
126    type BuilderState = NeedsParams;
127
128    fn apply_params(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_> {
129        builder.params(self)
130    }
131}
132
133impl EndpointParams for Vec<(String, String)> {
134    type BuilderState = NeedsParams;
135
136    fn apply_params(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_> {
137        builder.params_iter(self)
138    }
139}
140
141/// Applies query parameters to a [`RequestBuilder`].
142pub trait EndpointQuery {
143    /// Applies this type's query map to `builder`.
144    fn apply_query(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_>;
145}
146
147impl EndpointQuery for () {
148    fn apply_query(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_> {
149        builder
150    }
151}
152
153impl EndpointQuery for IndexMap<String, QueryValue> {
154    fn apply_query(self, builder: RequestBuilder<'_>) -> RequestBuilder<'_> {
155        builder.queries(self)
156    }
157}
158
159/// Applies a serde-serializable query struct to a request builder (feature `json`).
160#[cfg(feature = "json")]
161pub fn apply_serialized_query<T: serde::Serialize>(
162    query: T,
163    builder: RequestBuilder<'_>,
164) -> RequestBuilder<'_> {
165    match crate::url_build::serialize_to_query_map(&query) {
166        Ok(map) => builder.queries(map),
167        Err(_) => builder,
168    }
169}
170
171/// Fluent builder for a typed [`Endpoint`].
172///
173/// When `E::Params` is not [`()`], the builder starts in [`NeedsParams`] and requires
174/// [`.params()`](Self::params) before [`.send_json()`](Self::send_json).
175pub struct EndpointRequestBuilder<'a, E: Endpoint, S> {
176    pub(crate) inner: RequestBuilder<'a>,
177    _marker: PhantomData<(E, S)>,
178}
179
180impl<'a, E: Endpoint> EndpointRequestBuilder<'a, E, NeedsParams> {
181    pub(crate) fn new_needs_params(inner: RequestBuilder<'a>) -> Self {
182        Self {
183            inner,
184            _marker: PhantomData,
185        }
186    }
187
188    /// Applies typed path parameters for `E::Params` and transitions to [`Ready`].
189    pub fn params(self, params: E::Params) -> EndpointRequestBuilder<'a, E, Ready> {
190        EndpointRequestBuilder {
191            inner: params.apply_params(self.inner),
192            _marker: PhantomData,
193        }
194    }
195}
196
197impl<'a, E: Endpoint> EndpointRequestBuilder<'a, E, Ready> {
198    pub(crate) fn new_ready(inner: RequestBuilder<'a>) -> Self {
199        Self {
200            inner,
201            _marker: PhantomData,
202        }
203    }
204
205    /// Applies typed query parameters for `E::Query`.
206    pub fn query(self, query: E::Query) -> Self {
207        Self {
208            inner: query.apply_query(self.inner),
209            _marker: PhantomData,
210        }
211    }
212
213    /// Adds a request header.
214    pub fn header(self, key: impl AsRef<str>, value: impl AsRef<str>) -> crate::Result<Self> {
215        Ok(Self {
216            inner: self.inner.header(key, value)?,
217            _marker: PhantomData,
218        })
219    }
220
221    /// Sets bearer authentication.
222    pub fn bearer_token(self, token: impl Into<String>) -> Self {
223        Self {
224            inner: self.inner.bearer_token(token),
225            _marker: PhantomData,
226        }
227    }
228
229    /// Attaches a cancellation token.
230    pub fn cancellation_token(self, token: crate::CancellationToken) -> Self {
231        Self {
232            inner: self.inner.cancellation_token(token),
233            _marker: PhantomData,
234        }
235    }
236
237    /// When `true`, [`send`](Self::send) returns `Err` on non-2xx.
238    pub fn throw_on_error(self, throw: bool) -> Self {
239        Self {
240            inner: self.inner.throw_on_error(throw),
241            _marker: PhantomData,
242        }
243    }
244
245    /// Executes the request and returns [`Response`](crate::Response).
246    pub async fn send(self) -> crate::Result<crate::Response> {
247        self.inner.send().await
248    }
249
250    /// Executes and deserializes `E::Response` (feature `json`).
251    #[cfg(feature = "json")]
252    pub async fn send_json(self) -> crate::Result<E::Response> {
253        self.inner.send().await?.json::<E::Response>().await
254    }
255
256    /// Returns the underlying [`RequestBuilder`] for advanced options.
257    pub fn into_inner(self) -> RequestBuilder<'a> {
258        self.inner
259    }
260}
261
262/// Defines path parameters for a route and implements [`EndpointParams`].
263///
264/// Each struct field maps to a `:field` segment in `path` (by field name).
265/// For compile-time path validation, use `#[derive(EndpointParamsDerive)]` (feature `macros`).
266///
267/// # Examples
268///
269/// ```
270/// use better_fetch::{define_params, EndpointParams, NeedsParams};
271///
272/// define_params!(GetTodoParams for "/todos/:id" { id: u64 });
273///
274/// fn assert_needs_params<T: EndpointParams<BuilderState = NeedsParams>>() {}
275/// assert_needs_params::<GetTodoParams>();
276/// ```
277#[macro_export]
278macro_rules! define_params {
279    (
280        $name:ident for $path:literal {
281            $( $field:ident : $ty:ty ),* $(,)?
282        }
283    ) => {
284        #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
285        pub struct $name {
286            $( pub $field: $ty, )*
287        }
288
289        impl $crate::EndpointParams for $name {
290            type BuilderState = $crate::NeedsParams;
291
292            fn apply_params(self, builder: $crate::RequestBuilder<'_>) -> $crate::RequestBuilder<'_> {
293                let builder = builder;
294                $(
295                    let builder = builder.param(stringify!($field), self.$field);
296                )*
297                builder
298            }
299        }
300    };
301}
302
303/// Implements [`EndpointQuery`] for a serde-serializable query struct (feature `json`).
304#[cfg(feature = "json")]
305#[macro_export]
306macro_rules! impl_serde_endpoint_query {
307    ($ty:ty) => {
308        impl $crate::EndpointQuery for $ty {
309            fn apply_query(
310                self,
311                builder: $crate::RequestBuilder<'_>,
312            ) -> $crate::RequestBuilder<'_> {
313                $crate::endpoint::apply_serialized_query(self, builder)
314            }
315        }
316    };
317}
318
319/// Defines a simple [`Endpoint`] with optional typed params and query.
320///
321/// # Examples
322///
323/// ```
324/// use better_fetch::{endpoint, define_params};
325/// use serde::Deserialize;
326///
327/// #[derive(Deserialize)]
328/// pub struct Health {
329///     ok: bool,
330/// }
331///
332/// endpoint!(HealthCheck, GET, "/health", Response = Health);
333///
334/// define_params!(GetTodoParams for "/todos/:id" { id: u64 });
335/// endpoint!(GetTodo, GET, "/todos/:id", Response = Health, Params = GetTodoParams);
336/// ```
337#[macro_export]
338macro_rules! endpoint {
339    (
340        $name:ident,
341        $method:ident,
342        $path:literal,
343        Response = $response:ty
344    ) => {
345        $crate::endpoint!(
346            $name,
347            $method,
348            $path,
349            Response = $response,
350            Params = (),
351            Query = ()
352        );
353    };
354    (
355        $name:ident,
356        $method:ident,
357        $path:literal,
358        Response = $response:ty,
359        Params = $params:ty
360    ) => {
361        $crate::endpoint!(
362            $name,
363            $method,
364            $path,
365            Response = $response,
366            Params = $params,
367            Query = ()
368        );
369    };
370    (
371        $name:ident,
372        $method:ident,
373        $path:literal,
374        Response = $response:ty,
375        Query = $query:ty
376    ) => {
377        $crate::endpoint!(
378            $name,
379            $method,
380            $path,
381            Response = $response,
382            Params = (),
383            Query = $query
384        );
385    };
386    (
387        $name:ident,
388        $method:ident,
389        $path:literal,
390        Response = $response:ty,
391        Params = $params:ty,
392        Query = $query:ty
393    ) => {
394        pub struct $name;
395        impl $crate::Endpoint for $name {
396            const METHOD: http::Method = http::Method::$method;
397            const PATH: &'static str = $path;
398            type Response = $response;
399            type Params = $params;
400            type Query = $query;
401        }
402    };
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    define_params!(TestParams for "/items/:id" { id: u64 });
410
411    #[test]
412    fn params_builder_state_is_needs_params() {
413        fn assert_needs<T: EndpointParams<BuilderState = NeedsParams>>() {}
414        assert_needs::<TestParams>();
415    }
416
417    #[test]
418    fn unit_params_builder_state_is_ready() {
419        fn assert_ready<T: EndpointParams<BuilderState = Ready>>() {}
420        assert_ready::<()>();
421    }
422}