Home > Enterprise >  Swift, OptionSet, Encodable: Need help writing a custom encoder for OptionSet to generate user frien
Swift, OptionSet, Encodable: Need help writing a custom encoder for OptionSet to generate user frien

Time:11-05

I have an application that stores some configuration options which I'd like to write out to a JSON file.

Simplified version of my app's config/options structs and JSON encoding ...

struct AppOptions: OptionSet, Encodable {
  let rawValue: Int
  static let optA = AppOptions(rawValue: 1 << 0)
  static let optB = AppOptions(rawValue: 1 << 1)
  static let optC = AppOptions(rawValue: 1 << 2)
  static let all: AppOptions = [.optA, .optB, .optC]
}

struct AppConfig: Encodable {
  var configName: String
  var options: AppOptions
}

let appCfg = AppConfig(configName: "SomeConfig", options: [ .optA, .optC ])

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(appCfg)
print(String(decoding: data, as: UTF8.self) )
//  {
//    "configName" : "SomeConfig",
//    "options" : 5
//  }

So although this works the generated JSON file is not particularly user friendly as the options are just saved as the variables raw value -> "options": 5.

I'd prefer the encoder to generate more user friendly JSON such that the options are written more like an array of elements, as follows...

  {
    "configName" : "SomeConfig",
    "options" : [ "optA", "optC" ]
  }

I am somewhat at a dead-end figuring out how to create the custom encode(to: ) required to achieve this, suggestions or solutions please.


Just for some additional context, I have already got a solution for the decode part to read the config from JSON file into my app, it is just the encode back to a JSON file that I need a solution for.

Code, including decode part ...

struct AppOptions: OptionSet {
  let rawValue: Int
  static let optA = AppOptions(rawValue: 1 << 0)
  static let optB = AppOptions(rawValue: 1 << 1)
  static let optC = AppOptions(rawValue: 1 << 2)
  static let all: AppOptions = [.optA, .optB, .optC]
}
extension AppOptions: Codable {
  init(from decoder: Decoder) throws {
    var container = try decoder.unkeyedContainer()
    var result: AppOptions = []
    while !container.isAtEnd {
      let optionName = try container.decode(String.self)
      guard let opt = AppOptions.mapping[optionName] else {
        let context = DecodingError.Context(
          codingPath: decoder.codingPath,
          debugDescription: "Option not recognised: \(optionName)")
        throw DecodingError.typeMismatch(String.self, context)
      }
      result.insert(opt)
    }
    self = result
  }

//  func encode(to encoder: Encoder) throws {
//    // What to do here?
//  }

  private static let mapping: [String: AppOptions] = [
    "optA" : .optA,
    "optB" : .optB,
    "optC" : .optC,
    "all"   : .all
  ]
}


struct AppConfig: Codable {
  var configName: String
  var options: AppOptions
}

var json = """
{
  "configName": "SomeConfig",
  "options": ["optA", "optC"]
}
"""

let decoder = JSONDecoder()
var appCfg = try decoder.decode(AppConfig.self, from: Data(json.utf8))
print(appCfg)
//Correct ->  AppConfig(configName: "SomeConfig", options: __lldb_expr_115.AppOptions(rawValue: 5))

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(appCfg)
print(String(decoding: data, as: UTF8.self) )
//  {
//    "configName" : "SomeConfig",
//    "options" : 5
//  }
//  needs to be...
//  {
//    "configName" : "SomeConfig",
//    "options" : [ "optA", "optC" ]
//  }

CodePudding user response:

You could do something like this but I'm not sure if you are using an OptionSet the way it's supposed to.

func encode(to encoder: Encoder) throws {
  var container = encoder.unkeyedContainer()

  let optionsRaw: [String]
  if self == .all {
    optionsRaw = ["all"]
  } else {
    optionsRaw = Self.mapping
      .filter { $0.key != "all" }
      .compactMap { self.contains($0.value) ? $0.key : nil }
      .sorted() // if sorting is important
  }
  try container.encode(contentsOf:  optionsRaw)
}

This handles the all in a way that only ["all"] is encoded and also sorts the keys in case it's a subset.

CodePudding user response:

Similar to decoding, you get an unkeyed container. And for each of the options in mapping, you check if it is contained in self.

func encode(to encoder: Encoder) throws {
    var container = encoder.unkeyedContainer()
    for (name, option) in Self.mapping {
        if self.contains(option) {
            try container.encode(name)
        }
    }
}

Note that the order isn't maintained. AppConfig(configName: "Foo", options: [.optA, .optB]) could be encoded as ["optB", "optA"]. It's an "option set", not an option list, after all.

  • Related