WebApiClient is an application framework aimed at making RESTful web service requests in a standardized way. It works by configuring named routes for each web service endpoint so your application focuses on consuming the logical web service API, not HTTP implementation details.
The project is divided into modules, staring with a Core module and then branching off into various supporting modules that add functionality like object mapping, caching, and UI support.
The Core module provides a HTTP client framework based on routes with support for object mapping for transforming requests and responses between native objects and serialized forms, such as JSON. This module provides just a protocol based API and some scaffolding classes to support the API, but does not provide an actual full implementation itself, so that different HTTP back-ends can be used as needed. The AFNetworking module provides a full implementation of the API.
The WebApiClient protocol defines the main HTTP client entry point for applications to use. The API is purposefully simple and based on asynchronous block callbacks:
- (void)requestAPI:(NSString *)name
withPathVariables:(id)pathVariables
parameters:(id)parameters
data:(id<WebApiResource>)data
finished:(void (^)(id<WebApiResponse> response, NSError *error))callback;
An example invocation of this API might look like this:
// make a GET request to /documents/123
[client requestAPI:@"doc" withPathVariables:@{@"uniqueId" : @123 } parameters:nil data:nil
finished:^(id<WebApiResponse> response, NSError *error) {
if ( !error ) {
MyDocument *doc = response.responseObject;
} else if ( response.statusCode == 422 ) {
// handle 422 (validation) errors here...
}
}];
By default the callback block is called on the main thread (queue). If you prefer to have the callabck on a specific queue, you can use an alternate method that accepts a dispatch queue as a parameter. In that case, the passed in queue will be used for the callback:
- (void)requestAPI:(NSString *)name
withPathVariables:(id)pathVariables
parameters:(id)parameters
data:(id<WebApiResource>)data
queue:(dispatch_queue_t)callbackQueue
progress:(nullable WebApiClientRequestProgressBlock)progressCallback
finished:(void (^)(id<WebApiResponse> response, NSError *error))callback;
The same method that accepts an explicit callback block shown in the previous section
also accepts an optional WebApiClientRequestProgressBlock
, which is defined as this:
typedef void (^WebApiClientRequestProgressBlock)(NSString *routeName,
NSProgress * _Nullable uploadProgress,
NSProgress * _Nullable downloadProgress);
By passing in this type of block to the progress
parameter, you can monitor both
the upload and download progress of the HTTP request. In addition the
WebApiClientRequestDidProgressNotification
and
WebApiClientResponseDidProgressNotification
notifications can be used to listen for
progress updates as well. The WebApiClientProgressNotificationKey
notification user
info key will contain the relevant NSProgress
object.
Sometimes it can be useful to make a blocking, synchronous request to get a HTTP resource. WebApiClient supports that as well:
- (id<WebApiResponse>)blockingRequestAPI:(NSString *)name
withPathVariables:(id)pathVariables
parameters:(id)parameters
data:(id<WebApiResource>)data
maximumWait:(NSTimeInterval)maximumWait
error:(NSError **)error;
Calling this method will block the calling thread until either the response is
available or maximumWait
seconds have elapsed.
The WebApiRoute protocol defines a single API endpoint definition,
assigned a unique name. Routes are typically configured when an application starts
up. Each route defines some standardized properties, such as a HTTP method
and URL
path
. For convenience, routes support arbitrary property access via Objective-C's
keyed subscript support, so the following is possible:
id<WebApiRoute> myRoute = ...;
// access the path property
NSString *path1 = myRoute.path;
// access the path property using keyed subscript notation
NSString *path2 = myRoute[@"path"];
// access some arbitrary property not defined in WebApiRoute specifically
id something = myRoute[@"extendedProperty"];
For even more convenience, WebApiRoute provides
extensions to NSDictionary
and NSMutableDictionary
so
that they conform to WebApiRoute
and MutableWebApiRoute
, respectively. That means
you can use dictionaries directly as routes, like this:
// define a route
id<WebApiRoute> myRoute = @{ @"name" : "login", @"path" : @"user/login", @"method" : @"POST" };
// create a mutable copy and extend
id<MutableWebApiRoute> mutableRoute = [myRoute mutableCopy];
mutableRoute[@"extendedProperty"] = @"special";
The WebApiDataMapper protocol defines an API for encoding
native objects into HTTP requests and mapping HTTP responses into native objects.
Routes can be configured with a dataMapper
property to support this feature. The
API is also pretty simple:
@protocol WebApiDataMapper <NSObject>
// Map a source data object into some domain object.
- (id)performMappingWithSourceObject:(id)sourceObject route:(id<WebApiRoute>)route error:(NSError *__autoreleasing *)error;
// Encode a domain object into an encoded form, such as @c NSDictionary or @c NSData.
- (id)performEncodingWithObject:(id)domainObject route:(id<WebApiRoute>)route error:(NSError *__autoreleasing *)error;
@end
The AFNetworking module provides a full implementation of the WebApiClient
API,
based on AFNetworking and NSURLSession
.
Routes can be configured in code via the registerRoute:forName:
method, but more
conveniently they can be configured via BREnvironment. The webservice.api
key will be inspected by default, and can be a dictionary representing all the routes
that should be registered for the application. For example, the following JSON would
register three routes, login
, register
, and absolute
:
{
"App_webservice_protocol" : "https",
"App_webservice_host" : "example.com",
"App_webservice_port" : 443,
"webservice" : {
"api" : {
"register" : {
"method" : "POST",
"path" : "user/register",
},
"login" : {
"method" : "POST",
"path" : "user/login",
},
"absolute" : {
"method" : "GET",
"path" : "https://example.com/something"
}
}
}
}
You'll notice that the register
and login
routes have relative paths. All
webservice URLs are constructed as relative to a configurable baseApiURL
property,
which by default is configured via the various App_webservice_*
BREnvironment keys you can see in the previous example JSON.
The gzip
property on routes is supported. When set to true
any request data
will be compressed and a request HTTP header of Content-Encoding: gzip
will be
added.
To support compressed response data (highly recommended!) you only need to
configure an Accept-Encoding: gzip
HTTP header, either individually on routes via
the requestHeaders
property or via the globalHTTPRequestHeaders
property
available on AFNetworkingWebApiClient
.
Here's an example route that configures both request and response compression:
{
"webservice" : {
"api" : {
"trim" : {
"method" : "POST",
"path" : "upload/jumbo",
"gzip" : true,
"requestHeaders" : {
"Accept-Encoding" : "gzip"
}
}
}
}
}
Raw data can be uploaded directly in the body of the HTTP request. This can be
useful, for example, when you need to upload images, or any other type of data, and
the URL contains sufficient information to identify the content. To perform a raw
data upload, pass a WebApiResource instance on the data
parameter
in the WebApiClient API. WebApiClient provides two implementations of
WebApiResource
: DataWebApiResource
for in-memory data and FileWebApiResource
for file-based data. The WebApiResource
instance you pass in will be sent directly
in the body of the request, and appropriate Content-Type
and Content-MD5
HTTP
headers will be included. This means the parameters
object is ignored. If you need
to post both parameters and a file, use the multipart/form-data
upload method
described in the next section.
Instead of uploading raw data, you can also upload using a multipart/form-data
attachment encoding by passing a WebApiResource instance on the
data
parameter in the WebApiClient API and configuring the route with a
serialization type of form
, like this:
{
"webservice" : {
"api" : {
"trim" : {
"method" : "POST",
"path" : "upload/image",
"serializationName" : "form"
}
}
}
}
The WebApiClient API parameters
object, if provided, will also be included in the
request, serialized into additional parts of the request body.
You can configure a route to save the response data into a file, instead of the
default of loading the response in RAM, by adding a saveAsResource
property with a
truthy value, like this:
{
"webservice" : {
"api" : {
"download" : {
"method" : "GET",
"path" : "download/image",
"saveAsResource" : true
}
}
}
}
Then the responseObject
returned in the WebApiResponse
will be a
WebApiResource which you can then move to an appropriate location
as needed, for example:
[client requestAPI:@"download" withPathVariables:nil parameters:nil data:nil
queue:dispatch_get_main_queue()
progress:nil
finished:^(id<WebApiResponse> response, NSError *error) {
if ( !error ) {
id<WebApiResource> resource = response.responseObject;
NSURL *dest = [NSURL fileURLWithPath:@"/some/path"];
[[NSFileManager defaultManager] moveItemAtURL:[resource URLValue] toURL:dest error:nil];
}
}];
The Cache module provides response caching support to the WebApiClient
API by
providing the PINCacheWebApiClient
proxy that can cache
result objects using PINCache. Caching support is enabled by configuring a
cacheTTL
property on routes, for example:
{
"webservice" : {
"api" : {
"info" : {
"method" : "GET",
"path" : "infrequentlyupdated/info",
"cacheTTL" : 3600
}
}
}
}
See the CachingWebApiRoute protocol for more route cache details.
You can also configure a route so that it invalidates any cached data for other routes. A good example of where this is useful is when you define a list route that returns a list of objects, and another add route to add to that same list of objects. We can make the latter route invalidate the cached data of the former like this:
{
"webservice" : {
"api" : {
"list" : {
"method" : "GET",
"path" : "stuff/list",
"cacheTTL" : 3600
},
"add" : {
"method" : "PUT",
"path" : "stuff/:thingId",
"invalidatesCachedRouteNames" : [ "list" ]
}
}
}
}
The invalidatesCachedRouteNames
is configured as an array of route names that
should be invalidated when that route is called successfully.
By default URL query parameters will be included when calculating the cache key for
each route. Sometimes it can be useful to ignore the query parameters, however. For
example, pre-signed Amazon S3 resource URLs contain authorization query parameters
that change each time the same resource is requested. By configuring the route with
cacheIgnoreQueryParameters = YES
then the query parameters for that route will be
not be included in the request's cache key:
{
"webservice" : {
"api" : {
"info" : {
"method" : "GET",
"path" : "https://s3.amazon.com/info/foo.txt",
"cacheTTL" : 3600,
"cacheIgnoreQueryParameters" : true
}
}
}
}
PINCacheWebApiClient
supports a keyDiscriminator
property
that can be changed at runtime to support isolating all cached route data into
groups. The main use case for this is to support multi-user apps where route URLs do
not contain user-identifying parameters, so when different users are signed in they
don't see cached data from some other user. You can assign the active user's unique
identifier to the keyDiscriminator
property and then all cached data becomes
user-specific. When a user logs out and a different user logs in, change the
keyDiscriminator
to the new user's identifier.
The CachingWebApiClient
API adds a method that can be used
for testing if cached data is available:
- (void)requestCachedAPI:(NSString *)name
withPathVariables:(nullable id)pathVariables
parameters:(nullable id)parameters
queue:(dispatch_queue_t)callbackQueue
finished:(void (^)(id<WebApiResponse> _Nullable response, NSError * _Nullable error))callback;
The callback will be passed a response only if the data was available in the cache. Sometimes its handy to know if something is already downloaded!
The RestKit module provides an object mapping implementation for the
WebApiClient
API based on the RestKit. It provides a way to transform native
objects into JSON, and vice versa. This module only makes use of the
RestKit/ObjectMapping
module, so it does not conflict with AFNetworking 2. In fact,
part of the motivation for WebApiClient was to be able to use AFNetworking 2 with
RestKit's object mapping support because RestKit's networking layer is based on
AFNetworking 1. In some respects the WebApiClient API provides some of the same
scaffolding that the full RestKit project provides.
The RestKitWebApiDataMapper
class supports a shared
singleton pattern that your application can configure when it starts up with any
required RKObjectMapping
objects. You configure it like this:
RestKitWebApiDataMapper *dataMapper = [RestKitWebApiDataMapper sharedDataMapper];
// get RestKit mapper for user objects
RKObjectMapper *userObjectMapper = ...;
// register user mapper for requests and responses
[dataMapper registerRequestObjectMapping:[userObjectMapper inverseMapping] forRouteName:@"login"];
[dataMapper registerResponseObjectMapping:userObjectMapper forRouteName:@"login"];
The RestKitWebApiDataMapper
class also supports
block-based mapping hooks for both request encoding and response mapping. The blocks
are executed after any configured RKObjectMapper
has done its job on the request
or response data.
One useful example of this support is for wiring up parent-child relationship
properties that are implied by the data. Imagine a Person
class that has an array
of child Person
objects, and each child has a parent
property that points to its
parent Person
instance:
@interface Person : NSObject
@property (strong) NSString *name;
@property (strong) NSArray<Person *> *children;
@property (weak) Person *parent;
@end
The server returns JSON like this:
{
"name" : "John Doe",
"children" : [
{ "name" : "Johnny Doe" },
{ "name" : "Jane Doe" }
]
}
To populate each child's parent
property with the John Doe object, a response
mapping block could be configured like this:
[dataMapper registerResponseMappingBlock:^(id sourceObject, id<WebApiRoute> route, NSError * __autoreleasing *error) {
if ( [sourceObject isKindOfClass:[Person class]] ) {
// populate the Child -> Parent relationship
Person *parent = sourceObject;
for ( Person *child in parent.children ) {
child.parent = parent;
}
}
return sourceObject;
} forRouteName:@"parent-child-tree"];
To use RestKit-based object mapping with a route, you configure the dataMapper
property of the route with RestKitWebApiDataMapper
like this:
{
"webservice" : {
"api" : {
"login" : {
"method" : "POST",
"path" : "user/login",
"dataMapper" : "RestKitWebApiDataMapper"
}
}
}
}
Sometimes the request or response JSON needs to be nested in some top-level object. For example imagine that the register endpoint expects the user object to be posted as JSON like this:
{
"user" : { "email" : "joe@example.com", "name" : "Joe" }
}
This can be done by adding a dataMapperRequestRootKeyPath
property (or
dataMapperResponseRootKeyPath
for mapping responses), like this:
{
"webservice" : {
"api" : {
"login" : {
"method" : "POST",
"path" : "user/login",
"dataMapper" : "RestKitWebApiDataMapper",
"dataMapperRequestRootKeyPath" : "user"
}
}
}
}
The UI module provides some UI utilities, such as the
WebApiClientActivitySupport
class that listens to
route requests and for those that specify preventUserInteraction
with a truthy
value will throw up a full-screen "request taking too long" view to let the user of
the app know it's waiting for a response. For example, a route can be configured like:
{
"webservice" : {
"api" : {
"login" : {
"method" : "POST",
"path" : "user/login",
"preventUserInteraction" : true
}
}
}
}