UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: Suggestions on a better way to implement OptionsSet with Codable

Forums > Swift

I am making a command line app that loads a JSON config file in which the user can specify various settings; my app crunches a ton of data and saves several image and data files as the end result.

I'd like to refine the app such that the user can specify which files they want creating. A simple way to do this would be to have a "flag" in the JSON, one for each option, example might look like as follows ...

NB: Code copied from a Playground for brevity.

let jsonA = """
  {
    "configName":"SomeConfigA",
    "options":{
      "fileA":true,
      "fileB":true,
      "fileC":false
      }
  }
"""

My Codable structs would be ...

struct AppOptionsA: Codable {
  var fileA: Bool
  var fileB: Bool
  var fileC: Bool
}

struct AppConfigA: Codable {
  var configName: String
  var options: AppOptionsA
}

... reading the config is simply ...

let decoder = JSONDecoder()
var appCfgA = try decoder.decode(AppConfigA.self, from: Data(jsonA.utf8))

So this works, but I'd like to improve how I use the options in code to use the OptionSet protocol.

What I want in my code is ...

struct AppOptionsB: OptionSet, Codable {
  let rawValue: Int
  static let fileA = AppOptionsB(rawValue: 1 << 0)
  static let fileB = AppOptionsB(rawValue: 1 << 1)
  static let fileC = AppOptionsB(rawValue: 1 << 2)
}

struct AppConfigB: Codable {
  var configName: String
  var options: AppOptionsB
}

To read this in from my JSON config file, this becomes ...

let jsonB = """
  {
    "configName":"SomeConfig",
    "options":5
  }
"""
var appCfgB = try decoder.decode(AppConfigB.self, from: Data(jsonB.utf8))

Again, this works and with options set to 5 this means that the user wants fileA and fileC to used; but as far as editing the JSON file is concerned this is not very intuitive for the user.

What I would like to have as the user-facing json config file would be something like ...

  {
    "configName":"SomeConfig",
    "options":[ "fileA", "fileC" ]
  }

I'm sure this is possible by using CodableKeys, but I'm not sure how to do it.
Any help / suggestions very welcome.

2      

Christian Tietze has some thoughts on how to do this.

2      

Doing some further research, I have found a solution for decoding my JSON config file and to create my OptionSet. My solution is based on this article by Christian Tietze.

By creating an extension for AppOptions I was able to check the decoder's container key(s) and populate the OptionSet by looking up the value. The extension will throw a DecodingError if an invald option is given in the JSON file.

struct AppOptions: OptionSet {
  let rawValue: Int
  static let fileA = AppOptions(rawValue: 1 << 0)
  static let fileB = AppOptions(rawValue: 1 << 1)
  static let fileC = AppOptions(rawValue: 1 << 2)
  static let all: AppOptions = [.fileA, .fileB, .fileC]
}
extension AppOptions: Decodable {
  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
  }

  private static let mapping: [String: AppOptions] = [
    ".fileA" : .fileA,
    ".fileB" : .fileB,
    ".fileC" : .fileC,
    ".all"   : .all
  ]
}
struct AppConfig: Decodable {
  var configName: String
  var options: AppOptions
}

var json = """
{
  "configName": "SomeConfig",
  "options": [ ".fileA", ".fileC" ]
}
"""

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

For my specific purpose, I only need to decode the JSON file.

However, if I change the AppOptionsextension and AppConfig to conform to Codable, I then have the problem that I can not encode the JSON file correctly.

Not necessary for my specific case but for completeness, it would be good to get some assistance on how to do that.

2      

@roosterboy Thanks.

That's the same article that I found - I should have quoted it in my own follow-up post (I'll edit and do that).

2      

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 now added to extension AppOtions.

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 {
    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)
  }

  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": ["optC", "optA"]
}
"""

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" : [
//    "optA",
//    "optC"
//  ]
//}

2      

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.