spikard 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +1 -1
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -0,0 +1,167 @@
1
+ use axum::body::Body;
2
+ use axum::http::{Request, StatusCode};
3
+ use axum::response::IntoResponse;
4
+ use axum::{Router, routing::any};
5
+ use spikard_http::testing::{MultipartFilePart, TestClient};
6
+
7
+ async fn echo(req: Request<Body>) -> axum::response::Response {
8
+ let method = req.method().to_string();
9
+ let uri = req.uri().to_string();
10
+ let headers = req
11
+ .headers()
12
+ .iter()
13
+ .fold(serde_json::Map::new(), |mut map, (key, value)| {
14
+ map.insert(
15
+ key.to_string(),
16
+ serde_json::Value::String(value.to_str().unwrap_or("").to_string()),
17
+ );
18
+ map
19
+ });
20
+ let bytes = axum::body::to_bytes(req.into_body(), usize::MAX).await.unwrap();
21
+ let body_text = String::from_utf8_lossy(&bytes).to_string();
22
+
23
+ let payload = serde_json::json!({
24
+ "method": method,
25
+ "uri": uri,
26
+ "headers": headers,
27
+ "body": body_text,
28
+ });
29
+
30
+ (StatusCode::OK, axum::Json(payload)).into_response()
31
+ }
32
+
33
+ #[tokio::test]
34
+ async fn test_client_sends_query_headers_and_bodies() {
35
+ let app = Router::new().route("/{*path}", any(echo));
36
+ let client = TestClient::from_router(app).expect("client");
37
+
38
+ let snapshot = client
39
+ .get(
40
+ "/items",
41
+ Some(vec![("q".to_string(), "a b".to_string())]),
42
+ Some(vec![("x-test".to_string(), "1".to_string())]),
43
+ )
44
+ .await
45
+ .expect("get");
46
+ assert_eq!(snapshot.status, 200);
47
+ let json = snapshot.json().expect("json");
48
+ assert_eq!(json["method"], "GET");
49
+ assert!(json["uri"].as_str().unwrap().contains("/items?q=a%20b"));
50
+ assert_eq!(json["headers"]["x-test"], "1");
51
+
52
+ let snapshot = client
53
+ .post(
54
+ "/json",
55
+ Some(serde_json::json!({"hello":"world"})),
56
+ None,
57
+ None,
58
+ None,
59
+ None,
60
+ )
61
+ .await
62
+ .expect("post");
63
+ let json = snapshot.json().expect("json");
64
+ assert_eq!(json["method"], "POST");
65
+ assert!(json["body"].as_str().unwrap().contains("\"hello\":\"world\""));
66
+
67
+ let snapshot = client
68
+ .post(
69
+ "/form",
70
+ None,
71
+ Some(vec![("a".to_string(), "b".to_string())]),
72
+ None,
73
+ None,
74
+ None,
75
+ )
76
+ .await
77
+ .expect("post");
78
+ let json = snapshot.json().expect("json");
79
+ let body = json["body"].as_str().unwrap();
80
+ assert!(body.contains('a'));
81
+ assert!(body.contains('b'));
82
+
83
+ let snapshot = client
84
+ .post(
85
+ "/multipart",
86
+ None,
87
+ None,
88
+ Some((
89
+ vec![("field".to_string(), "value".to_string())],
90
+ vec![MultipartFilePart {
91
+ field_name: "file".to_string(),
92
+ filename: "hello.txt".to_string(),
93
+ content_type: Some("text/plain".to_string()),
94
+ content: b"hello".to_vec(),
95
+ }],
96
+ )),
97
+ None,
98
+ None,
99
+ )
100
+ .await
101
+ .expect("post");
102
+ let json = snapshot.json().expect("json");
103
+ assert!(
104
+ json["headers"]["content-type"]
105
+ .as_str()
106
+ .unwrap()
107
+ .contains("multipart/form-data")
108
+ );
109
+ assert!(json["body"].as_str().unwrap().contains("hello"));
110
+ }
111
+
112
+ #[tokio::test]
113
+ async fn test_client_supports_other_http_methods_and_query_merging() {
114
+ let app = Router::new().route("/{*path}", any(echo));
115
+ let client = TestClient::from_router(app).expect("client");
116
+
117
+ let snapshot = client
118
+ .put(
119
+ "/put",
120
+ Some(serde_json::json!({"name":"spikard"})),
121
+ None,
122
+ Some(vec![("x-test".to_string(), "2".to_string())]),
123
+ )
124
+ .await
125
+ .expect("put");
126
+ let json = snapshot.json().expect("json");
127
+ assert_eq!(json["method"], "PUT");
128
+ assert_eq!(json["headers"]["x-test"], "2");
129
+ assert!(json["body"].as_str().unwrap().contains("\"name\":\"spikard\""));
130
+
131
+ let snapshot = client.delete("/delete", None, None).await.expect("delete");
132
+ assert_eq!(snapshot.json().expect("json")["method"], "DELETE");
133
+
134
+ let snapshot = client.options("/options", None, None).await.expect("options");
135
+ assert_eq!(snapshot.json().expect("json")["method"], "OPTIONS");
136
+
137
+ let snapshot = client.head("/head", None, None).await.expect("head");
138
+ assert_eq!(snapshot.status, 200);
139
+ assert!(snapshot.body.is_empty());
140
+
141
+ let snapshot = client.trace("/trace", None, None).await.expect("trace");
142
+ assert_eq!(snapshot.json().expect("json")["method"], "TRACE");
143
+
144
+ let snapshot = client
145
+ .get("/items?x=1", Some(vec![("y".to_string(), "2".to_string())]), None)
146
+ .await
147
+ .expect("get");
148
+ assert!(
149
+ snapshot.json().expect("json")["uri"]
150
+ .as_str()
151
+ .unwrap()
152
+ .contains("x=1&y=2")
153
+ );
154
+ }
155
+
156
+ #[tokio::test]
157
+ async fn test_client_rejects_invalid_header_names() {
158
+ let app = Router::new().route("/{*path}", any(echo));
159
+ let client = TestClient::from_router(app).expect("client");
160
+
161
+ let error = client
162
+ .get("/items", None, Some(vec![("bad\n".to_string(), "1".to_string())]))
163
+ .await
164
+ .expect_err("invalid header");
165
+ let message = error.to_string();
166
+ assert!(message.contains("Invalid header name"));
167
+ }
@@ -0,0 +1,87 @@
1
+ use axum::http::HeaderValue;
2
+ use axum::{Router, response::IntoResponse, routing::get};
3
+ use brotli::CompressorWriter;
4
+ use flate2::Compression;
5
+ use flate2::write::GzEncoder;
6
+ use spikard_http::testing::{MultipartFilePart, build_multipart_body, encode_urlencoded_body, snapshot_response};
7
+ use std::io::Write;
8
+
9
+ #[test]
10
+ fn urlencoded_encoding_handles_scalars_and_objects() {
11
+ let s = serde_json::Value::String("a=b&c=d".to_string());
12
+ assert_eq!(encode_urlencoded_body(&s).unwrap(), b"a=b&c=d".to_vec());
13
+
14
+ let mut obj = serde_json::Map::new();
15
+ obj.insert("name".to_string(), serde_json::Value::String("Alice".to_string()));
16
+ obj.insert("tags".to_string(), serde_json::json!(["a", "b"]));
17
+ let value = serde_json::Value::Object(obj);
18
+ let encoded = String::from_utf8(encode_urlencoded_body(&value).unwrap()).unwrap();
19
+ assert!(encoded.contains("name=Alice"));
20
+ assert!(encoded.contains("tags"));
21
+ }
22
+
23
+ #[test]
24
+ fn multipart_body_contains_fields_and_files() {
25
+ let (body, boundary) = build_multipart_body(
26
+ &[("field".to_string(), "value".to_string())],
27
+ &[MultipartFilePart {
28
+ field_name: "file".to_string(),
29
+ filename: "hello.txt".to_string(),
30
+ content_type: Some("text/plain".to_string()),
31
+ content: b"hello".to_vec(),
32
+ }],
33
+ );
34
+
35
+ let body_str = String::from_utf8_lossy(&body);
36
+ assert!(body_str.contains(&format!("--{boundary}")));
37
+ assert!(body_str.contains("name=\"field\""));
38
+ assert!(body_str.contains("value"));
39
+ assert!(body_str.contains("name=\"file\"; filename=\"hello.txt\""));
40
+ assert!(body_str.contains("Content-Type: text/plain"));
41
+ assert!(body_str.contains("hello"));
42
+ }
43
+
44
+ #[tokio::test]
45
+ async fn snapshot_response_decodes_gzip_body() {
46
+ let app = Router::new().route(
47
+ "/gzip",
48
+ get(|| async move {
49
+ let raw = b"hello gzip".to_vec();
50
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
51
+ encoder.write_all(&raw).unwrap();
52
+ let compressed = encoder.finish().unwrap();
53
+
54
+ ([("content-encoding", HeaderValue::from_static("gzip"))], compressed).into_response()
55
+ }),
56
+ );
57
+
58
+ let server = axum_test::TestServer::new(app).unwrap();
59
+ let response = server.get("/gzip").await;
60
+
61
+ let snapshot = snapshot_response(response).await.expect("snapshot failed");
62
+ assert_eq!(snapshot.status, 200);
63
+ assert_eq!(snapshot.text().unwrap(), "hello gzip");
64
+ }
65
+
66
+ #[tokio::test]
67
+ async fn snapshot_response_decodes_brotli_body() {
68
+ let app = Router::new().route(
69
+ "/br",
70
+ get(|| async move {
71
+ let raw = b"hello br".to_vec();
72
+ let mut writer = CompressorWriter::new(Vec::new(), 4096, 6, 22);
73
+ writer.write_all(&raw).unwrap();
74
+ writer.flush().unwrap();
75
+ let compressed = writer.into_inner();
76
+
77
+ ([("content-encoding", HeaderValue::from_static("br"))], compressed).into_response()
78
+ }),
79
+ );
80
+
81
+ let server = axum_test::TestServer::new(app).unwrap();
82
+ let response = server.get("/br").await;
83
+
84
+ let snapshot = snapshot_response(response).await.expect("snapshot failed");
85
+ assert_eq!(snapshot.status, 200);
86
+ assert_eq!(snapshot.text().unwrap(), "hello br");
87
+ }
@@ -0,0 +1,156 @@
1
+ use axum::body::Body;
2
+ use axum::http::{HeaderValue, Request, StatusCode};
3
+ use axum::response::IntoResponse;
4
+ use axum::routing::get;
5
+ use flate2::Compression;
6
+ use flate2::write::GzEncoder;
7
+ use spikard_http::testing::{SnapshotError, WebSocketMessage, call_test_server, connect_websocket, snapshot_response};
8
+ use std::io::Write;
9
+
10
+ #[tokio::test]
11
+ async fn call_test_server_preserves_method_headers_query_and_body() {
12
+ let app = axum::Router::new().route(
13
+ "/echo",
14
+ get(|req: Request<Body>| async move {
15
+ let method = req.method().to_string();
16
+ let uri = req.uri().to_string();
17
+ let header = req
18
+ .headers()
19
+ .get("x-test")
20
+ .and_then(|v| v.to_str().ok())
21
+ .map_or_else(|| "<missing>".to_string(), str::to_string);
22
+ let bytes = axum::body::to_bytes(req.into_body(), usize::MAX).await.unwrap();
23
+ (StatusCode::OK, format!("{method} {uri} {header} {}", bytes.len())).into_response()
24
+ }),
25
+ );
26
+
27
+ let server = axum_test::TestServer::new(app).expect("server");
28
+ let request = Request::builder()
29
+ .method("GET")
30
+ .uri("/echo?q=1")
31
+ .header("x-test", "1")
32
+ .body(Body::from("abc"))
33
+ .expect("request");
34
+
35
+ let response = call_test_server(&server, request).await;
36
+ assert_eq!(response.status_code(), StatusCode::OK);
37
+ let text = response.text();
38
+ assert!(text.contains("GET"));
39
+ assert!(text.contains("/echo"));
40
+ assert!(text.contains("q=1"));
41
+ }
42
+
43
+ #[tokio::test]
44
+ async fn snapshot_response_reports_invalid_headers_and_decompression_errors() {
45
+ let bad_header = HeaderValue::from_bytes(b"\xFF").expect("header value");
46
+ let app = axum::Router::new()
47
+ .route(
48
+ "/bad-header",
49
+ get(move || async move {
50
+ (
51
+ StatusCode::OK,
52
+ [(axum::http::header::HeaderName::from_static("x-bad"), bad_header.clone())],
53
+ "ok",
54
+ )
55
+ }),
56
+ )
57
+ .route(
58
+ "/bad-gzip",
59
+ get(|| async move {
60
+ (
61
+ StatusCode::OK,
62
+ [(axum::http::header::CONTENT_ENCODING, "gzip")],
63
+ vec![0_u8, 1, 2, 3],
64
+ )
65
+ }),
66
+ );
67
+
68
+ let server = axum_test::TestServer::new(app).expect("server");
69
+
70
+ let err = snapshot_response(server.get("/bad-header").await)
71
+ .await
72
+ .expect_err("invalid header");
73
+ assert!(matches!(err, SnapshotError::InvalidHeader(_)));
74
+
75
+ let err = snapshot_response(server.get("/bad-gzip").await)
76
+ .await
77
+ .expect_err("bad gzip");
78
+ assert!(matches!(err, SnapshotError::Decompression(_)));
79
+ }
80
+
81
+ #[tokio::test]
82
+ async fn websocket_testing_wrappers_roundtrip_and_message_helpers() {
83
+ let app = axum::Router::new().route(
84
+ "/ws",
85
+ get(|ws: axum::extract::ws::WebSocketUpgrade| async move {
86
+ ws.on_upgrade(|mut socket| async move {
87
+ while let Some(msg) = socket.recv().await {
88
+ match msg {
89
+ Ok(axum::extract::ws::Message::Text(text)) => {
90
+ let _ = socket.send(axum::extract::ws::Message::Text(text)).await;
91
+ }
92
+ Ok(axum::extract::ws::Message::Binary(data)) => {
93
+ let _ = socket.send(axum::extract::ws::Message::Binary(data)).await;
94
+ }
95
+ Ok(axum::extract::ws::Message::Ping(data)) => {
96
+ let _ = socket.send(axum::extract::ws::Message::Pong(data)).await;
97
+ }
98
+ Ok(axum::extract::ws::Message::Close(_)) | Err(_) => break,
99
+ Ok(axum::extract::ws::Message::Pong(_)) => {}
100
+ }
101
+ }
102
+ })
103
+ }),
104
+ );
105
+
106
+ let server = axum_test::TestServer::new_with_config(
107
+ app,
108
+ axum_test::TestServerConfig {
109
+ transport: Some(axum_test::Transport::HttpRandomPort),
110
+ ..axum_test::TestServerConfig::default()
111
+ },
112
+ )
113
+ .expect("server");
114
+
115
+ let mut ws = connect_websocket(&server, "/ws").await;
116
+
117
+ ws.send_text("hi").await;
118
+ let msg = ws.receive_message().await;
119
+ assert_eq!(msg.as_text(), Some("hi"));
120
+ assert!(msg.as_json().is_err());
121
+
122
+ ws.send_message(axum_test::WsMessage::Binary(bytes::Bytes::from_static(b"bin")))
123
+ .await;
124
+ let msg = ws.receive_message().await;
125
+ assert_eq!(msg.as_binary().expect("binary"), b"bin");
126
+ assert!(msg.as_json().is_err());
127
+
128
+ ws.send_message(axum_test::WsMessage::Ping(bytes::Bytes::from_static(b"ping")))
129
+ .await;
130
+ let msg = ws.receive_message().await;
131
+ assert!(matches!(msg, WebSocketMessage::Pong(_)));
132
+
133
+ ws.close().await;
134
+ }
135
+
136
+ #[tokio::test]
137
+ async fn snapshot_response_decodes_gzip_body() {
138
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
139
+ encoder.write_all(b"hello gzip").expect("write");
140
+ let gzipped = encoder.finish().expect("finish");
141
+
142
+ let app = axum::Router::new().route(
143
+ "/gzip",
144
+ get(move || async move {
145
+ (
146
+ StatusCode::OK,
147
+ [(axum::http::header::CONTENT_ENCODING, "gzip")],
148
+ gzipped.clone(),
149
+ )
150
+ }),
151
+ );
152
+
153
+ let server = axum_test::TestServer::new(app).expect("server");
154
+ let snapshot = snapshot_response(server.get("/gzip").await).await.expect("snapshot");
155
+ assert_eq!(snapshot.text().expect("text"), "hello gzip");
156
+ }
@@ -0,0 +1,82 @@
1
+ #![allow(clippy::pedantic, clippy::nursery, clippy::all)]
2
+ //! Integration coverage for validate_content_type_middleware with urlencoded bodies.
3
+
4
+ use axum::Router;
5
+ use axum::extract::Extension;
6
+ use axum::http::{HeaderMap, StatusCode};
7
+ use axum::middleware;
8
+ use axum::routing::post;
9
+ use spikard_http::middleware::PreReadBody;
10
+ use spikard_http::middleware::{RouteInfo, validate_content_type_middleware};
11
+
12
+ /// Build a router with the content-type middleware and route configuration.
13
+ fn build_router(route_info: RouteInfo) -> Router {
14
+ Router::new()
15
+ .route(
16
+ "/forms",
17
+ post(
18
+ |headers: HeaderMap, Extension(pre_read): Extension<PreReadBody>| async move {
19
+ let content_type = headers
20
+ .get(axum::http::header::CONTENT_TYPE)
21
+ .and_then(|h| h.to_str().ok())
22
+ .unwrap_or_default()
23
+ .to_string();
24
+ let body_str = String::from_utf8(pre_read.0.to_vec()).unwrap();
25
+ (
26
+ StatusCode::OK,
27
+ axum::Json(serde_json::json!({ "content_type": content_type, "body": body_str })),
28
+ )
29
+ },
30
+ ),
31
+ )
32
+ .layer(middleware::from_fn_with_state(
33
+ route_info,
34
+ validate_content_type_middleware,
35
+ ))
36
+ }
37
+
38
+ #[tokio::test]
39
+ async fn urlencoded_body_is_transformed_to_json() {
40
+ let app = build_router(RouteInfo {
41
+ expects_json_body: true,
42
+ });
43
+ let server = axum_test::TestServer::new(app).expect("start test server");
44
+
45
+ let response = server
46
+ .post("/forms")
47
+ .text("name=alice&active=true&count=3&empty=")
48
+ .content_type("application/x-www-form-urlencoded")
49
+ .await;
50
+
51
+ assert_eq!(response.status_code(), StatusCode::OK);
52
+ let payload: serde_json::Value = response.json();
53
+ assert_eq!(payload["content_type"], "application/json");
54
+
55
+ let body_json: serde_json::Value =
56
+ serde_json::from_str(payload["body"].as_str().expect("body string")).expect("valid json");
57
+ assert_eq!(body_json["name"], "alice");
58
+ assert_eq!(body_json["active"], true);
59
+ assert_eq!(body_json["count"], 3);
60
+ assert_eq!(body_json["empty"], "");
61
+ }
62
+
63
+ #[tokio::test]
64
+ async fn invalid_charset_on_json_returns_unsupported_media_type() {
65
+ let app = build_router(RouteInfo {
66
+ expects_json_body: true,
67
+ });
68
+ let server = axum_test::TestServer::new(app).expect("start test server");
69
+
70
+ let response = server
71
+ .post("/forms")
72
+ .text("{\"name\":\"alice\"}")
73
+ .content_type("application/json; charset=utf-16")
74
+ .await;
75
+
76
+ assert_eq!(response.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
77
+ let body: serde_json::Value = response.json();
78
+ assert_eq!(
79
+ body["type"],
80
+ serde_json::Value::String("https://spikard.dev/errors/unsupported-charset".to_string())
81
+ );
82
+ }