Guss
Guss

Reputation: 32335

NSJSONSerialization to plain old object?

A lot of modern programming languages have JSON libraries that support encoding and decoding json to/from "plain old objects" - i.e. instances of classes that primarily just have data properties (properties can either be types that can be trivially de/encoded or other plain old objects). Examples include Google's GSON, golang's encoding/json and others.

Is there something similar to Objective-C?

I know that it is possible to enumerate properties for Objective-C classes, and it seems reasonable that someone would have used that capability to create a JSON "bean mapper", but Google searching yielded no results for me, except this blog post on Apple's Swift website showing how to manually deserialize JSON to "model objects" and why they think that doing this automatically (DRYing the code) is a bad idea (*).

*) The reasoning is basically, that not needing to write a lot of boilerplate (their sample implementation is 36 LoC to parse 3 properties) is not a significant improvement and building a couple of optional callbacks to allow data validation is hard. I obviously disagree with all of this.

Upvotes: 0

Views: 311

Answers (1)

Guss
Guss

Reputation: 32335

Here is my solution, which is not based on a library - as I couldn't find any - but instead using the Foundation and Objective-C runtime methods - as discussed in the comments above:

#import <objc/runtime.h>

NSArray<NSString*>* classPropertyList(id instance) {
    NSMutableArray* propList = [NSMutableArray array];
    unsigned int numProps = 0;
    objc_property_t* props = class_copyPropertyList(object_getClass(instance), &numProps);
    for (int i = 0; i < numProps; i++)
        [propList addObject:[NSString stringWithUTF8String:property_getName(props[i])]];
    free(props);
    return propList;
}

NSString* typeOfProperty(Class clazz, NSString* propertyName) {
    objc_property_t prop = class_getProperty(clazz, [propertyName UTF8String]);
    NSArray<NSString*>* propAttrs = [[NSString stringWithUTF8String:property_getAttributes(prop)] componentsSeparatedByString:@","];
    if ([(propAttrs[0]) hasPrefix:@"T@\""])
        return [propAttrs[0] componentsSeparatedByString:@"\""][1];
    return nil;
}

@implementation JSONMarshallable

- (NSData*)toJSON {
    return [self toJSON:self withNullValues:YES];
}

- (NSString*)toJSONString {
    return [self toJSONString:self withNullValues:YES];
}

- (NSData*)toJSON:_ withNullValues:(bool)nullables {
    NSError* error;
    NSDictionary* dic = [self toDictionary:self withNullValues:nullables];
    NSData* json = [NSJSONSerialization dataWithJSONObject:dic options:0 error:&error];
    if (!json) {
        NSLog(@"Error encoding DeviceConfigurationRequest: %@", error);
        return nil;
    }
    return json;
}

- (NSString*) toJSONString:_ withNullValues:(bool)nullables {
    NSData* json = [self toJSON:self withNullValues:nullables];
    return [[NSString alloc] initWithBytes:[json bytes] length:[json length] encoding:NSUTF8StringEncoding];
}

- (NSDictionary*)toDictionary:_ withNullValues:(bool)nullables {
    NSMutableDictionary* dic = [NSMutableDictionary new];
    for (id propName in classPropertyList(self)) {
        id val = [self valueForKey:propName];
        if (!nullables && (val == nil || val == NSNull.null))
            continue;
        if ([val respondsToSelector:@selector(toDictionary:withNullValues:)])
            val = [val toDictionary:val withNullValues:nullables];
        [dic setObject:(val == nil ? NSNull.null : val) forKey:propName];
    }
    return dic;
}

- (instancetype)initWithJSONString:(NSString*)json {
    return [self initWithJSON:[json dataUsingEncoding:NSUTF8StringEncoding]];
}

- (instancetype)initWithJSON:(NSData*)json {
    NSError* error;
    if (json == nil)
        return nil;
    NSDictionary* dataValues = [NSJSONSerialization JSONObjectWithData:json options:0 error:&error];
    if (!dataValues) {
        NSLog(@"Error parsing invalid JSON for %@: %@", NSStringFromClass(object_getClass(self)), error);
        return nil;
    }
    return [self initWithDictionary:dataValues];
}

- (instancetype)initWithDictionary:(NSDictionary*)dataValues {
    if (dataValues == nil)
        return nil;
    if (self = [super init])
        for (id key in dataValues) {
            id val = [dataValues objectForKey:key];
            if (![self respondsToSelector:NSSelectorFromString(key)])
                continue;
            NSString* typeName = typeOfProperty([self class], key);
            if ([val isKindOfClass:[NSNull class]]) { // translate NSNull values to something useful, if we can
                if (typeName == nil)
                    continue; // don't try to set nil to non-pointer fields
                val = nil;
            } else if ([val isKindOfClass:[NSDictionary class]] && typeName != nil)
                val = [[NSClassFromString(typeName) alloc] initWithDictionary:val];
            [self setValue:val forKey:key];
        }
    return self;
}

@end

It is then easy to create custom model objects by inheriting from JSONMarshallable, like so:

model.h:

#import "JSONMarshallable.h"

@interface MyModel : JSONMarshallable

@property NSString* stringValue;
@property NSNumber* numericValue;
@property bool boolValue;

@end

model.m:

@implementation MyModel
@end

SomeThingElse.m:

// ...

NSData* someJson;
MyModel* obj = [[MyModel alloc] initWithJSON:someJson];
NSString* jsonObj = [obj toJSONString:nil withNullValues:NO];

Critics are welcome! (I'm not very good at Objective C and probably made a lot of faux pas 🤭)

Issues:

  • I can handle nullable numbers with NSNumber* (though C primitives work fine for non-nullable numbers), but I don't know how to represent nullable booleans - i.e. a field that is optional and not encoded when using withNullValues:NO.
  • Sending fields for which there are no properties (for example, the server I work with sends values in both snake-case and underscrore-case to make it easy to parse) throws exception. (solved by using respondsToSelector: and setValue: instead of setValuesForKeysWithDictionary:).
  • Trying to set nil values to primitive-typed fields causes exceptions. (solved by checking for property type and NSNull).
  • Doesn't work at all for nesting objects - i.e. a custom model object with properties that are also custom model objects. (solved by checking for property types and recursing encoding/decoding).
  • Probably doesn't handle arrays well - I have yet to need those in my software, so I haven't implemented proper support (though I verified that encoding simple string arrays works well).

Upvotes: 1

Related Questions