How to fit an elephant in the room: RestKit experience

1045 Views
|
27 Oct 2015
|
9 min
author avatar
Yana P.
Copywriter

This article covers the experience of using RestKit framework for a particular project. The specificity of the project was that it was already implemented backend was built and client iOS application was created. The app worked somehow badly and obviously didn't satisfy the customer. Our task was to rewrite the iOS app from scratch having absolutely no access to app's backend. It is worth noting that there was no RESTful web service, so the feasibility of RestKit usage is very controversial. Nevertheless, in order to explore the possibilities of this framework, we decided to use it and eventually managed to fit an elephant in the room. As a result, we found many useful solutions, which can be effective in other projects.

Mapping configuration

It's no secret that RestKit can map objects nested with other objects, which are linked by relationship. This feature is used to reduce the number of queries to the server and to receive a complete entity object with all relations per request. For example, the User model has attributes: userId, firstname, lastname of NSString type; and relationships: city of City type, location of Location type, and profession of Profession type. There is no need to make these four requests (there can be more requests in case User has more relationships):

[XXXAPI cityGetByUserId:userId]
[XXXAPI locationGetByUserId:userId]
[XXXAPI professionGetByUserId:userId]
[XXXAPI userGetByUserId:userId]

Instead, we can receive the full User model with only one request [ XXXAPI userGetByUserId:userId ], which will return the complete User model ready to use.

This approach reduces the number of requests to the server, which speeds up the response time of the application and simplifies the control of the object integrity. What to do, for example, if the requests cityGetByUserId and professionGetByUserId are successful, but locationGetByUserId fails? In this case, User object will not be recorded in a local database completely. The approach with one request increases the server workload, but you can always rent a more powerful server while you will never make application users move to LTE.

Let's suppose that the server returns the following response to the request [ XXXAPI cityGetByUserId:@(777) ]:

{
        "user":{
              "id":2587,
              "firstname":"Johnnie",
              "lastname":"Schaefer",
              "city":{
                    "id":2,
                    "name":"New New-York"
              },
              "location":{
                    "latitude":"40.773389",
                    "longitude":"-74.036196"
              },
              "profession":{
                    "id":911,
                    "name":"Fireman"
              }
      }
}

We need to configure mappings for these requests. Mapping is configured on two levels basic and composite. The fields of entity attributes are defined in the basic mapping, and the relations are added in the composite one. Hereinafter we will find out the reason why it is done. Let's begin to configure User base mapping:

+ (RKEntityMapping *)userMapping
{
    static RKEntityMapping *userMapping;
    if (!userMapping)
    {
        userMapping = [RKEntityMapping mappingForEntityForName:NSStringFromClass([User class])
                                          inManagedObjectStore:[RKObjectManager sharedManager].managedObjectStore];
        [userMapping addAttributeMappingsFromDictionary:@{
                                                          @"id"           : @"userId",
                                                          @"firstName"    : @"firstName",
                                                          @"lastName"     : @"lastName",
                                                           }];
        
        userMapping.identificationAttributes = @[@"userId"];
    }
    return userMapping;
}

We have described the basic fields of User entity userId, firstName, lastName. Now we need to add relationships to City, Profession, Location entities. It is done in the integral mapping:

+ (void)setupUserMapping
{
    RKEntityMapping *userMapping = [self userMapping];
    
    [userMapping addRelationshipMappingWithSourceKeyPath:@"city" mapping:[self cityMapping]];
    [userMapping addRelationshipMappingWithSourceKeyPath:@"profession" mapping:[self professionMapping]];
    [userMapping addRelationshipMappingWithSourceKeyPath:@"location" mapping:[self locationMapping]];
}

Note: Relation is declared in the basic mapping.

Basic mapping realization for Location:

+ (RKEntityMapping *)locationMapping
{
    static RKEntityMapping *locationMapping;
    if (!locationMapping) {
        locationMapping = [RKEntityMapping mappingForEntityForName:NSStringFromClass([Location class])
                                                  inManagedObjectStore:[RKObjectManager sharedManager].managedObjectStore];
        
        [userLocationMapping addAttributeMappingsFromDictionary:@{
                                                                  @"latitude" : @"latitude",
                                                                  @"longitude" : @"longitude"
                                                                  }];
    }
    return locationMapping;
}

Basic mapping realization for Profession:

+ (RKEntityMapping *)professionMapping
{
    static RKEntityMapping *professionMapping;
    if (!professionMapping) {
        professionMapping = [RKEntityMapping mappingForEntityForName:NSStringFromClass([Profession class])
                                                    inManagedObjectStore:[RKObjectManager sharedManager].managedObjectStore];
        
        [userProfessionMapping addAttributeMappingsFromDictionary:@{
                                                                    @"id" : @"professionId",
                                                                    @"name" : @"name",
                                                                     }];
        
        professionMapping.identificationAttributes = @[@"professionId"];
    }
    return professionMapping;
}

Basic mapping realization for City:

+ (RKEntityMapping *)cityMapping {
    static RKEntityMapping *cityMapping;
    if (!cityMapping)
    {
        cityMapping = [RKEntityMapping mappingForEntityForName:NSStringFromClass([City class])
                                          inManagedObjectStore:[RKObjectManager sharedManager].managedObjectStore];
        
        [cityMapping addAttributeMappingsFromDictionary:@{
                                                          @"id" : @"cityId",
                                                          @"name" : @"name",
                                                          }];
        
        cityMapping.identificationAttributes = @[@"cityId"];
    }
    return cityMapping;
}

Why do we need a two-level mapping?

When RestKit analyses server response and every object fields automatically get nil, if they have mapping but the server didn't return them. For instance, we have the object User with filled fields in the database:

"userId":2587 "firstname":"Johnnie" "lastname":"Schaefer"
"city":city object
"location":location object
"profession":profession object

Then we call the request [ XXXAPI userGetByUserId:userId ], which will return the response, for example, like:

"user":{ "id":2587 }>

Supposingly, this response will be parsed using the in so-called composite mapping, created in setupUserMapping method. As a result, User object in the local database will contain the following fields:

"userId":2587,
"firstname":nil,
"lastname":nil,
"city":nil,
"location":nil,
"profession":nil

As we see, All User fields, except for userId, return nil. This happens because mapping, which contains userld field as well as firstname, lastname fields, City, Profession and Location objects, is used to parse the given object of User type. So as these fields were not in the response, RestKit automatically assigns nil to them.

Hence we receive the following situation. The server in response returns objects with nested objects (the User object contains nested objects Profession, City, Location). These nested objects often have relationships with other objects, i.e. contain nested objects (for example, City can have relationship with Country). Such nesting can continue up to n levels deep. That's why when designing app, nesting depth is usually agreed with a backend developer. In practise, the first level is enough User will be completely returned with simple fields userId, firstname, lastname and with the objects of the first level of nesting Location, Profession, City. Still the objects of the first level of nesting (location, profession, city) will not contain nested objects, just simple attribute fields of type "latitude", "name", "id", etc.

Therefore, if for nested object parsing we use composite mapping (instead of basic), which has matches for simple field attributes, then relationships of nested objects will be assigned nil.

Problem with JSON server response

While parsing objects, RestKit is case-sensitive for attributes fields. For example, "sportId", "sportid" or "sportID" fields will be treated as three different fields and you will have to register a separate mapping in each case. So what can be done? The best thing to do is to ask backend developers to unify everything. But what to do if there are neither backend developers nor access to the server? There is one reasonable solution edit JSON on client.

To solve this problem we can use our own serialization class FUNSJSONSerialization, which will capture and edit JSON before sending it to RestKit. To begin with, let's register a new serialization class in AppDelegate where RestKit is configured:

[RKMIMETypeSerialization registerClass:[FUNSJSONSerialization class] forMIMEType:@"text/html"];

The serialization class is inherited from NSObject and supports protocol.

#import <Foundation/Foundation.h>
@interface FUNSJSONSerialization : NSObject <RKSerialization>
@end

In order to make a serialization class become the serialization class in its full essence, we have to realize both protocol methods in the implementation section:

+ (NSData *)dataFromObject:(id)object error:(NSError **)error + (id)objectFromData:(NSData *)data error:(NSError **)error

We are not interested in dataFromObject method, so we use the standard approach:

+ (NSData *)dataFromObject:(id)object error:(NSError **)error
{
    return [NSJSONSerialization dataWithJSONObject:object options:0 error:error];
}

However, in objectFromData method we have to make some edits into existing JSON:

+ (id)objectFromData:(NSData *)data error:(NSError **)error
{
    NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:error];
    NSDictionary *fixedDict = [jsonResponse dictionaryByFixingNameConvention];
    return fixedDict;
}

As it is clear from the code, we take initial JSON, serialize it by standard methods into the dictionary, and then this dictionary is transferred to dictionaryByFixingNameConvention method. What happens there?

This method is an NSDictionary category. Each key of the dictionary is reviewed there. If the key is equivalent to the line "sportid" or "sportID", we replace it by the 'correct' one "sportId". All other fields we transform the same way: for instance, "userid" or "userID" is transformed into "userId". It looks like this:

- (NSDictionary *)dictionaryByFixingNameConvention
{
    NSMutableDictionary *replaced = [self mutableCopy];
    for (NSString *key in self)
    {
        id object = [self objectForKey:key];
        
        if ([[key lowercaseString] isEqualToString:@"sportid"])
        {
            [replaced removeObjectForKey:key];
            [replaced setObject:object forKey:@"sportId"];
        }
        else if ([[key lowercaseString] isEqualToString:@"userid"])
        {
            [replaced removeObjectForKey:key];
            [replaced setObject:object forKey:@"userId"];
        }
    }
    return [NSDictionary dictionaryWithDictionary:[replaced copy]];
}

Now what to do in case the object doesn't belong to NSString class? The response may contain nested objects that may have 'wrong' keys. Here the options are limited. Nested object is either dictionary or array. In the first case we call the same dictionaryByFixingNameConvention method for nested object-dictionary. The method becomes recursive and processes all nested objects. In the second case we need to use the separate method arrayByFixingNameConvention, which will work similarly to dictionaryByFixingNameConvention method.

KeysNameConventionFix category for NSDictionary looks the following way:

#import "NSDictionary+KeysNameConventionFix.h"
#import "NSArray+KeysNameConventionFix.h"
@implementation NSDictionary (KeysNameConventionFix)
- (NSDictionary *)dictionaryByFixingNameConvention
{
    NSMutableDictionary *replaced = [self mutableCopy];
    
    for (NSString *key in self)
    {
        id object = [self objectForKey:key];
        
        if ([[key lowercaseString] isEqualToString:@"sportid"])
        {
            [replaced removeObjectForKey:key];
            [replaced setObject:object forKey:@"sportId"];
        }
        else if ([[key lowercaseString] isEqualToString:@"userid"])
        {
            [replaced removeObjectForKey:key];
            [replaced setObject:object forKey:@"userId"];
        }
        else if ([object isKindOfClass:[NSDictionary class]])
        {
            [replaced setObject:[object dictionaryByFixingNameConvention] forKey:key];
        }
        else if ([object isKindOfClass:[NSArray class]])
        {
            [replaced setObject:[object arrayByFixingNameConvention] forKey:key];
        }
    }
    return [NSDictionary dictionaryWithDictionary:[replaced copy]];
}
@end

Let's consider a similar category for the array and its unique method arrayByFixingNameConvention. This method is slightly easier than the similar method for the dictionary. The reason is that each element of the given array will be either another array or a dictionary. In the first case we recursively call this method, while in the second case we call dictionaryByFixingNameConvention method. Namely our task is to review all the elements of the array and call either dictionaryByFixingNameConvention or arrayByFixingNameConvention method for each element. That's it. No substitutions and editing in this case is required. Here is how the category will look:

#import "NSArray+KeysNameConventionFix.h"
#import "NSDictionary+KeysNameConventionFix.h"
@implementation NSArray (KeysNameConventionFix)
- (NSArray *)arrayByFixingNameConvention;
{
     NSMutableArray *replaced = [self mutableCopy];
    
    for (int idx = 0; idx < [replaced count]; idx++)
    {
        id object = [replaced objectAtIndex:idx];
        
        if ([object isKindOfClass:[NSDictionary class]])
        {
            [replaced replaceObjectAtIndex:idx withObject:[object dictionaryByFixingNameConvention]];
        }
        else if ([object isKindOfClass:[NSArray class]])
        {
            [replaced replaceObjectAtIndex:idx withObject:[object arrayByFixingNameConvention]];
        }
    }
    return [replaced copy];
}
@end

Response descriptors configuration and dynamic mapping

So, we have configured the mappings and edited incoming JSON. It's time to configure the descriptors, which help RestKit to define its behavior it recognizes the object and chooses what mapping to apply. Let's have a brief digression and remember what REST request actually is. Looking in Wikipedia we find out that REST stands for Representational State Transfer. Representational state means that the request contains all needed parameters for this request execution. Execution doesn't depend on any other parameters (sessions, cookies, etc.)

Let's consider the example of creating and adding a descriptor in RKObjectManager subclass:

[self addResponseDescriptorWithMapping:[self userMapping
                         requestMethod:RKRequestMethodGET
                          pathPatterns:@[@"/api/users"]
                               keyPath:@"user"];

This descriptor sets that the request needs to be parsed using userMapping as it was created using method GET (also possible POST, PUT, DELETE, etc.), contains in its request url the path "/api/users" and its object returns to 'user' in response. That is in fact the set of these three parameters define how RestKit will process the response of particular request. When developing a REST-server this matter is obvious.

But what if all requests use only the POST method, path pattern is the same and represented as "/fellowUpApi.php", and the key path is nil? To get out of this difficult situation we can apply dynamic mapping. Let's create it:

RKDynamicMapping *dynamicMapping = [RKDynamicMapping new];

Next we register the only response descriptor for all requests basing on this mapping:

[self addResponseDescriptorWithMapping:dynamicMapping
                         requestMethod:RKRequestMethodPOST
                          pathPatterns:@[FUAPIPathPattern]
                               keyPath:nil];

Dynamic mapping feature is that we can manually specify which of the mapping created by us to apply to this request response. To do this we set the unit that will check incoming object fields, and depending on them, decide what to do with these objects, how to parse them, and where to save. For example we have the request that returns the response in this form:

{
"people":[
         {
                  "name":"Blake Watters",
                  "type":"Boy",
                  "friends":[
                           {
                                "name":"John Doe",
                                "type":"Boy"
                            },
                            {
                                "name":"Jane Doe",
                                "type":"Girl"
                            }
                ]
                },
                {
                          "name":"Sarah",
                          "type":"Girl"
                 }
      ]
}

And we want one mapping to be used for the object of type 'Boy' and another mapping for the object of type 'Girl'. In such situation we simply set objectMappingForRepresentationBlock:

[dynamicMapping setObjectMappingForRepresentationBlock:^RKObjectMapping *(id representation)
         {
             NSString *type = [representation valueForKey: @"type"];
             
             if ([type isEqualToString:@"Boy"])
             {
                 return [self boyMapping];
             }
             if ([type isEqualToString:@"Girl"])
             {
                 return [self girlMapping];
             }
             return nil;
         }];

But in this particular case there was another problem. objectMappingForRepresentationBlock defines which mapping to return in the end of its execution (return [self girlMapping]; return [self boyMapping];). But all the requests the server return in the following way:

{
    Message = " Sports";
    Status = true;
    items =     (
                {
            sport = "Any Activity";
            sportId = 0;
        },
                {
            sport = "Alpine Skiing";
            sportId = 11;
        },
.
.
.               
                {
            sport = "Cross-country Skiing";
            sportId = 13;
        }
}

Or:
{
"Status":true,
"Message":" Country",
"items":[
         {
                     "countryid":"3",
                     "countryname":"Australia"
          },
          {
                     "countryid":"7",
                     "countryname":"Austria"
           },
           .
           .
           .
           {
                     "countryid":"9",
                     "countryname":"USA"
            }
]
}

That is, "Message" field serves as type field for determination the object type and there is no possibility to map the part of JSON, which starts with "items":... and contains the needed object array. Thus we have to return the mapping that will parse the whole object id representation with "Message", "Status", and just on the next nesting level parse the part we are interested in "items". This has created significant problems. To solve this issue we had to create another third level of mapping that includes basic and composite mappings.

Third mapping level

Here is how it looks like. We add a new entity ResponseHostObject into the database model. This entity will have two attributes: message of String type and status of Boolean type. Also there will be relationship called items of one-to-one type. When creating relations we should indicate the Destination entity by all means. So we will indicate Sport as an entity responsible for the kinds of sport. We indicated Sport entity but in reality there is no difference which one to indicate and soon you will know why. We leave Relation without inverse relation. Here is how in looks in a database editor:

code

When we added the model into database, we have to create the mapping for the ResponseHostObject entity. The matter is that ResponseHostObject entity is cover of other entities Sport, Country, etc. That's why we need to create a separate ResponseHostObject mapping. Let's create it for Sport:

+ (RKEntityMapping*)responseHostObjectMappingSport
{
    static RKEntityMapping *responseHostObjectMappingSport;
    
    if (!responseHostObjectMappingSport)
    {
        responseHostObjectMappingSport = [RKEntityMapping mappingForEntityForName:NSStringFromClass([ResponseHostObject class])
                                                             inManagedObjectStore:[BUObjectManager sharedManager].managedObjectStore];
        
        [responseHostObjectMappingSport addAttributeMappingsFromDictionary:@{@"Status" :@"status",
                                                                             @"Message":@"message"}];
        
        [responseHostObjectMappingSport addPropertyMapping:[RKRelationshipMapping relationshipMappingFromKeyPath:@"items" toKeyPath:@"items" withMapping:[self sportMapping]]];
        
    }
    return responseHostObjectMappingSport;
}

Pay attention to the line of adding relation to items:

[responseHostObjectMappingSport addPropertyMapping:[RKRelationshipMapping relationshipMappingFromKeyPath:@"items" toKeyPath:@"items" withMapping:[self sportMapping]]];

Relation to the object items is created in this line. Items in this case will contain objects of Sport type, so we have to map them and save to database using corresponding mapping [self sportMapping].

responseHostObjectMapping Country is created similarly with the addition Country mapping to items:

[responseHostObjectMappingCountry addPropertyMapping:[RKRelationshipMapping relationshipMappingFromKeyPath:@"items" toKeyPath:@"items" withMapping:[self countryMapping]]];

And in the end, unit of dynamic mapping takes the following form:

[dynamicMapping setObjectMappingForRepresentationBlock:^RKObjectMapping *(id representation)
         {
             BOOL status = (BOOL)[representation objectForKey:@"Status"];
             if (status)
             {
                 NSString *message = [representation valueForKey:@"Message"];
                 
                 if ([message isEqualToString:@" Sport"])
                 {
                     return responseHostObjectMappingSport;
                 }
                 else if ([message isEqualToString:@" Country"])
                 {
                     return responseHostObjectMappingCountry;
                 }
             }
             else
             {
                 NSLog(@"Properly mapping not found");
             }
             return nil;
         }];

In this way we were able to ensure an awkward structure of server response was brought under the automatic mapping by RestKit forces. Surely we could use other solutions, such as to process each request manually. But it doesn't makes fun, does it?

Rate this article!

An image An image
Bad!
An image An image
Strange!
An image An image
Boring!
An image An image
Good!
An image An image
Love it!
(1403 ratings, average: 4.9 out of 5)
Latest articles
Back to top
As s part of our team, be ready for:
An image
Competitive Base Salary
An image
Comprehensive Benefits
An image
Great Work Environment
An image
Drug Free Workplace
Tell us more about yourself