12

We often want to use JSON for human readability. As such, it is common to ask to sort the JSON keys alphabetically (or alphanumerically) in Go, in .NET, in Python, in Java, ...

But how to output a JSON with JSON keys sorted alphabetically in Swift?

PrettyPrinted output is easy:

JSONSerialization.writeJSONObject(jsonObject, to: outputStream, options: [.prettyPrinted], error: nil)

Yet the keys are not alphabetically sorted for human readability. They are likely in the order given by NSDictionary.keyEnumerator(). But sadly, we can't subclass Dictionary, NSDictionary or CFDictionary in Swift, so we can't override the behavior of keys order.

[edit: actually, we can subclass NSDictionary, see one of my answers below]

Cœur
  • 37,241
  • 25
  • 195
  • 267

4 Answers4

18

For iOS 11+ and macOS High Sierra (10.13+), a new option .sortedKeys solves the problem easily:

JSONSerialization.writeJSONObject(jsonObject, to: outputStream, options: [.sortedKeys, .prettyPrinted], error: nil)

Thank you Hamish for the hint.

Cœur
  • 37,241
  • 25
  • 195
  • 267
3

Pure Swift solution for iOS 7+, macOS 10.9+ (OS X Mavericks and up).

A solution is to subclass NSDictionary (but not overriding the default init method as it wouldn't compile with Swift).

class MutableOrderedDictionary: NSDictionary {
    let _values: NSMutableArray = []
    let _keys: NSMutableOrderedSet = []

    override var count: Int {
        return _keys.count
    }
    override func keyEnumerator() -> NSEnumerator {
        return _keys.objectEnumerator()
    }
    override func object(forKey aKey: Any) -> Any? {
        let index = _keys.index(of: aKey)
        if index != NSNotFound {
            return _values[index]
        }
        return nil
    }
    func setObject(_ anObject: Any, forKey aKey: String) {
        let index = _keys.index(of: aKey)
        if index != NSNotFound {
            _values[index] = anObject
        } else {
            _keys.add(aKey)
            _values.add(anObject)
        }
    }
}

With it, we can order the keys of our object with .forcedOrdering before writing it with .prettyPrinted:

// force ordering
let orderedJson = MutableOrderedDictionary()
jsonObject.sorted { $0.0.compare($1.0, options: [.forcedOrdering, .caseInsensitive]) == .orderedAscending }
          .forEach { orderedJson.setObject($0.value, forKey: $0.key) }

// write pretty printed
_ = JSONSerialization.writeJSONObject(orderedJson, to: outputJSON, options: [.prettyPrinted], error: nil)

But be careful: you will need to subclass and sort all subdictionaries of your JSON object if you have any. Here is an extension for doing that recursion, inspired by Evgen Bodunov's gist (thank you).

extension MutableOrderedDictionary {
    private static let defaultOrder: ((String, Any), (String, Any)) -> Bool = {
        $0.0.compare($1.0, options: [.forcedOrdering, .caseInsensitive]) == .orderedAscending
    }
    static func sorted(object: Any, by areInIncreasingOrder: ((key: String, value: Value), (key: String, value: Value)) -> Bool = defaultOrder) -> Any {
        if let dict = object as? [String: Any] {
            return MutableOrderedDictionary(dict, by: areInIncreasingOrder)
        } else if let array = object as? [Any] {
            return array.map { sorted(object: $0, by: areInIncreasingOrder) }
        } else {
            return object
        }
    }
    convenience init(_ dict: [String: Any], by areInIncreasingOrder: ((key: String, value: Value), (key: String, value: Value)) -> Bool = defaultOrder) {
        self.init()
        dict.sorted(by: areInIncreasingOrder)
            .forEach { setObject(MutableOrderedDictionary.sorted(object: $0.value, by: areInIncreasingOrder), forKey: $0.key) }
    }
}

Usage:

// force ordering
let orderedJson = MutableOrderedDictionary(jsonObject)

// write pretty printed
_ = JSONSerialization.writeJSONObject(orderedJson, to: outputJSON, options: [.prettyPrinted], error: nil)
Cœur
  • 37,241
  • 25
  • 195
  • 267
  • 2
    Great answer. I've updated it to work with recursive objects and made convenience init. https://gist.github.com/molind/94b750bdd6c603d608b2310f6be5f050 – Evgen Bodunov Jan 22 '20 at 12:55
1

Solution for iOS 7+, macOS 10.9+ (OS X Mavericks and up).

A solution is to subclass NSDictionary in Objective-C, then use the subclass from a framework (for Application) or static library (for Command Line Tool).

For this demonstration, I will use nicklockwood/OrderedDictionary (700+ lines of code) instead of doing it from scratch, but there may be untested alternatives like quinntaylor/CHOrderedDictionary or rhodgkins/RDHOrderedDictionary. To integrate it as a Framework, add this dependency in your PodFile:

pod 'OrderedDictionary', '~> 1.4'

Then we will order the keys of our object:

import OrderedDictionary

let orderedJson = MutableOrderedDictionary()
jsonObject.sorted { $0.0.compare($1.0, options: [.forcedOrdering, .caseInsensitive]) == .orderedAscending }
          .forEach { orderedJson.setObject($0.value, forKey: $0.key) }

(note: setObject(_,forKey:) is specific to MutableOrderedDictionary)
And finally we can write it prettyPrinted:

_ = JSONSerialization.writeJSONObject(orderedJson, to: outputJSON, options: [.prettyPrinted], error: nil)

But be careful: you need to subclass and sort all subdictionaries of your JSON object.

Cœur
  • 37,241
  • 25
  • 195
  • 267
0

Here is one possible workaround that works only for macOS Swift scripts. It is not for iOS.

We workaround Swift Foundation limitations with a different programming language (Python 2.7 for example).

import Cocoa

// sample jsonObject
let jsonObject = ["hello": "world", "foo": "bar"]

// writing JSON to file
let jsonPath = "myJson.json"
let outputJSON = OutputStream(toFileAtPath: jsonPath, append: false)!
outputJSON.open()
_ = JSONSerialization.writeJSONObject(jsonObject, to: outputJSON, options: [], error: nil)
outputJSON.close()

// sortedKeys equivalent using `sort_keys=True`
// prettyPrinted equivalent using `indent=2, separators=(',', ' : ')`
// unicode using `io.open` and `ensure_ascii=False`
func shell(_ args: String...) {
    let task = Process()
    task.launchPath = "/usr/bin/env"
    task.arguments = args
    task.launch()
    task.waitUntilExit()
}
shell("python", "-c", ""
    + "import io\n"
    + "import json\n"
    + "jsonPath = 'myJson.json'\n"
    + "with io.open(jsonPath, mode='r', encoding='utf8') as json_file:\n"
    + "    all_data = json.load(json_file)\n"
    + "with io.open(jsonPath, mode='w', encoding='utf8') as json_file:\n"
    + "    json_file.write(unicode(json.dumps(all_data, ensure_ascii=False, sort_keys=True, indent=2, separators=(',', ' : '))))\n"
)
Cœur
  • 37,241
  • 25
  • 195
  • 267