diff --git a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift index 592804bf16..cccbacab00 100644 --- a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift +++ b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift @@ -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.. 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)" } } @@ -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 @@ -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())" } +} diff --git a/Tests/VaporTests/URLEncodedFormTests.swift b/Tests/VaporTests/URLEncodedFormTests.swift index dd8ea312fe..b3ceced779 100644 --- a/Tests/VaporTests/URLEncodedFormTests.swift +++ b/Tests/VaporTests/URLEncodedFormTests.swift @@ -1,6 +1,7 @@ @testable import Vapor import XCTest import NIOPosix +import Foundation final class URLEncodedFormTests: XCTestCase { // MARK: Codable @@ -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 """ @@ -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 @@ -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 {