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