From 657613efa330a73809c1f659822b919aa0bb8fac Mon Sep 17 00:00:00 2001 From: grosch Date: Wed, 15 Apr 2020 13:20:32 -0700 Subject: [PATCH] Add Content hooks (#2267) * Adds automatic calls to beforeEncode() and afterDecode() on Content * Added doc comment --- Sources/Vapor/Client/ClientRequest.swift | 17 +++ Sources/Vapor/Client/ClientResponse.swift | 17 +++ Sources/Vapor/Content/Content.swift | 20 ++++ Sources/Vapor/Content/ContentContainer.swift | 27 ++++- Sources/Vapor/Request/Request.swift | 18 ++++ Sources/Vapor/Response/Response.swift | 17 +++ Sources/XCTVapor/XCTHTTPRequest.swift | 6 ++ Sources/XCTVapor/XCTHTTPResponse.swift | 6 ++ Tests/VaporTests/ContentTests.swift | 107 +++++++++++++------ 9 files changed, 201 insertions(+), 34 deletions(-) diff --git a/Sources/Vapor/Client/ClientRequest.swift b/Sources/Vapor/Client/ClientRequest.swift index ea1664be5f..0f41a9ac15 100644 --- a/Sources/Vapor/Client/ClientRequest.swift +++ b/Sources/Vapor/Client/ClientRequest.swift @@ -63,6 +63,23 @@ extension ClientRequest { } return try decoder.decode(D.self, from: body, headers: self.headers) } + + mutating func encode(_ content: C, using encoder: ContentEncoder) throws where C : Content { + var content = content + try content.beforeEncode() + var body = ByteBufferAllocator().buffer(capacity: 0) + try encoder.encode(content, to: &body, headers: &self.headers) + self.body = body + } + + func decode(_ content: C.Type, using decoder: ContentDecoder) throws -> C where C : Content { + guard let body = self.body else { + throw Abort(.lengthRequired) + } + var decoded = try decoder.decode(C.self, from: body, headers: self.headers) + try decoded.afterDecode() + return decoded + } } public var content: ContentContainer { diff --git a/Sources/Vapor/Client/ClientResponse.swift b/Sources/Vapor/Client/ClientResponse.swift index ca9f466703..7cb7742db1 100644 --- a/Sources/Vapor/Client/ClientResponse.swift +++ b/Sources/Vapor/Client/ClientResponse.swift @@ -31,6 +31,23 @@ extension ClientResponse { } return try decoder.decode(D.self, from: body, headers: self.headers) } + + mutating func encode(_ content: C, using encoder: ContentEncoder) throws where C : Content { + var body = ByteBufferAllocator().buffer(capacity: 0) + var content = content + try content.beforeEncode() + try encoder.encode(content, to: &body, headers: &self.headers) + self.body = body + } + + func decode(_ content: C.Type, using decoder: ContentDecoder) throws -> C where C : Content { + guard let body = self.body else { + throw Abort(.lengthRequired) + } + var decoded = try decoder.decode(C.self, from: body, headers: self.headers) + try decoded.afterDecode() + return decoded + } } public var content: ContentContainer { diff --git a/Sources/Vapor/Content/Content.swift b/Sources/Vapor/Content/Content.swift index e370cdd2fa..edd5a986c1 100644 --- a/Sources/Vapor/Content/Content.swift +++ b/Sources/Vapor/Content/Content.swift @@ -37,6 +37,23 @@ public protocol Content: Codable, RequestDecodable, ResponseEncodable { /// } /// static var defaultContentType: HTTPMediaType { get } + + /// Called before this `Content` is encoded, generally for a `Response` object. + /// + /// You should use this method to perform any "sanitizing" which you need on the data. + /// For example, you may wish to replace empty strings with a `nil`, `trim()` your + /// strings or replace empty arrays with `nil`. You can also use this method to abort + /// the encoding if something isn't valid. An empty array may indicate an error, for example. + mutating func beforeEncode() throws + + + /// Called after this `Content` is decoded, generally from a `Request` object. + /// + /// You should use this method to perform any "sanitizing" which you need on the data. + /// For example, you may wish to replace empty strings with a `nil`, `trim()` your + /// strings or replace empty arrays with `nil`. You can also use this method to abort + /// the decoding if something isn't valid. An empty string may indicate an error, for example. + mutating func afterDecode() throws } /// MARK: Default Implementations @@ -64,6 +81,9 @@ extension Content { } return request.eventLoop.makeSucceededFuture(response) } + + public mutating func beforeEncode() throws { } + public mutating func afterDecode() throws { } } // MARK: Default Conformances diff --git a/Sources/Vapor/Content/ContentContainer.swift b/Sources/Vapor/Content/ContentContainer.swift index 55ae0e9953..8c8c4ec7c5 100644 --- a/Sources/Vapor/Content/ContentContainer.swift +++ b/Sources/Vapor/Content/ContentContainer.swift @@ -15,6 +15,13 @@ extension ContentContainer { return try self.decode(D.self, using: self.configuredDecoder()) } + public func decode(_ decodable: C.Type) throws -> C where C: Content { + var content = try self.decode(C.self, using: self.configuredDecoder()) + try content.afterDecode() + + return content + } + // MARK: Encode /// Serializes an `Encodable` object to this message using specific `HTTPMessageEncoder`. @@ -27,6 +34,8 @@ extension ContentContainer { public mutating func encode(_ encodable: C) throws where C: Content { + var encodable = encodable + try encodable.beforeEncode() try self.encode(encodable, as: C.defaultContentType) } @@ -44,7 +53,23 @@ extension ContentContainer { { try self.encode(encodable, using: self.configuredEncoder(for: contentType)) } - + + /// Serializes a `Content` object to this message using specific `HTTPMessageEncoder`. + /// + /// try req.content.encode(user, using: JSONEncoder()) + /// + /// - parameters: + /// - content: Instance of generic `Content` to serialize to this HTTP message. + /// - encoder: Specific `HTTPMessageEncoder` to use. + /// - throws: Errors during serialization. + public mutating func encode(_ content: C, as contentType: HTTPMediaType) throws + where C: Content + { + var content = content + try content.beforeEncode() + try self.encode(content, using: self.configuredEncoder(for: contentType)) + } + // MARK: Single Value /// Fetches a single `Decodable` value at the supplied key-path from this HTTP request's query string. diff --git a/Sources/Vapor/Request/Request.swift b/Sources/Vapor/Request/Request.swift index 519195f9b3..d190473d42 100644 --- a/Sources/Vapor/Request/Request.swift +++ b/Sources/Vapor/Request/Request.swift @@ -78,6 +78,24 @@ public final class Request: CustomStringConvertible { } return try decoder.decode(D.self, from: body, headers: self.request.headers) } + + func encode(_ content: C, using encoder: ContentEncoder) throws where C : Content { + var content = content + try content.beforeEncode() + var body = ByteBufferAllocator().buffer(capacity: 0) + try encoder.encode(content, to: &body, headers: &self.request.headers) + self.request.bodyStorage = .collected(body) + } + + func decode(_ content: C.Type, using decoder: ContentDecoder) throws -> C where C : Content { + guard let body = self.request.body.data else { + self.request.logger.error("Decoding streaming bodies not supported") + throw Abort(.unprocessableEntity) + } + var decoded = try decoder.decode(C.self, from: body, headers: self.request.headers) + try decoded.afterDecode() + return decoded + } } public var content: ContentContainer { diff --git a/Sources/Vapor/Response/Response.swift b/Sources/Vapor/Response/Response.swift index fa02aa6637..277cfde913 100644 --- a/Sources/Vapor/Response/Response.swift +++ b/Sources/Vapor/Response/Response.swift @@ -84,6 +84,23 @@ public final class Response: CustomStringConvertible { } return try decoder.decode(D.self, from: body, headers: self.response.headers) } + + func encode(_ content: C, using encoder: ContentEncoder) throws where C : Content { + var content = content + try content.beforeEncode() + var body = ByteBufferAllocator().buffer(capacity: 0) + try encoder.encode(content, to: &body, headers: &self.response.headers) + self.response.body = .init(buffer: body) + } + + func decode(_ content: C.Type, using decoder: ContentDecoder) throws -> C where C : Content { + guard let body = self.response.body.buffer else { + throw Abort(.unprocessableEntity) + } + var decoded = try decoder.decode(C.self, from: body, headers: self.response.headers) + try decoded.afterDecode() + return decoded + } } public var content: ContentContainer { diff --git a/Sources/XCTVapor/XCTHTTPRequest.swift b/Sources/XCTVapor/XCTHTTPRequest.swift index fe1d27a4d2..87af3163e7 100644 --- a/Sources/XCTVapor/XCTHTTPRequest.swift +++ b/Sources/XCTVapor/XCTHTTPRequest.swift @@ -19,6 +19,12 @@ public struct XCTHTTPRequest { func decode(_ decodable: D.Type, using decoder: ContentDecoder) throws -> D where D : Decodable { fatalError("Decoding from test request is not supported.") } + + mutating func encode(_ content: C, using encoder: ContentEncoder) throws where C : Content { + var content = content + try content.beforeEncode() + try encoder.encode(content, to: &self.body, headers: &self.headers) + } } public var content: ContentContainer { diff --git a/Sources/XCTVapor/XCTHTTPResponse.swift b/Sources/XCTVapor/XCTHTTPResponse.swift index 375bee4b13..ef97187f62 100644 --- a/Sources/XCTVapor/XCTHTTPResponse.swift +++ b/Sources/XCTVapor/XCTHTTPResponse.swift @@ -20,6 +20,12 @@ extension XCTHTTPResponse { func decode(_ decodable: D.Type, using decoder: ContentDecoder) throws -> D where D : Decodable { try decoder.decode(D.self, from: self.body, headers: self.headers) } + + func decode(_ content: C.Type, using decoder: ContentDecoder) throws -> C where C : Content { + var decoded = try decoder.decode(C.self, from: self.body, headers: self.headers) + try decoded.afterDecode() + return decoded + } } public var content: ContentContainer { diff --git a/Tests/VaporTests/ContentTests.swift b/Tests/VaporTests/ContentTests.swift index 1d30539af6..ed0101b92b 100644 --- a/Tests/VaporTests/ContentTests.swift +++ b/Tests/VaporTests/ContentTests.swift @@ -232,38 +232,79 @@ final class ContentTests: XCTestCase { } func testJSONPreservesHTTPHeaders() throws { - let app = Application(.testing) - defer { app.shutdown() } - - app.get("check") { (req: Request) -> String in - return "\(req.headers.first(name: .init("X-Test-Value")) ?? "MISSING").\(req.headers.first(name: .contentType) ?? "?")" - } - - try app.test(.GET, "/check", headers: ["X-Test-Value": "PRESENT"], beforeRequest: { req in - try req.content.encode(["foo": "bar"], as: .json) - }) { res in - XCTAssertEqual(res.body.string, "PRESENT.application/json; charset=utf-8") - } - } - - func testJSONAllowsContentTypeOverride() throws { - let app = Application(.testing) - defer { app.shutdown() } - - app.get("check") { (req: Request) -> String in - return "\(req.headers.first(name: .init("X-Test-Value")) ?? "MISSING").\(req.headers.first(name: .contentType) ?? "?")" - } - // Me and my sadistic sense of humor. - ContentConfiguration.global.use(decoder: try! ContentConfiguration.global.requireDecoder(for: .json), for: .xml) - - try app.testable().test(.GET, "/check", headers: [ - "X-Test-Value": "PRESENT" - ], beforeRequest: { req in - try req.content.encode(["foo": "bar"], as: .json) - req.headers.contentType = .xml - }) { res in - XCTAssertEqual(res.body.string, "PRESENT.application/xml; charset=utf-8") - } - } + let app = Application(.testing) + defer { app.shutdown() } + + app.get("check") { (req: Request) -> String in + return "\(req.headers.first(name: .init("X-Test-Value")) ?? "MISSING").\(req.headers.first(name: .contentType) ?? "?")" + } + + try app.test(.GET, "/check", headers: ["X-Test-Value": "PRESENT"], beforeRequest: { req in + try req.content.encode(["foo": "bar"], as: .json) + }) { res in + XCTAssertEqual(res.body.string, "PRESENT.application/json; charset=utf-8") + } + } + + func testJSONAllowsContentTypeOverride() throws { + let app = Application(.testing) + defer { app.shutdown() } + app.get("check") { (req: Request) -> String in + return "\(req.headers.first(name: .init("X-Test-Value")) ?? "MISSING").\(req.headers.first(name: .contentType) ?? "?")" + } + // Me and my sadistic sense of humor. + ContentConfiguration.global.use(decoder: try! ContentConfiguration.global.requireDecoder(for: .json), for: .xml) + + try app.testable().test(.GET, "/check", headers: [ + "X-Test-Value": "PRESENT" + ], beforeRequest: { req in + try req.content.encode(["foo": "bar"], as: .json) + req.headers.contentType = .xml + }) { res in + XCTAssertEqual(res.body.string, "PRESENT.application/xml; charset=utf-8") + } + } + + func testBeforeEncodeContent() throws { + let content = SampleContent() + XCTAssertEqual(content.name, "old name") + + let response = Response(status: .ok) + try response.content.encode(content) + + let body = try XCTUnwrap(response.body.string) + XCTAssertEqual(body, #"{"name":"new name"}"#) + } + + func testAfterContentEncode() throws { + let app = Application() + defer { app.shutdown() } + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(#"{"name": "before decode"}"#) + + let request = Request( + application: app, + collectedBody: body, + on: EmbeddedEventLoop() + ) + + request.headers.contentType = .json + + let content = try request.content.decode(SampleContent.self) + XCTAssertEqual(content.name, "new name after decode") + } +} + +private struct SampleContent: Content { + var name = "old name" + + mutating func beforeEncode() throws { + name = "new name" + } + + mutating func afterDecode() throws { + name = "new name after decode" + } }