TestsTested | ✓ |
LangLanguage | SwiftSwift |
License | MIT |
ReleasedLast Release | Oct 2017 |
SwiftSwift Version | 4.0 |
SPMSupports SPM | ✗ |
Maintained by Mauricio Cousillas, Diego Ernst, Martin Barreto, Karina XL.
Depends on: | |
Alamofire | ~> 4.5.1 |
RxSwift | = 4.0.0 |
RxCocoa | = 4.0.0 |
Made with
Protocol-Oriented Network abstraction layer written in Swift. Greatly inspired by RxPagination project but working on top of Alamofire and the JSON parsing library of your choice.
RouteType
conformance.PaginationRequestType
conformance.OperaDecodable
protocol conformance.OperaError
type. OperaSwift OperaError
indicates either an NSURLSession
error, Alamofire
error, or your JSON parsing library error.Alamofire.Request
that return either a Single
of a JSON serialized type or an array if it or a completable sequence. NetworkError is passed when error event happens.PaginationRequestType
that return a Single
of a PaginationResponseType
which contains the serialized elements and information about the current, next and previous page.RouteType.sampleData
.RequestAdapters
through CompositeAdapter
.RouteType
and upload progress on MultipartRouteType
.A RouteType
is a high level representation of the request for a REST API endpoint. By adopting the RouteType
protocol a type is able to create its corresponding request.
import Alamofire
import OperaSwift
// just a hierarchy structure to organize routes
struct GithubAPI {
struct Repository {}
}
extension GithubAPI.Repository {
struct Search: RouteType {
var method: HTTPMethod { return .get }
var path: String { return "search/repositories" }
}
struct GetInfo: RouteType {
let owner: String
let repo: String
var method: HTTPMethod { return .get }
var path: String { return "repos/\(owner)/\(repo)" }
}
}
Alternatively, you can opt to conform to
RouteType
form an enum where each enum value is a specific route (api endpoint) with its own associated values.
If you are curious check out the rest of RouteType protocol definition.
As you may have seen, any type that conforms to RouteType
must provide baseUrl
and the Alamofire manager
instance.
Usually these values do not change among our routes so we can provide them by implementing a protocol extension over RouteType
as shown below.
extension RouteType {
var baseURL: URL {
return URL(string: "https://api.github.com")!
}
var manager: ManagerType {
return Manager.singleton
}
}
Now, by default, all
RouteType
s we define will providehttps://api.github.com
asbaseUrl
andManager.singleton
asmananger
. It's up to you to customize it within a specific RouteType protocol conformance.
To avoid having to implement the method
property in every RouteType
Opera provides A protocol for each HTTPMethod so you can implement those:
protocol GetRouteType: RouteType {}
protocol PostRouteType: RouteType {}
protocol OptionsRouteType: RouteType {}
protocol HeadRouteType: RouteType {}
protocol PutRouteType: RouteType {}
protocol PatchRouteType: RouteType {}
protocol DeleteRouteType: RouteType {}
protocol TraceRouteType: RouteType {}
protocol ConnectRouteType: RouteType {}
They are pretty simple, they only implement the method
property of RouteType
with the HTTPMethod that matches.
struct Upload: ImageUploadRouteType {
let image: UIImage
let encoding: ImageUploadEncoding = .jpeg(quality: 0.80)
let path = "/upload"
let baseURL = URL(string: "...")!
}
And then use it like this:
Upload(image: UIImage(named: "myImage")!)
.rx
.completable()
.subscribe(
onCompleted: {
// success :)
},
onError: { error in
// do something when something went wrong
}
)
.addDisposableTo(disposeBag)
Note: If you want to upload a generic list of files through
an HTTP multipart request, useMultipartRouteType
instead.
At this point we can easily create an Alamofire Request:
let request: Request = GithubAPI.Repository.GetInfo(owner: "xmartlabs", repo: "Opera").request
Notice that
RouteType
conforms toAlamofire.URLConvertible
so having the manager we can create the associatedRequest
.
We can also take advantage of the reactive helpers provided by Opera:
request
.rx.collection()
.subscribe(
onNext: { (repositories: [Repository]) in
// do something when networking and Json parsing completes successfully
},
onError: {(error: Error) in
// do something when something went wrong
}
)
.addDisposableTo(disposeBag)
getInfoRequest
.rx.collection()
.subscribe(
onSuccess: { (repositories: [Repository]) in
// do something when networking and Json parsing completes successfully
},
onError: {(error: Error) in
guard let error = error as? OperaError else {
//do something when it's not an OperaError
}
// do something with the OperaError
}
)
.addDisposableTo(disposeBag)
If you are not interested in decode your JSON response into a Model you can invoke
request.rx.any()
which returns anSingle
ofAny
for the current request and propagates aOperaError
error through the result sequence if something goes wrong.
If you are using the reactive helpers (which are awesome btw!) you can handle the errors on the onError
callback which returns an Error
that, in case of Networking or Parsing issues, can be casted to OperaError
for easier usage.
OperaError
wraps any error that is Networking or Parsing related. Keep in mind that you have to cast the Error
on the onError
callback before using it.
OperaError
also provides a set of properties that make accessing the error's data easier:
public var error: Error
public var request: URLRequest?
public var response: HTTPURLResponse?
public var body: Any?
public var statusCode: Int?
public var localizedDescription: String
Example:
getInfoRequest
.rx.object()
.subscribe(
onError: {(error: Error) in
guard let error = error as? OperaError else {
//do something when it's not an OperaError
}
// do something with the OperaError
debugPrint("Request failed with status code \(error.statusCode)")
}
)
.addDisposableTo(disposeBag)
Every RouteType
can optionally chain a download progress handler through its reactive extension:
let request: RouteType = ...
request
.rx.collection()
.downloadProgress {
debugPrint("Download progress: \($0.fractionCompleted)")
}
.subscribe(
onNext: { (repositories: [Repository]) in
// do something when networking and Json parsing completes successfully
},
onError: {(error: Error) in
// do something when something went wrong
}
)
.addDisposableTo(disposeBag)
Only if the routeType is a MultipartRouteType
we can also chain an upload progress handler:
ImageUploadRouteType
is a specificMultipartRouteType
to easily upload images.
let imageUpload: ImageUploadRouteType = ...
imageUpload
.rx
.uploadProgress {
debugPrint("Upload progress: \($0.fractionCompleted)")
}
.downloadProgress {
debugPrint("Download progress: \($0.fractionCompleted)")
}
.completable()
.subscribe(
onCompleted: {
debugPrint("Completed")
},
onError: { error in
...
}
)
.addDisposableTo(disposeBag)
We've said Opera is able to decode JSON response into a Model using your favorite JSON parsing library. Let's see how Opera accomplishes that.
At Xmartlabs we have been using
Decodable
as our JSON parsing library since March 16. Before that we had used Argo, ObjectMapper and many others. I don't want to deep into the reason of our JSON parsing library choice (we do have our reasons ;)) but during Opera implementation/design we thought it was a good feature to be flexible about it.
This is our Repository model...
struct Repository {
let id: Int
let name: String
let desc: String?
let company: String?
let language: String?
let openIssues: Int
let stargazersCount: Int
let forksCount: Int
let url: NSURL
let createdAt: NSDate
}
and OperaDecodable
protocol:
public protocol OperaDecodable {
static func decode(_ json: Any) throws -> Self
}
Since OperaDecodable
and Decodable.Decodable
require us to implement the same method, we only have to declare protocol conformance.
// Make Repository conforms to Decodable.Decodable
extension Repository: Decodable {
static func decode(j: Any) throws -> Repository {
return try Repository.init( id: j => "id",
name: j => "name",
desc: j =>? "description",
company: j =>? ["owner", "login"],
language: j =>? "language",
openIssues: j => "open_issues_count",
stargazersCount: j => "stargazers_count",
forksCount: j => "forks_count",
url: j => "url",
createdAt: j => "created_at")
}
}
// Declare OperaDecodable adoption
extension Repository : OperaDecodable {}
Using Argo is a little bit harder, we need to implement OperaDecodable
in addition to declare the protocol adoption. Here is where swift language protocol extension feature comes in handy....
extension Argo.Decodable where Self.DecodedType == Self, Self: OperaDecodable {
static func decode(json: Any) throws -> Self {
let decoded = decode(JSON.parse(json))
switch decoded {
case .Success(let value):
return value
case .Failure(let error):
throw error
}
}
}
Now we can make any Argo.Decodable model conform to OperaDecodable
by simply declaring OperaDecodable
protocol adoption.
extension Repository : OperaDecodable {}
Opera can be used along with RxAlamofire.
Opera represents pagination request through PaginationRequestType
protocol which also conforms to URLRequestConvertible
. Typically we don't need to create a new type to conform to it. Opera provides PaginationRequest<Element: OperaDecodable>
generic type that can be used in most of the scenarios.
One of the requirements to adopt PaginationRequestType
is to implement the following initializer:
init(route: RouteType, page: String?, query: String?, filter: FilterType?, collectionKeyPath: String?)
so we create a pagination request doing:
let paginationRequest: PaginationRequest<Repository> = PaginationRequest(route: GithubAPI.Repository.Search(), collectionKeyPath: "items")
Repositories JSON response array is under "items" key as github repositories api documentation indicates so we pass
"items"
ascollectionKeyPath
parameter.
A PaginationRequestType
wraps up a RouteType
instance and holds additional info related with pagination such as query string, page, filters, etc. It also provides some helpers to get a new pagination request from the current pagination request info updating its query string, page or filters value.
let firtPageRequest = paginatinRequest.routeWithPage("1").request
let filteredFirstPageRequest = firtPageRequest.routeWithQuery("Eureka").request
Another variant of the previous helpers is
public func routeWithFilter(filter: FilterType) -> Self
.
Finally let's look into PaginationViewModel
generic class thats allows us to list/paginate/sort/filter decodable items in a very straightforward way.
import UIKit
import RxSwift
import RxCocoa
import Opera
class SearchRepositoriesController: UIViewController {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet weak var searchBar: UISearchBar!
lazy var viewModel: PaginationViewModel<PaginationRequest<Repository>> = {
return PaginationViewModel(paginationRequest: PaginationRequest(route: GithubAPI.Repository.Search(), collectionKeyPath: "items"))
}()
override func viewDidLoad() {
super.viewDidLoad()
// set up views
tableView.backgroundView = emptyStateLabel
tableView.keyboardDismissMode = .OnDrag
// on viewWill appear load pagination view model by emitting false (do not cancel pending
// request if any) to view model `refreshTrigger` PublishSubject.
// viewModel is subscribed to `refreshTrigger` observable and starts a new request.
rx.sentMessage(#selector(SearchRepositoriesController.viewWillAppear(_:)))
.skip(1)
.map { _ in false }
.bindTo(viewModel.refreshTrigger)
.addDisposableTo(disposeBag)
// make model view loads next page when reaches table view bottom...
tableView.rx.reachedBottom
.bindTo(viewModel.loadNextPageTrigger)
.addDisposableTo(disposeBag)
// Updates activity indicator accordingly based on modelView `loading` variable.
viewModel.loading
.drive(activityIndicatorView.rx.animating)
.addDisposableTo(disposeBag)
// updates tableView observing viewModel `elements`, since github api only works
// if a query string is present we show no items if the first page is being loading
// or UISearchBar text is empty.
// By doing that whenever the search criteria is updated we take away all the item
// from the table view giving a sense of being fetching/searching the server.
// Notice the strongly typed `Repository` type below.
Driver.combineLatest(viewModel.elements.asDriver(), viewModel.firstPageLoading, searchBar.rx.text.asDriver()) { elements, loading, searchText in
return loading || searchText.isEmpty ? [] : elements
}
.asDriver()
.drive(tableView.rx.itemsWithCellIdentifier("Cell")) { _, repository: Repository, cell in
cell.textLabel?.text = repository.name
cell.detailTextLabel?.text = "🌟\(repository.stargazersCount)"
}
.addDisposableTo(disposeBag)
// whenever search bar text is changed, wait for 1/4 sec of search bar inactivity
// then update the `viewModel` pagination request type (will cancel any pending request).
// We propagates query string by binding it to viewModel.queryTrigger.
searchBar.rx.text
.filter { !$0.isEmpty }
.throttle(0.25, scheduler: MainScheduler.instance)
.bindTo(viewModel.queryTrigger)
.addDisposableTo(disposeBag)
// handles view empty state.
Driver.combineLatest(viewModel.emptyState, searchBar.rx.text.asDriver().throttle(0.25)) { $0 || $1.isEmpty }
.driveNext { [weak self] state in
self?.emptyStateLabel.hidden = !state
self?.emptyStateLabel.text = (self?.searchBar.text?.isEmpty ?? true) ? "Enter text to search repositories" : "No repositories found"
}
.addDisposableTo(disposeBag)
}
private lazy var emptyStateLabel: UILabel = {
let emptyStateLabel = UILabel()
emptyStateLabel.text = ControllerConstants.NoTextMessage
emptyStateLabel.textAlignment = .Center
return emptyStateLabel
}()
private let disposeBag = DisposeBag()
}
If you want to continue using the conventional Alamofire way to make requests, Opera makes this easy by providing the following response serializers.
extension Request {
/**
Generic response object serialization that returns a OperaDecodable instance.
- parameter keyPath: keyPath to look up JSON object to serialize. Ignore parameter or pass nil when JSON object is the JSON root item.
- parameter completionHandler: A closure to be executed once the request has finished.
- returns: The request.
*/
public func responseObject<T : OperaDecodable>(keyPath: String? = default, completionHandler: Response<T, OperaError> -> Void) -> Self
/**
Generic response object serialization that returns an Array of OperaDecodable instances.
- parameter collectionKeyPath: keyPath to look up JSON array to serialize. Ignore parameter or pass nil when JSON array is the JSON root item.
- parameter completionHandler: A closure to be executed once the request has finished.
- returns: The request.
*/
public func responseCollection<T : OperaDecodable>(collectionKeyPath: String? = default, completionHandler: Response<[T], OperaError> -> Void) -> Self
/**
Generic response object serialization. Notice that Response Error type is NetworkError.
- parameter completionHandler: A closure to be executed once the request has finished.
- returns: The request.
*/
public func responseAnyObject(completionHandler: Response<AnyObject, OperaError> -> Void) -> Self
}
Opera provides a way to use multiple RequestAdapter
to adapt your requests. The class CompositeAdapter
provides a way to setup a pipeline of RequestAdapter
that will be applied to your requests.
To use it you just have to create a CompositeAdapter
, add all your adapters ad set it as your NetworkManager's adapter.
Example:
let adapter = CompositeAdapter()
adapter.append(adapter: KeychainAccessTokenAdapter())
adapter.append(adapter: LanguageAdapter())
manager.adapter = adapter
Before contribute check the CONTRIBUTING file for more info.
If you use Opera in your app We would love to hear about it! Drop us a line on twitter.
Follow these 4 steps to run Example project:
By making any of them adopt URLRequestParametersSetup
protocol.
/**
* By adopting URLRequestParametersSetup a RouteType or PaginationRequestType is able to make a final customization to request parameters dictionary before they are encoded.
*/
public protocol URLRequestParametersSetup {
func urlRequestParametersSetup(urlRequest: NSMutableURLRequest, parameters: [String: AnyObject]?) -> [String: AnyObject]?
}
NSMutableURLRequest
that is not possible through RouteType and PaginationRouteType adoption?You can setup an Alamofire RequestAdapter
on your manager to customize your request right before sending it.
You can make PaginationRequest adopt PaginationRequestTypeSettings
.
/**
* By adopting PaginationRequestTypeSettings a PaginationRequestType is able to customize its default parameter names such as query, page and its first page value.
*/
public protocol PaginationRequestTypeSettings {
var queryParameterName: String { get }
var pageParameterName: String { get }
var firstPageParameterValue: String { get }
}
The default settings by Opera are the following ones:
This can be found in the CHANGELOG.md file.