Skip to main content

jsonapi_axum/
extract.rs

1//! axum extractors for JSON:API requests.
2//!
3//! Each extractor is a thin binding: it reads the relevant part of the request
4//! and delegates to [`jsonapi_http`], turning failures into a [`JsonApiError`]
5//! rejection.
6
7use std::convert::Infallible;
8use std::marker::PhantomData;
9use std::sync::Arc;
10
11use axum::extract::{FromRef, FromRequest, FromRequestParts, Request};
12use bytes::Bytes;
13use http::request::Parts;
14use serde::de::DeserializeOwned;
15
16use jsonapi_core::{Document, JsonApiMediaType, PrimaryData, Query, ResourceObject, TypeRegistry};
17use jsonapi_http::{
18    ClientIdPolicy, check_client_id, check_content_type, check_id_matches, deserialize_body,
19    parse_query,
20};
21
22use crate::error::JsonApiError;
23
24/// The response media type negotiated by [`AcceptLayer`](crate::AcceptLayer),
25/// read back out of the request extensions where the layer stored it.
26///
27/// A responder's `IntoResponse` cannot see the request, so a handler that wants
28/// the response `Content-Type` to reflect the negotiated `ext`/`profile`
29/// parameters extracts this and passes it to
30/// [`JsonApiResponse::media_type`](crate::JsonApiResponse::media_type):
31///
32/// ```no_run
33/// use jsonapi_axum::{NegotiatedMediaType, JsonApiResponse};
34/// # use jsonapi_axum::{Document, Resource};
35/// async fn handler(NegotiatedMediaType(media): NegotiatedMediaType) -> JsonApiResponse<Resource> {
36///     # let document: Document<Resource> = todo!();
37///     JsonApiResponse::new(document).media_type(media)
38/// }
39/// ```
40///
41/// When no [`AcceptLayer`](crate::AcceptLayer) ran (nothing stored an extension),
42/// it falls back to [`JsonApiMediaType::plain`], so it never fails.
43#[derive(Debug, Clone)]
44pub struct NegotiatedMediaType(pub JsonApiMediaType);
45
46impl<S> FromRequestParts<S> for NegotiatedMediaType
47where
48    S: Send + Sync,
49{
50    type Rejection = Infallible;
51
52    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
53        let media = parts
54            .extensions
55            .get::<JsonApiMediaType>()
56            .cloned()
57            .unwrap_or_else(JsonApiMediaType::plain);
58        Ok(NegotiatedMediaType(media))
59    }
60}
61
62/// Typed JSON:API request-body extractor. Validates the `Content-Type`, buffers
63/// the body, and deserializes a [`Document<T>`], rejecting with a JSON:API error
64/// document (415 / 400 / 409 / 422) on failure.
65///
66/// # PATCH
67///
68/// This is also the PATCH extractor: define a companion resource whose patchable
69/// members are [`Field<T>`](jsonapi_core::Field) and use `JsonApi<ArticlePatch>`.
70/// Absent members deserialize to `Field::Absent` (leave unchanged), `null` to
71/// `Field::Null` (clear), and values to `Field::Set` — and such members are never
72/// required, so a partial body does not 422. See `examples/crud_server.rs`.
73#[derive(Debug, Clone)]
74pub struct JsonApi<T>(pub Document<T>);
75
76impl<T, S> FromRequest<S> for JsonApi<T>
77where
78    T: ResourceObject + DeserializeOwned + 'static,
79    S: Send + Sync,
80{
81    type Rejection = JsonApiError;
82
83    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
84        check_content_type(req.headers()).map_err(|err| JsonApiError::from_core(&err))?;
85
86        let bytes = Bytes::from_request(req, state).await.map_err(|rejection| {
87            // Mirror axum's own status for the rejection rather than hardcoding
88            // 400: a `DefaultBodyLimit` length-limit rejection reports 413
89            // (Payload Too Large), while other read failures stay 400. Reading
90            // `.status()` keeps future rejection variants correctly mapped.
91            JsonApiError::from_status(rejection.status(), Some(rejection.body_text()))
92        })?;
93
94        let document = deserialize_body::<T>(&bytes).map_err(JsonApiError::from)?;
95        Ok(JsonApi(document))
96    }
97}
98
99impl<T: ResourceObject> JsonApi<T> {
100    /// The primary resource's `id` from a single-resource body, if any.
101    fn body_id(&self) -> Option<&str> {
102        match &self.0 {
103            Document::Data {
104                data: PrimaryData::Single(primary),
105                ..
106            } => primary.resource_id(),
107            _ => None,
108        }
109    }
110
111    /// Assert the body's `data.id` matches `path_id` (the JSON:API `PATCH` rule),
112    /// rejecting with a **409 Conflict** JSON:API error (`source.pointer`
113    /// `/data/id`) on mismatch. An absent body id is accepted (identity comes
114    /// from the URL). Delegates to [`jsonapi_http::check_id_matches`].
115    ///
116    /// ```no_run
117    /// # use jsonapi_axum::{JsonApi, JsonApiError};
118    /// # async fn h(id: String, doc: JsonApi<jsonapi_core::Resource>) -> Result<(), JsonApiError> {
119    /// doc.require_id(&id)?;
120    /// # Ok(()) }
121    /// ```
122    ///
123    /// # Errors
124    /// A 409 [`JsonApiError`] when a present body id differs from `path_id`.
125    pub fn require_id(&self, path_id: &str) -> Result<(), JsonApiError> {
126        check_id_matches(self.body_id(), path_id).map_err(JsonApiError::from)
127    }
128
129    /// Apply a [`ClientIdPolicy`] to a create body's client-supplied `id`.
130    /// Under [`ClientIdPolicy::Forbid`], a present id is rejected with a
131    /// **403 Forbidden** JSON:API error. Delegates to
132    /// [`jsonapi_http::check_client_id`].
133    ///
134    /// # Errors
135    /// A 403 [`JsonApiError`] when `policy` is `Forbid` and the body carries an id.
136    pub fn check_client_id(&self, policy: ClientIdPolicy) -> Result<(), JsonApiError> {
137        check_client_id(policy, self.body_id()).map_err(JsonApiError::from)
138    }
139}
140
141/// The application's base URL for building `self`/`related` links and the
142/// `Location` header, provided from application state via
143/// [`FromRef`].
144///
145/// The base URL is an explicit,
146/// app-configured value rather than one derived from the request `Host` /
147/// `X-Forwarded-*` headers, which are fragile behind proxies. Store it in your
148/// state and implement [`FromRef`] (or make it the state itself); a handler then
149/// extracts it directly:
150///
151/// ```no_run
152/// use axum::extract::FromRef;
153/// use jsonapi_axum::BaseUrl;
154///
155/// #[derive(Clone)]
156/// struct AppState { base_url: BaseUrl }
157/// impl FromRef<AppState> for BaseUrl {
158///     fn from_ref(state: &AppState) -> BaseUrl { state.base_url.clone() }
159/// }
160///
161/// async fn handler(BaseUrl(base): BaseUrl) -> String { base }
162/// ```
163#[derive(Debug, Clone)]
164pub struct BaseUrl(pub String);
165
166impl<S> FromRequestParts<S> for BaseUrl
167where
168    S: Send + Sync,
169    Self: FromRef<S>,
170{
171    type Rejection = Infallible;
172
173    async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
174        Ok(Self::from_ref(state))
175    }
176}
177
178/// JSON:API query-parameter extractor (`sort`/`page`/`filter`/`fields`/
179/// `include`). Does **not** validate include paths — use
180/// [`JsonApiQueryValidated`] for that. Requires no application state.
181#[derive(Debug, Clone)]
182pub struct JsonApiQuery(pub Query);
183
184impl<S> FromRequestParts<S> for JsonApiQuery
185where
186    S: Send + Sync,
187{
188    type Rejection = JsonApiError;
189
190    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
191        let query = parse_query(&parts.uri).map_err(|err| JsonApiError::from_core(&err))?;
192        Ok(JsonApiQuery(query))
193    }
194}
195
196/// JSON:API query extractor that additionally validates `include` paths against
197/// an [`Arc<TypeRegistry>`] pulled from application state, rooted at `T`'s
198/// resource type. Opt-in: apps without a registry use [`JsonApiQuery`] instead.
199#[derive(Debug, Clone)]
200pub struct JsonApiQueryValidated<T> {
201    /// The parsed and include-validated query.
202    pub query: Query,
203    _marker: PhantomData<T>,
204}
205
206impl<T, S> FromRequestParts<S> for JsonApiQueryValidated<T>
207where
208    T: ResourceObject + 'static,
209    S: Send + Sync,
210    Arc<TypeRegistry>: FromRef<S>,
211{
212    type Rejection = JsonApiError;
213
214    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
215        let query = parse_query(&parts.uri).map_err(|err| JsonApiError::from_core(&err))?;
216
217        let registry = Arc::<TypeRegistry>::from_ref(state);
218        let root = T::type_info().type_name;
219        let includes: Vec<&str> = query.include.iter().map(String::as_str).collect();
220        registry
221            .validate_include_paths(root, &includes)
222            .map_err(|err| JsonApiError::from_core(&err))?;
223
224        Ok(JsonApiQueryValidated {
225            query,
226            _marker: PhantomData,
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use axum::Router;
235    use axum::body::Body;
236    use axum::routing::{get, post};
237    use http::{Request, StatusCode, header};
238    use jsonapi_core::Relationship;
239    use serde_json::Value;
240    use tower::ServiceExt;
241
242    #[derive(Debug, Clone, jsonapi_core::JsonApi)]
243    #[jsonapi(type = "people")]
244    struct Person {
245        #[jsonapi(id)]
246        id: String,
247        #[allow(dead_code)]
248        name: String,
249    }
250
251    #[derive(Debug, Clone, jsonapi_core::JsonApi)]
252    #[jsonapi(type = "articles")]
253    struct Article {
254        #[jsonapi(id)]
255        id: String,
256        #[allow(dead_code)]
257        title: String,
258        #[jsonapi(relationship, type = "people")]
259        #[allow(dead_code)]
260        author: Relationship<Person>,
261    }
262
263    const VALID_ARTICLE: &str = r#"{"data":{"type":"articles","id":"1",
264        "attributes":{"title":"Hi"},
265        "relationships":{"author":{"data":{"type":"people","id":"9"}}}}}"#;
266
267    fn registry() -> Arc<TypeRegistry> {
268        let mut registry = TypeRegistry::new();
269        registry.register::<Article>().register::<Person>();
270        Arc::new(registry)
271    }
272
273    async fn status_of(response: axum::response::Response) -> (StatusCode, Value) {
274        let status = response.status();
275        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
276            .await
277            .unwrap();
278        let value = if bytes.is_empty() {
279            Value::Null
280        } else {
281            serde_json::from_slice(&bytes).unwrap_or(Value::Null)
282        };
283        (status, value)
284    }
285
286    // --- JsonApi<T> body extractor ---
287
288    fn body_router() -> Router {
289        async fn create(JsonApi(_doc): JsonApi<Article>) -> StatusCode {
290            StatusCode::CREATED
291        }
292        Router::new().route("/articles", post(create))
293    }
294
295    fn post_request(content_type: &str, body: &str) -> Request<Body> {
296        Request::builder()
297            .method("POST")
298            .uri("/articles")
299            .header(header::CONTENT_TYPE, content_type)
300            .body(Body::from(body.to_owned()))
301            .unwrap()
302    }
303
304    #[test]
305    fn body_extractor_accepts_valid_document() {
306        pollster::block_on(async {
307            let response = body_router()
308                .oneshot(post_request("application/vnd.api+json", VALID_ARTICLE))
309                .await
310                .unwrap();
311            assert_eq!(response.status(), StatusCode::CREATED);
312        });
313    }
314
315    #[test]
316    fn body_extractor_rejects_wrong_content_type_with_415() {
317        pollster::block_on(async {
318            let response = body_router()
319                .oneshot(post_request("application/json", VALID_ARTICLE))
320                .await
321                .unwrap();
322            let (status, _) = status_of(response).await;
323            assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
324        });
325    }
326
327    #[test]
328    fn body_extractor_maps_body_limit_to_413() {
329        pollster::block_on(async {
330            // A tiny DefaultBodyLimit layer makes axum's Bytes extractor reject an
331            // oversized body with a length-limit rejection; that must surface as a
332            // JSON:API 413, not a generic 400.
333            use axum::extract::DefaultBodyLimit;
334            let router = body_router().layer(DefaultBodyLimit::max(8));
335            let big_body = format!(
336                r#"{{"data":{{"type":"articles","id":"1","attributes":{{"title":"{}"}}}}}}"#,
337                "x".repeat(256)
338            );
339            let response = router
340                .oneshot(post_request("application/vnd.api+json", &big_body))
341                .await
342                .unwrap();
343            let (status, json) = status_of(response).await;
344            assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
345            assert_eq!(json["errors"][0]["status"], "413");
346        });
347    }
348
349    #[test]
350    fn body_extractor_maps_missing_attribute_to_422_with_pointer() {
351        pollster::block_on(async {
352            let body = r#"{"data":{"type":"articles","id":"1","attributes":{},
353                "relationships":{"author":{"data":{"type":"people","id":"9"}}}}}"#;
354            let response = body_router()
355                .oneshot(post_request("application/vnd.api+json", body))
356                .await
357                .unwrap();
358            let (status, json) = status_of(response).await;
359            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
360            assert_eq!(
361                json["errors"][0]["source"]["pointer"],
362                "/data/attributes/title"
363            );
364        });
365    }
366
367    #[test]
368    fn body_extractor_maps_type_mismatch_to_409_with_pointer() {
369        pollster::block_on(async {
370            // Wire `type` is "people" but the route deserializes into `Article`.
371            let body = r#"{"data":{"type":"people","id":"1","attributes":{"title":"x"},
372                "relationships":{"author":{"data":{"type":"people","id":"9"}}}}}"#;
373            let response = body_router()
374                .oneshot(post_request("application/vnd.api+json", body))
375                .await
376                .unwrap();
377            let (status, json) = status_of(response).await;
378            assert_eq!(status, StatusCode::CONFLICT);
379            assert_eq!(json["errors"][0]["source"]["pointer"], "/data/type");
380        });
381    }
382
383    // --- JsonApiQuery (no state) ---
384
385    #[test]
386    fn query_extractor_parses_values_through_to_the_handler() {
387        pollster::block_on(async {
388            // Reflect the parsed query into the body so we assert the values
389            // actually reached the handler, not merely that extraction returned OK.
390            async fn list(JsonApiQuery(q): JsonApiQuery) -> String {
391                let field = &q.sort[0];
392                let page_size = q.page.get("size").map(String::as_str).unwrap_or("none");
393                format!(
394                    "sort={}:{} page_size={page_size}",
395                    field.field, field.descending
396                )
397            }
398            let router = Router::new().route("/articles", get(list));
399            let request = Request::builder()
400                .uri("/articles?sort=-created&page[size]=2")
401                .body(Body::empty())
402                .unwrap();
403            let (status, body) = {
404                let response = router.oneshot(request).await.unwrap();
405                let status = response.status();
406                let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
407                    .await
408                    .unwrap();
409                (status, String::from_utf8(bytes.to_vec()).unwrap())
410            };
411            assert_eq!(status, StatusCode::OK);
412            assert_eq!(body, "sort=created:true page_size=2");
413        });
414    }
415
416    #[test]
417    fn query_extractor_rejects_malformed_param_with_400() {
418        pollster::block_on(async {
419            async fn list(JsonApiQuery(_q): JsonApiQuery) -> StatusCode {
420                StatusCode::OK
421            }
422            let router = Router::new().route("/articles", get(list));
423            // `fields[` has no closing bracket -> QueryParse error.
424            let request = Request::builder()
425                .uri("/articles?fields[=title")
426                .body(Body::empty())
427                .unwrap();
428            let response = router.oneshot(request).await.unwrap();
429            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
430        });
431    }
432
433    // --- JsonApiQueryValidated<T> (registry from state) ---
434
435    fn validated_router() -> Router {
436        async fn list(_q: JsonApiQueryValidated<Article>) -> StatusCode {
437            StatusCode::OK
438        }
439        Router::new()
440            .route("/articles", get(list))
441            .with_state(registry())
442    }
443
444    #[test]
445    fn validated_query_accepts_known_include() {
446        pollster::block_on(async {
447            let request = Request::builder()
448                .uri("/articles?include=author")
449                .body(Body::empty())
450                .unwrap();
451            let response = validated_router().oneshot(request).await.unwrap();
452            assert_eq!(response.status(), StatusCode::OK);
453        });
454    }
455
456    #[test]
457    fn validated_query_rejects_unknown_include_with_400() {
458        pollster::block_on(async {
459            let request = Request::builder()
460                .uri("/articles?include=bogus")
461                .body(Body::empty())
462                .unwrap();
463            let response = validated_router().oneshot(request).await.unwrap();
464            let (status, json) = status_of(response).await;
465            assert_eq!(status, StatusCode::BAD_REQUEST);
466            assert_eq!(json["errors"][0]["source"]["parameter"], Value::Null);
467            assert!(
468                json["errors"][0]["detail"]
469                    .as_str()
470                    .unwrap()
471                    .contains("bogus")
472            );
473        });
474    }
475
476    // --- NegotiatedMediaType (G10) ---
477
478    const TEST_PROFILE: &str = "https://example.com/p";
479
480    async fn body_text(response: axum::response::Response) -> String {
481        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
482            .await
483            .unwrap();
484        String::from_utf8(bytes.to_vec()).unwrap()
485    }
486
487    #[test]
488    fn negotiated_media_type_reflects_accept_layer_profile() {
489        pollster::block_on(async {
490            // Reflect the negotiated profile into the body so we prove the layer's
491            // stored media type reached the extractor.
492            async fn handler(NegotiatedMediaType(media): NegotiatedMediaType) -> String {
493                media.profile.join(",")
494            }
495            let router = Router::new()
496                .route("/x", get(handler))
497                .layer(crate::AcceptLayer::new().profile([TEST_PROFILE]));
498            let request = Request::builder()
499                .uri("/x")
500                .header(
501                    header::ACCEPT,
502                    format!("application/vnd.api+json; profile=\"{TEST_PROFILE}\""),
503                )
504                .body(Body::empty())
505                .unwrap();
506            let response = router.oneshot(request).await.unwrap();
507            assert_eq!(response.status(), StatusCode::OK);
508            assert_eq!(body_text(response).await, TEST_PROFILE);
509        });
510    }
511
512    #[test]
513    fn negotiated_media_type_without_layer_falls_back_to_plain() {
514        pollster::block_on(async {
515            async fn handler(NegotiatedMediaType(media): NegotiatedMediaType) -> String {
516                (media == JsonApiMediaType::plain()).to_string()
517            }
518            // No AcceptLayer: nothing stored in extensions.
519            let router = Router::new().route("/x", get(handler));
520            let request = Request::builder().uri("/x").body(Body::empty()).unwrap();
521            let response = router.oneshot(request).await.unwrap();
522            assert_eq!(body_text(response).await, "true");
523        });
524    }
525
526    #[test]
527    fn negotiated_media_type_drives_response_content_type_end_to_end() {
528        pollster::block_on(async {
529            use crate::JsonApiResponse;
530            async fn handler(
531                NegotiatedMediaType(media): NegotiatedMediaType,
532            ) -> JsonApiResponse<jsonapi_core::Resource> {
533                let document: Document<jsonapi_core::Resource> =
534                    serde_json::from_str(r#"{"data":{"type":"articles","id":"1"}}"#).unwrap();
535                JsonApiResponse::new(document).media_type(media)
536            }
537            let router = Router::new()
538                .route("/x", get(handler))
539                .layer(crate::AcceptLayer::new().profile([TEST_PROFILE]));
540            let request = Request::builder()
541                .uri("/x")
542                .header(
543                    header::ACCEPT,
544                    format!("application/vnd.api+json; profile=\"{TEST_PROFILE}\""),
545                )
546                .body(Body::empty())
547                .unwrap();
548            let response = router.oneshot(request).await.unwrap();
549            let content_type = response
550                .headers()
551                .get(header::CONTENT_TYPE)
552                .and_then(|v| v.to_str().ok())
553                .unwrap()
554                .to_string();
555            assert_eq!(
556                content_type,
557                format!("application/vnd.api+json; profile=\"{TEST_PROFILE}\"")
558            );
559        });
560    }
561}