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, value_is_filter};
13pub use types::*;
14
15use crate::error::{Error, Result};
16use http::{Method, Request};
17use percent_encoding::percent_decode_str;
18use std::collections::{HashMap, HashSet};
19
20/// Parse an HTTP request into an ApiRequest.
21///
22/// `max_rows` is the server-configured ceiling on returned rows
23/// (`PGRST_MAX_ROWS`); pass `None` for no ceiling. It is applied when the read
24/// plan resolves its range, so it caps requests that specify no `limit`.
25pub fn parse_request<B>(
26    req: &Request<B>,
27    default_schema: &str,
28    schemas: &[String],
29    max_rows: Option<i64>,
30) -> Result<ApiRequest>
31where
32    B: AsRef<[u8]>,
33{
34    let method = req.method();
35    let path = req.uri().path();
36    let query = req.uri().query().unwrap_or("");
37
38    // Parse resource from path
39    let resource = parse_resource(path)?;
40
41    // Determine schema from headers or use default
42    let (schema, negotiated_by_profile) = parse_schema(req, default_schema, schemas)?;
43
44    // Parse action from method and resource
45    let action = parse_action(method, &resource, &schema)?;
46
47    // Parse query parameters. On a function *called over GET* the
48    // unrecognized ones are arguments rather than malformed filters. Over
49    // POST the arguments are in the body, so the query string is filters and
50    // nothing else -- `POST /rpc/f?name=John` is a malformed filter, which is
51    // what PostgREST says about it.
52    let is_rpc = matches!(
53        action,
54        Action::Db(DbAction::Routine {
55            invoke_method: InvokeMethod::InvRead { .. },
56            ..
57        }) | Action::RoutineInfo { .. }
58    );
59    let query_params = parse_query_params(query, is_rpc)?;
60
61    // Parse preferences from Prefer header
62    let preferences = parse_preferences(req.headers())?;
63
64    // Parse Accept header for content negotiation
65    let accept_media_types = parse_accept(req.headers())?;
66
67    // Parse Content-Type header
68    let content_media_type = parse_content_type(req.headers())?;
69
70    // Parse Range header
71    let top_level_range = parse_range(req.headers())?;
72
73    // Extract headers and cookies for GUC passthrough
74    let headers = extract_headers(req.headers());
75    let cookies = extract_cookies(req.headers());
76
77    Ok(ApiRequest {
78        action,
79        schema,
80        payload: None, // Payload parsed separately
81        query_params,
82        accept_media_types,
83        content_media_type,
84        preferences,
85        columns: HashSet::new(),
86        top_level_range,
87        range_map: HashMap::new(),
88        max_rows,
89        negotiated_by_profile,
90        method: method.to_string(),
91        path: path.to_string(),
92        headers,
93        cookies,
94        // Set by the caller, which has the request's extensions; this sees
95        // a request rebuilt from the headers.
96        trace_id: None,
97    })
98}
99
100/// Parse the resource from the URL path.
101fn parse_resource(path: &str) -> Result<Resource> {
102    let path = path.trim_start_matches('/');
103
104    if path.is_empty() {
105        return Ok(Resource::Schema);
106    }
107
108    // A trailing slash names the same resource: `/items/` is `/items`.
109    let path = path.trim_end_matches('/');
110    if path.is_empty() {
111        return Ok(Resource::Schema);
112    }
113
114    // A name is the name the schema gave it, not the encoding a URL had to use
115    // to carry it: `/%D9%85%D9%88%D8%A7%D8%B1%D8%AF` addresses a table called
116    // `موارد`. Looking the encoded form up finds nothing, and says so using
117    // the escape sequence rather than the name.
118    let decoded = |segment: &str| -> String {
119        percent_decode_str(segment)
120            .decode_utf8()
121            .map(|name| name.into_owned())
122            .unwrap_or_else(|_| segment.to_string())
123    };
124
125    if let Some(func_name) = path.strip_prefix("rpc/") {
126        if func_name.is_empty() {
127            return Err(Error::InvalidPath("Empty function name".into()));
128        }
129        if func_name.contains('/') {
130            return Err(Error::InvalidResourcePath);
131        }
132        return Ok(Resource::Routine(decoded(func_name)));
133    }
134
135    // A table is named by one segment. More than one names nothing at all --
136    // there is no nesting in the API -- and reading only the first would
137    // answer a request for `/first/second/third` with the contents of
138    // `first`, which is not what was asked for.
139    if path.contains('/') {
140        return Err(Error::InvalidResourcePath);
141    }
142
143    Ok(Resource::Relation(decoded(path)))
144}
145
146/// Parse the schema from Accept-Profile or Content-Profile headers.
147fn parse_schema<B>(
148    req: &Request<B>,
149    default_schema: &str,
150    schemas: &[String],
151) -> Result<(String, bool)> {
152    // Check Accept-Profile header first (for reads)
153    if let Some(profile) = req.headers().get("accept-profile") {
154        let schema = profile
155            .to_str()
156            .map_err(|_| Error::InvalidHeader("Accept-Profile"))?;
157        if !schemas.contains(&schema.to_string()) {
158            return Err(Error::UnacceptableSchema {
159                requested: schema.into(),
160                exposed: schemas.to_vec(),
161            });
162        }
163        return Ok((schema.to_string(), true));
164    }
165
166    // Check Content-Profile header (for writes)
167    if let Some(profile) = req.headers().get("content-profile") {
168        let schema = profile
169            .to_str()
170            .map_err(|_| Error::InvalidHeader("Content-Profile"))?;
171        if !schemas.contains(&schema.to_string()) {
172            return Err(Error::UnacceptableSchema {
173                requested: schema.into(),
174                exposed: schemas.to_vec(),
175            });
176        }
177        return Ok((schema.to_string(), true));
178    }
179
180    Ok((default_schema.to_string(), false))
181}
182
183/// Parse the action from HTTP method and resource.
184fn parse_action(method: &Method, resource: &Resource, schema: &str) -> Result<Action> {
185    match (method, resource) {
186        // Schema endpoints
187        (&Method::GET, Resource::Schema) => Ok(Action::Db(DbAction::SchemaRead {
188            schema: schema.to_string(),
189            headers_only: false,
190        })),
191        (&Method::HEAD, Resource::Schema) => Ok(Action::Db(DbAction::SchemaRead {
192            schema: schema.to_string(),
193            headers_only: true,
194        })),
195        (&Method::OPTIONS, Resource::Schema) => Ok(Action::SchemaInfo),
196
197        // Table/view endpoints
198        (&Method::GET, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationRead {
199            qi: QualifiedIdentifier::new(schema, name),
200            headers_only: false,
201        })),
202        (&Method::HEAD, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationRead {
203            qi: QualifiedIdentifier::new(schema, name),
204            headers_only: true,
205        })),
206        (&Method::POST, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
207            qi: QualifiedIdentifier::new(schema, name),
208            mutation: Mutation::Create,
209        })),
210        (&Method::PATCH, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
211            qi: QualifiedIdentifier::new(schema, name),
212            mutation: Mutation::Update,
213        })),
214        (&Method::PUT, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
215            qi: QualifiedIdentifier::new(schema, name),
216            mutation: Mutation::SingleUpsert,
217        })),
218        (&Method::DELETE, Resource::Relation(name)) => Ok(Action::Db(DbAction::RelationMut {
219            qi: QualifiedIdentifier::new(schema, name),
220            mutation: Mutation::Delete,
221        })),
222        (&Method::OPTIONS, Resource::Relation(name)) => {
223            Ok(Action::RelationInfo(QualifiedIdentifier::new(schema, name)))
224        }
225
226        // RPC endpoints
227        (&Method::GET, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
228            qi: QualifiedIdentifier::new(schema, name),
229            invoke_method: InvokeMethod::InvRead {
230                headers_only: false,
231            },
232        })),
233        (&Method::HEAD, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
234            qi: QualifiedIdentifier::new(schema, name),
235            invoke_method: InvokeMethod::InvRead { headers_only: true },
236        })),
237        (&Method::POST, Resource::Routine(name)) => Ok(Action::Db(DbAction::Routine {
238            qi: QualifiedIdentifier::new(schema, name),
239            invoke_method: InvokeMethod::Inv,
240        })),
241        (&Method::OPTIONS, Resource::Routine(name)) => Ok(Action::RoutineInfo {
242            qi: QualifiedIdentifier::new(schema, name),
243            invoke_method: InvokeMethod::Inv,
244        }),
245
246        // Unsupported methods
247        (_, Resource::Routine(_)) => Err(Error::InvalidRpcMethod(method.to_string())),
248        _ => Err(Error::UnsupportedMethod(method.to_string())),
249    }
250}
251
252/// Parse Accept header for content negotiation.
253fn parse_accept(headers: &http::HeaderMap) -> Result<Vec<MediaType>> {
254    if let Some(accept) = headers.get(http::header::ACCEPT) {
255        let accept_str = accept
256            .to_str()
257            .map_err(|_| Error::InvalidHeader("Accept"))?;
258        // A quality factor orders the list and says nothing about the type,
259        // so it is dropped. The other parameters are not decoration: `;nulls=
260        // stripped` is part of what `application/vnd.pgrst.array+json` *is*,
261        // and cutting the entry at the semicolon threw it away along with the
262        // `q=`.
263        let types: Vec<MediaType> = accept_str
264            .split(',')
265            .map(str::trim)
266            .map(|entry| {
267                let kept: Vec<&str> = entry
268                    .split(';')
269                    .map(str::trim)
270                    .filter(|part| !part.starts_with("q="))
271                    .collect();
272                parse_media_type(&kept.join(";"))
273            })
274            .collect();
275        if types.is_empty() {
276            return Ok(vec![MediaType::ApplicationJson]);
277        }
278        return Ok(types);
279    }
280    Ok(vec![MediaType::ApplicationJson])
281}
282
283/// Parse a single media type, parameters included.
284///
285/// Only the vendored types read their parameters; for everything else the
286/// name alone is the type, so `application/json;charset=utf-8` is still JSON.
287fn parse_media_type(s: &str) -> MediaType {
288    let mut parts = s.split(';').map(str::trim);
289    let base = parts.next().unwrap_or(s);
290    // A parameter value may be quoted -- `for="application/json"` -- because
291    // it is itself a media type and carries a `/`. The quotes delimit it and
292    // are not part of it.
293    let parameters: Vec<(String, &str)> = parts
294        .filter_map(|part| part.split_once('='))
295        .map(|(key, value)| {
296            (
297                key.trim().to_ascii_lowercase(),
298                value.trim().trim_matches('"'),
299            )
300        })
301        .collect();
302    let param = |name: &str| {
303        parameters
304            .iter()
305            .find(|(key, _)| key == name)
306            .map(|(_, value)| *value)
307    };
308    let given = |parameter: &str| match parameter.split_once('=') {
309        Some((name, value)) => param(name) == Some(value),
310        None => false,
311    };
312
313    match base {
314        "application/json" => MediaType::ApplicationJson,
315        "application/geo+json" => MediaType::GeoJson,
316        "text/csv" => MediaType::TextCsv,
317        "text/plain" => MediaType::TextPlain,
318        "text/xml" => MediaType::TextXml,
319        "application/openapi+json" => MediaType::OpenApi,
320        "application/x-www-form-urlencoded" => MediaType::UrlEncoded,
321        "application/octet-stream" => MediaType::OctetStream,
322        "*/*" => MediaType::Any,
323        // A plan is a plan *for* something, and that something is a media
324        // type in its own right: read the same way, and named back the same
325        // way. `for="application/vnd.pgrst.object"` is how the client says
326        // "the plan for the query that would answer a singular request", and
327        // it comes back out as `application/vnd.pgrst.object+json` because
328        // that is the name that type has.
329        s if s.starts_with("application/vnd.pgrst.plan") => {
330            let format = match s.ends_with("+json") {
331                true => PlanFormat::Json,
332                false => PlanFormat::Text,
333            };
334            let requested = param("options").unwrap_or_default();
335            // Named in a fixed order rather than the order they were asked
336            // for, so that one set of options has one name.
337            let options = [
338                ("analyze", PlanOption::Analyze),
339                ("verbose", PlanOption::Verbose),
340                ("settings", PlanOption::Settings),
341                ("buffers", PlanOption::Buffers),
342                ("wal", PlanOption::Wal),
343            ]
344            .into_iter()
345            .filter(|(name, _)| requested.split('|').any(|asked| asked == *name))
346            .map(|(_, option)| option)
347            .collect();
348            MediaType::Plan {
349                base: Box::new(parse_media_type(param("for").unwrap_or("application/json"))),
350                format,
351                options,
352            }
353        }
354        s if s.starts_with("application/vnd.pgrst.object") => MediaType::SingularJson {
355            nullable: given("nulls=null"),
356            strip_nulls: given("nulls=stripped"),
357        },
358        s if s.starts_with("application/vnd.pgrst.array") => MediaType::ArrayJson {
359            strip_nulls: given("nulls=stripped"),
360        },
361        other => MediaType::Other(other.to_string()),
362    }
363}
364
365/// Parse Content-Type header.
366fn parse_content_type(headers: &http::HeaderMap) -> Result<MediaType> {
367    if let Some(ct) = headers.get(http::header::CONTENT_TYPE) {
368        let ct_str = ct
369            .to_str()
370            .map_err(|_| Error::InvalidHeader("Content-Type"))?;
371        return Ok(parse_media_type(ct_str.trim()));
372    }
373    Ok(MediaType::ApplicationJson)
374}
375
376/// Parse Range header for pagination.
377///
378/// `0-9`, `10-19` and `10-` are all ranges. Only the first of those was read
379/// before, by matching the literal prefix `0-`; every other range was dropped
380/// on the floor and the request answered with the whole relation. A client
381/// paging with `Range: 1000-1999` was handed all of it and a `Content-Range`
382/// saying so, which is the sort of disagreement a client discovers in
383/// production rather than in a test.
384///
385/// A range that is not a range at all is left alone, as before: hyper accepts
386/// header values this never has to make sense of, and the query parameters
387/// remain the way to page.
388fn parse_range(headers: &http::HeaderMap) -> Result<Range> {
389    let Some(range) = headers.get(http::header::RANGE) else {
390        return Ok(Range::default());
391    };
392    let range_str = range.to_str().map_err(|_| Error::InvalidHeader("Range"))?;
393
394    // PostgREST writes the unit in `Range-Unit` and the bounds bare, but a
395    // client following RFC 9110 puts the unit here. Both name the same rows.
396    let bounds = range_str
397        .split_once('=')
398        .map_or(range_str, |(_unit, bounds)| bounds)
399        .trim();
400
401    let Some((start, end)) = bounds.split_once('-') else {
402        return Ok(Range::default());
403    };
404    let Ok(start) = start.trim().parse::<i64>() else {
405        return Ok(Range::default());
406    };
407    if start < 0 {
408        return Ok(Range::default());
409    }
410
411    let end = end.trim();
412    if end.is_empty() {
413        // Open-ended: from here to the end of the relation.
414        return Ok(Range::new(start, None));
415    }
416    let Ok(end) = end.parse::<i64>() else {
417        return Ok(Range::default());
418    };
419    if end < start {
420        return Err(Error::InvalidRange(
421            "Requested range not satisfiable".into(),
422        ));
423    }
424
425    Ok(Range::from_bounds(start, Some(end)))
426}
427
428/// Extract headers for GUC passthrough.
429fn extract_headers(headers: &http::HeaderMap) -> indexmap::IndexMap<String, String> {
430    headers
431        .iter()
432        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
433        .collect()
434}
435
436/// Extract cookies from Cookie header.
437fn extract_cookies(headers: &http::HeaderMap) -> indexmap::IndexMap<String, String> {
438    headers
439        .get(http::header::COOKIE)
440        .and_then(|v| v.to_str().ok())
441        .map(|s| {
442            s.split(';')
443                .filter_map(|cookie| {
444                    let (key, value) = cookie.trim().split_once('=')?;
445
446                    Some((key.to_string(), value.to_string()))
447                })
448                .collect()
449        })
450        .unwrap_or_default()
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn range_of(value: &str) -> Result<Range> {
458        let mut headers = http::HeaderMap::new();
459        headers.insert(http::header::RANGE, value.parse().unwrap());
460        parse_range(&headers)
461    }
462
463    /// Every range, not only the ones that begin at the first row.
464    #[test]
465    fn a_range_header_names_the_rows_it_says() {
466        assert_eq!(range_of("0-9").unwrap(), Range::from_bounds(0, Some(9)));
467        assert_eq!(range_of("5-9").unwrap(), Range::from_bounds(5, Some(9)));
468        assert_eq!(range_of("10-19").unwrap(), Range::from_bounds(10, Some(19)));
469        // Open-ended: from there to the end of the relation.
470        assert_eq!(range_of("10-").unwrap(), Range::new(10, None));
471        assert_eq!(range_of("0-").unwrap(), Range::new(0, None));
472        // A unit, for a client that follows RFC 9110 rather than PostgREST.
473        assert_eq!(
474            range_of("items=5-9").unwrap(),
475            Range::from_bounds(5, Some(9))
476        );
477    }
478
479    /// A range whose end precedes its start names no rows at all.
480    #[test]
481    fn an_inverted_range_is_refused_rather_than_widened() {
482        assert!(matches!(range_of("9-5"), Err(Error::InvalidRange(_))));
483    }
484
485    /// Anything that is not a range leaves paging to the query parameters,
486    /// rather than being guessed at.
487    #[test]
488    fn a_header_that_is_not_a_range_is_left_alone() {
489        for value in ["", "nonsense", "-5", "a-b", "5"] {
490            assert_eq!(range_of(value).unwrap(), Range::default(), "{value:?}");
491        }
492    }
493
494    /// A plan is named back with everything that made it that plan.
495    ///
496    /// The cases are PostgREST's own doctests for `decodeMediaType`, read
497    /// back out through `toMime`.
498    #[test]
499    fn a_plan_media_type_is_named_back_in_full() {
500        let named = |s: &str| parse_media_type(s).to_mime();
501
502        assert_eq!(
503            named("application/vnd.pgrst.plan+json"),
504            "application/vnd.pgrst.plan+json; for=\"application/json\""
505        );
506        // The `for` type is read as a media type, so it is named by the name
507        // that type has rather than the one the client wrote.
508        assert_eq!(
509            named("application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object\""),
510            "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object+json\""
511        );
512        assert_eq!(
513            named("application/vnd.pgrst.plan; for=\"text/csv\""),
514            "application/vnd.pgrst.plan+text; for=\"text/csv\""
515        );
516        // Options come back in a fixed order, not the order they were asked
517        // for, and anything unrecognised is not an option.
518        assert_eq!(
519            named("application/vnd.pgrst.plan+json; options=verbose|analyze|nonsense"),
520            "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze|verbose"
521        );
522    }
523
524    #[test]
525    fn test_parse_resource() {
526        assert_eq!(parse_resource("/").unwrap(), Resource::Schema);
527        assert_eq!(
528            parse_resource("/users").unwrap(),
529            Resource::Relation("users".into())
530        );
531        assert_eq!(
532            parse_resource("/rpc/my_func").unwrap(),
533            Resource::Routine("my_func".into())
534        );
535    }
536
537    #[test]
538    fn test_parse_media_type() {
539        assert_eq!(
540            parse_media_type("application/json"),
541            MediaType::ApplicationJson
542        );
543        assert_eq!(parse_media_type("text/csv"), MediaType::TextCsv);
544        assert_eq!(parse_media_type("*/*"), MediaType::Any);
545    }
546}