├── .editorconfig
├── .github
├── FUNDING.yml
└── workflows
│ ├── docc.yml
│ └── swift.yml
├── .gitignore
├── .swiftformat
├── .vscode
└── tasks.json
├── Example
├── Petstore
│ ├── Package.resolved
│ ├── Package.swift
│ └── Sources
│ │ ├── PetStore
│ │ ├── CustomDecoder.swift
│ │ ├── ExampleOfCalls.swift
│ │ ├── Models.swift
│ │ ├── PetStore.swift
│ │ └── PetStoreBaseURL.swift
│ │ └── PetStoreWithMacros
│ │ ├── CustomDecoder.swift
│ │ ├── ExampleOfCalls.swift
│ │ ├── Models.swift
│ │ ├── PetStore.swift
│ │ └── PetStoreBaseURL.swift
└── Search
│ ├── README.md
│ ├── Search.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ ├── xcshareddata
│ │ │ ├── IDEWorkspaceChecks.plist
│ │ │ └── swiftpm
│ │ │ │ └── Package.resolved
│ │ └── xcuserdata
│ │ │ └── danil.xcuserdatad
│ │ │ └── UserInterfaceState.xcuserstate
│ ├── xcshareddata
│ │ └── xcschemes
│ │ │ └── Search.xcscheme
│ └── xcuserdata
│ │ └── danil.xcuserdatad
│ │ ├── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
│ └── Search
│ ├── Assets.xcassets
│ ├── AppIcon.appiconset
│ │ ├── Contents.json
│ │ ├── swift-api-client-logo 1.png
│ │ ├── swift-api-client-logo 2.png
│ │ ├── swift-api-client-logo 3.png
│ │ ├── swift-api-client-logo.png
│ │ └── transparent.png
│ └── Contents.json
│ ├── Models
│ ├── Forecast.swift
│ └── GeocodingSearch.swift
│ ├── Search.swift
│ ├── SearchApp.swift
│ └── WeatherClient.swift
├── LICENSE
├── Package.resolved
├── Package.swift
├── Package@swift-5.9.swift
├── README.md
├── Sources
├── SwiftAPIClient
│ ├── APIClient.swift
│ ├── APIClientCaller.swift
│ ├── APIClientConfigs.swift
│ ├── Clients
│ │ ├── HTTPClient.swift
│ │ ├── HTTPDownloadClient.swift
│ │ ├── HTTPPublisher.swift
│ │ ├── HTTPUploadClient.swift
│ │ └── URLSession+Client.swift
│ ├── Extensions
│ │ ├── Async++.swift
│ │ ├── String++.swift
│ │ ├── URLComponentBuilder.swift
│ │ └── URLResponse++.swift
│ ├── Imports.swift
│ ├── Macros.swift
│ ├── Modifiers
│ │ ├── AuthModifier.swift
│ │ ├── BackgroundModifiers.swift
│ │ ├── CodersModifiers.swift
│ │ ├── ErrorDecodeModifiers.swift
│ │ ├── ErrorHandler.swift
│ │ ├── FileIDLine.swift
│ │ ├── HTTPClientMiddleware.swift
│ │ ├── HTTPResponseValidator.swift
│ │ ├── LoggingModifier.swift
│ │ ├── MetricsModifier.swift
│ │ ├── MockResponses.swift
│ │ ├── RateLimitModifier.swift
│ │ ├── RedirectModifier.swift
│ │ ├── RequestCompression.swift
│ │ ├── RequestModifiers.swift
│ │ ├── RequestValidator.swift
│ │ ├── ResponseWrapModifires.swift
│ │ ├── RetryModifier.swift
│ │ ├── ThrottleModifier.swift
│ │ ├── TimeoutModifiers.swift
│ │ ├── TokenRefresher
│ │ │ ├── TokenCacheService.swift
│ │ │ └── TokenRefresher.swift
│ │ ├── URLSessionModifiers.swift
│ │ └── WaitForConnectionModifier.swift
│ ├── RequestBuilder.swift
│ ├── Types
│ │ ├── AsyncValue.swift
│ │ ├── ContentSerializer.swift
│ │ ├── ContentType.swift
│ │ ├── Errors.swift
│ │ ├── HTTPFields.swift
│ │ ├── HTTPRequestComponents.swift
│ │ ├── LoggingComponent.swift
│ │ ├── Mockable.swift
│ │ ├── RedirectBehaviour.swift
│ │ ├── Serializer.swift
│ │ └── TimeoutError.swift
│ └── Utils
│ │ ├── AnyAsyncSequence.swift
│ │ ├── AnyEncodable.swift
│ │ ├── Coders
│ │ ├── ContentEncoder.swift
│ │ ├── DataDecoder.swift
│ │ ├── EncodingStrategies.swift
│ │ ├── ErrorDecoder.swift
│ │ ├── FormURLEncoder.swift
│ │ ├── HeadersEncoder.swift
│ │ ├── JSONContentEncoders.swift
│ │ ├── MultipartFormData
│ │ │ ├── MultipartFormData.swift
│ │ │ └── MultipartFormDataEncoder.swift
│ │ ├── ParametersEncoder.swift
│ │ ├── ParametersValue.swift
│ │ ├── QueryEncoder.swift
│ │ └── URLQuery
│ │ │ ├── HTTPHeadersEncoder.swift
│ │ │ ├── PlainCodingKey.swift
│ │ │ ├── Ref.swift
│ │ │ └── URLQueryEncoder.swift
│ │ ├── ConsoleStyle.swift
│ │ ├── Error+String.swift
│ │ ├── NoneLogger.swift
│ │ ├── Publisher+Create.swift
│ │ ├── Publishers+Task.swift
│ │ ├── Reachability.swift
│ │ ├── Status+Ext.swift
│ │ ├── URLSessionDelegateWrapper.swift
│ │ ├── UpdateMetrics.swift
│ │ └── WithSynchronizedAccess.swift
└── SwiftAPIClientMacros
│ ├── Collection++.swift
│ ├── MacroError.swift
│ ├── String++.swift
│ └── SwiftAPIClientMacros.swift
├── SwagGen_Template
├── APIClient
│ └── Coding.swift
├── Includes
│ ├── Enum.stencil
│ └── Model.stencil
├── Sources
│ ├── APIModule.swift
│ ├── Enum.swift
│ ├── Model.swift
│ └── Request.swift
└── template.yml
└── Tests
├── SwiftAPIClientMacrosTests
├── APIMacroTests.swift
├── CallMacroTests.swift
└── PathMacroTests.swift
└── SwiftAPIClientTests
├── CURLTests.swift
├── EncodersTests
└── MultipartFormDataTests.swift
├── HTTPHeadersEncoderTests.swift
├── Modifiers
├── AuthModifierTests.swift
├── ErrorDecodingTests.swift
├── HTTPResponseValidatorTests.swift
├── LogLevelModifierTests.swift
├── MockResponsesTests.swift
├── RequestCompressionTests.swift
└── RequestModifiersTests.swift
├── NetworkClientTests.swift
├── TestUtils
├── Client+Ext.swift
└── TestHTTPClient.swift
├── URLQueryEncoderTests.swift
└── UtilsTests
├── URLComponentBuilderTests.swift
└── WithTimeoutTests.swift
/.editorconfig:
--------------------------------------------------------------------------------
1 | # 2 space indentation
2 | [*.swift]
3 | indent_style = tab
4 | indent_size = 2
5 | insert_final_newline = true
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: dankinsoid
4 | open_collective: voidilov-daniil
5 | ko_fi: dankinsoid
6 | custom: ["https://paypal.me/voidilovuae", "https://www.buymeacoffee.com/dankinsoid"]
7 |
--------------------------------------------------------------------------------
/.github/workflows/docc.yml:
--------------------------------------------------------------------------------
1 | name: DocC Runner
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 |
7 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | # Allow one concurrent deployment
14 | concurrency:
15 | group: "pages"
16 | cancel-in-progress: true
17 |
18 | # A single job that builds and deploys the DocC documentation
19 | jobs:
20 | deploy:
21 | environment:
22 | name: github-pages
23 | url: $
24 | runs-on: macos-latest
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 | - id: pages
29 | name: Setup Pages
30 | uses: actions/configure-pages@v4
31 | - name: Select Latest Xcode
32 | uses: maxim-lobanov/setup-xcode@v1
33 | with:
34 | xcode-version: latest-stable
35 | - name: Build DocC
36 | run: |
37 | swift package resolve;
38 |
39 | xcodebuild -resolvePackageDependencies;
40 | xcodebuild docbuild -scheme swift-api-client -derivedDataPath /tmp/docbuild -destination 'generic/platform=macOS';
41 |
42 | $(xcrun --find docc) process-archive \
43 | transform-for-static-hosting /tmp/docbuild/Build/Products/Debug/SwiftAPIClient.doccarchive \
44 | --output-path docs \
45 | --hosting-base-path 'swift-api-client';
46 |
47 | echo "" > docs/index.html;
48 | - name: Upload artifact
49 | uses: actions/upload-pages-artifact@v3
50 | with:
51 | path: 'docs'
52 | - id: deployment
53 | name: Deploy to GitHub Pages
54 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a Swift project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift
3 |
4 | name: Swift
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Build
20 | run: swift build -v
21 | - name: Run tests
22 | run: swift test -v
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .swiftpm
2 | Package.resolved~main
3 | Package.resolved~main_0
4 | .build
5 | .idea
6 | **/.DS_Store
7 | *.xcuserstate
8 | .aider*
9 | .env
10 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --indent tab
2 | --ifdef no-indent
3 | --swiftversion 5.7
4 | --exclude Pods,**/Templates
5 | --disable preferKeyPath
6 | --disable wrapConditionalBodies
7 | --disable sortDeclarations
8 | --disable blankLinesAtStartOfScope
9 | --disable opaqueGenericParameters
10 | --disable unusedArguments
11 | --disable enumnamespaces
12 | --redundanttype inferred
13 | --header ""
14 | --enable organizeDeclarations
15 | --organizetypes markcategories
16 | --extensionacl on-extension
17 | --stripunusedargs unnamed-only
18 | --enable genericExtensions
19 | --enable typeSugar
20 | --enable docComments
21 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "swift",
6 | "args": [
7 | "build",
8 | "--build-tests",
9 | "-Xswiftc",
10 | "-diagnostic-style=llvm"
11 | ],
12 | "env": {},
13 | "cwd": "/Users/danil/Code/swift-api-client",
14 | "disableTaskQueue": true,
15 | "group": {
16 | "kind": "build",
17 | "isDefault": true
18 | },
19 | "problemMatcher": [],
20 | "label": "Build",
21 | "detail": "swift build --build-tests -Xswiftc -diagnostic-style=llvm"
22 | },
23 | {
24 | "type": "swift",
25 | "args": [
26 | "test",
27 | "--parallel",
28 | "--enable-test-discovery",
29 | "-Xswiftc",
30 | "-diagnostic-style=llvm"
31 | ],
32 | "env": {},
33 | "cwd": "/Users/danil/Code/swift-api-client",
34 | "disableTaskQueue": true,
35 | "group": {
36 | "kind": "test",
37 | "isDefault": true
38 | },
39 | "problemMatcher": [],
40 | "label": "Test",
41 | "detail": "swift test --parallel --enable-test-discovery -Xswiftc -diagnostic-style=llvm"
42 | }
43 | ]
44 | }
--------------------------------------------------------------------------------
/Example/Petstore/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-http-types",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-http-types.git",
7 | "state" : {
8 | "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "swift-log",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/apple/swift-log.git",
16 | "state" : {
17 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
18 | "version" : "1.5.4"
19 | }
20 | },
21 | {
22 | "identity" : "swift-syntax",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-syntax.git",
25 | "state" : {
26 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
27 | "version" : "509.1.1"
28 | }
29 | }
30 | ],
31 | "version" : 2
32 | }
33 |
--------------------------------------------------------------------------------
/Example/Petstore/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | var package = Package(
7 | name: "pet-store",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13),
11 | .watchOS(.v5),
12 | .tvOS(.v13),
13 | ],
14 | products: [
15 | .library(name: "PetStore", targets: ["PetStore"]),
16 | .library(name: "PetStoreWithMacros", targets: ["PetStoreWithMacros"]),
17 | ],
18 | dependencies: [
19 | .package(path: "../../"),
20 | ],
21 | targets: [
22 | .target(
23 | name: "PetStore",
24 | dependencies: [
25 | .product(name: "SwiftAPIClient", package: "swift-api-client"),
26 | ]
27 | ),
28 | .target(
29 | name: "PetStoreWithMacros",
30 | dependencies: [
31 | .product(name: "SwiftAPIClient", package: "swift-api-client"),
32 | ]
33 | ),
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStore/CustomDecoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | struct PetStoreDecoder: DataDecoder {
5 |
6 | func decode(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
7 | let decoder = JSONDecoder()
8 | decoder.keyDecodingStrategy = .convertFromSnakeCase
9 | let response = try decoder.decode(PetStoreResponse.self, from: data)
10 | guard let result = response.response, response.success else {
11 | throw DecodingError.valueNotFound(T.self, DecodingError.Context(codingPath: [], debugDescription: "Server error"))
12 | }
13 | return result
14 | }
15 | }
16 |
17 | struct PetStoreResponse: Decodable {
18 |
19 | var success: Bool
20 | var error: String?
21 | var response: T?
22 | }
23 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStore/ExampleOfCalls.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | // MARK: - Usage example
5 |
6 | func exampleOfAPICalls() async throws {
7 | _ = try await api().pet("some-id").get()
8 | _ = try await api().pet.findBy(status: .available)
9 | _ = try await api().store.inventory()
10 | _ = try await api().user.logout()
11 | _ = try await api().user("name").delete()
12 | }
13 |
14 | /// In order to get actual #line and #fileID in loggs use the following function instead of variable.
15 | func api(fileID: String = #fileID, line: UInt = #line) -> PetStore {
16 | PetStore(baseURL: .production, fileID: fileID, line: line)
17 | }
18 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStore/Models.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct LoginQuery: Codable {
4 |
5 | public var username: String
6 | public var password: String
7 | }
8 |
9 | public struct UserModel: Codable {
10 |
11 | public var id: Int
12 | public var username: String
13 | public var firstName: String
14 | public var lastName: String
15 | public var email: String
16 | public var password: String
17 | public var phone: String
18 | public var userStatus: Int
19 | }
20 |
21 | public struct OrderModel: Codable {
22 |
23 | public var id: Int
24 | public var petId: Int
25 | public var quantity: Int
26 | public var shipDate: Date
27 | public var complete: Bool
28 | }
29 |
30 | public struct PetModel: Codable {
31 |
32 | public var id: Int
33 | public var name: String
34 | public var tag: String?
35 | }
36 |
37 | public enum PetStatus: String, Codable {
38 |
39 | case available
40 | case pending
41 | case sold
42 | }
43 |
44 | public struct Tokens: Codable {
45 |
46 | public var accessToken: String
47 | public var refreshToken: String
48 | public var expiryDate: Date
49 | }
50 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStore/PetStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | public struct PetStore {
5 |
6 | // MARK: - BaseURL
7 |
8 | var client: APIClient
9 |
10 | public init(baseURL: BaseURL, fileID: String, line: UInt) {
11 | client = APIClient(baseURL: baseURL.url)
12 | .fileIDLine(fileID: fileID, line: line)
13 | .bodyDecoder(PetStoreDecoder())
14 | .tokenRefresher { refreshToken, client, _ in
15 | guard let refreshToken else {
16 | throw Errors.noRefreshToken
17 | }
18 | let tokens: Tokens = try await client("auth", "token")
19 | .body(["refresh_token": refreshToken])
20 | .post()
21 | return (tokens.accessToken, tokens.refreshToken, tokens.expiryDate)
22 | }
23 | }
24 |
25 | public enum Errors: Error {
26 |
27 | case noRefreshToken
28 | }
29 | }
30 |
31 | // MARK: - "pet" path
32 |
33 | public extension PetStore {
34 |
35 | var pet: Pet {
36 | Pet(client: client("pet"))
37 | }
38 |
39 | struct Pet {
40 |
41 | var client: APIClient
42 |
43 | public func update(_ pet: PetModel) async throws -> PetModel {
44 | try await client.body(pet).put()
45 | }
46 |
47 | public func add(_ pet: PetModel) async throws -> PetModel {
48 | try await client.body(pet).post()
49 | }
50 |
51 | public func findBy(status: PetStatus) async throws -> [PetModel] {
52 | try await client("findByStatus").query("status", status).call()
53 | }
54 |
55 | public func findBy(tags: [String]) async throws -> [PetModel] {
56 | try await client("findByTags").query("tags", tags).call()
57 | }
58 |
59 | public func callAsFunction(_ id: String) -> PetByID {
60 | PetByID(client: client.path(id))
61 | }
62 |
63 | public struct PetByID {
64 |
65 | var client: APIClient
66 |
67 | public func get() async throws -> PetModel {
68 | try await client()
69 | }
70 |
71 | public func update(name: String?, status: PetStatus?) async throws -> PetModel {
72 | try await client
73 | .query(["name": name, "status": status])
74 | .post()
75 | }
76 |
77 | public func delete() async throws {
78 | try await client.delete()
79 | }
80 |
81 | public func uploadImage(_ image: Data, additionalMetadata: String? = nil) async throws {
82 | try await client("uploadImage")
83 | .query("additionalMetadata", additionalMetadata)
84 | .body(image)
85 | .headers(.contentType(.application(.octetStream)))
86 | .post()
87 | }
88 | }
89 | }
90 | }
91 |
92 | // MARK: - "store" path
93 |
94 | public extension PetStore {
95 |
96 | var store: Store {
97 | Store(client: client("store").auth(enabled: false))
98 | }
99 |
100 | struct Store {
101 |
102 | var client: APIClient
103 |
104 | public func inventory() async throws -> [String: Int] {
105 | try await client("inventory").auth(enabled: true).call()
106 | }
107 |
108 | public func order(_ model: OrderModel) async throws -> OrderModel {
109 | try await client("order").body(model).post()
110 | }
111 |
112 | public func order(_ id: String) -> Order {
113 | Order(client: client.path("order", id))
114 | }
115 |
116 | public struct Order {
117 |
118 | var client: APIClient
119 |
120 | public func find() async throws -> OrderModel {
121 | try await client()
122 | }
123 |
124 | public func delete() async throws -> OrderModel {
125 | try await client.delete()
126 | }
127 | }
128 | }
129 | }
130 |
131 | // MARK: "user" path
132 |
133 | public extension PetStore {
134 |
135 | var user: User {
136 | User(client: client("user").auth(enabled: false))
137 | }
138 |
139 | struct User {
140 |
141 | var client: APIClient
142 |
143 | public func create(_ model: UserModel) async throws -> UserModel {
144 | try await client.body(model).post()
145 | }
146 |
147 | public func createWith(list: [UserModel]) async throws {
148 | try await client("createWithList").body(list).post()
149 | }
150 |
151 | public func login(username: String, password: String) async throws -> String {
152 | try await client("login")
153 | .headers(.authorization(username: username, password: password))
154 | .get()
155 | }
156 |
157 | public func logout() async throws {
158 | try await client("logout").call()
159 | }
160 |
161 | public func callAsFunction(_ username: String) -> UserByUsername {
162 | UserByUsername(client: client.path(username))
163 | }
164 |
165 | public struct UserByUsername {
166 |
167 | var client: APIClient
168 |
169 | public func get() async throws -> UserModel {
170 | try await client()
171 | }
172 |
173 | public func update(_ model: UserModel) async throws -> UserModel {
174 | try await client.body(model).put()
175 | }
176 |
177 | public func delete() async throws -> UserModel {
178 | try await client.delete()
179 | }
180 | }
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStore/PetStoreBaseURL.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension PetStore {
4 |
5 | // MARK: - BaseURL
6 |
7 | enum BaseURL: String {
8 |
9 | case production = "https://petstore.com"
10 | case staging = "https://staging.petstore.com"
11 | case test = "http://localhost:8080"
12 |
13 | public var url: URL { URL(string: rawValue)! }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStoreWithMacros/CustomDecoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | struct PetStoreDecoder: DataDecoder {
5 |
6 | func decode(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
7 | let decoder = JSONDecoder()
8 | decoder.keyDecodingStrategy = .convertFromSnakeCase
9 | let response = try decoder.decode(PetStoreResponse.self, from: data)
10 | guard let result = response.response, response.success else {
11 | throw DecodingError.valueNotFound(T.self, DecodingError.Context(codingPath: [], debugDescription: "Server error"))
12 | }
13 | return result
14 | }
15 | }
16 |
17 | struct PetStoreResponse: Decodable {
18 |
19 | var success: Bool
20 | var error: String?
21 | var response: T?
22 | }
23 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStoreWithMacros/ExampleOfCalls.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | // MARK: - Usage example
5 |
6 | func exampleOfAPICalls() async throws {
7 | let api = PetStore(baseURL: .production)
8 | _ = try await api.pet("some-id").get()
9 | _ = try await api.pet.findByStatus(.available)
10 | _ = try await api.store.inventory()
11 | _ = try await api.user.logout()
12 | _ = try await api.user("name").delete()
13 | }
14 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStoreWithMacros/Models.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct LoginQuery: Codable {
4 |
5 | public var username: String
6 | public var password: String
7 | }
8 |
9 | public struct UserModel: Codable {
10 |
11 | public var id: Int
12 | public var username: String
13 | public var firstName: String
14 | public var lastName: String
15 | public var email: String
16 | public var password: String
17 | public var phone: String
18 | public var userStatus: Int
19 | }
20 |
21 | public struct OrderModel: Codable {
22 |
23 | public var id: Int
24 | public var petId: Int
25 | public var quantity: Int
26 | public var shipDate: Date
27 | public var complete: Bool
28 | }
29 |
30 | public struct PetModel: Codable {
31 |
32 | public var id: Int
33 | public var name: String
34 | public var tag: String?
35 | }
36 |
37 | public enum PetStatus: String, Codable {
38 |
39 | case available
40 | case pending
41 | case sold
42 | }
43 |
44 | public struct Tokens: Codable {
45 |
46 | public var accessToken: String
47 | public var refreshToken: String
48 | public var expiryDate: Date
49 | }
50 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStoreWithMacros/PetStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | @API
5 | public struct PetStore {
6 |
7 | // MARK: - BaseURL
8 |
9 | public init(baseURL: BaseURL) {
10 | client = APIClient(baseURL: baseURL.url)
11 | .bodyDecoder(PetStoreDecoder())
12 | .tokenRefresher { refreshToken, client, _ in
13 | guard let refreshToken else {
14 | throw Errors.noRefreshToken
15 | }
16 | let tokens: Tokens = try await client("auth", "token")
17 | .body(["refresh_token": refreshToken])
18 | .post()
19 | return (tokens.accessToken, tokens.refreshToken, tokens.expiryDate)
20 | }
21 | }
22 | }
23 |
24 | // MARK: - "pet" path
25 |
26 | public extension PetStore {
27 |
28 | @Path
29 | struct Pet {
30 |
31 | @PUT("/") public func update(_ body: PetModel) -> PetModel {}
32 | @POST("/") public func add(_ body: PetModel) -> PetModel {}
33 | @GET public func findByStatus(@Query _ status: PetStatus) -> [PetModel] {}
34 | @GET public func findByTags(@Query _ tags: [String]) -> [PetModel] {}
35 |
36 | @Path("{id}")
37 | public struct PetByID {
38 |
39 | @GET("/") public func get() -> PetModel {}
40 | @DELETE("/") public func delete() {}
41 | @POST("/") public func update(@Query name: String?, @Query status: PetStatus?) -> PetModel {}
42 | @POST public func uploadImage(_ body: Data, @Query additionalMetadata: String? = nil) {}
43 | }
44 | }
45 | }
46 |
47 | // MARK: - "store" path
48 |
49 | public extension PetStore {
50 |
51 | @Path
52 | struct Store {
53 |
54 | init(client: APIClient) {
55 | self.client = client.auth(enabled: false)
56 | }
57 |
58 | @GET public func inventory() -> [String: Int] { client.auth(enabled: true) }
59 | @POST public func order(_ body: OrderModel) -> OrderModel {}
60 |
61 | @Path("order", "{id}")
62 | public struct Order {
63 |
64 | @GET("/") public func get() -> OrderModel {}
65 | @DELETE("/") public func delete() {}
66 | }
67 | }
68 | }
69 |
70 | // MARK: "user" path
71 |
72 | extension PetStore {
73 |
74 | @Path
75 | struct User {
76 |
77 | init(client: APIClient) {
78 | self.client = client.auth(enabled: false)
79 | }
80 |
81 | @POST public func create(_ body: UserModel) -> UserModel {}
82 | @POST public func createWithList(_ body: [UserModel]) {}
83 | @GET public func login(username: String, password: String) -> String {
84 | client.headers(.authorization(username: username, password: password))
85 | }
86 |
87 | @GET public func logout() {}
88 |
89 | @Path("{username}")
90 | public struct UserByUsername {
91 |
92 | @GET("/") public func get() -> UserModel {}
93 | @DELETE("/") public func delete() {}
94 | @PUT("/") public func update(_ body: UserModel) -> UserModel {}
95 | }
96 | }
97 | }
98 |
99 | private enum Errors: Error {
100 | case noRefreshToken
101 | }
102 |
--------------------------------------------------------------------------------
/Example/Petstore/Sources/PetStoreWithMacros/PetStoreBaseURL.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension PetStore {
4 |
5 | // MARK: - BaseURL
6 |
7 | enum BaseURL: String {
8 |
9 | case production = "https://petstore.com"
10 | case staging = "https://staging.petstore.com"
11 | case test = "http://localhost:8080"
12 |
13 | public var url: URL { URL(string: rawValue)! }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Example/Search/README.md:
--------------------------------------------------------------------------------
1 | # Search
2 |
3 | This application demonstrates how to build a search feature in the swift-api-client:
4 |
5 | * Typing into the search field executes an API request to search for locations.
6 | * Tapping a location runs another API request to fetch the weather for that location, and when a response is received the data is displayed inline in that row.
7 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-http-types",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-http-types.git",
7 | "state" : {
8 | "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "swift-log",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/apple/swift-log.git",
16 | "state" : {
17 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
18 | "version" : "1.5.4"
19 | }
20 | },
21 | {
22 | "identity" : "swift-metrics",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-metrics.git",
25 | "state" : {
26 | "revision" : "5e63558d12e0267782019f5dadfcae83a7d06e09",
27 | "version" : "2.5.1"
28 | }
29 | },
30 | {
31 | "identity" : "swift-syntax",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/swiftlang/swift-syntax",
34 | "state" : {
35 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
36 | "version" : "509.1.1"
37 | }
38 | }
39 | ],
40 | "version" : 2
41 | }
42 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/xcuserdata/danil.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
9 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Example/Search/Search.xcodeproj/xcuserdata/danil.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Search.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 1
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "filename" : "swift-api-client-logo.png",
35 | "idiom" : "iphone",
36 | "scale" : "2x",
37 | "size" : "60x60"
38 | },
39 | {
40 | "filename" : "swift-api-client-logo 1.png",
41 | "idiom" : "iphone",
42 | "scale" : "3x",
43 | "size" : "60x60"
44 | },
45 | {
46 | "idiom" : "ipad",
47 | "scale" : "1x",
48 | "size" : "20x20"
49 | },
50 | {
51 | "idiom" : "ipad",
52 | "scale" : "2x",
53 | "size" : "20x20"
54 | },
55 | {
56 | "idiom" : "ipad",
57 | "scale" : "1x",
58 | "size" : "29x29"
59 | },
60 | {
61 | "idiom" : "ipad",
62 | "scale" : "2x",
63 | "size" : "29x29"
64 | },
65 | {
66 | "idiom" : "ipad",
67 | "scale" : "1x",
68 | "size" : "40x40"
69 | },
70 | {
71 | "idiom" : "ipad",
72 | "scale" : "2x",
73 | "size" : "40x40"
74 | },
75 | {
76 | "idiom" : "ipad",
77 | "scale" : "1x",
78 | "size" : "76x76"
79 | },
80 | {
81 | "filename" : "swift-api-client-logo 2.png",
82 | "idiom" : "ipad",
83 | "scale" : "2x",
84 | "size" : "76x76"
85 | },
86 | {
87 | "filename" : "swift-api-client-logo 3.png",
88 | "idiom" : "ipad",
89 | "scale" : "2x",
90 | "size" : "83.5x83.5"
91 | },
92 | {
93 | "filename" : "transparent.png",
94 | "idiom" : "ios-marketing",
95 | "scale" : "1x",
96 | "size" : "1024x1024"
97 | }
98 | ],
99 | "info" : {
100 | "author" : "xcode",
101 | "version" : 1
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 1.png
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 2.png
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo 3.png
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/swift-api-client-logo.png
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dankinsoid/swift-api-client/b098f18e412e00ca5dda0fe2d38b396f181fd958/Example/Search/Search/Assets.xcassets/AppIcon.appiconset/transparent.png
--------------------------------------------------------------------------------
/Example/Search/Search/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Search/Search/Models/Forecast.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | struct Forecast: Decodable, Equatable, Sendable {
5 |
6 | var daily: Daily
7 | var dailyUnits: DailyUnits
8 |
9 | enum CodingKeys: String, CodingKey {
10 |
11 | case daily
12 | case dailyUnits = "daily_units"
13 | }
14 |
15 | struct Daily: Decodable, Equatable, Sendable {
16 |
17 | var temperatureMax: [Double]
18 | var temperatureMin: [Double]
19 | var time: [Date]
20 |
21 | enum CodingKeys: String, CodingKey {
22 | case temperatureMax = "temperature_2m_max"
23 | case temperatureMin = "temperature_2m_min"
24 | case time
25 | }
26 | }
27 |
28 | struct DailyUnits: Decodable, Equatable, Sendable {
29 |
30 | var temperatureMax: String
31 | var temperatureMin: String
32 |
33 | enum CodingKeys: String, CodingKey, Codable {
34 | case temperatureMax = "temperature_2m_max"
35 | case temperatureMin = "temperature_2m_min"
36 | }
37 | }
38 | }
39 |
40 | // MARK: - Mocks
41 |
42 | extension Forecast: Mockable {
43 |
44 | static let mock = Forecast(daily: .mock, dailyUnits: .mock)
45 | }
46 |
47 | extension Forecast.Daily: Mockable {
48 |
49 | static let mock = Forecast.Daily(
50 | temperatureMax: [17, 20, 25],
51 | temperatureMin: [10, 12, 15],
52 | time: [0, 86400, 172_800].map(Date.init(timeIntervalSince1970:))
53 | )
54 | }
55 |
56 | extension Forecast.DailyUnits: Mockable {
57 |
58 | static let mock = Forecast.DailyUnits(temperatureMax: "°C", temperatureMin: "°C")
59 | }
60 |
--------------------------------------------------------------------------------
/Example/Search/Search/Models/GeocodingSearch.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | struct GeocodingSearch: Decodable, Equatable, Sendable {
5 |
6 | var results: [Result]?
7 |
8 | struct Result: Decodable, Equatable, Identifiable, Sendable {
9 |
10 | var country: String
11 | var latitude: Double
12 | var longitude: Double
13 | var id: Int
14 | var name: String
15 | var admin1: String?
16 | }
17 | }
18 |
19 | // MARK: - Mocks
20 |
21 | extension GeocodingSearch: Mockable {
22 |
23 | static let mock = GeocodingSearch(
24 | results: [
25 | GeocodingSearch.Result(
26 | country: "United States",
27 | latitude: 40.6782,
28 | longitude: -73.9442,
29 | id: 1,
30 | name: "Brooklyn",
31 | admin1: nil
32 | ),
33 | GeocodingSearch.Result(
34 | country: "United States",
35 | latitude: 34.0522,
36 | longitude: -118.2437,
37 | id: 2,
38 | name: "Los Angeles",
39 | admin1: nil
40 | ),
41 | GeocodingSearch.Result(
42 | country: "United States",
43 | latitude: 37.7749,
44 | longitude: -122.4194,
45 | id: 3,
46 | name: "San Francisco",
47 | admin1: nil
48 | ),
49 | ]
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/Example/Search/Search/SearchApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct SearchApp: App {
5 |
6 | var body: some Scene {
7 | WindowGroup {
8 | SearchView()
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Example/Search/Search/WeatherClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftAPIClient
3 |
4 | @API
5 | struct WeatherClient {
6 |
7 | init() {
8 | client = APIClient(baseURL: \.weatherURL.url)
9 | .path("v1")
10 | .bodyDecoder(jsonDecoder)
11 | .queryEncoder(.urlQuery(arrayEncodingStrategy: .commaSeparator))
12 | }
13 |
14 | /// **GET** https://api.open-meteo.com/v1/forecast
15 | @GET
16 | func forecast(
17 | @Query latitude: Double,
18 | @Query longitude: Double,
19 | @Query daily: [Forecast.DailyUnits.CodingKeys] = [.temperatureMin, .temperatureMax],
20 | @Query timezone: String = TimeZone.autoupdatingCurrent.identifier
21 | ) async throws -> Forecast {}
22 |
23 | /// **GET** https://geocoding-api.open-meteo.com/v1/search
24 | @GET
25 | func search(@Query name: String) async throws -> GeocodingSearch {
26 | client.configs(\.weatherURL, .geocoding)
27 | }
28 | }
29 |
30 | extension WeatherClient {
31 |
32 | enum BaseURL: String {
33 |
34 | case base = "https://api.open-meteo.com"
35 | case geocoding = "https://geocoding-api.open-meteo.com"
36 |
37 | var url: URL { URL(string: rawValue)! }
38 | }
39 | }
40 |
41 | extension APIClient.Configs {
42 |
43 | var weatherURL: WeatherClient.BaseURL {
44 | get { self[\.weatherURL] ?? .base }
45 | set { self[\.weatherURL] = newValue }
46 | }
47 | }
48 |
49 | // MARK: - Private helpers
50 |
51 | private let jsonDecoder: JSONDecoder = {
52 | let decoder = JSONDecoder()
53 | let formatter = DateFormatter()
54 | formatter.calendar = Calendar(identifier: .iso8601)
55 | formatter.dateFormat = "yyyy-MM-dd"
56 | formatter.timeZone = TimeZone(secondsFromGMT: 0)
57 | formatter.locale = Locale(identifier: "en_US_POSIX")
58 | decoder.dateDecodingStrategy = .formatted(formatter)
59 | return decoder
60 | }()
61 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 dankinsoid
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-http-types",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-http-types.git",
7 | "state" : {
8 | "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "swift-log",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/apple/swift-log.git",
16 | "state" : {
17 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
18 | "version" : "1.5.4"
19 | }
20 | },
21 | {
22 | "identity" : "swift-metrics",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-metrics.git",
25 | "state" : {
26 | "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1",
27 | "version" : "2.4.1"
28 | }
29 | },
30 | {
31 | "identity" : "swift-syntax",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/swiftlang/swift-syntax",
34 | "state" : {
35 | "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25",
36 | "version" : "600.0.0"
37 | }
38 | }
39 | ],
40 | "version" : 2
41 | }
42 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | var package = Package(
7 | name: "swift-api-client",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13),
11 | .watchOS(.v5),
12 | .tvOS(.v13),
13 | ],
14 | products: [
15 | .library(name: "SwiftAPIClient", targets: ["SwiftAPIClient"]),
16 | ],
17 | dependencies: [
18 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
19 | .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"),
20 | .package(url: "https://github.com/apple/swift-metrics.git", from: "2.0.0")
21 | ],
22 | targets: [
23 | .target(
24 | name: "SwiftAPIClient",
25 | dependencies: [
26 | .product(name: "Logging", package: "swift-log"),
27 | .product(name: "Metrics", package: "swift-metrics"),
28 | .product(name: "HTTPTypes", package: "swift-http-types"),
29 | .product(name: "HTTPTypesFoundation", package: "swift-http-types"),
30 | ]
31 | ),
32 | .testTarget(
33 | name: "SwiftAPIClientTests",
34 | dependencies: [.target(name: "SwiftAPIClient")]
35 | ),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/Package@swift-5.9.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import CompilerPluginSupport
5 | import PackageDescription
6 |
7 | var package = Package(
8 | name: "swift-api-client",
9 | platforms: [
10 | .macOS(.v10_15),
11 | .iOS(.v13),
12 | .watchOS(.v5),
13 | .tvOS(.v13),
14 | ],
15 | products: [
16 | .library(name: "SwiftAPIClient", targets: ["SwiftAPIClient"])
17 | ],
18 | dependencies: [
19 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
20 | .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"),
21 | .package(url: "https://github.com/apple/swift-metrics.git", from: "2.0.0"),
22 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease"),
23 | ],
24 | targets: [
25 | .target(
26 | name: "SwiftAPIClient",
27 | dependencies: [
28 | .target(name: "SwiftAPIClientMacros"),
29 | .product(name: "Logging", package: "swift-log"),
30 | .product(name: "Metrics", package: "swift-metrics"),
31 | .product(name: "HTTPTypes", package: "swift-http-types"),
32 | .product(name: "HTTPTypesFoundation", package: "swift-http-types"),
33 | ]
34 | ),
35 | .testTarget(
36 | name: "SwiftAPIClientTests",
37 | dependencies: [.target(name: "SwiftAPIClient")]
38 | ),
39 | .macro(
40 | name: "SwiftAPIClientMacros",
41 | dependencies: [
42 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
43 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
44 | ]
45 | ),
46 | .testTarget(
47 | name: "SwiftAPIClientMacrosTests",
48 | dependencies: [
49 | .target(name: "SwiftAPIClientMacros"),
50 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
51 | ]
52 | ),
53 | ]
54 | )
55 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/APIClientConfigs.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 | #if canImport(FoundationNetworking)
4 | import FoundationNetworking
5 | #endif
6 |
7 | public extension APIClient {
8 |
9 | /// A struct representing the configuration settings for a `APIClient`.
10 | struct Configs: @unchecked Sendable {
11 |
12 | private var values: [PartialKeyPath: Any] = [:]
13 |
14 | /// Initializes a new configuration set for `APIClient`.
15 | public init() {}
16 |
17 | /// Provides subscript access to configuration values based on their key paths.
18 | /// - Parameter keyPath: A `WritableKeyPath` to the configuration property.
19 | /// - Returns: The value of the configuration property if it exists, or `nil` otherwise.
20 | public subscript(_ keyPath: WritableKeyPath) -> T? {
21 | get { values[keyPath] as? T }
22 | set { values[keyPath] = newValue }
23 | }
24 |
25 | /// Provides subscript access to configuration values based on their key paths.
26 | /// - Parameter keyPath: A `WritableKeyPath` to the configuration property.
27 | /// - Returns: The value of the configuration property if it exists, or `nil` otherwise.
28 | public subscript(_ keyPath: WritableKeyPath) -> T? {
29 | get { values[keyPath] as? T }
30 | set { values[keyPath] = newValue }
31 | }
32 |
33 | /// Returns a new `Configs` instance with a modified configuration value.
34 | /// - Parameters:
35 | /// - keyPath: A `WritableKeyPath` to the configuration property to be modified.
36 | /// - value: The new value to set for the specified configuration property.
37 | /// - Returns: A new `Configs` instance with the updated configuration setting.
38 | public func with(_ keyPath: WritableKeyPath, _ value: T) -> APIClient.Configs {
39 | var result = self
40 | result[keyPath: keyPath] = value
41 | return result
42 | }
43 |
44 | /// Creates a new `APIClient` instance with the current configurations and the provided request.
45 | public func client(for request: HTTPRequestComponents) -> APIClient {
46 | APIClient(request: request).configs(\.self, self)
47 | }
48 | }
49 | }
50 |
51 | /// Provides a default value for a given configuration, which can differ between live, test, and preview environments.
52 | /// - Parameters:
53 | /// - live: An autoclosure returning the value for the live environment.
54 | /// - test: An optional autoclosure returning the value for the test environment.
55 | /// - preview: An optional autoclosure returning the value for the preview environment.
56 | /// - Returns: The appropriate value depending on the current environment.
57 | public func valueFor(
58 | live: @autoclosure () -> Value,
59 | test: @autoclosure () -> Value? = nil,
60 | preview: @autoclosure () -> Value? = nil
61 | ) -> Value {
62 | #if DEBUG
63 | if _isPreview {
64 | return preview() ?? test() ?? live()
65 | } else if _XCTIsTesting {
66 | return test() ?? preview() ?? live()
67 | } else {
68 | return live()
69 | }
70 | #else
71 | return live()
72 | #endif
73 | }
74 |
75 | public let _isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
76 |
77 | #if !os(WASI)
78 | public let _XCTIsTesting: Bool = ProcessInfo.processInfo.environment.keys.contains("XCTestBundlePath")
79 | || ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath")
80 | || ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier")
81 | || (ProcessInfo.processInfo.arguments.first
82 | .flatMap(URL.init(fileURLWithPath:))
83 | .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" }
84 | ?? false)
85 | || XCTCurrentTestCase != nil
86 | #else
87 | public let _XCTIsTesting = false
88 | #endif
89 |
90 | #if canImport(ObjectiveC)
91 | private var XCTCurrentTestCase: AnyObject? {
92 | guard
93 | let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"),
94 | let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol,
95 | let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))?
96 | .takeUnretainedValue(),
97 | let observers = shared.perform(Selector(("observers")))?
98 | .takeUnretainedValue() as? [AnyObject],
99 | let observer =
100 | observers
101 | .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }),
102 | let currentTestCase = observer.perform(Selector(("currentTestCase")))?
103 | .takeUnretainedValue()
104 | else { return nil }
105 | return currentTestCase
106 | }
107 | #else
108 | private var XCTCurrentTestCase: AnyObject? {
109 | nil
110 | }
111 | #endif
112 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Clients/HTTPDownloadClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 | #if canImport(FoundationNetworking)
4 | import FoundationNetworking
5 | #endif
6 |
7 | public struct HTTPDownloadClient {
8 |
9 | /// A closure that asynchronously retrieves data and an HTTP response for a given URL request and network configurations.
10 | public var download: (HTTPRequestComponents, APIClient.Configs) async throws -> (URL, HTTPResponse)
11 |
12 | /// Initializes a new `HTTPDownloadClient` with a custom data retrieval closure.
13 | /// - Parameter data: A closure that takes a URL request and `APIClient.Configs`, then asynchronously returns `Data` and an `HTTPURLResponse`.
14 | public init(
15 | _ download: @escaping (HTTPRequestComponents, APIClient.Configs) async throws -> (URL, HTTPResponse))
16 | {
17 | self.download = download
18 | }
19 | }
20 |
21 | public extension APIClient {
22 |
23 | /// Sets a custom HTTP download client for the network client.
24 | /// - Parameter client: The `HTTPDownloadClient` to be used for network requests.
25 | /// - Returns: An instance of `APIClient` configured with the specified HTTP client.
26 | func httpDownloadClient(_ client: HTTPDownloadClient) -> APIClient {
27 | configs(\.httpDownloadClient, client)
28 | }
29 |
30 | /// Observe the download progress of the request.
31 | func trackDownload(_ action: @escaping (_ progress: Double) -> Void) -> Self {
32 | trackDownload { totalBytesWritten, totalBytesExpectedToWrite in
33 | guard totalBytesExpectedToWrite > 0 else {
34 | action(1)
35 | return
36 | }
37 | action(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
38 | }
39 | }
40 |
41 | /// Observe the download progress of the request.
42 | func trackDownload(_ action: @escaping (_ totalBytesWritten: Int64, _ totalBytesExpectedToWrite: Int64) -> Void) -> Self {
43 | configs {
44 | let current = $0.downloadTracker
45 | $0.downloadTracker = { totalBytesWritten, totalBytesExpectedToWrite in
46 | current(totalBytesWritten, totalBytesExpectedToWrite)
47 | action(totalBytesWritten, totalBytesExpectedToWrite)
48 | }
49 | }
50 | }
51 | }
52 |
53 | public extension APIClient.Configs {
54 |
55 | /// The HTTP client used for network download operations.
56 | /// Gets the currently set `HTTPDownloadClient`, or the default `URLsession`-based client if not set.
57 | /// Sets a new `HTTPDownloadClient`.
58 | var httpDownloadClient: HTTPDownloadClient {
59 | get { self[\.httpDownloadClient] ?? .urlSession }
60 | set { self[\.httpDownloadClient] = newValue }
61 | }
62 |
63 | /// The closure that provides the data for the request.
64 | var downloadTracker: (_ totalBytesWritten: Int64, _ totalBytesExpectedToWrite: Int64) -> Void {
65 | get { self[\.downloadTracker] ?? { _, _ in } }
66 | set { self[\.downloadTracker] = newValue }
67 | }
68 | }
69 |
70 | public extension APIClientCaller where Result == AsyncThrowingValue, Response == URL {
71 |
72 | static var httpDownload: APIClientCaller {
73 | APIClientCaller>
74 | .httpDownloadResponse
75 | .dropHTTPResponse
76 | }
77 | }
78 |
79 | public extension APIClientCaller where Result == AsyncThrowingValue<(Value, HTTPResponse)>, Response == URL {
80 |
81 | static var httpDownloadResponse: APIClientCaller {
82 | HTTPClientCaller.http { request, configs in
83 | try await configs.httpDownloadClient.download(request, configs)
84 | } validate: { _, _, _ in
85 | } data: { _ in
86 | nil
87 | }
88 | .mapResponse(\.0)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Clients/HTTPPublisher.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Combine)
2 | import Combine
3 | import Foundation
4 | import HTTPTypes
5 |
6 | public extension APIClientCaller where Result == AnyPublisher, Response == Data {
7 |
8 | static var httpPublisher: APIClientCaller {
9 | APIClientCaller>
10 | .httpResponsePublisher
11 | .map { publisher in
12 | publisher.map(\.0)
13 | .eraseToAnyPublisher()
14 | }
15 | }
16 | }
17 |
18 | public extension APIClientCaller where Result == AnyPublisher<(Value, HTTPResponse), Error>, Response == Data {
19 |
20 | static var httpResponsePublisher: APIClientCaller {
21 | APIClientCaller>.httpResponse.map { value in
22 | Publishers.Run {
23 | try await value()
24 | }
25 | .eraseToAnyPublisher()
26 | }
27 | }
28 | }
29 | #endif
30 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Clients/HTTPUploadClient.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient.Configs {
7 |
8 | /// The closure that is called when the upload progress is updated.
9 | var uploadTracker: (_ totalBytesSent: Int64, _ totalBytesExpectedToSend: Int64) -> Void {
10 | get { self[\.uploadTracker] ?? { _, _ in } }
11 | set { self[\.uploadTracker] = newValue }
12 | }
13 | }
14 |
15 | public extension APIClient {
16 |
17 | /// Observe the upload progress of the request.
18 | func trackUpload(_ action: @escaping (_ progress: Double) -> Void) -> Self {
19 | trackUpload { totalBytesSent, totalBytesExpectedToSend in
20 | guard totalBytesExpectedToSend > 0 else {
21 | action(1)
22 | return
23 | }
24 | action(Double(totalBytesSent) / Double(totalBytesExpectedToSend))
25 | }
26 | }
27 |
28 | /// Observe the upload progress of the request.
29 | func trackUpload(_ action: @escaping (_ totalBytesSent: Int64, _ totalBytesExpectedToSend: Int64) -> Void) -> Self {
30 | configs {
31 | let current = $0.uploadTracker
32 | $0.uploadTracker = { totalBytesSent, totalBytesExpectedToSend in
33 | current(totalBytesSent, totalBytesExpectedToSend)
34 | action(totalBytesSent, totalBytesExpectedToSend)
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Clients/URLSession+Client.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 | #if canImport(FoundationNetworking)
4 | import FoundationNetworking
5 | #endif
6 |
7 | public extension HTTPClient {
8 |
9 | /// Creates an `HTTPClient` that uses a specified `URLSession` for network requests.
10 | /// - Returns: An `HTTPClient` that uses the given `URLSession` to fetch data.
11 | static var urlSession: Self {
12 | HTTPClient { request, configs in
13 | guard
14 | let url = request.url,
15 | let httpRequest = request.request,
16 | var urlRequest = URLRequest(httpRequest: httpRequest)
17 | else {
18 | throw Errors.custom("Invalid request")
19 | }
20 | urlRequest.url = url
21 | #if os(Linux)
22 | return try await asyncMethod { completion in
23 | configs.urlSession.uploadTask(with: urlRequest, body: request.body, completionHandler: completion)
24 | }
25 | #else
26 | if #available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) {
27 | let (data, response) = try await customErrors {
28 | try await configs.urlSession.data(for: urlRequest, body: request.body)
29 | }
30 | return (data, response.http)
31 | } else {
32 | return try await asyncMethod { completion in
33 | configs.urlSession.uploadTask(with: urlRequest, body: request.body, completionHandler: completion)
34 | }
35 | }
36 | #endif
37 | }
38 | }
39 | }
40 |
41 | public extension HTTPDownloadClient {
42 |
43 | static var urlSession: Self {
44 | HTTPDownloadClient { request, configs in
45 | guard var urlRequest = request.urlRequest else {
46 | throw Errors.custom("Invalid request")
47 | }
48 | urlRequest.timeoutInterval = configs.timeoutInterval
49 | return try await asyncMethod { completion in
50 | configs.urlSession.downloadTask(with: urlRequest, completionHandler: completion)
51 | }
52 | }
53 | }
54 | }
55 |
56 | private extension URLSession {
57 |
58 | #if os(Linux)
59 | #else
60 | @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
61 | func data(for request: URLRequest, body: RequestBody?) async throws -> (Data, URLResponse) {
62 | switch body {
63 | case let .file(url):
64 | return try await upload(for: request, fromFile: url)
65 | case let .data(body):
66 | return try await upload(for: request, from: body)
67 | case nil:
68 | return try await data(for: request)
69 | }
70 | }
71 | #endif
72 |
73 | func uploadTask(with request: URLRequest, body: RequestBody?, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
74 | switch body {
75 | case let .data(data):
76 | return uploadTask(with: request, from: data, completionHandler: completionHandler)
77 | case let .file(url):
78 | return uploadTask(with: request, fromFile: url, completionHandler: completionHandler)
79 | case nil:
80 | return dataTask(with: request, completionHandler: completionHandler)
81 | }
82 | }
83 | }
84 |
85 | private func asyncMethod(
86 | _ method: @escaping (
87 | @escaping @Sendable (T?, URLResponse?, Error?) -> Void
88 | ) -> S
89 | ) async throws -> (T, HTTPResponse) {
90 | try await customErrors {
91 | try await completionToThrowsAsync { continuation, handler in
92 | let task = method { t, response, error in
93 | if let t, let response {
94 | continuation.resume(returning: (t, response.http))
95 | } else {
96 | continuation.resume(throwing: error ?? Errors.unknown)
97 | }
98 | }
99 | handler.onCancel {
100 | task.cancel()
101 | }
102 | task.resume()
103 | }
104 | }
105 | }
106 |
107 | private func customErrors(_ operation: () async throws -> T) async throws -> T {
108 | do {
109 | return try await operation()
110 | } catch let error as URLError {
111 | switch error.code {
112 | case .cancelled:
113 | throw CancellationError()
114 | case .timedOut:
115 | throw TimeoutError()
116 | default:
117 | throw error
118 | }
119 | } catch let error as NSError {
120 | if error.code == NSURLErrorCancelled {
121 | throw CancellationError()
122 | }
123 | throw error
124 | } catch {
125 | throw error
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Extensions/Async++.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct AsyncSequenceOfElements: AsyncSequence {
4 |
5 | public typealias AsyncIterator = AsyncStream.AsyncIterator
6 | public typealias Element = S.Element
7 |
8 | let sequence: S
9 |
10 | public init(_ sequence: S) {
11 | self.sequence = sequence
12 | }
13 |
14 | public func makeAsyncIterator() -> AsyncStream.AsyncIterator {
15 | AsyncStream { cont in
16 | for element in sequence {
17 | cont.yield(element)
18 | }
19 | cont.finish()
20 | }
21 | .makeAsyncIterator()
22 | }
23 | }
24 |
25 | public extension Sequence {
26 |
27 | var async: AsyncSequenceOfElements {
28 | AsyncSequenceOfElements(self)
29 | }
30 | }
31 |
32 | func completionToThrowsAsync(
33 | _ body: @escaping (CheckedContinuation, CheckedContinuationCancellationHandler) -> Void
34 | ) async throws -> T {
35 | let handler = CheckedContinuationCancellationHandler()
36 | return try await withTaskCancellationHandler {
37 | try Task.checkCancellation()
38 | return try await withCheckedThrowingContinuation { continuation in
39 | body(continuation, handler)
40 | }
41 | } onCancel: {
42 | handler.handler.cancel()
43 | }
44 | }
45 |
46 | struct CheckedContinuationCancellationHandler {
47 |
48 | fileprivate let handler = TransactionCancelHandler()
49 |
50 | func onCancel(_ body: @escaping () -> Void) {
51 | handler.setOnCancel(body)
52 | }
53 | }
54 |
55 | /// There is currently no good way to asynchronously cancel an object that is initiated inside the `body` closure of `with*Continuation`.
56 | /// As a workaround we use `TransactionCancelHandler` which will take care of the race between instantiation of `Transaction`
57 | /// in the `body` closure and cancelation from the `onCancel` closure of `withTaskCancellationHandler`.
58 | @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
59 | private actor TransactionCancelHandler {
60 |
61 | private enum State {
62 |
63 | case initialised
64 | case cancelled
65 | }
66 |
67 | private var state: State = .initialised
68 | private var onCancel: () -> Void
69 |
70 | init() {
71 | onCancel = {}
72 | }
73 |
74 | private func _cancel() {
75 | switch state {
76 | case .cancelled:
77 | break
78 | case .initialised:
79 | state = .cancelled
80 | onCancel()
81 | onCancel = {}
82 | }
83 | }
84 |
85 | private func _setOnCancel(_ onCancel: @escaping () -> Void) {
86 | self.onCancel = onCancel
87 | }
88 |
89 | nonisolated func setOnCancel(_ onCancel: @escaping () -> Void) {
90 | Task {
91 | await self._setOnCancel(onCancel)
92 | }
93 | }
94 |
95 | nonisolated func cancel() {
96 | Task {
97 | await self._cancel()
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Extensions/String++.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension String {
4 |
5 | /// Returns all matches for a regular expression pattern
6 | func matches(for pattern: String) -> [[String?]] {
7 | guard let regex = try? NSRegularExpression(pattern: pattern) else {
8 | return []
9 | }
10 |
11 | let range = NSRange(location: 0, length: utf16.count)
12 | let matches = regex.matches(in: self, range: range)
13 |
14 | return matches.map { match in
15 | (0 ..< match.numberOfRanges).map { rangeIndex in
16 | let range = match.range(at: rangeIndex)
17 | guard range.location != NSNotFound else { return nil }
18 | return (self as NSString).substring(with: range)
19 | }
20 | }
21 | }
22 |
23 | /// Returns the first match for a regular expression pattern
24 | func firstMatch(for pattern: String) -> [String?]? {
25 | matches(for: pattern).first
26 | }
27 |
28 | /// Converts a string to a different case
29 | /// - Parameter transform: The transformation to apply to the string words
30 | /// - Returns: The transformed string
31 | /// Examples:
32 | /// ```swift
33 | /// "hello world".convertToCase { $0.map { $0.uppercased() }.joined(separator: " ") } // "HELLO WORLD"
34 | /// "hello world".convertToCase { $0.map(\.capitalized).joined(separator: " ") } // "Hello World"
35 | /// "helloWorld".convertToCase { $0.map { $0.lowercased() }.joined(separator: " ") } // "hello world"
36 | /// "hello-world".convertToCase { $0.map(\.capitalized).joined(separator: " ") } // "Hello World"
37 | /// "hello_world".convertToCase { $0.map(\.capitalized).joined(separator: " ") } // "Hello World"
38 | /// "helloWorld".convertToCase { $0.map(\.capitalized).joined(separator: "-") } // "Hello-World"
39 | /// ```
40 | func convertToCase(_ transform: ([String]) -> String) -> String {
41 | guard !isEmpty else { return transform([]) }
42 | var words = [String]()
43 | var currentWord = ""
44 | var lastCharacter: Character?
45 | for character in self {
46 | if character.isUppercase && lastCharacter?.isLowercase == true || !character.isWord && lastCharacter?.isWord == true {
47 | words.append(currentWord)
48 | if character.isWord {
49 | currentWord = String(character)
50 | }
51 | } else {
52 | if character.isWord || words.isEmpty {
53 | currentWord.append(character)
54 | }
55 | }
56 | lastCharacter = character
57 | }
58 | words.append(currentWord)
59 | return transform(words)
60 | }
61 |
62 | func convertToSnakeCase() -> String {
63 | var result = ""
64 | for (i, char) in enumerated() {
65 | if char.isUppercase {
66 | if i != 0 {
67 | result.append("_")
68 | }
69 | result.append(char.lowercased())
70 | } else {
71 | result.append(char)
72 | }
73 | }
74 | return result
75 | }
76 |
77 | var lowercasedFirstLetter: String {
78 | prefix(1).lowercased() + dropFirst()
79 | }
80 | }
81 |
82 | private extension Character {
83 |
84 | var isWord: Bool {
85 | isLetter || isNumber
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Extensions/URLResponse++.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 | import HTTPTypes
6 |
7 | extension URLResponse {
8 |
9 | var http: HTTPResponse {
10 | if let response = (self as? HTTPURLResponse)?.httpResponse {
11 | return response
12 | }
13 | return HTTPResponse(status: .accepted)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Imports.swift:
--------------------------------------------------------------------------------
1 | @_exported import HTTPTypes
2 | @_exported import HTTPTypesFoundation
3 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/AuthModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient.Configs {
7 |
8 | /// Indicates whether authentication is enabled for network requests.
9 | var isAuthEnabled: Bool {
10 | get { self[\.isAuthEnabled] ?? true }
11 | set { self[\.isAuthEnabled] = newValue }
12 | }
13 | }
14 |
15 | public extension APIClient {
16 |
17 | /// Configures the network client with a custom authentication modifier.
18 | /// - Parameter authModifier: An `AuthModifier` that modifies the request for authentication.
19 | /// - Returns: An instance of `APIClient` configured with the specified authentication modifier.
20 | func auth(_ authModifier: AuthModifier) -> APIClient {
21 | finalizeRequest { request, configs in
22 | if configs.isAuthEnabled {
23 | try authModifier.modifier(&request, configs)
24 | }
25 | }
26 | }
27 |
28 | /// Enables or disables authentication for the network client.
29 | /// - Parameter enabled: A Boolean value indicating whether to enable authentication.
30 | /// - Returns: An instance of `APIClient` with authentication set as specified.
31 | func auth(enabled: Bool) -> APIClient {
32 | configs(\.isAuthEnabled, enabled)
33 | }
34 | }
35 |
36 | /// A struct representing an authentication modifier for network requests.
37 | public struct AuthModifier {
38 |
39 | /// A closure that modifies a URL request for authentication.
40 | public let modifier: (inout HTTPRequestComponents, APIClient.Configs) throws -> Void
41 |
42 | /// Initializes a new `AuthModifier` with a custom modifier closure.
43 | /// - Parameter modifier: A closure that modifies a URL request and `APIClient.Configs` for authentication.
44 | public init(modifier: @escaping (inout HTTPRequestComponents, APIClient.Configs) throws -> Void) {
45 | self.modifier = modifier
46 | }
47 |
48 | /// Initializes a new `AuthModifier` with a custom modifier closure.
49 | /// - Parameter modifier: A closure that modifies a URL request for authentication.
50 | public init(modifier: @escaping (inout HTTPRequestComponents) throws -> Void) {
51 | self.init { request, _ in
52 | try modifier(&request)
53 | }
54 | }
55 |
56 | /// Creates an authentication modifier for adding a `Authorization` header.
57 | public static func header(_ value: String) -> AuthModifier {
58 | AuthModifier {
59 | $0.headers[.authorization] = value
60 | }
61 | }
62 | }
63 |
64 | public extension AuthModifier {
65 |
66 | /// Creates an authentication modifier for adding a basic authentication header.
67 | ///
68 | /// Basic authentication is a simple authentication scheme built into the HTTP protocol.
69 | /// The client sends HTTP requests with the Authorization header that contains the word Basic word followed by a space and a base64-encoded string username:password.
70 | /// For example, to authorize as demo / p@55w0rd the client would send
71 | static func basic(username: String, password: String) -> AuthModifier {
72 | AuthModifier {
73 | let field = HTTPField.authorization(username: username, password: password)
74 | $0.headers[field.name] = field.value
75 | }
76 | }
77 |
78 | /// Creates an authentication modifier for adding an API key.
79 | ///
80 | /// An API key is a token that a client provides when making API calls
81 | static func apiKey(_ key: String, field: String = "X-API-Key") -> AuthModifier {
82 | AuthModifier {
83 | guard let name = HTTPField.Name(field) else {
84 | throw Errors.custom("Invalid field name: \(field)")
85 | }
86 | $0.headers[name] = key
87 | }
88 | }
89 |
90 | /// Creates an authentication modifier for adding a bearer token.
91 | ///
92 | /// Bearer authentication (also called token authentication) is an HTTP authentication scheme that involves security tokens called bearer tokens.
93 | /// The name “Bearer authentication” can be understood as “give access to the bearer of this token.”
94 | /// The bearer token is a cryptic string, usually generated by the server in response to a login request.
95 | /// The client must send this token in the Authorization header when making requests to protected resources
96 | static func bearer(token: String) -> AuthModifier {
97 | AuthModifier {
98 | let field = HTTPField.authorization(bearerToken: token)
99 | $0.headers[field.name] = field.value
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/BackgroundModifiers.swift:
--------------------------------------------------------------------------------
1 | #if canImport(UIKit)
2 | import UIKit
3 |
4 | public extension APIClient {
5 |
6 | /// Execute the http request in the background task.
7 | ///
8 | /// To know more about background task, see [Apple Documentation](https://developer.apple.com/documentation/backgroundtasks)
9 | func backgroundTask() -> Self {
10 | httpClientMiddleware(BackgroundTaskMiddleware())
11 | }
12 |
13 | /// Retry the http request when enter foreground if it was failed in the background.
14 | func retryIfFailedInBackground() -> Self {
15 | httpClientMiddleware(RetryOnEnterForegroundMiddleware())
16 | }
17 | }
18 |
19 | private struct BackgroundTaskMiddleware: HTTPClientMiddleware {
20 |
21 | func execute(
22 | request: HTTPRequestComponents,
23 | configs: APIClient.Configs,
24 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
25 | ) async throws -> (T, HTTPResponse) {
26 | let id = await UIApplication.shared.beginBackgroundTask(
27 | withName: "Background Task for \(request.url?.absoluteString ?? "")"
28 | )
29 | guard id != .invalid else {
30 | return try await next(request, configs)
31 | }
32 | do {
33 | let result = try await next(request, configs)
34 | await UIApplication.shared.endBackgroundTask(id)
35 | return result
36 | } catch {
37 | await UIApplication.shared.endBackgroundTask(id)
38 | throw error
39 | }
40 | }
41 | }
42 |
43 | private struct RetryOnEnterForegroundMiddleware: HTTPClientMiddleware {
44 |
45 | func execute(
46 | request: HTTPRequestComponents,
47 | configs: APIClient.Configs,
48 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
49 | ) async throws -> (T, HTTPResponse) {
50 | func makeRequest() async throws -> (T, HTTPResponse) {
51 | let wasInBackground = WasInBackgroundService()
52 | var isInBackground = await UIApplication.shared.applicationState == .background
53 | if !isInBackground {
54 | await wasInBackground.start()
55 | }
56 | do {
57 | return try await next(request, configs)
58 | } catch {
59 | isInBackground = await UIApplication.shared.applicationState == .background
60 | if !isInBackground, await wasInBackground.wasInBackground {
61 | return try await makeRequest()
62 | }
63 | throw error
64 | }
65 | }
66 | return try await makeRequest()
67 | }
68 | }
69 |
70 | private final actor WasInBackgroundService {
71 |
72 | public private(set) var wasInBackground = false
73 | private var observer: NSObjectProtocol?
74 |
75 | public func start() async {
76 | observer = await NotificationCenter.default.addObserver(
77 | forName: UIApplication.didEnterBackgroundNotification,
78 | object: nil,
79 | queue: nil
80 | ) { [weak self] _ in
81 | guard let self else { return }
82 | Task {
83 | await self.setTrue()
84 | }
85 | }
86 | }
87 |
88 | public func reset() {
89 | wasInBackground = false
90 | }
91 |
92 | private func setTrue() {
93 | wasInBackground = true
94 | }
95 | }
96 | #endif
97 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/CodersModifiers.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient {
7 |
8 | /// Sets a request body encoder.
9 | ///
10 | /// - Parameter encoder: A request body encoder.
11 | ///
12 | /// - Returns: A new network client with the request body encoder.
13 | func bodyEncoder(_ encoder: some ContentEncoder) -> APIClient {
14 | configs(\.bodyEncoder, encoder)
15 | }
16 |
17 | /// Sets a response body decoder.
18 | ///
19 | /// - Parameter decoder: A response body decoder.
20 | ///
21 | /// - Returns: A new network client with the response body decoder.
22 | func bodyDecoder(_ decoder: some DataDecoder) -> APIClient {
23 | configs(\.bodyDecoder, decoder)
24 | }
25 |
26 | /// Sets a request query encoder.
27 | ///
28 | /// - Parameter encoder: A request query encoder.
29 | ///
30 | /// - Returns: A new network client with the request query encoder.
31 | func queryEncoder(_ encoder: some QueryEncoder) -> APIClient {
32 | configs(\.queryEncoder, encoder)
33 | }
34 |
35 | /// Sets a request headers encoder.
36 | ///
37 | /// - Parameter encoder: A request headers encoder.
38 | ///
39 | /// - Returns: A new network client with the request headers encoder.
40 | func headersEncoder(_ encoder: some HeadersEncoder) -> APIClient {
41 | configs(\.headersEncoder, encoder)
42 | }
43 | }
44 |
45 | public extension APIClient.Configs {
46 |
47 | /// A request body encoder.
48 | var bodyEncoder: any ContentEncoder {
49 | get { self[\.bodyEncoder] ?? JSONEncoder() }
50 | set { self[\.bodyEncoder] = newValue }
51 | }
52 |
53 | /// A response body decoder.
54 | var bodyDecoder: any DataDecoder {
55 | get { self[\.bodyDecoder] ?? JSONDecoder() }
56 | set { self[\.bodyDecoder] = newValue }
57 | }
58 |
59 | /// A request query encoder.
60 | var queryEncoder: any QueryEncoder {
61 | get { self[\.queryEncoder] ?? URLQueryEncoder() }
62 | set { self[\.queryEncoder] = newValue }
63 | }
64 |
65 | /// A request headers encoder.
66 | var headersEncoder: any HeadersEncoder {
67 | get { self[\.headersEncoder] ?? HTTPHeadersEncoder() }
68 | set { self[\.headersEncoder] = newValue }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/ErrorDecodeModifiers.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient.Configs {
4 |
5 | /// The error decoder used to decode errors from network responses.
6 | /// Gets the currently set `ErrorDecoder`, or `.none` if not set.
7 | /// Sets a new `ErrorDecoder`.
8 | var errorDecoder: ErrorDecoder {
9 | get { self[\.errorDecoder] ?? .none }
10 | set { self[\.errorDecoder] = newValue }
11 | }
12 | }
13 |
14 | public extension APIClient {
15 |
16 | /// Use this modifier when you want the client to throw an error that is decoded from the response.
17 | /// - Parameter decoder: The `ErrorDecoder` to be used for decoding errors.
18 | /// - Returns: An instance of `APIClient` configured with the specified error decoder.
19 | ///
20 | /// Example usage:
21 | /// ```swift
22 | /// client.errorDecoder(.decodable(ErrorResponse.self))
23 | /// ```
24 | func errorDecoder(_ decoder: ErrorDecoder) -> APIClient {
25 | configs(\.errorDecoder, decoder)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/ErrorHandler.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient {
4 |
5 | /// Sets the error handler.
6 | func errorHandler(_ handler: @escaping (Error, APIClient.Configs, APIErrorContext) throws -> Void) -> APIClient {
7 | configs { configs in
8 | let current = configs.errorHandler
9 | configs.errorHandler = { failure, configs, context in
10 | do {
11 | try current(failure, configs, context)
12 | } catch {
13 | try handler(error, configs, context)
14 | throw error
15 | }
16 | }
17 | }
18 | }
19 |
20 | /// Sets the error handler to throw an `APIClientError` with detailed information.
21 | func detailedError(includeBody: APIClientError.IncludeBodyPolicy = .auto) -> APIClient {
22 | errorHandler { error, configs, context in
23 | if error is APIClientError {
24 | throw error
25 | } else {
26 | throw APIClientError(error: error, configs: configs, context: context, includeBody: includeBody)
27 | }
28 | }
29 | }
30 | }
31 |
32 | public extension APIClient.Configs {
33 |
34 | var errorHandler: (Error, APIClient.Configs, APIErrorContext) throws -> Void {
35 | get { self[\.errorHandler] ?? { _, _, _ in } }
36 | set { self[\.errorHandler] = newValue }
37 | }
38 | }
39 |
40 | public struct APIErrorContext: Equatable {
41 |
42 | public var request: HTTPRequestComponents?
43 | public var response: Data?
44 | public var status: HTTPResponse.Status?
45 | public var fileID: String
46 | public var line: UInt
47 |
48 | public init(
49 | request: HTTPRequestComponents? = nil,
50 | response: Data? = nil,
51 | status: HTTPResponse.Status? = nil,
52 | fileID: String,
53 | line: UInt
54 | ) {
55 | self.request = request
56 | self.response = response
57 | self.status = status
58 | self.fileID = fileID
59 | self.line = line
60 | }
61 |
62 | public init(
63 | request: HTTPRequestComponents? = nil,
64 | response: Data? = nil,
65 | status: HTTPResponse.Status? = nil,
66 | fileIDLine: FileIDLine
67 | ) {
68 | self.init(
69 | request: request,
70 | response: response,
71 | status: status,
72 | fileID: fileIDLine.fileID,
73 | line: fileIDLine.line
74 | )
75 | }
76 | }
77 |
78 | public struct APIClientError: LocalizedError, CustomStringConvertible {
79 |
80 | public var error: Error
81 | public var configs: APIClient.Configs
82 | public var context: APIErrorContext
83 | public var includeBody: IncludeBodyPolicy
84 |
85 | public init(error: Error, configs: APIClient.Configs, context: APIErrorContext, includeBody: IncludeBodyPolicy) {
86 | self.error = error
87 | self.configs = configs
88 | self.context = context
89 | self.includeBody = includeBody
90 | }
91 |
92 | public var errorDescription: String? {
93 | description
94 | }
95 |
96 | public var description: String {
97 | var components = [error.humanReadable]
98 |
99 | if let request = context.request {
100 | let urlString = request.urlComponents.url?.absoluteString ?? request.urlComponents.path
101 | components.append("Request: \(request.method) \(urlString)")
102 | }
103 | if let response = context.response {
104 | switch includeBody {
105 | case .never:
106 | break
107 | case .always:
108 | if let utf8 = String(data: response, encoding: .utf8) {
109 | components.append("Response: \(utf8)")
110 | }
111 | case let .auto(sizeLimit):
112 | if response.count < sizeLimit, let utf8 = String(data: response, encoding: .utf8) {
113 | components.append("Response: \(utf8)")
114 | }
115 | }
116 | }
117 | components.append("File: \(context.fileID) Line: \(context.line)")
118 | return components.joined(separator: " - ")
119 | }
120 |
121 | public enum IncludeBodyPolicy {
122 |
123 | case never
124 | case always
125 | case auto(sizeLimit: Int)
126 |
127 | public static var auto: IncludeBodyPolicy { .auto(sizeLimit: 1024) }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/FileIDLine.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient {
4 |
5 | /// Set the fileID and line for logging. When setted `#line` and `#fileID` parameters in the `call` methods are ignored.
6 | func fileIDLine(fileID: String, line: UInt) -> APIClient {
7 | configs(\.fileIDLine, FileIDLine(fileID: fileID, line: line))
8 | }
9 | }
10 |
11 | public extension APIClient.Configs {
12 |
13 | /// The fileID and line of the call site.
14 | var fileIDLine: FileIDLine? {
15 | get { self[\.fileIDLine] }
16 | set { self[\.fileIDLine] = newValue }
17 | }
18 | }
19 |
20 | public struct FileIDLine: Hashable {
21 |
22 | public let fileID: String
23 | public let line: UInt
24 |
25 | public init(fileID: String = #fileID, line: UInt = #line) {
26 | self.fileID = fileID
27 | self.line = line
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/HTTPClientMiddleware.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol HTTPClientMiddleware {
4 |
5 | func execute(
6 | request: HTTPRequestComponents,
7 | configs: APIClient.Configs,
8 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
9 | ) async throws -> (T, HTTPResponse)
10 | }
11 |
12 | public extension APIClient {
13 |
14 | /// Add a modifier to the HTTP client.
15 | func httpClientMiddleware(_ middleware: some HTTPClientMiddleware) -> APIClient {
16 | configs {
17 | $0.httpClientArrayMiddleware.middlewares.append(middleware)
18 | }
19 | }
20 | }
21 |
22 | public extension APIClient.Configs {
23 |
24 | var httpClientMiddleware: HTTPClientMiddleware {
25 | httpClientArrayMiddleware
26 | }
27 | }
28 |
29 | private extension APIClient.Configs {
30 |
31 | var httpClientArrayMiddleware: HTTPClientArrayMiddleware {
32 | get { self[\.httpClientArrayMiddleware] ?? HTTPClientArrayMiddleware() }
33 | set { self[\.httpClientArrayMiddleware] = newValue }
34 | }
35 | }
36 |
37 | private struct HTTPClientArrayMiddleware: HTTPClientMiddleware {
38 |
39 | var middlewares: [HTTPClientMiddleware] = []
40 |
41 | func execute(
42 | request: HTTPRequestComponents,
43 | configs: APIClient.Configs,
44 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
45 | ) async throws -> (T, HTTPResponse) {
46 | var next = next
47 | for middleware in middlewares {
48 | next = { [next] in try await middleware.execute(request: $0, configs: $1, next: next) }
49 | }
50 | return try await next(request, configs)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/HTTPResponseValidator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | /// A struct for validating HTTP responses.
7 | public struct HTTPResponseValidator {
8 |
9 | /// A closure that validates an `HTTPURLResponse` and associated `Data` and the current network client configs.
10 | /// - Throws: An error if validation fails.
11 | public var validate: (HTTPResponse, Data, APIClient.Configs) throws -> Void
12 |
13 | /// Initializes a new `HTTPResponseValidator` with a custom validation closure.
14 | /// - Parameter validate: A closure that takes an `HTTPURLResponse` and `Data` and the current network client configs, and throws an error if validation fails.
15 | public init(_ validate: @escaping (HTTPResponse, Data, APIClient.Configs) throws -> Void) {
16 | self.validate = validate
17 | }
18 | }
19 |
20 | public extension HTTPResponseValidator {
21 |
22 | /// A default validator that checks if the status code is within the given range.
23 | /// Defaults to the range 200...299.
24 | static var statusCode: Self {
25 | statusCode(.successful)
26 | }
27 |
28 | /// Creates a validator to check if the status code is within a specific range.
29 | /// - Parameter codes: The range of acceptable status codes.
30 | /// - Returns: An `HTTPResponseValidator` that validates based on the specified status code range.
31 | static func statusCode(_ codes: ClosedRange) -> Self {
32 | HTTPResponseValidator { response, _, configs in
33 | guard codes.contains(response.status.code) || configs.ignoreStatusCodeValidator else {
34 | throw InvalidStatusCode(response.status)
35 | }
36 | }
37 | }
38 |
39 | /// Creates a validator to check if the status is of a specific kind.
40 | /// - Parameter kind: The kind of acceptable status.
41 | /// - Returns: An `HTTPResponseValidator` that validates based on the specified status kind.
42 | static func statusCode(_ kind: HTTPResponse.Status.Kind) -> Self {
43 | HTTPResponseValidator { response, _, configs in
44 | guard response.status.kind == kind || configs.ignoreStatusCodeValidator else {
45 | throw InvalidStatusCode(response.status)
46 | }
47 | }
48 | }
49 | }
50 |
51 | public struct InvalidStatusCode: Error, LocalizedError, CustomStringConvertible {
52 |
53 | /// The invalid status code.
54 | public let status: HTTPResponse.Status
55 |
56 | public init(_ status: HTTPResponse.Status) {
57 | self.status = status
58 | }
59 |
60 | public var errorDescription: String? { description }
61 | public var description: String {
62 | "Invalid status code: \(status.code) \(status.reasonPhrase)"
63 | }
64 | }
65 |
66 | public extension HTTPResponseValidator {
67 |
68 | /// A validator that always considers the response as successful.
69 | static var alwaysSuccess: Self {
70 | HTTPResponseValidator { _, _, _ in }
71 | }
72 | }
73 |
74 | public extension APIClient.Configs {
75 |
76 | /// The HTTP response validator used for validating network responses.
77 | /// Gets the currently set `HTTPResponseValidator`, or `.alwaysSuccess` if not set.
78 | /// Sets a new `HTTPResponseValidator`.
79 | var httpResponseValidator: HTTPResponseValidator {
80 | get { self[\.httpResponseValidator] ?? .alwaysSuccess }
81 | set { self[\.httpResponseValidator] = newValue }
82 | }
83 | }
84 |
85 | public extension APIClient {
86 |
87 | /// Sets a custom HTTP response validator for the network client.
88 | /// - Parameter validator: The `HTTPResponseValidator` to be used for validating responses.
89 | /// - Returns: An instance of `APIClient` configured with the specified HTTP response validator.
90 | func httpResponseValidator(_ validator: HTTPResponseValidator) -> APIClient {
91 | configs {
92 | let oldValidator = $0.httpResponseValidator.validate
93 | $0.httpResponseValidator = HTTPResponseValidator {
94 | try oldValidator($0, $1, $2)
95 | try validator.validate($0, $1, $2)
96 | }
97 | }
98 | }
99 | }
100 |
101 | public extension APIClient.Configs {
102 |
103 | var ignoreStatusCodeValidator: Bool {
104 | get { self[\.ignoreStatusCodeValidator] ?? false }
105 | set { self[\.ignoreStatusCodeValidator] = newValue }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/LoggingModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 |
4 | public extension APIClient {
5 |
6 | /// Sets the custom logger.
7 | /// - Parameter logger: The `Logger` to be used for logging messages.
8 | /// - Returns: An instance of `APIClient` configured with the specified logging level.
9 | func logger(_ logger: Logger) -> APIClient {
10 | configs(\.logger, logger)
11 | }
12 |
13 | /// Sets the logging level for the logger.
14 | /// - Parameter level: The `Logger.Level` to be used for logging messages.
15 | /// - Returns: An instance of `APIClient` configured with the specified logging level.
16 | func log(level: Logger.Level) -> APIClient {
17 | configs(\.logLevel, level)
18 | }
19 |
20 | /// Sets the logging level for error logs.
21 | /// - Parameter level: The `Logger.Level` to be used for error logs. When `nil`, `logLevel` is used.
22 | /// - Returns: An instance of `APIClient` configured with the specified error logging level.
23 | func errorLog(level: Logger.Level?) -> APIClient {
24 | configs(\.errorLogLevel, level)
25 | }
26 |
27 | /// Sets the components to be logged.
28 | func loggingComponents(_ components: LoggingComponents) -> APIClient {
29 | configs(\.loggingComponents, components)
30 | }
31 |
32 | /// Sets the components to be logged for error logs.
33 | /// - Parameter components: The `LoggingComponents` to be used for error logs. When `nil`, `loggingComponents` is used.
34 | /// - Returns: An instance of `APIClient` configured with the specified error logging components.
35 | func errorLoggingComponents(_ components: LoggingComponents?) -> APIClient {
36 | configs(\.errorLogginComponents, components)
37 | }
38 | }
39 |
40 | public extension APIClient.Configs {
41 |
42 | /// The logger used for network operations.
43 | /// - Returns: A `Logger` instance configured with the appropriate log level.
44 | var logger: Logger {
45 | get { self[\.logger] ?? defaultLogger }
46 | set { self[\.logger] = newValue }
47 | }
48 |
49 | /// The log level to be used for logs.
50 | /// - Returns: A `Logger.Level` used in logs.
51 | var logLevel: Logger.Level {
52 | get { self[\.logLevel] ?? .info }
53 | set { self[\.logLevel] = newValue }
54 | }
55 |
56 | /// The log level to be used for error logs.
57 | /// - Returns: A `Logger.Level` used in error logs.
58 | var errorLogLevel: Logger.Level? {
59 | get { self[\.errorLogLevel] ?? nil }
60 | set { self[\.errorLogLevel] = newValue }
61 | }
62 |
63 | /// The components to be logged.
64 | /// - Returns: A `LoggingComponents` instance configured with the appropriate components.
65 | var loggingComponents: LoggingComponents {
66 | get { self[\.loggingComponents] ?? .standart }
67 | set { self[\.loggingComponents] = newValue }
68 | }
69 |
70 | /// The components to be logged for error logs.
71 | /// - Returns: A `LoggingComponents` instance configured with the appropriate components.
72 | var errorLogginComponents: LoggingComponents? {
73 | get { self[\.errorLogginComponents] ?? nil }
74 | set { self[\.errorLogginComponents] = newValue }
75 | }
76 | }
77 |
78 | extension APIClient.Configs {
79 |
80 | var _errorLogLevel: Logger.Level {
81 | errorLogLevel ?? logLevel
82 | }
83 |
84 | var _errorLoggingComponents: LoggingComponents {
85 | errorLogginComponents ?? loggingComponents
86 | }
87 | }
88 |
89 | private let defaultLogger = Logger(label: "swift-api-client")
90 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/MetricsModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient {
4 |
5 | /// Whether to report metrics.
6 | /// - Parameter reportMetrics: A boolean value indicating whether to report metrics.
7 | /// - Returns: An instance of `APIClient` configured with the specified metrics reporting setting.
8 | func reportMetrics(_ reportMetrics: Bool) -> APIClient {
9 | configs(\.reportMetrics, reportMetrics)
10 | }
11 | }
12 |
13 | public extension APIClient.Configs {
14 |
15 | /// Whether to report metrics.
16 | var reportMetrics: Bool {
17 | get { self[\.reportMetrics] ?? true }
18 | set { self[\.reportMetrics] = newValue }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/MockResponses.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient {
7 |
8 | /// Use this method to specify a mock response for a specific request.
9 | /// The usage of the mock is configured by the `usingMocks(policy:)` modifier.
10 | /// The default policy is `.ignore` for live environments and `.ifSpecified` for test and preview environments.
11 | /// You can set a value of either the response type or `Data`.
12 | ///
13 | /// - Parameter value: The mock value to be returned for requests expecting a response of type `T`.
14 | /// - Returns: An instance of `APIClient` configured with the specified mock.
15 | ///
16 | func mock(_ value: T) -> APIClient {
17 | configs {
18 | $0.mocks[ObjectIdentifier(T.self)] = value
19 | }
20 | }
21 |
22 | /// Configures the client to use a specific policy for handling mocks.
23 | /// - Parameter policy: The `UsingMockPolicy` indicating how mocks should be used.
24 | /// - Returns: An instance of `APIClient` configured with the specified mock policy.
25 | func usingMocks(policy: UsingMocksPolicy) -> APIClient {
26 | configs(\.usingMocksPolicy, policy)
27 | }
28 | }
29 |
30 | public extension APIClient.Configs {
31 |
32 | /// The policy for using mock responses in the client.
33 | var usingMocksPolicy: UsingMocksPolicy {
34 | get { self[\.usingMocksPolicy] ?? valueFor(live: .ignore, test: .ifSpecified, preview: .ifSpecified) }
35 | set { self[\.usingMocksPolicy] = newValue }
36 | }
37 |
38 | /// Retrieves a mock response for the specified type if it exists.
39 | /// - Parameter type: The type for which to retrieve a mock response.
40 | /// - Returns: The mock response of the specified type, if it exists.
41 | func mock(for type: T.Type) -> T? {
42 | (mocks[ObjectIdentifier(type)] as? T) ?? (type as? Mockable.Type)?.mock as? T
43 | }
44 |
45 | /// Returns a new configuration set with a specified mock response.
46 | /// - Parameters:
47 | /// - mock: The mock response to add to the configuration.
48 | /// - Returns: A new `APIClient.Configs` instance with the specified mock.
49 | func with(mock: T) -> Self {
50 | var new = self
51 | new.mocks[ObjectIdentifier(T.self)] = mock
52 | return new
53 | }
54 |
55 | /// Retrieves a mock response if needed based on the current mock policy.
56 | /// - Parameter type: The type for which to retrieve a mock response.
57 | /// - Throws: An error if a required mock is missing.
58 | /// - Returns: The mock response of the specified type, if available and required by policy.
59 | func getMockIfNeeded(for type: T.Type) throws -> T? {
60 | guard usingMocksPolicy != .ignore else { return nil }
61 | if let mock = mock(for: T.self) {
62 | return mock
63 | }
64 | if usingMocksPolicy == .require {
65 | throw Errors.mockIsMissed(type)
66 | }
67 | return nil
68 | }
69 |
70 | /// Retrieves a mock response if needed for a specific serializer, based on the current mock policy.
71 | /// - Parameters:
72 | /// - type: The type for which to retrieve a mock response.
73 | /// - serializer: A `Serializer` to process the mock response.
74 | /// - Throws: An error if a required mock is missing.
75 | /// - Returns: The mock response of the specified type, if available and required by policy.
76 | func getMockIfNeeded(for type: T.Type, serializer: Serializer) throws -> T? {
77 | guard usingMocksPolicy != .ignore else { return nil }
78 | if !(T.self is Response.Type), let mock = mock(for: T.self) {
79 | return mock
80 | }
81 | if let mockData = mock(for: Response.self) {
82 | return try serializer.serialize(mockData, self)
83 | }
84 | if usingMocksPolicy == .require {
85 | throw Errors.mockIsMissed(type)
86 | }
87 | return nil
88 | }
89 | }
90 |
91 | /// An enumeration defining policies for using mock responses.
92 | public enum UsingMocksPolicy: Hashable {
93 |
94 | /// Ignores mock responses.
95 | case ignore
96 | /// Uses mock responses if they exist.
97 | case ifSpecified
98 | /// Requires the use of mock responses, throws error if not available./
99 | case require
100 | }
101 |
102 | private extension APIClient.Configs {
103 |
104 | var mocks: [ObjectIdentifier: Any] {
105 | get { self[\.mocks] ?? [:] }
106 | set { self[\.mocks] = newValue }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/RateLimitModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 |
4 | public extension APIClient {
5 |
6 | /// When the rate limit is exceeded, the request will be repeated after the specified interval and all requests with the same identifier will be suspended.
7 | /// - Parameters:
8 | /// - id: The identifier to use for rate limiting. Default to the base URL of the request.
9 | /// - interval: The interval to wait before repeating the request. Default to 30 seconds.
10 | /// - statusCodes: The set of status codes that indicate a rate limit exceeded. Default to `[429]`.
11 | /// - maxRepeatCount: The maximum number of times the request can be repeated. Default to 3.
12 | func waitIfRateLimitExceeded(
13 | id: @escaping (HTTPRequestComponents) -> ID,
14 | interval: TimeInterval = 30,
15 | statusCodes: Set = [.tooManyRequests],
16 | maxRepeatCount: Int = 3
17 | ) -> Self {
18 | httpClientMiddleware(RateLimitMiddleware(id: id, interval: interval, statusCodes: statusCodes, maxCount: maxRepeatCount))
19 | }
20 |
21 | /// When the rate limit is exceeded, the request will be repeated after the specified interval and all requests with the same base URL will be suspended.
22 | /// - Parameters:
23 | /// - interval: The interval to wait before repeating the request. Default to 30 seconds.
24 | /// - statusCodes: The set of status codes that indicate a rate limit exceeded. Default to `[429]`.
25 | /// - maxRepeatCount: The maximum number of times the request can be repeated. Default to 3.
26 | func waitIfRateLimitExceeded(
27 | interval: TimeInterval = 30,
28 | statusCodes: Set = [.tooManyRequests],
29 | maxRepeatCount: Int = 3
30 | ) -> Self {
31 | waitIfRateLimitExceeded(
32 | id: { $0.url?.host ?? UUID().uuidString },
33 | interval: interval,
34 | statusCodes: statusCodes,
35 | maxRepeatCount: maxRepeatCount
36 | )
37 | }
38 | }
39 |
40 | private struct RateLimitMiddleware: HTTPClientMiddleware {
41 |
42 | let id: (HTTPRequestComponents) -> ID
43 | let interval: TimeInterval
44 | let statusCodes: Set
45 | let maxCount: Int
46 |
47 | func execute(
48 | request: HTTPRequestComponents,
49 | configs: APIClient.Configs,
50 | next: @escaping (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
51 | ) async throws -> (T, HTTPResponse) {
52 | let id = id(request)
53 | await waitForSynchronizedAccess(id: id, of: Void.self)
54 | var res = try await next(request, configs)
55 | var count: UInt = 0
56 | while
57 | statusCodes.contains(res.1.status),
58 | count < maxCount
59 | {
60 | count += 1
61 | try await withThrowingSynchronizedAccess(id: id) {
62 | try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
63 | }
64 | res = try await next(request, configs)
65 | }
66 | return res
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/RedirectModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient.Configs {
4 |
5 | /// The redirect behaviour for the client. Default is `.follow`.
6 | var redirectBehaviour: RedirectBehaviour {
7 | get { self[\.redirectBehaviour] ?? .follow }
8 | set { self[\.redirectBehaviour] = newValue }
9 | }
10 | }
11 |
12 | public extension APIClient {
13 |
14 | /// Sets the redirect behaviour for the client. Default is `.follow`.
15 | /// - Note: Redirect behaviour is only applicable to clients that use `URLSession`.
16 | func redirect(behaviour: RedirectBehaviour) -> Self {
17 | configs(\.redirectBehaviour, behaviour)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/RequestCompression.swift:
--------------------------------------------------------------------------------
1 | #if canImport(zlib)
2 | import Foundation
3 | import zlib
4 | #if canImport(FoundationNetworking)
5 | import FoundationNetworking
6 | #endif
7 |
8 | public extension APIClient {
9 |
10 | /// Compresses outgoing URL request bodies using the `deflate` `Content-Encoding` and adds the
11 | /// appropriate header.
12 | ///
13 | /// - Note: Most requests to most APIs are small and so would only be slowed down by applying this adapter. Measure the
14 | /// size of your request bodies and the performance impact of using this adapter before use. Using this adapter
15 | /// with already compressed data, such as images, will, at best, have no effect. Additionally, body compression
16 | /// is a synchronous operation. Finally, not all servers support request
17 | /// compression, so test with all of your server configurations before deploying.
18 | ///
19 | /// - Parameters:
20 | /// - duplicateHeaderBehavior: `DuplicateHeaderBehavior` to use. `.skip` by default.
21 | /// - shouldCompressBodyData: Closure which determines whether the outgoing body data should be compressed. `true` by default.
22 | @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
23 | func compressRequest(
24 | duplicateHeaderBehavior: DuplicateHeaderBehavior = .skip,
25 | shouldCompressBodyData: @escaping (_ bodyData: Data) -> Bool = { _ in true }
26 | ) -> APIClient {
27 | httpClientMiddleware(
28 | CompressionMiddleware(
29 | duplicateHeaderBehavior: duplicateHeaderBehavior,
30 | shouldCompressBodyData: shouldCompressBodyData
31 | )
32 | )
33 | }
34 | }
35 |
36 | private struct CompressionMiddleware: HTTPClientMiddleware {
37 |
38 | let duplicateHeaderBehavior: DuplicateHeaderBehavior
39 | let shouldCompressBodyData: (_ bodyData: Data) -> Bool
40 |
41 | func execute(
42 | request: HTTPRequestComponents,
43 | configs: APIClient.Configs,
44 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
45 | ) async throws -> (T, HTTPResponse) {
46 | // No need to compress unless we have body data. No support for compressing streams.
47 | guard let body = request.body else {
48 | return try await next(request, configs)
49 | }
50 |
51 | guard let data = body.data, shouldCompressBodyData(data) else {
52 | return try await next(request, configs)
53 | }
54 |
55 | if request.headers[.contentEncoding] != nil {
56 | switch duplicateHeaderBehavior {
57 | case .error:
58 | throw Errors.duplicateHeader(.contentEncoding)
59 | case .replace:
60 | // Header will be replaced once the body data is compressed.
61 | break
62 | case .skip:
63 | return try await next(request, configs)
64 | }
65 | }
66 |
67 | var urlRequest = request
68 | urlRequest.headers[.contentEncoding] = "deflate"
69 | urlRequest.body = try .data(deflate(data))
70 | return try await next(urlRequest, configs)
71 | }
72 | }
73 |
74 | private func deflate(_ data: Data) throws -> Data {
75 | var output = Data([0x78, 0x5E]) // Header
76 | try output.append((data as NSData).compressed(using: .zlib) as Data)
77 | var checksum = adler32Checksum(of: data).bigEndian
78 | output.append(Data(bytes: &checksum, count: MemoryLayout.size))
79 |
80 | return output
81 | }
82 |
83 | private func adler32Checksum(of data: Data) -> UInt32 {
84 | data.withUnsafeBytes { buffer in
85 | UInt32(adler32(1, buffer.baseAddress, UInt32(buffer.count)))
86 | }
87 | }
88 |
89 | /// Type that determines the action taken when the URL request already has a `Content-Encoding` header.
90 | public enum DuplicateHeaderBehavior {
91 |
92 | /// Throws a `DuplicateHeaderError`. The default.
93 | case error
94 | /// Replaces the existing header value with `deflate`.
95 | case replace
96 | /// Silently skips compression when the header exists.
97 | case skip
98 | }
99 | #endif
100 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/RequestValidator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | /// A struct for validating URL request instances.
7 | public struct RequestValidator {
8 |
9 | /// A closure that validates an URL request.
10 | /// - Throws: An error if validation fails.
11 | public var validate: (_ request: HTTPRequestComponents, APIClient.Configs) throws -> Void
12 |
13 | /// Initializes a new `RequestValidator` with a custom validation closure.
14 | /// - Parameter validate: A closure that takes an URL request and throws an error if validation fails.
15 | public init(validate: @escaping (_ request: HTTPRequestComponents, APIClient.Configs) throws -> Void) {
16 | self.validate = validate
17 | }
18 | }
19 |
20 | public extension RequestValidator {
21 |
22 | /// A default validator that always considers the request as successful, regardless of its content.
23 | static var alwaysSuccess: Self {
24 | RequestValidator { _, _ in }
25 | }
26 | }
27 |
28 | public extension APIClient {
29 |
30 | /// Sets a custom request validator for the network client.
31 | /// - Parameter validator: The `RequestValidator` to be used for validating URL request instances.
32 | /// - Returns: An instance of `APIClient` configured with the specified request validator.
33 | func requestValidator(_ validator: RequestValidator) -> APIClient {
34 | httpClientMiddleware(RequestValidatorMiddleware(validator: validator))
35 | }
36 | }
37 |
38 | private struct RequestValidatorMiddleware: HTTPClientMiddleware {
39 |
40 | let validator: RequestValidator
41 |
42 | func execute(
43 | request: HTTPRequestComponents,
44 | configs: APIClient.Configs,
45 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
46 | ) async throws -> (T, HTTPResponse) {
47 | try validator.validate(request, configs)
48 | return try await next(request, configs)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/ResponseWrapModifires.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient {
7 |
8 | /// Configures the network client to use a custom decoder as specified by the provided mapping function.
9 | /// - Parameter mapper: A closure that takes an existing `DataDecoder` and returns a modified `DataDecoder`.
10 | /// - Returns: An instance of `APIClient` configured with the specified decoder.
11 | func mapDecoder(_ mapper: @escaping (any DataDecoder) -> any DataDecoder) -> APIClient {
12 | configs {
13 | $0.bodyDecoder = mapper($0.bodyDecoder)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/RetryModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient {
7 |
8 | /// Retries the request if it fails.
9 | func retry(limit: Int?) -> APIClient {
10 | httpClientMiddleware(RetryMiddleware(limit: limit))
11 | }
12 | }
13 |
14 | private struct RetryMiddleware: HTTPClientMiddleware {
15 |
16 | let limit: Int?
17 |
18 | func execute(
19 | request: HTTPRequestComponents,
20 | configs: APIClient.Configs,
21 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
22 | ) async throws -> (T, HTTPResponse) {
23 | var count = 0
24 | func needRetry() -> Bool {
25 | if let limit {
26 | return count <= limit
27 | }
28 | return true
29 | }
30 |
31 | func retry() async throws -> (T, HTTPResponse) {
32 | count += 1
33 | return try await next(request, configs)
34 | }
35 |
36 | let response: HTTPResponse
37 | let data: T
38 | do {
39 | (data, response) = try await retry()
40 | } catch {
41 | if needRetry() {
42 | return try await retry()
43 | }
44 | throw error
45 | }
46 | if response.status.kind.isError, needRetry() {
47 | return try await retry()
48 | }
49 | return (data, response)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/ThrottleModifier.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension APIClient {
4 |
5 | /// Throttles equal requests to the server.
6 | /// - Parameters:
7 | /// - interval: The interval to throttle requests by.
8 | ///
9 | /// If the interval is nil, then `configs.throttleInterval` is used. This allows setting the default interval for all requests via `.configs(\.throttleInterval, value)`.
10 | func throttle(interval: TimeInterval? = nil) -> APIClient {
11 | throttle(interval: interval) { $0 }
12 | }
13 |
14 | /// Throttles requests to the server by request id.
15 | /// - Parameters:
16 | /// - interval: The interval for throttling requests.
17 | /// - id: A closure to uniquely identify the request.
18 | ///
19 | /// If the interval is nil, then `configs.throttleInterval` is used. This allows setting the default interval for all requests via `.configs(\.throttleInterval, value)`.
20 | func throttle(interval: TimeInterval? = nil, id: @escaping (HTTPRequestComponents) -> ID) -> APIClient {
21 | configs {
22 | if let interval {
23 | $0.throttleInterval = interval
24 | }
25 | }
26 | .httpClientMiddleware(
27 | RequestsThrottleMiddleware(cache: .shared, id: id)
28 | )
29 | }
30 | }
31 |
32 | public extension APIClient.Configs {
33 |
34 | /// The interval to throttle requests by. Default is 10 seconds.
35 | var throttleInterval: TimeInterval {
36 | get { self[\.throttleInterval] ?? 10 }
37 | set { self[\.throttleInterval] = newValue }
38 | }
39 | }
40 |
41 | private final actor RequestsThrottlerCache {
42 |
43 | static let shared = RequestsThrottlerCache()
44 | private var responses: [AnyHashable: (Any, HTTPResponse)] = [:]
45 |
46 | func response(for request: AnyHashable) -> (T, HTTPResponse)? {
47 | responses[request] as? (T, HTTPResponse)
48 | }
49 |
50 | func setResponse(response: (T, HTTPResponse), for request: AnyHashable) {
51 | responses[request] = response
52 | }
53 |
54 | func removeResponse(for request: AnyHashable) {
55 | responses[request] = nil
56 | }
57 | }
58 |
59 | private struct RequestsThrottleMiddleware: HTTPClientMiddleware {
60 |
61 | let cache: RequestsThrottlerCache
62 | let id: (HTTPRequestComponents) -> ID
63 |
64 | func execute(
65 | request: HTTPRequestComponents,
66 | configs: APIClient.Configs,
67 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
68 | ) async throws -> (T, HTTPResponse) {
69 | let interval = configs.throttleInterval
70 | guard interval > 0 else {
71 | return try await next(request, configs)
72 | }
73 | let requestID = id(request)
74 | if let response: (T, HTTPResponse) = await cache.response(for: requestID) {
75 | return response
76 | }
77 | let (value, httpResponse) = try await next(request, configs)
78 | await cache.setResponse(response: (value, httpResponse), for: requestID)
79 | Task {
80 | try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
81 | await cache.removeResponse(for: requestID)
82 | }
83 | return (value, httpResponse)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/URLSessionModifiers.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(FoundationNetworking)
3 | import FoundationNetworking
4 | #endif
5 |
6 | public extension APIClient.Configs {
7 |
8 | /// Underlying URLSession of the client.
9 | var urlSession: URLSession {
10 | let session = URLSession.apiClient
11 | SessionDelegateProxy.shared.configs = self
12 | return session
13 | }
14 | }
15 |
16 | public extension APIClient.Configs {
17 |
18 | /// The delegate for the URLSession of the client.
19 | var urlSessionDelegate: URLSessionDelegate? {
20 | get { self[\.urlSessionDelegate] ?? nil }
21 | set { self[\.urlSessionDelegate] = newValue }
22 | }
23 | }
24 |
25 | public extension APIClient {
26 |
27 | /// Sets the URLSession delegate for the client.
28 | func urlSession(delegate: URLSessionDelegate?) -> Self {
29 | configs(\.urlSessionDelegate, delegate)
30 | }
31 | }
32 |
33 | private extension URLSession {
34 |
35 | static var apiClient: URLSession = {
36 | var configs = URLSessionConfiguration.default
37 | configs.headers = .default
38 | return URLSession(
39 | configuration: configs,
40 | delegate: SessionDelegateProxy.shared,
41 | delegateQueue: nil
42 | )
43 | }()
44 | }
45 |
46 | private extension URLSessionConfiguration {
47 |
48 | /// Returns `httpAdditionalHeaders` as `HTTPFields`.
49 | var headers: HTTPFields {
50 | get {
51 | (httpAdditionalHeaders as? [String: String]).map {
52 | HTTPFields(
53 | $0.compactMap { key, value in HTTPField.Name(key).map { HTTPField(name: $0, value: value) } }
54 | )
55 | } ?? [:]
56 | }
57 | set {
58 | httpAdditionalHeaders = Dictionary(
59 | newValue.map { ($0.name.rawName, $0.value) }
60 | ) { [$0, $1].joined(separator: ", ") }
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Modifiers/WaitForConnectionModifier.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SystemConfiguration)
2 | import Foundation
3 |
4 | public extension APIClient {
5 |
6 | /// Wait for a connection to be equal to the given connection or available if not specified.
7 | /// - Parameters:
8 | /// - connection: The connection to wait for. If `nil` it will wait for any connection.
9 | /// - timeout: The time to wait for the connection. If `nil` it will wait indefinitely.
10 | /// - hostname: The hostname to monitor. If `nil` it will monitor the default route.
11 | func waitForConnection(
12 | _ connection: Reachability.Connection? = nil,
13 | hostname: String? = nil,
14 | fileID: String = #file,
15 | line: UInt = #line
16 | ) -> APIClient {
17 | httpClientMiddleware(
18 | WaitForConnectionMiddleware(
19 | connection: connection,
20 | fileID: fileID,
21 | line: line
22 | ) {
23 | if let cached = await Reachabilities.shared.reachabilities[hostname] {
24 | return cached
25 | } else if let hostname {
26 | let reachability = try Reachability(hostname: hostname)
27 | await Reachabilities.shared.set(reachability, for: hostname)
28 | return reachability
29 | } else {
30 | let reachability = try Reachability()
31 | await Reachabilities.shared.set(reachability, for: nil)
32 | return reachability
33 | }
34 | }
35 | )
36 | }
37 |
38 | /// Wait for a connection to be equal to the given connection or available if not specified.
39 | /// - Parameters:
40 | /// - connection: The connection to wait for. If `nil` it will wait for any connection.
41 | /// - timeout: The time to wait for the connection. If `nil` it will wait indefinitely.
42 | /// - reachability: The reachability instance to monitor.
43 | func waitForConnection(
44 | _ connection: Reachability.Connection? = nil,
45 | reachability: Reachability,
46 | fileID: String = #file,
47 | line: UInt = #line
48 | ) -> APIClient {
49 | httpClientMiddleware(
50 | WaitForConnectionMiddleware(
51 | connection: connection,
52 | fileID: fileID,
53 | line: line
54 | ) {
55 | reachability
56 | }
57 | )
58 | }
59 | }
60 |
61 | private struct WaitForConnectionMiddleware: HTTPClientMiddleware {
62 |
63 | let connection: Reachability.Connection?
64 | let fileID: String
65 | let line: UInt
66 | let createReachibility: () async throws -> Reachability
67 |
68 | func execute(
69 | request: HTTPRequestComponents,
70 | configs: APIClient.Configs,
71 | next: @escaping @Sendable (HTTPRequestComponents, APIClient.Configs) async throws -> (T, HTTPResponse)
72 | ) async throws -> (T, HTTPResponse) {
73 | let reachability = try await createReachibility()
74 |
75 | func execute() async throws -> (T, HTTPResponse) {
76 | try await wait(reachability: reachability)
77 | do {
78 | return try await next(request, configs)
79 | } catch {
80 | try Task.checkCancellation()
81 | if (error as? URLError)?.networkUnavailableReason != nil || reachability.connection == .unavailable && connection == nil {
82 | return try await execute()
83 | }
84 | throw error
85 | }
86 | }
87 |
88 | return try await execute()
89 | }
90 |
91 | private func wait(reachability: Reachability) async throws {
92 | try await reachability.wait(
93 | for: { $0 == connection || connection == nil && $0 != .unavailable },
94 | timeout: nil,
95 | fileID: fileID,
96 | line: line
97 | )
98 | }
99 | }
100 |
101 | private final actor Reachabilities {
102 |
103 | static let shared = Reachabilities()
104 |
105 | var reachabilities: [String?: Reachability] = [:]
106 |
107 | func set(_ reachability: Reachability, for hostname: String?) {
108 | reachabilities[hostname] = reachability
109 | }
110 | }
111 | #endif
112 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/RequestBuilder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 |
4 | public protocol RequestBuilder {
5 |
6 | associatedtype Request = HTTPRequestComponents
7 | associatedtype Configs = APIClient.Configs
8 |
9 | func modifyRequest(
10 | _ modifier: @escaping (inout Request, Configs) throws -> Void
11 | ) -> Self
12 | func request() throws -> Request
13 | }
14 |
15 | public extension RequestBuilder {
16 |
17 | /// Modifies the URL request using the provided closure.
18 | /// - location: When the request should be modified.
19 | /// - modifier: A closure that takes `inout HTTPRequestComponents` and modifies the URL request.
20 | /// - Returns: An instance of `APIClient` with a modified URL request.
21 | func modifyRequest(
22 | _ modifier: @escaping (inout Request) throws -> Void
23 | ) -> Self {
24 | modifyRequest { req, _ in
25 | try modifier(&req)
26 | }
27 | }
28 | }
29 |
30 | public extension RequestBuilder where Request == HTTPRequestComponents {
31 |
32 | /// The request `URL`
33 | var url: URL? {
34 | try? request().url
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/AsyncValue.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public typealias AsyncThrowingValue = () async throws -> Res
4 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/ContentSerializer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A generic struct for serializing content into data and associated content type.
4 | public struct ContentSerializer {
5 |
6 | /// A closure that serializes a value of type `T` into `Data`.
7 | public var serialize: (_ value: T, _ configs: APIClient.Configs) throws -> Data
8 | /// A closure that return a content type of serialized data.
9 | public var contentType: (_ configs: APIClient.Configs) -> ContentType
10 |
11 | /// Initializes a new `ContentSerializer` with a custom serialization closure.
12 | /// - Parameters:
13 | /// - serialize: A closure that takes a value and network configurations and returns serialized data.
14 | /// - contentType: A closure that return a content type of serialized data.
15 | public init(
16 | _ serialize: @escaping (T, APIClient.Configs) throws -> Data,
17 | contentType: @escaping (_ configs: APIClient.Configs) -> ContentType
18 | ) {
19 | self.serialize = serialize
20 | self.contentType = contentType
21 | }
22 | }
23 |
24 | public extension ContentSerializer where T: Encodable {
25 |
26 | /// A static property to get a `ContentSerializer` for `Encodable` types.
27 | static var encodable: Self {
28 | .encodable(T.self)
29 | }
30 |
31 | /// Creates a `ContentSerializer` for a specific `Encodable` type.
32 | /// - Returns: A `ContentSerializer` that uses the body encoder from the network client configurations to serialize the value.
33 | static func encodable(_: T.Type) -> Self {
34 | ContentSerializer { value, configs in
35 | try configs.bodyEncoder.encode(value)
36 | } contentType: { configs in
37 | configs.bodyEncoder.contentType
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/Errors.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum Errors: LocalizedError, CustomStringConvertible {
4 |
5 | case unknown
6 | case notConnected
7 | case mockIsMissed(Any.Type)
8 | case unimplemented
9 | case responseTypeIsNotHTTP
10 | case duplicateHeader(HTTPField.Name)
11 | case invalidFileURL(URL)
12 | case invalidUTF8Data
13 | case custom(String)
14 |
15 | var errorDescription: String? {
16 | description
17 | }
18 |
19 | var description: String {
20 | switch self {
21 | case .unknown:
22 | return "Unknown error"
23 | case .notConnected:
24 | return "Not connected to the internet"
25 | case let .mockIsMissed(type):
26 | return "Mock for \(type) is missed"
27 | case .unimplemented:
28 | return "Unimplemented"
29 | case .responseTypeIsNotHTTP:
30 | return "Response type is not HTTP"
31 | case let .duplicateHeader(key):
32 | return "Duplicate header \(key)"
33 | case let .invalidFileURL(url):
34 | return "Invalid file URL \(url)"
35 | case .invalidUTF8Data:
36 | return "Invalid UTF-8 data"
37 | case let .custom(message):
38 | return message
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/Mockable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol Mockable {
4 |
5 | static var mock: Self { get }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/RedirectBehaviour.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 | #if canImport(FoundationNetworking)
4 | import FoundationNetworking
5 | #endif
6 |
7 | /// Defines a redirect behaviour.
8 | public enum RedirectBehaviour {
9 |
10 | /// Follow the redirect as defined in the response.
11 | case follow
12 |
13 | /// Do not follow the redirect defined in the response.
14 | case doNotFollow
15 |
16 | /// Modify the redirect request defined in the response.
17 | case modify((HTTPRequestComponents, HTTPResponse) -> HTTPRequestComponents?)
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Types/Serializer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A generic struct for serializing network responses into specified types.
4 | @dynamicMemberLookup
5 | public struct Serializer {
6 |
7 | /// A closure that serializes a network response into a specified type.
8 | public var serialize: (_ response: Response, _ configs: APIClient.Configs) throws -> T
9 |
10 | /// Initializes a new `Serializer` with a custom serialization closure.
11 | /// - Parameter serialize: A closure that takes a response and network configurations, then returns a serialized object of type `T`.
12 | public init(_ serialize: @escaping (Response, APIClient.Configs) throws -> T) {
13 | self.serialize = serialize
14 | }
15 |
16 | /// Maps the serialized object to another type.
17 | public func map(_ transform: @escaping (T, APIClient.Configs) throws -> U) -> Serializer {
18 | Serializer { response, configs in
19 | try transform(serialize(response, configs), configs)
20 | }
21 | }
22 |
23 | public subscript(dynamicMember keyPath: KeyPath) -> Serializer {
24 | map { value, _ in value[keyPath: keyPath] }
25 | }
26 | }
27 |
28 | public extension Serializer where Response == T {
29 |
30 | static var identity: Self {
31 | Self { response, _ in response }
32 | }
33 | }
34 |
35 | public extension Serializer where Response == Data, T == Data {
36 |
37 | /// A static property to get a `Serializer` that directly returns the response `Data`.
38 | static var data: Self {
39 | Self { data, _ in data }
40 | }
41 | }
42 |
43 | public extension Serializer where Response == Data, T == String {
44 |
45 | /// A static property to get a `Serializer` that directly returns the response `Data`.
46 | static var string: Self {
47 | Self { data, _ in
48 | guard let string = String(data: data, encoding: .utf8) else {
49 | throw Errors.custom("Invalid UTF8 data")
50 | }
51 | return string
52 | }
53 | }
54 |
55 | /// A static property to get a `Serializer` that directly returns the response `Data`.
56 | static func string(_ encoding: String.Encoding) -> Self {
57 | Self { data, _ in
58 | guard let string = String(data: data, encoding: encoding) else {
59 | throw Errors.custom("Invalid \(encoding) data")
60 | }
61 | return string
62 | }
63 | }
64 | }
65 |
66 | public extension Serializer where T == Void {
67 |
68 | /// A static property to get a `Serializer` that discards the response data.
69 | static var void: Self {
70 | Self { _, _ in }
71 | }
72 | }
73 |
74 | public extension Serializer where Response == Data, T: Decodable {
75 |
76 | /// Creates a `Serializer` for a specific `Decodable` type.
77 | /// - Returns: A `Serializer` that decodes the response data into the specified `Decodable` type.
78 | static func decodable(_: T.Type) -> Self {
79 | Self { data, configs in
80 | try configs.bodyDecoder.decode(T.self, from: data)
81 | }
82 | }
83 |
84 | /// A static property to get a `Serializer` for the generic `Decodable` type.
85 | static var decodable: Self {
86 | .decodable(T.self)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/AnyAsyncSequence.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A type-erasing wrapper for any `AsyncSequence`.
4 | public struct AnyAsyncSequence: AsyncSequence {
5 |
6 | private var _makeAsyncIterator: () -> AsyncIterator
7 |
8 | /// Initializes a new instance with the provided iterator-making closure.
9 | /// - Parameter makeAsyncIterator: A closure that returns an `AsyncIterator`.
10 | public init(makeAsyncIterator: @escaping () -> AsyncIterator) {
11 | _makeAsyncIterator = makeAsyncIterator
12 | }
13 |
14 | /// Initializes a new instance by wrapping an existing `AsyncSequence`.
15 | /// - Parameter sequence: An `AsyncSequence` whose elements to iterate over.
16 | public init(_ sequence: S) where S.Element == Element {
17 | self.init {
18 | var iterator = sequence.makeAsyncIterator()
19 | return AsyncIterator {
20 | try await iterator.next()
21 | }
22 | }
23 | }
24 |
25 | /// Creates an iterator for the underlying async sequence.
26 | public func makeAsyncIterator() -> AsyncIterator {
27 | _makeAsyncIterator()
28 | }
29 |
30 | /// The iterator for `AnyAsyncSequence`.
31 | public struct AsyncIterator: AsyncIteratorProtocol {
32 |
33 | private var _next: () async throws -> Element?
34 |
35 | public init(next: @escaping () async throws -> Element?) {
36 | _next = next
37 | }
38 |
39 | public mutating func next() async throws -> Element? {
40 | try await _next()
41 | }
42 | }
43 | }
44 |
45 | public extension AsyncSequence {
46 |
47 | /// Erases the type of this sequence and returns an `AnyAsyncSequence` instance.
48 | /// - Returns: An instance of `AnyAsyncSequence` wrapping the original sequence.
49 | func eraseToAnyAsyncSequence() -> AnyAsyncSequence {
50 | AnyAsyncSequence(self)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/AnyEncodable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct AnyEncodable: Encodable {
4 |
5 | var value: Encodable
6 |
7 | init(_ value: Encodable) {
8 | self.value = value
9 | }
10 |
11 | func encode(to encoder: Encoder) throws {
12 | try value.encode(to: encoder)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/ContentEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A protocol defining an encoder that serializes data.
4 | public protocol DataEncoder {
5 |
6 | func encode(_ value: T) throws -> Data
7 | }
8 |
9 | /// A protocol defining an encoder that serializes data into a specific content type.
10 | public protocol ContentEncoder: DataEncoder {
11 |
12 | /// The `ContentType` associated with the serialized data.
13 | /// This property specifies the MIME type that the encoder outputs.
14 | var contentType: ContentType { get }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/DataDecoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A protocol defining a decoder for deserializing `Data` into decodable types.
4 | public protocol DataDecoder {
5 |
6 | func decode(_ type: T.Type, from data: Data) throws -> T
7 | }
8 |
9 | extension JSONDecoder: DataDecoder {}
10 | extension PropertyListDecoder: DataDecoder {}
11 |
12 | public extension DataDecoder where Self == JSONDecoder {
13 |
14 | /// A static property to get a `JSONDecoder` instance with default settings.
15 | static var json: Self { .json() }
16 |
17 | /// Creates and returns a `JSONDecoder` with customizable decoding strategies.
18 | /// - Parameters:
19 | /// - dateDecodingStrategy: Strategy for decoding date values. Default is `.deferredToDate`.
20 | /// - dataDecodingStrategy: Strategy for decoding data values. Default is `.deferredToData`.
21 | /// - nonConformingFloatDecodingStrategy: Strategy for decoding non-conforming float values. Default is `.throw`.
22 | /// - keyDecodingStrategy: Strategy for decoding keys. Default is `.useDefaultKeys`.
23 | /// - Returns: An instance of `JSONDecoder` configured with the specified strategies.
24 | static func json(
25 | dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate,
26 | dataDecodingStrategy: JSONDecoder.DataDecodingStrategy = .deferredToData,
27 | nonConformingFloatDecodingStrategy: JSONDecoder.NonConformingFloatDecodingStrategy = .throw,
28 | keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys
29 | ) -> Self {
30 | let decoder = JSONDecoder()
31 | decoder.dataDecodingStrategy = dataDecodingStrategy
32 | decoder.dateDecodingStrategy = dateDecodingStrategy
33 | decoder.nonConformingFloatDecodingStrategy = nonConformingFloatDecodingStrategy
34 | decoder.keyDecodingStrategy = keyDecodingStrategy
35 | return decoder
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/ErrorDecoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A type that represents an error decoding from a response body.
4 | public struct ErrorDecoder {
5 |
6 | public var decodeError: (Data, APIClient.Configs) -> Error?
7 |
8 | public init(decodeError: @escaping (Data, APIClient.Configs) -> Error?) {
9 | self.decodeError = decodeError
10 | }
11 | }
12 |
13 | public extension ErrorDecoder {
14 |
15 | /// None custom error decoding.
16 | static var none: Self {
17 | ErrorDecoder { _, _ in nil }
18 | }
19 |
20 | /// Decodes the decodable error from the response body using the given `DataDecoder`.
21 | ///
22 | /// - Parameters:
23 | /// - type: The type of the decodable error.
24 | /// - dataDecoder: The `DataDecoder` to use for decoding. If `nil`, the `bodyDecoder` of the `APIClient.Configs` will be used.
25 | static func decodable(
26 | _ type: Failure.Type,
27 | dataDecoder: (any DataDecoder)? = nil
28 | ) -> Self {
29 | ErrorDecoder { data, configs in
30 | try? (dataDecoder ?? configs.bodyDecoder).decode(Failure.self, from: data)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/FormURLEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension ContentEncoder where Self == FormURLEncoder {
4 |
5 | /// A static property to get a `FormURLEncoder` instance with default encoding strategies.
6 | static var formURL: Self { .formURL() }
7 |
8 | /// Creates and returns a `FormURLEncoder` with customizable encoding strategies.
9 | /// - Parameters:
10 | /// - dateEncodingStrategy: Strategy for encoding date values. Default is `.secondsSince1970`.
11 | /// - dataEncodingStrategy: Strategy for encoding data values. Default is `.base64.
12 | /// - keyEncodingStrategy: Strategy for encoding key names. Default is `.useDeafultKeys`.
13 | /// - arrayEncodingStrategy: Strategy for encoding arrays. Default is `.commaSeparator`.
14 | /// - nestedEncodingStrategy: Strategy for encoding nested objects. Default is `.brackets`.
15 | /// - boolEncodingStrategy: Strategy for encoding boolean values. Default is `.literal`.
16 | /// - Returns: An instance of `Self` configured with the specified strategies.
17 | static func formURL(
18 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
19 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64,
20 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
21 | arrayEncodingStrategy: ArrayEncodingStrategy = .commaSeparator,
22 | nestedEncodingStrategy: NestedEncodingStrategy = .brackets,
23 | boolEncodingStrategy: BoolEncodingStrategy = .literal
24 | ) -> Self {
25 | FormURLEncoder(
26 | dateEncodingStrategy: dateEncodingStrategy,
27 | dataEncodingStrategy: dataEncodingStrategy,
28 | keyEncodingStrategy: keyEncodingStrategy,
29 | arrayEncodingStrategy: arrayEncodingStrategy,
30 | nestedEncodingStrategy: nestedEncodingStrategy,
31 | boolEncodingStrategy: boolEncodingStrategy
32 | )
33 | }
34 | }
35 |
36 | /// A `ContentEncoder` for encoding objects into `x-www-form-urlencoded` format.
37 | public struct FormURLEncoder: ContentEncoder {
38 |
39 | private var urlEncoder: URLQueryEncoder
40 |
41 | /// Initializes a new `FormURLEncoder` with the specified encoding strategies.
42 | /// - Parameters:
43 | /// - dateEncodingStrategy: Strategy for encoding date values. Default is `.secondsSince1970`.
44 | /// - dataEncodingStrategy: Strategy for encoding data values. Default is `.base64.
45 | /// - keyEncodingStrategy: Strategy for encoding key names. Default is `.useDeafultKeys`.
46 | /// - arrayEncodingStrategy: Strategy for encoding arrays. Default is `.commaSeparator`.
47 | /// - nestedEncodingStrategy: Strategy for encoding nested objects. Default is `.brackets`.
48 | /// - boolEncodingStrategy: Strategy for encoding boolean values. Default is `.literal`.
49 | public init(
50 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
51 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64,
52 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
53 | arrayEncodingStrategy: ArrayEncodingStrategy = .commaSeparator,
54 | nestedEncodingStrategy: NestedEncodingStrategy = .brackets,
55 | boolEncodingStrategy: BoolEncodingStrategy = .literal
56 | ) {
57 | urlEncoder = URLQueryEncoder(
58 | dateEncodingStrategy: dateEncodingStrategy,
59 | dataEncodingStrategy: dataEncodingStrategy,
60 | keyEncodingStrategy: keyEncodingStrategy,
61 | arrayEncodingStrategy: arrayEncodingStrategy,
62 | nestedEncodingStrategy: nestedEncodingStrategy,
63 | boolEncodingStrategy: boolEncodingStrategy
64 | )
65 | }
66 |
67 | /// The content type associated with this encoder, which is `application/x-www-form-urlencoded; charset=utf-8`.
68 | public var contentType: ContentType {
69 | .application(.urlEncoded).charset(.utf8)
70 | }
71 |
72 | /// Encodes the given `Encodable` value into `x-www-form-urlencoded` format.
73 | /// - Parameter value: The `Encodable` value to encode.
74 | /// - Throws: An `Error` if encoding fails.
75 | /// - Returns: The encoded data as `Data`.
76 | public func encode(_ value: some Encodable) throws -> Data {
77 | guard let data = try urlEncoder.encodeQuery(value).data(using: .utf8) else { throw Errors.unknown }
78 | return data
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/HeadersEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 |
4 | /// Protocol defining an encoder that serializes data into HTTP headers.
5 | public protocol HeadersEncoder {
6 |
7 | func encode(_ value: T) throws -> [HTTPField]
8 | }
9 |
10 | public extension HeadersEncoder where Self == HTTPHeadersEncoder {
11 |
12 | /// A static property to get a `HTTPHeadersEncoder` instance with default settings.
13 | static var `default`: Self { .default() }
14 |
15 | /// Creates and returns a `HTTPHeadersEncoder` with customizable encoding strategies.
16 | /// - Parameters:
17 | /// - dateEncodingStrategy: Strategy for encoding date values. Default is `.secondsSince1970`.
18 | /// - dataEncodingStrategy: Strategy for encoding data values. Default is `.base64.
19 | /// - keyEncodingStrategy: Strategy for encoding key names. Default is `.convertToTrainCase`.
20 | /// - arrayEncodingStrategy: Strategy for encoding arrays. Default is `.repeatKey`.
21 | /// - nestedEncodingStrategy: Strategy for encoding nested objects. Default is `.json`.
22 | /// - boolEncodingStrategy: Strategy for encoding boolean values. Default is `.literal`.
23 | /// - Returns: An instance of `HTTPHeadersEncoder` configured with the specified strategies.
24 | static func `default`(
25 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
26 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64,
27 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .convertToTrainCase,
28 | arrayEncodingStrategy: ArrayEncodingStrategy = .repeatKey,
29 | nestedEncodingStrategy: NestedEncodingStrategy = .json(inheritKeysStrategy: false),
30 | boolEncodingStrategy: BoolEncodingStrategy = .literal
31 | ) -> Self {
32 | HTTPHeadersEncoder(
33 | dateEncodingStrategy: dateEncodingStrategy,
34 | dataEncodingStrategy: dataEncodingStrategy,
35 | keyEncodingStrategy: keyEncodingStrategy,
36 | arrayEncodingStrategy: arrayEncodingStrategy,
37 | nestedEncodingStrategy: nestedEncodingStrategy,
38 | boolEncodingStrategy: boolEncodingStrategy
39 | )
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/JSONContentEncoders.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension ContentEncoder where Self == JSONEncoder {
4 |
5 | /// A static property to get a `JSONEncoder` instance with default settings.
6 | static var json: Self { .json() }
7 |
8 | /// Creates and returns a `JSONEncoder` with customizable encoding strategies.
9 | /// - Parameters:
10 | /// - outputFormatting: The formatting of the output JSON data. Default is `.sortedKeys`.
11 | /// - dataEncodingStrategy: Strategy for encoding data values. Default is `.deferredToData`.
12 | /// - dateEncodingStrategy: Strategy for encoding date values. Default is `.deferredToDate`.
13 | /// - keyEncodingStrategy: Strategy for encoding key names. Default is `.useDefaultKeys`.
14 | /// - nonConformingFloatEncodingStrategy: Strategy for encoding non-conforming float values. Default is `.throw`.
15 | /// - Returns: An instance of `JSONEncoder` configured with the specified strategies.
16 | static func json(
17 | outputFormatting: JSONEncoder.OutputFormatting = .sortedKeys,
18 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .deferredToData,
19 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
20 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
21 | nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy = .throw
22 | ) -> Self {
23 | let encoder = JSONEncoder()
24 | encoder.outputFormatting = outputFormatting
25 | encoder.dateEncodingStrategy = dateEncodingStrategy
26 | encoder.keyEncodingStrategy = keyEncodingStrategy
27 | encoder.dataEncodingStrategy = dataEncodingStrategy
28 | encoder.nonConformingFloatEncodingStrategy = nonConformingFloatEncodingStrategy
29 | return encoder
30 | }
31 | }
32 |
33 | extension JSONEncoder: ContentEncoder {
34 |
35 | /// The content type associated with this encoder, which is `application/json`.
36 | public var contentType: ContentType {
37 | .application(.json)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/MultipartFormData/MultipartFormData.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// RFC 7528 multipart/form-data
4 | /// 4. Definition of multipart/form-data
5 | ///
6 | /// The media type multipart/form-data follows the model of multipart
7 | /// MIME data streams as specified in Section 5.1 of [RFC2046]; changes
8 | /// are noted in this document.
9 | ///
10 | /// A multipart/form-data body contains a series of parts separated by a
11 | /// boundary.
12 | public struct MultipartFormData: Hashable {
13 |
14 | public var parts: [Part]
15 |
16 | /// 4.1. "Boundary" Parameter of multipart/form-data
17 | ///
18 | /// As with other multipart types, the parts are delimited with a
19 | /// boundary delimiter, constructed using CRLF, "--", and the value of
20 | /// the "boundary" parameter. The boundary is supplied as a "boundary"
21 | /// parameter to the multipart/form-data type. As noted in Section 5.1
22 | /// of [RFC2046], the boundary delimiter MUST NOT appear inside any of
23 | /// the encapsulated parts, and it is often necessary to enclose the
24 | /// "boundary" parameter values in quotes in the Content-Type header
25 | /// field.
26 | public var boundary: String
27 |
28 | public init(parts: [Part], boundary: String) {
29 | self.parts = parts
30 | self.boundary = boundary
31 | }
32 |
33 | public var data: Data {
34 | var data = Data()
35 | let boundaryData = Data(boundary.utf8)
36 | for part in parts {
37 | data.append(DASH)
38 | data.append(boundaryData)
39 | data.append(CRLF)
40 | part.write(to: &data)
41 | }
42 |
43 | data.append(DASH)
44 | data.append(boundaryData)
45 | data.append(DASH)
46 | data.append(CRLF)
47 | return data
48 | }
49 | }
50 |
51 | public extension MultipartFormData {
52 |
53 | struct Part: Hashable {
54 |
55 | /// Each part MUST contain a Content-Disposition header field [RFC2183]
56 | /// where the disposition type is "form-data". The Content-Disposition
57 | /// header field MUST also contain an additional parameter of "name"; the
58 | /// value of the "name" parameter is the original field name from the
59 | /// form (possibly encoded; see Section 5.1). For example, a part might
60 | /// contain a header field such as the following, with the body of the
61 | /// part containing the form data of the "user" field:
62 | ///
63 | /// Content-Disposition: form-data; name="user"
64 | ///
65 | public let name: String
66 |
67 | /// For form data that represents the content of a file, a name for the
68 | /// file SHOULD be supplied as well, by using a "filename" parameter of
69 | /// the Content-Disposition header field. The file name isn't mandatory
70 | /// for cases where the file name isn't available or is meaningless or
71 | /// private; this might result, for example, when selection or drag-and-
72 | /// drop is used or when the form data content is streamed directly from
73 | /// a device.
74 | public let filename: String?
75 | public let mimeType: ContentType?
76 | public let content: Data
77 |
78 | /// RFC 2046 Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types
79 | /// 5.1.1. Common Syntax
80 | ///
81 | /// ...
82 | ///
83 | /// This Content-Type value indicates that the content consists of one or
84 | /// more parts, each with a structure that is syntactically identical to
85 | /// an RFC 822 message, except that the header area is allowed to be
86 | /// completely empty, and that the parts are each preceded by the line
87 | ///
88 | /// --gc0pJq0M:08jU534c0p
89 | ///
90 | /// The boundary delimiter MUST occur at the beginning of a line, i.e.,
91 | /// following a CRLF, and the initial CRLF is considered to be attached
92 | /// to the boundary delimiter line rather than part of the preceding
93 | /// part. The boundary may be followed by zero or more characters of
94 | /// linear whitespace. It is then terminated by either another CRLF and
95 | /// the header fields for the next part, or by two CRLFs, in which case
96 | /// there are no header fields for the next part. If no Content-Type
97 | /// field is present it is assumed to be "message/rfc822" in a
98 | /// "multipart/digest" and "text/plain" otherwise.
99 | public func write(to data: inout Data) {
100 | let header = HTTPField.contentDisposition("form-data", name: name, filename: filename)
101 | let contentDispositionData = Data(header.description.utf8)
102 |
103 | data.append(contentDispositionData)
104 | data.append(CRLF)
105 | if let mimeType {
106 | let contentTypeHeader = HTTPField.contentType(mimeType)
107 | let contentTypeData = Data(contentTypeHeader.description.utf8)
108 | data.append(contentTypeData)
109 | data.append(CRLF)
110 | }
111 | data.append(CRLF)
112 | data.append(content)
113 | data.append(CRLF)
114 | }
115 |
116 | public init(
117 | name: String,
118 | filename: String? = nil,
119 | mimeType: ContentType?,
120 | data: Data
121 | ) {
122 | self.name = name
123 | self.filename = filename
124 | self.mimeType = mimeType
125 | content = data
126 | }
127 | }
128 | }
129 |
130 | private let CRLF: Data = "\r\n".data(using: .ascii)!
131 | private let DASH = "--".data(using: .utf8)!
132 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/MultipartFormData/MultipartFormDataEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension ContentEncoder where Self == MultipartFormDataEncoder {
4 |
5 | /// A static property to get a `MultipartFormDataEncoder` instance with a default boundary.
6 | static var multipartFormData: Self {
7 | multipartFormData()
8 | }
9 |
10 | /// Creates and returns a `MultipartFormDataEncoder` with an optional custom boundary.
11 | /// - Parameter boundary: An optional string specifying the boundary. If `nil`, a default boundary is used.
12 | /// - Returns: An instance of `MultipartFormDataEncoder` configured with the specified boundary.
13 | static func multipartFormData(
14 | boundary: String? = nil,
15 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
16 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
17 | arrayEncodingStrategy: URLQueryEncoder.ArrayEncodingStrategy = .commaSeparator,
18 | nestedEncodingStrategy: URLQueryEncoder.NestedEncodingStrategy = .brackets,
19 | boolEncodingStrategy: URLQueryEncoder.BoolEncodingStrategy = .literal
20 | ) -> Self {
21 | MultipartFormDataEncoder(
22 | boundary: boundary,
23 | dateEncodingStrategy: dateEncodingStrategy,
24 | keyEncodingStrategy: keyEncodingStrategy,
25 | arrayEncodingStrategy: arrayEncodingStrategy,
26 | nestedEncodingStrategy: nestedEncodingStrategy,
27 | boolEncodingStrategy: boolEncodingStrategy
28 | )
29 | }
30 | }
31 |
32 | public struct MultipartFormDataEncoder: ContentEncoder {
33 |
34 | /// The content type associated with this encoder, which is `multipart/form-data`.
35 | public var contentType: SwiftAPIClient.ContentType {
36 | .multipart(.formData, boundary: boundary)
37 | }
38 |
39 | /// The boundary used to separate parts of the multipart/form-data.
40 | public var boundary: String
41 | private let queryEncoder: URLQueryEncoder
42 |
43 | public init(
44 | boundary: String? = nil,
45 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
46 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
47 | arrayEncodingStrategy: URLQueryEncoder.ArrayEncodingStrategy = .commaSeparator,
48 | nestedEncodingStrategy: URLQueryEncoder.NestedEncodingStrategy = .brackets,
49 | boolEncodingStrategy: URLQueryEncoder.BoolEncodingStrategy = .literal
50 | ) {
51 | self.boundary = boundary ?? RandomBoundaryGenerator.defaultBoundary
52 | queryEncoder = URLQueryEncoder(
53 | dateEncodingStrategy: dateEncodingStrategy,
54 | keyEncodingStrategy: keyEncodingStrategy,
55 | arrayEncodingStrategy: arrayEncodingStrategy,
56 | nestedEncodingStrategy: nestedEncodingStrategy,
57 | boolEncodingStrategy: boolEncodingStrategy
58 | )
59 | }
60 |
61 | /// Encodes the given `Encodable` value into `multipart/form-data` format.
62 | /// - Parameter value: The `Encodable` value to encode.
63 | /// - Throws: An `Error` if encoding fails.
64 | /// - Returns: The encoded data as `Data`.
65 | public func encode(_ value: some Encodable) throws -> Data {
66 | let params = try queryEncoder.encode(value)
67 | return MultipartFormData(
68 | parts: params.map {
69 | MultipartFormData.Part(
70 | name: $0.name,
71 | filename: nil,
72 | mimeType: nil,
73 | data: $0.value?.data(using: .utf8) ?? Data()
74 | )
75 | },
76 | boundary: boundary
77 | ).data
78 | }
79 | }
80 |
81 | private enum RandomBoundaryGenerator {
82 |
83 | static let defaultBoundary = "boundary." + RandomBoundaryGenerator.generate()
84 |
85 | static func generate() -> String {
86 | String(format: "%08x%08x", UInt32.random(in: 0 ... UInt32.max), UInt32.random(in: 0 ... UInt32.max))
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/ParametersValue.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum ParametersValue: Encodable {
4 |
5 | typealias Keyed = [([CodingKey], String)]
6 |
7 | case single(String, Encodable)
8 | case keyed([(String, ParametersValue)])
9 | case unkeyed([ParametersValue])
10 | case null
11 |
12 | static let start = "?"
13 | static let comma = ","
14 | static let separator = "&"
15 | static let setter = "="
16 | static let openKey: Character = "["
17 | static let closeKey: Character = "]"
18 | static let point: Character = "."
19 |
20 | static func separateKey(_ key: String) -> [String] {
21 | var result: [String] = []
22 | var str = ""
23 | for char in key {
24 | switch char {
25 | case ParametersValue.openKey:
26 | if result.isEmpty, !str.isEmpty {
27 | result.append(str)
28 | str = ""
29 | }
30 | case ParametersValue.closeKey:
31 | result.append(str)
32 | str = ""
33 | case ParametersValue.point:
34 | result.append(str)
35 | str = ""
36 | default:
37 | str.append(char)
38 | }
39 | }
40 | if result.isEmpty, !str.isEmpty {
41 | result.append(str)
42 | }
43 | return result
44 | }
45 |
46 | var unkeyed: [ParametersValue] {
47 | get {
48 | if case let .unkeyed(result) = self {
49 | return result
50 | }
51 | return []
52 | }
53 | set {
54 | self = .unkeyed(newValue)
55 | }
56 | }
57 |
58 | var keyed: [(String, ParametersValue)] {
59 | get {
60 | if case let .keyed(result) = self {
61 | return result
62 | }
63 | return []
64 | }
65 | set {
66 | self = .keyed(newValue)
67 | }
68 | }
69 |
70 | func encode(to encoder: any Encoder) throws {
71 | switch self {
72 | case let .single(_, value):
73 | try value.encode(to: encoder)
74 | case let .keyed(values):
75 | var container = encoder.container(keyedBy: PlainCodingKey.self)
76 | for (key, value) in values {
77 | try container.encode(value, forKey: PlainCodingKey(key))
78 | }
79 | case let .unkeyed(values):
80 | var container = encoder.unkeyedContainer()
81 | for value in values {
82 | try container.encode(value)
83 | }
84 | case .null:
85 | var container = encoder.singleValueContainer()
86 | try container.encodeNil()
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/QueryEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Protocol defining an encoder that serializes data into a query parameters array.
4 | public protocol QueryEncoder {
5 |
6 | func encode(_ value: T, percentEncoded: Bool) throws -> [URLQueryItem]
7 | }
8 |
9 | public extension QueryEncoder where Self == URLQueryEncoder {
10 |
11 | /// A static property to get a `URLQueryEncoder` instance with default settings.
12 | static var urlQuery: Self { .urlQuery() }
13 |
14 | /// Creates and returns a `URLQueryEncoder` with customizable encoding strategies.
15 | /// - Parameters:
16 | /// - dateEncodingStrategy: Strategy for encoding date values. Default is `SecondsSince1970CodingStrategy`.
17 | /// - keyEncodingStrategy: Strategy for encoding key names. Default is `UseDeafultKeyCodingStrategy`.
18 | /// - arrayEncodingStrategy: Strategy for encoding arrays. Default is `.commaSeparator`.
19 | /// - nestedEncodingStrategy: Strategy for encoding nested objects. Default is `.brackets`.
20 | /// - boolEncodingStrategy: Strategy for encoding boolean values. Default is `.literal`.
21 | /// - Returns: An instance of `URLQueryEncoder` configured with the specified strategies.
22 | static func urlQuery(
23 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
24 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
25 | arrayEncodingStrategy: ArrayEncodingStrategy = .commaSeparator,
26 | nestedEncodingStrategy: NestedEncodingStrategy = .brackets,
27 | boolEncodingStrategy: BoolEncodingStrategy = .literal
28 | ) -> Self {
29 | URLQueryEncoder(
30 | dateEncodingStrategy: dateEncodingStrategy,
31 | keyEncodingStrategy: keyEncodingStrategy,
32 | arrayEncodingStrategy: arrayEncodingStrategy,
33 | nestedEncodingStrategy: nestedEncodingStrategy,
34 | boolEncodingStrategy: boolEncodingStrategy
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/URLQuery/HTTPHeadersEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HTTPTypes
3 |
4 | public struct HTTPHeadersEncoder: HeadersEncoder, ParametersEncoderOptions {
5 |
6 | public typealias Output = [HTTPField]
7 | public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy
8 | public var dataEncodingStrategy: JSONEncoder.DataEncodingStrategy
9 | public var arrayEncodingStrategy: ArrayEncodingStrategy
10 | public var nestedEncodingStrategy: NestedEncodingStrategy
11 | public var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy
12 | public var boolEncodingStrategy: BoolEncodingStrategy
13 |
14 | public init(
15 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
16 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64,
17 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .convertToTrainCase,
18 | arrayEncodingStrategy: ArrayEncodingStrategy = .repeatKey,
19 | nestedEncodingStrategy: NestedEncodingStrategy = .json(inheritKeysStrategy: false),
20 | boolEncodingStrategy: BoolEncodingStrategy = .literal
21 | ) {
22 | self.dateEncodingStrategy = dateEncodingStrategy
23 | self.dataEncodingStrategy = dataEncodingStrategy
24 | self.arrayEncodingStrategy = arrayEncodingStrategy
25 | self.keyEncodingStrategy = keyEncodingStrategy
26 | self.boolEncodingStrategy = boolEncodingStrategy
27 | self.nestedEncodingStrategy = nestedEncodingStrategy
28 | }
29 |
30 | public func encode(_ value: T) throws -> [HTTPField] {
31 | let encoder = ParametersEncoder(path: [], context: self)
32 | return try getKeyedItems(from: encoder.encode(value), value: value, percentEncoded: false) {
33 | guard let name = HTTPField.Name($0) else {
34 | throw EncodingError.invalidValue($0, EncodingError.Context(codingPath: [PlainCodingKey($0)], debugDescription: "Invalid header name '\($0)'"))
35 | }
36 | return HTTPField(name: name, value: $1)
37 | }
38 | }
39 |
40 | public func encodeParameters(_ value: T) throws -> [String: String] {
41 | let items = try encode(value)
42 | var result: [String: String] = [:]
43 | for item in items {
44 | result[item.name.rawName] = item.value
45 | }
46 | return result
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/URLQuery/PlainCodingKey.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct PlainCodingKey: CodingKey, CustomStringConvertible {
4 |
5 | public var stringValue: String
6 | public var intValue: Int?
7 |
8 | public init(stringValue: String) {
9 | self.init(stringValue: stringValue, intValue: nil)
10 | }
11 |
12 | public init(_ codingKey: CodingKey) {
13 | self.init(stringValue: codingKey.stringValue, intValue: codingKey.intValue)
14 | }
15 |
16 | public init(_ stringValue: String) {
17 | self.init(stringValue: stringValue)
18 | }
19 |
20 | public init(stringValue: String, intValue: Int?) {
21 | self.stringValue = stringValue
22 | self.intValue = intValue
23 | }
24 |
25 | public init(intValue: Int) {
26 | self.init(stringValue: "\(intValue)", intValue: intValue)
27 | }
28 |
29 | public var description: String {
30 | stringValue
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/URLQuery/Ref.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @propertyWrapper
4 | struct Ref {
5 |
6 | let get: () -> Value
7 | let set: (Value) -> Void
8 |
9 | var wrappedValue: Value {
10 | get { get() }
11 | nonmutating set { set(newValue) }
12 | }
13 |
14 | var projectedValue: Ref {
15 | get { self }
16 | set { self = newValue }
17 | }
18 | }
19 |
20 | extension Ref {
21 |
22 | static func constant(_ value: Value) -> Ref {
23 | self.init {
24 | value
25 | } set: { _ in
26 | }
27 | }
28 |
29 | init(_ value: T, _ keyPath: ReferenceWritableKeyPath) {
30 | self.init {
31 | value[keyPath: keyPath]
32 | } set: { newValue in
33 | value[keyPath: keyPath] = newValue
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Coders/URLQuery/URLQueryEncoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct URLQueryEncoder: QueryEncoder, ParametersEncoderOptions {
4 |
5 | public typealias Output = [URLQueryItem]
6 | public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy
7 | public var dataEncodingStrategy: JSONEncoder.DataEncodingStrategy
8 | public var arrayEncodingStrategy: SwiftAPIClient.ArrayEncodingStrategy
9 | public var nestedEncodingStrategy: SwiftAPIClient.NestedEncodingStrategy
10 | public var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy
11 | public var boolEncodingStrategy: SwiftAPIClient.BoolEncodingStrategy
12 |
13 | public init(
14 | dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate,
15 | dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64,
16 | keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys,
17 | arrayEncodingStrategy: SwiftAPIClient.ArrayEncodingStrategy = .commaSeparator,
18 | nestedEncodingStrategy: SwiftAPIClient.NestedEncodingStrategy = .brackets,
19 | boolEncodingStrategy: SwiftAPIClient.BoolEncodingStrategy = .literal
20 | ) {
21 | self.dateEncodingStrategy = dateEncodingStrategy
22 | self.dataEncodingStrategy = dataEncodingStrategy
23 | self.arrayEncodingStrategy = arrayEncodingStrategy
24 | self.nestedEncodingStrategy = nestedEncodingStrategy
25 | self.keyEncodingStrategy = keyEncodingStrategy
26 | self.boolEncodingStrategy = boolEncodingStrategy
27 | }
28 |
29 | public func encode(_ value: T, for baseURL: URL) throws -> URL {
30 | let items = try encode(value)
31 | guard !items.isEmpty else { return baseURL }
32 | guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
33 | throw EncodingError.invalidValue(
34 | baseURL,
35 | EncodingError.Context(codingPath: [], debugDescription: "Invalid URL components")
36 | )
37 | }
38 | components.queryItems = (components.queryItems ?? []) + items
39 | guard let baseURL = components.url else {
40 | throw EncodingError.invalidValue(
41 | baseURL,
42 | EncodingError.Context(codingPath: [], debugDescription: "Invalid URL components")
43 | )
44 | }
45 | return baseURL
46 | }
47 |
48 | public func encode(_ value: T, percentEncoded: Bool = false) throws
49 | -> [URLQueryItem]
50 | {
51 | let encoder = ParametersEncoder(path: [], context: self)
52 | let query = try encoder.encode(value)
53 | return try getKeyedItems(from: query, value: value, percentEncoded: percentEncoded) {
54 | URLQueryItem(name: $0, value: $1)
55 | }
56 | }
57 |
58 | public func encodeQuery(_ value: T) throws -> String {
59 | try encode(value, percentEncoded: true)
60 | .map {
61 | "\($0.name)=\($0.value ?? "")"
62 | }
63 | .joined(separator: "&")
64 | }
65 |
66 | public func encodeParameters(_ value: T) throws -> [String: String] {
67 | let items = try encode(value)
68 | var result: [String: String] = [:]
69 | for item in items {
70 | result[item.name] = item.value ?? result[item.name]
71 | }
72 | return result
73 | }
74 |
75 | @available(*, deprecated, renamed: "SwiftAPIClient.ArrayEncodingStrategy")
76 | public typealias ArrayEncodingStrategy = SwiftAPIClient.ArrayEncodingStrategy
77 | @available(*, deprecated, renamed: "SwiftAPIClient.NestedEncodingStrategy")
78 | public typealias NestedEncodingStrategy = SwiftAPIClient.NestedEncodingStrategy
79 | @available(*, deprecated, renamed: "SwiftAPIClient.BoolEncodingStrategy")
80 | public typealias BoolEncodingStrategy = SwiftAPIClient.BoolEncodingStrategy
81 | }
82 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/ConsoleStyle.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 |
4 | struct ConsoleStyle {
5 |
6 | var prefix: String
7 |
8 | static let error = ConsoleStyle(prefix: "\u{001B}[91m")
9 | static let success = ConsoleStyle(prefix: "\u{001B}[32m")
10 | }
11 |
12 | extension String {
13 |
14 | func consoleStyle(_ style: ConsoleStyle) -> String {
15 | "\(style.prefix)\(self)\u{001B}[0m"
16 | }
17 | }
18 |
19 | extension Logger.Level {
20 |
21 | /// Converts log level to console style
22 | var style: ConsoleStyle {
23 | switch self {
24 | case .trace: return ConsoleStyle(prefix: "\u{001B}[96m")
25 | case .debug: return ConsoleStyle(prefix: "\u{001B}[94m")
26 | case .info, .notice: return .success
27 | case .warning: return ConsoleStyle(prefix: "\u{001B}[33m")
28 | case .error: return .error
29 | case .critical: return ConsoleStyle(prefix: "\u{001B}[95m")
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Error+String.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Error {
4 |
5 | var humanReadable: String {
6 | if let decodingError = self as? DecodingError {
7 | return decodingError.humanReadable
8 | }
9 | if let encodingError = self as? EncodingError {
10 | return encodingError.humanReadable
11 | }
12 | return localizedDescription
13 | }
14 | }
15 |
16 | private extension DecodingError {
17 |
18 | var humanReadable: String {
19 | switch self {
20 | case let .typeMismatch(_, context):
21 | return context.humanReadable
22 | case let .valueNotFound(_, context):
23 | return context.humanReadable
24 | case let .keyNotFound(_, context):
25 | return context.humanReadable
26 | case let .dataCorrupted(context):
27 | return context.humanReadable
28 | @unknown default:
29 | return localizedDescription
30 | }
31 | }
32 | }
33 |
34 | private extension DecodingError.Context {
35 |
36 | var humanReadable: String {
37 | "\(debugDescription) Path: \\\(codingPath.humanReadable)"
38 | }
39 | }
40 |
41 | extension [CodingKey] {
42 |
43 | var humanReadable: String {
44 | isEmpty ? "root" : map(\.string).joined()
45 | }
46 | }
47 |
48 | private extension EncodingError {
49 |
50 | var humanReadable: String {
51 | switch self {
52 | case let .invalidValue(any, context):
53 | return "Invalid value \(any) at \(context.humanReadable)"
54 | @unknown default:
55 | return errorDescription ?? "\(self)"
56 | }
57 | }
58 | }
59 |
60 | private extension EncodingError.Context {
61 |
62 | var humanReadable: String {
63 | codingPath.map(\.string).joined()
64 | }
65 | }
66 |
67 | private extension CodingKey {
68 |
69 | var string: String {
70 | if let intValue {
71 | return "[\(intValue)]"
72 | }
73 | return "." + stringValue
74 | }
75 | }
76 |
77 | struct CodableError: LocalizedError, CustomStringConvertible {
78 |
79 | var error: Error
80 | var description: String { error.humanReadable }
81 | var errorDescription: String? { description }
82 |
83 | init(_ error: Error) {
84 | self.error = error
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/NoneLogger.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 |
4 | extension Logger {
5 |
6 | /// A logger that discards all log messages.
7 | public static var none: Logger {
8 | Logger(label: "none") { _ in
9 | NoneLogger()
10 | }
11 | }
12 | }
13 |
14 | private struct NoneLogger: LogHandler {
15 |
16 | var metadata: Logger.Metadata = [:]
17 | var logLevel: Logger.Level = .critical
18 |
19 | func log(
20 | level: Logger.Level,
21 | message: Logger.Message,
22 | metadata: Logger.Metadata?,
23 | source: String,
24 | file: String,
25 | function: String,
26 | line: UInt
27 | ) {}
28 |
29 | subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
30 | get { nil }
31 | set {}
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/SwiftAPIClient/Utils/Publisher+Create.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Combine)
2 | import Combine
3 | import Foundation
4 |
5 | extension Publishers {
6 |
7 | struct Create