├── .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 | [](https://github.com/Fleuronic/Skewer/releases/latest)
6 | [](https://raw.githubusercontent.com/Fleuronic/Skewer/main/LICENSE)
7 | [](https://github.com/Fleuronic/Skewer/issues)
8 | [](https://github.com/Fleuronic/Skewer/releases)
9 |
10 | [](https://swiftpackageindex.com/Fleuronic/Skewer)
11 |
12 | [](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 |
--------------------------------------------------------------------------------