diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 4b575a9..0000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Pages Deploy - -on: - push: - branches: [main] - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow one concurrent deployment -concurrency: - group: "pages" - cancel-in-progress: true - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - # Must be set to this for deploying to GitHub Pages - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: macos-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v3 - - name: Build DocC - run: | - swift package --allow-writing-to-directory ./docs \ - generate-documentation --target HPNetwork \ - --transform-for-static-hosting \ - --hosting-base-path HPNetwork \ - --output-path ./docs - - name: Fix Root Path - run: echo "" > docs/index.html - - name: Upload artifact - uses: actions/upload-pages-artifact@v1 - with: - # Upload only docs directory - path: 'docs' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v1 \ No newline at end of file diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index a13f7a3..c8f6b0d 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,4 +1,4 @@ -name: Code Testing +name: Swift on: push: @@ -7,13 +7,100 @@ on: branches: [main] jobs: - build: - runs-on: macos-latest - + test-swift: + name: Test Swift Code + runs-on: macos-14 steps: - - name: Checkout 🛎️ - uses: actions/checkout@v3 + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- - name: Build run: swift build -v - name: Run tests - run: swift test -v + run: swift test --enable-code-coverage -v + - name: Convert coverage report + run: Scripts/convert-coverage-report --target HPNetworkPackageTests + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: henrik-dmg/HPNetwork + + lint-code: + name: Lint Swift Code + runs-on: macos-14 + steps: + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + - name: Install SwiftLint + run: brew install swift-format peripheryapp/periphery/periphery + - name: Lint code + run: Scripts/lint-swift-code + - name: Scan for dead code + run: periphery scan --strict + + deploy-pages: + name: Deploy Documentation to GitHub Pages + runs-on: macos-14 + if: github.event_name != 'pull_request' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + needs: + - test-swift + steps: + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + - name: Build DocC + run: | + swift package \ + --allow-writing-to-directory "$RUNNER_TEMP/docs" \ + generate-documentation \ + --target HPNetwork \ + --transform-for-static-hosting \ + --hosting-base-path HPNetwork \ + --output-path "$RUNNER_TEMP/docs" + - name: Fix Root Path + run: echo "" > "$RUNNER_TEMP/docs/index.html" + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ${{ runner.temp }}/docs + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 0ca1966..709748f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ xcuserdata/ .swiftpm icon-configuration.json -.vscode \ No newline at end of file +.vscode +/coverage diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/.swift-format b/.swift-format.json similarity index 69% rename from .swift-format rename to .swift-format.json index 06530a2..e7bcf68 100644 --- a/.swift-format +++ b/.swift-format.json @@ -9,48 +9,59 @@ "indentSwitchCaseLabels": false, "lineBreakAroundMultilineExpressionChainComponents": false, "lineBreakBeforeControlFlowKeywords": false, - "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachArgument": true, "lineBreakBeforeEachGenericRequirement": false, - "lineLength": 160, + "lineLength": 140, "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": ["XCTAssertNoThrow"] + }, "prioritizeKeepingFunctionOutputTogether": false, "respectsExistingLineBreaks": true, "rules": { "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": true, "AlwaysUseLowerCamelCase": true, "AmbiguousTrailingClosureOverload": true, - "BeginDocumentationCommentWithOneLineSummary": false, + "BeginDocumentationCommentWithOneLineSummary": true, "DoNotUseSemicolons": true, "DontRepeatTypeInStaticProperties": true, "FileScopedDeclarationPrivacy": true, "FullyIndirectEnum": true, "GroupNumericLiterals": true, "IdentifiersMustBeASCII": true, - "NeverForceUnwrap": false, - "NeverUseForceTry": false, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, "NeverUseImplicitlyUnwrappedOptionals": false, - "NoAccessLevelOnExtensionDeclaration": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, "NoEmptyTrailingClosureParentheses": true, "NoLabelsInCasePatterns": true, "NoLeadingUnderscores": false, "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, "OneCasePerLine": true, "OneVariableDeclarationPerLine": true, "OnlyOneTrailingClosureArgument": true, "OrderedImports": true, + "ReplaceForEachWithForLoop": true, "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, "UseEarlyExits": false, "UseLetInEveryBoundCaseVariable": true, "UseShorthandTypeNames": true, "UseSingleLinePropertyGetter": true, "UseSynthesizedInitializer": true, "UseTripleSlashForDocumentationComments": true, - "UseWhereClausesInForLoops": false, - "ValidateDocumentationComments": false + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true }, + "spacesAroundRangeFormationOperators": true, "tabWidth": 4, "version": 1 } diff --git a/Assets/Banner.png b/Assets/Banner.png deleted file mode 100644 index 3842afa..0000000 Binary files a/Assets/Banner.png and /dev/null differ diff --git a/Assets/Logo.png b/Assets/Logo.png deleted file mode 100644 index ee566b8..0000000 Binary files a/Assets/Logo.png and /dev/null differ diff --git a/HPNetwork.podspec b/HPNetwork.podspec deleted file mode 100644 index af2cc88..0000000 --- a/HPNetwork.podspec +++ /dev/null @@ -1,22 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'HPNetwork' - s.version = '4.0.0-alpha.2' - s.summary = 'A lightweight but customisable networking stack written in Swift' - - s.homepage = 'https://panhans.dev/opensource/hpnetwork' - s.license = 'MIT' - s.author = { 'Henrik Panhans' => 'henrik@panhans.dev' } - s.social_media_url = 'https://twitter.com/henrik_dmg' - - s.ios.deployment_target = '13.0' - s.watchos.deployment_target = '6.0' - s.tvos.deployment_target = '13.0' - s.osx.deployment_target = '10.15' - - s.source = { git: 'https://github.com/henrik-dmg/HPNetwork.git', tag: s.version } - s.source_files = 'Sources/HPNetwork/**/*.swift' - s.framework = 'Foundation' - - s.swift_version = '5.5' - s.requires_arc = true -end diff --git a/Package.resolved b/Package.resolved index 3c0709b..a5393b7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version" : "1.1.4" + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-cmark.git", + "state" : { + "revision" : "29d9c97e6310b87c4799268eaa2fc76164b2dbd8", + "version" : "0.2.0" } }, { @@ -15,7 +24,7 @@ "location" : "https://github.com/apple/swift-docc-plugin", "state" : { "branch" : "main", - "revision" : "e013865138b53db901825fe4c002c9ec827a4109" + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247" } }, { @@ -23,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-symbolkit", "state" : { - "branch" : "main", - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34" + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" } }, { @@ -33,34 +42,34 @@ "location" : "https://github.com/apple/swift-format", "state" : { "branch" : "main", - "revision" : "e5875f32d37d0de760bd4ca3b988f42373866f96" + "revision" : "235667552abb69db75ba9c45bdff901b1626c71d" } }, { - "identity" : "swift-syntax", + "identity" : "swift-http-types", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "branch" : "main", - "revision" : "f9985dda45b883f750f2fc0d47ee10893572cc22" + "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65", + "version" : "1.0.3" } }, { - "identity" : "swift-system", + "identity" : "swift-markdown", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", + "location" : "https://github.com/apple/swift-markdown.git", "state" : { - "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", - "version" : "1.1.1" + "revision" : "68b2fed9fb12fb71ac81e537f08bed430b189e35", + "version" : "0.2.0" } }, { - "identity" : "swift-tools-support-core", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-tools-support-core.git", + "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "284a41800b7c5565512ec6ae21ee818aac1f84ac", - "version" : "0.4.0" + "branch" : "main", + "revision" : "09119754db685e6762d70e670567f522bd8828dd" } } ], diff --git a/Package.swift b/Package.swift index 916f44a..7e1337a 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "HPNetwork", platforms: [ - .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macOS(.v10_15), + .iOS(.v15), .tvOS(.v15), .watchOS(.v6), .macOS(.v12), ], products: [ .library( @@ -14,27 +14,34 @@ let package = Package( targets: ["HPNetwork"] ), .library( - name: "HPNetwork-Dynamic", - type: .dynamic, - targets: ["HPNetwork"] - ), - .library( - name: "HPNetwork-Static", - type: .static, - targets: ["HPNetwork"] + name: "HPNetworkMock", + targets: ["HPNetworkMock"] ), ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), - .package(url: "https://github.com/apple/swift-format", branch: "main") + .package(url: "https://github.com/apple/swift-format", branch: "main"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. - .target(name: "HPNetwork"), + .target( + name: "HPNetwork", + dependencies: [ + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + ] + ), + .target( + name: "HPNetworkMock", + dependencies: [ + "HPNetwork" + ] + ), .testTarget( name: "HPNetworkTests", - dependencies: ["HPNetwork"] + dependencies: ["HPNetwork", "HPNetworkMock"] ), ] ) diff --git a/README.md b/README.md index 0c2632a..7fac908 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,16 @@ # HPNetwork -![HPNetwork](/Assets/Banner.png) - -![Swift](https://github.com/henrik-dmg/HPNetwork/workflows/Swift/badge.svg) +![Swift](https://github.com/henrik-dmg/HPNetwork/workflows/Swift/badge.svg) [![codecov](https://codecov.io/gh/henrik-dmg/HPNetwork/graph/badge.svg?token=WZU3LZK4VD)](https://codecov.io/gh/henrik-dmg/HPNetwork) `HPNetwork` is a protocol-based networking stack written in pure Swift ## Installation -### SPM - -Add a new dependency for `https://github.com/henrik-dmg/HPNetwork` to your Xcode project or `.package(url: "https://github.com/henrik-dmg/HPNetwork/tree/feature/async", from: "3.0.0")` to your `Package.swift` +Starting with v4 HPNetwork is only available via Swift Package Manager. -### CocoaPods +Add a new dependency for `https://github.com/henrik-dmg/HPNetwork` to your Xcode project or `.package(url: "https://github.com/henrik-dmg/HPNetwork", from: "4.0.0")` to your `Package.swift` -Add `pod 'HPNetwork'` to your Podfile and run `pod install` - -## Posting Request +## Scheduling Requests Scheduling a request is as easy as this: @@ -26,17 +20,24 @@ let response = try await request.response() The `response` is a `NetworkResponse` containing the output and statisticsof the request. -### Combine - -You can also call `dataTaskPublisher()` on any `NetworkRequest` instance to get a `AnyPublisher` as in the closure of the regular method call. There's also a convenience method for `NetworkRequest` directly which you can call by `request.scheduleSynchronously(...)` +```swift +let result = await request.result() // Result, Error> +``` -### Cancelling Requests +Or schedule requests callback-based: -Any call to `schedule(request) { result in ... }` returns an instance of `NetworkTask` that you can cancel by calling `task.cancel()` +```swift +let task = request.schedule { result in + switch result { + case .success(let response): + // handle response + case .failure(let error): + // handle error + } +} +``` ## Creating Requests @@ -51,7 +52,7 @@ struct BasicDataRequest: DataRequest { typealias Output = Data - var requestMethod: NetworkRequestMethod { + var requestMethod: HTTPRequest.Method { .get } @@ -70,7 +71,7 @@ struct BasicDataRequest: DataRequest { typealias Output = Data let url: URL? - let requestMethod: NetworkRequestMethod + let requestMethod: HTTPRequest.Method func makeURL() throws -> URL { // construct your URL here @@ -93,7 +94,7 @@ If you're working with JSON, you can also use `DecodableRequest` which requires ```swift struct BasicDecodableRequest: DecodableRequest { - let requestMethod: NetworkRequestMethod + let requestMethod: HTTPRequest.Method var decoder: JSONDecoder { JSONDecoder() // use default or custom decoder @@ -106,26 +107,15 @@ struct BasicDecodableRequest: DecodableRequest { } ``` -### Intercepting Errors - -By default, instances of `NetworkRequest` will simply forward any encountered errors to the completion block. If you want to do some custom error conversion based on the raw `Data` that was received, you can implement `func convertError(_ error: Error, data: Data?, response: URLResponse?) -> Error` in your request model. - ### URLBuilder `URLBuilder` has been broken out into a separate package `HPURLBuilder` that can be found [here](https://github.com/henrik-dmg/HPURLBuilder) -### Request Authentication +### Request Authorization -To add authentication to a request, simply supply a `authentication: NetworkRequestAuthentication?` instance to your request. `NetworkRequestAuthentication` is an enum and supports basic authentication with a username and password, bearer token authorisation or a raw option if you want full control. +To add authorization to a request, simply supply a `authorization: Authorization?` instance to your request. +You can either use `BasicAuthorization` for basic authentication with a username and password, or `BearerAuthorization` for bearer token authorization or implement you own custom `Authorization` type. ### Authors - Henrik Panhans ([@henrik_dmg](https://twitter.com/henrik_dmg)) - -## WIP - -- [x] Cancellation support -- [x] Progress callback -- [x] Improving the documentation -- [x] Add `async` variants for the new Swift version -- [ ] Cookie support diff --git a/Scripts/configure-hooks b/Scripts/configure-hooks new file mode 100755 index 0000000..ec66f01 --- /dev/null +++ b/Scripts/configure-hooks @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +GIT_ROOT=$(git rev-parse --show-toplevel) + +# Check if the pre-commit hook already exists +if [ -f "$GIT_ROOT/.git/hooks/pre-commit" ]; then + rm "$GIT_ROOT/.git/hooks/pre-commit" +fi + +ln -s "$GIT_ROOT/Scripts/lint-swift-code" "$GIT_ROOT/.git/hooks/pre-commit" \ No newline at end of file diff --git a/Scripts/convert-coverage-report b/Scripts/convert-coverage-report new file mode 100755 index 0000000..26963df --- /dev/null +++ b/Scripts/convert-coverage-report @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Adapted from https://github.com/michaelhenry/swifty-code-coverage/blob/main/lcov.sh + +OUTPUT_FILE="coverage/lcov.info" +IGNORE_FILENAME_REGEX=".build|Tests|Pods|Carthage|DerivedData" +BUILD_PATH=".build" + +while :; do + case $1 in + --target) TARGET=$2 + shift + ;; + --output) OUTPUT_FILE=$2 + shift + ;; + *) break + esac + shift +done + +if [ -z "$BUILD_PATH" ]; then + echo "Missing --build-path. Either DerivedData or .build (for spm)" + exit 1 +fi + +if [ -z "$TARGET" ]; then + echo "Missing --target. Either an .app or an .xctest (for spm)" + exit 1 +fi + +INSTR_PROFILE=$(find $BUILD_PATH -name "*.profdata") +TARGET_PATH=$(find $BUILD_PATH -name "$TARGET" | tail -n1) +if [ -f $TARGET_PATH ]; then + OBJECT_FILE="$TARGET_PATH" +else + TARGET=$(echo $TARGET | sed 's/\.[^.]*$//') + OBJECT_FILE=$(find $BUILD_PATH -name "$TARGET" | tail -n1) +fi + +mkdir -p $(dirname "$OUTPUT_FILE") + +# print to stdout +xcrun llvm-cov report \ + "$OBJECT_FILE" \ + --instr-profile=$INSTR_PROFILE \ + --ignore-filename-regex=$IGNORE_FILENAME_REGEX \ + --use-color + +# Export to code coverage file +xcrun llvm-cov export \ + "$OBJECT_FILE" \ + --instr-profile=$INSTR_PROFILE \ + --ignore-filename-regex=$IGNORE_FILENAME_REGEX \ + --format="lcov" > $OUTPUT_FILE \ No newline at end of file diff --git a/Scripts/format-swift-code b/Scripts/format-swift-code new file mode 100755 index 0000000..ac7d965 --- /dev/null +++ b/Scripts/format-swift-code @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +swift-format format \ + --recursive \ + --parallel \ + --in-place \ + --configuration .swift-format.json \ + Sources/ \ + Tests/ \ + Package.swift \ No newline at end of file diff --git a/Scripts/lint-swift-code b/Scripts/lint-swift-code new file mode 100755 index 0000000..e63abfb --- /dev/null +++ b/Scripts/lint-swift-code @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +swift-format lint \ + --recursive \ + --parallel \ + --strict \ + --configuration .swift-format.json \ + Sources/ \ + Tests/ \ + Package.swift \ No newline at end of file diff --git a/Sources/HPNetwork/Authorization/Authorization.swift b/Sources/HPNetwork/Authorization/Authorization.swift index c0c9aa9..d114a04 100644 --- a/Sources/HPNetwork/Authorization/Authorization.swift +++ b/Sources/HPNetwork/Authorization/Authorization.swift @@ -1,7 +1,9 @@ import Foundation +/// A type that specifies authorization for a network request. public protocol Authorization { + /// The value that the `Authorization` header-field will be set to. var headerString: String { get } } diff --git a/Sources/HPNetwork/Authorization/BasicAuthorization.swift b/Sources/HPNetwork/Authorization/BasicAuthorization.swift index 30f2f7a..7d9a590 100644 --- a/Sources/HPNetwork/Authorization/BasicAuthorization.swift +++ b/Sources/HPNetwork/Authorization/BasicAuthorization.swift @@ -1,9 +1,14 @@ import Foundation +/// A type representing basic authorization for network requests. public struct BasicAuthorization: Authorization { public let headerString: String + /// Creates a new basic authorization instance. + /// - Parameters: + /// - username: The username to encode + /// - password: The password to encode public init?(username: String, password: String) { let loginString = String(format: "%@:%@", username, password) guard let loginDataString = loginString.data(using: .utf8)?.base64EncodedString() else { diff --git a/Sources/HPNetwork/Authorization/BearerAuthorization.swift b/Sources/HPNetwork/Authorization/BearerAuthorization.swift index 5bc13c5..5d82e71 100644 --- a/Sources/HPNetwork/Authorization/BearerAuthorization.swift +++ b/Sources/HPNetwork/Authorization/BearerAuthorization.swift @@ -1,9 +1,12 @@ import Foundation +/// A type representing Bearer authorization using a token for network requests. public struct BearerAuthorization: Authorization { public let headerString: String + /// Creates a new bearer authorization instance. + /// - Parameter bearerToken: The token to use with the authorization public init(_ bearerToken: String) { headerString = "Bearer \(bearerToken)" } diff --git a/Sources/HPNetwork/ConnectionMonitor.swift b/Sources/HPNetwork/ConnectionMonitor.swift index 7de2a5b..a864225 100644 --- a/Sources/HPNetwork/ConnectionMonitor.swift +++ b/Sources/HPNetwork/ConnectionMonitor.swift @@ -1,24 +1,18 @@ import Foundation import Network -public final class ConnectionMonitor { +public final class ConnectionMonitor: ObservableObject { // MARK: - Properties - public static let `default` = ConnectionMonitor() public static let connectionBecameSatisfiedNotification = Notification.Name("ConnectionBecameSatisfiedNotification") public static let connectionBecameUnsatisfiedNotification = Notification.Name("ConnectionBecameSatisfiedNotification") public static let connectionRequiresConnectionNoticication = Notification.Name("ConnectionRequiresConnectionNoticication") public static let updatedPathKey = "ConnectionMonitorUpdatedPathKey" - private let pathMonitor: NWPathMonitor - - public var pathUpdateHandler: ((NWPath) -> Void)? - public private(set) var isMonitoring = false + @Published public private(set) var currentPath: NWPath - public var currentPath: NWPath? { - isMonitoring ? pathMonitor.currentPath : nil - } + private let pathMonitor: NWPathMonitor // MARK: - Init @@ -30,11 +24,17 @@ public final class ConnectionMonitor { self.init(pathMonitor: NWPathMonitor()) } - private init(pathMonitor: NWPathMonitor) { + private init( + pathMonitor: NWPathMonitor, + queue: DispatchQueue = DispatchQueue(label: "dev.panhans.ConnectionMonitor", qos: .background) + ) { + self._currentPath = Published(initialValue: pathMonitor.currentPath) self.pathMonitor = pathMonitor - pathMonitor.pathUpdateHandler = { [weak self] path in + + self.pathMonitor.pathUpdateHandler = { [weak self] path in self?.emitNotification(path) } + self.pathMonitor.start(queue: queue) } // MARK: - State Changes @@ -42,6 +42,8 @@ public final class ConnectionMonitor { private func emitNotification(_ path: NWPath) { let userInfo = [ConnectionMonitor.updatedPathKey: path] + currentPath = path + switch path.status { case .satisfied: NotificationCenter.default.post( @@ -66,16 +68,4 @@ public final class ConnectionMonitor { } } - // MARK: - Notifications - - public func startMonitoring(on queue: DispatchQueue = .init(label: "dev.panhans.ConnectionMonitor", qos: .background)) { - pathMonitor.start(queue: queue) - isMonitoring = true - } - - public func stopMonitoring() { - pathMonitor.cancel() - isMonitoring = false - } - } diff --git a/Sources/HPNetwork/Extensions/NSError+Extensions.swift b/Sources/HPNetwork/Extensions/NSError+Extensions.swift deleted file mode 100644 index 6681e0f..0000000 --- a/Sources/HPNetwork/Extensions/NSError+Extensions.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -extension NSError { - - public static let hpCodableDataKey = "hpCodableDataKey" - - func withDescription(_ message: String) -> NSError { - var dict = userInfo - dict[NSLocalizedDescriptionKey] = message - return NSError(domain: domain, code: code, userInfo: dict) - } - - func withFailureReason(_ reason: String) -> NSError { - var dict = userInfo - dict[NSLocalizedFailureReasonErrorKey] = reason - return NSError(domain: domain, code: code, userInfo: dict) - } - - func injectJSON(_ data: Data) -> NSError { - let jsonString = data.prettyPrintedJSONString - - var dict = userInfo - dict[NSError.hpCodableDataKey] = jsonString - return NSError(domain: domain, code: code, userInfo: dict) - } - - convenience init(domain: String = "com.henrikpanhans.HPNetwork", code: Int = 1, description: String) { - let dictionary = [NSLocalizedDescriptionKey: description] - self.init(domain: domain, code: code, userInfo: dictionary) - } - - static let unknown = NSError(code: 1, description: "Unknown error") - static let failedToCreateRequest = NSError(code: 42, description: "Failed to create URLRequest") - static let imageError = NSError(code: 78, description: "Could not convert data to image") - static let cancelledNetworkOperation = NSError(code: 101, description: "The network operation was cancelled") - static let urlBuilderFailed = NSError(code: 56, description: "URLBuilder failed to construct the URL") - -} - -extension Data { - - var prettyPrintedJSONString: NSString? { - /// NSString gives us a nice sanitized debugDescription - guard - let object = try? JSONSerialization.jsonObject(with: self, options: []), - let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), - let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) - else { - return nil - } - return prettyPrintedString - } - -} diff --git a/Sources/HPNetwork/Extensions/URLRequest+Extensions.swift b/Sources/HPNetwork/Extensions/URLRequest+Extensions.swift deleted file mode 100644 index 3c4783d..0000000 --- a/Sources/HPNetwork/Extensions/URLRequest+Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public extension URLRequest { - - /// Sets all specified header fields on the URL request - /// - Parameter headerFields: The header fields which should be set on the URL request - mutating func configureHeaderFields(@HeaderFieldBuilder headerFields: () -> [HeaderField]) { - headerFields().forEach { headerField in - addHeaderField(headerField) - } - } - - /// Adds a new header field with specified name and value - /// - Parameter field: The header field that will be added to the request - mutating func addHeaderField(_ field: HeaderField) { - setValue(field.value, forHTTPHeaderField: field.name) - } - -} diff --git a/Sources/HPNetwork/Extensions/URLResponse+Extensions.swift b/Sources/HPNetwork/Extensions/URLResponse+Extensions.swift deleted file mode 100644 index 59f700f..0000000 --- a/Sources/HPNetwork/Extensions/URLResponse+Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public extension URLResponse { - - func urlError() -> URLError? { - guard let httpResponse = self as? HTTPURLResponse else { - return nil - } - - switch httpResponse.statusCode { - case 200...299: - return nil - default: - let errorCode = URLError.Code(rawValue: httpResponse.statusCode) - return URLError(errorCode) - } - } - -} diff --git a/Sources/HPNetwork/Extensions/URLSession+Extensions.swift b/Sources/HPNetwork/Extensions/URLSession+Extensions.swift deleted file mode 100644 index d205fb4..0000000 --- a/Sources/HPNetwork/Extensions/URLSession+Extensions.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -extension URLSession { - - // MARK: - DataRequest - - func hp_data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { - #if os(iOS) - if #available(iOS 15, *) { - return try await data(for: request, delegate: delegate) - } - #elseif os(OSX) - if #available(macOS 12, *) { - return try await data(for: request, delegate: delegate) - } - #elseif os(tvOS) - if #available(tvOS 15, *) { - return try await data(for: request, delegate: delegate) - } - #elseif os(watchOS) - if #available(watchOS 8, *) { - return try await data(for: request, delegate: delegate) - } - #endif - - return try await hp_dataContinuation(for: request) - } - - func hp_dataContinuation(for request: URLRequest) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - let task = dataTask(with: request) { data, response, error in - guard let data = data, let response = response else { - let error = error ?? URLError(.badServerResponse) - return continuation.resume(throwing: error) - } - continuation.resume(returning: (data, response)) - } - task.resume() - } - } - - // MARK: - DownloadRequest - - func hp_download(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (URL, URLResponse) { - #if os(iOS) - if #available(iOS 15, *) { - return try await download(for: request, delegate: delegate) - } - #elseif os(OSX) - if #available(macOS 12, *) { - return try await download(for: request, delegate: delegate) - } - #elseif os(tvOS) - if #available(tvOS 15, *) { - return try await download(for: request, delegate: delegate) - } - #elseif os(watchOS) - if #available(watchOS 8, *) { - return try await download(for: request, delegate: delegate) - } - #endif - - return try await hp_downloadContinuation(for: request) - } - - func hp_downloadContinuation(for request: URLRequest) async throws -> (URL, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - let task = downloadTask(with: request) { url, response, error in - guard let url = url, let response = response else { - let error = error ?? URLError(.badServerResponse) - return continuation.resume(throwing: error) - } - continuation.resume(returning: (url, response)) - } - task.resume() - } - } - -} diff --git a/Sources/HPNetwork/HTTPTypes+Proxy.swift b/Sources/HPNetwork/HTTPTypes+Proxy.swift new file mode 100644 index 0000000..3d4db08 --- /dev/null +++ b/Sources/HPNetwork/HTTPTypes+Proxy.swift @@ -0,0 +1 @@ +@_exported import HTTPTypes diff --git a/Sources/HPNetwork/Header Fields/AuthorizationHeaderField.swift b/Sources/HPNetwork/Header Fields/AuthorizationHeaderField.swift deleted file mode 100644 index 2ae642d..0000000 --- a/Sources/HPNetwork/Header Fields/AuthorizationHeaderField.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -public struct AuthorizationHeaderField: HeaderField { - - public var name: String { - "Authorization" - } - - public let value: String? - - public init(_ authorization: Authorization) { - value = authorization.headerString - } - -} diff --git a/Sources/HPNetwork/Header Fields/ContentType+HTTPField.swift b/Sources/HPNetwork/Header Fields/ContentType+HTTPField.swift new file mode 100644 index 0000000..39d1d42 --- /dev/null +++ b/Sources/HPNetwork/Header Fields/ContentType+HTTPField.swift @@ -0,0 +1,26 @@ +import Foundation +import HTTPTypes + +/// Common content types for network requests. +public enum ContentType: String { + /// `application/json` + case applicationJSON = "application/json" +} + +extension HTTPField { + + /// Convenience method to create a `Content-Type` header field with the specified content type. + /// - Parameter type: The content type for the header field + /// - Returns: A `Content-Type` header field with the specified content type + public static func contentType(_ type: ContentType) -> HTTPField { + HTTPField(name: .contentType, value: type.rawValue) + } + + /// Convenience method to create a `Accept` header field with the specified content type. + /// - Parameter type: The content type for the header field + /// - Returns: A `Accept` header field with the specified content type + public static func accept(_ type: ContentType) -> HTTPField { + HTTPField(name: .accept, value: type.rawValue) + } + +} diff --git a/Sources/HPNetwork/Header Fields/ContentTypeHeaderField.swift b/Sources/HPNetwork/Header Fields/ContentTypeHeaderField.swift deleted file mode 100644 index 952ea73..0000000 --- a/Sources/HPNetwork/Header Fields/ContentTypeHeaderField.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public struct ContentTypeHeaderField: HeaderField { - - public static var json: ContentTypeHeaderField { - Self("application/json") - } - - public var name: String { - "Content-Type" - } - - public let value: String? - - public init(_ contentType: String) { - value = contentType - } - -} diff --git a/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift b/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift new file mode 100644 index 0000000..cbe4156 --- /dev/null +++ b/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift @@ -0,0 +1,45 @@ +import Foundation +import HTTPTypes + +@resultBuilder +public enum HTTPFieldBuilder { + + public static func buildBlock(_ components: [HTTPField]...) -> [HTTPField] { + components.flatMap { $0 } + } + + public static func buildOptional(_ component: [HTTPField]?) -> [HTTPField] { + component ?? [] + } + + public static func buildOptional(_ component: [HTTPField?]?) -> [HTTPField] { + component?.compactMap { $0 } ?? [] + } + + /// Add support for both single and collections of constraints. + public static func buildExpression(_ expression: HTTPField) -> [HTTPField] { + [expression] + } + + public static func buildExpression(_ expression: [HTTPField]) -> [HTTPField] { + expression + } + + /// Add support for if statements. + public static func buildEither(first components: [HTTPField]) -> [HTTPField] { + components + } + + public static func buildEither(second components: [HTTPField]) -> [HTTPField] { + components + } + + public static func buildArray(_ components: [[HTTPField]]) -> [HTTPField] { + components.flatMap { $0 } + } + + public static func buildLimitedAvailability(_ component: [HTTPField]) -> [HTTPField] { + component + } + +} diff --git a/Sources/HPNetwork/Header Fields/HeaderField.swift b/Sources/HPNetwork/Header Fields/HeaderField.swift deleted file mode 100644 index e7d316d..0000000 --- a/Sources/HPNetwork/Header Fields/HeaderField.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// A type representing a network request header field -public protocol HeaderField { - - /// The name of the header field - var name: String { get } - - /// The value of the header field - var value: String? { get } -} diff --git a/Sources/HPNetwork/Header Fields/HeaderFieldBuilder.swift b/Sources/HPNetwork/Header Fields/HeaderFieldBuilder.swift deleted file mode 100644 index 76f3540..0000000 --- a/Sources/HPNetwork/Header Fields/HeaderFieldBuilder.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -@resultBuilder -public enum HeaderFieldBuilder { - - public static func buildBlock(_ components: [HeaderField]...) -> [HeaderField] { - components.flatMap { $0 } - } - - /// Add support for both single and collections of constraints. - public static func buildExpression(_ expression: HeaderField) -> [HeaderField] { - [expression] - } - - public static func buildExpression(_ expression: [HeaderField]) -> [HeaderField] { - expression - } - - /// Add support for optionals. - public static func buildOptional(_ components: [HeaderField]?) -> [HeaderField] { - components ?? [] - } - - /// Add support for if statements. - public static func buildEither(first components: [HeaderField]) -> [HeaderField] { - components - } - - public static func buildEither(second components: [HeaderField]) -> [HeaderField] { - components - } - - public static func buildArray(_ components: [[HeaderField]]) -> [HeaderField] { - components.flatMap { $0 } - } - - public static func buildLimitedAvailability(_ component: [HeaderField]) -> [HeaderField] { - component - } - -} diff --git a/Sources/HPNetwork/Header Fields/RawHeaderField.swift b/Sources/HPNetwork/Header Fields/RawHeaderField.swift deleted file mode 100644 index efd0c69..0000000 --- a/Sources/HPNetwork/Header Fields/RawHeaderField.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public struct RawHeaderField: HeaderField { - - public let name: String - public let value: String? - - public init(name: String, value: String?) { - self.name = name - self.value = value - } - -} - -public extension RawHeaderField { - - /// A header field specifying `application/json` as accepted content type - static var acceptJSON: HeaderField { - Self(name: "Accept", value: "application/json") - } - - /// A header field specifying `utf-8` as accepted character set - static var acceptCharsetUTF8: HeaderField { - Self(name: "Accept-Charset", value: "utf-8") - } - -} diff --git a/Sources/HPNetwork/NetworkClient.swift b/Sources/HPNetwork/NetworkClient.swift new file mode 100644 index 0000000..4937b48 --- /dev/null +++ b/Sources/HPNetwork/NetworkClient.swift @@ -0,0 +1,60 @@ +import Foundation + +/// A type that can schedule and handle network requests. +public protocol NetworkClientProtocol { + + func response( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> NetworkResponse + + func result( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? + ) async -> Request.RequestResult + + func schedule( + _ request: Request, + delegate: (any URLSessionTaskDelegate)?, + finishingQueue: DispatchQueue, + completion: @escaping (Request.RequestResult) -> Void + ) -> Task + +} + +/// A type that can schedule and handle network requests. +public final class NetworkClient: NetworkClientProtocol { + + /// The `URLSession` instance that will be used to execute network requests. + private let urlSession: URLSession + + /// Creates a new network client. + /// - Parameter urlSession: The `URLSession` instance that will be used to execute network requests + public init(urlSession: URLSession) { + self.urlSession = urlSession + } + + public func response( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil + ) async throws -> NetworkResponse { + try await request.response(urlSession: urlSession, delegate: delegate) + } + + public func result( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil + ) async -> Request.RequestResult { + await request.result(urlSession: urlSession, delegate: delegate) + } + + public func schedule( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil, + finishingQueue: DispatchQueue = .main, + completion: @escaping (Request.RequestResult) -> Void + ) -> Task where Request: NetworkRequest { + request.schedule(urlSession: urlSession, delegate: delegate, finishingQueue: finishingQueue, completion: completion) + } + +} diff --git a/Sources/HPNetwork/RequestMethod.swift b/Sources/HPNetwork/RequestMethod.swift deleted file mode 100644 index f1a5d22..0000000 --- a/Sources/HPNetwork/RequestMethod.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public enum RequestMethod: String, Codable, CaseIterable, Identifiable { - - case get = "GET" - case post = "POST" - case head = "HEAD" - case put = "PUT" - case delete = "DELETE" - case connect = "CONNECT" - case options = "OPTIONS" - case trace = "TRACE" - case patch = "PATCH" - - public var id: String { - rawValue - } - -} diff --git a/Sources/HPNetwork/Requests/DataRequest+Combine.swift b/Sources/HPNetwork/Requests/DataRequest+Combine.swift deleted file mode 100644 index 78f7ba0..0000000 --- a/Sources/HPNetwork/Requests/DataRequest+Combine.swift +++ /dev/null @@ -1,32 +0,0 @@ -#if canImport(Combine) -import Combine -import Foundation - -public extension DataRequest { - - func dataTaskPublisher() -> AnyPublisher { - do { - let request = try urlRequest() - return dataTaskPublisher(with: request) - } catch { - return Future { completion in - completion(.failure(error)) - }.eraseToAnyPublisher() - } - } - - private func dataTaskPublisher(with request: URLRequest) -> AnyPublisher { - urlSession.dataTaskPublisher(for: request) - .tryMap { data, response in - if let error = response.urlError() { - let convertedError = convertError(error: error, data: data, response: response) - throw convertedError - } - return (data, response) - } - .tryMap(convertResponse) - .eraseToAnyPublisher() - } - -} -#endif diff --git a/Sources/HPNetwork/Requests/DataRequest.swift b/Sources/HPNetwork/Requests/DataRequest.swift index b4bc125..8f5212c 100644 --- a/Sources/HPNetwork/Requests/DataRequest.swift +++ b/Sources/HPNetwork/Requests/DataRequest.swift @@ -1,64 +1,80 @@ import Foundation +import HTTPTypes +import HTTPTypesFoundation -/// A protocol that's used to handle regular network request where data is downloaded +/// A protocol that's used to handle regular network request where data is downloaded. public protocol DataRequest: NetworkRequest { /// Called by ``response(delegate:)``, ``schedule(delegate:completion:)`` or ``result(delegate:)`` once the networking has finished. /// /// For more convenient handling of `Decodable` output types, use ``DecodableRequest`` /// - Parameters: - /// - data: The raw data returned by the networking - /// - response: The network response + /// - data: The raw data returned by the networking + /// - response: The network response /// - Returns: An instance of the specified output type - func convertResponse(data: Data, response: URLResponse) throws -> Output - - /// Called by ``response(delegate:)``, ``schedule(delegate:completion:)`` or ``result(delegate:)`` - /// if the networking has finished successfully but `response` indicates an error. - /// Can be used to simply log errors or inspect them otherwise - /// - /// The default implementation of this simply forwards the passed in error - /// - Parameters: - /// - error: The error that occured based on `response` - /// - data: The raw data returned by the networking - /// - response: The network response - /// - Returns: The passed in or modified error - func convertError(error: URLError, data: Data, response: URLResponse) -> Error + /// - Throws: When converting the data to the desired output type failed + func convertResponse(data: Data, response: HTTPResponse) throws -> Output } // MARK: - Scheduling and Convenience -public extension DataRequest { - - func convertError(error: URLError, data _: Data, response _: URLResponse) -> Error { - error - } +extension DataRequest { - @discardableResult func response(delegate: URLSessionDataDelegate? = nil) async throws -> NetworkResponse { - let urlRequest = try urlRequest() + @discardableResult public func response( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> NetworkResponse { + // Make request + let request = try makeRequest() + // Keep track of start time let startTime = DispatchTime.now() - let result = try await urlSession.hp_data(for: urlRequest, delegate: delegate) + // Actually execute network request + let (data, response) = try await urlSession.data(for: request, delegate: delegate) + // Keep track of networking duration let networkingEndTime = DispatchTime.now() - let convertedResult = try dataTaskResult(data: result.0, response: result.1) - let processingEndTime = DispatchTime.now() - let elapsedTime = calculateElapsedTime(startTime: startTime, networkingEndTime: networkingEndTime, processingEndTime: processingEndTime) - return NetworkResponse(output: convertedResult, response: result.1, networkingDuration: elapsedTime.0, processingDuration: elapsedTime.1) + // Convert response + guard let httpResponse = (response as? HTTPURLResponse)?.httpResponse else { + throw NetworkRequestConversionError.failedToConvertURLResponseToHTTPResponse + } + // Validate response and convert output + try validateResponse(httpResponse) + let convertedResult = try convertResponse(data: data, response: httpResponse) + // Calculate total elapsed times + let elapsedTime = calculateElapsedTime( + startTime: startTime, + networkingEndTime: networkingEndTime, + processingEndTime: DispatchTime.now() + ) + // Return a NetworkResponse + return NetworkResponse( + output: convertedResult, + response: httpResponse, + networkingDuration: elapsedTime.0, + processingDuration: elapsedTime.1 + ) } - @discardableResult func result(delegate: URLSessionDataDelegate? = nil) async -> Result, Error> { + @discardableResult public func result( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)? + ) async -> RequestResult { do { - let result = try await response(delegate: delegate) + let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) } catch { return .failure(error) } } - @discardableResult func schedule( - delegate: URLSessionDataDelegate? = nil, finishingQueue: DispatchQueue = .main, completion: @escaping (RequestResult) -> Void + @discardableResult public func schedule( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)?, + finishingQueue: DispatchQueue = .main, + completion: @escaping (RequestResult) -> Void ) -> Task { Task { - let result = await result(delegate: delegate) + let result = await result(urlSession: urlSession, delegate: delegate) finishingQueue.async { completion(result) } @@ -69,30 +85,17 @@ public extension DataRequest { // MARK: - Raw Data -public extension DataRequest where Output == Data { +extension DataRequest where Output == Data { /// Called by ``schedule(delegate:)`` once the networking has finished. /// /// - Parameters: - /// - data: The raw data returned by the networking - /// - response: The network response + /// - data: The raw data returned by the networking + /// - response: The network response /// - Returns: The raw data returned by the networking - func convertResponse(data: Data, response _: URLResponse) throws -> Output { + /// - Throws: Doesn't throw, because the input `data` is simply forwarded + public func convertResponse(data: Data, response: HTTPResponse) throws -> Output { data } } - -// MARK: - Result - -extension DataRequest { - - func dataTaskResult(data: Data, response: URLResponse) throws -> Output { - if let error = response.urlError() { - throw convertError(error: error, data: data, response: response) - } else { - return try convertResponse(data: data, response: response) - } - } - -} diff --git a/Sources/HPNetwork/Requests/DecodableRequest.swift b/Sources/HPNetwork/Requests/DecodableRequest.swift index 75bb5b3..59ea7fd 100644 --- a/Sources/HPNetwork/Requests/DecodableRequest.swift +++ b/Sources/HPNetwork/Requests/DecodableRequest.swift @@ -1,32 +1,18 @@ import Foundation +import HTTPTypes -/// A protocol that's used to handle network request where the downloaded data is converted into a `Decodable` type +/// A protocol that's used to handle network request where the downloaded data is converted into a `Decodable` type. public protocol DecodableRequest: DataRequest where Output: Decodable { - /// The decoder used to decode the downloaded data + /// The decoder used to decode the downloaded data. var decoder: JSONDecoder { get } - /// A boolean indicating whether the downloaded data should be added the error in case the decoding of the desired type fails - /// - /// Defaults to false - var injectJSONOnError: Bool { get } - } -public extension DecodableRequest { - - var injectJSONOnError: Bool { false } +extension DecodableRequest { - func convertResponse(data: Data, response _: URLResponse) throws -> Output { - do { - return try decoder.decode(Output.self, from: data) - } catch let error as NSError { - if injectJSONOnError { - throw error.injectJSON(data) - } else { - throw error - } - } + public func convertResponse(data: Data, response _: HTTPResponse) throws -> Output { + try decoder.decode(Output.self, from: data) } } diff --git a/Sources/HPNetwork/Requests/DownloadRequest.swift b/Sources/HPNetwork/Requests/DownloadRequest.swift index 5de389a..27ff751 100644 --- a/Sources/HPNetwork/Requests/DownloadRequest.swift +++ b/Sources/HPNetwork/Requests/DownloadRequest.swift @@ -1,49 +1,73 @@ import Foundation +import HTTPTypes public protocol DownloadRequest: NetworkRequest where Output == URL { - func convertResponse(url: URL, response: URLResponse) throws -> Output - func convertError(error: URLError, url: URL, response: URLResponse) -> Error + func convertResponse(url: URL, response: HTTPResponse) throws -> Output } // MARK: - Scheduling and Convenience -public extension DownloadRequest { +extension DownloadRequest { - func convertResponse(url: URL, response _: URLResponse) throws -> Output { + public func convertResponse(url: URL, response: HTTPResponse) throws -> Output { url } - func convertError(error: URLError, url _: URL, response _: URLResponse) -> Error { - error - } - - @discardableResult func response(delegate: URLSessionDataDelegate? = nil) async throws -> NetworkResponse { - let urlRequest = try urlRequest() + @discardableResult public func response( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> NetworkResponse { + let request = try makeRequest() let startTime = DispatchTime.now() - let result = try await urlSession.hp_download(for: urlRequest, delegate: delegate) + + let (url, response) = try await urlSession.download(for: request, delegate: delegate) + let networkingEndTime = DispatchTime.now() - let convertedResult = try downloadTaskResult(url: result.0, response: result.1) + + guard let httpResponse = (response as? HTTPURLResponse)?.httpResponse else { + throw NetworkRequestConversionError.failedToConvertURLResponseToHTTPResponse + } + + try validateResponse(httpResponse) + let convertedResult = try convertResponse(url: url, response: httpResponse) + let processingEndTime = DispatchTime.now() - let elapsedTime = calculateElapsedTime(startTime: startTime, networkingEndTime: networkingEndTime, processingEndTime: processingEndTime) - return NetworkResponse(output: convertedResult, response: result.1, networkingDuration: elapsedTime.0, processingDuration: elapsedTime.1) + + let elapsedTime = calculateElapsedTime( + startTime: startTime, + networkingEndTime: networkingEndTime, + processingEndTime: processingEndTime + ) + return NetworkResponse( + output: convertedResult, + response: httpResponse, + networkingDuration: elapsedTime.0, + processingDuration: elapsedTime.1 + ) } - @discardableResult func result(delegate: URLSessionDataDelegate? = nil) async -> Result, Error> { + @discardableResult public func result( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)? + ) async -> RequestResult { do { - let result = try await response(delegate: delegate) + let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) } catch { return .failure(error) } } - func schedule(delegate: URLSessionDataDelegate? = nil, finishingQueue: DispatchQueue = .main, completion: @escaping (RequestResult) -> Void) -> Task< - Void, Never - > { + public func schedule( + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)?, + finishingQueue: DispatchQueue = .main, + completion: @escaping (RequestResult) -> Void + ) -> Task { Task { - let result = await result(delegate: delegate) + let result = await result(urlSession: urlSession, delegate: delegate) finishingQueue.async { completion(result) } @@ -51,17 +75,3 @@ public extension DownloadRequest { } } - -// MARK: - Result - -extension DownloadRequest { - - func downloadTaskResult(url: URL, response: URLResponse) throws -> Output { - if let error = response.urlError() { - throw convertError(error: error, url: url, response: response) - } else { - return try convertResponse(url: url, response: response) - } - } - -} diff --git a/Sources/HPNetwork/Requests/NetworkRequest.swift b/Sources/HPNetwork/Requests/NetworkRequest.swift index ada88a1..042770d 100644 --- a/Sources/HPNetwork/Requests/NetworkRequest.swift +++ b/Sources/HPNetwork/Requests/NetworkRequest.swift @@ -1,96 +1,123 @@ import Foundation +import HTTPTypes +import HTTPTypesFoundation // MARK: - NetworkRequest -/// A base protocol to define network requests +/// A base protocol to define network requests. public protocol NetworkRequest { - /// The expected output type returned in the network request + /// The expected output type returned in the network request. associatedtype Output + /// The result of a network request. typealias RequestResult = Result, Error> - /// The `URLSession` that will be used to schedule the network request - /// - /// Defaults to `URLSession.shared` - var urlSession: URLSession { get } - - /// The header fields that will be send with the network request + /// The header fields that will be send with the network request. /// /// Defaults to an empty array - @HeaderFieldBuilder var headerFields: [HeaderField] { get } + @HTTPFieldBuilder var headerFields: [HTTPField] { get } - /// The request method that will be used - var requestMethod: RequestMethod { get } + /// The request method that will be used. + var requestMethod: HTTPRequest.Method { get } - /// The authorization method used to authorize the network request + /// The authorization method used to authorize the network request. /// /// An instance of ``AuthorizationHeaderField`` will be created from this and appended to the other provided header fields. Defaults to `nil` var authorization: Authorization? { get } - /// A method used to construct or create the URL of the network request + /// A method used to construct or create the URL of the network request. /// - /// This method is the very first call when calling ``response(delegate:)``, ``result(delegate:)`` or ``schedule(delegate:finishingQueue:completion:)`` + /// This method is the very first call when calling scheduling a request func makeURL() throws -> URL - /// The data that will be send in the HTTP body of the request + /// The data that will be send in the HTTP body of the request. /// /// Defaults to `nil` func httpBody() throws -> Data? - /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly + /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly. /// - Parameters: - /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running + /// - urlSession: The `URLSession` instance to use to execute this network request + /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - Returns: a wrapper object containing an instance of ``Output`` along with the elapsed time for both networking and processing in seconds - func response(delegate: URLSessionDataDelegate?) async throws -> NetworkResponse + /// - Throws: Throws an error when anything went wrong while executing the network request + func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> NetworkResponse - /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly + /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly. /// - Parameters: - /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running + /// - urlSession: The `URLSession` instance to use to execute this network request + /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - Returns: a result with either a wrapper object containing an instance of ``Output`` along with the elapsed time for /// both networking and processing in seconds or an error - func result(delegate: URLSessionDataDelegate?) async -> RequestResult + func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> RequestResult - /// Uses all the provided information to create a `URLRequest` and schedules that request + /// Uses all the provided information to create a `URLRequest` and schedules that request. /// - Parameters: + /// - urlSession: The `URLSession` instance to use to execute this network request /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - finishingQueue: The `DispatchQueue` that the completion handler will be called on /// - completion: The block that will be executed with the result of the network request /// - Returns: A task that wraps the running network request func schedule( - delegate: URLSessionDataDelegate?, + urlSession: URLSession, + delegate: (any URLSessionTaskDelegate)?, finishingQueue: DispatchQueue, completion: @escaping (RequestResult) -> Void ) -> Task + /// A method that can be used to validate the response of a network request before any further processing will be attempted. + /// + /// The response can be checked for status codes for example. + /// - Throws: Throws an error when the response is deemed an error (for example for 400 status codes and the likes) + func validateResponse(_ response: HTTPResponse) throws + } -public extension NetworkRequest { +extension NetworkRequest { - /// Constructs a `URLRequest` from the provided values + /// Constructs a `URLRequest` from the provided values. /// - Returns: a new `URLRequest` instance - func urlRequest() throws -> URLRequest { + public func makeRequest() throws -> URLRequest { let url = try makeURL() - var request = URLRequest(url: url) - request.httpMethod = requestMethod.rawValue - request.httpBody = try httpBody() - headerFields.forEach { - request.addHeaderField($0) + var request = HTTPRequest(method: requestMethod, url: url) + + for field in headerFields { + request.headerFields.append(field) } - if let auth = authorization { - request.addHeaderField(AuthorizationHeaderField(auth)) + if let authorization { + request.headerFields[.authorization] = authorization.headerString } - return request + guard var urlRequest = URLRequest(httpRequest: request) else { + // Should theoretically never throw because we verify beforehand that `URL` is not nil + throw NetworkRequestConversionError.failedToConvertHTTPRequestToURLRequest + } + urlRequest.httpBody = try httpBody() + + return urlRequest + } + + public func validateResponse(_ response: HTTPResponse) throws { + switch response.status.kind { + case .clientError, .invalid, .redirection, .serverError: + throw URLError(URLError.Code(rawValue: response.status.code)) + case .informational, .successful: + break + } } } extension NetworkRequest { - func calculateElapsedTime(startTime: DispatchTime, networkingEndTime: DispatchTime, processingEndTime: DispatchTime) -> (TimeInterval, TimeInterval) { + func calculateElapsedTime( + startTime: DispatchTime, + networkingEndTime: DispatchTime, + processingEndTime: DispatchTime + ) -> (TimeInterval, TimeInterval) { let networkingTime = Double(networkingEndTime.uptimeNanoseconds - startTime.uptimeNanoseconds) let processingTime = Double(processingEndTime.uptimeNanoseconds - networkingEndTime.uptimeNanoseconds) @@ -100,16 +127,49 @@ extension NetworkRequest { } +enum NetworkRequestConversionError: Error { + case failedToConvertHTTPRequestToURLRequest + case failedToConvertURLResponseToHTTPResponse +} + // MARK: - Sensible Defaults -public extension NetworkRequest { +extension NetworkRequest { + + public func httpBody() throws -> Data? { nil } - func httpBody() throws -> Data? { nil } + public var headerFields: [HTTPField] { [] } - var urlSession: URLSession { .shared } + public var authorization: Authorization? { nil } - var headerFields: [HeaderField] { [] } + /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly. + /// - Parameter urlSession: The `URLSession` instance to use to execute this network request + /// - Returns: a wrapper object containing an instance of ``Output`` along with the elapsed time for both networking and processing in seconds + /// - Throws: Throws an error when anything went wrong while executing the network request + public func response(urlSession: URLSession) async throws -> NetworkResponse { + try await response(urlSession: urlSession, delegate: nil) + } + + /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly. + /// - Parameter urlSession: The `URLSession` instance to use to execute this network request + /// - Returns: a result with either a wrapper object containing an instance of ``Output`` along with the elapsed time for + /// both networking and processing in seconds or an error + public func result(urlSession: URLSession) async -> RequestResult { + await result(urlSession: urlSession, delegate: nil) + } - var authorization: Authorization? { nil } + /// Uses all the provided information to create a `URLRequest` and schedules that request. + /// - Parameters: + /// - urlSession: The `URLSession` instance to use to execute this network request + /// - finishingQueue: The `DispatchQueue` that the completion handler will be called on + /// - completion: The block that will be executed with the result of the network request + /// - Returns: A task that wraps the running network request + public func schedule( + urlSession: URLSession, + finishingQueue: DispatchQueue = .main, + completion: @escaping (RequestResult) -> Void + ) -> Task { + schedule(urlSession: urlSession, delegate: nil, finishingQueue: finishingQueue, completion: completion) + } } diff --git a/Sources/HPNetwork/Responses/NetworkResponse.swift b/Sources/HPNetwork/Responses/NetworkResponse.swift index d237010..ff84c0f 100644 --- a/Sources/HPNetwork/Responses/NetworkResponse.swift +++ b/Sources/HPNetwork/Responses/NetworkResponse.swift @@ -1,18 +1,32 @@ import Foundation +import HTTPTypes -/// A wrapper type representing the result of a network request -public struct NetworkResponse { +/// A wrapper type representing the result of a network request. +public struct NetworkResponse { - /// The actual output of the network request - public let output: T + /// The actual output of the network request. + public let output: Output - /// The original response of the network call - public let response: URLResponse + /// The original response of the network call. + public let response: HTTPResponse - /// The time that elapsed during the actual network request + /// The time that elapsed during the actual network request. public let networkingDuration: TimeInterval - /// The time that elapsed during the processing of the network request's result + /// The time that elapsed during the processing of the network request's result. public let processingDuration: TimeInterval + /// Creates a new `NetworkResponse`. + /// - Parameters: + /// - output: The actual output of the network request. + /// - response: The original response of the network call. + /// - networkingDuration: The time that elapsed during the actual network request. + /// - processingDuration: The time that elapsed during the processing of the network request's result. + public init(output: Output, response: HTTPResponse, networkingDuration: TimeInterval, processingDuration: TimeInterval) { + self.output = output + self.response = response + self.networkingDuration = networkingDuration + self.processingDuration = processingDuration + } + } diff --git a/Sources/HPNetworkMock/NetworkClientMock.swift b/Sources/HPNetworkMock/NetworkClientMock.swift new file mode 100644 index 0000000..1a78ebe --- /dev/null +++ b/Sources/HPNetworkMock/NetworkClientMock.swift @@ -0,0 +1,93 @@ +import Foundation +import HPNetwork +import HTTPTypes + +public enum NetworkClientMockError: Error { + case noMockConfiguredForRequest +} + +private protocol MockedRequest { + associatedtype Request: NetworkRequest + typealias RequestHandler = (Request) async throws -> Request.Output +} + +public final class NetworkClientMock: NetworkClientProtocol { + + // MARK: - Nested Types + + // periphery:ignore + private struct ConcreteMockedRequest: MockedRequest { + let handler: RequestHandler + } + + // MARK: - Properties + + public var fallbackToURLSessionIfNoMatchingMock = true + public var urlSession: URLSession = .shared + private var mockedRequests: [String: any MockedRequest] = [:] + + // MARK: - NetworkClientProtocol + + public func response( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil + ) async throws -> NetworkResponse { + guard let mockedRequest = mockedRequest(forType: Request.self) else { + if fallbackToURLSessionIfNoMatchingMock { + return try await request.response(urlSession: urlSession, delegate: delegate) + } + throw NetworkClientMockError.noMockConfiguredForRequest + } + let output = try await mockedRequest.handler(request) + return NetworkResponse( + output: output, + response: HTTPResponse(status: .ok, headerFields: HTTPFields()), + networkingDuration: 0.00, + processingDuration: 0.00 + ) + } + + public func result( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil + ) async -> Request.RequestResult { + do { + let response = try await response(request, delegate: delegate) + return .success(response) + } catch { + return .failure(error) + } + } + + public func schedule( + _ request: Request, + delegate: (any URLSessionTaskDelegate)? = nil, + finishingQueue: DispatchQueue = .main, + completion: @escaping (Request.RequestResult) -> Void + ) -> Task { + Task { + let result = await result(request, delegate: delegate) + finishingQueue.async { + completion(result) + } + } + } + + // MARK: - Mocking + + public func removeAllMocks() { + mockedRequests.removeAll() + } + + public func mockRequest(ofType type: Request.Type, handler: @escaping (Request) async throws -> Request.Output) + { + let typeName = String(describing: type.self) + mockedRequests[typeName] = ConcreteMockedRequest(handler: handler) + } + + private func mockedRequest(forType type: Request.Type) -> ConcreteMockedRequest? { + let typeName = String(describing: type.self) + return mockedRequests[typeName] as? ConcreteMockedRequest + } + +} diff --git a/Sources/HPNetworkMock/URLSessionMock.swift b/Sources/HPNetworkMock/URLSessionMock.swift new file mode 100644 index 0000000..cbf4b1c --- /dev/null +++ b/Sources/HPNetworkMock/URLSessionMock.swift @@ -0,0 +1,107 @@ +import Foundation +import HPNetwork +import HTTPTypes +import HTTPTypesFoundation +import XCTest + +public enum URLSessionMockError: Error { + case cantCreateURL +} + +public final class URLSessionMock: URLProtocol { + + // MARK: - Nested Types + + private struct MockedNetworkRequest { + let url: URL + let ignoresQuery: Bool + let handler: (URLRequest) throws -> (Data, HTTPURLResponse) + let id = UUID() + } + + // MARK: - Properties + + private static var mockedRequests: [UUID: MockedNetworkRequest] = [:] + + // MARK: - Registering Mocks + + @discardableResult + public static func mockRequest(to url: URL, ignoresQuery: Bool, handler: @escaping (URLRequest) throws -> (Data, HTTPURLResponse)) + -> UUID + { + let mockedRequest = MockedNetworkRequest(url: url, ignoresQuery: ignoresQuery, handler: handler) + mockedRequests[mockedRequest.id] = mockedRequest + return mockedRequest.id + } + + @discardableResult + public static func mockRequest( + to urlString: String, + ignoresQuery: Bool, + handler: @escaping (URLRequest) throws -> (Data, HTTPURLResponse) + ) throws -> UUID { + guard let url = URL(string: urlString) else { + throw URLSessionMockError.cantCreateURL + } + return mockRequest(to: url, ignoresQuery: ignoresQuery, handler: handler) + } + + public static func unregisterMockedRequest(with id: UUID) { + mockedRequests[id] = nil + } + + public static func unregisterAllMockedRequests() { + mockedRequests.removeAll() + } + + private static func mockedRequest(for url: URL) -> MockedNetworkRequest? { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + urlComponents.query = nil + let urlWithoutQuery = urlComponents.url + + return mockedRequests.values.first { request in + if request.url == url { + return true + } else if request.ignoresQuery, let urlWithoutQuery { + return request.url == urlWithoutQuery + } + return false + } + } + + // MARK: - Overridden methods + + public override class func canInit(with request: URLRequest) -> Bool { + true + } + + public override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + public override func startLoading() { + guard let url = request.url else { + XCTFail("URLRequest has no URL") + return + } + guard let mockedRequest = Self.mockedRequest(for: url) else { + XCTFail("No mocked request configured for url \"\(url.absoluteString)\"") + return + } + + do { + let (data, response) = try mockedRequest.handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + XCTFail("No response returned for url \"\(url.absoluteString)\"") + } + } + + public override func stopLoading() {} + +} diff --git a/Tests/HPNetworkTests/AuthorizationTests.swift b/Tests/HPNetworkTests/AuthorizationTests.swift new file mode 100644 index 0000000..5f03c07 --- /dev/null +++ b/Tests/HPNetworkTests/AuthorizationTests.swift @@ -0,0 +1,19 @@ +import XCTest + +@testable import HPNetwork + +final class AuthorizationTests: XCTestCase { + + func testBasicAuthorization() throws { + let auth = BasicAuthorization(username: "henrik", password: "admin") + let encodedString = try XCTUnwrap("henrik:admin".data(using: .utf8)?.base64EncodedString()) + let expectedString = "Basic \(encodedString)" + XCTAssertEqual(auth?.headerString, expectedString) + } + + func testBearerAuthorization() { + let auth = BearerAuthorization("someToken") + XCTAssertEqual(auth.headerString, "Bearer someToken") + } + +} diff --git a/Tests/HPNetworkTests/ConnectionMonitorTests.swift b/Tests/HPNetworkTests/ConnectionMonitorTests.swift new file mode 100644 index 0000000..8b9cc25 --- /dev/null +++ b/Tests/HPNetworkTests/ConnectionMonitorTests.swift @@ -0,0 +1,20 @@ +import Foundation +import Network +import XCTest + +@testable import HPNetwork + +final class ConnectionMonitorTests: XCTestCase { + + func testConnectionMonitor_StartsMonitoring() async throws { + let monitor = ConnectionMonitor() + try await Task.sleep(nanoseconds: 1_000_000) + + let expection = XCTestExpectation(description: "Networking finished") + _ = monitor.$currentPath.sink { path in + expection.fulfill() + } + await fulfillment(of: [expection], timeout: 10) + } + +} diff --git a/Tests/HPNetworkTests/DataRequestTests.swift b/Tests/HPNetworkTests/DataRequestTests.swift new file mode 100644 index 0000000..0943ee4 --- /dev/null +++ b/Tests/HPNetworkTests/DataRequestTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import HPNetwork +@testable import HPNetworkMock + +final class DataRequestTests: XCTestCase { + + // MARK: - Properties + + let url = URL(string: "https://ipapi.co/json")! + + lazy var mockedURLSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLSessionMock.self] + return URLSession(configuration: configuration) + }() + + lazy var networkClient = NetworkClient(urlSession: mockedURLSession) + + // MARK: - Tests + + func testBasicRequest_Async() async throws { + mockNetworkRequest(url: url, dataToReturn: "{}".data(using: .utf8)) + + let request = BasicDecodableRequest(url: url) + let response = try await networkClient.response(request, delegate: nil) + XCTAssertEqual(response.output, EmptyStruct()) + } + + func testBasicRequest_Result() async throws { + mockNetworkRequest(url: url, dataToReturn: "{}".data(using: .utf8)) + + let request = BasicDecodableRequest(url: url) + switch await networkClient.result(request) { + case .success(let response): + XCTAssertEqual(response.output, EmptyStruct()) + case .failure(let error): + throw error + } + } + + func testBasicRequest_Completion() async throws { + mockNetworkRequest(url: url, dataToReturn: "{}".data(using: .utf8)) + + let request = BasicDecodableRequest(url: url) + let expection = XCTestExpectation(description: "Networking finished") + _ = networkClient.schedule(request) { result in + expection.fulfill() + switch result { + case .success(let response): + XCTAssertEqual(response.output, EmptyStruct()) + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + await fulfillment(of: [expection], timeout: 10) + } + + // MARK: - Helpers + + private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { + _ = URLSessionMock.mockRequest(to: url, ignoresQuery: false) { _ in + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": ContentType.applicationJSON.rawValue] + )! + return (data ?? Data(), response) + } + } + +} + +private struct EmptyStruct: Codable, Equatable {} diff --git a/Tests/HPNetworkTests/DownloadRequestTests.swift b/Tests/HPNetworkTests/DownloadRequestTests.swift new file mode 100644 index 0000000..2bd27e6 --- /dev/null +++ b/Tests/HPNetworkTests/DownloadRequestTests.swift @@ -0,0 +1,93 @@ +import XCTest + +@testable import HPNetwork +@testable import HPNetworkMock + +final class DownloadRequestTests: XCTestCase { + + // MARK: - Properties + + private lazy var networkClient = NetworkClient(urlSession: mockedURLSession) + private lazy var mockedURLSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLSessionMock.self] + return URLSession(configuration: configuration) + }() + + private let url = URL(string: "https://ipapi.co/json")! + private let jsonString = "{}" + private var fileURL: URL? + + // MARK: - Test Lifecycle + + override func tearDownWithError() throws { + if let fileURL { + try FileManager.default.removeItem(at: fileURL) + } + try super.tearDownWithError() + } + + // MARK: - Tests + + func testBasicRequest_Async() async throws { + mockNetworkRequest(url: url, dataToReturn: jsonString.data(using: .utf8)) + + let request = BasicDownloadRequest(url: url) + let response = try await networkClient.response(request, delegate: nil) + fileURL = response.output + let downloadedContents = try String(contentsOf: response.output) + XCTAssertEqual(downloadedContents, jsonString) + } + + func testBasicRequest_Result() async throws { + mockNetworkRequest(url: url, dataToReturn: jsonString.data(using: .utf8)) + + let request = BasicDownloadRequest(url: url) + switch await networkClient.result(request) { + case .success(let response): + fileURL = response.output + let downloadedContents = try String(contentsOf: response.output) + XCTAssertEqual(downloadedContents, jsonString) + case .failure(let error): + throw error + } + } + + func testBasicRequest_Completion() async throws { + mockNetworkRequest(url: url, dataToReturn: jsonString.data(using: .utf8)) + + let request = BasicDownloadRequest(url: url) + let expection = XCTestExpectation(description: "Networking finished") + _ = networkClient.schedule(request) { [weak self] result in + expection.fulfill() + switch result { + case .success(let response): + self?.fileURL = response.output + do { + let downloadedContents = try String(contentsOf: response.output) + XCTAssertEqual(downloadedContents, self?.jsonString) + } catch { + XCTFail(error.localizedDescription) + } + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + await fulfillment(of: [expection], timeout: 10) + } + + // MARK: - Helpers + + private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { + _ = URLSessionMock.mockRequest(to: url, ignoresQuery: false) { _ in + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": ContentType.applicationJSON.rawValue] + )! + return (data ?? Data(), response) + } + } + +} diff --git a/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift new file mode 100644 index 0000000..70d8554 --- /dev/null +++ b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift @@ -0,0 +1,63 @@ +import XCTest + +@testable import HPNetwork +@testable import HPNetworkMock + +final class HTTPFieldBuilderTests: XCTestCase { + + func testFieldBuiler_SimpleField() { + let fields = buildHTTPFields { + HTTPField.contentType(.applicationJSON) + } + XCTAssertEqual(fields, [HTTPField.contentType(.applicationJSON)]) + } + + func testFieldBuiler_Array() { + let expectedFields = [HTTPField.contentType(.applicationJSON), HTTPField.contentType(.applicationJSON)] + let fields = buildHTTPFields { + expectedFields + } + XCTAssertEqual(fields, expectedFields) + } + + func testFieldBuiler_Optional() { + let expectedField: HTTPField? = HTTPField.contentType(.applicationJSON) + let fields = buildHTTPFields { + if let expectedField { + expectedField + } + } + XCTAssertEqual(fields, [expectedField]) + } + + func testFieldBuiler_IfBranchFirst() { + let expectedField = HTTPField.contentType(.applicationJSON) + let branch = true + let fields = buildHTTPFields { + if branch { + expectedField + } else { + expectedField + } + } + XCTAssertEqual(fields, [expectedField]) + } + + func testFieldBuiler_IfBranchSecond() { + let expectedField = HTTPField.contentType(.applicationJSON) + let branch = false + let fields = buildHTTPFields { + if branch { + expectedField + } else { + expectedField + } + } + XCTAssertEqual(fields, [expectedField]) + } + + private func buildHTTPFields(@HTTPFieldBuilder fields: () -> [HTTPField]) -> [HTTPField] { + fields() + } + +} diff --git a/Tests/HPNetworkTests/Helpers.swift b/Tests/HPNetworkTests/Helpers.swift deleted file mode 100644 index 6742d02..0000000 --- a/Tests/HPNetworkTests/Helpers.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation -import XCTest - -func HPAssertNoThrow( - _ expression: @autoclosure () async throws -> T, _: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line -) async { - do { - _ = try await expression() - } catch { - XCTFail(error.localizedDescription) - } -} diff --git a/Tests/HPNetworkTests/Helpers/BasicDataRequest.swift b/Tests/HPNetworkTests/Helpers/BasicDataRequest.swift new file mode 100644 index 0000000..08307cb --- /dev/null +++ b/Tests/HPNetworkTests/Helpers/BasicDataRequest.swift @@ -0,0 +1,35 @@ +import Foundation +import HPNetwork + +enum URLError: Error { + case urlNil +} + +struct BasicDataRequest: DataRequest { + + typealias Output = Data + let url: URL? + let requestMethod: HTTPRequest.Method + let authorization: (any Authorization)? + let headerFields: [HTTPField] + + init( + url: URL?, + requestMethod: HTTPRequest.Method = .get, + authorization: (any Authorization)? = nil, + @HTTPFieldBuilder headerFields: () -> [HTTPField] = { [] } + ) { + self.url = url + self.requestMethod = requestMethod + self.authorization = authorization + self.headerFields = headerFields() + } + + func makeURL() throws -> URL { + guard let url else { + throw URLError.urlNil + } + return url + } + +} diff --git a/Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift b/Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift new file mode 100644 index 0000000..508c745 --- /dev/null +++ b/Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift @@ -0,0 +1,26 @@ +import Foundation +import HPNetwork + +// periphery:ignore +struct BasicDecodableRequest: DecodableRequest { + + let url: URL? + let requestMethod: HTTPRequest.Method = .get + + var decoder: JSONDecoder { + JSONDecoder() + } + + var headerFields: [HTTPField] { + HTTPField.accept(.applicationJSON) + HTTPField.contentType(.applicationJSON) + } + + func makeURL() throws -> URL { + guard let url else { + throw URLError.urlNil + } + return url + } + +} diff --git a/Tests/HPNetworkTests/Helpers/BasicDownloadRequest.swift b/Tests/HPNetworkTests/Helpers/BasicDownloadRequest.swift new file mode 100644 index 0000000..d18759f --- /dev/null +++ b/Tests/HPNetworkTests/Helpers/BasicDownloadRequest.swift @@ -0,0 +1,30 @@ +import Foundation +import HPNetwork + +struct BasicDownloadRequest: DownloadRequest { + + let url: URL? + let requestMethod: HTTPRequest.Method + let authorization: (any Authorization)? + let headerFields: [HTTPField] + + init( + url: URL?, + requestMethod: HTTPRequest.Method = .get, + authorization: (any Authorization)? = nil, + @HTTPFieldBuilder headerFields: () -> [HTTPField] = { [] } + ) { + self.url = url + self.requestMethod = requestMethod + self.authorization = authorization + self.headerFields = headerFields() + } + + func makeURL() throws -> URL { + guard let url else { + throw URLError.urlNil + } + return url + } + +} diff --git a/Tests/HPNetworkTests/Helpers/FaultyRequest.swift b/Tests/HPNetworkTests/Helpers/FaultyRequest.swift new file mode 100644 index 0000000..63a793f --- /dev/null +++ b/Tests/HPNetworkTests/Helpers/FaultyRequest.swift @@ -0,0 +1,14 @@ +import Foundation +import HPNetwork + +struct FaultyRequest: DataRequest { + + typealias Output = Data + + let requestMethod: HTTPRequest.Method = .get + + func makeURL() throws -> URL { + throw URLError.urlNil + } + +} diff --git a/Tests/HPNetworkTests/NetworkClientMockTests.swift b/Tests/HPNetworkTests/NetworkClientMockTests.swift new file mode 100644 index 0000000..f4ea6eb --- /dev/null +++ b/Tests/HPNetworkTests/NetworkClientMockTests.swift @@ -0,0 +1,117 @@ +import XCTest + +@testable import HPNetwork +@testable import HPNetworkMock + +class NetworkClientMockTests: XCTestCase { + + // MARK: - Properties + + let url = URL(string: "https://ipapi.co/json")! + + lazy var networkClient: NetworkClientMock = { + let client = NetworkClientMock() + client.fallbackToURLSessionIfNoMatchingMock = false + return client + }() + + // MARK: - Tests + + func testBasicRequest_Async_Mocked() async throws { + networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + 32 + } + + let request = BasicDecodableRequest(url: url) + let response = try await networkClient.response(request, delegate: nil) + XCTAssertEqual(response.output, 32) + } + + func testBasicRequest_Async_Unmocked() async throws { + let request = BasicDecodableRequest(url: url) + do { + _ = try await networkClient.response(request, delegate: nil) + XCTFail("Request should not succeed") + } catch { + XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) + } + } + + func testBasicRequest_Result_Mocked() async throws { + networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + 32 + } + + let request = BasicDecodableRequest(url: url) + switch await networkClient.result(request) { + case .success(let response): + XCTAssertEqual(response.output, 32) + case .failure(let error): + throw error + } + } + + func testBasicRequest_Result_Unmocked() async throws { + let request = BasicDecodableRequest(url: url) + switch await networkClient.result(request) { + case .success: + XCTFail("Request should not succeed") + case .failure(let error): + XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) + } + } + + func testBasicRequest_Completion_Mocked() async throws { + networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + 32 + } + + let request = BasicDecodableRequest(url: url) + let expection = XCTestExpectation(description: "Networking finished") + _ = networkClient.schedule(request) { result in + expection.fulfill() + switch result { + case .success(let response): + XCTAssertEqual(response.output, 32) + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + await fulfillment(of: [expection], timeout: 10) + } + + func testBasicRequest_Completion_Unmocked() async throws { + let request = BasicDecodableRequest(url: url) + let expection = XCTestExpectation(description: "Networking finished") + _ = networkClient.schedule(request) { result in + expection.fulfill() + switch result { + case .success: + XCTFail("Request should not succeed") + case .failure(let error): + XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) + } + } + await fulfillment(of: [expection], timeout: 10) + } + + func testNetworkClientMock_RemovesAllMocks() async throws { + networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + 32 + } + + let request = BasicDecodableRequest(url: url) + let response = try await networkClient.response(request, delegate: nil) + XCTAssertEqual(response.output, 32) + + networkClient.removeAllMocks() + + do { + _ = try await networkClient.response(request, delegate: nil) + XCTFail("Request should not succeed after mock is removed") + } catch { + XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) + } + } + +} diff --git a/Tests/HPNetworkTests/NetworkRequestTests.swift b/Tests/HPNetworkTests/NetworkRequestTests.swift new file mode 100644 index 0000000..3d625d3 --- /dev/null +++ b/Tests/HPNetworkTests/NetworkRequestTests.swift @@ -0,0 +1,21 @@ +import XCTest + +@testable import HPNetwork + +final class NetworkRequestTests: XCTestCase { + + func testNetworkRequest_HasAuthorizationHeaderField_WhenSpecified() throws { + let request = BasicDataRequest( + url: URL(string: "https://google.com"), + authorization: BasicAuthorization(username: "henrik", password: "admin") + ) + let urlRequest = try request.makeRequest() + XCTAssertNotNil(urlRequest.allHTTPHeaderFields?["Authorization"]) + } + + func testNetworkRequest_ThrowsError_WhenURLIsNil() throws { + let request = FaultyRequest() + XCTAssertThrowsError(try request.makeRequest()) + } + +} diff --git a/Tests/HPNetworkTests/NetworkTests.swift b/Tests/HPNetworkTests/NetworkTests.swift deleted file mode 100644 index 398b03d..0000000 --- a/Tests/HPNetworkTests/NetworkTests.swift +++ /dev/null @@ -1,124 +0,0 @@ -import XCTest - -@testable import HPNetwork - -@available(iOS 15.0, macOS 12.0, *) -class NetworkTests: XCTestCase { - - func testSimpleRequest() async { - let request = BasicDecodableRequest(url: URL(string: "https://ipapi.co/json")) - await HPAssertNoThrow(try await request.response()) - } - - func testSimpleRequestCompletionHandler() async { - let request = BasicDecodableRequest(url: URL(string: "https://ipapi.co/json")) - - let expectiona = XCTestExpectation(description: "Networking finished") - - request.schedule { _ in - expectiona.fulfill() - } - - wait(for: [expectiona], timeout: 10) - } - - func testPublisher() { - let expectationFinished = expectation(description: "finished") - let expectationReceive = expectation(description: "receiveValue") - - let request = BasicDecodableRequest(url: URL(string: "https://ipapi.co/json")) - - let cancellable = request.dataTaskPublisher().sink { result in - switch result { - case let .failure(error): - XCTFail(error.localizedDescription) - case .finished: - expectationFinished.fulfill() - } - } receiveValue: { _ in - expectationReceive.fulfill() - } - - waitForExpectations(timeout: 10) { _ in - cancellable.cancel() - } - } - - func testPublisherFailure() { - let expectationFinished = expectation(description: "finished") - - let request = FaultyRequest() - - let cancellable = request.dataTaskPublisher().sink { result in - switch result { - case let .failure(error as NSError): - XCTAssertEqual(error, NSError.failedToCreateRequest.withFailureReason("The URL instance to create the request is nil")) - expectationFinished.fulfill() - case .finished: - XCTFail("Networking should not be successful") - } - } receiveValue: { _ in - // - } - - waitForExpectations(timeout: 10) { _ in - cancellable.cancel() - } - } - -} - -struct BasicDecodableRequest: DecodableRequest { - - let url: URL? - let requestMethod: RequestMethod = .get - - var decoder: JSONDecoder { - JSONDecoder() - } - - var headerFields: [HeaderField] { - RawHeaderField.acceptJSON - ContentTypeHeaderField.json - } - - func makeURL() throws -> URL { - guard let url = url else { - throw NSError.failedToCreateRequest - } - return url - } - -} - -struct BasicRequest: DataRequest { - - typealias Output = Data - let url: URL? - var finishingQueue: DispatchQueue - let requestMethod: RequestMethod = .get - - func makeURL() throws -> URL { - guard let url = url else { - throw NSError.failedToCreateRequest - } - return url - } - -} - -struct FaultyRequest: DataRequest { - - typealias Output = Data - - var requestMethod: RequestMethod { - .get - } - - func makeURL() throws -> URL { - throw NSError.failedToCreateRequest.withFailureReason("The URL instance to create the request is nil") - } - -} - -struct EmptyStruct: Codable {}