Skip to main content

yeti_types/
content_type.rs

1//! Content type enum and format resolution.
2//!
3//! Defines the unified content type for all interfaces. Lives in yeti-types (L0)
4//! so Context can reference it directly. Serialization implementations that need
5//! heavy deps (ciborium, rmp-serde, serde_yaml) live in yeti-sdk (L2).
6//!
7//! Resolution priority:
8//! 1. Transport headers (Upgrade, Accept: text/event-stream, Content-Type: application/grpc)
9//! 2. URL extension (.json, .yaml, .sse, .proto, etc.)
10//! 3. ?format= query parameter
11//! 4. ?stream=sse query parameter
12//! 5. Accept header (standard content negotiation)
13//! 6. Default: JSON
14
15/// Supported content types, transports, and discovery formats.
16///
17/// Copy, stack-allocated, zero-cost to pass around.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
19pub enum ContentType {
20    // ── Data formats (serialization) ──
21    /// JSON (application/json) — default
22    #[default]
23    Json,
24    /// YAML (application/yaml)
25    Yaml,
26    /// CBOR (application/cbor) — binary
27    Cbor,
28    /// `MessagePack` (application/msgpack) — binary
29    MsgPack,
30    /// CSV (text/csv) — tabular
31    Csv,
32    /// Protobuf (application/proto) — binary, also Connect/gRPC
33    Proto,
34    /// Form URL-encoded (application/x-www-form-urlencoded) — request deserialization
35    FormUrlEncoded,
36    /// Multipart form data (multipart/form-data) — request deserialization
37    MultipartForm,
38
39    // ── Streaming transports ──
40    /// Server-Sent Events (text/event-stream)
41    Sse,
42    /// WebSocket (Upgrade: websocket)
43    WebSocket,
44    /// gRPC-Web / Connect server-streaming (application/grpc-web+json).
45    /// Length-prefixed envelope frames `[flag:u8][len:u32 BE][payload]`
46    /// over a held-open HTTP/2 response — the browser-reachable member of
47    /// the gRPC family (raw gRPC needs HTTP/2 trailers the fetch API
48    /// can't read). Shares the subscribe path with [`Self::Sse`]; only
49    /// the frame encoding differs.
50    GrpcWeb,
51
52    // ── Discovery formats ──
53    /// `OpenAPI` 3.1 spec
54    OpenApi,
55    /// MCP tools/list response
56    Mcp,
57    /// Human-readable Markdown
58    Markdown,
59}
60
61impl ContentType {
62    /// Parse from URL file extension.
63    #[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    /// Parse from Content-Type request header for transport/format detection.
82    ///
83    /// Returns `None` for standard types that don't need special routing.
84    #[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            // gRPC-Web server-streaming → the streaming subscribe path.
89            // Unambiguous (distinct from unary application/grpc+proto and
90            // application/connect+proto), so it can't misroute unary RPC.
91            "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    /// Parse from Accept header value. Falls back to Json.
104    #[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    /// Parse from `?format=` query parameter.
131    #[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    /// Content-Type header value for the response.
151    #[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    /// Whether this is a streaming transport (SSE, WebSocket, gRPC-Web).
170    #[must_use]
171    pub const fn is_streaming(self) -> bool {
172        matches!(self, Self::Sse | Self::WebSocket | Self::GrpcWeb)
173    }
174
175    /// Whether this is a server-to-client streaming-subscribe transport
176    /// (SSE or gRPC-Web) — the formats the subscribe handler serves over
177    /// a held-open response. Excludes WebSocket (handled by an upgrade in
178    /// the host layer before dispatch).
179    #[must_use]
180    pub const fn is_streaming_subscribe(self) -> bool {
181        matches!(self, Self::Sse | Self::GrpcWeb)
182    }
183
184    /// Whether this is a data serialization format.
185    #[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
194/// Resolve content type from all sources. Zero allocations.
195///
196/// Priority: transport headers > extension > ?format= > ?stream=sse > Accept > JSON default.
197pub fn resolve_format(
198    headers: &http::HeaderMap,
199    extension: Option<&str>,
200    query: &str,
201) -> ContentType {
202    // 1. Transport headers (highest priority)
203    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    // 2. URL extension
226    if let Some(ext) = extension
227        && let Some(ct) = ContentType::from_extension(ext)
228    {
229        return ct;
230    }
231
232    // 3. ?format= query param
233    if let Some(ct) = ContentType::from_query_param(query) {
234        return ct;
235    }
236
237    // 4. ?stream=sse / ?stream=grpcweb opt-in for streaming subscribe
238    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    // 5. Accept header
248    let accept = headers.get("accept").and_then(|v| v.to_str().ok());
249    ContentType::from_accept_header(accept)
250}
251
252/// Strip a known file extension from a path segment.
253///
254/// Only strips extensions that map to known `ContentType` variants.
255/// Returns (`stripped_segment`, `optional_extension`).
256///
257/// Zero allocations — returns slices into the input.
258#[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/// Strip a known file extension from the last segment of a URL path.
270///
271/// Returns (`clean_path_slice`, `optional_extension`). Zero allocations —
272/// both return values are slices into the input.
273///
274/// Examples:
275/// - "/Users/user-123.json" → ("/Users/user-123", Some("json"))
276/// - "/Users.sse" → ("/Users", Some("sse"))
277/// - "/Users/user-123" → ("/Users/user-123", None)
278#[must_use]
279pub fn strip_extension_from_path(path: &str) -> (&str, Option<&str>) {
280    // Find the last '/' to isolate the final segment
281    let last_slash = path.rfind('/').unwrap_or(0);
282    let last_segment = &path[last_slash..];
283
284    // Check for known extension in the last segment
285    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}