Skip to main content

minco_http/
request.rs

1use std::ops::{Deref, DerefMut};
2
3use axum::{
4    Json,
5    extract::{FromRequest, FromRequestParts, Path, Query, Request},
6};
7use http::{StatusCode, request::Parts};
8use minco_contract::{ContractValidate, ContractValidationErrors};
9use serde::de::DeserializeOwned;
10
11use crate::{ApiFailure, request_id_from_headers};
12
13/// One native Axum JSON extraction followed by static contract validation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct ValidatedJson<T>(pub T);
16
17/// One native Axum query extraction followed by static contract validation.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct ValidatedQuery<T>(pub T);
20
21/// One native Axum path extraction followed by static contract validation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ValidatedPath<T>(pub T);
24
25macro_rules! validated_accessors {
26    ($name:ident) => {
27        impl<T> $name<T> {
28            #[must_use]
29            pub fn into_inner(self) -> T {
30                self.0
31            }
32        }
33
34        impl<T> Deref for $name<T> {
35            type Target = T;
36
37            fn deref(&self) -> &Self::Target {
38                &self.0
39            }
40        }
41
42        impl<T> DerefMut for $name<T> {
43            fn deref_mut(&mut self) -> &mut Self::Target {
44                &mut self.0
45            }
46        }
47    };
48}
49
50validated_accessors!(ValidatedJson);
51validated_accessors!(ValidatedQuery);
52validated_accessors!(ValidatedPath);
53
54impl<T, S> FromRequest<S> for ValidatedJson<T>
55where
56    T: DeserializeOwned + ContractValidate + Send,
57    S: Send + Sync,
58{
59    type Rejection = ApiFailure;
60
61    async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
62        let request_id = request_id_from_headers(request.headers());
63        let Json(value) = Json::<T>::from_request(request, state)
64            .await
65            .map_err(|rejection| json_failure(rejection, request_id.clone()))?;
66        validate(value, request_id).map(Self)
67    }
68}
69
70impl<T, S> FromRequestParts<S> for ValidatedQuery<T>
71where
72    T: DeserializeOwned + ContractValidate + Send,
73    S: Send + Sync,
74{
75    type Rejection = ApiFailure;
76
77    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
78        let request_id = request_id_from_headers(&parts.headers);
79        let Query(value) = Query::<T>::from_request_parts(parts, state)
80            .await
81            .map_err(|_| {
82                ApiFailure::new(
83                    StatusCode::BAD_REQUEST,
84                    "invalid_query",
85                    "Invalid query",
86                    "Query parameters do not match the operation contract.",
87                    request_id.clone(),
88                )
89            })?;
90        validate(value, request_id).map(Self)
91    }
92}
93
94impl<T, S> FromRequestParts<S> for ValidatedPath<T>
95where
96    T: DeserializeOwned + ContractValidate + Send,
97    S: Send + Sync,
98{
99    type Rejection = ApiFailure;
100
101    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
102        let request_id = request_id_from_headers(&parts.headers);
103        let Path(value) = Path::<T>::from_request_parts(parts, state)
104            .await
105            .map_err(|_| {
106                ApiFailure::new(
107                    StatusCode::BAD_REQUEST,
108                    "invalid_path",
109                    "Invalid path",
110                    "Path parameters do not match the operation contract.",
111                    request_id.clone(),
112                )
113            })?;
114        validate(value, request_id).map(Self)
115    }
116}
117
118fn validate<T: ContractValidate>(value: T, request_id: String) -> Result<T, ApiFailure> {
119    let mut errors = ContractValidationErrors::new();
120    value.validate_contract(&mut errors);
121    if errors.is_empty() {
122        return Ok(value);
123    }
124    let mut failure =
125        ApiFailure::validation("Request fields violate the operation contract.", request_id);
126    failure.errors = errors.into_fields();
127    Err(failure)
128}
129
130fn json_failure(
131    rejection: axum::extract::rejection::JsonRejection,
132    request_id: String,
133) -> ApiFailure {
134    use axum::extract::rejection::JsonRejection;
135
136    match rejection {
137        JsonRejection::MissingJsonContentType(_) => ApiFailure::new(
138            StatusCode::UNSUPPORTED_MEDIA_TYPE,
139            "unsupported_media_type",
140            "Unsupported media type",
141            "A supported application/json Content-Type is required.",
142            request_id,
143        ),
144        JsonRejection::JsonSyntaxError(_) => ApiFailure::new(
145            StatusCode::BAD_REQUEST,
146            "invalid_json",
147            "Invalid JSON",
148            "Request body is not valid JSON.",
149            request_id,
150        ),
151        JsonRejection::BytesRejection(rejection)
152            if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE =>
153        {
154            ApiFailure::new(
155                StatusCode::PAYLOAD_TOO_LARGE,
156                "payload_too_large",
157                "Payload too large",
158                "Request body exceeds the configured limit.",
159                request_id,
160            )
161        }
162        _ => ApiFailure::new(
163            StatusCode::BAD_REQUEST,
164            "invalid_request",
165            "Invalid request",
166            "Request body does not match the operation contract.",
167            request_id,
168        ),
169    }
170}