Introducing SLRESTfulCoreData
Today, I would like to introduce one of our Open Source Frameworks to you: SLRESTfulCoreData. In a nutshell, SLRESTfulCoreData lets you tie a JSON REST API of a webservice easily to your CoreData model. By default SLRESTfulCoreData maps rails conventions (API communicating with JSON objects with underscored attribute names and underscored URLs) to objc conventions. Everything is customizable and integration of other conventions is possible. I think the best approach to explain SLRESTfulCoreData is by example. So let's implement the GitHub API in objc real quick:
Basic setup
The inital project setup is an empty Xcode project with the following dependencies:
- AFNetworking: Everyones favorite networking library which I guess I don't have to introduce here.
- SLRESTfulCoreData
- CTDataStoreManager: An
NSObject
subclass which implements a CoreData stack with 2NSManagedObjectContexts
. One main thread context and one background thread context.CTDataStoreManager
keeps these contexts always in sync by merging changes automatically. A blog post aboutCTDataStoreManager
will be coming in the future. The only thing you need to know right now is that we introduce a subclassGHDataStoreManager
ofCTDataStoreManager
which will manage a CoreData stack for our GitHubAPI and provide the following interface:
@interface GHDataStoreManager : CTDataStoreManager
@property (nonatomic, strong) NSManagedObjectContext *mainThreadContext;
@property (nonatomic, strong) NSManagedObjectContext *backgroundThreadContext;
+ (instancetype)sharedInstance;
@end
Next, SLRESTfulCoreData needs an object which handles network communication. This has been abstracted with the following protocol:
@protocol(SLRESTfulCoreDataBackgroundQueue)
+ (id<SLRESTfulCoreDataBackgroundQueue>)sharedQueue;
- (void)getRequestToURL:(NSURL *)URL
completionHandler:(void(^)(id JSONObject, NSError *error))completionHandler;
- (void)deleteRequestToURL:(NSURL *)URL
completionHandler:(void(^)(NSError *error))completionHandler;
- (void)postJSONObject:(id)JSONObject
toURL:(NSURL *)URL
completionHandler:(void(^)(id JSONObject, NSError *error))completionHandler;
- (void)putJSONObject:(id)JSONObject
toURL:(NSURL *)URL
completionHandler:(void(^)(id JSONObject, NSError *error))completionHandler;
@end
This is super fast implemented thanks to AFNetworking in the GHBackgroundQueue
class. Next we implement an initializer which sets up our SLRESTfulCoreData configuration:
__attribute__((constructor))
void GHInitializeSLRESTfulCoreData(void)
{
@autoreleasepool {
// register a default background queue
[NSManagedObject setDefaultBackgroundQueue:[GHBackgroundQueue sharedInstance]];
// register default main and background thread context. SLRESTfulCoreData is designed to not block the main thread in any way. Any changes are therefore performed on the background thread context.
[NSManagedObject registerDefaultBackgroundThreadManagedObjectContextWithAction:^NSManagedObjectContext *{
return [GHDataStoreManager sharedInstance].backgroundThreadContext;
}];
[NSManagedObject registerDefaultMainThreadManagedObjectContextWithAction:^NSManagedObjectContext *{
return [GHDataStoreManager sharedInstance].mainThreadContext;
}];
// SLObjectConverter encapsulates conversion between JSON objects and NSManagedObjects. Each NSManagedObject subclass receives its own instance and we can register global default values like:
[SLObjectConverter setDefaultDateTimeFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"];
}
}
The basic setup is done and we can start implementing our first model.
Implementing the user model
Our first model to implement will be a user. The GitHubAPI returns a specific user from the following route: GET /users/:user
and returns a JSON object like
{
"login": "octocat",
"id": 1,
"avatar_url": "https://GitHub.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"name": "monalisa octocat",
"company": "GitHub",
"blog": "https://GitHub.com/blog",
"email": "octocat@GitHub.com",
"created_at": "2008-01-14T04:33:35Z",
...
}
which we want to store in the following CoreData model:
@interface GHUser : NSManagedObject
@property (nonatomic, strong) NSNumber *identifier;
@property (nonatomic, strong) NSString *login;
@property (nonatomic, strong) NSString *avatarURL;
@property (nonatomic, strong) NSString *gravatarIdentifier;
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *company;
@property (nonatomic, strong) NSString *blog;
@property (nonatomic, strong) NSString *email;
@property (nonatomic, strong) NSDate *createdAt;
@end
Attribute mapping
By default, SLRESTfulCoreData maps camelized objc attributes to underscored JSON object attributes. That means createdAt
will be mapped to created_at
by default. Therefor we get the mapping for login
, name
, company
, blog
, email
and createdAt
for free. Custom attribute mapping can be done in initializer of GHUser
:
@implementation GHUser
+ (void)initialize
{
[self registerAttributeMapping:@{
@"identifier": @"id",
@"gravatarIdentifier": @"gravatar_id",
@"avatarURL": @"avatar_url"
}];
}
@end
But wait? Is this DRY? GitHub has the convention to give all attribute names containing identifiers an id
suffix like in gravatar_id
and all URLs have an url
suffix. Of course, we would like to introduce some conventions for our own model as well. We would like to map an underscored JSON attribute containg id
to a camelized objc attribute containing identifier
. For example, gravatar_id
should always be mapped to gravatarIdentifier
and some_id_value
to someIdentifierValue
. SLRESTfulCoreData supports these naming conventions and we can extend our global Initilizer as follows:
void GHInitializeSLRESTfulCoreData(void)
{
@autoreleasepool {
...
// SLAttributeMapping encapsulates everything regarding attribute mappings. Each NSManagedObject subclass has with its own instance and we can register global default values here as well:
[SLAttributeMapping registerDefaultObjcNamingConvention:@"identifier" forJSONNamingConvention:@"id"];
[SLAttributeMapping registerDefaultObjcNamingConvention:@"URL" forJSONNamingConvention:@"url"];
}
}
Now gravatar_id
will be automatically mapped to gravatarIdentifier
, id
will be mapped to identifier
and avatar_url
will be mapped to avatarURL
. We can delete +[GHUser initialize]
entirely and nothing more is to be done for GHUser
's attribute mapping.
SLRESTfulCoreData automatically type checks all attributes coming from the API to match the types defined in your CoreData model.
Fetching a single user object
To now fetch a user with a specific name from the GitHub API, we add a class method on GHUser
:
@interface GHUser : NSManagedObject
+ (void)userWithName:(NSString *)name completionHandler:(void(^)(GHUser *user, NSError *error))completionHandler;
@end
@implementation GHUser
+ (void)userWithName:(NSString *)name completionHandler:(void(^)(GHUser *user, NSError *error))completionHandler
{
NSURL *URL = [NSURL URLWithString:[NSString stringWithFormat:@"/users/%@", name]];
[self fetchObjectFromURL:URL completionHandler:completionHandler];
}
@end
SLRESTfulCoreData introduces a convenience method on NSManagedObject: +[NSManagedObject fetchObjectFromURL:completionHandler:]
which fetches a JSON object from the specified URL. Since each object has its own unique identifier stored in the id
attribute, SLRESTfulCoreData will at first try to refresh an existing GHUser
instance stored in the database. If no such object can be found, a new GHUser
object is being inserted in the database. This has the advantage, that at runtime, only one GHUser
instance per identifier is in memory (actually two; one in the main thread context and one in the background thread context).
Now fetching a user is as easy as
[GHUser userWithName:@"OliverLetterer" completionHandler:^(GHUser *user, NSError *error) {
NSLog(@"%@", user);
}];
and everything regarding the user model is encapsulated in GHUser
class.
A second model
Let's setup GHRepository
real quick:
[
{
"id": 1296269,
"owner": {
"login": "octocat",
"id": 1,
"avatar_url": "https://GitHub.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.GitHub.com/users/octocat"
},
"name": "Hello-World",
"full_name": "octocat/Hello-World",
"description": "This your first repo!",
"private": false,
"fork": false,
"url": "https://api.GitHub.com/repos/octocat/Hello-World",
"html_url": "https://GitHub.com/octocat/Hello-World",
"clone_url": "https://GitHub.com/octocat/Hello-World.git",
"git_url": "git://GitHub.com/octocat/Hello-World.git",
"ssh_url": "git@GitHub.com:octocat/Hello-World.git",
"svn_url": "https://svn.GitHub.com/octocat/Hello-World",
"mirror_url": "git://git.example.com/octocat/Hello-World",
"homepage": "https://GitHub.com",
"language": null,
"forks": 9,
"forks_count": 9,
"watchers": 80,
"watchers_count": 80,
"size": 108,
"master_branch": "master",
"open_issues": 0,
"pushed_at": "2011-01-26T19:06:43Z",
"created_at": "2011-01-26T19:01:12Z",
"updated_at": "2011-01-26T19:14:43Z"
}
]
@interface GHRepository : NSManagedObject
@property (nonatomic, strong) NSNumber *identifier;
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *fullName;
@property (nonatomic, strong) NSString *repositoryDescription;
@property (nonatomic, strong) NSNumber *private;
@property (nonatomic, strong) NSNumber *fork;
@property (nonatomic, strong) NSString *htmlURL;
@property (nonatomic, strong) NSString *cloneURL;
@property (nonatomic, strong) NSString *gitURL;
@property (nonatomic, strong) NSString *sshURL;
@property (nonatomic, strong) NSString *svnURL;
@property (nonatomic, strong) NSString *mirrorURL;
@property (nonatomic, strong) NSString *homepage;
...
@property (nonatomic, strong) GHUser *owner;
@end
@implementation GHRepository
+ (void)initialize
{
[self registerAttributeName:@"repositoryDescription" forJSONObjectKeyPath:@"description"];
}
@end
Since description
has already taken by NSObject
, we map the JSON description
attribute to repositoryDescription
in our own model.
Notice the to-one relationship owner
? Since the GitHub API returns a user object in owner
, SLRESTfulCoreData automatically detects the returned data and refreshes an existing GHUser
object accordingly (if possible). A correct object-graph is automatically created and setup for you. If the API would return an owner_id
in a repository, the owner relation would be set if an owner with the corresponding identifier
already exists in the database.
Fetching repositories of a user
GitHub provides the route GET /users/:user/repos
for fetching repositories of a given user. SLRESTfuleCoreData extends NSManagedObject
with -[NSManagedObject fetchObjectsForRelationship:fromURL:completionHandler:]
for exactly that purpose. Let's first extend the relationship between our GHUser
and GHRepository
model on the user site:
@interface GHUser : NSManagedObject
...
@property (nonatomic, strong) NSSet *repositories;
@end
@interface GHUser (CoreDataGeneratedAccessors)
- (void)addRepositoriesObject:(GHRepository *)value;
- (void)removeRepositoriesObject:(GHRepository *)value;
- (void)addRepositories:(NSSet *)values;
- (void)removeRepositories:(NSSet *)values;
@end
and implement a corresponding method for fetching repositories from that URL:
@interface GHUser : NSManagedObject
...
- (void)repositoriesWithCompletionHandler:(void(^)(NSArray *repositories, NSError *error))completionHandler;
@end
@implementation GHUser
...
- (void)repositoriesWithCompletionHandler:(void(^)(NSArray *repositories, NSError *error))completionHandler
{
NSURL *URL = [NSURL URLWithString:[NSString stringWithFormat:@"/users/%@/repos", self.login]];
[self fetchObjectsForRelationship:@"repositories" fromURL:URL completionHandler:completionHandler];
}
@end
That's it. Let's take a look at the following snippet:
[GHUser userWithName:@"OliverLetterer" completionHandler:^(GHUser *user, NSError *error) {
[user repositoriesWithCompletionHandler:^(NSArray *repositories, NSError *error) {
for (GHRepository *repository in repositories) {
repository.owner == user; # => true
}
}];
}];
The owner of each repository in the above example is the same as the user object and can be compared with ==
.
SLRESTfulCoreData provides some really cool techniques to make default tasks like fetching objects and fetching objects for relationsships easier.
- URL substitution:
Building the corresponding URL from which objects will be fetched can be quiet ugly in case you would have to worry about percent escapes and lots of values beeing substituted into the URL. Thanks to SLRESTfulCoreDatas URL substitution feature, we could also specify the URL in the above example as [NSURL URLWithString:@"/users/:login/repos"]
where :login
will be evaluated in the context of the current GHUser
instance. Since URL naming conventions are underscored based, we would specify them here in an underscored way as well. For example the key path :repository.some_user.gravatar_id
will be translated into the objc key path repository.someUser.gravatarIdentifier
.
- Generated accessors at runtime
Fetching objects for relationships in such a way seem like a repetitive task as well. Therefore, you can specify CRUD base URLs for each relationship in your initializer like:
+ (void)initialize
{
[self registerCRUDBaseURL:[NSURL URLWithString:@"/users/:login/repos"] forRelationship:@"repositories"];
}
. SLRESTfulCoreData can now implement -[GHUser repositoriesWithCompletionHandler:]
at runtime by implementing its own version of +[NSManagedObject resolveInstanceMethod:]
. We can remove the implementation -[GHUser repositoriesWithCompletionHandler:]
entirely and move the definition in the CoreDataGeneratedAccessors
category:
@interface GHUser (CoreDataGeneratedAccessors)
- (void)repositoriesWithCompletionHandler:(void(^)(NSArray *repositories, NSError *error))completionHandler;
@end
CRUD support for models
To demonstrate the last featureset, we are going to implement the issue model. Here is what the GitHub API returns for an issue:
[
{
"url": "https://api.GitHub.com/repos/octocat/Hello-World/issues/1347",
"html_url": "https://GitHub.com/octocat/Hello-World/issues/1347",
"number": 1347,
"state": "open",
"title": "Found a bug",
"body": "I'm having a problem with this.",
"user": {
"login": "octocat",
"id": 1,
"avatar_url": "https://GitHub.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.GitHub.com/users/octocat"
},
"labels": [
{
"url": "https://api.GitHub.com/repos/octocat/Hello-World/labels/bug",
"name": "bug",
"color": "f29513"
}
],
"assignee": {
"login": "octocat",
"id": 1,
"avatar_url": "https://GitHub.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.GitHub.com/users/octocat"
},
"milestone": {
"url": "https://api.GitHub.com/repos/octocat/Hello-World/milestones/1",
"number": 1,
"state": "open",
"title": "v1.0",
"description": "",
"creator": {
"login": "octocat",
"id": 1,
"avatar_url": "https://GitHub.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.GitHub.com/users/octocat"
},
"open_issues": 4,
"closed_issues": 8,
"created_at": "2011-04-10T20:09:31Z",
"due_on": null
},
"comments": 0,
"pull_request": {
"html_url": "https://GitHub.com/octocat/Hello-World/issues/1347",
"diff_url": "https://GitHub.com/octocat/Hello-World/issues/1347.diff",
"patch_url": "https://GitHub.com/octocat/Hello-World/issues/1347.patch"
},
"closed_at": null,
"created_at": "2011-04-22T13:33:48Z",
"updated_at": "2011-04-22T13:33:48Z"
}
]
For simplicity, we won't implement the labels or milestones model here. Our CoreData model will look like:
@interface GHIssue : NSManagedObject
@property (nonatomic, strong) NSNumber *number;
@property (nonatomic, strong) NSString *htmlURL;
@property (nonatomic, strong) NSString *state;
@property (nonatomic, strong) NSString *title;
@property (nonatomic, strong) NSString *body;
@property (nonatomic, strong) NSNumber *comments;
@property (nonatomic, strong) NSDate *closedAt;
@property (nonatomic, strong) NSDate *createdAt;
@property (nonatomic, strong) NSDate *updatedAt;
@property (nonatomic, strong) GHUser *user;
@property (nonatomic, strong) GHUser *assignee;
@property (nonatomic, strong) GHRepository *repository;
@end
@implementation GHIssue
+ (void)initialize
{
[self registerUniqueIdentifierOfJSONObjects:@"number"];
[self registerCRUDBaseURL:[NSURL URLWithString:@"/repos/:repository.full_name/issues"]];
}
@end
Notice two things:
- GitHub is not following its
id
convention so we have to register the new namenumber
for the attribute holding the unique key. - The CRUD base URL for an issue is defined in underscored naming conventions, as explained above.
This will give us multiple things:
- SLRESTfulCoreData implements the following methods at runtime for us:
@interface GHRepository (CoreDataGeneratedAccessors)
// GET /repos/:self.full_name/issues
- (void)issuesWithCompletionHandler:(void(^)(NSArray *issues, NSError *error))completionHandler;
// POST /repos/:repository.full_name/issues
- (void)addIssuesObject:(GHIssue *)issue withCompletionHandler:(void(^)(GHIssue *issue, NSError *error))completionHandler;
// DELETE /repos/:repository.full_name/issues/:number
- (void)deleteIssuesObject:(GHIssue *)issue withCompletionHandler:(void(^)(GHIssue *issue, NSError *error))completionHandler;
@end
- SLRESTfulCoreData also provides the following CRUD methods for us:
GHIssue *issue = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([GHIssue class])
inManagedObjectContext:[GHDataStoreManager sharedInstance].mainThreadContext];
issue.number = @1;
issue.repository = ...;
// GET /repos/:repository.full_name/issues/:number
[issue updateWithCompletionHandler:^(GHIssue *issue, NSError *error) {
}];
// POST /repos/:repository.full_name/issues
[issue createWithCompletionHandler:^(GHIssue *issue, NSError *error) {
}];
// PATCH /repos/:repository.full_name/issues/:number
[issue saveWithCompletionHandler:^(GHIssue *issue, NSError *error) {
}];
// DELETE /repos/:repository.full_name/issues/:number
[issue deleteWithCompletionHandler:^(NSError *error) {
}];
If you like what you see so far, you can go ahead and take a look at the Source Code, checkout out the GitHubAPI sample project or follow me on Twitter.