diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..3e3e129 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,19 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift +on: + push: + branches: ["main"] + pull_request: + branches: ["*"] +jobs: + build: + runs-on: ubuntu-latest # For docker container + container: swift:latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v --configuration release + - name: Test + run: swift test -v --configuration release \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..73b0a76 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,48 @@ +excluded: + - .build +analyzer_rules: + - unused_declaration + - unused_import +opt_in_rules: + - all +disabled_rules: + - anonymous_argument_in_multiline_closure + - balanced_xctest_lifecycle + - conditional_returns_on_newline + - contrasted_opening_brace + - explicit_acl + - explicit_enum_raw_value + - explicit_top_level_acl + - explicit_type_interface + - file_header + - inert_defer # Deprecated + - missing_docs + - no_extension_access_modifier + - no_grouping_extension + - one_declaration_per_file + - prefer_nimble + - required_deinit + - sorted_enum_cases + - switch_case_on_newline + - trailing_closure + - unused_capture_list # Deprecated + - vertical_whitespace_between_cases +attributes: + attributes_with_arguments_always_on_line_above: true + always_on_line_above: + - "@MainActor" + - "@Option" + - "@Suite" + always_on_same_line: + - "@ViewBuilder" + - "@Environment" + - "@EnvironmentObject" + - "@Test" +closure_body_length: + warning: 10 + error: 20 +function_body_length: 40 +large_tuple: 3 +number_separator: + minimum_length: 5 +type_body_length: 300 diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DataRequest.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DataRequest.xcscheme new file mode 100644 index 0000000..27ddb74 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DataRequest.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index 8d8c472..320d754 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,24 @@ { + "originHash" : "3976824d324d5540b0007a3daf5c747340f900e41c18e9222b21091a712f5800", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "b2fa556e4e48cbf06cf8c63def138c98f4b811fa", - "version" : "5.8.0" + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "f9731bef175c3eea3a0ca960f1be78fcc2bc7853", + "version" : "0.57.1" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 29a48b4..7768534 100644 --- a/Package.swift +++ b/Package.swift @@ -1,36 +1,73 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +#if os(macOS) +let dependencies: [Dependency] = [.alamofire, .swiftLint] +let plugins: [Plugin] = [.swiftLint] +#else +let dependencies: [Dependency] = [.alamofire] +let plugins: [Plugin] = [] +#endif + +let name = "DataRequest" let package = Package( - name: "DataRequest", + name: name, platforms: [ .macOS(.v13), .iOS(.v16) ], products: [ .library( - name: "DataRequest", - targets: ["DataRequest"] - ) - ], - dependencies: [ - .package( - url: "https://github.com/Alamofire/Alamofire.git", - .upToNextMajor(from: "5.8.0") + name: name, + targets: [name] ) ], + dependencies: dependencies, targets: [ .target( - name: "DataRequest", + name: name, dependencies: ["Alamofire"], - path: "Sources" + path: "Sources", + plugins: plugins ), .testTarget( - name: "DataRequestTests", - dependencies: ["DataRequest"], - path: "Tests" + name: "\(name)Tests", + dependencies: [.byName(name: name)], + path: "Tests", + plugins: plugins ) ] ) + +// MARK: - Dependency + +typealias Dependency = Package.Dependency +extension Dependency { + static var alamofire: Dependency { + .package( + url: "https://github.com/Alamofire/Alamofire.git", + .upToNextMajor(from: "5.8.0") + ) + } + + static var swiftLint: Dependency { + .package( + url: "https://github.com/SimplyDanny/SwiftLintPlugins", + .upToNextMajor(from: "0.0.0") + ) + } +} + +// MARK: - Plugin + +typealias Plugin = PackageDescription.Target.PluginUsage +extension Plugin { + static var swiftLint: Plugin { + .plugin( + name: "SwiftLintBuildToolPlugin", + package: "SwiftLintPlugins" + ) + } +} diff --git a/README.md b/README.md index a74b344..2f308d8 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Modularization of data requests using Alamofire with concurrency. -This package encourages a design pattern where the configuration of an endpoint is encapsulated into the properties of a structure. +This package encourages a design pattern where the _description_ of an endpoint is encapsulated into the properties of a structure. A similar design to a SwiftUI `View`. -It adds rather than replaces; direct use of Alamofire (or vanilla `URLSession`) is still encouraged. +It adds rather than replaces; direct use of Alamofire (or `URLSession`) is still encouraged. There is also some helpful shorthand. ## Example Usage @@ -12,7 +12,7 @@ There is also some helpful shorthand. Define a decodable model returned in a response: ```swift -struct Model: Decodable { ... } +struct Model: Decodable, Sendable { ... } ``` Specify the configuration of the request endpoint: @@ -21,9 +21,10 @@ Specify the configuration of the request endpoint: struct GetModel: DecodableRequest { typealias ResponseBody = Model - var urlComponents: URLComponents { - ... - } + var urlComponents: URLComponents { ... } + var method: HTTPMethod { ... } + var headers: HTTPHeaders { ... } + var body: HTTPBody? { ... } } ``` @@ -46,13 +47,9 @@ dependencies: [ ] ``` -## Notes - -* The `URLRequestMaker` checks for conformance of `RequestBody` and adds the HTTP body accordingly -* A `DecodableRequest` is a `URLRequestMaker` with the configuration properties of a data request defaulted - ## Uploads +A `DecodableRequest` is a `URLRequestMaker` with the configuration properties of a data request defaulted. Since `URLRequestMaker` conforms to `URLRequestConvertible` you can use Alamofire directly: ```swift @@ -78,3 +75,7 @@ extension Session { ``` This can be returned in the `session` property of the `DecodableRequest`. + +## GitHub Actions + +The `.github/workflows/swift.yml` GitHub action checks that the Swift package builds and the tests past using a swift docker container. diff --git a/Sources/Body/DataBody.swift b/Sources/Body/DataBody.swift deleted file mode 100644 index 0e6a6f7..0000000 --- a/Sources/Body/DataBody.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// DataBody.swift -// DataRequest -// -// Created by Ben Shutt on 18/09/2023. -// Copyright © 2023 Ben Shutt. All rights reserved. -// - -import Foundation -import Alamofire - -/// Defines a data request body -public protocol DataBody: RequestBody {} - -// MARK: - Extensions - -public extension DataBody { - - /// Defaults to `.contentTypeData` - var contentType: HTTPHeader { - .contentTypeData - } -} diff --git a/Sources/Body/JSONBody.swift b/Sources/Body/JSONBody.swift deleted file mode 100644 index 5475c66..0000000 --- a/Sources/Body/JSONBody.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// JSONBody.swift -// DataRequest -// -// Created by Ben Shutt on 18/09/2023. -// Copyright © 2023 Ben Shutt. All rights reserved. -// - -import Foundation -import Alamofire - -/// Defines a JSON request body -public protocol JSONBody: RequestBody { - - /// The request body that will be encoded into JSON data - associatedtype Body: Encodable - - /// Make request body that will be encoded into JSON data - var jsonBody: Body { get } - - /// The encoder which makes the JSON data - var encoder: JSONEncoder { get } -} - -// MARK: - Extensions - -public extension JSONBody { - - /// Defaults to `.contentTypeJSON` - var contentType: HTTPHeader { - .contentTypeJSON - } - - /// Defaults to `JSONEncoder()` - var encoder: JSONEncoder { - JSONEncoder() - } - - /// Encode `jsonBody` with `encoder` - var body: Data { - get throws { - try encoder.encode(jsonBody) - } - } -} diff --git a/Sources/Body/RequestBody.swift b/Sources/Body/RequestBody.swift deleted file mode 100644 index 44a52ab..0000000 --- a/Sources/Body/RequestBody.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// RequestBody.swift -// DataRequest -// -// Created by Ben Shutt on 18/09/2023. -// Copyright © 2023 Ben Shutt. All rights reserved. -// - -import Foundation -import Alamofire - -/// Defines a HTTP request body -public protocol RequestBody { - - /// `HTTPHeader` for the content type of the body - var contentType: HTTPHeader { get } - - /// Request body data - var body: Data { get throws } -} diff --git a/Sources/DecodableRequest.swift b/Sources/DecodableRequest.swift index 8c45e44..30fc76a 100644 --- a/Sources/DecodableRequest.swift +++ b/Sources/DecodableRequest.swift @@ -6,29 +6,32 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation /// An endpoint that executes a URL request and decodes the response into a model. public protocol DecodableRequest: URLRequestMaker { - /// Model expected in the response body that will be decoded from data. /// - Note: `Empty` can be used when a response is empty. E.g. on HTTP status code 204. - associatedtype ResponseBody: Decodable + associatedtype ResponseBody: Decodable, Sendable /// The Alamofire session. + /// /// Defaults to `.default`. var session: Session { get } /// Define how the response data should be decoded. + /// /// Defaults to `JSONDecoder()`. var decoder: DataDecoder { get } /// The request interceptor. + /// /// Defaults to `nil`. var interceptor: RequestInterceptor? { get } /// Validate the response. + /// /// Defaults to `true`. var validate: Bool { get } } @@ -36,34 +39,22 @@ public protocol DecodableRequest: URLRequestMaker { // MARK: - Extensions public extension DecodableRequest { - - /// Defaults to `.default` var session: Session { .default } - /// Defaults to `JSONDecoder()` var decoder: DataDecoder { JSONDecoder() } - /// Defaults to `nil` var interceptor: RequestInterceptor? { nil } - /// Defaults to `true` var validate: Bool { true } - // MARK: URLRequestMaker - - /// Defaults to `[.acceptJSON]` - var additionalHeaders: HTTPHeaders { - [.acceptJSON] - } - // MARK: Request /// Execute the data request decoding the response @@ -71,7 +62,7 @@ public extension DecodableRequest { @discardableResult func request() async throws -> ResponseBody { try await session.request( - urlRequest, + self, interceptor: interceptor ) .decodeValue( diff --git a/Sources/Extensions/DataRequest+Extensions.swift b/Sources/Extensions/DataRequest+Extensions.swift index 40f7a5a..bf64bd9 100644 --- a/Sources/Extensions/DataRequest+Extensions.swift +++ b/Sources/Extensions/DataRequest+Extensions.swift @@ -6,11 +6,10 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation public extension DataRequest { - /// Validate if required /// - Parameter value: Should validate /// - Returns: `Self` @@ -24,7 +23,7 @@ public extension DataRequest { /// - validate: If true, validate the response, defaults to `true` /// - decoder: The data decoder to use, defaults to `JSONDecoder()` /// - Returns: `ResponseBody` - func decodeValue( + func decodeValue( _ responseBody: ResponseBody.Type, validate: Bool = true, decoder: DataDecoder = JSONDecoder() diff --git a/Sources/Extensions/HTTPHeader+Extensions.swift b/Sources/Extensions/HTTPHeader+Extensions.swift index 6478b96..92f8d76 100644 --- a/Sources/Extensions/HTTPHeader+Extensions.swift +++ b/Sources/Extensions/HTTPHeader+Extensions.swift @@ -6,11 +6,10 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation public extension HTTPHeader { - /// `"Accept: application/json"` static let acceptJSON: HTTPHeader = .accept("application/json") diff --git a/Sources/Extensions/HTTPHeaders+Extensions.swift b/Sources/Extensions/HTTPHeaders+Extensions.swift index bf34e6a..91a30b9 100644 --- a/Sources/Extensions/HTTPHeaders+Extensions.swift +++ b/Sources/Extensions/HTTPHeaders+Extensions.swift @@ -6,11 +6,10 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation public extension HTTPHeaders { - /// Append `headers` to this instance /// - Parameter headers: `HTTPHeader` mutating func append(_ headers: HTTPHeader...) { diff --git a/Sources/HTTPBody.swift b/Sources/HTTPBody.swift new file mode 100644 index 0000000..b8b49a3 --- /dev/null +++ b/Sources/HTTPBody.swift @@ -0,0 +1,58 @@ +// +// HTTPBody.swift +// DataRequest +// +// Created by Ben Shutt on 18/09/2023. +// Copyright © 2023 Ben Shutt. All rights reserved. +// + +import Alamofire +import Foundation + +/// Defines a HTTP request body +public struct HTTPBody { + /// `HTTPHeader` for the content type of the body + public var contentType: HTTPHeader + + /// Request body data + public var body: Data + + /// Public memberwise initializer + public init(contentType: HTTPHeader, body: Data) { + self.contentType = contentType + self.body = body + } +} + +// MARK: - HTTPBody + JSON + +extension HTTPBody { + /// Make a JSON request body + /// - Parameters: + /// - model: Object that will be encoded into JSON data + /// - encoder: The encoder which makes the JSON data + /// - Returns: The JSON request body + static func json( + _ encodable: some Encodable, + encoder: JSONEncoder = JSONEncoder() + ) throws -> HTTPBody { + try HTTPBody( + contentType: .contentTypeJSON, + body: encoder.encode(encodable) + ) + } +} + +// MARK: - HTTPBody + Data + +extension HTTPBody { + /// Make a `Data` request body + /// - Parameter data: The data of the body + /// - Returns: The `Data` request body + static func data(_ data: Data) -> HTTPBody { + HTTPBody( + contentType: .contentTypeData, + body: data + ) + } +} diff --git a/Sources/URLRequestMaker.swift b/Sources/URLRequestMaker.swift index d60f778..19b7499 100644 --- a/Sources/URLRequestMaker.swift +++ b/Sources/URLRequestMaker.swift @@ -6,64 +6,84 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation -/// An entity which builds a `URLRequest`. -/// If the entity (also) conforms to `RequestBody` then the HTTP body and content type header is set. +/// An entity that builds a `URLRequest`. public protocol URLRequestMaker: URLRequestConvertible { - /// The components of a URL. var urlComponents: URLComponents { get } /// The HTTP method. + /// /// Defaults to `.get`. var method: HTTPMethod { get } - /// Additional headers to append to the default. - /// Defaults to empty. - var additionalHeaders: HTTPHeaders { get } + /// The default HTTP headers. + /// + /// Defaults to `.default` (from Alamofire). + /// + /// - Note: The final `URLRequest` should be considered the source of truth. + /// For example, the content type might be added while making the `URLRequest` + /// but may not be defined here. + var headers: HTTPHeaders { get } + + /// The HTTP body. + /// + /// Defaults to `nil`. + var body: HTTPBody? { get throws } + + /// The `URLRequest` constructed from the other properties, before updates. + var urlRequest: URLRequest { get throws } + + // MARK: URLRequestConvertible + + /// Apply updates to the `urlRequest` before returning. + /// Conformers may provide their own implementation mutating `urlRequest`. + /// For example, applying `URLEncoding`. + /// + /// By default, returns `urlRequest`. + func asURLRequest() throws -> URLRequest } -// MARK: - Extensions +// MARK: - Defaults public extension URLRequestMaker { - - /// Defaults to `.get` var method: HTTPMethod { .get } - /// Defaults to empty - var additionalHeaders: HTTPHeaders { - [] + var headers: HTTPHeaders { + .default } - /// Get all HTTP headers - var headers: HTTPHeaders { - var headers: HTTPHeaders = .default - if let requestBody = self as? RequestBody { - headers.append(requestBody.contentType) - } - headers.append(additionalHeaders) - return headers + var body: HTTPBody? { + nil } - /// Make the URL request var urlRequest: URLRequest { get throws { + let body = try body + + var headers = headers + if let contentType = body?.contentType { + headers.append(contentType) + } + var request = try URLRequest( url: urlComponents, method: method, headers: headers ) - if let requestBody = self as? RequestBody { - request.httpBody = try requestBody.body - } + request.httpBody = body?.body return request } } +} +// MARK: - URLRequestConvertible + +public extension URLRequestMaker { func asURLRequest() throws -> URLRequest { try urlRequest } diff --git a/Sources/Utilities/ResponseEventMonitor.swift b/Sources/Utilities/ResponseEventMonitor.swift index a2c8c44..844729c 100644 --- a/Sources/Utilities/ResponseEventMonitor.swift +++ b/Sources/Utilities/ResponseEventMonitor.swift @@ -6,20 +6,21 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire +import Foundation /// `EventMonitor` that logs responses public struct ResponseEventMonitor: EventMonitor { - - /// `DispatchQueue` to execute on + /// The `DispatchQueue` onto which Alamofire's root `CompositeEventMonitor` will dispatch events public var queue: DispatchQueue { .main } /// Public initializer - public init() {} + public init() { + // Defined for public access level + } public func request( - _ request: DataRequest, + _ _: DataRequest, didParseResponse response: DataResponse ) { debugPrint(response) diff --git a/Tests/Extensions/DateFormatter+ISO8601.swift b/Tests/Extensions/DateFormatter+ISO8601.swift deleted file mode 100644 index 9d3edc3..0000000 --- a/Tests/Extensions/DateFormatter+ISO8601.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ISO8601DateFormatter+Extensions.swift -// DataRequest -// -// Created by Ben Shutt on 18/09/2023. -// Copyright © 2023 Ben Shutt. All rights reserved. -// - -import Foundation - -// MARK: - DateFormatter + ISO8601 - -extension DateFormatter { - - /// An ISO8601 `DateFormatter` with milliseconds. - static var iso8601Millis: DateFormatter { - iso8601Formatter(dateFormat: .iso8601Millis) - } - - /// An ISO8601 `DateFormatter` with: - /// - Calendar identifier `.iso8601` - /// - Locale identifier `"en_US_POSIX"` - /// - Given `timeZone`, defaults to `.current` - /// - Given `dateFormat` - /// - /// - Parameters: - /// - timeZone: The time zone, defaults to `.current` - /// - dateFormat: The date format - /// - Returns: `DateFormatter` - private static func iso8601Formatter( - timeZone: TimeZone? = .current, - dateFormat: String - ) -> DateFormatter { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = timeZone - formatter.dateFormat = dateFormat - return formatter - } -} - -// MARK: - String + ISO8601 - -private extension String { - - /// Date format for ISO8601 including milliseconds - static let iso8601Millis: String = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" -} diff --git a/Tests/Extensions/Session+Extensions.swift b/Tests/Extensions/Session+Extensions.swift deleted file mode 100644 index 4227154..0000000 --- a/Tests/Extensions/Session+Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Session+Extensions.swift -// DataRequest -// -// Created by Ben Shutt on 18/09/2023. -// Copyright © 2023 Ben Shutt. All rights reserved. -// - -import Foundation -import Alamofire -import DataRequest - -extension Session { - - /// Make custom `Session` with event monitors - static let worldTime = Session(eventMonitors: [ - ResponseEventMonitor() - ]) -} diff --git a/Tests/HTTPBodyTests.swift b/Tests/HTTPBodyTests.swift new file mode 100644 index 0000000..108040c --- /dev/null +++ b/Tests/HTTPBodyTests.swift @@ -0,0 +1,61 @@ +// +// HTTPBodyTests.swift +// DataRequest +// +// Created by Ben Shutt on 03/01/2025. +// Copyright © 2025 Ben Shutt. All rights reserved. +// + +@testable import DataRequest +import Foundation +import Testing + +// MARK: - EncodableBody + +struct EncodableBody: Encodable { + var int = 1 + var string = "123" +} + +// MARK: - HTTPBodyTests + +@Suite("Unit tests for HTTPBody") +struct HTTPBodyTests { + // MARK: - Data + + @Test func dataHeader() throws { + let httpBody = HTTPBody.data(Data()) + #expect(httpBody.contentType == .init( + name: "Content-Type", + value: "application/octet-stream" + )) + } + + @Test func dataBody() throws { + let httpBody = HTTPBody.data(Data()) + #expect(httpBody.body == Data()) + } + + // MARK: - JSON + + @Test func jsonHeader() throws { + let httpBody = try HTTPBody.json(EncodableBody()) + #expect(httpBody.contentType == .init( + name: "Content-Type", + value: "application/json" + )) + } + + @Test func jsonBody() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + let httpBody = try HTTPBody.json(EncodableBody(), encoder: encoder) + let json = try #require(String(data: httpBody.body, encoding: .utf8)) + + #expect(json == """ + {"int":1,"string":"123"} + """ + ) + } +} diff --git a/Tests/URLRequestMakerTests.swift b/Tests/URLRequestMakerTests.swift new file mode 100644 index 0000000..15bcac7 --- /dev/null +++ b/Tests/URLRequestMakerTests.swift @@ -0,0 +1,98 @@ +// +// URLRequestMakerTests.swift +// DataRequest +// +// Created by Ben Shutt on 03/01/2025. +// Copyright © 2025 Ben Shutt. All rights reserved. +// + +import Alamofire +@testable import DataRequest +import Foundation +import Testing + +// MARK: - EmptyURLRequestMaker + +struct EmptyURLRequestMaker: URLRequestMaker { + let urlComponents = URLComponents() +} + +// MARK: - URLRequestMakerWithBody + +struct URLRequestMakerWithBody: URLRequestMaker { + let urlComponents = URLComponents() + let method: HTTPMethod = .post + + @Atomic var bodyCount = 0 + + var headers: HTTPHeaders { + .default.appending(.acceptJSON) + } + + var body: HTTPBody? { + bodyCount += 1 + return HTTPBody( + contentType: .contentTypeData, + body: Data() + ) + } +} + +// MARK: - URLRequestMakerTests + +@Suite("Unit tests for URLRequestMaker") +struct URLRequestMakerTests { + // MARK: - Defaults + + @Test func defaultMethod() throws { + let sut = EmptyURLRequestMaker() + let urlRequest = try sut.urlRequest + #expect(urlRequest.httpMethod == "GET") + } + + @Test func defaultHeaders() throws { + let sut = EmptyURLRequestMaker() + let urlRequest = try sut.urlRequest + let headers = urlRequest.allHTTPHeaderFields ?? [:] + #expect(Set(headers.keys) == [ + "Accept-Encoding", + "Accept-Language", + "User-Agent" + ]) + } + + @Test func defaultBody() throws { + let sut = EmptyURLRequestMaker() + let urlRequest = try sut.urlRequest + #expect(urlRequest.httpBody == nil) + } + + // MARK: - Defaults + + @Test func asURLRequestCallsBodyOnce() throws { + let sut = URLRequestMakerWithBody() + _ = try sut.asURLRequest() + #expect(sut.bodyCount == 1) + } + + @Test func withBodyMethod() throws { + let sut = URLRequestMakerWithBody() + let urlRequest = try sut.asURLRequest() + #expect(urlRequest.httpMethod == "POST") + } + + @Test func withBodyHeaders() throws { + let sut = URLRequestMakerWithBody() + let urlRequest = try sut.asURLRequest() + let headers = urlRequest.allHTTPHeaderFields ?? [:] + #expect(Set(headers.keys) == [ + "Accept", + "Accept-Encoding", + "Accept-Language", + "Content-Type", + "User-Agent" + ]) + #expect(headers["Accept"] == "application/json") + #expect(headers["Content-Type"] == "application/octet-stream") + } +} diff --git a/Tests/Utilities/Atomic.swift b/Tests/Utilities/Atomic.swift new file mode 100644 index 0000000..a53ecc7 --- /dev/null +++ b/Tests/Utilities/Atomic.swift @@ -0,0 +1,47 @@ +// +// Atomic.swift +// DataRequest +// +// Created by Ben Shutt on 03/01/2025. +// Copyright © 2025 Ben Shutt. All rights reserved. +// + +import Foundation + +/// Example of making a thread safe class using GCD +@propertyWrapper +final class Atomic: @unchecked Sendable { + /// Concurrent dispatch queue + private let queue = DispatchQueue( + label: "\(Atomic.self)", + attributes: .concurrent + ) + + /// A property that is thread safe and accessed only on the queue + private var storedValue: Stored + + /// Get and set the stored value in a thread safe manner + var wrappedValue: Stored { + get { + // Add operation to the queue. + // Wait for the operation to finish. + queue.sync { + storedValue + } + } + set { + // Add operation to the queue. + // Stop anything else running on the queue while this operation runs. + // Do not wait for the operation to finish. + queue.async(flags: .barrier) { [weak self] in + self?.storedValue = newValue + } + } + } + + /// Memberwise initializer + /// - Parameter wrappedValue: The value to store + init(wrappedValue: Stored) { + storedValue = wrappedValue + } +} diff --git a/Tests/WorldTime/GetWorldTime.swift b/Tests/WorldTime/GetWorldTime.swift index 1aeadae..3293610 100644 --- a/Tests/WorldTime/GetWorldTime.swift +++ b/Tests/WorldTime/GetWorldTime.swift @@ -6,33 +6,33 @@ // Copyright © 2023 Ben Shutt. All rights reserved. // -import Foundation import Alamofire import DataRequest +import Foundation /// Get the world time for a time zone struct GetWorldTime: DecodableRequest { - /// Decode response as `WorldTime` typealias ResponseBody = WorldTime - /// The time zone to fetch, defaults to `"Europe/London"` - var timeZone: String + /// The time zone to fetch + let timeZone: String /// The Alamofire session - var session: Session { - .worldTime - } + let session: Session = .worldTime - /// Define a specific `JSONDecoder` - var decoder: DataDecoder { + /// Define a specific `JSONDecoder` with snake case and ISO8601 dates + let decoder: DataDecoder = { let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(.iso8601Millis) decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let iso8601 = try decoder.singleValueContainer().decode(String.self) + let format = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + return try format.parse(iso8601) + } return decoder - } + }() - /// `URLComponents` for World Time var urlComponents: URLComponents { var urlComponents = URLComponents() urlComponents.scheme = "https" @@ -40,4 +40,16 @@ struct GetWorldTime: DecodableRequest { urlComponents.path = "/api/timezone/\(timeZone)" return urlComponents } + + var headers: HTTPHeaders { + .default.appending(.acceptJSON) + } +} + +// MARK: - Session + WorldTime + +private extension Session { + static let worldTime = Session(eventMonitors: [ + ResponseEventMonitor() + ]) } diff --git a/Tests/WorldTime/WorldTime.swift b/Tests/WorldTime/WorldTime.swift index d295411..05ccc12 100644 --- a/Tests/WorldTime/WorldTime.swift +++ b/Tests/WorldTime/WorldTime.swift @@ -12,6 +12,7 @@ import Foundation /// http://worldtimeapi.org struct WorldTime: Decodable { var abbreviation: String + var unixtime: Int var datetime: Date var dstOffset: Int var rawOffset: Int diff --git a/Tests/JSONDataRequestTests.swift b/Tests/WorldTimeTests.swift similarity index 56% rename from Tests/JSONDataRequestTests.swift rename to Tests/WorldTimeTests.swift index 930825a..aefdd72 100644 --- a/Tests/JSONDataRequestTests.swift +++ b/Tests/WorldTimeTests.swift @@ -1,19 +1,20 @@ // -// JSONDataRequestTests.swift +// WorldTimeTests.swift // DataRequestTests // // Created by Ben Shutt on 18/09/2023. // Copyright © 2023 Ben Shutt. All rights reserved. // -import XCTest @testable import DataRequest +import Testing -final class JSONDataRequestTests: XCTestCase { +@Suite("Integration tests for the World Time API", .disabled()) +struct WorldTimeTests { private let timeZone = "Europe/London" - func test() async throws { + @Test func worldTimeAPI() async throws { let worldTime = try await GetWorldTime(timeZone: timeZone).request() - XCTAssertEqual(worldTime.timezone, timeZone) + #expect(worldTime.timezone == timeZone) } }