Developed and Maintained by ipodishima Founder & CTO at Wasappli Inc.
Sponsored by Wisembly
A routing library to fetch objects from an API and map them to your app
- Highly customizable: network request, authentication and mapping layers are separate, you can create your own.
- Default network request layer built on top of AFNetworking 3.0
- Default mapping layer built on top of WAMapping
- Built-in object router
- Built-in batch manager
- Different configurations available
-
NSProgress
support - Tested and used on real projects
Go visit the wiki for more details about WANetworkRouting
advanced use.
WANetworkRouting
is a library which turns GET enterprises/:itemID
to Enterprise : NSManagedObject
from a simple configuration step.
Use Cocoapods, this is the easiest way to install the router.
pod 'WANetworkRouting'
#import <WANetworkRouting/WANetworkRouting.h>
WANetworkRoutingManager
is the core component of WANetworkRouting
. It handles all the requests for you.
The initialization should contains at least a request manager. WANetworkRouting
has a built in manager WAAFNetworkingRequestManager
built on top of AFNetworking 3.0
.
// Create a request manager
WAAFNetworkingRequestManager *requestManager = [WAAFNetworkingRequestManager new];
// Create the network routing manager
WANetworkRoutingManager *routingManager = [WANetworkRoutingManager managerWithBaseURL:[NSURL URLWithString:@"http://baseURL.com"]
requestManager:requestManager
mappingManager:nil
authenticationManager:nil];
** Note ** This routingManager
will have not mapping layer nor authentication layer. This example demonstrates how easy it is to test your API before configuring the mapping.
You can then perform any operations among GET|POST|PUT|PATCH|DELETE|HEAD
. For eg:
- Fetch all the enterprises
[routingManager getObjectsAtPath:@"enterprises"
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
NSDictionary *json = response.responseObject;
// Do something with the JSON
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
- Update an enterprise
[routingManager putObject:nil
path:@"enterprises/1"
parameters:@{@"key": @"newValue"}
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
This is where things become to be interesting
The idea is to add a mapping layer so that your json gets mapped as objects. This is done by using a default mapping service built on top of WAMapping
. I strongly encourage you to read the docs about how to configure the mapping.
There are 3 steps:
- Configure the mapping,
- Configure the response descriptor,
- Optional: configure the request descriptor.
Have a look at WAMapping
for every details about the mapping:
// Specify a store to use between WAMemoryStore, WANSCodingStore, WACoreDataStore, your own store.
WAMemoryStore *memoryStore = [[WAMemoryStore alloc] init];
// Create the mapping description for `Enterprise`
WAEntityMapping *enterpriseMapping = [WAEntityMapping mappingForEntityName:@"Enterprise"];
enterpriseMapping.identificationAttribute = @"itemID";
[enterpriseMapping addAttributeMappingsFromDictionary:@{
@"id": @"itemID",
@"name": @"name",
@"address.street_number": @"streetNumber"}];
// Create the mapping manager
WAMappingManager *mappingManager = [WAMappingManager mappingManagerWithStore:memoryStore];
WAAFNetworkingRequestManager *requestManager = [WAAFNetworkingRequestManager new];
// Create the network routing manager
WANetworkRoutingManager *routingManager = [WANetworkRoutingManager managerWithBaseURL:[NSURL URLWithString:@"http://baseURL.com"]
requestManager:requestManager
mappingManager:mappingManager
authenticationManager:nil];
// Add a default date formatter on mapper
id(^toDateMappingBlock)(id ) = ^id(id value) {
if ([value isKindOfClass:[NSString class]]) {
return [dateFormatter dateFromString:value];
}
return value;
};
[mappingManager addDefaultMappingBlock:toDateMappingBlock
forDestinationClass:[NSDate class]];
This will map a path with a mapping. For example, when fetching /enterprises
, you would get a JSON as
{
"array_of_enterprises": [{
"name": "Enterprise 1"
}, {
"name": "Enterprise 2"
}]
}
The request descriptor would then be as follows:
WAResponseDescriptor *enterprisesResponseDescriptor =
[WAResponseDescriptor responseDescriptorWithMapping:enterpriseMapping
method:WAObjectRequestMethodGET
pathPattern:@"enterprises" // The path on the URL
keyPath:@"array_of_enterprises"]; // The key path to access the enterprises on JSON
For a GET
or a PUT
on an enterprise, which would be returned as
{
"name": "Enterprise 1"
}
WAResponseDescriptor *singleEnterpriseResponseDescriptor =
[WAResponseDescriptor responseDescriptorWithMapping:enterpriseMapping
method:WAObjectRequestMethodGET | WAObjectRequestMethodPUT // Specify multiple methods
pathPattern:@"enterprises/:itemID" // The path
keyPath:nil]; // The response would directly returns the object
Please note the syntax enterprises/:itemID
: :
means that the value would be replaced dynamically knowning that itemID
is the property name on client side.
Finally, add the response descriptors to the mapping manager
[mappingManager addResponseDescriptor:enterprisesResponseDescriptor];
[mappingManager addResponseDescriptor:singleEnterpriseResponseDescriptor];
You can now use WANetworkRouting
as this:
[routingManager getObjectsAtPath:@"enterprises"
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
NSArray<Enterprise *> *enterprises = mappedObjects;
// Do something with the enterprises
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
This step is optional, but allows you to reverse map an object to send it to the server.
Rather than creating by yourself the parameters dictionary on a POST
for example, you could write:
WARequestDescriptor *enterpriseRequestDescriptor =
[WARequestDescriptor requestDescriptorWithMethod:WAObjectRequestMethodPOST
pathPattern:@"enterprises" // The path on the URL
mapping:enterpriseMapping
shouldMapBlock:nil // On optional block which let you configure rather you want to reverse map a relation ship or not
requestKeyPath:nil]; // The key path for the final dictionary
[mappingManager addRequestDescriptor:enterpriseRequestDescriptor];
Please note that the requestKeyPath
fits with how your object would be wrapped on the parameters. If nil, the dictionary is the object as dictionary.
{
"requestKeyPathValue": {
"name": "Enterprise 1"
}
}
This allows you to post an object like this
Enterprise *enterprise = [Enterprise new];
enterprise.name = @"Test";
[routingManager postObject:enterprise
path:@"enterprises"
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
This will reverse map the enterprise
to a dictionary and send it as parameters on POST enterprise
. All magically without anymore work!
Note: If enterprise
has no value for its identificationAttribute
(see the mapping), the enterprise object would be automatically deleted from the store since the object returned by the server would be a new one with an identificationAttribute
. Said in other words: you do not have to deal with potential duplicates.
We just saw this call
[routingManager postObject:enterprise
path:@"enterprises"
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
Wouldn't it be great to write instead?
[routingManager postObject:enterprise
path:nil // No value for path
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
Here comes the router!
WANetworkRoute *postEnterpriseRoute =
[WANetworkRoute routeWithObjectClass:[Enterprise class]
pathPattern:@"enterprises"
method:WAObjectRequestMethodPOST];
[routingManager.router postEnterpriseRoute];
Each time you are trying to POST
an object of class Enterprise
, it will supply the enterprises
path for you.
Here is an other example with GET
and PUT
routes:
WANetworkRoute *enterpriseRoute =
[WANetworkRoute routeWithObjectClass:[Enterprise class]
pathPattern:@"enterprises/:itemID"
method:WAObjectRequestMethodGET | WAObjectRequestMethodPUT];
[routingManager.router addRoute:enterpriseRoute];
This layer is important if your API has some authentication. Basically you should have a login/signup endpoint which returns some kind of token that you can renew if the requests gets an error like 401: token expired
.
By implementing the simple WARequestAuthenticationManagerProtocol
protocol, and passing an instance of your class to the router manager, it will:
- Ask to authenticate the
NSMutableURLRequest
. Should be a token onAuthorization
HTTP header field. - Ask if a request should be replayed: you received
401: token expired
for example then yes. - Ask you to authenticate (renew the authorization somehow) and replay the request (
[routingManager enqueueRequest:]
) (The request will automatically be re authorized for you).
This will allow the routing manager to run every requests without any surprise when authentication has expired!
The batch managers answers two needs:
1/ Let's say that you want to minimize the impact on server of having multiple calls to perform at the same time. Like GET /me
, GET /meetings
, GET /configuration
2/ Have offline support, for example add notes to a meeting and automatically sync them when you are back online!
Well, WABatchManager
is there for you
This library comes with a built-in batch manager from the following specifications. You can either create an API which conforms to the specification, or create a new batch manager from WABatchManager
which fits your needs.
The specifications: (this is closely following the batch api requests by facebook Facebook batch API)
- Set a limit per session
- The data sent has the following format
{
"batch": [{
"uri": "\/meetings\/Ufd8f4e\/notes",
"method": "POST",
"body": "{\"note\":{\"hash\":\"817ebd76b6a89eff47bbd8e53f633c93eeadf23e\",\"title\":\"New first note\",\"type\":\"item\",\"position\":0}}",
"headers": {
"Content-Type": "application/json"
}
}, {
"uri": "\/meetings\/Ufd8f4e\/notes",
"method": "POST",
"body": "{\"note\":{\"hash\":\"f4e893ab9a754c273707b264359e7ec12262959d\",\"title\":\"New second note\",\"type\":\"note\",\"position\":1}}"
}]
}
Please note that uri
is relative to the base URL and body
is an encoded string
- Respond with
[
{
"body": "[{\"note\":\"...\"}]
"code": 200
},
{
"body": "[{\"note\":\"...\"}]
"code": 200
}
]
WABatchManager *batchManager = [[WABatchManager alloc] initWithBatchPath:@"/batch" limit:20];
routingManager = [WANetworkRoutingManager managerWithBaseURL:[NSURL URLWithString:kBaseURL]
requestManager:requestManager
mappingManager:mappingManager
authenticationManager:authManager
batchManager:batchManager];
// Create a batch session
WABatchSession *batchSession = [WABatchSession new];
// Enqueue the requests you want to perform
[batchSession addRequest:[WAObjectRequest requestWithHTTPMethod:WAObjectRequestMethodGET
path:@"/me"
parameters:nil
optionalHeaders:nil]];
[batchSession addRequest:[WAObjectRequest requestWithHTTPMethod:WAObjectRequestMethodGET
path:@"/meetings"
parameters:nil
optionalHeaders:nil]];
[batchSession addRequest:[WAObjectRequest requestWithHTTPMethod:WAObjectRequestMethodGET
path:@"/configuration"
parameters:nil
optionalHeaders:nil]];
// Send the session
[batchManager sendBatchSession:batchSession
successBlock:^(id<WABatchManagerProtocol> batchManager, NSArray<WABatchResponse *> *batchResponses) {
}
failureBlock:^(id<WABatchManagerProtocol> batchManager, WAObjectRequest *request, WAObjectResponse *response, id<WANRErrorProtocol> error) {
}];
The offline mode works out of the box.
Basically, you register routes describing requests which can be enqueued if there is a network issue (you should not include GET
) and you let the library do its job! It also integrates with mapping and authentication without any further step.
- Create routes to describe the requests to be enqueued
// Meetings
WANetworkRoute *modifyMeeting = [WANetworkRoute routeWithObjectClass:nil
pathPattern:@"meetings/:itemID"
method:WAObjectRequestMethodPUT | WAObjectRequestMethodeDELETE];
// Action items
WANetworkRoute *postActionItem = [WANetworkRoute routeWithObjectClass:nil
pathPattern:@"meetings/:itemID/notes"
method:WAObjectRequestMethodPOST];
- Add them to the batch manager
[batchManager addRouteToBatchIfOffline:modifyMeeting];
[batchManager addRouteToBatchIfOffline:postActionItem];
- Answers to errors
[self.apiManager putObject:meeting
path:nil
parameters:nil
success:...
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
SLDMeeting *meetingReturned = nil;
// Check if the error is a batch one
if ([[error.originalError.domain isEqualToString:WANetworkRoutingManagerErrorDomain] && error.originalError.code == WANetworkRoutingManagerErrorRequestBatched) {
// Return the meeting for any UI update
meetingReturned = meeting;
// Store a "has offline changes" flag
meeting.hasOfflineModifications = @YES;
// Save the store
[store save];
}
if (completion) {
completion(meetingReturned, error);
}
}];
- Wait for flush. The library automatically flush the offline changes on any request made while online. You can also manually trigger the flush with
if ([batchManager needsFlushing]) {
[batchManager flushDataWithCompletion:completion];
}
- Perform actions post flush
[batchManager setOfflineFlushSuccessBlock:^(id <WABatchManagerProtocol> batchManager, NSArray<WABatchResponse *> *batchResponses) {
if (![batchManager needsFlushing]) {
// Consider all meetings synchronized. You could also iterate trough batch responses
NSArray *notSyncedMeetings = [SLDMeeting findAll];
for (SLDMeeting *meeting in notSyncedMeetings) {
meeting.hasOfflineModifications = @NO;
}
[store save];
}
}];
You can track the progress of your request. This library uses NSProgress
class which is a great tool for dealing with progress.
If your app supports iOS 9+, there is the explicit child feature. You can then write:
(Please note that we are using a subclass of NSProgress
that you can find on the sample. This is because NSProgress
does not allow you to check if a progress already has a child :/)
WAProgress *mainProgress = [[WAProgress alloc] initWithParent:nil userInfo:nil];
mainProgress.totalUnitCount = 100; // 100%
// Add an observer to track the fraction completed
[mainProgress addObserver:self forKeyPath:NSStringFromSelector(@selector(fractionCompleted)) options:NSKeyValueObservingOptionNew context:nil];
}];
[routingManager getObjectsAtPath:@"posts"
parameters:nil
progress:^(WAObjectRequest *objectRequest, NSProgress *uploadProgress, NSProgress *downloadProgress, NSProgress *mappingProgress) {
// Add the progress as a child
[mainProgress addChildOnce:downloadProgress withPendingUnitCount:80]; // Network counts as 80% of the time
[mainProgress addChildOnce:mappingProgress withPendingUnitCount:20]; // Mapping counts as 20% of the time. This is arbitrary
}
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
self.posts = mappedObjects;
[self.tableView reloadData];
// Remove the observer!
[mainProgress removeObserver:self forKeyPath:NSStringFromSelector(@selector(fractionCompleted))];
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, id<WANRErrorProtocol> error) {
[mainProgress removeObserver:self forKeyPath:NSStringFromSelector(@selector(fractionCompleted))];
}];
...
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if ([object isKindOfClass:[NSProgress class]]) {
NSLog(@"New progress is %f", [change[NSKeyValueChangeNewKey] floatValue]);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
If your app targets less than iOS 9, I suggest you to calculate your own progress, this is simple since it is synchronous.
Because of NSProgress
, you can easily cancel the request:
You have to call [downloadProgress cancel]
and [mappingProgress cancel]
and it will cancel either the mapping or the server fetch task depending on where you are on the process.
Each api has a way to describe errors details others than the http codes (404, 403, 401, ...). For example, you could have a response like:
{
"error": {
"error_code": 4,
"error_description": "The token is expired"
}
}
The WANetworkRouting
allows you to customize the error handling and retrieve both code and error description. Let's see how:
It's an other protocol (again): WANRErrorProtocol
The routing manager comes with a default error class which does nothing except being allocated.
@interface MyAPIError : NSObject <WANRErrorProtocol>
@end
@implementation MyAPIError
- (instancetype)initWithOriginalError:(NSError *)error response:(WAObjectResponse *)response {
self = [super init];
if (self) {
self->_originalError = error;
self->_response = response;
self->_finalError = error;
NSDictionary *errorDescription = response.responseObject[@"error"];
NSInteger errorCode = [errorDescription[@"error_code"] integerValue];
NSString *errorDesc = errorDescription[@"error_description"];
self->_finalError = [NSError errorWithDomain:MyDomain
code:errorCode
userInfo:@{
NSLocalizedDescriptionKey: errorDesc
}];
}
return self;
}
@end
WAAFNetworkingRequestManager *requestManager = [WAAFNetworkingRequestManager new];
requestManager.errorClass = [MyAPIError class];
[routingManager postObject:enterprise
path:nil
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, MyAPIError *error) {
if (error.finalError.errorCode == 4) {
// Token has expired :/
}
}];
A quick word about deletion:
If you write
[routingManager deleteObject:enterprise
path:nil
parameters:nil
success:^(WAObjectRequest *objectRequest, WAObjectResponse *response, NSArray *mappedObjects) {
}
failure:^(WAObjectRequest *objectRequest, WAObjectResponse *response, MyAPIError *error) {
}];
This will automatically delete enterprise from the store on success! Be careful that if you delete an object only from it's ressource, it's up to you to delete it from your store ([routingManager deleteObject:nil path:@"enterprises/1"...]
).
// WAObjectResponse *response
NSInteger responseStatusCode = response.urlResponse.statusCode;
NSDictionary *httpHeaderFields = response.urlResponse.httpHeaderFields;
Let's be honest, you'll find some things which looks like Restkit.
I used RestKit for a while on a very big projects, but it tends to be too much magic and hard to maintain for our need which remains simple.
For example: upgrading AFNetworking
on RestKit is just... Well, something we wait for months!
By compartimenting the layers, I hope to fix this issue!
#Contributing : Problems, Suggestions, Pull Requests?
Please open a new Issue here if you run into a problem specific to WANetworkRouting.
For new features pull requests are encouraged and greatly appreciated! Please try to maintain consistency with the existing code style. If you're considering taking on significant changes or additions to the project, please ask me before by opening a new issue to have a chance for a merge. Please also run the tests before ;)
#That's all folks !
- If your are happy don't hesitate to send me a tweet @ipodishima!
- Distributed under MIT licence.
- Follow Wasappli on facebook