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)
}
}