├── .gitignore ├── http-api-problem-derive ├── README.md ├── Cargo.toml └── src │ └── lib.rs ├── examples └── additional_fields.rs ├── LICENSE-MIT ├── src ├── test.rs ├── api_error.rs └── lib.rs ├── .github └── workflows │ └── CI.yml ├── Cargo.toml ├── README.md ├── CHANGELOG.md └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/*.rs.bk 3 | Cargo.lock 4 | .vscode 5 | -------------------------------------------------------------------------------- /http-api-problem-derive/README.md: -------------------------------------------------------------------------------- 1 | Support crate for HTTP-API-PROBLEM -------------------------------------------------------------------------------- /http-api-problem-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "http-api-problem-derive" 3 | version = "0.1.0" 4 | description = "A support crate for HTTP-API-PROBLEM" 5 | license = "Apache-2.0/MIT" 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | proc-macro = true 12 | 13 | [dependencies] 14 | derive_utils = "0.12" 15 | -------------------------------------------------------------------------------- /http-api-problem-derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | use derive_utils::quick_derive; 2 | use proc_macro::TokenStream; 3 | 4 | #[proc_macro_derive(IntoApiError)] 5 | pub fn derive_iterator(input: TokenStream) -> TokenStream { 6 | quick_derive! { 7 | input, 8 | // trait path 9 | ::http_api_problem::IntoApiError, 10 | // trait definition 11 | pub trait IntoApiError { 12 | fn into_api_error(self) -> ::http_api_problem::ApiError; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/additional_fields.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use http_api_problem::*; 4 | 5 | #[derive(Serialize, Deserialize)] 6 | struct Person { 7 | name: String, 8 | age: u8, 9 | } 10 | 11 | fn main() { 12 | let problem = HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR) 13 | .value("error", &"this sucks") 14 | .value("everything", &42) 15 | .value( 16 | "person", 17 | &Person { 18 | name: "Peter".into(), 19 | age: 77, 20 | }, 21 | ); 22 | 23 | let json = problem.json_string(); 24 | 25 | println!("{}", json); 26 | 27 | let parsed: HttpApiProblem = serde_json::from_str(&json).unwrap(); 28 | 29 | println!("\n\n{:#?}", parsed); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Christian Douven 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | mod serialization { 2 | use crate::HttpApiProblem; 3 | use http::StatusCode; 4 | use serde_json::{self, json}; 5 | 6 | #[test] 7 | fn should_serialize_status_present_correctly() { 8 | let prob = HttpApiProblem::with_title(StatusCode::NOT_FOUND); 9 | 10 | let sample = serde_json::to_value(prob).unwrap(); 11 | let expected = json!({ 12 | "title": "Not Found", 13 | "status": 404 14 | }); 15 | 16 | assert_eq!(sample, expected); 17 | } 18 | 19 | #[test] 20 | fn should_serialize_status_apsent_correctly() { 21 | let prob = HttpApiProblem::empty().title("foo"); 22 | 23 | let sample = serde_json::to_value(prob).unwrap(); 24 | let expected = json!({ 25 | "title": "foo" 26 | }); 27 | 28 | assert_eq!(sample, expected); 29 | } 30 | 31 | #[test] 32 | fn deserialize_status_present() { 33 | let json = r#"{"title": "foo", "status": 500}"#; 34 | 35 | let prob: HttpApiProblem = serde_json::from_str(json).unwrap(); 36 | 37 | assert_eq!(prob.status, Some(StatusCode::INTERNAL_SERVER_ERROR)); 38 | } 39 | 40 | #[test] 41 | fn deserialize_status_apsent() { 42 | let json = r#"{"title": "foo"}"#; 43 | 44 | let prob: HttpApiProblem = serde_json::from_str(json).unwrap(); 45 | 46 | assert_eq!(prob.status, None); 47 | } 48 | 49 | #[test] 50 | fn deserialize_status_null() { 51 | let json = r#"{"title": "foo", "status": null}"#; 52 | 53 | let prob: HttpApiProblem = serde_json::from_str(json).unwrap(); 54 | 55 | assert_eq!(prob.status, None); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | features: 10 | [ 11 | "--no-default-features", 12 | "--features axum", 13 | "--features hyper", 14 | "--features actix-web", 15 | "--features warp", 16 | "--features salvo", 17 | "--features tide", 18 | "--features rocket", 19 | "--features api-error", 20 | "--features 'axum api-error'", 21 | "--features 'hyper api-error'", 22 | "--features 'actix-web api-error'", 23 | "--features 'warp api-error'", 24 | "--features 'salvo api-error'", 25 | "--features 'tide api-error'", 26 | "--features 'rocket api-error'", 27 | ] 28 | rust: [ 29 | 1.80.0, # MSRV 30 | nightly, # it is good practise to test libraries against nightly to catch regressions in the compiler early 31 | ] 32 | fail-fast: false # don't want to kill the whole CI if nightly fails 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout sources 36 | uses: actions/checkout@v1 37 | 38 | - name: Install rust 39 | uses: actions-rs/toolchain@v1 40 | with: 41 | profile: minimal 42 | toolchain: ${{ matrix.rust }} 43 | override: true 44 | 45 | - name: Cache target directory 46 | uses: actions/cache@v1 47 | with: 48 | path: target 49 | key: target-directory-${{ matrix.rust }}-${{ matrix.features }}-${{ hashFiles('Cargo.toml') }} 50 | 51 | - run: cargo build ${{ matrix.features }} 52 | - run: cargo test ${{ matrix.features }} 53 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "http-api-problem" 3 | version = "0.60.0" 4 | authors = ["Christian Douven "] 5 | description = "A library to create HTTP error response content for APIs based on RFC 7807" 6 | repository = "https://github.com/chridou/http-api-problem" 7 | homepage = "https://github.com/chridou/http-api-problem" 8 | documentation = "https://docs.rs/http-api-problem" 9 | readme = "README.md" 10 | keywords = ["http", "api", "json", "error", "7807"] 11 | categories = ["web-programming"] 12 | license = "Apache-2.0/MIT" 13 | edition = "2021" 14 | 15 | [dependencies] 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = { version = "1.0" } 18 | http = { version = "1.2" } 19 | hyper = { version = "1.5", optional = true } 20 | actix-web-crate = { package = "actix-web", version = "4", optional = true } 21 | actix = { version = "0.13", optional = true } 22 | rocket = { version = "0.5.0-rc.2", optional = true, default-features = false } 23 | warp = { version = "0.3", optional = true, default-features = false } 24 | salvo = { version = "0.75.0", optional = true, default-features = false } 25 | tide = { version = "0.16", optional = true, default-features = false } 26 | axum-core = { version = "^0.5.0", optional = true } 27 | http-api-problem-derive = { version = "0.1.0", path = "http-api-problem-derive", optional = true } 28 | schemars = { version = "0.8.10", optional = true } 29 | rocket_okapi = { version = ">= 0.8.0-rc.2, < 0.10", optional = true } 30 | 31 | [features] 32 | default = [] 33 | actix-web = ["actix-web-crate", "actix"] 34 | api-error = ["http-api-problem-derive"] 35 | json-schema = ["schemars"] 36 | rocket-okapi = ["dep:rocket_okapi", "rocket", "json-schema"] 37 | axum = ["axum-core"] 38 | 39 | [package.metadata.docs.rs] 40 | all-features = true 41 | rustdoc-args = ["--cfg", "doc_cfg"] 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP-API-PROBLEM 2 | 3 | [![crates.io](https://img.shields.io/crates/v/http-api-problem.svg)](https://crates.io/crates/http-api-problem) 4 | [![docs.rs](https://docs.rs/http-api-problem/badge.svg)](https://docs.rs/http-api-problem) 5 | [![downloads](https://img.shields.io/crates/d/http-api-problem.svg)](https://crates.io/crates/http-api-problem) 6 | ![CI](https://github.com/chridou/http-api-problem/workflows/CI/badge.svg) 7 | [![license-mit](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/chridou/http-api-problem/blob/master/LICENSE-MIT) 8 | [![license-apache](http://img.shields.io/badge/license-APACHE-blue.svg)](https://github.com/chridou/http-api-problem/blob/master/LICENSE-APACHE) 9 | 10 | A library to create HTTP response content for APIs based on 11 | [RFC7807](https://tools.ietf.org/html/rfc7807). 12 | 13 | ## Usage 14 | 15 | Get the latest version for your `Cargo.toml` from 16 | [crates.io](https://crates.io/crates/http-api-problem). 17 | 18 | ## Serde 19 | 20 | `HttpApiProblem` implements `Serialize` and `Deserialize`. 21 | 22 | ## Examples 23 | 24 | ```rust 25 | use http_api_problem::*; 26 | let p = HttpApiProblem::new(StatusCode::UNPROCESSABLE_ENTITY) 27 | .title("You do not have enough credit.") 28 | .detail("Your current balance is 30, but that costs 50.") 29 | .type_url("https://example.com/probs/out-of-credit") 30 | .instance("/account/12345/msgs/abc"); 31 | 32 | assert_eq!(Some(StatusCode::UNPROCESSABLE_ENTITY), p.status); 33 | assert_eq!(Some("You do not have enough credit."), p.title.as_deref()); 34 | assert_eq!(Some("Your current balance is 30, but that costs 50."), p.detail.as_deref()); 35 | assert_eq!(Some("https://example.com/probs/out-of-credit"), p.type_url.as_deref()); 36 | assert_eq!(Some("/account/12345/msgs/abc"), p.instance.as_deref()); 37 | ``` 38 | 39 | There is also `TryFrom` implemented for [StatusCode]: 40 | 41 | ```rust 42 | use http_api_problem::*; 43 | let p = HttpApiProblem::try_new(422).unwrap() 44 | .title("You do not have enough credit.") 45 | .detail("Your current balance is 30, but that costs 50.") 46 | .type_url("https://example.com/probs/out-of-credit") 47 | .instance("/account/12345/msgs/abc"); 48 | 49 | assert_eq!(Some(StatusCode::UNPROCESSABLE_ENTITY), p.status); 50 | assert_eq!(Some("You do not have enough credit."), p.title.as_deref()); 51 | assert_eq!(Some("Your current balance is 30, but that costs 50."), p.detail.as_deref()); 52 | assert_eq!(Some("https://example.com/probs/out-of-credit"), p.type_url.as_deref()); 53 | assert_eq!(Some("/account/12345/msgs/abc"), p.instance.as_deref()); 54 | ``` 55 | 56 | ## Features 57 | 58 | ### Web Frameworks 59 | 60 | There are multiple features to integrate with web frameworks: 61 | 62 | + `axum` 63 | * `warp` 64 | * `hyper` 65 | * `actix-web` 66 | * `salvo` 67 | * `tide` 68 | * `rocket` 69 | 70 | These mainly convert the `HttpApiProblem` to response types of 71 | the frameworks and implement traits to integrate with the frameworks 72 | error handling 73 | 74 | ### ApiError 75 | 76 | The feature `api-error` enables a structure which can be 77 | return from "api handlers" that generate responses and can be 78 | converted into an `HttpApiProblem`. 79 | 80 | ## Thank you 81 | 82 | A big "thank you" for contributions and inspirations goes to the 83 | following GitHub users: 84 | 85 | * panicbit 86 | * thomaseizinger 87 | * SohumB 88 | 89 | ## License 90 | 91 | `http-api-problem` is primarily distributed under the terms of both the MIT 92 | license and the Apache License (Version 2.0). 93 | 94 | Copyright (c) 2017 Christian Douven. 95 | 96 | License: Apache-2.0/MIT 97 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.60.0] - 2025-01-06 8 | 9 | ### CHANGED 10 | 11 | - update `axum-core` to 0.5.0 12 | - update `hyper` to 1.5 13 | - update `salvo` to 0.75.0 14 | - update `http` to 1.2 15 | - update MSRV to `1.80` due to the requirement from `salvo` 16 | 17 | ## [0.59.0] - 2024-07-07 18 | 19 | ### CHANGED 20 | 21 | - update `hyper` to 1.0 (use String as body) 22 | - update `salvo` to 0.68.0 23 | - use `rust 2021` 24 | - update MSRV to `1.79` 25 | - use pretty json string 26 | 27 | ### FIXED 28 | 29 | - clippy warnings (use clone_from) 30 | 31 | ## [0.58.0] - 2023-12-14 32 | 33 | ### CHANGED 34 | 35 | - update `http` to 1.0 36 | - update `axum-core` to 0.4.1 37 | - update MSRV to 1.68 38 | 39 | ## [0.57.0] - 2023-07-04 40 | 41 | ### CHANGED 42 | 43 | - provide `PartialEq` and `Eq` derives for the `HttpApiProblem` struct 44 | Previously, it was only available in the test profiles 45 | 46 | ### ADDED 47 | 48 | - conversions from std::convert::Inallible 49 | 50 | ## [0.56.0] - 2022-11-25 51 | 52 | ### CHANGED 53 | 54 | - update `axum-core` to 0.3.0 55 | 56 | ## [0.55.0] - 2022-09-05 57 | 58 | ### ADDED 59 | 60 | - `axum` support 61 | 62 | ## [0.54.0] - 2022-08-26 63 | 64 | ### ADDED 65 | 66 | - make `fields` of `ApiError` mutably accessible 67 | - make `additional_fields` of `HttpApiProblem` mutably accessible 68 | - `http::Extensions` on `HttpApiProblem` 69 | 70 | ## [0.53.0] 71 | 72 | ### ADDED 73 | 74 | - derive `IntoApiError` macro 75 | - OpenApi support 76 | 77 | ## [0.52.0] - 2022-05-15 78 | 79 | ### CHANGED 80 | 81 | - bump salvo dependency to 0.23 82 | - bump actix-web dependency to 4 83 | - bump actix dependency to 0.13 84 | - Updated MSRV to 1.60 85 | 86 | ## [0.51.0] - 2021-11-03 87 | 88 | ### ADDED 89 | 90 | - rocket support 91 | 92 | ### CHANGED 93 | 94 | - MSRV 1.46 (warp only) 95 | 96 | ## [0.50.1] - 2021-04-16 97 | 98 | ### CHANGED 99 | 100 | - improved documentation on deserialization of invalid status codes 101 | ## [0.50.0] - 2021-04-13 102 | 103 | This release contains multiple breaking changes 104 | 105 | ### CHANGED 106 | 107 | - make `HttpApiProblem` methods more builder like [BREAKING CHANGE] 108 | - `title` is no longer a mandatory field on `HttpApiProblem` [BREAKING CHANGE] 109 | - only accept statuscode as parameter for ApiError [BREAKING CHANGE] to get rid of 500 as a default 110 | - `display_message` now writes into a formatter 111 | - `From for HttpApiProblem` will set the status only 112 | - `Display` for `HttpApiProblem` 113 | - Fields of `ApiError` are private 114 | - `ApiError` now has an `instance field` 115 | 116 | ### ADDED 117 | 118 | - tide support 119 | 120 | ## [0.23.0] - 2021-03-31 121 | 122 | ### ADDED 123 | 124 | - salvo support 125 | - Builder for ApiError 126 | 127 | ## [0.22.0] - 2021-03-28 128 | 129 | ### Changed 130 | 131 | - Updated `actix` to 0.11 132 | - MSRV 1.46 (warp only) 133 | ## [0.21.0] - 2021-01-24 134 | 135 | ### Changed 136 | 137 | - Drop `with-` prefix from all features. 138 | The new set of supported features is: `warp`, `actix-web`, `hyper` and `api-error`. 139 | 140 | ## [0.20.0] - 2021-01-22 141 | 142 | - use warp 0.3 143 | 144 | ## [0.19.0] - 2021-01-15 145 | 146 | ## CHANGED 147 | - updated web frameworks (features) 148 | - MSRV: 1.45 149 | 150 | 151 | ## [0.18.0] - 2021-01-15 152 | 153 | ## CHANGED 154 | 155 | - make to_hyper_response accept &self #26 156 | - MSRV: 1.42 157 | 158 | ## [0.17.0] - 2020-02-21 159 | ### Removed 160 | - failure support and "with-failure" feature. This can brake codes but clients simply can use "compat" 161 | 162 | ## [0.16.0] - 2020-02-05 163 | ### Added 164 | - A changelog 165 | - `ApiError` implements `std::error::Error` if `failure` feature is not activated. 166 | 167 | ### Changed 168 | - `failure` support must be activated via feature `with-failure` 169 | - If `failure` feature is activated, both `HttpApiProblem` and `ApiError` implement `failure::Fail`, otherwise `std::error::Error` 170 | - Feature `with_warp` became `with-warp` 171 | - Feature `with_actix_web` became `with-actix-web` 172 | - Feature `with_hyper` became `with-hyper` 173 | - Updated `README.md` 174 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Christian Douven 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/api_error.rs: -------------------------------------------------------------------------------- 1 | //! An error that should be returned from an HTTP API handler. 2 | //! 3 | //! It is able to carry typed data via [Extensions] to be used 4 | //! in middlewares. These values will not become part of any response. 5 | //! 6 | //! # Things to know 7 | //! 8 | //! [ApiError] can be converted to an [HttpApiProblem] and 9 | //! also has many conversions to responses of web framewors implemented. 10 | use std::borrow::Cow; 11 | use std::collections::HashMap; 12 | use std::fmt::{self, Display}; 13 | use std::io; 14 | 15 | use std::error::Error; 16 | 17 | use http::Extensions; 18 | use serde::Serialize; 19 | use serde_json::Value; 20 | 21 | use super::*; 22 | pub use http_api_problem_derive::IntoApiError; 23 | 24 | pub struct ApiErrorBuilder { 25 | /// The suggested status code for the server to be returned to the client 26 | pub status: StatusCode, 27 | 28 | /// This is an optional title which can be used to create a valuable output 29 | /// for consumers. 30 | pub title: Option, 31 | 32 | /// A message that describes the error in a human readable form. 33 | /// 34 | /// In an [HttpApiProblem] this becomes the `detail` in most cases. 35 | pub message: Option, 36 | 37 | /// A URL that points to a detailed description of the error. 38 | pub type_url: Option, 39 | 40 | /// A URI reference that identifies the specific 41 | /// occurrence of the problem. It may or may not yield further 42 | /// information if dereferenced. 43 | pub instance: Option, 44 | 45 | /// Additional JSON encodable information. It is up to the server how and if 46 | /// it adds the given information. 47 | pub fields: HashMap, 48 | 49 | /// Typed extensions for carrying processable data server side 50 | /// 51 | /// Can be used e.g. for middlewares 52 | /// 53 | /// Extensions will not be part of an [HttpApiProblem] 54 | pub extensions: Extensions, 55 | 56 | pub source: Option>, 57 | } 58 | 59 | impl ApiErrorBuilder { 60 | /// Set the [StatusCode] 61 | pub fn status>(mut self, status: T) -> Self { 62 | self.status = status.into(); 63 | self 64 | } 65 | 66 | /// Try to set the [StatusCode] 67 | /// 68 | /// Fails if the `status` argument can not be converted to a [StatusCode] 69 | pub fn try_status>(self, status: T) -> Result 70 | where 71 | T::Error: Into, 72 | { 73 | let status = status.try_into().map_err(|e| e.into())?; 74 | Ok(self.status(status)) 75 | } 76 | 77 | /// This is an optional title which can be used to create a valuable output 78 | /// for consumers. 79 | pub fn title(mut self, title: T) -> Self { 80 | self.title = Some(title.to_string()); 81 | self 82 | } 83 | 84 | /// A message that describes the error in a human readable form. 85 | /// 86 | /// In an [HttpApiProblem] this becomes the `detail` in most cases. 87 | pub fn message(mut self, message: M) -> Self { 88 | self.message = Some(message.to_string()); 89 | self 90 | } 91 | 92 | /// A URL that points to a detailed description of the error. 93 | pub fn type_url(mut self, type_url: U) -> Self { 94 | self.type_url = Some(type_url.to_string()); 95 | self 96 | } 97 | 98 | /// Sets the `instance` 99 | /// 100 | /// A URI reference that identifies the specific 101 | /// occurrence of the problem. It may or may not yield further 102 | /// information if dereferenced. 103 | pub fn instance(mut self, instance: T) -> Self { 104 | self.instance = Some(instance.to_string()); 105 | self 106 | } 107 | 108 | /// Adds a serializable field. 109 | /// 110 | /// If the serialization fails nothing will be added. 111 | /// An already present field with the same name will be replaced. 112 | pub fn field, V: Serialize>(mut self, name: T, value: V) -> Self { 113 | if let Ok(value) = serde_json::to_value(value) { 114 | self.fields.insert(name.into(), value); 115 | } 116 | 117 | self 118 | } 119 | 120 | /// Modify the fields values from within a closure 121 | pub fn with_fields(mut self, f: F) -> Self 122 | where 123 | F: FnOnce(HashMap) -> HashMap, 124 | { 125 | self.fields = f(self.fields); 126 | 127 | self 128 | } 129 | 130 | /// Adds an extension value. 131 | /// 132 | /// Existing values will be overwritten 133 | /// 134 | /// Extensions will not be part of an [HttpApiProblem] 135 | pub fn extension(mut self, val: T) -> Self { 136 | let _ = self.extensions.insert(val); 137 | 138 | self 139 | } 140 | 141 | /// Modify the extension values from within a closure 142 | /// 143 | /// Extensions will not be part of an [HttpApiProblem] 144 | pub fn with_extensions(mut self, f: F) -> Self 145 | where 146 | F: FnOnce(Extensions) -> Extensions, 147 | { 148 | self.extensions = f(self.extensions); 149 | 150 | self 151 | } 152 | 153 | pub fn source(self, source: E) -> Self { 154 | self.source_in_a_box(Box::new(source)) 155 | } 156 | 157 | pub fn source_in_a_box>>( 158 | mut self, 159 | source: E, 160 | ) -> Self { 161 | self.source = Some(source.into()); 162 | self 163 | } 164 | 165 | /// Build the [ApiError] 166 | pub fn finish(self) -> ApiError { 167 | ApiError { 168 | status: self.status, 169 | title: self.title, 170 | message: self.message, 171 | type_url: self.type_url, 172 | instance: self.instance, 173 | fields: self.fields, 174 | extensions: self.extensions, 175 | source: self.source, 176 | } 177 | } 178 | } 179 | 180 | /// An error that should be returned from an API handler of a web service. 181 | /// 182 | /// This should be returned from a handler as an error instead of a response 183 | /// or [HttpApiProblem]. Allows for logging etc. right before a response is generated. 184 | /// 185 | /// Advantage over using an [HttpApiProblem] directly are that the [StatusCode] is 186 | /// mandatory and that this struct can also capture a source error 187 | /// which the [HttpApiProblem] does not since no error chains 188 | /// should be transmitted to clients. 189 | /// 190 | /// # Message on Display and converting to HttpApiProblem 191 | /// 192 | /// When [Display::fmt] is invoked or when the details field of an [HttpApiProblem] 193 | /// is filled, the `message` field is used if present. If no `message` is set 194 | /// but there is a `source` error set, `to_string()` of the source will 195 | /// be used instead. Otherwise nothing will be displayed or set. 196 | /// 197 | /// `ApiError` requires the feature `api-error` to be enabled. 198 | #[derive(Debug)] 199 | pub struct ApiError { 200 | status: StatusCode, 201 | title: Option, 202 | message: Option, 203 | instance: Option, 204 | type_url: Option, 205 | fields: HashMap, 206 | extensions: Extensions, 207 | source: Option>, 208 | } 209 | 210 | impl ApiError { 211 | /// Get an [ApiErrorBuilder] with the given [StatusCode] preset. 212 | pub fn builder>(status: T) -> ApiErrorBuilder { 213 | ApiErrorBuilder { 214 | status: status.into(), 215 | title: None, 216 | message: None, 217 | type_url: None, 218 | instance: None, 219 | fields: HashMap::default(), 220 | source: None, 221 | extensions: Extensions::default(), 222 | } 223 | } 224 | 225 | /// Try to get an [ApiErrorBuilder] with the given [StatusCode] preset. 226 | /// 227 | /// Fails if the `status` argument can not be converted to a [StatusCode] 228 | pub fn try_builder>( 229 | status: S, 230 | ) -> Result 231 | where 232 | S::Error: Into, 233 | { 234 | let status = status.try_into().map_err(|e| e.into())?; 235 | Ok(Self::builder(status)) 236 | } 237 | 238 | /// Create a new instance with the given [StatusCode] 239 | pub fn new>(status: T) -> Self { 240 | Self { 241 | status: status.into(), 242 | title: None, 243 | message: None, 244 | type_url: None, 245 | instance: None, 246 | fields: HashMap::new(), 247 | extensions: Extensions::default(), 248 | source: None, 249 | } 250 | } 251 | 252 | /// Try to create a new instance with the given [StatusCode] 253 | /// 254 | /// Fails if the `status` argument can not be converted to a [StatusCode] 255 | pub fn try_new>(status: S) -> Result 256 | where 257 | S::Error: Into, 258 | { 259 | let status = status.try_into().map_err(|e| e.into())?; 260 | Ok(Self::new(status)) 261 | } 262 | 263 | /// Set the [StatusCode]. 264 | pub fn set_status>(&mut self, status: T) { 265 | self.status = status.into(); 266 | } 267 | 268 | /// Get the [StatusCode]. 269 | pub fn status(&self) -> StatusCode { 270 | self.status 271 | } 272 | 273 | /// This is an optional title which can be used to create a valuable output 274 | /// for consumers. 275 | pub fn set_title(&mut self, title: T) { 276 | self.title = Some(title.to_string()) 277 | } 278 | 279 | /// This is an optional title which can be used to create a valuable output 280 | /// for consumers. 281 | pub fn title(&self) -> Option<&str> { 282 | self.title.as_deref() 283 | } 284 | 285 | /// Set a message that describes the error in a human readable form. 286 | pub fn set_message(&mut self, message: T) { 287 | self.message = Some(message.to_string()) 288 | } 289 | 290 | /// A message that describes the error in a human readable form. 291 | pub fn message(&self) -> Option<&str> { 292 | self.message.as_deref() 293 | } 294 | 295 | /// Set a URL that points to a detailed description of the error. 296 | /// 297 | /// If not set it will most probably become `httpstatus.es.com/XXX` when 298 | /// the problem response is generated. 299 | pub fn set_type_url(&mut self, type_url: T) { 300 | self.type_url = Some(type_url.to_string()) 301 | } 302 | 303 | /// A URL that points to a detailed description of the error. 304 | pub fn type_url(&self) -> Option<&str> { 305 | self.type_url.as_deref() 306 | } 307 | 308 | pub fn set_instance(&mut self, instance: T) { 309 | self.instance = Some(instance.to_string()) 310 | } 311 | 312 | /// A URL that points to a detailed description of the error. 313 | pub fn instance(&self) -> Option<&str> { 314 | self.instance.as_deref() 315 | } 316 | 317 | pub fn set_source(&mut self, source: E) { 318 | self.set_source_in_a_box(Box::new(source)) 319 | } 320 | 321 | pub fn set_source_in_a_box>>( 322 | &mut self, 323 | source: E, 324 | ) { 325 | self.source = Some(source.into()); 326 | } 327 | 328 | /// Adds a serializable field. If the serialization fails nothing will be 329 | /// added. This method returns `true` if the field was added and `false` if 330 | /// the field could not be added. 331 | /// 332 | /// An already present field with the same name will be replaced. 333 | pub fn add_field, V: Serialize>(&mut self, name: T, value: V) -> bool { 334 | self.try_add_field(name, value).is_ok() 335 | } 336 | 337 | /// Adds a serializable field. If the serialization fails nothing will be 338 | /// added. This fails if a failure occurred while adding the field. 339 | /// 340 | /// An already present field with the same name will be replaced. 341 | pub fn try_add_field, V: Serialize>( 342 | &mut self, 343 | name: T, 344 | value: V, 345 | ) -> Result<(), Box> { 346 | let name: String = name.into(); 347 | 348 | match name.as_ref() { 349 | "type" => return Err("'type' is a reserved field name".into()), 350 | "status" => return Err("'status' is a reserved field name".into()), 351 | "title" => return Err("'title' is a reserved field name".into()), 352 | "detail" => return Err("'detail' is a reserved field name".into()), 353 | "instance" => return Err("'instance' is a reserved field name".into()), 354 | _ => (), 355 | } 356 | 357 | match serde_json::to_value(value) { 358 | Ok(value) => { 359 | self.fields.insert(name, value); 360 | Ok(()) 361 | } 362 | Err(err) => Err(Box::new(err)), 363 | } 364 | } 365 | 366 | /// Returns a reference to the serialized fields 367 | pub fn fields(&self) -> &HashMap { 368 | &self.fields 369 | } 370 | 371 | /// Returns a mutable reference to the serialized fields 372 | pub fn fields_mut(&mut self) -> &mut HashMap { 373 | &mut self.fields 374 | } 375 | 376 | /// Get a reference to the extensions 377 | /// 378 | /// Extensions will not be part of an [HttpApiProblem] 379 | pub fn extensions(&self) -> &Extensions { 380 | &self.extensions 381 | } 382 | 383 | /// Get a mutable reference to the extensions 384 | /// 385 | /// Extensions will not be part of an [HttpApiProblem] 386 | pub fn extensions_mut(&mut self) -> &mut Extensions { 387 | &mut self.extensions 388 | } 389 | 390 | /// Creates an [HttpApiProblem] from this. 391 | /// 392 | /// Note: If the status is [StatusCode]::UNAUTHORIZED fields will 393 | /// **not** be put into the problem. 394 | pub fn to_http_api_problem(&self) -> HttpApiProblem { 395 | let mut problem = HttpApiProblem::with_title_and_type(self.status); 396 | 397 | problem.title.clone_from(&self.title); 398 | 399 | if let Some(message) = self.detail_message() { 400 | problem.detail = Some(message.into()) 401 | } 402 | 403 | problem.type_url.clone_from(&self.type_url); 404 | problem.instance.clone_from(&self.instance); 405 | 406 | if self.status != StatusCode::UNAUTHORIZED { 407 | for (key, value) in self.fields.iter() { 408 | problem.set_value(key.to_string(), value); 409 | } 410 | } 411 | 412 | problem 413 | } 414 | 415 | /// Turns this into an [HttpApiProblem]. 416 | /// 417 | /// Note: If the status is [StatusCode]::UNAUTHORIZED fields will 418 | /// **not** be put into the problem. 419 | pub fn into_http_api_problem(self) -> HttpApiProblem { 420 | let mut problem = HttpApiProblem::with_title_and_type(self.status); 421 | 422 | if let Some(title) = self.title.as_ref() { 423 | problem.title = Some(title.to_owned()); 424 | } 425 | 426 | if let Some(message) = self.detail_message() { 427 | problem.detail = Some(message.into()) 428 | } 429 | 430 | if let Some(type_url) = self.type_url.as_ref() { 431 | problem.type_url = Some(type_url.to_owned()) 432 | } 433 | 434 | if let Some(instance) = self.instance.as_ref() { 435 | problem.instance = Some(instance.to_owned()) 436 | } 437 | 438 | if self.status != StatusCode::UNAUTHORIZED { 439 | for (key, value) in self.fields.iter() { 440 | problem.set_value(key.to_string(), value); 441 | } 442 | } 443 | 444 | problem 445 | } 446 | 447 | /// If there is a message it will be the message otherwise the source error stringified 448 | /// 449 | /// If none is present, `None` is returned 450 | pub fn detail_message(&self) -> Option> { 451 | if let Some(message) = self.message.as_ref() { 452 | return Some(Cow::Borrowed(message)); 453 | } 454 | 455 | if let Some(source) = self.source() { 456 | return Some(Cow::Owned(source.to_string())); 457 | } 458 | 459 | None 460 | } 461 | 462 | /// Creates a [hyper] response containing a problem JSON. 463 | /// 464 | /// Requires the `hyper` feature 465 | #[cfg(feature = "hyper")] 466 | pub fn into_hyper_response(self) -> hyper::Response { 467 | let problem = self.into_http_api_problem(); 468 | problem.to_hyper_response() 469 | } 470 | 471 | /// Creates an axum [Response](axum_core::response::response) containing a problem JSON. 472 | /// 473 | /// Requires the `axum` feature 474 | #[cfg(feature = "axum")] 475 | pub fn into_axum_response(self) -> axum_core::response::Response { 476 | let problem = self.into_http_api_problem(); 477 | problem.to_axum_response() 478 | } 479 | 480 | /// Creates a `actix-web` response containing a problem JSON. 481 | /// 482 | /// Requires the `actix.web` feature 483 | #[cfg(feature = "actix-web")] 484 | pub fn into_actix_web_response(self) -> actix_web::HttpResponse { 485 | let problem = self.into_http_api_problem(); 486 | problem.into() 487 | } 488 | 489 | /// Creates a [salvo] response containing a problem JSON. 490 | /// 491 | /// Requires the `salvo` feature 492 | #[cfg(feature = "salvo")] 493 | pub fn into_salvo_response(self) -> salvo::Response { 494 | let problem = self.into_http_api_problem(); 495 | problem.to_salvo_response() 496 | } 497 | 498 | /// Creates a [tide] response containing a problem JSON. 499 | /// 500 | /// Requires the `tide` feature 501 | #[cfg(feature = "tide")] 502 | pub fn into_tide_response(self) -> tide::Response { 503 | let problem = self.into_http_api_problem(); 504 | problem.to_tide_response() 505 | } 506 | } 507 | 508 | impl Error for ApiError { 509 | fn source(&self) -> Option<&(dyn Error + 'static)> { 510 | self.source.as_ref().map(|e| &**e as _) 511 | } 512 | } 513 | 514 | impl Display for ApiError { 515 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 516 | write!(f, "{}", self.status)?; 517 | 518 | match (self.title.as_ref(), self.detail_message()) { 519 | (Some(title), Some(detail)) => return write!(f, " - {} - {}", title, detail), 520 | (Some(title), None) => return write!(f, " - {}", title), 521 | (None, Some(detail)) => return write!(f, " - {}", detail), 522 | (None, None) => (), 523 | } 524 | 525 | if let Some(type_url) = self.type_url.as_ref() { 526 | return write!(f, " of type {}", type_url); 527 | } 528 | 529 | if let Some(instance) = self.instance.as_ref() { 530 | return write!(f, " on {}", instance); 531 | } 532 | 533 | Ok(()) 534 | } 535 | } 536 | 537 | impl From for ApiError { 538 | fn from(s: StatusCode) -> Self { 539 | Self::new(s) 540 | } 541 | } 542 | 543 | impl From for ApiError { 544 | fn from(builder: ApiErrorBuilder) -> Self { 545 | builder.finish() 546 | } 547 | } 548 | 549 | impl From for HttpApiProblem { 550 | fn from(error: ApiError) -> Self { 551 | error.into_http_api_problem() 552 | } 553 | } 554 | 555 | impl From for ApiError { 556 | fn from(error: io::Error) -> Self { 557 | ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) 558 | .title("An IO error occurred") 559 | .source(error) 560 | .finish() 561 | } 562 | } 563 | 564 | impl From for ApiError { 565 | fn from(error: std::convert::Infallible) -> Self { 566 | match error {} 567 | } 568 | } 569 | 570 | pub trait IntoApiError { 571 | fn into_api_error(self) -> ApiError; 572 | } 573 | 574 | impl From for ApiError { 575 | fn from(t: T) -> ApiError { 576 | t.into_api_error() 577 | } 578 | } 579 | 580 | #[cfg(feature = "hyper")] 581 | impl From for ApiError { 582 | fn from(error: hyper::Error) -> Self { 583 | ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) 584 | .source(error) 585 | .finish() 586 | } 587 | } 588 | 589 | #[cfg(feature = "hyper")] 590 | impl From for hyper::Response { 591 | fn from(error: ApiError) -> hyper::Response { 592 | error.into_hyper_response() 593 | } 594 | } 595 | 596 | #[cfg(feature = "axum")] 597 | impl From for axum_core::response::Response { 598 | fn from(error: ApiError) -> axum_core::response::Response { 599 | error.into_axum_response() 600 | } 601 | } 602 | 603 | #[cfg(feature = "axum")] 604 | impl axum_core::response::IntoResponse for ApiError { 605 | fn into_response(self) -> axum_core::response::Response { 606 | self.into() 607 | } 608 | } 609 | 610 | #[cfg(feature = "actix-web")] 611 | impl From for ApiError { 612 | fn from(error: actix::prelude::MailboxError) -> Self { 613 | ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) 614 | .source(error) 615 | .finish() 616 | } 617 | } 618 | 619 | #[cfg(feature = "actix-web")] 620 | impl From for actix_web::HttpResponse { 621 | fn from(error: ApiError) -> Self { 622 | error.into_actix_web_response() 623 | } 624 | } 625 | 626 | #[cfg(feature = "actix-web")] 627 | impl actix_web::error::ResponseError for ApiError { 628 | fn error_response(&self) -> actix_web::HttpResponse { 629 | let json = self.to_http_api_problem().json_bytes(); 630 | let actix_status = actix_web::http::StatusCode::from_u16(self.status.as_u16()) 631 | .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); 632 | 633 | actix_web::HttpResponse::build(actix_status) 634 | .append_header(( 635 | actix_web::http::header::CONTENT_TYPE, 636 | PROBLEM_JSON_MEDIA_TYPE, 637 | )) 638 | .body(json) 639 | } 640 | } 641 | 642 | #[cfg(feature = "warp")] 643 | impl warp::reject::Reject for ApiError {} 644 | 645 | #[cfg(feature = "salvo")] 646 | impl From for ApiError { 647 | fn from(error: salvo::Error) -> Self { 648 | ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) 649 | .source(error) 650 | .finish() 651 | } 652 | } 653 | 654 | #[cfg(feature = "salvo")] 655 | impl From for salvo::Response { 656 | fn from(error: ApiError) -> salvo::Response { 657 | error.into_salvo_response() 658 | } 659 | } 660 | 661 | #[cfg(feature = "tide")] 662 | impl From for ApiError { 663 | fn from(error: tide::Error) -> Self { 664 | // tide also has its version of status which should always be 665 | // convertible without an error. 666 | let status: StatusCode = u16::from(error.status()) 667 | .try_into() 668 | .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 669 | ApiError::builder(status) 670 | .source_in_a_box(error.into_inner()) 671 | .finish() 672 | } 673 | } 674 | 675 | #[cfg(feature = "tide")] 676 | impl From for tide::Response { 677 | fn from(error: ApiError) -> tide::Response { 678 | error.into_tide_response() 679 | } 680 | } 681 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # HTTP-API-PROBLEM 2 | //! 3 | //! [![crates.io](https://img.shields.io/crates/v/http-api-problem.svg)](https://crates.io/crates/http-api-problem) 4 | //! [![docs.rs](https://docs.rs/http-api-problem/badge.svg)](https://docs.rs/http-api-problem) 5 | //! [![downloads](https://img.shields.io/crates/d/http-api-problem.svg)](https://crates.io/crates/http-api-problem) 6 | //! ![CI](https://github.com/chridou/http-api-problem/workflows/CI/badge.svg) 7 | //! [![license-mit](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/chridou/http-api-problem/blob/master/LICENSE-MIT) 8 | //! [![license-apache](http://img.shields.io/badge/license-APACHE-blue.svg)](https://github.com/chridou/http-api-problem/blob/master/LICENSE-APACHE) 9 | //! 10 | //! A library to create HTTP response content for APIs based on 11 | //! [RFC7807](https://tools.ietf.org/html/rfc7807). 12 | //! 13 | //! ## Usage 14 | //! 15 | //! Get the latest version for your `Cargo.toml` from 16 | //! [crates.io](https://crates.io/crates/http-api-problem). 17 | //! 18 | //! Add this to your crate root: 19 | //! 20 | //! ```rust 21 | //! use http_api_problem; 22 | //! ``` 23 | //! 24 | //! ## serde 25 | //! 26 | //! [HttpApiProblem] implements [Serialize] and [Deserialize] for 27 | //! [HttpApiProblem]. 28 | //! 29 | //! ## Examples 30 | //! 31 | //! ```rust 32 | //! use http_api_problem::*; 33 | //! 34 | //! let p = HttpApiProblem::new(StatusCode::UNPROCESSABLE_ENTITY) 35 | //! .title("You do not have enough credit.") 36 | //! .detail("Your current balance is 30, but that costs 50.") 37 | //! .type_url("https://example.com/probs/out-of-credit") 38 | //! .instance("/account/12345/msgs/abc"); 39 | //! 40 | //! assert_eq!(Some(StatusCode::UNPROCESSABLE_ENTITY), p.status); 41 | //! assert_eq!(Some("You do not have enough credit."), p.title.as_deref()); 42 | //! assert_eq!(Some("Your current balance is 30, but that costs 50."), p.detail.as_deref()); 43 | //! assert_eq!(Some("https://example.com/probs/out-of-credit"), p.type_url.as_deref()); 44 | //! assert_eq!(Some("/account/12345/msgs/abc"), p.instance.as_deref()); 45 | //! ``` 46 | //! 47 | //! There is also `TryFrom` implemented for [StatusCode]: 48 | //! 49 | //! ```rust 50 | //! use http_api_problem::*; 51 | //! 52 | //! let p = HttpApiProblem::try_new(422).unwrap() 53 | //! .title("You do not have enough credit.") 54 | //! .detail("Your current balance is 30, but that costs 50.") 55 | //! .type_url("https://example.com/probs/out-of-credit") 56 | //! .instance("/account/12345/msgs/abc"); 57 | //! 58 | //! assert_eq!(Some(StatusCode::UNPROCESSABLE_ENTITY), p.status); 59 | //! assert_eq!(Some("You do not have enough credit."), p.title.as_deref()); 60 | //! assert_eq!(Some("Your current balance is 30, but that costs 50."), p.detail.as_deref()); 61 | //! assert_eq!(Some("https://example.com/probs/out-of-credit"), p.type_url.as_deref()); 62 | //! assert_eq!(Some("/account/12345/msgs/abc"), p.instance.as_deref()); 63 | //! ``` 64 | //! 65 | //! ## Status Codes 66 | //! 67 | //! The specification does not require the [HttpApiProblem] to contain a 68 | //! status code. Nevertheless this crate supports creating responses 69 | //! for web frameworks. Responses require a status code. If no status code 70 | //! was set on the [HttpApiProblem] `500 - Internal Server Error` will be 71 | //! used as a fallback. This can be easily avoided by only using those constructor 72 | //! functions which require a [StatusCode]. 73 | //! 74 | //! ## Features 75 | //! 76 | //! ### JsonSchema 77 | //! 78 | //! The feature `json-schema` enables a derived implementation for 79 | //! JsonSchema, via `schemars`. 80 | //! 81 | //! ### Web Frameworks 82 | //! 83 | //! There are multiple features to integrate with web frameworks: 84 | //! 85 | //! * `axum` 86 | //! * `warp` 87 | //! * `hyper` 88 | //! * `actix-web` 89 | //! * `salvo` 90 | //! * `tide` 91 | //! * `rocket (v0.5.0-rc1)` 92 | //! 93 | //! These mainly convert the `HttpApiProblem` to response types of 94 | //! the frameworks and implement traits to integrate with the frameworks 95 | //! error handling. 96 | //! 97 | //! Additionally, the feature `rocket-okapi` (which implies the features 98 | //! `rocket` and `json-schema`) implements `rocket_okapi`'s `OpenApiResponder` 99 | //! for the json schema generated by the `json-schema` feature. 100 | //! 101 | //! ### ApiError 102 | //! 103 | //! The feature `api-error` enables a structure which can be 104 | //! return from "api handlers" that generate responses and can be 105 | //! converted into an `HttpApiProblem`. 106 | //! 107 | //! ## License 108 | //! 109 | //! `http-api-problem` is primarily distributed under the terms of both the MIT 110 | //! license and the Apache License (Version 2.0). 111 | //! 112 | //! Copyright (c) 2017 Christian Douven. 113 | use std::convert::TryInto; 114 | use std::error::Error; 115 | use std::fmt; 116 | 117 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 118 | use serde_json::Value; 119 | use std::collections::HashMap; 120 | 121 | #[cfg(feature = "api-error")] 122 | mod api_error; 123 | #[cfg(feature = "api-error")] 124 | pub use api_error::*; 125 | 126 | #[cfg(feature = "json-schema")] 127 | use schemars::JsonSchema; 128 | 129 | #[cfg(feature = "actix-web")] 130 | use actix_web_crate as actix_web; 131 | 132 | #[cfg(feature = "axum")] 133 | use axum_core; 134 | 135 | pub use http::status::{InvalidStatusCode, StatusCode}; 136 | 137 | /// The recommended media type when serialized to JSON 138 | /// 139 | /// "application/problem+json" 140 | pub static PROBLEM_JSON_MEDIA_TYPE: &str = "application/problem+json"; 141 | 142 | /// Description of a problem that can be returned by an HTTP API 143 | /// based on [RFC7807](https://tools.ietf.org/html/rfc7807) 144 | /// 145 | /// # Example 146 | /// 147 | /// ```javascript 148 | /// { 149 | /// "type": "https://example.com/probs/out-of-credit", 150 | /// "title": "You do not have enough credit.", 151 | /// "detail": "Your current balance is 30, but that costs 50.", 152 | /// "instance": "/account/12345/msgs/abc", 153 | /// } 154 | /// ``` 155 | /// 156 | /// # Purpose 157 | /// 158 | /// The purpose of [HttpApiProblem] is to generate a meaningful response 159 | /// for clients. It is not intended to be used as a replacement 160 | /// for a proper `Error` struct within applications. 161 | /// 162 | /// For a struct which can be returned by HTTP handlers use [ApiError] which 163 | /// can be enabled with the feature toggle `api-error`. [ApiError] can be directly 164 | /// converted into [HttpApiProblem]. 165 | /// 166 | /// # Status Codes and Responses 167 | /// 168 | /// Prefer to use one of the constructors which 169 | /// ensure that a [StatusCode] is set. If no [StatusCode] is 170 | /// set and a transformation to a response of a web framework 171 | /// is made a [StatusCode] becomes mandatory which in this case will 172 | /// default to `500`. 173 | /// 174 | /// When receiving an [HttpApiProblem] there might be an invalid 175 | /// [StatusCode] contained. In this case the `status` field will be empty. 176 | /// This is a trade off so that the recipient does not have to deal with 177 | /// another error and can still have access to the remaining fields of the 178 | /// struct. 179 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 180 | #[cfg_attr(feature = "json-schema", derive(JsonSchema))] 181 | #[cfg_attr( 182 | feature = "json-schema", 183 | schemars( 184 | description = "Description of a problem that can be returned by an HTTP API based on [RFC7807](https://tools.ietf.org/html/rfc7807)" 185 | ) 186 | )] 187 | pub struct HttpApiProblem { 188 | /// A URI reference [RFC3986](https://tools.ietf.org/html/rfc3986) that identifies the 189 | /// problem type. This specification encourages that, when 190 | /// dereferenced, it provide human-readable documentation for the 191 | /// problem type (e.g., using HTML [W3C.REC-html5-20141028]). When 192 | /// this member is not present, its value is assumed to be 193 | /// "about:blank". 194 | #[serde(rename = "type")] 195 | #[serde(skip_serializing_if = "Option::is_none")] 196 | #[cfg_attr( 197 | feature = "json-schema", 198 | schemars( 199 | description = "A [RFC3986 URI reference](https://tools.ietf.org/html/rfc3986) that identifies the problem type. When dereferenced, it may provide human-readable documentation for the problem type." 200 | ) 201 | )] 202 | pub type_url: Option, 203 | 204 | /// The HTTP status code [RFC7231, Section 6](https://tools.ietf.org/html/rfc7231#section-6) 205 | /// generated by the origin server for this occurrence of the problem. 206 | #[serde(default)] 207 | #[serde(with = "custom_http_status_serialization")] 208 | #[cfg_attr(feature = "json-schema", schemars(with = "u16"))] 209 | #[serde(skip_serializing_if = "Option::is_none")] 210 | pub status: Option, 211 | 212 | /// A short, human-readable summary of the problem 213 | /// type. It SHOULD NOT change from occurrence to occurrence of the 214 | /// problem, except for purposes of localization (e.g., using 215 | /// proactive content negotiation; 216 | /// see [RFC7231, Section 3.4](https://tools.ietf.org/html/rfc7231#section-3.4). 217 | #[cfg_attr( 218 | feature = "json-schema", 219 | schemars( 220 | description = "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem." 221 | ) 222 | )] 223 | #[serde(skip_serializing_if = "Option::is_none")] 224 | pub title: Option, 225 | 226 | /// A human-readable explanation specific to this 227 | /// occurrence of the problem. 228 | #[serde(skip_serializing_if = "Option::is_none")] 229 | pub detail: Option, 230 | 231 | /// A URI reference that identifies the specific 232 | /// occurrence of the problem. It may or may not yield further 233 | /// information if dereferenced. 234 | #[serde(skip_serializing_if = "Option::is_none")] 235 | pub instance: Option, 236 | 237 | /// Additional fields that must be JSON values 238 | /// 239 | /// These values get serialized into the JSON 240 | /// on top level. 241 | #[serde(flatten)] 242 | additional_fields: HashMap, 243 | } 244 | 245 | impl HttpApiProblem { 246 | /// Creates a new instance with the given [StatusCode]. 247 | /// 248 | /// #Example 249 | /// 250 | /// ```rust 251 | /// use http_api_problem::*; 252 | /// 253 | /// let p = HttpApiProblem::new(StatusCode::INTERNAL_SERVER_ERROR); 254 | /// 255 | /// assert_eq!(Some(StatusCode::INTERNAL_SERVER_ERROR), p.status); 256 | /// assert_eq!(None, p.title); 257 | /// assert_eq!(None, p.detail); 258 | /// assert_eq!(None, p.type_url); 259 | /// assert_eq!(None, p.instance); 260 | /// ``` 261 | pub fn new>(status: T) -> Self { 262 | Self::empty().status(status) 263 | } 264 | 265 | /// Creates a new instance with the given [StatusCode]. 266 | /// 267 | /// Fails if the argument can not be converted into a [StatusCode]. 268 | /// 269 | /// #Example 270 | /// 271 | /// ```rust 272 | /// use http_api_problem::*; 273 | /// 274 | /// let p = HttpApiProblem::try_new(500).unwrap(); 275 | /// 276 | /// assert_eq!(Some(StatusCode::INTERNAL_SERVER_ERROR), p.status); 277 | /// assert_eq!(None, p.title); 278 | /// assert_eq!(None, p.detail); 279 | /// assert_eq!(None, p.type_url); 280 | /// assert_eq!(None, p.instance); 281 | /// ``` 282 | pub fn try_new>(status: T) -> Result 283 | where 284 | T::Error: Into, 285 | { 286 | let status = status.try_into().map_err(|e| e.into())?; 287 | Ok(Self::new(status)) 288 | } 289 | 290 | /// Creates a new instance with `title` derived from a [StatusCode]. 291 | /// 292 | /// #Example 293 | /// 294 | /// ```rust 295 | /// use http_api_problem::*; 296 | /// 297 | /// let p = HttpApiProblem::with_title(StatusCode::NOT_FOUND); 298 | /// 299 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 300 | /// assert_eq!(Some("Not Found"), p.title.as_deref()); 301 | /// assert_eq!(None, p.detail); 302 | /// assert_eq!(None, p.type_url); 303 | /// assert_eq!(None, p.instance); 304 | /// ``` 305 | pub fn with_title>(status: T) -> Self { 306 | let status = status.into(); 307 | Self::new(status).title( 308 | status 309 | .canonical_reason() 310 | .unwrap_or("") 311 | .to_string(), 312 | ) 313 | } 314 | 315 | /// Creates a new instance with `title` derived from a [StatusCode]. 316 | /// 317 | /// Fails if the argument can not be converted into a [StatusCode]. 318 | /// 319 | /// #Example 320 | /// 321 | /// ```rust 322 | /// use http_api_problem::*; 323 | /// 324 | /// let p = HttpApiProblem::try_with_title(404).unwrap(); 325 | /// 326 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 327 | /// assert_eq!(Some("Not Found"), p.title.as_deref()); 328 | /// assert_eq!(None, p.detail); 329 | /// assert_eq!(None, p.type_url); 330 | /// assert_eq!(None, p.instance); 331 | /// ``` 332 | pub fn try_with_title>(status: T) -> Result 333 | where 334 | T::Error: Into, 335 | { 336 | let status = status.try_into().map_err(|e| e.into())?; 337 | Ok(Self::with_title(status)) 338 | } 339 | 340 | /// Creates a new instance with the `title` and `type_url` derived from the 341 | /// [StatusCode]. 342 | /// 343 | /// #Example 344 | /// 345 | /// ```rust 346 | /// use http_api_problem::*; 347 | /// 348 | /// let p = HttpApiProblem::with_title_and_type(StatusCode::SERVICE_UNAVAILABLE); 349 | /// 350 | /// assert_eq!(Some(StatusCode::SERVICE_UNAVAILABLE), p.status); 351 | /// assert_eq!(Some("Service Unavailable"), p.title.as_deref()); 352 | /// assert_eq!(None, p.detail); 353 | /// assert_eq!(Some("https://httpstatuses.com/503".to_string()), p.type_url); 354 | /// assert_eq!(None, p.instance); 355 | /// ``` 356 | pub fn with_title_and_type>(status: T) -> Self { 357 | let status = status.into(); 358 | Self::with_title(status).type_url(format!("https://httpstatuses.com/{}", status.as_u16())) 359 | } 360 | 361 | /// Creates a new instance with the `title` and `type_url` derived from the 362 | /// [StatusCode]. 363 | /// 364 | /// Fails if the argument can not be converted into a [StatusCode]. 365 | /// 366 | /// #Example 367 | /// 368 | /// ```rust 369 | /// use http_api_problem::*; 370 | /// 371 | /// let p = HttpApiProblem::try_with_title_and_type(503).unwrap(); 372 | /// 373 | /// assert_eq!(Some(StatusCode::SERVICE_UNAVAILABLE), p.status); 374 | /// assert_eq!(Some("Service Unavailable"), p.title.as_deref()); 375 | /// assert_eq!(None, p.detail); 376 | /// assert_eq!(Some("https://httpstatuses.com/503".to_string()), p.type_url); 377 | /// assert_eq!(None, p.instance); 378 | /// ``` 379 | pub fn try_with_title_and_type>( 380 | status: T, 381 | ) -> Result 382 | where 383 | T::Error: Into, 384 | { 385 | let status = status.try_into().map_err(|e| e.into())?; 386 | 387 | Ok(Self::with_title_and_type(status)) 388 | } 389 | 390 | /// Creates a new instance without any field set. 391 | /// 392 | /// Prefer to use one of the other constructors which 393 | /// ensure that a [StatusCode] is set. If no [StatusCode] is 394 | /// set and a transformation to a response of a web framework 395 | /// is made a [StatusCode] becomes mandatory which in this case will 396 | /// default to `500`. 397 | pub fn empty() -> Self { 398 | HttpApiProblem { 399 | type_url: None, 400 | status: None, 401 | title: None, 402 | detail: None, 403 | instance: None, 404 | additional_fields: Default::default(), 405 | } 406 | } 407 | 408 | /// Sets the `status` 409 | /// 410 | /// #Example 411 | /// 412 | /// ```rust 413 | /// use http_api_problem::*; 414 | /// 415 | /// let p = HttpApiProblem::new(StatusCode::NOT_FOUND).title("Error"); 416 | /// 417 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 418 | /// assert_eq!(Some("Error"), p.title.as_deref()); 419 | /// assert_eq!(None, p.detail); 420 | /// assert_eq!(None, p.type_url); 421 | /// assert_eq!(None, p.instance); 422 | /// ``` 423 | pub fn status>(mut self, status: T) -> Self { 424 | self.status = Some(status.into()); 425 | self 426 | } 427 | 428 | /// Sets the `type_url` 429 | /// 430 | /// #Example 431 | /// 432 | /// ```rust 433 | /// use http_api_problem::*; 434 | /// 435 | /// let p = HttpApiProblem::new(StatusCode::NOT_FOUND).type_url("http://example.com/my/real_error"); 436 | /// 437 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 438 | /// assert_eq!(None, p.title); 439 | /// assert_eq!(None, p.detail); 440 | /// assert_eq!(Some("http://example.com/my/real_error".to_string()), p.type_url); 441 | /// assert_eq!(None, p.instance); 442 | /// ``` 443 | pub fn type_url>(mut self, type_url: T) -> Self { 444 | self.type_url = Some(type_url.into()); 445 | self 446 | } 447 | 448 | /// Tries to set the `status` 449 | /// 450 | /// Fails if the argument can not be converted into a [StatusCode]. 451 | /// 452 | /// #Example 453 | /// 454 | /// ```rust 455 | /// use http_api_problem::*; 456 | /// 457 | /// let p = HttpApiProblem::try_new(404).unwrap().title("Error"); 458 | /// 459 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 460 | /// assert_eq!(Some("Error"), p.title.as_deref()); 461 | /// assert_eq!(None, p.detail); 462 | /// assert_eq!(None, p.type_url); 463 | /// assert_eq!(None, p.instance); 464 | /// ``` 465 | pub fn try_status>( 466 | mut self, 467 | status: T, 468 | ) -> Result 469 | where 470 | T::Error: Into, 471 | { 472 | self.status = Some(status.try_into().map_err(|e| e.into())?); 473 | Ok(self) 474 | } 475 | 476 | /// Sets the `title` 477 | /// 478 | /// #Example 479 | /// 480 | /// ```rust 481 | /// use http_api_problem::*; 482 | /// 483 | /// let p = HttpApiProblem::new(StatusCode::NOT_FOUND).title("Another Error"); 484 | /// 485 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 486 | /// assert_eq!(Some("Another Error"), p.title.as_deref()); 487 | /// assert_eq!(None, p.detail); 488 | /// assert_eq!(None, p.type_url); 489 | /// assert_eq!(None, p.instance); 490 | /// ``` 491 | pub fn title>(mut self, title: T) -> Self { 492 | self.title = Some(title.into()); 493 | self 494 | } 495 | 496 | /// Sets the `detail` 497 | /// 498 | /// #Example 499 | /// 500 | /// ```rust 501 | /// use http_api_problem::*; 502 | /// 503 | /// let p = HttpApiProblem::new(StatusCode::NOT_FOUND).detail("a detailed description"); 504 | /// 505 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 506 | /// assert_eq!(None, p.title); 507 | /// assert_eq!(Some("a detailed description".to_string()), p.detail); 508 | /// assert_eq!(None, p.type_url); 509 | /// assert_eq!(None, p.instance); 510 | /// ``` 511 | pub fn detail>(mut self, detail: T) -> HttpApiProblem { 512 | self.detail = Some(detail.into()); 513 | self 514 | } 515 | 516 | /// Sets the `instance` 517 | /// 518 | /// #Example 519 | /// 520 | /// ```rust 521 | /// use http_api_problem::*; 522 | /// 523 | /// let p = HttpApiProblem::new(StatusCode::NOT_FOUND).instance("/account/1234/withdraw"); 524 | /// 525 | /// assert_eq!(Some(StatusCode::NOT_FOUND), p.status); 526 | /// assert_eq!(None, p.title); 527 | /// assert_eq!(None, p.detail); 528 | /// assert_eq!(None, p.type_url); 529 | /// assert_eq!(Some("/account/1234/withdraw".to_string()), p.instance); 530 | /// ``` 531 | pub fn instance>(mut self, instance: T) -> HttpApiProblem { 532 | self.instance = Some(instance.into()); 533 | self 534 | } 535 | 536 | /// Add a value that must be serializable. 537 | /// 538 | /// The key must not be one of the field names of this struct. 539 | /// 540 | /// These values get serialized into the JSON 541 | /// on top level. 542 | pub fn try_value( 543 | mut self, 544 | key: K, 545 | value: &V, 546 | ) -> Result> 547 | where 548 | V: Serialize, 549 | K: Into, 550 | { 551 | self.try_set_value(key, value)?; 552 | Ok(self) 553 | } 554 | 555 | /// Add a value that must be serializable. 556 | /// 557 | /// The key must not be one of the field names of this struct. 558 | /// If the key is a field name or the value is not serializable nothing happens. 559 | /// 560 | /// These values get serialized into the JSON 561 | /// on top level. 562 | pub fn value(mut self, key: K, value: &V) -> Self 563 | where 564 | V: Serialize, 565 | K: Into, 566 | { 567 | self.set_value(key, value); 568 | self 569 | } 570 | 571 | /// Add a value that must be serializable. 572 | /// 573 | /// The key must not be one of the field names of this struct. 574 | /// If the key is a field name or the value is not serializable nothing happens. 575 | /// 576 | /// These values get serialized into the JSON 577 | /// on top level. 578 | pub fn set_value(&mut self, key: K, value: &V) 579 | where 580 | V: Serialize, 581 | K: Into, 582 | { 583 | let _ = self.try_set_value(key, value); 584 | } 585 | 586 | /// Returns the deserialized field for the given key. 587 | /// 588 | /// If the key does not exist or the field is not deserializable to 589 | /// the target type `None` is returned 590 | pub fn get_value(&self, key: &str) -> Option 591 | where 592 | V: DeserializeOwned, 593 | { 594 | self.json_value(key) 595 | .and_then(|v| serde_json::from_value(v.clone()).ok()) 596 | } 597 | 598 | pub fn try_set_value( 599 | &mut self, 600 | key: K, 601 | value: &V, 602 | ) -> Result<(), Box> 603 | where 604 | V: Serialize, 605 | K: Into, 606 | { 607 | let key: String = key.into(); 608 | match key.as_ref() { 609 | "type" => return Err("'type' is a reserved field name".into()), 610 | "status" => return Err("'status' is a reserved field name".into()), 611 | "title" => return Err("'title' is a reserved field name".into()), 612 | "detail" => return Err("'detail' is a reserved field name".into()), 613 | "instance" => return Err("'instance' is a reserved field name".into()), 614 | "additional_fields" => { 615 | return Err("'additional_fields' is a reserved field name".into()); 616 | } 617 | _ => (), 618 | } 619 | let serialized = serde_json::to_value(value).map_err(|err| err.to_string())?; 620 | self.additional_fields.insert(key, serialized); 621 | Ok(()) 622 | } 623 | 624 | /// Returns a reference to the serialized fields 625 | /// 626 | /// If the key does not exist or the field is not deserializable to 627 | /// the target type `None` is returned 628 | pub fn additional_fields(&self) -> &HashMap { 629 | &self.additional_fields 630 | } 631 | 632 | /// Returns a mutable reference to the serialized fields 633 | /// 634 | /// If the key does not exist or the field is not deserializable to 635 | /// the target type `None` is returned 636 | pub fn additional_fields_mut(&mut self) -> &mut HashMap { 637 | &mut self.additional_fields 638 | } 639 | 640 | pub fn keys(&self) -> impl Iterator 641 | where 642 | V: DeserializeOwned, 643 | { 644 | self.additional_fields.keys() 645 | } 646 | 647 | /// Returns the `serde_json::Value` for the given key if the key exists. 648 | pub fn json_value(&self, key: &str) -> Option<&serde_json::Value> { 649 | self.additional_fields.get(key) 650 | } 651 | 652 | /// Serialize to a JSON `Vec` 653 | pub fn json_bytes(&self) -> Vec { 654 | serde_json::to_vec(self).unwrap() 655 | } 656 | 657 | /// Serialize to a JSON `String` 658 | pub fn json_string(&self) -> String { 659 | serde_json::to_string_pretty(self).unwrap() 660 | } 661 | 662 | /// Creates a [hyper] response. 663 | /// 664 | /// If status is `None` `500 - Internal Server Error` is the 665 | /// default. 666 | /// 667 | /// Requires the `hyper` feature 668 | #[cfg(feature = "hyper")] 669 | pub fn to_hyper_response(&self) -> hyper::Response { 670 | use hyper::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; 671 | use hyper::*; 672 | 673 | let json = self.json_string(); 674 | let length = json.len() as u64; 675 | 676 | let (mut parts, body) = Response::new(json).into_parts(); 677 | 678 | parts.headers.insert( 679 | CONTENT_TYPE, 680 | HeaderValue::from_static(PROBLEM_JSON_MEDIA_TYPE), 681 | ); 682 | parts.headers.insert( 683 | CONTENT_LENGTH, 684 | HeaderValue::from_str(&length.to_string()).unwrap(), 685 | ); 686 | parts.status = self 687 | .status_or_internal_server_error() 688 | .as_u16() 689 | .try_into() 690 | .unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR); 691 | 692 | Response::from_parts(parts, body) 693 | } 694 | 695 | /// Creates an axum [Response](axum_core::response::Response). 696 | /// 697 | /// If status is `None` `500 - Internal Server Error` is the 698 | /// default. 699 | /// 700 | /// Requires the `axum` feature 701 | #[cfg(feature = "axum")] 702 | pub fn to_axum_response(&self) -> axum_core::response::Response { 703 | use axum_core::response::IntoResponse; 704 | use http::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; 705 | 706 | let json = self.json_bytes(); 707 | let length = json.len() as u64; 708 | 709 | let status = self.status_or_internal_server_error(); 710 | 711 | let mut response = (status, json).into_response(); 712 | 713 | *response.status_mut() = self.status_or_internal_server_error(); 714 | 715 | response.headers_mut().insert( 716 | CONTENT_TYPE, 717 | HeaderValue::from_static(PROBLEM_JSON_MEDIA_TYPE), 718 | ); 719 | response.headers_mut().insert( 720 | CONTENT_LENGTH, 721 | HeaderValue::from_str(&length.to_string()).unwrap(), 722 | ); 723 | 724 | response 725 | } 726 | 727 | /// Creates an `actix` response. 728 | /// 729 | /// If status is `None` or not convertible 730 | /// to an actix status `500 - Internal Server Error` is the 731 | /// default. 732 | /// 733 | /// Requires the `actix-web` feature 734 | #[cfg(feature = "actix-web")] 735 | pub fn to_actix_response(&self) -> actix_web::HttpResponse { 736 | let effective_status = self.status_or_internal_server_error(); 737 | let actix_status = actix_web::http::StatusCode::from_u16(effective_status.as_u16()) 738 | .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); 739 | 740 | let json = self.json_bytes(); 741 | 742 | actix_web::HttpResponse::build(actix_status) 743 | .append_header(( 744 | actix_web::http::header::CONTENT_TYPE, 745 | PROBLEM_JSON_MEDIA_TYPE, 746 | )) 747 | .body(json) 748 | } 749 | 750 | /// Creates a `rocket` response. 751 | /// 752 | /// If status is `None` `500 - Internal Server Error` is the 753 | /// default. 754 | /// 755 | /// Requires the `rocket` feature 756 | #[cfg(feature = "rocket")] 757 | pub fn to_rocket_response(&self) -> rocket::Response<'static> { 758 | use rocket::http::ContentType; 759 | use rocket::http::Status; 760 | use rocket::Response; 761 | use std::io::Cursor; 762 | 763 | let content_type: ContentType = PROBLEM_JSON_MEDIA_TYPE.parse().unwrap(); 764 | let json = self.json_bytes(); 765 | let response = Response::build() 766 | .status(Status { 767 | code: self.status_code_or_internal_server_error(), 768 | }) 769 | .sized_body(json.len(), Cursor::new(json)) 770 | .header(content_type) 771 | .finalize(); 772 | 773 | response 774 | } 775 | 776 | /// Creates a [salvo] response. 777 | /// 778 | /// If status is `None` `500 - Internal Server Error` is the 779 | /// default. 780 | /// 781 | /// Requires the `salvo` feature 782 | #[cfg(feature = "salvo")] 783 | pub fn to_salvo_response(&self) -> salvo::Response { 784 | use salvo::hyper::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; 785 | use salvo::hyper::*; 786 | 787 | let json = self.json_string(); 788 | let length = json.len() as u64; 789 | 790 | let (mut parts, body) = Response::new(json).into_parts(); 791 | 792 | parts.headers.insert( 793 | CONTENT_TYPE, 794 | HeaderValue::from_static(PROBLEM_JSON_MEDIA_TYPE), 795 | ); 796 | parts.headers.insert( 797 | CONTENT_LENGTH, 798 | HeaderValue::from_str(&length.to_string()).unwrap(), 799 | ); 800 | parts.status = self 801 | .status_or_internal_server_error() 802 | .as_u16() 803 | .try_into() 804 | .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 805 | 806 | Response::from_parts(parts, body).into() 807 | } 808 | 809 | /// Creates a [tide] response. 810 | /// 811 | /// If status is `None` `500 - Internal Server Error` is the 812 | /// default. 813 | /// 814 | /// Requires the `tide` feature 815 | #[cfg(feature = "tide")] 816 | pub fn to_tide_response(&self) -> tide::Response { 817 | let json = self.json_bytes(); 818 | let length = json.len() as u64; 819 | 820 | tide::Response::builder(self.status_code_or_internal_server_error()) 821 | .body(json) 822 | .header("Content-Length", length.to_string()) 823 | .content_type(PROBLEM_JSON_MEDIA_TYPE) 824 | .build() 825 | } 826 | 827 | #[allow(dead_code)] 828 | fn status_or_internal_server_error(&self) -> StatusCode { 829 | self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) 830 | } 831 | 832 | #[allow(dead_code)] 833 | fn status_code_or_internal_server_error(&self) -> u16 { 834 | self.status_or_internal_server_error().as_u16() 835 | } 836 | 837 | // Deprecations 838 | 839 | #[deprecated(since = "0.50.0", note = "please use `with_title` instead")] 840 | pub fn with_title_from_status>(status: T) -> Self { 841 | Self::with_title(status) 842 | } 843 | #[deprecated(since = "0.50.0", note = "please use `with_title_and_type` instead")] 844 | pub fn with_title_and_type_from_status>(status: T) -> Self { 845 | Self::with_title_and_type(status) 846 | } 847 | #[deprecated(since = "0.50.0", note = "please use `status` instead")] 848 | pub fn set_status>(self, status: T) -> Self { 849 | self.status(status) 850 | } 851 | #[deprecated(since = "0.50.0", note = "please use `title` instead")] 852 | pub fn set_title>(self, title: T) -> Self { 853 | self.title(title) 854 | } 855 | #[deprecated(since = "0.50.0", note = "please use `detail` instead")] 856 | pub fn set_detail>(self, detail: T) -> Self { 857 | self.detail(detail) 858 | } 859 | #[deprecated(since = "0.50.0", note = "please use `type_url` instead")] 860 | pub fn set_type_url>(self, type_url: T) -> Self { 861 | self.type_url(type_url) 862 | } 863 | #[deprecated(since = "0.50.0", note = "please use `instance` instead")] 864 | pub fn set_instance>(self, instance: T) -> Self { 865 | self.instance(instance) 866 | } 867 | } 868 | 869 | impl fmt::Display for HttpApiProblem { 870 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 871 | if let Some(status) = self.status { 872 | write!(f, "{}", status)?; 873 | } else { 874 | write!(f, "")?; 875 | } 876 | 877 | match (self.title.as_ref(), self.detail.as_ref()) { 878 | (Some(title), Some(detail)) => return write!(f, " - {} - {}", title, detail), 879 | (Some(title), None) => return write!(f, " - {}", title), 880 | (None, Some(detail)) => return write!(f, " - {}", detail), 881 | (None, None) => (), 882 | } 883 | 884 | if let Some(type_url) = self.type_url.as_ref() { 885 | return write!(f, " - {}", type_url); 886 | } 887 | 888 | Ok(()) 889 | } 890 | } 891 | 892 | impl Error for HttpApiProblem { 893 | fn source(&self) -> Option<&(dyn Error + 'static)> { 894 | None 895 | } 896 | } 897 | 898 | impl From for HttpApiProblem { 899 | fn from(status: StatusCode) -> HttpApiProblem { 900 | HttpApiProblem::new(status) 901 | } 902 | } 903 | 904 | impl From for HttpApiProblem { 905 | fn from(error: std::convert::Infallible) -> HttpApiProblem { 906 | match error {} 907 | } 908 | } 909 | 910 | /// Creates an [hyper::Response] from something that can become an 911 | /// `HttpApiProblem`. 912 | /// 913 | /// If status is `None` `500 - Internal Server Error` is the 914 | /// default. 915 | #[cfg(feature = "hyper")] 916 | pub fn into_hyper_response>(what: T) -> hyper::Response { 917 | let problem: HttpApiProblem = what.into(); 918 | problem.to_hyper_response() 919 | } 920 | 921 | #[cfg(feature = "hyper")] 922 | impl From for hyper::Response { 923 | fn from(problem: HttpApiProblem) -> hyper::Response { 924 | problem.to_hyper_response() 925 | } 926 | } 927 | 928 | /// Creates an axum [Response](axum_core::response::Response) from something that can become an 929 | /// `HttpApiProblem`. 930 | /// 931 | /// If status is `None` `500 - Internal Server Error` is the 932 | /// default. 933 | /// 934 | /// Requires the `axum` feature 935 | #[cfg(feature = "axum")] 936 | pub fn into_axum_response>(what: T) -> axum_core::response::Response { 937 | let problem: HttpApiProblem = what.into(); 938 | problem.to_axum_response() 939 | } 940 | 941 | #[cfg(feature = "axum")] 942 | impl From for axum_core::response::Response { 943 | fn from(problem: HttpApiProblem) -> axum_core::response::Response { 944 | problem.to_axum_response() 945 | } 946 | } 947 | 948 | #[cfg(feature = "axum")] 949 | impl axum_core::response::IntoResponse for HttpApiProblem { 950 | fn into_response(self) -> axum_core::response::Response { 951 | self.into() 952 | } 953 | } 954 | 955 | // Creates an `actix::HttpResponse` from something that can become an 956 | /// `HttpApiProblem`. 957 | /// 958 | /// If status is `None` `500 - Internal Server Error` is the 959 | /// default. 960 | #[cfg(feature = "actix-web")] 961 | pub fn into_actix_response>(what: T) -> actix_web::HttpResponse { 962 | let problem: HttpApiProblem = what.into(); 963 | problem.to_actix_response() 964 | } 965 | 966 | #[cfg(feature = "actix-web")] 967 | impl From for actix_web::HttpResponse { 968 | fn from(problem: HttpApiProblem) -> actix_web::HttpResponse { 969 | problem.to_actix_response() 970 | } 971 | } 972 | 973 | /// Creates an `rocket::Response` from something that can become an 974 | /// `HttpApiProblem`. 975 | /// 976 | /// If status is `None` `500 - Internal Server Error` is the 977 | /// default. 978 | #[cfg(feature = "rocket")] 979 | pub fn into_rocket_response>(what: T) -> ::rocket::Response<'static> { 980 | let problem: HttpApiProblem = what.into(); 981 | problem.to_rocket_response() 982 | } 983 | 984 | #[cfg(feature = "rocket")] 985 | impl From for ::rocket::Response<'static> { 986 | fn from(problem: HttpApiProblem) -> ::rocket::Response<'static> { 987 | problem.to_rocket_response() 988 | } 989 | } 990 | 991 | #[cfg(feature = "rocket")] 992 | impl<'r> ::rocket::response::Responder<'r, 'static> for HttpApiProblem { 993 | fn respond_to(self, _request: &::rocket::Request) -> ::rocket::response::Result<'static> { 994 | Ok(self.into()) 995 | } 996 | } 997 | 998 | #[cfg(feature = "rocket-okapi")] 999 | impl rocket_okapi::response::OpenApiResponderInner for HttpApiProblem { 1000 | fn responses( 1001 | gen: &mut rocket_okapi::gen::OpenApiGenerator, 1002 | ) -> rocket_okapi::Result { 1003 | let mut responses = rocket_okapi::okapi::openapi3::Responses::default(); 1004 | let schema = gen.json_schema::(); 1005 | rocket_okapi::util::add_default_response_schema( 1006 | &mut responses, 1007 | PROBLEM_JSON_MEDIA_TYPE, 1008 | schema, 1009 | ); 1010 | Ok(responses) 1011 | } 1012 | } 1013 | 1014 | #[cfg(feature = "warp")] 1015 | impl warp::reject::Reject for HttpApiProblem {} 1016 | 1017 | /// Creates a [salvo::Response] from something that can become an 1018 | /// `HttpApiProblem`. 1019 | /// 1020 | /// If status is `None` `500 - Internal Server Error` is the 1021 | /// default. 1022 | #[cfg(feature = "salvo")] 1023 | pub fn into_salvo_response>(what: T) -> salvo::Response { 1024 | let problem: HttpApiProblem = what.into(); 1025 | problem.to_salvo_response() 1026 | } 1027 | 1028 | #[cfg(feature = "salvo")] 1029 | impl From for salvo::Response { 1030 | fn from(problem: HttpApiProblem) -> salvo::Response { 1031 | problem.to_salvo_response() 1032 | } 1033 | } 1034 | 1035 | /// Creates a [tide::Response] from something that can become an 1036 | /// `HttpApiProblem`. 1037 | /// 1038 | /// If status is `None` `500 - Internal Server Error` is the 1039 | /// default. 1040 | #[cfg(feature = "tide")] 1041 | pub fn into_tide_response>(what: T) -> tide::Response { 1042 | let problem: HttpApiProblem = what.into(); 1043 | problem.to_tide_response() 1044 | } 1045 | 1046 | #[cfg(feature = "tide")] 1047 | impl From for tide::Response { 1048 | fn from(problem: HttpApiProblem) -> tide::Response { 1049 | problem.to_tide_response() 1050 | } 1051 | } 1052 | 1053 | mod custom_http_status_serialization { 1054 | use http::StatusCode; 1055 | use serde::{Deserialize, Deserializer, Serializer}; 1056 | use std::convert::TryFrom; 1057 | 1058 | pub fn serialize(status: &Option, s: S) -> Result 1059 | where 1060 | S: Serializer, 1061 | { 1062 | if let Some(ref status_code) = *status { 1063 | return s.serialize_u16(status_code.as_u16()); 1064 | } 1065 | s.serialize_none() 1066 | } 1067 | 1068 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 1069 | where 1070 | D: Deserializer<'de>, 1071 | { 1072 | let s: Option = Option::deserialize(deserializer)?; 1073 | if let Some(numeric_status_code) = s { 1074 | // If the status code numeral is invalid we simply return None. 1075 | // This is a trade off to guarantee that the client can still 1076 | // have access to the rest of the problem struct instead of 1077 | // having to deal with an error caused by trying to deserialize an invalid status 1078 | // code. Additionally the received response still contains a status code. 1079 | let status_code = StatusCode::try_from(numeric_status_code).ok(); 1080 | return Ok(status_code); 1081 | } 1082 | 1083 | Ok(None) 1084 | } 1085 | } 1086 | 1087 | #[cfg(test)] 1088 | mod test; 1089 | --------------------------------------------------------------------------------