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