diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8dc7e75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: "🐛 Bug Report" +about: Report a reproducible bug or regression. +title: 'Bug: ' +labels: 'bug' + +--- + + + +Application version: + +## Steps To Reproduce + +1. +2. + + + +Link to code example: + + + +## The current behavior + + +## The expected behavior \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d5e4d05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: 🛠 Feature request +about: If you have a feature request for the network-layer, file it here. +labels: 'type: enhancement' +--- + +**Feature description** +Clearly and concisely describe the feature. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_template.yml b/.github/PULL_REQUEST_TEMPLATE/bug_template.yml new file mode 100644 index 0000000..7d6a149 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bug_template.yml @@ -0,0 +1,9 @@ +## Bug description +Clearly and concisely describe the problem. + +## Solution description +Describe your code changes in detail for reviewers. Explain the technical solution you have provided and how it fixes the issue case. + +## Covered unit test cases +- [x] yes +- [x] no \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.yml b/.github/PULL_REQUEST_TEMPLATE/feature_template.yml new file mode 100644 index 0000000..ab3978b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.yml @@ -0,0 +1,12 @@ +## Feature description +Clearly and concisely describe the feature. + +## Solution description +Describe your code changes in detail for reviewers. + +## Areas affected and ensured +List out the areas affected by your code changes. + +## Covered unit test cases +- [x] yes +- [x] no \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..619319c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: network-layer + +on: + push: + branches: + - main + - dev + pull_request: + paths: + - '.swiftlint.yml' + - ".github/workflows/**" + - "Package.swift" + - "Source/**" + - "Tests/**" +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: GitHub Action for SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict + env: + DIFF_BASE: ${{ github.base_ref }} + Latest: + name: Test Latest (iOS, macOS, tvOS, watchOS) + runs-on: macOS-12 + env: + DEVELOPER_DIR: "/Applications/Xcode_14.1.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=16.1,name=iPhone 14 Pro" + name: "iOS" + scheme: "NetworkLayer" + sdk: iphonesimulator + - destination: "OS=16.1,name=Apple TV" + name: "tvOS" + scheme: "NetworkLayer" + sdk: appletvsimulator + - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" + name: "watchOS" + scheme: "NetworkLayer" + sdk: watchsimulator + - destination: "platform=macOS" + name: "macOS" + scheme: "NetworkLayer" + sdk: macosx + steps: + - uses: actions/checkout@v3 + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./${{ matrix.sdk }}.xcresult" + \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000..e6c6e9a --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,31 @@ +name: Danger + +on: + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled, edited] + +env: + LC_CTYPE: en_US.UTF-8 + LANG: en_US.UTF-8 + +jobs: + run-danger: + runs-on: ubuntu-latest + steps: + - name: ruby setup + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + bundler-cache: true + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup gems + run: | + gem install bundler + bundle install --clean --path vendor/bundle + - name: danger + env: + + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + + run: bundle exec danger --verbose \ No newline at end of file diff --git a/.gitignore b/.gitignore index 330d167..3b29812 100644 --- a/.gitignore +++ b/.gitignore @@ -1,90 +1,9 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings +.DS_Store +/.build +/Packages +/*.xcodeproj xcuserdata/ - -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# 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/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - -# 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/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..13422b1 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,64 @@ +# Stream rules + +--swiftversion 5.3 + +# Use 'swiftformat --options' to list all of the possible options + +--header "\nnetwork-layer\nCopyright © {created.year} Space Code. All rights reserved.\n//" + +--enable blankLinesBetweenScopes +--enable blankLinesAtStartOfScope +--enable blankLinesAtEndOfScope +--enable blankLinesAroundMark +--enable anyObjectProtocol +--enable consecutiveBlankLines +--enable consecutiveSpaces +--enable duplicateImports +--enable elseOnSameLine +--enable emptyBraces +--enable initCoderUnavailable +--enable leadingDelimiters +--enable numberFormatting +--enable preferKeyPath +--enable redundantBreak +--enable redundantExtensionACL +--enable redundantFileprivate +--enable redundantGet +--enable redundantInit +--enable redundantLet +--enable redundantLetError +--enable redundantNilInit +--enable redundantObjc +--enable redundantParens +--enable redundantPattern +--enable redundantRawValues +--enable redundantReturn +--enable redundantSelf +--enable redundantVoidReturnType +--enable semicolons +--enable sortImports +--enable sortSwitchCases +--enable spaceAroundBraces +--enable spaceAroundBrackets +--enable spaceAroundComments +--enable spaceAroundGenerics +--enable spaceAroundOperators +--enable spaceInsideBraces +--enable spaceInsideBrackets +--enable spaceInsideComments +--enable spaceInsideGenerics +--enable spaceInsideParens +--enable strongOutlets +--enable strongifiedSelf +--enable todos +--enable trailingClosures +--enable unusedArguments +--enable void +--enable markTypes +--enable isEmpty + +# format options + +--wraparguments before-first +--wrapcollections before-first +--maxwidth 140 \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..297f875 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,133 @@ +excluded: + - Tests + - Package.swift + - Package@swift-5.7.swift + - .build + +# Rules + +disabled_rules: + - trailing_comma + - todo + - opening_brace + +opt_in_rules: # some rules are only opt-in + - array_init + - attributes + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - 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 + - fatal_error_message + - file_name + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - ibinspectable_in_extension + - identical_operands + - implicit_return + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - no_space_in_method_call + - operator_usage_whitespace + - optional_enum_case_matching + - orphaned_doc_comment + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - prefixed_toplevel_constant + - private_action + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_objc_attribute + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strict_fileprivate + - switch_case_on_newline + - toggle_bool + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition + +force_cast: warning +force_try: warning + +identifier_name: + excluded: + - id + - URL + +analyzer_rules: + - unused_import + - unused_declaration + +line_length: + warning: 130 + error: 200 + +type_body_length: + warning: 300 + error: 400 + +file_length: + warning: 500 + error: 1200 + +function_body_length: + warning: 30 + error: 50 + +large_tuple: + error: 3 + +nesting: + type_level: + warning: 2 + statement_level: + warning: 10 + + +type_name: + max_length: + warning: 40 + error: 50 \ No newline at end of file diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayer.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayer.xcscheme new file mode 100644 index 0000000..a82d203 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayer.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerInterfaces.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerInterfaces.xcscheme new file mode 100644 index 0000000..2395097 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerInterfaces.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerTests.xcscheme new file mode 100644 index 0000000..af4fec8 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkLayerTests.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86c4648 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Change Log +All notable changes to this project will be documented in this file. + +#### 1.x Releases +- `1.0.x` Releases - [1.0.0](#100) + +## [1.0.0](https://github.com/space-code/network-layer/releases/tag/1.0.0) +Released on 2023-12-04. + +#### Added +- Initial release of `network-layer`. + - Added by [Nikita Vasilev](https://github.com/nik3212). diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000..b266982 --- /dev/null +++ b/Dangerfile @@ -0,0 +1 @@ +danger.import_dangerfile(github: 'space-code/dangerfile') \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..20dff64 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'danger' \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..856d64b --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +all: bootstrap + +bootstrap: hook + mint bootstrap + +hook: + ln -sf ../../hooks/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + +mint: + mint bootstrap + +lint: + mint run swiftlint + +fmt: + mint run swiftformat Sources Tests + +.PHONY: all bootstrap hook mint lint fmt \ No newline at end of file diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..e2cdefa --- /dev/null +++ b/Mintfile @@ -0,0 +1,2 @@ +nicklockwood/SwiftFormat@0.52.7 +realm/SwiftLint@0.53.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..418af9a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "atomic", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/atomic", + "state" : { + "revision" : "53fae2fc8216bb5c27c87b245f893176d0d290eb", + "version" : "1.0.0" + } + }, + { + "identity" : "mocker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/WeTransfer/Mocker", + "state" : { + "revision" : "4384e015cae4916a6828252467a4437173c7ae17", + "version" : "3.0.1" + } + }, + { + "identity" : "typhoon", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/typhoon", + "state" : { + "revision" : "c0e2970812f4ec837fc61afd72a7ca1f0aa39306", + "version" : "1.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c9f72ca --- /dev/null +++ b/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NetworkLayer", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v7), + .tvOS(.v13), + .visionOS(.v1), + ], + products: [ + .library(name: "NetworkLayer", targets: ["NetworkLayer"]), + .library(name: "NetworkLayerInterfaces", targets: ["NetworkLayerInterfaces"]), + ], + dependencies: [ + .package(url: "https://github.com/space-code/atomic", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/space-code/typhoon", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/WeTransfer/Mocker", .upToNextMajor(from: "3.0.1")), + ], + targets: [ + .target( + name: "NetworkLayer", + dependencies: [ + "NetworkLayerInterfaces", + .product(name: "Atomic", package: "atomic"), + .product(name: "Typhoon", package: "typhoon"), + ] + ), + .target( + name: "NetworkLayerInterfaces", + dependencies: [ + .product(name: "Typhoon", package: "typhoon"), + ] + ), + .testTarget( + name: "NetworkLayerTests", + dependencies: [ + "NetworkLayer", + .product(name: "Mocker", package: "Mocker"), + .product(name: "Typhoon", package: "typhoon"), + ], + resources: [ + .process("Resources"), + ] + ), + ] +) diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift new file mode 100644 index 0000000..0a132cd --- /dev/null +++ b/Package@swift-5.7.swift @@ -0,0 +1,50 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NetworkLayer", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v7), + .tvOS(.v13), + ], + products: [ + .library(name: "NetworkLayer", targets: ["NetworkLayer"]), + .library(name: "NetworkLayerInterfaces", targets: ["NetworkLayerInterfaces"]), + ], + dependencies: [ + .package(url: "https://github.com/space-code/atomic", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/space-code/typhoon", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/WeTransfer/Mocker", .upToNextMajor(from: "3.0.1")), + ], + targets: [ + .target( + name: "NetworkLayer", + dependencies: [ + "NetworkLayerInterfaces", + .product(name: "Atomic", package: "atomic"), + .product(name: "Typhoon", package: "typhoon"), + ] + ), + .target( + name: "NetworkLayerInterfaces", + dependencies: [ + .product(name: "Typhoon", package: "typhoon"), + ] + ), + .testTarget( + name: "NetworkLayerTests", + dependencies: [ + "NetworkLayer", + .product(name: "Mocker", package: "Mocker"), + .product(name: "Typhoon", package: "typhoon"), + ], + resources: [ + .process("Resources"), + ] + ), + ] +) diff --git a/README.md b/README.md index 21c4160..6c45a18 100644 --- a/README.md +++ b/README.md @@ -1 +1,99 @@ -# network-layer \ No newline at end of file +![NetworkLayer: Network communication made easy](https://raw.githubusercontent.com/space-code/network-layer/dev/Resources/network-layer.png) + +

network-layer

+ +

+License +Swift Compability +Platform Compability +CI + + +

+ +## Description +`network-layer` is a library for network communication. + +- [Usage](#usage) +- [Documentation](#documentation) +- [Requirements](#requirements) +- [Installation](#installation) +- [Communication](#communication) +- [Contributing](#contributing) +- [Author](#author) +- [Dependencies](#dependencies) +- [License](#license) + +## Usage + +```swift +import NetworkLayer +import NetworkLayerInterfaces + +struct Request: IRequest { + var domainName: String { + "https://example.com" + } + + var path: String { + "user" + } + + var httpMethod: HTTPMethod { + .get + } +} + +let request = Request() +let requestProcessor = NetworkLayerAssembly().assemble() +let user: User = try await requestProcessor.send(request) +``` + +## Documentation + +Check out [network-layer documentation](https://github.com/space-code/network-layer/blob/main/Sources/NetworkLayer/NetworkLayer.docc/NetworkLayer.md). + +## Requirements +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ +- Xcode 14.0 +- Swift 5.7 + +## Installation +### Swift Package Manager + +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. It is in early development, but `network-layer` does support its use on supported platforms. + +Once you have your Swift package set up, adding `network-layer` as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. + +```swift +dependencies: [ + .package(url: "https://github.com/space-code/network-layer.git", .upToNextMajor(from: "1.0.0")) +] +``` + +## Communication +- If you **found a bug**, open an issue. +- If you **have a feature request**, open an issue. +- If you **want to contribute**, submit a pull request. + +## Contributing +Bootstrapping development environment + +``` +make bootstrap +``` + +Please feel free to help out with this project! If you see something that could be made better or want a new feature, open up an issue or send a Pull Request! + +## Author +Nikita Vasilev, nv3212@gmail.com + +## Dependencies +This project uses several open-source packages: + +* [Atomic](https://github.com/space-code/atomic) is a Swift property wrapper designed to make values thread-safe. +* [Typhoon](https://github.com/space-code/typhoon) is a service for retry policies. +* [Mocker](https://github.com/WeTransfer/Mocker) is a library written in Swift which makes it possible to mock data requests using a custom `URLProtocol`. + +## License +network-layer is available under the MIT license. See the LICENSE file for more info. diff --git a/Resources/network-layer.png b/Resources/network-layer.png new file mode 100644 index 0000000..19d77b8 Binary files /dev/null and b/Resources/network-layer.png differ diff --git a/Sources/NetworkLayer/Classes/Core/Authentification/AuthenticationInterceptor.swift b/Sources/NetworkLayer/Classes/Core/Authentification/AuthenticationInterceptor.swift new file mode 100644 index 0000000..b017286 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Authentification/AuthenticationInterceptor.swift @@ -0,0 +1,95 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Atomic +import Foundation +import NetworkLayerInterfaces + +/// A custom AuthenticationInterceptor implementation that works with a specific type +/// of Authenticator conforming to the IAuthenticator protocol. +public final class AuthenticationInterceptor: IAuthenticationInterceptor { + // MARK: Types + + public typealias Credential = Authenticator.Credential + + // MARK: Private + + private let authenticator: Authenticator + @Atomic public var credential: Credential? + + // MARK: Initialization + + /// Creates a new instance of `AuthenticationInterceptor`. + /// + /// - Parameters: + /// - authenticator: The authenticator. + /// - credential: The credential. + public init(authenticator: Authenticator, credential: Credential? = nil) { + self.authenticator = authenticator + self.credential = credential + } + + // MARK: IAuthentificatorInterceptor + + /// Adapts the request with credentials. + /// + /// - Parameters: + /// - request: The URLRequest to be adapted. + /// - session: The URLSession for which the request is being adapted. + public func adapt(request: inout URLRequest, for session: URLSession) async throws { + guard let credential else { + throw AuthenticatorInterceptorError.missingCredential + } + + if credential.requiresRefresh { + try await refresh(credential, for: session) + } else { + try await authenticator.apply(credential, to: request) + } + } + + /// Refreshes credential for the request. + /// + /// - Parameters: + /// - request: The URLRequest to be refreshed. + /// - session: The URLSession for which the request is being refreshed. + public func refresh( + _ request: URLRequest, + with response: HTTPURLResponse, + for session: URLSession + ) async throws { + guard isRequireRefresh(request, response: response) else { + return + } + + guard let credential = credential else { + throw AuthenticatorInterceptorError.missingCredential + } + + guard authenticator.isRequest(request, authenticatedWith: credential) else { + return + } + + try await refresh(credential, for: session) + } + + /// Determines whether a request requires a credential refresh. + /// + /// - Parameters: + /// - request: The URLRequest to check. + /// - response: The HTTPURLResponse received for the request. + /// + /// - Returns: A boolean indicating whether a credential refresh is required. + public func isRequireRefresh(_ request: URLRequest, response: HTTPURLResponse) -> Bool { + authenticator.didRequest(request, with: response) + } + + // MARK: Private + + private func refresh(_ credential: Credential, for session: URLSession) async throws { + let credential = try await authenticator.refresh(credential, for: session) + self.credential = credential + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/IRequestBodyEncoder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/IRequestBodyEncoder.swift new file mode 100644 index 0000000..7bc4b0a --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/IRequestBodyEncoder.swift @@ -0,0 +1,17 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +/// A type defines the interface for the request body encoder. +protocol IRequestBodyEncoder { + /// Encodes parameters into the request body. + /// + /// - Parameters: + /// - body: The parameters to be encoded. + /// - request: The request. + func encode(body: RequestBody, to request: inout URLRequest) throws +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/RequestBodyEncoder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/RequestBodyEncoder.swift new file mode 100644 index 0000000..db481d4 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBodyEncoder/RequestBodyEncoder.swift @@ -0,0 +1,32 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +struct RequestBodyEncoder: IRequestBodyEncoder { + // MARK: Properties + + private let jsonEncoder: JSONEncoder + + // MARK: Initialization + + init(jsonEncoder: JSONEncoder) { + self.jsonEncoder = jsonEncoder + } + + // MARK: IRequestBodyEncoder + + func encode(body: NetworkLayerInterfaces.RequestBody, to request: inout URLRequest) throws { + switch body { + case let .data(data): + request.httpBody = data + case let .encodable(encodable): + request.httpBody = try jsonEncoder.encode(encodable) + case let .dictonary(dictionary): + request.httpBody = try JSONSerialization.data(withJSONObject: dictionary) + } + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift new file mode 100644 index 0000000..48f581f --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift @@ -0,0 +1,62 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class RequestBuilder: IRequestBuilder { + // MARK: Properties + + private let parametersEncoder: IRequestParametersEncoder + private let requestBodyEncoder: IRequestBodyEncoder + + // MARK: Initialization + + init( + parametersEncoder: IRequestParametersEncoder, + requestBodyEncoder: IRequestBodyEncoder + ) { + self.parametersEncoder = parametersEncoder + self.requestBodyEncoder = requestBodyEncoder + } + + // MARK: IRequestBuilder + + func build( + _ request: NetworkLayerInterfaces.IRequest, + _ configure: ((inout URLRequest) throws -> Void)? + ) throws -> URLRequest? { + guard let fullPath = request.fullPath, let url = URL(string: fullPath) else { + throw URLError(.badURL) + } + + var urlRequest = URLRequest( + url: url, + cachePolicy: request.cachePolicy, + timeoutInterval: request.timeoutInterval + ) + + urlRequest.httpMethod = request.httpMethod.rawValue + + setHeaders(to: &urlRequest, headers: request.headers) + + try parametersEncoder.encode(parameters: request.parameters ?? [:], to: &urlRequest) + + if let httpBody = request.httpBody { + try requestBodyEncoder.encode(body: httpBody, to: &urlRequest) + } + + try configure?(&urlRequest) + + return urlRequest + } + + // MARK: Private + + private func setHeaders(to request: inout URLRequest, headers: [String: String]?) { + guard let headers else { return } + headers.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) } + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/IRequestParametersEncoder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/IRequestParametersEncoder.swift new file mode 100644 index 0000000..6b96812 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/IRequestParametersEncoder.swift @@ -0,0 +1,16 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A type defines the interface for the request parameters encoder. +protocol IRequestParametersEncoder { + /// Encodes parameters into the request query. + /// + /// - Parameters: + /// - parameters: The parameters to be encoded. + /// - request: The request. + func encode(parameters: [String: String], to request: inout URLRequest) throws +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/RequestParametersEncoder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/RequestParametersEncoder.swift new file mode 100644 index 0000000..863e8f2 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestParameterEncoder/RequestParametersEncoder.swift @@ -0,0 +1,29 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +struct RequestParametersEncoder: IRequestParametersEncoder { + func encode(parameters: [String: String], to request: inout URLRequest) throws { + guard let url = request.url else { + throw URLError(.badURL) + } + + if parameters.isEmpty { + return + } + + let queries = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + var urlComponents = URLComponents(string: url.absoluteString) + + urlComponents?.queryItems = queries + + guard let url = urlComponents?.url else { + throw URLError(.badURL) + } + + request.url = url + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Services/DataRequestHandler/DataRequestHandler.swift b/Sources/NetworkLayer/Classes/Core/Services/DataRequestHandler/DataRequestHandler.swift new file mode 100644 index 0000000..64f3400 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Services/DataRequestHandler/DataRequestHandler.swift @@ -0,0 +1,190 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Atomic +import Foundation +import NetworkLayerInterfaces + +// MARK: - DataRequestHandler + +/// Manages data request handlers for URLSessionTasks. +final class DataRequestHandler: NSObject { + // MARK: Properties + + private typealias HandlerDictonary = [URLSessionTask: DataTaskHandler] + + /// The dictonary that stores handlers. + @Atomic private var handlers: HandlerDictonary = [:] + /// A protocol that defines methods that URL session instances call on their + /// delegates to handle task-level events specific to data and upload tasks. + private var userDataDelegate: URLSessionDataDelegate? + + /// A protocol that defines methods that URL session instances call on their + /// delegates to handle session-level events, like session life cycle changes. + var urlSessionDelegate: URLSessionDelegate? { + didSet { + userDataDelegate = urlSessionDelegate as? URLSessionDataDelegate + } + } +} + +// MARK: IDataRequestHandler + +extension DataRequestHandler: IDataRequestHandler { + func startDataTask( + _ task: URLSessionDataTask, + delegate: URLSessionDelegate? + ) async throws -> Response { + try await withTaskCancellationHandler(operation: { + try await withUnsafeThrowingContinuation { continuation in + let dataTaskHandler = DataTaskHandler(delegate: delegate) + dataTaskHandler.completion = continuation.resume(with:) + handlers[task] = dataTaskHandler + task.resume() + } + }, onCancel: { + task.cancel() + }) + } +} + +// MARK: URLSessionDataDelegate + +extension DataRequestHandler { + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let handler = handlers[dataTask] else { return } + + if handler.data == nil { + handler.data = Data() + } + + handler.data?.append(data) + } + + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + userDataDelegate?.urlSession?(session, didBecomeInvalidWithError: error) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void + ) { + userDataDelegate?.urlSession?( + session, + dataTask: dataTask, + willCacheResponse: proposedResponse, + completionHandler: completionHandler + ) + completionHandler(proposedResponse) + } +} + +// MARK: URLSessionTaskDelegate + +extension DataRequestHandler { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let handler = handlers[task] else { return } + handlers[task] = nil + + userDataDelegate?.urlSession?(session, task: task, didCompleteWithError: error) + + if let error = error { + handler.completion?(.failure(error)) + } else { + if let response = task.response { + let data = handler.data ?? Data() + let response = Response(data: data, response: response, task: task) + handler.completion?(.success(response)) + } else { + handler.completion?(.failure(URLError(.unknown))) + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + userDataDelegate?.urlSession?(session, task: task, didFinishCollecting: metrics) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + userDataDelegate?.urlSession?( + session, + task: task, + willPerformHTTPRedirection: response, + newRequest: request, + completionHandler: completionHandler + ) + completionHandler(request) + } + + func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + userDataDelegate?.urlSession?(session, taskIsWaitingForConnectivity: task) + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + userDataDelegate?.urlSession?(session, didReceive: challenge, completionHandler: completionHandler) + completionHandler(.performDefaultHandling, nil) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willBeginDelayedRequest request: URLRequest, + completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void + ) { + userDataDelegate?.urlSession?(session, task: task, willBeginDelayedRequest: request, completionHandler: completionHandler) + completionHandler(.continueLoading, nil) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + userDataDelegate?.urlSession?( + session, + task: task, + didSendBodyData: bytesSent, + totalBytesSent: totalBytesSent, + totalBytesExpectedToSend: totalBytesExpectedToSend + ) + } +} + +// MARK: DataRequestHandler.DataTaskHandler + +private extension DataRequestHandler { + private class DataTaskHandler { + // MARK: Types + + typealias Completion = (Result, Error>) -> Void + + // MARK: Properties + + let delegate: URLSessionDelegate? + + var data: Data? + var completion: Completion? + + // MARK: Initialization + + init(delegate: URLSessionDelegate?) { + self.delegate = delegate + } + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift b/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift new file mode 100644 index 0000000..8754162 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift @@ -0,0 +1,185 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces +import Typhoon + +// MARK: - RequestProcessor + +/// An object that handles request processing. +actor RequestProcessor { + // MARK: Properties + + /// The network layer's configuration. + private let configuration: Configuration + /// The object that coordinates a group of related, network data transfer tasks. + private let session: URLSession + /// The data request handler. + private let dataRequestHandler: any IDataRequestHandler + /// The request builder. + private let requestBuilder: IRequestBuilder + /// The retry policy service. + private let retryPolicyService: IRetryPolicyService + /// The authenticator interceptor. + private let interceptor: IAuthenticationInterceptor? + /// The delegate. + private weak var delegate: RequestProcessorDelegate? + + // MARK: Initialization + + /// Creates a new `RequestProcessor` instance. + /// + /// - Parameters: + /// - configure: The network layer's configuration. + /// - requestBuilder: The request builder. + /// - dataRequestHandler: The data request handler. + /// - retryPolicyService: The retry policy service. + init( + configuration: Configuration, + requestBuilder: IRequestBuilder, + dataRequestHandler: any IDataRequestHandler, + retryPolicyService: IRetryPolicyService, + delegate: RequestProcessorDelegate?, + interceptor: IAuthenticationInterceptor? + ) { + self.configuration = configuration + self.requestBuilder = requestBuilder + self.dataRequestHandler = dataRequestHandler + self.retryPolicyService = retryPolicyService + self.delegate = delegate + self.interceptor = interceptor + self.dataRequestHandler.urlSessionDelegate = configuration.sessionDelegate + session = URLSession( + configuration: configuration.sessionConfiguration, + delegate: dataRequestHandler, + delegateQueue: configuration.sessionDelegateQueue + ) + } + + // MARK: Private + + /// Performs a network request. + /// + /// - Parameters: + /// - request: The network request. + /// - strategy: The retry policy strategy. + /// - delegate: A protocol that defines methods that URL session instances call on their delegates + /// to handle session-level events, like session life cycle changes. + /// - configure: A closure to configure the URLRequest. + /// + /// - Returns: The response from the network request. + private func performRequest( + _ request: T, + strategy: RetryPolicyStrategy? = nil, + delegate: URLSessionDelegate?, + configure: ((inout URLRequest) throws -> Void)? + ) async throws -> Response { + guard var urlRequest = try requestBuilder.build(request, configure) else { + throw NetworkLayerError.badURL + } + + try await adapt(request, urlRequest: &urlRequest, session: session) + + return try await performRequest(strategy: strategy) { + try await self.delegate?.requestProcessor(self, willSendRequest: urlRequest) + + let task = session.dataTask(with: urlRequest) + + do { + let response = try await dataRequestHandler.startDataTask(task, delegate: delegate) + + if request.requiresAuthentication { + let isRefreshedCredential = try await refresh( + urlRequest: urlRequest, + response: response, + session: session + ) + + if isRefreshedCredential { + throw AuthenticatorInterceptorError.missingCredential + } + } + + try self.validate(response) + + return response + } catch { + throw error + } + } + } + + /// Adapts an initial request. + /// + /// - Parameters: + /// - request: The request model. + /// - urlRequest: The request that needs to be authenticated. + /// - session: The URLSession for which the request is being refreshed. + private func adapt(_ request: T, urlRequest: inout URLRequest, session: URLSession) async throws { + guard request.requiresAuthentication else { return } + try await interceptor?.adapt(request: &urlRequest, for: session) + } + + /// Refreshes credential. + /// + /// - Parameters: + /// - urlRequest: The request that needs to be authenticated. + /// - response: The metadata associated with the response to an HTTP protocol URL load request. + /// - session: The URLSession for which the request is being refreshed. + /// + /// - Returns: `true` if the request's token is refreshed, false otherwise. + private func refresh( + urlRequest: URLRequest, + response: Response, + session: URLSession + ) async throws -> Bool { + guard let interceptor, let response = response.response as? HTTPURLResponse else { return false } + + if interceptor.isRequireRefresh(urlRequest, response: response) { + try await interceptor.refresh(urlRequest, with: response, for: session) + return true + } + + return false + } + + /// Performs a request with a retry policy. + /// + /// - Parameters: + /// - strategy: The strategy for retrying the request. + /// - send: The closure that sends the request. + /// + /// - Returns: The response from the network request. + private func performRequest( + strategy: RetryPolicyStrategy? = nil, + _ send: () async throws -> T + ) async throws -> T { + do { + return try await send() + } catch { + return try await retryPolicyService.retry(strategy: strategy, send) + } + } + + private func validate(_ response: Response) throws { + guard let urlResponse = response.response as? HTTPURLResponse else { return } + try delegate?.requestProcessor(self, validateResponse: urlResponse, data: response.data, task: response.task) + } +} + +// MARK: IRequestProcessor + +extension RequestProcessor: IRequestProcessor { + func send( + _ request: T, + strategy: RetryPolicyStrategy? = nil, + delegate: URLSessionDelegate? = nil, + configure: ((inout URLRequest) throws -> Void)? = nil + ) async throws -> Response { + let response = try await performRequest(request, strategy: strategy, delegate: delegate, configure: configure) + return try response.map { data in try self.configuration.jsonDecoder.decode(M.self, from: data) } + } +} diff --git a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift new file mode 100644 index 0000000..18e0bc2 --- /dev/null +++ b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift @@ -0,0 +1,78 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces +import Typhoon + +public final class NetworkLayerAssembly: INetworkLayerAssembly { + // MARK: Properties + + /// The network layer's configuration. + private let configure: Configuration + /// The retry policy service. + private let retryPolicyStrategy: RetryPolicyStrategy? + /// The request processor delegate. + private let delegate: RequestProcessorDelegate? + /// The authenticator interceptor. + private let interceptor: IAuthenticationInterceptor? + /// The json encoder. + private let jsonEncoder: JSONEncoder + + // MARK: Initialization + + public init( + configure: Configuration = .init( + sessionConfiguration: .default, + sessionDelegate: nil, + sessionDelegateQueue: nil, + jsonDecoder: JSONDecoder() + ), + retryPolicyStrategy: RetryPolicyStrategy? = nil, + delegate: RequestProcessorDelegate? = nil, + interceptor: IAuthenticationInterceptor? = nil, + jsonEncoder: JSONEncoder = JSONEncoder() + ) { + self.configure = configure + self.retryPolicyStrategy = retryPolicyStrategy + self.delegate = delegate + self.interceptor = interceptor + self.jsonEncoder = jsonEncoder + } + + // MARK: INetworkLayerAssembly + + public func assemble() -> IRequestProcessor { + RequestProcessor( + configuration: configure, + requestBuilder: requestBuilder, + dataRequestHandler: DataRequestHandler(), + retryPolicyService: RetryPolicyService(strategy: retryPolicyStrategy ?? defaultStrategy), + delegate: delegate, + interceptor: interceptor + ) + } + + // MARK: Private + + private var defaultStrategy: RetryPolicyStrategy { + .constant(retry: 5, duration: .seconds(1)) + } + + private var requestBuilder: IRequestBuilder { + RequestBuilder( + parametersEncoder: parametersEncoder, + requestBodyEncoder: requestBodyEncoder + ) + } + + private var parametersEncoder: IRequestParametersEncoder { + RequestParametersEncoder() + } + + private var requestBodyEncoder: IRequestBodyEncoder { + RequestBodyEncoder(jsonEncoder: jsonEncoder) + } +} diff --git a/Sources/NetworkLayer/Classes/Extensions/IRequest+.swift b/Sources/NetworkLayer/Classes/Extensions/IRequest+.swift new file mode 100644 index 0000000..fc6fe74 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Extensions/IRequest+.swift @@ -0,0 +1,16 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +extension IRequest { + var fullPath: String? { + if !domainName.isEmpty { + return [domainName, path].joined(separator: "/") + } + return nil + } +} diff --git a/Sources/NetworkLayer/Classes/Extensions/Response+Map.swift b/Sources/NetworkLayer/Classes/Extensions/Response+Map.swift new file mode 100644 index 0000000..36242ec --- /dev/null +++ b/Sources/NetworkLayer/Classes/Extensions/Response+Map.swift @@ -0,0 +1,13 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +extension Response { + func map(_ closure: @escaping (T) throws -> U) rethrows -> Response { + try Response(data: closure(data), response: response, task: task) + } +} diff --git a/Sources/NetworkLayer/NetworkLayer.docc/Articles/Authentication.md b/Sources/NetworkLayer/NetworkLayer.docc/Articles/Authentication.md new file mode 100644 index 0000000..c44f16c --- /dev/null +++ b/Sources/NetworkLayer/NetworkLayer.docc/Articles/Authentication.md @@ -0,0 +1,72 @@ +# Authentication + +Learn how to implement authentication. + +## Overview + +The `network-layer` includes an ``AuthenticationInterceptor`` responsible for handling authentication options. + +## Access Tokens + +``AuthenticationInterceptor`` requires passing an `IAuthenticator` as initialization parameters that contain logic for updating the access token. + +A credential object must conform to `IAuthenticationCredential` protocol that indicates whether the credential is valid. + +```swift +import NetworkLayerInterfaces + +// Defines a credential model +struct Credential: IAuthenticationCredential { + let expires: Date + let requiresRefresh: Bool { expires < Date() } +} +``` + +Define a `Authenticator` object that confrorms to `IAuthenticator` and implement your own logic for validation and refreshing an access token. + +```swift +import NetworkLayerInterfaces + +struct Authenticator: IAuthenticator { + + /// Applies the `Credential` to the `URLRequest`. + /// + /// - Parameters: + /// - credential: The `Credential`. + /// - urlRequest: The `URLRequest`. + func apply(_ credential: Credential, to urlRequest: URLRequest) async throws { + request.addValue("Bearer ", forHTTPHeaderField: "Authorization") + } + + /// Refreshes the `Credential`. + /// + /// - Parameters: + /// - credential: The `Credential` to refresh. + /// - session: The `URLSession` requiring the refresh. + func refresh(_ credential: Credential, for session: URLSession) async throws -> Credential { + // Token refresh logic here + } + + /// Determines whether the `URLRequest` failed due to an authentication error based on the `HTTPURLResponse`. + /// + /// - Parameters: + /// - urlRequest: The `URLRequest`. + /// - response: The `HTTPURLResponse`. + /// + /// - Returns: `true` if the `URLRequest` failed due to an authentication error, `false` otherwise. + func didRequest(_ urlRequest: URLRequest, with response: HTTPURLResponse) -> Bool { + response.statusCode == 401 + } + + /// Determines whether the `URLRequest` is authenticated with the `Credential`. + /// + /// - Parameters: + /// - urlRequest: The `URLRequest`. + /// - credential: The `Credential`. + /// + /// - Returns: `true` if the `URLRequest` is authenticated with the `Credential`, `false` otherwise. + func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: Credential) -> Bool { + true + } +} +``` diff --git a/Sources/NetworkLayer/NetworkLayer.docc/Articles/GettingStarted.md b/Sources/NetworkLayer/NetworkLayer.docc/Articles/GettingStarted.md new file mode 100644 index 0000000..51cfec4 --- /dev/null +++ b/Sources/NetworkLayer/NetworkLayer.docc/Articles/GettingStarted.md @@ -0,0 +1,77 @@ +# Getting Started with Network Layer + +## Defining a Request + +Before sending a request to a web server, it is necessary to define a request model. For this, define a new `struct` object that conforms to `IRequest` protocol. + +```swift +import NetworkLayerInterfaces + +struct UserRequest: IRequest { + // MARK: Properties + + private let id: Int + + // MARK: Initialization + + init(id: Int) { + self.id = id + } + + // MARK: IRequest + + /// The base `URL` for the resource. + var domainName: String { + "https://example.com" + } + + /// The endpoint path. + var path: String { + "user" + } + + /// A dictionary that contains the parameters to be encoded into the request. + var parameters: [String: String]? { + ["user_id": id] + } + + /// A Boolean value indicating whether authentication is required. + var requiresAuthentication: Bool { + true + } + + /// The HTTP method. + var httpMethod: HTTPMethod { + .get + } +} +``` + +## Defining a Response Model + +While the `network-layer` returns a `Response` object that expects a decodable object, it is necessary to define a response model. + +```swift +import NetworkLayerInterfaces + +struct UserResponse: Decodable { + let id: Int + let userName: String +} +``` + +## Usage + +```swift +import NetworkLayerInterfaces + +let request = UserRequest(id: 1) + +do { + let user = try await requestProcessor.send(request) +} catch { + // Catch an error here +} + +``` + diff --git a/Sources/NetworkLayer/NetworkLayer.docc/Articles/Retry.md b/Sources/NetworkLayer/NetworkLayer.docc/Articles/Retry.md new file mode 100644 index 0000000..8d07ba1 --- /dev/null +++ b/Sources/NetworkLayer/NetworkLayer.docc/Articles/Retry.md @@ -0,0 +1,31 @@ +# Retry + +Learn how to retry failed requests. + +## Overview + +The `network-layer` is implemented using the `Typhoon` framework to handle the retrying of failed requests. You can read more about `Typhoon` framework [here](https://github.com/space-code/typhoon/). + +## Retrying Failed Requests + +By default, the `network-layer` attempts to resend a failed request five times. If you wish to customize this behavior, you can pass the desired value to an assembly of the `RequestProcessor`. + +```swift +import NetworkLayer +import NetworkLayerInterfaces + +let requestProcessor = NetworkLayerAssembly.assemble(retryPolicyStrategy: .constant(retry: 10, duration: .seconds(1))) +``` + +> Tip: `typhoon` framework provides different strategies for retrying failed request. You can read more [here](https://github.com/space-code/typhoon). + +This behavior will be applied to all future requests. + +In case you desire to customize a particular request, you can pass the desired strategy to that specific request: + +```swift +import NetworkLayerInterfaces + +let request = UserRequest(id: 1) +let user: Response = try await requestProcessor.send(request, strategy: .constant(retry: 10, duration: .seconds(1))) +``` diff --git a/Sources/NetworkLayer/NetworkLayer.docc/NetworkLayer.md b/Sources/NetworkLayer/NetworkLayer.docc/NetworkLayer.md new file mode 100644 index 0000000..8ecdf01 --- /dev/null +++ b/Sources/NetworkLayer/NetworkLayer.docc/NetworkLayer.md @@ -0,0 +1,30 @@ +# ``NetworkLayer`` + +The library for network communication. + +## Overview + +The `network-layer` provides a simple interface for communication, making it very easy to send a request to a web server. + +```swift +import NetworkLayer + +let requestProcessor = NetworkLayerAssembly().assemble() +let user: User = try await requestProcessor.send(request) +``` + +The `network-layer` separates into two modules: `NetworkLayer`, which contains core functionality, and `NetworkLayerInterfaces`, which only contains public protocols for this framework. + +The library supports authentication, retrying requests, and more. + +## License + +network-layer is available under the MIT license. See the LICENSE file for more info. + +## Topics + +### Essentials + +- +- +- Bool +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Authenticator/IAuthenticator.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Authenticator/IAuthenticator.swift new file mode 100644 index 0000000..a67cd64 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Authenticator/IAuthenticator.swift @@ -0,0 +1,43 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A protocol defining the interface for an authenticator type. +public protocol IAuthenticator { + associatedtype Credential: IAuthenticationCredential + + /// Applies the `Credential` to the `URLRequest`. + /// + /// - Parameters: + /// - credential: The `Credential`. + /// - urlRequest: The `URLRequest`. + func apply(_ credential: Credential, to urlRequest: URLRequest) async throws + + /// Refreshes the `Credential`. + /// + /// - Parameters: + /// - credential: The `Credential` to refresh. + /// - session: The `URLSession` requiring the refresh. + func refresh(_ credential: Credential, for session: URLSession) async throws -> Credential + + /// Determines whether the `URLRequest` failed due to an authentication error based on the `HTTPURLResponse`. + /// + /// - Parameters: + /// - urlRequest: The `URLRequest`. + /// - response: The `HTTPURLResponse`. + /// + /// - Returns: `true` if the `URLRequest` failed due to an authentication error, `false` otherwise. + func didRequest(_ urlRequest: URLRequest, with response: HTTPURLResponse) -> Bool + + /// Determines whether the `URLRequest` is authenticated with the `Credential`. + /// + /// - Parameters: + /// - urlRequest: The `URLRequest`. + /// - credential: The `Credential`. + /// + /// - Returns: `true` if the `URLRequest` is authenticated with the `Credential`, `false` otherwise. + func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: Credential) -> Bool +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/Configuration.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Configuration.swift new file mode 100644 index 0000000..1d929ac --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Configuration.swift @@ -0,0 +1,43 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A type that represents a configuration for the network layer. +public struct Configuration { + // MARK: Properties + + /// A configuration object that defines behavior and policies for a URL session. + public let sessionConfiguration: URLSessionConfiguration + /// A protocol that defines methods that URL session instances call on their delegates + /// to handle session-level events, like session life cycle changes. + public let sessionDelegate: URLSessionDelegate? + /// A queue that regulates the execution of operations. + public let sessionDelegateQueue: OperationQueue? + /// An object that decodes instances of a data type from JSON objects. + public let jsonDecoder: JSONDecoder + + // MARK: Initialization + + /// Creates a new `Configuration` instance. + /// + /// - Parameters: + /// - sessionConfiguration: A configuration object that defines behavior and policies for a URL session. + /// - sessionDelegate: A protocol that defines methods that URL session instances call on their + /// delegates to handle session-level events, like session life cycle changes. + /// - sessionDelegateQueue: A queue that regulates the execution of operations. + /// - jsonDecoder: An object that decodes instances of a data type from JSON objects. + public init( + sessionConfiguration: URLSessionConfiguration, + sessionDelegate: URLSessionDelegate?, + sessionDelegateQueue: OperationQueue?, + jsonDecoder: JSONDecoder + ) { + self.sessionConfiguration = sessionConfiguration + self.sessionDelegate = sessionDelegate + self.sessionDelegateQueue = sessionDelegateQueue + self.jsonDecoder = jsonDecoder + } +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/HTTPMethod.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/HTTPMethod.swift new file mode 100644 index 0000000..f87a185 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/HTTPMethod.swift @@ -0,0 +1,32 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing HTTP methods. +/// +/// See https://tools.ietf.org/html/rfc7231#section-4.3 +public enum HTTPMethod: String { + /// `CONNECT` method. + case connect = "CONNECT" + /// `DELETE` method. + case delete = "DELETE" + /// `GET` method. + case get = "GET" + /// `HEAD` method. + case head = "HEAD" + /// `OPTIONS` method. + case options = "OPTIONS" + /// `PATCH` method. + case patch = "PATCH" + /// `POST` method. + case post = "POST" + /// `PUT` method. + case put = "PUT" + /// `QUERY` method. + case query = "QUERY" + /// `TRACE` method. + case trace = "TRACE" +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift new file mode 100644 index 0000000..91caaae --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift @@ -0,0 +1,70 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - IRequest + +/// A type to which all requests must conform. +public protocol IRequest { + /// The base `URL` for the resource. + var domainName: String { get } + + /// The endpoint path. + var path: String { get } + + /// A dictionary that contains the parameters to be encoded into the request's header. + var headers: [String: String]? { get } + + /// A dictionary that contains the parameters to be encoded into the request. + var parameters: [String: String]? { get } + + /// A Boolean value indicating whether authentication is required. + var requiresAuthentication: Bool { get } + + /// Request's timeout interval. + var timeoutInterval: TimeInterval { get } + + /// The HTTP method. + var httpMethod: HTTPMethod { get } + + /// A dictonary that contains the request's body. + var httpBody: RequestBody? { get } + + /// An alias for the cache policy. + var cachePolicy: URLRequest.CachePolicy { get } +} + +public extension IRequest { + /// A dictionary that contains the parameters to be encoded into the request's header. + var headers: [String: String]? { + nil + } + + /// A dictionary that contains the parameters to be encoded into the request. + var parameters: [String: String]? { + nil + } + + /// A Boolean value indicating whether authentication is required. + var requiresAuthentication: Bool { + false + } + + /// Request's timeout interval. + var timeoutInterval: TimeInterval { + 60 + } + + /// A dictonary that contains the request's body. + var httpBody: RequestBody? { + nil + } + + /// An alias for the cache policy. + var cachePolicy: URLRequest.CachePolicy { + .useProtocolCachePolicy + } +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/NetworkLayerError.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/NetworkLayerError.swift new file mode 100644 index 0000000..50ad3a7 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/NetworkLayerError.swift @@ -0,0 +1,12 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// `NetworkLayerError` is the error type returned by NetworkLayer. +public enum NetworkLayerError: Swift.Error { + /// A malformed URL prevented a URL request from being initiated. + case badURL +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift new file mode 100644 index 0000000..f884d5c --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift @@ -0,0 +1,12 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +public enum RequestBody { + case data(Data) + case encodable(Encodable) + case dictonary([String: Any]) +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift new file mode 100644 index 0000000..1eaeba7 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift @@ -0,0 +1,34 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A generic struct representing an HTTP response. +public struct Response { + /// The data associated with the response. + public let data: T + + /// The URL response received. + public let response: URLResponse + + /// The URLSessionTask associated with the response. + public let task: URLSessionTask + + /// The HTTP status code of the response, if available. + public let statusCode: Int? + + /// Initializes a new instance of `Response`. + /// + /// - Parameters: + /// - data: The data associated with the response. + /// - response: The URL response received. + /// - task: The URLSessionTask associated with the response. + public init(data: T, response: URLResponse, task: URLSessionTask) { + self.data = data + self.response = response + statusCode = (response as? HTTPURLResponse)?.statusCode + self.task = task + } +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IDataRequestHandler.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IDataRequestHandler.swift new file mode 100644 index 0000000..7fb0299 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IDataRequestHandler.swift @@ -0,0 +1,23 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A protocol for handling data requests. +public protocol IDataRequestHandler: URLSessionTaskDelegate & URLSessionDataDelegate { + var urlSessionDelegate: URLSessionDelegate? { get set } + + /// Starts a data task for handling network requests. + /// + /// - Parameters: + /// - task: The `URLSessionDataTask` representing the network task to be initiated. + /// - delegate: An optional `URLSessionDelegate` for handling `URLSession` events and callbacks. Pass `nil` if not needed. + /// + /// - Returns: An asynchronous task that will result in a Response object containing data when the request is completed. + func startDataTask( + _ task: URLSessionDataTask, + delegate: URLSessionDelegate? + ) async throws -> Response +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestBuilder.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestBuilder.swift new file mode 100644 index 0000000..f47e8bf --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestBuilder.swift @@ -0,0 +1,16 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A type that creates a `URLRequest`. +public protocol IRequestBuilder { + /// Creates a new `URLRequest` using `IRequest.` + /// + /// - Parameter request: The request object that defines the request details. + /// + /// - Returns: A `URLRequest` constructed based on the given data. + func build(_ request: IRequest, _ configure: ((inout URLRequest) throws -> Void)?) throws -> URLRequest? +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift new file mode 100644 index 0000000..c3a6561 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift @@ -0,0 +1,40 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import Typhoon + +// MARK: - IRequestProcessor + +/// A type capable of performing network requests. +public protocol IRequestProcessor { + /// Sends a network request. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol, representing the network request to be sent. + /// - delegate: An optional `URLSessionDelegate` for handling `URLSession` events and callbacks. Pass `nil` if not needed. + /// - configure: An optional closure that allows custom configuration of the URLRequest before sending the request. + /// Pass `nil` if not + /// needed. + func send( + _ request: T, + strategy: RetryPolicyStrategy?, + delegate: URLSessionDelegate?, + configure: ((inout URLRequest) throws -> Void)? + ) async throws -> Response +} + +extension IRequestProcessor { + /// Sends a network request with default parameters. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol, representing the network request to be sent. + func send( + _ request: T, + strategy: RetryPolicyStrategy? + ) async throws -> Response { + try await send(request, strategy: strategy, delegate: nil, configure: nil) + } +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/RequestProcessorDelegate.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/RequestProcessorDelegate.swift new file mode 100644 index 0000000..f1bc2f1 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/RequestProcessorDelegate.swift @@ -0,0 +1,36 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - RequestProcessorDelegate + +/// A protocol to define the delegate methods for handling network requests. +public protocol RequestProcessorDelegate: AnyObject { + /// Notifies the delegate that the request processor is about to send a request. + /// + /// - Parameters: + /// - requestProcessor: The request processor responsible for handling the request. + /// - request: The URLRequest about to be sent. + func requestProcessor(_ requestProcessor: IRequestProcessor, willSendRequest request: URLRequest) async throws + + /// Notifies the delegate that the request processor received a response and provides an opportunity to validate it. + /// + /// - Parameters: + /// - requestProcessor: The request processor responsible for handling the request. + /// - response: The HTTPURLResponse received from the server. + /// - data: The data received in the response. + /// - task: The URLSessionTask associated with the request. + func requestProcessor( + _ requestProcessor: IRequestProcessor, + validateResponse response: HTTPURLResponse, + data: Data, + task: URLSessionTask + ) throws +} + +public extension RequestProcessorDelegate { + func requestProcessor(_: IRequestProcessor, willSendRequest _: URLRequest) async throws {} +} diff --git a/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift b/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift new file mode 100644 index 0000000..b02c60f --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift @@ -0,0 +1,41 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import enum Typhoon.RetryPolicyStrategy + +// MARK: - INetworkLayerAssembly + +/// A type that represents a network layer assembly. +public protocol INetworkLayerAssembly { + /// Creates a new `INetworkLayerAssembly` instance. + /// + /// - Parameters: + /// - configure: The network layer's configuration. + /// - retryPolicyStrategy: The retry policy strategy. + /// - delegate: The request processor delegate. + /// - interceptor: The authenticator interceptor. + /// - jsonEncoder: The json encoder. + init( + configure: Configuration, + retryPolicyStrategy: RetryPolicyStrategy?, + delegate: RequestProcessorDelegate?, + interceptor: IAuthenticationInterceptor?, + jsonEncoder: JSONEncoder + ) + + /// Assembles a request processor. + /// + /// - Returns: A request processor. + func assemble() -> IRequestProcessor +} + +public extension INetworkLayerAssembly { + init( + configure: Configuration + ) { + self.init(configure: configure, retryPolicyStrategy: nil, delegate: nil, interceptor: nil, jsonEncoder: JSONEncoder()) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Fakes/HTTPURLResponse+Fake.swift b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/HTTPURLResponse+Fake.swift new file mode 100644 index 0000000..ec46c14 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/HTTPURLResponse+Fake.swift @@ -0,0 +1,12 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension HTTPURLResponse { + static func fake() -> HTTPURLResponse { + HTTPURLResponse() + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLRequest+Fake.swift b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLRequest+Fake.swift new file mode 100644 index 0000000..1cfa8fa --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLRequest+Fake.swift @@ -0,0 +1,12 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension URLRequest { + static func fake(string: String = "https://google.com/") -> URLRequest { + URLRequest(url: URL(string: string)!) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionDataTask+Fake.swift b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionDataTask+Fake.swift new file mode 100644 index 0000000..d289807 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionDataTask+Fake.swift @@ -0,0 +1,13 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension URLSessionDataTask { + @objc override dynamic + class func fake() -> URLSessionDataTask { + URLSession.shared.dataTask(with: .fake()) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionTask+Fake.swift b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionTask+Fake.swift new file mode 100644 index 0000000..25801c3 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Fakes/URLSessionTask+Fake.swift @@ -0,0 +1,13 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension URLSessionTask { + @objc dynamic + class func fake() -> URLSessionTask { + URLSession.shared.dataTask(with: .fake()) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/DynamicStubs.swift b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/DynamicStubs.swift new file mode 100644 index 0000000..20c7904 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/DynamicStubs.swift @@ -0,0 +1,23 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import Mocker + +final class DynamicStubs { + static func register(stubs: [StubResponse], prefix: String = "https://github.com", statusCode: Int = 200) { + for stub in stubs { + let mock = Mock( + url: URL(string: [prefix, stub.name].joined(separator: "/"))!, + dataType: .json, + statusCode: statusCode, + data: [ + stub.httpMethod: try! Data(contentsOf: stub.fileURL), + ] + ) + mock.register() + } + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift new file mode 100644 index 0000000..5385dae --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift @@ -0,0 +1,46 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import Mocker +@testable import NetworkLayer +import NetworkLayerInterfaces +import Typhoon + +extension RequestProcessor { + static func mock( + requestProcessorDelegate: RequestProcessorDelegate? = nil, + interceptor: IAuthenticationInterceptor? = nil + ) -> RequestProcessor { + RequestProcessor( + configuration: .init( + sessionConfiguration: sessionConfiguration, + sessionDelegate: nil, + sessionDelegateQueue: nil, + jsonDecoder: jsonDecoder + ), + requestBuilder: RequestBuilder( + parametersEncoder: RequestParametersEncoder(), + requestBodyEncoder: RequestBodyEncoder(jsonEncoder: JSONEncoder()) + ), + dataRequestHandler: DataRequestHandler(), + retryPolicyService: RetryPolicyService(strategy: .constant(retry: 1, duration: .seconds(0))), + delegate: requestProcessorDelegate, + interceptor: interceptor + ) + } + + private static var sessionConfiguration: URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockingURLProtocol.self] + return configuration + } + + private static var jsonDecoder: JSONDecoder { + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + return jsonDecoder + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthenticatorMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthenticatorMock.swift new file mode 100644 index 0000000..4b1d42b --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthenticatorMock.swift @@ -0,0 +1,65 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class AuthenticatorMock: IAuthenticator { + typealias Credential = AuthenticationCredentialStub + + var invokedApply = false + var invokedApplyCount = 0 + var invokedApplyParameters: (credential: Credential, urlRequest: URLRequest)? + var invokedApplyParametersList = [(credential: Credential, urlRequest: URLRequest)]() + + func apply(_ credential: Credential, to urlRequest: URLRequest) async throws { + invokedApply = true + invokedApplyCount += 1 + invokedApplyParameters = (credential, urlRequest) + invokedApplyParametersList.append((credential, urlRequest)) + } + + var invokedRefresh = false + var invokedRefreshCount = 0 + var invokedRefreshParameters: (credential: Credential, session: URLSession)? + var invokedRefreshParametersList = [(credential: Credential, session: URLSession)]() + var stubbedRefresh: Credential! + + func refresh(_ credential: Credential, for session: URLSession) async throws -> Credential { + invokedRefresh = true + invokedRefreshCount += 1 + invokedRefreshParameters = (credential, session) + invokedRefreshParametersList.append((credential, session)) + return stubbedRefresh + } + + var invokedDidRequest = false + var invokedDidRequestCount = 0 + var invokedDidRequestParameters: (urlRequest: URLRequest, response: HTTPURLResponse)? + var invokedDidRequestParametersList = [(urlRequest: URLRequest, response: HTTPURLResponse)]() + var stubbedDidRequestResult: Bool! = false + + func didRequest(_ urlRequest: URLRequest, with response: HTTPURLResponse) -> Bool { + invokedDidRequest = true + invokedDidRequestCount += 1 + invokedDidRequestParameters = (urlRequest, response) + invokedDidRequestParametersList.append((urlRequest, response)) + return stubbedDidRequestResult + } + + var invokedIsRequest = false + var invokedIsRequestCount = 0 + var invokedIsRequestParameters: (urlRequest: URLRequest, credential: Credential)? + var invokedIsRequestParametersList = [(urlRequest: URLRequest, credential: Credential)]() + var stubbedIsRequestResult: Bool! = false + + func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: Credential) -> Bool { + invokedIsRequest = true + invokedIsRequestCount += 1 + invokedIsRequestParameters = (urlRequest, credential) + invokedIsRequestParametersList.append((urlRequest, credential)) + return stubbedIsRequestResult + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift new file mode 100644 index 0000000..4313e21 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift @@ -0,0 +1,47 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class AuthentificatorInterceptorMock: IAuthenticationInterceptor { + var invokedAdapt = false + var invokedAdaptCount = 0 + var invokedAdaptParameters: (request: URLRequest, session: URLSession)? + var invokedAdaptParametersList = [(request: URLRequest, session: URLSession)]() + + func adapt(request: inout URLRequest, for session: URLSession) { + invokedAdapt = true + invokedAdaptCount += 1 + invokedAdaptParameters = (request, session) + invokedAdaptParametersList.append((request, session)) + } + + var invokedRefresh = false + var invokedRefreshCount = 0 + var invokedRefreshParameters: (request: URLRequest, response: HTTPURLResponse, session: URLSession)? + var invokedRefreshParametersList = [(request: URLRequest, response: HTTPURLResponse, session: URLSession)]() + + func refresh(_ request: URLRequest, with response: HTTPURLResponse, for session: URLSession) { + invokedRefresh = true + invokedRefreshCount += 1 + invokedRefreshParameters = (request, response, session) + invokedRefreshParametersList.append((request, response, session)) + } + + var invokedIsRequireRefresh = false + var invokedIsRequireRefreshCount = 0 + var invokedIsRequireRefreshParameters: (request: URLRequest, response: HTTPURLResponse)? + var invokedIsRequireRefreshParametersList = [(request: URLRequest, response: HTTPURLResponse)]() + var stubbedIsRequireRefreshResult: Bool! = false + + func isRequireRefresh(_ request: URLRequest, response: HTTPURLResponse) -> Bool { + invokedIsRequireRefresh = true + invokedIsRequireRefreshCount += 1 + invokedIsRequireRefreshParameters = (request, response) + invokedIsRequireRefreshParametersList.append((request, response)) + return stubbedIsRequireRefreshResult + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift new file mode 100644 index 0000000..8889a5c --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift @@ -0,0 +1,47 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class DataRequestHandlerMock: NSObject, IDataRequestHandler { + var invokedUrlSessionGetDelegate = false + var invokedUrlSessionGetDelegateCount = 0 + var invokedUrlSessionSetDelegate = false + var invokedUrlSessionSetDelegateCount = 0 + var stubbedUrlSessionDelegate: URLSessionDelegate? + var urlSessionDelegate: URLSessionDelegate? { + get { + invokedUrlSessionGetDelegate = true + invokedUrlSessionGetDelegateCount += 1 + return stubbedUrlSessionDelegate + } + set { + invokedUrlSessionSetDelegate = true + invokedUrlSessionSetDelegateCount += 1 + } + } + + var invokedStartDataTask = false + var invokedStartDataTaskCount = 0 + var invokedStartDataTaskParameters: (task: URLSessionDataTask, delegate: URLSessionDelegate?)? + var invokedStartDataTaskParametersList = [(task: URLSessionDataTask, delegate: URLSessionDelegate?)]() + var stubbedStartDataTask: Response! + var startDataTaskThrowError: Error? + + func startDataTask( + _ task: URLSessionDataTask, + delegate: URLSessionDelegate? + ) async throws -> Response { + invokedStartDataTask = true + invokedStartDataTaskCount += 1 + invokedStartDataTaskParameters = (task, delegate) + invokedStartDataTaskParametersList.append((task, delegate)) + if let error = startDataTaskThrowError { + throw error + } + return stubbedStartDataTask + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBodyEncoderMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBodyEncoderMock.swift new file mode 100644 index 0000000..e6cc138 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBodyEncoderMock.swift @@ -0,0 +1,26 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +@testable import NetworkLayer +import NetworkLayerInterfaces + +final class RequestBodyEncoderMock: IRequestBodyEncoder { + var invokedEncode = false + var invokedEncodeCount = 0 + var invokedEncodeParameters: (body: RequestBody, request: URLRequest)? + var invokedEncodeParametersList = [(body: RequestBody, request: URLRequest)]() + var stubbedEncodeError: Error? + + func encode(body: RequestBody, to request: inout URLRequest) throws { + invokedEncode = true + invokedEncodeCount += 1 + invokedEncodeParameters = (body, request) + invokedEncodeParametersList.append((body, request)) + if let error = stubbedEncodeError { + throw error + } + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBuilderMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBuilderMock.swift new file mode 100644 index 0000000..bb845c2 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestBuilderMock.swift @@ -0,0 +1,27 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class RequestBuilderMock: IRequestBuilder { + var invokedBuild = false + var invokedBuildCount = 0 + var invokedBuildParameters: (request: IRequest, Void)? + var invokedBuildParametersList = [(request: IRequest, Void)]() + var stubbedBuildConfigureResult: (URLRequest, Void)? + var stubbedBuildResult: URLRequest! + + func build(_ request: IRequest, _ configure: ((inout URLRequest) throws -> Void)?) -> URLRequest? { + invokedBuild = true + invokedBuildCount += 1 + invokedBuildParameters = (request, ()) + invokedBuildParametersList.append((request, ())) + if var result = stubbedBuildConfigureResult { + try? configure?(&result.0) + } + return stubbedBuildResult + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestMock.swift new file mode 100644 index 0000000..36cc932 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestMock.swift @@ -0,0 +1,89 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class RequestMock: IRequest { + var invokedDomainNameGetter = false + var invokedDomainNameGetterCount = 0 + var stubbedDomainName: String! = "" + + var domainName: String { + invokedDomainNameGetter = true + invokedDomainNameGetterCount += 1 + return stubbedDomainName + } + + var invokedPathGetter = false + var invokedPathGetterCount = 0 + var stubbedPath: String! = "" + + var path: String { + invokedPathGetter = true + invokedPathGetterCount += 1 + return stubbedPath + } + + var invokedHeadersGetter = false + var invokedHeadersGetterCount = 0 + var stubbedHeaders: [String: String]! + + var headers: [String: String]? { + invokedHeadersGetter = true + invokedHeadersGetterCount += 1 + return stubbedHeaders + } + + var invokedParametersGetter = false + var invokedParametersGetterCount = 0 + var stubbedParameters: [String: String]! + + var parameters: [String: String]? { + invokedParametersGetter = true + invokedParametersGetterCount += 1 + return stubbedParameters + } + + var invokedRequiresAuthenticationGetter = false + var invokedRequiresAuthenticationGetterCount = 0 + var stubbedRequiresAuthentication: Bool! = false + + var requiresAuthentication: Bool { + invokedRequiresAuthenticationGetter = true + invokedRequiresAuthenticationGetterCount += 1 + return stubbedRequiresAuthentication + } + + var invokedTimeoutIntervalGetter = false + var invokedTimeoutIntervalGetterCount = 0 + var stubbedTimeoutInterval: TimeInterval! + + var timeoutInterval: TimeInterval { + invokedTimeoutIntervalGetter = true + invokedTimeoutIntervalGetterCount += 1 + return stubbedTimeoutInterval + } + + var invokedHttpMethodGetter = false + var invokedHttpMethodGetterCount = 0 + var stubbedHttpMethod: HTTPMethod! + + var httpMethod: HTTPMethod { + invokedHttpMethodGetter = true + invokedHttpMethodGetterCount += 1 + return stubbedHttpMethod + } + + var invokedHttpBodyGetter = false + var invokedHttpBodyGetterCount = 0 + var stubbedHttpBody: [String: Any]! + + var httpBody: [String: Any]? { + invokedHttpBodyGetter = true + invokedHttpBodyGetterCount += 1 + return stubbedHttpBody + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestParametersEncoderMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestParametersEncoderMock.swift new file mode 100644 index 0000000..1e6c9e4 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestParametersEncoderMock.swift @@ -0,0 +1,25 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +@testable import NetworkLayer + +final class RequestParametersEncoderMock: IRequestParametersEncoder { + var invokedEncode = false + var invokedEncodeCount = 0 + var invokedEncodeParameters: (parameters: [String: String], request: URLRequest)? + var invokedEncodeParametersList = [(parameters: [String: String], request: URLRequest)]() + var stubbedEncodeError: Error? + + func encode(parameters: [String: String], to request: inout URLRequest) throws { + invokedEncode = true + invokedEncodeCount += 1 + invokedEncodeParameters = (parameters, request) + invokedEncodeParametersList.append((parameters, request)) + if let error = stubbedEncodeError { + throw error + } + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestProcessorDelegateMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestProcessorDelegateMock.swift new file mode 100644 index 0000000..0e248e5 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/RequestProcessorDelegateMock.swift @@ -0,0 +1,52 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class RequestProcessorDelegateMock: RequestProcessorDelegate { + var invokedRequestProcessor = false + var invokedRequestProcessorCount = 0 + var invokedRequestProcessorParameters: (requestProcessor: IRequestProcessor, request: URLRequest)? + var invokedRequestProcessorParametersList = [(requestProcessor: IRequestProcessor, request: URLRequest)]() + + func requestProcessor(_ requestProcessor: IRequestProcessor, willSendRequest request: URLRequest) async throws { + invokedRequestProcessor = true + invokedRequestProcessorCount += 1 + invokedRequestProcessorParameters = (requestProcessor, request) + invokedRequestProcessorParametersList.append((requestProcessor, request)) + } + + var invokedRequestProcessorValidateResponse = false + var invokedRequestProcessorValidateResponseCount = 0 + var invokedRequestProcessorValidateResponseParameters: ( + requestProcessor: IRequestProcessor, + response: HTTPURLResponse, + data: Data, + task: URLSessionTask + )? + var invokedRequestProcessorValidateResponseParametersList = [( + requestProcessor: IRequestProcessor, + response: HTTPURLResponse, + data: Data, + task: URLSessionTask + )]() + var stubbedRequestProcessorValidateResponseError: Error? + + func requestProcessor( + _ requestProcessor: IRequestProcessor, + validateResponse response: HTTPURLResponse, + data: Data, + task: URLSessionTask + ) throws { + invokedRequestProcessorValidateResponse = true + invokedRequestProcessorValidateResponseCount += 1 + invokedRequestProcessorValidateResponseParameters = (requestProcessor, response, data, task) + invokedRequestProcessorValidateResponseParametersList.append((requestProcessor, response, data, task)) + if let error = stubbedRequestProcessorValidateResponseError { + throw error + } + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/URLSessionDelegateMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/URLSessionDelegateMock.swift new file mode 100644 index 0000000..98e2694 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/URLSessionDelegateMock.swift @@ -0,0 +1,192 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +final class URLSessionDelegateMock: NSObject, URLSessionDataDelegate { + var invokedUrlSessionDidBecomeInvalidWithError = false + var invokedUrlSessionDidBecomeInvalidWithErrorCount = 0 + var invokedUrlSessionDidBecomeInvalidWithErrorParameters: (session: URLSession, error: Error?)? + var invokedUrlSessionDidBecomeInvalidWithErrorParametersList = [(session: URLSession, error: Error?)]() + + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + invokedUrlSessionDidBecomeInvalidWithError = true + invokedUrlSessionDidBecomeInvalidWithErrorCount += 1 + invokedUrlSessionDidBecomeInvalidWithErrorParameters = (session, error) + invokedUrlSessionDidBecomeInvalidWithErrorParametersList.append((session, error)) + } + + var invokedUrlSessionWillCacheResponse = false + var invokedUrlSessionWillCacheResponseCount = 0 + var invokedUrlSessionWillCacheResponseParameters: ( + session: URLSession, + dataTask: URLSessionDataTask, + proposedResponse: CachedURLResponse, + completionHandler: (CachedURLResponse?) -> Void + )? + var invokedUrlSessionWillCacheResponseParametersList = [( + session: URLSession, + dataTask: URLSessionDataTask, + proposedResponse: CachedURLResponse, + completionHandler: (CachedURLResponse?) -> Void + )]() + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void + ) { + invokedUrlSessionWillCacheResponse = true + invokedUrlSessionWillCacheResponseCount += 1 + invokedUrlSessionWillCacheResponseParameters = (session, dataTask, proposedResponse, completionHandler) + invokedUrlSessionWillCacheResponseParametersList.append((session, dataTask, proposedResponse, completionHandler)) + } + + var invokedUrlSessionDidFinishCollectingMetrics = false + var invokedUrlSessionDidFinishCollectingMetricsCount = 0 + var invokedUrlSessionDidFinishCollectingMetricsParameters: (session: URLSession, task: URLSessionTask, metrics: URLSessionTaskMetrics)? + var invokedUrlSessionDidFinishCollectingMetricsParametersList = [( + session: URLSession, + task: URLSessionTask, + metrics: URLSessionTaskMetrics + )]() + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + invokedUrlSessionDidFinishCollectingMetrics = true + invokedUrlSessionDidFinishCollectingMetricsCount += 1 + invokedUrlSessionDidFinishCollectingMetricsParameters = (session, task, metrics) + invokedUrlSessionDidFinishCollectingMetricsParametersList.append((session, task, metrics)) + } + + var invokedUrlSessionWillPerformHTTPRedirection = false + var invokedUrlSessionWillPerformHTTPRedirectionCount = 0 + var invokedUrlSessionWillPerformHTTPRedirectionParameters: ( + session: URLSession, + task: URLSessionTask, + response: HTTPURLResponse, + request: URLRequest, + completionHandler: (URLRequest?) -> Void + )? + var invokedUrlSessionWillPerformHTTPRedirectionParametersList = [( + session: URLSession, + task: URLSessionTask, + response: HTTPURLResponse, + request: URLRequest, + completionHandler: (URLRequest?) -> Void + )]() + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + invokedUrlSessionWillPerformHTTPRedirection = true + invokedUrlSessionWillPerformHTTPRedirectionCount += 1 + invokedUrlSessionWillPerformHTTPRedirectionParameters = (session, task, response, request, completionHandler) + invokedUrlSessionWillPerformHTTPRedirectionParametersList = [(session, task, response, request, completionHandler)] + } + + var invokedUrlSessionTaskIsWaitingForConnectivity = false + var invokedUrlSessionTaskIsWaitingForConnectivityCount = 0 + var invokedUrlSessionTaskIsWaitingForConnectivityParameters: (session: URLSession, task: URLSessionTask)? + var invokedUrlSessionTaskIsWaitingForConnectivityParametersList = [(session: URLSession, task: URLSessionTask)]() + func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + invokedUrlSessionTaskIsWaitingForConnectivity = true + invokedUrlSessionTaskIsWaitingForConnectivityCount += 1 + invokedUrlSessionTaskIsWaitingForConnectivityParameters = (session, task) + invokedUrlSessionTaskIsWaitingForConnectivityParametersList.append((session, task)) + } + + var invokedUrlSessionDidReceiveChallenge = false + var invokedUrlSessionDidReceiveChallengeCount = 0 + var invokedUrlSessionDidReceiveChallengeParamters: ( + session: URLSession, + challenge: URLAuthenticationChallenge, + completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + )? + var invokedUrlSessionDidReceiveChallengeParamtersList = [( + session: URLSession, + challenge: URLAuthenticationChallenge, + completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + )]() + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + invokedUrlSessionDidReceiveChallenge = true + invokedUrlSessionDidReceiveChallengeCount += 1 + invokedUrlSessionDidReceiveChallengeParamters = (session, challenge, completionHandler) + invokedUrlSessionDidReceiveChallengeParamtersList.append((session, challenge, completionHandler)) + } + + var invokedUrlSessionWillBeginDelayedRequest = false + var invokedUrlSessionWillBeginDelayedRequestCount = 0 + var invokedUrlSessionWillBeginDelayedRequestParameters: ( + session: URLSession, + task: URLSessionTask, + request: URLRequest, + completionHandler: (URLSession.DelayedRequestDisposition, URLRequest?) -> Void + )? + var invokedUrlSessionWillBeginDelayedRequestParametersList = [( + session: URLSession, + task: URLSessionTask, + request: URLRequest, + completionHandler: (URLSession.DelayedRequestDisposition, URLRequest?) -> Void + )]() + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willBeginDelayedRequest request: URLRequest, + completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void + ) { + invokedUrlSessionWillBeginDelayedRequest = true + invokedUrlSessionWillBeginDelayedRequestCount += 1 + invokedUrlSessionWillBeginDelayedRequestParameters = (session, task, request, completionHandler) + invokedUrlSessionWillBeginDelayedRequestParametersList.append((session, task, request, completionHandler)) + } + + var invokedUrlSessionDidSendBodyData = false + var invokedUrlSessionDidSendBodyDataCount = 0 + var invokedUrlSessionDidSendBodyDataParameters: ( + session: URLSession, + task: URLSessionTask, + bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + )? + var invokedUrlSessionDidSendBodyDataParametersList = [ + ( + session: URLSession, + task: URLSessionTask, + bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) + ]() + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + invokedUrlSessionDidSendBodyData = true + invokedUrlSessionDidSendBodyDataCount += 1 + invokedUrlSessionDidSendBodyDataParameters = (session, task, bytesSent, totalBytesSent, totalBytesExpectedToSend) + invokedUrlSessionDidSendBodyDataParametersList = [(session, task, bytesSent, totalBytesSent, totalBytesExpectedToSend)] + } + + var invokedUrlSessionDidCompleteTaskWithError = false + var invokedUrlSessionDidCompleteTaskWithErrorCount = 0 + var invokedUrlSessionDidCompleteTaskWithErrorParameters: (session: URLSession, task: URLSessionTask, error: Error?)? + var invokedUrlSessionDidCompleteTaskWithErrorParametersList = [(session: URLSession, task: URLSessionTask, error: Error?)]() + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + invokedUrlSessionDidCompleteTaskWithError = true + invokedUrlSessionDidCompleteTaskWithErrorCount += 1 + invokedUrlSessionDidCompleteTaskWithErrorParameters = (session, task, error) + invokedUrlSessionDidCompleteTaskWithErrorParametersList.append((session, task, error)) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Stubs/AuthenticationCredentialStub.swift b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/AuthenticationCredentialStub.swift new file mode 100644 index 0000000..9e9b4f3 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/AuthenticationCredentialStub.swift @@ -0,0 +1,15 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class AuthenticationCredentialStub: IAuthenticationCredential { + var stubbedRequiresRefresh: Bool! = false + + var requiresRefresh: Bool { + stubbedRequiresRefresh + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Stubs/RequestStub.swift b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/RequestStub.swift new file mode 100644 index 0000000..148c675 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/RequestStub.swift @@ -0,0 +1,63 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +final class RequestStub: IRequest { + var stubbedDomainName: String! = "" + + var domainName: String { + stubbedDomainName + } + + var stubbedPath: String! = "" + + var path: String { + stubbedPath + } + + var stubbedHeaders: [String: String]! + + var headers: [String: String]? { + stubbedHeaders + } + + var stubbedParameters: [String: String]! + + var parameters: [String: String]? { + stubbedParameters + } + + var stubbedRequiresAuthentication: Bool! = false + + var requiresAuthentication: Bool { + stubbedRequiresAuthentication + } + + var stubbedTimeoutInterval: TimeInterval = 60 + + var timeoutInterval: TimeInterval { + stubbedTimeoutInterval + } + + var stubbedHttpMethod: HTTPMethod = .get + + var httpMethod: HTTPMethod { + stubbedHttpMethod + } + + var stubbedHttpBody: RequestBody? = nil + + var httpBody: RequestBody? { + stubbedHttpBody + } + + var stubbedCachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy + + var cachePolicy: URLRequest.CachePolicy { + stubbedCachePolicy + } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Stubs/StubResponse.swift b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/StubResponse.swift new file mode 100644 index 0000000..97f4471 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Stubs/StubResponse.swift @@ -0,0 +1,13 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import Mocker + +struct StubResponse { + let name: String + let fileURL: URL + let httpMethod: Mock.HTTPMethod +} diff --git a/Tests/NetworkLayerTests/Classes/Models/MockedData.swift b/Tests/NetworkLayerTests/Classes/Models/MockedData.swift new file mode 100644 index 0000000..993d77d --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Models/MockedData.swift @@ -0,0 +1,10 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +public enum MockedData { + public static let userJSON: URL = Bundle.module.url(forResource: "user", withExtension: "json")! +} diff --git a/Tests/NetworkLayerTests/Classes/Models/User.swift b/Tests/NetworkLayerTests/Classes/Models/User.swift new file mode 100644 index 0000000..eb7e938 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Models/User.swift @@ -0,0 +1,13 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +struct User: Codable { + let id: Int + let login: String? + let avatarUrl: String? + let type: String? +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift new file mode 100644 index 0000000..51821df --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift @@ -0,0 +1,170 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +@testable import NetworkLayer +import NetworkLayerInterfaces +import Typhoon +import XCTest + +// MARK: - RequestProcessorAuthenicationTests + +final class RequestProcessorAuthenicationTests: XCTestCase { + // MARK: Tests + + func test_thatRequestProcessorAuthenticatesRequest_whenTokenIsCorrect() async throws { + // given + let interceptor = AuthInterceptor() + let sut = RequestProcessor.mock(interceptor: interceptor) + + interceptor.token = .init(token: .token, expiresDate: Date()) + + DynamicStubs.register(stubs: [.user], statusCode: 200) + + let request = makeRequest(.user) + + // when + let _: Response = try await sut.send(request) + } + + func test_thatRequestProcessorAuthenticatesRequest_whenTokenIsInvalid() async throws { + // given + let interceptor = AuthInterceptor() + let sut = RequestProcessor.mock(interceptor: interceptor) + + interceptor.token = .init(token: .token, expiresDate: Date()) + + DynamicStubs.register(stubs: [.user], statusCode: 401) + + let request = makeRequest(.user) + + // when + let _: Response = try await sut.send(request) + } + + func test_thatRequestProcessorAuthenticatesRequest_whenTokenExpired() async throws { + // given + let interceptor = AuthInterceptor() + let sut = RequestProcessor.mock(interceptor: interceptor) + + interceptor.token = .init(token: .token, expiresDate: Date(timeIntervalSinceNow: 1001)) + + DynamicStubs.register(stubs: [.user], statusCode: 200) + + let request = makeRequest(.user) + + // when + let _: Response = try await sut.send(request) + } + + func test_thatRequestProcessorThrowsAnError_whenInterceptorAdaptDidFail() async throws { + try await test_failAuthentication(adaptError: URLError(.unknown), refreshError: nil, expectedError: URLError(.unknown)) + } + + func test_thatRequestProcessorThrowsAnError_whenInterceptorRefreshDidFail() async throws { + try await test_failAuthentication( + adaptError: nil, + refreshError: URLError(.unknown), + expectedError: RetryPolicyError.retryLimitExceeded + ) + } + + // MARK: Private + + private func test_failAuthentication(adaptError: Error?, refreshError: Error?, expectedError: Error) async throws { + class FailInterceptor: IAuthenticationInterceptor { + let adaptError: Error? + let refreshError: Error? + + init(adaptError: Error?, refreshError: Error?) { + self.adaptError = adaptError + self.refreshError = refreshError + } + + func adapt(request _: inout URLRequest, for _: URLSession) async throws { + guard let adaptError = adaptError else { return } + throw adaptError + } + + func refresh(_: URLRequest, with _: HTTPURLResponse, for _: URLSession) async throws { + guard let refreshError = refreshError else { return } + throw refreshError + } + + func isRequireRefresh(_: URLRequest, response _: HTTPURLResponse) -> Bool { + true + } + } + + // given + let interceptor = FailInterceptor(adaptError: adaptError, refreshError: refreshError) + let sut = RequestProcessor.mock(interceptor: interceptor) + + let request = makeRequest(.user) + + DynamicStubs.register(stubs: [.user], statusCode: 200) + + // when + do { + let _: Response = try await sut.send(request) + } catch { + XCTAssertEqual(error as NSError, expectedError as NSError) + } + } + + private func makeRequest(_ path: String) -> IRequest { + let request = RequestStub() + request.stubbedDomainName = "https://github.com" + request.stubbedPath = path + request.stubbedRequiresAuthentication = true + return request + } +} + +// MARK: - AuthInterceptor + +private final class AuthInterceptor: IAuthenticationInterceptor { + var token: Token! + + private var attempts = 0 + + func adapt(request: inout URLRequest, for _: URLSession) async throws { + request.addValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + } + + func refresh(_: URLRequest, with _: HTTPURLResponse, for _: URLSession) async throws { + if token.expiresDate < Date() { + token = Token(token: .token, expiresDate: Date(timeIntervalSinceNow: 1000)) + } + } + + func isRequireRefresh(_: URLRequest, response: HTTPURLResponse) -> Bool { + if response.statusCode == 401, attempts == 0 { + attempts += 1 + return true + } + return false + } +} + +// MARK: - Token + +private struct Token { + let token: String + let expiresDate: Date +} + +// MARK: - Stubs + +private extension StubResponse { + static let user = StubResponse(name: .user, fileURL: MockedData.userJSON, httpMethod: .get) +} + +// MARK: - Constants + +private extension String { + static let user = "user" + static let token = "token" +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift new file mode 100644 index 0000000..8548539 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift @@ -0,0 +1,82 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import Mocker +@testable import NetworkLayer +import NetworkLayerInterfaces +import Typhoon +import XCTest + +// MARK: - RequestProcessorRequestTests + +final class RequestProcessorRequestTests: XCTestCase { + // MARK: Tests + + func test_thatRequestProcessorSendsASimpleRequest() async throws { + // given + DynamicStubs.register(stubs: [.user]) + + let sut = RequestProcessor.mock() + let request = makeRequest(.user) + + // when + let user: Response = try await sut.send(request) + + // then + XCTAssertEqual(user.data.id, 1) + XCTAssertNotNil(user.data.avatarUrl) + } + + func test_thatRequestProcessorThrowsRretryLimitExceededError_whenRequestDidFail() async { + // given + DynamicStubs.register(stubs: [.user], statusCode: 500) + + let delegate = GitHubDelegate() + let sut = RequestProcessor.mock(requestProcessorDelegate: delegate) + let request = makeRequest(.user) + + // when + do { + let _: Response = try await sut.send(request) + } catch { + XCTAssertEqual(error as NSError, RetryPolicyError.retryLimitExceeded as NSError) + } + } + + // MARK: Private + + private func makeRequest(_ path: String) -> IRequest { + let request = RequestStub() + request.stubbedDomainName = "https://github.com" + request.stubbedPath = path + return request + } +} + +// MARK: - GitHubDelegate + +private final class GitHubDelegate: RequestProcessorDelegate { + func requestProcessor( + _: NetworkLayerInterfaces.IRequestProcessor, + validateResponse response: HTTPURLResponse, + data _: Data, + task _: URLSessionTask + ) throws { + if !(200 ..< 300).contains(response.statusCode) { + throw URLError(.unknown) + } + } +} + +// MARK: - Stubs + +private extension StubResponse { + static let user = StubResponse(name: .user, fileURL: MockedData.userJSON, httpMethod: .get) +} + +private extension String { + static let user = "user" +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/AuthenticationInterceptorTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/AuthenticationInterceptorTests.swift new file mode 100644 index 0000000..0365ebe --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/AuthenticationInterceptorTests.swift @@ -0,0 +1,146 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import NetworkLayer +import NetworkLayerInterfaces +import XCTest + +final class AuthenticationInterceptorTests: XCTestCase { + // MARK: Properties + + private var authenticatorMock: AuthenticatorMock! + + private var sut: AuthenticationInterceptor! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + authenticatorMock = AuthenticatorMock() + sut = AuthenticationInterceptor( + authenticator: authenticatorMock, + credential: nil + ) + } + + override func tearDown() { + authenticatorMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatAuthenticatorInterceptorThrowsAnErrorOnAdaptRequest_whenCredentialIsMissing() async throws { + // given + var requestMock = URLRequest.fake() + + // when + var receivedError: NSError? + do { + try await sut.adapt(request: &requestMock, for: .shared) + } catch { + receivedError = error as NSError + } + + // then + XCTAssertEqual(receivedError, AuthenticatorInterceptorError.missingCredential as NSError) + } + + func test_thatAuthenticatorInterceptorAdaptsRequest_whenCredentialIsNotMissingAndValid() async throws { + // given + let credentialStub = AuthenticationCredentialStub() + var requestMock = URLRequest.fake() + credentialStub.stubbedRequiresRefresh = false + + sut.credential = credentialStub + + // when + try await sut.adapt(request: &requestMock, for: .shared) + + // then + XCTAssertEqual(authenticatorMock.invokedApplyCount, 1) + } + + func test_thatAuthenticatorInterceptorAdaptsRequest_whenCredentialIsNotMissingAndNotValid() async throws { + // given + var requestMock = URLRequest.fake() + + let credentialStub = AuthenticationCredentialStub() + credentialStub.stubbedRequiresRefresh = true + + authenticatorMock.stubbedRefresh = AuthenticationCredentialStub() + sut.credential = credentialStub + + // when + try await sut.adapt(request: &requestMock, for: .shared) + + // then + XCTAssertEqual(authenticatorMock.invokedRefreshCount, 1) + } + + func test_thatAuthenticatorInterceptorRefreshesCredential() async throws { + // given + let requestMock = URLRequest.fake() + + sut.credential = AuthenticationCredentialStub() + authenticatorMock.stubbedRefresh = AuthenticationCredentialStub() + authenticatorMock.stubbedDidRequestResult = true + authenticatorMock.stubbedIsRequestResult = true + + // when + try await sut.refresh(requestMock, with: .init(), for: .shared) + + // then + XCTAssertEqual(authenticatorMock.invokedRefreshCount, 1) + } + + func test_thatAuthenticatorInterceptorDoesNotRefreshCredential_whenRequestDidNotFailDueToAuthenticationError() async throws { + // given + let requestMock = URLRequest.fake() + + authenticatorMock.stubbedDidRequestResult = false + + // when + try await sut.refresh(requestMock, with: .init(), for: .shared) + + // then + XCTAssertFalse(authenticatorMock.invokedRefresh) + XCTAssertEqual(authenticatorMock.invokedDidRequestCount, 1) + } + + func test_thatAuthenticatorInterceptorThrowsCredentialIsMissingError_whenCredentialIsNil() async throws { + // given + let requestMock = URLRequest.fake() + + authenticatorMock.stubbedDidRequestResult = true + + // when + var receivedError: NSError? + do { + try await sut.refresh(requestMock, with: .init(), for: .shared) + } catch { + receivedError = error as NSError + } + + // then + XCTAssertFalse(authenticatorMock.invokedRefresh) + XCTAssertEqual(receivedError, AuthenticatorInterceptorError.missingCredential as NSError) + } + + func test_thatAuthenticatorInterceptorDoesNotRefreshCredential_whenRequestIsNotAuthenticatedWithCredential() async throws { + // given + let requestMock = URLRequest.fake() + + sut.credential = AuthenticationCredentialStub() + authenticatorMock.stubbedDidRequestResult = true + + // when + try await sut.refresh(requestMock, with: .init(), for: .shared) + + // then + XCTAssertFalse(authenticatorMock.invokedRefresh) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/DataRequestHanderTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/DataRequestHanderTests.swift new file mode 100644 index 0000000..0dcb032 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/DataRequestHanderTests.swift @@ -0,0 +1,105 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import NetworkLayer +import XCTest + +final class DataRequestHanderTests: XCTestCase { + // MARK: Properties + + private var delegateMock: URLSessionDelegateMock! + + private var sut: DataRequestHandler! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + delegateMock = URLSessionDelegateMock() + sut = DataRequestHandler() + sut.urlSessionDelegate = delegateMock + } + + override func tearDown() { + delegateMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionDidBecomeInvalidWithError() { + // given + let errorMock = URLError(.unknown) + + // when + sut.urlSession(.shared, didBecomeInvalidWithError: errorMock) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionDidBecomeInvalidWithError) + XCTAssertEqual(delegateMock.invokedUrlSessionDidBecomeInvalidWithErrorParameters?.error as? URLError, errorMock) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionWillCacheResponseWithCompletionHandler() { + // when + sut.urlSession( + .shared, + dataTask: .fake(), + willCacheResponse: .init(), + completionHandler: { _ in } + ) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionWillCacheResponse) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionDidFinishCollectingMetrics() { + // when + sut.urlSession(.shared, task: .fake(), didFinishCollecting: .init()) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionDidFinishCollectingMetrics) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionWillPerformHTTPRedirection() { + // when + sut.urlSession(.shared, task: .fake(), willPerformHTTPRedirection: .fake(), newRequest: .fake(), completionHandler: { _ in }) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionWillPerformHTTPRedirection) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionTaskIsWaitingForConnectivity() { + // when + sut.urlSession(.shared, taskIsWaitingForConnectivity: .fake()) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionTaskIsWaitingForConnectivity) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionDidReceiveChallenge() { + // when + sut.urlSession(.shared, didReceive: .init(), completionHandler: { _, _ in }) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionDidReceiveChallenge) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionWillBeginDelayedRequest() { + // when + sut.urlSession(.shared, task: .fake(), willBeginDelayedRequest: .fake(), completionHandler: { _, _ in }) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionWillBeginDelayedRequest) + } + + func test_thatDataRequestHandlerTriggersDelegate_whenSessionDidSendBodyData() { + // when + sut.urlSession(.shared, task: .fake(), didSendBodyData: .zero, totalBytesSent: .zero, totalBytesExpectedToSend: .zero) + + // then + XCTAssertTrue(delegateMock.invokedUrlSessionDidSendBodyData) + } +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBodyEncoderTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBodyEncoderTests.swift new file mode 100644 index 0000000..ad3c44c --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBodyEncoderTests.swift @@ -0,0 +1,73 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import NetworkLayer +import XCTest + +// MARK: - RequestBodyEncoderTests + +final class RequestBodyEncoderTests: XCTestCase { + // MARK: Properties + + private var sut: RequestBodyEncoder! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = RequestBodyEncoder(jsonEncoder: JSONEncoder()) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatRequestBodyEncoderEncodesBodyIntoRequest_whenTypeIsData() throws { + // given + var requestFake = URLRequest.fake() + let data = Data() + + // when + try sut.encode(body: .data(data), to: &requestFake) + + // then + XCTAssertEqual(requestFake.httpBody, data) + } + + func test_thatRequestBodyEncoderEncodesBodyIntoRequest_whenTypeIsDictonary() throws { + // given + var requestFake = URLRequest.fake() + let dictonary = ["test": "test"] + + // when + try sut.encode(body: .dictonary(dictonary), to: &requestFake) + + // then + let data = try JSONSerialization.data(withJSONObject: dictonary) + XCTAssertEqual(requestFake.httpBody, data) + } + + func test_thatRequestBodyEncoderEncodesBodyIntoRequest_whenTypeIsEncodable() throws { + // given + var requestFake = URLRequest.fake() + let object = DummyObject() + + // when + try sut.encode(body: .encodable(object), to: &requestFake) + + // then + let data = try JSONEncoder().encode(object) + XCTAssertEqual(requestFake.httpBody, data) + } +} + +// MARK: RequestBodyEncoderTests.DummyObject + +private extension RequestBodyEncoderTests { + struct DummyObject: Encodable {} +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift new file mode 100644 index 0000000..ce7c383 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift @@ -0,0 +1,95 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import NetworkLayer +import XCTest + +// MARK: - RequestBuilderTests + +final class RequestBuilderTests: XCTestCase { + // MARK: Properties + + private var parametersEncoderMock: RequestParametersEncoderMock! + private var requestBodyEncoderMock: RequestBodyEncoderMock! + + private var sut: RequestBuilder! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + parametersEncoderMock = RequestParametersEncoderMock() + requestBodyEncoderMock = RequestBodyEncoderMock() + sut = RequestBuilder( + parametersEncoder: parametersEncoderMock, + requestBodyEncoder: requestBodyEncoderMock + ) + } + + override func tearDown() { + parametersEncoderMock = nil + requestBodyEncoderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatRequestBuilderThrowsAnError_whenRequestIsNotValid() { + // given + let request = RequestStub() + + // when + var receivedError: NSError? + do { + _ = try sut.build(request, nil) + } catch { + receivedError = error as NSError + } + + // then + XCTAssertEqual(receivedError, URLError(.badURL) as NSError) + } + + func test_thatRequestBuilderBuildsARequest() throws { + // given + let requestStub = RequestStub() + requestStub.stubbedDomainName = .domainName + requestStub.stubbedHeaders = .contentType + requestStub.stubbedHttpMethod = .post + requestStub.stubbedHttpBody = .dictonary(.item) + requestStub.stubbedParameters = .contentType + + // when + var invokedConfigure = false + let request = try sut.build(requestStub) { _ in invokedConfigure = true } + + // then + XCTAssertTrue(invokedConfigure) + XCTAssertEqual(request?.allHTTPHeaderFields, .contentType) + XCTAssertEqual(request?.httpMethod, "POST") + XCTAssertEqual(parametersEncoderMock.invokedEncodeParameters?.parameters, .contentType) + + if case let .dictonary(dict) = requestBodyEncoderMock.invokedEncodeParameters?.body { + XCTAssertTrue(NSDictionary(dictionary: dict).isEqual(to: Dictionary.item)) + } else { + XCTFail("body should be equal to a dictionary") + } + } +} + +// MARK: - Constants + +private extension String { + static let domainName = "https://google.com" +} + +private extension Dictionary where Self.Key == String, Self.Value == String { + static let contentType = ["Content-Type": "application/json"] +} + +private extension Dictionary where Self.Key == String, Self.Value == Any { + static let item = ["Content-Type": "application/json"] +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestParametersEncoderTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestParametersEncoderTests.swift new file mode 100644 index 0000000..9cb4a31 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestParametersEncoderTests.swift @@ -0,0 +1,68 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +@testable import NetworkLayer +import XCTest + +// MARK: - RequestParametersEncoderTests + +final class RequestParametersEncoderTests: XCTestCase { + // MARK: Properties + + private var sut: RequestParametersEncoder! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = RequestParametersEncoder() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatRequestParametersEncoderEncodesParametersIntoRequest() throws { + // given + var requestMock = URLRequest.fake(string: .domainName) + + // when + try sut.encode(parameters: .parameters, to: &requestMock) + + // then + XCTAssertEqual(requestMock.url?.absoluteString, "https://google.com?id=1") + } + + func test_thatRequestParametersEncoderThrowsAnError_whenURLIsNotValid() { + // given + var requestMock = URLRequest.fake(string: .wrongURL) + + // when + var receivedError: NSError? + do { + try sut.encode(parameters: .parameters, to: &requestMock) + } catch { + receivedError = error as NSError + } + + // then + XCTAssertEqual(receivedError, URLError(.badURL) as NSError) + } +} + +// MARK: - Constants + +private extension String { + static let domainName = "https://google.com" + static let wrongURL = "http://example.com:-80" +} + +private extension Dictionary where Self.Key == String, Self.Value == String { + static let parameters = ["id": "1"] +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift new file mode 100644 index 0000000..a46d5a8 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift @@ -0,0 +1,135 @@ +// +// network-layer +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import NetworkLayer +import NetworkLayerInterfaces +import Typhoon +import XCTest + +final class RequestProcessorTests: XCTestCase { + // MARK: Properties + + private var requestBuilderMock: RequestBuilderMock! + private var dataRequestHandler: DataRequestHandlerMock! + private var retryPolicyMock: RetryPolicyService! + private var delegateMock: RequestProcessorDelegateMock! + private var interceptorMock: AuthentificatorInterceptorMock! + + private var sut: RequestProcessor! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + requestBuilderMock = RequestBuilderMock() + dataRequestHandler = DataRequestHandlerMock() + retryPolicyMock = RetryPolicyService( + strategy: .constant( + retry: 5, + duration: .seconds(.zero) + ) + ) + delegateMock = RequestProcessorDelegateMock() + interceptorMock = AuthentificatorInterceptorMock() + sut = RequestProcessor( + configuration: Configuration( + sessionConfiguration: .default, + sessionDelegate: nil, + sessionDelegateQueue: nil, + jsonDecoder: JSONDecoder() + ), + requestBuilder: requestBuilderMock, + dataRequestHandler: dataRequestHandler, + retryPolicyService: retryPolicyMock, + delegate: delegateMock, + interceptor: interceptorMock + ) + } + + override func tearDown() { + requestBuilderMock = nil + dataRequestHandler = nil + retryPolicyMock = nil + delegateMock = nil + interceptorMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatRequestProcessorSignsRequest_whenRequestRequiresAuthentication() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: .init(), task: .fake()) + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + do { + let _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertTrue(interceptorMock.invokedAdapt) + XCTAssertFalse(interceptorMock.invokedRefresh) + } + + func test_thatRequestProcessorDoesNotSignRequest_whenRequestDoesNotRequireAuthentication() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: .init(), task: .fake()) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + let _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertFalse(interceptorMock.invokedAdapt) + XCTAssertFalse(interceptorMock.invokedRefresh) + } + + func test_thatRequestProcessorRefreshesCredential_whenCredentialIsNotValid() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: HTTPURLResponse(), task: .fake()) + interceptorMock.stubbedIsRequireRefreshResult = true + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + do { + let _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertTrue(interceptorMock.invokedAdapt) + XCTAssertTrue(interceptorMock.invokedRefresh) + } + + func test_thatRequestProcessorDoesNotRefreshesCredential_whenRequestDoesNotRequireAuthentication() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.unknown) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + let _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertFalse(interceptorMock.invokedAdapt) + XCTAssertFalse(interceptorMock.invokedRefresh) + } +} diff --git a/Tests/NetworkLayerTests/Resources/JSONs/user.json b/Tests/NetworkLayerTests/Resources/JSONs/user.json new file mode 100644 index 0000000..21764ac --- /dev/null +++ b/Tests/NetworkLayerTests/Resources/JSONs/user.json @@ -0,0 +1,6 @@ +{ + "id": 1, + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "type": "User", +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..8bb858a --- /dev/null +++ b/codecov.yml @@ -0,0 +1,48 @@ +codecov: + # Require CI to pass to show coverage, default yes + require_ci_to_pass: yes + notify: + # Codecov should wait for all CI statuses to complete, default yes + wait_for_ci: yes + +coverage: + # Coverage precision range 0-5, default 2 + precision: 2 + + # Direction to round the coverage value - up, down, nearest, default down + round: nearest + + # Value range for red...green, default 70...100 + range: "70...90" + + status: + # Overall project coverage, compare against pull request base + project: + default: + # The required coverage value + target: 50% + + # The leniency in hitting the target. Allow coverage to drop by X% + threshold: 5% + + # Only measure lines adjusted in the pull request or single commit, if the commit in not in the pr + patch: + default: + # The required coverage value + target: 85% + + # Allow coverage to drop by X% + threshold: 50% + changes: no + +comment: + # Pull request Codecov comment format. + # diff: coverage diff of the pull request + # files: a list of files impacted by the pull request (coverage changes, file is new or removed) + layout: "diff, files" + + # Update Codecov comment, if exists. Otherwise post new + behavior: default + + # If true, only post the Codecov comment if coverage changes + require_changes: false \ No newline at end of file diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..956fdcb --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,38 @@ +#!/bin/bash +git diff --diff-filter=d --staged --name-only | grep -e '\.swift$' | while read line; do + if [[ $line == *"/Generated"* ]]; then + echo "IGNORING GENERATED FILE: " "$line"; + else + mint run swiftformat swiftformat "${line}"; + git add "$line"; + fi +done + +LINT=$(which mint) +if [[ -e "${LINT}" ]]; then + # Export files in SCRIPT_INPUT_FILE_$count to lint against later + count=0 + while IFS= read -r file_path; do + export SCRIPT_INPUT_FILE_$count="$file_path" + count=$((count + 1)) + done < <(git diff --name-only --cached --diff-filter=d | grep ".swift$") + export SCRIPT_INPUT_FILE_COUNT=$count + + if [ "$count" -eq 0 ]; then + echo "No files to lint!" + exit 0 + fi + + echo "Found $count lintable files! Linting now.." + mint run swiftlint --use-script-input-files --strict --config .swiftlint.yml + RESULT=$? # swiftline exit value is number of errors + + if [ $RESULT -eq 0 ]; then + echo "🎉 Well done. No violation." + fi + exit $RESULT +else + echo "⚠️ WARNING: SwiftLint not found" + echo "⚠️ You might want to edit .git/hooks/pre-commit to locate your swiftlint" + exit 0 +fi \ No newline at end of file