Skip to content

Commit dc0e790

Browse files
committed
Add support of kebab-case for JSON keys & case class fields
1 parent efeb7be commit dc0e790

File tree

4 files changed

+104
-24
lines changed

4 files changed

+104
-24
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ There are number of configurable options that can be set in compile-time:
6363
- Ability to read/write number of containers from/to string values
6464
- Skipping of unexpected fields or throwing of parse exceptions
6565
- Mapping function for names between case classes and JSON, including predefined functions which enforce
66-
snake_case or camelCase names for all fields
66+
snake_case, kebab-case or camelCase names for all fields
6767
- Name of a discriminator field for ADTs
6868
- Mapping function for values of a discriminator field that is used for distinguish classes of ADTs
6969

macros/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ case class CodecMakerConfig(
4444

4545
object JsonCodecMaker {
4646
def enforceCamelCase(s: String): String =
47-
if (s.indexOf('_') == -1) s
47+
if (s.indexOf('_') == -1 && s.indexOf('-') == -1) s
4848
else {
4949
val len = s.length
5050
val sb = new StringBuilder(len)
@@ -53,7 +53,7 @@ object JsonCodecMaker {
5353
while (i < len) isPrecedingDash = {
5454
val ch = s.charAt(i)
5555
i += 1
56-
if (ch == '_') true
56+
if (ch == '_' || ch == '-') true
5757
else {
5858
sb.append(if (isPrecedingDash) toUpperCase(ch) else toLowerCase(ch))
5959
false
@@ -70,8 +70,8 @@ object JsonCodecMaker {
7070
while (i < len) isPrecedingLowerCased = {
7171
val ch = s.charAt(i)
7272
i += 1
73-
if (ch == '_') {
74-
sb.append(ch)
73+
if (ch == '_' || ch == '-') {
74+
sb.append('_')
7575
false
7676
} else if (isLowerCase(ch)) {
7777
sb.append(ch)
@@ -85,6 +85,29 @@ object JsonCodecMaker {
8585
sb.toString
8686
}
8787

88+
def `enforce-kebab-case`(s: String): String = {
89+
val len = s.length
90+
val sb = new StringBuilder(len << 1)
91+
var i = 0
92+
var isPrecedingLowerCased = false
93+
while (i < len) isPrecedingLowerCased = {
94+
val ch = s.charAt(i)
95+
i += 1
96+
if (ch == '_' || ch == '-') {
97+
sb.append('-')
98+
false
99+
} else if (isLowerCase(ch)) {
100+
sb.append(ch)
101+
true
102+
} else {
103+
if (isPrecedingLowerCased) sb.append('-')
104+
sb.append(toLowerCase(ch))
105+
false
106+
}
107+
}
108+
sb.toString
109+
}
110+
88111
def simpleClassName(fullClassName: String): String =
89112
fullClassName.substring(Math.max(fullClassName.lastIndexOf('.') + 1, 0))
90113

@@ -332,8 +355,11 @@ object JsonCodecMaker {
332355
def getStringified(annotations: Map[String, FieldAnnotations], name: String): Boolean =
333356
annotations.get(name).fold(false)(_.stringified)
334357

358+
def fixMinuses(name: String): String =
359+
if (name.indexOf("$minus") == -1) name else name.replace("$minus", "-")
360+
335361
def getMappedName(annotations: Map[String, FieldAnnotations], defaultName: String): String =
336-
annotations.get(defaultName).fold(codecConfig.fieldNameMapper(defaultName))(_.name)
362+
annotations.get(defaultName).fold(codecConfig.fieldNameMapper(fixMinuses(defaultName)))(_.name)
337363

338364
def getCollisions(names: Traversable[String]): Traversable[String] =
339365
names.groupBy(identity).collect { case (x, xs) if xs.size > 1 => x }

macros/src/test/scala/com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMakerSpec.scala

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ class JsonCodecMakerSpec extends WordSpec with Matchers {
101101

102102
case class BitSets(bs: BitSet, mbs: mutable.BitSet)
103103

104-
case class CamelAndSnakeCases(camelCase: String, snake_case: String, `camel1`: String, `snake_1`: String)
104+
case class CamelSnakeKebabCases(camelCase: Int, snake_case: Int, `kebab-case`: Int,
105+
`camel1`: Int, `snake_1`: Int, `kebab-1`: Int)
105106

106107
case class Indented(s: String, bd: BigDecimal, l: List[Int], m: Map[Char, Double])
107108

@@ -677,31 +678,57 @@ class JsonCodecMakerSpec extends WordSpec with Matchers {
677678
})
678679
}
679680
"serialize and deserialize with keys defined as is by fields" in {
680-
verifySerDeser(make[CamelAndSnakeCases](CodecMakerConfig()),
681-
CamelAndSnakeCases("VVV", "WWW", "YYY", "ZZZ"),
682-
"""{"camelCase":"VVV","snake_case":"WWW","camel1":"YYY","snake_1":"ZZZ"}""".getBytes)
681+
verifySerDeser(make[CamelSnakeKebabCases](CodecMakerConfig()),
682+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
683+
"""{"camelCase":1,"snake_case":2,"kebab-case":3,"camel1":4,"snake_1":5,"kebab-1":6}""".getBytes)
683684
}
684685
"serialize and deserialize with keys enforced to camelCase and throw parse exception when they are missing" in {
685-
val codecOfCamelAndSnakeCases = make[CamelAndSnakeCases](CodecMakerConfig(JsonCodecMaker.enforceCamelCase))
686+
val codecOfCamelAndSnakeCases = make[CamelSnakeKebabCases](CodecMakerConfig(JsonCodecMaker.enforceCamelCase))
686687
verifySerDeser(codecOfCamelAndSnakeCases,
687-
CamelAndSnakeCases("VVV", "WWW", "YYY", "ZZZ"),
688-
"""{"camelCase":"VVV","snakeCase":"WWW","camel1":"YYY","snake1":"ZZZ"}""".getBytes)
688+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
689+
"""{"camelCase":1,"snakeCase":2,"kebabCase":3,"camel1":4,"snake1":5,"kebab1":6}""".getBytes)
689690
assert(intercept[JsonParseException] {
690691
verifyDeser(codecOfCamelAndSnakeCases,
691-
CamelAndSnakeCases("VVV", "WWW", "YYY", "ZZZ"),
692-
"""{"camel_case":"VVV","snake_case":"WWW","camel_1":"YYY","snake_1":"ZZZ"}""".getBytes)
693-
}.getMessage.contains("missing required field(s) \"camelCase\", \"snakeCase\", \"camel1\", \"snake1\", offset: 0x00000046"))
692+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
693+
"""{"camel_case":1,"snake_case":2,"kebab_case":3,"camel_1":4,"snake_1":5,"kebab_1":6}""".getBytes)
694+
}.getMessage.contains("missing required field(s) \"camelCase\", \"snakeCase\", \"kebabCase\", \"camel1\", \"snake1\", \"kebab1\", offset: 0x00000051"))
695+
assert(intercept[JsonParseException] {
696+
verifyDeser(codecOfCamelAndSnakeCases,
697+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
698+
"""{"camel-case":1,"snake-case":2,"kebab-case":3,"camel-1":4,"snake-1":5,"kebab-1":6}""".getBytes)
699+
}.getMessage.contains("missing required field(s) \"camelCase\", \"snakeCase\", \"kebabCase\", \"camel1\", \"snake1\", \"kebab1\", offset: 0x00000051"))
694700
}
695701
"serialize and deserialize with keys enforced to snake_case and throw parse exception when they are missing" in {
696-
val codecOfCamelAndSnakeCases = make[CamelAndSnakeCases](CodecMakerConfig(JsonCodecMaker.enforce_snake_case))
702+
val codecOfCamelAndSnakeCases = make[CamelSnakeKebabCases](CodecMakerConfig(JsonCodecMaker.enforce_snake_case))
703+
verifySerDeser(codecOfCamelAndSnakeCases,
704+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
705+
"""{"camel_case":1,"snake_case":2,"kebab_case":3,"camel_1":4,"snake_1":5,"kebab_1":6}""".getBytes)
706+
assert(intercept[JsonParseException] {
707+
verifyDeser(codecOfCamelAndSnakeCases,
708+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
709+
"""{"camelCase":1,"snakeCase":2,"kebabCase":3,"camel1":4,"snake1":5,"kebab1":6}""".getBytes)
710+
}.getMessage.contains("missing required field(s) \"camel_case\", \"snake_case\", \"kebab_case\", \"camel_1\", \"snake_1\", \"kebab_1\", offset: 0x0000004b"))
711+
assert(intercept[JsonParseException] {
712+
verifyDeser(codecOfCamelAndSnakeCases,
713+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
714+
"""{"camel-case":1,"snake-case":2,"kebab-case":3,"camel-1":4,"snake-1":5,"kebab-1":6}""".getBytes)
715+
}.getMessage.contains("missing required field(s) \"camel_case\", \"snake_case\", \"kebab_case\", \"camel_1\", \"snake_1\", \"kebab_1\", offset: 0x00000051"))
716+
}
717+
"serialize and deserialize with keys enforced to kebab-case and throw parse exception when they are missing" in {
718+
val codecOfCamelAndSnakeCases = make[CamelSnakeKebabCases](CodecMakerConfig(JsonCodecMaker.`enforce-kebab-case`))
697719
verifySerDeser(codecOfCamelAndSnakeCases,
698-
CamelAndSnakeCases("VVV", "WWW", "YYY", "ZZZ"),
699-
"""{"camel_case":"VVV","snake_case":"WWW","camel_1":"YYY","snake_1":"ZZZ"}""".getBytes)
720+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
721+
"""{"camel-case":1,"snake-case":2,"kebab-case":3,"camel-1":4,"snake-1":5,"kebab-1":6}""".getBytes)
722+
assert(intercept[JsonParseException] {
723+
verifyDeser(codecOfCamelAndSnakeCases,
724+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
725+
"""{"camelCase":1,"snakeCase":2,"kebabCase":3,"camel1":4,"snake1":5,"kebab1":6}""".getBytes)
726+
}.getMessage.contains("missing required field(s) \"camel-case\", \"snake-case\", \"kebab-case\", \"camel-1\", \"snake-1\", \"kebab-1\", offset: 0x0000004b"))
700727
assert(intercept[JsonParseException] {
701728
verifyDeser(codecOfCamelAndSnakeCases,
702-
CamelAndSnakeCases("VVV", "WWW", "YYY", "ZZZ"),
703-
"""{"camelCase":"VVV","snakeCase":"WWW","camel1":"YYY","snake1":"ZZZ"}""".getBytes)
704-
}.getMessage.contains("missing required field(s) \"camel_case\", \"snake_case\", \"camel_1\", \"snake_1\", offset: 0x00000042"))
729+
CamelSnakeKebabCases(1, 2, 3, 4, 5, 6),
730+
"""{"camel_case":1,"snake_case":2,"kebab_case":3,"camel_1":4,"snake_1":5,"kebab_1":6}""".getBytes)
731+
}.getMessage.contains("missing required field(s) \"camel-case\", \"snake-case\", \"kebab-case\", \"camel-1\", \"snake-1\", \"kebab-1\", offset: 0x00000051"))
705732
}
706733
"serialize and deserialize with keys overridden by annotation and throw parse exception when they are missing" in {
707734
verifySerDeser(codecOfNameOverridden, NameOverridden(oldName = "VVV"), """{"new_name":"VVV"}""".getBytes)
@@ -1060,6 +1087,11 @@ class JsonCodecMakerSpec extends WordSpec with Matchers {
10601087
JsonCodecMaker.enforceCamelCase("o_ooo_") shouldBe "oOoo"
10611088
JsonCodecMaker.enforceCamelCase("O_OOO_111") shouldBe "oOoo111"
10621089
}
1090+
"transform kebab-case names to camelCase" in {
1091+
JsonCodecMaker.enforceCamelCase("o-o") shouldBe "oO"
1092+
JsonCodecMaker.enforceCamelCase("o-ooo-") shouldBe "oOoo"
1093+
JsonCodecMaker.enforceCamelCase("O-OOO-111") shouldBe "oOoo111"
1094+
}
10631095
"leave camelCase names as is" in {
10641096
JsonCodecMaker.enforceCamelCase("oO") shouldBe "oO"
10651097
JsonCodecMaker.enforceCamelCase("oOoo") shouldBe "oOoo"
@@ -1072,12 +1104,34 @@ class JsonCodecMakerSpec extends WordSpec with Matchers {
10721104
JsonCodecMaker.enforce_snake_case("oOoo") shouldBe "o_ooo"
10731105
JsonCodecMaker.enforce_snake_case("oOoo111") shouldBe "o_ooo_111"
10741106
}
1075-
"enforce lower case for snake_case names as is" in {
1107+
"transform kebab-case names to snake_case" in {
1108+
JsonCodecMaker.enforce_snake_case("o-O") shouldBe "o_o"
1109+
JsonCodecMaker.enforce_snake_case("o-ooo-") shouldBe "o_ooo_"
1110+
JsonCodecMaker.enforce_snake_case("O-OOO-111") shouldBe "o_ooo_111"
1111+
}
1112+
"enforce lower case for snake_case names" in {
10761113
JsonCodecMaker.enforce_snake_case("o_O") shouldBe "o_o"
10771114
JsonCodecMaker.enforce_snake_case("o_ooo_") shouldBe "o_ooo_"
10781115
JsonCodecMaker.enforce_snake_case("O_OOO_111") shouldBe "o_ooo_111"
10791116
}
10801117
}
1118+
"JsonCodecMaker.enforce-kebab-case" should {
1119+
"transform camelCase names to kebab-case" in {
1120+
JsonCodecMaker.`enforce-kebab-case`("oO") shouldBe "o-o"
1121+
JsonCodecMaker.`enforce-kebab-case`("oOoo") shouldBe "o-ooo"
1122+
JsonCodecMaker.`enforce-kebab-case`("oOoo111") shouldBe "o-ooo-111"
1123+
}
1124+
"transform snake_case names to kebab-case" in {
1125+
JsonCodecMaker.`enforce-kebab-case`("o_O") shouldBe "o-o"
1126+
JsonCodecMaker.`enforce-kebab-case`("o_ooo_") shouldBe "o-ooo-"
1127+
JsonCodecMaker.`enforce-kebab-case`("O_OOO_111") shouldBe "o-ooo-111"
1128+
}
1129+
"enforce lower case for kebab-case names" in {
1130+
JsonCodecMaker.`enforce-kebab-case`("o-O") shouldBe "o-o"
1131+
JsonCodecMaker.`enforce-kebab-case`("o-ooo-") shouldBe "o-ooo-"
1132+
JsonCodecMaker.`enforce-kebab-case`("O-OOO-111") shouldBe "o-ooo-111"
1133+
}
1134+
}
10811135
"JsonCodecMaker.simpleClassName" should {
10821136
"shorten full class name to simple class name" in {
10831137
JsonCodecMaker.simpleClassName("com.github.plohkotnyuk.jsoniter_scala.Test") shouldBe "Test"

version.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version in ThisBuild := "0.16.1-SNAPSHOT"
1+
version in ThisBuild := "0.17.0-SNAPSHOT"

0 commit comments

Comments
 (0)