Plug and play parsers

I've been building iOS apps since iOS v3 and most of the apps that I've created have had to connect to APIs to populate their UI. The technologies used to connect to these APIs and consume their content has changed over the years but I keep coming back to the same pattern parsing the response. I split my parsers out into focused classes that only have one responsibility - parsing the response.

You may be thinking:

"Well, yes.....that's what a parser does....."

but throughout those years building iOS apps I've seen many different implementions that go beyond this - some are concerned with executing on background thread, others with notifying the UI when they are finished and others still parse in the view controllers they are called from. None of those implementation details really have anything to do with parsing but it's easy mix up the environment that a parser will execute in with the parsing itself.

The example we are going to explore is based on an app I was recently working on that parsed a Post response object into an NSManagedObject subclass. This happened on a background thread and because of the way Core Data is configured the parser has to use an NSManagedObjectContext set up for that thread - none of which parser will/should care about.

Show me the post

First things first, as this is a solution for supporting more than one parser we need to abstract out the common functionality into a parent/base class that our PostParser will inherit from:

@interface WBMParser : NSObject

/**
 Convenience alloc/init that will return a parser instance.

 @param managedObjectContext - context that will be used to access and create NSManagedObject subclasses.

 @return WBMParser instance.
 */
+ (instancetype)parserWithManagedObjectContext:(NSManagedObjectContext *)managedObjectContext;

/**
 Context that will be used to access and create NSManagedObject subclasses.
 */
@property (nonatomic, strong, readonly) NSManagedObjectContext *localManagedObjectContext;

@end

In the above, we create an interface that will allow us to inject our NSManagedObjectContext instance into this parser using Constructor/Initialiser Injection. This frees the parser from making any assumption as to what thread it will be running on.

Just to round it up here is the .m:

@interface WBMParser ()

@property (nonatomic, strong, readwrite) NSManagedObjectContext *localManagedObjectContext;

@end

@implementation WBMParser

#pragma mark - Parser

+ (instancetype)parserWithManagedObjectContext:(NSManagedObjectContext *)managedObjectContext
{
    WBMParser *parser = [[self.class alloc] init];
    parser.localManagedObjectContext = managedObjectContext;

    return parser;
}

@end

The convenience initialiser is especially important when we come to writing our unit tests - as those unit tests will execute the parser on the main context but the app will execute it on a background context. So here we make it easier to test the parser and we also ensure that the developer who is going to use the parser must think about how the context is configured.

Ok, so thats our parent/base class - let's take a look at the PostParser

/**
 Post parser - creates or updates posts.
 */
@interface WBMPostParser : WBMParser

/**
 Parse's the post.

 @param postDictionary - JSON containing the Post.

 @return WBMPost instance that was parsed.
 */
- (WBMPost *)parsePost:(NSDictionary *)postDictionary;

It's a simple enough interface, one method that takes an NSDictionary instance containing the JSON response from the server (post NSJSONSerialization).

@implementation WBMPostParser

#pragma mark - Post

- (WBMPost *)parsePost:(NSDictionary *)postResponse
{
    WBMPost *post = nil;

    if (postResponse[@"id"])
    {
        NSString *postID = [NSString stringWithFormat:@"%@", postResponse[@"id"]];

        post = [WBMPost fetchPostWithID:postID
                   managedObjectContext:self.localManagedObjectContext];

        if (!post)
        {
            post = (WBMPost *)[NSEntityDescription cds_insertNewObjectForEntityForClass:[WBMPost class]
                                                                 inManagedObjectContext:self.localManagedObjectContext];

            post.postID = postID;
        }

        NSDateFormatter *dateFormatter = [NSDateFormatter wbm_dateFormatter];

        post.createdDate = WBMValueOrDefault([dateFormatter dateFromString:postResponse[@"created_at"]],
                                             post.createdDate);

        /*-------------------*/

        post.country = WBMValueOrDefault(postResponse[@"country"],
                                         post.country);

        /*-------------------*/

        post.content = WBMValueOrDefault(postResponse[@"content"],
                                         post.content);

        /*-------------------*/

        post.shareCount = WBMValueOrDefault(postResponse[@"share_count"],
                                            post.repostCount);

        post.localUserHasShared = WBMValueOrDefault(postResponse[@"is_shared"],
                                                    post.isRepost);

        post.viewCount = WBMValueOrDefault(postResponse[@"views_count"],
                                           post.viewCount);

    }

    return post;
}

@end

(You may be wondering where cds_insertNewObjectForEntityForClass came from, it's from apod called CoreDataServices that I use to simplify interacting with Core Data.)

In the above method we pass in the JSON response and check if that post already exists and if it does we update it and if it doesn't we created it - after that we update/assign it's properties and return a fully formed Post instance at the end. By passing in the JSON response rather than directly including the above method in the success/completion block/callback we make this parser much easier to unit test - no need to mock out an API call instead just build a valid NSDictionary instance with the response that you are expecting to be given and pass it in.

Apart from the Method Injection technique there is nothing overly powerful about the above parser but let's say the app changes and we now want to return the author (user) of each post in this response. Now as we already have a profile screen for each user (did I not tell you that - sorry), we already have a UserParser - wouldn't it be great if we could treat these parsers as components and plug them into each other 😝

Let's see how we would do that with our architecture

#pragma mark - Post

- (WBMPost *)parsePost:(NSDictionary *)postResponse
{
    WBMPost *post = nil;

    if (postResponse[@"id"])
    {
        NSString *postID = [NSString stringWithFormat:@"%@", postResponse[@"id"]];

        post = [WBMPost fetchPostWithID:postID
                   managedObjectContext:self.localManagedObjectContext];

        if (!post)
        {
            post = (WBMPost *)[NSEntityDescription cds_insertNewObjectForEntityForClass:[WBMPost class]
                                                                 inManagedObjectContext:self.localManagedObjectContext];

            post.postID = postID;
        }

        NSDateFormatter *dateFormatter = [NSDateFormatter wbm_dateFormatter];

        post.createdDate = WBMValueOrDefault([dateFormatter dateFromString:postResponse[@"created_at"]],
                                             post.createdDate);

        /*-------------------*/

        post.country = WBMValueOrDefault(postResponse[@"country"],
                                         post.country);

        /*-------------------*/

        WBMUserParser *userParser = [WBMUserParser parserWithManagedObjectContext:self.localManagedObjectContext];

        NSDictionary *authorResponse = postResponse[@"user"];

        post.author = [userParser parseUser:authorResponse];

        /*-------------------*/

        post.content = WBMValueOrDefault(postResponse[@"content"],
                                         post.content);

        /*-------------------*/

        post.shareCount = WBMValueOrDefault(postResponse[@"share_count"],
                                            post.repostCount);

        post.localUserHasShared = WBMValueOrDefault(postResponse[@"is_shared"],
                                                    post.isRepost);

        post.viewCount = WBMValueOrDefault(postResponse[@"views_count"],
                                           post.viewCount);

    }

    return post;
}

@end

In the above method we've added parsing the user for the post. Provided that you can agree with your server team to return the same user object in the response, it should be that easy (of course you need to implement a UserParser but it follows the same pattern as the PostParser so I will leave it for you).

Ok, so thats it....

I can tell you are not convinced, let's see one more example. Our app is growing like crazy and we decide that we want to support more than type of Post - in fact wouldn't it be great if the app could support Text, Image and Video posts. In order to do we decide to create a new Media model class that will hold the content of the Post.

WBMMediaParser *mediaParser = [WBMMediaParser parserWithManagedObjectContext:self.localManagedObjectContext];

NSDictionary *mediaDictionary = postResponse[@"media"];    

post.media = [mediaParser parseMedia:mediaDictionary];

Those 3 lines of code are all that you need to add.

Using the parser

This parser is now free to be called from anywhere in your app and ran on any thread, it makes no assumptions/demands on if you should use GCD or NSOperationQueue and on how you handle 4xx or 5xx responses instead it only cares about parsing data and building a valid model object.

After thoughts

There are other approaches to creating parsers or even not creating a parser at all (directly mapping the server response to the model classes' properties - I think it's best to avoid this unless you want to inherit the technical debt of the server team into your project) and each approach has it's pros and cons. The Pros of the above approach are that you produce a parser layer that is decoupled and easily unit testable. The Cons are that you need to talk more to the server team (in fact is that even a con 🤔).