Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 106 additions & 7 deletions Sources/ObjectEncoder/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,17 @@ public struct ObjectEncoder {
set { options.encodingStrategies = newValue }
}

/// The strategies to use for encoding keys.
public var keyEncodingStrategy: KeyEncodingStrategy {
get { return options.keyEncodingStrategy }
set { options.keyEncodingStrategy = newValue }
}

// MARK: -

fileprivate struct Options {
fileprivate var encodingStrategies = EncodingStrategies()
fileprivate var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys
}

fileprivate var options = Options()
Expand Down Expand Up @@ -133,6 +140,84 @@ extension ObjectEncoder.Encoder {
}
}

extension ObjectEncoder {
/// The strategy to use for automatically changing the value of keys before encoding.
public enum KeyEncodingStrategy {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Convert from "camelCaseKeys" to "snake_case_keys" before writing a key to JSON payload.
///
/// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt).
/// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means the result is consistent regardless of the current user's locale and language preferences.
///
/// Converting from camel case to snake case:
/// 1. Splits words at the boundary of lower-case to upper-case
/// 2. Inserts `_` between words
/// 3. Lowercases the entire string
/// 4. Preserves starting and ending `_`.
///
/// For example, `oneTwoThree` becomes `one_two_three`. `_oneTwoThree_` becomes `_one_two_three_`.
///
/// - Note: Using a key encoding strategy has a nominal performance cost, as each string key has to be converted.
case convertToSnakeCase

/// Provide a custom conversion to the key in the encoded JSON from the keys specified by the encoded types.
/// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the result.
case custom((_ codingPath: [CodingKey]) -> CodingKey)

fileprivate static func _convertToSnakeCase(_ 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
}
}
}

private class _KeyReferencingEncoder: ObjectEncoder.Encoder {
let encoder: ObjectEncoder.Encoder
let key: String
Expand Down Expand Up @@ -172,24 +257,38 @@ private struct _KeyedEncodingContainer<Key: CodingKey>: KeyedEncodingContainerPr
self.encoder = encoder
}

// MARK: - Coding Path Operations

private func _converted(_ key: CodingKey) -> CodingKey {
switch encoder.options.keyEncodingStrategy {
case .useDefaultKeys:
return key
case .convertToSnakeCase:
let newKeyString = ObjectEncoder.KeyEncodingStrategy._convertToSnakeCase(key.stringValue)
return _ObjectCodingKey(stringValue: newKeyString)!
case .custom(let converter):
return converter(codingPath + [key])
}
}

// MARK: - Swift.KeyedEncodingContainerProtocol Methods

var codingPath: [CodingKey] { return encoder.codingPath }
func encodeNil(forKey key: Key) throws { try encoder(for: key).encodeNil() }
func encode<T>(_ value: T, forKey key: Key) throws where T: Primitive { try encoder(for: key).encode(value) }
func encode<T>(_ value: T, forKey key: Key) throws where T: Encodable { try encoder(for: key).encode(value) }
func encodeNil(forKey key: Key) throws { try encoder(for: _converted(key)).encodeNil() }
func encode<T>(_ value: T, forKey key: Key) throws where T: Primitive { try encoder(for: _converted(key)).encode(value) }
func encode<T>(_ value: T, forKey key: Key) throws where T: Encodable { try encoder(for: _converted(key)).encode(value) }

func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type,
forKey key: Key) -> KeyedEncodingContainer<NestedKey> {
return encoder(for: key).container(keyedBy: type)
return encoder(for: _converted(key)).container(keyedBy: type)
}

func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
return encoder(for: key).unkeyedContainer()
return encoder(for: _converted(key)).unkeyedContainer()
}

func superEncoder() -> Encoder { return encoder(for: _ObjectCodingKey.super) }
func superEncoder(forKey key: Key) -> Encoder { return encoder(for: key) }
func superEncoder() -> Encoder { return encoder(for: _converted(_ObjectCodingKey.super)) }
func superEncoder(forKey key: Key) -> Encoder { return encoder(for: _converted(key)) }
}

private struct _UnkeyedEncodingContainer: UnkeyedEncodingContainer {
Expand Down