TestsTested | ✓ |
LangLanguage | Obj-CObjective C |
License | MIT |
ReleasedLast Release | Mar 2015 |
Maintained by Ethan Nagel.
NTJsonModel provides and easy-to-use and high-performance wrapper for JSON objects. It features an intuitive model for declaring properties and works to preserve the original JSON.
NTJsonModel is unique in that it supports both immutable and mutable states for objects and gives you the tools to make this practical for model objects. If you take advantage of these features you can have immutable model objects, only "mutating" them in specific contexts, similar to the approach you would see in functional languages.
@property
declarations and a macro, NTJsonProperty
mutableCopy
.) With a little extra work you use immutable objects throught your application, making changes only in controlled 'mutation' blocks.NTJsonProperty
macro)UIColor
for instance), working in both directions.Properties are declared using a combination of the standard @property which gives us the data type and and a macro (NTJsonProperty
) which adds meta-data such as the jsonPath.
@interface User : NTJsonModel
@property (nonatomic) NSString *firstName;
@property (nonatomic) NSString *lastName;
@property (nonatomic) int age;
@end
@implementation User
NTJsonProperty(firstName)
NTJsonProperty(lastName)
NTJsonProperty(age)
@end
// usage
NSDictionary *json = ...
User *user = [[User alloc] initWithJson:json];
NSLog(@"Hello, %@", user.firstName);
NTJsonProperty is a fancy macro that requires the property name as the first parameter. Additionally it has several optional parameters that are of the form key=value.
jsonPath="path"
- Sets the JSON path for this property. By default this matches the property name. Nested jsonPaths (ie "a.b") are allowed for read-only propertiesenumValues=NSArray
- Defines an array of strings that define valid values for this property. See String-based enumerations.cachedObject=YES
- Specifies that this should be treated as a cached object, which is a special case of conversion. See Object Caching for more information.elementType=class
- Allows you to set the element type for an Array. The protocol syntax may also be used, which is a bit more elegant. See Declaring typed arrays, below.NTJsonModel supports typed arrays which will automatically be converted into child NTJsonModels or other native types the first time they are accessed. This can be done using the NTJsonProperty
elementType=
parameter or using prococols. To use protocols, simply declare a protocol with the same name as the class and make the array conform to the protocol. (Thanks to JsonModel for poineering this approach.) Here's an example:
@protocol Address // empty
@end
@interface Address
@property (nonatomic) NSString *street;
@property (nonatomic) NSString *city;
@end
@interface User : NTJsonModel
...
@property (nonatomic) NSArray<Address> *addresses;
@end
@implementation User
NTJsonProperty(address)
-- or, without protocols --
NTJsonProperty(address, elementType=[Address class])
@end
NTJsonModel supports string-based enumerations using the enumValues=
NTJsonProperty parameter. See the example below for an example of using it. If the JSON value matches any of the enumerations that exact value will be returned allowing you to use ==
instead of isEqualToString:
, which can be very convenient.
typedef NSString *UserType;
extern UserType UserTypePrimary;
extern UserType UserTypeSecondary;
@interface User : NTJsonModel
...
@property (nontatomic) UserType type;
+(NSArray *)types;
@end
UserType UserTypePrimary = @"primary";
UserType UserTypeSecondary = @"secondary";
@implementation
NTJsonProperty(type, enumValues=[User types])
@end
...
User *user = ...
if ( user.type == UserTypePrimary )
...
Each model object may be created as mutable or immutable. Immutable objects are thread-safe, and very effecient. calling -initWithJson:
or +mutableModelWithJson:
creates an immutable model. Additionally, you may call copy
on any object to get an immutable version. (Calling copy
on an immutable object simply returns the sender.) You may check if a model is mutable or immutable using the isMutable
property. Attempting to set a property value on an immutable instance will throw an exception.
This is similar to the approach that we see with objects like NSMutableArray
and NSArray
, but we have only a single object to work with which can be created in either a mutable or immutable state. (With a little more work, there is a way to get the compiler to enforce mutable and immutable properties, see Mutable protocols
Mutable objects are not thread-safe and you must enforce this yourself. You may create a mutable instance using initMutable
(Which creates an empty mutable instance), initMutableWithJson
or mutableModelWithJson:
. Additionally, you can get a mutable version calling mutableCopy
on any model.
When setting properties, the system will try to keep the JSON as consistent with the current data as possible. For instance, If you have a property you have exposed as an int but it is stored as a string in the JSON, setting it will cause the system to set a string to the JSON (it will always be exposed as an int property.) If the underlying JSON was an int already then it would store an int.
Immutable objects are designed to be high-perfomance and thread-safe. It's good practice to use immutable objects whenever possible and create mutable copies when changes are needed.
Using immutable objects can create suprising results when you have mutable properties since there is only a single object. You might expect the following to work, but it would throw an exception:
User *user = [User modelWithJson:userJson];
user.age = 21; // Fails with an exception, user is immutable!
You can always create mutable objects instead, so this would work:
User *user = [User mutableModelWithJson:userJson];
user.age = 21; // succeeds
Things can get complex if you are mixing immutable and mutable objects in your code too much. Ideally, you should use immutable objects and only "mutate" them in very controlled situations. You can get the same effect as the above by creating a mutable copy, updating that then storing an immutable version:
User *user = [User modelWithJson:userJson];
User *mutableUser = [user mutableCopy];
mutableUser.age = 21; // allowed, this is a mutable object
user = [mutableUser copy];
Of course this a lot of boilerplate to change a single property; the mutate:
method is here to simplify things for you - see the next section.
mutate:
methodThe mutate method encapsulates the common pattern of creating a mutable copy of an object, making changes then getting an immutable copy of the result:
-(id)mutate:(void (^)(id mutable))mutationBlock;
This method creates a mutable copy of the sender, passes that to the mutation block and returns an immutable copy of the result. As you can see, the above code is much cleaner using mutate:
User *user = [User modelWithJson:userJson];
user = [user mutate:^(User *mutable) {
mutable.age = 21;
}];
This makes things your code much safer and clearer.
Unfortunately, the compiler won't warn you if you attempt to set a property on an immutable object (you will get an exception at runtime, however.) The next section shows how you, with a little more work, can get the compiler to enforce mutablility for you model objects.
With a little more work in declaring your models, it's possible to get the compiler to help enforce mutability for your objects. This involves declaring all properties as readonly in your class and creating a "paired" protocol with readwrite versions of the updatable properties. You can then use the class under normal (immutable) conditions and apply the protocol when you explicity create a mutable instance (or copy) of an object. This is easiest to see in code - here is an updated version of our User
model:
@interface User : NTJsonModel
@property (nonatomic,readonly) NSString *firstName;
@property (nonatomic,readonly) NSString *lastName;
@property (nonatomic,readonly) int age;
@end
@protocol MutableUser <NTJsonMutableModel>
@property (nonatomic) NSString *firstName;
@property (nonatomic) NSString *lastName;
@property (nonatomic) int age;
@end
typedef User<MutableUser> MutableUser;
...
@implementation User
NTJsonMutable(MutableUser) // this is required!
NTJsonProperty(firstName)
NTJsonProperty(lastName)
NTJsonProperty(age)
@end
Here's an example:
User *user = [User modelWithJson:userJson];
this will cause a compiler error:
user.age = 21; // compiler error
but this will work just fine:
user = [user mutate:^(MutableUser *mutable) {
mutable.age = 21; // all good!
}];
This is a little more work in declaring the properties (and there is some extra work when creating methods that change properties), but having the compiler help you avoid mistakes with mutability is very (very) helpful, especially with larger applications.
Here are some additional details:
NTJsonMutable
macro is what binds the protocol to the class and allows ths setters to be created. If you omit this step, you will get a 'method not found' exception when attempting to set properties.AdminUser
we could create a protocol MutableAdminUser
which would implement MutableUser
.typedef
as an instance of the class that implements the mutable protocol is optional but will simplify the syntax when using the mutable protocol later. The basic format for this with class 'XXX' is typedef XXX<MutableXXX> *MutableXXX;
Which translates to create a type named MutableXXX
that is a class XXX
which implements protocol MutableXXX
.readonly
if you have a mutable protocol (declared with the NTJsonMutable
macro.) Any readwrite
NTJson properties will reault in an exception the first time the type is accessed.readonly
in the class, any attempt to modify the properties -- even in a method you intend to be mutable -- will result in a compiler error. A special property mutableSelf
will allow you to explicitly access the setters for your properties. (This is actually just your self
pointer cast to the mutable protocol.)While JSON is easy to parse and very universal, it does lack richness. NTJsonModel makes it easy to define converters (or transformers) that are automatically called to convert the underlying JSON to rich values. The system will search for a class method that can satisfy the conversion by checking in three places:
+(id)convert<propertyName>ToJson:(id)json
and +(id)convertJsonTo<propertyName>(id)json.
Additionally, cached value validation may be optionally done with +(id)validateCached<propertyName>:(id)value forJson:(id)json
+(id)convert<className>ToJson:(id)json
and +(id)convertJsonTo<className>:(id)value.
Additionally, cached value validation may be optionally done with +(id)validateCached<className>:(id)value forJson:(id)json
+(id)convertValueToJson:(id)value
and +(id)convertJsonToValue:(id)value.
Additionally, cached value validation may be optionally done with +(id)validateCachedValue:(id)value forJson:(id)json
These methods conform to the NTJsonPropertyConversion
protocol.In the following example:
@interface User : NTJsonModel
@property (nonatomic) NSDate *dateCreated;
@end
The system would search for the following selectors:
+(id)convertDateCreatedToJson:(id)json
, +(id)convertJsonToDateCreated(id)json
or +(id)validateCachedDateCreated:(id)value forJson:(id)json
in class User
+(id)convertNSDateToJson:(id)json
, +(id)convertJsonToNSDate:(id)value.
or +(id)validateCachedNSDate:(id)value forJson:(id)json
in class User
+(id)convertValueToJson:(id)value
, +(id)convertJsonToValue:(id)value.
or +(id)validateCachedValue:(id)value forJson:(id)json
in class NSDate
The system will perform the conversion the first time it reads the value and cache the results, so repeated calls will be effecient. If there is a chance the value could expire, you can implement -(id)validateCachedValue:(id)value forJson:(id)json
. If implemented, this will be called each time the value is accessed; returning NO
will cause the system to get the latest value. Validation can be particularly useful when working with caching objects from a datastore.
The same machinery that allows conversion of primitives such as NSDate
s, UIColor
s or NSURL
's from and to JSON can be used to cache lookups of objects from a datastore.
@interface FeedItem : NTJsonModel
@property (nonatomic) User *user;
@end
@implementation FeedItem
NTJsonProperty(user, jsonPath='user_id', cachedObject=YES)
@end
...
@implementation User
+(id)convertJsonToValue:(id )json
{
NSString *userId = json;
NSDictionary *userJson = (get json for User from data store with userId)
return [User modelWithJson:userJson];
}
+(id)convertValueToJson:(id)value
{
User *user = value;
return user.userId;
}
It's not unusual to have "ploymorphic" objects in JSON, where a base class has several descendent classes that vary based on some field (the object type.) NTJsonModel can automatically create the correct descendent class, all you need to do is declare the following method in the base class:
+(Class)modelClassForJson:(NSDictionary *)json;
This method should inspect the json and return the descendent class to create an instance of.
Let's say we have an array of 'Shapes' which may be rectangles or squares. the json might look something like this:
[
{"type": "rectangle", "x": 8, "y": 16, "width": 64, "height": 32},
{"type": "circle", "x": 32, "y": 32, "radius": 16}
]
Resulting in the following interfaces (excluding mutable protocols here for brevity)
@interface Shape : NTJsonModel
@property (nonatomic,readonly) NSString *type;
@property (nonatomic,readonly) double x;
@property (nonatomic,readonly) double y;
@end
@interface Rectangle: Shape
@property (nonatomic,readonly) double width;
@property (nonatomic,readonly) double height;
@end
@interface Circle: Shape
@property (nonatomic,readonly) double radius;
@end
The Shape implemenation would declare the following:
+(Class)modelClassForJson:(NSDictionary *)json
{
if ( [json[@"type"] isEqualToString:@"rectangle"] )
return [Rectangle class];
else if ( []json[@"type"] isEqualToString:@"circle"] )
return [Circle class];
return [Shape class]; // default
}
Now, creating an instance of Shape will actually create the correct type (Rectangle or Circle), depending on the JSON.
Objects may be created based on the JSON content by overriding +modelClassForJson:
NTJsonModel includes helper methods (and classes) that convert an entire array of JSON objects to model objects. These methods return a special implementation of NSArray that is lazy-loaded - the actual NTJsonModel objects are only created as they are referenced. Using our shapes example above, you could write something like:
NSArray *shapesJson = (get array of JSON objects from somewhere)
NSArray *shapes = [Shape arrayWithJsonArray:shapesJson];
for (Shape *shape in shapes)
{
// do something cool
}
In the above example each Shape object will be instantiated as it is used in the loop.
isEqual:
and hash
work as expected.description
will output non-default properties and tries hard to output something that is useful. Additionally, fullDescription
will out a more detailed version, recursing into nested objects and showing the contents of arrays.