Skip to content

Commit a543a40

Browse files
committed
Supports snakecase.
1 parent 326a001 commit a543a40

2 files changed

Lines changed: 165 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package spray.json
2+
3+
import scala.reflect.ClassTag
4+
import scala.collection.mutable
5+
6+
/**
7+
* Provides snake cased JsonFormats
8+
*/
9+
trait SnakeCaseJsonSupport extends DefaultJsonProtocol {
10+
11+
override protected def extractFieldNames(classTag: ClassTag[_]): Array[String] = {
12+
def snakify(name: String) = {
13+
val chars = name.toCharArray
14+
val len = name.length
15+
val sb = new mutable.StringBuilder(len + len / 5)
16+
17+
def isAlphabetic(char: Char): Boolean = (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
18+
19+
def go(i: Int, rest: Int, processedUpper: Boolean, processedAlphaNumeric: Boolean): String =
20+
if (rest == 0) {
21+
sb.toString()
22+
} else if (rest > 1 && chars(i).isUpper && chars(i + 1).isLower) {
23+
if (processedAlphaNumeric) {
24+
sb.append('_')
25+
}
26+
sb.append(chars(i).toLower).append(chars(i + 1))
27+
go(i + 2, rest - 2, false, true)
28+
} else if (chars(i).isUpper) {
29+
if (!processedUpper && processedAlphaNumeric) {
30+
sb.append('_')
31+
}
32+
sb.append(chars(i).toLower)
33+
go(i + 1, rest - 1, true, chars(i).isDigit || isAlphabetic(chars(i)))
34+
} else if (!isAlphabetic(chars(i))) {
35+
sb.append(chars(i))
36+
go(i + 1, rest - 1, false, chars(i).isDigit)
37+
} else {
38+
sb.append(chars(i).toLower)
39+
go(i + 1, rest - 1, chars(i).isUpper, chars(i).isDigit || isAlphabetic(chars(i)))
40+
}
41+
42+
go(0, len, true, false)
43+
}
44+
45+
super.extractFieldNames(classTag).map {
46+
snakify(_)
47+
}
48+
}
49+
50+
}
51+
52+
object SnakeCaseJsonSupport extends SnakeCaseJsonSupport
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package spray.json
2+
3+
import java.util.Locale
4+
5+
import org.specs2.mutable._
6+
7+
class SnakeCaseJsonSupportSpec extends Specification with SnakeCaseJsonSupport {
8+
9+
private val PASS1 = """([A-Z]+)([A-Z][a-z])""".r
10+
private val PASS2 = """([a-z\d])([A-Z])""".r
11+
private val REPLACEMENT = "$1_$2"
12+
13+
def snakify(name: String) =
14+
PASS2.replaceAllIn(PASS1.replaceAllIn(name, REPLACEMENT), REPLACEMENT).toLowerCase(Locale.US)
15+
16+
"SnakeCaseJsonSUpport" should {
17+
"convert field names to snake cased fields names." in {
18+
// Given
19+
case class SampleFields(
20+
HelloWorld: Int = 0,
21+
A1AAbBBBBBbCCcc: Int = 0,
22+
AbAAbbAAAbbb1AA2bb1AA2AAAAbb1bAA: Int = 0,
23+
x: Int = 0,
24+
Y: Int = 0,
25+
`^There-arE_Non_(Alphabetic))_character!S`: Int = 0,
26+
`tHere-Are_(AlsO)_noN_(alphaBetic))_CharaCter!s`: Int = 0,
27+
` There are space character `: Int = 0,
28+
` これは マルチバイトAbcのaBc確認です。`: Int = 0,
29+
aa: Int = 0,
30+
AA: Int = 0,
31+
Aa: Int = 0,
32+
aA: Int = 0,
33+
AAA: Int = 0,
34+
AAa: Int = 0,
35+
AaA: Int = 0,
36+
aAA: Int = 0,
37+
aaA: Int = 0,
38+
aAa: Int = 0,
39+
Aaa: Int = 0,
40+
aaa: Int = 0
41+
)
42+
case class SampleFields2(
43+
aaa123bbb: Int = 0,
44+
aaA123bbb: Int = 0,
45+
aaa123Bbb: Int = 0,
46+
aaA123Bbb: Int = 0,
47+
aaa123: Int = 0,
48+
aaA123: Int = 0,
49+
aAA123: Int = 0
50+
)
51+
implicit val jsonFormat1: RootJsonFormat[SampleFields] =
52+
jsonFormat21(SampleFields.apply)
53+
implicit val jsonFormat2: RootJsonFormat[SampleFields2] =
54+
jsonFormat7(SampleFields2.apply)
55+
// When
56+
val sampleFields1 = SampleFields().toJson.asJsObject
57+
val sampleFields2 = SampleFields2().toJson.asJsObject
58+
// Then
59+
val fields1 = sampleFields1.fields
60+
fields1.contains(snakify("HelloWorld")) mustEqual true
61+
fields1.contains(snakify("A1AAbBBBBBbCCcc")) mustEqual true
62+
fields1.contains(snakify("AbAAbbAAAbbb1AA2bb1AA2AAAAbb1bAA")) mustEqual true
63+
fields1.contains(snakify("x")) mustEqual true
64+
fields1.contains(snakify("Y")) mustEqual true
65+
fields1.contains(snakify("^There-arE_Non_(Alphabetic))_character!S")) mustEqual true
66+
fields1.contains(snakify("tHere-Are_(AlsO)_noN_(alphaBetic))_CharaCter!s")) mustEqual true
67+
fields1.contains(snakify(" There are space character ")) mustEqual true
68+
fields1.contains(snakify(" これは マルチバイトAbcのaBc確認です。")) mustEqual true
69+
fields1.contains(snakify("aa")) mustEqual true
70+
fields1.contains(snakify("aA")) mustEqual true
71+
fields1.contains(snakify("Aa")) mustEqual true
72+
fields1.contains(snakify("AA")) mustEqual true
73+
fields1.contains(snakify("AAA")) mustEqual true
74+
fields1.contains(snakify("aaA")) mustEqual true
75+
fields1.contains(snakify("aAa")) mustEqual true
76+
fields1.contains(snakify("Aaa")) mustEqual true
77+
fields1.contains(snakify("AAa")) mustEqual true
78+
fields1.contains(snakify("AaA")) mustEqual true
79+
fields1.contains(snakify("aAA")) mustEqual true
80+
fields1.contains(snakify("aaa")) mustEqual true
81+
val fields2 = sampleFields2.fields
82+
fields2.contains(snakify("aaa123bbb")) mustEqual true
83+
fields2.contains(snakify("aaa123Bbb")) mustEqual true
84+
fields2.contains(snakify("aaA123bbb")) mustEqual true
85+
fields2.contains(snakify("aaa123")) mustEqual true
86+
fields2.contains(snakify("aaA123")) mustEqual true
87+
fields2.contains(snakify("aAA123")) mustEqual true
88+
}
89+
"deserialize snake cased json" in {
90+
// Given
91+
case class SampleFields(
92+
a: Int,
93+
aA: Int,
94+
helloWorld: Int,
95+
HelloTheWorld: Int
96+
)
97+
implicit val jsonFormat: RootJsonFormat[SampleFields] =
98+
jsonFormat4(SampleFields.apply)
99+
val json = JsObject(
100+
"hello_the_world" -> 4.toJson,
101+
"a_a" -> 2.toJson,
102+
"a" -> 1.toJson,
103+
"hello_world" -> 3.toJson
104+
)
105+
// When
106+
val result = jsonFormat.read(json)
107+
// Then
108+
result mustEqual SampleFields(
109+
a = 1, aA = 2, helloWorld = 3, HelloTheWorld = 4
110+
)
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)