Skip to content

Commit

Permalink
added some changes from @gwynne + another test
Browse files Browse the repository at this point in the history
  • Loading branch information
MahdiBM committed Nov 3, 2023
1 parent 7e01c2c commit 5673062
Show file tree
Hide file tree
Showing 2 changed files with 54 additions and 51 deletions.
65 changes: 24 additions & 41 deletions Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift
Expand Up @@ -50,51 +50,26 @@ public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder {
/// Provide a custom conversion from the key in the encoded queries to the keys specified by the decoded types.
/// The full path to the current decoding 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 decoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the container for the type to decode from.
case custom((_ codingPath: [CodingKey]) -> CodingKey)
case custom(@Sendable (_ codingPath: [CodingKey]) -> CodingKey)

fileprivate static func _convertFromSnakeCase(_ stringKey: String) -> String {
guard !stringKey.isEmpty else { return stringKey }
static func _convertedFromSnakeCase(_ key: String) -> String {
guard !key.isEmpty, let firstNonUnderscore = key.firstIndex(where: { $0 != "_" })
else { return .init(key) }

// Find the first non-underscore character
guard let firstNonUnderscore = stringKey.firstIndex(where: { $0 != "_" }) else {
// Reached the end without finding an _
return stringKey
}

// Find the last non-underscore character
var lastNonUnderscore = stringKey.index(before: stringKey.endIndex)
while lastNonUnderscore > firstNonUnderscore && stringKey[lastNonUnderscore] == "_" {
stringKey.formIndex(before: &lastNonUnderscore)
}
var lastNonUnderscore = key.endIndex
repeat {
key.formIndex(before: &lastNonUnderscore)
} while lastNonUnderscore > firstNonUnderscore && key[lastNonUnderscore] == "_"

let keyRange = firstNonUnderscore...lastNonUnderscore
let leadingUnderscoreRange = stringKey.startIndex..<firstNonUnderscore
let trailingUnderscoreRange = stringKey.index(after: lastNonUnderscore)..<stringKey.endIndex
let keyRange = key[firstNonUnderscore...lastNonUnderscore]
let leading = key[key.startIndex..<firstNonUnderscore]
let trailing = key[key.index(after: lastNonUnderscore)..<key.endIndex]
let words = keyRange.split(separator: "_")

let components = stringKey[keyRange].split(separator: "_")
let joinedString: String
if components.count == 1 {
// No underscores in key, leave the word as is - maybe already camel cased
joinedString = String(stringKey[keyRange])
} else {
joinedString = ([components[0].lowercased()] + components[1...].map { $0.capitalized }).joined()
guard words.count > 1 else {
return "\(leading)\(keyRange)\(trailing)"
}

// Do a cheap isEmpty check before creating and appending potentially empty strings
let result: String
if (leadingUnderscoreRange.isEmpty && trailingUnderscoreRange.isEmpty) {
result = joinedString
} else if (!leadingUnderscoreRange.isEmpty && !trailingUnderscoreRange.isEmpty) {
// Both leading and trailing underscores
result = String(stringKey[leadingUnderscoreRange]) + joinedString + String(stringKey[trailingUnderscoreRange])
} else if (!leadingUnderscoreRange.isEmpty) {
// Just leading
result = String(stringKey[leadingUnderscoreRange]) + joinedString
} else {
// Just trailing
result = joinedString + String(stringKey[trailingUnderscoreRange])
}
return result
return "\(leading)\(([words[0].decapitalized] + words[1...].map(\.encapitalized)).joined())\(trailing)"
}
}

Expand Down Expand Up @@ -571,7 +546,7 @@ private func convertKeys(
var converted = [String: URLEncodedFormData]()
converted.reserveCapacity(data.children.count)
data.children.forEach { (key, value) in
converted[Configuration.KeyDecodingStrategy._convertFromSnakeCase(key)] = convertKeys(
converted[Configuration.KeyDecodingStrategy._convertedFromSnakeCase(key)] = convertKeys(
data: value,
codingPath: codingPath,
strategy: strategy
Expand All @@ -593,3 +568,11 @@ private func convertKeys(
return URLEncodedFormData(values: data.values, children: converted)
}
}

private extension StringProtocol {
/// Returns the string with its first character lowercased.
var decapitalized: String { self.isEmpty ? "" : "\(self[self.startIndex].lowercased())\(self.dropFirst())" }

/// Returns the string with its first character uppercased.
var encapitalized: String { self.isEmpty ? "" : "\(self[self.startIndex].uppercased())\(self.dropFirst())" }
}
40 changes: 30 additions & 10 deletions Tests/VaporTests/URLEncodedFormTests.swift
@@ -1,6 +1,7 @@
@testable import Vapor
import XCTest
import NIOPosix
import Foundation

final class URLEncodedFormTests: XCTestCase {
// MARK: Codable
Expand All @@ -22,16 +23,7 @@ final class URLEncodedFormTests: XCTestCase {
XCTAssertEqual(user.nums[0], 3.14)
}

func testDecodeWithKeyDecodingStrategy() throws {

struct KeyDecodingTester: Codable, Equatable {
var dataPoint22: Int
var urlSession: Int
var _iAmAnAppDeveloper: Int
var single: Int
var asdfĆqer: Int
}

func testDecodeWithKeyDecodingStrategyConvertFromSnakeCase() throws {
let data = """
data_point22=33&url_session=33&_i_am_an_app_developer=33&single=33&asdf_ćqer=33
"""
Expand All @@ -46,6 +38,26 @@ final class URLEncodedFormTests: XCTestCase {
XCTAssertEqual(container.asdfĆqer, 33)
}

func testDecodeWithKeyDecodingStrategyCustomConverter() throws {
let data = """
data_point22=33&url_session=33&_i_am_an_app_developer=33&single=33&asdf_ćqer=33
"""
typealias KeyDecodingStrategy = URLEncodedFormDecoder.Configuration.KeyDecodingStrategy
/// The same `KeyDecodingStrategy` as `.convertFromSnakeCase`, but using `.custom`.
let strategy = KeyDecodingStrategy.custom {
BasicCodingKey.key(KeyDecodingStrategy._convertedFromSnakeCase($0.last!.stringValue))
}
let decoder = URLEncodedFormDecoder(
configuration: .init(keyDecodingStrategy: strategy)
)
let container = try decoder.decode(KeyDecodingTester.self, from: data)
XCTAssertEqual(container.dataPoint22, 33)
XCTAssertEqual(container.urlSession, 33)
XCTAssertEqual(container._iAmAnAppDeveloper, 33)
XCTAssertEqual(container.single, 33)
XCTAssertEqual(container.asdfĆqer, 33)
}

func testDecodeCommaSeparatedArray() throws {
let data = """
name=Tanner&age=23&pets=Zizek,Foo%2C&dict[a]=1&dict[b]=2&foos=baz&nums=3.14
Expand Down Expand Up @@ -714,6 +726,14 @@ private struct User: Codable, Equatable {
var isCool: Bool
}

private struct KeyDecodingTester: Codable, Equatable {
var dataPoint22: Int
var urlSession: Int
var _iAmAnAppDeveloper: Int
var single: Int
var asdfĆqer: Int
}

class BaseClass: Codable, Equatable {
var baseField: String?
static func == (lhs: BaseClass, rhs: BaseClass) -> Bool {
Expand Down

0 comments on commit 5673062

Please sign in to comment.