Skip to main content

ocre/
api.rs

1//! JSON APIs: errors as JSON, a JSON body extractor whose failures are JSON
2//! too, and `?limit=&offset=` pagination.
3
4use axum::{
5    extract::{FromRequest, FromRequestParts, Query, Request},
6    http::{HeaderValue, StatusCode, header, request::Parts},
7    response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
8};
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10
11use crate::Error;
12
13/// Handler error for JSON endpoints: an [`Error`] rendered as a JSON body.
14///
15/// The body is `{"error": {"status": 404, "message": "Not found"}}`; a 422
16/// adds `"fields": {"title": ["can't be blank"]}` (the shape Rails APIs use),
17/// and a 401 adds the `WWW-Authenticate: Bearer` header. Internal messages are
18/// logged and answered as `Internal server error`. Converts from [`Error`]
19/// and [`worker::Error`], so `?` works on everything Ocre returns. It is also
20/// the rejection of [`Json`] and [`Page`], and of [`Session`](crate::Session)
21/// in API-only apps (without the `html` feature).
22///
23/// # Examples
24///
25/// ```
26/// use axum::{http::StatusCode, response::IntoResponse};
27/// use ocre::{ApiError, Error};
28///
29/// let response = ApiError(Error::NotFound).into_response();
30/// assert_eq!(response.status(), StatusCode::NOT_FOUND);
31/// let response = ApiError::from(Error::Unauthorized).into_response();
32/// assert_eq!(response.headers()["www-authenticate"], "Bearer");
33/// ```
34#[derive(Debug)]
35pub struct ApiError(pub Error);
36
37/// `Result` for JSON handlers: errors become [`ApiError`] JSON responses.
38///
39/// # Examples
40///
41/// ```no_run
42/// use axum::extract::{Path, State};
43/// use ocre::{ApiResult, Ctx, Json, OptionExt, params};
44///
45/// async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> ApiResult<Json<serde_json::Value>> {
46///     let post = ctx.db()?.first("SELECT * FROM posts WHERE id = ?1", params![id]).await?.or_404()?;
47///     Ok(Json(post))
48/// }
49/// # let _ = show;
50/// ```
51pub type ApiResult<T> = Result<T, ApiError>;
52
53impl From<Error> for ApiError {
54    fn from(err: Error) -> Self {
55        Self(err)
56    }
57}
58
59impl From<worker::Error> for ApiError {
60    fn from(err: worker::Error) -> Self {
61        Self(err.into())
62    }
63}
64
65impl IntoResponse for ApiError {
66    fn into_response(self) -> Response {
67        let mut public = self.0.into_public();
68        let internal = public.internal.take();
69        let fields = (!public.fields.is_empty()).then(|| public.fields_json());
70        let mut error = serde_json::json!({ "status": public.status.as_u16(), "message": public.message });
71        if let Some(fields) = fields {
72            error["fields"] = fields;
73        }
74        let mut response = (public.status, axum::Json(serde_json::json!({ "error": error }))).into_response();
75        if public.status == StatusCode::UNAUTHORIZED {
76            // RFC 9110: a 401 names the scheme the client should use.
77            response.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
78        }
79        crate::error::mark(internal, &mut response);
80        response
81    }
82}
83
84/// JSON request body extractor and response, whose failures are JSON too.
85///
86/// As an extractor it requires `Content-Type: application/json`; unlike
87/// `axum::Json`, a body that is missing that header or is not valid JSON for
88/// `T` is answered with a JSON 400 ([`ApiError`] with
89/// [`Error::BadRequest`] carrying axum's explanation). As a response it
90/// serializes `T` with status 200 and `Content-Type: application/json`.
91///
92/// # Examples
93///
94/// ```
95/// use axum::{http::StatusCode, response::IntoResponse};
96/// use ocre::Json;
97///
98/// let response = Json(serde_json::json!({"id": 1})).into_response();
99/// assert_eq!(response.status(), StatusCode::OK);
100/// assert_eq!(response.headers()["content-type"], "application/json");
101/// ```
102///
103/// In a handler:
104///
105/// ```no_run
106/// use ocre::{ApiResult, Json};
107/// use serde::{Deserialize, Serialize};
108///
109/// #[derive(Deserialize, Serialize)]
110/// struct Echo {
111///     message: String,
112/// }
113///
114/// async fn echo(Json(body): Json<Echo>) -> ApiResult<Json<Echo>> {
115///     Ok(Json(body))
116/// }
117/// # let _ = echo;
118/// ```
119#[derive(Debug, Clone, Copy, Default)]
120pub struct Json<T>(pub T);
121
122impl<T: DeserializeOwned, S: Send + Sync> FromRequest<S> for Json<T> {
123    type Rejection = ApiError;
124
125    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
126        match axum::Json::<T>::from_request(req, state).await {
127            Ok(axum::Json(value)) => Ok(Self(value)),
128            Err(rejection) => Err(ApiError(Error::bad_request(rejection.body_text()))),
129        }
130    }
131}
132
133impl<T: Serialize> IntoResponse for Json<T> {
134    fn into_response(self) -> Response {
135        axum::Json(self.0).into_response()
136    }
137}
138
139/// `201 Created` response with a JSON body, for create endpoints.
140///
141/// # Examples
142///
143/// ```
144/// use axum::{http::StatusCode, response::IntoResponse};
145/// use ocre::Created;
146///
147/// let response = Created(serde_json::json!({"id": 7})).into_response();
148/// assert_eq!(response.status(), StatusCode::CREATED);
149/// assert_eq!(response.headers()["content-type"], "application/json");
150/// ```
151pub struct Created<T>(pub T);
152
153impl<T: Serialize> IntoResponse for Created<T> {
154    fn into_response(self) -> Response {
155        (StatusCode::CREATED, axum::Json(self.0)).into_response()
156    }
157}
158
159/// `?limit=&offset=` pagination for list endpoints, as an extractor.
160///
161/// `limit` defaults to [`DEFAULT_LIMIT`](Self::DEFAULT_LIMIT) (50) and is at
162/// most [`MAX_LIMIT`](Self::MAX_LIMIT) (100), to keep each request within the
163/// free plan's D1 rows-read budget; `offset` defaults to 0. Out-of-range or
164/// non-numeric values are rejected with a JSON 400 ([`ApiError`]). Bind both
165/// fields as `LIMIT ?1 OFFSET ?2`.
166///
167/// # Examples
168///
169/// ```no_run
170/// use axum::extract::State;
171/// use ocre::{ApiResult, Ctx, Json, Page, params};
172///
173/// async fn index(State(ctx): State<Ctx>, page: Page) -> ApiResult<Json<Vec<serde_json::Value>>> {
174///     let sql = "SELECT * FROM posts ORDER BY id DESC LIMIT ?1 OFFSET ?2";
175///     Ok(Json(ctx.db()?.all(sql, params![page.limit, page.offset]).await?))
176/// }
177/// # let _ = index;
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct Page {
181    /// Rows to return, `1..=100`.
182    pub limit: i64,
183    /// Rows to skip, `0` or more.
184    pub offset: i64,
185}
186
187impl Page {
188    /// `limit` when the query string has none: 50.
189    pub const DEFAULT_LIMIT: i64 = 50;
190    /// Largest accepted `limit`: 100.
191    pub const MAX_LIMIT: i64 = 100;
192
193    /// Builds a page after checking the bounds: `1..=100` for `limit`, `0..` for `offset`.
194    ///
195    /// The extractor calls it; call it yourself where there is no query
196    /// string, e.g. GraphQL arguments.
197    ///
198    /// # Errors
199    ///
200    /// [`Error::BadRequest`] (400) when `limit` is outside `1..=100`
201    /// ("limit must be between 1 and 100") or `offset` is negative ("offset
202    /// must be 0 or more").
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use ocre::Page;
208    ///
209    /// assert_eq!(Page::new(20, 40).unwrap(), Page { limit: 20, offset: 40 });
210    /// assert_eq!(Page::new(101, 0).unwrap_err().to_string(), "bad request: limit must be between 1 and 100");
211    /// assert!(Page::new(10, -1).is_err());
212    /// ```
213    pub fn new(limit: i64, offset: i64) -> Result<Self, Error> {
214        if !(1..=Self::MAX_LIMIT).contains(&limit) {
215            return Err(Error::bad_request(format!("limit must be between 1 and {}", Self::MAX_LIMIT)));
216        }
217        if offset < 0 {
218            return Err(Error::bad_request("offset must be 0 or more"));
219        }
220        Ok(Self { limit, offset })
221    }
222
223    /// The page after this one, or `None` when `returned` (the rows this page got) is less than `limit`.
224    ///
225    /// Ocre paginates without `COUNT(*)` (that query reads every row, and D1
226    /// bills rows read), so "is there more?" is guessed from a full page: when
227    /// the last page is exactly full, its "Next" link leads to an empty page.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use ocre::Page;
233    ///
234    /// let page = Page::new(50, 0).unwrap();
235    /// assert_eq!(page.next(50), Some(Page { limit: 50, offset: 50 }));
236    /// assert_eq!(page.next(12), None);
237    /// ```
238    pub fn next(&self, returned: usize) -> Option<Self> {
239        (i64::try_from(returned).is_ok_and(|returned| returned >= self.limit))
240            .then_some(Self { limit: self.limit, offset: self.offset.saturating_add(self.limit) })
241    }
242
243    /// The page before this one, or `None` on the first page.
244    ///
245    /// # Examples
246    ///
247    /// ```
248    /// use ocre::Page;
249    ///
250    /// assert_eq!(Page::new(50, 70).unwrap().previous(), Some(Page { limit: 50, offset: 20 }));
251    /// assert_eq!(Page::new(50, 0).unwrap().previous(), None);
252    /// ```
253    pub fn previous(&self) -> Option<Self> {
254        (self.offset > 0).then(|| Self { limit: self.limit, offset: (self.offset - self.limit).max(0) })
255    }
256
257    /// The query string of this page, without `?`: `offset=50`, with `limit=` first when it is not the default.
258    ///
259    /// Templates link to other pages with it:
260    /// `{% if let Some(next) = page.next(posts.len()) %}<a href="?{{ next.query() }}">Next</a>{% endif %}`.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use ocre::Page;
266    ///
267    /// assert_eq!(Page::new(50, 100).unwrap().query(), "offset=100");
268    /// assert_eq!(Page::new(20, 40).unwrap().query(), "limit=20&offset=40");
269    /// ```
270    pub fn query(&self) -> String {
271        if self.limit == Self::DEFAULT_LIMIT {
272            format!("offset={}", self.offset)
273        } else {
274            format!("limit={}&offset={}", self.limit, self.offset)
275        }
276    }
277
278    /// `Link` header (RFC 8288) pointing JSON clients at the `next`, `prev` and `first` pages of `path`.
279    ///
280    /// GitHub's API convention. Returns a response part: put it before the
281    /// body in a tuple. `returned` is the number of rows this page got; see
282    /// [`next`](Self::next). No header on a single page.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use axum::response::IntoResponse;
288    /// use ocre::{Json, Page};
289    ///
290    /// let page = Page::new(2, 2).unwrap();
291    /// let posts = vec!["c", "d"];
292    /// let response = (page.links("/api/posts", posts.len()), Json(posts)).into_response();
293    /// assert_eq!(
294    ///     response.headers()["link"],
295    ///     r#"</api/posts?limit=2&offset=4>; rel="next", </api/posts?limit=2&offset=0>; rel="prev", </api/posts?limit=2&offset=0>; rel="first""#
296    /// );
297    /// ```
298    pub fn links(&self, path: &str, returned: usize) -> PageLinks {
299        let mut links = Vec::new();
300        if let Some(next) = self.next(returned) {
301            links.push(format!("<{path}?{}>; rel=\"next\"", next.query()));
302        }
303        if let Some(previous) = self.previous() {
304            links.push(format!("<{path}?{}>; rel=\"prev\"", previous.query()));
305            let first = Self { limit: self.limit, offset: 0 };
306            links.push(format!("<{path}?{}>; rel=\"first\"", first.query()));
307        }
308        PageLinks((!links.is_empty()).then(|| links.join(", ")).and_then(|value| HeaderValue::from_str(&value).ok()))
309    }
310}
311
312/// The `Link` header built by [`Page::links`], as a response part (nothing when there is no other page).
313///
314/// # Examples
315///
316/// ```
317/// use axum::response::IntoResponse;
318/// use ocre::Page;
319///
320/// let only_page = Page::new(50, 0).unwrap().links("/api/posts", 3);
321/// let response = (only_page, "[]").into_response();
322/// assert!(response.headers().get("link").is_none());
323/// ```
324#[derive(Debug, Clone)]
325pub struct PageLinks(Option<HeaderValue>);
326
327impl IntoResponseParts for PageLinks {
328    type Error = std::convert::Infallible;
329
330    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
331        if let Some(value) = self.0 {
332            res.headers_mut().insert(header::LINK, value);
333        }
334        Ok(res)
335    }
336}
337
338impl<S: Send + Sync> FromRequestParts<S> for Page {
339    type Rejection = ApiError;
340
341    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
342        #[derive(Deserialize)]
343        struct Raw {
344            limit: Option<i64>,
345            offset: Option<i64>,
346        }
347        let Query(raw) = Query::<Raw>::from_request_parts(parts, state)
348            .await
349            .map_err(|rejection| ApiError(Error::bad_request(rejection.body_text())))?;
350        Ok(Self::new(raw.limit.unwrap_or(Self::DEFAULT_LIMIT), raw.offset.unwrap_or(0))?)
351    }
352}
353
354#[cfg(test)]
355#[path = "../tests/api.rs"]
356mod tests;