├── .editorconfig ├── .github └── CODE_OF_CONDUCT.md ├── .gitignore ├── .swift-version ├── .swiftformat ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Skewer │ ├── AnyKey.swift │ ├── ComponentTransform.swift │ ├── Conversion.swift │ ├── Hyphen.swift │ ├── KeyDecodingStrategy.swift │ └── KeyEncodingStrategy.swift └── Tests └── SkewerTests ├── DecodingTests.swift └── EncodingTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jordan@fleuronic.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | /*.xcworkspacedata 7 | xcuserdata/ 8 | DerivedData/ 9 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.5 -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --enable isEmpty 2 | 3 | --disable blankLinesAroundMark 4 | --disable hoistPatternLet 5 | --disable sortedImports 6 | --disable trailingCommas 7 | --disable preferKeypath 8 | 9 | --decimalgrouping ignore 10 | --ifdef outdent 11 | --indent tab 12 | --nospaceoperators ...,..< 13 | --tabwidth 4 14 | --stripunusedargs closure-only -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fleuronic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | var targets: [Target] = [.target(name: "Skewer")] 6 | 7 | #if swift(>=5.4) 8 | targets.append( 9 | .testTarget( 10 | name: "SkewerTests", 11 | dependencies: ["Skewer"] 12 | ) 13 | ) 14 | #endif 15 | 16 | let package = Package( 17 | name: "Skewer", 18 | platforms: [ 19 | .iOS(.v13), 20 | .macOS(.v10_15), 21 | .watchOS(.v6), 22 | .tvOS(.v13) 23 | ], 24 | products: [ 25 | .library( 26 | name: "Skewer", 27 | targets: ["Skewer"] 28 | ) 29 | ], 30 | targets: targets 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skewer 🍡 2 | 3 |
4 | 5 | [![Release](https://img.shields.io/github/v/release/Fleuronic/Skewer?cache-seconds=0)](https://github.com/Fleuronic/Skewer/releases/latest) 6 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://raw.githubusercontent.com/Fleuronic/Skewer/main/LICENSE) 7 | [![Issues](https://img.shields.io/github/issues/Fleuronic/Skewer?logo=github&cache-seconds=0)](https://github.com/Fleuronic/Skewer/issues) 8 | [![Downloads](https://img.shields.io/github/downloads/Fleuronic/Skewer/total?cache-seconds=0)](https://github.com/Fleuronic/Skewer/releases) 9 | 10 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFleuronic%2FSkewer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Fleuronic/Skewer) 11 | 12 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFleuronic%2FSkewer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Fleuronic/Skewer) 13 | 14 |
15 | 16 | Provides support for "kebab-case" formatted (as opposed to just "snake_case" formatted) coding keys for `JSONEncoder` and `JSONDecoder`. 17 | 18 | ## Encoding 19 | 20 | ```swift 21 | struct Website: Encodable { 22 | let homepageURLString: String 23 | } 24 | 25 | let encoder = JSONEncoder() 26 | encoder.keyEncodingStrategy = .convertToKebabCase 27 | 28 | let website = Website(homepageURLString: "http://www.apple.com") 29 | let data = try! encoder.encode(website) 30 | let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] 31 | let convertedKey = json.keys.first! 32 | // homepage-url-string 33 | ``` 34 | 35 | ## Decoding 36 | 37 | ```swift 38 | struct App: Decodable { 39 | let downloadCount: Int 40 | } 41 | 42 | let decoder = JSONDecoder() 43 | decoder.keyDecodingStrategy = .convertFromKebabCase 44 | 45 | let json = ["download-count": 999] 46 | let data = try! JSONSerialization.data(withJSONObject: json, options: []) 47 | let app = try! decoder.decode(App.self, from: data) 48 | let downloadCount = app.downloadCount 49 | // 999 50 | ``` 51 | 52 | ## Installation 53 | **Using the Swift Package Manager** 54 | 55 | Add Skewer as a dependency to your `Package.swift` file. For more information, see the [Swift Package Manager documentation](https://github.com/apple/swift-package-manager/tree/master/Documentation). 56 | 57 | ```swift 58 | .package(url: "https://github.com/Fleuronic/Skewer", from: "3.0.0") 59 | ``` 60 | 61 | ## License 62 | 63 | Skewer is available under the MIT license. See the LICENSE file for more info. 64 | -------------------------------------------------------------------------------- /Sources/Skewer/AnyKey.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | struct AnyKey { 4 | let stringValue: String 5 | let intValue: Int? 6 | } 7 | 8 | // MARK: - 9 | extension AnyKey: CodingKey { 10 | // MARK: CodingKey 11 | init?(stringValue: String) { 12 | self.stringValue = stringValue 13 | intValue = nil 14 | } 15 | 16 | init?(intValue: Int) { 17 | stringValue = String(intValue) 18 | self.intValue = intValue 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Skewer/ComponentTransform.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import Foundation 4 | 5 | public extension JSONEncoder.KeyEncodingStrategy { 6 | enum ComponentTransform { 7 | case capitalize(prefix: String? = nil) 8 | case lowercase 9 | } 10 | } 11 | 12 | // MARK: - 13 | extension JSONEncoder.KeyEncodingStrategy.ComponentTransform { 14 | var prefix: String? { 15 | switch self { 16 | case .capitalize(let prefix): 17 | return prefix 18 | case .lowercase: 19 | return nil 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Skewer/Conversion.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import Foundation 4 | 5 | extension String { 6 | func convertedToCase(separatedBy separator: String, using componentTransform: JSONEncoder.KeyEncodingStrategy.ComponentTransform) -> Self { 7 | guard !isEmpty else { return self } 8 | 9 | var wordStart = startIndex 10 | var wordRanges: [Range] = [] 11 | var searchRange = index(after: wordStart).. Self { 47 | guard !isEmpty else { return self } 48 | 49 | let components = split(separator: separator) 50 | if components.count == 1 { 51 | return self 52 | } else { 53 | let lowercasedStrings = [components[0].lowercased()] 54 | let capitalizedStrings = components[1...].map { $0.capitalized } 55 | return (lowercasedStrings + capitalizedStrings).joined() 56 | } 57 | } 58 | } 59 | 60 | // MARK: - 61 | private extension Substring { 62 | func applying(_ componentTransform: JSONEncoder.KeyEncodingStrategy.ComponentTransform) -> String { 63 | switch componentTransform { 64 | case .capitalize: 65 | return capitalized 66 | case .lowercase: 67 | return lowercased() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Skewer/Hyphen.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | extension Character { 4 | static let hyphen: Self = "-" 5 | } 6 | 7 | // MARK: - 8 | extension String { 9 | static let hyphen = Self(.hyphen) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Skewer/KeyDecodingStrategy.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import Foundation 4 | 5 | public extension JSONDecoder.KeyDecodingStrategy { 6 | static var convertFromKebabCase: Self { 7 | convertFromCase(separatedBy: .hyphen) 8 | } 9 | 10 | static func convertFromCase(separatedBy separator: Character) -> Self { 11 | convert { 12 | $0.convertedFromCase(separatedBy: separator) 13 | } 14 | } 15 | 16 | static func convert(using conversion: @escaping (String) -> String) -> Self { 17 | .custom { keys in 18 | let stringValue = keys.last!.stringValue 19 | let convertedStringValue = conversion(stringValue) 20 | return AnyKey(stringValue: convertedStringValue)! 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Skewer/KeyEncodingStrategy.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import Foundation 4 | 5 | public extension JSONEncoder.KeyEncodingStrategy { 6 | static func convertToKebabCase(using componentTransform: ComponentTransform = .lowercase) -> Self { 7 | convertToCase(separatedBy: .hyphen, using: componentTransform) 8 | } 9 | 10 | static func convertToCase(separatedBy separator: String, using componentTransform: ComponentTransform = .lowercase) -> Self { 11 | convert { 12 | $0.convertedToCase(separatedBy: separator, using: componentTransform) 13 | } 14 | } 15 | 16 | static func convert(using conversion: @escaping (String) -> String) -> Self { 17 | .custom { keys in 18 | let stringValue = keys.last!.stringValue 19 | let convertedStringValue = conversion(stringValue) 20 | return AnyKey(stringValue: convertedStringValue)! 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/SkewerTests/DecodingTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import XCTest 4 | import Skewer 5 | 6 | final class DecodingTests: XCTestCase { 7 | func testSingleComponent() { 8 | struct City: Decodable { 9 | let name: String 10 | } 11 | 12 | let decoder = JSONDecoder() 13 | decoder.keyDecodingStrategy = .convertFromKebabCase 14 | 15 | let name = "Cupertino" 16 | let json = ["name": name] 17 | let data = try! Data(json: json) 18 | let city = try! decoder.decode(City.self, from: data) 19 | 20 | XCTAssertEqual(city.name, name) 21 | } 22 | 23 | func testMultipleComponents() { 24 | struct App: Decodable { 25 | let downloadCount: Int 26 | } 27 | 28 | let decoder = JSONDecoder() 29 | decoder.keyDecodingStrategy = .convertFromKebabCase 30 | 31 | let downloadCount = 999 32 | let json = ["download-count": downloadCount] 33 | let data = try! Data(json: json) 34 | let app = try! decoder.decode(App.self, from: data) 35 | 36 | XCTAssertEqual(app.downloadCount, downloadCount) 37 | } 38 | 39 | func testSeparator() { 40 | struct App: Decodable { 41 | let downloadCount: Int 42 | } 43 | 44 | let decoder = JSONDecoder() 45 | decoder.keyDecodingStrategy = .convertFromCase(separatedBy: ".") 46 | 47 | let downloadCount = 999 48 | let json = ["download.count": downloadCount] 49 | let data = try! Data(json: json) 50 | let app = try! decoder.decode(App.self, from: data) 51 | 52 | XCTAssertEqual(app.downloadCount, downloadCount) 53 | } 54 | 55 | func testConversion() { 56 | struct City: Decodable { 57 | let name: String 58 | } 59 | 60 | let decoder = JSONDecoder() 61 | decoder.keyDecodingStrategy = .convert { $0.lowercased() } 62 | 63 | let name = "Cupertino" 64 | let json = ["NAME": name] 65 | let data = try! Data(json: json) 66 | let city = try! decoder.decode(City.self, from: data) 67 | 68 | XCTAssertEqual(city.name, name) 69 | } 70 | } 71 | 72 | // MARK: - 73 | private extension Data { 74 | init(json: [String: Any]) throws { 75 | self = try JSONSerialization.data(withJSONObject: json, options: []) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/SkewerTests/EncodingTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright © Fleuronic LLC. All rights reserved. 2 | 3 | import XCTest 4 | import Skewer 5 | 6 | final class EncodingTests: XCTestCase { 7 | func testSingleComponent() { 8 | struct Person: Encodable { 9 | let name: String 10 | } 11 | 12 | let encoder = JSONEncoder() 13 | encoder.keyEncodingStrategy = .convertToKebabCase(using: .lowercase) 14 | 15 | let person = Person(name: "Tim Cook") 16 | let data = try! encoder.encode(person) 17 | let convertedKey = data.firstEncodedJSONKey! 18 | 19 | XCTAssertEqual(convertedKey, "name") 20 | } 21 | 22 | func testCapitalizedComponents() { 23 | struct Company: Encodable { 24 | let originalIncorporationName: String 25 | } 26 | 27 | let encoder = JSONEncoder() 28 | encoder.keyEncodingStrategy = .convertToKebabCase(using: .capitalize()) 29 | 30 | let company = Company(originalIncorporationName: "Apple Computer, Inc.") 31 | let data = try! encoder.encode(company) 32 | let convertedKey = data.firstEncodedJSONKey! 33 | 34 | XCTAssertEqual(convertedKey, "Original-Incorporation-Name") 35 | } 36 | 37 | func testCapitalizedPrefixedComponents() { 38 | struct Company: Encodable { 39 | let originalIncorporationName: String 40 | } 41 | 42 | let encoder = JSONEncoder() 43 | encoder.keyEncodingStrategy = .convertToKebabCase(using: .capitalize(prefix: "X")) 44 | 45 | let company = Company(originalIncorporationName: "Apple Computer, Inc.") 46 | let data = try! encoder.encode(company) 47 | let convertedKey = data.firstEncodedJSONKey! 48 | 49 | XCTAssertEqual(convertedKey, "X-Original-Incorporation-Name") 50 | } 51 | 52 | func testLowercasedComponents() { 53 | struct Company: Encodable { 54 | let originalIncorporationName: String 55 | } 56 | 57 | let encoder = JSONEncoder() 58 | encoder.keyEncodingStrategy = .convertToKebabCase(using: .lowercase) 59 | 60 | let company = Company(originalIncorporationName: "Apple Computer, Inc.") 61 | let data = try! encoder.encode(company) 62 | let convertedKey = data.firstEncodedJSONKey! 63 | 64 | XCTAssertEqual(convertedKey, "original-incorporation-name") 65 | } 66 | 67 | func testSeparator() { 68 | struct Company: Encodable { 69 | let originalIncorporationName: String 70 | } 71 | 72 | let encoder = JSONEncoder() 73 | encoder.keyEncodingStrategy = .convertToCase(separatedBy: ".") 74 | 75 | let company = Company(originalIncorporationName: "Apple Computer, Inc.") 76 | let data = try! encoder.encode(company) 77 | let convertedKey = data.firstEncodedJSONKey! 78 | 79 | XCTAssertEqual(convertedKey, "original.incorporation.name") 80 | } 81 | 82 | func testConversion() { 83 | struct Company: Encodable { 84 | let name: String 85 | } 86 | 87 | let encoder = JSONEncoder() 88 | encoder.keyEncodingStrategy = .convert { $0.uppercased() } 89 | 90 | let company = Company(name: "Apple") 91 | let data = try! encoder.encode(company) 92 | let convertedKey = data.firstEncodedJSONKey! 93 | 94 | XCTAssertEqual(convertedKey, "NAME") 95 | } 96 | } 97 | 98 | // MARK: - 99 | private extension Data { 100 | var firstEncodedJSONKey: String? { 101 | let json = try! JSONSerialization.jsonObject(with: self, options: []) as! [String: Any] 102 | return json.keys.first 103 | } 104 | } 105 | --------------------------------------------------------------------------------