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}