diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2992f3..f26a49c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main] jobs: - test-macos: + test-macos-xcode-15: runs-on: macos-13 env: DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer @@ -17,6 +17,16 @@ jobs: run: swift build -v - name: Test run: swift test -v + test-macos-xcode-16: + runs-on: macos-14 + env: + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - name: Test + run: swift test -v test-linux: runs-on: ubuntu-22.04 strategy: @@ -29,3 +39,26 @@ jobs: run: swift build - name: Run tests run: swift test + test-linux-swift6: + runs-on: ubuntu-24.04 + strategy: + matrix: + swift: [6.0.1] + container: swift:${{ matrix.swift }} + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build + - name: Run tests + run: swift test + check-macro-compatibility: + name: Check Macro Compatibility + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Run Swift Macro Compatibility Check + uses: Matejkob/swift-macro-compatibility-check@v1 + with: + run-tests: false + major-versions-only: true diff --git a/Example/Example.swift b/Example/Example.swift new file mode 100644 index 0000000..706ad26 --- /dev/null +++ b/Example/Example.swift @@ -0,0 +1,67 @@ +import Foundation +import Papyrus + +// MARK: 0. Define your API. + +@API +@Mock +public protocol Sample: Sendable { + @GET("/todos") + func getTodos() async throws -> [Todo] + + @POST("/todos") + func createTodo(name: String) async throws -> Todo + + @URLForm + @POST("/todos/:id/tags") + func createTag(id: Int) async throws + + @Multipart + @POST("/todo/:id/attachment") + func upload(id: Int, part1: Part, part2: Part) async throws +} + +public struct Todo: Codable, Sendable { + let id: Int + let name: String +} + +@main +struct Example { + static func main() async throws { + // MARK: 1. Create a Provider with any custom configuration. + + let provider = Provider(baseURL: "http://127.0.0.1:3000") + .modifyRequests { + $0.addAuthorization(.bearer("")) + $0.keyMapping = .snakeCase + } + .intercept { req, next in + let start = Date() + let res = try await next(req) + let elapsedTime = String(format: "%.2fs", Date().timeIntervalSince(start)) + let statusCode = res.statusCode.map { "\($0)" } ?? "N/A" + print("Got a \(statusCode) for \(req.method) \(req.url!) after \(elapsedTime)") + return res + } + + // MARK: 2. Initialize an API instance & call an endpoint. + + let api: any Sample = SampleAPI(provider: provider) + let todos = try await api.getTodos() + print(todos) + + // MARK: 3. Easily mock endpoints for tests. + + let mock = SampleMock() + mock.mockGetTodos { + return [ + Todo(id: 1, name: "Foo"), + Todo(id: 2, name: "Bar"), + ] + } + + let mockedTodos = try await mock.getTodos() + print(mockedTodos) + } +} diff --git a/Example/main.swift b/Example/main.swift deleted file mode 100644 index 60d9f77..0000000 --- a/Example/main.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import Papyrus - -// MARK: 0. Define your API. - -@API -@Mock -public protocol Sample { - @GET("/todos") - func getTodos() async throws -> [Todo] - - @POST("/todos") - func createTodo(name: String) async throws -> Todo - - @URLForm - @POST("/todos/:id/tags") - func createTag(id: Int) async throws - - @Multipart - @POST("/todo/:id/attachment") - func upload(id: Int, part1: Part, part2: Part) async throws -} - -public struct Todo: Codable { - let id: Int - let name: String -} - -// MARK: 1. Create a Provider with any custom configuration. - -let provider = Provider(baseURL: "http://127.0.0.1:3000") - .modifyRequests { - $0.addAuthorization(.bearer("")) - $0.keyMapping = .snakeCase - } - .intercept { req, next in - let start = Date() - let res = try await next(req) - let elapsedTime = String(format: "%.2fs", Date().timeIntervalSince(start)) - let statusCode = res.statusCode.map { "\($0)" } ?? "N/A" - print("Got a \(statusCode) for \(req.method) \(req.url!) after \(elapsedTime)") - return res - } - -// MARK: 2. Initialize an API instance & call an endpoint. - -let api: Sample = SampleAPI(provider: provider) -let todos = try await api.getTodos() - -// MARK: 3. Easily mock endpoints for tests. - -let mock = SampleMock() -mock.mockGetTodos { - return [ - Todo(id: 1, name: "Foo"), - Todo(id: 2, name: "Bar"), - ] -} - -let mockedTodos = try await mock.getTodos() diff --git a/Package.swift b/Package.swift index 91ea174..dcec3b3 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( .library(name: "Papyrus", targets: ["Papyrus"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-syntax", "509.0.0"..<"601.0.0-prerelease"), + .package(url: "https://github.com/apple/swift-syntax", "509.0.0"..<"601.0.0"), .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.1.0"), ], targets: [ @@ -36,7 +36,10 @@ let package = Package( dependencies: [ "PapyrusPlugin" ], - path: "Papyrus/Sources" + path: "Papyrus/Sources", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] ), .testTarget( name: "PapyrusTests", @@ -70,3 +73,14 @@ let package = Package( ), ] ) + +#if compiler(>=6) + for target in package.targets where target.type != .system && target.type != .test { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings?.append(contentsOf: [ + .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InferSendableFromCaptures"), + ]) + } +#endif diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000..a30079b --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,90 @@ +// swift-tools-version:6.0 +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "papyrus", + platforms: [ + .iOS("13.0"), + .macOS("10.15"), + .tvOS("13.0") + ], + products: [ + .executable(name: "Example", targets: ["Example"]), + .library(name: "Papyrus", targets: ["Papyrus"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax", "509.0.0"..<"601.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.1.0"), + ], + targets: [ + + // MARK: Demo + + .executableTarget( + name: "Example", + dependencies: [ + "Papyrus" + ], + path: "Example" + ), + + // MARK: Library + + .target( + name: "Papyrus", + dependencies: [ + "PapyrusPlugin" + ], + path: "Papyrus/Sources", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "PapyrusTests", + dependencies: [ + "Papyrus" + ], + path: "Papyrus/Tests" + ), + + // MARK: Plugin + + .macro( + name: "PapyrusPlugin", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftOperators", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftParserDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "PapyrusPlugin/Sources" + ), + .testTarget( + name: "PapyrusPluginTests", + dependencies: [ + "PapyrusPlugin", + .product(name: "MacroTesting", package: "swift-macro-testing"), + ], + path: "PapyrusPlugin/Tests" + ), + ] +) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings?.append(contentsOf: [ + .enableUpcomingFeature("ExistentialAny") + ]) +} + +for target in package.targets where target.type == .system || target.type == .test { + target.swiftSettings?.append(contentsOf: [ + .swiftLanguageMode(.v5), + .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("InferSendableFromCaptures"), + ]) +} diff --git a/Papyrus/Sources/CoderProvider.swift b/Papyrus/Sources/CoderProvider.swift new file mode 100644 index 0000000..14154e1 --- /dev/null +++ b/Papyrus/Sources/CoderProvider.swift @@ -0,0 +1,26 @@ +public protocol CoderProvider: Sendable { + func provideHttpBodyEncoder() -> any HTTPBodyEncoder + func provideHttpBodyDecoder() -> any HTTPBodyDecoder + func provideQueryEncoder() -> URLEncodedFormEncoder + func provideQueryDecoder() -> URLEncodedFormDecoder +} + +public struct DefaultProvider: CoderProvider { + public init() {} + + public func provideHttpBodyEncoder() -> any HTTPBodyEncoder { + return .json() + } + + public func provideHttpBodyDecoder() -> any HTTPBodyDecoder { + return .json() + } + + public func provideQueryEncoder() -> URLEncodedFormEncoder { + return URLEncodedFormEncoder() + } + + public func provideQueryDecoder() -> URLEncodedFormDecoder { + return URLEncodedFormDecoder() + } +} diff --git a/Papyrus/Sources/Coders.swift b/Papyrus/Sources/Coders.swift deleted file mode 100644 index d55a696..0000000 --- a/Papyrus/Sources/Coders.swift +++ /dev/null @@ -1,12 +0,0 @@ -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/URLSession+Papyrus.swift b/Papyrus/Sources/Extensions/URLSession+Papyrus.swift index 9d24d80..52e122a 100644 --- a/Papyrus/Sources/Extensions/URLSession+Papyrus.swift +++ b/Papyrus/Sources/Extensions/URLSession+Papyrus.swift @@ -6,8 +6,8 @@ import FoundationNetworking extension Provider { public convenience init(baseURL: String, urlSession: URLSession = .shared, - modifiers: [RequestModifier] = [], - interceptors: [Interceptor] = []) { + modifiers: [any RequestModifier] = [], + interceptors: [any Interceptor] = []) { self.init(baseURL: baseURL, http: urlSession, modifiers: modifiers, interceptors: interceptors) } } @@ -15,7 +15,7 @@ extension Provider { // MARK: `HTTPService` Conformance extension URLSession: HTTPService { - public func build(method: String, url: URL, headers: [String: String], body: Data?) -> PapyrusRequest { + public func build(method: String, url: URL, headers: [String: String], body: Data?) -> any PapyrusRequest { var request = URLRequest(url: url) request.httpMethod = method request.httpBody = body @@ -23,7 +23,7 @@ extension URLSession: HTTPService { return request } - public func request(_ req: PapyrusRequest) async -> PapyrusResponse { + public func request(_ req: any PapyrusRequest) async -> any PapyrusResponse { #if os(Linux) // Linux doesn't have access to async URLSession APIs await withCheckedContinuation { continuation in let urlRequest = req.urlRequest @@ -55,13 +55,13 @@ private struct _Response: PapyrusResponse { let urlRequest: URLRequest let urlResponse: URLResponse? - var request: PapyrusRequest? { urlRequest } - let error: Error? + var request: (any PapyrusRequest)? { urlRequest } + let error: (any Error)? let body: Data? let headers: [String: String]? var statusCode: Int? { (urlResponse as? HTTPURLResponse)?.statusCode } - init(request: URLRequest, response: URLResponse?, error: Error?, body: Data?) { + init(request: URLRequest, response: URLResponse?, error: (any Error)?, body: Data?) { self.urlRequest = request self.urlResponse = response self.error = error diff --git a/Papyrus/Sources/HTTPService.swift b/Papyrus/Sources/HTTPService.swift index b5b416e..22ec4ae 100644 --- a/Papyrus/Sources/HTTPService.swift +++ b/Papyrus/Sources/HTTPService.swift @@ -1,10 +1,11 @@ import Foundation /// A type that can perform arbitrary HTTP requests. -public protocol HTTPService { +public protocol HTTPService: Sendable { /// Build a `Request` from the given components. - func build(method: String, url: URL, headers: [String: String], body: Data?) -> PapyrusRequest + func build(method: String, url: URL, headers: [String: String], body: Data?) -> any PapyrusRequest /// Concurrency based API - func request(_ req: PapyrusRequest) async -> PapyrusResponse + @Sendable + func request(_ req: any PapyrusRequest) async -> any PapyrusResponse } diff --git a/Papyrus/Sources/Interceptors/CurlInterceptor.swift b/Papyrus/Sources/Interceptors/CurlInterceptor.swift index 9878017..db99d78 100644 --- a/Papyrus/Sources/Interceptors/CurlInterceptor.swift +++ b/Papyrus/Sources/Interceptors/CurlInterceptor.swift @@ -2,28 +2,29 @@ import Foundation /// An `Interceptor` that logs requests based on a condition public struct CurlLogger { - public enum Condition { + public typealias LogHandler = @Sendable (String) -> Void + public enum Condition: Sendable { case always /// only log when the request encountered an error case onError } - - let logHandler: (String) -> Void + + let logHandler: LogHandler let condition: Condition /// An `Interceptor` that calls a logHandler with a request based on a condition /// - Parameters: /// - condition: must be met for the logging function to be called /// - logHandler: a function that implements logging. defaults to `print()` - public init(when condition: Condition, using logHandler: @escaping (String) -> Void = { print($0) }) { + public init(when condition: Condition, using logHandler: @escaping LogHandler = { print($0) }) { self.condition = condition self.logHandler = logHandler } } extension CurlLogger: Interceptor { - public func intercept(req: PapyrusRequest, next: Next) async throws -> PapyrusResponse { + public func intercept(req: any PapyrusRequest, next: Next) async throws -> any PapyrusResponse { if condition == .always { logHandler(req.curl(sortedHeaders: true)) } diff --git a/Papyrus/Sources/KeyMapping.swift b/Papyrus/Sources/KeyMapping.swift index 9dc4d87..432c60f 100644 --- a/Papyrus/Sources/KeyMapping.swift +++ b/Papyrus/Sources/KeyMapping.swift @@ -2,7 +2,7 @@ import Foundation /// Represents the mapping between your type's property names and /// their corresponding request field key. -public enum KeyMapping { +public enum KeyMapping: Sendable { /// Use the literal name for all properties on a type as its field key. case useDefaultKeys @@ -12,8 +12,8 @@ public enum KeyMapping { case snakeCase /// A custom key mapping. - case custom(to: (String) -> String, from: (String) -> String) - + case custom(to: @Sendable (String) -> String, from: @Sendable (String) -> String) + /// Encode String from camelCase to this KeyMapping strategy. public func encode(_ string: String) -> String { switch self { diff --git a/Papyrus/Sources/PapyrusError.swift b/Papyrus/Sources/PapyrusError.swift index 9ef9126..16e3d85 100644 --- a/Papyrus/Sources/PapyrusError.swift +++ b/Papyrus/Sources/PapyrusError.swift @@ -3,16 +3,16 @@ public struct PapyrusError: Error, CustomDebugStringConvertible { /// What went wrong. public let message: String /// Error related request. - public let request: PapyrusRequest? + public let request: (any PapyrusRequest)? /// Error related response. - public let response: PapyrusResponse? - + public let response: (any PapyrusResponse)? + /// Create an error with the specified message. /// /// - Parameter message: What went wrong. /// - Parameter request: Error related request. /// - Parameter response: Error related response. - public init(_ message: String, _ request: PapyrusRequest? = nil, _ response: PapyrusResponse? = nil) { + public init(_ message: String, _ request: (any PapyrusRequest)? = nil, _ response: (any PapyrusResponse)? = nil) { self.message = message self.request = request self.response = response diff --git a/Papyrus/Sources/PapyrusMacros.swift b/Papyrus/Sources/PapyrusMacros.swift index b17978d..14a7e26 100644 --- a/Papyrus/Sources/PapyrusMacros.swift +++ b/Papyrus/Sources/PapyrusMacros.swift @@ -33,7 +33,7 @@ public macro URLForm(_ encoder: URLEncodedFormEncoder = URLEncodedFormEncoder()) public macro Multipart(_ encoder: MultipartEncoder = MultipartEncoder()) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") @attached(peer) -public macro Coder(encoder: HTTPBodyEncoder, decoder: HTTPBodyDecoder) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +public macro Coder(encoder: any HTTPBodyEncoder, decoder: any HTTPBodyDecoder) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") // MARK: Function attributes diff --git a/Papyrus/Sources/PapyrusRequest.swift b/Papyrus/Sources/PapyrusRequest.swift index d0c9bac..c33b5a6 100644 --- a/Papyrus/Sources/PapyrusRequest.swift +++ b/Papyrus/Sources/PapyrusRequest.swift @@ -1,6 +1,6 @@ import Foundation -public protocol PapyrusRequest { +public protocol PapyrusRequest: Sendable { var url: URL? { get set } var method: String { get set } var headers: [String: String] { get set } diff --git a/Papyrus/Sources/PapyrusResponse.swift b/Papyrus/Sources/PapyrusResponse.swift index df9a075..9c2c09d 100644 --- a/Papyrus/Sources/PapyrusResponse.swift +++ b/Papyrus/Sources/PapyrusResponse.swift @@ -1,11 +1,11 @@ import Foundation -public protocol PapyrusResponse { - var request: PapyrusRequest? { get } +public protocol PapyrusResponse: Sendable { + var request: (any PapyrusRequest)? { get } var body: Data? { get } var headers: [String: String]? { get } var statusCode: Int? { get } - var error: Error? { get } + var error: (any Error)? { get } } extension PapyrusResponse { @@ -17,11 +17,11 @@ extension PapyrusResponse { return self } - public func decode(_ type: Data?.Type = Data?.self, using decoder: HTTPBodyDecoder) throws -> Data? { + public func decode(_ type: Data?.Type = Data?.self, using decoder: any HTTPBodyDecoder) throws -> Data? { try validate().body } - public func decode(_ type: Data.Type = Data.self, using decoder: HTTPBodyDecoder) throws -> Data { + public func decode(_ type: Data.Type = Data.self, using decoder: any 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 PapyrusResponse { return body } - public func decode(_ type: D?.Type = D?.self, using decoder: HTTPBodyDecoder) throws -> D? { + public func decode(_ type: D?.Type = D?.self, using decoder: any HTTPBodyDecoder) throws -> D? { guard let body, !body.isEmpty else { return nil } @@ -37,7 +37,7 @@ extension PapyrusResponse { return try decoder.decode(type, from: body) } - public func decode(_ type: D.Type = D.self, using decoder: HTTPBodyDecoder) throws -> D { + public func decode(_ type: D.Type = D.self, using decoder: any HTTPBodyDecoder) throws -> D { guard let body else { throw makePapyrusError(with: "Unable to decode `\(Self.self)` from a `Response`; body was nil.") } @@ -51,21 +51,21 @@ extension PapyrusResponse { } extension PapyrusResponse where Self == ErrorResponse { - public static func error(_ error: Error) -> PapyrusResponse { + public static func error(_ error: any Error) -> any PapyrusResponse { ErrorResponse(error) } } public struct ErrorResponse: PapyrusResponse { - let _error: Error? + let _error: (any Error)? - public init(_ error: Error) { + public init(_ error: any Error) { self._error = error } - public var request: PapyrusRequest? { nil } + public var request: (any PapyrusRequest)? { nil } public var body: Data? { nil } public var headers: [String : String]? { nil } public var statusCode: Int? { nil } - public var error: Error? { _error } + public var error: (any Error)? { _error } } diff --git a/Papyrus/Sources/Provider.swift b/Papyrus/Sources/Provider.swift index f7f4bfd..c4694bd 100644 --- a/Papyrus/Sources/Provider.swift +++ b/Papyrus/Sources/Provider.swift @@ -1,23 +1,43 @@ import Foundation /// Makes URL requests. -public final class Provider { +public final class Provider: Sendable { public let baseURL: String - public let http: HTTPService - public var interceptors: [Interceptor] - public var modifiers: [RequestModifier] + public let http: any HTTPService + public let provider: any CoderProvider + private let interceptors: ResourceMutex<[any Interceptor]> + private let modifiers: ResourceMutex<[any RequestModifier]> - public init(baseURL: String, http: HTTPService, modifiers: [RequestModifier] = [], interceptors: [Interceptor] = []) { + public init( + baseURL: String, + http: any HTTPService, + modifiers: [any RequestModifier] = [], + interceptors: [any Interceptor] = [], + provider: any CoderProvider = DefaultProvider() + ) { self.baseURL = baseURL self.http = http - self.interceptors = interceptors - self.modifiers = modifiers + self.provider = provider + self.interceptors = .init(resource: interceptors) + self.modifiers = .init(resource: modifiers) } public func newBuilder(method: String, path: String) -> RequestBuilder { RequestBuilder(baseURL: baseURL, method: method, path: path) } + public func add(interceptor: any Interceptor) { + interceptors.withLock { resource in + resource.append(interceptor) + } + } + + public func insert(interceptor: any Interceptor, at index: Int) { + interceptors.withLock { resource in + resource.insert(interceptor, at: index) + } + } + public func modifyRequests(action: @escaping (inout RequestBuilder) throws -> Void) -> Self { struct AnonymousModifier: RequestModifier { let action: (inout RequestBuilder) throws -> Void @@ -27,39 +47,49 @@ public final class Provider { } } - modifiers.append(AnonymousModifier(action: action)) + modifiers.withLock { resource in + resource.append(AnonymousModifier(action: action)) + } return self } @discardableResult - public func intercept(action: @escaping (PapyrusRequest, (PapyrusRequest) async throws -> PapyrusResponse) async throws -> PapyrusResponse) -> Self { + public func intercept(action: @Sendable @escaping ( + any PapyrusRequest, + (any PapyrusRequest) async throws -> any PapyrusResponse + ) async throws -> any PapyrusResponse) -> Self { struct AnonymousInterceptor: Interceptor { - let action: (PapyrusRequest, Interceptor.Next) async throws -> PapyrusResponse + let action: @Sendable (any PapyrusRequest, Interceptor.Next) async throws -> any PapyrusResponse - func intercept(req: PapyrusRequest, next: Interceptor.Next) async throws -> PapyrusResponse { + func intercept(req: any PapyrusRequest, + next: Interceptor.Next) async throws -> any PapyrusResponse { try await action(req, next) } } - - interceptors.append(AnonymousInterceptor(action: action)) + interceptors.withLock { resource in + resource.append(AnonymousInterceptor(action: action)) + } return self } @discardableResult - public func request(_ builder: inout RequestBuilder) async throws -> PapyrusResponse { + public func request(_ builder: inout RequestBuilder) async throws -> any PapyrusResponse { let request = try createRequest(&builder) - var next: (PapyrusRequest) async throws -> PapyrusResponse = http.request - for interceptor in interceptors.reversed() { - let _next = next - next = { try await interceptor.intercept(req: $0, next: _next) } + var next: @Sendable (any PapyrusRequest) async throws -> any PapyrusResponse = http.request + interceptors.withLock { resource in + for interceptor in resource.reversed() { + let _next = next + next = { try await interceptor.intercept(req: $0, next: _next) } + } } - return try await next(request) } - private func createRequest(_ builder: inout RequestBuilder) throws -> PapyrusRequest { - for modifier in modifiers { - try modifier.modify(req: &builder) + private func createRequest(_ builder: inout RequestBuilder) throws -> any PapyrusRequest { + try modifiers.withLock { resource in + for modifier in resource { + try modifier.modify(req: &builder) + } } let url = try builder.fullURL() @@ -68,9 +98,9 @@ public final class Provider { } } -public protocol Interceptor { - typealias Next = (PapyrusRequest) async throws -> PapyrusResponse - func intercept(req: PapyrusRequest, next: Next) async throws -> PapyrusResponse +public protocol Interceptor: Sendable { + typealias Next = @Sendable (any PapyrusRequest) async throws -> any PapyrusResponse + func intercept(req: any PapyrusRequest, next: Next) async throws -> any PapyrusResponse } public protocol RequestModifier { diff --git a/Papyrus/Sources/RequestBuilder.swift b/Papyrus/Sources/RequestBuilder.swift index e0fbe1d..bdcf080 100644 --- a/Papyrus/Sources/RequestBuilder.swift +++ b/Papyrus/Sources/RequestBuilder.swift @@ -54,7 +54,7 @@ public struct RequestBuilder { } public struct ContentValue: Encodable { - private let _encode: (Encoder) throws -> Void + private let _encode: (any Encoder) throws -> Void public init(_ wrapped: T) { _encode = wrapped.encode @@ -62,7 +62,7 @@ public struct RequestBuilder { // MARK: Encodable - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { try _encode(encoder) } } @@ -74,7 +74,6 @@ public struct RequestBuilder { } // MARK: Data - public var baseURL: String public var method: String public var path: String @@ -85,34 +84,39 @@ public struct RequestBuilder { // MARK: Configuration + private let provider: any CoderProvider public var keyMapping: KeyMapping? public var queryEncoder: URLEncodedFormEncoder { + get { return _queryEncoder.with(keyMapping: keyMapping) } set { _queryEncoder = newValue } - get { _queryEncoder.with(keyMapping: keyMapping) } } - public var requestBodyEncoder: HTTPBodyEncoder { + public var requestBodyEncoder: any HTTPBodyEncoder { + get { return _requestBodyEncoder.with(keyMapping: keyMapping) } set { _requestBodyEncoder = newValue } - get { _requestBodyEncoder.with(keyMapping: keyMapping) } } - public var responseBodyDecoder: HTTPBodyDecoder { + public var responseBodyDecoder: any HTTPBodyDecoder { + get { return _responseBodyDecoder.with(keyMapping: keyMapping) } set { _responseBodyDecoder = newValue } - get { _responseBodyDecoder.with(keyMapping: keyMapping) } } - private var _queryEncoder: URLEncodedFormEncoder = Coders.defaultQueryEncoder - private var _requestBodyEncoder: HTTPBodyEncoder = Coders.defaultHTTPBodyEncoder - private var _responseBodyDecoder: HTTPBodyDecoder = Coders.defaultHTTPBodyDecoder + private var _queryEncoder: URLEncodedFormEncoder + private var _requestBodyEncoder: any HTTPBodyEncoder + private var _responseBodyDecoder: any HTTPBodyDecoder - public init(baseURL: String, method: String, path: String) { + public init(baseURL: String, method: String, path: String, provider: any CoderProvider = DefaultProvider()) { self.baseURL = baseURL self.method = method self.path = path self.parameters = [:] self.headers = [:] self.queries = [:] + self.provider = provider + self._queryEncoder = provider.provideQueryEncoder() + self._requestBodyEncoder = provider.provideHttpBodyEncoder() + self._responseBodyDecoder = provider.provideHttpBodyDecoder() self.body = nil } @@ -122,10 +126,6 @@ public struct RequestBuilder { parameters[key] = value.description } - public mutating func addParameter>(_ key: String, value: R) { - parameters[key] = value.rawValue.description - } - public mutating func addQuery(_ key: String, value: E?, mapKey: Bool = true) { guard let value else { return } let key: ContentKey = mapKey ? .implicit(key) : .explicit(key) diff --git a/Papyrus/Sources/ResourceMutex.swift b/Papyrus/Sources/ResourceMutex.swift new file mode 100644 index 0000000..4c89d1f --- /dev/null +++ b/Papyrus/Sources/ResourceMutex.swift @@ -0,0 +1,58 @@ +// +// ResourceMutex.swift +// +// +// Created by Kevin Pittevils on 11/07/2024. +// + +import Foundation + +// Note: Can be replaced with Synchronization framework starting with iOS 18. +public final class ResourceMutex: @unchecked Sendable { + private var resource: R + private let mutex: UnsafeMutablePointer + + public init(resource: R) { + let mutexAttr = UnsafeMutablePointer.allocate(capacity: 1) + pthread_mutexattr_init(mutexAttr) + pthread_mutexattr_settype(mutexAttr, Int32(PTHREAD_MUTEX_RECURSIVE)) + mutex = UnsafeMutablePointer.allocate(capacity: 1) + pthread_mutex_init(mutex, mutexAttr) + pthread_mutexattr_destroy(mutexAttr) + mutexAttr.deallocate() + self.resource = resource + } + + deinit { + pthread_mutex_destroy(mutex) + mutex.deallocate() + } + + public func withLock(method: (inout R) -> T) -> T { + defer { unlock() } + lock() + return method(&resource) + } + + public func withLock(method: (inout R) throws -> T) throws -> T { + defer { unlock() } + lock() + return try method(&resource) + } + + public func withLock(method: (inout R) async throws -> T) async throws -> T { + defer { unlock() } + lock() + return try await method(&resource) + } +} + +private extension ResourceMutex { + func lock() { + pthread_mutex_lock(mutex) + } + + func unlock() { + pthread_mutex_unlock(mutex) + } +} diff --git a/Papyrus/Sources/Routing/RequestParser.swift b/Papyrus/Sources/Routing/RequestParser.swift index a574bde..b0f3886 100644 --- a/Papyrus/Sources/Routing/RequestParser.swift +++ b/Papyrus/Sources/Routing/RequestParser.swift @@ -4,26 +4,30 @@ 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 { + public var requestBodyDecoder: any HTTPBodyDecoder { set { _requestBodyDecoder = newValue } get { _requestBodyDecoder.with(keyMapping: keyMapping) } } - private var _responseBodyEncoder: HTTPBodyEncoder = Coders.defaultHTTPBodyEncoder - public var responseBodyEncoder: HTTPBodyEncoder { + public var responseBodyEncoder: any HTTPBodyEncoder { set { _responseBodyEncoder = newValue } get { _responseBodyEncoder.with(keyMapping: keyMapping) } } - public init(req: RouterRequest) { + private var _requestQueryDecoder: URLEncodedFormDecoder + private var _requestBodyDecoder: any HTTPBodyDecoder + private var _responseBodyEncoder: any HTTPBodyEncoder + + public init(req: RouterRequest, provider: any CoderProvider) { self.request = req + _requestQueryDecoder = provider.provideQueryDecoder() + _requestBodyDecoder = provider.provideHttpBodyDecoder() + _responseBodyEncoder = provider.provideHttpBodyEncoder() } // MARK: Parsing methods diff --git a/Papyrus/Sources/URLEncoded/URLEncodedForm.swift b/Papyrus/Sources/URLEncoded/URLEncodedForm.swift index bae4b73..cf3bdde 100644 --- a/Papyrus/Sources/URLEncoded/URLEncodedForm.swift +++ b/Papyrus/Sources/URLEncoded/URLEncodedForm.swift @@ -48,9 +48,9 @@ internal enum URLEncodedForm { @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) /// ISO8601 data formatter used throughout URL encoded form code - static var iso8601Formatter: ISO8601DateFormatter = { + static let iso8601Formatter: ResourceMutex = { let formatter = ISO8601DateFormatter() formatter.formatOptions = .withInternetDateTime - return formatter + return .init(resource: formatter) }() } diff --git a/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift index 42747de..6dfb5c3 100644 --- a/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift +++ b/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift @@ -15,9 +15,9 @@ import Foundation /// The wrapper struct for decoding URL encoded form data to Codable classes -public struct URLEncodedFormDecoder { +public struct URLEncodedFormDecoder: Sendable { /// The strategy to use for decoding `Date` values. - public enum DateDecodingStrategy { + public enum DateDecodingStrategy: Sendable { /// Defer to `Date` for decoding. This is the default strategy. case deferredToDate @@ -35,7 +35,7 @@ public struct URLEncodedFormDecoder { case formatted(DateFormatter) /// Decode the `Date` as a custom value encoded by the given closure. - case custom((_ decoder: Decoder) throws -> Date) + case custom(@Sendable (_ decoder: any Decoder) throws -> Date) } /// The strategy to use in Encoding dates. Defaults to `.deferredToDate`. @@ -45,7 +45,7 @@ public struct URLEncodedFormDecoder { public var keyMapping: KeyMapping /// Contextual user-provided information for use during encoding. - public var userInfo: [CodingUserInfoKey: Any] + public var userInfo: [CodingUserInfoKey: any Sendable] /// Options set on the top-level encoder to pass down the encoding hierarchy. fileprivate struct _Options { @@ -70,7 +70,7 @@ public struct URLEncodedFormDecoder { public init( dateDecodingStrategy: URLEncodedFormDecoder.DateDecodingStrategy = .deferredToDate, keyMapping: KeyMapping = .useDefaultKeys, - userInfo: [CodingUserInfoKey: Any] = [:] + userInfo: [CodingUserInfoKey: any Sendable] = [:] ) { self.dateDecodingStrategy = dateDecodingStrategy self.keyMapping = keyMapping @@ -99,7 +99,7 @@ private class _URLEncodedFormDecoder: Decoder { fileprivate let options: URLEncodedFormDecoder._Options /// The path to the current point in encoding. - public fileprivate(set) var codingPath: [CodingKey] + public fileprivate(set) var codingPath: [any CodingKey] /// Contextual user-provided information for use during encoding. public var userInfo: [CodingUserInfoKey: Any] { @@ -109,7 +109,7 @@ private class _URLEncodedFormDecoder: Decoder { // MARK: - Initialization /// Initializes `self` with the given top-level container and options. - fileprivate init(at codingPath: [CodingKey] = [], options: URLEncodedFormDecoder._Options) { + fileprivate init(at codingPath: [any CodingKey] = [], options: URLEncodedFormDecoder._Options) { self.codingPath = codingPath self.options = options self.storage = .init() @@ -122,19 +122,19 @@ private class _URLEncodedFormDecoder: Decoder { return KeyedDecodingContainer(KDC(container: map, decoder: self)) } - func unkeyedContainer() throws -> UnkeyedDecodingContainer { + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { guard case .array(let array) = self.storage.topContainer else { throw DecodingError.dataCorrupted(.init(codingPath: self.codingPath, debugDescription: "Expected an array")) } return UKDC(container: array, decoder: self) } - func singleValueContainer() throws -> SingleValueDecodingContainer { + func singleValueContainer() throws -> any SingleValueDecodingContainer { return self } struct KDC: KeyedDecodingContainerProtocol { - var codingPath: [CodingKey] { self.decoder.codingPath } + var codingPath: [any CodingKey] { self.decoder.codingPath } let decoder: _URLEncodedFormDecoder let container: URLEncodedFormNode.Map @@ -230,7 +230,7 @@ private class _URLEncodedFormDecoder: Decoder { defer { self.decoder.codingPath.removeLast() } guard let node = container.values[key.stringValue] else { - if let t = type as? AnyOptional.Type { return t.nil as! T } + if let t = type as? any AnyOptional.Type { return t.nil as! T } throw DecodingError.keyNotFound(key, .init(codingPath: self.codingPath, debugDescription: "")) } return try self.decoder.unbox(node, as: T.self) @@ -248,7 +248,7 @@ private class _URLEncodedFormDecoder: Decoder { return KeyedDecodingContainer(container) } - func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } @@ -259,11 +259,11 @@ private class _URLEncodedFormDecoder: Decoder { return UKDC(container: array, decoder: self.decoder) } - func superDecoder() throws -> Decoder { + func superDecoder() throws -> any Decoder { fatalError() } - func superDecoder(forKey key: Key) throws -> Decoder { + func superDecoder(forKey key: Key) throws -> any Decoder { fatalError() } } @@ -271,7 +271,7 @@ private class _URLEncodedFormDecoder: Decoder { struct UKDC: UnkeyedDecodingContainer { let container: URLEncodedFormNode.Array let decoder: _URLEncodedFormDecoder - var codingPath: [CodingKey] { self.decoder.codingPath } + var codingPath: [any CodingKey] { self.decoder.codingPath } let count: Int? var isAtEnd: Bool { self.currentIndex == self.count } var currentIndex: Int @@ -388,7 +388,7 @@ private class _URLEncodedFormDecoder: Decoder { return KeyedDecodingContainer(container) } - mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { let node = self.container.values[self.currentIndex] self.currentIndex += 1 guard case .array(let array) = node else { @@ -397,7 +397,7 @@ private class _URLEncodedFormDecoder: Decoder { return UKDC(container: array, decoder: self.decoder) } - mutating func superDecoder() throws -> Decoder { + mutating func superDecoder() throws -> any Decoder { fatalError() } } @@ -601,10 +601,12 @@ extension _URLEncodedFormDecoder { case .iso8601: if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { let dateString = try unbox(node, as: String.self) - guard let date = URLEncodedForm.iso8601Formatter.date(from: dateString) else { - throw DecodingError.dataCorrupted(.init(codingPath: self.codingPath, debugDescription: "Invalid date format")) + return try URLEncodedForm.iso8601Formatter.withLock { resource in + guard let date = resource.date(from: dateString) else { + throw DecodingError.dataCorrupted(.init(codingPath: self.codingPath, debugDescription: "Invalid date format")) + } + return date } - return date } else { preconditionFailure("ISO8601DateFormatter is unavailable on this platform") } @@ -633,7 +635,7 @@ extension _URLEncodedFormDecoder { return try self.unbox_(node, as: T.self) as! T } - func unbox_(_ node: URLEncodedFormNode, as type: Decodable.Type) throws -> Any { + func unbox_(_ node: URLEncodedFormNode, as type: any Decodable.Type) throws -> Any { if type == Data.self { return try self.unbox(node, as: Data.self) } else if type == Date.self { diff --git a/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift index f35eb58..f74b383 100644 --- a/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift +++ b/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift @@ -15,9 +15,9 @@ import Foundation /// The wrapper struct for encoding Codable classes to URL encoded form data -public struct URLEncodedFormEncoder { +public struct URLEncodedFormEncoder: Sendable { /// The strategy to use for encoding `Date` values. - public enum DateEncodingStrategy { + public enum DateEncodingStrategy: Sendable { /// Defer to `Date` for encoding. This is the default strategy. case deferredToDate @@ -35,7 +35,7 @@ public struct URLEncodedFormEncoder { case formatted(DateFormatter) /// Encode the `Date` as a custom value encoded by the given closure. - case custom((Date, Encoder) throws -> Void) + case custom(@Sendable (Date, any Encoder) throws -> Void) } /// The strategy to use in Encoding dates. Defaults to `.deferredToDate`. @@ -45,13 +45,13 @@ public struct URLEncodedFormEncoder { public var keyMapping: KeyMapping /// Contextual user-provided information for use during encoding. - public var userInfo: [CodingUserInfoKey: Any] + public var userInfo: [CodingUserInfoKey: any Sendable] /// Options set on the top-level encoder to pass down the encoding hierarchy. fileprivate struct _Options { let dateEncodingStrategy: DateEncodingStrategy let keyMapping: KeyMapping - let userInfo: [CodingUserInfoKey: Any] + let userInfo: [CodingUserInfoKey: any Sendable] } /// The options set on the top-level encoder. @@ -70,7 +70,7 @@ public struct URLEncodedFormEncoder { public init( dateEncodingStrategy: URLEncodedFormEncoder.DateEncodingStrategy = .deferredToDate, keyMapping: KeyMapping = .useDefaultKeys, - userInfo: [CodingUserInfoKey: Any] = [:] + userInfo: [CodingUserInfoKey: any Sendable] = [:] ) { self.dateEncodingStrategy = dateEncodingStrategy self.userInfo = userInfo @@ -93,7 +93,7 @@ public struct URLEncodedFormEncoder { /// Internal QueryEncoder class. Does all the heavy lifting private class _URLEncodedFormEncoder: Encoder { - var codingPath: [CodingKey] + var codingPath: [any CodingKey] /// the encoder's storage var storage: URLEncodedFormEncoderStorage @@ -129,7 +129,7 @@ private class _URLEncodedFormEncoder: Encoder { } struct KEC: KeyedEncodingContainerProtocol { - var codingPath: [CodingKey] { return self.encoder.codingPath } + var codingPath: [any CodingKey] { return self.encoder.codingPath } let container: URLEncodedFormNode.Map let encoder: _URLEncodedFormEncoder @@ -144,7 +144,7 @@ private class _URLEncodedFormEncoder: Encoder { self.container.addChild(key: key, value: value) } - mutating func encode(_ value: LosslessStringConvertible, key: Key) { + mutating func encode(_ value: any LosslessStringConvertible, key: Key) { self.encode(.leaf(.init(value)), key: key) } @@ -183,7 +183,7 @@ private class _URLEncodedFormEncoder: Encoder { return KeyedEncodingContainer(kec) } - mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { self.encoder.codingPath.append(key) defer { self.encoder.codingPath.removeLast() } @@ -193,22 +193,22 @@ private class _URLEncodedFormEncoder: Encoder { return UKEC(referencing: self.encoder, container: unkeyedContainer) } - mutating func superEncoder() -> Encoder { + mutating func superEncoder() -> any Encoder { return self.encoder } - mutating func superEncoder(forKey key: Key) -> Encoder { + mutating func superEncoder(forKey key: Key) -> any Encoder { return self.encoder } } - func unkeyedContainer() -> UnkeyedEncodingContainer { + func unkeyedContainer() -> any UnkeyedEncodingContainer { let container = self.storage.pushUnkeyedContainer() return UKEC(referencing: self, container: container) } struct UKEC: UnkeyedEncodingContainer { - var codingPath: [CodingKey] { return self.encoder.codingPath } + var codingPath: [any CodingKey] { return self.encoder.codingPath } let container: URLEncodedFormNode.Array let encoder: _URLEncodedFormEncoder var count: Int @@ -224,7 +224,7 @@ private class _URLEncodedFormEncoder: Encoder { self.container.addChild(value: value) } - mutating func encodeResult(_ value: LosslessStringConvertible) { + mutating func encodeResult(_ value: any LosslessStringConvertible) { self.encodeResult(.leaf(.init(value))) } @@ -267,7 +267,7 @@ private class _URLEncodedFormEncoder: Encoder { return KeyedEncodingContainer(kec) } - mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self.count += 1 let unkeyedContainer = URLEncodedFormNode.Array() @@ -276,7 +276,7 @@ private class _URLEncodedFormEncoder: Encoder { return UKEC(referencing: self.encoder, container: unkeyedContainer) } - mutating func superEncoder() -> Encoder { + mutating func superEncoder() -> any Encoder { return self.encoder } } @@ -287,7 +287,7 @@ extension _URLEncodedFormEncoder: SingleValueEncodingContainer { self.storage.push(container: value) } - func encodeResult(_ value: LosslessStringConvertible) { + func encodeResult(_ value: any LosslessStringConvertible) { self.storage.push(container: .leaf(.init(value))) } @@ -314,7 +314,7 @@ extension _URLEncodedFormEncoder: SingleValueEncodingContainer { try value.encode(to: self) } - func singleValueContainer() -> SingleValueEncodingContainer { + func singleValueContainer() -> any SingleValueEncodingContainer { return self } } @@ -330,7 +330,9 @@ extension _URLEncodedFormEncoder { try self.encode(Double(date.timeIntervalSince1970).description) case .iso8601: if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { - try encode(URLEncodedForm.iso8601Formatter.string(from: date)) + try URLEncodedForm.iso8601Formatter.withLock { resource in + try encode(resource.string(from: date)) + } } else { preconditionFailure("ISO8601DateFormatter is unavailable on this platform") } @@ -347,7 +349,7 @@ extension _URLEncodedFormEncoder { return self.storage.popContainer() } - func box(_ value: Encodable) throws -> URLEncodedFormNode { + func box(_ value: any Encodable) throws -> URLEncodedFormNode { let type = Swift.type(of: value) if type == Data.self { return try self.box(value as! Data) diff --git a/Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift index 8be4bec..cc49f69 100644 --- a/Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift +++ b/Papyrus/Sources/URLEncoded/URLEncodedFormNode.swift @@ -121,7 +121,7 @@ enum URLEncodedFormNode: CustomStringConvertible, Equatable { /// string value of node (with percent encoding removed) let value: String - init(_ value: LosslessStringConvertible) { + init(_ value: any LosslessStringConvertible) { self.value = String(describing: value) } diff --git a/Papyrus/Tests/APITests.swift b/Papyrus/Tests/APITests.swift index 7540c61..fbbdd31 100644 --- a/Papyrus/Tests/APITests.swift +++ b/Papyrus/Tests/APITests.swift @@ -95,7 +95,7 @@ fileprivate struct _Person: Decodable { let name: String } -fileprivate class _HTTPServiceMock: HTTPService { +fileprivate struct _HTTPServiceMock: HTTPService { enum ResponseType { case `nil` @@ -120,15 +120,15 @@ fileprivate class _HTTPServiceMock: HTTPService { _responseType = responseType } - func build(method: String, url: URL, headers: [String : String], body: Data?) -> PapyrusRequest { + func build(method: String, url: URL, headers: [String : String], body: Data?) -> any PapyrusRequest { _Request(method: "", headers: [:]) } - func request(_ req: PapyrusRequest) async -> PapyrusResponse { + func request(_ req: any PapyrusRequest) async -> any PapyrusResponse { _Response(body: _responseType.value?.data(using: .utf8), statusCode: 200) } - func request(_ req: PapyrusRequest, completionHandler: @escaping (PapyrusResponse) -> Void) { + func request(_ req: any PapyrusRequest, completionHandler: @escaping (any PapyrusResponse) -> Void) { completionHandler(_Response(body: "".data(using: .utf8))) } } @@ -141,9 +141,9 @@ fileprivate struct _Request: PapyrusRequest { } fileprivate struct _Response: PapyrusResponse { - var request: PapyrusRequest? + var request: (any PapyrusRequest)? var body: Data? var headers: [String : String]? var statusCode: Int? - var error: Error? + var error: (any Error)? } diff --git a/Papyrus/Tests/CurlTests.swift b/Papyrus/Tests/CurlTests.swift index cee77db..d989a84 100644 --- a/Papyrus/Tests/CurlTests.swift +++ b/Papyrus/Tests/CurlTests.swift @@ -114,27 +114,31 @@ final class CurlTests: XCTestCase { req.addHeader("High", value: "Ground") let request = try TestRequest(from: req) - var message: String? = nil + let message: ResourceMutex = .init(resource: nil) - let logger = CurlLogger(when: .always, using: { - message = $0 + let logger = CurlLogger(when: .always, using: { value in + message.withLock { resource in + resource = value + } }) _ = try await logger.intercept(req: request) { req in return TestResponse(request: req) } - - XCTAssertNotNil(message, "Logger did not output") - - guard let message else { return } - - XCTAssertEqual(message, """ - curl 'foo/baz?Hello=There' \\ - -X GET \\ - -H 'Content-Length: 0' \\ - -H 'Content-Type: application/json' \\ - -H 'High: Ground' - """) + + message.withLock { resource in + XCTAssertNotNil(resource, "Logger did not output") + + guard let resource else { return } + + XCTAssertEqual(resource, """ + curl 'foo/baz?Hello=There' \\ + -X GET \\ + -H 'Content-Length: 0' \\ + -H 'Content-Type: application/json' \\ + -H 'High: Ground' + """) + } } func testInterceptorOnError() async throws { @@ -143,26 +147,29 @@ final class CurlTests: XCTestCase { req.addHeader("High", value: "Ground") let request = try TestRequest(from: req) - var message: String? = nil + let message: ResourceMutex = .init(resource: nil) - let logger = CurlLogger(when: .onError, using: { - message = $0 + let logger = CurlLogger(when: .onError, using: { value in + message.withLock { resource in + resource = value + } }) _ = try? await logger.intercept(req: request) { req in throw PapyrusError("") } - XCTAssertNotNil(message, "Logger did not output") - guard let message else { return } - - XCTAssertEqual(message, """ - curl 'foo/baz?Hello=There' \\ - -X GET \\ - -H 'Content-Length: 0' \\ - -H 'Content-Type: application/json' \\ - -H 'High: Ground' - """) + message.withLock { resource in + XCTAssertNotNil(resource, "Logger did not output") + guard let resource else { return } + XCTAssertEqual(resource, """ + curl 'foo/baz?Hello=There' \\ + -X GET \\ + -H 'Content-Length: 0' \\ + -H 'Content-Type: application/json' \\ + -H 'High: Ground' + """) + } } func testInterceptorOnErrorNoError() async throws { @@ -172,26 +179,30 @@ final class CurlTests: XCTestCase { let request = try TestRequest(from: req) - var message: String? = nil + let message: ResourceMutex = .init(resource: nil) - let logger = CurlLogger(when: .onError, using: { - message = $0 + let logger = CurlLogger(when: .onError, using: { value in + message.withLock { resource in + resource = value + } }) _ = try await logger.intercept(req: request) { req in return TestResponse(request: req) } - XCTAssertNil(message, "Logger did output") + message.withLock { resource in + XCTAssertNil(resource, "Logger did output") + } } } private struct TestResponse: PapyrusResponse { - var request: PapyrusRequest? = nil + var request: (any PapyrusRequest)? = nil var body: Data? = nil var headers: [String : String]? = nil var statusCode: Int? = nil - var error: Error? = nil + var error: (any Error)? = nil } private struct TestRequest: PapyrusRequest { diff --git a/Papyrus/Tests/ProviderTests.swift b/Papyrus/Tests/ProviderTests.swift index 4ccdc78..b7e9f47 100644 --- a/Papyrus/Tests/ProviderTests.swift +++ b/Papyrus/Tests/ProviderTests.swift @@ -12,15 +12,15 @@ final class ProviderTests: XCTestCase { } private struct TestHTTPService: HTTPService { - func build(method: String, url: URL, headers: [String : String], body: Data?) -> PapyrusRequest { + func build(method: String, url: URL, headers: [String : String], body: Data?) -> any PapyrusRequest { fatalError() } - func request(_ req: PapyrusRequest) async -> PapyrusResponse { + func request(_ req: any PapyrusRequest) async -> any PapyrusResponse { fatalError() } - func request(_ req: PapyrusRequest, completionHandler: @escaping (PapyrusResponse) -> Void) { + func request(_ req: any PapyrusRequest, completionHandler: @escaping (any PapyrusResponse) -> Void) { } } diff --git a/Papyrus/Tests/ResponseDecoderTests.swift b/Papyrus/Tests/ResponseDecoderTests.swift index c052366..b8eb19d 100644 --- a/Papyrus/Tests/ResponseDecoderTests.swift +++ b/Papyrus/Tests/ResponseDecoderTests.swift @@ -19,7 +19,7 @@ final class ResponseDecoderTests: XCTestCase { func testResponseWithOptionalTypeAndNilBody() throws { // Arrange - let response = _Response() + var response = _Response() response.body = nil // Act @@ -31,7 +31,7 @@ final class ResponseDecoderTests: XCTestCase { func testResponseWithOptionalTypeAndEmptyBody() throws { // Arrange - let response = _Response() + var response = _Response() response.body = "".data(using: .utf8) // Act @@ -43,7 +43,7 @@ final class ResponseDecoderTests: XCTestCase { func testResponseWithOptionalTypeAndNonNilBody() throws { // Arrange - let response = _Response() + var response = _Response() response.body = "{ \"name\": \"Petru\" }".data(using: .utf8) // Act @@ -56,7 +56,7 @@ final class ResponseDecoderTests: XCTestCase { func testResponseWithNonOptionalTypeAndNonNilBody() throws { // Arrange - let response = _Response() + var response = _Response() response.body = "{ \"name\": \"Petru\" }".data(using: .utf8) // Act @@ -72,10 +72,10 @@ fileprivate struct _Person: Decodable { let name: String } -fileprivate class _Response : PapyrusResponse { - var request: PapyrusRequest? +fileprivate struct _Response : PapyrusResponse { + var request: (any PapyrusRequest)? var body: Data? var headers: [String : String]? var statusCode: Int? - var error: Error? + var error: (any Error)? } diff --git a/Papyrus/Tests/ResponseTests.swift b/Papyrus/Tests/ResponseTests.swift index 013c0fc..a9e0888 100644 --- a/Papyrus/Tests/ResponseTests.swift +++ b/Papyrus/Tests/ResponseTests.swift @@ -7,7 +7,7 @@ final class ResponseTests: XCTestCase { case test } - let res: PapyrusResponse = .error(TestError.test) + let res: any PapyrusResponse = .error(TestError.test) XCTAssertThrowsError(try res.validate()) } } diff --git a/PapyrusPlugin/Sources/Extensions/String+Utilities.swift b/PapyrusPlugin/Sources/Extensions/String+Utilities.swift index b861182..ab21fc2 100644 --- a/PapyrusPlugin/Sources/Extensions/String+Utilities.swift +++ b/PapyrusPlugin/Sources/Extensions/String+Utilities.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftSyntax extension String { diff --git a/PapyrusPlugin/Sources/Macros/APIMacro.swift b/PapyrusPlugin/Sources/Macros/APIMacro.swift index a134a64..941aa1a 100644 --- a/PapyrusPlugin/Sources/Macros/APIMacro.swift +++ b/PapyrusPlugin/Sources/Macros/APIMacro.swift @@ -80,7 +80,7 @@ extension API.Endpoint { switch responseType { case .none, "Void": "try await provider.request(&req).validate()" - case "Response": + case "PapyrusResponse": "return try await provider.request(&req)" case .some(let type): "let res = try await provider.request(&req)" diff --git a/PapyrusPlugin/Sources/Macros/MockMacro.swift b/PapyrusPlugin/Sources/Macros/MockMacro.swift index 1188964..a49b778 100644 --- a/PapyrusPlugin/Sources/Macros/MockMacro.swift +++ b/PapyrusPlugin/Sources/Macros/MockMacro.swift @@ -18,12 +18,11 @@ public struct MockMacro: PeerMacro { extension API { fileprivate func mockImplementation(suffix: String) -> Declaration { Declaration("final class \(name)\(suffix): \(name)") { - "private let notMockedError: Error" - "private var mocks: [String: Any]" + "private let notMockedError: any Error" + "private let mocks: Papyrus.ResourceMutex<[String: Any]> = .init(resource: [:])" - Declaration("init(notMockedError: Error = PapyrusError(\"Not mocked\"))") { + Declaration("init(notMockedError: any Error = PapyrusError(\"Not mocked\"))") { "self.notMockedError = notMockedError" - "mocks = [:]" } .access(access) @@ -39,18 +38,22 @@ extension API { extension API.Endpoint { fileprivate func mockFunction() -> Declaration { Declaration("func \(name)\(functionSignature)") { - Declaration("guard let mocker = mocks[\(name.inQuotes)] as? \(mockClosureType) else") { - "throw notMockedError" - } + Declaration("return try await mocks.withLock", "resource") { + Declaration("guard let mocker = resource[\(name.inQuotes)] as? \(mockClosureType) else") { + "throw notMockedError" + } - let arguments = parameters.map(\.name).joined(separator: ", ") - "return try await mocker(\(arguments))" + let arguments = parameters.map(\.name).joined(separator: ", ") + "return try await mocker(\(arguments))" + } } } fileprivate func mockerFunction() -> Declaration { Declaration("func mock\(name.capitalizeFirst)(mock: @escaping \(mockClosureType))") { - "mocks[\(name.inQuotes)] = mock" + Declaration("mocks.withLock", "resource") { + "resource[\(name.inQuotes)] = mock" + } } } diff --git a/PapyrusPlugin/Sources/Models/API.swift b/PapyrusPlugin/Sources/Models/API.swift index a53db74..4d1eee1 100644 --- a/PapyrusPlugin/Sources/Models/API.swift +++ b/PapyrusPlugin/Sources/Models/API.swift @@ -28,7 +28,17 @@ extension API { guard let proto = decl.as(ProtocolDeclSyntax.self) else { throw PapyrusPluginError("APIs must be protocols for now") } - + return try parse(proto) + } + + static func parse(_ decl: some SwiftSyntax.DeclGroupSyntax) throws -> API { + guard let proto = decl.as(ProtocolDeclSyntax.self) else { + throw PapyrusPluginError("APIs must be protocols for now") + } + return try parse(proto) + } + + private static func parse(_ proto: ProtocolDeclSyntax) throws -> API { return API( name: proto.protocolName, access: proto.access, diff --git a/PapyrusPlugin/Sources/Models/Declaration.swift b/PapyrusPlugin/Sources/Models/Declaration.swift index 00844f9..ee1af16 100644 --- a/PapyrusPlugin/Sources/Models/Declaration.swift +++ b/PapyrusPlugin/Sources/Models/Declaration.swift @@ -1,6 +1,7 @@ +import Foundation import SwiftSyntax -struct Declaration: ExpressibleByStringLiteral { +struct Declaration: ExpressibleByStringLiteral, Sendable { var text: String let closureParameters: String? /// Declarations inside a closure following `text`. @@ -199,7 +200,7 @@ extension Declaration { @resultBuilder struct DeclarationsBuilder { - static func buildBlock(_ components: DeclarationBuilderBlock...) -> [Declaration] { + static func buildBlock(_ components: any DeclarationBuilderBlock...) -> [Declaration] { components.flatMap(\.declarations) } diff --git a/PapyrusPlugin/Sources/Plugin.swift b/PapyrusPlugin/Sources/Plugin.swift index f44e9b8..eff1a6f 100644 --- a/PapyrusPlugin/Sources/Plugin.swift +++ b/PapyrusPlugin/Sources/Plugin.swift @@ -4,7 +4,7 @@ import SwiftSyntaxMacros @main struct MyPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ + let providingMacros: [any Macro.Type] = [ APIMacro.self, RoutesMacro.self, MockMacro.self, diff --git a/README.md b/README.md index d7746b0..0becd08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 📜 Papyrus Swift Version +Swift Version Latest Release License