├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── APIErrorMiddleware │ ├── APIErrorMiddleware.swift │ └── Specialization │ └── Specialization.swift └── Tests ├── APIErrorMiddlewareTests ├── APIErrorMiddlewareTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Console", 6 | "repositoryURL": "https://github.com/vapor/console.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "5b9796d39f201b3dd06800437abd9d774a455e57", 10 | "version": "3.0.2" 11 | } 12 | }, 13 | { 14 | "package": "Core", 15 | "repositoryURL": "https://github.com/vapor/core.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "3b72f2cc3fb21d505fbe97215418951a8e4a2871", 19 | "version": "3.2.1" 20 | } 21 | }, 22 | { 23 | "package": "Crypto", 24 | "repositoryURL": "https://github.com/vapor/crypto.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "1b8c2ba5a42f1adf2aa812204678d8b16466fa59", 28 | "version": "3.1.2" 29 | } 30 | }, 31 | { 32 | "package": "DatabaseKit", 33 | "repositoryURL": "https://github.com/vapor/database-kit.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "0db303439e5ef8b6df50a2b6c4029edddee90cb0", 37 | "version": "1.0.1" 38 | } 39 | }, 40 | { 41 | "package": "HTTP", 42 | "repositoryURL": "https://github.com/vapor/http.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "5e766f72d81ef5fe8805d704efdffd17e4906134", 46 | "version": "3.0.6" 47 | } 48 | }, 49 | { 50 | "package": "Multipart", 51 | "repositoryURL": "https://github.com/vapor/multipart.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "7778dcb62f3efa845e8e2808937bb347575ba7ce", 55 | "version": "3.0.1" 56 | } 57 | }, 58 | { 59 | "package": "Routing", 60 | "repositoryURL": "https://github.com/vapor/routing.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "3219e328491b0853b8554c5a694add344d2c6cfb", 64 | "version": "3.0.1" 65 | } 66 | }, 67 | { 68 | "package": "Service", 69 | "repositoryURL": "https://github.com/vapor/service.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "281a70b69783891900be31a9e70051b6fe19e146", 73 | "version": "1.0.0" 74 | } 75 | }, 76 | { 77 | "package": "swift-nio", 78 | "repositoryURL": "https://github.com/apple/swift-nio.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "77dc77b9b5cdddfb3c385c5ee6cb74153d284967", 82 | "version": "1.7.2" 83 | } 84 | }, 85 | { 86 | "package": "swift-nio-ssl", 87 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "0adc938bc8de3d3829b842f9767d81c7480b8403", 91 | "version": "1.1.1" 92 | } 93 | }, 94 | { 95 | "package": "swift-nio-ssl-support", 96 | "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", 100 | "version": "1.0.0" 101 | } 102 | }, 103 | { 104 | "package": "swift-nio-zlib-support", 105 | "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", 109 | "version": "1.0.0" 110 | } 111 | }, 112 | { 113 | "package": "TemplateKit", 114 | "repositoryURL": "https://github.com/vapor/template-kit.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "43b57b5861d5181b906ac6411d28645e980bb638", 118 | "version": "1.0.1" 119 | } 120 | }, 121 | { 122 | "package": "URLEncodedForm", 123 | "repositoryURL": "https://github.com/vapor/url-encoded-form.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "57cf7fb9c1a1014c50bc05123684a9139ad44127", 127 | "version": "1.0.3" 128 | } 129 | }, 130 | { 131 | "package": "Validation", 132 | "repositoryURL": "https://github.com/vapor/validation.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "ab6c5a352d97c8687b91ed4963aef8e7cfe0795b", 136 | "version": "2.0.0" 137 | } 138 | }, 139 | { 140 | "package": "Vapor", 141 | "repositoryURL": "https://github.com/vapor/vapor.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "39b4d3fa36e58c6f7415c9da6c65a703bec34cea", 145 | "version": "3.0.3" 146 | } 147 | }, 148 | { 149 | "package": "WebSocket", 150 | "repositoryURL": "https://github.com/vapor/websocket.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "141cb4d3814dc8062cb0b2f43e72801b5dfcf272", 154 | "version": "1.0.1" 155 | } 156 | } 157 | ] 158 | }, 159 | "version": 1 160 | } 161 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "APIErrorMiddleware", 7 | products: [ 8 | .library(name: "APIErrorMiddleware", targets: ["APIErrorMiddleware"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0") 12 | ], 13 | targets: [ 14 | .target(name: "APIErrorMiddleware", dependencies: ["Vapor"]), 15 | .testTarget(name: "APIErrorMiddlewareTests", dependencies: ["APIErrorMiddleware"]), 16 | ] 17 | ) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APIErrorMiddleware 2 | 3 | A middleware to catch errors from route handlers and other middleware, and convert them to a JSON response. 4 | 5 | ## Instillation 6 | 7 | Add the package declaration to your project's manifest `dependencies` array: 8 | 9 | ```swift 10 | .package(url: "https://github.com/skelpo/APIErrorMiddleware.git", from: "0.1.0") 11 | ``` 12 | 13 | Then add the `APIErrorMiddleware` library to the `dependencies` array of any target you want to access the module in. 14 | 15 | ## Usage 16 | 17 | If you only want `APIErrorMiddleware` on some of your routes, you can create a new route group and register your routes with it: 18 | 19 | ```swift 20 | let api = router.group(APIErrorMiddleware()) 21 | api.get(...) 22 | ``` 23 | 24 | However, if you are creating an API service and want all errors to be caught by the middleware, you probably want to add it to the your `MiddlewareConfig`. In `configure.swift`, import the APIErrorMiddleware module. The body of the `configure(_:_:_:)` function probably has a `MiddlewareConfig` instance in it. If not, create one and register it with the services. 25 | 26 | You can register the middleware to the `MiddlewareConfig` using: 27 | 28 | ```swift 29 | middlewares.use(APIErrorMiddleware.self) 30 | ``` 31 | 32 | Most likely, you will want to register this middleware first. This ensures that all the errors are caught and we don't have any thrown after its responder is run. There are some that you might want to run afterwards though, such as Vapor's built in `DateMiddleware`. 33 | 34 | ## Specializations 35 | 36 | This middleware supports custom specializations which convert a Swift `Error` to a message and status code for the response returned by the middleware. 37 | 38 | To add specializations to the middleware, initialize it with the ones to use: 39 | 40 | ```swift 41 | middlewares.use(APIErrorMiddleware(specializations: [ 42 | ModelNotFound() 43 | ])) 44 | ``` 45 | 46 | The specializations available the package are the following: 47 | 48 | - `ModelNotFound`: Catches the `modelNotFound` error thrown by Fluent when getting a model from a parameter and converts it to a 404 error. 49 | 50 | To create your own specializations, just conform any type to `ErrorCatchingSpecialization` and implement the `convert(error:on:)` method. -------------------------------------------------------------------------------- /Sources/APIErrorMiddleware/APIErrorMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | /// Catches errors thrown from route handlers or middleware 5 | /// further down the responder chain and converts it to 6 | /// a JSON response. 7 | /// 8 | /// Errors with an identifier of `modelNotFound` get 9 | /// a 404 status code. 10 | public final class APIErrorMiddleware: Middleware, Service, ServiceType { 11 | 12 | /// Specializations for converting specific errors 13 | /// to `ErrorResult` objects. 14 | public var specializations: [ErrorCatchingSpecialization] 15 | 16 | /// The current environemnt that the application is in. 17 | public let environment: Environment 18 | 19 | /// Create an instance if `APIErrorMiddleware`. 20 | public init(environment: Environment, specializations: [ErrorCatchingSpecialization] = []) { 21 | self.specializations = specializations 22 | self.environment = environment 23 | } 24 | 25 | /// Creates a service instance. Used by a `ServiceFactory`. 26 | public static func makeService(for worker: Container) throws -> APIErrorMiddleware { 27 | #if canImport(Fluent) 28 | return APIErrorMiddleware(environment: worker.environment, specializations: [ModelNotFound()]) 29 | #else 30 | return APIErrorMiddleware(environment: worker.environment, specializations: []) 31 | #endif 32 | } 33 | 34 | /// Catch all errors thrown by the route handler or 35 | /// middleware futher down the responder chain and 36 | /// convert it to a JSON response. 37 | public func respond(to request: Request, chainingTo next: Responder) throws -> Future { 38 | 39 | // Call the next responder in the reponse chain. 40 | // If the future returned contains an error, or if 41 | // the next responder throws an error, catch it and 42 | // convert it to a JSON response. 43 | return Future.flatMap(on: request) { 44 | return try next.respond(to: request) 45 | }.mapIfError { error in 46 | return self.response(for: error, with: request) 47 | } 48 | } 49 | 50 | /// Creates a response with a JSON body. 51 | /// 52 | /// - Parameters: 53 | /// - error: The error that will be the value of the 54 | /// `error` key in the responses JSON body. 55 | /// - request: The request we wil get a container from 56 | /// to create the resulting reponse in. 57 | /// 58 | /// - Returns: A response with a JSON body with a `{"error":}` structure. 59 | private func response(for error: Error, with request: Request) -> Response { 60 | 61 | // The error message and status code 62 | // for the response returned by the 63 | // middleware. 64 | var result: ErrorResult! 65 | 66 | // The HTTP headers to send with the error 67 | var headers: HTTPHeaders = ["Content-Type": "application/json"] 68 | 69 | 70 | // Loop through the specializations, running 71 | // the error converter on each one. 72 | for converter in self.specializations { 73 | if let formatted = converter.convert(error: error, on: request) { 74 | 75 | // Found a non-nil response. Save it and break 76 | // from the loop so we don't override it. 77 | result = formatted 78 | break 79 | } 80 | } 81 | 82 | if result == nil { 83 | switch error { 84 | case let abort as AbortError: 85 | // We have an `AbortError` which has both a 86 | // status code and error message. 87 | // Assign the data to the correct varaibles. 88 | result = ErrorResult(message: abort.reason, status: abort.status) 89 | 90 | abort.headers.forEach { name, value in 91 | headers.add(name: name, value: value) 92 | } 93 | case let debuggable as Debuggable where !self.environment.isRelease: 94 | // Since we are not in a production environment and we 95 | // have a error conforming to `Debuggable`, we get the 96 | // data about the error and create a result with it. 97 | // We don't do this in a production env because the error 98 | // might container sensetive information 99 | let reason = debuggable.debuggableHelp(format: .short) 100 | result = ErrorResult(message: reason, status: .internalServerError) 101 | default: 102 | // We use a compiler OS check because `Error` can be directly 103 | // convertred to `CustomStringConvertible` on macOS, but not 104 | // on Linux. 105 | #if !os(macOS) 106 | if let error = error as? CustomStringConvertible { 107 | result = ErrorResult(message: error.description, status: nil) 108 | } else { 109 | result = ErrorResult(message: "Unknown error.", status: nil) 110 | } 111 | #else 112 | result = ErrorResult(message: (error as CustomStringConvertible).description, status: nil) 113 | #endif 114 | } 115 | } 116 | 117 | let json: Data 118 | do { 119 | // Create JSON with an `error` key with the `message` constant as its value. 120 | json = try JSONEncoder().encode(["error": result.message]) 121 | } catch { 122 | // Creating JSON data from error failed, so create a generic response message 123 | // because we can't have any Swift errors leaving the middleware. 124 | json = Data("{\"error\": \"Unable to encode error to JSON\"}".utf8) 125 | } 126 | 127 | // Create an HTTPResponse with 128 | // - The detected status code, using 129 | // 400 (Bad Request) if one does not exist. 130 | // - A `application/json` Content Type header. 131 | // A body with the JSON we created. 132 | let httpResponse = HTTPResponse( 133 | status: result.status ?? .badRequest, 134 | headers: headers, 135 | body: HTTPBody(data: json) 136 | ) 137 | 138 | // Create the response and return it. 139 | return Response(http: httpResponse, using: request.sharedContainer) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Sources/APIErrorMiddleware/Specialization/Specialization.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// The data used to create a response 4 | /// from a Swift error. 5 | public struct ErrorResult { 6 | 7 | /// The value of the 'error' key in 8 | /// the JSON returned by the middleware. 9 | public let message: String 10 | 11 | /// The status code for the response 12 | /// returned by the middleware 13 | public let status: HTTPStatus? 14 | 15 | /// Creates an instance with a 'message' and 'status'. 16 | public init(message: String, status: HTTPStatus?) { 17 | self.message = message 18 | self.status = status 19 | } 20 | } 21 | 22 | /// Converts a Swift error, along with data from a request, 23 | /// to a `ErroResult` instance, which can be used to create 24 | /// a JSON response 25 | public protocol ErrorCatchingSpecialization { 26 | 27 | /// Converts a Swift error, along with data from a request, 28 | /// to a `ErroResult` instance, which can be used to create 29 | /// a JSON response 30 | /// 31 | /// - Parameters: 32 | /// - error: The error to convert to a message. 33 | /// - request: The request that the error originated from. 34 | /// 35 | /// - Returns: An `ErrorResult` instance. The result should be `nil` 36 | /// if the specialization doesn't convert the kind of the error 37 | /// passed in. 38 | func convert(error: Error, on request: Request) -> ErrorResult? 39 | } 40 | 41 | // MARK: - ErrorCatchingSpecialization implementations 42 | 43 | #if canImport(Fluent) 44 | import Fluent 45 | 46 | /// Catches Fluent's `modelNotFound` error and returns a 404 status code. 47 | public struct ModelNotFound: ErrorCatchingSpecialization { 48 | public init() {} 49 | 50 | public func convert(error: Error, on request: Request) -> ErrorResult? { 51 | if 52 | let wrappingError = error as? NotFound, 53 | let error = wrappingError.rootCause as? FluentError 54 | { 55 | return ErrorResult(message: error.reason, status: .notFound) 56 | } 57 | 58 | return nil 59 | } 60 | } 61 | #endif 62 | -------------------------------------------------------------------------------- /Tests/APIErrorMiddlewareTests/APIErrorMiddlewareTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import APIErrorMiddleware 3 | 4 | final class APIErrorMiddlewareTests: XCTestCase { 5 | func testExample() {} 6 | 7 | 8 | static var allTests = [ 9 | ("testExample", testExample), 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /Tests/APIErrorMiddlewareTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !os(macOS) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(APIErrorMiddlewareTests.allTests), 7 | ] 8 | } 9 | #endif -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import APIErrorMiddlewareTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += APIErrorMiddlewareTests.allTests() 7 | XCTMain(tests) --------------------------------------------------------------------------------