├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── VaporOpenAPI │ ├── ContentConfiguration+JSONEncoder.swift │ ├── EventLoopFuture+OpenAPI.swift │ ├── Exports.swift │ ├── QueryParam+OpenAPI.swift │ ├── Route+Metadata.swift │ ├── Sampleable+OpenAPIExample.swift │ ├── VaporHttp+OpenAPI.swift │ ├── VaporPathComponent+OpenAPI.swift │ ├── VaporRoute+OpenAPI.swift │ └── VaporRoutes+OpenAPI.swift └── Tests └── VaporOpenAPITests └── VaporOpenAPITests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mathew Polzin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-http-client", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swift-server/async-http-client.git", 7 | "state" : { 8 | "revision" : "16f7e62c08c6969899ce6cc277041e868364e5cf", 9 | "version" : "1.19.0" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "eab9edff78e8ace20bd7cb6e792ab46d54f59ab9", 18 | "version" : "1.18.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/vapor/console-kit.git", 25 | "state" : { 26 | "revision" : "f4ef965dadd999f7e4687053153c97b8b320819c", 27 | "version" : "4.10.1" 28 | } 29 | }, 30 | { 31 | "identity" : "multipart-kit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/vapor/multipart-kit.git", 34 | "state" : { 35 | "revision" : "1adfd69df2da08f7931d4281b257475e32c96734", 36 | "version" : "4.5.4" 37 | } 38 | }, 39 | { 40 | "identity" : "openapikit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/mattpolzin/OpenAPIKit.git", 43 | "state" : { 44 | "revision" : "ae98338a8e660ae547b058ebb69c010e70b64e31", 45 | "version" : "3.0.0" 46 | } 47 | }, 48 | { 49 | "identity" : "openapireflection", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/mattpolzin/OpenAPIReflection.git", 52 | "state" : { 53 | "revision" : "aa9d56c75b913818c513a3b0a2cd716b8443e81e", 54 | "version" : "2.0.0" 55 | } 56 | }, 57 | { 58 | "identity" : "routing-kit", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/vapor/routing-kit.git", 61 | "state" : { 62 | "revision" : "e0539da5b60a60d7381f44cdcf04036f456cee2f", 63 | "version" : "4.8.0" 64 | } 65 | }, 66 | { 67 | "identity" : "sampleable", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/mattpolzin/Sampleable.git", 70 | "state" : { 71 | "revision" : "df44bf1a860481109dcf455e3c6daf0a0f1bc259", 72 | "version" : "2.1.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-algorithms", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-algorithms.git", 79 | "state" : { 80 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 81 | "version" : "1.0.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-atomics", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-atomics.git", 88 | "state" : { 89 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10", 90 | "version" : "1.1.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-backtrace", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/swift-server/swift-backtrace.git", 97 | "state" : { 98 | "revision" : "80746bdd0ac8a7d83aad5d89dac3cbf15de652e6", 99 | "version" : "1.3.4" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-collections", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-collections.git", 106 | "state" : { 107 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 108 | "version" : "1.0.4" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-crypto", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/apple/swift-crypto.git", 115 | "state" : { 116 | "revision" : "629f0b679d0fd0a6ae823d7f750b9ab032c00b80", 117 | "version" : "3.0.0" 118 | } 119 | }, 120 | { 121 | "identity" : "swift-log", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/apple/swift-log.git", 124 | "state" : { 125 | "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", 126 | "version" : "1.5.3" 127 | } 128 | }, 129 | { 130 | "identity" : "swift-metrics", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/apple/swift-metrics.git", 133 | "state" : { 134 | "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", 135 | "version" : "2.4.1" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-nio", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/apple/swift-nio.git", 142 | "state" : { 143 | "revision" : "3db5c4aeee8100d2db6f1eaf3864afdad5dc68fd", 144 | "version" : "2.59.0" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-nio-extras", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-nio-extras.git", 151 | "state" : { 152 | "revision" : "fb70a0f5e984f23be48b11b4f1909f3bee016178", 153 | "version" : "1.19.1" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-nio-http2", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/apple/swift-nio-http2.git", 160 | "state" : { 161 | "revision" : "9c22e4f810ce780453f563fba98e1a1039f83d56", 162 | "version" : "1.28.1" 163 | } 164 | }, 165 | { 166 | "identity" : "swift-nio-ssl", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/apple/swift-nio-ssl.git", 169 | "state" : { 170 | "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", 171 | "version" : "2.25.0" 172 | } 173 | }, 174 | { 175 | "identity" : "swift-nio-transport-services", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 178 | "state" : { 179 | "revision" : "e7403c35ca6bb539a7ca353b91cc2d8ec0362d58", 180 | "version" : "1.19.0" 181 | } 182 | }, 183 | { 184 | "identity" : "swift-numerics", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/apple/swift-numerics", 187 | "state" : { 188 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 189 | "version" : "1.0.2" 190 | } 191 | }, 192 | { 193 | "identity" : "vapor", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/vapor/vapor.git", 196 | "state" : { 197 | "revision" : "3744d4200c9293584603d650f8ef70ae7e6896e7", 198 | "version" : "4.86.0" 199 | } 200 | }, 201 | { 202 | "identity" : "vaportypedroutes", 203 | "kind" : "remoteSourceControl", 204 | "location" : "https://github.com/mattpolzin/VaporTypedRoutes.git", 205 | "state" : { 206 | "revision" : "f28103ae5a8124dfb8b2386460e7ff8b0dd9aef7", 207 | "version" : "0.10.0" 208 | } 209 | }, 210 | { 211 | "identity" : "websocket-kit", 212 | "kind" : "remoteSourceControl", 213 | "location" : "https://github.com/vapor/websocket-kit.git", 214 | "state" : { 215 | "revision" : "53fe0639a98903858d0196b699720decb42aee7b", 216 | "version" : "2.14.0" 217 | } 218 | }, 219 | { 220 | "identity" : "yams", 221 | "kind" : "remoteSourceControl", 222 | "location" : "https://github.com/jpsim/Yams.git", 223 | "state" : { 224 | "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", 225 | "version" : "5.0.6" 226 | } 227 | } 228 | ], 229 | "version" : 2 230 | } 231 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "VaporOpenAPI", 7 | platforms: [ 8 | .macOS(.v12), 9 | .iOS(.v13), 10 | .watchOS(.v6), 11 | .tvOS(.v13) 12 | ], 13 | products: [ 14 | .library( 15 | name: "VaporOpenAPI", 16 | targets: ["VaporOpenAPI"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/vapor/vapor.git", from: "4.86.0"), 20 | .package(url: "https://github.com/mattpolzin/VaporTypedRoutes.git", from: "0.10.0"), 21 | .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "3.0.0"), 22 | .package(url: "https://github.com/mattpolzin/OpenAPIReflection.git", from: "2.0.0") 23 | ], 24 | targets: [ 25 | .target( 26 | name: "VaporOpenAPI", 27 | dependencies: [ 28 | .product(name: "Vapor", package: "vapor"), 29 | "VaporTypedRoutes", 30 | "OpenAPIKit", 31 | "OpenAPIReflection" 32 | ] 33 | ), 34 | .testTarget( 35 | name: "VaporOpenAPITests", 36 | dependencies: [ 37 | "VaporOpenAPI", 38 | .product(name: "XCTVapor", package: "vapor") 39 | ] 40 | ), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VaporOpenAPI 2 | 3 | This is more of a prototype of a library, not a polished or feature-complete API by a long stretch. That said, folks have found it useful and I certainly encourage you to PR fixes and improvements if you also find this library useful! 4 | 5 | As of the release of OpenAPIKit v3.0.0, this library produces OpenAPI v3.1 compatible documents instead of OpenAPI v3.0 compatible documents. 6 | 7 | See https://github.com/mattpolzin/VaporOpenAPIExample for an example of a simple app using this library. 8 | 9 | You use `VaporTypedRoutes.TypedRequest` instead of `Vapor.Request` to form a request context that can be used to build out an OpenAPI description. You use custom methods to attach your routes to the app. These methods mirror the methods available in Vapor already. 10 | 11 | You can use the library like this with Swift Concurrency: 12 | 13 | ```swift 14 | enum WidgetController { 15 | struct ShowRoute: RouteContext { 16 | ... 17 | } 18 | 19 | static func show(_ req: TypedRequest) try await -> Response { 20 | ... 21 | } 22 | } 23 | 24 | func routes(_ app: Application) { 25 | app.get( 26 | "widgets", 27 | ":type".description("The type of widget"), 28 | ":id".parameterType(Int.self), 29 | use: WidgetController.show 30 | ).tags("Widgets") 31 | .summary("Get a widget") 32 | } 33 | ``` 34 | 35 | ...and like this with a NIO EventLoopFuture: 36 | 37 | ```swift 38 | enum WidgetController { 39 | struct ShowRoute: RouteContext { 40 | ... 41 | } 42 | 43 | static func show(_ req: TypedRequest) -> EventLoopFuture { 44 | ... 45 | } 46 | } 47 | 48 | func routes(_ app: Application) { 49 | app.get( 50 | "widgets", 51 | ":type".description("The type of widget"), 52 | ":id".parameterType(Int.self), 53 | use: WidgetController.show 54 | ).tags("Widgets") 55 | .summary("Get a widget") 56 | } 57 | ``` 58 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/ContentConfiguration+JSONEncoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentConfiguration+JSONEncoder.swift 3 | // 4 | // 5 | // Created by Charlie Welsh on 9/16/22. 6 | // 7 | 8 | import Vapor 9 | 10 | extension ContentConfiguration { 11 | /// The configured JSON encoder for the content configuration. 12 | func jsonEncoder() throws -> JSONEncoder { 13 | guard let encoder = try self 14 | .requireEncoder(for: .json) 15 | as? JSONEncoder 16 | else { 17 | // This is an Abort since this is an error with a Vapor component. 18 | throw Abort( 19 | .internalServerError, reason: "Couldn't get encoder for OpenAPI schema.") 20 | } 21 | 22 | return encoder 23 | } 24 | 25 | /// The content JSON encoder, but with the settings to encode an OpenAPI schema. 26 | func openAPIJSONEncoder() throws -> JSONEncoder { 27 | let encoder = try self.jsonEncoder() 28 | encoder.dateEncodingStrategy = .iso8601 29 | encoder.outputFormatting = .sortedKeys 30 | 31 | return encoder 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/EventLoopFuture+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventLoopFuture+OpenAPI.swift 3 | // App 4 | // 5 | // Created by Mathew Polzin on 12/8/19. 6 | // 7 | 8 | import Foundation 9 | import NIO 10 | import OpenAPIKit 11 | import OpenAPIReflection 12 | import Vapor 13 | 14 | extension EventLoopFuture: OpenAPIEncodedSchemaType where Value: OpenAPIEncodedSchemaType { 15 | /// Get the OpenAPISchema for for the value using a given encoder. 16 | /// - Parameters: 17 | /// - encoder: The JSONEncoder to encode the schema with. 18 | /// - Returns: A JSONSchema object for the `EventLoopFuture`'s value. 19 | public static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { 20 | return try Value.openAPISchema(using: encoder) 21 | } 22 | 23 | /// Get the OpenAPISchema for for the value using the ContentConfiguration. 24 | /// - Returns: A JSONSchema object for the `EventLoopFuture`'s value. 25 | public static func openAPISchema() throws -> JSONSchema { 26 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 27 | 28 | return try self.openAPISchema(using: encoder) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/Exports.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Exports.swift 3 | // VaporOpenAPI 4 | // 5 | // Created by Mathew Polzin on 12/28/19. 6 | // 7 | 8 | @_exported import OpenAPIKit 9 | @_exported import VaporTypedRoutes 10 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/QueryParam+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryParam+OpenAPI.swift 3 | // AppAPIDocumentation 4 | // 5 | // Created by Mathew Polzin on 12/8/19. 6 | // 7 | 8 | import OpenAPIKit 9 | 10 | /// A protocol meant only for internal use. 11 | /// Allows generic constraints and runtime casts on arrays 12 | /// even though concrete arrays have an associated type. 13 | protocol _Array { 14 | /// The type of element in the array. 15 | static var elementType: Any.Type { get } 16 | } 17 | extension Array: _Array { 18 | static var elementType: Any.Type { 19 | return Element.self 20 | } 21 | } 22 | 23 | protocol _Dictionary { 24 | static var valueType: Any.Type { get } 25 | } 26 | extension Dictionary: _Dictionary { 27 | static var valueType: Any.Type { 28 | return Value.self 29 | } 30 | } 31 | 32 | extension AbstractQueryParam { 33 | /// Get the equivalent OpenAPI parameter for the query param. 34 | public func openAPIQueryParam() -> OpenAPI.Parameter { 35 | let schema: OpenAPI.Parameter.SchemaContext 36 | 37 | /// Guess the equivalent JSON Schema type for a given Swift type. 38 | func guessJsonSchema(for type: Any.Type) -> JSONSchema { 39 | guard let schemaType = type as? OpenAPISchemaType.Type else { 40 | return .string 41 | } 42 | let ret = schemaType.openAPISchema 43 | guard let allowedValues = self.allowedValues else { 44 | return ret 45 | } 46 | 47 | return ret.with(allowedValues: allowedValues.map { AnyCodable($0) }) 48 | } 49 | 50 | let style: OpenAPI.Parameter.SchemaContext.Style 51 | let explode: Bool 52 | let jsonSchema: JSONSchema 53 | switch swiftType { 54 | case let t as _Dictionary.Type: 55 | style = .deepObject 56 | explode = true 57 | jsonSchema = .object( 58 | additionalProperties: .init(guessJsonSchema(for: t.valueType)) 59 | ) 60 | case let t as _Array.Type: 61 | style = .form 62 | explode = false 63 | jsonSchema = .array( 64 | items: guessJsonSchema(for: t.elementType) 65 | ) 66 | default: 67 | style = .form 68 | explode = true 69 | jsonSchema = guessJsonSchema(for: swiftType) 70 | } 71 | 72 | schema = .init( 73 | jsonSchema, 74 | style: style, 75 | explode: explode 76 | ) 77 | 78 | return .init( 79 | name: name, 80 | context: .query(required: `required`), 81 | schema: schema, 82 | description: description, 83 | deprecated: deprecated 84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/Route+Metadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route+Metadata.swift 3 | // VaporOpenAPI 4 | // 5 | // Created by Mathew Polzin on 12/6/19. 6 | // 7 | 8 | import Vapor 9 | 10 | // 11 | // These extensions provide an easy way to add 12 | // metadata to routes at the defintion site. 13 | // 14 | // For example: 15 | // 16 | // routes.get("hello world") { return "hello world" } 17 | // .summary("Says hello") 18 | // .tags("Greetings") 19 | // 20 | extension Route { 21 | // NOTE `func description(_:)` exists out-of-box. 22 | 23 | /// Add an OpenAPI-compatible summary to the route. 24 | @discardableResult 25 | public func summary(_ summary: String) -> Route { 26 | userInfo["openapi:summary"] = summary 27 | return self 28 | } 29 | 30 | /// Add OpenAPI-compatible tags to the route. 31 | @discardableResult 32 | public func tags(_ tags: String...) -> Route { 33 | return self.tags(tags) 34 | } 35 | 36 | /// Add OpenAPI-compatible tags to the route. 37 | @discardableResult 38 | public func tags(_ tags: [String]) -> Route { 39 | userInfo["openapi:tags"] = tags 40 | return self 41 | } 42 | 43 | /// Add OpenAPI-compatible deprecation notice to the route. 44 | @discardableResult 45 | public func deprecated() -> Route { 46 | userInfo["openapi:deprecated"] = true 47 | return self 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/Sampleable+OpenAPIExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sampleable+OpenAPIExample.swift 3 | // 4 | // 5 | // Created by Mathew Polzin on 3/21/20. 6 | // 7 | 8 | import Foundation 9 | import OpenAPIKit 10 | import OpenAPIReflection 11 | import Sampleable 12 | import Vapor 13 | 14 | /// Types that provide an example for the OpenAPI definition. 15 | public protocol OpenAPIExampleProvider: OpenAPIEncodedSchemaType { 16 | /// The example for the OpenAPI schema. 17 | /// - Parameters: 18 | /// - encoder: The encoder to use to generate the OpenAPI example with. 19 | static func openAPIExample(using encoder: JSONEncoder) throws -> AnyCodable? 20 | } 21 | 22 | extension OpenAPIExampleProvider where Self: Encodable, Self: Sampleable { 23 | /// The example for the OpenAPI schema. 24 | public static func openAPIExample() throws -> AnyCodable? { 25 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 26 | 27 | return try self.openAPIExample(using: encoder) 28 | } 29 | 30 | // Automatically implement the OpenAPI example for types conforming to Encodable and Sampleable. 31 | public static func openAPIExample(using encoder: JSONEncoder) throws -> AnyCodable? { 32 | let encodedSelf = try encoder.encode(sample) 33 | return try JSONDecoder().decode(AnyCodable.self, from: encodedSelf) 34 | } 35 | 36 | /// Get the OpenAPI schema for the `OpenAPIExampleProvider`. 37 | public static func openAPISchema() throws -> JSONSchema { 38 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 39 | 40 | return try self.openAPISchema(using: encoder) 41 | } 42 | 43 | /// Get the OpenAPI schema for the `OpenAPIExampleProvider`. 44 | public static func openAPISchema(using encoder: JSONEncoder) throws -> JSONSchema { 45 | return try genericOpenAPISchemaGuess(using: encoder) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/VaporHttp+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VaporHttp+OpenAPI.swift 3 | // App 4 | // 5 | // Created by Mathew Polzin on 12/8/19. 6 | // 7 | 8 | import Vapor 9 | import OpenAPIKit 10 | 11 | extension HTTPMethod { 12 | /// The equivalent OpenAPI verb for the `HTTPMethod`. 13 | internal func openAPIVerb() throws -> OpenAPI.HttpMethod { 14 | switch self { 15 | case .GET: 16 | return .get 17 | case .PUT: 18 | return .put 19 | case .POST: 20 | return .post 21 | case .DELETE: 22 | return .delete 23 | case .OPTIONS: 24 | return .options 25 | case .HEAD: 26 | return .head 27 | case .PATCH: 28 | return .patch 29 | case .TRACE: 30 | return .trace 31 | default: 32 | throw OpenAPIHTTPMethodError.unsupportedHttpMethod(String(describing: self)) 33 | } 34 | } 35 | 36 | /// Errors that can be thrown when attempting to convert from `HTTPMethod` to OpenAPI's equivalent. 37 | enum OpenAPIHTTPMethodError: Swift.Error { 38 | case unsupportedHttpMethod(String) 39 | } 40 | } 41 | 42 | extension HTTPMediaType { 43 | /// The equivalent OpenAPI `ContentType` for the `HTTPMediaType`. 44 | public var openAPIContentType: OpenAPI.ContentType? { 45 | return OpenAPI.ContentType(rawValue: "\(self.type)/\(self.subType)") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/VaporPathComponent+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VaporPathComponent+OpenAPI.swift 3 | // App 4 | // 5 | // Created by Mathew Polzin on 12/8/19. 6 | // 7 | 8 | import Vapor 9 | import VaporTypedRoutes 10 | import OpenAPIKit 11 | 12 | extension Vapor.PathComponent { 13 | /// The OpenAPI equivalent of the path component's name. 14 | internal func openAPIPathComponent() throws -> String { 15 | switch self { 16 | case .constant(let val): 17 | return val 18 | case .parameter(let val): 19 | return "{\(val)}" 20 | case .anything, 21 | .catchall: 22 | throw OpenAPIPathComponentError.unsupportedPathComponent(String(describing: self)) 23 | } 24 | } 25 | 26 | /// The OpenAPI equivalent of the path parameter (including a type, if specified). 27 | internal func openAPIPathParameter(in route: Vapor.Route) -> OpenAPI.Parameter? { 28 | switch self { 29 | case .parameter(let name): 30 | let meta = route.userInfo[AnySendableHashable("typed_parameter:\(name)")] as? TypedPathComponent.Meta 31 | 32 | return .init( 33 | name: name, 34 | context: .path, 35 | schema: (meta?.type as? OpenAPISchemaType.Type)?.openAPISchema ?? .string, 36 | description: meta?.description 37 | ) 38 | default: 39 | return nil 40 | } 41 | } 42 | 43 | /// Errors that can arise with the conversion from Vapor path component to the OpenAPI equivalent. 44 | enum OpenAPIPathComponentError: Swift.Error { 45 | case unsupportedPathComponent(String) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/VaporRoute+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VaporRoute+OpenAPIEncodedNodeType.swift 3 | // AppAPIDocumentation 4 | // 5 | // Created by Mathew Polzin on 10/19/19. 6 | // 7 | 8 | import Foundation 9 | import OpenAPIKit 10 | import OpenAPIReflection 11 | import Vapor 12 | import Sampleable 13 | 14 | /// Types that contain wrapped values (for OpenAPI conversion). 15 | protocol _Wrapper { 16 | /// The wrapped type. 17 | static var wrappedType: Any.Type { get } 18 | } 19 | 20 | extension Optional: _Wrapper { 21 | static var wrappedType: Any.Type { 22 | return Wrapped.self 23 | } 24 | } 25 | 26 | extension AbstractRouteContext { 27 | /// The OpenAPI equivalents of any responses in the route context. 28 | /// - Returns: A `Response.Map` containing the converted responses. 29 | public static func openAPIResponses() throws -> OpenAPI.Response.Map { 30 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 31 | 32 | return try self.openAPIResponses(using: encoder) 33 | } 34 | 35 | /// The OpenAPI equivalents of any responses in the route context. 36 | /// - Parameters: 37 | /// - encoder: The `JSONEncoder` to generate the responses with. 38 | /// - Returns: A `Response.Map` containing the converted responses. 39 | public static func openAPIResponses(using encoder: JSONEncoder) throws -> OpenAPI.Response.Map { 40 | let responseTuples = try responseBodyTuples 41 | .compactMap { responseTuple -> (OpenAPI.Response.StatusCode, OpenAPI.Response)? in 42 | 43 | let statusCode = OpenAPI.Response.StatusCode.status( 44 | code: responseTuple.statusCode 45 | ) 46 | 47 | let responseReason = HTTPStatus(statusCode: responseTuple.statusCode) 48 | .reasonPhrase 49 | 50 | if responseTuple.responseBodyType == EmptyResponseBody.self { 51 | return ( 52 | statusCode, 53 | OpenAPI.Response( 54 | description: responseReason 55 | ) 56 | ) 57 | } 58 | 59 | let contentType = responseTuple.contentType?.openAPIContentType 60 | 61 | let example = reverseEngineeredExample(for: responseTuple.responseBodyType, using: encoder) 62 | 63 | // first handle things explicitly supporting OpenAPI 64 | if let schema = try (responseTuple.responseBodyType as? OpenAPIEncodedSchemaType.Type)?.openAPISchema(using: encoder) { 65 | return ( 66 | statusCode, 67 | OpenAPI.Response( 68 | description: responseReason, 69 | content: [ 70 | (contentType ?? .json): .init(schema: .init(schema), example: example) 71 | ] 72 | ) 73 | ) 74 | } 75 | 76 | // then try for a generic guess if the content type is JSON 77 | if (contentType == .json || contentType == .jsonapi), 78 | let sample = (responseTuple.responseBodyType as? AbstractSampleable.Type)?.abstractSample, 79 | let schema = try? genericOpenAPISchemaGuess(for: sample, using: encoder) { 80 | 81 | return ( 82 | statusCode, 83 | OpenAPI.Response( 84 | description: responseReason, 85 | content: [ 86 | (contentType ?? .json): .init(schema: .init(schema), example: example) 87 | ] 88 | ) 89 | ) 90 | } 91 | 92 | // finally, handle binary files and give a wildly vague schema for anything else. 93 | let schema: JSONSchema 94 | let stringLikeTypes: [OpenAPI.ContentType?] = [ 95 | .any, .anyText, .css, .csv, .form, .html, .javascript, .json, .jsonapi, .multipartForm, .rtf, .txt, .xml, .yaml 96 | ] 97 | let binaryLikeTypes: [OpenAPI.ContentType?] = [ 98 | .anyApplication, .anyAudio, .anyImage, .anyVideo, .bmp, .jpg, .mov, .mp3, .mp4, .mpg, .pdf, .rar, .tar, .tif, .zip 99 | ] 100 | if stringLikeTypes.contains(contentType) { 101 | schema = .string 102 | } else if binaryLikeTypes.contains(contentType) { 103 | schema = .string(contentEncoding: .binary) 104 | } else { 105 | schema = .string 106 | } 107 | 108 | return contentType.map { 109 | OpenAPI.Response( 110 | description: responseReason, 111 | content: [ 112 | $0: .init(schema: .init(schema)) 113 | ] 114 | ) 115 | }.map { (statusCode, $0) } 116 | } 117 | 118 | return OrderedDictionary( 119 | responseTuples, 120 | uniquingKeysWith: { $1 } 121 | ).mapValues { .init($0) } 122 | } 123 | } 124 | 125 | extension Vapor.Route { 126 | /// Generates the constructor for an OpenAPI `PathOperation` equivalent to the Vapor `Route`. 127 | func openAPIPathOperationConstructor() throws -> PathOperationConstructor { 128 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 129 | 130 | return try self.openAPIPathOperationConstructor(using: encoder) 131 | } 132 | 133 | /// Generates the constructor for an OpenAPI `PathOperation` equivalent to the Vapor `Route`. 134 | /// - Parameters: 135 | /// - encoder: The JSON encoder to generate the `PathOperationConstructor` with. 136 | func openAPIPathOperationConstructor(using encoder: JSONEncoder) throws -> PathOperationConstructor { 137 | let pathComponents = try OpenAPI.Path( 138 | path.map { try $0.openAPIPathComponent() } 139 | ) 140 | 141 | let verb = try method.openAPIVerb() 142 | 143 | let requestBody = try openAPIRequest(for: requestType, using: encoder) 144 | 145 | let responses = try openAPIResponses(from: responseType, using: encoder) 146 | 147 | let pathParameters = path.compactMap { $0.openAPIPathParameter(in: self) } 148 | let queryParameters = openAPIQueryParams(from: responseType) 149 | 150 | let parameters = pathParameters 151 | + queryParameters 152 | 153 | return { context in 154 | 155 | let operation = OpenAPI.Operation( 156 | tags: context.tags, 157 | summary: context.summary, 158 | description: context.description, 159 | externalDocs: nil, 160 | operationId: nil, 161 | parameters: parameters.map { .init($0) }, 162 | requestBody: requestBody, 163 | responses: responses, 164 | deprecated: context.deprecated, 165 | servers: nil 166 | ) 167 | 168 | return ( 169 | path: pathComponents, 170 | verb: verb, 171 | operation: operation 172 | ) 173 | } 174 | } 175 | 176 | /// Generates an OpenAPI `PathOperation` equivalent to the Vapor `Route`. 177 | func openAPIPathOperation() throws -> PathOperation { 178 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 179 | 180 | return try self.openAPIPathOperation(using: encoder) 181 | } 182 | 183 | /// Generates an OpenAPI `PathOperation` equivalent to the Vapor `Route`. 184 | /// - Parameters: 185 | /// - encoder: An optional override JSON encoder to generate the `PathOperation` with. Otherwise, defaults to the ContentEncoder. 186 | func openAPIPathOperation(using encoder: JSONEncoder) throws -> PathOperation { 187 | let operation = try openAPIPathOperationConstructor(using: encoder) 188 | 189 | let summary = userInfo["openapi:summary"] as? String 190 | let description = userInfo["description"] as? String 191 | let tags = userInfo["openapi:tags"] as? [String] 192 | let deprecated = userInfo["openapi:deprecated"] as? Bool ?? false 193 | 194 | return operation( 195 | ( 196 | summary: summary, 197 | description: description, 198 | tags: tags, 199 | deprecated: deprecated 200 | ) 201 | ) 202 | } 203 | 204 | /// Generates an array of OpenAPI parameters equivalent to the query params in the given response's body type. 205 | /// - Parameters: 206 | /// - responseType: The type of response to get the query parameters from. Must be an `AbstractRouteContext` to extract query parameters. 207 | private func openAPIQueryParams(from responseType: Any.Type) -> [OpenAPI.Parameter] { 208 | if let responseBodyType = responseType as? AbstractRouteContext.Type { 209 | return responseBodyType 210 | .requestQueryParams 211 | .map { $0.openAPIQueryParam() } 212 | } 213 | 214 | return [] 215 | } 216 | 217 | /// Generates the equivalent OpenAPI request for a given request type. 218 | /// - Parameters: 219 | /// - requestType: The request body type to convert. 220 | func openAPIRequest(for requestType: Any.Type) throws -> OpenAPI.Request? { 221 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 222 | 223 | return try self.openAPIRequest(for: requestType, using: encoder) 224 | } 225 | 226 | /// Generates the equivalent OpenAPI request for a given request type. 227 | /// - Parameters: 228 | /// - requestType: The request body type to convert. 229 | /// - encoder: A JSON encoder to generate the OpenAPI request with. 230 | private func openAPIRequest(for requestType: Any.Type, using encoder: JSONEncoder) throws -> OpenAPI.Request? { 231 | guard !(requestType is EmptyRequestBody.Type) else { 232 | return nil 233 | } 234 | 235 | let example = reverseEngineeredExample(for: requestType, using: encoder) 236 | 237 | let customRequestBodyType = (requestType as? OpenAPIEncodedSchemaType.Type) 238 | ?? ((requestType as? _Wrapper.Type)?.wrappedType as? OpenAPIEncodedSchemaType.Type) 239 | 240 | guard let requestBodyType = customRequestBodyType else { 241 | return nil 242 | } 243 | 244 | let schema = try requestBodyType.openAPISchema(using: encoder) 245 | 246 | return OpenAPI.Request( 247 | content: [ 248 | .json: .init(schema: .init(schema), example: example) 249 | ] 250 | ) 251 | } 252 | 253 | /// Generates the equivalent OpenAPI request for a given request type. 254 | /// - Parameters: 255 | /// - responseType: The response type to convert. If it conforms to `AbstractRouteContext`, this function will use all included responses. 256 | private func openAPIResponses(from responseType: Any.Type) throws -> OpenAPI.Response.Map { 257 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 258 | 259 | return try self.openAPIResponses(from: responseType, using: encoder) 260 | } 261 | 262 | /// Generates the equivalent OpenAPI request for a given request type. 263 | /// - Parameters: 264 | /// - responseType: The response type to convert. If it conforms to `AbstractRouteContext`, this function will use all included responses. 265 | /// - encoder: The JSON encoder to generate the OpenAPI response map with. 266 | private func openAPIResponses(from responseType: Any.Type, using encoder: JSONEncoder) throws -> OpenAPI.Response.Map { 267 | if let responseBodyType = responseType as? AbstractRouteContext.Type { 268 | return try responseBodyType.openAPIResponses(using: encoder) 269 | } 270 | 271 | let responseBodyType = (responseType as? OpenAPIEncodedSchemaType.Type) 272 | ?? ((responseType as? _Wrapper.Type)?.wrappedType as? OpenAPIEncodedSchemaType.Type) 273 | 274 | let successResponse = try responseBodyType 275 | .map { responseType -> OpenAPI.Response in 276 | let schema = try responseType.openAPISchema(using: encoder) 277 | 278 | return .init( 279 | description: "Success", 280 | content: [ 281 | .json: .init(schema: .init(schema)) 282 | ] 283 | ) 284 | } 285 | 286 | let responseTuples = [ 287 | successResponse.map{ (OpenAPI.Response.StatusCode(200), $0) } 288 | ].compactMap { $0 } 289 | 290 | return OrderedDictionary( 291 | responseTuples, 292 | uniquingKeysWith: { $1 } 293 | ).mapValues { .init($0) } 294 | } 295 | } 296 | 297 | /// Generates an example from a given type. 298 | /// - Parameters: 299 | /// - typeToSample: The type to generate a sample from. If it doesn't conform to `OpenAPIExampleProvider`, an example can't be generated. 300 | /// - encoder: The JSON encoder to generate the example with. 301 | private func reverseEngineeredExample(for typeToSample: Any.Type, using encoder: JSONEncoder) -> AnyCodable? { 302 | guard let exampleType = typeToSample as? OpenAPIExampleProvider.Type else { 303 | return nil 304 | } 305 | 306 | return try? exampleType.openAPIExample(using: encoder) 307 | } 308 | 309 | /// The context for an OpenAPI path operation. 310 | /// - Parameters: 311 | /// - summary: The summary of the path operation, if any. 312 | /// - description: The longer description of the path operation, if any. 313 | /// - tags: Any tags the path operation can have. 314 | /// - deprecated: If `true`, then it marks the path operation as deprecated. 315 | typealias PartialPathOperationContext = ( 316 | summary: String?, 317 | description: String?, 318 | tags: [String]?, 319 | deprecated: Bool 320 | ) 321 | 322 | /// A function that takes a `PartialPathOperationContext` and returns a `PathOperation`. 323 | typealias PathOperationConstructor = (PartialPathOperationContext) -> PathOperation 324 | 325 | /// A tuple containing a path, verb, and operation. 326 | typealias PathOperation = (path: OpenAPI.Path, verb: OpenAPI.HttpMethod, operation: OpenAPI.Operation) 327 | 328 | 329 | extension OpenAPI.Document: Content { } 330 | -------------------------------------------------------------------------------- /Sources/VaporOpenAPI/VaporRoutes+OpenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VaporRoutes+OpenAPI.swift 3 | // AppAPIDocumentation 4 | // 5 | // Created by Mathew Polzin on 10/20/19. 6 | // 7 | 8 | import Foundation 9 | import OpenAPIKit 10 | import Vapor 11 | 12 | extension Vapor.Routes { 13 | /// Generates the equivalent OpenAPI `PathItem` map for the Vapor Routes. 14 | public func openAPIPathItems() throws -> OpenAPI.PathItem.Map { 15 | let encoder = try ContentConfiguration.global.openAPIJSONEncoder() 16 | 17 | return try self.openAPIPathItems(using: encoder) 18 | } 19 | 20 | /// Generates the equivalent OpenAPI `PathItem` map for the Vapor Routes. 21 | /// - Parameters: 22 | /// - encoder: The `JSONEncoder` to generate the OpenAPI path item map with. 23 | public func openAPIPathItems(using encoder: JSONEncoder) throws -> OpenAPI.PathItem.Map { 24 | let operations = try all 25 | .map { try $0.openAPIPathOperation(using: encoder) } 26 | 27 | let operationsByPath = OrderedDictionary( 28 | grouping: operations, 29 | by: { $0.path } 30 | ) 31 | 32 | return operationsByPath.mapValues { operations in 33 | var pathItem = OpenAPI.PathItem() 34 | 35 | for item in operations { 36 | pathItem[item.verb] = item.operation 37 | } 38 | 39 | return .pathItem(pathItem) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/VaporOpenAPITests/VaporOpenAPITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import OpenAPIReflection 3 | import VaporOpenAPI 4 | import Sampleable 5 | import XCTVapor 6 | 7 | final class VaporOpenAPITests: XCTestCase { 8 | func testExample() throws { 9 | let app = Application(.testing) 10 | defer { app.shutdown() } 11 | 12 | app.get("hello", use: TestController.indexRoute) 13 | app.post("hello", use: TestController.createRoute) 14 | app.get( 15 | "hello", 16 | ":id".parameterType(Int.self).description("hello world"), 17 | use: TestController.showRoute 18 | ) 19 | app.delete("hello", use: TestController.deleteRoute) 20 | app.post("hello", "empty", use: TestController.createEmptyReturn) 21 | 22 | try testRoutes(on: app) 23 | } 24 | 25 | func testAsyncExample() throws { 26 | let app = Application(.testing) 27 | defer { app.shutdown() } 28 | 29 | app.get("hello", use: AsyncTestController.indexRoute) 30 | app.post("hello", use: AsyncTestController.createRoute) 31 | app.get( 32 | "hello", 33 | ":id".parameterType(Int.self).description("hello world"), 34 | use: AsyncTestController.showRoute 35 | ) 36 | app.delete("hello", use: AsyncTestController.deleteRoute) 37 | app.post("hello", "empty", use: AsyncTestController.createEmptyReturn) 38 | 39 | try testRoutes(on: app) 40 | } 41 | 42 | /// Just the route-checking bits in their own function so we can test out EventLoopFuture handling and async/await cleanly. 43 | func testRoutes(on app: Application) throws { 44 | let info = OpenAPI.Document.Info( 45 | title: "Vapor OpenAPI Test API", 46 | description: 47 | """ 48 | ## Descriptive Text 49 | This text supports _markdown_! 50 | """, 51 | version: "1.0" 52 | ) 53 | 54 | let portString = "\(app.http.server.configuration.port == 80 ? "" : ":\(app.http.server.configuration.port)")" 55 | 56 | let servers = [ 57 | OpenAPI.Server(url: URL(string: "http://\(app.http.server.configuration.hostname)\(portString)")!) 58 | ] 59 | 60 | let components = OpenAPI.Components( 61 | schemas: [:], 62 | responses: [:], 63 | parameters: [:], 64 | examples: [:], 65 | requestBodies: [:], 66 | headers: [:] 67 | ) 68 | 69 | let paths = try app.routes.openAPIPathItems() 70 | 71 | let document = OpenAPI.Document( 72 | info: info, 73 | servers: servers, 74 | paths: paths, 75 | components: components, 76 | security: [] 77 | ) 78 | 79 | XCTAssertEqual(document.paths.count, 3) 80 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.get) 81 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.post) 82 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.delete) 83 | XCTAssertNil(document.paths["/hello"]?.pathItemValue?.put) 84 | XCTAssertNil(document.paths["/hello"]?.pathItemValue?.patch) 85 | XCTAssertNil(document.paths["/hello"]?.pathItemValue?.head) 86 | XCTAssertNil(document.paths["/hello"]?.pathItemValue?.options) 87 | XCTAssertNil(document.paths["/hello"]?.pathItemValue?.trace) 88 | XCTAssertNotNil(document.paths["/hello/{id}"]?.pathItemValue?.get) 89 | 90 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.get?.responses[.status(code: 200)]) 91 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.get?.responses[.status(code: 400)]) 92 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.delete?.responses[.status(code: 204)]) 93 | 94 | XCTAssertNotNil(document.paths["/hello/empty"]?.pathItemValue?.post?.responses[.status(code: 201)]) 95 | 96 | XCTAssertEqual(document.paths["/hello/{id}"]?.pathItemValue?.get?.parameters[0].parameterValue?.description, "hello world") 97 | XCTAssertEqual(document.paths["/hello/{id}"]?.pathItemValue?.get?.parameters[0].parameterValue?.schemaOrContent.schemaValue, .integer) 98 | 99 | let requestExample = document.paths["/hello"]?.pathItemValue?.post?.requestBody?.b?.content[.json]?.example 100 | XCTAssertNotNil(requestExample) 101 | XCTAssertNotNil(document.paths["/hello"]?.pathItemValue?.post?.responses[.status(code: 201)]) 102 | let requestExampleDict = requestExample?.value as? [String: Any] 103 | XCTAssertNotNil(requestExampleDict, "Expected request example to decode as a dictionary from String to Any") 104 | 105 | XCTAssertEqual(requestExampleDict?["stringValue"] as? String, "hello world!") 106 | } 107 | } 108 | 109 | struct CreatableResource: Codable, Sampleable, OpenAPIExampleProvider { 110 | let stringValue: String 111 | 112 | static let sample: Self = .init(stringValue: "hello world!") 113 | } 114 | 115 | struct TestIndexRouteContext: RouteContext { 116 | typealias RequestBodyType = EmptyRequestBody 117 | 118 | static let defaultContentType: HTTPMediaType? = nil 119 | 120 | static let shared = Self() 121 | 122 | let echo: IntegerQueryParam = .init(name: "echo") 123 | 124 | let success: ResponseContext = .init { response in 125 | response.headers = Self.plainTextHeader 126 | response.status = .ok 127 | } 128 | 129 | let badRequest: CannedResponse = .init( 130 | response: Response( 131 | status: .badRequest, 132 | headers: Self.plainTextHeader, 133 | body: .empty 134 | ) 135 | ) 136 | 137 | static let plainTextHeader = HTTPHeaders([ 138 | (HTTPHeaders.Name.contentType.description, HTTPMediaType.plainText.serialize()) 139 | ]) 140 | } 141 | 142 | struct TestShowRouteContext: RouteContext { 143 | typealias RequestBodyType = EmptyRequestBody 144 | 145 | static let defaultContentType: HTTPMediaType? = nil 146 | 147 | static let shared = Self() 148 | 149 | let badQuery: StringQueryParam = .init(name: "failHard") 150 | let echo: IntegerQueryParam = .init(name: "echo") 151 | 152 | let success: ResponseContext = .init { response in 153 | response.headers = Self.plainTextHeader 154 | response.status = .ok 155 | } 156 | 157 | let badRequest: CannedResponse = .init( 158 | response: Response( 159 | status: .badRequest, 160 | headers: Self.plainTextHeader, 161 | body: .empty 162 | ) 163 | ) 164 | 165 | static let plainTextHeader = HTTPHeaders([ 166 | (HTTPHeaders.Name.contentType.description, HTTPMediaType.plainText.serialize()) 167 | ]) 168 | } 169 | 170 | struct TestCreateRouteContext: RouteContext { 171 | typealias RequestBodyType = CreatableResource 172 | 173 | static let defaultContentType: HTTPMediaType? = nil 174 | 175 | static let shared = Self() 176 | 177 | let badQuery: StringQueryParam = .init(name: "failHard") 178 | 179 | let success: ResponseContext = .init { response in 180 | response.headers = Self.plainTextHeader 181 | response.status = .created 182 | } 183 | 184 | let badRequest: CannedResponse = .init( 185 | response: Response( 186 | status: .badRequest, 187 | headers: Self.plainTextHeader, 188 | body: .empty 189 | ) 190 | ) 191 | 192 | static let plainTextHeader = HTTPHeaders([ 193 | (HTTPHeaders.Name.contentType.description, HTTPMediaType.plainText.serialize()) 194 | ]) 195 | } 196 | 197 | struct TestDeleteRouteContext: RouteContext { 198 | typealias RequestBodyType = EmptyRequestBody 199 | 200 | static let defaultContentType: HTTPMediaType? = nil 201 | 202 | static let shared = Self() 203 | 204 | let success: ResponseContext = .init { response in 205 | response.status = .noContent 206 | } 207 | } 208 | 209 | struct TestCreateEmptyReturnRouteContext: RouteContext { 210 | typealias RequestBodyType = CreatableResource 211 | 212 | static let defaultContentType: HTTPMediaType? = nil 213 | 214 | static let shared = Self() 215 | 216 | let success: ResponseContext = .init { response in 217 | response.status = .created 218 | } 219 | } 220 | 221 | final class TestController { 222 | static func indexRoute(_ req: TypedRequest) -> EventLoopFuture { 223 | if let text = req.query.echo { 224 | return req.response.success.encode("\(text)") 225 | } 226 | return req.response.success.encode("Hello") 227 | } 228 | 229 | static func showRoute(_ req: TypedRequest) -> EventLoopFuture { 230 | if req.query.badQuery != nil { 231 | return req.response.badRequest 232 | } 233 | if let text = req.query.echo { 234 | return req.response.success.encode("\(text)") 235 | } 236 | return req.response.success.encode("Hello") 237 | } 238 | 239 | static func createRoute(_ req: TypedRequest) -> EventLoopFuture { 240 | if req.query.badQuery != nil { 241 | return req.response.badRequest 242 | } 243 | return req.response.success.encode("Hello") 244 | } 245 | 246 | static func deleteRoute(_ req: TypedRequest) -> EventLoopFuture { 247 | return req.response.success.encodeEmptyResponse() 248 | } 249 | 250 | static func createEmptyReturn(_ req: TypedRequest) -> EventLoopFuture { 251 | return req.response.success.encodeEmptyResponse() 252 | } 253 | } 254 | 255 | final class AsyncTestController { 256 | static func indexRoute(_ req: TypedRequest) async throws -> Response { 257 | if let text = req.query.echo { 258 | return try await req.response.success.encode("\(text)") 259 | } 260 | return try await req.response.success.encode("Hello") 261 | } 262 | 263 | static func showRoute(_ req: TypedRequest) async throws -> Response { 264 | if req.query.badQuery != nil { 265 | return try await req.response.get(\.badRequest) 266 | } 267 | if let text = req.query.echo { 268 | return try await req.response.success.encode("\(text)") 269 | } 270 | return try await req.response.success.encode("Hello") 271 | } 272 | 273 | static func createRoute(_ req: TypedRequest) async throws -> Response { 274 | if req.query.badQuery != nil { 275 | return try await req.response.get(\.badRequest) 276 | } 277 | return try await req.response.success.encode("Hello") 278 | } 279 | 280 | static func deleteRoute(_ req: TypedRequest) async throws -> Response { 281 | return try await req.response.success.encodeEmptyResponse() 282 | } 283 | 284 | static func createEmptyReturn(_ req: TypedRequest) async throws -> Response { 285 | return try await req.response.success.encodeEmptyResponse() 286 | } 287 | } 288 | --------------------------------------------------------------------------------