use std::future::Future;
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::response::Response;
use bytes::Bytes;
use http::header::{self, HeaderName, HeaderValue};
use http::{HeaderMap, Method, Request, StatusCode};
use serde::Serialize;
use serde_json::Value;
use tower::ServiceExt;
use jsonapi_http::JSON_API_MEDIA_TYPE;
#[must_use = "call `.build()` to produce the request"]
pub struct TestRequest {
method: Method,
uri: String,
headers: HeaderMap,
body: Body,
}
impl TestRequest {
fn new(method: Method, uri: impl Into<String>) -> Self {
Self {
method,
uri: uri.into(),
headers: HeaderMap::new(),
body: Body::empty(),
}
}
pub fn get(uri: impl Into<String>) -> Self {
Self::new(Method::GET, uri)
}
pub fn post(uri: impl Into<String>) -> Self {
Self::new(Method::POST, uri)
}
pub fn patch(uri: impl Into<String>) -> Self {
Self::new(Method::PATCH, uri)
}
pub fn delete(uri: impl Into<String>) -> Self {
Self::new(Method::DELETE, uri)
}
pub fn header(mut self, name: HeaderName, value: &str) -> Self {
self.headers.insert(
name,
HeaderValue::from_str(value).expect("valid header value"),
);
self
}
pub fn content_type_json_api(self) -> Self {
self.header(header::CONTENT_TYPE, JSON_API_MEDIA_TYPE)
}
pub fn accept_json_api(self) -> Self {
self.header(header::ACCEPT, JSON_API_MEDIA_TYPE)
}
pub fn body_document<D: Serialize>(mut self, document: &D) -> Self {
let bytes = serde_json::to_vec(document).expect("document serializes to JSON");
self.body = Body::from(bytes);
self
}
pub fn body_json(mut self, value: &Value) -> Self {
self.body = Body::from(value.to_string());
self
}
pub fn raw_body(mut self, body: impl Into<Bytes>) -> Self {
self.body = Body::from(body.into());
self
}
#[must_use]
pub fn build(self) -> Request<Body> {
let mut headers = self.headers;
let is_body_method = matches!(self.method, Method::POST | Method::PATCH | Method::PUT);
if is_body_method && !headers.contains_key(header::CONTENT_TYPE) {
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(JSON_API_MEDIA_TYPE),
);
}
let mut builder = Request::builder().method(self.method).uri(self.uri);
for (name, value) in &headers {
builder = builder.header(name, value);
}
builder.body(self.body).expect("valid request")
}
}
pub trait RouterTestExt {
fn send(self, request: Request<Body>) -> impl Future<Output = JsonApiTestResponse> + Send;
}
impl RouterTestExt for Router {
async fn send(self, request: Request<Body>) -> JsonApiTestResponse {
let response = self
.oneshot(request)
.await
.expect("router response is infallible");
JsonApiTestResponse::collect(response).await
}
}
pub trait IntoStatusCode {
fn into_status_code(self) -> StatusCode;
}
impl IntoStatusCode for StatusCode {
fn into_status_code(self) -> StatusCode {
self
}
}
impl IntoStatusCode for u16 {
fn into_status_code(self) -> StatusCode {
StatusCode::from_u16(self).expect("invalid HTTP status code in test assertion")
}
}
#[derive(Debug, Clone)]
pub struct JsonApiTestResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Option<Value>,
}
impl JsonApiTestResponse {
async fn collect(response: Response) -> Self {
let status = response.status();
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("collect response body");
let body = if bytes.is_empty() {
None
} else {
serde_json::from_slice(&bytes).ok()
};
Self {
status,
headers,
body,
}
}
pub fn assert_status(self, expected: impl IntoStatusCode) -> Self {
let expected = expected.into_status_code();
assert_eq!(
self.status, expected,
"expected status {expected}, got {} (body: {:?})",
self.status, self.body
);
self
}
pub fn assert_json_api_content_type(self) -> Self {
let content_type = self
.headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
assert!(
content_type.starts_with(JSON_API_MEDIA_TYPE),
"expected `Content-Type` {JSON_API_MEDIA_TYPE}, got {content_type:?}"
);
self
}
pub fn assert_error(self, status: impl IntoStatusCode) -> Self {
let status = status.into_status_code();
let expected = status.as_u16().to_string();
let got = self.first_error().get("status").and_then(Value::as_str);
assert_eq!(
got,
Some(expected.as_str()),
"expected errors[0].status {expected:?}, got {got:?}"
);
self
}
pub fn assert_error_pointer(self, pointer: &str) -> Self {
let got = self
.first_error()
.pointer("/source/pointer")
.and_then(Value::as_str);
assert_eq!(
got,
Some(pointer),
"expected errors[0].source.pointer {pointer:?}, got {got:?}"
);
self
}
pub fn assert_error_parameter(self, parameter: &str) -> Self {
let got = self
.first_error()
.pointer("/source/parameter")
.and_then(Value::as_str);
assert_eq!(
got,
Some(parameter),
"expected errors[0].source.parameter {parameter:?}, got {got:?}"
);
self
}
pub fn assert_error_count(self, count: usize) -> Self {
let got = self.errors().as_array().map_or(0, std::vec::Vec::len);
assert_eq!(got, count, "expected {count} error(s), got {got}");
self
}
#[must_use]
pub fn json(&self) -> &Value {
self.body
.as_ref()
.expect("response had no JSON body to inspect")
}
#[must_use]
pub fn data(&self) -> &Value {
self.json()
.get("data")
.expect("response document has no `data` member")
}
#[must_use]
pub fn errors(&self) -> &Value {
self.json()
.get("errors")
.expect("response document has no `errors` member")
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|value| value.to_str().ok())
}
fn first_error(&self) -> &Value {
self.errors()
.as_array()
.expect("`errors` is an array")
.first()
.expect("`errors` array is empty")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ApiErrorExt;
use axum::routing::{get, post};
use serde_json::json;
fn app() -> Router {
async fn ok() -> crate::JsonApiResponse<jsonapi_core::Resource> {
let resource = jsonapi_core::Resource {
r#type: "articles".into(),
id: Some("1".into()),
lid: None,
attributes: json!({"title": "Hi"}),
relationships: Default::default(),
links: None,
meta: None,
};
crate::JsonApiResponse::new(jsonapi_core::DocumentBuilder::single(resource).build())
}
async fn boom() -> crate::JsonApiError {
crate::JsonApiError::from_api_error(
crate::with_status(StatusCode::UNPROCESSABLE_ENTITY)
.pointer("/data/attributes/title")
.detail("must not be empty"),
)
}
async fn empty() -> StatusCode {
StatusCode::NO_CONTENT
}
Router::new()
.route("/ok", get(ok))
.route("/boom", post(boom))
.route("/empty", get(empty))
}
#[test]
fn build_defaults_content_type_for_body_methods_only() {
let post = TestRequest::post("/x").body_json(&json!({"a": 1})).build();
assert_eq!(
post.headers().get(header::CONTENT_TYPE).unwrap(),
JSON_API_MEDIA_TYPE
);
let get = TestRequest::get("/x").build();
assert!(get.headers().get(header::CONTENT_TYPE).is_none());
}
#[test]
fn explicit_header_overrides_the_content_type_default() {
let request = TestRequest::post("/x")
.header(header::CONTENT_TYPE, "text/plain")
.raw_body("not json")
.build();
assert_eq!(
request.headers().get(header::CONTENT_TYPE).unwrap(),
"text/plain"
);
}
#[test]
fn send_collects_a_data_document() {
pollster::block_on(async {
let response = app().send(TestRequest::get("/ok").build()).await;
response
.clone()
.assert_status(StatusCode::OK)
.assert_json_api_content_type();
assert_eq!(response.data()["attributes"]["title"], "Hi");
});
}
#[test]
fn send_collects_and_asserts_an_error_document() {
pollster::block_on(async {
app()
.send(TestRequest::post("/boom").body_json(&json!({})).build())
.await
.assert_status(StatusCode::UNPROCESSABLE_ENTITY)
.assert_json_api_content_type()
.assert_error_count(1)
.assert_error(422)
.assert_error_pointer("/data/attributes/title");
});
}
#[test]
fn status_assertions_accept_both_u16_and_statuscode() {
pollster::block_on(async {
app()
.send(TestRequest::post("/boom").body_json(&json!({})).build())
.await
.assert_status(422u16)
.assert_error(422u16);
app()
.send(TestRequest::post("/boom").body_json(&json!({})).build())
.await
.assert_status(StatusCode::UNPROCESSABLE_ENTITY)
.assert_error(StatusCode::UNPROCESSABLE_ENTITY);
});
}
#[test]
fn empty_body_parses_to_none() {
pollster::block_on(async {
let response = app().send(TestRequest::get("/empty").build()).await;
response.clone().assert_status(StatusCode::NO_CONTENT);
assert!(response.body.is_none());
});
}
#[test]
#[should_panic(expected = "expected status")]
fn assert_status_panics_with_a_clear_message_on_mismatch() {
pollster::block_on(async {
app()
.send(TestRequest::get("/ok").build())
.await
.assert_status(StatusCode::IM_A_TEAPOT);
});
}
#[test]
#[should_panic(expected = "expected errors[0].source.pointer")]
fn assert_error_pointer_panics_on_mismatch() {
pollster::block_on(async {
app()
.send(TestRequest::post("/boom").body_json(&json!({})).build())
.await
.assert_error_pointer("/data/attributes/body");
});
}
}