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;