Update June 20, 2019: Thanks to @rudedog, I arrived at a working solution. I've appended implementation below my original post...
Note that I am NOT looking for "use private enum CodingKeys: String, CodingKey" in your struct/enum declaration.
I have a situation in which a service I call requires upper snake_case (UPPER_SNAKE_CASE
) for all enumerations.
Given the following struct
:
public struct Request: Encodable {
public let foo: Bool?
public let barId: BarIdType
public enum BarIdType: String, Encodable {
case test
case testGroup
}
}
All enums in any request should be converted to UPPER_SNAKE_CASE.
For example, let request = Request(foo: true, barId: testGroup)
should end up looking like the following when sent:
{
"foo": true,
"barId": "TEST_GROUP"
}
I would like to provide a custom JSONEncoder.KeyEncodingStrategy
that would ONLY apply to enum
types.
Creating a custom strategy seems straightforward, at least according to Apple's JSONEncoder.KeyEncodingStrategy.custom(_:) documentation.
Here's what I have so far:
public struct AnyCodingKey : CodingKey {
public var stringValue: String
public var intValue: Int?
public init(_ base: CodingKey) {
self.init(stringValue: base.stringValue, intValue: base.intValue)
}
public init(stringValue: String) {
self.stringValue = stringValue
}
public init(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
public init(stringValue: String, intValue: Int?) {
self.stringValue = stringValue
self.intValue = intValue
}
}
extension JSONEncoder.KeyEncodingStrategy {
static var convertToUpperSnakeCase: JSONEncoder.KeyEncodingStrategy {
return .custom { keys in // codingKeys is [CodingKey]
// keys = Enum ???
var key = AnyCodingKey(keys.last!)
// key = Enum ???
key.stringValue = key.stringValue.toUpperSnakeCase // toUpperSnakeCase is a String extension
return key
}
}
}
I'm stuck trying to determine whether [CodingKey] represents an enum, or whether the individual CodingKey represents an enum, and should therefor become UPPER_SNAKE_CASE.
I know this sounds pointless, since I can simply supply hardcoded CodingKeys, but we have a lot of service calls, all requiring the same handling of enum cases. It would be simpler to just specify a custom KeyEncodingStrategy for the encoder.
What would be ideal is to apply JSONEncoder.KeyEncodingStrategy.convertToSnakeCase
in the custom strategy and then just return the uppercased value. But again, only if the value represents an enum case.
Any thoughts?
Here is the code I arrived at that solved my problem, with help from @rudedog:
import Foundation
public protocol UpperSnakeCaseRepresentable: Encodable {
var upperSnakeCaseValue: String { get }
}
extension UpperSnakeCaseRepresentable where Self: RawRepresentable, Self.RawValue == String {
var upperSnakeCaseValue: String {
return _upperSnakeCaseValue(rawValue)
}
}
extension KeyedEncodingContainer {
mutating func encode(_ value: UpperSnakeCaseRepresentable, forKey key: KeyedEncodingContainer<K>.Key) throws {
try encode(value.upperSnakeCaseValue, forKey: key)
}
}
// The following is copied verbatim from https://github.com/apple/swift/blob/master/stdlib/public/Darwin/Foundation/JSONEncoder.swift
// Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
// The only change is to call uppercased() on the encoded value as part of the return.
fileprivate func _upperSnakeCaseValue(_ stringKey: String) -> String {
guard !stringKey.isEmpty else { return stringKey }
var words : [Range<String.Index>] = []
// The general idea of this algorithm is to split words on transition from lower to upper case, then on transition of >1 upper case characters to lowercase
//
// myProperty -> my_property
// myURLProperty -> my_url_property
//
// We assume, per Swift naming conventions, that the first character of the key is lowercase.
var wordStart = stringKey.startIndex
var searchRange = stringKey.index(after: wordStart)..<stringKey.endIndex
// Find next uppercase character
while let upperCaseRange = stringKey.rangeOfCharacter(from: CharacterSet.uppercaseLetters, options: [], range: searchRange) {
let untilUpperCase = wordStart..<upperCaseRange.lowerBound
words.append(untilUpperCase)
// Find next lowercase character
searchRange = upperCaseRange.lowerBound..<searchRange.upperBound
guard let lowerCaseRange = stringKey.rangeOfCharacter(from: CharacterSet.lowercaseLetters, options: [], range: searchRange) else {
// There are no more lower case letters. Just end here.
wordStart = searchRange.lowerBound
break
}
// Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase letters that we should treat as its own word
let nextCharacterAfterCapital = stringKey.index(after: upperCaseRange.lowerBound)
if lowerCaseRange.lowerBound == nextCharacterAfterCapital {
// The next character after capital is a lower case character and therefore not a word boundary.
// Continue searching for the next upper case for the boundary.
wordStart = upperCaseRange.lowerBound
} else {
// There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character.
let beforeLowerIndex = stringKey.index(before: lowerCaseRange.lowerBound)
words.append(upperCaseRange.lowerBound..<beforeLowerIndex)
// Next word starts at the capital before the lowercase we just found
wordStart = beforeLowerIndex
}
searchRange = lowerCaseRange.upperBound..<searchRange.upperBound
}
words.append(wordStart..<searchRange.upperBound)
let result = words.map({ (range) in
return stringKey[range].lowercased()
}).joined(separator: "_")
return result.uppercased()
}
enum Snake: String, UpperSnakeCaseRepresentable, Encodable {
case blackAdder
case mamba
}
struct Test: Encodable {
let testKey: String?
let snake: Snake
}
let test = Test(testKey: "testValue", snake: .mamba)
let enumData = try! JSONEncoder().encode(test)
let json = String(data: enumData, encoding: .utf8)!
print(json)