Problem using OptionSet with Codable

I am making a command line app that loads a JSON config file in which the user can specify various options.

In my code these options are defined as an OptionSet.

Initially, I had problems decoding the JSON file as I wanted to use more user friendly syntax in the JSON file such that I could replace "options": 5 with "options": [ ".optA", ".optC" ]. I managed to get this part to work using a custom init(from: Decoder) and mapping json string to the required option.

But I still have a problem with the encode side as I’m not sure how to generate the option strings in the encoded JSON. Pretty sure I need to implement CodingKeys but I’m not sure how.

Any help / suggestions would be very welcome.

Xcode Playground code…

import Foundation

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()
appCfg.options = [ .optA, .optC ]
let data = try encoder.encode(appCfg)
print(String(decoding: data, as: UTF8.self) )
  //Wrong ->  {"configName":"SomeConfig", "options":5}
  //Needs to be -> {"configName":"SomeConfig", "options": [".optA", ".optC"]}

Hi @kiwi5555

I am not 100% sure on the problem that you are trying to solve.

Swift has some great smarts already built in, with the app options what if you use an enum?

Here’s a little test/scratchpad to achieve what you are after, provided this is what you are after

enum Options2: String, Codable {
    case opt1, opt2, opt3
}

var array = ["opt1", "opt2", "opt3"]
let data = array.map { Options2(rawValue: $0) }

print(data)


struct TestOne: Codable {
    var name: String
    var age: Int
    var options: [Options2]
}

let testOne = TestOne(name: "Jayant", age: 42, options: [.opt1, .opt3])
let jsonData = try? JSONEncoder().encode(testOne)
print(String(data: jsonData!, encoding: .utf8))


let json2 = """
 {
  "name": "Jayant Varma",
  "age": 16,
  "options":["opt3", "opt2"]
 }
"""

let json2Data = json2.data(using: .utf8)
let json2data = try? JSONDecoder().decode(TestOne.self, from: json2Data!)

print(json2data)

cheers,

Jayant

1 Like

Hi @jayantvarma

Thanks for answering, but maybe I should rephrase the question/subject of the post.

The problem that I want to solve is: How to implement Encodable for an OptionSet data type, to allow a more user-friendly syntax in a JSON file?.

Your suggested solution, although it does provide a work-around to get data in and out of the JSON file in the format that I want by using an enum, but it specifically avoids using the OptionSet data type.

I’d prefer to find a solution that uses OptionSet, otherwise I may have quite a lot of code refactoring to do!

Before posting the OP, I did figure out how to implement decode for OptionSet such that I can read the JSON file correctly (code for that is in the OP), it is the encode part is giving me the problem.

I want my JSON config file to look like:

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

Currently my code will successfully decode the options in the JSON file into the options: OptionSet variable in my app, but when going the other way to encode the options variable (e.g. where the selected options are .optA and .optC) I get the following, which is not desirable or consistent.

{
  "configName": "SomeConfig",
  "options": 5
}

The (dot) . is what will cause a parse error. @jayantvarma 's example addresses the parsing, giving you a best possible scenario, which is similar to what you want, without the dot prefix.

1 Like

Using (dot). in the JSON config file is not the issue, that already works and is parsed without a problem.
TBH, I’m not fixed on the (dot). notation in the JSON file, I could very easily switch .optA for just optA in the JSON file, the mapping array used in decode just needs to be updated accordingly.

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

Will allow us to decode the JSON file…

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

But getting back to the original question.
Suppose I have a variable of type OptionSet, appConfig.options = [ .optA, .optC ].
If I encode this using the default encoding behaviour I would get "options": 5 in the JSON file, whereas I’d like to get "options": [ "optA", "optC" ], so how would I write a custom encode(to: ) to make this work?

After a little more research and posting the question on another forum, I did get an answer to my question.

The encode(to: ) implementation that works as I wanted is as follows.

  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() //optional but nice to have
    }
    try container.encode(contentsOf:  optionsRaw)
  }

1 Like

Good on you @kiwi5555 that you found the solution to the way you wanted to approach the issue.

Cheers,

2 Likes

This topic was automatically closed after 166 days. New replies are no longer allowed.