From 2f59f60e90df8c3039d82b9c72738f479ee31b85 Mon Sep 17 00:00:00 2001 From: Karol Poliwka <82140068+polkx@users.noreply.github.com> Date: Wed, 28 Aug 2024 11:59:27 +0200 Subject: [PATCH] Kpoliwka/create case class from yaml (#534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * created crd module in codegen using CR overlays for kubernetes @polkx * moved crd module to separate crd2besom module @lbialy --------- Co-authored-by: Łukasz Biały --- .gitignore | 1 + Justfile | 20 + codegen/project.scala | 1 + .../apiextensions/CustomResource.scala | 4 +- .../apiextensions/CustomResourcePatch.scala | 4 +- codegen/src/scalameta.scala | 3 + .../scala/besom/internal/ProtobufUtil.scala | 6 + .../main/scala/besom/internal/codecs.scala | 16 + crd2besom/.scalafmt.conf | 11 + crd2besom/project.scala | 18 + crd2besom/src/AdditionalCodecs.scala | 84 ++ crd2besom/src/ArgsClass.scala | 157 ++++ crd2besom/src/ClassGenerator.scala | 298 +++++++ crd2besom/src/ClassGenerator.test.scala | 829 ++++++++++++++++++ crd2besom/src/EnumClass.scala | 27 + crd2besom/src/FieldTypeInfo.scala | 30 + crd2besom/src/utils.scala | 10 + crd2besom/src/yaml.scala | 73 ++ 18 files changed, 1588 insertions(+), 4 deletions(-) create mode 100644 crd2besom/.scalafmt.conf create mode 100644 crd2besom/project.scala create mode 100644 crd2besom/src/AdditionalCodecs.scala create mode 100644 crd2besom/src/ArgsClass.scala create mode 100644 crd2besom/src/ClassGenerator.scala create mode 100644 crd2besom/src/ClassGenerator.test.scala create mode 100644 crd2besom/src/EnumClass.scala create mode 100644 crd2besom/src/FieldTypeInfo.scala create mode 100644 crd2besom/src/utils.scala create mode 100644 crd2besom/src/yaml.scala diff --git a/.gitignore b/.gitignore index 58c80fa7..3313f912 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ src/main/scala/besom/rpc .out/ *.asc + diff --git a/Justfile b/Justfile index f3191a0d..a4d83c61 100644 --- a/Justfile +++ b/Justfile @@ -369,6 +369,26 @@ publish-local-codegen: test-codegen publish-maven-codegen: test-codegen scala-cli --power publish {{no-bloop-ci}} codegen --project-version {{besom-version}} {{publish-maven-auth-options}} --suppress-experimental-feature-warning +#################### +# crd2besom +#################### + +# Compiles crd2besom module +compile-crd2besom: + scala-cli --power compile {{no-bloop-ci}} crd2besom --suppress-experimental-feature-warning + +# Runs tests for crd2besom +test-crd2besom: + scala-cli --power test {{no-bloop-ci}} crd2besom --suppress-experimental-feature-warning + +# Cleans crd2besom build +clean-crd2besom: + scala-cli clean crd2besom + +# Build crd2besom binary +build-crd2besom: + scala-cli --power package {{no-bloop-ci}} crd2besom --suppress-experimental-feature-warning --graal -o .out/crd2besom/bin/$(arch)/crd2besom + #################### # Integration testing #################### diff --git a/codegen/project.scala b/codegen/project.scala index c574239b..68d0b9de 100644 --- a/codegen/project.scala +++ b/codegen/project.scala @@ -1,6 +1,7 @@ //> using scala 3.3.1 //> using options -release:11 -deprecation -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement +//> using dep org.virtuslab::scala-yaml:0.1.0 //> using dep org.scalameta:scalameta_2.13:4.8.15 //> using dep com.lihaoyi::upickle:3.1.4 //> using dep com.lihaoyi::os-lib:0.9.3 diff --git a/codegen/resources/overlays/kubernetes/apiextensions/CustomResource.scala b/codegen/resources/overlays/kubernetes/apiextensions/CustomResource.scala index fbd51b06..385195ca 100644 --- a/codegen/resources/overlays/kubernetes/apiextensions/CustomResource.scala +++ b/codegen/resources/overlays/kubernetes/apiextensions/CustomResource.scala @@ -13,7 +13,7 @@ object CustomResource: def apply[A: besom.types.Encoder: besom.types.Decoder](using ctx: besom.types.Context)( name: besom.util.NonEmptyString, args: CustomResourceArgs[A], - opts: besom.ResourceOptsVariant.Custom ?=> besom.CustomResourceOptions = besom.CustomResourceOptions() + opts: besom.ResourceOptsVariant.Component ?=> besom.ComponentResourceOptions = besom.ComponentResourceOptions() ): besom.types.Output[CustomResource[A]] = { val resourceName = besom.types.ResourceType.unsafeOf(s"kubernetes:${args.apiVersion}:${args.kind}") given besom.types.ResourceDecoder[CustomResource[A]] = besom.internal.ResourceDecoder.derived[CustomResource[A]] @@ -24,7 +24,7 @@ object CustomResource: resourceName, name, args, - opts(using besom.ResourceOptsVariant.Custom) + opts(using besom.ResourceOptsVariant.Component) ) } diff --git a/codegen/resources/overlays/kubernetes/apiextensions/CustomResourcePatch.scala b/codegen/resources/overlays/kubernetes/apiextensions/CustomResourcePatch.scala index 9ea066af..1a2bc236 100644 --- a/codegen/resources/overlays/kubernetes/apiextensions/CustomResourcePatch.scala +++ b/codegen/resources/overlays/kubernetes/apiextensions/CustomResourcePatch.scala @@ -13,7 +13,7 @@ object CustomResourcePatch: def apply[A: besom.types.Encoder: besom.types.Decoder](using ctx: besom.types.Context)( name: besom.util.NonEmptyString, args: CustomResourcePatchArgs[A], - opts: besom.ResourceOptsVariant.Custom ?=> besom.CustomResourceOptions = besom.CustomResourceOptions() + opts: besom.ResourceOptsVariant.Component ?=> besom.ComponentResourceOptions = besom.ComponentResourceOptions() ): besom.types.Output[CustomResourcePatch[A]] = { val resourceName = besom.types.ResourceType.unsafeOf(s"kubernetes:${args.apiVersion}:${args.kind}") given besom.types.ResourceDecoder[CustomResourcePatch[A]] = besom.internal.ResourceDecoder.derived[CustomResourcePatch[A]] @@ -24,7 +24,7 @@ object CustomResourcePatch: resourceName, name, args, - opts(using besom.ResourceOptsVariant.Custom) + opts(using besom.ResourceOptsVariant.Component) ) } diff --git a/codegen/src/scalameta.scala b/codegen/src/scalameta.scala index 47e9a270..368d0b73 100644 --- a/codegen/src/scalameta.scala +++ b/codegen/src/scalameta.scala @@ -76,7 +76,10 @@ object scalameta: val Boolean: Type.Ref = Type.Name("Boolean") val String: Type.Ref = Type.Name("String") + val Byte: Type.Ref = Type.Name("Byte") val Int: Type.Ref = Type.Name("Int") + val Long: Type.Ref = Type.Name("Long") + val Float: Type.Ref = Type.Name("Float") val Double: Type.Ref = Type.Name("Double") val Unit: Type.Ref = Type.Select(Term.Name("scala"), Type.Name("Unit")) val Option: Type.Ref = Type.Select(Term.Name("scala"), Type.Name("Option")) diff --git a/core/src/main/scala/besom/internal/ProtobufUtil.scala b/core/src/main/scala/besom/internal/ProtobufUtil.scala index c2af1c9c..631383c2 100644 --- a/core/src/main/scala/besom/internal/ProtobufUtil.scala +++ b/core/src/main/scala/besom/internal/ProtobufUtil.scala @@ -21,9 +21,15 @@ object ProtobufUtil: given ToValue[Int] with extension (i: Int) def asValue: Value = Value(Kind.NumberValue(i)) + given ToValue[Long] with + extension (l: Long) def asValue: Value = Value(Kind.NumberValue(l)) + given ToValue[String] with extension (s: String) def asValue: Value = Value(Kind.StringValue(s)) + given ToValue[Float] with + extension (f: Float) def asValue: Value = Value(Kind.NumberValue(f)) + given ToValue[Double] with extension (d: Double) def asValue: Value = Value(Kind.NumberValue(d)) diff --git a/core/src/main/scala/besom/internal/codecs.scala b/core/src/main/scala/besom/internal/codecs.scala index 1456b1b3..915100aa 100644 --- a/core/src/main/scala/besom/internal/codecs.scala +++ b/core/src/main/scala/besom/internal/codecs.scala @@ -206,12 +206,22 @@ object Decoder extends DecoderInstancesLowPrio1: if v.kind.isNumberValue then v.getNumberValue.valid else error(s"$label: Expected a number, got: '${v.kind}'", label).invalid + given floatDecoder: Decoder[Float] with + def mapping(v: Value, label: Label): Validated[DecodingError, Float] = + doubleDecoder.mapping(v, label).map(_.toFloat) + given intDecoder(using doubleDecoder: Decoder[Double]): Decoder[Int] = doubleDecoder.emap { (double, label) => if (double % 1 == 0) ValidatedResult.valid(double.toInt) else error(s"$label: Numeric value was expected to be integer, but had a decimal value", label).invalidResult } + given longDecoder(using doubleDecoder: Decoder[Double]): Decoder[Long] = + doubleDecoder.emap { (double, label) => + if (double % 1 == 0) ValidatedResult.valid(double.toLong) + else error(s"$label: Numeric value was expected to be long, but had a decimal value", label).invalidResult + } + given stringDecoder: Decoder[String] with def mapping(v: Value, label: Label): Validated[DecodingError, String] = if v.kind.isStringValue then v.getStringValue.valid @@ -964,6 +974,12 @@ object Encoder: given intEncoder: Encoder[Int] with def encode(int: Int)(using Context): Result[(Metadata, Value)] = Result.pure(Metadata.empty -> int.asValue) + given longEncoder: Encoder[Long] with + def encode(long: Long)(using Context): Result[(Metadata, Value)] = Result.pure(Metadata.empty -> long.asValue) + + given floatEncoder: Encoder[Float] with + def encode(float: Float)(using Context): Result[(Metadata, Value)] = Result.pure(Metadata.empty -> float.asValue) + given doubleEncoder: Encoder[Double] with def encode(dbl: Double)(using Context): Result[(Metadata, Value)] = Result.pure(Metadata.empty -> dbl.asValue) diff --git a/crd2besom/.scalafmt.conf b/crd2besom/.scalafmt.conf new file mode 100644 index 00000000..81666df6 --- /dev/null +++ b/crd2besom/.scalafmt.conf @@ -0,0 +1,11 @@ +version = 3.5.2 +runner.dialect = scala3 +project.git = true +align = most +align.openParenCallSite = false +align.openParenDefnSite = false +align.tokens = [{code = "=>", owner = "Case"}, "<-", "%", "%%", "="] +indent.defnSite = 2 +maxColumn = 140 + +rewrite.scala3.insertEndMarkerMinLines = 40 diff --git a/crd2besom/project.scala b/crd2besom/project.scala new file mode 100644 index 00000000..8a52ae36 --- /dev/null +++ b/crd2besom/project.scala @@ -0,0 +1,18 @@ +//> using scala 3.3.1 +//> using options -java-output-version:11 +//> using options -deprecation -feature -Werror -Wunused:all + +//> using dep org.virtuslab::besom-codegen:0.4.0-SNAPSHOT +//> using dep org.virtuslab::scala-yaml:0.1.0 + +//> using dep org.scalameta::munit:1.0.1 + +//> using publish.name "besom-crd2besom" +//> using publish.organization "org.virtuslab" +//> using publish.url "https://github.com/VirtusLab/besom" +//> using publish.vcs "github:VirtusLab/besom" +//> using publish.license "Apache-2.0" +//> using publish.repository "central" +//> using publish.developer "lbialy|Łukasz Biały|https://github.com/lbialy" +//> using publish.developer "pawelprazak|Paweł Prażak|https://github.com/pawelprazak" +//> using repository sonatype:snapshots diff --git a/crd2besom/src/AdditionalCodecs.scala b/crd2besom/src/AdditionalCodecs.scala new file mode 100644 index 00000000..2df0400e --- /dev/null +++ b/crd2besom/src/AdditionalCodecs.scala @@ -0,0 +1,84 @@ +package besom.codegen.crd + +import scala.meta.* +import scala.meta.dialects.Scala33 +import besom.codegen.scalameta.interpolator.* +import besom.codegen.scalameta.ref + +enum AdditionalCodecs(val name: Type, val codecs: Seq[Stat]): + case LocalDateTime + extends AdditionalCodecs( + Type.Select(ref("java", "time"), Type.Name("LocalDateTime")), + Seq( + m""" + | given besom.Encoder[java.time.LocalDateTime] with + | def encode(t: java.time.LocalDateTime)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(t.format(java.time.format.DateTimeFormatter.ISO_DATE_TIME)) + |""", + m""" + | given besom.Decoder[java.time.LocalDateTime] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, java.time.LocalDateTime] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try(java.time.LocalDateTime.parse(v.getStringValue, java.time.format.DateTimeFormatter.ISO_DATE_TIME)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$$label: Expected a LocalDateTime, got: '$${v.kind}'", label)) + | ) + |""" + ).map(_.stripMargin.parse[Stat].get) + ) + + case LocalDate + extends AdditionalCodecs( + Type.Select(ref("java", "time"), Type.Name("LocalDate")), + Seq( + m""" + | given besom.Encoder[java.time.LocalDate] with + | def encode(t: java.time.LocalDate)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(t.format(java.time.format.DateTimeFormatter.ISO_DATE)) + |""", + m""" + | given besom.Decoder[java.time.LocalDate] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, java.time.LocalDate] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try(java.time.LocalDate.parse(v.getStringValue, java.time.format.DateTimeFormatter.ISO_DATE)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$$label: Expected a LocalDate, got: '$${v.kind}'", label)) + | ) + |""" + ).map(_.stripMargin.parse[Stat].get) + ) +end AdditionalCodecs + +object AdditionalCodecs: + private val nameToValuesMap: Map[String, AdditionalCodecs] = + AdditionalCodecs.values.map(c => c.name.syntax -> c).toMap + + def getCodec(name: Type): Option[AdditionalCodecs] = + nameToValuesMap.get(name.syntax) + + private def enumEncoder(enumName: String): Stat = + m""" + | given besom.Encoder[$enumName] with + | def encode(e: $enumName)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(e.toString) + |""".stripMargin.parse[Stat].get + + private def enumDecoder(enumName: String): Stat = + m""" + | given besom.Decoder[$enumName] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, $enumName] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try($enumName.valueOf(str)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$$label: Expected a $enumName enum, got: '$${v.kind}'", label)) + | ) + |""".stripMargin.parse[Stat].get + + def enumCodecs(enumName: String): Seq[Stat] = Seq(enumEncoder(enumName), enumDecoder(enumName)) +end AdditionalCodecs diff --git a/crd2besom/src/ArgsClass.scala b/crd2besom/src/ArgsClass.scala new file mode 100644 index 00000000..d7df4d7d --- /dev/null +++ b/crd2besom/src/ArgsClass.scala @@ -0,0 +1,157 @@ +package besom.codegen.crd + +import besom.codegen.scalameta.interpolator.* +import besom.codegen.* +import scala.meta.* +import scala.meta.dialects.Scala33 +import besom.codegen.scalameta.types + +object ArgsClass: + def makeArgsClassSourceFile( + argsClassName: meta.Type.Name, + packagePath: PackagePath, + properties: Seq[FieldTypeInfo], + additionalCodecs: List[AdditionalCodecs] + ): SourceFile = { + val argsClass = makeArgsClass( + argsClassName = argsClassName, + properties = properties + ) + val argsCompanion = makeArgsCompanion( + argsClassName = argsClassName, + properties = properties, + additionalCodecs = additionalCodecs + ) + + val fileContent = + m"""|package ${packagePath.path.mkString(".")} + | + |$argsClass + | + |$argsCompanion + |""".stripMargin.parse[Source].get + SourceFile( + filePath = FilePath(packagePath.path :+ s"$argsClassName.scala"), + sourceCode = fileContent.syntax + ) + } + + private def makeArgsClass(argsClassName: meta.Type, properties: Seq[FieldTypeInfo]): Stat = { + val argsClassParams = properties.flatMap { propertyInfo => + val paramType = + if propertyInfo.isOptional + then types.besom.types.Output(types.Option(propertyInfo.baseType)) + else types.besom.types.Output(propertyInfo.baseType) + + val termParam = Term.Param( + mods = List.empty, + name = propertyInfo.name, + decltpe = Some(paramType), + default = None + ) + + val docs = propertyInfo.description.map(_.mkString(s" /**\n * ", s"\n * ", s"\n */")) + Seq(docs, Some(s" ${termParam.syntax},")).flatten + } + + m"""|final case class $argsClassName private( + |${argsClassParams.mkString("\n")} + |) derives besom.Decoder, besom.Encoder""".stripMargin.parse[Stat].get + } + + private def makeArgsCompanion( + argsClassName: meta.Type.Name, + properties: Seq[FieldTypeInfo], + additionalCodecs: List[AdditionalCodecs] + ): Stat = { + val argsCompanionApplyParams = properties + .map { propertyInfo => + val paramType = + if propertyInfo.isOptional + then types.besom.types.InputOptional(propertyInfo.baseType) + else types.besom.types.Input(propertyInfo.baseType) + + val defaultValue = + if propertyInfo.isOptional then Some(scalameta.None) else None + + Term.Param( + mods = List.empty, + name = propertyInfo.name, + decltpe = Some(paramType), + default = defaultValue + ) + } + + val argsCompanionApplyBodyArgs = properties.map { propertyInfo => + val isSecret = Lit.Boolean(propertyInfo.isSecret) + val argValue = + if (propertyInfo.isOptional) + m"${propertyInfo.name}.asOptionOutput(isSecret = $isSecret)".parse[Term].get + else + m"${propertyInfo.name}.asOutput(isSecret = $isSecret)".parse[Term].get + + Term.Assign(Term.Name(propertyInfo.name.value), argValue) + } + + val outputExtensionMethods = properties.map { propertyInfo => + val innerType = + if propertyInfo.isOptional + then scalameta.types.Option(propertyInfo.baseType) + else propertyInfo.baseType + + m"""def ${propertyInfo.name}: besom.types.Output[$innerType] = output.flatMap(_.${propertyInfo.name})""" + .parse[Stat] + .get + } + val optionOutputExtensionMethods = properties.map { propertyInfo => + val innerMethodName = + if propertyInfo.isOptional then m"flatMapOpt" else m"mapOpt" + + m"""def ${propertyInfo.name}: besom.types.Output[scala.Option[${propertyInfo.baseType}]] = output.$innerMethodName(_.${propertyInfo.name})""" + .parse[Stat] + .get + } + + val argsCompanionWithArgsParams = argsCompanionApplyParams.map { param => + param.copy(default = Some(m"cls.${param.name.syntax}".parse[Term].get)) + } + + m"""|object $argsClassName: + | def apply( + |${argsCompanionApplyParams.map(arg => s" ${arg.syntax}").mkString(",\n")} + | )(using besom.types.Context): $argsClassName = + | new $argsClassName( + |${argsCompanionApplyBodyArgs.map(arg => s" ${arg.syntax}").mkString(",\n")} + | ) + | + | extension (cls: $argsClassName) def withArgs( + |${argsCompanionWithArgsParams.map(arg => s" ${arg.syntax}").mkString(",\n")} + | )(using besom.types.Context): $argsClassName = + | new $argsClassName( + |${argsCompanionApplyBodyArgs.map(arg => s" ${arg.syntax}").mkString(",\n")} + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[$argsClassName]) + |${outputExtensionMethods.map(meth => m" ${meth.syntax}").mkString("\n")} + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[$argsClassName]]) + |${optionOutputExtensionMethods.map(meth => m" ${meth.syntax}").mkString("\n")} + | + |${additionalCodecs.flatMap(_.codecs).mkString("\n")} + |$outputOptionExtension + |""".stripMargin.parse[Stat].get + } + + private val outputOptionExtension = + m"""| extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_)))""".stripMargin.parse[Stat].get +end ArgsClass diff --git a/crd2besom/src/ClassGenerator.scala b/crd2besom/src/ClassGenerator.scala new file mode 100644 index 00000000..4a2949b4 --- /dev/null +++ b/crd2besom/src/ClassGenerator.scala @@ -0,0 +1,298 @@ +package besom.codegen.crd + +import besom.codegen.scalameta.types +import besom.codegen.* +import org.virtuslab.yaml.* + +import scala.meta.* +import scala.util.Try + +object ClassGenerator: + private val jsValueType = Type.Select(scalameta.ref("besom", "json"), Type.Name("JsValue")) + + def main(args: Array[String]): Unit = { + args.toList match { + case yamlFilePath :: outputDirPath :: Nil => + val yamlFile = yamlFilePath.toWorkingDirectoryPath + val outputDir = outputDirPath.toWorkingDirectoryPath + manageCrds(yamlFile, outputDir) + case _ => + System.err.println( + s"""|Unknown arguments: '${args.mkString(" ")}' + | + |Usage: + | - Generate classes from path and generate it to path + |""".stripMargin + ) + sys.exit(1) + } match + case Left(value) => + System.err.println("Error " + value) + case Right(_) => + println("Success") + } + + private def manageCrds(yamlFile: os.Path, outputDir: os.Path): Either[Throwable, Unit] = { + os.remove.all(outputDir) + println(s"Remove all from $outputDir") + for + yamlFile <- Try(os.read(yamlFile)).toEither + rawCrds = yamlFile.split("---").toSeq + sourceFiles <- rawCrds.map(createSourceFiles).flattenWithFirstError + _ <- sourceFiles.flatten + .map(createSourceFile(outputDir, _)) + .flattenWithFirstError + yield () + } + + private[crd] def createSourceFiles(rawCrd: String): Either[Throwable, Seq[SourceFile]] = + rawCrd.as[CRD].map(crd => createCaseClassVersions(crd.spec)) + + private def createSourceFile(mainDir: os.Path, sourceFile: SourceFile): Either[Throwable, Unit] = { + val filePath = mainDir / sourceFile.filePath.osSubPath + os.makeDir.all(filePath / os.up) + Try(os.write(filePath, sourceFile.sourceCode, createFolders = true)).toEither + } + + private def createCaseClassVersions(crd: CRDSpec): Seq[SourceFile] = + crd.versions + .flatMap { version => + val basePath = Seq(crd.names.singular, version.name) + // we are only interested in the spec field + version.schema.openAPIV3Schema.properties.flatMap(_.get("spec")) match + case Some(spec) => + parseJsonSchema( + packagePath = PackagePath(basePath), + className = crd.names.kind, + parentJsonSchema = spec + ) + case None => + throw Exception("Spec field not found in openAPIV3Schema properties") + } + + private def parseJsonSchema(packagePath: PackagePath, className: String, parentJsonSchema: JsonSchemaProps): Seq[SourceFile] = { + val (classFields, sourceFileAcc) = + parentJsonSchema.properties + .map(_.toList) + .getOrElse(List.empty) + .map(parseJsonSchemaProperty(packagePath, parentJsonSchema)(_, _)) + .unzip + val sourceFile = + ArgsClass.makeArgsClassSourceFile( + argsClassName = Type.Name(className), + packagePath = packagePath.removeLastSegment, + properties = classFields, + additionalCodecs = classFields + .flatMap(fieldNameWithType => AdditionalCodecs.getCodec(fieldNameWithType.baseType)) + .distinct + ) + + sourceFile +: sourceFileAcc.flatten + } + + private def parseJsonSchemaProperty( + packagePath: PackagePath, + parentJsonSchema: JsonSchemaProps + ): (String, JsonSchemaProps) => (FieldTypeInfo, Seq[SourceFile]) = + case (fieldName, jsonSchema) if jsonSchema.`enum`.nonEmpty => + enumType(packagePath, fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.number) => + numberType(fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.integer) => + integerType(fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.boolean) => + booleanType(fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.string) => + stringType(fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.array) && jsonSchema.items.isDefined => + arrayType(packagePath, fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) if jsonSchema.`type`.contains(DataTypeEnum.`object`) => + objectType(packagePath, fieldName, jsonSchema, parentJsonSchema) + case (fieldName, jsonSchema) => + defaultType(fieldName, jsonSchema, parentJsonSchema) + + private def enumType( + packagePath: PackagePath, + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val enumList = jsonSchema.`enum`.get + val enumName = fieldName.capitalize + val sourceFile = EnumClass.enumFile(packagePath, enumName, enumList) + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = Type.Select(scalameta.ref(packagePath.path.toList), Type.Name(enumName)), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq(sourceFile)) + } + + private def numberType( + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val baseType = jsonSchema.format.map(NumberFormat.valueOf) match + case Some(NumberFormat.float) => types.Float + case Some(NumberFormat.double) | None => types.Double + + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = baseType, + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq.empty) + } + + private def integerType( + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val baseType = jsonSchema.format.map(IntegerFormat.valueOf) match + case Some(IntegerFormat.int64) => types.Long + case Some(IntegerFormat.int32) | None => types.Int + + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = baseType, + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq.empty) + } + + private def booleanType( + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = types.Boolean, + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq.empty) + } + + private def stringType( + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val (isSecret, baseType) = jsonSchema.format.map(stringParseType).getOrElse((false, types.String)) + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = baseType, + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema, + isSecret = isSecret + ) + (fieldTypeInfo, Seq.empty) + } + + private def stringParseType(format: String): (Boolean, Type) = + StringFormat.valueOf(format) match + case StringFormat.date => + val `type` = Type.Select(scalameta.ref("java", "time"), Type.Name("LocalDate")) + (false, `type`) + case StringFormat.`date-time` => + val `type` = Type.Select(scalameta.ref("java", "time"), Type.Name("LocalDateTime")) + (false, `type`) + case StringFormat.password => + (true, types.String) + case StringFormat.byte => + (false, types.String) + case StringFormat.binary => + (false, types.String) + + private def arrayType( + packagePath: PackagePath, + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + val (classType, sourceFiles) = + parseJsonSchemaProperty(packagePath, jsonSchema)(fieldName, jsonSchema.items.get) + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = types.Iterable(classType.baseType), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, sourceFiles) + } + + private def objectType( + packagePath: PackagePath, + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + (jsonSchema.properties, jsonSchema.additionalProperties) match + case (Some(_), _) => + val className = fieldName.capitalize + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = Type.Select(scalameta.ref(packagePath.path.toList), Type.Name(className)), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, parseJsonSchema(packagePath.addPart(fieldName), className, jsonSchema)) + case (_, Some(_: Boolean) | None) => + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = types.Map(types.String, jsValueType), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq.empty) + case (_, Some(js: JsonSchemaProps)) => + val (classType, sourceFiles) = + parseJsonSchemaProperty(packagePath, jsonSchema)(fieldName, js) + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = types.Map(types.String, classType.baseType), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema, + isSecret = classType.isSecret + ) + (fieldTypeInfo, sourceFiles) + } + + private def defaultType( + fieldName: String, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps + ): (FieldTypeInfo, Seq[SourceFile]) = { + println(s"Problem when decoding `$fieldName` field with type ${jsonSchema.`type`}, create Map[String, JsValue]") + val fieldTypeInfo = + FieldTypeInfo( + name = fieldName, + baseType = types.Map(types.String, jsValueType), + jsonSchema = jsonSchema, + parentJsonSchema = parentJsonSchema + ) + (fieldTypeInfo, Seq.empty) + } +end ClassGenerator + +case class PackagePath(baseSegments: Seq[String], segments: Seq[String] = Seq.empty): + def addPart(part: String): PackagePath = PackagePath(baseSegments, part +: segments) + def path: Seq[String] = baseSegments ++ segments.reverse + def removeLastSegment: PackagePath = PackagePath(baseSegments, segments.drop(1)) +object PackagePath: + def apply(base: Seq[String]): PackagePath = new PackagePath(base) diff --git a/crd2besom/src/ClassGenerator.test.scala b/crd2besom/src/ClassGenerator.test.scala new file mode 100644 index 00000000..9cd20818 --- /dev/null +++ b/crd2besom/src/ClassGenerator.test.scala @@ -0,0 +1,829 @@ +package besom.codegen.crd + +import besom.codegen.* + +import scala.meta.* +import scala.meta.dialects.Scala33 + +class ClassGeneratorTest extends munit.FunSuite { + case class Data( + name: String, + yaml: String, + ignored: List[String] = List.empty, + expected: Map[String, String] = Map.empty, + expectedError: Option[String] = None, + tags: Set[munit.Tag] = Set() + ) + + Vector( + Data( + name = "Simple CronTab definition", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |metadata: + | name: crontabs.stable.example.com + |spec: + | group: stable.example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | cronSpec: + | type: string + | image: + | type: string + | replicas: + | type: integer + | names: + | plural: crontabs + | singular: crontab + | kind: CronTab + | shortNames: + | - ct + |""".stripMargin, + expected = Map( + "crontab/v1/CronTab.scala" -> + """package crontab.v1 + | + |final case class CronTab private( + | cronSpec: besom.types.Output[scala.Option[String]], + | image: besom.types.Output[scala.Option[String]], + | replicas: besom.types.Output[scala.Option[Int]], + |) derives besom.Decoder, besom.Encoder + | + |object CronTab: + | def apply( + | cronSpec: besom.types.Input.Optional[String] = scala.None, + | image: besom.types.Input.Optional[String] = scala.None, + | replicas: besom.types.Input.Optional[Int] = scala.None + | )(using besom.types.Context): CronTab = + | new CronTab( + | cronSpec = cronSpec.asOptionOutput(isSecret = false), + | image = image.asOptionOutput(isSecret = false), + | replicas = replicas.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: CronTab) def withArgs( + | cronSpec: besom.types.Input.Optional[String] = cls.cronSpec, + | image: besom.types.Input.Optional[String] = cls.image, + | replicas: besom.types.Input.Optional[Int] = cls.replicas + | )(using besom.types.Context): CronTab = + | new CronTab( + | cronSpec = cronSpec.asOptionOutput(isSecret = false), + | image = image.asOptionOutput(isSecret = false), + | replicas = replicas.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[CronTab]) + | def cronSpec: besom.types.Output[scala.Option[String]] = output.flatMap(_.cronSpec) + | def image: besom.types.Output[scala.Option[String]] = output.flatMap(_.image) + | def replicas: besom.types.Output[scala.Option[Int]] = output.flatMap(_.replicas) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[CronTab]]) + | def cronSpec: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.cronSpec) + | def image: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.image) + | def replicas: besom.types.Output[scala.Option[Int]] = output.flatMapOpt(_.replicas) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ), + Data( + name = "Nested array types with object type", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | foo: + | type: array + | items: + | type: array + | items: + | type: object + | properties: + | bar: + | type: string + | names: + | singular: test + | kind: Test + |""".stripMargin, + expected = Map( + "test/v1/Foo.scala" -> + """package test.v1 + | + |final case class Foo private( + | bar: besom.types.Output[scala.Option[String]], + |) derives besom.Decoder, besom.Encoder + | + |object Foo: + | def apply( + | bar: besom.types.Input.Optional[String] = scala.None + | )(using besom.types.Context): Foo = + | new Foo( + | bar = bar.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: Foo) def withArgs( + | bar: besom.types.Input.Optional[String] = cls.bar + | )(using besom.types.Context): Foo = + | new Foo( + | bar = bar.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[Foo]) + | def bar: besom.types.Output[scala.Option[String]] = output.flatMap(_.bar) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[Foo]]) + | def bar: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.bar) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin, + "test/v1/Test.scala" -> + """package test.v1 + | + |final case class Test private( + | foo: besom.types.Output[scala.Option[scala.collection.immutable.Iterable[scala.collection.immutable.Iterable[test.v1.Foo]]]], + |) derives besom.Decoder, besom.Encoder + | + |object Test: + | def apply( + | foo: besom.types.Input.Optional[scala.collection.immutable.Iterable[scala.collection.immutable.Iterable[test.v1.Foo]]] = scala.None + | )(using besom.types.Context): Test = + | new Test( + | foo = foo.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: Test) def withArgs( + | foo: besom.types.Input.Optional[scala.collection.immutable.Iterable[scala.collection.immutable.Iterable[test.v1.Foo]]] = cls.foo + | )(using besom.types.Context): Test = + | new Test( + | foo = foo.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[Test]) + | def foo: besom.types.Output[scala.Option[scala.collection.immutable.Iterable[scala.collection.immutable.Iterable[test.v1.Foo]]]] = output.flatMap(_.foo) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[Test]]) + | def foo: besom.types.Output[scala.Option[scala.collection.immutable.Iterable[scala.collection.immutable.Iterable[test.v1.Foo]]]] = output.flatMapOpt(_.foo) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ), + Data( + name = "Numbers with boolean types", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | boolean: + | type: boolean + | anyNumber: + | type: number + | floatNumber: + | type: number + | format: float + | doubleNumber: + | type: number + | format: double + | anyInteger: + | type: integer + | int32Integer: + | type: integer + | format: int32 + | int64Integer: + | type: integer + | format: int64 + | names: + | singular: number + | kind: Number + |""".stripMargin, + expected = Map( + "number/v1/Number.scala" -> + """package number.v1 + | + |final case class Number private( + | anyInteger: besom.types.Output[scala.Option[Int]], + | boolean: besom.types.Output[scala.Option[Boolean]], + | doubleNumber: besom.types.Output[scala.Option[Double]], + | floatNumber: besom.types.Output[scala.Option[Float]], + | int32Integer: besom.types.Output[scala.Option[Int]], + | int64Integer: besom.types.Output[scala.Option[Long]], + | anyNumber: besom.types.Output[scala.Option[Double]], + |) derives besom.Decoder, besom.Encoder + | + |object Number: + | def apply( + | anyInteger: besom.types.Input.Optional[Int] = scala.None, + | boolean: besom.types.Input.Optional[Boolean] = scala.None, + | doubleNumber: besom.types.Input.Optional[Double] = scala.None, + | floatNumber: besom.types.Input.Optional[Float] = scala.None, + | int32Integer: besom.types.Input.Optional[Int] = scala.None, + | int64Integer: besom.types.Input.Optional[Long] = scala.None, + | anyNumber: besom.types.Input.Optional[Double] = scala.None + | )(using besom.types.Context): Number = + | new Number( + | anyInteger = anyInteger.asOptionOutput(isSecret = false), + | boolean = boolean.asOptionOutput(isSecret = false), + | doubleNumber = doubleNumber.asOptionOutput(isSecret = false), + | floatNumber = floatNumber.asOptionOutput(isSecret = false), + | int32Integer = int32Integer.asOptionOutput(isSecret = false), + | int64Integer = int64Integer.asOptionOutput(isSecret = false), + | anyNumber = anyNumber.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: Number) def withArgs( + | anyInteger: besom.types.Input.Optional[Int] = cls.anyInteger, + | boolean: besom.types.Input.Optional[Boolean] = cls.boolean, + | doubleNumber: besom.types.Input.Optional[Double] = cls.doubleNumber, + | floatNumber: besom.types.Input.Optional[Float] = cls.floatNumber, + | int32Integer: besom.types.Input.Optional[Int] = cls.int32Integer, + | int64Integer: besom.types.Input.Optional[Long] = cls.int64Integer, + | anyNumber: besom.types.Input.Optional[Double] = cls.anyNumber + | )(using besom.types.Context): Number = + | new Number( + | anyInteger = anyInteger.asOptionOutput(isSecret = false), + | boolean = boolean.asOptionOutput(isSecret = false), + | doubleNumber = doubleNumber.asOptionOutput(isSecret = false), + | floatNumber = floatNumber.asOptionOutput(isSecret = false), + | int32Integer = int32Integer.asOptionOutput(isSecret = false), + | int64Integer = int64Integer.asOptionOutput(isSecret = false), + | anyNumber = anyNumber.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[Number]) + | def anyInteger: besom.types.Output[scala.Option[Int]] = output.flatMap(_.anyInteger) + | def boolean: besom.types.Output[scala.Option[Boolean]] = output.flatMap(_.boolean) + | def doubleNumber: besom.types.Output[scala.Option[Double]] = output.flatMap(_.doubleNumber) + | def floatNumber: besom.types.Output[scala.Option[Float]] = output.flatMap(_.floatNumber) + | def int32Integer: besom.types.Output[scala.Option[Int]] = output.flatMap(_.int32Integer) + | def int64Integer: besom.types.Output[scala.Option[Long]] = output.flatMap(_.int64Integer) + | def anyNumber: besom.types.Output[scala.Option[Double]] = output.flatMap(_.anyNumber) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[Number]]) + | def anyInteger: besom.types.Output[scala.Option[Int]] = output.flatMapOpt(_.anyInteger) + | def boolean: besom.types.Output[scala.Option[Boolean]] = output.flatMapOpt(_.boolean) + | def doubleNumber: besom.types.Output[scala.Option[Double]] = output.flatMapOpt(_.doubleNumber) + | def floatNumber: besom.types.Output[scala.Option[Float]] = output.flatMapOpt(_.floatNumber) + | def int32Integer: besom.types.Output[scala.Option[Int]] = output.flatMapOpt(_.int32Integer) + | def int64Integer: besom.types.Output[scala.Option[Long]] = output.flatMapOpt(_.int64Integer) + | def anyNumber: besom.types.Output[scala.Option[Double]] = output.flatMapOpt(_.anyNumber) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + | + |""".stripMargin + ) + ), + Data( + name = "Base string types", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | date: + | type: string + | format: date + | dateTime: + | type: string + | format: date-time + | password: + | type: string + | format: password + | byte: + | type: string + | format: byte + | binary: + | type: string + | format: binary + | names: + | singular: string + | kind: String + |""".stripMargin, + expected = Map( + "string/v1/String.scala" -> + """package string.v1 + | + |final case class String private( + | binary: besom.types.Output[scala.Option[String]], + | dateTime: besom.types.Output[scala.Option[java.time.LocalDateTime]], + | date: besom.types.Output[scala.Option[java.time.LocalDate]], + | byte: besom.types.Output[scala.Option[String]], + | password: besom.types.Output[scala.Option[String]], + |) derives besom.Decoder, besom.Encoder + | + |object String: + | def apply( + | binary: besom.types.Input.Optional[String] = scala.None, + | dateTime: besom.types.Input.Optional[java.time.LocalDateTime] = scala.None, + | date: besom.types.Input.Optional[java.time.LocalDate] = scala.None, + | byte: besom.types.Input.Optional[String] = scala.None, + | password: besom.types.Input.Optional[String] = scala.None + | )(using besom.types.Context): String = + | new String( + | binary = binary.asOptionOutput(isSecret = false), + | dateTime = dateTime.asOptionOutput(isSecret = false), + | date = date.asOptionOutput(isSecret = false), + | byte = byte.asOptionOutput(isSecret = false), + | password = password.asOptionOutput(isSecret = true) + | ) + | + | extension (cls: String) def withArgs( + | binary: besom.types.Input.Optional[String] = cls.binary, + | dateTime: besom.types.Input.Optional[java.time.LocalDateTime] = cls.dateTime, + | date: besom.types.Input.Optional[java.time.LocalDate] = cls.date, + | byte: besom.types.Input.Optional[String] = cls.byte, + | password: besom.types.Input.Optional[String] = cls.password + | )(using besom.types.Context): String = + | new String( + | binary = binary.asOptionOutput(isSecret = false), + | dateTime = dateTime.asOptionOutput(isSecret = false), + | date = date.asOptionOutput(isSecret = false), + | byte = byte.asOptionOutput(isSecret = false), + | password = password.asOptionOutput(isSecret = true) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[String]) + | def binary: besom.types.Output[scala.Option[String]] = output.flatMap(_.binary) + | def dateTime: besom.types.Output[scala.Option[java.time.LocalDateTime]] = output.flatMap(_.dateTime) + | def date: besom.types.Output[scala.Option[java.time.LocalDate]] = output.flatMap(_.date) + | def byte: besom.types.Output[scala.Option[String]] = output.flatMap(_.byte) + | def password: besom.types.Output[scala.Option[String]] = output.flatMap(_.password) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[String]]) + | def binary: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.binary) + | def dateTime: besom.types.Output[scala.Option[java.time.LocalDateTime]] = output.flatMapOpt(_.dateTime) + | def date: besom.types.Output[scala.Option[java.time.LocalDate]] = output.flatMapOpt(_.date) + | def byte: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.byte) + | def password: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.password) + | + | + | given besom.Encoder[java.time.LocalDateTime] with + | def encode(t: java.time.LocalDateTime)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(t.format(java.time.format.DateTimeFormatter.ISO_DATE_TIME)) + | + | + | given besom.Decoder[java.time.LocalDateTime] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, java.time.LocalDateTime] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try(java.time.LocalDateTime.parse(v.getStringValue, java.time.format.DateTimeFormatter.ISO_DATE_TIME)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$label: Expected a LocalDateTime, got: '${v.kind}'", label)) + | ) + | + | + | given besom.Encoder[java.time.LocalDate] with + | def encode(t: java.time.LocalDate)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(t.format(java.time.format.DateTimeFormatter.ISO_DATE)) + | + | + | given besom.Decoder[java.time.LocalDate] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, java.time.LocalDate] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try(java.time.LocalDate.parse(v.getStringValue, java.time.format.DateTimeFormatter.ISO_DATE)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$label: Expected a LocalDate, got: '${v.kind}'", label)) + | ) + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ), + Data( + name = "Nested object", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | foo: + | type: object + | properties: + | bar: + | type: object + | properties: + | str: + | type: string + | names: + | singular: nestedObject + | kind: NestedObject + |""".stripMargin, + expected = Map( + "nestedObject/v1/Foo.scala" -> + """package nestedObject.v1 + | + |final case class Foo private( + | bar: besom.types.Output[scala.Option[nestedObject.v1.foo.Bar]], + |) derives besom.Decoder, besom.Encoder + | + |object Foo: + | def apply( + | bar: besom.types.Input.Optional[nestedObject.v1.foo.Bar] = scala.None + | )(using besom.types.Context): Foo = + | new Foo( + | bar = bar.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: Foo) def withArgs( + | bar: besom.types.Input.Optional[nestedObject.v1.foo.Bar] = cls.bar + | )(using besom.types.Context): Foo = + | new Foo( + | bar = bar.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[Foo]) + | def bar: besom.types.Output[scala.Option[nestedObject.v1.foo.Bar]] = output.flatMap(_.bar) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[Foo]]) + | def bar: besom.types.Output[scala.Option[nestedObject.v1.foo.Bar]] = output.flatMapOpt(_.bar) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin, + "nestedObject/v1/NestedObject.scala" -> + """package nestedObject.v1 + | + |final case class NestedObject private( + | foo: besom.types.Output[scala.Option[nestedObject.v1.Foo]], + |) derives besom.Decoder, besom.Encoder + | + |object NestedObject: + | def apply( + | foo: besom.types.Input.Optional[nestedObject.v1.Foo] = scala.None + | )(using besom.types.Context): NestedObject = + | new NestedObject( + | foo = foo.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: NestedObject) def withArgs( + | foo: besom.types.Input.Optional[nestedObject.v1.Foo] = cls.foo + | )(using besom.types.Context): NestedObject = + | new NestedObject( + | foo = foo.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[NestedObject]) + | def foo: besom.types.Output[scala.Option[nestedObject.v1.Foo]] = output.flatMap(_.foo) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[NestedObject]]) + | def foo: besom.types.Output[scala.Option[nestedObject.v1.Foo]] = output.flatMapOpt(_.foo) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin, + "nestedObject/v1/foo/Bar.scala" -> + """package nestedObject.v1.foo + | + |final case class Bar private( + | str: besom.types.Output[scala.Option[String]], + |) derives besom.Decoder, besom.Encoder + | + |object Bar: + | def apply( + | str: besom.types.Input.Optional[String] = scala.None + | )(using besom.types.Context): Bar = + | new Bar( + | str = str.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: Bar) def withArgs( + | str: besom.types.Input.Optional[String] = cls.str + | )(using besom.types.Context): Bar = + | new Bar( + | str = str.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[Bar]) + | def str: besom.types.Output[scala.Option[String]] = output.flatMap(_.str) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[Bar]]) + | def str: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.str) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ), + Data( + name = "Description and require field", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | required: + | - requiredField + | properties: + | requiredField: + | type: string + | descField: + | description: |- + | Description line 1 + | Description line 2 + | type: string + | names: + | singular: descWithRequiredField + | kind: DescWithRequiredField + |""".stripMargin, + expected = Map( + "descWithRequiredField/v1/DescWithRequiredField.scala" -> + """package descWithRequiredField.v1 + | + |final case class DescWithRequiredField private( + | requiredField: besom.types.Output[String], + | /** + | * Description line 1 + | * Description line 2 + | */ + | descField: besom.types.Output[scala.Option[String]], + |) derives besom.Decoder, besom.Encoder + | + |object DescWithRequiredField: + | def apply( + | requiredField: besom.types.Input[String], + | descField: besom.types.Input.Optional[String] = scala.None + | )(using besom.types.Context): DescWithRequiredField = + | new DescWithRequiredField( + | requiredField = requiredField.asOutput(isSecret = false), + | descField = descField.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: DescWithRequiredField) def withArgs( + | requiredField: besom.types.Input[String] = cls.requiredField, + | descField: besom.types.Input.Optional[String] = cls.descField + | )(using besom.types.Context): DescWithRequiredField = + | new DescWithRequiredField( + | requiredField = requiredField.asOutput(isSecret = false), + | descField = descField.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[DescWithRequiredField]) + | def requiredField: besom.types.Output[String] = output.flatMap(_.requiredField) + | def descField: besom.types.Output[scala.Option[String]] = output.flatMap(_.descField) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[DescWithRequiredField]]) + | def requiredField: besom.types.Output[scala.Option[String]] = output.mapOpt(_.requiredField) + | def descField: besom.types.Output[scala.Option[String]] = output.flatMapOpt(_.descField) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ), + Data( + name = "Enum file", + yaml = """apiVersion: apiextensions.k8s.io/v1 + |kind: CustomResourceDefinition + |spec: + | group: example.com + | versions: + | - name: v1 + | schema: + | openAPIV3Schema: + | type: object + | properties: + | spec: + | type: object + | properties: + | enumFile: + | type: string + | enum: + | - enum1 + | - enum2 + | names: + | singular: enumTest + | kind: EnumTest + |""".stripMargin, + expected = Map( + "enumTest/v1/EnumFile.scala" -> + """package enumTest.v1 + | + |enum EnumFile: + | case enum1 extends EnumFile + | case enum2 extends EnumFile + | + |object EnumFile: + | + | given besom.Encoder[EnumFile] with + | def encode(e: EnumFile)(using besom.Context): besom.internal.Result[(besom.internal.Metadata, com.google.protobuf.struct.Value)] = + | besom.internal.Encoder.stringEncoder.encode(e.toString) + | + | + | given besom.Decoder[EnumFile] with + | def mapping(v: com.google.protobuf.struct.Value, label: besom.types.Label): besom.util.Validated[besom.internal.DecodingError, EnumFile] = + | besom.internal.Decoder.stringDecoder.mapping(v, label).flatMap(str => + | scala.util.Try(EnumFile.valueOf(str)) match + | case scala.util.Success(value) => + | besom.util.Validated.valid(value) + | case scala.util.Failure(_) => + | besom.util.Validated.invalid(besom.internal.Decoder.error(s"$label: Expected a EnumFile enum, got: '${v.kind}'", label)) + | ) + |""".stripMargin, + "enumTest/v1/EnumTest.scala" -> + """package enumTest.v1 + | + |final case class EnumTest private( + | enumFile: besom.types.Output[scala.Option[enumTest.v1.EnumFile]], + |) derives besom.Decoder, besom.Encoder + | + |object EnumTest: + | def apply( + | enumFile: besom.types.Input.Optional[enumTest.v1.EnumFile] = scala.None + | )(using besom.types.Context): EnumTest = + | new EnumTest( + | enumFile = enumFile.asOptionOutput(isSecret = false) + | ) + | + | extension (cls: EnumTest) def withArgs( + | enumFile: besom.types.Input.Optional[enumTest.v1.EnumFile] = cls.enumFile + | )(using besom.types.Context): EnumTest = + | new EnumTest( + | enumFile = enumFile.asOptionOutput(isSecret = false) + | ) + | + | given outputOps: {} with + | extension(output: besom.types.Output[EnumTest]) + | def enumFile: besom.types.Output[scala.Option[enumTest.v1.EnumFile]] = output.flatMap(_.enumFile) + | + | given optionOutputOps: {} with + | extension(output: besom.types.Output[scala.Option[EnumTest]]) + | def enumFile: besom.types.Output[scala.Option[enumTest.v1.EnumFile]] = output.flatMapOpt(_.enumFile) + | + | + | extension [A](output: besom.types.Output[scala.Option[A]]) + | def flatMapOpt[B](f: A => besom.types.Output[Option[B]]): besom.types.Output[scala.Option[B]] = + | output.flatMap( + | _.map(f) + | .getOrElse(output.map(_ => scala.None)) + | ) + | + | def mapOpt[B](f: A => besom.types.Output[B]): besom.types.Output[scala.Option[B]] = + | flatMapOpt(f(_).map(Some(_))) + |""".stripMargin + ) + ) + ).foreach(data => + test(data.name.withTags(data.tags)) { + if (data.expectedError.isDefined) + interceptMessage[Exception](data.expectedError.get)(ClassGenerator.createSourceFiles(data.yaml)) + else + ClassGenerator.createSourceFiles(data.yaml) match + case Left(ex) => + fail(s"Error: $ex") + case Right(sourceFiles) => + sourceFiles.foreach { + case SourceFile(FilePath(f: String), code: String) if data.expected.contains(f) => + assertNoDiff(code, data.expected(f)) + code.parse[Source].get + case SourceFile(FilePath(f: String), _: String) if data.ignored.contains(f) => + println(s"Ignoring file: $f") + case SourceFile(filename, _) => + fail(s"Unexpected file: ${filename.osSubPath}") + } + } + ) +} diff --git a/crd2besom/src/EnumClass.scala b/crd2besom/src/EnumClass.scala new file mode 100644 index 00000000..84ed5bcd --- /dev/null +++ b/crd2besom/src/EnumClass.scala @@ -0,0 +1,27 @@ +package besom.codegen.crd + +import besom.codegen.scalameta.interpolator.* +import besom.codegen.* +import scala.meta.* +import scala.meta.dialects.Scala33 + +object EnumClass: + def enumFile(packagePath: PackagePath, enumName: String, enumList: List[String]): SourceFile = { + val companionObject = + m"""|object $enumName: + |${AdditionalCodecs.enumCodecs(enumName).mkString("\n")} + |""".stripMargin.parse[Stat].get + + val createdClass = + m"""|package ${packagePath.path.mkString(".")} + | + |enum $enumName: + |${enumList.map(e => s" case ${Type.Name(e).syntax} extends $enumName").mkString("\n")} + | + |$companionObject + |""".stripMargin.parse[Source].get + SourceFile( + filePath = besom.codegen.FilePath(packagePath.path :+ s"$enumName.scala"), + sourceCode = createdClass.syntax + ) + } diff --git a/crd2besom/src/FieldTypeInfo.scala b/crd2besom/src/FieldTypeInfo.scala new file mode 100644 index 00000000..bff1bf9a --- /dev/null +++ b/crd2besom/src/FieldTypeInfo.scala @@ -0,0 +1,30 @@ +package besom.codegen.crd + +import scala.meta.* + +case class FieldTypeInfo( + name: Name, + description: Option[Seq[String]], + isOptional: Boolean, + baseType: Type, + isSecret: Boolean +) + +object FieldTypeInfo: + def apply( + name: String, + baseType: Type, + jsonSchema: JsonSchemaProps, + parentJsonSchema: JsonSchemaProps, + isSecret: Boolean = false + ): FieldTypeInfo = + FieldTypeInfo( + name = Name(name), + description = jsonSchema.description.map(_.value), + isOptional = isOptional(parentJsonSchema.required, name), + baseType = baseType, + isSecret = isSecret + ) + + private def isOptional(required: Option[Set[String]], fieldName: String): Boolean = + !required.getOrElse(Set.empty).contains(fieldName) diff --git a/crd2besom/src/utils.scala b/crd2besom/src/utils.scala new file mode 100644 index 00000000..b0a31611 --- /dev/null +++ b/crd2besom/src/utils.scala @@ -0,0 +1,10 @@ +package besom.codegen.crd + +extension [A](ie: Iterable[Either[Throwable, A]]) + def flattenWithFirstError: Either[Throwable, Iterable[A]] = + val (error, pass) = ie.partitionMap(identity) + if (error.nonEmpty) Left(error.head) else Right(pass) + +extension (s: String) + def toWorkingDirectoryPath: os.Path = + besom.codegen.FilePath(s.split("/").toSeq).osSubPath.resolveFrom(os.pwd) diff --git a/crd2besom/src/yaml.scala b/crd2besom/src/yaml.scala new file mode 100644 index 00000000..de9e8861 --- /dev/null +++ b/crd2besom/src/yaml.scala @@ -0,0 +1,73 @@ +package besom.codegen.crd + +import besom.codegen.* +import org.virtuslab.yaml.* +import org.virtuslab.yaml.Node.ScalarNode + +import scala.util.Try + +case class CRD(spec: CRDSpec) derives YamlDecoder +case class CRDSpec(names: CRDNames, versions: Seq[CRDVersion]) derives YamlDecoder +case class CRDNames(singular: String, kind: String) derives YamlDecoder +case class CRDVersion(name: String, schema: CustomResourceValidation) derives YamlDecoder +case class CustomResourceValidation(openAPIV3Schema: JsonSchemaProps) derives YamlDecoder +case class JsonSchemaProps( + description: Option[Description], + `enum`: Option[List[String]], + `type`: Option[DataTypeEnum], + format: Option[String], + required: Option[Set[String]], + items: Option[JsonSchemaProps], + properties: Option[Map[String, JsonSchemaProps]], + additionalProperties: Option[Boolean | JsonSchemaProps] +) derives YamlDecoder + +object JsonSchemaProps: + given YamlDecoder[DataTypeEnum] = YamlDecoder { case s @ ScalarNode(value, _) => + Try(DataTypeEnum.valueOf(value)).toEither.left + .map(ConstructError.from(_, "enum DataTypeEnum", s)) + } + given YamlDecoder[Option[JsonSchemaProps]] = YamlDecoder { case node => + YamlDecoder.forOption[JsonSchemaProps].construct(node) + } + given YamlDecoder[Map[String, JsonSchemaProps]] = YamlDecoder { case node => + YamlDecoder.forMap[String, JsonSchemaProps].construct(node) + } + given booleanOrJsonSchemaProps: YamlDecoder[Boolean | JsonSchemaProps] = YamlDecoder { case node => + YamlDecoder.forBoolean + .construct(node) + .left + .flatMap(_ => summon[YamlDecoder[JsonSchemaProps]].construct(node)) + } + +opaque type Description = Seq[String] + +extension (d: Description) def value: Seq[String] = d + +object Description: + given YamlDecoder[Description] = YamlDecoder { case node => + YamlDecoder.forString.map(_.split("\\\\n").toSeq).construct(node) + } + +enum DataTypeEnum: + case string extends DataTypeEnum + case integer extends DataTypeEnum + case number extends DataTypeEnum + case `object` extends DataTypeEnum + case boolean extends DataTypeEnum + case array extends DataTypeEnum + +enum StringFormat: + case date extends StringFormat + case `date-time` extends StringFormat + case password extends StringFormat + case byte extends StringFormat + case binary extends StringFormat + +enum NumberFormat: + case float extends NumberFormat + case double extends NumberFormat + +enum IntegerFormat: + case int32 extends IntegerFormat + case int64 extends IntegerFormat