├── .github
└── workflows
│ ├── release.yml
│ └── tests.yml
├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── Apexy.xcscheme
├── Apexy.podspec
├── ApexyLoaderExample
├── ApexyLoaderExample.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── ApexyLoaderExample
│ ├── Resources
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ └── Contents.json
│ └── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Sources
│ ├── Application
│ │ └── AppDelegate.swift
│ ├── Business Logic
│ │ ├── Endpoint
│ │ │ ├── BaseEndpoint.swift
│ │ │ ├── OrganizationEndpoint.swift
│ │ │ └── RepositoriesEndpoint.swift
│ │ ├── Loaders
│ │ │ ├── OrganizationLoader.swift
│ │ │ └── RepositoriesLoader.swift
│ │ ├── Models
│ │ │ ├── Organization.swift
│ │ │ ├── OrganizationRepositories.swift
│ │ │ └── Repository.swift
│ │ └── ServiceLayer.swift
│ └── Presentation
│ │ ├── Fetch
│ │ ├── FetchViewController.swift
│ │ └── FetchViewController.xib
│ │ └── Result
│ │ ├── ResultViewController.swift
│ │ └── ResultViewController.xib
│ └── Supporting
│ └── Info.plist
├── Documentation
├── error_handling.md
├── error_handling.ru.md
├── loader.md
├── loader_ru.md
├── nested_response.md
├── nested_response.ru.md
├── reactive.md
├── reactive.ru.md
├── resources
│ ├── demo.gif
│ ├── img_1.png
│ └── uml_state.png
├── tests.md
└── tests.ru.md
├── Example
├── Example.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ ├── Example.xcscheme
│ │ └── ExampleAPI.xcscheme
├── Example.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── Example
│ ├── Resources
│ │ ├── Assets.xcassets
│ │ │ ├── AppIcon.appiconset
│ │ │ │ └── Contents.json
│ │ │ └── Contents.json
│ │ └── Base.lproj
│ │ │ └── LaunchScreen.storyboard
│ ├── Sources
│ │ ├── AppDelegate.swift
│ │ ├── Business Logic
│ │ │ ├── Service
│ │ │ │ ├── BookService.swift
│ │ │ │ └── FileService.swift
│ │ │ └── ServiceLayer.swift
│ │ └── Presentation
│ │ │ ├── Base.lproj
│ │ │ └── Main.storyboard
│ │ │ ├── Helpers
│ │ │ └── Streamer.swift
│ │ │ └── ViewController.swift
│ └── Supporting Files
│ │ └── Info.plist
├── ExampleAPI
│ ├── Common
│ │ ├── APIError.swift
│ │ ├── Codable.swift
│ │ ├── HTTPError.swift
│ │ └── ResponseValidator.swift
│ ├── Endpoint
│ │ ├── Base
│ │ │ ├── EmptyEndpoint.swift
│ │ │ └── JsonEndpoint.swift
│ │ ├── BookListEndpoint.swift
│ │ ├── FileUploadEndpoint.swift
│ │ └── StreamUploadEndpoint.swift
│ ├── ExampleAPI.h
│ ├── Info.plist
│ └── Model
│ │ ├── Book.swift
│ │ └── Form.swift
├── ExampleAPITests
│ ├── Common
│ │ └── Asserts.swift
│ ├── Endpoint
│ │ ├── BookListEndpointTests.swift
│ │ ├── FileUploadEndpointTests.swift
│ │ └── StreamUploadEndpointTests.swift
│ └── Info.plist
├── Podfile
└── Podfile.lock
├── Images
└── apexy.png
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── README.ru.md
├── Sources
├── Apexy
│ ├── APIResult.swift
│ ├── Client.swift
│ ├── Clients
│ │ ├── CombineClient.swift
│ │ └── ConcurrencyClient.swift
│ ├── Endpoint.swift
│ ├── HTTPBody.swift
│ ├── ResponseObserver.swift
│ ├── URLRequestBuildable.swift
│ └── UploadEndpoint.swift
├── ApexyAlamofire
│ ├── AlamofireClient+Concurrency.swift
│ ├── AlamofireClient.swift
│ └── BaseRequestInterceptor.swift
├── ApexyLoader
│ ├── ContentLoader.swift
│ ├── LoaderObservation.swift
│ ├── LoadingState.swift
│ ├── ObservableLoader.swift
│ └── WebLoader.swift
└── ApexyURLSession
│ ├── BaseRequestAdapter.swift
│ ├── URLSessionClient+Concurrency.swift
│ └── URLSessionClient.swift
└── Tests
├── ApexyAlamofireTests
├── AlamofireClientCombineTests.swift
├── AlamofireClientTests.swift
├── BaseRequestInterceptorTests.swift
└── Helpers
│ ├── EmptyEndpoint.swift
│ ├── MockURLProtocol.swift
│ └── SimpleUploadEndpoint.swift
├── ApexyLoaderTests
├── ContentLoaderTests.swift
├── LoaderObservationTests.swift
└── LoadingStateTests.swift
├── ApexyTests
├── HTTPBodyTests.swift
└── URLRequestBuildableTests.swift
└── ApexyURLSessionTests
├── BaseRequestAdapterTests.swift
└── URLSessionClientTests.swift
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | jobs:
9 | release:
10 | name: Make CocoaPods release
11 | runs-on: macos-11
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v2
15 |
16 | - name: Change Xcode
17 | run: sudo xcode-select -s /Applications/Xcode_13.2.1.app
18 |
19 | - name: Install Cocoapods
20 | run: gem install cocoapods
21 |
22 | - name: Deploy to Cocoapods
23 | run: |
24 | set -eo pipefail
25 | pod trunk push Apexy.podspec --allow-warnings --verbose
26 | env:
27 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | # Trigger the workflow on push or pull request,
4 | # for all branches, but never trigger on new tags
5 | push:
6 | branches:
7 | - '**'
8 | tags-ignore:
9 | - '**'
10 | pull_request:
11 | branches:
12 | - '**'
13 |
14 | jobs:
15 | test:
16 | name: Run tests
17 | runs-on: macos-13
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v2
21 | - name: Change Xcode
22 | run: sudo xcode-select -s /Applications/Xcode_15.0.1.app
23 | - name: Build and test
24 | run: swift test --enable-code-coverage --disable-automatic-resolution
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | # Xcode
3 | #
4 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
5 |
6 | ## Build generated
7 | build/
8 | DerivedData/
9 |
10 | ## Various settings
11 | *.pbxuser
12 | !default.pbxuser
13 | *.mode1v3
14 | !default.mode1v3
15 | *.mode2v3
16 | !default.mode2v3
17 | *.perspectivev3
18 | !default.perspectivev3
19 | xcuserdata/
20 |
21 | ## Other
22 | *.moved-aside
23 | *.xccheckout
24 | *.xcscmblueprint
25 |
26 | ## Obj-C/Swift specific
27 | *.hmap
28 | *.ipa
29 | *.dSYM.zip
30 | *.dSYM
31 |
32 | ## Playgrounds
33 | timeline.xctimeline
34 | playground.xcworkspace
35 |
36 | # Swift Package Manager
37 | #
38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
39 | # Packages/
40 | # Package.pins
41 | # Package.resolved
42 | .build/
43 |
44 | # CocoaPods
45 | #
46 | # We recommend against adding the Pods directory to your .gitignore. However
47 | # you should judge for yourself, the pros and cons are mentioned at:
48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
49 | #
50 | Pods/
51 | Example/Pods/
52 | Source/Pods/
53 | #
54 | # Add this line if you want to avoid checking in source code from the Xcode workspace
55 | # *.xcworkspace
56 |
57 | # Carthage
58 | #
59 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
60 | # Carthage/Checkouts
61 |
62 | Carthage/Build
63 |
64 | # Accio dependency management
65 | Dependencies/
66 | .accio/
67 |
68 | # fastlane
69 | #
70 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
71 | # screenshots whenever they are needed.
72 | # For more information about the recommended setup visit:
73 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
74 |
75 | fastlane/report.xml
76 | fastlane/Preview.html
77 | fastlane/screenshots/**/*.png
78 | fastlane/test_output
79 |
80 | # Code Injection
81 | #
82 | # After new code Injection tools there's a generated folder /iOSInjectionProject
83 | # https://github.com/johnno1962/injectionforxcode
84 |
85 | iOSInjectionProject/
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Apexy.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
57 |
63 |
64 |
65 |
66 |
67 |
72 |
73 |
75 |
81 |
82 |
83 |
84 |
85 |
95 |
96 |
102 |
103 |
109 |
110 |
111 |
112 |
114 |
115 |
118 |
119 |
120 |
--------------------------------------------------------------------------------
/Apexy.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = "Apexy"
3 | s.version = "1.7.4"
4 | s.summary = "HTTP transport library"
5 | s.homepage = "https://github.com/RedMadRobot/apexy-ios"
6 | s.license = { :type => "MIT"}
7 | s.author = { "Alexander Ignatiev" => "ai@redmadrobot.com" }
8 | s.source = { :git => "https://github.com/RedMadRobot/apexy-ios.git", :tag => "#{s.version}" }
9 |
10 | s.ios.deployment_target = "13.0"
11 | s.tvos.deployment_target = "13.0"
12 | s.osx.deployment_target = "10.15"
13 | s.watchos.deployment_target = "6.0"
14 |
15 | s.swift_version = "5.3"
16 |
17 | s.subspec 'Core' do |sp|
18 | sp.source_files = "Sources/Apexy/**/*.swift"
19 | end
20 |
21 | s.subspec 'Alamofire' do |sp|
22 | sp.source_files = "Sources/ApexyAlamofire/*.swift"
23 | sp.dependency "Apexy/Core"
24 | sp.dependency "Alamofire", '~>5.6'
25 | end
26 |
27 | s.subspec 'URLSession' do |sp|
28 | sp.source_files = "Sources/ApexyURLSession/*.swift"
29 | sp.dependency "Apexy/Core"
30 | end
31 |
32 | s.subspec 'Loader' do |sp|
33 | sp.source_files = "Sources/ApexyLoader/*.swift"
34 | sp.dependency "Apexy/Core"
35 | end
36 |
37 | s.default_subspecs = ["Alamofire"]
38 |
39 | end
40 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "Alamofire",
6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493",
10 | "version": "5.4.1"
11 | }
12 | },
13 | {
14 | "package": "RxSwift",
15 | "repositoryURL": "https://github.com/ReactiveX/RxSwift.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "b4307ba0b6425c0ba4178e138799946c3da594f8",
19 | "version": "6.5.0"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Resources/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Application/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import UIKit
9 |
10 | @UIApplicationMain
11 | final class AppDelegate: UIResponder, UIApplicationDelegate {
12 |
13 | var window: UIWindow?
14 |
15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
16 |
17 | let window = UIWindow()
18 | self.window = window
19 |
20 | let fetchVC = FetchViewController()
21 | fetchVC.tabBarItem = UITabBarItem(
22 | title: "Fetch",
23 | image: UIImage(systemName: "arrow.down.square.fill"),
24 | tag: 0)
25 |
26 | let resultVC = ResultViewController()
27 | resultVC.tabBarItem = UITabBarItem(
28 | title: "Result",
29 | image: UIImage(systemName: "list.bullet"),
30 | tag: 1)
31 |
32 | let tbc = UITabBarController()
33 | tbc.viewControllers = [fetchVC, resultVC]
34 |
35 | window.frame = UIScreen.main.bounds
36 | window.rootViewController = tbc
37 | window.makeKeyAndVisible()
38 |
39 | return true
40 | }
41 |
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/BaseEndpoint.swift:
--------------------------------------------------------------------------------
1 | import Apexy
2 | import Foundation
3 |
4 | /// Base Endpoint for application remote resource.
5 | ///
6 | /// Contains shared logic for all endpoints in app.
7 | protocol BaseEndpoint: Endpoint where Content: Decodable {
8 | /// Content wrapper.
9 | associatedtype Root: Decodable = Content
10 |
11 | /// Extract content from root.
12 | func content(from root: Root) -> Content
13 | }
14 |
15 | extension BaseEndpoint where Root == Content {
16 | func content(from root: Root) -> Content { return root }
17 | }
18 |
19 | extension BaseEndpoint {
20 |
21 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
22 | let resource = try JSONDecoder.default.decode(Root.self, from: body)
23 | return content(from: resource)
24 | }
25 | }
26 |
27 | extension JSONDecoder {
28 | internal static let `default`: JSONDecoder = {
29 | let decoder = JSONDecoder()
30 | decoder.keyDecodingStrategy = .convertFromSnakeCase
31 | return decoder
32 | }()
33 | }
34 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/OrganizationEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrganizationEndpoint.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import Apexy
9 | import Foundation
10 |
11 | struct OrganizationEndpoint: BaseEndpoint {
12 |
13 | typealias Content = Organization
14 |
15 | func makeRequest() -> URLRequest {
16 | let url = URL(string: "orgs/RedMadRobot")!
17 | return URLRequest(url: url)
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/RepositoriesEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RepositoriesEndpoint.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import Apexy
9 | import Foundation
10 |
11 | /// List of all Redmadrobot repositories on GitHub
12 | struct RepositoriesEndpoint: BaseEndpoint {
13 |
14 | typealias Content = [Repository]
15 |
16 | func makeRequest() -> URLRequest {
17 | let url = URL(string: "orgs/RedMadRobot/repos")!
18 | return URLRequest(url: url)
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrganizationLoader.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import Foundation
9 | import ApexyLoader
10 |
11 | protocol OrganizationLoading: ContentLoading {
12 | var state: LoadingState { get }
13 | }
14 |
15 | final class OrganizationLoader: WebLoader, OrganizationLoading {
16 | func load() {
17 | guard startLoading() else { return }
18 | request(OrganizationEndpoint()) { result in
19 | // imitation of waiting for the request for 3 seconds
20 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
21 | self.finishLoading(result)
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RepositoriesLoader.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import Foundation
9 | import ApexyLoader
10 |
11 | protocol RepoLoading: ContentLoading {
12 | var state: LoadingState<[Repository]> { get }
13 | }
14 |
15 | final class RepositoriesLoader: WebLoader<[Repository]>, RepoLoading {
16 | func load() {
17 | guard startLoading() else { return }
18 | request(RepositoriesEndpoint()) { result in
19 | // imitation of waiting for the request for 5 seconds
20 | DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
21 | self.finishLoading(result)
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Organization.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Organization.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 09.03.2021.
6 | //
7 |
8 | struct Organization: Decodable {
9 | let name: String
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/OrganizationRepositories.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrganizationRepositories.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 09.03.2021.
6 | //
7 |
8 | import Foundation
9 |
10 | struct OrganizationRepositories {
11 | let org: Organization
12 | let repos: [Repository]
13 | }
14 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Repository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Repository.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 09.03.2021.
6 | //
7 |
8 | struct Repository: Decodable {
9 | let name: String
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/ServiceLayer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ServiceLayer.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import Apexy
9 | import ApexyURLSession
10 | import Foundation
11 |
12 | final class ServiceLayer {
13 | static let shared = ServiceLayer()
14 | private init() {}
15 |
16 | private(set) lazy var repoLoader: RepoLoading = RepositoriesLoader(apiClient: apiClient)
17 | private(set) lazy var orgLoader: OrganizationLoading = OrganizationLoader(apiClient: apiClient)
18 |
19 | private lazy var apiClient: Client = {
20 | URLSessionClient(
21 | baseURL: URL(string: "https://api.github.com")!,
22 | configuration: .ephemeral
23 | )
24 | }()
25 | }
26 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FetchViewController.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import ApexyLoader
9 | import UIKit
10 |
11 | final class FetchViewController: UIViewController {
12 |
13 | // MARK: - Private Properties
14 |
15 | @IBOutlet private var downloadButton: UIButton!
16 | @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
17 | @IBOutlet private var repoTextView: UITextView!
18 |
19 | private let repoLoader: RepoLoading
20 | private let orgLoader: OrganizationLoading
21 |
22 | private var observers = [LoaderObservation]()
23 |
24 | // MARK: - Init
25 |
26 | init(
27 | repoLoader: RepoLoading = ServiceLayer.shared.repoLoader,
28 | orgLoader: OrganizationLoading = ServiceLayer.shared.orgLoader) {
29 |
30 | self.repoLoader = repoLoader
31 | self.orgLoader = orgLoader
32 |
33 | super.init(nibName: nil, bundle: nil)
34 | }
35 |
36 | required init?(coder: NSCoder) {
37 | fatalError("init(coder:) has not been implemented")
38 | }
39 |
40 | // MARK: - UIViewController
41 |
42 | override func viewDidLoad() {
43 | super.viewDidLoad()
44 |
45 | observers.append(repoLoader.observe { [weak self] in
46 | self?.stateDidChange()
47 | })
48 |
49 | observers.append(orgLoader.observe { [weak self] in
50 | self?.stateDidChange()
51 | })
52 | }
53 |
54 | // MARK: - Private Methods
55 |
56 | private func stateDidChange() {
57 |
58 | let state = orgLoader.state.merge(repoLoader.state) { org, repos in
59 | OrganizationRepositories(org: org, repos: repos)
60 | }
61 |
62 | if state.isLoading {
63 | activityIndicatorView.startAnimating()
64 | } else {
65 | activityIndicatorView.stopAnimating()
66 | }
67 |
68 | switch state {
69 | case .failure(_, let content?),
70 | .loading(let content?),
71 | .success(let content):
72 | let repos = content.repos.map { $0.name }.joined(separator: "\n")
73 | repoTextView.text = "Repositories of the \(content.org.name) organization:\n\n\(repos)"
74 | default:
75 | break
76 | }
77 | }
78 |
79 | @IBAction private func fetchFileURL() {
80 | repoLoader.load()
81 | orgLoader.load()
82 | }
83 |
84 | }
85 |
86 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
34 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResultViewController.swift
3 | // ApexyLoaderExample
4 | //
5 | // Created by Daniil Subbotin on 04.03.2021.
6 | //
7 |
8 | import ApexyLoader
9 | import UIKit
10 |
11 | final class ResultViewController: UIViewController {
12 |
13 | // MARK: - Private Properties
14 |
15 | @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
16 | @IBOutlet private var repoTextView: UITextView!
17 |
18 | private let repoLoader: RepoLoading
19 | private var observer: LoaderObservation?
20 |
21 | // MARK: - Init
22 |
23 | init(repoLoader: RepoLoading = ServiceLayer.shared.repoLoader) {
24 | self.repoLoader = repoLoader
25 | super.init(nibName: nil, bundle: nil)
26 | }
27 |
28 | required init?(coder: NSCoder) {
29 | fatalError("init(coder:) has not been implemented")
30 | }
31 |
32 | // MARK: - UIViewController
33 |
34 | override func viewDidLoad() {
35 | super.viewDidLoad()
36 |
37 | observer = repoLoader.observe { [weak self] in
38 | self?.stateDidUpdate()
39 | }
40 | stateDidUpdate()
41 | }
42 |
43 | // MARK: - Private Methods
44 |
45 | private func stateDidUpdate() {
46 | if repoLoader.state.isLoading {
47 | activityIndicatorView.startAnimating()
48 | } else {
49 | activityIndicatorView.stopAnimating()
50 | }
51 |
52 | switch repoLoader.state {
53 | case .failure(_, let content?),
54 | .loading(let content?),
55 | .success(let content):
56 | let repos = content.map { $0.name }.joined(separator: "\n")
57 | repoTextView.text = "Repositories:\n\n\(repos)"
58 | default:
59 | break
60 | }
61 | }
62 |
63 | }
64 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/ApexyLoaderExample/ApexyLoaderExample/Supporting/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSupportsIndirectInputEvents
24 |
25 | UILaunchStoryboardName
26 | LaunchScreen
27 | UIRequiredDeviceCapabilities
28 |
29 | armv7
30 |
31 | UISupportedInterfaceOrientations
32 |
33 | UIInterfaceOrientationPortrait
34 |
35 | UISupportedInterfaceOrientations~ipad
36 |
37 | UIInterfaceOrientationPortrait
38 | UIInterfaceOrientationPortraitUpsideDown
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/Documentation/error_handling.md:
--------------------------------------------------------------------------------
1 | # Error handling
2 |
3 | ## The types of errors
4 |
5 | There are several types of errors:
6 | * API Errors — e.g. when the username or password is wrong.
7 | * Network errors (URLError) — e.g. when the internet isn't available (URLError.notConnectedToInternet).
8 | * HTTP errors (HTTPURLResponse) — e.g. if a resource isn't found HTTPURLResponse's statusCode will be 404.
9 | * Decoding errors (DecodingError) — e.g. if there's a type mismatch during decoding.
10 |
11 | ## Preparing for error handling
12 |
13 | API and HTTP error handling should take place before trying to decode a response from a server in the method `func content(from response: URLResponse?, with body: Data) throws -> Content` of `Endpoint` protocol. Below you can see an example of the basic `BaseEndpoint` protocol to which all other `Endpoint` will conforms. In `BaseEndpoint` the response from the server is validated and decoded.
14 |
15 | **BaseEndpoint.swift**
16 | ```swift
17 | import Foundation
18 |
19 | protocol BaseEndpoint: Endpoint where Content: Decodable {
20 | associatedtype Root: Decodable = Content
21 |
22 | func content(from root: Root) -> Content
23 | }
24 |
25 | extension BaseEndpoint where Root == Content {
26 | func content(from root: Root) -> Content { return root }
27 | }
28 |
29 | extension BaseEndpoint {
30 |
31 | var encoder: JSONEncoder { return JSONEncoder.default }
32 |
33 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
34 | try ResponseValidator.validate(response, with: body)
35 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body)
36 | return content(from: resource.data)
37 | }
38 | }
39 |
40 | // MARK: - Response
41 |
42 | struct ResponseData: Decodable where Resource: Decodable {
43 | let data: Resource
44 | }
45 | ```
46 |
47 | `BaseEndpoint` protocol has `associatedtype Root: Decodable` which allows you to specify the decodable type in `Endpoint` objects that conforms to the `BaseEndpoint` protocol. Example:
48 | ```swift
49 | public struct BookListEndpoint: BaseEndpoint {
50 | public typealias Content = [Book]
51 | ...
52 | }
53 | ```
54 |
55 | In `BaseEndpoint` it is assumed that the response from the server will always come to the data field.
56 | ```json
57 | {
58 | "data": { decodable object }
59 | }
60 | ```
61 |
62 | ## Handling decoding errors (DecodingError)
63 |
64 | In the example above, a decoding error can occurs in the method `public func content(from response: URLResponse?, with body: Data) throws -> Content {`. The error will be passed to `completionHandler` when calling the `request` method of `Client` instance.
65 |
66 | ## Handling network errors (URLError)
67 |
68 | If a network error occurs it will be passed to `completionHandler` when calling the `request` method from an instance of `Client`.
69 |
70 | ## Handling API errors
71 |
72 | Usually, an API specification contains a description of the error format. Here is an example:
73 | ```json
74 | {
75 | "error": {
76 | "code": "token_invalid",
77 | "title": "Token invalid"
78 | }
79 | }
80 | ```
81 |
82 | A model object describing this error looks like this:
83 |
84 | ```swift
85 | struct ResponseError: Decodable {
86 | let error: APIError
87 | }
88 |
89 | struct APIError: Decodable, Error {
90 | let code: String
91 | let title: String
92 | }
93 | ```
94 |
95 | To check the response from the server for an API error, create `ResponseValidator` as shown in the example below.
96 |
97 | ```swift
98 | enum ResponseValidator {
99 |
100 | static func validate(_ response: URLResponse?, with body: Data) throws {
101 | try validateAPIResponse(response, with: body)
102 | }
103 |
104 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws {
105 | let decoder = JSONDecoder.default
106 | guard var error = try? decoder.decode(ResponseError.self, from: body).error else {
107 | return
108 | }
109 | throw error
110 | }
111 | }
112 | ```
113 |
114 | In the example above, when calling the `validate` method, an attempt is made to decode the response as an error. If there is a decoding error, then the response from the server is not an error.
115 |
116 | ## Handling HTTP Errors
117 |
118 | HTTP error has a status code, URL, and description. Let's create a structure describing an HTTP error.
119 |
120 | ```swift
121 | public struct HTTPError: Error {
122 | public let statusCode: Int
123 | public let url: URL?
124 |
125 | public var localizedDescription: String {
126 | return HTTPURLResponse.localizedString(forStatusCode: statusCode)
127 | }
128 | }
129 | ```
130 |
131 | Let's add a method to validate HTTP errors in `ResponseValidator.validate()`.
132 |
133 | ```swift
134 | ...
135 | static func validate(_ response: URLResponse?, with body: Data) throws {
136 | try validateAPIResponse(response, with: body)
137 | try validateHTTPstatus(response)
138 | }
139 | ...
140 | private static func validateHTTPstatus(_ response: URLResponse?) throws {
141 | guard let httpResponse = response as? HTTPURLResponse,
142 | !(200..<300).contains(httpResponse.statusCode) else { return }
143 |
144 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url)
145 | }
146 | ```
147 |
148 | If a status code doesn't belong to the 200...<300 range, the validate method will throw an HTTPError.
149 |
--------------------------------------------------------------------------------
/Documentation/error_handling.ru.md:
--------------------------------------------------------------------------------
1 | # Обработка ошибок
2 |
3 | ## Типы ошибок
4 |
5 | Есть несколько типов ошибок:
6 | * Ошибки API — например, когда неправильно введен логин или пароль.
7 | * Ошибки сети (URLError) — например, когда интернет не доступен (URLError.notConnectedToInternet)
8 | * HTTP ошибки (HTTPURLResponse) — например, если страница не найдена то statusCode у HTTPURLResponse будет равен 404.
9 | * Ошибки парсинга (DecodingError) — например если при декодинге есть несоответствие типов. В модельном объекте `var id: String`, а с сервера пришло `"id": 123`
10 |
11 | ## Подготовка к обработке ошибок
12 |
13 | Обработка API и HTTP ошибок должна происходить перед попыткой декодировать ответ от сервера в методе `func content(from response: URLResponse?, with body: Data) throws -> Content` протокола `Endpoint`. Ниже показан пример базового протокола `BaseEndpoint` которому будут соответствовать все остальные `Endpoint`. В `BaseEndpoint` происходит валидация и декодирование ответа от сервера.
14 |
15 | **BaseEndpoint.swift**
16 | ```swift
17 | import Foundation
18 |
19 | protocol BaseEndpoint: Endpoint where Content: Decodable {
20 | associatedtype Root: Decodable = Content
21 |
22 | func content(from root: Root) -> Content
23 | }
24 |
25 | extension BaseEndpoint where Root == Content {
26 | func content(from root: Root) -> Content { return root }
27 | }
28 |
29 | extension BaseEndpoint {
30 |
31 | var encoder: JSONEncoder { return JSONEncoder.default }
32 |
33 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
34 | try ResponseValidator.validate(response, with: body)
35 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body)
36 | return content(from: resource.data)
37 | }
38 | }
39 |
40 | // MARK: - Response
41 |
42 | struct ResponseData: Decodable where Resource: Decodable {
43 | let data: Resource
44 | }
45 | ```
46 |
47 | `BaseEndpoint` протокол имеет `associatedtype Root: Decodable` что позволяет указывать декодируемый тип в объектах `Endpoint` соответствующих `BaseEndpoint`. Пример:
48 | ```swift
49 | public struct BookListEndpoint: BaseEndpoint {
50 | public typealias Content = [Book]
51 | ...
52 | }
53 | ```
54 |
55 | В `BaseEndpoint` считается что ответ от сервера всегда будет приходить в поле data.
56 | ```json
57 | {
58 | "data": { декодируемый объект }
59 | }
60 | ```
61 |
62 | ## Обработка ошибок декодинга (DecodingError)
63 |
64 | В примере выше ошибка декодинга может произойти в методе `public func content(from response: URLResponse?, with body: Data) throws -> Content {`. Она будет передана в completionHandler при вызове метода `request` у экземпляра `Client`.
65 |
66 | ## Обработка сетевых ошибок (URLError)
67 |
68 | Если возникнет сетевая ошибка, то она будет передана в completionHandler при вызове метода `request` у экземпляра `Client`.
69 |
70 | ## Обработка ошибок API
71 |
72 | Обычно в спецификации API есть описание формата ошибок. Пример:
73 | ```json
74 | {
75 | "error": {
76 | "code": "token_invalid",
77 | "title": "Токен неверный"
78 | }
79 | }
80 | ```
81 |
82 | В коде модельный объект описывающий эту ошибку выглядит так:
83 |
84 | ```swift
85 | struct ResponseError: Decodable {
86 | let error: APIError
87 | }
88 |
89 | struct APIError: Decodable, Error {
90 | let code: String
91 | let title: String
92 | }
93 | ```
94 |
95 | Чтобы проверить ответ от сервера на наличие API ошибки создайте `ResponseValidator` как показано в примере ниже.
96 |
97 | ```swift
98 | enum ResponseValidator {
99 |
100 | static func validate(_ response: URLResponse?, with body: Data) throws {
101 | try validateAPIResponse(response, with: body)
102 | }
103 |
104 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws {
105 | let decoder = JSONDecoder.default
106 | guard var error = try? decoder.decode(ResponseError.self, from: body).error else {
107 | return
108 | }
109 | throw error
110 | }
111 | }
112 | ```
113 |
114 | В примере выше при вызове метода `validate` происходит попытка декодировать ответ в виде ошибки. Если в процессе декодирования произошла ошибка — значит ответ от сервера не является ошибкой.
115 |
116 | ## Обработка HTTP ошибок
117 |
118 | HTTP ошибка имеет статус код, URL и описание. Создадим стуктуру описывающую HTTP ошибку.
119 |
120 | ```swift
121 | public struct HTTPError: Error {
122 | public let statusCode: Int
123 | public let url: URL?
124 |
125 | public var localizedDescription: String {
126 | return HTTPURLResponse.localizedString(forStatusCode: statusCode)
127 | }
128 | }
129 | ```
130 |
131 | Добавим метод для валидации HTTP ошибок в `ResponseValidator`.
132 | ```swift
133 | ...
134 | static func validate(_ response: URLResponse?, with body: Data) throws {
135 | try validateAPIResponse(response, with: body)
136 | try validateHTTPstatus(response)
137 | }
138 | ...
139 | private static func validateHTTPstatus(_ response: URLResponse?) throws {
140 | guard let httpResponse = response as? HTTPURLResponse,
141 | !(200..<300).contains(httpResponse.statusCode) else { return }
142 |
143 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url)
144 | }
145 | ```
146 | Если статус код не будет лежать в диапазоне 200..<300 то метод validate кинет ошибку HTTPError.
147 |
--------------------------------------------------------------------------------
/Documentation/loader.md:
--------------------------------------------------------------------------------
1 | # ApexyLoader
2 |
3 | ApexyLoader is an add-on for Apexy that lets you store fetched data in memory and observe the loading state.
4 |
5 | The main concepts of ApexyLoader are loader and state.
6 |
7 | ## Loader
8 |
9 | A loader is an object that fetches, stores data, and notifies subscribers about loading state changes.
10 |
11 | Loader inherits from `WebLoader`. When inheriting from this class you must specify the content type, which must be the same as the content type of `Endpoint`. For example `WebLoader`.
12 |
13 | In the example below a user profile loader is shown.
14 |
15 | `UserProfileEndpoint` returns `UserProfile` and `UserProfileLoader` also must returns `UserProfile`.
16 |
17 | ```swift
18 | import Foundation
19 | import ApexyLoader
20 |
21 | protocol UserProfileLoading: ContentLoading {
22 | var state: LoadingState { get }
23 | }
24 |
25 | final class UserProfileLoader: WebLoader, UserProfileLoading {
26 | func load() {
27 | guard startLoading() else { return }
28 | request(UserProfileEndpoint())
29 | }
30 | }
31 | ```
32 |
33 | When you create a Loader, you must pass a class that conforms to the `Client` protocol from the Apexy library.
34 |
35 | Example of creating a loader using the Service Locator pattern:
36 |
37 | ```swift
38 | import Apexy
39 | import ApexyURLSession
40 | import Foundation
41 |
42 | final class ServiceLayer {
43 | static let shared = ServiceLayer()
44 | private init() {}
45 |
46 | private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient)
47 |
48 | private lazy var apiClient: Client = {
49 | URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral)
50 | }()
51 | }
52 | ```
53 |
54 | Example of passing a Loader to the `UIViewController`.
55 |
56 | ```swift
57 | final class ProfileViewController: UIViewController {
58 |
59 | private let profileLoader: UserProfileLoading
60 |
61 | init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) {
62 | self.profileLoader = profileLoader
63 | super.init(nibName: nil, bundle: nil)
64 | }
65 | }
66 | ```
67 |
68 | ## Loading state
69 |
70 | The `enum LoadingState` represents a loading state. It may have the following states:
71 | - `initial` — initial state when content loading has not yet started.
72 | - `loading(cache: Content?)` — content is loading, and there may be cached (previously loaded) content.
73 | - `success(content: Content)` — content successfully loaded.
74 | - `failure(error: Error, cache: Content?)` — unable to load content, there may be cached (previously loaded) content.
75 |
76 | When you create a loader its initial state is `initial`. The loader has `startLoading()` method which must be called to change the state to `loading`. Immediately after the first call of this method the state of the loader becomes `loading(cache: nil)`. If an error occurs then the state becomes `failure(error: Error, cache: nil)`, otherwise `success(Content)`. If after successful content loading the loading content is repeated (e.g. by a pull to refresh), the `loading` and `failure` states will contain the previously loaded content in the `cache` argument.
77 |
78 |
79 |
80 | The state of multiple loaders can be combined using the `merge` method of `LoadingState`. This method takes a second state and closure which returns a new content based on the content of both states.
81 |
82 | In the example below there are two states: the state of loading user info and the state of loading service list. The `merge` method combines these two states into one. Instead of two model objects: `User` and `Service` there will be one `UserServices`.
83 |
84 | ```swift
85 | let userState = LoadingState.loading(cache: nil)
86 | let servicesState = LoadingState<[Service]>.success(content: 3)
87 |
88 | let state = userState.merge(servicesState) { user, services in
89 | UserServices(user: user, services: services)
90 | }
91 |
92 | switch state {
93 | case .initial:
94 | // initial state
95 | case .loading(let userServices):
96 | // loading state with optional cache (info about user and list of services)
97 | case .success(let userServices):
98 | // successfull state with info about user and list of services
99 | case .failure(let error, let userServices):
100 | // failed state with optional cache (info about user and list of services)
101 | }
102 | ```
103 |
104 | ## Observing loading state
105 |
106 | The `observe` method is used to keep track of the loader state. As with RxSwift and Combine, and in the case of ApexyLoader you need to save the reference to the observer. To do this, you need to declare a variable of `LoaderObservation` type in class properties.
107 |
108 | ```swift
109 | final class ProfileViewController: UIViewController {
110 | private var observer: LoaderObservation?
111 | ...
112 | override func viewDidLoad() {
113 | super.viewDidLoad()
114 | observer = userProfileLoader.observe { [weak self] in
115 | guard let self = self else { return }
116 |
117 | switch self.userProfileLoader.state {
118 | case .initial:
119 | //
120 | case .loading(let cache):
121 | //
122 | case .success(let content):
123 | //
124 | case .failure(let error, let cache):
125 | //
126 | }
127 | }
128 | }
129 | }
130 | ```
131 |
132 | ## Observing loading state via Combine
133 |
134 | To keep track of the loader state via Combine use `statePublisher`.
135 |
136 | ```swift
137 | final class ProfileViewController: UIViewController {
138 | private var bag = Set()
139 | ...
140 | override func viewDidLoad() {
141 | super.viewDidLoad()
142 | userProfileLoader.statePublisher.sink { [weak self] newState in
143 | guard let self = self else { return }
144 |
145 | switch newState {
146 | case .initial:
147 | //
148 | case .loading(let cache):
149 | //
150 | case .success(let content):
151 | //
152 | case .failure(let error, let cache):
153 | //
154 | }
155 | }.store(in: &bag)
156 | }
157 | }
158 | ```
159 |
160 | ## Use cases
161 |
162 | ApexyLoader used in the following scenarios:
163 | 1. When you want to store the loaded data in memory.
164 | For example, to use previously loaded data instead of loading it again each time you open a screen.
165 | 2. The fetch progress and the fetched data itself are displayed on different screens.
166 | For example, one screen may have a button that initiates a long loading operation. Once the data is fetched, it may be displayed on different screens. The loading process itself may also be displayed on different screens.
167 |
168 | 3. When you want to load data from multiple sources and show the loading process and the result as a whole.
169 |
170 | Example:
171 |
172 |
173 |
174 | In this app, the main screen loads a lot of data from different sources: a list of cameras, intercoms, barriers, notifications, user profile. Each loader has its own state. The states of all loaders can be combined into one state and show the result of loading as a whole.
175 |
176 | The camera list loader is reused on the camera list screen. When you go to the camera list screen, you can immediately display the previously loaded data. If you make pull-to-refresh on this screen, the camera list on the main screen will also be updated.
177 |
178 | ## Example project
179 |
180 | In the `ApexyLoaderExample` folder, you can see an example of how to use the `ApexyLoader`.
181 |
182 | This app consists of two screens. On the first screen, you can start downloading data, see the download progress and the result (list of repositories and organization name). On the second screen, you can see the download progress and the result (list of repositories).
183 |
184 | This example demonstrates how to use a shared loader between multiple screens, how to observe the loading state, and to merge the states.
185 |
186 |
187 |
--------------------------------------------------------------------------------
/Documentation/loader_ru.md:
--------------------------------------------------------------------------------
1 | # ApexyLoader
2 |
3 | ApexyLoader — дополнение для Apexy, которое позволяет хранить загруженные данные в памяти и следить за состоянием загрузки.
4 |
5 | Основными понятиями ApexyLoader являются: загрузчик и состояние.
6 |
7 | ## Загрузчик
8 |
9 | Загрузчик — объект который занимается загрузкой, хранением данных и уведомляет подписчиков об изменении состояния загрузки.
10 |
11 | Загрузчик является наследником `WebLoader`. При наследовании от этого класса необходимо указать тип контента который должен быть таким же как и тип контента у `Endpoint`. Например `WebLoader`.
12 |
13 | В примере ниже показан загрузчик профиля пользователя.
14 | `UserProfileEndpoint` возвращает `UserProfile` следовательно и `UserProfileLoader` тоже должен возвращать `UserProfile`.
15 |
16 | ```swift
17 | import Foundation
18 | import ApexyLoader
19 |
20 | protocol UserProfileLoading: ContentLoading {
21 | var state: LoadingState { get }
22 | }
23 |
24 | final class UserProfileLoader: WebLoader, UserProfileLoading {
25 | func load() {
26 | guard startLoading() else { return }
27 | request(UserProfileEndpoint())
28 | }
29 | }
30 | ```
31 |
32 | При создании загрузчика необходимо передать класс который реализует протокол `Client` из библиотеки Apexy.
33 |
34 | Пример создания загрузчика используя паттерн Service Locator:
35 |
36 | ```swift
37 | import Apexy
38 | import ApexyURLSession
39 | import Foundation
40 |
41 | final class ServiceLayer {
42 | static let shared = ServiceLayer()
43 | private init() {}
44 |
45 | private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient)
46 |
47 | private lazy var apiClient: Client = {
48 | URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral)
49 | }()
50 | }
51 | ```
52 |
53 | Пример передачи зависимости в `UIViewController`.
54 |
55 | ```swift
56 | final class ProfileViewController: UIViewController {
57 |
58 | private let profileLoader: UserProfileLoading
59 |
60 | init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) {
61 | self.profileLoader = profileLoader
62 | super.init(nibName: nil, bundle: nil)
63 | }
64 | }
65 | ```
66 |
67 | ## Состояния загрузки
68 |
69 | За состояние загрузки отвечает `enum LoadingState`. У него могут быть следующие состояния:
70 | - `initial` — начальное состояние, когда загрузка данных ещё не начата.
71 | - `loading(cache: Content?)` — данные загружаются, при этом может быть закэшированный (ранее загруженный) контент.
72 | - `success(content: Content)` — данные успешно загружены.
73 | - `failure(error: Error, cache: Content?)` — ошибка загрузки данных, при этом может быть закэшированный (ранее загруженный) контент.
74 |
75 | При создании загрузчика его начальное состояние будет `initial`. У загрузчика есть метод `startLoading()` который необходимо вызвать чтобы поменять состояние на `loading`. Сразу после первого вызова этого метода состояние загрузчика становится `loading(cache: nil)`. Если возникнет ошибка то состояние станет `failure(error: Error, cache: nil)`, иначе `success(Content)`. Если после успешной загрузки данных повторить загрузку данных (например при pull to refresh), то состояния `loading` и `failure` будут содержать в аргументе `cache` ранее загруженные данные.
76 |
77 |
78 |
79 | Состояния нескольких загрузчиков можно объединить с помощью метода `merge` у `LoadingState`. Этот метод принимает второе состояние и замыкание которое возвращает новый контент на основе контента обоих состояний.
80 |
81 | В примере ниже есть два состояния: состояние загрузки информации о пользователе и состояние загрузки списка услуг. С помощью метода `merge` эти два состояния объединяются в одно. Вместо двух модельных объектов: `User` и `Service` будет один `UserServices`.
82 |
83 | ```swift
84 | let userState = LoadingState.loading(cache: nil)
85 | let servicesState = LoadingState<[Service]>.success(content: 3)
86 |
87 | let state = userState.merge(servicesState) { user, services in
88 | UserServices(user: user, services: services)
89 | }
90 |
91 | switch state {
92 | case .initial:
93 | // initial state
94 | case .loading(let userServices):
95 | // loading state with optional cache (info about user and list of services)
96 | case .success(let userServices):
97 | // successful state with info about user and list of services
98 | case .failure(let error, let userServices):
99 | // failed state with optional cache (info about user and list of services)
100 | }
101 | ```
102 |
103 | ## Отслеживание состояния загрузки
104 |
105 | Чтобы следить за состоянием загрузчика используется метод `observe`. Как с RxSwift и Combine так и в случае ApexyLoader нужно сохранить ссылку на обсервер. Для этого нужно в свойствах класса объявить переменную типа `LoaderObservation`.
106 |
107 | ```swift
108 | final class ProfileViewController: UIViewController {
109 | private var observer: LoaderObservation?
110 | ...
111 | override func viewDidLoad() {
112 | super.viewDidLoad()
113 | observer = userProfileLoader.observe { [weak self] in
114 | guard let self = self else { return }
115 |
116 | switch self.userProfileLoader.state {
117 | case .initial:
118 | //
119 | case .loading(let cache):
120 | //
121 | case .success(let content):
122 | //
123 | case .failure(let error, let cache):
124 | //
125 | }
126 | }
127 | }
128 | }
129 | ```
130 |
131 | ## Отслеживание состояния загрузки через Combine
132 |
133 | Чтобы следить за состоянием загрузчика с помощью Combine используйте паблишер `statePublisher`.
134 |
135 | ```swift
136 | final class ProfileViewController: UIViewController {
137 | private var bag = Set()
138 | ...
139 | override func viewDidLoad() {
140 | super.viewDidLoad()
141 | userProfileLoader.statePublisher.sink { [weak self] newState in
142 | guard let self = self else { return }
143 |
144 | switch newState {
145 | case .initial:
146 | //
147 | case .loading(let cache):
148 | //
149 | case .success(let content):
150 | //
151 | case .failure(let error, let cache):
152 | //
153 | }
154 | }.store(in: &bag)
155 | }
156 | }
157 | ```
158 |
159 | ## Сценарии использования
160 |
161 | ApexyLoader применяется когда:
162 | 1. Необходимо хранить загруженные данные в памяти.
163 | Например, чтобы при каждом заходе на экран не загружать данные заново, а использовать уже загруженные данные.
164 | 2. Процесс загрузки и сами загруженные данные отображаются на разных экранах.
165 | Например, на одном экране может быть кнопка которая инициирует долгую операцию загрузки. После загрузки данных они могут отображаться на разных экранах. Сам процесс загрузки также может отображаться на разных экранах.
166 | 3. Необходимо загрузить данные из нескольких источников и показать процесс загрузки и результат как одно целое.
167 |
168 | Пример:
169 |
170 |
171 |
172 | В этом приложении на главном экране загружается большое кол-во данных из разных источников: список камер, домофонов, шлагбаумов, уведомления, профиль пользователя. Каждый загрузчик имеет своё состояние. Состояния всех загрузчиков можно объединить в одно состояние и показывать результат загрузки как одно целое.
173 |
174 | Загрузчик списка камер переиспользуется на отдельном экране со списком камер. За счет этого, при переходе на экран со списком камер, можно сразу отобразить загруженные ранее данные. При этом, если на этом экране сделать pull-to-refresh, то список камер на главном экране тоже обновится.
175 |
176 | ## Example проект
177 |
178 | Пример использования `ApexyLoader` смотри в папке `ApexyLoaderExample`.
179 |
180 | Это приложение состоит из двух экранов. На первом экране можно начать загрузку данных, видеть индикацию загрузки и результат (список репозиториев и название организации). На втором экране можно видеть индикацию загрузки и результат (список репозиториев).
181 |
182 | В этом примере демонстрируется шаринг загрузчика между экранами, отслеживание состояния загрузки и объединение состояний.
183 |
184 |
185 |
--------------------------------------------------------------------------------
/Documentation/nested_response.md:
--------------------------------------------------------------------------------
1 | # Nested Responses
2 |
3 | A server almost always returns JSON objects nested in other objects.
4 |
5 | Consider a pair of requests and two cases where a server can return nested responses.
6 |
7 | Requests:
8 | - `GET books/` Returns a list of books as an array of `Book`.
9 | - `GET books/{book_id}` Returns a `Book` by `id`.
10 |
11 | ```swift
12 | public struct Book: Codable, Identifiable {
13 | public let id: Int
14 | public let name: String
15 | }
16 | ```
17 |
18 | ## Nested responses with the same key
19 |
20 | In the first case, a server will wrap the response objects in `data`.
21 |
22 | ```json
23 | {
24 | "data": "content"
25 | }
26 | ```
27 |
28 | `GET books/`
29 |
30 | The request to receive all the books will return an array wrapped in `data`.
31 |
32 | ```json
33 | {
34 | "data": [
35 | {
36 | "id": 1,
37 | "name": "Mu mu",
38 | }
39 | ]
40 | }
41 | ```
42 |
43 | `GET books/{book_id}`
44 |
45 | The request to receive a book by `id` will return one book wrapped in `data`
46 |
47 | ```json
48 | {
49 | "data": {
50 | "id": "A-1",
51 | "name": "Mu mu",
52 | }
53 | }
54 | ```
55 |
56 | To hide the `data` wrapper, let's create `JsonEndpoint`, which will get us the necessary `Content`.
57 |
58 | ```swift
59 | protocol JsonEndpoint: Endpoint where Content: Decodable {}
60 |
61 | extension JsonEndpoint {
62 |
63 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
64 | let decoder = JSONDecoder()
65 | let value = try decoder.decode(ResponseData.self, from: body)
66 | return value.data
67 | }
68 | }
69 |
70 | private struct ResponseData: Decodable where Content: Decodable {
71 | let data: Content
72 | }
73 | ```
74 |
75 | As a result, our requests hide the nesting of the response.
76 |
77 | - `BookListEndpoint.Content = [Book]`
78 | - `BookEndpoint.Content = Book`
79 |
80 | ```swift
81 | public struct BookEndpoint: JsonEndpoint {
82 | public typealias Content = Book
83 | // ..,
84 | }
85 |
86 | public struct BookListEndpoint: JsonEndpoint {
87 | public typealias Content = [Book]
88 | // ..,
89 | }
90 | ```
91 |
92 | ## Nested responses with different keys
93 |
94 | In the second more complex case, the server will send responses nested with different keys.
95 |
96 | `GET books/`
97 |
98 | The request to receive all books will return an array wrapped in `book_list`.
99 |
100 | ```json
101 | {
102 | "book_list": [
103 | {
104 | "id": 1,
105 | "name": "Mu mu",
106 | }
107 | ]
108 | }
109 | ```
110 |
111 | `GET books/{book_id}`
112 |
113 | The request to receive a book by id will return a book wrapped in `book`.
114 |
115 | ```json
116 | {
117 | "book": {
118 | "id": "A-1",
119 | "name": "Mu mu",
120 | }
121 | }
122 | ```
123 |
124 | To unwrap responses, create `JsonEndpoint` with the `content(from:)` method which will unwrap the responses.
125 |
126 | ```swift
127 | protocol JsonEndpoint: Endpoint where Content: Decodable {
128 | associatedtype Root: Decodable = Content
129 |
130 | func content(from root: Root) -> Content
131 | }
132 |
133 | extension JsonEndpoint {
134 |
135 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
136 | let decoder = JSONDecoder()
137 | decoder.keyDecodingStrategy = .convertFromSnakeCase
138 | let root = try decoder.decode(Root.self, from: body)
139 | return content(from: root)
140 | }
141 | }
142 | ```
143 |
144 | Thus, the request to receive all the books will look like this.
145 |
146 | ```swift
147 | struct BookListResponse: Decodable {
148 | let bookList: [Book]
149 | }
150 |
151 | public struct BookListEndpoint: JsonEndpoint {
152 | public typealias Content = [Book]
153 |
154 | func content(from root: BookListResponse) -> Content {
155 | return root.bookList
156 | }
157 |
158 | public func makeRequest() throws -> URLRequest {
159 | return URLRequest(url: URL(string: "books")!)
160 | }
161 | }
162 | ```
163 |
164 | > Notice that `BookListResponse` and `content(from:)` remains `internal` and hide the features of the response format.
165 |
166 | The request to get a book by `id` will look like this.
167 |
168 | ```swift
169 | struct BookResponse: Decodable {
170 | let book: Book
171 | }
172 |
173 | public struct BookEndpoint: JsonEndpoint {
174 | public typealias Content = Book
175 |
176 | public let id: Book.ID
177 |
178 | public init(id: Book.ID) {
179 | self.id = id
180 | }
181 |
182 | func content(from root: BookResponse) -> Content {
183 | return root.book
184 | }
185 |
186 | public func makeRequest() throws -> URLRequest {
187 | let url = URL(string: "books")!.appendingPathComponent(id)
188 | return URLRequest(url: url)
189 | }
190 | }
191 | ```
192 |
193 | # Conclusion
194 |
195 | In the end, I would note that these two cases can be combined, and it will allow you to work without a boilerplate with complex APIs.
--------------------------------------------------------------------------------
/Documentation/nested_response.ru.md:
--------------------------------------------------------------------------------
1 | # Вложенные ответы
2 |
3 | Почти всегда сервер возвращает json объекты вложенные в другие объекты.
4 |
5 | Рассмотрим пару запросов и два случая, когда сервер может может возвращать ответ вложенным.
6 |
7 | Запросы:
8 | - `GET books/` Получение списка книг. Ожидаем массив книг `Book`.
9 | - `GET books/{book_id}` Получение книги по `id` Ожидаем просто книгу `Book`.
10 |
11 | ```swift
12 | public struct Book: Codable, Identifiable {
13 | public let id: Int
14 | public let name: String
15 | }
16 | ```
17 |
18 | ## Вложенные ответы с одинаковым ключом
19 |
20 | В первом случае сервер будет оборачивать объекты ответов в `data`.
21 |
22 | ```json
23 | {
24 | "data": "content"
25 | }
26 | ```
27 |
28 | `GET books/`
29 |
30 | На запрос получения всех книг вернется массив обернутый в `data`.
31 |
32 | ```json
33 | {
34 | "data": [
35 | {
36 | "id": 1,
37 | "name": "Mu mu",
38 | }
39 | ]
40 | }
41 | ```
42 |
43 |
44 | `GET books/{book_id}`
45 |
46 | На запрос получения книги по `id` вернется одна книга обернутая в `data`.
47 |
48 | ```json
49 | {
50 | "data": {
51 | "id": "A-1",
52 | "name": "Mu mu",
53 | }
54 | }
55 | ```
56 |
57 | Чтобы скрыть обертку `data`, создадим `JsonEndpoint`, который будет доставать необходимый нам `Content`.
58 |
59 | ```swift
60 | protocol JsonEndpoint: Endpoint where Content: Decodable {}
61 |
62 | extension JsonEndpoint {
63 |
64 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
65 | let decoder = JSONDecoder()
66 | let value = try decoder.decode(ResponseData.self, from: body)
67 | return value.data
68 | }
69 | }
70 |
71 | private struct ResponseData: Decodable where Content: Decodable {
72 | let data: Content
73 | }
74 | ```
75 |
76 | В итоге наши запросы скрывают вложенность ответа.
77 |
78 | - `BookListEndpoint.Content = [Book]`
79 | - `BookEndpoint.Content = Book`
80 |
81 | ```swift
82 | public struct BookEndpoint: JsonEndpoint {
83 | public typealias Content = Book
84 | // ..,
85 | }
86 |
87 | public struct BookListEndpoint: JsonEndpoint {
88 | public typealias Content = [Book]
89 | // ..,
90 | }
91 | ```
92 |
93 | ## Вложенные ответы с разными ключами
94 |
95 | Во втором более сложном случае сервер будет отправлять ответы вложенные в разные ключи.
96 |
97 | `GET books/`
98 |
99 | На запрос получения всех книг вернется массив обернутый в `book_list`.
100 |
101 | ```json
102 | {
103 | "book_list": [
104 | {
105 | "id": 1,
106 | "name": "Mu mu",
107 | }
108 | ]
109 | }
110 | ```
111 |
112 | `GET books/{book_id}`
113 |
114 | На запрос получения книги по `id` вернется одна книга обернутая в `book`.
115 |
116 | ```json
117 | {
118 | "book": {
119 | "id": "A-1",
120 | "name": "Mu mu",
121 | }
122 | }
123 | ```
124 |
125 | Для разворачивания ответов создадим `JsonEndpoint` c методом `content(from:)`, который будет разворачивать ответы.
126 |
127 | ```swift
128 | protocol JsonEndpoint: Endpoint where Content: Decodable {
129 | associatedtype Root: Decodable = Content
130 |
131 | func content(from root: Root) -> Content
132 | }
133 |
134 | extension JsonEndpoint {
135 |
136 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
137 | let decoder = JSONDecoder()
138 | decoder.keyDecodingStrategy = .convertFromSnakeCase
139 | let root = try decoder.decode(Root.self, from: body)
140 | return content(from: root)
141 | }
142 | }
143 | ```
144 |
145 | Таким образом запрос получения всех книг будет оформлен так.
146 |
147 | ```swift
148 | struct BookListResponse: Decodable {
149 | let bookList: [Book]
150 | }
151 |
152 | public struct BookListEndpoint: JsonEndpoint {
153 | public typealias Content = [Book]
154 |
155 | func content(from root: BookListResponse) -> Content {
156 | return root.bookList
157 | }
158 |
159 | public func makeRequest() throws -> URLRequest {
160 | return URLRequest(url: URL(string: "books")!)
161 | }
162 | }
163 | ```
164 |
165 | > Обратите внимание, что `BookListResponse` и `content(from:)` остались `internal` и скрывают особенности формата ответа.
166 |
167 | Для получения книги по `id` запрос будет таким.
168 |
169 | ```swift
170 | struct BookResponse: Decodable {
171 | let book: Book
172 | }
173 |
174 | public struct BookEndpoint: JsonEndpoint {
175 | public typealias Content = Book
176 |
177 | public let id: Book.ID
178 |
179 | public init(id: Book.ID) {
180 | self.id = id
181 | }
182 |
183 | func content(from root: BookResponse) -> Content {
184 | return root.book
185 | }
186 |
187 | public func makeRequest() throws -> URLRequest {
188 | let url = URL(string: "books")!.appendingPathComponent(id)
189 | return URLRequest(url: url)
190 | }
191 | }
192 | ```
193 |
194 | # Заключение
195 |
196 | В конце я бы отметил, что эти два случая могут комбинироваться, и это позволит вам работать без бойлерплейта со сложными API.
197 |
--------------------------------------------------------------------------------
/Documentation/reactive.md:
--------------------------------------------------------------------------------
1 | # Reactive programming
2 |
3 | ## Combine
4 |
5 | Apexy supports Combine framework
6 |
7 | How to use by example `BookService` (see Example project).
8 |
9 | ```swift
10 | final class BookService {
11 | ...
12 | func fetchBooks() -> AnyPublisher<[Book], Error> {
13 | let endpoint = BookListEndpoint()
14 | return apiClient.request(endpoint)
15 | }
16 | ...
17 | }
18 | ```
19 |
20 | ```swift
21 | bookService.fetchBooks().sink(receiveCompletion: { [weak self] completion in
22 | self?.activityView.isHidden = true
23 | switch completion {
24 | case .finished:
25 | break
26 | case .failure(let error):
27 | self?.resultLabel.text = error.localizedDescription
28 | }
29 | }, receiveValue: { [weak self] books in
30 | self?.show(books: books)
31 | }).store(in: &bag)
32 | ```
33 |
--------------------------------------------------------------------------------
/Documentation/reactive.ru.md:
--------------------------------------------------------------------------------
1 | # Реактивное программирование
2 |
3 | ## Combine
4 |
5 | Apexy поддерживает Combine.
6 |
7 | Как использовать на примере `BookService` (смотри Example проект).
8 |
9 | ```swift
10 | final class BookService {
11 | ...
12 | func fetchBooks() -> AnyPublisher<[Book], Error> {
13 | let endpoint = BookListEndpoint()
14 | return apiClient.request(endpoint)
15 | }
16 | ...
17 | }
18 | ```
19 |
20 | ```swift
21 | bookService.fetchBooks().sink(receiveCompletion: { [weak self] completion in
22 | self?.activityView.isHidden = true
23 | switch completion {
24 | case .finished:
25 | break
26 | case .failure(let error):
27 | self?.resultLabel.text = error.localizedDescription
28 | }
29 | }, receiveValue: { [weak self] books in
30 | self?.show(books: books)
31 | }).store(in: &bag)
32 | ```
33 |
--------------------------------------------------------------------------------
/Documentation/resources/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/demo.gif
--------------------------------------------------------------------------------
/Documentation/resources/img_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/img_1.png
--------------------------------------------------------------------------------
/Documentation/resources/uml_state.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/uml_state.png
--------------------------------------------------------------------------------
/Documentation/tests.md:
--------------------------------------------------------------------------------
1 | # Testing Apexy
2 |
3 | ## What to test?
4 | You can test all the Endpoints and models which contain business logic.
5 |
6 | ### Endpoint
7 | In the case of Endpoint, test how it creates the URLRequest object (method `makeRequest`):
8 | * HTTP method
9 | * URL address
10 | * HTTP Body
11 | * HTTP headers
12 |
13 | **Example**
14 |
15 | There is a `BookListEndpoint` in the example project. This endpoint is used to obtain a list of books. The following example shows how to test this Endpoint.
16 |
17 | ```swift
18 | import ExampleAPI
19 | import XCTest
20 |
21 | final class BookListEndpointTests: XCTestCase {
22 |
23 | func testMakeRequest() throws {
24 | let endpoint = BookListEndpoint()
25 |
26 | let urlRequest = try endpoint.makeRequest()
27 |
28 | XCTAssertEqual(urlRequest.httpMethod, "GET")
29 | XCTAssertNil(urlRequest.httpBody)
30 | XCTAssertEqual(urlRequest.url?.absoluteString, "books")
31 | }
32 | }
33 | ```
34 | This test checks that:
35 | * HTTP method equals to "GET"
36 | * HTTP body doesn't exist
37 | * URL equals to "books"
38 |
39 | ### Model
40 | If a model object contains business logic, then this object must be tested. For example, if a model object has computed properties where data is formatted.
41 |
42 | You can also test the decoding of a model object in the case of complex transformations, for example, converting a string to a date.
43 |
44 | ```swift
45 | /// An abstract access code that has an expiration date
46 | struct Code: Decodable, Equatable {
47 | /// Code value, e.g. "1234"
48 | let code: String
49 | /// Code expiration date
50 | let endDate: Date
51 | }
52 |
53 | final class CodeTests: XCTestCase {
54 |
55 | func testDecode() throws {
56 | let json = """
57 | {
58 | "code": "1234",
59 | "end_date": "2019-03-21T13:13:36Z"
60 | }
61 | """.data(using: .utf8)!
62 |
63 | let code = try JSONDecoder().decode(Code.self, from: json)
64 |
65 | XCTAssertEqual(
66 | code.endDate,
67 | makeDate(year: 2019, month: 3, day: 21, hour: 13, minute: 13, second: 36))
68 | }
69 |
70 | private func makeDate(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date {
71 | return DateComponents(
72 | calendar: .current,
73 | timeZone: TimeZone(secondsFromGMT: 0),
74 | year: year, month: month, day: day,
75 | hour: hour, minute: minute, second: second).date!
76 | }
77 | }
78 | ```
79 |
80 | ## Helpers
81 | The following helpers can be used to improve readability and reduce the amount of code in tests:
82 |
83 | _Asserts.swift_
84 | ```swift
85 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
86 | guard let method = urlRequest.httpMethod else {
87 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
88 | }
89 | XCTAssertEqual(method, "GET", file: file, line: line)
90 | XCTAssertNil(urlRequest.httpBody, "GET request must not contains body", file: file, line: line)
91 | }
92 |
93 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
94 | guard let method = urlRequest.httpMethod else {
95 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
96 | }
97 | XCTAssertEqual(method, "POST", file: file, line: line)
98 | }
99 |
100 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
101 | guard let method = urlRequest.httpMethod else {
102 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
103 | }
104 | XCTAssertEqual(method, "DELETE", file: file, line: line)
105 | }
106 |
107 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
108 | guard let method = urlRequest.httpMethod else {
109 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
110 | }
111 | XCTAssertEqual(method, "PATCH", file: file, line: line)
112 | }
113 |
114 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) {
115 | guard let url = urlRequest.url else {
116 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
117 | }
118 | XCTAssertEqual(url.path, path, "Paths does not equal", file: file, line: line)
119 | }
120 |
121 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) {
122 | guard let url = urlRequest.url else {
123 | return XCTFail("The request does not contains HTTP method", file: file, line: line)
124 | }
125 | XCTAssertEqual(url.absoluteString, urlString, "URLs does not equal", file: file, line: line)
126 | }
127 | ```
128 |
129 | The example above could be written like this:
130 | ```swift
131 | func testMakeRequest() throws {
132 | let endpoint = BookListEndpoint()
133 | let urlRequest = try endpoint.makeRequest()
134 |
135 | assertGET(urlRequest)
136 | assertURL(urlRequest, "books")
137 | }
138 | ```
139 |
--------------------------------------------------------------------------------
/Documentation/tests.ru.md:
--------------------------------------------------------------------------------
1 | # Тестирование Apexy
2 |
3 | ## Что тестировать
4 | Нужно тестировать все Endpoint'ы и модельные объекты, если в них есть логика.
5 |
6 | ### Endpoint
7 | В случае Endpoint тестируется то как он создает объект URLRequest (метод `makeRequest`):
8 | * HTTP метод
9 | * URL адрес
10 | * Тело запроса
11 | * HTTP заголовки
12 |
13 | **Пример**
14 |
15 | В example проекте есть `BookListEndpoint` для получения списка книг. В примере ниже показано как его тестировать.
16 |
17 | ```swift
18 | import ExampleAPI
19 | import XCTest
20 |
21 | final class BookListEndpointTests: XCTestCase {
22 |
23 | func testMakeRequest() throws {
24 | let endpoint = BookListEndpoint()
25 |
26 | let urlRequest = try endpoint.makeRequest()
27 |
28 | XCTAssertEqual(urlRequest.httpMethod, "GET")
29 | XCTAssertNil(urlRequest.httpBody)
30 | XCTAssertEqual(urlRequest.url?.absoluteString, "books")
31 | }
32 | }
33 | ```
34 | В тесте проверяется что:
35 | * HTTP метод равен "GET"
36 | * Тело запроса отсутствует
37 | * url равен "books"
38 |
39 | ### Model
40 | Если модельный объект содержит логику то на эту логику надо написать тесты. Например, если модельный объект имеет свойства где происходит форматирование данных, то это нужно протестировать.
41 |
42 | Ещё можно протестировать декодинг модельного объекта в случае сложных преобразований, например конвертации строки в дату.
43 |
44 | ```swift
45 | /// Абстрактный код доступа у которого есть дата окончания действия
46 | struct Code: Decodable, Equatable {
47 | /// Значение кода, например "1234"
48 | let code: String
49 | /// Дата окончания действия кода
50 | let endDate: Date
51 | }
52 |
53 | final class CodeTests: XCTestCase {
54 |
55 | func testDecode() throws {
56 | let json = """
57 | {
58 | "code": "1234",
59 | "end_date": "2019-03-21T13:13:36Z"
60 | }
61 | """.data(using: .utf8)!
62 |
63 | let code = try JSONDecoder().decode(Code.self, from: json)
64 |
65 | XCTAssertEqual(
66 | code.endDate,
67 | makeDate(year: 2019, month: 3, day: 21, hour: 13, minute: 13, second: 36))
68 | }
69 |
70 | private func makeDate(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date {
71 | return DateComponents(
72 | calendar: .current,
73 | timeZone: TimeZone(secondsFromGMT: 0),
74 | year: year, month: month, day: day,
75 | hour: hour, minute: minute, second: second).date!
76 | }
77 | }
78 | ```
79 |
80 | ## Хелперы
81 | Для улучшения читаемости и уменьшения количества кода в тестах можно использовать следующие хелперы:
82 |
83 | _Asserts.swift_
84 | ```swift
85 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
86 | guard let method = urlRequest.httpMethod else {
87 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line)
88 | }
89 | XCTAssertEqual(method, "GET", file: file, line: line)
90 | XCTAssertNil(urlRequest.httpBody, "GET запрос не должен иметь тела", file: file, line: line)
91 | }
92 |
93 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
94 | guard let method = urlRequest.httpMethod else {
95 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line)
96 | }
97 | XCTAssertEqual(method, "POST", file: file, line: line)
98 | }
99 |
100 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
101 | guard let method = urlRequest.httpMethod else {
102 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line)
103 | }
104 | XCTAssertEqual(method, "DELETE", file: file, line: line)
105 | }
106 |
107 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
108 | guard let method = urlRequest.httpMethod else {
109 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line)
110 | }
111 | XCTAssertEqual(method, "PATCH", file: file, line: line)
112 | }
113 |
114 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) {
115 | guard let url = urlRequest.url else {
116 | return XCTFail("У запроса остутствует URL", file: file, line: line)
117 | }
118 | XCTAssertEqual(url.path, path, "путь запроса не совпадает", file: file, line: line)
119 | }
120 |
121 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) {
122 | guard let url = urlRequest.url else {
123 | return XCTFail("У запроса остутствует URL", file: file, line: line)
124 | }
125 | XCTAssertEqual(url.absoluteString, urlString, "URL запроса не совпадает", file: file, line: line)
126 | }
127 | ```
128 |
129 | Пример выше мог бы быть записан так:
130 | ```swift
131 | func testMakeRequest() throws {
132 | let endpoint = BookListEndpoint()
133 | let urlRequest = try endpoint.makeRequest()
134 |
135 | assertGET(urlRequest)
136 | assertURL(urlRequest, "books")
137 | }
138 | ```
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
54 |
56 |
62 |
63 |
64 |
65 |
71 |
73 |
79 |
80 |
81 |
82 |
84 |
85 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcshareddata/xcschemes/ExampleAPI.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
44 |
45 |
51 |
52 |
58 |
59 |
60 |
61 |
63 |
64 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/Example/Example.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/Example/Example/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/Example/Example/Resources/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Example/Example/Sources/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Example
4 | //
5 | // Created by Anton Glezman on 18/06/2019.
6 | // Copyright © 2019 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @UIApplicationMain
12 | class AppDelegate: UIResponder, UIApplicationDelegate {
13 |
14 | var window: UIWindow?
15 |
16 | func application(
17 | _ application: UIApplication,
18 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
19 | return true
20 | }
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/Example/Example/Sources/Business Logic/Service/BookService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BooksService.swift
3 | // Example
4 | //
5 | // Created by Anton Glezman on 18.06.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Apexy
10 | import ExampleAPI
11 |
12 | typealias Book = ExampleAPI.Book
13 |
14 | protocol BookService {
15 | func fetchBooks() async throws -> [Book]
16 | }
17 |
18 |
19 | final class BookServiceImpl: BookService {
20 |
21 | let apiClient: ConcurrencyClient
22 |
23 | init(apiClient: ConcurrencyClient) {
24 | self.apiClient = apiClient
25 | }
26 |
27 | func fetchBooks() async throws -> [Book] {
28 | let endpoint = BookListEndpoint()
29 | return try await apiClient.request(endpoint)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Example/Example/Sources/Business Logic/Service/FileService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileService.swift
3 | // DemoApp
4 | //
5 | // Created by Anton Glezman on 17/06/2019.
6 | // Copyright © 2019 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Apexy
10 | import ExampleAPI
11 |
12 | protocol FileService {
13 | func upload(file: URL) async throws
14 | func upload(stream: InputStream, size: Int) async throws
15 | }
16 |
17 |
18 | final class FileServiceImpl: FileService {
19 |
20 | let apiClient: ConcurrencyClient
21 |
22 | init(apiClient: ConcurrencyClient) {
23 | self.apiClient = apiClient
24 | }
25 |
26 | func upload(file: URL) async throws {
27 | let endpoint = FileUploadEndpoint(fileURL: file)
28 | return try await apiClient.upload(endpoint)
29 | }
30 |
31 | func upload(stream: InputStream, size: Int) async throws {
32 | let endpoint = StreamUploadEndpoint(stream: stream, size: size)
33 | return try await apiClient.upload(endpoint)
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/Example/Example/Sources/Business Logic/ServiceLayer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ServiceLayer.swift
3 | // Example
4 | //
5 | // Created by Anton Glezman on 18/06/2019.
6 | // Copyright © 2019 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Apexy
10 | import ExampleAPI
11 |
12 | final class ServiceLayer {
13 |
14 | // MARK: - Public properties
15 |
16 | static let shared = ServiceLayer()
17 |
18 | private(set) lazy var apiClient: ConcurrencyClient = AlamofireClient(
19 | baseURL: URL(string: "https://library.mock-object.redmadserver.com/api/v1/")!,
20 | configuration: .ephemeral,
21 | responseObserver: { [weak self] request, response, data, error in
22 | self?.validateSession(responseError: error)
23 | })
24 |
25 | private(set) lazy var bookService: BookService = BookServiceImpl(apiClient: apiClient)
26 |
27 | private(set) lazy var fileService: FileService = FileServiceImpl(apiClient: apiClient)
28 |
29 |
30 | // MARK: - Private methods
31 |
32 | private func validateSession(responseError: Error?) {
33 | if let error = responseError as? APIError, error.code == .tokenInvalid {
34 | // TODO: Logout
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Example/Example/Sources/Presentation/Helpers/Streamer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StreamExample.swift
3 | // Example
4 | //
5 | // Created by Anton Glezman on 17.06.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// This class contains an implementation of slow writing data to a stream.
12 | /// It is used only for example of tracking network upload progress.
13 | final class Streamer: NSObject, StreamDelegate {
14 |
15 | struct Streams {
16 | let input: InputStream
17 | let output: OutputStream
18 | }
19 |
20 | lazy var boundStreams: Streams = {
21 | var inputOrNil: InputStream? = nil
22 | var outputOrNil: OutputStream? = nil
23 | Stream.getBoundStreams(withBufferSize: chunkSize,
24 | inputStream: &inputOrNil,
25 | outputStream: &outputOrNil)
26 | guard let input = inputOrNil, let output = outputOrNil else {
27 | fatalError("On return of `getBoundStreams`, both `inputStream` and `outputStream` will contain non-nil streams.")
28 | }
29 | output.schedule(in: .current, forMode: .default)
30 | output.open()
31 | return Streams(input: input, output: output)
32 | }()
33 |
34 | let totalDataSize = 4096
35 | let chunkSize = 128
36 | let chunksCount = 32
37 | private var timer: Timer?
38 | private var counter: Int = 0
39 |
40 | func run() {
41 | counter = 0
42 | timer = Timer.scheduledTimer(
43 | withTimeInterval: 0.5,
44 | repeats: true) { [weak self] _ in
45 | self?.timerFired()
46 | }
47 | }
48 |
49 | func stop() {
50 | boundStreams.output.close()
51 | boundStreams.input.close()
52 | timer?.invalidate()
53 | }
54 |
55 | private func timerFired() {
56 | if counter == chunksCount {
57 | boundStreams.output.close()
58 | timer?.invalidate()
59 | timer = nil
60 | } else {
61 | let data = Data(count: chunkSize)
62 | _ = data.withUnsafeBytes {
63 | boundStreams.output.write($0.bindMemory(to: UInt8.self).baseAddress!, maxLength: data.count)
64 | }
65 | counter += 1
66 | }
67 | }
68 |
69 | deinit {
70 | stop()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Example/Example/Sources/Presentation/ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // Example
4 | //
5 | // Created by Anton Glezman on 18/06/2019.
6 | // Copyright © 2019 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class ViewController: UIViewController {
12 |
13 | @IBOutlet private weak var activityView: UIStackView!
14 | @IBOutlet private weak var resultLabel: UILabel!
15 |
16 | private let bookService: BookService = ServiceLayer.shared.bookService
17 | private let fileService: FileService = ServiceLayer.shared.fileService
18 |
19 | private var observation: NSKeyValueObservation?
20 | private var progress: Progress?
21 |
22 | private var task: Any?
23 | private var streamer: Streamer?
24 |
25 | override func viewDidLoad() {
26 | super.viewDidLoad()
27 | resultLabel.text = nil
28 | }
29 |
30 | @IBAction private func performRequest() {
31 | activityView.isHidden = false
32 |
33 | task = Task {
34 | do {
35 | let books = try await bookService.fetchBooks()
36 | show(books: books)
37 | } catch {
38 | show(error: error)
39 | }
40 | activityView.isHidden = true
41 | }
42 | }
43 |
44 | @IBAction private func upload() {
45 | guard let file = Bundle.main.url(forResource: "Info", withExtension: "plist") else { return }
46 | activityView.isHidden = false
47 |
48 | task = Task {
49 | do {
50 | try await fileService.upload(file: file)
51 | showOKUpload()
52 | } catch {
53 | show(error: error)
54 | }
55 | activityView.isHidden = true
56 | }
57 | }
58 |
59 | @IBAction private func uploadStream() {
60 | let streamer = Streamer()
61 | self.streamer = streamer
62 | activityView.isHidden = false
63 |
64 | streamer.run()
65 |
66 | task = Task {
67 | do {
68 | try await fileService.upload(stream: streamer.boundStreams.input, size: streamer.totalDataSize)
69 | } catch {
70 | show(error: error)
71 | self.streamer = nil
72 | }
73 | }
74 | }
75 |
76 | @IBAction private func cancel() {
77 | if #available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) {
78 | (task as? Task)?.cancel()
79 | } else {
80 | progress?.cancel()
81 | }
82 | }
83 |
84 | private func show(books: [Book]) {
85 | resultLabel.text = books.map { "• \($0.title)" }.joined(separator: "\n")
86 | }
87 |
88 | private func show(error: Error) {
89 | resultLabel.text = error.localizedDescription
90 | }
91 |
92 | private func showOKUpload() {
93 | resultLabel.text = "ok"
94 | }
95 |
96 | }
97 |
98 |
--------------------------------------------------------------------------------
/Example/Example/Supporting Files/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UILaunchStoryboardName
24 | LaunchScreen
25 | UIMainStoryboardFile
26 | Main
27 | UIRequiredDeviceCapabilities
28 |
29 | armv7
30 |
31 | UISupportedInterfaceOrientations
32 |
33 | UIInterfaceOrientationPortrait
34 | UIInterfaceOrientationLandscapeLeft
35 | UIInterfaceOrientationLandscapeRight
36 |
37 | UISupportedInterfaceOrientations~ipad
38 |
39 | UIInterfaceOrientationPortrait
40 | UIInterfaceOrientationPortraitUpsideDown
41 | UIInterfaceOrientationLandscapeLeft
42 | UIInterfaceOrientationLandscapeRight
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Common/APIError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // APIError.swift
3 | //
4 | // Created by Alexander Ignatev on 08/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Error from API.
11 | public struct APIError: Decodable, Error {
12 |
13 | public struct Code: RawRepresentable, Decodable, Equatable {
14 | public var rawValue: String
15 |
16 | public init(rawValue: String) {
17 | self.rawValue = rawValue
18 | }
19 |
20 | public init(_ rawValue: String) {
21 | self.rawValue = rawValue
22 | }
23 | }
24 |
25 | /// Error code.
26 | public let code: Code
27 |
28 | /// Error description.
29 | public let description: String?
30 |
31 | public init(
32 | code: Code,
33 | description: String? = nil) {
34 |
35 | self.code = code
36 | self.description = description
37 | }
38 | }
39 |
40 | // MARK: - General Error Code
41 |
42 | extension APIError.Code {
43 |
44 | /// Invalid Token Error.
45 | public static let tokenInvalid = APIError.Code("token_invalid")
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Common/Codable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Codable.swift
3 | //
4 | // Created by Alexander Ignatev on 18/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | extension JSONEncoder {
11 | internal static let `default`: JSONEncoder = {
12 | let encoder = JSONEncoder()
13 | encoder.keyEncodingStrategy = .convertToSnakeCase
14 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
15 | return encoder
16 | }()
17 | }
18 |
19 | extension JSONDecoder {
20 | internal static let `default`: JSONDecoder = {
21 | let decoder = JSONDecoder()
22 | decoder.keyDecodingStrategy = .convertFromSnakeCase
23 | return decoder
24 | }()
25 | }
26 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Common/HTTPError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPError.swift
3 | //
4 | // Created by Alexander Ignatev on 15/03/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | struct HTTPError: Error {
11 | let statusCode: Int
12 | let url: URL?
13 |
14 | var localizedDescription: String {
15 | return HTTPURLResponse.localizedString(forStatusCode: statusCode)
16 | }
17 | }
18 |
19 | // MARK: - CustomNSError
20 |
21 | extension HTTPError: CustomNSError {
22 | static var errorDomain = "Example.HTTPErrorDomain"
23 |
24 | public var errorCode: Int { return statusCode }
25 |
26 | public var errorUserInfo: [String: Any] {
27 | var userInfo: [String: Any] = [NSLocalizedDescriptionKey: localizedDescription]
28 | userInfo[NSURLErrorKey] = url
29 | return userInfo
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Common/ResponseValidator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResponseValidator.swift
3 | //
4 | // Created by Alexander Ignatev on 19/03/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | private struct ResponseError: Decodable {
11 | let error: APIError
12 | }
13 |
14 | /// Response validation helper.
15 | internal enum ResponseValidator {
16 |
17 | /// Error response validation.
18 | ///
19 | /// - Parameters:
20 | /// - response: The metadata associated with the response.
21 | /// - body: The response body.
22 | /// - Throws: `APIError`.
23 | internal static func validate(_ response: URLResponse?, with body: Data) throws {
24 | try validateAPIResponse(response, with: body)
25 | try validateHTTPstatus(response)
26 | }
27 |
28 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws {
29 | let decoder = JSONDecoder.default
30 | if let error = try? decoder.decode(ResponseError.self, from: body).error {
31 | throw error
32 | }
33 | }
34 |
35 | private static func validateHTTPstatus(_ response: URLResponse?) throws {
36 | guard let httpResponse = response as? HTTPURLResponse,
37 | !(200..<300).contains(httpResponse.statusCode) else { return }
38 |
39 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Endpoint/Base/EmptyEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmptyEndpoint.swift
3 | //
4 | // Created by Alexander Ignatev on 18/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 |
10 | /// Empty Body Request Endpoint.
11 | protocol EmptyEndpoint: Endpoint, URLRequestBuildable where Content == Void {}
12 |
13 | extension EmptyEndpoint {
14 |
15 | public func content(from response: URLResponse?, with body: Data) throws {
16 | try ResponseValidator.validate(response, with: body)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Endpoint/Base/JsonEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JsonEndpoint.swift
3 | //
4 | // Created by Alexander Ignatev on 08/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 |
10 | /// Base Endpoint for application remote resource.
11 | ///
12 | /// Contains shared logic for all endpoints in app.
13 | protocol JsonEndpoint: Endpoint, URLRequestBuildable where Content: Decodable {}
14 |
15 | extension JsonEndpoint {
16 |
17 | /// Request body encoder.
18 | internal var encoder: JSONEncoder { return JSONEncoder.default }
19 |
20 | public func content(from response: URLResponse?, with body: Data) throws -> Content {
21 | try ResponseValidator.validate(response, with: body)
22 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body)
23 | return resource.data
24 | }
25 | }
26 |
27 | // MARK: - Response
28 |
29 | private struct ResponseData: Decodable where Resource: Decodable {
30 | let data: Resource
31 | }
32 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Endpoint/BookListEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BookListEndpoint.swift
3 | //
4 | // Created by Anton Glezman on 17/06/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 |
10 | /// Example of GET request.
11 | public struct BookListEndpoint: JsonEndpoint {
12 |
13 | public typealias Content = [Book]
14 |
15 | public init() {}
16 |
17 | public func makeRequest() throws -> URLRequest {
18 | return get(URL(string: "books")!)
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Endpoint/FileUploadEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileUploadEndpoint.swift
3 | // ExampleAPI
4 | //
5 | // Created by Anton Glezman on 17.06.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Apexy
10 |
11 | /// Endpoint for uploading a file
12 | public struct FileUploadEndpoint: UploadEndpoint {
13 |
14 | public typealias Content = Void
15 |
16 | private let fileURL: URL
17 |
18 | public init(fileURL: URL) {
19 | self.fileURL = fileURL
20 | }
21 |
22 | public func content(from response: URLResponse?, with body: Data) throws {
23 | try ResponseValidator.validate(response, with: body)
24 | }
25 |
26 | public func makeRequest() throws -> (URLRequest, UploadEndpointBody) {
27 | var request = URLRequest(url: URL(string: "upload")!)
28 | request.httpMethod = "POST"
29 | request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
30 | return (request, .file(fileURL))
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Endpoint/StreamUploadEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StreamUploadEndpoint.swift
3 | // ExampleAPI
4 | //
5 | // Created by Anton Glezman on 18.06.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Apexy
10 |
11 | /// Endpoint for uploading a data form a stream
12 | public struct StreamUploadEndpoint: UploadEndpoint {
13 |
14 | public typealias Content = Void
15 |
16 | private let stream: InputStream
17 | private let size: Int
18 |
19 | public init(stream: InputStream, size: Int) {
20 | self.stream = stream
21 | self.size = size
22 | }
23 |
24 | public func content(from response: URLResponse?, with body: Data) throws {
25 | try ResponseValidator.validate(response, with: body)
26 | }
27 |
28 | public func makeRequest() throws -> (URLRequest, UploadEndpointBody) {
29 | var request = URLRequest(url: URL(string: "upload")!)
30 | request.httpMethod = "POST"
31 | request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
32 |
33 | // To track upload progress, it is important to set the Content-Length value.
34 | request.setValue("\(size)", forHTTPHeaderField: "Content-Length")
35 | return (request, .stream(stream))
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/ExampleAPI.h:
--------------------------------------------------------------------------------
1 | //
2 | // ExampleAPI.h
3 | // ExampleAPI
4 | //
5 | // Created by Daniil Subbotin on 28.07.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for ExampleAPI.
12 | FOUNDATION_EXPORT double ExampleAPIVersionNumber;
13 |
14 | //! Project version string for ExampleAPI.
15 | FOUNDATION_EXPORT const unsigned char ExampleAPIVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Model/Book.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Book.swift
3 | //
4 | // Created by Anton Glezman on 17/06/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Response model.
11 | public struct Book: Decodable, Identifiable {
12 |
13 | public let id: Int
14 | public let title: String
15 | public let authors: String
16 | public let isbn: String?
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/Example/ExampleAPI/Model/Form.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Form.swift
3 | // ExampleAPI
4 | //
5 | // Created by Anton Glezman on 19.06.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Form {
12 | public let fields: [FormField]
13 |
14 | public init(fields: [FormField]) {
15 | self.fields = fields
16 | }
17 | }
18 |
19 | public struct FormField {
20 | public let name: String
21 | public let data: Data
22 | public let fileName: String?
23 | public let mimeType: String?
24 |
25 | public init(data: Data, name: String) {
26 | self.data = data
27 | self.name = name
28 | self.fileName = nil
29 | self.mimeType = nil
30 | }
31 |
32 | public init(data: Data, name: String, mimeType: String) {
33 | self.data = data
34 | self.name = name
35 | self.fileName = nil
36 | self.mimeType = mimeType
37 | }
38 |
39 | public init(data: Data, name: String, fileName: String, mimeType: String) {
40 | self.data = data
41 | self.name = name
42 | self.fileName = fileName
43 | self.mimeType = mimeType
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Example/ExampleAPITests/Common/Asserts.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Asserts.swift
3 | // ExampleAPITests
4 | //
5 | // Created by Daniil Subbotin on 28.07.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
12 | guard let method = urlRequest.httpMethod else {
13 | return XCTFail("The request doesn't have HTTP method", file: file, line: line)
14 | }
15 | XCTAssertEqual(method, "GET", file: file, line: line)
16 | XCTAssertNil(urlRequest.httpBody, "GET request must not have body", file: file, line: line)
17 | }
18 |
19 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
20 | guard let method = urlRequest.httpMethod else {
21 | return XCTFail("The request doesn't have HTTP method", file: file, line: line)
22 | }
23 | XCTAssertEqual(method, "POST", file: file, line: line)
24 | }
25 |
26 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
27 | guard let method = urlRequest.httpMethod else {
28 | return XCTFail("The request doesn't have HTTP method", file: file, line: line)
29 | }
30 | XCTAssertEqual(method, "DELETE", file: file, line: line)
31 | }
32 |
33 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) {
34 | guard let method = urlRequest.httpMethod else {
35 | return XCTFail("The request doesn't have HTTP method", file: file, line: line)
36 | }
37 | XCTAssertEqual(method, "PATCH", file: file, line: line)
38 | }
39 |
40 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) {
41 | guard let url = urlRequest.url else {
42 | return XCTFail("The request doesn't have URL", file: file, line: line)
43 | }
44 | XCTAssertEqual(url.path, path, "Request's path doesn't match", file: file, line: line)
45 | }
46 |
47 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) {
48 | guard let url = urlRequest.url else {
49 | return XCTFail("The request doesn't have URL", file: file, line: line)
50 | }
51 | XCTAssertEqual(url.absoluteString, urlString, "Request's URL doesn't match", file: file, line: line)
52 | }
53 |
54 | func assertHTTPHeaders(_ urlRequest: URLRequest, _ headers: [String: String], file: StaticString = #file) {
55 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, headers)
56 | }
57 |
58 | func assertJsonBody(_ urlRequest: URLRequest, _ json: [String: Any], file: StaticString = #file, line: UInt = #line) {
59 | guard let body = urlRequest.httpBody else {
60 | return XCTFail("The request doesn't have body", file: file, line: line)
61 | }
62 |
63 | if let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type") {
64 | XCTAssertTrue(contentType.contains("application/json"), "Content-Type запрос не json")
65 | } else {
66 | XCTFail("The request doesn't have HTTP Header Content-Type", file: file, line: line)
67 | }
68 |
69 | do {
70 | let json1 = try JSONSerialization.jsonObject(with: body)
71 | guard let dict = json1 as? NSDictionary else {
72 | return XCTFail("The body of the request isn't a JSON dictionary", file: file, line: line)
73 | }
74 | XCTAssertEqual(dict, json as NSDictionary, file: file, line: line)
75 | } catch {
76 | XCTFail("The body of the request isn't a JSON \(error)", file: file, line: line)
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Example/ExampleAPITests/Endpoint/BookListEndpointTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BookListEndpointTests.swift
3 | // ExampleAPITests
4 | //
5 | // Created by Daniil Subbotin on 28.07.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import ExampleAPI
10 | import XCTest
11 |
12 | final class BookListEndpointTests: XCTestCase {
13 |
14 | func testMakeRequest() throws {
15 | let endpoint = BookListEndpoint()
16 | let urlRequest = try endpoint.makeRequest()
17 |
18 | assertGET(urlRequest)
19 | assertURL(urlRequest, "books")
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/Example/ExampleAPITests/Endpoint/FileUploadEndpointTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileUploadEndpointTests.swift
3 | // ExampleAPITests
4 | //
5 | // Created by Daniil Subbotin on 28.07.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import ExampleAPI
10 | import XCTest
11 |
12 | final class FileUploadEndpointTests: XCTestCase {
13 |
14 | func testMakeRequest() throws {
15 | let fileURL = URL(string: "path/to/file")!
16 | let endpoint = FileUploadEndpoint(fileURL: fileURL)
17 |
18 | let (request, body) = try endpoint.makeRequest()
19 |
20 | assertPOST(request)
21 | assertURL(request, "upload")
22 | assertHTTPHeaders(request, [
23 | "Content-Type": "application/octet-stream"
24 | ])
25 |
26 | switch body {
27 | case .file(let url):
28 | XCTAssertEqual(url, fileURL)
29 | default:
30 | XCTFail("urlRequest's UploadEndpointBody must be .file")
31 | }
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/Example/ExampleAPITests/Endpoint/StreamUploadEndpointTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StreamUploadEndpointTests.swift
3 | // ExampleAPITests
4 | //
5 | // Created by Daniil Subbotin on 28.07.2020.
6 | // Copyright © 2020 RedMadRobot. All rights reserved.
7 | //
8 |
9 | import ExampleAPI
10 | import XCTest
11 |
12 | final class StreamUploadEndpointTests: XCTestCase {
13 |
14 | func testMakeRequest() throws {
15 | let fileStream = InputStream(data: Data())
16 | let fileSize = 1024
17 | let endpoint = StreamUploadEndpoint(stream: fileStream, size: fileSize)
18 |
19 | let (request, body) = try endpoint.makeRequest()
20 |
21 | assertPOST(request)
22 | assertURL(request, "upload")
23 | assertHTTPHeaders(request, [
24 | "Content-Type": "application/octet-stream",
25 | "Content-Length": String(fileSize)
26 | ])
27 |
28 | switch body {
29 | case .stream(let stream):
30 | XCTAssertEqual(stream, fileStream)
31 | default:
32 | XCTFail("urlRequest's UploadEndpointBody must be .stream")
33 | }
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/Example/ExampleAPITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Example/Podfile:
--------------------------------------------------------------------------------
1 | platform :ios, '11.0'
2 |
3 | target 'Example' do
4 | use_frameworks!
5 | end
6 |
7 | target 'ExampleAPI' do
8 | use_frameworks!
9 |
10 | pod 'Apexy', :path => "../"
11 |
12 | target 'ExampleAPITests' do
13 | inherit! :search_paths
14 | end
15 | end
--------------------------------------------------------------------------------
/Example/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - Alamofire (5.8.1)
3 | - Apexy (1.7.4):
4 | - Apexy/Alamofire (= 1.7.4)
5 | - Apexy/Alamofire (1.7.4):
6 | - Alamofire (~> 5.6)
7 | - Apexy/Core
8 | - Apexy/Core (1.7.4)
9 |
10 | DEPENDENCIES:
11 | - Apexy (from `../`)
12 |
13 | SPEC REPOS:
14 | trunk:
15 | - Alamofire
16 |
17 | EXTERNAL SOURCES:
18 | Apexy:
19 | :path: "../"
20 |
21 | SPEC CHECKSUMS:
22 | Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
23 | Apexy: a3218097135e746fd7c9215da167521f9275df23
24 |
25 | PODFILE CHECKSUM: f86a90e7590ccb3aa7caeceaf315abe256650c66
26 |
27 | COCOAPODS: 1.12.1
28 |
--------------------------------------------------------------------------------
/Images/apexy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Images/apexy.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Redmadrobot
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "Alamofire",
6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "8dd85aee02e39dd280c75eef88ffdb86eed4b07b",
10 | "version": "5.6.2"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.2
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Apexy",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13),
11 | .tvOS(.v13),
12 | .watchOS(.v6)
13 | ],
14 | products: [
15 | .library(name: "Apexy", targets: ["ApexyURLSession"]),
16 | .library(name: "ApexyAlamofire", targets: ["ApexyAlamofire"]),
17 | .library(name: "ApexyLoader", targets: ["ApexyLoader"])
18 | ],
19 | dependencies: [
20 | .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.2.0"))
21 | ],
22 | targets: [
23 | .target(name: "ApexyLoader", dependencies: ["Apexy"]),
24 | .target(name: "ApexyAlamofire", dependencies: ["Apexy", "Alamofire"]),
25 | .target(name: "ApexyURLSession", dependencies: ["Apexy"]),
26 | .target(name: "Apexy"),
27 |
28 | .testTarget(name: "ApexyLoaderTests", dependencies: ["ApexyLoader"]),
29 | .testTarget(name: "ApexyAlamofireTests", dependencies: ["ApexyAlamofire"]),
30 | .testTarget(name: "ApexyURLSessionTests", dependencies: ["ApexyURLSession"]),
31 | .testTarget(name: "ApexyTests", dependencies: ["Apexy"])
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/Sources/Apexy/APIResult.swift:
--------------------------------------------------------------------------------
1 | //
2 | // APIResult.swift
3 | //
4 | //
5 | // Created by Aleksei Tiurnin on 17.08.2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public typealias APIResult = Swift.Result
11 |
12 | public extension APIResult {
13 | var error: Error? {
14 | switch self {
15 | case .failure(let error):
16 | return error
17 | default:
18 | return nil
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Apexy/Client.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol Client: AnyObject {
4 |
5 | /// Send request to specified endpoint.
6 | ///
7 | /// - Parameters:
8 | /// - endpoint: endpoint of remote content.
9 | /// - completionHandler: The completion closure to be executed when request is completed.
10 | /// - Returns: The progress of fetching the response data from the server for the request.
11 | func request(
12 | _ endpoint: T,
13 | completionHandler: @escaping (APIResult) -> Void
14 | ) -> Progress where T: Endpoint
15 |
16 | /// Upload data to specified endpoint.
17 | ///
18 | /// - Parameters:
19 | /// - endpoint: The remote endpoint and data to upload.
20 | /// - completionHandler: The completion closure to be executed when request is completed.
21 | /// - Returns: The progress of uploading data to the server.
22 | func upload(
23 | _ endpoint: T,
24 | completionHandler: @escaping (APIResult) -> Void
25 | ) -> Progress where T: UploadEndpoint
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/Apexy/Clients/CombineClient.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Combine)
2 | import Combine
3 |
4 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
5 | public protocol CombineClient: AnyObject {
6 |
7 | /// Send request to specified endpoint.
8 | /// - Parameters:
9 | /// - endpoint: endpoint of remote content.
10 | /// - Returns: Publisher which you can subscribe to
11 | func request(_ endpoint: T) -> AnyPublisher where T: Endpoint
12 | }
13 |
14 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
15 | public extension Client where Self: CombineClient {
16 | func request(_ endpoint: T) -> AnyPublisher where T: Endpoint {
17 | Deferred> {
18 | let subject = PassthroughSubject()
19 |
20 | let progress = self.request(endpoint) { (result: Result) in
21 | switch result {
22 | case .success(let content):
23 | subject.send(content)
24 | subject.send(completion: .finished)
25 | case .failure(let error):
26 | subject.send(completion: .failure(error))
27 | }
28 | }
29 |
30 | return subject.handleEvents(receiveCancel: {
31 | progress.cancel()
32 | subject.send(completion: .finished)
33 | }).eraseToAnyPublisher()
34 | }
35 | .eraseToAnyPublisher()
36 | }
37 | }
38 |
39 | #endif
40 |
--------------------------------------------------------------------------------
/Sources/Apexy/Clients/ConcurrencyClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConcurrencyClient.swift
3 | //
4 | //
5 | // Created by Aleksei Tiurnin on 16.08.2022.
6 | //
7 |
8 | import Foundation
9 |
10 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
11 | public protocol ConcurrencyClient: AnyObject {
12 | /// Send request to specified endpoint.
13 | /// - Parameters:
14 | /// - endpoint: endpoint of remote content.
15 | /// - Returns: response data from the server for the request.
16 | func request(_ endpoint: T) async throws -> T.Content where T: Endpoint
17 |
18 | /// Upload data to specified endpoint.
19 | /// - Parameters:
20 | /// - endpoint: endpoint of remote content.
21 | /// - Returns: response data from the server for the upload.
22 | func upload(_ endpoint: T) async throws -> T.Content where T: UploadEndpoint
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/Apexy/Endpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Endpoint.swift
3 | //
4 | // Created by Alexander Ignatev on 08/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | /// The endpoint to work with a remote content.
11 | public protocol Endpoint {
12 |
13 | /// Resource type.
14 | ///
15 | /// - Author: Nino
16 | associatedtype Content
17 |
18 | /// Create a new `URLRequest`.
19 | ///
20 | /// - Returns: Resource request.
21 | /// - Throws: Any error creating request.
22 | func makeRequest() throws -> URLRequest
23 |
24 | /// Obtain new content from response with body.
25 | ///
26 | /// - Parameters:
27 | /// - response: The metadata associated with the response.
28 | /// - body: The response body.
29 | /// - Returns: A new endpoint content.
30 | /// - Throws: Any error creating content.
31 | func content(from response: URLResponse?, with body: Data) throws -> Content
32 |
33 | /// Validate response.
34 | ///
35 | /// - Parameters:
36 | /// - request: The metadata associated with the request.
37 | /// - response: The metadata associated with the response.
38 | /// - data: The response body data.
39 | /// - Throws: Any response validation error.
40 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws
41 | }
42 |
43 | public extension Endpoint {
44 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws { }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/Apexy/HTTPBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPBody.swift
3 | //
4 | // Created by z.samarskaya on 30/06/2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | /// The HTTP body for request
11 | public struct HTTPBody {
12 | public let data: Data
13 | public let contentType: String
14 |
15 | public init(data: Data, contentType: String) {
16 | self.data = data
17 | self.contentType = contentType
18 | }
19 | }
20 |
21 | public extension HTTPBody {
22 | /// Create HTTP body with json content type.
23 | ///
24 | /// - Parameters:
25 | /// - data: HTTP body data.
26 | /// - Returns: HTTPBody.
27 | static func json(_ data: Data) -> HTTPBody {
28 | return HTTPBody(data: data, contentType: "application/json")
29 | }
30 |
31 | /// Create HTTP body with form-urlencoded content type.
32 | ///
33 | /// - Parameters:
34 | /// - data: HTTP body data.
35 | /// - Returns: HTTPBody.
36 | static func form(_ data: Data) -> HTTPBody {
37 | return HTTPBody(data: data, contentType: "application/x-www-form-urlencoded")
38 | }
39 |
40 | /// Create HTTP body with text/plain content type.
41 | ///
42 | /// - Parameters:
43 | /// - data: HTTP body data.
44 | /// - Returns: HTTPBody.
45 | static func text(_ data: Data) -> HTTPBody {
46 | return HTTPBody(data: data, contentType: "text/plain")
47 | }
48 |
49 | /// Create HTTP body with text/plain content type.
50 | ///
51 | /// - Parameters:
52 | /// - data: HTTP body data.
53 | /// - Returns: HTTPBody.
54 | static func string(_ string: String) -> HTTPBody {
55 | return HTTPBody(data: Data(string.utf8), contentType: "text/plain")
56 | }
57 |
58 | /// Create HTTP body with octet-stream content type.
59 | ///
60 | /// - Parameters:
61 | /// - data: HTTP body data.
62 | /// - Returns: HTTPBody.
63 | static func binary(_ data: Data) -> HTTPBody {
64 | return HTTPBody(data: data, contentType: "application/octet-stream")
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Sources/Apexy/ResponseObserver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResponseObserver.swift
3 | //
4 | //
5 | // Created by Aleksei Tiurnin on 31.08.2022.
6 | //
7 |
8 | import Foundation
9 |
10 | public typealias ResponseObserver = (URLRequest?, HTTPURLResponse?, Data?, Error?) -> Void
11 |
--------------------------------------------------------------------------------
/Sources/Apexy/URLRequestBuildable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLRequestBuildable.swift
3 | //
4 | // Created by z.samarskaya on 30/06/2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol URLRequestBuildable {
11 | func get(_ url: URL, queryItems: [URLQueryItem]?) -> URLRequest
12 | func post(_ url: URL, body: HTTPBody?) -> URLRequest
13 | func patch(_ url: URL, body: HTTPBody) -> URLRequest
14 | func put(_ url: URL, body: HTTPBody) -> URLRequest
15 | func delete(_ url: URL) -> URLRequest
16 | }
17 |
18 | public extension URLRequestBuildable {
19 |
20 | /// Create HTTP GET request.
21 | ///
22 | /// - Parameters:
23 | /// - url: Request URL.
24 | /// - queryItems: Request parameters.
25 | /// - Returns: HTTP GET Request.
26 | func get(_ url: URL, queryItems: [URLQueryItem]? = nil) -> URLRequest {
27 | guard let queryItems = queryItems, !queryItems.isEmpty else {
28 | return URLRequest(url: url)
29 | }
30 |
31 | var components = URLComponents(url: url, resolvingAgainstBaseURL: true)
32 | components?.queryItems = queryItems
33 |
34 | guard let queryURL = components?.url else {
35 | return URLRequest(url: url)
36 | }
37 |
38 | return URLRequest(url: queryURL)
39 | }
40 |
41 | /// Create HTTP POST request.
42 | ///
43 | /// - Parameters:
44 | /// - url: Request URL.
45 | /// - body: HTTP body.
46 | /// - Returns: HTTP POST request.
47 | func post(_ url: URL, body: HTTPBody?) -> URLRequest {
48 | var request = URLRequest(url: url)
49 | request.httpMethod = "POST"
50 |
51 | if let body = body {
52 | request.setValue(body.contentType, forHTTPHeaderField: "Content-Type")
53 | request.httpBody = body.data
54 | }
55 | return request
56 | }
57 |
58 | /// Create HTTP PATCH request.
59 | ///
60 | /// - Parameters:
61 | /// - url: Request URL.
62 | /// - body: HTTP body.
63 | /// - Returns: HTTP PATCH request.
64 | func patch(_ url: URL, body: HTTPBody) -> URLRequest {
65 | var request = post(url, body: body)
66 | request.httpMethod = "PATCH"
67 | return request
68 | }
69 |
70 | /// Create HTTP PUT request.
71 | ///
72 | /// - Parameters:
73 | /// - url: Request URL.
74 | /// - body: HTTP body.
75 | /// - Returns: HTTP PUT request.
76 | func put(_ url: URL, body: HTTPBody) -> URLRequest {
77 | var request = post(url, body: body)
78 | request.httpMethod = "PUT"
79 | return request
80 | }
81 |
82 | /// Create HTTP DELETE request.
83 | ///
84 | /// - Parameters:
85 | /// - url: Request URL.
86 | /// - Returns: HTTP DELETE request.
87 | func delete(_ url: URL) -> URLRequest {
88 | var request = URLRequest(url: url)
89 | request.httpMethod = "DELETE"
90 | return request
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/Apexy/UploadEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UploadEndpoint.swift
3 | //
4 | // Created by Anton Glezman on 17.06.2020.
5 | //
6 |
7 | import Foundation
8 |
9 | /// Type of uploadable content
10 | public enum UploadEndpointBody {
11 | case data(Data)
12 | case file(URL)
13 | case stream(InputStream)
14 | }
15 |
16 | /// The endpoint for upload data to the remote server.
17 | public protocol UploadEndpoint {
18 |
19 | /// Response type.
20 | associatedtype Content
21 |
22 | /// Create a new `URLRequest` and uploadable payload.
23 | ///
24 | /// - Returns: Resource request and uploadable data
25 | /// - Throws: Any error creating request.
26 | func makeRequest() throws -> (URLRequest, UploadEndpointBody)
27 |
28 | /// Obtain new content from response with body.
29 | ///
30 | /// - Parameters:
31 | /// - response: The metadata associated with the response.
32 | /// - body: The response body.
33 | /// - Returns: A new endpoint content.
34 | /// - Throws: Any error creating content.
35 | func content(from response: URLResponse?, with body: Data) throws -> Content
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlamofireClient+Concurrency.swift
3 | //
4 | //
5 | // Created by Aleksei Tiurnin on 15.08.2022.
6 | //
7 |
8 | import Alamofire
9 | import Apexy
10 | import Foundation
11 |
12 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
13 | extension AlamofireClient: ConcurrencyClient {
14 |
15 | func observeResponse(
16 | dataResponse: DataResponse,
17 | error: Error?) {
18 | self.responseObserver?(
19 | dataResponse.request,
20 | dataResponse.response,
21 | dataResponse.data,
22 | error)
23 | }
24 |
25 | open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint {
26 |
27 | let anyRequest = AnyRequest(create: endpoint.makeRequest)
28 | let request = sessionManager.request(anyRequest)
29 | .validate { request, response, data in
30 | Result(catching: { try endpoint.validate(request, response: response, data: data) })
31 | }
32 |
33 | let dataResponse = await request.serializingData().response
34 | let result = APIResult(catching: { () throws -> T.Content in
35 | do {
36 | let data = try dataResponse.result.get()
37 | return try endpoint.content(from: dataResponse.response, with: data)
38 | } catch {
39 | throw error.unwrapAlamofireValidationError()
40 | }
41 | })
42 |
43 | Task.detached { [weak self, dataResponse, result] in
44 | self?.observeResponse(dataResponse: dataResponse, error: result.error)
45 | }
46 |
47 | return try result.get()
48 | }
49 |
50 | open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint {
51 |
52 | let urlRequest: URLRequest
53 | let body: UploadEndpointBody
54 | (urlRequest, body) = try endpoint.makeRequest()
55 |
56 | let request: UploadRequest
57 | switch body {
58 | case .data(let data):
59 | request = sessionManager.upload(data, with: urlRequest)
60 | case .file(let url):
61 | request = sessionManager.upload(url, with: urlRequest)
62 | case .stream(let stream):
63 | request = sessionManager.upload(stream, with: urlRequest)
64 | }
65 |
66 | let dataResponse = await request.serializingData().response
67 | let result = APIResult(catching: { () throws -> T.Content in
68 | do {
69 | let data = try dataResponse.result.get()
70 | return try endpoint.content(from: dataResponse.response, with: data)
71 | } catch {
72 | throw error.unwrapAlamofireValidationError()
73 | }
74 | })
75 |
76 | Task.detached { [weak self, dataResponse, result] in
77 | self?.observeResponse(dataResponse: dataResponse, error: result.error)
78 | }
79 |
80 | return try result.get()
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Sources/ApexyAlamofire/BaseRequestInterceptor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseRequestInterceptor.swift
3 | //
4 | // Created by Alexander Ignatev on 12/02/2019.
5 | // Copyright © 2019 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Alamofire
9 | import Foundation
10 |
11 | /// Implementation of Alamofire.RequestInterceptor.
12 | open class BaseRequestInterceptor: Alamofire.RequestInterceptor {
13 |
14 | /// Contains Base `URL`.
15 | ///
16 | /// Must end with a slash character `https://example.com/api/v1/`
17 | ///
18 | /// - Warning: declared as open variable for debug purposes only.
19 | open var baseURL: URL
20 |
21 | /// Creates a `BaseRequestInterceptor` instance with specified Base `URL`.
22 | ///
23 | /// - Parameter baseURL: Base `URL` for adapter.
24 | public init(baseURL: URL) {
25 | self.baseURL = baseURL
26 | }
27 |
28 | // MARK: - Alamofire.RequestInterceptor
29 |
30 | open func adapt(
31 | _ urlRequest: URLRequest,
32 | for session: Session,
33 | completion: @escaping (Result) -> Void) {
34 |
35 | guard let url = urlRequest.url else {
36 | completion(.failure(URLError(.badURL)))
37 | return
38 | }
39 |
40 | var request = urlRequest
41 | request.url = appendingBaseURL(to: url)
42 |
43 | completion(.success(request))
44 | }
45 |
46 | open func retry(
47 | _ request: Request,
48 | for session: Session,
49 | dueTo error: Error,
50 | completion: @escaping (RetryResult) -> Void) {
51 |
52 | return completion(.doNotRetry)
53 | }
54 |
55 | // MARK: - Private
56 |
57 | private func appendingBaseURL(to url: URL) -> URL {
58 | URL(string: url.absoluteString, relativeTo: baseURL)!
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/ApexyLoader/ContentLoader.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Combine)
2 | import Combine
3 | #endif
4 | import Foundation
5 |
6 | private final class StateChangeHandler {
7 | let notify: () -> Void
8 |
9 | init(_ notify: @escaping () -> Void) {
10 | self.notify = notify
11 | }
12 | }
13 |
14 | public protocol ContentLoading: ObservableLoader {
15 | /// Starts loading data.
16 | func load()
17 | }
18 |
19 | /// A object that stores loaded content, loading state and allow to observing loading state.
20 | open class ContentLoader: ObservableLoader {
21 |
22 | /// An array of the loader state change handlers
23 | private var stateHandlers: [StateChangeHandler] = []
24 |
25 | /// An array of the external loader observers.
26 | final public var observations: [LoaderObservation] = []
27 |
28 | /// Content loading status. The default value is `.initial`.
29 | ///
30 | /// - Remark: To change state use `update(_:)`.
31 | public var state: LoadingState = .initial {
32 | didSet {
33 | stateHandlers.forEach { $0.notify() }
34 |
35 | if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
36 | stateSubject.send(state)
37 | }
38 | }
39 | }
40 |
41 | // Can not use `@available` with lazy properties in Xcode 14. This is a workaround.
42 | // https://stackoverflow.com/a/55534141/7453375
43 | private var storedStateSubject: Any?
44 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
45 | private var stateSubject: CurrentValueSubject, Never> {
46 | if let subject = storedStateSubject as? CurrentValueSubject, Never> {
47 | return subject
48 | }
49 | let subject = CurrentValueSubject, Never>(.initial)
50 | storedStateSubject = subject
51 | return subject
52 | }
53 |
54 | /// Content loading status. The default value is `.initial`.
55 | ///
56 | /// - Remark: To change state use `update(_:)`.
57 | private var storedStatePublisher: Any?
58 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *)
59 | public var statePublisher: AnyPublisher, Never> {
60 | if let publisher = storedStatePublisher as? AnyPublisher, Never> {
61 | return publisher
62 | }
63 | let publisher = stateSubject.eraseToAnyPublisher()
64 | storedStatePublisher = publisher
65 | return publisher
66 | }
67 |
68 | public init() {}
69 |
70 | // MARK: - ObservableLoader
71 |
72 | /// Starts state observing.
73 | ///
74 | /// - Parameter changeHandler: A closure to execute when the loader state changes.
75 | /// - Returns: An instance of the `LoaderObservation`.
76 | final public func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation {
77 | let handler = StateChangeHandler(changeHandler)
78 | stateHandlers.append(handler)
79 | return LoaderObservation { [weak self] in
80 | if let index = self?.stateHandlers.firstIndex(where: { $0 === handler }) {
81 | self?.stateHandlers.remove(at: index)
82 | }
83 | }
84 | }
85 |
86 | // MARK: - Loading
87 |
88 | /// Updates the loader state to `.loading`.
89 | ///
90 | /// Call this method before loading data to update the loader state.
91 | /// - Returns: A boolean value indicating the possibility to start loading data. The method return `false` if the current state is `loading`.
92 | @discardableResult
93 | final public func startLoading() -> Bool {
94 | if state.isLoading {
95 | return false
96 | }
97 | state = .loading(cache: state.content)
98 | return true
99 | }
100 |
101 | /// Updates the loader state using result.
102 | ///
103 | /// Call this method at the end of data loading to update the loader state.
104 | /// - Parameter result: Data loading result.
105 | final public func finishLoading(_ result: Result) {
106 | switch result {
107 | case .success(let content):
108 | state = .success(content: content)
109 | case .failure(let error):
110 | state = .failure(error: error, cache: state.content)
111 | }
112 | }
113 | }
114 |
115 | // MARK: - Content + Equatable
116 |
117 | public extension ContentLoader where Content: Equatable {
118 |
119 | /// Updates state of the loader.
120 | /// - Parameter state: New state.
121 | func update(_ state: LoadingState) {
122 | if self.state != state {
123 | self.state = state
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/Sources/ApexyLoader/LoaderObservation.swift:
--------------------------------------------------------------------------------
1 | /// Cancels observation for changes to `ContentLoader` on deinitialization.
2 | ///
3 | /// - Remark: Works like `NSKeyValueObservation`, `AnyCancellable` and `DisposeBag`.
4 | public final class LoaderObservation {
5 | typealias Cancel = () -> Void
6 |
7 | private let cancel: Cancel
8 |
9 | init(_ cancel: @escaping Cancel) {
10 | self.cancel = cancel
11 | }
12 |
13 | deinit {
14 | cancel()
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/ApexyLoader/LoadingState.swift:
--------------------------------------------------------------------------------
1 | /// Represents content loading state.
2 | public enum LoadingState {
3 |
4 | /// Initial empty state.
5 | case initial
6 |
7 | /// Content is loading.
8 | ///
9 | /// - `cache`: Cached content that was previously loaded.
10 | case loading(cache: Content?)
11 |
12 | /// Content successfull loaded.
13 | ///
14 | /// - `content`: Actual loaded content.
15 | case success(content: Content)
16 |
17 | /// Content failed to load.
18 | ///
19 | /// - `error`: An error that occurs while loading content.
20 | /// - `cache`: Cached content that was previously loaded.
21 | case failure(error: Error, cache: Content?)
22 | }
23 |
24 | // MARK: - Properties
25 |
26 | extension LoadingState {
27 |
28 | public var content: Content? {
29 | switch self {
30 | case .loading(let content?),
31 | .success(let content),
32 | .failure(_, let content?):
33 | return content
34 | default:
35 | return nil
36 | }
37 | }
38 |
39 | public var isLoading: Bool {
40 | switch self {
41 | case .loading:
42 | return true
43 | default:
44 | return false
45 | }
46 | }
47 |
48 | public var error: Error? {
49 | switch self {
50 | case .failure(let error, _):
51 | return error
52 | default:
53 | return nil
54 | }
55 | }
56 | }
57 |
58 | // MARK: - Methods
59 |
60 | public extension LoadingState {
61 |
62 | /// Merges two states.
63 | func merge(_ state: LoadingState, transform: (Content, C2) -> C3) -> LoadingState {
64 |
65 | switch (self, state) {
66 | case (.loading(let cache1?), _):
67 | let cache3 = state.content.map { transform(cache1, $0) }
68 | return LoadingState.loading(cache: cache3)
69 | case (_, .loading(let cache2?)):
70 | let cache3 = content.map { transform($0, cache2) }
71 | return LoadingState.loading(cache: cache3)
72 | case (.loading, _),
73 | (_, .loading):
74 | return LoadingState.loading(cache: nil)
75 | case (.failure(let error, let cache1?), _):
76 | let cache3 = state.content.map { transform(cache1, $0) }
77 | return LoadingState.failure(error: error, cache: cache3)
78 | case (_, .failure(let error, let cache2?)):
79 | let cache3 = content.map { transform($0, cache2) }
80 | return LoadingState.failure(error: error, cache: cache3)
81 | case (.failure(let error, _), _),
82 | (_, .failure(let error, _)):
83 | return LoadingState.failure(error: error, cache: nil)
84 | case (.success(let lhs), .success(let rhs)):
85 | return LoadingState.success(content: transform(lhs, rhs))
86 | case (.initial, .initial),
87 | (.initial, .success),
88 | (.success, .initial):
89 | return LoadingState.initial
90 | }
91 | }
92 | }
93 |
94 | // MARK: - Equatable
95 |
96 | extension LoadingState: Equatable where Content: Equatable {
97 | static public func == (lhs: LoadingState, rhs: LoadingState) -> Bool {
98 | switch (lhs, rhs) {
99 | case (.initial, .initial):
100 | return true
101 | case (.failure(_, let cache1), .failure(_, let cache2)),
102 | (.loading(let cache1), .loading(let cache2)):
103 | return cache1 == cache2
104 | case (.success(let content1), .success(let content2)):
105 | return content1 == content2
106 | default:
107 | return false
108 | }
109 | }
110 | }
111 |
112 | // MARK: - CustomStringConvertible
113 |
114 | extension LoadingState: CustomStringConvertible {
115 | public var description: String {
116 | switch self {
117 | case .initial:
118 | return "Initial"
119 | case .loading(let cache):
120 | return "Loading: cache \(String(describing: cache))"
121 | case .success(let content):
122 | return "Success: \(content)"
123 | case .failure(let error, let cache):
124 | return "Failure: \(error), cache \(String(describing: cache))"
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/Sources/ApexyLoader/ObservableLoader.swift:
--------------------------------------------------------------------------------
1 | // Loader, which can be observed.
2 | public protocol ObservableLoader: AnyObject {
3 |
4 | /// Starts observing the loader state change.
5 | ///
6 | /// - Parameter changeHandler: State change handler.
7 | /// - Returns: An instance of `LoaderObservation` to cancel observation.
8 | func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/ApexyLoader/WebLoader.swift:
--------------------------------------------------------------------------------
1 | import Apexy
2 | import Foundation
3 |
4 | /// Loads content by network.
5 | open class WebLoader: ContentLoader {
6 | private let apiClient: Client
7 | public private(set) var progress: Progress?
8 |
9 | /// Creates an instance of `WebLoader` to load content by network using specified `Client`.
10 | /// - Parameter apiClient: An instance of the `Client` protocol. Use `AlamofireClient` or `URLSessionClient`.
11 | public init(apiClient: Client) {
12 | self.apiClient = apiClient
13 | }
14 |
15 | deinit {
16 | progress?.cancel()
17 | }
18 |
19 | /// Sends requests to the network.
20 | ///
21 | /// - Warning: You must call `startLoading` before calling this method!
22 | /// - Parameter endpoint: An object representing request.
23 | public func request(_ endpoint: T) where T: Endpoint, T.Content == Content {
24 | progress = apiClient.request(endpoint) { [weak self] result in
25 | self?.progress = nil
26 | self?.finishLoading(result)
27 | }
28 | }
29 |
30 | /// Sends requests to the network and transform successfull result
31 | ///
32 | /// - Parameters:
33 | /// - endpoint: An object representing request.
34 | /// - transform: A closure that transforms successfull result.
35 | public func request(_ endpoint: T, transform: @escaping (T.Content) -> Content) where T: Endpoint {
36 | progress = apiClient.request(endpoint) { [weak self] result in
37 | self?.progress = nil
38 | self?.finishLoading(result.map(transform))
39 | }
40 | }
41 |
42 | /// Sends requests to the network and calls completion handler.
43 | /// - Parameters:
44 | /// - endpoint: An object representing request.
45 | /// - completion: A completion handler.
46 | public func request(_ endpoint: T, completion: @escaping (Result) -> Void) where T: Endpoint {
47 | progress = apiClient.request(endpoint) { [weak self] result in
48 | self?.progress = nil
49 | completion(result)
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/ApexyURLSession/BaseRequestAdapter.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A type that can inspect and optionally adapt a `URLRequest` in some manner if necessary.
4 | public protocol RequestAdapter {
5 | func adapt(_ urlRequest: URLRequest) throws -> URLRequest
6 | }
7 |
8 | /// A type that adapt a `URLRequest` by appending base URL.
9 | open class BaseRequestAdapter: RequestAdapter {
10 |
11 | /// Contains Base `URL`.
12 | ///
13 | /// Must end with a slash character `https://example.com/api/v1/`
14 | ///
15 | /// - Warning: declared as open variable for debug purposes only.
16 | open var baseURL: URL
17 |
18 | /// Creates a `BaseRequestAdapter` instance with specified Base `URL`.
19 | ///
20 | /// - Parameter baseURL: Base `URL` for adapter.
21 | public init(baseURL: URL) {
22 | self.baseURL = baseURL
23 | }
24 |
25 | // MARK: - RequestAdapter
26 |
27 | open func adapt(_ urlRequest: URLRequest) throws -> URLRequest {
28 | guard let url = urlRequest.url else {
29 | throw URLError(.badURL)
30 | }
31 |
32 | var request = urlRequest
33 | request.url = appendingBaseURL(to: url)
34 | return request
35 | }
36 |
37 | // MARK: - Private
38 |
39 | private func appendingBaseURL(to url: URL) -> URL {
40 | URL(string: url.absoluteString, relativeTo: baseURL)!
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/ApexyURLSession/URLSessionClient+Concurrency.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLSessionClient+Concurrency.swift
3 | //
4 | //
5 | // Created by Aleksei Tiurnin on 15.08.2022.
6 | //
7 |
8 | import Apexy
9 | import Foundation
10 |
11 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
12 | extension URLSessionClient: ConcurrencyClient {
13 |
14 | func observeResponse(
15 | request: URLRequest?,
16 | responseResult: Result<(data: Data, response: URLResponse), Error>) {
17 | let tuple = try? responseResult.get()
18 | self.responseObserver?(
19 | request,
20 | tuple?.response as? HTTPURLResponse,
21 | tuple?.data,
22 | responseResult.error)
23 | }
24 |
25 | open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint {
26 |
27 | var request = try endpoint.makeRequest()
28 | request = try requestAdapter.adapt(request)
29 | var responseResult: Result<(data: Data, response: URLResponse), Error>
30 |
31 | do {
32 | let response: (data: Data, response: URLResponse) = try await session.data(for: request)
33 |
34 | if let httpResponse = response.response as? HTTPURLResponse {
35 | try endpoint.validate(request, response: httpResponse, data: response.data)
36 | }
37 |
38 | responseResult = .success(response)
39 | } catch let someError {
40 | responseResult = .failure(someError)
41 | }
42 |
43 | Task.detached { [weak self, request, responseResult] in
44 | self?.observeResponse(request: request, responseResult: responseResult)
45 | }
46 |
47 | return try responseResult.flatMap { tuple in
48 | do {
49 | return .success(try endpoint.content(from: tuple.response, with: tuple.data))
50 | } catch {
51 | return .failure(error)
52 | }
53 | }.get()
54 | }
55 |
56 | open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint {
57 |
58 | var request: (request: URLRequest, body: UploadEndpointBody) = try endpoint.makeRequest()
59 | request.request = try requestAdapter.adapt(request.request)
60 | var responseResult: Result<(data: Data, response: URLResponse), Error>
61 |
62 | do {
63 | let response: (data: Data, response: URLResponse)
64 | switch request {
65 | case (_, .data(let data)):
66 | response = try await session.upload(for: request.request, from: data)
67 | case (_, .file(let url)):
68 | response = try await session.upload(for: request.request, fromFile: url)
69 | case (_, .stream):
70 | throw URLSessionClientError.uploadStreamUnimplemented
71 | }
72 |
73 | responseResult = .success(response)
74 | } catch let someError {
75 | responseResult = .failure(someError)
76 | }
77 |
78 | Task.detached { [weak self, request, responseResult] in
79 | self?.observeResponse(request: request.request, responseResult: responseResult)
80 | }
81 |
82 | return try responseResult.flatMap { tuple in
83 | do {
84 | return .success(try endpoint.content(from: tuple.response, with: tuple.data))
85 | } catch {
86 | return .failure(error)
87 | }
88 | }.get()
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Sources/ApexyURLSession/URLSessionClient.swift:
--------------------------------------------------------------------------------
1 | import Apexy
2 | import Foundation
3 |
4 | open class URLSessionClient: Client, CombineClient {
5 |
6 | let session: URLSession
7 |
8 | let requestAdapter: RequestAdapter
9 |
10 | /// The queue on which the completion handler is dispatched.
11 | let completionQueue: DispatchQueue
12 |
13 | /// This closure to be called after each response from the server for the request.
14 | let responseObserver: ResponseObserver?
15 |
16 | /// Creates new 'URLSessionClient' instance.
17 | ///
18 | /// - Parameters:
19 | /// - baseURL: Base `URL`.
20 | /// - configuration: The configuration used to construct the managed session.
21 | /// - delegate: The delegate of URLSession.
22 | /// - completionQueue: The serial operation queue used to dispatch all completion handlers. `.main` by default.
23 | /// - responseObserver: The closure to be called after each response.
24 | public convenience init(
25 | baseURL: URL,
26 | configuration: URLSessionConfiguration = .default,
27 | delegate: URLSessionDelegate? = nil,
28 | completionQueue: DispatchQueue = .main,
29 | responseObserver: ResponseObserver? = nil) {
30 |
31 | self.init(
32 | requestAdapter: BaseRequestAdapter(baseURL: baseURL),
33 | configuration: configuration,
34 | delegate: delegate,
35 | completionQueue: completionQueue,
36 | responseObserver: responseObserver)
37 | }
38 |
39 | /// Creates new 'URLSessionClient' instance.
40 | ///
41 | /// - Parameters:
42 | /// - requestAdapter: RequestAdapter used to adapt a `URLRequest`.
43 | /// - configuration: The configuration used to construct the managed session.
44 | /// - delegate: The delegate of URLSession.
45 | /// - completionQueue: The serial operation queue used to dispatch all completion handlers. `.main` by default.
46 | /// - responseObserver: The closure to be called after each response.
47 | public init(
48 | requestAdapter: RequestAdapter,
49 | configuration: URLSessionConfiguration = .default,
50 | delegate: URLSessionDelegate? = nil,
51 | completionQueue: DispatchQueue = .main,
52 | responseObserver: ResponseObserver? = nil) {
53 |
54 | self.requestAdapter = requestAdapter
55 | self.session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
56 | self.completionQueue = completionQueue
57 | self.responseObserver = responseObserver
58 | }
59 |
60 | open func request(
61 | _ endpoint: T,
62 | completionHandler: @escaping (APIResult) -> Void) -> Progress where T : Endpoint {
63 |
64 | var request: URLRequest
65 | do {
66 | request = try endpoint.makeRequest()
67 | request = try requestAdapter.adapt(request)
68 | } catch {
69 | completionHandler(.failure(error))
70 | return Progress()
71 | }
72 |
73 | let task = session.dataTask(with: request) { (data, response, error) in
74 | let result = APIResult(catching: { () throws -> T.Content in
75 | if let httpResponse = response as? HTTPURLResponse {
76 | try endpoint.validate(request, response: httpResponse, data: data)
77 | }
78 | let data = data ?? Data()
79 | if let error = error {
80 | throw error
81 | }
82 | return try endpoint.content(from: response, with: data)
83 | })
84 | self.completionQueue.async {
85 | self.responseObserver?(request, response as? HTTPURLResponse, data, error)
86 | completionHandler(result)
87 | }
88 | }
89 | task.resume()
90 |
91 | return task.progress
92 | }
93 |
94 | open func upload(_ endpoint: T, completionHandler: @escaping (APIResult) -> Void) -> Progress where T : UploadEndpoint {
95 | var request: (URLRequest, UploadEndpointBody)
96 | do {
97 | request = try endpoint.makeRequest()
98 | request.0 = try requestAdapter.adapt(request.0)
99 | } catch {
100 | completionHandler(.failure(error))
101 | return Progress()
102 | }
103 |
104 | let handler: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in
105 | let result = APIResult(catching: { () throws -> T.Content in
106 | let data = data ?? Data()
107 | if let error = error {
108 | throw error
109 | }
110 | return try endpoint.content(from: response, with: data)
111 | })
112 | self.completionQueue.async {
113 | self.responseObserver?(request.0, response as? HTTPURLResponse, data, error)
114 | completionHandler(result)
115 | }
116 | }
117 |
118 | let task: URLSessionUploadTask
119 | switch request {
120 | case (let request, .data(let data)):
121 | task = session.uploadTask(with: request, from: data, completionHandler: handler)
122 | case (let request, .file(let url)):
123 | task = session.uploadTask(with: request, fromFile: url, completionHandler: handler)
124 | case (_, .stream):
125 | completionHandler(.failure(URLSessionClientError.uploadStreamUnimplemented))
126 | return Progress()
127 | }
128 | task.resume()
129 |
130 | return task.progress
131 | }
132 | }
133 |
134 | enum URLSessionClientError: LocalizedError {
135 | case uploadStreamUnimplemented
136 |
137 | var errorDescription: String? {
138 | switch self {
139 | case .uploadStreamUnimplemented:
140 | return """
141 | UploadEndpointBody.stream is unimplemented. If you need it feel free to create an issue \
142 | on GitHub https://github.com/RedMadRobot/apexy-ios/issues/new
143 | """
144 | }
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Combine)
2 | import Combine
3 | import Apexy
4 | import ApexyAlamofire
5 | import XCTest
6 |
7 | final class AlamofireClientCombineTests: XCTestCase {
8 |
9 | private var client: AlamofireClient!
10 | private var cancellables = Set()
11 |
12 | override func setUp() {
13 | let url = URL(string: "https://booklibrary.com")!
14 |
15 | let config = URLSessionConfiguration.ephemeral
16 | config.protocolClasses = [MockURLProtocol.self]
17 |
18 | client = AlamofireClient(baseURL: url, configuration: config)
19 | }
20 |
21 | func testClientRequestWithCombineMultipleTimes() {
22 | let endpoint = EmptyEndpoint()
23 | MockURLProtocol.requestHandler = { request in
24 | let data = UUID().uuidString.data(using: .utf8)!
25 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
26 | return (response, data)
27 | }
28 |
29 | let exp = expectation(description: "wait for response")
30 | exp.expectedFulfillmentCount = 2
31 | let request = client.request(endpoint)
32 |
33 | // First subscription
34 | var firstRequestContent: Data?
35 | request
36 | .sink(
37 | receiveCompletion: { _ in },
38 | receiveValue: { content in
39 | firstRequestContent = content
40 | exp.fulfill()
41 | }
42 | )
43 | .store(in: &cancellables)
44 |
45 | // Second subscription
46 | var secondRequestContent: Data?
47 | request
48 | .sink(
49 | receiveCompletion: { _ in },
50 | receiveValue: { content in
51 | secondRequestContent = content
52 | exp.fulfill()
53 | }
54 | )
55 | .store(in: &cancellables)
56 |
57 | // Third subscription which will be cancelled at once
58 | request
59 | .sink(
60 | receiveCompletion: { _ in },
61 | receiveValue: { _ in }
62 | )
63 | .cancel()
64 |
65 | wait(for: [exp], timeout: 1)
66 |
67 | XCTAssertNotNil(firstRequestContent)
68 | XCTAssertNotNil(secondRequestContent)
69 | XCTAssertNotEqual(firstRequestContent, secondRequestContent)
70 | }
71 | }
72 | #endif
73 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/AlamofireClientTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlamofireClientTests.swift
3 | //
4 | // Created by Daniil Subbotin on 07.09.2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 | import ApexyAlamofire
10 | import XCTest
11 |
12 | final class AlamofireClientTests: XCTestCase {
13 |
14 | private var client: AlamofireClient!
15 |
16 | override func setUp() {
17 | let url = URL(string: "https://booklibrary.com")!
18 |
19 | let config = URLSessionConfiguration.ephemeral
20 | config.protocolClasses = [MockURLProtocol.self]
21 |
22 | client = AlamofireClient(baseURL: url, configuration: config)
23 | }
24 |
25 | func testClientRequest() {
26 | let endpoint = EmptyEndpoint()
27 | let data = "Test".data(using: .utf8)!
28 | MockURLProtocol.requestHandler = { request in
29 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
30 | return (response, data)
31 | }
32 |
33 | let exp = expectation(description: "wait for response")
34 | _ = client.request(endpoint) { result in
35 | switch result {
36 | case .success(let content):
37 | XCTAssertEqual(content, data)
38 | case .failure:
39 | XCTFail("Expected result: .success, actual result: .failure")
40 | }
41 | exp.fulfill()
42 | }
43 | wait(for: [exp], timeout: 1)
44 | }
45 |
46 | func testClientUpload() {
47 | let data = "apple".data(using: .utf8)!
48 | let endpoint = SimpleUploadEndpoint(data: data)
49 | MockURLProtocol.requestHandler = { request in
50 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
51 | return (response, data)
52 | }
53 |
54 | let exp = expectation(description: "wait for response")
55 | _ = client.upload(endpoint, completionHandler: { result in
56 | switch result {
57 | case .success(let content):
58 | XCTAssertEqual(content, data)
59 | case .failure:
60 | XCTFail("Expected result: .success, actual result: .failure")
61 | }
62 | exp.fulfill()
63 | })
64 | wait(for: [exp], timeout: 1)
65 | }
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/BaseRequestInterceptorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseRequestInterceptorTests.swift
3 | //
4 | // Created by Daniil Subbotin on 07.09.2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 | import ApexyAlamofire
10 | import Alamofire
11 | import XCTest
12 |
13 | final class BaseRequestInterceptorTests: XCTestCase {
14 |
15 | private let url = URL(string: "https://booklibrary.com")!
16 |
17 | private var interceptor: RequestInterceptor {
18 | BaseRequestInterceptor(baseURL: url)
19 | }
20 |
21 | func testAdaptWhenURLNotContainsTrailingSlash() {
22 | let request = URLRequest(url: URL(string: "books/10")!)
23 |
24 | let expectation = XCTestExpectation(description: "Wait for completion")
25 | interceptor.adapt(request, for: .default) { result in
26 | switch result {
27 | case .success(let req):
28 | XCTAssertEqual(req.url?.absoluteString, "https://booklibrary.com/books/10")
29 | case .failure:
30 | XCTFail("Expected result: .success, actual result: .failure")
31 | }
32 | expectation.fulfill()
33 | }
34 | wait(for: [expectation], timeout: 1)
35 | }
36 |
37 | func testAdaptWhenURLContainsTrailingSlash() {
38 | let request = URLRequest(url: URL(string: "path/")!)
39 | let exp = expectation(description: "Adapting url request")
40 | interceptor.adapt(request, for: .default) { result in
41 | let request = try! result.get()
42 | XCTAssertEqual(request.url?.absoluteString, "https://booklibrary.com/path/")
43 | exp.fulfill()
44 | }
45 | wait(for: [exp], timeout: 1)
46 | }
47 |
48 | func testAdaptWhenURLContainsQueryItems() {
49 | let url = URL(string: "api/path/")!
50 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
51 | components.queryItems = [URLQueryItem(name: "param", value: "value")]
52 |
53 | let request = URLRequest(url: components.url!)
54 | let exp = expectation(description: "Adapting url request")
55 | interceptor.adapt(request, for: .default) { result in
56 | let request = try! result.get()
57 | XCTAssertEqual(request.url?.absoluteString, "https://booklibrary.com/api/path/?param=value")
58 | exp.fulfill()
59 | }
60 | wait(for: [exp], timeout: 1)
61 | }
62 |
63 | func testAdaptWhenRequestContainsHeaders() {
64 | var request = URLRequest(url: URL(string: "books")!)
65 | request.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
66 |
67 | let expectation = XCTestExpectation(description: "Wait for completion")
68 | interceptor.adapt(request, for: .default) { result in
69 | switch result {
70 | case .success(let req):
71 | XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/octet-stream")
72 | case .failure:
73 | XCTFail("Expected result: .success, actual result: .failure")
74 | }
75 | expectation.fulfill()
76 | }
77 | wait(for: [expectation], timeout: 1)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/Helpers/EmptyEndpoint.swift:
--------------------------------------------------------------------------------
1 | import Apexy
2 | import Foundation
3 |
4 | struct EmptyEndpoint: Endpoint {
5 |
6 | typealias Content = Data
7 |
8 | func makeRequest() throws -> URLRequest {
9 | URLRequest(url: URL(string: "empty")!)
10 | }
11 |
12 | func content(from response: URLResponse?, with body: Data) throws -> Data {
13 | return body
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/Helpers/MockURLProtocol.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | final class MockURLProtocol: URLProtocol {
4 |
5 | static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data) )?
6 |
7 | override class func canInit(with request: URLRequest) -> Bool {
8 | true
9 | }
10 |
11 | override class func canonicalRequest(for request: URLRequest) -> URLRequest {
12 | request
13 | }
14 |
15 | override func stopLoading() {}
16 |
17 | override func startLoading() {
18 | guard let handler = MockURLProtocol.requestHandler else { return }
19 | do {
20 | let (response, data) = try handler(request)
21 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
22 | client?.urlProtocol(self, didLoad: data)
23 | client?.urlProtocolDidFinishLoading(self)
24 | } catch {
25 | client?.urlProtocol(self, didFailWithError: error)
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Tests/ApexyAlamofireTests/Helpers/SimpleUploadEndpoint.swift:
--------------------------------------------------------------------------------
1 | import Apexy
2 | import Foundation
3 |
4 | struct SimpleUploadEndpoint: UploadEndpoint {
5 |
6 | typealias Content = Data
7 |
8 | private let data: Data
9 |
10 | init(data: Data) {
11 | self.data = data
12 | }
13 |
14 | func makeRequest() throws -> (URLRequest, UploadEndpointBody) {
15 | var req = URLRequest(url: URL(string: "upload")!)
16 | req.httpMethod = "POST"
17 |
18 | let body = UploadEndpointBody.data(data)
19 | return (req, body)
20 | }
21 |
22 | func content(from response: URLResponse?, with body: Data) throws -> Data {
23 | body
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Tests/ApexyLoaderTests/ContentLoaderTests.swift:
--------------------------------------------------------------------------------
1 | @testable import ApexyLoader
2 | import Combine
3 | import XCTest
4 |
5 | final class ContentLoaderTests: XCTestCase {
6 |
7 | private var contentLoader: ContentLoader!
8 | private var numberOfChanges = 0
9 | private var observation: LoaderObservation!
10 |
11 | private var bag = Set()
12 | private var receivedValues = [LoadingState]()
13 |
14 | override func setUp() {
15 | super.setUp()
16 |
17 | numberOfChanges = 0
18 | contentLoader = ContentLoader()
19 | observation = contentLoader.observe { [weak self] in
20 | self?.numberOfChanges += 1
21 | }
22 |
23 | receivedValues.removeAll()
24 |
25 | contentLoader.statePublisher.sink(receiveCompletion: { _ in }) { loadingState in
26 | self.receivedValues.append(loadingState)
27 | }.store(in: &bag)
28 |
29 | XCTAssertTrue(
30 | contentLoader.observations.isEmpty,
31 | "No observation of other loaders")
32 | XCTAssertEqual(
33 | contentLoader.state,
34 | .initial,
35 | "Initial loader state")
36 | }
37 |
38 | func testCancelObservation() {
39 | observation = nil
40 | contentLoader.state = .success(content: 10)
41 | XCTAssertEqual(
42 | numberOfChanges, 0,
43 | "The change handler didn‘t triggered because the observation was canceled")
44 | }
45 |
46 | func testCancelObservationCombine() {
47 | bag.removeAll()
48 | contentLoader.state = .success(content: 10)
49 | XCTAssertEqual(
50 | receivedValues,
51 | [.initial],
52 | "The change handler didn‘t triggered because the observation was canceled")
53 | }
54 |
55 | func testStartLoading() {
56 | XCTAssertTrue(
57 | contentLoader.startLoading(),
58 | "Loading has begun")
59 | XCTAssertTrue(
60 | contentLoader.state == .loading(cache: nil),
61 | "State of the loader must be loading")
62 | XCTAssertEqual(
63 | numberOfChanges, 1,
64 | "Change handler triggered")
65 |
66 | XCTAssertFalse(
67 | contentLoader.startLoading(),
68 | "The second loading didn‘t start before the end of the first one.")
69 | XCTAssertTrue(
70 | contentLoader.state == .loading(cache: nil),
71 | "The load status has NOT changed")
72 | XCTAssertEqual(
73 | numberOfChanges, 1,
74 | "The change handler did NOT triggered")
75 | }
76 |
77 | func testStartLoadingCombine() {
78 | XCTAssertTrue(
79 | contentLoader.startLoading(),
80 | "Loading has begun")
81 | XCTAssertEqual(
82 | receivedValues,
83 | [.initial, .loading(cache: nil)],
84 | "State of the loader must be loading")
85 | XCTAssertFalse(
86 | contentLoader.startLoading(),
87 | "The second loading didn‘t start before the end of the first one.")
88 | XCTAssertEqual(
89 | receivedValues,
90 | [.initial, .loading(cache: nil)],
91 | "The load status has NOT changed")
92 | }
93 |
94 | func testFinishLoading() {
95 | contentLoader.finishLoading(.success(12))
96 | XCTAssertTrue(
97 | contentLoader.state == .success(content: 12),
98 | "Successfully loading state")
99 | XCTAssertEqual(
100 | numberOfChanges, 1,
101 | "The change handler triggered")
102 |
103 | let error = URLError(.networkConnectionLost)
104 | contentLoader.finishLoading(.failure(error))
105 | XCTAssertTrue(
106 | contentLoader.state == .failure(error: error, cache: 12),
107 | "The state must me failure with cache")
108 | XCTAssertEqual(
109 | numberOfChanges, 2,
110 | "The handler triggered")
111 | }
112 |
113 | func testFinishLoadingCombine() {
114 | contentLoader.finishLoading(.success(12))
115 | XCTAssertEqual(
116 | receivedValues,
117 | [.initial, .success(content: 12)],
118 | "Successfully loading state")
119 |
120 | receivedValues.removeAll()
121 |
122 | let error = URLError(.networkConnectionLost)
123 | contentLoader.finishLoading(.failure(error))
124 |
125 | XCTAssertEqual(
126 | receivedValues,
127 | [.failure(error: error, cache: 12)],
128 | "The state must me failure with cache")
129 | }
130 |
131 | func testUpdate() {
132 | contentLoader.update(.initial)
133 | XCTAssertEqual(
134 | numberOfChanges, 0,
135 | "The state didn't change and the handler didn't triggered")
136 |
137 | contentLoader.update(.success(content: 1))
138 | XCTAssertEqual(
139 | numberOfChanges, 1,
140 | "The state changed and the handler triggered")
141 |
142 | contentLoader.update(.success(content: 1))
143 | XCTAssertEqual(
144 | numberOfChanges, 1,
145 | "The state didn't changed and the handler didn't triggered")
146 | }
147 |
148 | func testUpdateCombine() {
149 | contentLoader.update(.initial)
150 | XCTAssertEqual(
151 | receivedValues,
152 | [.initial],
153 | "The state didn't change and the handler didn't triggered")
154 |
155 | contentLoader.update(.success(content: 1))
156 | XCTAssertEqual(
157 | receivedValues,
158 | [.initial, .success(content: 1)],
159 | "The state changed and the handler triggered")
160 |
161 | contentLoader.update(.success(content: 1))
162 | XCTAssertEqual(
163 | receivedValues,
164 | [.initial, .success(content: 1)],
165 | "The state didn't changed and the handler didn't triggered")
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/Tests/ApexyLoaderTests/LoaderObservationTests.swift:
--------------------------------------------------------------------------------
1 | @testable import ApexyLoader
2 | import XCTest
3 |
4 | final class LoaderObservationTests: XCTestCase {
5 |
6 | private var observation: LoaderObservation!
7 |
8 | func testDeinit() {
9 | var numberOfTriggers = 0
10 | observation = LoaderObservation {
11 | numberOfTriggers += 1
12 | }
13 |
14 | observation = nil
15 |
16 | XCTAssertEqual(numberOfTriggers, 1, "The handler triggered once")
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/ApexyLoaderTests/LoadingStateTests.swift:
--------------------------------------------------------------------------------
1 | @testable import ApexyLoader
2 | import XCTest
3 |
4 | final class LoadingStateTests: XCTestCase {
5 |
6 | private let error = URLError(.badURL)
7 |
8 | func testContent() {
9 | XCTAssertNil(LoadingState.initial.content)
10 | XCTAssertNil(LoadingState.loading(cache: nil).content)
11 | XCTAssertNil(LoadingState.failure(error: error, cache: nil).content)
12 |
13 | XCTAssertEqual(LoadingState.loading(cache: 1).content, 1)
14 | XCTAssertEqual(LoadingState.success(content: 2).content, 2)
15 | XCTAssertEqual(LoadingState.failure(error: error, cache: 3).content, 3)
16 | }
17 |
18 | func testIsLoading() {
19 | XCTAssertTrue(
20 | LoadingState.loading(cache: nil).isLoading)
21 | XCTAssertFalse(
22 | LoadingState.initial.isLoading)
23 | XCTAssertFalse(
24 | LoadingState.success(content: 0).isLoading)
25 | XCTAssertFalse(
26 | LoadingState.failure(error: error, cache: 6).isLoading)
27 | }
28 |
29 | func testError() throws {
30 | XCTAssertNil(
31 | LoadingState.loading(cache: nil).error)
32 | XCTAssertNil(
33 | LoadingState.initial.error)
34 | XCTAssertNil(
35 | LoadingState.success(content: 0).error)
36 |
37 | let error = try XCTUnwrap(LoadingState.failure(error: self.error, cache: 6).error as? URLError)
38 | XCTAssertEqual(error, self.error)
39 | }
40 |
41 | func testMerge() {
42 | XCTAssertEqual(
43 | LoadingState.loading(cache: 2).merge(.success(content: 3), transform: +),
44 | LoadingState.loading(cache: 5))
45 | XCTAssertEqual(
46 | LoadingState.success(content: 2).merge(.loading(cache: 3), transform: +),
47 | LoadingState.loading(cache: 5))
48 | XCTAssertEqual(
49 | LoadingState.loading(cache: nil).merge(.success(content: 3), transform: +),
50 | LoadingState.loading(cache: nil))
51 | XCTAssertEqual(
52 | LoadingState.success(content: 2).merge(.loading(cache: nil), transform: +),
53 | LoadingState.loading(cache: nil))
54 |
55 | XCTAssertEqual(
56 | LoadingState.failure(error: error, cache: 7).merge(.failure(error: error, cache: 7), transform: +),
57 | LoadingState.failure(error: error, cache: 14))
58 | XCTAssertEqual(
59 | LoadingState.failure(error: error, cache: 7).merge(.success(content: 8), transform: +),
60 | LoadingState.failure(error: error, cache: 15))
61 | XCTAssertEqual(
62 | LoadingState.success(content: 9).merge(.failure(error: error, cache: 7), transform: +),
63 | LoadingState.failure(error: error, cache: 16))
64 | XCTAssertEqual(
65 | LoadingState.success(content: 9).merge(.failure(error: error, cache: nil), transform: +),
66 | LoadingState.failure(error: error, cache: nil))
67 |
68 | XCTAssertEqual(
69 | LoadingState.success(content: 5).merge(.success(content: 5), transform: +),
70 | LoadingState.success(content: 10))
71 | XCTAssertEqual(
72 | LoadingState.initial.merge(.initial, transform: +),
73 | LoadingState.initial)
74 | XCTAssertEqual(
75 | LoadingState.initial.merge(.success(content: 1), transform: +),
76 | LoadingState.initial)
77 | XCTAssertEqual(
78 | LoadingState.success(content: 2).merge(.initial, transform: +),
79 | LoadingState.initial)
80 | }
81 |
82 | func testInitialEquatable() {
83 | XCTAssertEqual(
84 | LoadingState.initial,
85 | LoadingState.initial)
86 | XCTAssertNotEqual(
87 | LoadingState.initial,
88 | LoadingState.loading(cache: nil))
89 | XCTAssertNotEqual(
90 | LoadingState.initial,
91 | LoadingState.loading(cache: 76))
92 | XCTAssertNotEqual(
93 | LoadingState.initial,
94 | LoadingState.success(content: 23))
95 | XCTAssertNotEqual(
96 | LoadingState.initial,
97 | LoadingState.failure(error: error, cache: nil))
98 | XCTAssertNotEqual(
99 | LoadingState.initial,
100 | LoadingState.failure(error: error, cache: 100))
101 | }
102 |
103 | func testLoadingEquatable() {
104 | XCTAssertEqual(
105 | LoadingState.loading(cache: 1),
106 | LoadingState.loading(cache: 1))
107 | XCTAssertNotEqual(
108 | LoadingState.loading(cache: 2),
109 | LoadingState.loading(cache: 3))
110 | XCTAssertNotEqual(
111 | LoadingState.loading(cache: 4),
112 | LoadingState.initial)
113 | XCTAssertNotEqual(
114 | LoadingState.loading(cache: 6),
115 | LoadingState.success(content: 6))
116 | XCTAssertNotEqual(
117 | LoadingState.loading(cache: 6),
118 | LoadingState.success(content: 7))
119 | XCTAssertNotEqual(
120 | LoadingState.loading(cache: 8),
121 | LoadingState.failure(error: error, cache: nil))
122 | }
123 |
124 | func testSuccessEquatable() {
125 | XCTAssertEqual(
126 | LoadingState.success(content: 43),
127 | LoadingState.success(content: 43))
128 | XCTAssertNotEqual(
129 | LoadingState.success(content: 43),
130 | LoadingState.success(content: 47))
131 | XCTAssertNotEqual(
132 | LoadingState.success(content: 43),
133 | LoadingState.initial)
134 | XCTAssertNotEqual(
135 | LoadingState.success(content: 43),
136 | LoadingState.loading(cache: nil))
137 | XCTAssertNotEqual(
138 | LoadingState.success(content: 43),
139 | LoadingState.failure(error: error, cache: nil))
140 | }
141 |
142 | func testFailureEquatable() {
143 | XCTAssertEqual(
144 | LoadingState.failure(error: error, cache: 3),
145 | LoadingState.failure(error: error, cache: 3))
146 | XCTAssertNotEqual(
147 | LoadingState.failure(error: error, cache: nil),
148 | LoadingState.failure(error: error, cache: 3))
149 | XCTAssertNotEqual(
150 | LoadingState.failure(error: error, cache: nil),
151 | LoadingState.initial)
152 | XCTAssertNotEqual(
153 | LoadingState.failure(error: error, cache: nil),
154 | LoadingState.loading(cache: nil))
155 | XCTAssertNotEqual(
156 | LoadingState.failure(error: error, cache: nil),
157 | LoadingState.success(content: 4))
158 | }
159 |
160 | }
161 |
--------------------------------------------------------------------------------
/Tests/ApexyTests/HTTPBodyTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPBodyTests.swift
3 | //
4 | // Created by Daniil Subbotin on 07.09.2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 | import XCTest
10 |
11 | final class HTTPBodyTests: XCTestCase {
12 |
13 | func testJsonHttpBody() {
14 | let emptyData = Data()
15 |
16 | let json = HTTPBody.json(emptyData)
17 |
18 | XCTAssertEqual(json.data, emptyData)
19 | XCTAssertEqual(json.contentType, "application/json")
20 | }
21 |
22 | func testFormHttpBody() {
23 | let emptyData = Data()
24 |
25 | let json = HTTPBody.form(emptyData)
26 |
27 | XCTAssertEqual(json.data, emptyData)
28 | XCTAssertEqual(json.contentType, "application/x-www-form-urlencoded")
29 | }
30 |
31 | func testBinaryHttpBody() {
32 | let emptyData = Data()
33 |
34 | let json = HTTPBody.binary(emptyData)
35 |
36 | XCTAssertEqual(json.data, emptyData)
37 | XCTAssertEqual(json.contentType, "application/octet-stream")
38 | }
39 |
40 | func testStringHttpBody() {
41 | let json = HTTPBody.string("Test")
42 |
43 | let testData = "Test".data(using: .utf8)
44 | XCTAssertEqual(json.data, testData)
45 | XCTAssertEqual(json.contentType, "text/plain")
46 | }
47 |
48 | func testTextHttpBody() {
49 | let testData = "Test".data(using: .utf8)!
50 |
51 | let json = HTTPBody.text(testData)
52 |
53 | XCTAssertEqual(json.data, testData)
54 | XCTAssertEqual(json.contentType, "text/plain")
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Tests/ApexyTests/URLRequestBuildableTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLRequestBuildableTests.swift
3 | //
4 | // Created by Daniil Subbotin on 07.09.2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 | import XCTest
10 |
11 | private struct URLRequestBuilder: URLRequestBuildable {}
12 |
13 | final class URLRequestBuildableTests: XCTestCase {
14 |
15 | private let url = URL(string: "https://apple.com")!
16 |
17 | func testGet() {
18 | let queryItems: [URLQueryItem] = [ URLQueryItem(name: "name", value: "John") ]
19 |
20 | let urlRequest = URLRequestBuilder().get(url, queryItems: queryItems)
21 |
22 | XCTAssertEqual(urlRequest.httpMethod, "GET")
23 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com?name=John")
24 | XCTAssertNil(urlRequest.httpBody)
25 | XCTAssertNil(urlRequest.allHTTPHeaderFields)
26 | }
27 |
28 | func testPost() {
29 | let bodyData = "Test".data(using: .utf8)!
30 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain")
31 |
32 | let urlRequest = URLRequestBuilder().post(url, body: httpBody)
33 |
34 | XCTAssertEqual(urlRequest.httpMethod, "POST")
35 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com")
36 | XCTAssertEqual(urlRequest.httpBody, bodyData)
37 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"])
38 | }
39 |
40 | func testPatch() {
41 | let bodyData = "Test".data(using: .utf8)!
42 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain")
43 |
44 | let urlRequest = URLRequestBuilder().patch(url, body: httpBody)
45 |
46 | XCTAssertEqual(urlRequest.httpMethod, "PATCH")
47 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com")
48 | XCTAssertEqual(urlRequest.httpBody, bodyData)
49 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"])
50 | }
51 |
52 | func testPut() {
53 | let bodyData = "Test".data(using: .utf8)!
54 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain")
55 |
56 | let urlRequest = URLRequestBuilder().put(url, body: httpBody)
57 |
58 | XCTAssertEqual(urlRequest.httpMethod, "PUT")
59 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com")
60 | XCTAssertEqual(urlRequest.httpBody, bodyData)
61 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"])
62 | }
63 |
64 | func testDelete() {
65 | let urlRequest = URLRequestBuilder().delete(url)
66 |
67 | XCTAssertEqual(urlRequest.httpMethod, "DELETE")
68 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com")
69 | XCTAssertNil(urlRequest.httpBody)
70 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, [:])
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Tests/ApexyURLSessionTests/BaseRequestAdapterTests.swift:
--------------------------------------------------------------------------------
1 | @testable import ApexyURLSession
2 | import XCTest
3 |
4 | final class BaseRequestAdapterTests: XCTestCase {
5 |
6 | private let url = URL(string: "https://booklibrary.com")!
7 |
8 | private var adapter: RequestAdapter {
9 | BaseRequestAdapter(baseURL: url)
10 | }
11 |
12 | func testAdaptWhenURLNotContainsTrailingSlash() throws {
13 | let request = URLRequest(url: URL(string: "books/10")!)
14 |
15 | let adaptedRequest = try adapter.adapt(request)
16 |
17 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/books/10")
18 | }
19 |
20 | func testAdaptWhenURLContainsTrailingSlash() throws {
21 | let request = URLRequest(url: URL(string: "path/")!)
22 |
23 | let adaptedRequest = try adapter.adapt(request)
24 |
25 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/path/")
26 | }
27 |
28 | func testAdaptWhenURLContainsQueryItems() throws {
29 | let url = URL(string: "api/path/")!
30 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
31 | components.queryItems = [URLQueryItem(name: "param", value: "value")]
32 | let request = URLRequest(url: components.url!)
33 |
34 | let adaptedRequest = try adapter.adapt(request)
35 |
36 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/api/path/?param=value")
37 | }
38 |
39 | func testAdaptWhenRequestContainsHeaders() throws {
40 | var request = URLRequest(url: URL(string: "books")!)
41 | request.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
42 |
43 | let adaptedRequest = try adapter.adapt(request)
44 |
45 | XCTAssertEqual(adaptedRequest.value(forHTTPHeaderField: "Content-Type"), "application/octet-stream")
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/Tests/ApexyURLSessionTests/URLSessionClientTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLSessionClientTests.swift
3 | //
4 | // Created by Daniil Subbotin on 07.09.2020.
5 | // Copyright © 2020 RedMadRobot. All rights reserved.
6 | //
7 |
8 | import Apexy
9 | import ApexyURLSession
10 | import Combine
11 | import XCTest
12 |
13 | final class URLSessionClientTests: XCTestCase {
14 |
15 | private var client: URLSessionClient!
16 | private var bag = Set()
17 |
18 | override func setUp() {
19 | let url = URL(string: "https://booklibrary.com")!
20 |
21 | let config = URLSessionConfiguration.ephemeral
22 | config.protocolClasses = [MockURLProtocol.self]
23 |
24 | client = URLSessionClient(baseURL: url, configuration: config)
25 | }
26 |
27 | func testClientRequest() {
28 | let endpoint = EmptyEndpoint()
29 | let data = "Test".data(using: .utf8)!
30 | MockURLProtocol.requestHandler = { request in
31 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
32 | return (response, data)
33 | }
34 |
35 | let exp = expectation(description: "wait for response")
36 | _ = client.request(endpoint) { result in
37 | switch result {
38 | case .success(let content):
39 | XCTAssertEqual(content, data)
40 | case .failure:
41 | XCTFail("Expected result: .success, actual result: .failure")
42 | }
43 | exp.fulfill()
44 | }
45 | wait(for: [exp], timeout: 1)
46 | }
47 |
48 | func testEndpointValidate() {
49 | var endpoint = EmptyEndpoint()
50 | endpoint.validateError = EndpointValidationError.validationFailed
51 |
52 | let data = "Test".data(using: .utf8)!
53 | MockURLProtocol.requestHandler = { request in
54 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
55 | return (response, data)
56 | }
57 |
58 | let exp = expectation(description: "wait for response")
59 |
60 | _ = client.request(endpoint) { result in
61 | switch result {
62 | case .success:
63 | XCTFail("Expected result: .failure, actual result: .success")
64 | case .failure(let error as EndpointValidationError):
65 | XCTAssertEqual(error, endpoint.validateError)
66 | case .failure(let error):
67 | XCTFail("Expected result: .failure(EndpointValidationError), actual result: .failure(\(error))")
68 | }
69 | exp.fulfill()
70 | }
71 | wait(for: [exp], timeout: 1)
72 | }
73 |
74 | func testClientUpload() {
75 | let data = "apple".data(using: .utf8)!
76 | let endpoint = SimpleUploadEndpoint(data: data)
77 | MockURLProtocol.requestHandler = { request in
78 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
79 | return (response, data)
80 | }
81 |
82 | let exp = expectation(description: "wait for response")
83 | _ = client.upload(endpoint, completionHandler: { result in
84 | switch result {
85 | case .success(let content):
86 | XCTAssertEqual(content, data)
87 | case .failure:
88 | XCTFail("Expected result: .success, actual result: .failure")
89 | }
90 | exp.fulfill()
91 | })
92 | wait(for: [exp], timeout: 1)
93 | }
94 |
95 | @available(iOS 13.0, *)
96 | @available(OSX 10.15, *)
97 | func testClientRequestUsingCombine() throws {
98 | let endpoint = EmptyEndpoint()
99 | let data = "Test".data(using: .utf8)!
100 | MockURLProtocol.requestHandler = { request in
101 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
102 | return (response, data)
103 | }
104 |
105 | let exp = expectation(description: "wait for response")
106 |
107 | let publisher = client.request(endpoint)
108 | publisher.sink(receiveCompletion: { result in
109 | switch result {
110 | case .finished:
111 | break
112 | case .failure:
113 | XCTFail("Expected result: .finished, actual result: .failure")
114 | }
115 | }) { content in
116 | XCTAssertEqual(content, data)
117 | exp.fulfill()
118 | }.store(in: &bag)
119 |
120 | wait(for: [exp], timeout: 0.1)
121 | }
122 |
123 | @available(iOS 13.0, *)
124 | @available(macOS 10.15, *)
125 | func testClientDataRequestUsingAsyncAwait() async throws {
126 | let endpoint = EmptyEndpoint()
127 | let data = "Test".data(using: .utf8)!
128 | MockURLProtocol.requestHandler = { request in
129 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
130 | return (response, data)
131 | }
132 |
133 | do {
134 | let content = try await client.request(endpoint)
135 | XCTAssertEqual(content, data)
136 | } catch {
137 | XCTFail("Expected result: .success, actual result: .failure")
138 | }
139 | }
140 |
141 | @available(iOS 13.0, *)
142 | @available(macOS 10.15, *)
143 | func testClientUploadUsingAsyncAwait() async throws {
144 | let data = "apple".data(using: .utf8)!
145 | let endpoint = SimpleUploadEndpoint(data: data)
146 | MockURLProtocol.requestHandler = { request in
147 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)!
148 | return (response, data)
149 | }
150 |
151 | do {
152 | let content = try await client.upload(endpoint)
153 | XCTAssertEqual(content, data)
154 | } catch {
155 | XCTFail("Expected result: .success, actual result: .failure")
156 | }
157 | }
158 | }
159 |
160 | private final class MockURLProtocol: URLProtocol {
161 |
162 | static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data) )?
163 |
164 | override class func canInit(with request: URLRequest) -> Bool {
165 | true
166 | }
167 |
168 | override class func canonicalRequest(for request: URLRequest) -> URLRequest {
169 | request
170 | }
171 |
172 | override func stopLoading() {}
173 |
174 | override func startLoading() {
175 | guard let handler = MockURLProtocol.requestHandler else { return }
176 | do {
177 | let (response, data) = try handler(request)
178 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
179 | client?.urlProtocol(self, didLoad: data)
180 | client?.urlProtocolDidFinishLoading(self)
181 | } catch {
182 | client?.urlProtocol(self, didFailWithError: error)
183 | }
184 | }
185 | }
186 |
187 | private struct EmptyEndpoint: Endpoint {
188 |
189 | typealias Content = Data
190 |
191 | var validateError: EndpointValidationError? = nil
192 |
193 | func makeRequest() throws -> URLRequest {
194 | URLRequest(url: URL(string: "empty")!)
195 | }
196 |
197 | func content(from response: URLResponse?, with body: Data) throws -> Data {
198 | return body
199 | }
200 |
201 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws {
202 | if let error = validateError {
203 | throw error
204 | }
205 | }
206 | }
207 |
208 | private struct SimpleUploadEndpoint: UploadEndpoint {
209 |
210 | typealias Content = Data
211 |
212 | private let data: Data
213 |
214 | init(data: Data) {
215 | self.data = data
216 | }
217 |
218 | func makeRequest() throws -> (URLRequest, UploadEndpointBody) {
219 | var req = URLRequest(url: URL(string: "upload")!)
220 | req.httpMethod = "POST"
221 |
222 | let body = UploadEndpointBody.data(data)
223 | return (req, body)
224 | }
225 |
226 | func content(from response: URLResponse?, with body: Data) throws -> Data {
227 | body
228 | }
229 | }
230 |
231 | private enum EndpointValidationError: String, Error, Equatable {
232 | case validationFailed
233 | }
234 |
--------------------------------------------------------------------------------