From 9ad6a853fa81927ef34957f44f5df111abf013d9 Mon Sep 17 00:00:00 2001 From: Kevin Pittevils Date: Wed, 12 Jun 2024 11:43:31 +0200 Subject: [PATCH 1/4] Initial work to resolve strict concurrency warnings --- Papyrus/Sources/Coders.swift | 8 ++++---- Papyrus/Sources/KeyMapping.swift | 6 +++--- Papyrus/Sources/Request.swift | 2 +- Papyrus/Sources/Response.swift | 2 +- Papyrus/Sources/URLEncoded/URLEncodedForm.swift | 2 +- .../Sources/URLEncoded/URLEncodedFormDecoder.swift | 10 +++++----- .../Sources/URLEncoded/URLEncodedFormEncoder.swift | 12 ++++++------ PapyrusPlugin/Sources/Models/Declaration.swift | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Papyrus/Sources/Coders.swift b/Papyrus/Sources/Coders.swift index d55a696..830c269 100644 --- a/Papyrus/Sources/Coders.swift +++ b/Papyrus/Sources/Coders.swift @@ -2,11 +2,11 @@ enum Coders { // MARK: HTTP Body - static var defaultHTTPBodyEncoder: HTTPBodyEncoder = .json() - static var defaultHTTPBodyDecoder: HTTPBodyDecoder = .json() + static let defaultHTTPBodyEncoder: HTTPBodyEncoder = .json() + static let defaultHTTPBodyDecoder: HTTPBodyDecoder = .json() // MARK: Query - static var defaultQueryEncoder = URLEncodedFormEncoder() - static var defaultQueryDecoder = URLEncodedFormDecoder() + static let defaultQueryEncoder = URLEncodedFormEncoder() + static let defaultQueryDecoder = URLEncodedFormDecoder() } 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/Request.swift b/Papyrus/Sources/Request.swift index cefe684..7a88ab5 100644 --- a/Papyrus/Sources/Request.swift +++ b/Papyrus/Sources/Request.swift @@ -1,6 +1,6 @@ import Foundation -public protocol Request { +public protocol Request: Sendable { var url: URL? { get set } var method: String { get set } var headers: [String: String] { get set } diff --git a/Papyrus/Sources/Response.swift b/Papyrus/Sources/Response.swift index 67b72d8..e5cb543 100644 --- a/Papyrus/Sources/Response.swift +++ b/Papyrus/Sources/Response.swift @@ -1,6 +1,6 @@ import Foundation -public protocol Response { +public protocol Response: Sendable { var request: Request? { get } var body: Data? { get } var headers: [String: String]? { get } diff --git a/Papyrus/Sources/URLEncoded/URLEncodedForm.swift b/Papyrus/Sources/URLEncoded/URLEncodedForm.swift index bae4b73..8635580 100644 --- a/Papyrus/Sources/URLEncoded/URLEncodedForm.swift +++ b/Papyrus/Sources/URLEncoded/URLEncodedForm.swift @@ -48,7 +48,7 @@ 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: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = .withInternetDateTime return formatter diff --git a/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormDecoder.swift index 42747de..5f37648 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: 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 diff --git a/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift b/Papyrus/Sources/URLEncoded/URLEncodedFormEncoder.swift index f35eb58..4bbd485 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, 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 diff --git a/PapyrusPlugin/Sources/Models/Declaration.swift b/PapyrusPlugin/Sources/Models/Declaration.swift index 00844f9..797dbc2 100644 --- a/PapyrusPlugin/Sources/Models/Declaration.swift +++ b/PapyrusPlugin/Sources/Models/Declaration.swift @@ -1,6 +1,6 @@ import SwiftSyntax -struct Declaration: ExpressibleByStringLiteral { +struct Declaration: ExpressibleByStringLiteral, Sendable { var text: String let closureParameters: String? /// Declarations inside a closure following `text`. From 2c2fbebbff4497fcb529167958ef771687fe3c77 Mon Sep 17 00:00:00 2001 From: Kevin Pittevils Date: Thu, 11 Jul 2024 17:43:57 +0200 Subject: [PATCH 2/4] Make Provider sendable and remove last warnings --- Package.swift | 5 +- Papyrus/Sources/Coders.swift | 28 +++++- Papyrus/Sources/HTTPBodyDecoder.swift | 4 +- Papyrus/Sources/HTTPBodyEncoder.swift | 4 +- Papyrus/Sources/HTTPService.swift | 3 +- .../Interceptors/CurlInterceptor.swift | 9 +- Papyrus/Sources/Provider.swift | 93 +++++++++++++++---- Papyrus/Sources/RequestBuilder.swift | 20 ++-- 8 files changed, 127 insertions(+), 39 deletions(-) diff --git a/Package.swift b/Package.swift index 11f6d49..374e504 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,10 @@ let package = Package( dependencies: [ "PapyrusPlugin" ], - path: "Papyrus/Sources" + path: "Papyrus/Sources", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] ), .testTarget( name: "PapyrusTests", diff --git a/Papyrus/Sources/Coders.swift b/Papyrus/Sources/Coders.swift index 830c269..cf68804 100644 --- a/Papyrus/Sources/Coders.swift +++ b/Papyrus/Sources/Coders.swift @@ -1,5 +1,4 @@ enum Coders { - // MARK: HTTP Body static let defaultHTTPBodyEncoder: HTTPBodyEncoder = .json() @@ -10,3 +9,30 @@ enum Coders { static let defaultQueryEncoder = URLEncodedFormEncoder() static let defaultQueryDecoder = URLEncodedFormDecoder() } + +public protocol CoderProvider: Sendable { + func provideHttpBodyEncoder() -> HTTPBodyEncoder + func provideHttpBodyDecoder() -> HTTPBodyDecoder + func provideQueryEncoder() -> URLEncodedFormEncoder + func provideQueryDecoder() -> URLEncodedFormDecoder +} + +public struct DefaultProvider: CoderProvider { + public init() {} + + public func provideHttpBodyEncoder() -> HTTPBodyEncoder { + return .json() + } + + public func provideHttpBodyDecoder() -> HTTPBodyDecoder { + return .json() + } + + public func provideQueryEncoder() -> URLEncodedFormEncoder { + return URLEncodedFormEncoder() + } + + public func provideQueryDecoder() -> URLEncodedFormDecoder { + return URLEncodedFormDecoder() + } +} diff --git a/Papyrus/Sources/HTTPBodyDecoder.swift b/Papyrus/Sources/HTTPBodyDecoder.swift index d742c12..9dcf88c 100644 --- a/Papyrus/Sources/HTTPBodyDecoder.swift +++ b/Papyrus/Sources/HTTPBodyDecoder.swift @@ -1,6 +1,6 @@ import Foundation -public protocol HTTPBodyDecoder: KeyMappable { +public protocol HTTPBodyDecoder: KeyMappable, Sendable { func decode(_ type: D.Type, from: Data) throws -> D } @@ -12,7 +12,7 @@ extension HTTPBodyDecoder where Self == JSONDecoder { } } -extension JSONDecoder: HTTPBodyDecoder { +extension JSONDecoder: HTTPBodyDecoder, @unchecked Sendable { public func with(keyMapping: KeyMapping) -> Self { let new = JSONDecoder() new.userInfo = userInfo diff --git a/Papyrus/Sources/HTTPBodyEncoder.swift b/Papyrus/Sources/HTTPBodyEncoder.swift index 8e1da81..75f29a4 100644 --- a/Papyrus/Sources/HTTPBodyEncoder.swift +++ b/Papyrus/Sources/HTTPBodyEncoder.swift @@ -1,6 +1,6 @@ import Foundation -public protocol HTTPBodyEncoder: KeyMappable { +public protocol HTTPBodyEncoder: KeyMappable, Sendable { var contentType: String { get } func encode(_ value: E) throws -> Data } @@ -13,7 +13,7 @@ extension HTTPBodyEncoder where Self == JSONEncoder { } } -extension JSONEncoder: HTTPBodyEncoder { +extension JSONEncoder: HTTPBodyEncoder, @unchecked Sendable { public var contentType: String { "application/json" } public func with(keyMapping: KeyMapping) -> Self { diff --git a/Papyrus/Sources/HTTPService.swift b/Papyrus/Sources/HTTPService.swift index b5b416e..4164eab 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 /// Concurrency based API + @Sendable func request(_ req: PapyrusRequest) async -> PapyrusResponse } diff --git a/Papyrus/Sources/Interceptors/CurlInterceptor.swift b/Papyrus/Sources/Interceptors/CurlInterceptor.swift index 9878017..7f91851 100644 --- a/Papyrus/Sources/Interceptors/CurlInterceptor.swift +++ b/Papyrus/Sources/Interceptors/CurlInterceptor.swift @@ -2,21 +2,22 @@ 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 } diff --git a/Papyrus/Sources/Provider.swift b/Papyrus/Sources/Provider.swift index f7f4bfd..5c1b7e2 100644 --- a/Papyrus/Sources/Provider.swift +++ b/Papyrus/Sources/Provider.swift @@ -1,17 +1,19 @@ 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 provider: CoderProvider + private let interceptors: ResourceMutex<[Interceptor]> + private let modifiers: ResourceMutex<[RequestModifier]> - public init(baseURL: String, http: HTTPService, modifiers: [RequestModifier] = [], interceptors: [Interceptor] = []) { + public init(baseURL: String, http: HTTPService, modifiers: [RequestModifier] = [], interceptors: [Interceptor] = [], provider: 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 { @@ -27,39 +29,45 @@ 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 (PapyrusRequest, (PapyrusRequest) async throws -> PapyrusResponse) async throws -> PapyrusResponse) -> Self { struct AnonymousInterceptor: Interceptor { - let action: (PapyrusRequest, Interceptor.Next) async throws -> PapyrusResponse + let action: @Sendable (PapyrusRequest, Interceptor.Next) async throws -> PapyrusResponse func intercept(req: PapyrusRequest, next: Interceptor.Next) async throws -> 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 { 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 (PapyrusRequest) async throws -> 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) + try modifiers.withLock { resource in + for modifier in resource { + try modifier.modify(req: &builder) + } } let url = try builder.fullURL() @@ -68,11 +76,56 @@ public final class Provider { } } -public protocol Interceptor { - typealias Next = (PapyrusRequest) async throws -> PapyrusResponse +public protocol Interceptor: Sendable { + typealias Next = @Sendable (PapyrusRequest) async throws -> PapyrusResponse func intercept(req: PapyrusRequest, next: Next) async throws -> PapyrusResponse } public protocol RequestModifier { func modify(req: inout RequestBuilder) throws } + +private extension Provider { + // Note: Can be replaced with Synchronization framework starting with iOS 18. + final class ResourceMutex: @unchecked Sendable { + private var resource: R + private let mutex: UnsafeMutablePointer + + 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() + } + + private func lock() { + pthread_mutex_lock(mutex) + } + + private func unlock() { + pthread_mutex_unlock(mutex) + } + + func withLock(method: (inout R) throws -> T) throws -> T { + defer { unlock() } + lock() + return try method(&resource) + } + + func withLock(method: (inout R) -> T) -> T { + defer { unlock() } + lock() + return method(&resource) + } + } + +} diff --git a/Papyrus/Sources/RequestBuilder.swift b/Papyrus/Sources/RequestBuilder.swift index e0fbe1d..71ef372 100644 --- a/Papyrus/Sources/RequestBuilder.swift +++ b/Papyrus/Sources/RequestBuilder.swift @@ -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: 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 { + get { return _requestBodyEncoder.with(keyMapping: keyMapping) } set { _requestBodyEncoder = newValue } - get { _requestBodyEncoder.with(keyMapping: keyMapping) } } public var responseBodyDecoder: 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: HTTPBodyEncoder + private var _responseBodyDecoder: HTTPBodyDecoder - public init(baseURL: String, method: String, path: String) { + public init(baseURL: String, method: String, path: String, provider: 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 } From 077537da75366c052ffcfa9b77bda83c098df27c Mon Sep 17 00:00:00 2001 From: Kevin Pittevils Date: Thu, 11 Jul 2024 18:00:07 +0200 Subject: [PATCH 3/4] Fix concurrency issues in tests --- Papyrus/Sources/Provider.swift | 45 -------------- Papyrus/Sources/ResourceMutex.swift | 52 ++++++++++++++++ Papyrus/Tests/APITests.swift | 2 +- Papyrus/Tests/CurlTests.swift | 75 ++++++++++++++---------- Papyrus/Tests/ResponseDecoderTests.swift | 10 ++-- 5 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 Papyrus/Sources/ResourceMutex.swift diff --git a/Papyrus/Sources/Provider.swift b/Papyrus/Sources/Provider.swift index 5c1b7e2..a0a443d 100644 --- a/Papyrus/Sources/Provider.swift +++ b/Papyrus/Sources/Provider.swift @@ -84,48 +84,3 @@ public protocol Interceptor: Sendable { public protocol RequestModifier { func modify(req: inout RequestBuilder) throws } - -private extension Provider { - // Note: Can be replaced with Synchronization framework starting with iOS 18. - final class ResourceMutex: @unchecked Sendable { - private var resource: R - private let mutex: UnsafeMutablePointer - - 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() - } - - private func lock() { - pthread_mutex_lock(mutex) - } - - private func unlock() { - pthread_mutex_unlock(mutex) - } - - func withLock(method: (inout R) throws -> T) throws -> T { - defer { unlock() } - lock() - return try method(&resource) - } - - func withLock(method: (inout R) -> T) -> T { - defer { unlock() } - lock() - return method(&resource) - } - } - -} diff --git a/Papyrus/Sources/ResourceMutex.swift b/Papyrus/Sources/ResourceMutex.swift new file mode 100644 index 0000000..074c745 --- /dev/null +++ b/Papyrus/Sources/ResourceMutex.swift @@ -0,0 +1,52 @@ +// +// ResourceMutex.swift +// +// +// Created by Kevin Pittevils on 11/07/2024. +// + +import Foundation + +// Note: Can be replaced with Synchronization framework starting with iOS 18. +final class ResourceMutex: @unchecked Sendable { + private var resource: R + private let mutex: UnsafeMutablePointer + + 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() + } + + func withLock(method: (inout R) throws -> T) throws -> T { + defer { unlock() } + lock() + return try method(&resource) + } + + func withLock(method: (inout R) -> T) -> T { + defer { unlock() } + lock() + return method(&resource) + } +} + +private extension ResourceMutex { + func lock() { + pthread_mutex_lock(mutex) + } + + func unlock() { + pthread_mutex_unlock(mutex) + } +} diff --git a/Papyrus/Tests/APITests.swift b/Papyrus/Tests/APITests.swift index 7540c61..4e02bd8 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` diff --git a/Papyrus/Tests/CurlTests.swift b/Papyrus/Tests/CurlTests.swift index cee77db..2d13bc5 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,17 +179,21 @@ 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") + } } } diff --git a/Papyrus/Tests/ResponseDecoderTests.swift b/Papyrus/Tests/ResponseDecoderTests.swift index c052366..5c327d0 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,7 +72,7 @@ fileprivate struct _Person: Decodable { let name: String } -fileprivate class _Response : PapyrusResponse { +fileprivate struct _Response : PapyrusResponse { var request: PapyrusRequest? var body: Data? var headers: [String : String]? From c086900fb48c057e958bf9062ede2699bb06eb2c Mon Sep 17 00:00:00 2001 From: Kevin Pittevils Date: Fri, 4 Oct 2024 09:53:43 +0200 Subject: [PATCH 4/4] Add method to add an interceptor --- Papyrus/Sources/Provider.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Papyrus/Sources/Provider.swift b/Papyrus/Sources/Provider.swift index a0a443d..1f1023d 100644 --- a/Papyrus/Sources/Provider.swift +++ b/Papyrus/Sources/Provider.swift @@ -20,6 +20,12 @@ public final class Provider: Sendable { RequestBuilder(baseURL: baseURL, method: method, path: path) } + public func add(interceptor: any Interceptor) { + interceptors.withLock { resource in + resource.append(interceptor) + } + } + public func modifyRequests(action: @escaping (inout RequestBuilder) throws -> Void) -> Self { struct AnonymousModifier: RequestModifier { let action: (inout RequestBuilder) throws -> Void