Skip to main content

postrust_core/api_request/
mod.rs

1//! API request parsing module.
2//!
3//! This module handles parsing HTTP requests into the domain-specific
4//! `ApiRequest` type that can be used for query planning.
5
6pub mod payload;
7pub mod preferences;
8pub mod query_params;
9pub mod types;
10
11pub use preferences::parse_preferences;
12pub use query_params::parse_query_params;
13pub use types::*;
14
15use crate::error::{Error, Result};
16use http::{Method, Request};
17use std::collections::{HashMap, HashSet};
18
19/// Parse an HTTP request into an ApiRequest.
20pub fn parse_request<B>(
21    req: &Request<B>,
22    default_schema: &str,
23    schemas: &[String],
24) -> Result<ApiRequest>
25where
26    B: AsRef<[u8]>,
27{
28    let method = req.method();
29    let path = req.uri().path();
30    let query = req.uri().query().unwrap_or("");
31
32    // Parse resource from path
33    let resource = parse_resource(path)?;
34
35    // Determine schema from headers or use default
36    let (schema, negotiated_by_profile) = parse_schema(req, default_schema, schemas)?;
37
38    // Parse action from method and resource
39    let action = parse_action(method, &resource, &schema)?;
40
41    // Parse query parameters
42    let query_params = parse_query_params(query)?;
43
44    // Parse preferences from Prefer header
45    let preferences = parse_preferences(req.headers())?;
46
47    // Parse Accept header for content negotiation
48    let accept_media_types = parse_accept(req.headers())?;
49
50    // Parse Content-Type header
51    let content_media_type = parse_content_type(req.headers())?;
52
53    // Parse Range header
54    let top_level_range = parse_range(req.headers())?;
55
56    // Extract headers and cookies for GUC passthrough
57    let headers = extract_headers(req.headers());
58    let cookies = extract_cookies(req.headers());
59
60    Ok(ApiRequest {
61        action,
62        schema,
63        payload: None, // Payload parsed separately
64        query_params,
65        accept_media_types,
66        content_media_type,
67        preferences,
68        columns: HashSet::new(),
69        top_level_range,
70        range_map: HashMap::new(),
71        negotiated_by_profile,
72        method: method.to_string(),
73        path: path.to_string(),
74        headers,
75        cookies,
76    })
77}
78
79/// Parse the resource from the URL path.
80fn parse_resource(path: &str) -> Result<Resource> {
81    let path = path.trim_start_matches('/');
82
83    if path.is_empty() {
84        return Ok(Resource::Schema);
85    }
86
87    if let Some(func_name) = path.strip_prefix("rpc/") {
88        if func_name.is_empty() {
89            return Err(Error::InvalidPath("Empty function name".into()));
90        }
91        return Ok(Resource::Routine(func_name.to_string()));
92    }
93
94    // Table/view name is the first path segment
95    let name = path.split('/').next().unwrap_or(path);
96    if name.is_empty() {
97        return Err(Error::InvalidPath("Empty resource name".into()));
98    }
99
100    Ok(Resource::Relation(name.to_string()))
101}
102
103/// Parse the schema from Accept-Profile or Content-Profile headers.
104fn parse_schema<B>(
105    req: &Request<B>,
106    default_schema: &str,
107    schemas: &[String],
108) -> Result<(String, bool)> {
109    // Check Accept-Profile header first (for reads)
110    if let Some(profile) = req.headers().get("accept-profile") {
111        let schema = profile
112            .to_str()
113            .map_err(|_| Error::InvalidHeader("Accept-Profile"))?;
114        if !schemas.contains(&schema.to_string()) {
115            return Err(Error::UnacceptableSchema(schema.into()));
116        }
117        return Ok((schema.to_string(), true));
118    }
119
120    // Check Content-Profile header (for writes)
121    if let Some(profile) = req.headers().get("content-profile") {
122        let schema = profile
123            .to_str()
124            .map_err(|_| Error::InvalidHeader("Content-Profile"))?;
125        if !schemas.contains(&schema.to_string()) {
126            return Err(Error::UnacceptableSchema(schema.into()));
127        }
128        return Ok((schema.to_string(), true));
129    }
130
131    Ok((default_schema.to_string(), false))
132}
133
134/// Parse the action from HTTP method and resource.
135fn parse_action(method: &Method, resource: &Resource, schema: &str) -> Result<Action> {
136    match (method, resource) {
137        // Schema endpoints
138        (&Method::GET, Resource::Schema) => Ok(Action::Db(DbAction::SchemaRead {
139            schema: schema.to_string(),
140            headers_only: false,
141        })),
142        (&Method::HEAD, Resource::Schema) => Ok(Action::Db(DbAction::SchemaRead {
143            schema: schema.to_string(),
144            headers_only: true,
145        })),
146        (&Method::OPTIONS, Resource::Schema) => Ok(Action::SchemaInfo),
147
148        // Table/view endpoints
149        (&Method::GET, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationRead {
150            qi: QualifiedIdentifier::new(schema, name),
151            headers_only: false,
152        })),
153        (&Method::HEAD, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationRead {
154            qi: QualifiedIdentifier::new(schema, name),
155            headers_only: true,
156        })),
157        (&Method::POST, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
158            qi: QualifiedIdentifier::new(schema, name),
159            mutation: Mutation::Create,
160        })),
161        (&Method::PATCH, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
162            qi: QualifiedIdentifier::new(schema, name),
163            mutation: Mutation::Update,
164        })),
165        (&Method::PUT, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
166            qi: QualifiedIdentifier::new(schema, name),
167            mutation: Mutation::SingleUpsert,
168        })),
169        (&Method::DELETE, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
170            qi: QualifiedIdentifier::new(schema, name),
171            mutation: Mutation::Delete,
172        })),
173        (&Method::OPTIONS, Resource::Relation(name)) => {
174            Ok(Action::RelationInfo(QualifiedIdentifier::new(schema, name)))
175        }
176
177        // RPC endpoints
178        (&Method::GET, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
179            qi: QualifiedIdentifier::new(schema, name),
180            invoke_method: InvokeMethod::InvRead {
181                headers_only: false,
182            },
183        })),
184        (&Method::HEAD, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
185            qi: QualifiedIdentifier::new(schema, name),
186            invoke_method: InvokeMethod::InvRead { headers_only: true },
187        })),
188        (&Method::POST, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
189            qi: QualifiedIdentifier::new(schema, name),
190            invoke_method: InvokeMethod::Inv,
191        })),
192        (&Method::OPTIONS, Resource::Routine(name)) => Ok(Action::RoutineInfo {
193            qi: QualifiedIdentifier::new(schema, name),
194            invoke_method: InvokeMethod::Inv,
195        }),
196
197        // Unsupported methods
198        _ => Err(Error::UnsupportedMethod(method.to_string())),
199    }
200}
201
202/// Parse Accept header for content negotiation.
203fn parse_accept(headers: &http::HeaderMap) -> Result<Vec<MediaType>> {
204    if let Some(accept) = headers.get(http::header::ACCEPT) {
205        let accept_str = accept
206            .to_str()
207            .map_err(|_| Error::InvalidHeader("Accept"))?;
208        // Simple parsing - full implementation would handle quality factors
209        let types: Vec<MediaType> = accept_str
210            .split(',')
211            .map(|s| s.trim())
212            .map(|s| s.split(';').next().unwrap_or(s).trim())
213            .map(parse_media_type)
214            .collect();
215        if types.is_empty() {
216            return Ok(vec![MediaType::ApplicationJson]);
217        }
218        return Ok(types);
219    }
220    Ok(vec![MediaType::ApplicationJson])
221}
222
223/// Parse a single media type string.
224fn parse_media_type(s: &str) -> MediaType {
225    match s {
226        "application/json" => MediaType::ApplicationJson,
227        "application/geo+json" => MediaType::GeoJson,
228        "text/csv" => MediaType::TextCsv,
229        "text/plain" => MediaType::TextPlain,
230        "text/xml" => MediaType::TextXml,
231        "application/openapi+json" => MediaType::OpenApi,
232        "application/x-www-form-urlencoded" => MediaType::UrlEncoded,
233        "application/octet-stream" => MediaType::OctetStream,
234        "*/*" => MediaType::Any,
235        s if s.starts_with("application/vnd.pgrst.object") => MediaType::SingularJson {
236            nullable: s.contains("nulls=null"),
237        },
238        s if s.starts_with("application/vnd.pgrst.array") => MediaType::ArrayJsonStrip,
239        other => MediaType::Other(other.to_string()),
240    }
241}
242
243/// Parse Content-Type header.
244fn parse_content_type(headers: &http::HeaderMap) -> Result<MediaType> {
245    if let Some(ct) = headers.get(http::header::CONTENT_TYPE) {
246        let ct_str = ct
247            .to_str()
248            .map_err(|_| Error::InvalidHeader("Content-Type"))?;
249        let media_type = ct_str.split(';').next().unwrap_or(ct_str).trim();
250        return Ok(parse_media_type(media_type));
251    }
252    Ok(MediaType::ApplicationJson)
253}
254
255/// Parse Range header for pagination.
256fn parse_range(headers: &http::HeaderMap) -> Result<Range> {
257    if let Some(range) = headers.get(http::header::RANGE) {
258        let range_str = range.to_str().map_err(|_| Error::InvalidHeader("Range"))?;
259        // Parse "0-9" or "10-" format
260        if let Some(range_value) = range_str.strip_prefix("0-") {
261            if range_value.is_empty() {
262                return Ok(Range::new(0, None));
263            }
264            if let Ok(end) = range_value.parse::<i64>() {
265                return Ok(Range::from_bounds(0, Some(end)));
266            }
267        }
268        // More complex range parsing would go here
269    }
270    Ok(Range::default())
271}
272
273/// Extract headers for GUC passthrough.
274fn extract_headers(headers: &http::HeaderMap) -> indexmap::IndexMap<String, String> {
275    headers
276        .iter()
277        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
278        .collect()
279}
280
281/// Extract cookies from Cookie header.
282fn extract_cookies(headers: &http::HeaderMap) -> indexmap::IndexMap<String, String> {
283    headers
284        .get(http::header::COOKIE)
285        .and_then(|v| v.to_str().ok())
286        .map(|s| {
287            s.split(';')
288                .filter_map(|cookie| {
289                    let (key, value) = cookie.trim().split_once('=')?;
290
291                    Some((key.to_string(), value.to_string()))
292                })
293                .collect()
294        })
295        .unwrap_or_default()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_parse_resource() {
304        assert_eq!(parse_resource("/").unwrap(), Resource::Schema);
305        assert_eq!(
306            parse_resource("/users").unwrap(),
307            Resource::Relation("users".into())
308        );
309        assert_eq!(
310            parse_resource("/rpc/my_func").unwrap(),
311            Resource::Routine("my_func".into())
312        );
313    }
314
315    #[test]
316    fn test_parse_media_type() {
317        assert_eq!(
318            parse_media_type("application/json"),
319            MediaType::ApplicationJson
320        );
321        assert_eq!(parse_media_type("text/csv"), MediaType::TextCsv);
322        assert_eq!(parse_media_type("*/*"), MediaType::Any);
323    }
324}