1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
19pub enum ContentType {
20 #[default]
23 Json,
24 Yaml,
26 Cbor,
28 MsgPack,
30 Csv,
32 Proto,
34 FormUrlEncoded,
36 MultipartForm,
38
39 Sse,
42 WebSocket,
44 GrpcWeb,
51
52 OpenApi,
55 Mcp,
57 Markdown,
59}
60
61impl ContentType {
62 #[must_use]
64 pub fn from_extension(ext: &str) -> Option<Self> {
65 match ext {
66 "json" => Some(Self::Json),
67 "yaml" | "yml" => Some(Self::Yaml),
68 "cbor" => Some(Self::Cbor),
69 "msgpk" | "msgpack" => Some(Self::MsgPack),
70 "csv" => Some(Self::Csv),
71 "proto" => Some(Self::Proto),
72 "sse" => Some(Self::Sse),
73 "ws" => Some(Self::WebSocket),
74 "openapi" => Some(Self::OpenApi),
75 "mcp" => Some(Self::Mcp),
76 "md" => Some(Self::Markdown),
77 _ => None,
78 }
79 }
80
81 #[must_use]
85 pub fn from_content_type_header(header: &str) -> Option<Self> {
86 let media_type = header.split(';').next().unwrap_or("").trim();
87 match media_type {
88 "application/grpc-web" | "application/grpc-web+proto" | "application/grpc-web+json" => {
92 Some(Self::GrpcWeb)
93 },
94 "application/grpc" | "application/grpc+proto" => Some(Self::Proto),
95 "application/proto" | "application/protobuf" => Some(Self::Proto),
96 "application/connect+proto" => Some(Self::Proto),
97 "application/x-www-form-urlencoded" => Some(Self::FormUrlEncoded),
98 "multipart/form-data" => Some(Self::MultipartForm),
99 _ => None,
100 }
101 }
102
103 #[must_use]
105 pub fn from_accept_header(accept: Option<&str>) -> Self {
106 let Some(accept) = accept else {
107 return Self::Json;
108 };
109 for part in accept.split(',') {
110 let media_type = part.split(';').next().unwrap_or("").trim();
111 match media_type {
112 "application/json" | "*/*" => return Self::Json,
113 "application/yaml" | "application/x-yaml" | "text/yaml" => return Self::Yaml,
114 "application/cbor" => return Self::Cbor,
115 "application/msgpack" | "application/x-msgpack" => return Self::MsgPack,
116 "text/csv" => return Self::Csv,
117 "application/grpc-web"
118 | "application/grpc-web+proto"
119 | "application/grpc-web+json" => return Self::GrpcWeb,
120 "application/proto" | "application/protobuf" | "application/grpc" => {
121 return Self::Proto;
122 },
123 "text/event-stream" => return Self::Sse,
124 _ => {},
125 }
126 }
127 Self::Json
128 }
129
130 #[must_use]
132 pub fn from_query_param(query: &str) -> Option<Self> {
133 for pair in query.split('&') {
134 let mut kv = pair.splitn(2, '=');
135 if let (Some("format"), Some(value)) = (kv.next(), kv.next()) {
136 return match value {
137 "json" => Some(Self::Json),
138 "yaml" | "yml" => Some(Self::Yaml),
139 "cbor" => Some(Self::Cbor),
140 "msgpack" | "messagepack" => Some(Self::MsgPack),
141 "csv" => Some(Self::Csv),
142 "proto" | "protobuf" => Some(Self::Proto),
143 _ => None,
144 };
145 }
146 }
147 None
148 }
149
150 #[must_use]
152 pub const fn header_value(self) -> &'static str {
153 match self {
154 Self::Json | Self::OpenApi | Self::Mcp => "application/json",
155 Self::Yaml => "application/yaml",
156 Self::Cbor => "application/cbor",
157 Self::MsgPack => "application/msgpack",
158 Self::Csv => "text/csv",
159 Self::Proto => "application/proto",
160 Self::FormUrlEncoded => "application/x-www-form-urlencoded",
161 Self::MultipartForm => "multipart/form-data",
162 Self::Sse => "text/event-stream",
163 Self::WebSocket => "text/plain",
164 Self::GrpcWeb => "application/grpc-web+json",
165 Self::Markdown => "text/markdown",
166 }
167 }
168
169 #[must_use]
171 pub const fn is_streaming(self) -> bool {
172 matches!(self, Self::Sse | Self::WebSocket | Self::GrpcWeb)
173 }
174
175 #[must_use]
180 pub const fn is_streaming_subscribe(self) -> bool {
181 matches!(self, Self::Sse | Self::GrpcWeb)
182 }
183
184 #[must_use]
186 pub const fn is_data_format(self) -> bool {
187 matches!(
188 self,
189 Self::Json | Self::Yaml | Self::Cbor | Self::MsgPack | Self::Csv | Self::Proto
190 )
191 }
192}
193
194pub fn resolve_format(
198 headers: &http::HeaderMap,
199 extension: Option<&str>,
200 query: &str,
201) -> ContentType {
202 if headers
204 .get("upgrade")
205 .and_then(|v| v.to_str().ok())
206 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
207 {
208 return ContentType::WebSocket;
209 }
210 if headers
211 .get("accept")
212 .and_then(|v| v.to_str().ok())
213 .is_some_and(|v| v.contains("text/event-stream"))
214 {
215 return ContentType::Sse;
216 }
217 if let Some(ct) = headers
218 .get("content-type")
219 .and_then(|v| v.to_str().ok())
220 .and_then(ContentType::from_content_type_header)
221 {
222 return ct;
223 }
224
225 if let Some(ext) = extension
227 && let Some(ct) = ContentType::from_extension(ext)
228 {
229 return ct;
230 }
231
232 if let Some(ct) = ContentType::from_query_param(query) {
234 return ct;
235 }
236
237 for pair in query.split('&') {
239 let mut kv = pair.splitn(2, '=');
240 match (kv.next(), kv.next()) {
241 (Some("stream"), Some("sse")) => return ContentType::Sse,
242 (Some("stream"), Some("grpcweb" | "grpc-web")) => return ContentType::GrpcWeb,
243 _ => {},
244 }
245 }
246
247 let accept = headers.get("accept").and_then(|v| v.to_str().ok());
249 ContentType::from_accept_header(accept)
250}
251
252#[must_use]
259pub fn strip_extension(segment: &str) -> (&str, Option<&str>) {
260 if let Some(dot) = segment.rfind('.') {
261 let ext = &segment[dot + 1..];
262 if ContentType::from_extension(ext).is_some() {
263 return (&segment[..dot], Some(ext));
264 }
265 }
266 (segment, None)
267}
268
269#[must_use]
279pub fn strip_extension_from_path(path: &str) -> (&str, Option<&str>) {
280 let last_slash = path.rfind('/').unwrap_or(0);
282 let last_segment = &path[last_slash..];
283
284 if let Some(dot) = last_segment.rfind('.') {
286 let ext = &last_segment[dot + 1..];
287 if ContentType::from_extension(ext).is_some() {
288 let clean_end = last_slash + dot;
289 return (&path[..clean_end], Some(ext));
290 }
291 }
292 (path, None)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn from_extension() {
301 assert_eq!(ContentType::from_extension("json"), Some(ContentType::Json));
302 assert_eq!(ContentType::from_extension("yaml"), Some(ContentType::Yaml));
303 assert_eq!(ContentType::from_extension("cbor"), Some(ContentType::Cbor));
304 assert_eq!(
305 ContentType::from_extension("msgpk"),
306 Some(ContentType::MsgPack)
307 );
308 assert_eq!(ContentType::from_extension("csv"), Some(ContentType::Csv));
309 assert_eq!(
310 ContentType::from_extension("proto"),
311 Some(ContentType::Proto)
312 );
313 assert_eq!(ContentType::from_extension("sse"), Some(ContentType::Sse));
314 assert_eq!(
315 ContentType::from_extension("ws"),
316 Some(ContentType::WebSocket)
317 );
318 assert_eq!(
319 ContentType::from_extension("openapi"),
320 Some(ContentType::OpenApi)
321 );
322 assert_eq!(ContentType::from_extension("mcp"), Some(ContentType::Mcp));
323 assert_eq!(
324 ContentType::from_extension("md"),
325 Some(ContentType::Markdown)
326 );
327 assert_eq!(ContentType::from_extension("xml"), None);
328 }
329
330 #[test]
331 fn from_content_type_header() {
332 assert_eq!(
333 ContentType::from_content_type_header("application/grpc"),
334 Some(ContentType::Proto)
335 );
336 assert_eq!(
337 ContentType::from_content_type_header("application/proto"),
338 Some(ContentType::Proto)
339 );
340 assert_eq!(
341 ContentType::from_content_type_header("multipart/form-data; boundary=abc"),
342 Some(ContentType::MultipartForm)
343 );
344 assert_eq!(
345 ContentType::from_content_type_header("application/x-www-form-urlencoded"),
346 Some(ContentType::FormUrlEncoded)
347 );
348 assert_eq!(
349 ContentType::from_content_type_header("application/json"),
350 None
351 );
352 }
353
354 #[test]
355 fn from_accept_header() {
356 assert_eq!(ContentType::from_accept_header(None), ContentType::Json);
357 assert_eq!(
358 ContentType::from_accept_header(Some("application/json")),
359 ContentType::Json
360 );
361 assert_eq!(
362 ContentType::from_accept_header(Some("application/yaml")),
363 ContentType::Yaml
364 );
365 assert_eq!(
366 ContentType::from_accept_header(Some("text/csv")),
367 ContentType::Csv
368 );
369 assert_eq!(
370 ContentType::from_accept_header(Some("application/proto")),
371 ContentType::Proto
372 );
373 assert_eq!(
374 ContentType::from_accept_header(Some("text/event-stream")),
375 ContentType::Sse
376 );
377 assert_eq!(
378 ContentType::from_accept_header(Some("text/html")),
379 ContentType::Json
380 );
381 }
382
383 #[test]
384 fn from_query_param() {
385 assert_eq!(ContentType::from_query_param(""), None);
386 assert_eq!(
387 ContentType::from_query_param("format=json"),
388 Some(ContentType::Json)
389 );
390 assert_eq!(
391 ContentType::from_query_param("format=csv"),
392 Some(ContentType::Csv)
393 );
394 assert_eq!(
395 ContentType::from_query_param("format=proto"),
396 Some(ContentType::Proto)
397 );
398 assert_eq!(
399 ContentType::from_query_param("limit=10&format=yaml&offset=0"),
400 Some(ContentType::Yaml)
401 );
402 assert_eq!(ContentType::from_query_param("format=xml"), None);
403 }
404
405 #[test]
406 fn test_strip_extension() {
407 assert_eq!(strip_extension("user-123.json"), ("user-123", Some("json")));
408 assert_eq!(strip_extension("Users.sse"), ("Users", Some("sse")));
409 assert_eq!(strip_extension("user-123"), ("user-123", None));
410 assert_eq!(
411 strip_extension("file.name.json"),
412 ("file.name", Some("json"))
413 );
414 assert_eq!(strip_extension("file.unknown"), ("file.unknown", None));
415 assert_eq!(
416 strip_extension("_discovery.openapi"),
417 ("_discovery", Some("openapi"))
418 );
419 }
420
421 #[test]
422 fn resolve_format_transport_headers_win() {
423 let mut headers = http::HeaderMap::new();
424 headers.insert("upgrade", "websocket".parse().unwrap());
425 assert_eq!(
426 resolve_format(&headers, Some("json"), "format=yaml"),
427 ContentType::WebSocket
428 );
429
430 let mut headers = http::HeaderMap::new();
431 headers.insert("accept", "text/event-stream".parse().unwrap());
432 assert_eq!(resolve_format(&headers, None, ""), ContentType::Sse);
433
434 let mut headers = http::HeaderMap::new();
435 headers.insert("content-type", "application/grpc".parse().unwrap());
436 assert_eq!(resolve_format(&headers, None, ""), ContentType::Proto);
437 }
438
439 #[test]
440 fn resolve_format_extension_over_query() {
441 let headers = http::HeaderMap::new();
442 assert_eq!(
443 resolve_format(&headers, Some("cbor"), "format=json"),
444 ContentType::Cbor
445 );
446 }
447
448 #[test]
449 fn resolve_format_stream_sse_query_param() {
450 let headers = http::HeaderMap::new();
451 assert_eq!(
452 resolve_format(&headers, None, "stream=sse"),
453 ContentType::Sse
454 );
455 }
456
457 #[test]
458 fn resolve_format_default_json() {
459 let headers = http::HeaderMap::new();
460 assert_eq!(resolve_format(&headers, None, ""), ContentType::Json);
461 }
462}