Skip to content

Commit

Permalink
Merge pull request #332 from Equwece/generic_configuration
Browse files Browse the repository at this point in the history
Add JsonConfiguration processing
  • Loading branch information
goshacodes authored Feb 15, 2025
2 parents 86da314 + 802ec35 commit 99102d7
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 37 deletions.
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ account.asJson == json

## Configuration


### Configuration via **ReaderBuilder** and **WriterBuilder**
1. You can configure only case class derivation
2. To configure **JsonReader** use **ReaderBuilder**
3. To configure **JsonWriter** use **WriterBuilder**
Expand Down Expand Up @@ -311,8 +313,102 @@ inline given ReaderBuilder[Foo] =
case 2 => JsonReader[Int]
case _ => JsonReader[Option[Boolean]]
}

// ensure that json contains only fields that JsonReader knows about, otherwise throw ReaderError
.strict
```

### Configuration via **JsonConfiguration**
1. To configure both **JsonWriter** and **JsonReader** you can use **JsonConfiguration**
2. **JsonConfiguration** can be provided as an inline given to derives
```scala 3
inline given JsonConfiguration = JsonConfiguration.default
```
3. **JsonConfiguration** will be applied recursively to all nested readers/writers
* Product types
```scala 3
import tethys.*
import tethys.jackson.*

inline given JsonConfiguration =
JsonConfiguration.default.fieldStyle(FieldStyle.LowerSnakeCase)

case class Inner(innerField: String)
case class Outer(outerField: Inner) derives JsonWriter, JsonReader

val outer = Outer(Inner("fooBar"))
val json = """{"outer_field": {"inner_field": "fooBar"}}"""

json.jsonAs[Outer] == Right(outer)
outer.asJson == json

```
* Sum types
```scala 3
import tethys.*
import tethys.jackson.*

inline given JsonConfiguration =
JsonConfiguration.default.fieldStyle(FieldStyle.LowerSnakeCase)

enum Choice(@selector val select: Int) derives JsonReader, JsonWriter:
case First(firstField: Int) extends Choice(0)
case Second(secondField: String) extends Choice(1)

val first = Choice.First(1)
val second = Choice.Second("foo")
val firstJson = """{"select": 0, "first_field": 1}"""
val secondJson = """{"select": 1, "second_field": "foo"}"""

first.asJson == firstJson
second.asJson == secondJson

firstJson.jsonAs[Choice] == first
secondJson.jsonAs[Choice] == second
```
4. **WriterBuilder** and **ReaderBuilder** settings have higher priority than **JsonConfiguration** settings
```scala 3
import tethys.*
import tethys.jackson.*

case class Customer(
id: Long,
phoneNumber: String
) derives JsonWriter, JsonReader

inline given JsonConfiguration =
JsonConfiguration.default.fieldStyle(FieldStyle.LowerSnakeCase)

inline given WriterBuilder[Customer] =
// has higher priority than JsonConfiguration's fieldStyle
WriterBuilder[Customer].fieldStyle(FieldStyle.UpperCase)

inline given ReaderBuilder[Customer] =
// has higher priority than JsonConfiguration's fieldStyle
ReaderBuilder[Customer].fieldStyle(FieldStyle.UpperCase)

val customer = Customer(id = 5L, phoneNumber = "+123")
val json = """{"ID": 5, "PHONENUMBER": "+123"}"""

json.jsonAs[Customer] == Right(customer)
customer.asJson == json

```
5. **JsonConfiguration** features
```scala 3

inline given JsonConfiguration =
JsonConfiguration
// default config, entrypoint for configuration
.default

// choose field style
.fieldStyle(FieldStyle.UpperSnakeCase)

// ensure that json contains only fields that JsonReader knows about, otherwise throw ReaderError
// applicable only for JsonReader
.strict
```

## integrations
In some cases, you may need to work with raw AST,
Expand Down
12 changes: 12 additions & 0 deletions modules/core/src/main/scala-3/tethys/JsonConfiguration.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package tethys

trait JsonConfiguration:
def fieldStyle(fieldStyle: FieldStyle): JsonConfiguration

def strict: JsonConfiguration

object JsonConfiguration:
@scala.annotation.compileTimeOnly(
"JsonConfiguration should be declared as inline given"
)
def default: JsonConfiguration = throw IllegalAccessException()
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,41 @@ trait ConfigurationMacroUtils:
case failure: ImplicitSearchFailure =>
None

private val FieldStyleAlreadyConfigured = "FieldStyle is already configured"

private def showUnknownConfigTree(tree: String): String =
s"Unknown tree. Config must be an inlined given. \nTree: $tree"

private def mergeWriterMacroConfigs(
writerBuilderConfig: WriterBuilderMacroConfig,
jsonConfig: WriterBuilderMacroConfig
): WriterBuilderMacroConfig =
writerBuilderConfig.copy(fieldStyle =
writerBuilderConfig.fieldStyle.orElse(jsonConfig.fieldStyle)
)

private def mergeReaderMacroConfigs(
readerBuilderConfig: ReaderBuilderMacroConfig,
jsonConfig: ReaderBuilderMacroConfig
): ReaderBuilderMacroConfig =
readerBuilderConfig.copy(
fieldStyle = readerBuilderConfig.fieldStyle.orElse(jsonConfig.fieldStyle),
isStrict = readerBuilderConfig.isStrict.orElse(jsonConfig.isStrict)
)

def prepareWriterProductFields[T: Type](
config: Expr[WriterBuilder[T]]
config: Expr[WriterBuilder[T]],
jsonConfig: Expr[JsonConfiguration]
): List[WriterField] =
val macroConfig = parseWriterBuilderMacroConfig[T](config)
val updates = macroConfig.update.map(it => it.name -> it).toMap
val writerConfig = parseWriterBuilderMacroConfig[T](config)
val parsedJsonConfig = parseWriterMacroJsonConfig(jsonConfig)
val mergedConfig = mergeWriterMacroConfigs(writerConfig, parsedJsonConfig)
val updates = mergedConfig.update.map(it => it.name -> it).toMap
val tpe = TypeRepr.of[T]
tpe.typeSymbol.caseFields.zipWithIndex
.filterNot((symbol, _) => macroConfig.delete(symbol.name))
.filterNot((symbol, _) => mergedConfig.delete(symbol.name))
.collect { (symbol, idx) =>
val name = macroConfig.fieldStyle.fold(symbol.name)(
val name = mergedConfig.fieldStyle.fold(symbol.name)(
FieldStyle.applyStyle(symbol.name, _)
)
updates.get(symbol.name) match
Expand All @@ -65,7 +90,49 @@ trait ConfigurationMacroUtils:
tpe = tpe.memberType(symbol),
newName = None
)
} ::: macroConfig.add
} ::: mergedConfig.add

private def parseWriterMacroJsonConfig(
config: Expr[JsonConfiguration]
): WriterBuilderMacroConfig = {
@tailrec
def loop(
config: Expr[JsonConfiguration],
acc: WriterBuilderMacroConfig = WriterBuilderMacroConfig()
): WriterBuilderMacroConfig =
config match
case '{
JsonConfiguration.default
} =>
acc

case '{
($rest: JsonConfiguration).strict
} =>
loop(rest, acc)

case '{
($rest: JsonConfiguration).fieldStyle(${ fieldStyle }: FieldStyle)
} =>
acc.fieldStyle match
case None =>
loop(rest, acc.copy(fieldStyle = Some(fieldStyle.valueOrAbort)))
case Some(_) =>
report.errorAndAbort(FieldStyleAlreadyConfigured)

case other =>
other.asTerm match
case Inlined(_, _, term) =>
loop(term.asExprOf[JsonConfiguration])
case _ =>
report.errorAndAbort(
showUnknownConfigTree(
other.asTerm.show(using Printer.TreeStructure)
)
)

loop(traverseTree(config.asTerm).asExprOf[JsonConfiguration])
}

private def parseWriterBuilderMacroConfig[T: Type](
config: Expr[WriterBuilder[T]]
Expand Down Expand Up @@ -362,24 +429,28 @@ trait ConfigurationMacroUtils:
loop(term.asExprOf[WriterBuilder[T]])
case _ =>
report.errorAndAbort(
s"Unknown tree. Config must be an inlined given.\nTree: ${other.asTerm
.show(using Printer.TreeStructure)}"
showUnknownConfigTree(
other.asTerm.show(using Printer.TreeStructure)
)
)

loop(traverseTree(config.asTerm).asExprOf[WriterBuilder[T]])._1

end parseWriterBuilderMacroConfig

def prepareReaderProductFields[T: Type](
config: Expr[ReaderBuilder[T]]
config: Expr[ReaderBuilder[T]],
jsonConfig: Expr[JsonConfiguration]
): (List[ReaderField], IsStrict) =
val macroConfig = parseReaderBuilderMacroConfig[T](config)
val readerConfig = parseReaderBuilderMacroConfig[T](config)
val parsedJsonConfig = parseReaderMacroJsonConfig(jsonConfig)
val mergedConfig = mergeReaderMacroConfigs(readerConfig, parsedJsonConfig)
val tpe = TypeRepr.of[T]
val defaults = collectDefaults[T]
val fields = tpe.typeSymbol.caseFields.zipWithIndex
.map { case (symbol, idx) =>
val default = defaults.get(idx).map(_.asExprOf[Any])
macroConfig.extracted.get(symbol.name) match
mergedConfig.extracted.get(symbol.name) match
case Some(field: ReaderField.Basic) =>
val updatedDefault = field.extractor match
case None => default
Expand All @@ -392,15 +463,15 @@ trait ConfigurationMacroUtils:
)
.map(_.asExprOf[Any])

field.update(idx, updatedDefault, macroConfig.fieldStyle)
field.update(idx, updatedDefault, mergedConfig.fieldStyle)

case Some(field) =>
field.update(idx, default, macroConfig.fieldStyle)
field.update(idx, default, mergedConfig.fieldStyle)

case None =>
ReaderField
.Basic(symbol.name, tpe.memberType(symbol), None)
.update(idx, default, macroConfig.fieldStyle)
.update(idx, default, mergedConfig.fieldStyle)
}
val existingFieldNames = fields.map(_.name).toSet
val additionalFields = fields
Expand All @@ -420,7 +491,47 @@ trait ConfigurationMacroUtils:
.distinctBy(_.name)
val allFields = fields ::: additionalFields
checkLoops(allFields)
(sortDependencies(allFields), macroConfig.isStrict)
(sortDependencies(allFields), mergedConfig.isStrict.getOrElse(false))

private def parseReaderMacroJsonConfig(
jsonConfig: Expr[JsonConfiguration]
): ReaderBuilderMacroConfig =
@tailrec
def loop(
config: Expr[JsonConfiguration],
acc: ReaderBuilderMacroConfig = ReaderBuilderMacroConfig()
): ReaderBuilderMacroConfig =
config match
case '{
JsonConfiguration.default
} =>
acc

case '{
($rest: JsonConfiguration).strict
} =>
acc.copy(isStrict = Some(true))

case '{
($rest: JsonConfiguration).fieldStyle($fieldStyle: FieldStyle)
} =>
acc.fieldStyle match
case None =>
loop(rest, acc.copy(fieldStyle = Some(fieldStyle.valueOrAbort)))
case Some(_) =>
report.errorAndAbort(FieldStyleAlreadyConfigured)

case other =>
other.asTerm match
case Inlined(_, _, term) =>
loop(term.asExprOf[JsonConfiguration])
case _ =>
report.errorAndAbort(
showUnknownConfigTree(
other.asTerm.show(using Printer.TreeStructure)
)
)
loop(traverseTree(jsonConfig.asTerm).asExprOf[JsonConfiguration])

private def sortDependencies(fields: List[ReaderField]): List[ReaderField] =
val known = fields.map(_.name).toSet
Expand Down Expand Up @@ -526,6 +637,7 @@ trait ConfigurationMacroUtils:
s"Field '$name' exists in your model, use selector or .extract(_.$name).as[...] instead"
)

@tailrec
def loop(
config: Expr[ReaderBuilder[T]],
acc: ReaderBuilderMacroConfig = ReaderBuilderMacroConfig(Map.empty)
Expand Down Expand Up @@ -583,9 +695,10 @@ trait ConfigurationMacroUtils:
case '{ ($rest: ReaderBuilder[T]).strict } =>
loop(
config = rest,
acc = acc.copy(isStrict = true)
acc = acc.copy(isStrict = Some(true))
)
case other =>
@tailrec
def loopInner(
term: Term,
extractors: List[(String, TypeRepr)] = Nil,
Expand Down Expand Up @@ -664,8 +777,9 @@ trait ConfigurationMacroUtils:
)
case other =>
report.errorAndAbort(
s"Unknown tree. Config must be an inlined given.\nTree: ${other
.show(using Printer.TreeStructure)}"
showUnknownConfigTree(
other.show(using Printer.TreeStructure)
)
)

loopInner(other.asTerm)
Expand Down Expand Up @@ -1056,7 +1170,7 @@ trait ConfigurationMacroUtils:
case class ReaderBuilderMacroConfig(
extracted: Map[String, ReaderField] = Map.empty,
fieldStyle: Option[FieldStyle] = None,
isStrict: IsStrict = false
isStrict: Option[IsStrict] = None
):
def withExtracted(field: ReaderField): ReaderBuilderMacroConfig =
copy(extracted = extracted.updated(field.name, field))
Expand Down
Loading

0 comments on commit 99102d7

Please sign in to comment.