spikard 0.3.5 → 0.3.6
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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +659 -659
- data/ext/spikard_rb/Cargo.toml +17 -17
- data/ext/spikard_rb/extconf.rb +10 -10
- data/ext/spikard_rb/src/lib.rs +6 -6
- data/lib/spikard/app.rb +386 -386
- data/lib/spikard/background.rb +27 -27
- data/lib/spikard/config.rb +396 -396
- data/lib/spikard/converters.rb +13 -13
- data/lib/spikard/handler_wrapper.rb +113 -113
- data/lib/spikard/provide.rb +214 -214
- data/lib/spikard/response.rb +173 -173
- data/lib/spikard/schema.rb +243 -243
- data/lib/spikard/sse.rb +111 -111
- data/lib/spikard/streaming_response.rb +44 -44
- data/lib/spikard/testing.rb +221 -221
- data/lib/spikard/upload_file.rb +131 -131
- data/lib/spikard/version.rb +5 -5
- data/lib/spikard/websocket.rb +59 -59
- data/lib/spikard.rb +43 -43
- data/sig/spikard.rbs +366 -360
- data/vendor/crates/spikard-core/Cargo.toml +40 -40
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
- data/vendor/crates/spikard-core/src/debug.rs +63 -63
- data/vendor/crates/spikard-core/src/di/container.rs +726 -726
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
- data/vendor/crates/spikard-core/src/di/error.rs +118 -118
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -538
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -545
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -411
- data/vendor/crates/spikard-core/src/di/value.rs +283 -283
- data/vendor/crates/spikard-core/src/errors.rs +39 -39
- data/vendor/crates/spikard-core/src/http.rs +153 -153
- data/vendor/crates/spikard-core/src/lib.rs +29 -29
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -422
- data/vendor/crates/spikard-core/src/parameters.rs +722 -722
- data/vendor/crates/spikard-core/src/problem.rs +310 -310
- data/vendor/crates/spikard-core/src/request_data.rs +189 -189
- data/vendor/crates/spikard-core/src/router.rs +249 -249
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
- data/vendor/crates/spikard-core/src/validation.rs +699 -699
- data/vendor/crates/spikard-http/Cargo.toml +68 -68
- data/vendor/crates/spikard-http/src/auth.rs +247 -247
- data/vendor/crates/spikard-http/src/background.rs +249 -249
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
- data/vendor/crates/spikard-http/src/cors.rs +490 -490
- data/vendor/crates/spikard-http/src/debug.rs +63 -63
- data/vendor/crates/spikard-http/src/di_handler.rs +423 -423
- data/vendor/crates/spikard-http/src/handler_response.rs +190 -190
- data/vendor/crates/spikard-http/src/handler_trait.rs +228 -228
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -284
- data/vendor/crates/spikard-http/src/lib.rs +529 -529
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -149
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -428
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -285
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -86
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -147
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -287
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -190
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -308
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -195
- data/vendor/crates/spikard-http/src/parameters.rs +1 -1
- data/vendor/crates/spikard-http/src/problem.rs +1 -1
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -369
- data/vendor/crates/spikard-http/src/response.rs +399 -399
- data/vendor/crates/spikard-http/src/router.rs +1 -1
- data/vendor/crates/spikard-http/src/schema_registry.rs +1 -1
- data/vendor/crates/spikard-http/src/server/handler.rs +87 -87
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -98
- data/vendor/crates/spikard-http/src/server/mod.rs +805 -805
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -119
- data/vendor/crates/spikard-http/src/sse.rs +447 -447
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -14
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -60
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -285
- data/vendor/crates/spikard-http/src/testing.rs +377 -377
- data/vendor/crates/spikard-http/src/type_hints.rs +1 -1
- data/vendor/crates/spikard-http/src/validation.rs +1 -1
- data/vendor/crates/spikard-http/src/websocket.rs +324 -324
- data/vendor/crates/spikard-rb/Cargo.toml +42 -42
- data/vendor/crates/spikard-rb/build.rs +8 -8
- data/vendor/crates/spikard-rb/src/background.rs +63 -63
- data/vendor/crates/spikard-rb/src/config.rs +294 -294
- data/vendor/crates/spikard-rb/src/conversion.rs +453 -453
- data/vendor/crates/spikard-rb/src/di.rs +409 -409
- data/vendor/crates/spikard-rb/src/handler.rs +625 -625
- data/vendor/crates/spikard-rb/src/lib.rs +2771 -2771
- data/vendor/crates/spikard-rb/src/lifecycle.rs +274 -274
- data/vendor/crates/spikard-rb/src/server.rs +283 -283
- data/vendor/crates/spikard-rb/src/sse.rs +231 -231
- data/vendor/crates/spikard-rb/src/test_client.rs +404 -404
- data/vendor/crates/spikard-rb/src/test_sse.rs +143 -143
- data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -221
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -233
- metadata +1 -1
|
@@ -1,287 +1,287 @@
|
|
|
1
|
-
//! JSON schema validation middleware
|
|
2
|
-
|
|
3
|
-
use crate::problem::{CONTENT_TYPE_PROBLEM_JSON, ProblemDetails};
|
|
4
|
-
use axum::http::{HeaderMap, StatusCode};
|
|
5
|
-
use axum::response::{IntoResponse, Response};
|
|
6
|
-
use serde_json::json;
|
|
7
|
-
|
|
8
|
-
/// Check if a media type is JSON or has a +json suffix
|
|
9
|
-
pub fn is_json_content_type(mime: &mime::Mime) -> bool {
|
|
10
|
-
(mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON) || mime.suffix() == Some(mime::JSON)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/// Validate that Content-Type is JSON-compatible when route expects JSON
|
|
14
|
-
#[allow(clippy::result_large_err)]
|
|
15
|
-
pub fn validate_json_content_type(headers: &HeaderMap) -> Result<(), Response> {
|
|
16
|
-
if let Some(content_type_header) = headers.get(axum::http::header::CONTENT_TYPE)
|
|
17
|
-
&& let Ok(content_type_str) = content_type_header.to_str()
|
|
18
|
-
&& let Ok(parsed_mime) = content_type_str.parse::<mime::Mime>()
|
|
19
|
-
{
|
|
20
|
-
let is_json = (parsed_mime.type_() == mime::APPLICATION && parsed_mime.subtype() == mime::JSON)
|
|
21
|
-
|| parsed_mime.suffix() == Some(mime::JSON);
|
|
22
|
-
|
|
23
|
-
let is_form = (parsed_mime.type_() == mime::APPLICATION && parsed_mime.subtype() == "x-www-form-urlencoded")
|
|
24
|
-
|| (parsed_mime.type_() == mime::MULTIPART && parsed_mime.subtype() == "form-data");
|
|
25
|
-
|
|
26
|
-
if !is_json && !is_form {
|
|
27
|
-
let problem = ProblemDetails::new(
|
|
28
|
-
"https://spikard.dev/errors/unsupported-media-type",
|
|
29
|
-
"Unsupported Media Type",
|
|
30
|
-
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
31
|
-
)
|
|
32
|
-
.with_detail("Unsupported media type");
|
|
33
|
-
|
|
34
|
-
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
35
|
-
return Err((
|
|
36
|
-
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
37
|
-
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
38
|
-
body,
|
|
39
|
-
)
|
|
40
|
-
.into_response());
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
Ok(())
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/// Validate Content-Length header matches actual body size
|
|
47
|
-
#[allow(clippy::result_large_err, clippy::collapsible_if)]
|
|
48
|
-
pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
|
|
49
|
-
if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
|
|
50
|
-
if let Ok(content_length_str) = content_length_header.to_str() {
|
|
51
|
-
if let Ok(declared_length) = content_length_str.parse::<usize>() {
|
|
52
|
-
if declared_length != actual_size {
|
|
53
|
-
let problem = ProblemDetails::bad_request(format!(
|
|
54
|
-
"Content-Length header ({}) does not match actual body size ({})",
|
|
55
|
-
declared_length, actual_size
|
|
56
|
-
));
|
|
57
|
-
|
|
58
|
-
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
59
|
-
return Err((
|
|
60
|
-
StatusCode::BAD_REQUEST,
|
|
61
|
-
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
62
|
-
body,
|
|
63
|
-
)
|
|
64
|
-
.into_response());
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
Ok(())
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/// Validate Content-Type header and related requirements
|
|
73
|
-
#[allow(clippy::result_large_err)]
|
|
74
|
-
pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
|
|
75
|
-
if let Some(content_type_str) = headers
|
|
76
|
-
.get(axum::http::header::CONTENT_TYPE)
|
|
77
|
-
.and_then(|h| h.to_str().ok())
|
|
78
|
-
{
|
|
79
|
-
let parsed_mime = match content_type_str.parse::<mime::Mime>() {
|
|
80
|
-
Ok(m) => m,
|
|
81
|
-
Err(_) => {
|
|
82
|
-
let error_body = json!({
|
|
83
|
-
"error": format!("Invalid Content-Type header: {}", content_type_str)
|
|
84
|
-
});
|
|
85
|
-
return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
let is_json = is_json_content_type(&parsed_mime);
|
|
90
|
-
let is_multipart = parsed_mime.type_() == mime::MULTIPART && parsed_mime.subtype() == "form-data";
|
|
91
|
-
|
|
92
|
-
if is_multipart && parsed_mime.get_param(mime::BOUNDARY).is_none() {
|
|
93
|
-
let error_body = json!({
|
|
94
|
-
"error": "multipart/form-data requires 'boundary' parameter"
|
|
95
|
-
});
|
|
96
|
-
return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
#[allow(clippy::collapsible_if)]
|
|
100
|
-
if is_json {
|
|
101
|
-
if let Some(charset) = parsed_mime.get_param(mime::CHARSET).map(|c| c.as_str()) {
|
|
102
|
-
if !charset.eq_ignore_ascii_case("utf-8") && !charset.eq_ignore_ascii_case("utf8") {
|
|
103
|
-
let problem = ProblemDetails::new(
|
|
104
|
-
"https://spikard.dev/errors/unsupported-charset",
|
|
105
|
-
"Unsupported Charset",
|
|
106
|
-
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
107
|
-
)
|
|
108
|
-
.with_detail(format!(
|
|
109
|
-
"Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
|
|
110
|
-
charset
|
|
111
|
-
));
|
|
112
|
-
|
|
113
|
-
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
114
|
-
return Err((
|
|
115
|
-
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
116
|
-
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
117
|
-
body,
|
|
118
|
-
)
|
|
119
|
-
.into_response());
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
Ok(())
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
#[cfg(test)]
|
|
129
|
-
mod tests {
|
|
130
|
-
use super::*;
|
|
131
|
-
use axum::http::HeaderValue;
|
|
132
|
-
|
|
133
|
-
#[test]
|
|
134
|
-
fn validate_content_length_accepts_matching_sizes() {
|
|
135
|
-
let mut headers = HeaderMap::new();
|
|
136
|
-
headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
|
|
137
|
-
|
|
138
|
-
assert!(validate_content_length(&headers, 5).is_ok());
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
#[test]
|
|
142
|
-
fn validate_content_length_rejects_mismatched_sizes() {
|
|
143
|
-
let mut headers = HeaderMap::new();
|
|
144
|
-
headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
|
|
145
|
-
|
|
146
|
-
let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
|
|
147
|
-
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
|
|
148
|
-
assert_eq!(
|
|
149
|
-
err.headers()
|
|
150
|
-
.get(axum::http::header::CONTENT_TYPE)
|
|
151
|
-
.and_then(|value| value.to_str().ok()),
|
|
152
|
-
Some(CONTENT_TYPE_PROBLEM_JSON)
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
#[test]
|
|
157
|
-
fn test_multipart_without_boundary() {
|
|
158
|
-
let mut headers = HeaderMap::new();
|
|
159
|
-
headers.insert(
|
|
160
|
-
axum::http::header::CONTENT_TYPE,
|
|
161
|
-
HeaderValue::from_static("multipart/form-data"),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
165
|
-
assert!(result.is_err());
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
#[test]
|
|
169
|
-
fn test_multipart_with_boundary() {
|
|
170
|
-
let mut headers = HeaderMap::new();
|
|
171
|
-
headers.insert(
|
|
172
|
-
axum::http::header::CONTENT_TYPE,
|
|
173
|
-
HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
177
|
-
assert!(result.is_ok());
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
#[test]
|
|
181
|
-
fn test_json_with_utf16_charset() {
|
|
182
|
-
let mut headers = HeaderMap::new();
|
|
183
|
-
headers.insert(
|
|
184
|
-
axum::http::header::CONTENT_TYPE,
|
|
185
|
-
HeaderValue::from_static("application/json; charset=utf-16"),
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
189
|
-
assert!(result.is_err());
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#[test]
|
|
193
|
-
fn test_json_with_utf8_charset() {
|
|
194
|
-
let mut headers = HeaderMap::new();
|
|
195
|
-
headers.insert(
|
|
196
|
-
axum::http::header::CONTENT_TYPE,
|
|
197
|
-
HeaderValue::from_static("application/json; charset=utf-8"),
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
201
|
-
assert!(result.is_ok());
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
#[test]
|
|
205
|
-
fn test_json_without_charset() {
|
|
206
|
-
let mut headers = HeaderMap::new();
|
|
207
|
-
headers.insert(
|
|
208
|
-
axum::http::header::CONTENT_TYPE,
|
|
209
|
-
HeaderValue::from_static("application/json"),
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
213
|
-
assert!(result.is_ok());
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
#[test]
|
|
217
|
-
fn test_vendor_json_accepted() {
|
|
218
|
-
let mut headers = HeaderMap::new();
|
|
219
|
-
headers.insert(
|
|
220
|
-
axum::http::header::CONTENT_TYPE,
|
|
221
|
-
HeaderValue::from_static("application/vnd.api+json"),
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
225
|
-
assert!(result.is_ok());
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
#[test]
|
|
229
|
-
fn test_problem_json_accepted() {
|
|
230
|
-
let mut headers = HeaderMap::new();
|
|
231
|
-
headers.insert(
|
|
232
|
-
axum::http::header::CONTENT_TYPE,
|
|
233
|
-
HeaderValue::from_static("application/problem+json"),
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
237
|
-
assert!(result.is_ok());
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
#[test]
|
|
241
|
-
fn test_vendor_json_with_utf16_charset_rejected() {
|
|
242
|
-
let mut headers = HeaderMap::new();
|
|
243
|
-
headers.insert(
|
|
244
|
-
axum::http::header::CONTENT_TYPE,
|
|
245
|
-
HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
249
|
-
assert!(result.is_err());
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
#[test]
|
|
253
|
-
fn test_vendor_json_with_utf8_charset_accepted() {
|
|
254
|
-
let mut headers = HeaderMap::new();
|
|
255
|
-
headers.insert(
|
|
256
|
-
axum::http::header::CONTENT_TYPE,
|
|
257
|
-
HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
let result = validate_content_type_headers(&headers, 0);
|
|
261
|
-
assert!(result.is_ok());
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
#[test]
|
|
265
|
-
fn test_is_json_content_type() {
|
|
266
|
-
let mime = "application/json".parse::<mime::Mime>().unwrap();
|
|
267
|
-
assert!(is_json_content_type(&mime));
|
|
268
|
-
|
|
269
|
-
let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
|
|
270
|
-
assert!(is_json_content_type(&mime));
|
|
271
|
-
|
|
272
|
-
let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
|
|
273
|
-
assert!(is_json_content_type(&mime));
|
|
274
|
-
|
|
275
|
-
let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
|
|
276
|
-
assert!(is_json_content_type(&mime));
|
|
277
|
-
|
|
278
|
-
let mime = "text/plain".parse::<mime::Mime>().unwrap();
|
|
279
|
-
assert!(!is_json_content_type(&mime));
|
|
280
|
-
|
|
281
|
-
let mime = "application/xml".parse::<mime::Mime>().unwrap();
|
|
282
|
-
assert!(!is_json_content_type(&mime));
|
|
283
|
-
|
|
284
|
-
let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
|
|
285
|
-
assert!(!is_json_content_type(&mime));
|
|
286
|
-
}
|
|
287
|
-
}
|
|
1
|
+
//! JSON schema validation middleware
|
|
2
|
+
|
|
3
|
+
use crate::problem::{CONTENT_TYPE_PROBLEM_JSON, ProblemDetails};
|
|
4
|
+
use axum::http::{HeaderMap, StatusCode};
|
|
5
|
+
use axum::response::{IntoResponse, Response};
|
|
6
|
+
use serde_json::json;
|
|
7
|
+
|
|
8
|
+
/// Check if a media type is JSON or has a +json suffix
|
|
9
|
+
pub fn is_json_content_type(mime: &mime::Mime) -> bool {
|
|
10
|
+
(mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON) || mime.suffix() == Some(mime::JSON)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Validate that Content-Type is JSON-compatible when route expects JSON
|
|
14
|
+
#[allow(clippy::result_large_err)]
|
|
15
|
+
pub fn validate_json_content_type(headers: &HeaderMap) -> Result<(), Response> {
|
|
16
|
+
if let Some(content_type_header) = headers.get(axum::http::header::CONTENT_TYPE)
|
|
17
|
+
&& let Ok(content_type_str) = content_type_header.to_str()
|
|
18
|
+
&& let Ok(parsed_mime) = content_type_str.parse::<mime::Mime>()
|
|
19
|
+
{
|
|
20
|
+
let is_json = (parsed_mime.type_() == mime::APPLICATION && parsed_mime.subtype() == mime::JSON)
|
|
21
|
+
|| parsed_mime.suffix() == Some(mime::JSON);
|
|
22
|
+
|
|
23
|
+
let is_form = (parsed_mime.type_() == mime::APPLICATION && parsed_mime.subtype() == "x-www-form-urlencoded")
|
|
24
|
+
|| (parsed_mime.type_() == mime::MULTIPART && parsed_mime.subtype() == "form-data");
|
|
25
|
+
|
|
26
|
+
if !is_json && !is_form {
|
|
27
|
+
let problem = ProblemDetails::new(
|
|
28
|
+
"https://spikard.dev/errors/unsupported-media-type",
|
|
29
|
+
"Unsupported Media Type",
|
|
30
|
+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
31
|
+
)
|
|
32
|
+
.with_detail("Unsupported media type");
|
|
33
|
+
|
|
34
|
+
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
35
|
+
return Err((
|
|
36
|
+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
37
|
+
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
38
|
+
body,
|
|
39
|
+
)
|
|
40
|
+
.into_response());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
Ok(())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Validate Content-Length header matches actual body size
|
|
47
|
+
#[allow(clippy::result_large_err, clippy::collapsible_if)]
|
|
48
|
+
pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
|
|
49
|
+
if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
|
|
50
|
+
if let Ok(content_length_str) = content_length_header.to_str() {
|
|
51
|
+
if let Ok(declared_length) = content_length_str.parse::<usize>() {
|
|
52
|
+
if declared_length != actual_size {
|
|
53
|
+
let problem = ProblemDetails::bad_request(format!(
|
|
54
|
+
"Content-Length header ({}) does not match actual body size ({})",
|
|
55
|
+
declared_length, actual_size
|
|
56
|
+
));
|
|
57
|
+
|
|
58
|
+
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
59
|
+
return Err((
|
|
60
|
+
StatusCode::BAD_REQUEST,
|
|
61
|
+
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
62
|
+
body,
|
|
63
|
+
)
|
|
64
|
+
.into_response());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
Ok(())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Validate Content-Type header and related requirements
|
|
73
|
+
#[allow(clippy::result_large_err)]
|
|
74
|
+
pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
|
|
75
|
+
if let Some(content_type_str) = headers
|
|
76
|
+
.get(axum::http::header::CONTENT_TYPE)
|
|
77
|
+
.and_then(|h| h.to_str().ok())
|
|
78
|
+
{
|
|
79
|
+
let parsed_mime = match content_type_str.parse::<mime::Mime>() {
|
|
80
|
+
Ok(m) => m,
|
|
81
|
+
Err(_) => {
|
|
82
|
+
let error_body = json!({
|
|
83
|
+
"error": format!("Invalid Content-Type header: {}", content_type_str)
|
|
84
|
+
});
|
|
85
|
+
return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let is_json = is_json_content_type(&parsed_mime);
|
|
90
|
+
let is_multipart = parsed_mime.type_() == mime::MULTIPART && parsed_mime.subtype() == "form-data";
|
|
91
|
+
|
|
92
|
+
if is_multipart && parsed_mime.get_param(mime::BOUNDARY).is_none() {
|
|
93
|
+
let error_body = json!({
|
|
94
|
+
"error": "multipart/form-data requires 'boundary' parameter"
|
|
95
|
+
});
|
|
96
|
+
return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[allow(clippy::collapsible_if)]
|
|
100
|
+
if is_json {
|
|
101
|
+
if let Some(charset) = parsed_mime.get_param(mime::CHARSET).map(|c| c.as_str()) {
|
|
102
|
+
if !charset.eq_ignore_ascii_case("utf-8") && !charset.eq_ignore_ascii_case("utf8") {
|
|
103
|
+
let problem = ProblemDetails::new(
|
|
104
|
+
"https://spikard.dev/errors/unsupported-charset",
|
|
105
|
+
"Unsupported Charset",
|
|
106
|
+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
107
|
+
)
|
|
108
|
+
.with_detail(format!(
|
|
109
|
+
"Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
|
|
110
|
+
charset
|
|
111
|
+
));
|
|
112
|
+
|
|
113
|
+
let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
|
|
114
|
+
return Err((
|
|
115
|
+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
116
|
+
[(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
|
|
117
|
+
body,
|
|
118
|
+
)
|
|
119
|
+
.into_response());
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
Ok(())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[cfg(test)]
|
|
129
|
+
mod tests {
|
|
130
|
+
use super::*;
|
|
131
|
+
use axum::http::HeaderValue;
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn validate_content_length_accepts_matching_sizes() {
|
|
135
|
+
let mut headers = HeaderMap::new();
|
|
136
|
+
headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
|
|
137
|
+
|
|
138
|
+
assert!(validate_content_length(&headers, 5).is_ok());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn validate_content_length_rejects_mismatched_sizes() {
|
|
143
|
+
let mut headers = HeaderMap::new();
|
|
144
|
+
headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
|
|
145
|
+
|
|
146
|
+
let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
|
|
147
|
+
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
|
|
148
|
+
assert_eq!(
|
|
149
|
+
err.headers()
|
|
150
|
+
.get(axum::http::header::CONTENT_TYPE)
|
|
151
|
+
.and_then(|value| value.to_str().ok()),
|
|
152
|
+
Some(CONTENT_TYPE_PROBLEM_JSON)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn test_multipart_without_boundary() {
|
|
158
|
+
let mut headers = HeaderMap::new();
|
|
159
|
+
headers.insert(
|
|
160
|
+
axum::http::header::CONTENT_TYPE,
|
|
161
|
+
HeaderValue::from_static("multipart/form-data"),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
165
|
+
assert!(result.is_err());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn test_multipart_with_boundary() {
|
|
170
|
+
let mut headers = HeaderMap::new();
|
|
171
|
+
headers.insert(
|
|
172
|
+
axum::http::header::CONTENT_TYPE,
|
|
173
|
+
HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
177
|
+
assert!(result.is_ok());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn test_json_with_utf16_charset() {
|
|
182
|
+
let mut headers = HeaderMap::new();
|
|
183
|
+
headers.insert(
|
|
184
|
+
axum::http::header::CONTENT_TYPE,
|
|
185
|
+
HeaderValue::from_static("application/json; charset=utf-16"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
189
|
+
assert!(result.is_err());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn test_json_with_utf8_charset() {
|
|
194
|
+
let mut headers = HeaderMap::new();
|
|
195
|
+
headers.insert(
|
|
196
|
+
axum::http::header::CONTENT_TYPE,
|
|
197
|
+
HeaderValue::from_static("application/json; charset=utf-8"),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
201
|
+
assert!(result.is_ok());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn test_json_without_charset() {
|
|
206
|
+
let mut headers = HeaderMap::new();
|
|
207
|
+
headers.insert(
|
|
208
|
+
axum::http::header::CONTENT_TYPE,
|
|
209
|
+
HeaderValue::from_static("application/json"),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
213
|
+
assert!(result.is_ok());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn test_vendor_json_accepted() {
|
|
218
|
+
let mut headers = HeaderMap::new();
|
|
219
|
+
headers.insert(
|
|
220
|
+
axum::http::header::CONTENT_TYPE,
|
|
221
|
+
HeaderValue::from_static("application/vnd.api+json"),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
225
|
+
assert!(result.is_ok());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn test_problem_json_accepted() {
|
|
230
|
+
let mut headers = HeaderMap::new();
|
|
231
|
+
headers.insert(
|
|
232
|
+
axum::http::header::CONTENT_TYPE,
|
|
233
|
+
HeaderValue::from_static("application/problem+json"),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
237
|
+
assert!(result.is_ok());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn test_vendor_json_with_utf16_charset_rejected() {
|
|
242
|
+
let mut headers = HeaderMap::new();
|
|
243
|
+
headers.insert(
|
|
244
|
+
axum::http::header::CONTENT_TYPE,
|
|
245
|
+
HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
249
|
+
assert!(result.is_err());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#[test]
|
|
253
|
+
fn test_vendor_json_with_utf8_charset_accepted() {
|
|
254
|
+
let mut headers = HeaderMap::new();
|
|
255
|
+
headers.insert(
|
|
256
|
+
axum::http::header::CONTENT_TYPE,
|
|
257
|
+
HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
let result = validate_content_type_headers(&headers, 0);
|
|
261
|
+
assert!(result.is_ok());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn test_is_json_content_type() {
|
|
266
|
+
let mime = "application/json".parse::<mime::Mime>().unwrap();
|
|
267
|
+
assert!(is_json_content_type(&mime));
|
|
268
|
+
|
|
269
|
+
let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
|
|
270
|
+
assert!(is_json_content_type(&mime));
|
|
271
|
+
|
|
272
|
+
let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
|
|
273
|
+
assert!(is_json_content_type(&mime));
|
|
274
|
+
|
|
275
|
+
let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
|
|
276
|
+
assert!(is_json_content_type(&mime));
|
|
277
|
+
|
|
278
|
+
let mime = "text/plain".parse::<mime::Mime>().unwrap();
|
|
279
|
+
assert!(!is_json_content_type(&mime));
|
|
280
|
+
|
|
281
|
+
let mime = "application/xml".parse::<mime::Mime>().unwrap();
|
|
282
|
+
assert!(!is_json_content_type(&mime));
|
|
283
|
+
|
|
284
|
+
let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
|
|
285
|
+
assert!(!is_json_content_type(&mime));
|
|
286
|
+
}
|
|
287
|
+
}
|