Skip to content

Commit

Permalink
Add name conversion change example for Circe/JSON libraries
Browse files Browse the repository at this point in the history
  • Loading branch information
MateuszKubuszok committed Jan 3, 2025
1 parent 0ae3fac commit 49aca00
Showing 1 changed file with 141 additions and 0 deletions.
141 changes: 141 additions & 0 deletions docs/docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,147 @@ Each of these transformations is provided by the same import:
import io.scalaland.chimney.protobufs._
```

## Changing naming conventions of fields decoded from JSON

Matching/generation of JSON field name is always done in runtime, which makes it relatively easy for JSON
libraries to let user inject their configuration: it's just a pure function that works on `String`/`List[String]`
and it can be defined even next to the codec that would use it. That's why you can simply define `def`/`val`
and pass it into e.g. `implicit` the `Configuration` in Circe, or as an argument for `CodecMaker.make` in Jsoniter.

It harder to provide such function for a macro to run it during compilation: macro can only call code compiled
into the bytecode and available in class path, so such function would have to be defined in another module,
compiler before the module that would have to use it. That's the reason behing limitation of
[custom name comparison](supported-transformations.md#customizing-field-name-matching) in Chimney.

That's why the most straightforward and recommended way of converting the name convention is by:

* having a dedicated type for domain operations/business logic
* having a dedicated type for decoding JSONs into, *reflecting the expected JSON schema*
* *using the same names* for those fields that should be each other's direct counterparts
* providing the name convention converter for JSON decoding library
* providing the overrides for Chimney transformers, minimizing the amout of customizations by having matching name conventions

!!! example

```scala
//> using dep io.scalaland::chimney::{{ chimney_version() }}
//> using dep io.circe::circe-generic-extras::0.14.4
//> using dep io.circe::circe-parser::0.14.10
//> using dep com.lihaoyi::pprint::{{ libraries.pprint }}

case class Foo(someName: String, anotherName: Int)
case class Bar(someName: String, anotherName: Int, extra: Option[Double])

import io.circe.{Encoder, Decoder}
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto._
import io.circe.parser.decode
import io.circe.syntax._

// Here we're configuring the convention conversion...
implicit val customConfig: Configuration =
Configuration.default.withKebabCaseMemberNames

implicit val fooDecoder: Decoder[Foo] = deriveConfiguredDecoder[Foo]
implicit val fooEncoder: Encoder[Foo] = deriveConfiguredEncoder[Foo]

import io.scalaland.chimney.dsl._

// ...so that we don't need to do it here:

pprint.pprintln(
decode[Foo]("""{ "some-name": "value", "another-name": 10 }""").toOption
.map(_.into[Bar].enableOptionDefaultsToNone.transform)
)
// expected output:
// Some(value = Bar(someName = "value", anotherName = 10, extra = None))

pprint.pprintln(
Bar("value", 10, None).transformInto[Foo].asJson
)
// expected output:
// JObject(value = object[some-name -> "value",another-name -> 10])
```

This isn't always possible. One might not be able to use it e.g. when:

* case classes are not controlled by the developer but generated by some codegen
* case classes are provided by some external dependency
* JSON library at use does not provide an ability to customize the naming convention conversion
* etc

In such case, Chimney can still match names with different conventions, although the user would have to provide a function
which would compare them according to [custom name comparison requirements](supported-transformations.md#customizing-field-name-matching).

!!! example

Name comparison which has to be defined in a separate module:

```scala
// file: your/organization/KebabNamesComparison.scala - part of custom naming comparison example
//> using dep io.scalaland::chimney::{{ chimney_version() }}
//> using dep io.circe::circe-generic::0.14.10
//> using dep io.circe::circe-parser::0.14.10
//> using dep com.lihaoyi::pprint::{{ libraries.pprint }}
package your.organization

import io.scalaland.chimney.dsl._

case object KebabNamesComparison extends TransformedNamesComparison {

private def normalize(name: String): String =
if (name.contains('-')) {
val head :: tail = name.split('-').filter(_.nonEmpty).toList: @unchecked
head + tail
.map(segment => s"${segment.head.toUpper}${segment.tail.toLowerCase}")
.mkString
} else name

def namesMatch(fromName: String, toName: String): Boolean =
normalize(fromName) == normalize(toName)
}
```

Module with name comparison has to be a dependency of the module which needs it to match field names:

```scala
// file: your/organization/KebabNamesComparison.test.scala - part of custom naming comparison example
//> using dep org.scalameta::munit::1.0.0

case class Foo(`some-name`: String, `another-name`: Int)
case class Bar(someName: String, anotherName: Int, extra: Option[Double])

class Test extends munit.FunSuite {
test("should compile") {
import io.circe.{Encoder, Decoder}
import io.circe.generic.semiauto._
import io.circe.parser.decode
import io.circe.syntax._

implicit val fooDecoder: Decoder[Foo] = deriveDecoder[Foo]
implicit val fooEncoder: Encoder[Foo] = deriveEncoder[Foo]

import io.scalaland.chimney.dsl._

implicit val cfg = TransformerConfiguration.default
.enableCustomFieldNameComparison(your.organization.KebabNamesComparison)

pprint.pprintln(
decode[Foo]("""{ "some-name": "value", "another-name": 10 }""").toOption
.map(_.into[Bar].enableOptionDefaultsToNone.transform)
)
// expected output:
// Some(value = Bar(someName = "value", anotherName = 10, extra = None))

pprint.pprintln(
Bar("value", 10, None).transformInto[Foo].asJson
)
// expected output:
// JObject(value = object[some-name -> "value",another-name -> 10])
}
}
```

## Encoding/decoding sealed/enum with `String`

Out of the box Chimney does not encode `sealed trait`/`enum` value as `String` and it does not decode `String`
Expand Down

0 comments on commit 49aca00

Please sign in to comment.