All Downloads are FREE. Search and download functionalities are using the official Maven repository.

zio.json.codegen.Generator.scala Maven / Gradle / Ivy

package zio.json.codegen

import zio.Chunk
import zio.json._
import zio.json.ast.Json
import zio.json.codegen.Generator.pascalFormat
import zio.json.codegen.JsonType._

import java.time.{ LocalDate, LocalDateTime }
import java.time.format.DateTimeFormatter
import java.util.UUID
import scala.collection.immutable.ListMap
import scala.math.BigDecimal.javaBigDecimal2bigDecimal
import scala.util.Try

object Generator {

  /**
   * Renders the JSON string as a series of Scala case classes derived from the
   * structure of the JSON.
   *
   * For example, the following JSON:
   *
   * {{{
   *   {
   *     "foo": "bar",
   *     "baz": {
   *       "qux": "quux"
   *     }
   *   }
   * }}}
   *
   * Would print the following Scala code to the console:
   *
   * {{{
   *   final case class RootObject(
   *     foo: String,
   *     baz: Baz
   *   )
   *
   *   object RootObject {
   *     implicit val codec: JsonCoder[RootObject] = DeriveJsonCodec.gen
   *   }
   *
   *   final case class Baz(
   *     qux: String
   *   )
   *
   *   object Baz {
   *   implicit val codec: JsonCoder[Baz] = DeriveJsonCodec.gen
   *   }
   * }}}
   */
  def printCaseClasses(input: String): Unit =
    input.fromJson[ast.Json].toOption match {
      case Some(json) => println(scala.Console.CYAN + generate(json) + scala.Console.RESET)
      case None       => println(s"Invalid JSON: ${input}")
    }

  private[codegen] def pascalFormat(s: String): String = {
    val parts = s.split("[_\\-.]")
    parts.map(_.capitalize).mkString("")
  }

  private[codegen] def generate(json: ast.Json): String =
    render(unifyTypes(json))

}

private[codegen] sealed trait JsonType extends Product with Serializable { self =>

  def unify(that: JsonType): JsonType =
    (self, that) match {
      case (lhs, rhs) if lhs == rhs                 => lhs
      case (JLong, JInt)                            => JLong
      case (JInt, JLong)                            => JLong
      case (JDouble, JInt | JLong)                  => JDouble
      case (JInt | JLong, JDouble)                  => JDouble
      case (JBigDecimal, JDouble | JInt | JLong)    => JBigDecimal
      case (JDouble | JInt | JLong, JBigDecimal)    => JBigDecimal
      case (JObject(lhsFields), JObject(rhsFields)) => JObject(mergeFields(lhsFields, rhsFields))

      case (JOption(left), JNull)  => JOption(left)
      case (JNull, JOption(right)) => JOption(right)

      case (JOption(left), JOption(right)) => JOption(left unify right)

      case (left, JOption(right)) => JOption(left unify right)
      case (JOption(left), right) => JOption(left unify right)

      case (JNull, right) => JOption(right)
      case (left, JNull)  => JOption(left)

      case (JArray(left), JArray(right)) => JArray(left unify right)
      case (CaseClass(left, leftFields), CaseClass(right, rightFields)) if left == right =>
        CaseClass(left, (leftFields unify rightFields).asInstanceOf[JObject])
      case (left, right) =>
        throw new Exception(s"""
                               |Cannot combine:
                               | LEFT: ${left.toString}
                               |RIGHT: ${right.toString}
                               |""".stripMargin)
    }

  def typeName: String = self match {
    case CaseClass(name, _)   => name
    case JObject(_)           => "RootObject"
    case JString              => "String"
    case JInt                 => "Int"
    case JLong                => "Long"
    case JDouble              => "Double"
    case JBigDecimal          => "BigDecimal"
    case JNull                => "null"
    case JBoolean             => "Boolean"
    case JLocalDate           => "java.time.LocalDate"
    case JLocalDateTime       => "java.time.LocalDateTime"
    case JUUID                => "java.util.UUID"
    case JOption(value)       => s"Option[${value.typeName}]"
    case JArray(value)        => s"List[${value.typeName}]"
    case Alternatives(values) => s"Alternatives[${values.map(_.typeName).mkString(", ")}]"
  }

  def makeOptional(jsonType: JsonType): JsonType = jsonType match {
    case JNull          => JNull
    case JOption(value) => JOption(value)
    case other          => JOption(other)
  }

  def mergeFields(
    lhs: ListMap[String, JsonType],
    rhs: ListMap[String, JsonType]
  ): ListMap[String, JsonType] = {
    val result = lhs.foldLeft(rhs) { case (acc, (name, lhsValue)) =>
      //        if (name == "thumbnails") {
      //          println(s"lhs: ${lhsValue.toString} rhs: ${rhs.get(name)}")
      //        }
      acc.get(name) match {
        case Some(rhsValue) => acc + (name -> lhsValue.unify(rhsValue))
        case None           => acc + (name -> makeOptional(lhsValue))
      }
    }

    println((rhs.keySet -- result.keySet) ++ (result.keySet -- rhs.keySet))

    (rhs.keySet -- lhs.keySet).foldLeft(result) { case (acc, name) =>
      acc + (name -> makeOptional(rhs(name)))
    }
  }

}

object JsonType {

  final case class CaseClass(name: String, fields: JObject) extends JsonType {
    def displayName = name
  }

  final case class JObject(fields: ListMap[String, JsonType]) extends JsonType
  case object JString                                         extends JsonType
  case object JInt                                            extends JsonType
  case object JLong                                           extends JsonType
  case object JDouble                                         extends JsonType
  case object JBigDecimal                                     extends JsonType
  case object JNull                                           extends JsonType
  case object JBoolean                                        extends JsonType
  case object JLocalDate                                      extends JsonType
  case object JLocalDateTime                                  extends JsonType
  case object JUUID                                           extends JsonType
  final case class JOption(value: JsonType)                   extends JsonType
  final case class JArray(value: JsonType)                    extends JsonType
  final case class Alternatives(values: Chunk[JsonType])      extends JsonType

  def render(jsonType: JsonType): String = {
    val caseClasses = flattenCaseClasses(jsonType).distinct
    caseClasses.map(renderCaseClass).mkString("\n\n")
  }

  def flattenCaseClasses(jsonType: JsonType): List[CaseClass] =
    jsonType match {
      case CaseClass(name, fields) =>
        CaseClass(name, fields) :: fields.fields.toList.flatMap(t => flattenCaseClasses(t._2))
      case JObject(fields) =>
        fields.values.flatMap(flattenCaseClasses).toList
      case JArray(values) =>
        flattenCaseClasses(values)
      case JOption(value) =>
        flattenCaseClasses(value)
      case _ =>
        Nil
    }

  def renderCaseClass(clazz: CaseClass): String = {
    val fields = clazz.fields.fields.map { case (name, value) =>
      s"  $name: ${value.typeName}"
    }.mkString(",\n")

    s"""
final case class ${clazz.name}(
$fields
)

object ${clazz.name} {
  implicit val codec: JsonCodec[${clazz.name}] = DeriveJsonCodec.gen
}
         """.trim
  }

  def unifyTypes(json: Json, key: Option[String] = None): JsonType =
    json match {
      case Json.Null => JNull
      case Json.Arr(elements) =>
        JArray(elements.map(unifyTypes(_, key)).reduce(_ unify _))
      case Json.Bool(_) => JBoolean
      case Json.Str(string) =>
        val localDateTime =
          Try(LocalDateTime.parse(string, DateTimeFormatter.ISO_DATE_TIME)).toOption.map(_ => JLocalDateTime)
        lazy val localDate = Try(LocalDate.parse(string)).toOption.map(_ => JLocalDate)
        lazy val uuid      = Try(UUID.fromString(string)).toOption.map(_ => JUUID)
        localDateTime.orElse(localDate).orElse(uuid).getOrElse(JString)
      case Json.Num(bigDecimal) =>
        if (bigDecimal.isValidInt) JInt
        else if (bigDecimal.isValidLong) JLong
        else if (bigDecimal <= Double.MaxValue) JDouble
        else JBigDecimal
      case Json.Obj(fields) =>
        val result = JObject {
          fields.foldLeft(ListMap.empty[String, JsonType]) { case (acc, (name, value)) =>
            acc + (name -> unifyTypes(value, Some(name)))
          }
        }
        key match {
          case Some(key) => CaseClass(pascalFormat(key), result)
          case None      => CaseClass("RootObject", result)
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy