trillium_api/
lib.rs

1/*!
2This crate represents a first pass at a utility crate for creating APIs with trillium.rs.
3
4## Formats supported:
5
6Currently, this crate supports *receiving* `application/json` and
7`application/x-form-www-urlencoded` by default. To disable
8`application/x-form-www-urlencoded` support, use `default-features =
9false`.
10
11This crate currently only supports sending json responses, but may
12eventually add `Accepts` negotiation and further outbound response
13content types.
14
15The [`ApiConnExt`] extension trait and [`ApiHandler`] can be used
16independently or in combination.
17
18[`ApiHandler`] provides an easy way to deserialize a single type from
19the request body, with a default approach to handling invalid
20serialization. ApiHandler does not handle serializing responses, so is
21best used in conjunction with [`ApiConnExt::with_json`]. If you need
22custom handling for deserialization errors, use
23[`ApiConnExt::deserialize`] instead of [`ApiHandler`].
24*/
25#![forbid(unsafe_code)]
26#![deny(
27    missing_copy_implementations,
28    rustdoc::missing_crate_level_docs,
29    missing_debug_implementations,
30    missing_docs,
31    nonstandard_style,
32    unused_qualifications
33)]
34
35use serde::{de::DeserializeOwned, Serialize};
36pub use serde_json::{json, Value};
37use std::{fmt::Debug, future::Future, marker::PhantomData};
38use trillium::{async_trait, conn_try, Conn, Handler, KnownHeaderName::ContentType};
39
40/**
41Trillium API handler
42
43Construct with [`api`] or [`ApiHandler::new`] and an async
44function that takes a [`Conn`] and any type that you've defined
45which implements [`DeserializeOwned`] and returns the [`Conn`].
46
47## Examples
48
49```
50use trillium_api::{ApiHandler, ApiConnExt};
51use serde::{Serialize, Deserialize};
52
53#[derive(Serialize, Deserialize)]
54struct BlogPost {
55    title: String,
56    body: String,
57}
58
59# async fn persist(blog_post: &mut BlogPost) -> Result<(), ()> { Ok(()) }
60async fn blog_post_handler(conn: trillium::Conn, mut blog_post: BlogPost) -> trillium::Conn {
61    match persist(&mut blog_post).await {
62        Ok(_) => conn.with_json(&blog_post),
63        Err(_) => conn.with_json(&blog_post).with_status(406),
64    }
65}
66
67let handler = ApiHandler::new(blog_post_handler); // equivalently, api(blog_post_handler)
68# use trillium_testing::prelude::*;
69
70/// accepts json
71assert_ok!(
72    post("/")
73        .with_request_body(r#"{ "title": "introducing trillium.rs", "body": "it's like plug, for async rust" }"#)
74        .with_request_header("content-type", "application/json")
75        .on(&handler),
76    "{\"title\":\"introducing trillium.rs\",\"body\":\"it's like plug, for async rust\"}",
77    "content-type" => "application/json"
78);
79
80
81/// accepts x-www-form-urlencoded
82assert_ok!(
83    post("/")
84        .with_request_body(r#"title=introducing+trillium.rs&body=it%27s+like+plug%2C+for+async+rust"#)
85        .with_request_header("content-type", "application/x-www-form-urlencoded")
86        .on(&handler),
87    "{\"title\":\"introducing trillium.rs\",\"body\":\"it's like plug, for async rust\"}",
88    "content-type" => "application/json"
89);
90```
91
92*/
93
94#[derive(Default, Debug)]
95pub struct ApiHandler<F, BodyType> {
96    handler_fn: F,
97    body_type: PhantomData<BodyType>,
98}
99
100/// Convenience function to build a trillium api handler. This is an
101/// alias for [`ApiHandler::new`].
102pub fn api<F, Fut, BodyType>(handler_fn: F) -> ApiHandler<F, BodyType>
103where
104    BodyType: DeserializeOwned + Send + Sync + 'static,
105    F: Fn(Conn, BodyType) -> Fut + Send + Sync + 'static,
106    Fut: Future<Output = Conn> + Send + 'static,
107{
108    ApiHandler::new(handler_fn)
109}
110
111impl<F, Fut, BodyType> ApiHandler<F, BodyType>
112where
113    BodyType: DeserializeOwned + Send + Sync + 'static,
114    F: Fn(Conn, BodyType) -> Fut + Send + Sync + 'static,
115    Fut: Future<Output = Conn> + Send + 'static,
116{
117    /// Build a new API handler for the given async function. This is
118    /// aliased as [`api`].
119    pub fn new(handler_fn: F) -> Self {
120        Self {
121            handler_fn,
122            body_type: PhantomData::default(),
123        }
124    }
125}
126
127#[async_trait]
128impl<F, Fut, BodyType> Handler for ApiHandler<F, BodyType>
129where
130    BodyType: DeserializeOwned + Send + Sync + 'static,
131    F: Fn(Conn, BodyType) -> Fut + Send + Sync + 'static,
132    Fut: Future<Output = Conn> + Send + 'static,
133{
134    async fn run(&self, mut conn: Conn) -> Conn {
135        match conn.deserialize::<BodyType>().await {
136            Ok(b) => (self.handler_fn)(conn, b).await,
137            Err(e) => conn.with_json(&e).with_status(422).halt(),
138        }
139    }
140}
141
142/// Extension trait that adds api methods to [`trillium::Conn`]
143#[trillium::async_trait]
144pub trait ApiConnExt {
145    /**
146    Sends a json response body. This sets a status code of 200,
147    serializes the body with serde_json, sets the content-type to
148    application/json, and [halts](trillium::Conn::halt) the
149    conn. If serialization fails, a 500 status code is sent as per
150    [`trillium::conn_try`]
151
152
153    ## Examples
154
155    ```
156    use trillium_api::{json, ApiConnExt};
157    async fn handler(conn: trillium::Conn) -> trillium::Conn {
158        conn.with_json(&json!({ "json macro": "is reexported" }))
159    }
160
161    # use trillium_testing::prelude::*;
162    assert_ok!(
163        get("/").on(&handler),
164        r#"{"json macro":"is reexported"}"#,
165        "content-type" => "application/json"
166    );
167    ```
168
169    ### overriding status code
170    ```
171    use trillium_api::ApiConnExt;
172    use serde::Serialize;
173
174    #[derive(Serialize)]
175    struct ApiResponse {
176       string: &'static str,
177       number: usize
178    }
179
180    async fn handler(conn: trillium::Conn) -> trillium::Conn {
181        conn.with_json(&ApiResponse { string: "not the most creative example", number: 100 })
182            .with_status(201) // note that this has to be chained _after_ the with_json call
183    }
184
185    # use trillium_testing::prelude::*;
186    assert_response!(
187        get("/").on(&handler),
188        Status::Created,
189        r#"{"string":"not the most creative example","number":100}"#,
190        "content-type" => "application/json"
191    );
192    ```
193    */
194    fn with_json(self, response: &impl Serialize) -> Self;
195
196    /**
197    Attempts to deserialize a type from the request body, based on the
198    request content type.
199
200    By default, both application/json and
201    application/x-www-form-urlencoded are supported, and future
202    versions may add accepted request content types. Please open an
203    issue if you need to accept another content type.
204
205
206    To exclusively accept application/json, disable default features
207    on this crate.
208
209
210    ## Examples
211
212    ### Deserializing to [`Value`]
213
214    ```
215    use trillium_api::{ApiConnExt, Value};
216
217    async fn handler(mut conn: trillium::Conn) -> trillium::Conn {
218        let value: Value = trillium::conn_try!(conn.deserialize().await, conn);
219        conn.with_json(&value)
220    }
221
222    # use trillium_testing::prelude::*;
223    assert_ok!(
224        post("/")
225            .with_request_body(r#"key=value"#)
226            .with_request_header("content-type", "application/x-www-form-urlencoded")
227            .on(&handler),
228        r#"{"key":"value"}"#,
229        "content-type" => "application/json"
230    );
231
232    ```
233
234    ### Deserializing a concrete type
235
236    ```
237    use trillium_api::ApiConnExt;
238
239    #[derive(serde::Deserialize)]
240    struct KvPair { key: String, value: String }
241
242    async fn handler(mut conn: trillium::Conn) -> trillium::Conn {
243        match conn.deserialize().await {
244            Ok(KvPair { key, value }) => {
245                conn.with_status(201)
246                    .with_body(format!("{} is {}", key, value))
247                    .halt()
248            }
249
250            Err(_) => conn.with_status(422).with_body("nope").halt()
251        }
252    }
253
254    # use trillium_testing::prelude::*;
255    assert_response!(
256        post("/")
257            .with_request_body(r#"key=name&value=trillium"#)
258            .with_request_header("content-type", "application/x-www-form-urlencoded")
259            .on(&handler),
260        Status::Created,
261        r#"name is trillium"#,
262    );
263
264    assert_response!(
265        post("/")
266            .with_request_body(r#"name=trillium"#)
267            .with_request_header("content-type", "application/x-www-form-urlencoded")
268            .on(&handler),
269        Status::UnprocessableEntity,
270        r#"nope"#,
271    );
272
273
274    ```
275
276    */
277    async fn deserialize<T>(&mut self) -> Result<T, Value>
278    where
279        T: DeserializeOwned;
280}
281
282#[trillium::async_trait]
283impl ApiConnExt for Conn {
284    fn with_json(self, response: &impl Serialize) -> Self {
285        let body = conn_try!(serde_json::to_string(&response), self);
286        self.ok(body).with_header(ContentType, "application/json")
287    }
288
289    async fn deserialize<T>(&mut self) -> Result<T, Value>
290    where
291        T: DeserializeOwned,
292    {
293        let body = self
294            .request_body_string()
295            .await
296            .map_err(|e| json!({ "errorType": "io error", "message": e.to_string() }))?;
297
298        let content_type = self
299            .headers()
300            .get_str(ContentType)
301            .and_then(|c| c.parse().ok())
302            .unwrap_or(mime::APPLICATION_JSON);
303
304        match content_type.subtype().as_str() {
305            "json" => serde_json::from_str::<T>(&body).map_err(|e| {
306                json!({
307                    "input": body,
308                    "line": e.line(),
309                    "column": e.column(),
310                    "message": e.to_string()
311                })
312            }),
313
314            #[cfg(feature = "forms")]
315            "x-www-form-urlencoded" => serde_urlencoded::from_str::<T>(&body)
316                .map_err(|e| json!({ "input": body, "message": e.to_string() })),
317
318            _ => Err(json!({
319                "errorType": format!("unknown content type {}", content_type)
320            })),
321        }
322    }
323}