1

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)
Community
  • 1
  • 1
David Nedrow
  • 1,098
  • 1
  • 9
  • 26
  • 1
    It looks like you need the values to be snake case, not the keys? – dan Jun 19 '19 at 16:05
  • Yeah, I had my nomenclature mixed up. @rudedog provided some guidance, and I did get a working solution. I’ve added my code at the bottom of my original question, if you’re interested. – David Nedrow Jun 21 '19 at 11:23

1 Answers1

3

I think you are actually looking for a value encoding strategy? A key encoding strategy changes how keys are encoded, not how their values are encoded. A value encoding strategy would be something like JSONDecoder's dateDecodingStrategy, and you're looking for one for enums.

This is an approach that might work for you:

protocol UpperSnakeCaseRepresentable {
  var upperSnakeCaseValue: String { get }
}

extension UpperSnakeCaseRepresentable where Self: RawRepresentable, RawValue == String {
  var upperSnakeCaseValue: String {
    // Correct implementation left as an exercise
    return rawValue.uppercased()
  }
}

extension KeyedEncodingContainer {
  mutating func encode(_ value: UpperSnakeCaseRepresentable, forKey key: KeyedEncodingContainer<K>.Key) throws {
    try encode(value.upperSnakeCaseValue, forKey: key)
  }
}

enum Snake: String, UpperSnakeCaseRepresentable, Encodable {
  case blackAdder
  case mamba
}

struct Test: Encodable {
  let snake: Snake
}
let test = Test(snake: .blackAdder)

let data = try! JSONEncoder().encode(test)
let json = String(data: data, encoding: .utf8)!
print(json)

Now, any enums that you declare as conforming to UpperSnakeCaseRepresentable will be encoded as you want.

You can extend the other encoding and decoding containers the same way.

Rudedog
  • 4,323
  • 1
  • 23
  • 34
  • Fantastic! You're right, I am actually talking about the encoding of the value. The information and example you provide got me to a working implementation. Thanks! – David Nedrow Jun 21 '19 at 03:23