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}