Most convenient use of hex encoded strings in json?

I need to parse a json format where many fields contain values of base types, such as uint8, uint16, uint32, and [uint8] – encoded as 0x-prefixed hex-strings, since for those who work on the input fields, they usually deal with binary protocols which the json format refers to and the server developer does not want to add a post processor that converts all those to NUMBER.

That said, I ponder whether something like this would be convenient:

import Foundation

struct HexEncodedNumber<T: FixedWidthInteger & UnsignedInteger>: Decodable {

	let value: T
	
	init(from decoder: Decoder) throws {
		let container = try decoder.singleValueContainer()
		let string = try container.decode(String.self)
		guard string.starts(with: "0x") else {
			throw DecodingError.dataCorruptedError(in: container, debugDescription: "HexEncodedNumber does not start with characters 0x")
		}
		guard let value = T(string.dropFirst(2), radix: 16) else {
			throw DecodingError.dataCorruptedError(in: container, debugDescription: "HexEncodedNumber does not represent a \(T.self)")
		}
		self.value = value
	}
	
}

struct Record: Decodable {
	
	let level: HexEncodedNumber<UInt8>
	let identifier: HexEncodedNumber<UInt16>	
}

let json = """
{
	"level": "0x80",
	"identifier": "0xf190"
}
"""

let data = json.data(using: .utf8)!
let record = try! JSONDecoder().decode(Record.self, from: data)
print(record)

That way I could ensure that – if the values pass the decoder – they're valid and could work with the individual fields by accessing their 'value' property. Does that make sense to you? Is there possibly an even more convenient way to realize something like this?

The alternative – having a separate set of model objects that are using the 'actual' types and get constructed out of the json codable model objects – thus using them only as an intermediate specification – does not sound very attractive to me.

Create an extension on KeyedDecodingContainer with a method for decoding a number from a hex string:

extension KeyedDecodingContainer {
    
    func decodeHex<T: FixedWidthInteger & UnsignedInteger>(
        _ type: T.Type,
        forKey key: Key
    ) throws -> T {
        
        let string = try self.decode(String.self, forKey: key)
        
        guard string.hasPrefix("0x") else {
            throw DecodingError.dataCorruptedError(
                forKey: key,
                in: self,
                debugDescription: "hex string does not start with 0x"
            )
        }
        
        guard let value = T(string.dropFirst(2), radix: 16) else {
            throw DecodingError.dataCorruptedError(
                forKey: key,
                in: self,
                debugDescription: "\(string) cannot be converted to \(T.self)"
            )
        }
        
        return value

    }

}

struct Record: Decodable {
    
    let level: UInt8
    let identifier: UInt16
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.level = try container.decodeHex(UInt8.self, forKey: .level)
        self.identifier = try container.decodeHex(UInt16.self, forKey: .identifier)
    }
    
    enum CodingKeys: String, CodingKey {
        case level
        case identifier
    }

}

Thanks. This is a good approach as well… I donβ€˜t fancy the additional work for every model though, but I guess itβ€˜s one way or the other.

Is there possibly an even more convenient way to realize something like this?

You can make your wrapper a propertyWrapper. That way you don't have to write .value to get to the number

import Foundation

@propertyWrapper
struct HexEncoded<T: FixedWidthInteger & UnsignedInteger>: Decodable {

    let wrappedValue: T

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        guard string.starts(with: "0x") else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "HexEncodedNumber does not start with characters 0x")
        }
        guard let value = T(string.dropFirst(2), radix: 16) else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "HexEncodedNumber does not represent a \(T.self)")
        }
        self.wrappedValue = value
    }

}

struct Record: Decodable {
    @HexEncoded
    var level: UInt8
    @HexEncoded
    var identifier: UInt16
}

let json = """
{
    "level": "0x80",
    "identifier": "0xf190"
}
"""

let data = json.data(using: .utf8)!
let record = try! JSONDecoder().decode(Record.self, from: data)
print(record)
print(record.level)
3 Likes

This is honestly the best way to do it, although it is laborious. Decode Codable structs that match the JSON response exactly (you could use the HexEncodedNumber here) and then convert all those decoded items into the models object for your app.

If your app is simple / just for fun, you don't need to bother with the intermediaries β€” but for proper production usage it's the best route in regard to separation of concerns, resilience, maintainability and testability.

1 Like

FWIW, now that Foundation (at least on macOS) supports JSON5, it's much more convenient to move to that format. Among other great features, JSON5 supports hexadecimal numbers.