├── .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: Publisher { 8 | 9 | typealias Events = ( 10 | @escaping (Output) -> Void, 11 | @escaping (Subscribers.Completion) -> Void, 12 | @escaping (@escaping () -> Void) -> Void 13 | ) -> Failure? 14 | 15 | let events: Events 16 | 17 | init(events: @escaping Events) { 18 | self.events = events 19 | } 20 | 21 | func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { 22 | let subscription = CreateSubscription(create: self, subscriber: subscriber) 23 | subscriber.receive(subscription: subscription) 24 | subscription.start() 25 | } 26 | 27 | private final actor CreateSubscription: Subscription where Failure == S.Failure, Output == S.Input { 28 | 29 | var subscriber: S? 30 | let create: Publishers.Create 31 | private var onCancel: [() -> Void] = [] 32 | 33 | init( 34 | create: Publishers.Create, 35 | subscriber: S 36 | ) { 37 | self.subscriber = subscriber 38 | self.create = create 39 | } 40 | 41 | nonisolated func start() { 42 | let failure = create.events( 43 | { [weak self] output in 44 | guard let self else { return } 45 | Task { 46 | _ = await self.subscriber?.receive(output) 47 | } 48 | }, 49 | { [weak self] completion in 50 | guard let self else { return } 51 | Task { 52 | await self.subscriber?.receive(completion: completion) 53 | await self._cancel() 54 | } 55 | }, 56 | { [weak self] onCancel in 57 | guard let self else { return } 58 | Task { 59 | await self.onCancel(onCancel) 60 | } 61 | } 62 | ) 63 | if let failure, !(failure is Never) { 64 | Task { 65 | await subscriber?.receive(completion: .failure(failure)) 66 | await _cancel() 67 | } 68 | } 69 | } 70 | 71 | nonisolated func request(_: Subscribers.Demand) {} 72 | 73 | nonisolated func cancel() { 74 | Task { 75 | await self._cancel() 76 | } 77 | } 78 | 79 | private func onCancel(_ block: @escaping () -> Void) { 80 | onCancel.append(block) 81 | } 82 | 83 | private func _cancel() { 84 | subscriber = nil 85 | onCancel.forEach { $0() } 86 | } 87 | } 88 | } 89 | } 90 | 91 | extension Publishers.Create where Failure == Never { 92 | 93 | init( 94 | events: @escaping ( 95 | @escaping (Output) -> Void, 96 | @escaping (Subscribers.Completion) -> Void, 97 | @escaping (@escaping () -> Void) -> Void 98 | ) -> Void 99 | ) { 100 | self.events = { onOutput, onCompletion, cancellationHandler in 101 | events(onOutput, onCompletion, cancellationHandler) 102 | return nil 103 | } 104 | } 105 | } 106 | 107 | extension Publishers.Create where Failure == Error { 108 | 109 | init( 110 | events: @escaping ( 111 | @escaping (Output) -> Void, 112 | @escaping (Subscribers.Completion) -> Void, 113 | @escaping (@escaping () -> Void) -> Void 114 | ) throws -> Void 115 | ) { 116 | self.events = { 117 | do { 118 | try events($0, $1, $2) 119 | return nil 120 | } catch { 121 | return error 122 | } 123 | } 124 | } 125 | } 126 | #endif 127 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClient/Utils/Publishers+Task.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | import Foundation 4 | 5 | public extension Publishers { 6 | 7 | struct Run: Publisher { 8 | 9 | fileprivate let task: ((Result) -> Void) async -> Void 10 | 11 | public init(_ task: @escaping ((Result) -> Void) async -> Void) { 12 | self.task = task 13 | } 14 | 15 | public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { 16 | Publishers.Create { onOutput, onCompletion, cancellationHandler in 17 | let concurrencyTask = Task { 18 | await task { 19 | switch $0 { 20 | case let .success(output): 21 | onOutput(output) 22 | onCompletion(.finished) 23 | 24 | case let .failure(error): 25 | if error is CancellationError { 26 | onCompletion(.finished) 27 | } else { 28 | onCompletion(.failure(error)) 29 | } 30 | } 31 | } 32 | } 33 | cancellationHandler { 34 | concurrencyTask.cancel() 35 | } 36 | return nil 37 | } 38 | .receive(subscriber: subscriber) 39 | } 40 | } 41 | } 42 | 43 | public extension Publishers.Run where Failure == Never { 44 | 45 | init(_ task: @escaping () async -> Output) { 46 | self.init { send in 47 | await send(.success(task())) 48 | } 49 | } 50 | 51 | init(_ task: @escaping (_ send: (Output) -> Void) async -> Void) { 52 | self.init { send in 53 | await task { 54 | send(.success($0)) 55 | } 56 | } 57 | } 58 | } 59 | 60 | public extension Publishers.Run where Failure == Error { 61 | 62 | init(_ task: @escaping (_ send: (Output) -> Void) async throws -> Void) { 63 | self.init { send in 64 | do { 65 | try await task { 66 | send(.success($0)) 67 | } 68 | } catch { 69 | send(.failure(error)) 70 | } 71 | } 72 | } 73 | 74 | init(_ task: @escaping () async throws -> Output) { 75 | self.init { send in 76 | do { 77 | try await send(.success(task())) 78 | } catch { 79 | send(.failure(error)) 80 | } 81 | } 82 | } 83 | } 84 | #endif 85 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClient/Utils/Status+Ext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPTypes 3 | 4 | extension HTTPResponse.Status.Kind { 5 | 6 | var isError: Bool { 7 | self == .clientError || self == .serverError || self == .invalid 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClient/Utils/URLSessionDelegateWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) 7 | final class SessionDelegateProxy: NSObject { 8 | 9 | var configs: APIClient.Configs? { 10 | get { 11 | lock.lock() 12 | defer { lock.unlock() } 13 | return _configs 14 | } 15 | set { 16 | lock.lock() 17 | defer { lock.unlock() } 18 | _configs = newValue 19 | } 20 | } 21 | 22 | var originalDelegate: URLSessionDelegate? { configs?.urlSessionDelegate } 23 | 24 | private let lock = NSRecursiveLock() 25 | private var _configs: APIClient.Configs? 26 | 27 | override func responds(to aSelector: Selector!) -> Bool { 28 | if super.responds(to: aSelector) { 29 | return true 30 | } 31 | return originalDelegate?.responds(to: aSelector) ?? false 32 | } 33 | 34 | override func forwardingTarget(for aSelector: Selector!) -> Any? { 35 | if originalDelegate?.responds(to: aSelector) == true { 36 | return originalDelegate 37 | } 38 | return super.forwardingTarget(for: aSelector) 39 | } 40 | } 41 | #else 42 | final class SessionDelegateProxy: NSObject { 43 | 44 | var configs: APIClient.Configs? 45 | var originalDelegate: URLSessionDelegate? { configs?.urlSessionDelegate } 46 | } 47 | #endif 48 | 49 | extension SessionDelegateProxy: URLSessionDelegate { 50 | 51 | static let shared = SessionDelegateProxy() 52 | } 53 | 54 | extension SessionDelegateProxy: URLSessionTaskDelegate { 55 | 56 | func urlSession( 57 | _ session: URLSession, 58 | task: URLSessionTask, 59 | willPerformHTTPRedirection response: HTTPURLResponse, 60 | newRequest request: URLRequest, 61 | completionHandler: @escaping (URLRequest?) -> Void 62 | ) { 63 | switch configs?.redirectBehaviour ?? .follow { 64 | case .follow: 65 | completionHandler(request) 66 | case .doNotFollow: 67 | completionHandler(nil) 68 | case let .modify(modifier): 69 | guard 70 | let request = HTTPRequestComponents(urlRequest: request), 71 | let response = response.httpResponse 72 | else { 73 | completionHandler(nil) 74 | return 75 | } 76 | completionHandler( 77 | modifier(request, response)?.urlRequest 78 | ) 79 | } 80 | } 81 | 82 | func urlSession( 83 | _ session: URLSession, 84 | task: URLSessionTask, 85 | didSendBodyData bytesSent: Int64, 86 | totalBytesSent: Int64, 87 | totalBytesExpectedToSend: Int64 88 | ) { 89 | configs?.uploadTracker(totalBytesSent, totalBytesExpectedToSend) 90 | } 91 | } 92 | 93 | #if canImport(UIKit) 94 | import UIKit 95 | 96 | extension SessionDelegateProxy { 97 | 98 | func urlSessionDidFinishEvents( 99 | forBackgroundURLSession session: URLSession 100 | ) {} 101 | } 102 | #endif 103 | 104 | extension SessionDelegateProxy: URLSessionDataDelegate {} 105 | 106 | extension SessionDelegateProxy: URLSessionDownloadDelegate { 107 | 108 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 109 | (originalDelegate as? URLSessionDownloadDelegate)? 110 | .urlSession(session, downloadTask: downloadTask, didFinishDownloadingTo: location) 111 | } 112 | 113 | func urlSession( 114 | _ session: URLSession, 115 | downloadTask: URLSessionDownloadTask, 116 | didWriteData bytesWritten: Int64, 117 | totalBytesWritten: Int64, 118 | totalBytesExpectedToWrite: Int64 119 | ) { 120 | configs?.downloadTracker(totalBytesWritten, totalBytesExpectedToWrite) 121 | } 122 | } 123 | 124 | extension SessionDelegateProxy: URLSessionStreamDelegate {} 125 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClient/Utils/UpdateMetrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPTypes 3 | import Metrics 4 | 5 | func updateTotalRequestsMetrics( 6 | for request: HTTPRequestComponents 7 | ) { 8 | Counter( 9 | label: "api_client_requests_total", 10 | dimensions: dimensions(for: request) 11 | ).increment() 12 | } 13 | 14 | func updateTotalResponseMetrics( 15 | for request: HTTPRequestComponents, 16 | successful: Bool 17 | ) { 18 | Counter( 19 | label: "api_client_responses_total", 20 | dimensions: dimensions(for: request) + [("successful", successful.description)] 21 | ).increment() 22 | if !successful { 23 | updateTotalErrorsMetrics(for: request) 24 | } 25 | } 26 | 27 | func updateTotalErrorsMetrics( 28 | for request: HTTPRequestComponents? 29 | ) { 30 | Counter( 31 | label: "api_client_errors_total", 32 | dimensions: dimensions(for: request) 33 | ).increment() 34 | } 35 | 36 | func updateHTTPMetrics( 37 | for request: HTTPRequestComponents?, 38 | status: HTTPResponse.Status?, 39 | duration: Double, 40 | successful: Bool 41 | ) { 42 | var dimensions = dimensions(for: request) 43 | dimensions.append(("status", status?.code.description ?? "undefined")) 44 | dimensions.append(("successful", successful.description)) 45 | Timer( 46 | label: "http_client_request_duration_seconds", 47 | dimensions: dimensions, 48 | preferredDisplayUnit: .seconds 49 | ) 50 | .recordSeconds(duration) 51 | } 52 | 53 | private func dimensions( 54 | for request: HTTPRequestComponents? 55 | ) -> [(String, String)] { 56 | [ 57 | ("method", request?.method.rawValue ?? "undefined"), 58 | ("path", request?.urlComponents.path ?? "undefined"), 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClientMacros/Collection++.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | 5 | func first(as map: (Element) throws -> T?) rethrows -> T? { 6 | for element in self { 7 | if let mapped = try map(element) { 8 | return mapped 9 | } 10 | } 11 | return nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClientMacros/MacroError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MacroError: LocalizedError, CustomStringConvertible { 4 | 5 | var errorDescription: String 6 | var localizedDescription: String { errorDescription } 7 | var description: String { errorDescription } 8 | 9 | init(_ errorDescription: String) { 10 | self.errorDescription = errorDescription 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftAPIClientMacros/String++.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | 5 | var firstLowercased: String { 6 | isEmpty ? "" : prefix(1).lowercased() + dropFirst() 7 | } 8 | 9 | var firstUppercased: String { 10 | isEmpty ? "" : prefix(1).uppercased() + dropFirst() 11 | } 12 | 13 | var isOptional: Bool { 14 | hasSuffix("?") || hasPrefix("Optional<") && hasSuffix(">") 15 | } 16 | 17 | func removeCharacters(in set: CharacterSet) -> String { 18 | components(separatedBy: set).joined() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SwagGen_Template/APIClient/Coding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias APIModel = Codable & Equatable 4 | 5 | public typealias DateTime = Date 6 | public typealias File = Data 7 | public typealias ID = UUID 8 | 9 | extension Encodable { 10 | 11 | func encode() -> String { 12 | (self as? String) ?? "\(self)" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SwagGen_Template/Includes/Enum.stencil: -------------------------------------------------------------------------------- 1 | {% if description %} 2 | /** {{ description }} */ 3 | {% endif %} 4 | public enum {{ enumName }}: {{ type }}, Codable, Equatable, CaseIterable{% if type == "String" %}, CustomStringConvertible{% endif %} { 5 | {% for enumCase in enums %} 6 | case {{ enumCase.name }} = {% if type == "String" %}"{% endif %}{{enumCase.value}}{% if type == "String" %}"{% endif %} 7 | {% endfor %} 8 | {% if options.enumUndecodedCase %} 9 | case undecoded 10 | 11 | public init(from decoder: Decoder) throws { 12 | let container = try decoder.singleValueContainer() 13 | let rawValue = try container.decode({{ type }}.self) 14 | self = {{ enumName }}(rawValue: rawValue) ?? .undecoded 15 | } 16 | {% endif %} 17 | 18 | {% if type == "String" %} 19 | public var description: String { rawValue } 20 | {% endif %} 21 | } 22 | -------------------------------------------------------------------------------- /SwagGen_Template/Includes/Model.stencil: -------------------------------------------------------------------------------- 1 | {% macro propertyType property %}{% if property.type == "DateTime" or property.type == "DateDay" or property.raw.format == "timestamp" %}Date{% else %}{{ property.type }}{% endif %}{% if property.optional or property.raw.nullable %}?{% endif %}{% endmacro %} 2 | {% macro propertyName property %}{% if property.value|hasPrefix:"_" %}_{{ property.name }}{% else %}{{ property.name }}{% endif %}{% endmacro %} 3 | {% if options.excludeTypes[type] == true %} 4 | 5 | {% else %} 6 | {% if description %} 7 | /** {{ description }} */ 8 | {% endif %} 9 | {% if enum %} 10 | {% include "Includes/Enum.stencil" enum %} 11 | {% elif aliasType %} 12 | {% if type != "String" %} 13 | public typealias {{ type }} = {{ aliasType }} 14 | {% endif %} 15 | {% elif additionalPropertiesType and allProperties.count == 0 %} 16 | public typealias {{ type }} = [String: {{ additionalPropertiesType }}] 17 | {% elif discriminatorType %} 18 | public enum {{ type }}: {% if options.modelProtocol %}{{ options.modelProtocol }}{% else %}Codable, Equatable{% endif %} { 19 | 20 | {% for subType in discriminatorType.subTypes %} 21 | case {{ subType.name }}({{ subType.type }}) 22 | {% endfor %} 23 | 24 | public init(from decoder: Decoder) throws { 25 | let container = try decoder.container(keyedBy: PlainCodingKey.self) 26 | let discriminator: String = try container.decode(path: "{{ discriminatorType.discriminatorProperty }}".components(separatedBy: ".").map { PlainCodingKey($0) }) 27 | switch discriminator { 28 | {% for name, subType in discriminatorType.mapping %} 29 | case "{{ name }}": 30 | self = .{{ subType.name}}(try {{ subType.type }}(from: decoder)) 31 | {% endfor %} 32 | default: 33 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Couldn't find type to decode with discriminator \"\(discriminator)\"")) 34 | } 35 | } 36 | 37 | public func encode(to encoder: Encoder) throws { 38 | var container = encoder.singleValueContainer() 39 | switch self { 40 | {% for subType in discriminatorType.subTypes %} 41 | case .{{ subType.name}}(let content): 42 | try container.encode(content) 43 | {% endfor %} 44 | } 45 | } 46 | 47 | {% for subType in discriminatorType.subTypes %} 48 | public var {{ subType.name }}: {{ subType.type }}? { 49 | if case .{{ subType.name }}(let value) = self { 50 | return value 51 | } 52 | return nil 53 | } 54 | {% endfor %} 55 | } 56 | {% else %} 57 | public struct {{ type }}: {% if options.modelProtocol %}{{ options.modelProtocol }}{% else %}Codable, Equatable{% endif %} { 58 | 59 | {% for property in allProperties %} 60 | {% if not property.raw.deprecated %} 61 | {% if property.description %} 62 | /** {{ property.description }} */ 63 | {% endif %} 64 | public {% if options.mutableModels %}var{% else %}let{% endif %} {% call propertyName property %}: {% call propertyType property %} 65 | {% endif %} 66 | {% endfor %} 67 | {% if additionalPropertiesType %} 68 | 69 | public {% if options.mutableModels %}var{% else %}let{% endif %} additionalProperties: [String: {{ additionalPropertiesType }}] = [:] 70 | {% endif %} 71 | 72 | public enum CodingKeys: String, CodingKey { 73 | 74 | {% for property in allProperties %} 75 | {% if not property.raw.deprecated %} 76 | case {% call propertyName property %}{% if property.name != property.value %} = "{{ property.value }}"{% endif %} 77 | {% endif %} 78 | {% endfor %} 79 | } 80 | 81 | public init( 82 | {% for property in allProperties %} 83 | {% if not property.raw.deprecated %} 84 | {% call propertyName property %}: {% call propertyType property %}{% if property.optional or property.raw.nullable %} = nil{% endif %}{% ifnot forloop.last %},{% endif %} 85 | {% endif %} 86 | {% endfor %} 87 | ) { 88 | {% for property in allProperties %} 89 | {% if not property.raw.deprecated %} 90 | self.{% call propertyName property %} = {% call propertyName property %} 91 | {% endif %} 92 | {% endfor %} 93 | } 94 | {% if additionalPropertiesType %} 95 | 96 | public subscript(key: String) -> {{ additionalPropertiesType }}? { 97 | get { 98 | return additionalProperties[key] 99 | } 100 | set { 101 | additionalProperties[key] = newValue 102 | } 103 | } 104 | {% endif %} 105 | {% for enum in enums %} 106 | {% if not enum.isGlobal %} 107 | 108 | {% filter indent:4 %}{% include "Includes/Enum.stencil" enum %}{% endfilter %} 109 | {% endif %} 110 | {% endfor %} 111 | {% for schema in schemas %} 112 | {% if options.globals[schema.type] != true %} 113 | {% if options.typealiases[type][schema.type] != null %} 114 | 115 | public typealias {{ schema.type }} = {{ options.typealiases[type][schema.type] }} 116 | {% else %} 117 | 118 | {% filter indent:4 %}{% include "Includes/Model.stencil" schema %}{% endfilter %} 119 | {% endif %} 120 | {% endif %} 121 | {% endfor %} 122 | } 123 | {% endif %} 124 | {% endif %} 125 | -------------------------------------------------------------------------------- /SwagGen_Template/Sources/APIModule.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | import Foundation 3 | import SwiftAPIClient 4 | 5 | {% if info.description %} 6 | /** {{ info.description }} */ 7 | {% endif %} 8 | public struct {{ options.name }} { 9 | 10 | {% if info.version %} 11 | public static let version = "{{ info.version }}" 12 | {% endif %} 13 | public var client: APIClient 14 | 15 | public init(client: APIClient) { 16 | self.client = client 17 | } 18 | } 19 | {% if servers %} 20 | extension {{ options.name }} { 21 | 22 | public struct Server: Hashable { 23 | 24 | /// URL of the server 25 | public var url: URL 26 | 27 | public init(_ url: URL) { 28 | self.url = url 29 | } 30 | 31 | {% ifnot servers[0].variables %} 32 | public static var `default` = {{ options.name }}.Server.{{ servers[0].name }} 33 | {% endif %} 34 | {% for server in servers %} 35 | 36 | {% if server.description %} 37 | /** {{ server.description }} */ 38 | {% endif %} 39 | {% if server.variables %} 40 | public static func {{ server.name }}({% for variable in server.variables %}{{ variable.name|replace:'-','_' }}: String = "{{ variable.defaultValue }}"{% ifnot forloop.last %}, {% endif %}{% endfor %}) -> {{ options.name }}.Server { 41 | var urlString = "{{ server.url }}" 42 | {% for variable in server.variables %} 43 | urlString = urlString.replacingOccurrences(of: {{'"{'}}{{variable.name}}{{'}"'}}, with: {{variable.name|replace:'-','_'}}) 44 | {% endfor %} 45 | return {{ options.name }}.Server(URL(string: urlString)!) 46 | } 47 | {% else %} 48 | public static let {{ server.name }} = {{ options.name }}.Server(URL(string: "{{ server.url }}")!) 49 | {% endif %} 50 | {% endfor %} 51 | } 52 | } 53 | 54 | extension APIClient.Configs { 55 | 56 | /// {{ options.name }} server 57 | public var {{ options.name|lowerFirstWord }}Server: {{ options.name }}.Server{% if servers[0].variables %}?{% endif %} { 58 | get { self[\.{{ options.name|lowerFirstWord }}Server]{% ifnot servers[0].variables %} ?? .default{% endif %} } 59 | set { self[\.{{ options.name|lowerFirstWord }}Server] = newValue } 60 | } 61 | } 62 | 63 | {% else %} 64 | 65 | // No servers defined in swagger. Documentation for adding them: https://swagger.io/specification/#schema 66 | {% endif %} 67 | {% if options.groupingType == "path" %} 68 | {% macro pathTypeName path %}{{ path|basename|upperFirstLetter|replace:"{","By_"|replace:"}",""|swiftIdentifier:"pretty" }}{% endmacro %} 69 | {% macro pathAsType path %}{% if path != "/" and path != "" %}{% call pathAsType path|dirname %}.{% call pathTypeName path %}{% endif %}{% endmacro %} 70 | 71 | {% macro pathVarAndType path allPaths definedPath %} 72 | {% set currentPath path|dirname %} 73 | {% if path != "/" and path != "" %} 74 | {% set _path %}|{{path}}|{% endset %} 75 | {% if definedPath|contains:_path == false %} 76 | extension {{ options.name }}{% call pathAsType currentPath %} { 77 | {% set name path|basename %} 78 | /// {{ path }} 79 | {% if name|contains:"{" %} 80 | public func callAsFunction(_ path: String) -> {% call pathTypeName path %} { {% call pathTypeName path %}(client: client(path)) } 81 | {% else %} 82 | public var {{ name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords }}: {% call pathTypeName path %} { {% call pathTypeName path %}(client: client("{{name}}")) } 83 | {% endif %} 84 | public struct {% call pathTypeName path %} { public var client: APIClient } 85 | } 86 | {% set newDefinedPaths %}{{_path}}{{definedPath}}{% endset %} 87 | {% call pathVarAndType currentPath allPaths newDefinedPaths %} 88 | {% else %} 89 | {% call pathVarAndType currentPath allPaths definedPath %} 90 | {% endif %} 91 | {% else %} 92 | {% if allPaths != "/" and allPaths != "" %} 93 | {% set path %}{{ allPaths|basename|replace:"$","/" }}{% endset %} 94 | {% set newAllPaths allPaths|dirname %} 95 | {% call pathVarAndType path newAllPaths definedPath %} 96 | {% endif %} 97 | {% endif %} 98 | {% endmacro %} 99 | 100 | {% map paths into pathArray %}{{maploop.item.path|replace:"/","$"}}{% endmap %} 101 | {% set allPaths %}/{{ pathArray|join:"/" }}{% endset %} 102 | 103 | {% set path %}{{ allPaths|basename|replace:"$","/" }}{% endset %} 104 | {% call pathVarAndType path allPaths "" %} 105 | {% elif options.groupingType == "tag" and tags %} 106 | {% for tag in tags %} 107 | extension {{ options.name }} { 108 | public var {{ tag|swiftIdentifier|lowerFirstLetter }}: {{ tag|swiftIdentifier }} { {{ tag|swiftIdentifier }}(client: client) } 109 | public struct {{ tag|swiftIdentifier }} { var client: APIClient } 110 | } 111 | {% endfor %} 112 | {% endif %} 113 | -------------------------------------------------------------------------------- /SwagGen_Template/Sources/Enum.swift: -------------------------------------------------------------------------------- 1 | {% include "Includes/Enum.stencil" %} 2 | -------------------------------------------------------------------------------- /SwagGen_Template/Sources/Model.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | 4 | {% include "Includes/Model.stencil" %} 5 | -------------------------------------------------------------------------------- /SwagGen_Template/Sources/Request.swift: -------------------------------------------------------------------------------- 1 | {% if options.excludeTypes[type] == false %} 2 | 3 | {% else %} 4 | // swiftlint:disable all 5 | import Foundation 6 | import SwiftAPIClient 7 | 8 | {% set tagType %}{{ tag|swiftIdentifier }}{% endset %} 9 | {% macro pathTypeName path %}{{ path|basename|upperFirstLetter|replace:"{","By_"|replace:"}",""|swiftIdentifier:"pretty" }}{% endmacro %} 10 | {% if options.groupingType == "path" %} 11 | {% macro pathPart path %}{% if path != "/" and path != "" %}{% call pathPart path|dirname %}.{% call pathTypeName path %}{% endif %}{% endmacro %} 12 | extension {{ options.name }}{% call pathPart path %} { 13 | {% elif options.groupingType == "tag" and tag %} 14 | extension {{ options.name }}.{{ tagType }} { 15 | {% else %} 16 | extension {{ options.name }} { 17 | {% endif %} 18 | 19 | /** 20 | {% if summary %} 21 | {{ summary }} 22 | 23 | {% endif %} 24 | {% if description %} 25 | {{ description }} 26 | 27 | {% endif %} 28 | **{{ method|uppercase }}** {{ path }} 29 | */ 30 | {% set funcName %}{% if options.groupingType == "path" %}{{ method|lowercase|escapeReservedKeywords }}{% elif options.groupingType == "tag" and tag %}{{ type|replace:tagType,""|lowerFirstLetter }}{% else %}{{ type|lowerFirstLetter }}{% endif %}{% endset %} 31 | public func {{ funcName }}({% if options.groupingType != "path" %}{% for param in pathParams %}{{ param.name }} {{ param.value }}: {{ param.type }}, {% endfor %}{% endif %}{% for param in queryParams %}{{ param.name }}: {{ param.optionalType }}{% ifnot param.required %} = nil{% endif %}, {% endfor %}{% for param in headerParams %}{% if options.excludeHeaders[param.value] != true %}{{ param.name }}: {{ param.optionalType }}{% ifnot param.required %} = nil{% endif %}, {% endif %}{% endfor %}{% if body %}{{ body.name }}: {{ body.optionalType }}{% ifnot body.required %} = nil{% endif %}, {% endif %}fileID: String = #fileID, line: UInt = #line) async throws -> {{ successType|default:"Void" }} { 32 | try await client 33 | {% if options.groupingType != "path" %} 34 | .path("{{ path|replace:"{","\("|replace:"}",")" }}") 35 | {% endif %} 36 | .method(.{{ method|lowercase }}) 37 | {% if queryParams %} 38 | .query([ 39 | {% for param in queryParams %} 40 | "{{ param.value }}": {{ param.name }}{% ifnot forloop.last %},{% endif %} 41 | {% endfor %} 42 | ]) 43 | {% endif %} 44 | {% if headerParams %} 45 | {% for param in headerParams %} 46 | {% if options.excludeHeaders[param.value] != true %} 47 | .header(HTTPField.Name("{{ param.value }}")!, {{ param.encodedValue }}) 48 | {% endif %} 49 | {% endfor %} 50 | {% endif %} 51 | .auth(enabled: {% if securityRequirements %}true{% else %}false{% endif %}) 52 | {% if body %} 53 | .body(body) 54 | {% endif %} 55 | .call( 56 | .http, 57 | as: .{% if successType == "String" %}string{% elif successType == "Data" or successType == "File" %}identity{% elif successType %}decodable{% else %}void{% endif %}, 58 | fileID: fileID, 59 | line: line 60 | ) 61 | } 62 | 63 | {% if requestEnums or requestSchemas %} 64 | public enum {{ type }} { 65 | {% for enum in requestEnums %} 66 | {% if not enum.isGlobal %} 67 | 68 | {% filter indent:8 %}{% include "Includes/Enum.stencil" enum %}{% endfilter %} 69 | {% endif %} 70 | {% endfor %} 71 | {% for schema in requestSchemas %} 72 | 73 | {% filter indent:12 %}{% include "Includes/Model.stencil" schema %}{% endfilter %} 74 | {% endfor %} 75 | 76 | {% for schema in responseSchemas %} 77 | 78 | {% filter indent:8 %}{% include "Includes/Model.stencil" schema %}{% endfilter %} 79 | 80 | {% endfor %} 81 | {% for enum in responseEnums %} 82 | {% if not enum.isGlobal %} 83 | 84 | {% filter indent:8 %}{% include "Includes/Enum.stencil" enum %}{% endfilter %} 85 | {% endif %} 86 | {% endfor %} 87 | } 88 | {% endif %} 89 | } 90 | {% endif %} 91 | -------------------------------------------------------------------------------- /SwagGen_Template/template.yml: -------------------------------------------------------------------------------- 1 | formatter: swift 2 | options: 3 | name: API 4 | fixedWidthIntegers: false # whether to use types like Int32 and Int64 5 | mutableModels: true # whether model properties are mutable 6 | safeOptionalDecoding: false # set invalid optionals to nil instead of throwing 7 | modelPrefix: null # applied to model classes and enums 8 | modelSuffix: null # applied to model classes 9 | modelNames: {} # override model type names 10 | enumNames: {} # override enum type names 11 | enumUndecodedCase: true # whether to add undecodable case to enums 12 | codableResponses: true # constrains all responses/model to be Codable 13 | propertyNames: {} # override property names 14 | anyType: JSON # override Any in generated models 15 | numberType: Decimal # number type without format 16 | groupingType: tag # how to group requests, can be path, tag or none 17 | excrsionCheck: false 18 | excludeTypes: {} # whether to exclude types from the autogenerated models, example: { Color: true } 19 | customSchemes: {} # custom schemas for types, example: { "hex-color": "Color" } 20 | excludeHeaders: {} # whether to exclude headers from the autogenerated requests, example: { ContentType: true } 21 | dependencies: 22 | - name: SwiftAPIClient 23 | github: dankinsoid/swift-api-client 24 | version: 1.1.0 25 | - name: SwiftJSON 26 | github: dankinsoid/swift-json 27 | version: 1.1.0 28 | templateFiles: 29 | - path: Sources/APIModule.swift 30 | destination: "{{options.name}}/{{options.name}}.swift" 31 | - path: Sources/Enum.swift 32 | context: enums 33 | destination: "Enums/{{ enumName }}.swift" 34 | - path: Sources/Model.swift 35 | context: schemas 36 | destination: "Models/{{ type }}.swift" 37 | - path: Sources/Request.swift 38 | context: operations 39 | destination: "{{options.name}}/Requests{% if tag %}/{{ tag|upperCamelCase }}{% endif %}/{{ type }}.swift" 40 | copiedFiles: ["Models", "APIClient"] 41 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientMacrosTests/APIMacroTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftAPIClientMacros 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import XCTest 5 | 6 | final class APIMacroTests: XCTestCase { 7 | 8 | private let macros: [String: Macro.Type] = [ 9 | "API": SwiftAPIClientPathMacro.self, 10 | ] 11 | 12 | func testExpansionAPI() { 13 | assertMacroExpansion( 14 | """ 15 | @API 16 | struct Pets { 17 | } 18 | """, 19 | expandedSource: """ 20 | struct Pets { 21 | 22 | public typealias Body = _APIParameterWrapper 23 | 24 | public typealias Query = _APIParameterWrapper 25 | 26 | public var client: APIClient 27 | 28 | public init(client: APIClient) { 29 | self.client = client 30 | } 31 | } 32 | """, 33 | macros: macros, 34 | indentationWidth: .spaces(2) 35 | ) 36 | } 37 | 38 | func testExpansionAPIWithInit() { 39 | assertMacroExpansion( 40 | """ 41 | @API 42 | struct Pets { 43 | init(client: APIClient) { 44 | self.client = client 45 | } 46 | } 47 | """, 48 | expandedSource: """ 49 | struct Pets { 50 | init(client: APIClient) { 51 | self.client = client 52 | } 53 | 54 | public typealias Body = _APIParameterWrapper 55 | 56 | public typealias Query = _APIParameterWrapper 57 | 58 | public var client: APIClient 59 | } 60 | """, 61 | macros: macros, 62 | indentationWidth: .spaces(2) 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientMacrosTests/PathMacroTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftAPIClientMacros 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import XCTest 5 | 6 | final class PathMacroTests: XCTestCase { 7 | 8 | private let macros: [String: Macro.Type] = [ 9 | "Path": SwiftAPIClientPathMacro.self, 10 | ] 11 | 12 | func testExpansionEmptyPath() { 13 | assertMacroExpansion( 14 | """ 15 | @Path 16 | struct Pets { 17 | } 18 | """, 19 | expandedSource: """ 20 | struct Pets { 21 | 22 | public typealias Body = _APIParameterWrapper 23 | 24 | public typealias Query = _APIParameterWrapper 25 | 26 | public var client: APIClient 27 | 28 | fileprivate init(client: APIClient) { 29 | self.client = client 30 | } 31 | } 32 | 33 | /// /pets 34 | var pets: Pets { 35 | Pets (client: client.path("pets")) 36 | } 37 | """, 38 | macros: macros, 39 | indentationWidth: .spaces(2) 40 | ) 41 | } 42 | 43 | func testExpansionPathWithString() { 44 | assertMacroExpansion( 45 | """ 46 | @Path("/some/long", "path", "/") 47 | struct Pets { 48 | } 49 | """, 50 | expandedSource: """ 51 | struct Pets { 52 | 53 | public typealias Body = _APIParameterWrapper 54 | 55 | public typealias Query = _APIParameterWrapper 56 | 57 | public var client: APIClient 58 | 59 | fileprivate init(client: APIClient) { 60 | self.client = client 61 | } 62 | } 63 | 64 | /// /some/long/path 65 | var pets: Pets { 66 | Pets (client: client.path("some", "long", "path")) 67 | } 68 | """, 69 | macros: macros, 70 | indentationWidth: .spaces(2) 71 | ) 72 | } 73 | 74 | func testExpansionPathWithArguments() { 75 | assertMacroExpansion( 76 | """ 77 | @Path("/some/{long}", "path", "{id: UUID}") 78 | struct Pets { 79 | } 80 | """, 81 | expandedSource: """ 82 | struct Pets { 83 | 84 | public typealias Body = _APIParameterWrapper 85 | 86 | public typealias Query = _APIParameterWrapper 87 | 88 | public var client: APIClient 89 | 90 | fileprivate init(client: APIClient) { 91 | self.client = client 92 | } 93 | } 94 | 95 | /// /some/{long}/path/{id: UUID} 96 | func pets(_ long: String, id: UUID) -> Pets { 97 | Pets (client: client.path("some", "\\(long)", "path", "\\(id)")) 98 | } 99 | """, 100 | macros: macros, 101 | indentationWidth: .spaces(2) 102 | ) 103 | } 104 | 105 | func testExpansionPathWithFunctions() { 106 | assertMacroExpansion( 107 | """ 108 | @Path 109 | struct Pets { 110 | @GET 111 | func pet() -> Pet {} 112 | } 113 | """, 114 | expandedSource: """ 115 | struct Pets { 116 | @GET 117 | @available(*, unavailable) @APICallFakeBuilder 118 | func pet() -> Pet {} 119 | 120 | public typealias Body = _APIParameterWrapper 121 | 122 | public typealias Query = _APIParameterWrapper 123 | 124 | public var client: APIClient 125 | 126 | fileprivate init(client: APIClient) { 127 | self.client = client 128 | } 129 | } 130 | 131 | /// /pets 132 | var pets: Pets { 133 | Pets (client: client.path("pets")) 134 | } 135 | """, 136 | macros: macros, 137 | indentationWidth: .spaces(2) 138 | ) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/EncodersTests/MultipartFormDataTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | import XCTest 4 | 5 | class MultipartFormDataTests: XCTestCase { 6 | 7 | private struct TestCase { 8 | let multipartFormData: MultipartFormData 9 | let expected: String 10 | } 11 | 12 | func testAsData() { 13 | let testCases: [UInt: TestCase] = [ 14 | #line: TestCase( 15 | multipartFormData: MultipartFormData( 16 | parts: [ 17 | MultipartFormData.Part( 18 | name: "field1", 19 | filename: nil, 20 | mimeType: nil, 21 | data: "value1".data(using: .utf8)! 22 | ), 23 | MultipartFormData.Part( 24 | name: "field2", 25 | filename: "example.txt", 26 | mimeType: .text(.plain), 27 | data: "value2".data(using: .utf8)! 28 | ), 29 | ], 30 | boundary: "boundary" 31 | ), 32 | expected: [ 33 | "--boundary", 34 | "Content-Disposition: form-data; name=\"field1\"", 35 | "", 36 | "value1", 37 | "--boundary", 38 | "Content-Disposition: form-data; name=\"field2\"; filename=\"example.txt\"", 39 | "Content-Type: text/plain", 40 | "", 41 | "value2", 42 | "--boundary--", 43 | ].joined(separator: "\r\n") + "\r\n" 44 | ), 45 | ] 46 | 47 | for (_, testCase) in testCases { 48 | let actual = String(data: testCase.multipartFormData.data, encoding: .utf8) 49 | let expected = testCase.expected 50 | 51 | XCTAssertEqual(actual, expected) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/AuthModifierTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SwiftAPIClient 3 | import XCTest 4 | #if canImport(FoundationNetworking) 5 | import FoundationNetworking 6 | #endif 7 | 8 | final class AuthModifierTests: XCTestCase { 9 | 10 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 11 | 12 | func testAuthEnabled() throws { 13 | let client = client.auth(.header("Bearer token")) 14 | let enabledClient = client.auth(enabled: true) 15 | let authorizedRequest = try enabledClient.request() 16 | XCTAssertEqual(authorizedRequest.headers[.authorization], "Bearer token") 17 | } 18 | 19 | func testAuthDisabled() throws { 20 | let client = client.auth(.header("Bearer token")) 21 | let disabledClient = client.auth(enabled: false) 22 | let unauthorizedRequest = try disabledClient.request() 23 | XCTAssertNil(unauthorizedRequest.headers[.authorization]) 24 | } 25 | 26 | func testAuthBearer() throws { 27 | let token = "token" 28 | let request = try client.auth(.bearer(token: token)).request() 29 | let header = request.headers[.authorization] 30 | XCTAssertEqual(header, "Bearer token") 31 | } 32 | 33 | func testAuthBasic() throws { 34 | let username = "username" 35 | let password = "password" 36 | let request = try client.auth(.basic(username: username, password: password)).request() 37 | let header = request.headers[.authorization] 38 | XCTAssertEqual(header, "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/ErrorDecodingTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | import XCTest 7 | 8 | final class ErrorDecodingTests: XCTestCase { 9 | 10 | func testErrorDecoding() throws { 11 | let errorJSON = Data(#"{"error": "test_error"}"#.utf8) 12 | do { 13 | let _ = try APIClient(baseURL: URL(string: "https://example.com")!) 14 | .errorDecoder(.decodable(ErrorResponse.self)) 15 | .call(.mock(errorJSON), as: .decodable(FakeResponse.self)) 16 | XCTFail() 17 | } catch { 18 | XCTAssertEqual(error.localizedDescription, "test_error") 19 | } 20 | } 21 | } 22 | 23 | struct FakeResponse: Codable { 24 | 25 | let anyValue: String 26 | } 27 | 28 | struct ErrorResponse: Codable, LocalizedError { 29 | 30 | var error: String? 31 | var errorDescription: String? { error } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/HTTPResponseValidatorTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | import XCTest 7 | 8 | final class HTTPResponseValidatorTests: XCTestCase { 9 | 10 | func testStatusCodeValidator() throws { 11 | let validator = HTTPResponseValidator.statusCode(200 ... 299) 12 | let response = HTTPResponse(status: 200) 13 | let data = Data() 14 | let configs = APIClient.Configs() 15 | 16 | // Validation should pass for a status code within the range 17 | XCTAssertNoThrow(try validator.validate(response, data, configs)) 18 | 19 | // Validation should throw an error for a status code outside the range 20 | let invalidResponse = HTTPResponse(status: 400) 21 | XCTAssertThrowsError(try validator.validate(invalidResponse, data, configs)) 22 | } 23 | 24 | func testAlwaysSuccessValidator() throws { 25 | let validator = HTTPResponseValidator.alwaysSuccess 26 | let response = HTTPResponse(status: 200) 27 | let data = Data() 28 | let configs = APIClient.Configs() 29 | 30 | // Validation should always pass without throwing any errors 31 | XCTAssertNoThrow(try validator.validate(response, data, configs)) 32 | } 33 | 34 | static var allTests = [ 35 | ("testStatusCodeValidator", testStatusCodeValidator), 36 | ("testAlwaysSuccessValidator", testAlwaysSuccessValidator), 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/LogLevelModifierTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | @testable import SwiftAPIClient 4 | #if canImport(FoundationNetworking) 5 | import FoundationNetworking 6 | #endif 7 | import XCTest 8 | 9 | final class LogLevelModifierTests: XCTestCase { 10 | 11 | func testLogLevel() { 12 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 13 | let modifiedClient = client.log(level: .debug) 14 | 15 | XCTAssertEqual(modifiedClient.configs().logLevel, .debug) 16 | } 17 | 18 | func testLogger() { 19 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 20 | let modifiedClient = client.log(level: .info) 21 | 22 | XCTAssertEqual(modifiedClient.configs().logger.logLevel, .info) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/MockResponsesTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SwiftAPIClient 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | import XCTest 7 | 8 | final class MockResponsesTests: XCTestCase { 9 | 10 | func testMock() throws { 11 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 12 | let mockValue = "Mock Response" 13 | let modifiedClient = client.mock(mockValue) 14 | 15 | try XCTAssertEqual(modifiedClient.usingMocks(policy: .ifSpecified).configs().getMockIfNeeded(for: String.self), mockValue) 16 | try XCTAssertEqual(modifiedClient.usingMocks(policy: .ignore).configs().getMockIfNeeded(for: String.self), nil) 17 | XCTAssertThrowsError(try modifiedClient.usingMocks(policy: .require).configs().getMockIfNeeded(for: Int.self)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/RequestCompressionTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(zlib) 2 | import Foundation 3 | @testable import SwiftAPIClient 4 | #if canImport(FoundationNetworking) 5 | import FoundationNetworking 6 | #endif 7 | import XCTest 8 | 9 | final class APIClientCompressionTests: XCTestCase { 10 | 11 | @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) 12 | func testThatRequestCompressorProperlyCalculatesAdler32() async throws { 13 | let client = APIClient.test.compressRequest() 14 | let body: Data = try await client 15 | .post 16 | .body(Data([0])) 17 | .httpTest { request, _ in 18 | request.body!.data! 19 | } 20 | // From https://en.wikipedia.org/wiki/Adler-32 21 | XCTAssertEqual(body, Data([0x78, 0x5E, 0x63, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01])) 22 | } 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/Modifiers/RequestModifiersTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SwiftAPIClient 3 | import XCTest 4 | #if canImport(FoundationNetworking) 5 | import FoundationNetworking 6 | #endif 7 | 8 | final class RequestModifiersTests: XCTestCase { 9 | 10 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 11 | 12 | func testPathAppending() throws { 13 | 14 | let modifiedClient = client.path("users", "123") 15 | 16 | try XCTAssertEqual(modifiedClient.request().url?.absoluteString, "https://example.com/users/123") 17 | } 18 | 19 | func testMethodSetting() throws { 20 | let modifiedClient = client.method(.post) 21 | 22 | try XCTAssertEqual(modifiedClient.request().method, .post) 23 | } 24 | 25 | func testHeadersAdding() throws { 26 | let modifiedClient = client.headers( 27 | .accept(.application(.json)), 28 | .contentType(.application(.json)) 29 | ) 30 | 31 | try XCTAssertEqual(modifiedClient.request().headers[.accept], "application/json") 32 | try XCTAssertEqual(modifiedClient.request().headers[.contentType], "application/json") 33 | } 34 | 35 | func testHeaderRemoving() throws { 36 | let modifiedClient = client 37 | .headers(.accept(.application(.json))) 38 | .removeHeader(.accept) 39 | 40 | try XCTAssertNil(modifiedClient.request().headers[.accept]) 41 | } 42 | 43 | func testHeaderUpdating() throws { 44 | let client = APIClient(baseURL: URL(string: "https://example.com")!) 45 | 46 | let modifiedClient = client 47 | .headers(HTTPField.accept(.application(.json))) 48 | .headers(HTTPField.accept(.application(.xml)), removeCurrent: true) 49 | 50 | try XCTAssertEqual(modifiedClient.request().headers[.accept], "application/xml") 51 | } 52 | 53 | func testBodySetting() throws { 54 | let modifiedClient = client.body(["name": "John"]) 55 | let body = try modifiedClient.request().body?.data 56 | XCTAssertNotNil(body) 57 | XCTAssertEqual(body, try? JSONSerialization.data(withJSONObject: ["name": "John"])) 58 | try XCTAssertEqual(modifiedClient.request().headers[.contentType], "application/json") 59 | } 60 | 61 | func testQueryParametersAdding() throws { 62 | let modifiedClient = client.query("page", "some parameter ❤️") 63 | 64 | try XCTAssertEqual(modifiedClient.request().url?.absoluteString, "https://example.com?page=some%20parameter%20%E2%9D%A4%EF%B8%8F") 65 | } 66 | 67 | func testBaseURLSetting() throws { 68 | let modifiedClient = client.query("test", "value").baseURL(URL(string: "http://test.net")!) 69 | try XCTAssertEqual(modifiedClient.request().url?.absoluteString, "http://test.net?test=value") 70 | } 71 | 72 | func testRemoveSlashIfNeeded() throws { 73 | let modifiedClient = client.query("test", "value") 74 | try XCTAssertEqual(modifiedClient.request().url?.absoluteString, "https://example.com?test=value") 75 | } 76 | 77 | func testSchemeSetting() throws { 78 | let modifiedClient = client.scheme("http") 79 | 80 | try XCTAssertEqual(modifiedClient.request().url?.scheme, "http") 81 | } 82 | 83 | func testHostSetting() throws { 84 | let modifiedClient = client.host("api.example.com") 85 | 86 | try XCTAssertEqual(modifiedClient.request().url?.host, "api.example.com") 87 | } 88 | 89 | func testPortSetting() throws { 90 | let modifiedClient = client.port(8080) 91 | 92 | try XCTAssertEqual(modifiedClient.request().url?.port, 8080) 93 | } 94 | 95 | func testTimeoutIntervalSetting() throws { 96 | let modifiedClient = client.timeout(30) 97 | XCTAssertEqual(modifiedClient.withConfigs(\.timeoutInterval), 30) 98 | } 99 | 100 | func testPathWithQuery() throws { 101 | let modifiedClient = client.path("test?offset=100") 102 | try XCTAssertEqual(modifiedClient.request().url?.absoluteString, "https://example.com/test?offset=100") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/NetworkClientTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | @testable import SwiftAPIClient 4 | import XCTest 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | final class APIClientTests: XCTestCase { 10 | 11 | func testInitWithBaseURL() throws { 12 | let url = URL(string: "https://example.com")! 13 | let client = APIClient(baseURL: url) 14 | let request = try client.request() 15 | XCTAssertEqual(request, HTTPRequestComponents(url: url)) 16 | } 17 | 18 | func testInitWithRequest() throws { 19 | let request = HTTPRequestComponents(url: URL(string: "https://example.com")!) 20 | let client = APIClient(request: request) 21 | let resultRequest = try client.request() 22 | XCTAssertEqual(request, resultRequest) 23 | } 24 | 25 | func testModifyRequest() throws { 26 | let method: HTTPRequest.Method = .patch 27 | let client = APIClient.test 28 | .modifyRequest { request in 29 | request.method = method 30 | } 31 | let request = try client.request() 32 | XCTAssertEqual(request.method, method) 33 | } 34 | 35 | func testWithRequest() throws { 36 | let client = APIClient.test 37 | let result = try client.withRequest { request, _ in 38 | request.url?.absoluteString 39 | } 40 | XCTAssertEqual(result, "https://example.com") 41 | } 42 | 43 | func testWithConfigs() throws { 44 | let client = APIClient.test 45 | let enabled = client 46 | .configs(\.testValue, true) 47 | .withConfigs(\.testValue) 48 | 49 | XCTAssertTrue(enabled) 50 | 51 | let disabled = client 52 | .configs(\.testValue, false) 53 | .withConfigs(\.testValue) 54 | 55 | XCTAssertFalse(disabled) 56 | } 57 | 58 | func testConfigsOrder() throws { 59 | let client = APIClient.test 60 | let (request, configs) = try client 61 | .configs(\.intValue, 1) 62 | .query { 63 | [URLQueryItem(name: "0", value: "\($0.intValue)")] 64 | } 65 | .configs(\.intValue, 2) 66 | .query { 67 | [URLQueryItem(name: "1", value: "\($0.intValue)")] 68 | } 69 | .configs(\.intValue, 3) 70 | .query { 71 | [URLQueryItem(name: "2", value: "\($0.intValue)")] 72 | } 73 | .withRequest { request, configs in 74 | (request, configs) 75 | } 76 | 77 | XCTAssertEqual(request.url?.query, "0=3&1=3&2=3") 78 | XCTAssertEqual(configs.intValue, 3) 79 | } 80 | 81 | func testConfigs() throws { 82 | let enabled = APIClient.test 83 | .configs(\.testValue, true) 84 | .withConfigs(\.testValue) 85 | 86 | XCTAssertTrue(enabled) 87 | 88 | let disabled = APIClient.test 89 | .configs(\.testValue, false) 90 | .withConfigs(\.testValue) 91 | 92 | XCTAssertFalse(disabled) 93 | } 94 | } 95 | 96 | extension APIClient.Configs { 97 | 98 | var testValue: Bool { 99 | get { self[\.testValue] ?? false } 100 | set { self[\.testValue] = newValue } 101 | } 102 | 103 | var intValue: Int { 104 | get { self[\.intValue] ?? 0 } 105 | set { self[\.intValue] = newValue } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/TestUtils/Client+Ext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | extension APIClient { 8 | 9 | static var test: APIClient { 10 | APIClient(baseURL: URL(string: "https://example.com")!) 11 | } 12 | 13 | func configs() -> Configs { 14 | withConfigs { $0 } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/TestUtils/TestHTTPClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftAPIClient 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | extension HTTPClient { 8 | 9 | static func test() -> HTTPClient { 10 | HTTPClient { request, configs in 11 | try configs.testHTTPClient(request, configs) 12 | } 13 | } 14 | } 15 | 16 | private extension APIClient.Configs { 17 | 18 | var testHTTPClient: (HTTPRequestComponents, APIClient.Configs) throws -> (Data, HTTPResponse) { 19 | get { self[\.testHTTPClient] ?? { _, _ in throw Unimplemented() } } 20 | set { self[\.testHTTPClient] = newValue } 21 | } 22 | } 23 | 24 | private struct Unimplemented: Error {} 25 | 26 | extension APIClient { 27 | 28 | @discardableResult 29 | func httpTest( 30 | test: @escaping (HTTPRequestComponents, APIClient.Configs) throws -> Void = { _, _ in } 31 | ) async throws -> Data { 32 | try await httpTest { 33 | try test($0, $1) 34 | return Data() 35 | } 36 | } 37 | 38 | @discardableResult 39 | func httpTest( 40 | test: @escaping (HTTPRequestComponents, APIClient.Configs) throws -> (Data, HTTPResponse) 41 | ) async throws -> Data { 42 | try await configs(\.testHTTPClient) { 43 | try test($0, $1) 44 | } 45 | .httpClient(.test()) 46 | .call(.http) 47 | } 48 | 49 | @discardableResult 50 | func httpTest( 51 | test: @escaping (HTTPRequestComponents, APIClient.Configs) throws -> Data 52 | ) async throws -> Data { 53 | try await httpTest { 54 | let data = try test($0, $1) 55 | return (data, HTTPResponse(status: .ok)) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/UtilsTests/URLComponentBuilderTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | @testable import SwiftAPIClient 5 | 6 | class URLComponentBuilderTests: XCTestCase { 7 | 8 | // MARK: - URLComponents Tests 9 | 10 | func testURLComponentsConfigureURLComponents() { 11 | let components = URLComponents() 12 | let result = components.configureURLComponents { components in 13 | components.scheme = "https" 14 | components.host = "example.com" 15 | } 16 | XCTAssertEqual(result.scheme, "https") 17 | XCTAssertEqual(result.host, "example.com") 18 | } 19 | 20 | func testURLComponentsPath() { 21 | let components = URLComponents() 22 | let result = components.path("path1", "path2") 23 | XCTAssertEqual(result.path, "/path1/path2") 24 | } 25 | 26 | func testURLComponentsQuery() throws { 27 | let components = URLComponents() 28 | let result = components.query(["key1": "value1", "key2": 2]) 29 | XCTAssertEqual(result.queryItems?.count, 2) 30 | XCTAssertEqual(result.queryItems?.first?.name, "key1") 31 | XCTAssertEqual(result.queryItems?.first?.value, "value1") 32 | } 33 | 34 | // MARK: - URL Tests 35 | 36 | func testURLConfigureURLComponents() { 37 | let url = URL(string: "https://example.com")! 38 | let result = url.configureURLComponents { components in 39 | components.path = "/test" 40 | } 41 | XCTAssertEqual(result.path, "/test") 42 | } 43 | 44 | func testURLPath() { 45 | let url = URL(string: "https://example.com")!.query("service", "spotify") 46 | let result = url.path("path1", "path2") 47 | XCTAssertEqual(result.path, "/path1/path2") 48 | } 49 | 50 | func testURLQuery() throws { 51 | let url = URL(string: "https://example.com")! 52 | let result = url.query(["key1": "value1", "key2": 2]) 53 | XCTAssertEqual(result.query, "key1=value1&key2=2") 54 | } 55 | 56 | // MARK: - HTTPRequestComponents Tests 57 | 58 | func testHTTPRequestComponentsConfigureURLComponents() { 59 | let requestComponents = HTTPRequestComponents() 60 | let result = requestComponents.configureURLComponents { components in 61 | components.scheme = "https" 62 | components.host = "example.com" 63 | } 64 | XCTAssertEqual(result.urlComponents.scheme, "https") 65 | XCTAssertEqual(result.urlComponents.host, "example.com") 66 | } 67 | 68 | func testHTTPRequestComponentsPath() { 69 | let requestComponents = HTTPRequestComponents() 70 | let result = requestComponents.path("path1", "path2") 71 | XCTAssertEqual(result.urlComponents.path, "/path1/path2") 72 | } 73 | 74 | func testHTTPRequestComponentsQuery() throws { 75 | let requestComponents = HTTPRequestComponents() 76 | let result = requestComponents.query(["key1": "value1", "key2": 2]) 77 | XCTAssertEqual(result.urlComponents.queryItems?.count, 2) 78 | XCTAssertEqual(result.urlComponents.queryItems?.first?.name, "key1") 79 | XCTAssertEqual(result.urlComponents.queryItems?.first?.value, "value1") 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/SwiftAPIClientTests/UtilsTests/WithTimeoutTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SwiftAPIClient 3 | import XCTest 4 | 5 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 6 | final class WithTimeoutTests: XCTestCase { 7 | 8 | func test_withTimeout_fast() async throws { 9 | let result = try await withTimeout(1) { 42 } 10 | XCTAssertEqual(result, 42) 11 | } 12 | 13 | func test_withTimeout_zero_interval() async { 14 | do { 15 | _ = try await withTimeout(0) { 42 } 16 | XCTFail("Expected timeout error") 17 | } catch { 18 | XCTAssertTrue(error is TimeoutError) 19 | } 20 | } 21 | 22 | #if swift(>=5.9) 23 | func test_withTimeout_timeout() async { 24 | do { 25 | _ = try await withTimeout(.milliseconds(5)) { 26 | try await ContinuousClock().sleep(until: ContinuousClock().now.advanced(by: .seconds(1))) 27 | return 28 | } 29 | XCTFail("Expected timeout error") 30 | } catch { 31 | XCTAssertTrue(error is TimeoutError) 32 | } 33 | } 34 | 35 | func test_withTimeout_success() async throws { 36 | _ = try await withTimeout(.seconds(1)) { 37 | try await ContinuousClock().sleep(until: ContinuousClock().now.advanced(by: .milliseconds(5))) 38 | return 39 | } 40 | } 41 | #endif 42 | } 43 | --------------------------------------------------------------------------------