From 5e50e4ae50ca972b873bc7ecc56e7e4b1e719249 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 27 May 2024 21:03:10 -0700 Subject: [PATCH] routes coding --- Papyrus/Sources/Coders.swift | 12 ++ .../Sources/Extensions/String+Multipart.swift | 9 ++ .../{ => Extensions}/URLSession+Papyrus.swift | 7 -- Papyrus/Sources/HTTPBodyDecoder.swift | 53 +++++++++ ...estEncoder.swift => HTTPBodyEncoder.swift} | 18 +-- Papyrus/Sources/HTTPService.swift | 3 - Papyrus/Sources/KeyMappable.swift | 12 ++ Papyrus/Sources/KeyMapping.swift | 4 - Papyrus/Sources/Macros.swift | 12 +- .../Sources/Multipart/MultipartDecoder.swift | 18 +++ .../Sources/Multipart/MultipartEncoder.swift | 11 +- Papyrus/Sources/PapyrusRouter.swift | 107 ------------------ Papyrus/Sources/Provider.swift | 41 ------- Papyrus/Sources/RequestBuilder.swift | 37 +++--- Papyrus/Sources/Response.swift | 8 +- Papyrus/Sources/ResponseDecoder.swift | 32 ------ Papyrus/Sources/Routing/PapyrusRouter.swift | 7 ++ Papyrus/Sources/Routing/RequestParser.swift | 93 +++++++++++++++ Papyrus/Sources/Routing/RouterRequest.swift | 15 +++ Papyrus/Sources/Routing/RouterResponse.swift | 13 +++ .../URLEncodedForm.swift | 0 .../URLEncodedFormDecoder.swift | 0 .../URLEncodedFormEncoder.swift | 0 .../URLEncodedFormNode.swift | 0 Papyrus/Tests/CurlTests.swift | 6 +- Papyrus/Tests/RequestBuilderTests.swift | 6 +- PapyrusPlugin/Sources/Macros/APIMacro.swift | 14 +-- .../Sources/Macros/RoutesMacro.swift | 63 ++++++++++- .../Sources/Models/EndpointAttribute.swift | 2 +- PapyrusPlugin/Tests/APIMacroTests.swift | 24 ++-- 30 files changed, 349 insertions(+), 278 deletions(-) create mode 100644 Papyrus/Sources/Coders.swift create mode 100644 Papyrus/Sources/Extensions/String+Multipart.swift rename Papyrus/Sources/{ => Extensions}/URLSession+Papyrus.swift (91%) create mode 100644 Papyrus/Sources/HTTPBodyDecoder.swift rename Papyrus/Sources/{RequestEncoder.swift => HTTPBodyEncoder.swift} (69%) create mode 100644 Papyrus/Sources/KeyMappable.swift create mode 100644 Papyrus/Sources/Multipart/MultipartDecoder.swift delete mode 100644 Papyrus/Sources/PapyrusRouter.swift delete mode 100644 Papyrus/Sources/ResponseDecoder.swift create mode 100644 Papyrus/Sources/Routing/PapyrusRouter.swift create mode 100644 Papyrus/Sources/Routing/RequestParser.swift create mode 100644 Papyrus/Sources/Routing/RouterRequest.swift create mode 100644 Papyrus/Sources/Routing/RouterResponse.swift rename Papyrus/Sources/{URLEncoder => URLEncoded}/URLEncodedForm.swift (100%) rename Papyrus/Sources/{URLEncoder => URLEncoded}/URLEncodedFormDecoder.swift (100%) rename Papyrus/Sources/{URLEncoder => URLEncoded}/URLEncodedFormEncoder.swift (100%) rename Papyrus/Sources/{URLEncoder => URLEncoded}/URLEncodedFormNode.swift (100%) diff --git a/Papyrus/Sources/Coders.swift b/Papyrus/Sources/Coders.swift new file mode 100644 index 0000000..d55a696 --- /dev/null +++ b/Papyrus/Sources/Coders.swift @@ -0,0 +1,12 @@ +enum Coders { + + // MARK: HTTP Body + + static var defaultHTTPBodyEncoder: HTTPBodyEncoder = .json() + static var defaultHTTPBodyDecoder: HTTPBodyDecoder = .json() + + // MARK: Query + + static var defaultQueryEncoder = URLEncodedFormEncoder() + static var defaultQueryDecoder = URLEncodedFormDecoder() +} diff --git a/Papyrus/Sources/Extensions/String+Multipart.swift b/Papyrus/Sources/Extensions/String+Multipart.swift new file mode 100644 index 0000000..0183316 --- /dev/null +++ b/Papyrus/Sources/Extensions/String+Multipart.swift @@ -0,0 +1,9 @@ +extension String { + static func randomMultipartBoundary() -> String { + let first = UInt32.random(in: UInt32.min...UInt32.max) + let second = UInt32.random(in: UInt32.min...UInt32.max) + return String(format: "papyrus.boundary.%08x%08x", first, second) + } + + static let crlf = "\r\n" +} diff --git a/Papyrus/Sources/URLSession+Papyrus.swift b/Papyrus/Sources/Extensions/URLSession+Papyrus.swift similarity index 91% rename from Papyrus/Sources/URLSession+Papyrus.swift rename to Papyrus/Sources/Extensions/URLSession+Papyrus.swift index 153d126..eaf7a2a 100644 --- a/Papyrus/Sources/URLSession+Papyrus.swift +++ b/Papyrus/Sources/Extensions/URLSession+Papyrus.swift @@ -38,13 +38,6 @@ extension URLSession: HTTPService { } #endif } - - public func request(_ req: Request, completionHandler: @escaping (Response) -> Void) { - let urlRequest = req.urlRequest - dataTask(with: urlRequest) { - completionHandler(_Response(request: urlRequest, response: $1, error: $2, body: $0)) - }.resume() - } } // MARK: `Response` Conformance diff --git a/Papyrus/Sources/HTTPBodyDecoder.swift b/Papyrus/Sources/HTTPBodyDecoder.swift new file mode 100644 index 0000000..d742c12 --- /dev/null +++ b/Papyrus/Sources/HTTPBodyDecoder.swift @@ -0,0 +1,53 @@ +import Foundation + +public protocol HTTPBodyDecoder: KeyMappable { + func decode(_ type: D.Type, from: Data) throws -> D +} + +// MARK: application/json + +extension HTTPBodyDecoder where Self == JSONDecoder { + public static func json(_ decoder: JSONDecoder = JSONDecoder()) -> Self { + decoder + } +} + +extension JSONDecoder: HTTPBodyDecoder { + public func with(keyMapping: KeyMapping) -> Self { + let new = JSONDecoder() + new.userInfo = userInfo + new.dataDecodingStrategy = dataDecodingStrategy + new.dateDecodingStrategy = dateDecodingStrategy + new.nonConformingFloatDecodingStrategy = nonConformingFloatDecodingStrategy +#if os(Linux) +#else + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + new.assumesTopLevelDictionary = assumesTopLevelDictionary + new.allowsJSON5 = allowsJSON5 + } +#endif + new.keyDecodingStrategy = keyMapping.jsonDecodingStrategy + return new as! Self + } +} + +// MARK: application/x-www-form-urlencoded + +extension HTTPBodyDecoder where Self == URLEncodedFormDecoder { + public static func urlForm(_ decoder: URLEncodedFormDecoder = URLEncodedFormDecoder()) -> Self { + decoder + } +} + +extension URLEncodedFormDecoder: HTTPBodyDecoder { + public func decode(_ type: D.Type, from data: Data) throws -> D { + let string = String(decoding: data, as: UTF8.self) + return try decode(type, from: string) + } + + public func with(keyMapping: KeyMapping) -> Self { + var copy = self + copy.keyMapping = keyMapping + return copy + } +} diff --git a/Papyrus/Sources/RequestEncoder.swift b/Papyrus/Sources/HTTPBodyEncoder.swift similarity index 69% rename from Papyrus/Sources/RequestEncoder.swift rename to Papyrus/Sources/HTTPBodyEncoder.swift index ac89a16..8e1da81 100644 --- a/Papyrus/Sources/RequestEncoder.swift +++ b/Papyrus/Sources/HTTPBodyEncoder.swift @@ -1,19 +1,19 @@ import Foundation -public protocol RequestEncoder: KeyMappable { +public protocol HTTPBodyEncoder: KeyMappable { var contentType: String { get } func encode(_ value: E) throws -> Data } // MARK: application/json -extension RequestEncoder where Self == JSONEncoder { - public static func json(_ encoder: JSONEncoder) -> Self { +extension HTTPBodyEncoder where Self == JSONEncoder { + public static func json(_ encoder: JSONEncoder = JSONEncoder()) -> Self { encoder } } -extension JSONEncoder: RequestEncoder { +extension JSONEncoder: HTTPBodyEncoder { public var contentType: String { "application/json" } public func with(keyMapping: KeyMapping) -> Self { @@ -30,13 +30,13 @@ extension JSONEncoder: RequestEncoder { // MARK: application/x-www-form-urlencoded -extension RequestEncoder where Self == URLEncodedFormEncoder { - public static func urlForm(_ encoder: URLEncodedFormEncoder) -> Self { +extension HTTPBodyEncoder where Self == URLEncodedFormEncoder { + public static func urlForm(_ encoder: URLEncodedFormEncoder = URLEncodedFormEncoder()) -> Self { encoder } } -extension URLEncodedFormEncoder: RequestEncoder { +extension URLEncodedFormEncoder: HTTPBodyEncoder { public var contentType: String { "application/x-www-form-urlencoded" } public func encode(_ value: E) throws -> Data { @@ -57,8 +57,8 @@ extension URLEncodedFormEncoder: RequestEncoder { // MARK: multipart/form-data -extension RequestEncoder where Self == MultipartEncoder { - public static func multipart(_ encoder: MultipartEncoder) -> Self { +extension HTTPBodyEncoder where Self == MultipartEncoder { + public static func multipart(_ encoder: MultipartEncoder = MultipartEncoder()) -> Self { encoder } } diff --git a/Papyrus/Sources/HTTPService.swift b/Papyrus/Sources/HTTPService.swift index df684ad..5b74b72 100644 --- a/Papyrus/Sources/HTTPService.swift +++ b/Papyrus/Sources/HTTPService.swift @@ -7,7 +7,4 @@ public protocol HTTPService { /// Concurrency based API func request(_ req: Request) async -> Response - - /// Callback based API - func request(_ req: Request, completionHandler: @escaping (Response) -> Void) } diff --git a/Papyrus/Sources/KeyMappable.swift b/Papyrus/Sources/KeyMappable.swift new file mode 100644 index 0000000..e14131a --- /dev/null +++ b/Papyrus/Sources/KeyMappable.swift @@ -0,0 +1,12 @@ +import Foundation + +public protocol KeyMappable { + func with(keyMapping: KeyMapping) -> Self +} + +extension KeyMappable { + func with(keyMapping: KeyMapping?) -> Self { + guard let keyMapping else { return self } + return with(keyMapping: keyMapping) + } +} diff --git a/Papyrus/Sources/KeyMapping.swift b/Papyrus/Sources/KeyMapping.swift index bcbc9c2..9dc4d87 100644 --- a/Papyrus/Sources/KeyMapping.swift +++ b/Papyrus/Sources/KeyMapping.swift @@ -1,9 +1,5 @@ import Foundation -public protocol KeyMappable { - func with(keyMapping: KeyMapping) -> Self -} - /// Represents the mapping between your type's property names and /// their corresponding request field key. public enum KeyMapping { diff --git a/Papyrus/Sources/Macros.swift b/Papyrus/Sources/Macros.swift index 541f3c3..debbc97 100644 --- a/Papyrus/Sources/Macros.swift +++ b/Papyrus/Sources/Macros.swift @@ -18,22 +18,22 @@ public macro Mock() = #externalMacro(module: "PapyrusPlugin", type: "MockMacro") public macro Headers(_ headers: [String: String]) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro JSON(encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro KeyMapping(_ mapping: KeyMapping) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro URLForm(_ encoder: URLEncodedFormEncoder = URLEncodedFormEncoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro Authorization(_ value: RequestBuilder.AuthorizationHeader) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro Multipart(_ encoder: MultipartEncoder = MultipartEncoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro JSON(encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro Converter(encoder: RequestEncoder, decoder: ResponseDecoder) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro URLForm(_ encoder: URLEncodedFormEncoder = URLEncodedFormEncoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro KeyMapping(_ mapping: KeyMapping) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro Multipart(_ encoder: MultipartEncoder = MultipartEncoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro Authorization(_ value: RequestBuilder.AuthorizationHeader) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro Coder(encoder: HTTPBodyEncoder, decoder: HTTPBodyDecoder) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") // MARK: Function attributes diff --git a/Papyrus/Sources/Multipart/MultipartDecoder.swift b/Papyrus/Sources/Multipart/MultipartDecoder.swift new file mode 100644 index 0000000..679349b --- /dev/null +++ b/Papyrus/Sources/Multipart/MultipartDecoder.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct MultipartDecoder: HTTPBodyDecoder { + public let boundary: String + + public init(boundary: String? = nil) { + self.boundary = boundary ?? .randomMultipartBoundary() + } + + public func with(keyMapping: KeyMapping) -> MultipartDecoder { + // KeyMapping isn't relevant since each part has already encoded data. + self + } + + public func decode(_ type: D.Type, from: Data) throws -> D where D : Decodable { + fatalError("multipart decoding isn't supported, yet") + } +} diff --git a/Papyrus/Sources/Multipart/MultipartEncoder.swift b/Papyrus/Sources/Multipart/MultipartEncoder.swift index d72b9d3..4b9e8ff 100644 --- a/Papyrus/Sources/Multipart/MultipartEncoder.swift +++ b/Papyrus/Sources/Multipart/MultipartEncoder.swift @@ -1,12 +1,12 @@ import Foundation -public struct MultipartEncoder: RequestEncoder { +public struct MultipartEncoder: HTTPBodyEncoder { public var contentType: String { "multipart/form-data; boundary=\(boundary)" } public let boundary: String private let crlf = "\r\n" public init(boundary: String? = nil) { - self.boundary = boundary ?? MultipartEncoder.randomBoundary() + self.boundary = boundary ?? .randomMultipartBoundary() } public func with(keyMapping: KeyMapping) -> MultipartEncoder { @@ -47,11 +47,4 @@ public struct MultipartEncoder: RequestEncoder { let string = headers.map { "\($0): \($1)\(crlf)" }.sorted().joined() + crlf return Data(string.utf8) } - - private static func randomBoundary() -> String { - let first = UInt32.random(in: UInt32.min...UInt32.max) - let second = UInt32.random(in: UInt32.min...UInt32.max) - - return String(format: "papyrus.boundary.%08x%08x", first, second) - } } diff --git a/Papyrus/Sources/PapyrusRouter.swift b/Papyrus/Sources/PapyrusRouter.swift deleted file mode 100644 index 96c82ea..0000000 --- a/Papyrus/Sources/PapyrusRouter.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation - -public protocol PapyrusRouter { - func register( - method: String, - path: String, - action: @escaping (RouterRequest) async throws -> RouterResponse - ) -} - -public struct RouterRequest { - public let url: URL - public let method: String - public let headers: [String: String] - public let body: Data? - - public init(url: URL, method: String, headers: [String : String], body: Data?) { - self.url = url - self.method = method - self.headers = headers - self.body = body - } - - public func getQuery(_ name: String) throws -> L { - guard let parameters = URLComponents(url: url, resolvingAgainstBaseURL: true) else { - throw PapyrusError("unable to parse url components for url `\(url)`") - } - - guard let item = parameters.queryItems?.first(where: { $0.name == name }) else { - throw PapyrusError("no query item found for `\(name)`") - } - - guard let string = item.value, let value = L(string) else { - throw PapyrusError("query `\(item.name)` was not convertible to `\(L.self)`") - } - - return value - } - - public func getBody(_ type: D.Type) throws -> D { - guard let body else { - throw PapyrusError("expected request body") - } - - let decoder = JSONDecoder() - return try decoder.decode(type, from: body) - } - - public func getHeader(_ name: String) throws -> L { - guard let string = headers[name] else { - throw PapyrusError("missing header `\(name)`") - } - - guard let value = L(string) else { - throw PapyrusError("header `\(name)` was not convertible to `\(L.self)`") - } - - return value - } - - public func getParameter(_ name: String, path: String) throws -> L { - let templatePathComponents = path.components(separatedBy: "/") - let requestPathComponents = url.pathComponents - let parametersByName = [String: String]( - zip(templatePathComponents, requestPathComponents) - .compactMap { - guard let parameter = $0.extractParameter else { return nil } - return (parameter, $1) - }, - uniquingKeysWith: { a, _ in a } - ) - - guard let string = parametersByName[name] else { - throw PapyrusError("parameter `\(name)` not found") - } - - guard let value = L(string) else { - throw PapyrusError("parameter `\(name)` was not convertible to `\(L.self)`") - } - - return value - } -} - -public struct RouterResponse { - public let status: Int - public let headers: [String: String] - public let body: Data? - - public init(_ status: Int, headers: [String: String] = [:], body: Data? = nil) { - self.status = status - self.headers = headers - self.body = body - } -} - -extension String { - fileprivate var extractParameter: String? { - if hasPrefix(":") { - String(dropFirst()) - } else if hasPrefix("{") && hasSuffix("}") { - String(dropFirst().dropLast()) - } else { - nil - } - } -} diff --git a/Papyrus/Sources/Provider.swift b/Papyrus/Sources/Provider.swift index 1342806..6145793 100644 --- a/Papyrus/Sources/Provider.swift +++ b/Papyrus/Sources/Provider.swift @@ -76,44 +76,3 @@ public protocol Interceptor { public protocol RequestModifier { func modify(req: inout RequestBuilder) throws } - -// MARK: Closure Based APIs - -extension Provider { - public func request(_ builder: inout RequestBuilder, completionHandler: @escaping (Response) -> Void) { - do { - let request = try createRequest(&builder) - var next = http.request - for interceptor in interceptors.reversed() { - let _next = next - next = { - interceptor.intercept(req: $0, completionHandler: $1, next: _next) - } - } - - return next(request, completionHandler) - } catch { - completionHandler(.error(error)) - } - } -} - -extension Interceptor { - fileprivate func intercept(req: Request, - completionHandler: @escaping (Response) -> Void, - next: @escaping (Request, @escaping (Response) -> Void) -> Void) { - Task { - do { - completionHandler( - try await intercept(req: req) { req in - return try await withCheckedThrowingContinuation { - next(req, $0.resume) - } - } - ) - } catch { - completionHandler(.error(error)) - } - } - } -} diff --git a/Papyrus/Sources/RequestBuilder.swift b/Papyrus/Sources/RequestBuilder.swift index 4ab83c7..e0fbe1d 100644 --- a/Papyrus/Sources/RequestBuilder.swift +++ b/Papyrus/Sources/RequestBuilder.swift @@ -73,10 +73,6 @@ public struct RequestBuilder { case multipart([ContentKey: Part]) } - public static var defaultQueryEncoder: URLEncodedFormEncoder = URLEncodedFormEncoder() - public static var defaultRequestEncoder: RequestEncoder = JSONEncoder() - public static var defaultResponseDecoder: ResponseDecoder = JSONDecoder() - // MARK: Data public var baseURL: String @@ -96,19 +92,19 @@ public struct RequestBuilder { get { _queryEncoder.with(keyMapping: keyMapping) } } - public var requestEncoder: RequestEncoder { - set { _requestEncoder = newValue } - get { _requestEncoder.with(keyMapping: keyMapping) } + public var requestBodyEncoder: HTTPBodyEncoder { + set { _requestBodyEncoder = newValue } + get { _requestBodyEncoder.with(keyMapping: keyMapping) } } - public var responseDecoder: ResponseDecoder { - set { _responseDecoder = newValue } - get { _responseDecoder.with(keyMapping: keyMapping) } + public var responseBodyDecoder: HTTPBodyDecoder { + set { _responseBodyDecoder = newValue } + get { _responseBodyDecoder.with(keyMapping: keyMapping) } } - private var _queryEncoder: URLEncodedFormEncoder = defaultQueryEncoder - private var _requestEncoder: RequestEncoder = defaultRequestEncoder - private var _responseDecoder: ResponseDecoder = defaultResponseDecoder + private var _queryEncoder: URLEncodedFormEncoder = Coders.defaultQueryEncoder + private var _requestBodyEncoder: HTTPBodyEncoder = Coders.defaultHTTPBodyEncoder + private var _responseBodyDecoder: HTTPBodyDecoder = Coders.defaultHTTPBodyDecoder public init(baseURL: String, method: String, path: String) { self.baseURL = baseURL @@ -205,7 +201,7 @@ public struct RequestBuilder { public func bodyAndHeaders() throws -> (Data?, [String: String]) { let body = try bodyData() var headers = headers - headers["Content-Type"] = requestEncoder.contentType + headers["Content-Type"] = requestBodyEncoder.contentType headers["Content-Length"] = "\(body?.count ?? 0)" return (body, headers) } @@ -236,15 +232,15 @@ public struct RequestBuilder { case .none: return nil case .value(let value): - return try requestEncoder.encode(value) + return try requestBodyEncoder.encode(value) case .multipart(let fields): let pairs = fields.map { ($0.mapped(keyMapping), $1) } let dict = Dictionary(uniqueKeysWithValues: pairs) - return try requestEncoder.encode(dict) + return try requestBodyEncoder.encode(dict) case .fields(let fields): let pairs = fields.map { ($0.mapped(keyMapping), $1) } let dict = Dictionary(uniqueKeysWithValues: pairs) - return try requestEncoder.encode(dict) + return try requestBodyEncoder.encode(dict) } } @@ -264,13 +260,6 @@ public struct RequestBuilder { } } -extension KeyMappable { - fileprivate func with(keyMapping: KeyMapping?) -> Self { - guard let keyMapping else { return self } - return with(keyMapping: keyMapping) - } -} - extension String { /// Converts a `camelCase` String to `Http-Header-Case`. fileprivate func httpHeaderCase() -> String { diff --git a/Papyrus/Sources/Response.swift b/Papyrus/Sources/Response.swift index c76d177..67b72d8 100644 --- a/Papyrus/Sources/Response.swift +++ b/Papyrus/Sources/Response.swift @@ -17,11 +17,11 @@ extension Response { return self } - public func decode(_ type: Data?.Type = Data?.self, using decoder: ResponseDecoder) throws -> Data? { + public func decode(_ type: Data?.Type = Data?.self, using decoder: HTTPBodyDecoder) throws -> Data? { try validate().body } - public func decode(_ type: Data.Type = Data.self, using decoder: ResponseDecoder) throws -> Data { + public func decode(_ type: Data.Type = Data.self, using decoder: HTTPBodyDecoder) throws -> Data { guard let body = try decode(Data?.self, using: decoder) else { throw makePapyrusError(with: "Unable to return the body of a `Response`; the body was nil.") } @@ -29,7 +29,7 @@ extension Response { return body } - public func decode(_ type: D?.Type = D?.self, using decoder: ResponseDecoder) throws -> D? { + public func decode(_ type: D?.Type = D?.self, using decoder: HTTPBodyDecoder) throws -> D? { guard let body, !body.isEmpty else { return nil } @@ -37,7 +37,7 @@ extension Response { return try decoder.decode(type, from: body) } - public func decode(_ type: D.Type = D.self, using decoder: ResponseDecoder) throws -> D { + public func decode(_ type: D.Type = D.self, using decoder: HTTPBodyDecoder) throws -> D { guard let body else { throw makePapyrusError(with: "Unable to decode `\(Self.self)` from a `Response`; body was nil.") } diff --git a/Papyrus/Sources/ResponseDecoder.swift b/Papyrus/Sources/ResponseDecoder.swift deleted file mode 100644 index 4d879cf..0000000 --- a/Papyrus/Sources/ResponseDecoder.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -public protocol ResponseDecoder: KeyMappable { - func decode(_ type: D.Type, from: Data) throws -> D -} - -// MARK: application/json - -extension ResponseDecoder where Self == JSONDecoder { - public static func json(_ decoder: JSONDecoder) -> Self { - decoder - } -} - -extension JSONDecoder: ResponseDecoder { - public func with(keyMapping: KeyMapping) -> Self { - let new = JSONDecoder() - new.userInfo = userInfo - new.dataDecodingStrategy = dataDecodingStrategy - new.dateDecodingStrategy = dateDecodingStrategy - new.nonConformingFloatDecodingStrategy = nonConformingFloatDecodingStrategy -#if os(Linux) -#else - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { - new.assumesTopLevelDictionary = assumesTopLevelDictionary - new.allowsJSON5 = allowsJSON5 - } -#endif - new.keyDecodingStrategy = keyMapping.jsonDecodingStrategy - return new as! Self - } -} diff --git a/Papyrus/Sources/Routing/PapyrusRouter.swift b/Papyrus/Sources/Routing/PapyrusRouter.swift new file mode 100644 index 0000000..72193c5 --- /dev/null +++ b/Papyrus/Sources/Routing/PapyrusRouter.swift @@ -0,0 +1,7 @@ +public protocol PapyrusRouter { + func register( + method: String, + path: String, + action: @escaping (RouterRequest) async throws -> RouterResponse + ) +} diff --git a/Papyrus/Sources/Routing/RequestParser.swift b/Papyrus/Sources/Routing/RequestParser.swift new file mode 100644 index 0000000..a574bde --- /dev/null +++ b/Papyrus/Sources/Routing/RequestParser.swift @@ -0,0 +1,93 @@ +import Foundation + +public struct RequestParser { + public var keyMapping: KeyMapping? + private let request: RouterRequest + + private var _requestQueryDecoder = Coders.defaultQueryDecoder + public var requestQueryDecoder: URLEncodedFormDecoder { + set { _requestQueryDecoder = newValue } + get { _requestQueryDecoder.with(keyMapping: keyMapping) } + } + + private var _requestBodyDecoder: HTTPBodyDecoder = Coders.defaultHTTPBodyDecoder + public var requestBodyDecoder: HTTPBodyDecoder { + set { _requestBodyDecoder = newValue } + get { _requestBodyDecoder.with(keyMapping: keyMapping) } + } + + private var _responseBodyEncoder: HTTPBodyEncoder = Coders.defaultHTTPBodyEncoder + public var responseBodyEncoder: HTTPBodyEncoder { + set { _responseBodyEncoder = newValue } + get { _responseBodyEncoder.with(keyMapping: keyMapping) } + } + + public init(req: RouterRequest) { + self.request = req + } + + // MARK: Parsing methods + + public func getQuery(_ type: D.Type) throws -> D { + guard let queryString = request.url.query else { + throw PapyrusError("request had no query `\(request.url)`") + } + + return try requestQueryDecoder.decode(type, from: queryString) + } + + public func getParameter(_ name: String, path: String) throws -> L { + let templatePathComponents = path.components(separatedBy: "/") + let requestPathComponents = request.url.pathComponents + let parametersByName = [String: String]( + zip(templatePathComponents, requestPathComponents) + .compactMap { + guard let parameter = $0.extractParameter else { return nil } + return (parameter, $1) + }, + uniquingKeysWith: { a, _ in a } + ) + + guard let string = parametersByName[name] else { + throw PapyrusError("parameter `\(name)` not found") + } + + guard let value = L(string) else { + throw PapyrusError("parameter `\(name)` was not convertible to `\(L.self)`") + } + + return value + } + + public func getHeader(_ name: String) throws -> L { + guard let string = request.headers[name] else { + throw PapyrusError("missing header `\(name)`") + } + + guard let value = L(string) else { + throw PapyrusError("header `\(name)` was not convertible to `\(L.self)`") + } + + return value + } + + public func getBody(_ type: D.Type) throws -> D { + guard let body = request.body else { + throw PapyrusError("expected request body") + } + + return try requestBodyDecoder.decode(type, from: body) + } +} + +extension String { + fileprivate var extractParameter: String? { + if hasPrefix(":") { + String(dropFirst()) + } else if hasPrefix("{") && hasSuffix("}") { + String(dropFirst().dropLast()) + } else { + nil + } + } +} diff --git a/Papyrus/Sources/Routing/RouterRequest.swift b/Papyrus/Sources/Routing/RouterRequest.swift new file mode 100644 index 0000000..8588bef --- /dev/null +++ b/Papyrus/Sources/Routing/RouterRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct RouterRequest { + public let url: URL + public let method: String + public let headers: [String: String] + public let body: Data? + + public init(url: URL, method: String, headers: [String : String], body: Data?) { + self.url = url + self.method = method + self.headers = headers + self.body = body + } +} diff --git a/Papyrus/Sources/Routing/RouterResponse.swift b/Papyrus/Sources/Routing/RouterResponse.swift new file mode 100644 index 0000000..1fbabdc --- /dev/null +++ b/Papyrus/Sources/Routing/RouterResponse.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct RouterResponse { + public let status: Int + public let headers: [String: String] + public let body: Data? + + public init(_ status: Int, headers: [String: String] = [:], body: Data? = nil) { + self.status = status + self.headers = headers + self.body = body + } +} diff --git a/Papyrus/Sources/URLEncoder/URLEncodedForm.swift b/Papyrus/Sources/URLEncoded/URLEncodedForm.swift similarity index 100% rename from Papyrus/Sources/URLEncoder/URLEncodedForm.swift rename to Papyrus/Sources/URLEncoded/URLEncodedForm.swift diff --git a/Papyrus/Sources/URLEncoder/URLEncodedFormDecoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift similarity index 100% rename from Papyrus/Sources/URLEncoder/URLEncodedFormDecoder.swift rename to Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift diff --git a/Papyrus/Sources/URLEncoder/URLEncodedFormEncoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift similarity index 100% rename from Papyrus/Sources/URLEncoder/URLEncodedFormEncoder.swift rename to Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift diff --git a/Papyrus/Sources/URLEncoder/URLEncodedFormNode.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift similarity index 100% rename from Papyrus/Sources/URLEncoder/URLEncodedFormNode.swift rename to Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift diff --git a/Papyrus/Tests/CurlTests.swift b/Papyrus/Tests/CurlTests.swift index 1e444b1..f70044c 100644 --- a/Papyrus/Tests/CurlTests.swift +++ b/Papyrus/Tests/CurlTests.swift @@ -39,7 +39,7 @@ final class CurlTests: XCTestCase { func testConvertMultipart() throws { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") let encoder = MultipartEncoder(boundary: UUID().uuidString) - req.requestEncoder = encoder + req.requestBodyEncoder = encoder req.addField("a", value: Part(data: Data("one".utf8), fileName: "one.txt", mimeType: "text/plain")) req.addField("b", value: Part(data: Data("two".utf8))) @@ -68,7 +68,7 @@ final class CurlTests: XCTestCase { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] - req.requestEncoder = encoder + req.requestBodyEncoder = encoder req.addField("a", value: "one") req.addField("b", value: "two") @@ -90,7 +90,7 @@ final class CurlTests: XCTestCase { func testConvertURLForm() async throws { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") - req.requestEncoder = URLEncodedFormEncoder() + req.requestBodyEncoder = URLEncodedFormEncoder() req.addField("a", value: "one") req.addField("b", value: "two") diff --git a/Papyrus/Tests/RequestBuilderTests.swift b/Papyrus/Tests/RequestBuilderTests.swift index b599c1f..54765a7 100644 --- a/Papyrus/Tests/RequestBuilderTests.swift +++ b/Papyrus/Tests/RequestBuilderTests.swift @@ -20,7 +20,7 @@ final class RequestBuilderTests: XCTestCase { func testMultipart() throws { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") let encoder = MultipartEncoder(boundary: UUID().uuidString) - req.requestEncoder = encoder + req.requestBodyEncoder = encoder req.addField("a", value: Part(data: Data("one".utf8), fileName: "one.txt", mimeType: "text/plain")) req.addField("b", value: Part(data: Data("two".utf8))) let (body, headers) = try req.bodyAndHeaders() @@ -58,7 +58,7 @@ final class RequestBuilderTests: XCTestCase { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] - req.requestEncoder = encoder + req.requestBodyEncoder = encoder req.addField("a", value: "one") req.addField("b", value: "two") let (body, headers) = try req.bodyAndHeaders() @@ -87,7 +87,7 @@ final class RequestBuilderTests: XCTestCase { func testURLForm() async throws { var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz") - req.requestEncoder = URLEncodedFormEncoder() + req.requestBodyEncoder = URLEncodedFormEncoder() req.addField("a", value: "one") req.addField("b", value: "two") let (body, headers) = try req.bodyAndHeaders() diff --git a/PapyrusPlugin/Sources/Macros/APIMacro.swift b/PapyrusPlugin/Sources/Macros/APIMacro.swift index c25395b..ac8fcf7 100644 --- a/PapyrusPlugin/Sources/Macros/APIMacro.swift +++ b/PapyrusPlugin/Sources/Macros/APIMacro.swift @@ -85,7 +85,7 @@ extension API.Endpoint { case .some(let type): "let res = try await provider.request(&req)" "try res.validate()" - "return try res.decode(\(type).self, using: req.responseDecoder)" + "return try res.decode(\(type).self, using: req.responseBodyDecoder)" } } } @@ -113,17 +113,17 @@ extension EndpointAttribute { switch self { case .json(let encoder, let decoder): """ - req.requestEncoder = .json(\(encoder)) - req.responseDecoder = .json(\(decoder)) + req.requestBodyEncoder = .json(\(encoder)) + req.responseBodyDecoder = .json(\(decoder)) """ case .urlForm(let encoder): - "req.requestEncoder = .urlForm(\(encoder))" + "req.requestBodyEncoder = .urlForm(\(encoder))" case .multipart(let encoder): - "req.requestEncoder = .multipart(\(encoder))" + "req.requestBodyEncoder = .multipart(\(encoder))" case .converter(let encoder, let decoder): """ - req.requestEncoder = \(encoder) - req.responseDecoder = \(decoder) + req.requestBodyEncoder = \(encoder) + req.responseBodyDecoder = \(decoder) """ case .headers(let value): "req.addHeaders(\(value))" diff --git a/PapyrusPlugin/Sources/Macros/RoutesMacro.swift b/PapyrusPlugin/Sources/Macros/RoutesMacro.swift index 8c39651..5960a9e 100644 --- a/PapyrusPlugin/Sources/Macros/RoutesMacro.swift +++ b/PapyrusPlugin/Sources/Macros/RoutesMacro.swift @@ -42,6 +42,21 @@ extension API { endpoint.registerStatement() } } + + Declaration("static func parser(req: RouterRequest) -> RequestParser") { + if attributes.isEmpty { + "RequestParser(req: req)" + } else { + "var req = RequestParser(req: req)" + + for modifier in attributes { + modifier.parserStatement() + } + + "return req" + } + } + .private() } .private() } @@ -59,8 +74,8 @@ extension API { extension API.Endpoint { func registerStatement() -> Declaration { Declaration("router.register(method: \(method.inQuotes), path: \(path.inQuotes))", "req") { - for parameter in parameters { - parameter.parserStatement(path: path) + if !parameters.isEmpty { + "let req = parser(req: req)" } let fields = parameters.filter { $0.kind == .field } @@ -74,6 +89,21 @@ extension API.Endpoint { "let body = try req.getBody(Body.self)" } + let queries = parameters.filter { $0.kind == .query } + if !queries.isEmpty { + Declaration("struct Query: Decodable") { + for query in queries { + "let \(query.name): \(query.type)" + } + } + + "let query = try req.getQuery(Query.self)" + } + + for parameter in parameters { + parameter.parserStatement(path: path) + } + let arguments = parameters.map(\.argumentString).joined(separator: ", ").inParentheses let status = method == "POST" ? 201 : 200 if responseType == "Void" || responseType == nil { @@ -92,23 +122,44 @@ extension EndpointParameter { fileprivate var argumentString: String { let argumentLabel = label == "_" ? nil : label ?? name let label = argumentLabel.map { "\($0): " } ?? "" - let prefix = kind == .field ? "body." : "" + let prefix = switch kind { + case .field: "body." + case .query: "query." + default: "" + } return label + prefix + name } } +extension EndpointAttribute { + fileprivate func parserStatement() -> String? { + switch self { + case .keyMapping(let value): + "req.keyMapping = \(value)" + case .json: + "req.requestBodyDecoder = JSONDecoder()" + case .urlForm: + "req.requestBodyDecoder = URLEncodedFormDecoder()" + case .multipart: + "req.requestBodyDecoder = MultipartDecoder()" + case .converter: // custom decoding will need to be at the Router level + nil + case .authorization, .headers: // nothing to parse here + nil + } + } +} + extension EndpointParameter { fileprivate func parserStatement(path: String) -> String? { switch kind { case .body: "let \(name): \(type) = try req.getBody(\(type).self)" - case .query: - "let \(name): \(type) = try req.getQuery(\(name.inQuotes))" case .header: "let \(name): \(type) = try req.getHeader(\(name.inQuotes))" case .path: "let \(name): \(type) = try req.getParameter(\(name.inQuotes), path: \(path.inQuotes))" - case .field: + case .field, .query: nil } } diff --git a/PapyrusPlugin/Sources/Models/EndpointAttribute.swift b/PapyrusPlugin/Sources/Models/EndpointAttribute.swift index f87cfcd..384988d 100644 --- a/PapyrusPlugin/Sources/Models/EndpointAttribute.swift +++ b/PapyrusPlugin/Sources/Models/EndpointAttribute.swift @@ -27,7 +27,7 @@ enum EndpointAttribute { self = .urlForm(encoder: firstArgument ?? "URLEncodedFormEncoder()") case "Multipart": self = .multipart(encoder: firstArgument ?? "MultipartEncoder()") - case "Converter": + case "Coder": guard let firstArgument, let secondArgument else { return nil } self = .converter(encoder: firstArgument, decoder: secondArgument) case "KeyMapping": diff --git a/PapyrusPlugin/Tests/APIMacroTests.swift b/PapyrusPlugin/Tests/APIMacroTests.swift index a8e1551..4d5ae64 100644 --- a/PapyrusPlugin/Tests/APIMacroTests.swift +++ b/PapyrusPlugin/Tests/APIMacroTests.swift @@ -99,13 +99,13 @@ final class APIMacroTests: XCTestCase { req.addQuery("userId", value: userId) let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder { var req = provider.newBuilder(method: method, path: path) - req.requestEncoder = .json(JSONEncoder()) - req.responseDecoder = .json(JSONDecoder()) + req.requestBodyEncoder = .json(JSONEncoder()) + req.responseBodyDecoder = .json(JSONDecoder()) return req } } @@ -141,7 +141,7 @@ final class APIMacroTests: XCTestCase { req.addQuery("userId", value: userId) let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder { @@ -180,7 +180,7 @@ final class APIMacroTests: XCTestCase { req.addField("userId", value: userId) let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder { @@ -226,7 +226,7 @@ final class APIMacroTests: XCTestCase { req.addQuery("since", value: since) let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder { @@ -273,7 +273,7 @@ final class APIMacroTests: XCTestCase { req.addQuery("since", value: since) let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder { @@ -319,8 +319,8 @@ final class APIMacroTests: XCTestCase { private func builder(method: String, path: String) -> RequestBuilder { var req = provider.newBuilder(method: method, path: path) req.keyMapping = .snakeCase - req.requestEncoder = .json(.foo) - req.responseDecoder = .json(.bar) + req.requestBodyEncoder = .json(.foo) + req.responseBodyDecoder = .json(.bar) return req } } @@ -369,8 +369,8 @@ final class APIMacroTests: XCTestCase { private func builder(method: String, path: String) -> RequestBuilder { var req = provider.newBuilder(method: method, path: path) req.keyMapping = .snakeCase - req.requestEncoder = .json(.foo) - req.responseDecoder = .json(.bar) + req.requestBodyEncoder = .json(.foo) + req.responseBodyDecoder = .json(.bar) return req } } @@ -445,7 +445,7 @@ final class APIMacroTests: XCTestCase { var req = builder(method: "GET", path: "name") let res = try await provider.request(&req) try res.validate() - return try res.decode(String.self, using: req.responseDecoder) + return try res.decode(String.self, using: req.responseBodyDecoder) } private func builder(method: String, path: String) -> RequestBuilder {