Skip to content
This repository was archived by the owner on Jun 23, 2023. It is now read-only.

Commit

Permalink
ProductionTargetType does not require sampleData
Browse files Browse the repository at this point in the history
  • Loading branch information
gobetti committed Jun 10, 2018
1 parent 4b91b0b commit 5dacdd8
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 112 deletions.
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,33 @@ While [Moya](https://github.com/Moya/Moya) is built on top of [Alamofire](https:

Thanks to the network request handling already embedded in `RxCocoa`, to Swift 4.1's conditional conformance (see [ReactiveURLSessionProtocol](https://github.com/gobetti/RxCocoaNetworking/blob/master/Sources/Core/ReactiveURLSessionProtocol.swift)) and heavily inspired by `Moya`'s architecture, **RxCocoaNetworking** provides you the same power and unit testing flexibility that `Moya` does, including full support to `TestScheduler` in a very lightweight framework.

The motivation to write a new framework came from the fact that Moya's `MoyaProvider` implements the `delayed` stub behavior with a real-time unit delay, preventing the usage of `RxTest` to assert this functionality. Being able to remove the dependency to `Alamofire` came next, since most projects need a much simpler network layer.
The main motivation to write a new framework came from the fact that Moya's `MoyaProvider` implements the `delayed` stub behavior with a real-time unit delay, preventing the usage of `RxTest` to assert this functionality. Other details came next, like removing `Alamofire` and the requirement for `sampleData`, among other minor details.

## Requirements

- 📱 iOS 9.0+ / Mac OS X 10.11+ / tvOS 10.0+ / watchOS 3.0+
- 🛠 Xcode 9.3+
- ✈️ Swift 4.1
- ✈️ Swift 4.1+
- ⚠️ RxCocoa
- 🔥 Does not require Alamofire

## Usage

If you're already used to [Moya](https://github.com/Moya/Moya), the good news is that **RxCocoaNetworking** (intentionally) has a very similar architecture! All you need is to create a structure to represent your API - an `enum` is recommended - and have it implement the [`TargetType`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Sources/Core/TargetType.swift) protocol. Requests to your API are managed by a [`Provider`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Sources/Core/Provider.swift) which is typed to your concrete `TargetType`.
If you're already used to [Moya](https://github.com/Moya/Moya), the good news is that **RxCocoaNetworking** (intentionally) has a very similar architecture! All you need is to create a structure to represent your API - an `enum` is recommended - and have it implement one of the [`TargetType`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Sources/Core/TargetType.swift) protocols. Requests to your API are managed by a [`Provider`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Sources/Core/Provider.swift) which is typed to your concrete `TargetType`.

Both if you're used to Moya or not, the other good news is that you can base off the example [`MockAPI`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Tests/Example/MockAPI.swift) and its [spec](https://github.com/gobetti/RxCocoaNetworking/blob/master/Tests/ExampleSpec.swift).
Both if you're used to Moya or not, the other good news is that you can base off the example [`ExampleAPI`](https://github.com/gobetti/RxCocoaNetworking/blob/master/Tests/Example/ExampleAPI.swift) and its [spec](https://github.com/gobetti/RxCocoaNetworking/blob/master/Tests/ExampleAPISpec.swift).

<details>
<summary><strong>Summarized `MockAPI`</strong></summary><p>
<summary><strong>Summarized `ExampleAPI`</strong></summary><p>

```swift
enum MockAPI {
enum ExampleAPI {
// Endpoints as cases:
case rate(movieID: String, rating: Float)
case reviews(movieID: String, page: Int)
}

extension MockAPI: TargetType {
extension ExampleAPI: ProductionTargetType {
// Your API's base URL is usually what determines an API enum.
var baseURL: URL { return URL(string: "...")! }

Expand All @@ -68,7 +68,9 @@ extension MockAPI: TargetType {
}

var headers: [String : String]? { return nil }

}

extension ExampleAPI: TargetType {
var sampleData: Data {
...
}
Expand All @@ -78,22 +80,24 @@ extension MockAPI: TargetType {

### Regular network requests (no stubbing):
```swift
let provider = Provider<MockAPI>()
let provider = Provider<ExampleAPI>()
```
The default `Provider` parameters is most often what you'll use in production code.

### Immediately stubbed network responses:
```swift
let provider = Provider<MockAPI>(stubBehavior: .immediate(stub: .default))
let provider = Provider<ExampleAPI>(stubBehavior: .immediate(stub: .default))
```
`stub: .default` means the `sampleData` from your API will be used. Other types allow you to define inline different responses.
`stub: .default` means the `sampleData` from your API will be used. Other `Stub` types allow you to specify different responses inline.

**Note:** if you implement `ProductionTargetType`, then `stub: .default` is not available, because `sampleData` is not required by that protocol 👌 You can always implement `TargetType` separately, for example, in the Tests target.

### `RxTest`-testable delayed stubbed network responses:
```swift
let testScheduler = TestScheduler(initialClock: 0)
let provider = Provider<MockAPI>(stubBehavior: .delayed(time: 3,
stub: .error(SomeError.anError)),
scheduler: testScheduler)
let provider = Provider<ExampleAPI>(stubBehavior: .delayed(time: 3,
stub: .error(SomeError.anError)),
scheduler: testScheduler)
```
An `error` will be emitted 3 virtual time units after the subscription occurs.

Expand Down
32 changes: 16 additions & 16 deletions RxCocoaNetworking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@
3AA60B3A20C34C9E001F6332 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B3820C34C9E001F6332 /* Task.swift */; };
3AA60B3B20C34C9E001F6332 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B3820C34C9E001F6332 /* Task.swift */; };
3AA60B3C20C34C9E001F6332 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B3820C34C9E001F6332 /* Task.swift */; };
3AA60B6C20C401AF001F6332 /* ExampleSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleSpec.swift */; };
3AA60B6D20C401AF001F6332 /* ExampleSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleSpec.swift */; };
3AA60B6E20C401AF001F6332 /* ExampleSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleSpec.swift */; };
3AA60B6C20C401AF001F6332 /* ExampleAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleAPISpec.swift */; };
3AA60B6D20C401AF001F6332 /* ExampleAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleAPISpec.swift */; };
3AA60B6E20C401AF001F6332 /* ExampleAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B5C20C3FDDD001F6332 /* ExampleAPISpec.swift */; };
3AE6B5E020C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE6B5DF20C40D3C0005835B /* SingleEvent+Extensions.swift */; };
3AE6B5E120C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE6B5DF20C40D3C0005835B /* SingleEvent+Extensions.swift */; };
3AE6B5E220C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE6B5DF20C40D3C0005835B /* SingleEvent+Extensions.swift */; };
3AE6B61220C4806C0005835B /* MockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* MockAPI.swift */; };
3AE6B61320C4806D0005835B /* MockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* MockAPI.swift */; };
3AE6B61420C4806E0005835B /* MockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* MockAPI.swift */; };
3AE6B61220C4806C0005835B /* ExampleAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* ExampleAPI.swift */; };
3AE6B61320C4806D0005835B /* ExampleAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* ExampleAPI.swift */; };
3AE6B61420C4806E0005835B /* ExampleAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA60B4B20C3F590001F6332 /* ExampleAPI.swift */; };
3AE6B61920C480AC0005835B /* delete-movie-rating.json in Resources */ = {isa = PBXBuildFile; fileRef = 3AE6B61620C480AC0005835B /* delete-movie-rating.json */; };
3AE6B61A20C480AC0005835B /* delete-movie-rating.json in Resources */ = {isa = PBXBuildFile; fileRef = 3AE6B61620C480AC0005835B /* delete-movie-rating.json */; };
3AE6B61B20C480AC0005835B /* delete-movie-rating.json in Resources */ = {isa = PBXBuildFile; fileRef = 3AE6B61620C480AC0005835B /* delete-movie-rating.json */; };
Expand Down Expand Up @@ -186,8 +186,8 @@
3A8B29E520BA2C6800769572 /* RxTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxTest.framework; path = Carthage/Build/tvOS/RxTest.framework; sourceTree = "<group>"; };
3A8B29E820BA2D0400769572 /* XCTest+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTest+Extensions.swift"; sourceTree = "<group>"; };
3AA60B3820C34C9E001F6332 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
3AA60B4B20C3F590001F6332 /* MockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAPI.swift; sourceTree = "<group>"; };
3AA60B5C20C3FDDD001F6332 /* ExampleSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleSpec.swift; sourceTree = "<group>"; };
3AA60B4B20C3F590001F6332 /* ExampleAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleAPI.swift; sourceTree = "<group>"; };
3AA60B5C20C3FDDD001F6332 /* ExampleAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleAPISpec.swift; sourceTree = "<group>"; };
3AE6B5D620C4052C0005835B /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
3AE6B5D720C4052C0005835B /* Info-tvOS.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-tvOS.plist"; sourceTree = "<group>"; };
3AE6B5DF20C40D3C0005835B /* SingleEvent+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SingleEvent+Extensions.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -321,7 +321,7 @@
children = (
3AA60B6720C40191001F6332 /* Example */,
3A8B29C920BA257500769572 /* Extensions */,
3AA60B5C20C3FDDD001F6332 /* ExampleSpec.swift */,
3AA60B5C20C3FDDD001F6332 /* ExampleAPISpec.swift */,
3A8B29D220BA27B200769572 /* ProviderTests.swift */,
3549BB8A1DA38C0A00C63030 /* RxCocoaNetworkingSpec.swift */,
3AE6B62220C482C90005835B /* SingleEventSpec.swift */,
Expand Down Expand Up @@ -390,7 +390,7 @@
isa = PBXGroup;
children = (
3AE6B61520C4808A0005835B /* Samples */,
3AA60B4B20C3F590001F6332 /* MockAPI.swift */,
3AA60B4B20C3F590001F6332 /* ExampleAPI.swift */,
);
path = Example;
sourceTree = "<group>";
Expand Down Expand Up @@ -780,8 +780,8 @@
3A8B29E120BA294A00769572 /* ProviderTests.swift in Sources */,
3549BB8B1DA38C0A00C63030 /* RxCocoaNetworkingSpec.swift in Sources */,
3A8B29E920BA2D0400769572 /* XCTest+Extensions.swift in Sources */,
3AA60B6C20C401AF001F6332 /* ExampleSpec.swift in Sources */,
3AE6B61220C4806C0005835B /* MockAPI.swift in Sources */,
3AA60B6C20C401AF001F6332 /* ExampleAPISpec.swift in Sources */,
3AE6B61220C4806C0005835B /* ExampleAPI.swift in Sources */,
3AE6B5E020C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */,
3AE6B62320C482C90005835B /* SingleEventSpec.swift in Sources */,
);
Expand All @@ -794,8 +794,8 @@
3A8B29D620BA27B200769572 /* ProviderTests.swift in Sources */,
3549BB8D1DA38C0A00C63030 /* RxCocoaNetworkingSpec.swift in Sources */,
3A8B29EB20BA2D0400769572 /* XCTest+Extensions.swift in Sources */,
3AA60B6E20C401AF001F6332 /* ExampleSpec.swift in Sources */,
3AE6B61420C4806E0005835B /* MockAPI.swift in Sources */,
3AA60B6E20C401AF001F6332 /* ExampleAPISpec.swift in Sources */,
3AE6B61420C4806E0005835B /* ExampleAPI.swift in Sources */,
3AE6B5E220C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */,
3AE6B62520C482C90005835B /* SingleEventSpec.swift in Sources */,
);
Expand All @@ -808,8 +808,8 @@
3A8B29D520BA27B200769572 /* ProviderTests.swift in Sources */,
3549BB8C1DA38C0A00C63030 /* RxCocoaNetworkingSpec.swift in Sources */,
3A8B29EA20BA2D0400769572 /* XCTest+Extensions.swift in Sources */,
3AA60B6D20C401AF001F6332 /* ExampleSpec.swift in Sources */,
3AE6B61320C4806D0005835B /* MockAPI.swift in Sources */,
3AA60B6D20C401AF001F6332 /* ExampleAPISpec.swift in Sources */,
3AE6B61320C4806D0005835B /* ExampleAPI.swift in Sources */,
3AE6B5E120C40D3C0005835B /* SingleEvent+Extensions.swift in Sources */,
3AE6B62420C482C90005835B /* SingleEventSpec.swift in Sources */,
);
Expand Down
32 changes: 11 additions & 21 deletions Sources/Core/Provider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ enum ProviderError: Error {
}

/// A self-mockable network data requester.
final public class Provider<Target: TargetType> {
private let stubBehavior: StubBehavior
final public class Provider<Target: ProductionTargetType> {
private let stubBehavior: StubBehavior<Target.TargetStub>
private let scheduler: SchedulerType

public init(stubBehavior: StubBehavior = .never,
public init(stubBehavior: StubBehavior<Target.TargetStub> = .never,
scheduler: SchedulerType = ConcurrentDispatchQueueScheduler(qos: .background)) {
self.stubBehavior = stubBehavior
self.scheduler = scheduler
Expand All @@ -30,7 +30,8 @@ final public class Provider<Target: TargetType> {
request = request.addingParameters(parameters)
}

return target.makeURLSession(stubBehavior: stubBehavior, scheduler: scheduler)
return URLSessionFactory(target: target)
.makeURLSession(stubBehavior: stubBehavior, scheduler: scheduler)
.data(request: request).asSingle()
}
}
Expand All @@ -54,32 +55,21 @@ private struct ReactiveURLSessionMock: ReactiveURLSessionProtocol {
}
}

private extension TargetType {
func makeURLSession(stubBehavior: StubBehavior,
private struct URLSessionFactory<Target: ProductionTargetType> {
let target: Target

func makeURLSession(stubBehavior: StubBehavior<Target.TargetStub>,
scheduler: SchedulerType) -> ReactiveURLSessionProtocol {
switch stubBehavior {
case .delayed(let time, let stub):
return ReactiveURLSessionMock(stubbed: stub.makeResponse(for: self),
return ReactiveURLSessionMock(stubbed: target.makeResponse(from: stub),
scheduler: scheduler,
delay: time)
case .immediate(let stub):
return ReactiveURLSessionMock(stubbed: stub.makeResponse(for: self),
return ReactiveURLSessionMock(stubbed: target.makeResponse(from: stub),
scheduler: scheduler)
case .never:
return URLSession.shared.rx
}
}
}

private extension Stub {
func makeResponse(for target: TargetType) -> Observable<Data> {
switch self {
case .default:
return .just(target.sampleData)
case .success(let data):
return .just(data)
case .error(let error):
return .error(error)
}
}
}
30 changes: 18 additions & 12 deletions Sources/Core/Stub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,29 @@
*/

import Foundation
import RxSwift

public enum ProductionStub {
case success(Data)
case error(Error)
}

/// The different types of stub, where `default` falls back to the `TargetType`'s `sampleData`.
public enum Stub {
case `default`
case success(Data)
case error(Error)
case `default`
case success(Data)
case error(Error)
}

/// https://github.com/Moya/Moya/blob/master/Sources/Moya/MoyaProvider.swift
/// The different stubbing modes.
public enum StubBehavior {
/// Stubs and delays the response for a specified amount of time.
case delayed(time: TimeInterval, stub: Stub)

/// Stubs the response without delaying it.
case immediate(stub: Stub)

/// Does not stub the response.
case never
public enum StubBehavior<AnyStub> {
/// Stubs and delays the response for a specified amount of time.
case delayed(time: TimeInterval, stub: AnyStub)
/// Stubs the response without delaying it.
case immediate(stub: AnyStub)
/// Does not stub the response.
case never
}
39 changes: 37 additions & 2 deletions Sources/Core/TargetType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import Foundation
import RxSwift

public enum HTTPMethod: String {
case connect = "CONNECT"
Expand All @@ -28,8 +29,10 @@ public enum HTTPMethod: String {
}

/// https://github.com/Moya/Moya/blob/master/Sources/Moya/TargetType.swift
/// Defines the contract for API models that are able to provide stubs by themselves.
public protocol TargetType {
/// Defines the contract for API structures.
public protocol ProductionTargetType {
associatedtype TargetStub = ProductionStub

/// The target's base `URL`.
var baseURL: URL { get }

Expand All @@ -43,6 +46,38 @@ public protocol TargetType {
/// Essentially this should be part of `Task`, however headers are often reused by a `Target`'s endpoints.
var headers: [String: String]? { get }

/// Factory method that converts a `TargetStub` into an immediate `Data` response.
func makeResponse(from stub: TargetStub) -> Observable<Data>
}

/// Defines the contract for API structures that provide stubs for themselves.
/// It is recommended to implement this protocol separately in the Tests target.
public protocol TargetType: ProductionTargetType where TargetStub == Stub {
/// Provides stub data for use in testing.
var sampleData: Data { get }
}

// MARK: - Default implementations
public extension ProductionTargetType where TargetStub == ProductionStub {
func makeResponse(from stub: TargetStub) -> Observable<Data> {
switch stub {
case .success(let data):
return .just(data)
case .error(let error):
return .error(error)
}
}
}

public extension TargetType where TargetStub == Stub {
func makeResponse(from stub: TargetStub) -> Observable<Data> {
switch stub {
case .default:
return .just(sampleData)
case .success(let data):
return .just(data)
case .error(let error):
return .error(error)
}
}
}
34 changes: 2 additions & 32 deletions Tests/Example/MockAPI.swift → Tests/Example/ExampleAPI.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
//
// MockAPI.swift
// RxCocoaNetworking-Example-macOS
//
// Created by Marcelo Gobetti on 6/3/18.
// Copyright © 2018 gobetti. All rights reserved.
//

import Foundation
import RxCocoaNetworking

enum MockAPI {
enum ExampleAPI {
case deleteRating(movieID: String)
case rate(movieID: String, rating: Float)
case reviews(movieID: String, page: Int)
}

extension MockAPI: TargetType {
extension ExampleAPI: ProductionTargetType {
var baseURL: URL { return URL(string: "http://localhost:8080")! }

var path: String {
Expand Down Expand Up @@ -45,26 +37,4 @@ extension MockAPI: TargetType {
}

var headers: [String : String]? { return nil }

var sampleData: Data {
switch self {
case .deleteRating:
return JSONHelper.data(fromFile: "delete-movie-rating")
case .rate:
return JSONHelper.data(fromFile: "rate-movie")
case .reviews:
return JSONHelper.data(fromFile: "get-movie-reviews")
}
}
}

private final class JSONHelper {
static func data(fromFile fileName: String) -> Data {
let bundle = Bundle(for: self)

// Nil-coalescing allows the resource to be found when testing with `swift test`:
let resourceURL = bundle.url(forResource: fileName, withExtension: "json") ??
bundle.resourceURL!.appendingPathComponent("../../../../../../wiremock/__files/\(fileName).json")
return try! Data(contentsOf: resourceURL)
}
}
Loading

0 comments on commit 5dacdd8

Please sign in to comment.