Skip to main content

postrust_core/
error.rs

1//! Error types for Postrust.
2//!
3//! Provides comprehensive error handling with HTTP status code mapping.
4
5use http::StatusCode;
6use thiserror::Error;
7
8/// Result type for Postrust operations.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Main error type for Postrust.
12#[derive(Error, Debug)]
13pub enum Error {
14    // ========================================================================
15    // Request Parsing Errors (4xx)
16    // ========================================================================
17    /// The request path does not name a resource this API serves.
18    #[error("Invalid path: {0}")]
19    InvalidPath(String),
20
21    /// An aggregate was requested while `db-aggregates-enabled` is off.
22    ///
23    /// Off is the default, matching PostgREST: an unrestricted aggregate over
24    /// a large table is an easy way to make a server do far more work than the
25    /// request looks like it asks for.
26    #[error("Use of aggregate functions is not allowed")]
27    AggregatesNotAllowed,
28
29    /// A query parameter this API does not accept, or one whose value is not
30    /// valid for it.
31    #[error("Invalid query parameter: {0}")]
32    InvalidQueryParam(String),
33
34    /// A fragment of the query string the grammar could not read.
35    ///
36    /// Reported the way a parser reports: what was being read, the text as the
37    /// client wrote it, where reading stopped, and what would have been
38    /// accepted there. All four are the client's own input or the API's own
39    /// grammar, so none of it is a disclosure -- and without them the client
40    /// is told only that something in a URL it wrote is wrong.
41    #[error("\"failed to parse {kind} ({text})\" (line 1, column {column})")]
42    UnparsableQuery {
43        /// What was being read: `filter`, `logic tree`, `order`.
44        kind: &'static str,
45        /// The fragment, as it was written.
46        text: String,
47        /// Where reading stopped, counting from one.
48        column: usize,
49        /// What was found there and what would have been accepted.
50        expected: String,
51    },
52
53    /// A request header whose value this server cannot read. Names the header
54    /// only; the value may carry credentials.
55    #[error("Invalid header: {0}")]
56    InvalidHeader(&'static str),
57
58    /// The body did not parse, or does not fit the resource it was sent to.
59    #[error("{0}")]
60    InvalidBody(String),
61
62    /// An HTTP method this API does not implement for any resource.
63    #[error("Unsupported HTTP method: {0}")]
64    UnsupportedMethod(String),
65
66    /// A method a function cannot be called with.
67    ///
68    /// A function is invoked with GET, HEAD or POST. The others are refused
69    /// with a message that says so rather than one about the method in
70    /// general, since the method is fine elsewhere in the API.
71    #[error("Cannot use the {0} method on RPC")]
72    InvalidRpcMethod(String),
73
74    /// A profile header named a schema the server does not expose.
75    ///
76    /// Carries the schemas that *are* exposed, because the client needs them
77    /// to correct the request and they are part of the API's own contract.
78    #[error("Invalid schema: {requested}")]
79    UnacceptableSchema {
80        /// The schema the profile header named.
81        requested: String,
82        /// The schemas that are exposed, so the client can correct itself.
83        exposed: Vec<String>,
84    },
85
86    /// A column the request named that the relation does not have.
87    #[error("Could not find the '{column}' column of '{relation}' in the schema cache")]
88    UnknownColumn {
89        /// The column the request named.
90        column: String,
91        /// The relation it was looked for on.
92        relation: String,
93    },
94
95    /// A `Range` header or `limit`/`offset` pair that cannot be satisfied.
96    #[error("Requested range not satisfiable")]
97    InvalidRange(String),
98
99    /// Content negotiation found nothing both sides accept.
100    #[error("None of these media types are available: {0}")]
101    InvalidMediaType(String),
102
103    /// A function argument the signature requires and the call omitted.
104    #[error("Missing required parameter: {0}")]
105    MissingParameter(String),
106
107    /// The request admits more than one reading and the server will not guess.
108    #[error("Ambiguous request: {0}")]
109    AmbiguousRequest(String),
110
111    /// Several signatures of a function fit the arguments equally well.
112    #[error("Could not choose the best candidate function between: {}", .candidates.join(", "))]
113    AmbiguousFunction {
114        /// The signatures that fit, as the client would have to distinguish
115        /// them.
116        candidates: Vec<String>,
117    },
118
119    /// Several relationships connect the two resources and none was named.
120    #[error("Could not embed because more than one relationship was found for '{origin}' and '{target}'")]
121    AmbiguousRelationship {
122        /// The resource being embedded from.
123        origin: String,
124        /// The resource being embedded.
125        target: String,
126        /// Each candidate as `(cardinality, description)`.
127        candidates: Vec<(String, String)>,
128        /// How the client would name each candidate: `big_projects!jobs`.
129        disambiguated: Vec<String>,
130    },
131
132    // ========================================================================
133    // Authentication/Authorization Errors (401/403)
134    // ========================================================================
135    /// The token could not be decoded or its signature did not verify.
136    ///
137    /// Distinct from a claims error: nothing about the token was readable, so
138    /// there is nothing to say about what it claimed.
139    #[error("{0}")]
140    InvalidJwt(String),
141
142    /// No key this server holds could decode the token.
143    ///
144    /// Its own code would be indistinguishable from any other decode failure,
145    /// so what separates it is the detail: the client is being told to look at
146    /// which key signed the token, not at the token's contents.
147    #[error("No suitable key or wrong key type")]
148    NoSuitableJwtKey,
149
150    /// The server is configured to read tokens and has nothing to read them
151    /// with. A fault in the deployment, not in the request.
152    #[error("Server lacks JWT secret")]
153    JwtSecretMissing,
154
155    /// The token decoded, but a claim was missing, malformed or unsatisfied.
156    #[error("{0}")]
157    JwtClaim(String),
158
159    /// Nothing said who is asking, and no anonymous role is configured to be.
160    #[error("Anonymous access is disabled")]
161    MissingAuth,
162
163    /// The caller is known and the role it resolved to may not do this.
164    #[error("Insufficient permissions: {0}")]
165    InsufficientPermissions(String),
166
167    // ========================================================================
168    // Resource Errors (404)
169    // ========================================================================
170    /// Nothing at this path, where no more specific variant applies.
171    #[error("Resource not found: {0}")]
172    NotFound(String),
173
174    /// No table, view or foreign table of this name in the exposed schema.
175    #[error("Table not found: {name}")]
176    TableNotFound {
177        /// The name the request asked for, schema-qualified.
178        name: String,
179        /// A table of a very similar name, where the schema has one.
180        suggestion: Option<String>,
181    },
182
183    /// No function of that name accepts the arguments supplied.
184    ///
185    /// Carries what was looked for so the client can see why nothing matched:
186    /// the name, the argument names it was called with, and the signature of
187    /// an overload that does exist, where one does.
188    #[error("Could not find the function {name}")]
189    FunctionNotFound {
190        /// The function's qualified name, without arguments.
191        name: String,
192        /// The argument names it was called with.
193        params: Vec<String>,
194        /// A signature that does exist, where one does.
195        candidate: Option<String>,
196        /// The type of a single unnamed parameter that would also have
197        /// matched, spelled as the message spells it.
198        ///
199        /// Over `POST` the whole body can be one argument, and which type it
200        /// could be is decided by the body's media type: `json/jsonb` for a
201        /// JSON body, `text`, `xml` or `bytea` for the others. Naming it says
202        /// what else was looked for besides the arguments themselves.
203        single_param: Option<String>,
204    },
205
206    /// A `RAISE sqlstate 'PGRST'` the schema wrote wrongly.
207    ///
208    /// The message and the detail are both JSON documents with required keys,
209    /// and a function that gets one wrong has not asked for a response -- it
210    /// has a bug. Reporting it as the 500 it is says so; applying half of what
211    /// it asked for would not.
212    #[error("Could not parse JSON in the \"RAISE SQLSTATE 'PGRST'\" error")]
213    RaiseNotUnderstood(RaiseFault),
214
215    /// `Accept: application/geo+json` on rows that carry no geometry.
216    ///
217    /// PostGIS raises this from `ST_AsGeoJSON` on a record with no geometry
218    /// column, and it is a database error rather than one of PostgREST's own
219    /// -- so it keeps the SQLSTATE it came with.
220    #[error("geometry column is missing")]
221    MissingGeometryColumn,
222
223    /// A column named in the request that the relation does not have. Prefer
224    /// [`Error::UnknownColumn`], which also says which relation was searched.
225    #[error("Column not found: {0}")]
226    ColumnNotFound(String),
227
228    /// Nothing connects the two resources, so the embed cannot be planned.
229    #[error("Could not find a relationship between '{origin}' and '{target}' in the schema cache")]
230    RelationshipNotFound {
231        /// The table the embedding started from.
232        origin: String,
233        /// The resource the request asked to embed.
234        target: String,
235        /// The `!hint` that was meant to identify it, if any.
236        hint: Option<String>,
237        /// The schema that was searched.
238        schema: String,
239        /// A relationship of a very similar name, where there is one.
240        suggestion: Option<String>,
241    },
242
243    /// A filter or order names something the request never embedded.
244    ///
245    /// `?non_existent.name=like.*x*` is not a column with a dot in it, and
246    /// answering it as though the prefix were meaningless would silently
247    /// return the unfiltered table.
248    #[error("'{0}' is not an embedded resource in this request")]
249    NotAnEmbeddedResource(String),
250
251    /// Ordering by a column of a resource that yields many rows per parent.
252    ///
253    /// There is no single value to order on: the parent has a list.
254    #[error("A related order on '{relation}' is not possible")]
255    RelatedOrderNotPossible {
256        /// The table the request started from.
257        origin: String,
258        /// The embedded resource the order named.
259        relation: String,
260    },
261
262    /// A single object was asked for and the result is not one.
263    ///
264    /// `Accept: application/vnd.pgrst.object+json` says the client will accept
265    /// exactly one row; anything else is a negotiation failure rather than a
266    /// missing resource, which is why it is 406 and not 404.
267    #[error("Cannot coerce the result to a single JSON object")]
268    NotSingular {
269        /// How many rows the query actually returned.
270        rows: usize,
271    },
272
273    /// A path with more segments than any resource has.
274    #[error("Invalid path specified in request URL")]
275    InvalidResourcePath,
276
277    /// A `PUT` whose filters do not name exactly one row by its key.
278    ///
279    /// `PUT` writes the row the URL names, so the URL has to name exactly one:
280    /// every primary key column, with `eq`, and nothing else.
281    #[error("Filters must include all and only primary key columns with 'eq' operators")]
282    InvalidPutFilters,
283
284    /// A `PUT` carrying a page.
285    #[error("limit/offset querystring parameters are not allowed for PUT")]
286    PutLimitNotAllowed,
287
288    /// A `PUT` whose body names a different row from its URL.
289    #[error("Payload values do not match URL in primary key column(s)")]
290    PutMatchingPk,
291
292    /// A function set `response.headers` to something that is not headers.
293    #[error(
294        "response.headers guc must be a JSON array composed of objects with a single key and a \
295         string value"
296    )]
297    InvalidGucHeaders,
298
299    /// A function set `response.status` to something that is not a status.
300    #[error("response.status guc must be a valid status code")]
301    InvalidGucStatus,
302
303    /// A mutation touched more rows than `Prefer: max-affected` allowed.
304    ///
305    /// The preference is a guard against a filter that turned out to match
306    /// more than the client meant; nothing is committed when it trips.
307    #[error("Query result exceeds max-affected preference constraint")]
308    MaxAffectedExceeded(i64),
309
310    /// Preferences the server does not know, sent with `handling=strict`.
311    ///
312    /// Strict handling is a request to be told rather than have them ignored.
313    #[error("Invalid preferences given with handling=strict")]
314    InvalidPreferences(Vec<String>),
315
316    // ========================================================================
317    // Schema Cache Errors
318    // ========================================================================
319    /// A request arrived before the schema had been read. Transient at
320    /// start-up; persistent means the initial load failed.
321    #[error("Schema cache not loaded")]
322    SchemaCacheNotLoaded,
323
324    /// Reading the schema from PostgreSQL failed. Usually a missing privilege
325    /// on the catalogue rather than a fault in the schema itself.
326    #[error("Schema cache load failed: {0}")]
327    SchemaCacheLoadFailed(String),
328
329    // ========================================================================
330    // Database Errors (500/4xx depending on type)
331    // ========================================================================
332    /// PostgreSQL refused the statement. The SQLSTATE decides the HTTP status,
333    /// so this is not always a 500.
334    #[error("Database error: {0}")]
335    Database(#[from] DatabaseError),
336
337    /// No connection could be obtained: the pool is exhausted, or the database
338    /// is unreachable.
339    #[error("Connection pool error: {0}")]
340    ConnectionPool(String),
341
342    // ========================================================================
343    // Internal Errors (500)
344    // ========================================================================
345    /// A fault in this server. Always a bug; the message is for its log rather
346    /// than for the client to act on.
347    #[error("Internal error: {0}")]
348    Internal(String),
349
350    /// The server was started with a setting it cannot act on.
351    #[error("Configuration error: {0}")]
352    Config(String),
353
354    // ========================================================================
355    // Plan Errors
356    // ========================================================================
357    /// The request parsed and no execution plan could be built from it.
358    #[error("Invalid plan: {0}")]
359    InvalidPlan(String),
360
361    /// An embed that cannot be planned, where no more specific variant fits.
362    #[error("Embedding error: {0}")]
363    EmbeddingError(String),
364}
365
366impl Error {
367    /// Get the HTTP status code for this error.
368    pub fn status_code(&self) -> StatusCode {
369        match self {
370            // 400 Bad Request
371            Self::InvalidPath(_)
372            | Self::InvalidQueryParam(_)
373            | Self::InvalidHeader(_)
374            | Self::InvalidBody(_)
375            | Self::MissingParameter(_)
376            | Self::AmbiguousRequest(_)
377            | Self::UnparsableQuery { .. }
378            | Self::UnknownColumn { .. }
379            | Self::NotAnEmbeddedResource(_)
380            | Self::RelatedOrderNotPossible { .. }
381            | Self::InvalidPreferences(_)
382            | Self::PutLimitNotAllowed
383            | Self::PutMatchingPk
384            | Self::MaxAffectedExceeded(_)
385            | Self::InvalidPlan(_)
386            | Self::EmbeddingError(_)
387            | Self::AggregatesNotAllowed
388            | Self::MissingGeometryColumn => StatusCode::BAD_REQUEST,
389
390            // The request names something real, but not which one of it.
391            Self::AmbiguousFunction { .. } | Self::AmbiguousRelationship { .. } => {
392                StatusCode::MULTIPLE_CHOICES
393            }
394
395            // 401 Unauthorized
396            Self::InvalidJwt(_)
397            | Self::NoSuitableJwtKey
398            | Self::JwtClaim(_)
399            | Self::MissingAuth => StatusCode::UNAUTHORIZED,
400
401            // 403 Forbidden
402            Self::InsufficientPermissions(_) => StatusCode::FORBIDDEN,
403
404            // 404 Not Found
405            Self::NotFound(_) | Self::TableNotFound { .. } | Self::FunctionNotFound { .. } => {
406                StatusCode::NOT_FOUND
407            }
408
409            // A relationship or a column the schema does not have is a fault
410            // in the request, not a missing resource: the resource addressed
411            // by the URL exists, and `?select=` asked it for something it
412            // cannot give. The table it names is what a 404 would be about.
413            Self::ColumnNotFound(_) | Self::RelationshipNotFound { .. } => StatusCode::BAD_REQUEST,
414
415            // 405 Method Not Allowed
416            Self::UnsupportedMethod(_) | Self::InvalidRpcMethod(_) | Self::InvalidPutFilters => {
417                StatusCode::METHOD_NOT_ALLOWED
418            }
419
420            // 406 Not Acceptable
421            Self::UnacceptableSchema { .. }
422            | Self::NotSingular { .. }
423            | Self::InvalidMediaType(_) => StatusCode::NOT_ACCEPTABLE,
424
425            Self::InvalidResourcePath => StatusCode::NOT_FOUND,
426
427            // A range the server cannot satisfy, which is what 416 is for --
428            // a negative limit asks for a window that does not exist rather
429            // than for one the server merely declines.
430            Self::InvalidRange(_) => StatusCode::RANGE_NOT_SATISFIABLE,
431
432            // 500 Internal Server Error
433            Self::JwtSecretMissing
434            | Self::RaiseNotUnderstood(_)
435            | Self::InvalidGucHeaders
436            | Self::InvalidGucStatus
437            | Self::SchemaCacheNotLoaded
438            | Self::SchemaCacheLoadFailed(_)
439            | Self::ConnectionPool(_)
440            | Self::Internal(_)
441            | Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
442
443            // Database errors map based on type
444            Self::Database(db_err) => db_err.status_code(),
445        }
446    }
447
448    /// Get the error code for API responses.
449    pub fn code(&self) -> &'static str {
450        match self {
451            Self::InvalidPath(_) => "PGRST100",
452            Self::AggregatesNotAllowed => "PGRST123",
453            // PGRST100 is the parse failure in PostgREST's taxonomy, covering
454            // both the path and the query string; PGRST101 is a function
455            // invoked with a method its volatility does not allow.
456            Self::InvalidQueryParam(_) | Self::UnparsableQuery { .. } => "PGRST100",
457            // Not one of PostgREST's own: a malformed header is a request
458            // that did not parse, which is what PGRST100 covers.
459            Self::InvalidHeader(_) => "PGRST100",
460            Self::InvalidBody(_) => "PGRST102",
461            Self::UnsupportedMethod(_) => "PGRST117",
462            Self::InvalidRpcMethod(_) => "PGRST101",
463            Self::UnacceptableSchema { .. } => "PGRST106",
464            Self::UnknownColumn { .. } => "PGRST204",
465            Self::InvalidRange(_) => "PGRST103",
466            Self::InvalidMediaType(_) => "PGRST107",
467            Self::MissingParameter(_) => "PGRST109",
468            Self::AmbiguousRequest(_) => "PGRST110",
469            Self::AmbiguousFunction { .. } => "PGRST203",
470            Self::AmbiguousRelationship { .. } => "PGRST201",
471
472            Self::InvalidJwt(_) | Self::NoSuitableJwtKey => "PGRST301",
473            Self::JwtSecretMissing => "PGRST300",
474            Self::JwtClaim(_) => "PGRST303",
475            Self::MissingAuth => "PGRST302",
476            Self::InsufficientPermissions(_) => "PGRST203",
477
478            Self::NotFound(_) => "PGRST205",
479            Self::TableNotFound { .. } => "PGRST205",
480            Self::FunctionNotFound { .. } => "PGRST202",
481            Self::MissingGeometryColumn => "22023",
482            Self::ColumnNotFound(_) => "PGRST204",
483            Self::RelationshipNotFound { .. } => "PGRST200",
484            Self::NotAnEmbeddedResource(_) => "PGRST108",
485            Self::NotSingular { .. } => "PGRST116",
486            Self::InvalidResourcePath => "PGRST125",
487            Self::InvalidPutFilters => "PGRST105",
488            Self::PutLimitNotAllowed => "PGRST114",
489            Self::PutMatchingPk => "PGRST115",
490            Self::MaxAffectedExceeded(_) => "PGRST124",
491            Self::RaiseNotUnderstood(_) => "PGRST121",
492            Self::InvalidGucHeaders => "PGRST111",
493            Self::InvalidGucStatus => "PGRST112",
494            Self::RelatedOrderNotPossible { .. } => "PGRST118",
495            Self::InvalidPreferences(_) => "PGRST122",
496
497            Self::SchemaCacheNotLoaded => "PGRST400",
498            Self::SchemaCacheLoadFailed(_) => "PGRST401",
499
500            Self::Database(e) => e.code(),
501            Self::ConnectionPool(_) => "PGRST500",
502
503            Self::Internal(_) => "PGRST900",
504            Self::Config(_) => "PGRST901",
505
506            Self::InvalidPlan(_) => "PGRST600",
507            Self::EmbeddingError(_) => "PGRST601",
508        }
509    }
510
511    /// Convert to JSON error response.
512    pub fn to_json(&self) -> serde_json::Value {
513        serde_json::json!({
514            "code": self.code(),
515            "message": self.to_string(),
516            "details": self.details(),
517            "hint": self.hint(),
518        })
519    }
520
521    /// Get additional details for the error.
522    pub fn details(&self) -> Option<serde_json::Value> {
523        match self {
524            Self::Database(db_err) => db_err.details.clone().map(serde_json::Value::String),
525            // A structured list rather than a sentence: each candidate is
526            // named so the client can pick one and say which.
527            Self::AmbiguousRelationship {
528                origin,
529                target,
530                candidates,
531                ..
532            } => Some(serde_json::Value::Array(
533                candidates
534                    .iter()
535                    .map(|(cardinality, description)| {
536                        serde_json::json!({
537                            "cardinality": cardinality,
538                            "embedding": format!("{} with {}", origin, target),
539                            "relationship": description,
540                        })
541                    })
542                    .collect(),
543            )),
544            Self::RelationshipNotFound {
545                origin,
546                target,
547                hint,
548                schema,
549                ..
550            } => Some(serde_json::Value::String(format!(
551                "Searched for a foreign key relationship between '{}' and '{}'{} in the schema \
552                 '{}', but no matches were found.",
553                origin,
554                target,
555                match hint {
556                    Some(hint) => format!(" using the hint '{}'", hint),
557                    None => String::new(),
558                },
559                schema
560            ))),
561            Self::RelatedOrderNotPossible { origin, relation } => {
562                Some(serde_json::Value::String(format!(
563                    "'{}' and '{}' do not form a many-to-one or one-to-one relationship",
564                    origin, relation
565                )))
566            }
567            Self::InvalidRange(reason) => Some(serde_json::Value::String(reason.clone())),
568            Self::MaxAffectedExceeded(affected) => Some(serde_json::Value::String(format!(
569                "The query affects {} rows",
570                affected
571            ))),
572            Self::NotSingular { rows } => Some(serde_json::Value::String(format!(
573                "The result contains {} rows",
574                rows
575            ))),
576            Self::InvalidPreferences(invalid) => Some(serde_json::Value::String(format!(
577                "Invalid preferences: {}",
578                invalid.join(", ")
579            ))),
580            Self::RaiseNotUnderstood(fault) => Some(serde_json::Value::String(fault.details())),
581            Self::NoSuitableJwtKey => Some(serde_json::Value::String(
582                "No suitable key was found to decode the JWT".into(),
583            )),
584            Self::UnparsableQuery { expected, .. } => {
585                Some(serde_json::Value::String(expected.clone()))
586            }
587            Self::FunctionNotFound {
588                name,
589                params,
590                single_param,
591                ..
592            } => Some(serde_json::Value::String(format!(
593                "Searched for the function {} {}, but no matches were found in the schema cache.",
594                name,
595                match single_param.as_deref() {
596                    // A body that is one value carries no argument names, so
597                    // the only thing that could have matched is the parameter
598                    // that takes it.
599                    Some(other) if other != JSON_PARAM =>
600                        format!("with a single unnamed {} parameter", other),
601                    Some(_) => format!(
602                        "{} or with a single unnamed {} parameter",
603                        describe_params(params),
604                        JSON_PARAM
605                    ),
606                    None => describe_params(params),
607                }
608            ))),
609            _ => None,
610        }
611    }
612
613    /// Get a hint for resolving the error.
614    pub fn hint(&self) -> Option<String> {
615        match self {
616            Self::NotAnEmbeddedResource(name) => Some(format!(
617                "Verify that '{}' is included in the 'select' query parameter.",
618                name
619            )),
620            Self::TableNotFound { suggestion, .. } => suggestion
621                .as_ref()
622                .map(|name| format!("Perhaps you meant the table '{}'", name)),
623            Self::RelationshipNotFound {
624                target, suggestion, ..
625            } => suggestion
626                .as_ref()
627                .map(|name| format!("Perhaps you meant '{}' instead of '{}'.", name, target)),
628            Self::AmbiguousFunction { .. } => Some(
629                "Try renaming the parameters or the function itself in the database so function \
630                 overloading can be resolved"
631                    .into(),
632            ),
633            Self::AmbiguousRelationship {
634                target,
635                disambiguated,
636                ..
637            } => Some(format!(
638                "Try changing '{}' to one of the following: {}. Find the desired relationship in \
639                 the 'details' key.",
640                target,
641                disambiguated
642                    .iter()
643                    .map(|name| format!("'{}'", name))
644                    .collect::<Vec<_>>()
645                    .join(", ")
646            )),
647            Self::RaiseNotUnderstood(fault) => Some(fault.hint().into()),
648            // A body that is one value names no arguments, so there is no
649            // near miss to point at: any function of that name would have
650            // done, and none of them takes this body.
651            Self::FunctionNotFound {
652                candidate,
653                single_param,
654                ..
655            } if !names_only_one_param(single_param.as_deref()) => candidate
656                .as_ref()
657                .map(|c| format!("Perhaps you meant to call the function {}", c)),
658            Self::FunctionNotFound { .. } => None,
659            // The schemas a server exposes are part of its contract, not an
660            // internal detail, so naming them is how the client is told what
661            // to ask for instead.
662            Self::UnacceptableSchema { exposed, .. } => Some(format!(
663                "Only the following schemas are exposed: {}",
664                exposed.join(", ")
665            )),
666            Self::Database(db_err) => db_err.hint.clone(),
667            _ => None,
668        }
669    }
670}
671
672/// How the message spells the type of a single unnamed JSON parameter.
673///
674/// It is the one media type that can also carry named arguments, so it reads
675/// as an alternative rather than as the only thing looked for.
676pub const JSON_PARAM: &str = "json/jsonb";
677
678/// Whether the request could only ever have matched one unnamed parameter.
679///
680/// True for a body that is one value -- text, xml or bytes -- which carries no
681/// argument names at all.
682pub fn names_only_one_param(single_param: Option<&str>) -> bool {
683    matches!(single_param, Some(other) if other != JSON_PARAM)
684}
685
686/// How a function's argument list reads in an error message.
687///
688/// PostgREST words the no-argument case differently rather than printing an
689/// empty list, and the messages are matched verbatim by clients.
690pub fn function_signature(name: &str, params: &[String]) -> String {
691    match params.is_empty() {
692        true => format!("{} without parameters", name),
693        false => format!("{}({})", name, params.join(", ")),
694    }
695}
696
697fn describe_params(params: &[String]) -> String {
698    match params.len() {
699        0 => "without parameters".to_string(),
700        1 => format!("with parameter {}", params[0]),
701        _ => format!("with parameters {}", params.join(", ")),
702    }
703}
704
705/// Database-specific error type.
706#[derive(Error, Debug)]
707#[error("Database error [{code}]: {message}")]
708pub struct DatabaseError {
709    /// The five-character SQLSTATE. This decides the HTTP status, so it is the
710    /// field that matters most.
711    pub code: String,
712    /// PostgreSQL's primary message.
713    pub message: String,
714    /// PostgreSQL's detail field, where it sent one.
715    pub details: Option<String>,
716    /// PostgreSQL's hint field, where it sent one.
717    pub hint: Option<String>,
718    /// The constraint that was violated, for the integrity SQLSTATEs.
719    pub constraint: Option<String>,
720    /// The table the failure concerns, where PostgreSQL identified one.
721    pub table: Option<String>,
722    /// The column the failure concerns, where PostgreSQL identified one.
723    pub column: Option<String>,
724}
725
726/// What was wrong with a `RAISE sqlstate 'PGRST'`.
727#[derive(Clone, Debug)]
728pub enum RaiseFault {
729    /// The MESSAGE was not the JSON object it has to be.
730    Message(String),
731    /// The DETAIL was not the JSON object it has to be.
732    Detail(String),
733    /// There was no DETAIL, and the status has to come from somewhere.
734    NoDetail,
735}
736
737impl RaiseFault {
738    /// What the schema author has to look at.
739    pub fn details(&self) -> String {
740        match self {
741            Self::Message(raw) => format!("Invalid JSON value for MESSAGE: '{}'", raw),
742            Self::Detail(raw) => format!("Invalid JSON value for DETAIL: '{}'", raw),
743            Self::NoDetail => "DETAIL is missing in the RAISE statement".to_string(),
744        }
745    }
746
747    /// What the document should have contained.
748    pub fn hint(&self) -> &'static str {
749        match self {
750            Self::Message(_) => {
751                "MESSAGE must be a JSON object with obligatory keys: 'code', 'message' and \
752                 optional keys: 'details', 'hint'."
753            }
754            _ => {
755                "DETAIL must be a JSON object with obligatory keys: 'status', 'headers' and \
756                 optional key: 'status_text'."
757            }
758        }
759    }
760}
761
762/// A response a database function asked for outright.
763#[derive(Clone, Debug)]
764pub struct RaisedResponse {
765    /// The HTTP status to answer with.
766    pub status: u16,
767    /// The reason phrase, where the schema named one of its own.
768    pub status_text: Option<String>,
769    /// Headers to add to the response.
770    pub headers: Vec<(String, String)>,
771    /// The response body, verbatim.
772    pub body: serde_json::Value,
773}
774
775impl DatabaseError {
776    /// The response a `RAISE sqlstate 'PGRST'` asked for.
777    ///
778    /// A function can take over the whole response by raising that SQLSTATE:
779    /// the message is the body, and the detail carries the status, an optional
780    /// status text and any headers. It is how a schema returns a 402 with its
781    /// own payload without the API layer knowing anything about billing.
782    ///
783    /// `None` when this is not that error. `Err` when it is and the schema
784    /// wrote it wrongly, which is a bug in the schema rather than a response
785    /// to half-apply.
786    pub fn raised_response(&self) -> Option<std::result::Result<RaisedResponse, RaiseFault>> {
787        if self.code != "PGRST" {
788            return None;
789        }
790        Some(self.parse_raise())
791    }
792
793    fn parse_raise(&self) -> std::result::Result<RaisedResponse, RaiseFault> {
794        let object = |raw: &str| -> Option<serde_json::Map<String, serde_json::Value>> {
795            match serde_json::from_str(raw) {
796                Ok(serde_json::Value::Object(map)) => Some(map),
797                _ => None,
798            }
799        };
800
801        // The message is the body, and a body has to say what went wrong and
802        // under what code -- the two keys a client reads first.
803        let message =
804            object(&self.message).ok_or_else(|| RaiseFault::Message(self.message.clone()))?;
805        if !message.contains_key("code") || !message.contains_key("message") {
806            return Err(RaiseFault::Message(self.message.clone()));
807        }
808
809        let raw_detail = self.details.as_deref().ok_or(RaiseFault::NoDetail)?;
810        let detail =
811            object(raw_detail).ok_or_else(|| RaiseFault::Detail(raw_detail.to_string()))?;
812        let status = detail
813            .get("status")
814            .and_then(serde_json::Value::as_u64)
815            .ok_or_else(|| RaiseFault::Detail(raw_detail.to_string()))?;
816        let headers = detail
817            .get("headers")
818            .and_then(serde_json::Value::as_object)
819            .ok_or_else(|| RaiseFault::Detail(raw_detail.to_string()))?;
820
821        // Every key the error body has, present whether the schema wrote it or
822        // not: a client reading `details` should not have to tell "no detail"
823        // from "no such key".
824        let key = |name: &str| {
825            message
826                .get(name)
827                .cloned()
828                .unwrap_or(serde_json::Value::Null)
829        };
830
831        Ok(RaisedResponse {
832            status: status as u16,
833            status_text: detail
834                .get("status_text")
835                .and_then(serde_json::Value::as_str)
836                .map(str::to_string),
837            headers: headers
838                .iter()
839                .filter_map(|(name, value)| Some((name.clone(), value.as_str()?.to_string())))
840                .collect(),
841            body: serde_json::json!({
842                "code": key("code"),
843                "message": key("message"),
844                "details": key("details"),
845                "hint": key("hint"),
846            }),
847        })
848    }
849
850    /// The HTTP status a PostgreSQL error code maps to.
851    ///
852    /// PostgREST's table (`mapSQLtoHTTP` in `Error.hs`), which is part of its
853    /// contract rather than an implementation detail: a client distinguishes a
854    /// missing function from a bad argument by the status.
855    ///
856    /// The default is 400, not 500: the great majority of SQLSTATEs a request
857    /// can provoke are provoked by the request. Reporting them as server
858    /// errors tells the client to retry something that will never succeed.
859    pub fn status_code(&self) -> StatusCode {
860        let code = self.code.as_str();
861        let message = self.message.as_str();
862
863        match self.raised_response() {
864            Some(Ok(raised)) => {
865                if let Ok(status) = StatusCode::from_u16(raised.status) {
866                    return status;
867                }
868            }
869            // A raise the schema wrote wrongly is the schema's fault, and a
870            // fault in the server's own data is a 500.
871            Some(Err(_)) => return StatusCode::INTERNAL_SERVER_ERROR,
872            None => {}
873        }
874
875        // `PT<nnn>` is a status code raised by the schema itself: a function
876        // says `RAISE sqlstate 'PT402'` to answer 402, with the message as the
877        // status text.
878        if let Some(digits) = code.strip_prefix("PT") {
879            if let Ok(status) = digits.parse::<u16>() {
880                if let Ok(status) = StatusCode::from_u16(status) {
881                    return status;
882                }
883            }
884            return StatusCode::INTERNAL_SERVER_ERROR;
885        }
886
887        match code {
888            "23503" | "23505" => StatusCode::CONFLICT,
889            // A write attempted inside the read-only transaction a read
890            // request runs in. The request was not wrong, the method was.
891            "25006" => StatusCode::METHOD_NOT_ALLOWED,
892            // Cardinality violation. `pg-safeupdate` raises it for an
893            // unqualified UPDATE or DELETE, which is the client's doing;
894            // anything else is a function or view misbehaving.
895            "21000" => match message.ends_with("requires a WHERE clause") {
896                true => StatusCode::BAD_REQUEST,
897                false => StatusCode::INTERNAL_SERVER_ERROR,
898            },
899            // Invalid parameter value, which is also how PostgreSQL reports a
900            // `SET ROLE` to a role that does not exist.
901            "22023" => match message.starts_with("role") && message.ends_with("does not exist") {
902                true => StatusCode::UNAUTHORIZED,
903                false => StatusCode::BAD_REQUEST,
904            },
905            "53400" => StatusCode::INTERNAL_SERVER_ERROR,
906            "57P01" => StatusCode::SERVICE_UNAVAILABLE,
907            "P0001" => StatusCode::BAD_REQUEST,
908            // `xmlagg` is missing only when the request asked for XML the
909            // server cannot produce, which is a negotiation failure.
910            "42883" => match message.starts_with("function xmlagg(") {
911                true => StatusCode::NOT_ACCEPTABLE,
912                false => StatusCode::NOT_FOUND,
913            },
914            "42P01" => StatusCode::NOT_FOUND,
915            "42P17" => StatusCode::INTERNAL_SERVER_ERROR,
916            // Without authentication the client may simply not have said who
917            // it is; with it, it has and is still not permitted.
918            "42501" => StatusCode::UNAUTHORIZED,
919            _ => match code.as_bytes() {
920                [b'0', b'8', ..] => StatusCode::SERVICE_UNAVAILABLE,
921                [b'0', b'9', ..] => StatusCode::INTERNAL_SERVER_ERROR,
922                [b'0', b'L', ..] | [b'0', b'P', ..] => StatusCode::FORBIDDEN,
923                [b'2', b'5', ..] => StatusCode::INTERNAL_SERVER_ERROR,
924                [b'2', b'8', ..] => StatusCode::FORBIDDEN,
925                [b'2', b'D', ..] => StatusCode::INTERNAL_SERVER_ERROR,
926                [b'3', b'8', ..] | [b'3', b'9', ..] | [b'3', b'B', ..] => {
927                    StatusCode::INTERNAL_SERVER_ERROR
928                }
929                [b'4', b'0', ..] => StatusCode::INTERNAL_SERVER_ERROR,
930                [b'5', b'3', ..] => StatusCode::SERVICE_UNAVAILABLE,
931                [b'5', b'4', ..] | [b'5', b'5', ..] | [b'5', b'7', ..] | [b'5', b'8', ..] => {
932                    StatusCode::INTERNAL_SERVER_ERROR
933                }
934                [b'F', b'0', ..] | [b'H', b'V', ..] => StatusCode::INTERNAL_SERVER_ERROR,
935                [b'P', b'0', ..] => StatusCode::INTERNAL_SERVER_ERROR,
936                [b'X', b'X', ..] => StatusCode::INTERNAL_SERVER_ERROR,
937                _ => StatusCode::BAD_REQUEST,
938            },
939        }
940    }
941
942    /// Get error code for API response.
943    pub fn code(&self) -> &'static str {
944        match self.code.as_str() {
945            c if c.starts_with("23") => "PGRST503", // Constraint violation
946            c if c.starts_with("42") => "PGRST504", // SQL error
947            c if c.starts_with("28") => "PGRST505", // Auth error
948            _ => "PGRST500",                        // Generic database error
949        }
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    #[test]
958    fn test_error_status_codes() {
959        assert_eq!(
960            Error::InvalidQueryParam("test".into()).status_code(),
961            StatusCode::BAD_REQUEST
962        );
963        assert_eq!(Error::MissingAuth.status_code(), StatusCode::UNAUTHORIZED);
964        assert_eq!(
965            Error::TableNotFound {
966                name: "users".into(),
967                suggestion: None
968            }
969            .status_code(),
970            StatusCode::NOT_FOUND
971        );
972        assert_eq!(
973            Error::UnsupportedMethod("TRACE".into()).status_code(),
974            StatusCode::METHOD_NOT_ALLOWED
975        );
976    }
977
978    #[test]
979    fn test_error_codes() {
980        // A malformed query string is a parse failure, PGRST100 -- the same
981        // code as a malformed path. PGRST101 means something else entirely.
982        assert_eq!(Error::InvalidQueryParam("test".into()).code(), "PGRST100");
983        assert_eq!(Error::MissingAuth.code(), "PGRST302");
984        assert_eq!(
985            Error::TableNotFound {
986                name: "users".into(),
987                suggestion: None
988            }
989            .code(),
990            "PGRST205"
991        );
992    }
993
994    #[test]
995    fn test_database_error_status() {
996        let constraint_error = DatabaseError {
997            code: "23505".into(), // unique_violation
998            message: "Duplicate key".into(),
999            details: None,
1000            hint: None,
1001            constraint: Some("users_pkey".into()),
1002            table: Some("users".into()),
1003            column: None,
1004        };
1005        assert_eq!(constraint_error.status_code(), StatusCode::CONFLICT);
1006    }
1007
1008    #[test]
1009    fn test_error_to_json() {
1010        let error = Error::InvalidQueryParam("bad filter".into());
1011        let json = error.to_json();
1012        assert_eq!(json["code"], "PGRST100");
1013        assert!(json["message"].as_str().unwrap().contains("bad filter"));
1014    }
1015}