diff --git a/.github/workflows/macos-14.yml b/.github/workflows/ci.yml similarity index 57% rename from .github/workflows/macos-14.yml rename to .github/workflows/ci.yml index 1a62baf..366919d 100644 --- a/.github/workflows/macos-14.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: macOS 14 +name: CI on: push: @@ -10,20 +10,15 @@ on: jobs: test: - runs-on: macos-14 + runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install SwiftLint run: brew install swiftlint - name: Lint run: | swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint --allow-warnings - name: Swift build & test run: | swift build diff --git a/.github/workflows/macos-15.yml b/.github/workflows/macos-15.yml deleted file mode 100644 index d5cc92c..0000000 --- a/.github/workflows/macos-15.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: macOS 15 - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - -jobs: - test: - runs-on: macos-15 - - steps: - - uses: actions/checkout@v4 - - name: Install SwiftLint - run: brew install swiftlint - - name: Lint - run: | - swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint --allow-warnings - - name: Swift build & test - run: | - swift build - swift test diff --git a/.github/workflows/ubuntu-latest.yml b/.github/workflows/ubuntu-latest.yml deleted file mode 100644 index b1b80df..0000000 --- a/.github/workflows/ubuntu-latest.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Ubuntu - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - -jobs: - ubuntu-swift-latest: - runs-on: ubuntu-latest - steps: - - name: Print Swift version to confirm - run: swift --version - - - name: Checkout FTAPIKit - uses: actions/checkout@v2 - - - name: Swift build & test - run: | - swift build - swift test diff --git a/.gitignore b/.gitignore index 19b2ba4..c6115e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,6 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## Build generated build/ DerivedData/ -docs/ - -## Various settings *.pbxuser !default.pbxuser *.mode1v3 @@ -16,60 +9,29 @@ docs/ !default.mode2v3 *.perspectivev3 !default.perspectivev3 -xcuserdata/ - -## Other +**/xcuserdata/ *.moved-aside *.xccheckout *.xcscmblueprint - -## Obj-C/Swift specific *.hmap *.ipa *.dSYM.zip *.dSYM -## Playgrounds +# Playgrounds timeline.xctimeline playground.xcworkspace +# macOS +.DS_Store + # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -Package.resolved -.swiftpm .build/ -# We do not need the project, because it can be generated or the Package.swift can be open in Xcode 11 +.swiftpm/ +Package.resolved FTAPIKit.xcodeproj -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/README.md +# Claude Code +.claude -# Ignore VSCode files -.vscode +*.xcuserstate diff --git a/.swiftlint.yml b/.swiftlint.yml index c94bd9a..4331da7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,19 +1,11 @@ disabled_rules: - line_length - # Combination of SwiftLint 0.43.1 and Xcode 12.5.1 - # warns about false positives for this rule - - empty_xctest_method - # Type inference of `await try` methods - # does not work well in Xcode 13 Beta - - implicit_return # Does not work with URL query literals. - duplicated_key_in_dictionary_literal excluded: - - Pods - .build - Templates opt_in_rules: - - anyobject_protocol - array_init - attributes - closure_body_length @@ -26,12 +18,10 @@ opt_in_rules: - contains_over_first_not_nil - contains_over_range_nil_comparison - convenience_type - - discouraged_object_literal - discouraged_optional_boolean - empty_collection_literal - empty_count - empty_string - - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough @@ -55,22 +45,17 @@ opt_in_rules: - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - - nimble_operator - number_separator - - object_literal - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension - pattern_matching_keywords - prefer_self_type_over_type_of_self - - private_action - - private_outlet - prohibited_super_call - reduce_into - redundant_nil_coalescing - required_enum_case - - single_test_class - sorted_first_last - sorted_imports - static_operator @@ -80,12 +65,9 @@ opt_in_rules: - trailing_closure - unneeded_parentheses_in_closure_argument - untyped_error_in_catch - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - yoda_condition - # Rule configurations identifier_name: excluded: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c69648 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FTAPIKit is a declarative async/await REST API framework for Swift using Swift Concurrency and Codable. It provides a protocol-oriented approach to defining web services with standard implementation using URLSession and JSON encoder/decoder. The framework is built for Swift 6.1 with full concurrency safety. + +**Key Features:** +- Declarative async/await API for defining web services +- Protocol-oriented design with `URLServer` and `Endpoint` protocols +- Multiple endpoint types for different use cases (GET, POST, multipart uploads, etc.) +- Async buildRequest enabling token refresh, dynamic configuration, and rate limiting +- `RequestConfiguring` protocol for per-request configuration at call site +- `NetworkObserver` protocol for request lifecycle monitoring (logging, analytics) +- Swift 6 concurrency safety with Sendable requirements +- Cross-platform support: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+ + +## Build and Test Commands + +### Building +```bash +# Use xcodebuild (preferred, avoids toolchain mismatch issues) +xcodebuild build -scheme FTAPIKit -destination 'platform=macOS' + +# Or with Swift CLI +swift build +``` + +### Running Tests +```bash +# Use xcodebuild +xcodebuild test -scheme FTAPIKit -destination 'platform=macOS' + +# Or with Swift CLI +swift test +``` + +### Linting +```bash +# Run SwiftLint with strict mode +swiftlint --strict +``` + +The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with many opt-in rules enabled. Linting must pass with `--strict` flag with zero violations before committing any code. + +## Architecture + +### Core Protocol Design + +The framework is built around two core protocols: + +1. **`URLServer` Protocol** - Represents a single web service + - Defines `baseUri`, `urlSession`, `encoding`/`decoding`, `networkObservers` + - Builds requests from endpoints via `buildRequest(endpoint:) async throws` + - Provides default implementations for all properties except `baseUri` + - Has `ErrorType` associated type (defaults to `APIError.Standard`) + +2. **`Endpoint` Protocol** - Represents access points for resources + - Defines path, headers, query parameters, and HTTP method + - Multiple specialized variants for different use cases + +### Endpoint Type Hierarchy + +- **`Endpoint`** - Base protocol with empty body (typically for GET requests) +- **`DataEndpoint`** - Sends raw data in body +- **`UploadEndpoint`** - Uploads files using URLSession upload +- **`MultipartEndpoint`** - Combines body parts into multipart request +- **`URLEncodedEndpoint`** - Body in URL query format +- **`RequestEndpoint`** - Has encodable request model (defaults to POST) +- **`ResponseEndpoint`** - Has decodable response model +- **`RequestResponseEndpoint`** - Typealias combining request and response endpoints + +### Key Architectural Patterns + +**URLServer Protocol**: The `URLServer` protocol provides all URLSession-based functionality with default implementations. Only `baseUri` must be provided by conforming types. + +**Network Observers**: The `NetworkObserver` protocol provides lifecycle callbacks (`willSendRequest`, `didReceiveResponse`, `didFail`) with type-safe context passing. Observer integration uses `BoundObserverContext` (private) for type erasure. + +**Request Configuration**: The `RequestConfiguring` protocol allows per-request async configuration at the call site, separate from server-level `buildRequest`. + +**Encoding/Decoding**: The `Encoding` protocol includes `configure(request:)` for setting content-type headers (with empty default). Both `Encoding` and `Decoding` require `Sendable`. + +**Swift 6 Concurrency Safety**: All `ResponseEndpoint` associated types must conform to `Sendable`. + +### Module Organization + +**Source Structure** (`Sources/FTAPIKit/`): +- Core protocols: `URLServer.swift`, `Endpoint.swift` +- Request building: `URLRequestBuilder.swift`, `RequestConfiguring.swift` +- Async execution: `URLServer+Async.swift` (includes download) +- Observers: `NetworkObserver.swift` +- Utilities: `Coding.swift`, `URLQuery.swift`, `MultipartFormData.swift`, etc. +- Error handling: `APIError.swift`, `APIError+Standard.swift` + +**Test Structure** (`Tests/FTAPIKitTests/`): +- Uses Swift Testing framework (`@Suite`, `@Test`, `#expect`) +- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift`, `NetworkObserverTests.swift`, `RequestConfiguringTests.swift`, `EndpointTypeTests.swift`, `ErrorHandlingTests.swift` +- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift`, `MockNetworkObserver.swift`, `MockTokenManager.swift`, `HTTPBinResponse.swift` + +### Call Execution Pattern + +The framework uses async/await exclusively: + +```swift +// Basic call +let response = try await server.call(response: endpoint) + +// Data call (raw Data response) +let data = try await server.call(data: endpoint) + +// Void call (no response body) +try await server.call(endpoint: endpoint) + +// Download call +let fileURL = try await server.download(endpoint: endpoint) +``` + +### Error Handling + +- `APIError` protocol defines error handling interface +- Default implementation: `APIError.Standard` (enum with connection, encoding, decoding, server, client, unhandled cases) +- Network errors (`URLError`) and decoding errors are routed through `ErrorType` for consistent error handling +- Encoding errors (from `buildStandardRequest`) propagate directly as `EncodingError` since they occur before the network request +- Custom error types can be defined via `URLServer.ErrorType` associated type + +## Package Management + +The project uses **Swift Package Manager** exclusively. See `Package.swift`. + +### Platform Support + +Minimum deployment targets: +- iOS 15+ +- macOS 12+ +- tvOS 15+ +- watchOS 8+ + +## Testing Approach + +Tests use Swift Testing framework and mock servers (HTTPBin-based) defined in `Tests/FTAPIKitTests/Mockups/Servers.swift`: +- `HTTPBinServer` - Standard test server with async authorization support +- `NonExistingServer` - For testing error conditions +- `ErrorThrowingServer` - Custom error type testing +- `HTTPBinServerWithObservers` - Observer integration testing + +## CI/CD + +Single GitHub Actions workflow (`ci.yml`) runs on `macos-latest`: +- `swiftlint --strict` +- `swift build` +- `swift test` diff --git a/FTAPIKit.podspec b/FTAPIKit.podspec deleted file mode 100644 index a940814..0000000 --- a/FTAPIKit.podspec +++ /dev/null @@ -1,28 +0,0 @@ -Pod::Spec.new do |s| - s.name = "FTAPIKit" - s.version = "1.5.0" - s.summary = "Declarative, generic and protocol-oriented REST API framework using URLSession and Codable" - s.description = <<-DESC - Protocol-oriented framework for communication with REST APIs. - Endpoint protocols describe the API resource access points - and the requests/responses codable types. Server protocol describes web services - and enables the user to call endoints in a type-safe manner. - DESC - s.homepage = "https://github.com/futuredapp/FTAPIKit" - s.license = { type: "MIT", file: "LICENSE" } - s.author = { "Matěj Kašpar Jirásek": "matej.jirasek@futured.app" } - s.social_media_url = "https://twitter.com/Futuredapps" - - s.source = { git: "https://github.com/futuredapp/FTAPIKit.git", tag: s.version.to_s } - s.source_files = "Sources/FTAPIKit/**/*" - s.exclude_files = "Sources/FTAPIKit/Documentation.docc/**/*" - - s.frameworks = ["Foundation", "CoreServices"] - s.weak_frameworks = ["Combine"] - - s.swift_version = "5.1" - s.ios.deployment_target = "12.0" - s.osx.deployment_target = "10.13" - s.watchos.deployment_target = "5.0" - s.tvos.deployment_target = "12.0" -end diff --git a/Gemfile b/Gemfile deleted file mode 100644 index c268956..0000000 --- a/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "cocoapods", "~> 1.14" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index d16c54b..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,112 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.8) - activesupport (7.2.3) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.0.1) - claide (1.1.0) - cocoapods (1.16.2) - addressable (~> 2.8) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.16.2) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 2.1, < 3.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.6.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.8.0) - nap (~> 1.0) - ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.27.0, < 2.0) - cocoapods-core (1.16.2) - activesupport (>= 5.0, < 8) - addressable (~> 2.8) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix (~> 4.0) - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.5) - cocoapods-downloader (2.1) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.1) - cocoapods-trunk (1.6.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) - drb (2.2.3) - escape (0.0.4) - ethon (0.15.0) - ffi (>= 1.15.0) - ffi (1.17.3) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.9.0) - mutex_m - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.18.0) - logger (1.7.0) - minitest (6.0.1) - prism (~> 1.5) - molinillo (0.8.0) - mutex_m (0.3.0) - nanaimo (0.4.0) - nap (1.1.0) - netrc (0.11.0) - prism (1.8.0) - public_suffix (4.0.7) - rexml (3.4.4) - ruby-macho (2.5.1) - securerandom (0.4.1) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - xcodeproj (1.27.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.4.0) - rexml (>= 3.3.6, < 4.0) - -PLATFORMS - ruby - -DEPENDENCIES - cocoapods (~> 1.14) - -BUNDLED WITH - 4.0.4 diff --git a/Package.swift b/Package.swift index b88a739..a861bb4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,14 +1,14 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.1 import PackageDescription let package = Package( name: "FTAPIKit", platforms: [ - .iOS(.v14), - .macOS(.v11), - .tvOS(.v14), - .watchOS(.v7) + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8) ], products: [ .library( diff --git a/README.md b/README.md index ef38445..2da6b84 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,25 @@ # FTAPIKit -![Cocoapods](https://img.shields.io/cocoapods/v/FTAPIKit) -![Cocoapods platforms](https://img.shields.io/cocoapods/p/FTAPIKit) -![License](https://img.shields.io/cocoapods/l/FTAPIKit) +![License](https://img.shields.io/github/license/futuredapp/FTAPIKit) -![macOS 15](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-15.yml/badge.svg?branch=main) -![macOS 14](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-14.yml/badge.svg?branch=main) -![Ubuntu](https://github.com/futuredapp/FTAPIKit/actions/workflows/ubuntu-latest.yml/badge.svg?branch=main) +![CI](https://github.com/futuredapp/FTAPIKit/actions/workflows/ci.yml/badge.svg?branch=main) -Declarative and generic REST API framework using Codable. -With standard implementation using URLSesssion and JSON encoder/decoder. -Easily extensible for your asynchronous framework or networking stack. -The repository also contains set of Xcode templates, see `Installation`. +Declarative async/await REST API framework using Swift Concurrency and Codable. +With standard implementation using URLSession and JSON encoder/decoder. +Built for Swift 6.1+ with full concurrency safety. -## Installation - -When using Swift package manager install using Xcode 11+ -or add following line to your dependencies: - -```swift -.package(url: "https://github.com/futuredapp/FTAPIKit.git", from: "1.5.0") -``` +## Requirements -When using CocoaPods add following line to your `Podfile`: +- Swift 6.1+ +- iOS 15+, macOS 12+, tvOS 15+, watchOS 8+ -```ruby -pod 'FTAPIKit', '~> 1.5' -``` +## Installation -The repository includes an API endpoint Xcode template for user convenience. You can install it using make: +Add the following line to your Swift Package Manager dependencies: -```bash -git clone --depth=1 https://github.com/futuredapp/FTAPIKit.git -cd Templates -make +```swift +.package(url: "https://github.com/futuredapp/FTAPIKit.git", from: "2.0.0") ``` ## Features @@ -46,14 +31,11 @@ and protocol-oriented programming in Swift. The framework provides two core protocols reflecting the physical infrastructure: -- `Server` protocol defining single web service. +- `URLServer` protocol defining single web service with built-in URLSession support. - `Endpoint` protocol defining access points for resources. -Combining instances of type conforming to `Server` and `Endpoint` we can build request. -`URLServer` has convenience method for calling endpoints using `URLSession`. -If some advanced features are required then we recommend implementing API client. -This client should encapsulate logic which is not provided by this framework -(like signing authorized endpoints or conforming to `URLSessionDelegate`). +Combining instances of type conforming to `URLServer` and `Endpoint` we can build request. +`URLServer` has convenience methods for calling endpoints using `URLSession`. ![Architecture](Sources/FTAPIKit/Documentation.docc/Resources/Architecture.png) @@ -63,12 +45,13 @@ are separated in various protocols for convenience. - `Endpoint` protocol has empty body. Typically used in `GET` endpoints. - `DataEndpoint` sends provided data in body. -- `UploadEndpoint` uploads file using `InputStream`. +- `UploadEndpoint` uploads file from a URL using `URLSession` upload task. - `MultipartEndpoint` combines body parts into `InputStream` and sends them to server. Body parts are represented by `MultipartBodyPart` struct and provided to the endpoint in an array. +- `URLEncodedEndpoint` sends body in URL query format. - `RequestEndpoint` has encodable request which is encoded using encoding - of the `Server` instance. + of the `URLServer` instance. ![Endpoint types](Sources/FTAPIKit/Documentation.docc/Resources/Endpoints.svg) @@ -106,7 +89,7 @@ authorization we can override default request building mechanism. ```swift struct HTTPBinServer: URLServer { ... - func buildRequest(endpoint: Endpoint) throws -> URLRequest { + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { var request = try buildStandardRequest(endpoint: endpoint) request.addValue("MyApp/1.0.0", forHTTPHeaderField: "User-Agent") return request @@ -117,7 +100,7 @@ struct HTTPBinServer: URLServer { ### Defining endpoints Most basic `GET` endpoint can be implemented using `Endpoint` protocol, -all default propertires are inferred. +all default properties are inferred. ```swift struct GetEndpoint: Endpoint { @@ -139,19 +122,128 @@ struct UpdateUserEndpoint: RequestResponseEndpoint { ### Executing the request -When we have server and enpoint defined we can call the web service: +When we have server and endpoint defined we can call the web service using async/await: ```swift let server = HTTPBinServer() let endpoint = UpdateUserEndpoint(request: user) -server.call(response: endpoint) { result in - switch result { - case .success(let updatedUser): - ... - case .failure(let error): - ... + +let updatedUser = try await server.call(response: endpoint) +``` + +### Async buildRequest + +One of the key features in FTAPIKit 2.0 is the ability to use async operations in `buildRequest`. This enables use cases like: + +- **Token Refresh**: Await token refresh before building the request +- **Dynamic Configuration**: Fetch configuration or headers asynchronously +- **Rate Limiting**: Implement delays or throttling + +Example with async token refresh: + +```swift +struct MyServer: URLServer { + let baseUri = URL(string: "https://api.example.com")! + let tokenManager: TokenManager + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + // Refresh token if needed + await tokenManager.refreshIfNeeded() + + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(tokenManager.token)", forHTTPHeaderField: "Authorization") + return request + } +} +``` + +### Request Configuration at Call Site + +For scenarios where you need to configure requests at the call site (rather than in the server), +use the `RequestConfiguring` protocol. This is useful for: + +- Adding authorization headers in an API service layer +- Per-request configuration that varies by context +- Keeping server implementations simple and reusable + +```swift +struct AuthorizedConfiguration: RequestConfiguring { + let authService: AuthService + + func configure(_ request: inout URLRequest) async throws { + let token = try await authService.getValidAccessToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } } + +// Usage - configuration is optional with nil default +let server = HTTPBinServer() +let authConfig = AuthorizedConfiguration(authService: authService) + +// Public endpoint - no configuration needed +let publicData = try await server.call(response: publicEndpoint) + +// Protected endpoint - with configuration +let protectedData = try await server.call(response: protectedEndpoint, configuring: authConfig) +``` + +### Network Observers + +Monitor request lifecycle with the `NetworkObserver` protocol: + +```swift +final class LoggingObserver: NetworkObserver { + func willSendRequest(_ request: URLRequest) -> String { + let id = UUID().uuidString + print("[\(id)] Sending: \(request.url!)") + return id + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: String) { + print("[\(context)] Received response") + } + + func didFail(request: URLRequest, error: Error, context: String) { + print("[\(context)] Failed: \(error)") + } +} + +struct MyServer: URLServer { + let baseUri = URL(string: "https://api.example.com")! + let networkObservers: [any NetworkObserver] = [LoggingObserver()] +} +``` + +### Error Handling + +The framework uses the `APIError` protocol for error handling. The default implementation `APIError.Standard` covers common cases: + +```swift +do { + let response = try await server.call(response: endpoint) +} catch let error as APIError.Standard { + switch error { + case .connection(let urlError): + // Network connectivity issue + case .client(let statusCode, _, _): + // 4xx client error + case .server(let statusCode, _, _): + // 5xx server error + case .decoding(let decodingError): + // Response parsing failed + default: + break + } +} +``` + +For custom error parsing, define a type conforming to `APIError` and set it as the `ErrorType` on your server: + +```swift +struct MyServer: URLServer { + typealias ErrorType = MyCustomError + let baseUri = URL(string: "https://api.example.com")! +} ``` ## Contributors diff --git a/Sources/FTAPIKit/APIError+Standard.swift b/Sources/FTAPIKit/APIError+Standard.swift index 0dd263f..d5584a6 100644 --- a/Sources/FTAPIKit/APIError+Standard.swift +++ b/Sources/FTAPIKit/APIError+Standard.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Standard API error returned when no custom error /// was parsed and the response from server /// was invalid. diff --git a/Sources/FTAPIKit/APIError.swift b/Sources/FTAPIKit/APIError.swift index b71259c..d9f8794 100644 --- a/Sources/FTAPIKit/APIError.swift +++ b/Sources/FTAPIKit/APIError.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Error protocol used in types conforming to ``URLServer`` protocol. Default implementation called ``APIErrorStandard`` /// is provided. A type conforming to ``APIError`` protocol can be provided to ``URLServer`` /// to use custom error handling. diff --git a/Sources/FTAPIKit/Coding.swift b/Sources/FTAPIKit/Coding.swift index 5b61627..52cdcc7 100644 --- a/Sources/FTAPIKit/Coding.swift +++ b/Sources/FTAPIKit/Coding.swift @@ -1,31 +1,31 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// `Encoding` represents Swift encoders and provides network-specific features, such as configuring /// the request with correct headers. -public protocol Encoding { +public protocol Encoding: Sendable { - /// Encodes the argument + /// Encodes the argument. func encode(_ object: T) throws -> Data -} -/// Protocol which enables use of any decoder using type-erasure. -public protocol Decoding { - func decode(data: Data) throws -> T -} - -/// Protocol extending encoding with ability to configure `URLRequest`. Used when encoding endpoints in ``URLServer`` calls. -public protocol URLRequestEncoding: Encoding { /// Allows modification of `URLRequest`. Enables things like adding `Content-Type` header etc. + /// + /// Default implementation is a no-op. Custom `Encoding` types should override this to set + /// the appropriate `Content-Type` header. See ``JSONEncoding`` for an example. /// - Parameter request: Request which can be modified. func configure(request: inout URLRequest) throws } -/// Type-erased JSON encoder for use with types conforming to ``Server`` protocol. -public struct JSONEncoding: URLRequestEncoding { +public extension Encoding { + func configure(request: inout URLRequest) throws {} +} + +/// Protocol which enables use of any decoder using type-erasure. +public protocol Decoding: Sendable { + func decode(data: Data) throws -> T +} + +/// Type-erased JSON encoder for use with types conforming to ``URLServer`` protocol. +public struct JSONEncoding: Encoding { private let encoder: JSONEncoder public init(encoder: JSONEncoder = .init()) { @@ -51,7 +51,7 @@ public struct JSONEncoding: URLRequestEncoding { } } -/// Type-erased JSON decoder for use with types conforming to ``Server`` protocol. +/// Type-erased JSON decoder for use with types conforming to ``URLServer`` protocol. public struct JSONDecoding: Decoding { private let decoder: JSONDecoder diff --git a/Sources/FTAPIKit/Combine/EndpointPublisher.swift b/Sources/FTAPIKit/Combine/EndpointPublisher.swift deleted file mode 100644 index 540a9bc..0000000 --- a/Sources/FTAPIKit/Combine/EndpointPublisher.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -extension Publishers { - // swiftlint:disable nesting - struct Endpoint: Publisher { - typealias Output = R - typealias Failure = E - - typealias Builder = (@escaping (Result) -> Void) -> URLSessionTask? - - let builder: Builder - - func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = EndpointSubscription(subscriber: subscriber, builder: builder) - subscriber.receive(subscription: subscription) - } - } - // swiftlint:enable nesting -} - -#endif diff --git a/Sources/FTAPIKit/Combine/EndpointSubscription.swift b/Sources/FTAPIKit/Combine/EndpointSubscription.swift deleted file mode 100644 index 082953a..0000000 --- a/Sources/FTAPIKit/Combine/EndpointSubscription.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -final class EndpointSubscription: Subscription where S.Input == R, S.Failure == E { - private let builder: Publishers.Endpoint.Builder - private var subscriber: S? - - private var task: URLSessionTask? - - init(subscriber: S, builder: @escaping Publishers.Endpoint.Builder) { - self.subscriber = subscriber - self.builder = builder - } - - func request(_ demand: Subscribers.Demand) { - guard demand > .none, task == nil else { - return - } - - task = builder { [subscriber] result in - switch result { - case .success(let input): - _ = subscriber?.receive(input) - subscriber?.receive(completion: .finished) - case .failure(let error): - subscriber?.receive(completion: .failure(error)) - } - } - } - - func cancel() { - task?.cancel() - task = nil - subscriber = nil - } -} - -#endif diff --git a/Sources/FTAPIKit/Combine/URLServer+Combine.swift b/Sources/FTAPIKit/Combine/URLServer+Combine.swift deleted file mode 100644 index b2fc5c8..0000000 --- a/Sources/FTAPIKit/Combine/URLServer+Combine.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -public extension URLServer { - - /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success void, otherwise error. - func publisher(endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(endpoint: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that won't be parsed by the decoder of the - /// server. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(data endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success plain data, otherwise error. - func publisher(data endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(data: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - /// Performs call to endpoint which returns data which will be parsed by the server decoder. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(response endpoint: EP, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success instance of the required type, otherwise error. - func publisher(response endpoint: EP) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(response: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - func publisher(download endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.download(endpoint: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } -} - -#endif diff --git a/Sources/FTAPIKit/Documentation.docc/Documentation.md b/Sources/FTAPIKit/Documentation.docc/Documentation.md index e16a7ce..b1942c0 100644 --- a/Sources/FTAPIKit/Documentation.docc/Documentation.md +++ b/Sources/FTAPIKit/Documentation.docc/Documentation.md @@ -1,12 +1,12 @@ # ``FTAPIKit`` -Declarative, generic and protocol-oriented REST API framework using `URLSession` and `Codable` +Declarative async/await REST API framework using Swift Concurrency and Codable. ## Overview -Declarative and generic REST API framework using `Codable`. -With standard implementation using `URLSesssion` and JSON encoder/decoder. -Easily extensible for your asynchronous framework or networking stack. +Declarative async/await REST API framework using `Codable`. +With standard implementation using `URLSession` and JSON encoder/decoder. +Built for Swift 6.1+ with full concurrency safety. ![Tree with a API Client root element. Its branches are servers. Each server branch has some endpoint branches.](Architecture) @@ -14,7 +14,6 @@ Easily extensible for your asynchronous framework or networking stack. ### Server -- ``Server`` - ``URLServer`` ### Endpoint @@ -29,6 +28,11 @@ Easily extensible for your asynchronous framework or networking stack. - ``RequestEndpoint`` - ``RequestResponseEndpoint`` +### Request Configuration + +- ``RequestConfiguring`` +- ``CompositeRequestConfiguring`` + ### Endpoint configuration - ``HTTPMethod`` @@ -41,7 +45,10 @@ Easily extensible for your asynchronous framework or networking stack. - ``JSONEncoding`` - ``Decoding`` - ``JSONDecoding`` -- ``URLRequestEncoding`` + +### Observers + +- ``NetworkObserver`` ### Error handling diff --git a/Sources/FTAPIKit/Endpoint.swift b/Sources/FTAPIKit/Endpoint.swift index f1d54bd..92bdf40 100644 --- a/Sources/FTAPIKit/Endpoint.swift +++ b/Sources/FTAPIKit/Endpoint.swift @@ -4,7 +4,7 @@ import Foundation /// data and parameters which are sent to it. /// /// Recommended conformance of this protocol is implemented using `struct`. It is -/// of course possible using `enum` or `class`. Endpoints are are not designed +/// of course possible using `enum` or `class`. Endpoints are not designed /// to be referenced and used instantly after creation, so no memory usage is required. /// The case for not using enums is long-term sustainability. Enums tend to have many /// cases and information about one endpoint is spreaded all over the files. Also, @@ -39,7 +39,6 @@ public protocol DataEndpoint: Endpoint { var body: Data { get } } -#if !os(Linux) /// ``UploadEndpoint`` will send the provided file to the API. /// /// - Note: If the standard implementation is used, `URLSession.uploadTask` methods will be used. @@ -58,7 +57,6 @@ public protocol MultipartEndpoint: Endpoint { /// List of individual body parts. var parts: [MultipartBodyPart] { get } } -#endif /// The body of the endpoint with the URL query format. public protocol URLEncodedEndpoint: Endpoint { @@ -66,7 +64,7 @@ public protocol URLEncodedEndpoint: Endpoint { } /// An abstract representation of endpoint, body of which is represented by Swift encodable type. It serves as an -/// abstraction between the ``Server`` protocol and more specific ``Endpoint`` conforming protocols. +/// abstraction between the ``URLServer`` protocol and more specific ``Endpoint`` conforming protocols. /// Do not use this protocol to represent an encodable endpoint, use ``RequestEndpoint`` instead. public protocol EncodableEndpoint: Endpoint { @@ -80,9 +78,9 @@ public protocol EncodableEndpoint: Endpoint { /// for automatic deserialization. public protocol ResponseEndpoint: Endpoint { /// Associated type describing the return type conforming to `Decodable` - /// protocol. This is only a phantom-type used by `APIAdapter` + /// protocol. This is only a phantom-type used by ``URLServer`` /// for automatic decoding/deserialization of API results. - associatedtype Response: Decodable + associatedtype Response: Decodable & Sendable } /// Protocol extending ``Endpoint``, which supports sending `Encodable` data to the server. diff --git a/Sources/FTAPIKit/MultipartFormData.swift b/Sources/FTAPIKit/MultipartFormData.swift index fae0ac4..416e168 100644 --- a/Sources/FTAPIKit/MultipartFormData.swift +++ b/Sources/FTAPIKit/MultipartFormData.swift @@ -11,6 +11,10 @@ struct MultipartFormData { self.boundary = boundary } + /// Returns the size of the temporary file containing the multipart body. + /// + /// - Important: Must be called after ``inputStream()`` which writes the file. + /// Returns `nil` if the file does not exist yet. var contentLength: Int64? { (try? FileManager.default.attributesOfItem(atPath: temporaryUrl.path)[.size] as? Int64)?.flatMap { $0 } } diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift index 7de99e8..f086e32 100644 --- a/Sources/FTAPIKit/NetworkObserver.swift +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Protocol for observing network request lifecycle events. /// /// Implement this protocol to add logging, analytics, or request tracking. @@ -12,10 +8,12 @@ import FoundationNetworking /// The `Context` associated type allows passing correlation data (request ID, start time, etc.) /// through the request lifecycle: /// 1. `willSendRequest` is called before the request starts and returns a `Context` value -/// 2. `didReceiveResponse` is always called with the raw response data (useful for debugging) +/// 2. `didReceiveResponse` is called when a response is received from the server /// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error) -/// 4. If the observer is deallocated before the request completes, the context is discarded -/// and no completion callback is invoked +/// +/// - Note: Observers are strongly retained for the duration of a request to ensure lifecycle callbacks +/// are always delivered. Observers must not hold strong references back to the server to avoid +/// retain cycles. public protocol NetworkObserver: AnyObject, Sendable { associatedtype Context: Sendable @@ -26,8 +24,8 @@ public protocol NetworkObserver: AnyObject, Sendable { /// Called when a response is received from the server. /// - /// This is always called with the raw response data, even if processing subsequently fails. - /// This allows observers to inspect the actual response for debugging purposes. + /// Only called when the server responds. Not called on network errors (e.g. no connectivity). + /// Called even if the response indicates an error (4xx, 5xx), before `didFail`. /// - Parameters: /// - request: The original request /// - response: The URL response (may be HTTPURLResponse) diff --git a/Sources/FTAPIKit/OutputStream+Write.swift b/Sources/FTAPIKit/OutputStream+Write.swift index 6d5948b..722543f 100644 --- a/Sources/FTAPIKit/OutputStream+Write.swift +++ b/Sources/FTAPIKit/OutputStream+Write.swift @@ -1,25 +1,11 @@ import Foundation -#if canImport(os) import os -#endif - -#if os(Linux) -public enum LinuxStreamError: Error { - case streamIsInErrorState -} -#endif extension Stream { func throwErrorIfStreamHasError() throws { - #if os(Linux) - if streamStatus == .error { - throw LinuxStreamError.streamIsInErrorState - } - #else - if let error = streamError { - throw error - } - #endif + if let error = streamError { + throw error + } } } @@ -29,14 +15,8 @@ extension OutputStream { /// We want our buffer to be as close to page size as possible. Therefore we use /// POSIX API to get pagesize. The alternative is using compiler private macro which /// is less explicit. - /// - /// In case os can't be imported from some reason, fallback to 4 KiB size. private static func memoryPageSize() -> Int { - #if canImport(os) - return Int(getpagesize()) - #else - return 4_096 - #endif + Int(getpagesize()) } func write(inputStream: InputStream) throws { @@ -88,7 +68,7 @@ extension OutputStream { } func writeLine(string: String? = nil) throws { - if let string = string { + if let string { try write(string: string) } try write(string: "\r\n") diff --git a/Sources/FTAPIKit/RequestConfiguring.swift b/Sources/FTAPIKit/RequestConfiguring.swift new file mode 100644 index 0000000..3970540 --- /dev/null +++ b/Sources/FTAPIKit/RequestConfiguring.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Protocol for configuring URLRequest before execution. +/// Implementations can perform async operations like token refresh. +/// +/// Use this protocol to add authorization headers, modify requests, or perform +/// any async configuration needed before the request is sent. +/// +/// Example: +/// ```swift +/// struct AuthorizedConfiguration: RequestConfiguring { +/// let authService: AuthService +/// +/// func configure(_ request: inout URLRequest) async throws { +/// let token = try await authService.getValidAccessToken() +/// request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") +/// } +/// } +/// ``` +public protocol RequestConfiguring: Sendable { + /// Configures the request before it is sent. + /// - Parameter request: The URLRequest to configure + /// - Throws: Any error that occurs during configuration (e.g., token refresh failure) + func configure(_ request: inout URLRequest) async throws +} + +/// Composes multiple ``RequestConfiguring`` instances into a single configuration. +/// +/// Configurations are applied in order, so later configurations can override +/// headers set by earlier ones. +/// +/// ```swift +/// let config = CompositeRequestConfiguring([authConfig, tracingConfig]) +/// let data = try await server.call(data: endpoint, configuring: config) +/// ``` +public struct CompositeRequestConfiguring: RequestConfiguring { + private let configurations: [any RequestConfiguring] + + /// Creates a composite configuration from multiple configurations. + /// - Parameter configurations: Configurations to apply in order. + public init(_ configurations: [any RequestConfiguring]) { + self.configurations = configurations + } + + public func configure(_ request: inout URLRequest) async throws { + for configuration in configurations { + try await configuration.configure(&request) + } + } +} diff --git a/Sources/FTAPIKit/Server.swift b/Sources/FTAPIKit/Server.swift deleted file mode 100644 index 7c2518a..0000000 --- a/Sources/FTAPIKit/Server.swift +++ /dev/null @@ -1,30 +0,0 @@ -/// `Server` is an abstraction rather than a protocol-bound requirement. -/// -/// The expectation of a type conforming to this protocol is, that it provides a gateway to an API over HTTP. Conforming -/// type should also have the ability to encode/decode data into requests and responses using the `Codable` -/// conformances and strongly typed coding of the Swift language. -/// -/// Conforming type must specify the type representing a request like `Foundation.URLRequest` or -/// `Alamofire.Request`. However, conforming type is expected to have the ability to execute the request too. -/// -/// ``FTAPIKit`` provides a standard implementation tailored for `Foundation.URLSession` and -/// `Foundation` JSON coders. The standard implementation is represented by ``URLServer``. -public protocol Server { - /// The type representing a `Request` of the network library, like `Foundation.URLRequest` or - /// `Alamofire.Request`. - associatedtype Request - - /// The instance providing strongly typed decoding. - var decoding: Decoding { get } - - /// The instance providing strongly typed encoding. - var encoding: Encoding { get } - - /// Takes a Swift description of an endpoint call and transforms it into a valid request. The reason why - /// the function returns the request to the user is so the user is able to modify the request before executing. - /// This is useful in cases when the API uses OAuth or some other token-based authorization, where - /// the request may be delayed before the valid tokens are received. - /// - Parameter endpoint: An instance of an endpoint representing a call. - /// - Returns: A valid request. - func buildRequest(endpoint: Endpoint) throws -> Request -} diff --git a/Sources/FTAPIKit/URL+MIME.swift b/Sources/FTAPIKit/URL+MIME.swift index 28a5004..0aa2a19 100644 --- a/Sources/FTAPIKit/URL+MIME.swift +++ b/Sources/FTAPIKit/URL+MIME.swift @@ -1,69 +1,7 @@ -import Foundation -#if canImport(CoreServices) -import CoreServices -#endif -#if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers -#endif extension URL { var mimeType: String { - let fallback = "application/octet-stream" - - #if os(Linux) - return linuxMimeType(for: path) ?? fallback - #else - if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) { - return uniformMimeType(for: pathExtension) ?? fallback - } else { - return coreServicesMimeType(for: pathExtension) ?? fallback - } - #endif - } - - #if canImport(UniformTypeIdentifiers) - @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) - private func uniformMimeType(for fileExtension: String) -> String? { - UTType(filenameExtension: fileExtension)?.preferredMIMEType - } - #endif - - #if canImport(CoreServices) - private func coreServicesMimeType(for fileExtension: String) -> String? { - if let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue() { - return contentType as String - } - return nil - } - #endif - - #if os(Linux) - private func linuxMimeType(for path: String) -> String? { - // Path to `env` on most operatin systems - let pathToEnv = "/bin/env" - - let stdOut = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: pathToEnv) - process.arguments = ["file", "-E", "--brief", "--mime-type", path] - process.standardOutput = stdOut - do { - try process.run() - process.waitUntilExit() - } catch { - assertionFailure("File mime could not be determined: \(error)") - } - - guard process.terminationStatus == 0 else { - assertionFailure("File mime could not be determined: termination status \(process.terminationStatus)") - return nil - } - - return String( - data: stdOut.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) + UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" } - #endif } diff --git a/Sources/FTAPIKit/URLQuery.swift b/Sources/FTAPIKit/URLQuery.swift index ce37e9a..9721f64 100644 --- a/Sources/FTAPIKit/URLQuery.swift +++ b/Sources/FTAPIKit/URLQuery.swift @@ -14,11 +14,12 @@ import Foundation /// "child[]": "Maggie" /// ] /// ``` -public struct URLQuery: ExpressibleByDictionaryLiteral { +public struct URLQuery: ExpressibleByDictionaryLiteral, Sendable { /// Array of URL query items. public let items: [URLQueryItem] - init() { + /// Creates an empty URL query. + public init() { self.items = [] } diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index fe16938..333fc9b 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - public extension URLServer { func buildStandardRequest(endpoint: Endpoint) throws -> URLRequest { try URLRequestBuilder(server: self, endpoint: endpoint).build() @@ -31,18 +27,20 @@ struct URLRequestBuilder { return request } + /// Populates the request body based on the endpoint's protocol conformance. + /// + /// - Important: Case ordering matters. If an endpoint conforms to multiple body-providing + /// protocols (e.g., both `DataEndpoint` and `EncodableEndpoint`), the first match wins. private func buildBody(to request: inout URLRequest) throws { switch endpoint { case let endpoint as DataEndpoint: request.httpBody = endpoint.body case let endpoint as EncodableEndpoint: - let requestEncoding = server.encoding as? URLRequestEncoding - try requestEncoding?.configure(request: &request) + try server.encoding.configure(request: &request) request.httpBody = try endpoint.body(encoding: server.encoding) case let endpoint as URLEncodedEndpoint: request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = endpoint.body.percentEncoded?.data(using: .ascii) - #if !os(Linux) case let endpoint as MultipartEndpoint: let formData = MultipartFormData(parts: endpoint.parts) request.httpBodyStream = try formData.inputStream() @@ -50,7 +48,6 @@ struct URLRequestBuilder { if let contentLength = formData.contentLength { request.setValue(contentLength.description, forHTTPHeaderField: "Content-Length") } - #endif default: break } diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index fd43fd5..00c3da5 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -1,85 +1,160 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif -// Support of async-await for Xcode 13.2+. -#if swift(>=5.5.2) -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) public extension URLServer { /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Note: This call maps ``call(endpoint:completion:)`` to the async/await API /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure - /// - Returns: Void on success - func call(endpoint: Endpoint) async throws { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(endpoint: endpoint) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } onCancel: { [task] in - task?.cancel() - } + /// - configuring: Optional request configuration to apply before sending + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. + func call(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws { + _ = try await execute(endpoint: endpoint, configuring: configuring) } - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that should not be parsed by the decoder of the - /// server. - /// - Note: This call maps ``call(data:completion:)`` to the async/await API + /// Performs call to endpoint which returns arbitrary data in the HTTP response, that should not be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure + /// - configuring: Optional request configuration to apply before sending + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. /// - Returns: Plain data returned with the HTTP Response - func call(data endpoint: Endpoint) async throws -> Data { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(data: endpoint) { result in - switch result { - case .success(let data): - continuation.resume(returning: data) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } onCancel: { [task] in - task?.cancel() - } + func call(data endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> Data { + try await execute(endpoint: endpoint, configuring: configuring).data } - /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder of the instance - /// conforming to ``Server`` in the HTTP response. - /// - Note: This call maps ``call(response:completion:)`` to the async/await API + /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure + /// - configuring: Optional request configuration to apply before sending + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. /// - Returns: Instance of the required type - func call(response endpoint: EP) async throws -> EP.Response { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(response: endpoint) { result in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } + func call(response endpoint: EP, configuring: RequestConfiguring? = nil) async throws -> EP.Response { + let result = try await execute(endpoint: endpoint, configuring: configuring) + do { + return try decoding.decode(data: result.data) + } catch { + let thrownError = ErrorType(data: result.data, response: nil, error: error, decoding: decoding) ?? error + result.observers.forEach { $0.didFail(request: result.request, error: thrownError) } + throw thrownError + } + } + + /// Downloads a file from the specified endpoint to a temporary location. + /// - Parameters: + /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. + /// - Returns: The location of a temporary file where the server's response is stored. + /// You must move this file or open it for reading before the async function returns. Otherwise, the file + /// is deleted, and the data is lost. + func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { + let (urlRequest, observers) = try await prepareObservers(endpoint: endpoint, configuring: configuring) + + let (localURL, response): (URL, URLResponse) + do { + (localURL, response) = try await urlSession.download(for: urlRequest) + } catch { + let thrownError = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? error + observers.forEach { $0.didFail(request: urlRequest, error: thrownError) } + throw thrownError + } + + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } + try checkForError(data: nil, response: response, request: urlRequest, observers: observers) + + return localURL + } +} + +// MARK: - Private helpers + +private struct ExecuteResult { + let data: Data + let request: URLRequest + let observers: [BoundObserverContext] +} + +private extension URLServer { + + /// Core execution method that builds the request, notifies observers, performs the network call, + /// and handles errors. + func execute(endpoint: Endpoint, configuring: RequestConfiguring?) async throws -> ExecuteResult { + let (urlRequest, observers) = try await prepareObservers(endpoint: endpoint, configuring: configuring) + + let file = (endpoint as? UploadEndpoint)?.file + + let (data, response): (Data, URLResponse) + do { + if let file { + (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) + } else { + (data, response) = try await urlSession.data(for: urlRequest) } - } onCancel: { [task] in - task?.cancel() + } catch { + let thrownError = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? error + observers.forEach { $0.didFail(request: urlRequest, error: thrownError) } + throw thrownError + } + + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: data) } + try checkForError(data: data, response: response, request: urlRequest, observers: observers) + + return ExecuteResult(data: data, request: urlRequest, observers: observers) + } + + /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer contexts. + func prepareObservers( + endpoint: Endpoint, + configuring: RequestConfiguring? + ) async throws -> (URLRequest, [BoundObserverContext]) { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) + let observers = networkObservers.map { BoundObserverContext(observer: $0, request: urlRequest) } + return (urlRequest, observers) + } + + /// Checks the response for API errors and notifies observers on failure. + func checkForError( + data: Data?, + response: URLResponse, + request: URLRequest, + observers: [BoundObserverContext] + ) throws { + if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { + observers.forEach { $0.didFail(request: request, error: error) } + throw error } } } -#endif + +/// Captures an observer and its context from `willSendRequest`, binding the lifecycle callbacks +/// for a single request. Created at request start and consumed before the call returns. +/// +/// Marked `@unchecked Sendable` because the stored closures capture a `Sendable` observer +/// and its `Sendable` context. Instances are created and consumed within a single async call +/// and never shared across task boundaries. +private final class BoundObserverContext: @unchecked Sendable { + private let _didReceiveResponse: (URLRequest, URLResponse?, Data?) -> Void + private let _didFail: (URLRequest, Error) -> Void + + init(observer: O, request: URLRequest) { + let context = observer.willSendRequest(request) + _didReceiveResponse = { req, resp, data in + observer.didReceiveResponse(for: req, response: resp, data: data, context: context) + } + _didFail = { req, error in + observer.didFail(request: req, error: error, context: context) + } + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?) { + _didReceiveResponse(request, response, data) + } + + func didFail(request: URLRequest, error: Error) { + _didFail(request, error) + } +} diff --git a/Sources/FTAPIKit/URLServer+Call.swift b/Sources/FTAPIKit/URLServer+Call.swift deleted file mode 100644 index 006aa0d..0000000 --- a/Sources/FTAPIKit/URLServer+Call.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation - -#if os(Linux) -import FoundationNetworking -#endif - -public extension URLServer { - - /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success void, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(request: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that should not be parsed by the decoder of the - /// server. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success plain data, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(data endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(data: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder of the instance - /// conforming to ``Server`` in the HTTP response. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success instance of the required type, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(response endpoint: EP, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(response: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - private func call(request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } - return .success(()) - }, completion: completion) - } - - private func call(data request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let data = data { - return .success(data) - } - return .failure(.unhandled) - }, completion: completion) - } - - private func call(response request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let data = data { - do { - let response: R = try self.decoding.decode(data: data) - return .success(response) - } catch { - return self.apiError(error: error) - } - } - return .failure(.unhandled) - }, completion: completion) - } -} diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift deleted file mode 100644 index 2e168c4..0000000 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -#if os(Linux) -import FoundationNetworking -#endif - -public extension URLServer { - - /// Creates an `URLSession` download task that call the specified endpoint, saves the result into a file and calls - /// the handler. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success, the location of a temporary file where the server’s response is stored. - /// You must move this file or open it for reading before your completion handler returns. Otherwise, the file - /// is deleted, and the data is lost. Error otherwise. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func download(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return download(request: request, completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - private func download(request: URLRequest, completion: @escaping (Result) -> Void) -> URLSessionTask? { - downloadTask(request: request, process: { url, response, error in - let urlData = (url?.absoluteString.utf8).flatMap { Data($0) } - if let error = ErrorType(data: urlData, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let url = url { - return .success(url) - } - return .failure(.unhandled) - }, completion: completion) - } -} diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift deleted file mode 100644 index fcfbcd6..0000000 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation - -#if os(Linux) -import FoundationNetworking -#endif - -extension URLServer { - func task( - request: URLRequest, - file: URL?, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDataTask? { - if let file = file { - return uploadTask(request: request, file: file, process: process, completion: completion) - } - return dataTask(request: request, process: process, completion: completion) - } - - private func dataTask( - request: URLRequest, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDataTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.dataTask(with: request) { data, response, error in - tokens.forEach { $0.didReceiveResponse(response, data) } - - let result = process(data, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - private func uploadTask( - request: URLRequest, - file: URL, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionUploadTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - tokens.forEach { $0.didReceiveResponse(response, data) } - - let result = process(data, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - func downloadTask( - request: URLRequest, - process: @escaping (URL?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDownloadTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.downloadTask(with: request) { url, response, error in - tokens.forEach { $0.didReceiveResponse(response, nil) } - - let result = process(url, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - func request(endpoint: Endpoint) -> Result { - do { - let request = try buildRequest(endpoint: endpoint) - return .success(request) - } catch { - return apiError(error: error) - } - } - - func uploadFile(endpoint: Endpoint) -> URL? { - #if !os(Linux) - if let endpoint = endpoint as? UploadEndpoint { - return endpoint.file - } - #endif - return nil - } - - func apiError(error: Error?) -> Result { - let error = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? .unhandled - return .failure(error) - } -} - -// This hides the specific 'Context' type inside closures. -private struct RequestToken: Sendable { - let didReceiveResponse: @Sendable (URLResponse?, Data?) -> Void - let didFail: @Sendable (Error) -> Void - - // The generic 'T' captures the specific observer type and its associated Context - init(observer: T, request: URLRequest) { - // We generate the context immediately upon initialization - let context = observer.willSendRequest(request) - - // We capture the specific 'observer' and 'context' inside these closures - self.didReceiveResponse = { [weak observer] response, data in - observer?.didReceiveResponse(for: request, response: response, data: data, context: context) - } - - self.didFail = { [weak observer] error in - observer?.didFail(request: request, error: error, context: context) - } - } -} diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index 4279bc8..2de41c9 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -1,53 +1,43 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - -/// The standard implementation of the `Server` protocol based on `Foundation.URLSession` networking -/// stack. -/// -/// The URLServer provides various means of executing its requests, depending on the needs of the programmer. -/// Extension for the following approaches are currently implemented: -/// - Completion handler based approach (the baseline implementation) -/// - Async/Await pattern -/// - Combine bindings -/// -/// It provides a default implementation for -/// `var decoding: Decoding`, `var encoding: Encoding` and -/// `func buildRequest(endpoint: Endpoint) throws -> URLRequest`. The `URLRequest` -/// creation is implemented in `struct URLRequestBuilder`. -/// -/// In case that the requests need to cooperate with other services, like OAuth, override the default implementation -/// of `func buildRequest`, use -/// `func buildStandardRequest(endpoint: Endpoint) throws -> URLRequest` within our new -/// implementation, and use the `URLRequest` as a baseline. +/// `URLServer` represents a single web service and provides a gateway to an API over HTTP. /// -/// - Note: The standard implementation is specifically made in order to let you customize: -/// * Error handling -/// * URLRequest creation -/// * Encoding and decoding -/// * URLSession configuration +/// Conforming type should have the ability to encode/decode data into requests and responses +/// using the `Codable` conformances and strongly typed coding of the Swift language. /// -/// In case you need further customization, it might not be worth the time required to bend the standard -/// implementation to your needs. +/// The protocol provides default implementations for `decoding`, `encoding`, `urlSession`, +/// and `networkObservers`. Only `baseUri` must be provided by conforming types. /// -public protocol URLServer: Server where Request == URLRequest { - /// Error type which is initialized during the request execution - /// - Note: Provided default implementation. +/// In case that the requests need to cooperate with other services, like OAuth, override the +/// default implementation of `buildRequest`, use `buildStandardRequest(endpoint:)` within +/// your new implementation, and use the `URLRequest` as a baseline. +public protocol URLServer: Sendable { + /// Error type which is initialized during the request execution. associatedtype ErrorType: APIError = APIError.Standard - /// Base URI of the server + /// Base URI of the server. var baseUri: URL { get } - /// `URLSession` instance, which is used for task execution - /// - Note: Provided default implementation. + /// `URLSession` instance, which is used for task execution. var urlSession: URLSession { get } + /// The instance providing strongly typed decoding. + var decoding: Decoding { get } + + /// The instance providing strongly typed encoding. + var encoding: Encoding { get } + /// Array of network observers. /// Each observer receives lifecycle callbacks for every request. - /// - Note: Provided default implementation returns empty array. var networkObservers: [any NetworkObserver] { get } + + /// Takes a Swift description of an endpoint call and transforms it into a valid request. + /// + /// This is useful in cases when the API uses OAuth or some other token-based authorization, + /// where the request may be delayed before the valid tokens are received. + /// - Parameter endpoint: An instance of an endpoint representing a call. + /// - Returns: A valid `URLRequest`. + func buildRequest(endpoint: Endpoint) async throws -> URLRequest } public extension URLServer { @@ -56,7 +46,7 @@ public extension URLServer { var encoding: Encoding { JSONEncoding() } var networkObservers: [any NetworkObserver] { [] } - func buildRequest(endpoint: Endpoint) throws -> URLRequest { + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) } } diff --git a/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift index 22d131b..0cc8234 100644 --- a/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift +++ b/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift @@ -2,7 +2,7 @@ import FTAPIKit -struct ___VARIABLE_templateName___Endpoint: APIEndpoint { +struct ___VARIABLE_templateName___Endpoint: Endpoint { let path: String = "" } diff --git a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift index de36a2f..7b71d65 100644 --- a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift +++ b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift @@ -2,9 +2,9 @@ import FTAPIKit -struct ___VARIABLE_templateName___Endpoint: APIRequestEndpoint { +struct ___VARIABLE_templateName___Endpoint: RequestEndpoint { typealias Request = ___VARIABLE_templateName___Request var body: ___VARIABLE_templateName___Request - var path: String + let path: String = "" } diff --git a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift index 3896b77..e912cac 100644 --- a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift +++ b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift @@ -2,5 +2,5 @@ import Foundation -struct ___VARIABLE_templateName___Request: Encodable { +struct ___VARIABLE_templateName___Request: Encodable, Sendable { } diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift index 254c379..36c5c95 100644 --- a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift @@ -2,7 +2,7 @@ import FTAPIKit -struct ___VARIABLE_templateName___Endpoint: APIRequestResponseEndpoint { +struct ___VARIABLE_templateName___Endpoint: RequestResponseEndpoint { typealias Response = ___VARIABLE_templateName___Response typealias Request = ___VARIABLE_templateName___Request diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift index 3896b77..e912cac 100644 --- a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift @@ -2,5 +2,5 @@ import Foundation -struct ___VARIABLE_templateName___Request: Encodable { +struct ___VARIABLE_templateName___Request: Encodable, Sendable { } diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift index 644df96..52a0c48 100644 --- a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift @@ -2,5 +2,5 @@ import Foundation -struct ___VARIABLE_templateName___Response: Decodable { +struct ___VARIABLE_templateName___Response: Decodable, Sendable { } diff --git a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift index 725f647..9ce8f08 100644 --- a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift +++ b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift @@ -2,7 +2,7 @@ import FTAPIKit -struct ___VARIABLE_templateName___Endpoint: APIResponseEndpoint { +struct ___VARIABLE_templateName___Endpoint: ResponseEndpoint { typealias Response = ___VARIABLE_templateName___Response let path: String = "" diff --git a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift index 644df96..52a0c48 100644 --- a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift +++ b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift @@ -2,5 +2,5 @@ import Foundation -struct ___VARIABLE_templateName___Response: Decodable { +struct ___VARIABLE_templateName___Response: Decodable, Sendable { } diff --git a/Templates/Endpoints.xctemplate/TemplateInfo.plist b/Templates/Endpoints.xctemplate/TemplateInfo.plist index a5703a2..683999f 100644 --- a/Templates/Endpoints.xctemplate/TemplateInfo.plist +++ b/Templates/Endpoints.xctemplate/TemplateInfo.plist @@ -5,7 +5,7 @@ DefaultCompletionName Endpoint Description - This generates a new endpoint for use with the APIAdapter from the FTAPIKit. + This generates a new endpoint for use with FTAPIKit. Kind Xcode.IDEKit.TextSubstitutionFileTemplateKind Options @@ -44,7 +44,7 @@ Name Generate Request file Description - Check whethe you want to create file containing requests. + Check whether you want to create file containing requests. Type checkbox @@ -54,7 +54,7 @@ Name Generate Response file Description - Check whethe you want to create file containing responses. + Check whether you want to create file containing responses. Type checkbox @@ -66,6 +66,6 @@ SortOrder 99 Summary - This generates a new service for use with the ServiceHolder (dependency injection) from the FuturedKit. + This generates a new endpoint for use with FTAPIKit. diff --git a/Templates/Makefile b/Templates/Makefile index 3ebb72a..0b2b531 100644 --- a/Templates/Makefile +++ b/Templates/Makefile @@ -1,9 +1,8 @@ -TEMPLATE_DIR = $(HOME)/Library/Developer/Xcode/Templates/FTAPIKit +INSTALL_DIR = ~/Library/Developer/Xcode/Templates/File\ Templates/FTAPIKit install: - mkdir -p $(TEMPLATE_DIR) - cp -R *.xctemplate $(TEMPLATE_DIR)/ - echo "\033[0;32mInstallation was successful!\033[0m" + mkdir -p $(INSTALL_DIR) + cp -R Endpoints.xctemplate $(INSTALL_DIR)/ -.PHONY: install -.SILENT: install +uninstall: + rm -rf $(INSTALL_DIR) diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift new file mode 100644 index 0000000..71986bf --- /dev/null +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -0,0 +1,68 @@ +import Foundation +import FTAPIKit +import Testing + +/// Tests demonstrating async buildRequest functionality addressing GitHub issue #105 +@Suite +struct AsyncBuildRequestTests { + + @Test + func asyncBuildRequestWithDynamicHeaders() async throws { + let server = DynamicHeaderServer() + let data = try await server.call(data: GetEndpoint()) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-App-Version"] == "2.0.0") + #expect(response.headers["X-Device-Id"] == "test-device-123") + } + + @Test + func asyncBuildRequestWithTokenRefresh() async throws { + let tokenManager = MockTokenManager() + let server = TokenRefreshServer(tokenManager: tokenManager) + tokenManager.currentToken = "expired-token" + let data = try await server.call(data: GetEndpoint()) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["Authorization"] == "Bearer refreshed-token-456") + #expect(tokenManager.refreshCalled) + } +} + +// MARK: - Mock Servers + +private struct DynamicHeaderServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + let config = await fetchConfiguration() + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue(config.appVersion, forHTTPHeaderField: "X-App-Version") + request.addValue(config.deviceId, forHTTPHeaderField: "X-Device-Id") + return request + } + + private func fetchConfiguration() async -> AppConfiguration { + try? await Task.sleep(nanoseconds: 10_000_000) + return AppConfiguration(appVersion: "2.0.0", deviceId: "test-device-123") + } +} + +private struct TokenRefreshServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + let tokenManager: MockTokenManager + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + await tokenManager.refreshIfNeeded() + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(tokenManager.currentToken)", forHTTPHeaderField: "Authorization") + return request + } +} + +// MARK: - Mock Models + +private struct AppConfiguration { + let appVersion: String + let deviceId: String +} diff --git a/Tests/FTAPIKitTests/AsyncTests.swift b/Tests/FTAPIKitTests/AsyncTests.swift index e4b6e1d..63dc3c4 100644 --- a/Tests/FTAPIKitTests/AsyncTests.swift +++ b/Tests/FTAPIKitTests/AsyncTests.swift @@ -1,34 +1,31 @@ -#if swift(>=5.5.2) && !os(Linux) import Foundation -import XCTest +import FTAPIKit +import Testing -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) -final class AsyncTests: XCTestCase { - func testCallWithoutResponse() async throws { +@Suite +struct AsyncTests { + + @Test + func callWithoutResponse() async throws { let server = HTTPBinServer() let endpoint = GetEndpoint() try await server.call(endpoint: endpoint) } - func testCallWithData() async throws { + @Test + func callWithData() async throws { let server = HTTPBinServer() let endpoint = GetEndpoint() let data = try await server.call(data: endpoint) - XCTAssertGreaterThan(data.count, 0) + #expect(!data.isEmpty) } - func testCallParsingResponse() async throws { + @Test + func callParsingResponse() async throws { let server = HTTPBinServer() let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) let endpoint = UpdateUserEndpoint(request: user) let response = try await server.call(response: endpoint) - XCTAssertEqual(user, response.json) + #expect(user == response.json) } - - static let allTests = [ - ("testCallWithoutResponse", testCallWithoutResponse), - ("testCallWithData", testCallWithData), - ("testCallParsingResponse", testCallParsingResponse) - ] } -#endif diff --git a/Tests/FTAPIKitTests/CombineTests.swift b/Tests/FTAPIKitTests/CombineTests.swift deleted file mode 100644 index 9957c94..0000000 --- a/Tests/FTAPIKitTests/CombineTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -#if canImport(Combine) - -import Combine -import XCTest - -/// There is a guard in each test to check whether Combine is available. -/// If the whole class is marked with `@available` we get segfaults, -/// because the test runner is still trying to execute unavailable test. -/// Last Xcode version where this was checked is 11.5. -final class CombineTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - private var cancellable: AnyObject? - - override func tearDown() { - super.tearDown() - cancellable = nil - } - - func testEmptyResult() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = NoContentEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(endpoint: endpoint) - .assertNoFailure() - .sink { expectation.fulfill() } - - wait(for: [expectation], timeout: timeout) - } - - func testDataPublisher() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .assertNoFailure() - .sink { data in - XCTAssert(!data.isEmpty) - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONResponse() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = JSONResponseEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .assertNoFailure() - .sink { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - } - - func testClientError() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .sink(receiveCompletion: { completion in - switch completion { - case .failure(.client): - XCTAssert(true) - default: - XCTFail("404 endpoint must return client error") - } - expectation.fulfill() - }, receiveValue: { _ in - XCTFail("404 endpoint must return error") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: timeout) - } -} - -#endif diff --git a/Tests/FTAPIKitTests/EndpointTypeTests.swift b/Tests/FTAPIKitTests/EndpointTypeTests.swift new file mode 100644 index 0000000..c85b68e --- /dev/null +++ b/Tests/FTAPIKitTests/EndpointTypeTests.swift @@ -0,0 +1,62 @@ +import Foundation +import FTAPIKit +import Testing + +/// Tests for various endpoint types, ported from the deleted ResponseTests.swift +@Suite +struct EndpointTypeTests { + + @Test + func responseEndpoint() async throws { + let server = HTTPBinServer() + let response = try await server.call(response: JSONResponseEndpoint()) + #expect(!response.slideshow.title.isEmpty) + } + + @Test + func requestResponseEndpoint() async throws { + let server = HTTPBinServer() + let user = User(uuid: UUID(), name: "Test User", age: 30) + let endpoint = UpdateUserEndpoint(request: user) + let response = try await server.call(response: endpoint) + #expect(response.json == user) + } + + @Test + func urlEncodedEndpoint() async throws { + let server = HTTPBinServer() + let endpoint = TestURLEncodedEndpoint() + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func multipartEndpoint() async throws { + let server = HTTPBinServer() + let file = File() + try file.write() + defer { file.cleanup() } + let endpoint = try TestMultipartEndpoint(file: file) + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func uploadEndpoint() async throws { + let server = HTTPBinServer() + let file = File() + try file.write() + defer { file.cleanup() } + let endpoint = TestUploadEndpoint(file: file) + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func downloadEndpoint() async throws { + let server = HTTPBinServer() + let endpoint = ImageEndpoint() + let url = try await server.download(endpoint: endpoint) + #expect(FileManager.default.fileExists(atPath: url.path)) + } +} diff --git a/Tests/FTAPIKitTests/ErrorHandlingTests.swift b/Tests/FTAPIKitTests/ErrorHandlingTests.swift new file mode 100644 index 0000000..d667ace --- /dev/null +++ b/Tests/FTAPIKitTests/ErrorHandlingTests.swift @@ -0,0 +1,108 @@ +import Foundation +import FTAPIKit +import Testing + +/// Tests for error handling, ported from the deleted ResponseTests.swift +@Suite +struct ErrorHandlingTests { + + @Test + func clientError() async throws { + let server = HTTPBinServer() + let endpoint = NotFoundEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected client error for 404") + } catch let error as APIError.Standard { + guard case .client(let statusCode, _, _) = error else { + Issue.record("Expected .client error, got \(error)") + return + } + #expect(statusCode == 404) + } catch { + Issue.record("Unexpected error type: \(type(of: error))") + } + } + + @Test + func serverError() async throws { + let server = HTTPBinServer() + let endpoint = ServerErrorEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected server error for 500") + } catch let error as APIError.Standard { + guard case .server(let statusCode, _, _) = error else { + Issue.record("Expected .server error, got \(error)") + return + } + #expect(statusCode == 500) + } catch { + Issue.record("Unexpected error type: \(type(of: error))") + } + } + + @Test + func connectionError() async throws { + let server = NonExistingServer() + let endpoint = GetEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected connection error") + } catch let error as APIError.Standard { + guard case .connection = error else { + Issue.record("Expected .connection error, got \(error)") + return + } + } catch { + Issue.record("Unexpected error type: \(type(of: error))") + } + } + + @Test + func decodingError() async throws { + let server = HTTPBinServer() + let user = User(uuid: UUID(), name: "Test", age: 25) + let endpoint = FailingUpdateUserEndpoint(request: user) + do { + _ = try await server.call(response: endpoint) + Issue.record("Expected decoding error") + } catch let error as APIError.Standard { + guard case .decoding = error else { + Issue.record("Expected .decoding error, got \(error)") + return + } + } catch { + Issue.record("Unexpected error type: \(type(of: error))") + } + } + + @Test + func customErrorType() async throws { + let server = ErrorThrowingServer() + let endpoint = NotFoundEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected ThrowawayAPIError") + } catch is ThrowawayAPIError { + // Expected + } catch { + Issue.record("Unexpected error type: \(type(of: error))") + } + } + + @Test + func emptyResponse() async throws { + let server = HTTPBinServer() + let endpoint = NoContentEndpoint() + try await server.call(endpoint: endpoint) + } + + @Test + func authorization() async throws { + let server = HTTPBinServer() + let endpoint = AuthorizedEndpoint() + let data = try await server.call(data: endpoint, configuring: BearerTokenConfiguration()) + #expect(!data.isEmpty) + } +} diff --git a/Tests/FTAPIKitTests/Mockups/Endpoints.swift b/Tests/FTAPIKitTests/Mockups/Endpoints.swift index 07f5529..389355f 100644 --- a/Tests/FTAPIKitTests/Mockups/Endpoints.swift +++ b/Tests/FTAPIKitTests/Mockups/Endpoints.swift @@ -1,10 +1,6 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct GetEndpoint: Endpoint { let path = "get" } @@ -64,7 +60,6 @@ struct FailingUpdateUserEndpoint: RequestResponseEndpoint { let path = "anything" } -#if !os(Linux) struct TestMultipartEndpoint: MultipartEndpoint { let parts: [MultipartBodyPart] let path = "post" @@ -79,7 +74,6 @@ struct TestMultipartEndpoint: MultipartEndpoint { ] } } -#endif struct TestURLEncodedEndpoint: URLEncodedEndpoint { let path = "post" @@ -90,7 +84,6 @@ struct TestURLEncodedEndpoint: URLEncodedEndpoint { ] } -#if !os(Linux) struct TestUploadEndpoint: UploadEndpoint { let file: URL let path = "put" @@ -100,7 +93,6 @@ struct TestUploadEndpoint: UploadEndpoint { self.file = file.url } } -#endif struct ImageEndpoint: Endpoint { let path = "image/jpeg" diff --git a/Tests/FTAPIKitTests/Mockups/Errors.swift b/Tests/FTAPIKitTests/Mockups/Errors.swift index f91e5f0..62d1a62 100644 --- a/Tests/FTAPIKitTests/Mockups/Errors.swift +++ b/Tests/FTAPIKitTests/Mockups/Errors.swift @@ -1,10 +1,6 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct ThrowawayAPIError: APIError { private init() {} diff --git a/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift b/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift new file mode 100644 index 0000000..1d21162 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Shared response model for httpbin.org endpoints that return headers. +struct HTTPBinHeadersResponse: Decodable, Sendable { + let headers: [String: String] +} diff --git a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift index bf3a948..8254adc 100644 --- a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift +++ b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift @@ -1,33 +1,37 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct MockContext: Sendable { let requestId: String let startTime: Date } final class MockNetworkObserver: NetworkObserver, @unchecked Sendable { - var willSendCount = 0 - var didReceiveCount = 0 - var didFailCount = 0 - var lastRequestId: String? + private let lock = NSLock() + private var _willSendCount = 0 + private var _didReceiveCount = 0 + private var _didFailCount = 0 + private var _lastRequestId: String? + + var willSendCount: Int { lock.withLock { _willSendCount } } + var didReceiveCount: Int { lock.withLock { _didReceiveCount } } + var didFailCount: Int { lock.withLock { _didFailCount } } + var lastRequestId: String? { lock.withLock { _lastRequestId } } func willSendRequest(_ request: URLRequest) -> MockContext { - willSendCount += 1 let context = MockContext(requestId: UUID().uuidString, startTime: Date()) - lastRequestId = context.requestId + lock.withLock { + _willSendCount += 1 + _lastRequestId = context.requestId + } return context } func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: MockContext) { - didReceiveCount += 1 + lock.withLock { _didReceiveCount += 1 } } func didFail(request: URLRequest, error: Error, context: MockContext) { - didFailCount += 1 + lock.withLock { _didFailCount += 1 } } } diff --git a/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift b/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift new file mode 100644 index 0000000..2fb2758 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Thread-safe mock token manager for testing async token refresh patterns. +final class MockTokenManager: @unchecked Sendable { + private let lock = NSLock() + private var _currentToken: String = "initial-token" + private var _refreshCalled = false + + var currentToken: String { + get { lock.withLock { _currentToken } } + set { lock.withLock { _currentToken = newValue } } + } + + var refreshCalled: Bool { + lock.withLock { _refreshCalled } + } + + func refreshIfNeeded() async { + try? await Task.sleep(nanoseconds: 10_000_000) + lock.withLock { + _refreshCalled = true + _currentToken = "refreshed-token-456" + } + } + + func getValidToken() async -> String { + try? await Task.sleep(nanoseconds: 10_000_000) + lock.withLock { + _refreshCalled = true + _currentToken = "refreshed-token" + } + return currentToken + } +} diff --git a/Tests/FTAPIKitTests/Mockups/Models.swift b/Tests/FTAPIKitTests/Mockups/Models.swift index 8b697f1..51c146d 100644 --- a/Tests/FTAPIKitTests/Mockups/Models.swift +++ b/Tests/FTAPIKitTests/Mockups/Models.swift @@ -1,6 +1,6 @@ import Foundation -struct User: Codable, Equatable { +struct User: Codable, Equatable, Sendable { let uuid: UUID let name: String let age: UInt @@ -17,4 +17,8 @@ struct File { func write() throws { try data.write(to: url) } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } } diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index b1fdc7c..3afdc25 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,20 +1,25 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif +// MARK: - Test servers +// Integration tests use httpbin.org for real HTTP requests. +// These tests require network access and may fail if httpbin.org is unreachable. struct HTTPBinServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! +} + +/// Configuration that adds a Bearer token to the request. +struct BearerTokenConfiguration: RequestConfiguring { + let token: String + + init(token: String = UUID().uuidString) { + self.token = token + } - func buildRequest(endpoint: Endpoint) throws -> URLRequest { - var request = try buildStandardRequest(endpoint: endpoint) - if endpoint is AuthorizedEndpoint { - request.addValue("Bearer \(UUID().uuidString)", forHTTPHeaderField: "Authorization") - } - return request + func configure(_ request: inout URLRequest) async throws { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } } diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift index 734eda1..99b2be3 100644 --- a/Tests/FTAPIKitTests/NetworkObserverTests.swift +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -1,94 +1,77 @@ +import Foundation import FTAPIKit -import XCTest +import Testing -#if os(Linux) -import FoundationNetworking -#endif +@Suite +struct NetworkObserverTests { -final class NetworkObserverTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - - // MARK: - Unit Tests (no network required) - - func testObserverIsCalledForRequest() { + @Test + func observerIsCalledForRequest() { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) - - XCTAssertEqual(server.networkObservers.count, 1, "NetworkObservers should contain one observer") + #expect(server.networkObservers.count == 1, "NetworkObservers should contain one observer") } - func testEmptyObserversDoesNotCauseIssues() { - let server = HTTPBinServer() // Default observers is empty array + @Test + func emptyObserversDoesNotCauseIssues() async throws { + let server = HTTPBinServer() let endpoint = GetEndpoint() - - // Verify empty observers doesn't cause problems during request building - XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) - XCTAssertTrue(server.networkObservers.isEmpty, "Default networkObservers should be empty") + _ = try await server.buildRequest(endpoint: endpoint) + #expect(server.networkObservers.isEmpty, "Default networkObservers should be empty") } - func testMultipleObserversSupported() { + @Test + func multipleObserversSupported() { let observer1 = MockNetworkObserver() let observer2 = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) - - XCTAssertEqual(server.networkObservers.count, 2, "Should support multiple observers") + #expect(server.networkObservers.count == 2, "Should support multiple observers") } // MARK: - Integration Tests (requires network) - // Note: These tests may fail if httpbin.org is unavailable - func testObserverReceivesLifecycleCallbacks() { + @Test + func observerReceivesLifecycleCallbacks() async throws { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Request completed") - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) + _ = try await server.call(data: endpoint) - XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") - // didReceiveResponse is always called; didFail is called additionally on failure - XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") + #expect(mockObserver.willSendCount == 1, "willSendRequest should be called once") + #expect(mockObserver.didReceiveCount == 1, "didReceiveResponse should always be called") } - func testObserverLogsFailedRequest() { + @Test + func observerLogsFailedRequest() async { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { _ in - expectation.fulfill() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected error for 404 endpoint") + } catch { + // Expected error } - wait(for: [expectation], timeout: timeout) - - // didReceiveResponse is always called with raw data; didFail is called additionally on failure - XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") - XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") - XCTAssertEqual(mockObserver.didFailCount, 1, "didFail should be called on failure") + #expect(mockObserver.willSendCount == 1, "willSendRequest should be called once") + #expect(mockObserver.didReceiveCount == 1, "didReceiveResponse should always be called") + #expect(mockObserver.didFailCount == 1, "didFail should be called on failure") } - func testMultipleObserversAllReceiveCallbacks() { + @Test + func multipleObserversAllReceiveCallbacks() async throws { let observer1 = MockNetworkObserver() let observer2 = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Request completed") - - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) + _ = try await server.call(data: endpoint) - // Both observers should receive callbacks - XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") - XCTAssertEqual(observer2.willSendCount, 1, "Observer 2 willSendRequest should be called") - XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") - XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") + #expect(observer1.willSendCount == 1, "Observer 1 willSendRequest should be called") + #expect(observer2.willSendCount == 1, "Observer 2 willSendRequest should be called") + #expect(observer1.didReceiveCount == 1, "Observer 1 didReceiveResponse should be called") + #expect(observer2.didReceiveCount == 1, "Observer 2 didReceiveResponse should be called") } } diff --git a/Tests/FTAPIKitTests/RequestConfiguringTests.swift b/Tests/FTAPIKitTests/RequestConfiguringTests.swift new file mode 100644 index 0000000..649d979 --- /dev/null +++ b/Tests/FTAPIKitTests/RequestConfiguringTests.swift @@ -0,0 +1,150 @@ +import Foundation +import FTAPIKit +import Testing + +/// Tests for RequestConfiguring protocol functionality +@Suite +struct RequestConfiguringTests { + + @Test + func nilConfigurationMakesNoChanges() async throws { + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint()) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-Custom-Header"] == nil) + } + + @Test + func customConfigurationModifiesRequest() async throws { + let config = HeaderAddingConfiguration(headerName: "X-Custom-Header", headerValue: "test-value-123") + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: config) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-Custom-Header"] == "test-value-123") + } + + @Test + func configurationErrorPropagates() async throws { + let config = FailingConfiguration() + let server = HTTPBinServer() + + do { + _ = try await server.call(data: GetEndpoint(), configuring: config) + Issue.record("Expected error to be thrown") + } catch let error as ConfigurationError { + #expect(error == .tokenRefreshFailed) + } + } + + @Test + func asyncOperationsInConfigure() async throws { + let tokenManager = MockTokenManager() + let config = AsyncTokenConfiguration(tokenManager: tokenManager) + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: config) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["Authorization"] == "Bearer refreshed-token") + #expect(tokenManager.refreshCalled) + } + + @Test + func responseEndpointWithConfiguration() async throws { + let config = HeaderAddingConfiguration(headerName: "X-Api-Key", headerValue: "secret-key") + let server = HTTPBinServer() + let response = try await server.call(response: JSONResponseEndpoint(), configuring: config) + #expect(!response.slideshow.title.isEmpty) + } + + @Test + func voidEndpointWithConfiguration() async throws { + let config = HeaderAddingConfiguration(headerName: "X-Request-Id", headerValue: "req-123") + let server = HTTPBinServer() + try await server.call(endpoint: NoContentEndpoint(), configuring: config) + } + + @Test + func configuringOverridesBuildRequest() async throws { + let server = UserAgentServer() + let config = HeaderAddingConfiguration(headerName: "User-Agent", headerValue: "ConfigOverride/1.0") + let data = try await server.call(data: GetEndpoint(), configuring: config) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["User-Agent"] == "ConfigOverride/1.0") + } + + @Test + func compositeConfiguration() async throws { + let config1 = HeaderAddingConfiguration(headerName: "X-First", headerValue: "one") + let config2 = HeaderAddingConfiguration(headerName: "X-Second", headerValue: "two") + let composite = CompositeRequestConfiguring([config1, config2]) + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: composite) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-First"] == "one") + #expect(response.headers["X-Second"] == "two") + } + + @Test + func compositeConfigurationOrderMatters() async throws { + let first = HeaderAddingConfiguration(headerName: "X-Order", headerValue: "first") + let second = HeaderAddingConfiguration(headerName: "X-Order", headerValue: "second") + let composite = CompositeRequestConfiguring([first, second]) + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: composite) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-Order"] == "second") + } + + @Test + func downloadWithConfiguration() async throws { + let config = HeaderAddingConfiguration(headerName: "X-Download-Id", headerValue: "dl-456") + let server = HTTPBinServer() + let url = try await server.download(endpoint: ImageEndpoint(), configuring: config) + #expect(FileManager.default.fileExists(atPath: url.path)) + } +} + +// MARK: - Test Configurations + +private struct HeaderAddingConfiguration: RequestConfiguring { + let headerName: String + let headerValue: String + + func configure(_ request: inout URLRequest) async throws { + request.setValue(headerValue, forHTTPHeaderField: headerName) + } +} + +private struct FailingConfiguration: RequestConfiguring { + func configure(_ request: inout URLRequest) async throws { + throw ConfigurationError.tokenRefreshFailed + } +} + +private struct AsyncTokenConfiguration: RequestConfiguring { + let tokenManager: MockTokenManager + + func configure(_ request: inout URLRequest) async throws { + let token = await tokenManager.getValidToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } +} + +// MARK: - Test Server + +/// Server that sets User-Agent in buildRequest, to verify configuring can override it. +private struct UserAgentServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + var request = try buildStandardRequest(endpoint: endpoint) + request.setValue("BuildRequest/1.0", forHTTPHeaderField: "User-Agent") + return request + } +} + +// MARK: - Test Helpers + +private enum ConfigurationError: Error, Equatable { + case tokenRefreshFailed +} diff --git a/Tests/FTAPIKitTests/ResponseTests.swift b/Tests/FTAPIKitTests/ResponseTests.swift deleted file mode 100644 index c974cad..0000000 --- a/Tests/FTAPIKitTests/ResponseTests.swift +++ /dev/null @@ -1,240 +0,0 @@ -import FTAPIKit -import XCTest - -final class ResponseTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - - func testGet() { - let server = HTTPBinServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testClientError() { - let server = HTTPBinServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("404 endpoint must return error") - case .failure(.client): - XCTAssert(true) - case .failure: - XCTFail("404 endpoint must return client error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testServerError() { - let server = HTTPBinServer() - let endpoint = ServerErrorEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("500 endpoint must return error") - case .failure(.server): - XCTAssert(true) - case .failure: - XCTFail("500 endpoint must return server error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testConnectionError() { - let server = NonExistingServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("Non-existing domain must fail") - case .failure(.connection): - XCTAssert(true) - case .failure: - XCTFail("Non-existing domain must throw connection error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testEmptyResult() { - let server = HTTPBinServer() - let endpoint = NoContentEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testCustomError() { - let server = ErrorThrowingServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case .success = result { - XCTFail("Custom error must be returned") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONResponse() { - let server = HTTPBinServer() - let endpoint = JSONResponseEndpoint() - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONRequestResponse() { - let server = HTTPBinServer() - let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) - let endpoint = UpdateUserEndpoint(request: user) - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - switch result { - case .success(let response): - XCTAssertEqual(user, response.json) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testInvalidJSONRequestResponse() { - let server = HTTPBinServer() - let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) - let endpoint = FailingUpdateUserEndpoint(request: user) - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - if case .success = result { - XCTFail("Received valid value, decoding must fail") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testAuthorization() { - let server = HTTPBinServer() - let endpoint = AuthorizedEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - -#if !os(Linux) - func testMultipartData() { - let server = HTTPBinServer() - let file = File() - XCTAssertNoThrow(try file.write()) - do { - let endpoint = try TestMultipartEndpoint(file: file) - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } catch { - let errorFunc = { throw error } - XCTAssertNoThrow(errorFunc) - } - } -#endif - - func testURLEncodedEndpoint() { - let server = HTTPBinServer() - let endpoint = TestURLEncodedEndpoint() - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - -#if !os(Linux) - func testUploadTask() { - let server = HTTPBinServer() - let file = File() - XCTAssertNoThrow(try file.write()) - let endpoint = TestUploadEndpoint(file: file) - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } -#endif - - func testDownloadTask() { - let server = HTTPBinServer() - let endpoint = ImageEndpoint() - let expectation = self.expectation(description: "Result") - server.download(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - static let allTests = [ - ("testGet", testGet), - ("testClientError", testClientError), - ("testServerError", testServerError), - ("testConnectionError", testConnectionError), - ("testEmptyResult", testEmptyResult), - ("testCustomError", testCustomError), - ("testValidJSONResponse", testValidJSONResponse), - ("testValidJSONRequestResponse", testValidJSONRequestResponse), - ("testInvalidJSONRequestResponse", testInvalidJSONRequestResponse), - ("testAuthorization", testAuthorization), - // Test disabled due to issues with endpoints on Linux https://github.com/futuredapp/FTAPIKit/issues/79 - // ("testMultipartData", testMultipartData), - ("testURLEncodedEndpoint", testURLEncodedEndpoint), - // Test disabled due to issues with endpoints on Linux https://github.com/futuredapp/FTAPIKit/issues/79 - // ("testUploadTask", testUploadTask), - ("testDownloadTask", testDownloadTask) - ] -} diff --git a/Tests/FTAPIKitTests/URLQueryTests.swift b/Tests/FTAPIKitTests/URLQueryTests.swift index 7a75a5e..1e43309 100644 --- a/Tests/FTAPIKitTests/URLQueryTests.swift +++ b/Tests/FTAPIKitTests/URLQueryTests.swift @@ -1,48 +1,55 @@ +import Foundation +import Testing + @testable import FTAPIKit -import XCTest -final class URLQueryTests: XCTestCase { - func testSpaceEncoding() { +@Suite +struct URLQueryTests { + + @Test + func spaceEncoding() { let query: URLQuery = [ "q": "some string" ] - XCTAssertEqual(query.percentEncoded, "q=some%20string") + #expect(query.percentEncoded == "q=some%20string") } - func testDelimitersEncoding() { + @Test + func delimitersEncoding() { let query: URLQuery = [ "array[]": "a", "array[]": "b" ] - XCTAssertEqual(query.percentEncoded, "array%5B%5D=a&array%5B%5D=b") + #expect(query.percentEncoded == "array%5B%5D=a&array%5B%5D=b") } - func testQueryAppending() { + @Test + func queryAppending() { var url = URL(string: "http://httpbin.org/get")! url.appendQuery(["a": "a"]) - XCTAssertEqual(url.absoluteString, "http://httpbin.org/get?a=a") + #expect(url.absoluteString == "http://httpbin.org/get?a=a") } - func testRepeatedQueryAppending() { + @Test + func repeatedQueryAppending() { var url = URL(string: "http://httpbin.org/get")! url.appendQuery(["a": "a"]) url.appendQuery(["b": "b"]) - XCTAssertEqual(url.absoluteString, "http://httpbin.org/get?a=a&b=b") + #expect(url.absoluteString == "http://httpbin.org/get?a=a&b=b") } - func testEmptyQueryItemValues() { + @Test + func emptyQueryReturnsNil() { + let query = URLQuery() + #expect(query.percentEncoded == nil) + } + + @Test + func emptyQueryItemValues() { let query = URLQuery(items: [ URLQueryItem(name: "a", value: nil), URLQueryItem(name: "b", value: nil) ]) - XCTAssertEqual(query.percentEncoded, "a=&b=") + #expect(query.percentEncoded == "a=&b=") } - - static let allTests = [ - ("testSpaceEncoding", testSpaceEncoding), - ("testDelimitersEncoding", testDelimitersEncoding), - ("testQueryAppending", testQueryAppending), - ("testRepeatedQueryAppending", testRepeatedQueryAppending), - ("testEmptyQueryItemValues", testEmptyQueryItemValues) - ] }