update to 0.5.0

This commit is contained in:
gengteng
2023-08-04 18:50:02 +08:00
parent f3f0171e72
commit d95e04b4d4
19 changed files with 836 additions and 292 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "axum-valid" name = "axum-valid"
version = "0.4.2" version = "0.5.0"
description = "Validation tools for axum using the validator library." description = "Validation tools for axum using the validator library."
authors = ["GengTeng <me@gteng.org>"] authors = ["GengTeng <me@gteng.org>"]
license = "MIT" license = "MIT"
@@ -23,15 +23,30 @@ edition = "2021"
axum = { version = "0.6.18", default-features = false } axum = { version = "0.6.18", default-features = false }
validator = "0.16.0" validator = "0.16.0"
[dependencies.axum-msgpack]
version = "0.3.0"
default-features = false
optional = true
[dependencies.axum-yaml]
version = "0.3.0"
default-features = false
optional = true
[dependencies.axum-extra]
version = "0.7.6"
default-features = false
optional = true
[dev-dependencies] [dev-dependencies]
anyhow = "1.0.71" anyhow = "1.0.72"
axum = { version = "0.6.18" } axum = { version = "0.6.19" }
tokio = { version = "1.28.2", features = ["full"] } tokio = { version = "1.29.1", features = ["full"] }
hyper = { version = "0.14.26", features = ["full"] } hyper = { version = "0.14.27", features = ["full"] }
reqwest = { version = "0.11.18", features = ["json"] } reqwest = { version = "0.11.18", features = ["json"] }
serde = { version = "1.0.163", features = ["derive"] } serde = { version = "1.0.180", features = ["derive"] }
validator = { version = "0.16.0", features = ["derive"] } validator = { version = "0.16.0", features = ["derive"] }
serde_json = "1.0.103" serde_json = "1.0.104"
mime = "0.3.17" mime = "0.3.17"
[features] [features]
@@ -39,5 +54,14 @@ default = ["json", "form", "query"]
json = ["axum/json"] json = ["axum/json"]
form = ["axum/form"] form = ["axum/form"]
query = ["axum/query"] query = ["axum/query"]
typed_header = ["axum/headers"]
msgpack = ["axum-msgpack"]
yaml = ["axum-yaml"]
into_json = ["json"] into_json = ["json"]
422 = [] 422 = []
extra = ["axum-extra"]
extra_query = ["axum-extra/query"]
extra_form = ["axum-extra/form"]
extra_protobuf = ["axum-extra/protobuf"]
extra_all = ["extra","extra_query", "extra_form", "extra_protobuf"]
all_types = ["json", "form", "query", "typed_header", "msgpack", "yaml", "extra_all"]

View File

@@ -6,7 +6,9 @@
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gengteng/axum-valid/.github/workflows/main.yml?branch=main)](https://github.com/gengteng/axum-valid/actions/workflows/ci.yml) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gengteng/axum-valid/.github/workflows/main.yml?branch=main)](https://github.com/gengteng/axum-valid/actions/workflows/ci.yml)
[![Coverage Status](https://coveralls.io/repos/github/gengteng/axum-valid/badge.svg?branch=main)](https://coveralls.io/github/gengteng/axum-valid?branch=main) [![Coverage Status](https://coveralls.io/repos/github/gengteng/axum-valid/badge.svg?branch=main)](https://coveralls.io/github/gengteng/axum-valid?branch=main)
This crate provides a `Valid` type that can be used in combination with `Json`, `Path`, `Query`, and `Form` types to validate the entities that implement the `Validate` trait. This crate provides a `Valid` type that can be used in combination with `Json`, `Path`, `Query`, and `Form` types to validate the entities that implement the `Validate` trait from the `validator` crate.
Additional extractors like `TypedHeader`, `MsgPack`, `Yaml` etc. are supported through optional features.
## Usage ## Usage
@@ -46,9 +48,33 @@ pub async fn get_page_by_json(
When validation errors occur, the extractor will automatically return 400 with validation errors as the HTTP message body. When validation errors occur, the extractor will automatically return 400 with validation errors as the HTTP message body.
For more usage examples, please refer to the `basic.rs` and `custom.rs` files in the `tests` directory.
## Features ## Features
* `422`: Use `422 Unprocessable Entity` instead of `400 Bad Request` as the status code when validation fails. | Feature | Description | Default | Tests |
* `into_json`: When this feature is enabled, validation errors will be serialized into JSON format and returned as the HTTP body. |----------------|------------------------------------------------------------------------------------------------------|---------|-------|
| default | Enables support for `Path`, `Query`, `Json` and `Form` | ✅ | ✅ |
| json | Enables support for `Json` | ✅ | ✅ |
| query | Enables support for `Query` | ✅ | ✅ |
| form | Enables support for `Form` | ✅ | ✅ |
| typed_header | Enables support for `TypedHeader` | ❌ | ✅ |
| msgpack | Enables support for `MsgPack` and `MsgPackRaw` from `axum-msgpack` | ❌ | ❌ |
| yaml | Enables support for `Yaml` from `axum-yaml` | ❌ | ❌ |
| extra_protobuf | Enables support for `Protobuf` from `axum-extra` | ❌ | ❌ |
| extra | Enables support for `Cached`, `WithRejection` from `axum-extra` | ❌ | ✅ |
| extra_query | Enables support for `Query` from `axum-extra` | ❌ | ❌ |
| extra_form | Enables support for `Form` from `axum-extra` | ❌ | ❌ |
| extra_protobuf | Enables support for `Protobuf` from `axum-extra` | ❌ | ❌ |
| extra_all | Enables support for all extractors above from `axum-extra` | ❌ | 🚧 |
| all_types | Enables support for all extractors above | ❌ | 🚧 |
| 422 | Use `422 Unprocessable Entity` instead of `400 Bad Request` as the status code when validation fails | ❌ | ✅ |
| into_json | Validation errors will be serialized into JSON format and returned as the HTTP body | ❌ | ✅ |
## License
This project is licensed under the MIT License.
## References
* [axum](https://crates.io/crates/axum)
* [validator](https://crates.io/crates/validator)
* [serde](https://crates.io/crates/serde)

71
src/extra.rs Normal file
View File

@@ -0,0 +1,71 @@
//! # Implementation of the `HasValidate` trait for the extractor in `axum-extra`.
//!
#[cfg(feature = "extra_form")]
pub mod form;
#[cfg(feature = "extra_protobuf")]
pub mod protobuf;
#[cfg(feature = "extra_query")]
pub mod query;
use crate::HasValidate;
use axum_extra::extract::{Cached, WithRejection};
use validator::Validate;
impl<T: Validate> HasValidate for Cached<T> {
type Validate = T;
fn get_validate(&self) -> &Self::Validate {
&self.0
}
}
impl<T: Validate, R> HasValidate for WithRejection<T, R> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}
#[cfg(test)]
mod tests {
use crate::tests::{Rejection, ValidTest};
use axum::http::StatusCode;
use axum_extra::extract::{Cached, WithRejection};
use reqwest::RequestBuilder;
impl<T: ValidTest> ValidTest for Cached<T> {
const ERROR_STATUS_CODE: StatusCode = T::ERROR_STATUS_CODE;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
T::set_valid_request(builder)
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
// cached never fails
T::set_error_request(builder)
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
T::set_invalid_request(builder)
}
}
impl<T: ValidTest, R: Rejection> ValidTest for WithRejection<T, R> {
// just use conflict to test
const ERROR_STATUS_CODE: StatusCode = R::STATUS_CODE;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
T::set_valid_request(builder)
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
// cached never fails
T::set_error_request(builder)
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
T::set_invalid_request(builder)
}
}
}

13
src/extra/form.rs Normal file
View File

@@ -0,0 +1,13 @@
//! # Implementation of the `HasValidate` trait for the `Form` extractor in `axum-extra`.
//!
use crate::HasValidate;
use axum_extra::extract::Form;
use validator::Validate;
impl<T: Validate> HasValidate for Form<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}

13
src/extra/protobuf.rs Normal file
View File

@@ -0,0 +1,13 @@
//! # Implementation of the `HasValidate` trait for the `Form` extractor.
//!
use crate::HasValidate;
use axum_extra::protobuf::Protobuf;
use validator::Validate;
impl<T: Validate> HasValidate for Protobuf<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}

13
src/extra/query.rs Normal file
View File

@@ -0,0 +1,13 @@
//! # Implementation of the `HasValidate` trait for the `Query` extractor in `axum-extra`.
//!
use crate::HasValidate;
use axum_extra::extract::Query;
use validator::Validate;
impl<T: Validate> HasValidate for Query<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}

View File

@@ -1,21 +1,37 @@
//! # Implementation of the `HasValidate` trait for the `Form` extractor. //! # Implementation of the `HasValidate` trait for the `Form` extractor.
//! //!
use crate::{HasValidate, ValidRejection}; use crate::HasValidate;
use axum::extract::rejection::FormRejection;
use axum::Form; use axum::Form;
use validator::Validate; use validator::Validate;
impl<T: Validate> HasValidate for Form<T> { impl<T: Validate> HasValidate for Form<T> {
type Validate = T; type Validate = T;
type Rejection = FormRejection;
fn get_validate(&self) -> &T { fn get_validate(&self) -> &T {
&self.0 &self.0
} }
} }
impl From<FormRejection> for ValidRejection<FormRejection> { #[cfg(test)]
fn from(value: FormRejection) -> Self { mod tests {
Self::Inner(value) use crate::tests::{ValidTest, ValidTestParameter};
use axum::http::StatusCode;
use axum::Form;
use reqwest::RequestBuilder;
impl<T: ValidTestParameter> ValidTest for Form<T> {
const ERROR_STATUS_CODE: StatusCode = StatusCode::UNPROCESSABLE_ENTITY;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
builder.form(T::valid())
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
builder.form(T::error())
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
builder.form(T::invalid())
}
} }
} }

View File

@@ -1,21 +1,37 @@
//! # Implementation of the `HasValidate` trait for the `Json` extractor. //! # Implementation of the `HasValidate` trait for the `Json` extractor.
//! //!
use crate::{HasValidate, ValidRejection}; use crate::HasValidate;
use axum::extract::rejection::JsonRejection;
use axum::Json; use axum::Json;
use validator::Validate; use validator::Validate;
impl<T: Validate> HasValidate for Json<T> { impl<T: Validate> HasValidate for Json<T> {
type Validate = T; type Validate = T;
type Rejection = JsonRejection;
fn get_validate(&self) -> &T { fn get_validate(&self) -> &T {
&self.0 &self.0
} }
} }
impl From<JsonRejection> for ValidRejection<JsonRejection> { #[cfg(test)]
fn from(value: JsonRejection) -> Self { mod tests {
Self::Inner(value) use crate::tests::{ValidTest, ValidTestParameter};
use axum::http::StatusCode;
use axum::Json;
use reqwest::RequestBuilder;
impl<T: ValidTestParameter> ValidTest for Json<T> {
const ERROR_STATUS_CODE: StatusCode = StatusCode::UNPROCESSABLE_ENTITY;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
builder.json(T::valid())
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
builder.json(T::error())
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
builder.json(T::invalid())
}
} }
} }

View File

@@ -1,19 +1,30 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![deny(unsafe_code, missing_docs, clippy::unwrap_used)] #![deny(unsafe_code, missing_docs, clippy::unwrap_used)]
#[cfg(feature = "extra")]
pub mod extra;
#[cfg(feature = "form")] #[cfg(feature = "form")]
pub mod form; pub mod form;
#[cfg(feature = "json")] #[cfg(feature = "json")]
pub mod json; pub mod json;
#[cfg(feature = "msgpack")]
pub mod msgpack;
pub mod path; pub mod path;
#[cfg(feature = "query")] #[cfg(feature = "query")]
pub mod query; pub mod query;
#[cfg(test)]
pub mod test;
#[cfg(feature = "typed_header")]
pub mod typed_header;
#[cfg(feature = "yaml")]
pub mod yaml;
use axum::async_trait; use axum::async_trait;
use axum::extract::{FromRequest, FromRequestParts}; use axum::extract::{FromRequest, FromRequestParts};
use axum::http::request::Parts; use axum::http::request::Parts;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use std::ops::{Deref, DerefMut};
use validator::{Validate, ValidationErrors}; use validator::{Validate, ValidationErrors};
/// Http status code returned when there are validation errors. /// Http status code returned when there are validation errors.
@@ -25,7 +36,21 @@ pub const VALIDATION_ERROR_STATUS: StatusCode = StatusCode::BAD_REQUEST;
/// Valid entity extractor /// Valid entity extractor
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct Valid<T>(pub T); pub struct Valid<E>(pub E);
impl<E> Deref for Valid<E> {
type Target = E;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<E> DerefMut for Valid<E> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// If the valid extractor fails it'll use this "rejection" type. /// If the valid extractor fails it'll use this "rejection" type.
/// This rejection type can be converted into a response. /// This rejection type can be converted into a response.
@@ -64,44 +89,84 @@ impl<E: IntoResponse> IntoResponse for ValidRejection<E> {
pub trait HasValidate { pub trait HasValidate {
/// Inner type that can be validated for correctness /// Inner type that can be validated for correctness
type Validate: Validate; type Validate: Validate;
/// If the inner extractor fails it'll use this "rejection" type. /// Get the inner value
/// A rejection is a kind of error that can be converted into a response.
type Rejection: IntoResponse;
/// get the inner type
fn get_validate(&self) -> &Self::Validate; fn get_validate(&self) -> &Self::Validate;
} }
#[async_trait] #[async_trait]
impl<S, B, T> FromRequest<S, B> for Valid<T> impl<S, B, E> FromRequest<S, B> for Valid<E>
where where
S: Send + Sync + 'static, S: Send + Sync + 'static,
B: Send + Sync + 'static, B: Send + Sync + 'static,
T: HasValidate + FromRequest<S, B>, E: HasValidate + FromRequest<S, B>,
T::Validate: Validate, E::Validate: Validate,
ValidRejection<<T as HasValidate>::Rejection>: From<<T as FromRequest<S, B>>::Rejection>,
{ {
type Rejection = ValidRejection<<T as HasValidate>::Rejection>; type Rejection = ValidRejection<<E as FromRequest<S, B>>::Rejection>;
async fn from_request(req: Request<B>, state: &S) -> Result<Self, Self::Rejection> { async fn from_request(req: Request<B>, state: &S) -> Result<Self, Self::Rejection> {
let inner = T::from_request(req, state).await?; let inner = E::from_request(req, state)
.await
.map_err(ValidRejection::Inner)?;
inner.get_validate().validate()?; inner.get_validate().validate()?;
Ok(Valid(inner)) Ok(Valid(inner))
} }
} }
#[async_trait] #[async_trait]
impl<S, T> FromRequestParts<S> for Valid<T> impl<S, E> FromRequestParts<S> for Valid<E>
where where
S: Send + Sync + 'static, S: Send + Sync + 'static,
T: HasValidate + FromRequestParts<S>, E: HasValidate + FromRequestParts<S>,
T::Validate: Validate, E::Validate: Validate,
ValidRejection<<T as HasValidate>::Rejection>: From<<T as FromRequestParts<S>>::Rejection>,
{ {
type Rejection = ValidRejection<<T as HasValidate>::Rejection>; type Rejection = ValidRejection<<E as FromRequestParts<S>>::Rejection>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let inner = T::from_request_parts(parts, state).await?; let inner = E::from_request_parts(parts, state)
.await
.map_err(ValidRejection::Inner)?;
inner.get_validate().validate()?; inner.get_validate().validate()?;
Ok(Valid(inner)) Ok(Valid(inner))
} }
} }
#[cfg(test)]
pub mod tests {
use reqwest::{RequestBuilder, StatusCode};
use serde::Serialize;
/// # Valid test parameter
pub trait ValidTestParameter: Serialize + 'static {
/// Create a valid parameter
fn valid() -> &'static Self;
/// Create an error serializable array
fn error() -> &'static [(&'static str, &'static str)];
/// Create a invalid parameter
fn invalid() -> &'static Self;
}
/// # Valid Tests
///
/// This trait defines three test cases to check
/// if an extractor combined with the Valid type works properly.
///
/// 1. For a valid request, the server should return `200 OK`.
/// 2. For an invalid request according to the extractor, the server should return the error HTTP status code defined by the extractor itself.
/// 3. For an invalid request according to Valid, the server should return VALIDATION_ERROR_STATUS as the error code.
///
pub trait ValidTest {
/// Http status code when inner extractor failed
const ERROR_STATUS_CODE: StatusCode;
/// Build a valid request, the server should return `200 OK`.
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder;
/// Build an invalid request according to the extractor, the server should return `Self::ERROR_STATUS_CODE`
fn set_error_request(builder: RequestBuilder) -> RequestBuilder;
/// Build an invalid request according to Valid, the server should return VALIDATION_ERROR_STATUS
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder;
}
#[cfg(feature = "extra")]
pub trait Rejection {
const STATUS_CODE: StatusCode;
}
}

20
src/msgpack.rs Normal file
View File

@@ -0,0 +1,20 @@
//! # Implementation of the `HasValidate` trait for the `MsgPack` extractor.
//!
use crate::HasValidate;
use axum_msgpack::{MsgPack, MsgPackRaw};
use validator::Validate;
impl<T: Validate> HasValidate for MsgPack<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}
impl<T: Validate> HasValidate for MsgPackRaw<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}

View File

@@ -1,21 +1,13 @@
//! # Implementation of the `HasValidate` trait for the `Path` extractor. //! # Implementation of the `HasValidate` trait for the `Path` extractor.
//! //!
use crate::{HasValidate, ValidRejection}; use crate::HasValidate;
use axum::extract::rejection::PathRejection;
use axum::extract::Path; use axum::extract::Path;
use validator::Validate; use validator::Validate;
impl<T: Validate> HasValidate for Path<T> { impl<T: Validate> HasValidate for Path<T> {
type Validate = T; type Validate = T;
type Rejection = PathRejection;
fn get_validate(&self) -> &T { fn get_validate(&self) -> &T {
&self.0 &self.0
} }
} }
impl From<PathRejection> for ValidRejection<PathRejection> {
fn from(value: PathRejection) -> Self {
Self::Inner(value)
}
}

View File

@@ -1,21 +1,37 @@
//! # Implementation of the `HasValidate` trait for the `Query` extractor. //! # Implementation of the `HasValidate` trait for the `Query` extractor.
//! //!
use crate::{HasValidate, ValidRejection}; use crate::HasValidate;
use axum::extract::rejection::QueryRejection;
use axum::extract::Query; use axum::extract::Query;
use validator::Validate; use validator::Validate;
impl<T: Validate> HasValidate for Query<T> { impl<T: Validate> HasValidate for Query<T> {
type Validate = T; type Validate = T;
type Rejection = QueryRejection;
fn get_validate(&self) -> &T { fn get_validate(&self) -> &T {
&self.0 &self.0
} }
} }
impl From<QueryRejection> for ValidRejection<QueryRejection> { #[cfg(test)]
fn from(value: QueryRejection) -> Self { mod tests {
Self::Inner(value) use crate::tests::{ValidTest, ValidTestParameter};
use axum::extract::Query;
use axum::http::StatusCode;
use reqwest::RequestBuilder;
impl<T: ValidTestParameter> ValidTest for Query<T> {
const ERROR_STATUS_CODE: StatusCode = StatusCode::BAD_REQUEST;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
builder.query(&T::valid())
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
builder.query(T::error())
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
builder.query(&T::invalid())
}
} }
} }

430
src/test.rs Normal file
View File

@@ -0,0 +1,430 @@
use crate::tests::{ValidTest, ValidTestParameter};
use crate::{Valid, VALIDATION_ERROR_STATUS};
use axum::extract::{Path, Query};
use axum::routing::{get, post};
use axum::{Form, Json, Router};
use hyper::Method;
use reqwest::{StatusCode, Url};
use serde::{Deserialize, Serialize};
use std::any::type_name;
use std::borrow::Cow;
use std::net::SocketAddr;
use validator::Validate;
#[derive(Debug, Clone, Deserialize, Serialize, Validate, Eq, PartialEq)]
pub struct Parameters {
#[validate(range(min = 5, max = 10))]
v0: i32,
#[validate(length(min = 1, max = 10))]
v1: Cow<'static, str>,
}
static VALID_PARAMETERS: Parameters = Parameters {
v0: 5,
v1: Cow::Borrowed("0123456789"),
};
static INVALID_PARAMETERS: Parameters = Parameters {
v0: 6,
v1: Cow::Borrowed("01234567890"),
};
impl ValidTestParameter for Parameters {
fn valid() -> &'static Self {
&VALID_PARAMETERS
}
fn error() -> &'static [(&'static str, &'static str)] {
&[("not_v0_or_v1", "value")]
}
fn invalid() -> &'static Self {
&INVALID_PARAMETERS
}
}
#[tokio::test]
async fn test_main() -> anyhow::Result<()> {
let router = Router::new()
.route(route::PATH, get(extract_path))
.route(route::QUERY, get(extract_query))
.route(route::FORM, post(extract_form))
.route(route::JSON, post(extract_json));
#[cfg(feature = "typed_header")]
let router = router.route(
typed_header::route::TYPED_HEADER,
post(typed_header::extract_typed_header),
);
#[cfg(feature = "extra")]
let router = router
.route(route::extra::CACHED, post(extra::extract_cached))
.route(
route::extra::WITH_REJECTION,
post(extra::extract_with_rejection),
);
let server = axum::Server::bind(&SocketAddr::from(([0u8, 0, 0, 0], 0u16)))
.serve(router.into_make_service());
let server_addr = server.local_addr();
println!("Axum server address: {}.", server_addr);
let (server_guard, close) = tokio::sync::oneshot::channel::<()>();
let server_handle = tokio::spawn(server.with_graceful_shutdown(async move {
let _ = close.await;
}));
let server_url = format!("http://{}", server_addr);
let test_executor = TestExecutor::from(Url::parse(&format!("http://{}", server_addr))?);
// Valid<Path<...>>
let valid_path_response = test_executor
.client()
.get(format!(
"{}/path/{}/{}",
server_url, VALID_PARAMETERS.v0, VALID_PARAMETERS.v1
))
.send()
.await?;
assert_eq!(valid_path_response.status(), StatusCode::OK);
let invalid_path_response = test_executor
.client()
.get(format!("{}/path/not_i32/path", server_url))
.send()
.await?;
assert_eq!(invalid_path_response.status(), StatusCode::BAD_REQUEST);
let invalid_path_response = test_executor
.client()
.get(format!(
"{}/path/{}/{}",
server_url, INVALID_PARAMETERS.v0, INVALID_PARAMETERS.v1
))
.send()
.await?;
assert_eq!(invalid_path_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
check_json(invalid_path_response).await;
println!("Valid<Path<...>> works.");
test_executor
.execute::<Query<Parameters>>(Method::GET, route::QUERY)
.await?;
test_executor
.execute::<Form<Parameters>>(Method::POST, route::FORM)
.await?;
test_executor
.execute::<Json<Parameters>>(Method::POST, route::JSON)
.await?;
#[cfg(feature = "typed_header")]
{
use axum::TypedHeader;
test_executor
.execute::<TypedHeader<Parameters>>(Method::POST, typed_header::route::TYPED_HEADER)
.await?;
}
#[cfg(feature = "extra")]
{
use axum_extra::extract::{Cached, WithRejection};
use extra::TestRejection;
test_executor
.execute::<Cached<Parameters>>(Method::POST, route::extra::CACHED)
.await?;
test_executor
.execute::<WithRejection<Parameters, TestRejection>>(
Method::POST,
route::extra::WITH_REJECTION,
)
.await?;
}
drop(server_guard);
server_handle.await??;
Ok(())
}
#[derive(Debug, Clone)]
pub struct TestExecutor {
client: reqwest::Client,
server_url: Url,
}
impl From<Url> for TestExecutor {
fn from(server_url: Url) -> Self {
Self {
client: Default::default(),
server_url,
}
}
}
impl TestExecutor {
/// Execute all tests
pub async fn execute<T: ValidTest>(&self, method: Method, route: &str) -> anyhow::Result<()> {
let url = {
let mut url_builder = self.server_url.clone();
url_builder.set_path(route);
url_builder
};
let valid_builder = self.client.request(method.clone(), url.clone());
let valid_response = T::set_valid_request(valid_builder).send().await?;
assert_eq!(valid_response.status(), StatusCode::OK);
let error_builder = self.client.request(method.clone(), url.clone());
let error_response = T::set_error_request(error_builder).send().await?;
assert_eq!(error_response.status(), T::ERROR_STATUS_CODE);
let invalid_builder = self.client.request(method, url);
let invalid_response = T::set_invalid_request(invalid_builder).send().await?;
assert_eq!(invalid_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
check_json(invalid_response).await;
println!("{} works.", type_name::<T>());
Ok(())
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
}
/// Check if the response is a json response
#[cfg(feature = "into_json")]
pub async fn check_json(response: reqwest::Response) {
assert_eq!(
response.headers()[axum::http::header::CONTENT_TYPE],
axum::http::HeaderValue::from_static(mime::APPLICATION_JSON.as_ref())
);
assert!(response.json::<serde_json::Value>().await.is_ok());
}
mod route {
pub const PATH: &str = "/path/:v0/:v1";
pub const QUERY: &str = "/query";
pub const FORM: &str = "/form";
pub const JSON: &str = "/json";
#[cfg(feature = "extra")]
pub mod extra {
pub const CACHED: &str = "/cached";
pub const WITH_REJECTION: &str = "/with_rejection";
}
}
async fn extract_path(Valid(Path(parameters)): Valid<Path<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_query(Valid(Query(parameters)): Valid<Query<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_form(Valid(Form(parameters)): Valid<Form<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_json(Valid(Json(parameters)): Valid<Json<Parameters>>) -> StatusCode {
validate_again(parameters)
}
fn validate_again(parameters: Parameters) -> StatusCode {
// The `Valid` extractor has validated the `parameters` once,
// it should have returned `400 BAD REQUEST` if the `parameters` were invalid,
// Let's validate them again to check if the `Valid` extractor works well.
// If it works properly, this function will never return `500 INTERNAL SERVER ERROR`
match parameters.validate() {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[cfg(feature = "typed_header")]
mod typed_header {
pub(crate) mod route {
pub const TYPED_HEADER: &str = "/typedHeader";
}
use super::{validate_again, Parameters};
use crate::Valid;
use axum::headers::{Error, Header, HeaderName, HeaderValue};
use axum::http::StatusCode;
use axum::TypedHeader;
use std::borrow::Cow;
pub static AXUM_VALID_PARAMETERS: HeaderName = HeaderName::from_static("axum-valid-parameters");
pub(super) async fn extract_typed_header(
Valid(TypedHeader(parameters)): Valid<TypedHeader<Parameters>>,
) -> StatusCode {
validate_again(parameters)
}
impl Header for Parameters {
fn name() -> &'static HeaderName {
&AXUM_VALID_PARAMETERS
}
fn decode<'i, I>(values: &mut I) -> Result<Self, Error>
where
Self: Sized,
I: Iterator<Item = &'i HeaderValue>,
{
let value = values.next().ok_or_else(Error::invalid)?;
let src = std::str::from_utf8(value.as_bytes()).map_err(|_| Error::invalid())?;
let split = src.split(',').collect::<Vec<_>>();
match split.as_slice() {
[v0, v1] => Ok(Parameters {
v0: v0.parse().map_err(|_| Error::invalid())?,
v1: Cow::Owned(v1.to_string()),
}),
_ => Err(Error::invalid()),
}
}
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
let v0 = self.v0.to_string();
let mut vec = Vec::with_capacity(v0.len() + 1 + self.v1.len());
vec.extend_from_slice(v0.as_bytes());
vec.push(b',');
vec.extend_from_slice(self.v1.as_bytes());
let value = HeaderValue::from_bytes(&vec).expect("Failed to build header");
values.extend(::std::iter::once(value));
}
}
#[test]
fn parameter_is_header() -> anyhow::Result<()> {
let parameter = Parameters {
v0: 123456,
v1: Cow::Owned("111111".to_string()),
};
let mut vec = Vec::new();
parameter.encode(&mut vec);
let mut iter = vec.iter();
assert_eq!(parameter, Parameters::decode(&mut iter)?);
Ok(())
}
}
#[cfg(feature = "extra")]
mod extra {
use crate::test::{validate_again, Parameters};
use crate::tests::{Rejection, ValidTest, ValidTestParameter};
use crate::Valid;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum_extra::extract::{Cached, WithRejection};
use reqwest::RequestBuilder;
pub const PARAMETERS_HEADER: &str = "parameters-header";
pub const CACHED_REJECTION_STATUS: StatusCode = StatusCode::FORBIDDEN;
// 1.2. Define you own `Rejection` type and implement `IntoResponse` for it.
pub enum ParametersRejection {
Null,
InvalidJson(serde_json::error::Error),
}
impl IntoResponse for ParametersRejection {
fn into_response(self) -> Response {
match self {
ParametersRejection::Null => {
(CACHED_REJECTION_STATUS, "My-Data header is missing").into_response()
}
ParametersRejection::InvalidJson(e) => (
CACHED_REJECTION_STATUS,
format!("My-Data is not valid json string: {e}"),
)
.into_response(),
}
}
}
// 1.3. Implement your extractor (`FromRequestParts` or `FromRequest`)
#[axum::async_trait]
impl<S> FromRequestParts<S> for Parameters
where
S: Send + Sync,
{
type Rejection = ParametersRejection;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let Some(value) = parts.headers.get(PARAMETERS_HEADER) else {
return Err(ParametersRejection::Null);
};
serde_json::from_slice(value.as_bytes()).map_err(ParametersRejection::InvalidJson)
}
}
impl ValidTest for Parameters {
const ERROR_STATUS_CODE: StatusCode = CACHED_REJECTION_STATUS;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
builder.header(
PARAMETERS_HEADER,
serde_json::to_string(Parameters::valid()).expect("Failed to serialize parameters"),
)
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
builder.header(
PARAMETERS_HEADER,
serde_json::to_string(Parameters::error()).expect("Failed to serialize parameters"),
)
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
builder.header(
PARAMETERS_HEADER,
serde_json::to_string(Parameters::invalid())
.expect("Failed to serialize parameters"),
)
}
}
pub struct TestRejection {
_inner: ParametersRejection,
}
impl Rejection for TestRejection {
const STATUS_CODE: StatusCode = StatusCode::CONFLICT;
}
impl IntoResponse for TestRejection {
fn into_response(self) -> Response {
Self::STATUS_CODE.into_response()
}
}
// satisfy the `WithRejection`'s extractor trait bound
// R: From<E::Rejection> + IntoResponse
impl From<ParametersRejection> for TestRejection {
fn from(_inner: ParametersRejection) -> Self {
Self { _inner }
}
}
pub async fn extract_cached(
Valid(Cached(parameters)): Valid<Cached<Parameters>>,
) -> StatusCode {
validate_again(parameters)
}
pub async fn extract_with_rejection(
Valid(WithRejection(parameters, _)): Valid<WithRejection<Parameters, TestRejection>>,
) -> StatusCode {
validate_again(parameters)
}
}

43
src/typed_header.rs Normal file
View File

@@ -0,0 +1,43 @@
//! # Implementation of the `HasValidate` trait for the `TypedHeader` extractor.
//!
use crate::HasValidate;
use axum::TypedHeader;
use validator::Validate;
impl<T: Validate> HasValidate for TypedHeader<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}
#[cfg(test)]
mod tests {
use crate::tests::{ValidTest, ValidTestParameter};
use axum::headers::{Header, HeaderMapExt};
use axum::http::StatusCode;
use axum::TypedHeader;
use reqwest::header::HeaderMap;
use reqwest::RequestBuilder;
impl<T: ValidTestParameter + Header + Clone> ValidTest for TypedHeader<T> {
const ERROR_STATUS_CODE: StatusCode = StatusCode::BAD_REQUEST;
fn set_valid_request(builder: RequestBuilder) -> RequestBuilder {
let mut headers = HeaderMap::default();
headers.typed_insert(T::valid().clone());
builder.headers(headers)
}
fn set_error_request(builder: RequestBuilder) -> RequestBuilder {
builder
}
fn set_invalid_request(builder: RequestBuilder) -> RequestBuilder {
let mut headers = HeaderMap::default();
headers.typed_insert(T::invalid().clone());
builder.headers(headers)
}
}
}

13
src/yaml.rs Normal file
View File

@@ -0,0 +1,13 @@
//! # Implementation of the `HasValidate` trait for the `Yaml` extractor.
//!
use crate::HasValidate;
use axum_yaml::Yaml;
use validator::Validate;
impl<T: Validate> HasValidate for Yaml<T> {
type Validate = T;
fn get_validate(&self) -> &T {
&self.0
}
}

View File

@@ -1,10 +1,13 @@
[feature_default] [feature_default]
[feature_all_types]
features = "all_types"
[feature_into_json] [feature_into_json]
features = "into_json" features = "all_types into_json"
[feature_422] [feature_422]
features = "422" features = "all_types 422"
[feature_422_into_json] [feature_422_into_json]
features = "422 into_json" features = "all_types 422 into_json"

View File

@@ -1,210 +0,0 @@
//! # Basic extractors validation
//!
//! * `Path`
//! * `Query`
//! * `Form`
//! * `Json`
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Form, Json, Router};
use axum_valid::{Valid, VALIDATION_ERROR_STATUS};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::net::SocketAddr;
use validator::Validate;
mod utils;
mod route {
pub const PATH: &str = "/path/:v0/:v1";
pub const QUERY: &str = "/query";
pub const FORM: &str = "/form";
pub const JSON: &str = "/json";
}
#[tokio::test]
async fn main() -> anyhow::Result<()> {
let router = Router::new()
.route(route::PATH, get(extract_path))
.route(route::QUERY, get(extract_query))
.route(route::FORM, post(extract_form))
.route(route::JSON, post(extract_json));
let server = axum::Server::bind(&SocketAddr::from(([0u8, 0, 0, 0], 0u16)))
.serve(router.into_make_service());
let server_addr = server.local_addr();
println!("Axum server address: {}.", server_addr);
let (server_guard, close) = tokio::sync::oneshot::channel::<()>();
let server_handle = tokio::spawn(server.with_graceful_shutdown(async move {
let _ = close.await;
}));
let server_url = format!("http://{}", server_addr);
let client = reqwest::Client::default();
let valid_parameters = Parameters {
v0: 5,
v1: "0123456789".to_string(),
};
let invalid_parameters = Parameters {
v0: 6,
v1: "01234567890".to_string(),
};
// Valid<Path<...>>
let valid_path_response = client
.get(format!(
"{}/path/{}/{}",
server_url, valid_parameters.v0, valid_parameters.v1
))
.send()
.await?;
assert_eq!(valid_path_response.status(), StatusCode::OK);
let invalid_path_response = client
.get(format!("{}/path/invalid/path", server_url))
.send()
.await?;
assert_eq!(invalid_path_response.status(), StatusCode::BAD_REQUEST);
let invalid_path_response = client
.get(format!(
"{}/path/{}/{}",
server_url, invalid_parameters.v0, invalid_parameters.v1
))
.send()
.await?;
assert_eq!(invalid_path_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
utils::check_json(invalid_path_response).await;
println!("Valid<Path<...>> works.");
// Valid<Query<...>>
let query_url = format!("{}{}", server_url, route::QUERY);
let valid_query_response = client
.get(&query_url)
.query(&valid_parameters)
.send()
.await?;
assert_eq!(valid_query_response.status(), StatusCode::OK);
let invalid_query_response = client
.get(&query_url)
.query(&[("invalid", "query")])
.send()
.await?;
assert_eq!(invalid_query_response.status(), StatusCode::BAD_REQUEST);
let invalid_query_response = client
.get(&query_url)
.query(&invalid_parameters)
.send()
.await?;
assert_eq!(invalid_query_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
utils::check_json(invalid_query_response).await;
println!("Valid<Query<...>> works.");
// Valid<Form<...>>
let form_url = format!("{}{}", server_url, route::FORM);
let valid_form_response = client
.post(&form_url)
.form(&valid_parameters)
.send()
.await?;
assert_eq!(valid_form_response.status(), StatusCode::OK);
let invalid_form_response = client
.post(&form_url)
.form(&[("invalid", "form")])
.send()
.await?;
assert_eq!(
invalid_form_response.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let invalid_form_response = client
.post(&form_url)
.form(&invalid_parameters)
.send()
.await?;
assert_eq!(invalid_form_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
utils::check_json(invalid_form_response).await;
println!("Valid<Form<...>> works.");
// Valid<Json<...>>
let json_url = format!("{}{}", server_url, route::JSON);
let valid_json_response = client
.post(&json_url)
.json(&valid_parameters)
.send()
.await?;
assert_eq!(valid_json_response.status(), StatusCode::OK);
let invalid_json_response = client
.post(&json_url)
.json(&json!({"invalid": "json"}))
.send()
.await?;
assert_eq!(
invalid_json_response.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let invalid_json_response = client
.post(&json_url)
.json(&invalid_parameters)
.send()
.await?;
assert_eq!(invalid_json_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")]
utils::check_json(invalid_json_response).await;
println!("Valid<Json<...>> works.");
drop(server_guard);
server_handle.await??;
Ok(())
}
// Implement `Deserialize` and `Validate` for `Parameters`,
// then `Valid` will work as you expect.
#[derive(Debug, Deserialize, Serialize, Validate)]
struct Parameters {
#[validate(range(min = 5, max = 10))]
v0: i32,
#[validate(length(min = 1, max = 10))]
v1: String,
}
async fn extract_path(Valid(Path(parameters)): Valid<Path<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_query(Valid(Query(parameters)): Valid<Query<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_form(Valid(Form(parameters)): Valid<Form<Parameters>>) -> StatusCode {
validate_again(parameters)
}
async fn extract_json(Valid(Json(parameters)): Valid<Json<Parameters>>) -> StatusCode {
validate_again(parameters)
}
fn validate_again(parameters: Parameters) -> StatusCode {
// The `Valid` extractor has validated the `parameters` once,
// it should have returned `400 BAD REQUEST` if the `parameters` were invalid,
// Let's validate them again to check if the `Valid` extractor works well.
// If it works properly, this function will never return `500 INTERNAL SERVER ERROR`
match parameters.validate() {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}

View File

@@ -6,14 +6,12 @@ use axum::http::request::Parts;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use axum_valid::{HasValidate, Valid, ValidRejection, VALIDATION_ERROR_STATUS}; use axum_valid::{HasValidate, Valid, VALIDATION_ERROR_STATUS};
use hyper::StatusCode; use hyper::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::SocketAddr; use std::net::SocketAddr;
use validator::Validate; use validator::Validate;
mod utils;
const MY_DATA_HEADER: &str = "My-Data"; const MY_DATA_HEADER: &str = "My-Data";
// 1. Implement your own extractor. // 1. Implement your own extractor.
@@ -66,20 +64,11 @@ where
// 2.1. Implement `HasValidate` for your extractor // 2.1. Implement `HasValidate` for your extractor
impl HasValidate for MyData { impl HasValidate for MyData {
type Validate = Self; type Validate = Self;
type Rejection = MyDataRejection;
fn get_validate(&self) -> &Self::Validate { fn get_validate(&self) -> &Self::Validate {
self self
} }
} }
// 2.2. Implement `From<MyDataRejection>` for `ValidRejection<MyDataRejection>`.
impl From<MyDataRejection> for ValidRejection<MyDataRejection> {
fn from(value: MyDataRejection) -> Self {
Self::Inner(value)
}
}
#[tokio::test] #[tokio::test]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let router = Router::new().route("/", get(handler)); let router = Router::new().route("/", get(handler));
@@ -125,8 +114,8 @@ async fn main() -> anyhow::Result<()> {
.send() .send()
.await?; .await?;
assert_eq!(invalid_my_data_response.status(), VALIDATION_ERROR_STATUS); assert_eq!(invalid_my_data_response.status(), VALIDATION_ERROR_STATUS);
#[cfg(feature = "into_json")] // #[cfg(feature = "into_json")]
utils::check_json(invalid_my_data_response).await; // test::check_json(invalid_my_data_response).await;
println!("Valid<MyData> works."); println!("Valid<MyData> works.");
drop(server_guard); drop(server_guard);

View File

@@ -1,9 +0,0 @@
/// Check if the response is a json response
#[cfg(feature = "into_json")]
pub async fn check_json(response: reqwest::Response) {
assert_eq!(
response.headers()[axum::http::header::CONTENT_TYPE],
axum::http::HeaderValue::from_static(mime::APPLICATION_JSON.as_ref())
);
assert!(response.json::<serde_json::Value>().await.is_ok());
}