From 3c406b9784af152a6eecf5138b8c241f871fc215 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:57:53 +0000 Subject: [PATCH 01/25] Add composed generator with models/framework backends Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/cli/OpenApi4sMain.scala | 25 ++- .../ba/sake/openapi4s/OpenApiGenerator.scala | 158 +++++++++++++++++- .../openapi4s/http4s/Http4sGenerator.scala | 2 +- .../openapi4s/sharaf/SharafGenerator.scala | 2 +- 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index b21fd83..685368c 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -9,8 +9,12 @@ object OpenApi4sMain { @main def run( - @arg(doc = "Generator name: 'sharaf' or 'http4s'. Default is 'sharaf'") - generator: String = "sharaf", + @arg(doc = "Model backend: 'circe', 'tupson' or 'none'. Default is 'tupson'") + models: String = "", + @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. Default is 'sharaf'") + framework: String = "", + @arg(doc = "DEPRECATED legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") + generator: String = "", @arg(doc = "OpenAPI URL or file path. Default is 'openapi.json'") url: String = "openapi.json", @arg(doc = "Base folder for generated sources. Default is 'src/main/scala'") @@ -18,12 +22,25 @@ object OpenApi4sMain { @arg(doc = "Base package for generated sources") basePackage: String ) = { + val (mappedModels, mappedFramework) = generator.toLowerCase match { + case "" => ("tupson", "sharaf") + case "sharaf" => ("tupson", "sharaf") + case "http4s" => ("circe", "http4s") + case other => + throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'") + } + val finalModels = if (models.nonEmpty) models else mappedModels + val finalFramework = if (framework.nonEmpty) framework else mappedFramework + if (generator.nonEmpty) { + println("WARNING: '--generator' is deprecated. Prefer '--models' and '--framework'.") + } val openApiGenerator = OpenApiGenerator( - name = generator, config = OpenApiGenerator.Config( url = url, baseFolder = Paths.get(baseFolder), - basePackage = basePackage + basePackage = basePackage, + models = finalModels, + framework = finalFramework ) ) openApiGenerator.generate() diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index b8cafa2..c2bf076 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -3,6 +3,7 @@ package ba.sake.openapi4s import java.nio.file.Path import ba.sake.openapi4s.http4s.Http4sGenerator import ba.sake.openapi4s.sharaf.SharafGenerator +import ba.sake.regenesca.{GeneratedFileSource, RegenescaGenerator, SourceMerger} trait OpenApiGenerator { def generate(): Unit @@ -10,19 +11,168 @@ trait OpenApiGenerator { object OpenApiGenerator { + sealed trait ModelFlavor + object ModelFlavor { + case object Circe extends ModelFlavor + case object Tupson extends ModelFlavor + case object External extends ModelFlavor + } + + case class ModelContract( + packageName: String, + flavor: ModelFlavor + ) + + trait ModelBackend { + def id: String + def flavor: ModelFlavor + def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] + def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor) + } + + trait FrameworkBackend { + def id: String + def requiredModelFlavor: ModelFlavor + def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[GeneratedFileSource] + } + + private object CirceModelBackend extends ModelBackend { + override val id: String = "circe" + override val flavor: ModelFlavor = ModelFlavor.Circe + override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { + new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("models/")) + } + } + + private object TupsonModelBackend extends ModelBackend { + override val id: String = "tupson" + override val flavor: ModelFlavor = ModelFlavor.Tupson + override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { + new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("models/")) + } + } + + private object Http4sFrameworkBackend extends FrameworkBackend { + override val id: String = "http4s" + override val requiredModelFlavor: ModelFlavor = ModelFlavor.Circe + override def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[GeneratedFileSource] = { + new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("routes/")) + } + } + + private object SharafFrameworkBackend extends FrameworkBackend { + override val id: String = "sharaf" + override val requiredModelFlavor: ModelFlavor = ModelFlavor.Tupson + override def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[GeneratedFileSource] = { + new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("controllers/")) + } + } + + private val modelBackends: Map[String, ModelBackend] = Map( + "circe" -> CirceModelBackend, + "tupson" -> TupsonModelBackend + ) + private val frameworkBackends: Map[String, FrameworkBackend] = Map( + "http4s" -> Http4sFrameworkBackend, + "sharaf" -> SharafFrameworkBackend + ) + + @deprecated("Use OpenApiGenerator(config) with models/framework fields", since = "0.7.0") def apply(name: String, config: Config): OpenApiGenerator = { - val openapiDefinition = OpenApiDefinition.parse(config.url) name.toLowerCase match { - case "http4s" => new Http4sGenerator(config, openapiDefinition) - case "sharaf" => new SharafGenerator(config, openapiDefinition) + case "http4s" => apply(config.copy(models = "circe", framework = "http4s")) + case "sharaf" => apply(config.copy(models = "tupson", framework = "sharaf")) case other => throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'") } } + def apply(config: Config): OpenApiGenerator = { + val modelName = config.models.toLowerCase + val frameworkName = config.framework.toLowerCase + + if (modelName == "none" && frameworkName == "none") { + throw new RuntimeException("Invalid config: models=none and framework=none means nothing to generate.") + } + + val modelBackendOpt = if (modelName == "none") None else modelBackends.get(modelName) + if (modelName != "none" && modelBackendOpt.isEmpty) { + throw new RuntimeException( + s"Unknown models backend '${config.models}'. Available models backends: '${(modelBackends.keys.toList :+ "none").sorted.mkString("', '")}'" + ) + } + + val frameworkBackendOpt = if (frameworkName == "none") None else frameworkBackends.get(frameworkName) + if (frameworkName != "none" && frameworkBackendOpt.isEmpty) { + throw new RuntimeException( + s"Unknown framework backend '${config.framework}'. Available framework backends: '${(frameworkBackends.keys.toList :+ "none").sorted.mkString("', '")}'" + ) + } + + val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) + frameworkBackendOpt.foreach { frameworkBackend => + if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { + throw new RuntimeException( + s"Incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' expects models flavor '${frameworkBackend.requiredModelFlavor.toString.toLowerCase}'." + ) + } + if (modelFlavor == ModelFlavor.External) { + println( + s"WARNING: models=none with framework='${frameworkBackend.id}'. " + + s"Generation will reference ${config.basePackage}.models types that must already exist." + ) + } + } + + new ComposedOpenApiGenerator(config, modelBackendOpt, frameworkBackendOpt) + } + case class Config( url: String, baseFolder: Path, - basePackage: String + basePackage: String, + models: String = "tupson", + framework: String = "sharaf" ) + private class ComposedOpenApiGenerator( + config: Config, + modelBackendOpt: Option[ModelBackend], + frameworkBackendOpt: Option[FrameworkBackend] + ) extends OpenApiGenerator { + private val openapiDefinition = OpenApiDefinition.parse(config.url) + private val merger = SourceMerger(mergeDefBodies = true) + private val regenescaGenerator = RegenescaGenerator(merger) + + override def generate(): Unit = { + println( + s"Started generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}' into '${config.baseFolder}' ..." + ) + val modelSources = modelBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition)) + val modelContract = modelBackendOpt.map(_.contract(config)).getOrElse(ModelContract(s"${config.basePackage}.models", ModelFlavor.External)) + val frameworkSources = + frameworkBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition, modelContract)) + val packagePath = config.basePackage.replaceAll("\\.", "/") + val adaptedGenSourceFiles = (modelSources ++ frameworkSources).map { gsf => + gsf.copy(file = config.baseFolder.resolve(packagePath).resolve(gsf.file.toString)) + } + regenescaGenerator.generate(adaptedGenSourceFiles) + println( + s"Finished generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}'." + ) + } + } + } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index 0afe495..f2b211d 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -26,7 +26,7 @@ class Http4sGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp println(s"Finished generating Http4s server for '${config.url}' OpenApi.") } - private[http4s] def generateSources: Seq[GeneratedFileSource] = { + private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") val modelImports = List[Import]( q"import java.time.*", diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index cb8ec46..85aeae1 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -26,7 +26,7 @@ class SharafGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp println(s"Finished generating Sharaf server for '${config.url}' OpenApi.") } - private[sharaf] def generateSources: Seq[GeneratedFileSource] = { + private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") val modelImports = List[Import]( q"import java.time.*", From 2c527b141f3ddcac8a266df8bc82f88e83f6a8c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:59:27 +0000 Subject: [PATCH 02/25] Add orchestration tests and README examples Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- README.md | 21 +++- .../ba/sake/openapi4s/OpenApiGenerator.scala | 6 +- .../openapi4s/OpenApiGeneratorSuite.scala | 110 ++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala diff --git a/README.md b/README.md index 0a0b438..166e42d 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,29 @@ You can use `openapi4s-cli` with Coursier launcher to generate your sources: ```shell cs launch ba.sake::openapi4s-cli:0.6.1 -M ba.sake.openapi4s.cli.OpenApi4sMain -- \ - --generator sharaf \ + --models tupson \ + --framework sharaf \ --url openapi.json \ --baseFolder src \ --basePackage com.example ``` +You can now combine model and framework generation independently: +```shell +# circe models + http4s routes +--models circe --framework http4s + +# circe models + sharaf controllers +--models circe --framework sharaf + +# models only +--models tupson --framework none + +# framework only (expects existing com.example.models) +--models none --framework http4s +``` + +`--generator` is still supported as a deprecated compatibility alias: +- `--generator sharaf` => `--models tupson --framework sharaf` +- `--generator http4s` => `--models circe --framework http4s` diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index c2bf076..ea5179d 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -123,9 +123,9 @@ object OpenApiGenerator { val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) frameworkBackendOpt.foreach { frameworkBackend => if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { - throw new RuntimeException( - s"Incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' expects models flavor '${frameworkBackend.requiredModelFlavor.toString.toLowerCase}'." + println( + s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' typically expects models flavor '${frameworkBackend.requiredModelFlavor.toString.toLowerCase}'." ) } if (modelFlavor == ModelFlavor.External) { diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala new file mode 100644 index 0000000..12847eb --- /dev/null +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -0,0 +1,110 @@ +package ba.sake.openapi4s + +import java.nio.file.{Files, Path, Paths} +import scala.jdk.CollectionConverters.* + +class OpenApiGeneratorSuite extends munit.FunSuite { + + test("composed generator should support circe + http4s") { + val baseFolder = Files.createTempDirectory("openapi4s-circe-http4s") + val config = OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = baseFolder, + basePackage = "pkg", + models = "circe", + framework = "http4s" + ) + OpenApiGenerator(config).generate() + val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.exists(_.startsWith("models/"))) + assert(generatedFiles.exists(_.startsWith("routes/"))) + assert(!generatedFiles.exists(_.startsWith("controllers/"))) + } + + test("composed generator should support tupson + none") { + val baseFolder = Files.createTempDirectory("openapi4s-tupson-none") + val config = OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = baseFolder, + basePackage = "pkg", + models = "tupson", + framework = "none" + ) + OpenApiGenerator(config).generate() + val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.exists(_.startsWith("models/"))) + assert(!generatedFiles.exists(_.startsWith("routes/"))) + assert(!generatedFiles.exists(_.startsWith("controllers/"))) + } + + test("composed generator should support none + http4s") { + val baseFolder = Files.createTempDirectory("openapi4s-none-http4s") + val config = OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = baseFolder, + basePackage = "pkg", + models = "none", + framework = "http4s" + ) + OpenApiGenerator(config).generate() + val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(!generatedFiles.exists(_.startsWith("models/"))) + assert(generatedFiles.exists(_.startsWith("routes/"))) + assert(!generatedFiles.exists(_.startsWith("controllers/"))) + } + + test("composed generator should reject none + none") { + intercept[RuntimeException] { + OpenApiGenerator( + OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = Paths.get("app"), + basePackage = "pkg", + models = "none", + framework = "none" + ) + ) + } + } + + test("composed generator should allow circe + sharaf") { + val baseFolder = Files.createTempDirectory("openapi4s-circe-sharaf") + val config = OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = baseFolder, + basePackage = "pkg", + models = "circe", + framework = "sharaf" + ) + OpenApiGenerator(config).generate() + val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.exists(_.startsWith("models/"))) + assert(generatedFiles.exists(_.startsWith("controllers/"))) + } + + test("legacy generator mapping should still work") { + val baseFolder = Files.createTempDirectory("openapi4s-legacy-http4s") + val config = OpenApiGenerator.Config( + url = TestUtils.getResourceUrl("petstore_3.0.0.json"), + baseFolder = baseFolder, + basePackage = "pkg" + ) + OpenApiGenerator("http4s", config).generate() + val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.exists(_.startsWith("models/"))) + assert(generatedFiles.exists(_.startsWith("routes/"))) + } + + private def listScalaFiles(base: Path): List[String] = { + if (!Files.exists(base)) List.empty + else { + val stream = Files.walk(base) + try { + stream.iterator().asScala + .filter(path => Files.isRegularFile(path) && path.getFileName.toString.endsWith(".scala")) + .map(path => base.relativize(path).toString.replace('\\', '/')) + .toList + } finally stream.close() + } + } +} From 85a0724e15d5b9d636607717241e2ea43a5a757f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:00:54 +0000 Subject: [PATCH 03/25] Address review feedback for warnings and tests Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/cli/OpenApi4sMain.scala | 2 +- .../ba/sake/openapi4s/OpenApiGenerator.scala | 15 +++++++++------ .../ba/sake/openapi4s/OpenApiGeneratorSuite.scala | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index 685368c..1e971ff 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -32,7 +32,7 @@ object OpenApi4sMain { val finalModels = if (models.nonEmpty) models else mappedModels val finalFramework = if (framework.nonEmpty) framework else mappedFramework if (generator.nonEmpty) { - println("WARNING: '--generator' is deprecated. Prefer '--models' and '--framework'.") + System.err.println("WARNING: '--generator' is deprecated. Prefer '--models' and '--framework'.") } val openApiGenerator = OpenApiGenerator( config = OpenApiGenerator.Config( diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index ea5179d..1e6a774 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -10,6 +10,9 @@ trait OpenApiGenerator { } object OpenApiGenerator { + private val ModelsPrefix = "models/" + private val RoutesPrefix = "routes/" + private val ControllersPrefix = "controllers/" sealed trait ModelFlavor object ModelFlavor { @@ -44,7 +47,7 @@ object OpenApiGenerator { override val id: String = "circe" override val flavor: ModelFlavor = ModelFlavor.Circe override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("models/")) + new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) } } @@ -52,7 +55,7 @@ object OpenApiGenerator { override val id: String = "tupson" override val flavor: ModelFlavor = ModelFlavor.Tupson override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("models/")) + new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) } } @@ -64,7 +67,7 @@ object OpenApiGenerator { openapiDefinition: OpenApiDefinition, modelContract: ModelContract ): Seq[GeneratedFileSource] = { - new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("routes/")) + new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) } } @@ -76,7 +79,7 @@ object OpenApiGenerator { openapiDefinition: OpenApiDefinition, modelContract: ModelContract ): Seq[GeneratedFileSource] = { - new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith("controllers/")) + new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) } } @@ -123,13 +126,13 @@ object OpenApiGenerator { val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) frameworkBackendOpt.foreach { frameworkBackend => if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { - println( + System.err.println( s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + s"Framework '${frameworkBackend.id}' typically expects models flavor '${frameworkBackend.requiredModelFlavor.toString.toLowerCase}'." ) } if (modelFlavor == ModelFlavor.External) { - println( + System.err.println( s"WARNING: models=none with framework='${frameworkBackend.id}'. " + s"Generation will reference ${config.basePackage}.models types that must already exist." ) diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index 12847eb..e209d4e 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -54,7 +54,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { } test("composed generator should reject none + none") { - intercept[RuntimeException] { + interceptMessage[RuntimeException]("Invalid config: models=none and framework=none means nothing to generate.") { OpenApiGenerator( OpenApiGenerator.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), From b99adf3596d3f7bd9085e4f8a119253a5454c6e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:02:03 +0000 Subject: [PATCH 04/25] Strengthen generation matrix test assertions Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index e209d4e..a977e69 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -16,6 +16,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { ) OpenApiGenerator(config).generate() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) @@ -32,6 +33,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { ) OpenApiGenerator(config).generate() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(!generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) @@ -48,6 +50,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { ) OpenApiGenerator(config).generate() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.nonEmpty) assert(!generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) @@ -78,6 +81,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { ) OpenApiGenerator(config).generate() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("controllers/"))) } @@ -91,6 +95,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { ) OpenApiGenerator("http4s", config).generate() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) + assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) } From d8956c4ec361530198b39cd2bf5cea6c70928b84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:03:18 +0000 Subject: [PATCH 05/25] Adjust deprecated CLI help text casing Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index 1e971ff..f65dd9d 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -13,7 +13,7 @@ object OpenApi4sMain { models: String = "", @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. Default is 'sharaf'") framework: String = "", - @arg(doc = "DEPRECATED legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") + @arg(doc = "deprecated legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") generator: String = "", @arg(doc = "OpenAPI URL or file path. Default is 'openapi.json'") url: String = "openapi.json", From b5cd8142f2d820118340b011ef089441dc28e775 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:04:51 +0000 Subject: [PATCH 06/25] Align flavor naming and CLI default docs Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala | 4 ++-- .../main/scala/ba/sake/openapi4s/OpenApiGenerator.scala | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index f65dd9d..9b4d5c8 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -9,9 +9,9 @@ object OpenApi4sMain { @main def run( - @arg(doc = "Model backend: 'circe', 'tupson' or 'none'. Default is 'tupson'") + @arg(doc = "Model backend: 'circe', 'tupson' or 'none'. If unset, defaults to 'tupson' (or mapped by --generator).") models: String = "", - @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. Default is 'sharaf'") + @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. If unset, defaults to 'sharaf' (or mapped by --generator).") framework: String = "", @arg(doc = "deprecated legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") generator: String = "", diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 1e6a774..c21642f 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -19,6 +19,12 @@ object OpenApiGenerator { case object Circe extends ModelFlavor case object Tupson extends ModelFlavor case object External extends ModelFlavor + + def asString(modelFlavor: ModelFlavor): String = modelFlavor match { + case Circe => "circe" + case Tupson => "tupson" + case External => "external" + } } case class ModelContract( @@ -128,7 +134,7 @@ object OpenApiGenerator { if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { System.err.println( s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${frameworkBackend.requiredModelFlavor.toString.toLowerCase}'." + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." ) } if (modelFlavor == ModelFlavor.External) { From b44051db16f33e00e14e10e2c2945f13c506237c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:05:38 +0000 Subject: [PATCH 07/25] Clarify compatibility validation flow Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/OpenApiGenerator.scala | 39 ++++++++++++------- .../openapi4s/OpenApiGeneratorSuite.scala | 1 + 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index c21642f..30b927f 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -129,21 +129,7 @@ object OpenApiGenerator { ) } - val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) - frameworkBackendOpt.foreach { frameworkBackend => - if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { - System.err.println( - s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." - ) - } - if (modelFlavor == ModelFlavor.External) { - System.err.println( - s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference ${config.basePackage}.models types that must already exist." - ) - } - } + validateBackendCompatibility(config, modelBackendOpt, frameworkBackendOpt) new ComposedOpenApiGenerator(config, modelBackendOpt, frameworkBackendOpt) } @@ -184,4 +170,27 @@ object OpenApiGenerator { } } + private def validateBackendCompatibility( + config: Config, + modelBackendOpt: Option[ModelBackend], + frameworkBackendOpt: Option[FrameworkBackend] + ): Unit = { + val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) + frameworkBackendOpt.foreach { frameworkBackend => + // with models=none we cannot infer compatibility, so we only emit the explicit external-model warning below + if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { + System.err.println( + s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." + ) + } + if (modelFlavor == ModelFlavor.External) { + System.err.println( + s"WARNING: models=none with framework='${frameworkBackend.id}'. " + + s"Generation will reference ${config.basePackage}.models types that must already exist." + ) + } + } + } + } diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index a977e69..c25879f 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -71,6 +71,7 @@ class OpenApiGeneratorSuite extends munit.FunSuite { } test("composed generator should allow circe + sharaf") { + // This combination intentionally emits a compatibility warning to stderr, but generation should still succeed. val baseFolder = Files.createTempDirectory("openapi4s-circe-sharaf") val config = OpenApiGenerator.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), From d1fbfd1b3b5ca7165c37470eed4d0806158117ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:06:23 +0000 Subject: [PATCH 08/25] Finalize validation logic and CLI doc style Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/883a92fe-ada9-459d-89a3-6c134f7e2681 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/cli/OpenApi4sMain.scala | 2 +- .../ba/sake/openapi4s/OpenApiGenerator.scala | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index 9b4d5c8..40c5f26 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -13,7 +13,7 @@ object OpenApi4sMain { models: String = "", @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. If unset, defaults to 'sharaf' (or mapped by --generator).") framework: String = "", - @arg(doc = "deprecated legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") + @arg(doc = "Deprecated legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") generator: String = "", @arg(doc = "OpenAPI URL or file path. Default is 'openapi.json'") url: String = "openapi.json", diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 30b927f..a6e15cf 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -177,18 +177,19 @@ object OpenApiGenerator { ): Unit = { val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) frameworkBackendOpt.foreach { frameworkBackend => - // with models=none we cannot infer compatibility, so we only emit the explicit external-model warning below - if (modelFlavor != ModelFlavor.External && frameworkBackend.requiredModelFlavor != modelFlavor) { - System.err.println( - s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." - ) - } - if (modelFlavor == ModelFlavor.External) { - System.err.println( - s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference ${config.basePackage}.models types that must already exist." - ) + modelFlavor match { + case ModelFlavor.External => + // with models=none we cannot infer compatibility, so emit explicit external-model warning + System.err.println( + s"WARNING: models=none with framework='${frameworkBackend.id}'. " + + s"Generation will reference ${config.basePackage}.models types that must already exist." + ) + case _ if frameworkBackend.requiredModelFlavor != modelFlavor => + System.err.println( + s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." + ) + case _ => () } } } From 64af7c297704a985b230e7333f7cebd4769c5a25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:20:53 +0000 Subject: [PATCH 09/25] Extract model import contracts and parameterize framework imports Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/ModelBackends.scala | 43 +++++++++++++++++ .../sake/openapi4s/ModelImportContracts.scala | 43 +++++++++++++++++ .../ba/sake/openapi4s/OpenApiGenerator.scala | 46 ++++++++----------- .../openapi4s/http4s/Http4sGenerator.scala | 22 ++++----- .../openapi4s/sharaf/SharafGenerator.scala | 22 ++++----- .../openapi4s/OpenApiGeneratorSuite.scala | 10 ++++ 6 files changed, 136 insertions(+), 50 deletions(-) create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala new file mode 100644 index 0000000..4c5b785 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -0,0 +1,43 @@ +package ba.sake.openapi4s + +import ba.sake.openapi4s.OpenApiGenerator.* +import ba.sake.openapi4s.http4s.Http4sGenerator +import ba.sake.openapi4s.sharaf.SharafGenerator +import ba.sake.regenesca.GeneratedFileSource + +object ModelBackends { + private val ModelsPrefix = "models/" + + val circe: ModelBackend = new ModelBackend { + override val id: String = "circe" + override val flavor: ModelFlavor = ModelFlavor.Circe + override val imports: ModelImportContract = ModelImportContracts.circe + override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { + new Http4sGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = imports.modelFileImports, + frameworkModelImports = imports.frameworkImports("http4s") + ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) + } + } + + val tupson: ModelBackend = new ModelBackend { + override val id: String = "tupson" + override val flavor: ModelFlavor = ModelFlavor.Tupson + override val imports: ModelImportContract = ModelImportContracts.tupson + override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { + new SharafGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = imports.modelFileImports, + frameworkModelImports = imports.frameworkImports("sharaf") + ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) + } + } + + val byId: Map[String, ModelBackend] = Map( + "circe" -> circe, + "tupson" -> tupson + ) +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala new file mode 100644 index 0000000..a308327 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala @@ -0,0 +1,43 @@ +package ba.sake.openapi4s + +import scala.meta.* + +case class ModelImportContract( + modelFileImports: List[Import], + frameworkImportsById: Map[String, List[Import]] = Map.empty +) { + def frameworkImports(frameworkId: String): List[Import] = + frameworkImportsById.getOrElse(frameworkId, List.empty) +} + +object ModelImportContracts { + val circe: ModelImportContract = ModelImportContract( + modelFileImports = List( + q"import java.time.*", + q"import java.util.UUID", + q"import io.circe.{Codec, Json}", + q"import io.circe.derivation.{Configuration, ConfiguredCodec, ConfiguredEnumCodec}" + ), + frameworkImportsById = Map( + "http4s" -> List(q"import org.http4s.circe.CirceEntityCodec.*") + ) + ) + + val tupson: ModelImportContract = ModelImportContract( + modelFileImports = List( + q"import java.time.*", + q"import java.util.UUID", + q"import org.typelevel.jawn.ast.JValue", + q"import ba.sake.tupson.*", + q"import ba.sake.validson.Validator" + ), + frameworkImportsById = Map( + "sharaf" -> List.empty + ) + ) + + val external: ModelImportContract = ModelImportContract( + modelFileImports = List.empty, + frameworkImportsById = Map.empty + ) +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index a6e15cf..8f8518c 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -10,7 +10,6 @@ trait OpenApiGenerator { } object OpenApiGenerator { - private val ModelsPrefix = "models/" private val RoutesPrefix = "routes/" private val ControllersPrefix = "controllers/" @@ -29,14 +28,16 @@ object OpenApiGenerator { case class ModelContract( packageName: String, - flavor: ModelFlavor + flavor: ModelFlavor, + imports: ModelImportContract ) trait ModelBackend { def id: String def flavor: ModelFlavor + def imports: ModelImportContract def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] - def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor) + def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor, imports) } trait FrameworkBackend { @@ -49,22 +50,6 @@ object OpenApiGenerator { ): Seq[GeneratedFileSource] } - private object CirceModelBackend extends ModelBackend { - override val id: String = "circe" - override val flavor: ModelFlavor = ModelFlavor.Circe - override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) - } - } - - private object TupsonModelBackend extends ModelBackend { - override val id: String = "tupson" - override val flavor: ModelFlavor = ModelFlavor.Tupson - override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) - } - } - private object Http4sFrameworkBackend extends FrameworkBackend { override val id: String = "http4s" override val requiredModelFlavor: ModelFlavor = ModelFlavor.Circe @@ -73,7 +58,12 @@ object OpenApiGenerator { openapiDefinition: OpenApiDefinition, modelContract: ModelContract ): Seq[GeneratedFileSource] = { - new Http4sGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) + new Http4sGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = modelContract.imports.modelFileImports, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) } } @@ -85,14 +75,16 @@ object OpenApiGenerator { openapiDefinition: OpenApiDefinition, modelContract: ModelContract ): Seq[GeneratedFileSource] = { - new SharafGenerator(config, openapiDefinition).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) + new SharafGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = modelContract.imports.modelFileImports, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) } } - private val modelBackends: Map[String, ModelBackend] = Map( - "circe" -> CirceModelBackend, - "tupson" -> TupsonModelBackend - ) + private val modelBackends: Map[String, ModelBackend] = ModelBackends.byId private val frameworkBackends: Map[String, FrameworkBackend] = Map( "http4s" -> Http4sFrameworkBackend, "sharaf" -> SharafFrameworkBackend @@ -156,7 +148,9 @@ object OpenApiGenerator { s"Started generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}' into '${config.baseFolder}' ..." ) val modelSources = modelBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition)) - val modelContract = modelBackendOpt.map(_.contract(config)).getOrElse(ModelContract(s"${config.basePackage}.models", ModelFlavor.External)) + val modelContract = modelBackendOpt + .map(_.contract(config)) + .getOrElse(ModelContract(s"${config.basePackage}.models", ModelFlavor.External, ModelImportContracts.external)) val frameworkSources = frameworkBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition, modelContract)) val packagePath = config.basePackage.replaceAll("\\.", "/") diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index f2b211d..ac59098 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -8,7 +8,12 @@ import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException -class Http4sGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenApiDefinition) extends OpenApiGenerator { +class Http4sGenerator( + config: OpenApiGenerator.Config, + openApiDefinition: OpenApiDefinition, + modelFileImports: List[Import] = ModelImportContracts.circe.modelFileImports, + frameworkModelImports: List[Import] = ModelImportContracts.circe.frameworkImports("http4s") +) extends OpenApiGenerator { private val merger = SourceMerger(mergeDefBodies = true) private val regenescaGenerator = RegenescaGenerator(merger) @@ -28,16 +33,10 @@ class Http4sGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelImports = List[Import]( - q"import java.time.*", - q"import java.util.UUID", - q"import io.circe.{Codec, Json}", - q"import io.circe.derivation.{Configuration, ConfiguredCodec, ConfiguredEnumCodec}" - ) val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => val namedSchemaName = namedSchemaDef.name.capitalize val modelSources = generateModelSources(namedSchemaDef, None) - val allStmts = modelImports ++ modelSources + val allStmts = modelFileImports ++ modelSources Option.when(modelSources.nonEmpty) { GeneratedFileSource( Paths.get(s"models/${namedSchemaName}.scala"), @@ -200,12 +199,11 @@ class Http4sGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp q"import java.util.UUID", q"import cats.effect.IO", q"import org.http4s.HttpRoutes", - q"import org.http4s.dsl.io._", - q"import org.http4s.circe.CirceEntityCodec.*", { + q"import org.http4s.dsl.io._" + ) ++ frameworkModelImports ++ List[Import]({ val importer = s"${config.basePackage}.models.*".parse[Importer].get q"import ..${List(importer)}" - } - ) + }) List( GeneratedFileSource( Paths.get(s"routes/${controllerName}.scala"), diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index 85aeae1..33d27cc 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -8,7 +8,12 @@ import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException -class SharafGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenApiDefinition) extends OpenApiGenerator { +class SharafGenerator( + config: OpenApiGenerator.Config, + openApiDefinition: OpenApiDefinition, + modelFileImports: List[Import] = ModelImportContracts.tupson.modelFileImports, + frameworkModelImports: List[Import] = ModelImportContracts.tupson.frameworkImports("sharaf") +) extends OpenApiGenerator { private val merger = SourceMerger(mergeDefBodies = true) private val regenescaGenerator = RegenescaGenerator(merger) @@ -28,17 +33,10 @@ class SharafGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelImports = List[Import]( - q"import java.time.*", - q"import java.util.UUID", - q"import org.typelevel.jawn.ast.JValue", - q"import ba.sake.tupson.*", - q"import ba.sake.validson.Validator" - ) val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => val namedSchemaName = namedSchemaDef.name.capitalize val modelSources = generateModelSources(namedSchemaDef, None) - val allStmts = modelImports ++ modelSources + val allStmts = modelFileImports ++ modelSources Option.when(modelSources.nonEmpty) { GeneratedFileSource( Paths.get(s"models/${namedSchemaName}.scala"), @@ -186,11 +184,11 @@ class SharafGenerator(config: OpenApiGenerator.Config, openApiDefinition: OpenAp q"import sttp.model.StatusCode", q"import ba.sake.querson.QueryStringRW", q"import ba.sake.validson.Validator", - q"import ba.sake.sharaf.*, routing.*", { + q"import ba.sake.sharaf.*, routing.*" + ) ++ frameworkModelImports ++ List[Import]({ val importer = s"${config.basePackage}.models.*".parse[Importer].get q"import ..${List(importer)}" - } - ) + }) List( GeneratedFileSource( Paths.get(s"controllers/${controllerName}.scala"), diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index c25879f..305ac28 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -20,6 +20,8 @@ class OpenApiGeneratorSuite extends munit.FunSuite { assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) + val routesFile = readGeneratedFile(baseFolder.resolve("pkg"), "routes/") + assert(routesFile.contains("import org.http4s.circe.CirceEntityCodec.*")) } test("composed generator should support tupson + none") { @@ -54,6 +56,8 @@ class OpenApiGeneratorSuite extends munit.FunSuite { assert(!generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) + val routesFile = readGeneratedFile(baseFolder.resolve("pkg"), "routes/") + assert(!routesFile.contains("import org.http4s.circe.CirceEntityCodec.*")) } test("composed generator should reject none + none") { @@ -113,4 +117,10 @@ class OpenApiGeneratorSuite extends munit.FunSuite { } finally stream.close() } } + + private def readGeneratedFile(base: Path, prefix: String): String = { + val generatedFiles = listScalaFiles(base) + val relative = generatedFiles.find(_.startsWith(prefix)).getOrElse(fail(s"Expected generated file with prefix '$prefix'")) + Files.readString(base.resolve(relative)) + } } From 077cc535ea335adfddb8f677997cfd395a8d467b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:21:35 +0000 Subject: [PATCH 10/25] Clarify explicit compatibility match branch Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 8f8518c..d11013e 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -183,7 +183,7 @@ object OpenApiGenerator { s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." ) - case _ => () + case _ if frameworkBackend.requiredModelFlavor == modelFlavor => () } } } From acb2de831cc3d99daf79cf4130a6c7f38848cc29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:22:35 +0000 Subject: [PATCH 11/25] Simplify compatibility warning logic Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/OpenApiGenerator.scala | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index d11013e..34381d6 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -171,19 +171,17 @@ object OpenApiGenerator { ): Unit = { val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) frameworkBackendOpt.foreach { frameworkBackend => - modelFlavor match { - case ModelFlavor.External => - // with models=none we cannot infer compatibility, so emit explicit external-model warning - System.err.println( - s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference ${config.basePackage}.models types that must already exist." - ) - case _ if frameworkBackend.requiredModelFlavor != modelFlavor => - System.err.println( - s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." - ) - case _ if frameworkBackend.requiredModelFlavor == modelFlavor => () + if (modelFlavor == ModelFlavor.External) { + // with models=none we cannot infer compatibility, so emit explicit external-model warning + System.err.println( + s"WARNING: models=none with framework='${frameworkBackend.id}'. " + + s"Generation will reference ${config.basePackage}.models types that must already exist." + ) + } else if (frameworkBackend.requiredModelFlavor != modelFlavor) { + System.err.println( + s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." + ) } } } From 5c6244b3222a5ec7fe452fe6bf83eda04a277cab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:23:52 +0000 Subject: [PATCH 12/25] Add shared import helper and clarify warnings Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../scala/ba/sake/openapi4s/GenerationImports.scala | 10 ++++++++++ .../scala/ba/sake/openapi4s/OpenApiGenerator.scala | 2 +- .../ba/sake/openapi4s/http4s/Http4sGenerator.scala | 5 +---- .../ba/sake/openapi4s/sharaf/SharafGenerator.scala | 5 +---- .../ba/sake/openapi4s/OpenApiGeneratorSuite.scala | 1 - 5 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala new file mode 100644 index 0000000..dbf3821 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala @@ -0,0 +1,10 @@ +package ba.sake.openapi4s + +import scala.meta.* + +object GenerationImports { + def modelWildcardImport(basePackage: String): Import = { + val importer = s"${basePackage}.models.*".parse[Importer].get + q"import ..${List(importer)}" + } +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 34381d6..630ff9f 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -175,7 +175,7 @@ object OpenApiGenerator { // with models=none we cannot infer compatibility, so emit explicit external-model warning System.err.println( s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference ${config.basePackage}.models types that must already exist." + s"Generation will reference expected model types under ${config.basePackage}.models that must already exist." ) } else if (frameworkBackend.requiredModelFlavor != modelFlavor) { System.err.println( diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index ac59098..e268d59 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -200,10 +200,7 @@ class Http4sGenerator( q"import cats.effect.IO", q"import org.http4s.HttpRoutes", q"import org.http4s.dsl.io._" - ) ++ frameworkModelImports ++ List[Import]({ - val importer = s"${config.basePackage}.models.*".parse[Importer].get - q"import ..${List(importer)}" - }) + ) ++ frameworkModelImports ++ List(GenerationImports.modelWildcardImport(config.basePackage)) List( GeneratedFileSource( Paths.get(s"routes/${controllerName}.scala"), diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index 33d27cc..b6ce495 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -185,10 +185,7 @@ class SharafGenerator( q"import ba.sake.querson.QueryStringRW", q"import ba.sake.validson.Validator", q"import ba.sake.sharaf.*, routing.*" - ) ++ frameworkModelImports ++ List[Import]({ - val importer = s"${config.basePackage}.models.*".parse[Importer].get - q"import ..${List(importer)}" - }) + ) ++ frameworkModelImports ++ List(GenerationImports.modelWildcardImport(config.basePackage)) List( GeneratedFileSource( Paths.get(s"controllers/${controllerName}.scala"), diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index 305ac28..f762d69 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -75,7 +75,6 @@ class OpenApiGeneratorSuite extends munit.FunSuite { } test("composed generator should allow circe + sharaf") { - // This combination intentionally emits a compatibility warning to stderr, but generation should still succeed. val baseFolder = Files.createTempDirectory("openapi4s-circe-sharaf") val config = OpenApiGenerator.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), From 01cda42b5057c2d3ad5ff62e0bf302f68f41fc2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:24:52 +0000 Subject: [PATCH 13/25] Improve compatibility and deprecation warnings Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala | 2 +- .../src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index 40c5f26..f3935e4 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -32,7 +32,7 @@ object OpenApi4sMain { val finalModels = if (models.nonEmpty) models else mappedModels val finalFramework = if (framework.nonEmpty) framework else mappedFramework if (generator.nonEmpty) { - System.err.println("WARNING: '--generator' is deprecated. Prefer '--models' and '--framework'.") + System.err.println("WARNING: '--generator' is deprecated since 0.7.0. Prefer '--models' and '--framework'.") } val openApiGenerator = OpenApiGenerator( config = OpenApiGenerator.Config( diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 630ff9f..8e0b758 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -175,12 +175,14 @@ object OpenApiGenerator { // with models=none we cannot infer compatibility, so emit explicit external-model warning System.err.println( s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference expected model types under ${config.basePackage}.models that must already exist." + s"Generation will reference expected model types under ${config.basePackage}.models that must already exist. " + + s"Ensure those model classes/instances are available or generation output may not compile." ) } else if (frameworkBackend.requiredModelFlavor != modelFlavor) { System.err.println( s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'." + s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'. " + + s"Generated sources may require manual import/type adjustments; prefer matching model/framework flavors when possible." ) } } From 96b7ff281c9222112c8c75854d5b471280fd3f1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:25:35 +0000 Subject: [PATCH 14/25] Decouple model generation from framework IDs Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../src/main/scala/ba/sake/openapi4s/ModelBackends.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala index 4c5b785..020f3cd 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -17,7 +17,7 @@ object ModelBackends { config = config, openApiDefinition = openapiDefinition, modelFileImports = imports.modelFileImports, - frameworkModelImports = imports.frameworkImports("http4s") + frameworkModelImports = List.empty ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) } } @@ -31,7 +31,7 @@ object ModelBackends { config = config, openApiDefinition = openapiDefinition, modelFileImports = imports.modelFileImports, - frameworkModelImports = imports.frameworkImports("sharaf") + frameworkModelImports = List.empty ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) } } From 3a2ae825e4d1ccfb1af8edea647dc29c580dc1ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:26:15 +0000 Subject: [PATCH 15/25] Consolidate legacy generator default mapping Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/1df83749-28d4-4631-9633-fde33c3fd68d Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index f3935e4..5011b25 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -22,9 +22,9 @@ object OpenApi4sMain { @arg(doc = "Base package for generated sources") basePackage: String ) = { + val defaultMapped = ("tupson", "sharaf") val (mappedModels, mappedFramework) = generator.toLowerCase match { - case "" => ("tupson", "sharaf") - case "sharaf" => ("tupson", "sharaf") + case "" | "sharaf" => defaultMapped case "http4s" => ("circe", "http4s") case other => throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'") From 634461b2423af9394cdf2bb75bd8c0787a92bc95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 16:08:15 +0000 Subject: [PATCH 16/25] Extract TupsonModelGenerator and CirceModelGenerator into dedicated packages Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/e97e4d50-5ed6-4527-b2c7-86efd87b177e Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../scala/ba/sake/openapi4s/SchemaUtils.scala | 113 ++++++++ .../openapi4s/circe/CirceModelGenerator.scala | 134 ++++++++++ .../openapi4s/http4s/Http4sGenerator.scala | 249 +----------------- .../openapi4s/sharaf/SharafGenerator.scala | 238 +---------------- .../tupson/TupsonModelGenerator.scala | 124 +++++++++ 5 files changed, 386 insertions(+), 472 deletions(-) create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala new file mode 100644 index 0000000..d090091 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala @@ -0,0 +1,113 @@ +package ba.sake.openapi4s + +import scala.meta.* +import org.apache.commons.text.CaseUtils +import ba.sake.openapi4s.exceptions.UnsupportedSchemaException + +object SchemaUtils { + + def resolveType( + schemaDef: SchemaDefinition, + propertyName: Option[String], + parentTypeName: Option[String], + // e.g. path enum cannot be null.. + allowNullable: Boolean, + context: String, + fallbackAnyType: Type + ): Type = schemaDef match { + case _: SchemaDefinition.Str => t"String" + case _: SchemaDefinition.Password => t"String" + case _: SchemaDefinition.Email => t"String" + case _: SchemaDefinition.Base64Bytes => t"String" // TODO use some kind of newtype.. ? + case _: SchemaDefinition.Int32 => t"Int" + case _: SchemaDefinition.Int64 => t"Long" + case _: SchemaDefinition.Num32 => t"Float" + case _: SchemaDefinition.Num64 => t"Double" + case _: SchemaDefinition.Bool => t"Boolean" + case _: SchemaDefinition.Uuid => t"UUID" + case _: SchemaDefinition.Date => t"LocalDate" + case _: SchemaDefinition.DateTime => t"Instant" + case SchemaDefinition.Opt(tpe) => + val coreTpe = + resolveType(tpe, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) + if (allowNullable) t"Option[${coreTpe}]" + else coreTpe + case arr: SchemaDefinition.Arr => + val coreTpe = + resolveType(arr.schema, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) + if (arr.uniqueItems) t"Set[${coreTpe}]" + else t"Seq[${coreTpe}]" + case SchemaDefinition.Enum(_, _) => + (parentTypeName.zip(propertyName)) match { + case Some((parentType, propName)) => + Type.Name(generateEnumName(parentType, propName)) + case _ => + throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for unnamed 'enum' [${context}]") + } + case SchemaDefinition.Ref(name) => Type.Name(name) + case SchemaDefinition.Named(name, _) => Type.Name(name) + case SchemaDefinition.Obj(_) => + throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'object' [${context}]") + case _: SchemaDefinition.OneOf => + throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'oneOf' [${context}]") + case _: SchemaDefinition.Unknown => fallbackAnyType + } + + def generateEnumName(parentType: String, propName: String): String = { + val camelizedParentType = CaseUtils.toCamelCase(parentType, true, '_') + val camelizedPropName = CaseUtils.toCamelCase(propName, true, '_') + s"${camelizedParentType}${camelizedPropName}" + } + + def generateValidatorStmts(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { + val validationCalls = properties.flatMap { case (name, schema) => + val propName = Term.Name(name) + schema match { + case int: SchemaDefinition.Int32 => + List( + int.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Int(min)) }, + int.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Int(max)) } + ).flatten + case long: SchemaDefinition.Int64 => + List( + long.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Long(min)) }, + long.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Long(max)) } + ).flatten + case float: SchemaDefinition.Num32 => + List( + float.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Float(min)) }, + float.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Float(max)) } + ).flatten + case double: SchemaDefinition.Num64 => + List( + double.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Double(min)) }, + double.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Double(max)) } + ).flatten + case str: SchemaDefinition.Str => + List( + str.minLength.map { min => "minLength" -> List(q"_.${propName}", Lit.Int(min)) }, + str.maxLength.map { max => "maxLength" -> List(q"_.${propName}", Lit.Int(max)) }, + str.pattern.map { pattern => "matches" -> List(q"_.${propName}", Lit.String(pattern)) } + ).flatten + case arr: SchemaDefinition.Arr => + List( + arr.minItems.map { min => "minItems" -> List(q"_.${propName}", Lit.Int(min)) }, + arr.maxItems.map { max => "maxItems" -> List(q"_.${propName}", Lit.Int(max)) } + ).flatten + case _ => List.empty + } + } + Option + .when(validationCalls.nonEmpty) { + val init = q"Validator.derived[${typeName}]" + val body = validationCalls.foldLeft(init: Term) { case (a, (funName, funArgs)) => + Term.Apply( + Term.Select(a, Term.Name(funName)), + Term.ArgClause(funArgs) + ) + } + q"given Validator[${typeName}] = ${body}" + } + .toList + } +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala new file mode 100644 index 0000000..ec4cf0a --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala @@ -0,0 +1,134 @@ +package ba.sake.openapi4s +package circe + +import scala.meta.* +import ba.sake.openapi4s.exceptions.UnsupportedSchemaException + +class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { + + // keep track of done schemas (to avoid generating a subtype multiple times) + private var generatedNamedSchemas = Set.empty[String] + + def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { + val namedSchemaName = namedSchemaDef.name.capitalize + if (generatedNamedSchemas(namedSchemaName)) return List.empty + val typeName = Type.Name(namedSchemaName) + val termName = Term.Name(namedSchemaName) + val generatedModelSources = namedSchemaDef.schema match { + case obj: SchemaDefinition.Obj => + val params = obj.properties.flatMap { property => + try { + val propertyTpe = SchemaUtils.resolveType( + property.schema, + Some(property.name), + Some(namedSchemaName), + allowNullable = true, + context = s"${namedSchemaName}.${property.name}", + fallbackAnyType = t"Json" + ) + Some(param"${Term.Name(property.name)}: ${propertyTpe}") + } catch { + case e: UnsupportedSchemaException => + println(e.toString) + None + } + } + // enums defined in-place, we invent a new name for them.. + val adHocEnums = obj.properties.flatMap { property => + val enumValuesOpt = property.schema match { + case SchemaDefinition.Enum(values, _) => Some(values) + case SchemaDefinition.Opt(SchemaDefinition.Enum(values, _)) => Some(values) + case SchemaDefinition.Arr(SchemaDefinition.Enum(values, _), _, _, _) => Some(values) + case _ => None + } + enumValuesOpt + .map { values => + val adhocEnumName = SchemaUtils.generateEnumName(namedSchemaName, property.name) + val adhocEnumType = Type.Name(adhocEnumName) + val adhocEnumTerm = Term.Name(adhocEnumName) + val enumCaseDefs = Defn.RepeatedEnumCase( + List.empty, + values.map { enumDefCaseValue => + Term.Name(enumDefCaseValue) + } + ) + List( + q" enum ${adhocEnumType} { ${enumCaseDefs} } ", + q""" + object ${adhocEnumTerm} { + given Configuration = Configuration.default + given Codec[${adhocEnumType}] = ConfiguredEnumCodec.derived + } + """ + ) + } + .getOrElse(List.empty) + } + val classDefinition: List[Stat] = superType match { + case Some(st) => + val extendsInit = init"${st}()" + List(q""" + case class ${typeName}( ..${Term.ParamClause(params)} ) extends ${extendsInit} + """) + case None => + List( + q" case class ${typeName}( ..${Term.ParamClause(params)} ) ", + q""" + object ${termName} { + given Configuration = Configuration.default + given Codec[${typeName}] = ConfiguredCodec.derived + } + """ + ) + } + classDefinition ++ adHocEnums + + case enumDef: SchemaDefinition.Enum => + val enumCaseDefs = Defn.RepeatedEnumCase( + List.empty, + enumDef.values.map { enumDefCaseValue => + Term.Name(enumDefCaseValue) + } + ) + List( + q" enum ${typeName} { ${enumCaseDefs} } ", + q""" + object ${termName} { + given Configuration = Configuration.default + given Codec[${typeName}] = ConfiguredEnumCodec.derived + } + """ + ) + case _: SchemaDefinition.Arr => + // TODO type alias ??? + List.empty + case oneOfSchema: SchemaDefinition.OneOf => + val oneOfCases = oneOfSchema.schemas.flatMap { + case SchemaDefinition.Ref(refName) => + openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { + case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) + case None => + println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") + None + } + case other => + println(s"Unsupported oneOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") + None + } + List( + q"sealed trait ${typeName} ", + q""" + object ${termName} { + given Configuration = Configuration.default.withDiscriminator(${Lit.String( + oneOfSchema.discriminatorPropertyName + )}) + given Codec[${typeName}] = ConfiguredCodec.derived + ..${oneOfCases} + } + """ + ) + } + generatedNamedSchemas += namedSchemaName + generatedModelSources + } +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index e268d59..0318c18 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -4,9 +4,9 @@ package http4s import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 -import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException +import ba.sake.openapi4s.circe.CirceModelGenerator class Http4sGenerator( config: OpenApiGenerator.Config, @@ -18,9 +18,6 @@ class Http4sGenerator( private val merger = SourceMerger(mergeDefBodies = true) private val regenescaGenerator = RegenescaGenerator(merger) - // keep track of done schemas (to avoid generating a subtype multiple times) - private var generatedNamedSchemas = Set.empty[String] - override def generate(): Unit = { println(s"Started generating Http4s server for '${config.url}' OpenApi into '${config.baseFolder}' ...") val packagePath = config.basePackage.replaceAll("\\.", "/") @@ -33,9 +30,10 @@ class Http4sGenerator( private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelGenerator = new CirceModelGenerator(openApiDefinition) val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = generateModelSources(namedSchemaDef, None) + val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) val allStmts = modelFileImports ++ modelSources Option.when(modelSources.nonEmpty) { GeneratedFileSource( @@ -66,7 +64,7 @@ class Http4sGenerator( case PathSegment.Literal(value) => Lit.String(value) case PathSegment.Param(name, schema) => val tpe = - resolveType( + SchemaUtils.resolveType( schema, None, None, @@ -146,7 +144,7 @@ class Http4sGenerator( val reqBodyStmts = pathDef.reqBody.flatMap { body => try { val tpe = - resolveType( + SchemaUtils.resolveType( body.schema, None, None, @@ -165,7 +163,7 @@ class Http4sGenerator( val resBodyExpr = pathDef.resBody .flatMap { body => try { - val tpe = resolveType( + val tpe = SchemaUtils.resolveType( body.schema, None, None, @@ -218,241 +216,6 @@ class Http4sGenerator( ) } - private def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { - val namedSchemaName = namedSchemaDef.name.capitalize - if (generatedNamedSchemas(namedSchemaName)) return List.empty - val typeName = Type.Name(namedSchemaName) - val termName = Term.Name(namedSchemaName) - val generatedModelSources = namedSchemaDef.schema match { - case obj: SchemaDefinition.Obj => - val params = obj.properties.flatMap { property => - try { - val propertyTpe = resolveType( - property.schema, - Some(property.name), - Some(namedSchemaName), - allowNullable = true, - context = s"${namedSchemaName}.${property.name}", - fallbackAnyType = t"Json" - ) - Some(param"${Term.Name(property.name)}: ${propertyTpe}") - } catch { - case e: UnsupportedSchemaException => - println(e.toString) - None - } - } - // enums defined in-place, we invent a new name for them.. - val adHocEnums = obj.properties.flatMap { property => - val enumValuesOpt = property.schema match { - case SchemaDefinition.Enum(values, _) => Some(values) - case SchemaDefinition.Opt(SchemaDefinition.Enum(values, _)) => Some(values) - case SchemaDefinition.Arr(SchemaDefinition.Enum(values, _), _, _, _) => Some(values) - case _ => None - } - enumValuesOpt - .map { values => - val adhocEnumName = generateEnumName(namedSchemaName, property.name) - val adhocEnumType = Type.Name(adhocEnumName) - val adhocEnumTerm = Term.Name(adhocEnumName) - val enumCaseDefs = Defn.RepeatedEnumCase( - List.empty, - values.map { enumDefCaseValue => - Term.Name(enumDefCaseValue) - } - ) - List( - q" enum ${adhocEnumType} { ${enumCaseDefs} } ", - q""" - object ${adhocEnumTerm} { - given Configuration = Configuration.default - given Codec[${adhocEnumType}] = ConfiguredEnumCodec.derived - } - """ - ) - } - .getOrElse(List.empty) - } - // validation - val validatorStmts = - List.empty[Stat] // generateValidatorStmts(typeName, obj.properties.map(p => (p.name, p.schema))) - val classDefinition: List[Stat] = superType match { - case Some(st) => - val extendsInit = init"${st}()" - List(q""" - case class ${typeName}( ..${Term.ParamClause(params)} ) extends ${extendsInit} - """) - case None => - List( - q" case class ${typeName}( ..${Term.ParamClause(params)} ) ", - q""" - object ${termName} { - given Configuration = Configuration.default - given Codec[${typeName}] = ConfiguredCodec.derived - } - """ - ) - } - val modelDefStats = classDefinition ++ - Option.when(validatorStmts.nonEmpty)(q""" object ${termName} { ..${validatorStmts} } """).toList - modelDefStats ++ adHocEnums - - case enumDef: SchemaDefinition.Enum => - val enumCaseDefs = Defn.RepeatedEnumCase( - List.empty, - enumDef.values.map { enumDefCaseValue => - Term.Name(enumDefCaseValue) - } - ) - List( - q" enum ${typeName} { ${enumCaseDefs} } ", - q""" - object ${termName} { - given Configuration = Configuration.default - given Codec[${typeName}] = ConfiguredEnumCodec.derived - } - """ - ) - case _: SchemaDefinition.Arr => - // TODO type alias ??? - List.empty - case oneOfSchema: SchemaDefinition.OneOf => - val oneOfCases = oneOfSchema.schemas.flatMap { - case SchemaDefinition.Ref(refName) => - openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { - case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) - case None => - println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") - None - } - case other => - println(s"Unsupported oneOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") - None - } - List( - q"sealed trait ${typeName} ", - q""" - object ${termName} { - given Configuration = Configuration.default.withDiscriminator(${Lit.String( - oneOfSchema.discriminatorPropertyName - )}) - given Codec[${typeName}] = ConfiguredCodec.derived - ..${oneOfCases} - } - """ - ) - } - generatedNamedSchemas += namedSchemaName - generatedModelSources - } - - private def resolveType( - schemaDef: SchemaDefinition, - propertyName: Option[String], - parentTypeName: Option[String], - // e.g. path enum cannot be null.. - allowNullable: Boolean, - context: String, - fallbackAnyType: Type - ): Type = schemaDef match { - case _: SchemaDefinition.Str => t"String" - case _: SchemaDefinition.Password => t"String" - case _: SchemaDefinition.Email => t"String" - case _: SchemaDefinition.Base64Bytes => t"String" // TODO use some kind of newtype.. ? - case _: SchemaDefinition.Int32 => t"Int" - case _: SchemaDefinition.Int64 => t"Long" - case _: SchemaDefinition.Num32 => t"Float" - case _: SchemaDefinition.Num64 => t"Double" - case _: SchemaDefinition.Bool => t"Boolean" - case _: SchemaDefinition.Uuid => t"UUID" - case _: SchemaDefinition.Date => t"LocalDate" - case _: SchemaDefinition.DateTime => t"Instant" - case SchemaDefinition.Opt(tpe) => - val coreTpe = - resolveType(tpe, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) - if (allowNullable) t"Option[${coreTpe}]" - else coreTpe - case arr: SchemaDefinition.Arr => - val coreTpe = - resolveType(arr.schema, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) - if (arr.uniqueItems) t"Set[${coreTpe}]" - else t"Seq[${coreTpe}]" - case SchemaDefinition.Enum(_, _) => - (parentTypeName.zip(propertyName)) match { - case Some((parentType, propName)) => - Type.Name(generateEnumName(parentType, propName)) - case _ => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for unnamed 'enum' [${context}]") - } - case SchemaDefinition.Ref(name) => Type.Name(name) - case SchemaDefinition.Named(name, _) => Type.Name(name) - case SchemaDefinition.Obj(_) => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'object' [${context}]") - case _: SchemaDefinition.OneOf => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'oneOf' [${context}]") - case _: SchemaDefinition.Unknown => fallbackAnyType - } - - // TODO use iron for refined types or smh - /* - private def generateValidatorStmts(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { - val validationCalls = properties.flatMap { case (name, schema) => - val propName = Term.Name(name) - schema match { - case int: SchemaDefinition.Int32 => - List( - int.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Int(min)) }, - int.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case long: SchemaDefinition.Int64 => - List( - long.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Long(min)) }, - long.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Long(max)) } - ).flatten - case float: SchemaDefinition.Num32 => - List( - float.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Float(min)) }, - float.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Float(max)) } - ).flatten - case double: SchemaDefinition.Num64 => - List( - double.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Double(min)) }, - double.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Double(max)) } - ).flatten - case str: SchemaDefinition.Str => - List( - str.minLength.map { min => "minLength" -> List(q"_.${propName}", Lit.Int(min)) }, - str.maxLength.map { max => "maxLength" -> List(q"_.${propName}", Lit.Int(max)) }, - str.pattern.map { pattern => "matches" -> List(q"_.${propName}", Lit.String(pattern)) } - ).flatten - case arr: SchemaDefinition.Arr => - List( - arr.minItems.map { min => "minItems" -> List(q"_.${propName}", Lit.Int(min)) }, - arr.maxItems.map { max => "maxItems" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case _ => List.empty - } - } - Option - .when(validationCalls.nonEmpty) { - val init = q"Validator.derived[${typeName}]" - val body = validationCalls.foldLeft(init: Term) { case (a, (funName, funArgs)) => - Term.Apply( - Term.Select(a, Term.Name(funName)), - Term.ArgClause(funArgs) - ) - } - q"given Validator[${typeName}] = ${body}" - } - .toList - }*/ - - private def generateEnumName(parentType: String, propName: String): String = { - val camelizedParentType = CaseUtils.toCamelCase(parentType, true, '_') - val camelizedPropName = CaseUtils.toCamelCase(propName, true, '_') - s"${camelizedParentType}${camelizedPropName}" - } - private def generatePkgSelect(pkg: String) = { val packageComponents = pkg.split("\\.").toList val firstSelect = q"${Term.Name(packageComponents(0))}.${Term.Name(packageComponents(1))}" diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index b6ce495..532e6d9 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -4,9 +4,9 @@ package sharaf import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 -import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException +import ba.sake.openapi4s.tupson.TupsonModelGenerator class SharafGenerator( config: OpenApiGenerator.Config, @@ -18,9 +18,6 @@ class SharafGenerator( private val merger = SourceMerger(mergeDefBodies = true) private val regenescaGenerator = RegenescaGenerator(merger) - // keep track of done schemas (to avoid generating a subtype multiple times) - private var generatedNamedSchemas = Set.empty[String] - override def generate(): Unit = { println(s"Started generating Sharaf server for '${config.url}' OpenApi into '${config.baseFolder}' ...") val packagePath = config.basePackage.replaceAll("\\.", "/") @@ -33,9 +30,10 @@ class SharafGenerator( private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelGenerator = new TupsonModelGenerator(openApiDefinition) val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = generateModelSources(namedSchemaDef, None) + val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) val allStmts = modelFileImports ++ modelSources Option.when(modelSources.nonEmpty) { GeneratedFileSource( @@ -69,7 +67,7 @@ class SharafGenerator( case PathSegment.Literal(value) => Lit.String(value) case PathSegment.Param(name, schema) => val tpe = - resolveType( + SchemaUtils.resolveType( schema, None, None, @@ -89,7 +87,7 @@ class SharafGenerator( .when(pathDef.queryParams.nonEmpty) { val (qpParams, adhocEnums) = pathDef.queryParams.flatMap { qp => val adhocEnumOpt = Option.when(qp.schema.isInstanceOf[SchemaDefinition.Enum]) { - val adhocEnumName = generateEnumName("QP", qp.name) + val adhocEnumName = SchemaUtils.generateEnumName("QP", qp.name) val adhocEnumType = Type.Name(adhocEnumName) val enumCaseDefs = Defn.RepeatedEnumCase( List.empty, @@ -103,7 +101,7 @@ class SharafGenerator( } val qpName = Name(qp.name) try { - val tpe = resolveType( + val tpe = SchemaUtils.resolveType( qp.schema, Some(qp.name), Some("QP"), @@ -122,7 +120,7 @@ class SharafGenerator( // validation // TODO figure out how to validate Option-al nicely val validatedQPs = pathDef.queryParams.filter(_.required).map(qp => (qp.name, qp.schema)) - val validatorStmts = generateValidatorStmts(t"QP", validatedQPs) + val validatorStmts = SchemaUtils.generateValidatorStmts(t"QP", validatedQPs) adhocEnums.flatten ++ List(q"case class QP(..${qpParams}) derives QueryStringRW") ++ Option.when(validatorStmts.nonEmpty)(q""" object QP { ..${validatorStmts} } """).toList ++ @@ -134,7 +132,7 @@ class SharafGenerator( val reqBodyStmts = pathDef.reqBody.flatMap { body => try { val tpe = - resolveType( + SchemaUtils.resolveType( body.schema, None, None, @@ -153,7 +151,7 @@ class SharafGenerator( val resBodyExpr = pathDef.resBody .flatMap { body => try { - val tpe = resolveType( + val tpe = SchemaUtils.resolveType( body.schema, None, None, @@ -203,224 +201,6 @@ class SharafGenerator( ) } - private def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { - val namedSchemaName = namedSchemaDef.name.capitalize - if (generatedNamedSchemas(namedSchemaName)) return List.empty - val typeName = Type.Name(namedSchemaName) - val termName = Term.Name(namedSchemaName) - val generatedModelSources = namedSchemaDef.schema match { - case obj: SchemaDefinition.Obj => - val params = obj.properties.flatMap { property => - try { - val propertyTpe = resolveType( - property.schema, - Some(property.name), - Some(namedSchemaName), - allowNullable = true, - context = s"${namedSchemaName}.${property.name}", - fallbackAnyType = t"JValue" - ) - Some(param"${Term.Name(property.name)}: ${propertyTpe}") - } catch { - case e: UnsupportedSchemaException => - println(e.toString) - None - } - } - // enums defined in-place, we invent a new name for them.. - val adHocEnums = obj.properties.flatMap { property => - val enumValuesOpt = property.schema match { - case SchemaDefinition.Enum(values, _) => Some(values) - case SchemaDefinition.Opt(SchemaDefinition.Enum(values, _)) => Some(values) - case SchemaDefinition.Arr(SchemaDefinition.Enum(values, _), _, _, _) => Some(values) - case _ => None - } - enumValuesOpt.flatMap { values => - val adhocEnumName = generateEnumName(namedSchemaName, property.name) - val adhocEnumType = Type.Name(adhocEnumName) - val enumCaseDefs = Defn.RepeatedEnumCase( - List.empty, - values.map { enumDefCaseValue => - Term.Name(enumDefCaseValue) - } - ) - Some( - q""" enum ${adhocEnumType} derives JsonRW { ${enumCaseDefs} }""" - ) - } - } - // validation - val validatorStmts = generateValidatorStmts(typeName, obj.properties.map(p => (p.name, p.schema))) - val classDefinition = superType match { - case Some(st) => - val extendsInit = init"${st}()" - q""" case class ${typeName}( ..${Term.ParamClause(params)} ) extends ${extendsInit}""" - case None => q""" case class ${typeName}( ..${Term.ParamClause(params)} ) derives JsonRW """ - } - val modelDefStats = List(classDefinition) ++ - Option.when(validatorStmts.nonEmpty)(q""" object ${termName} { ..${validatorStmts} } """).toList - - modelDefStats ++ adHocEnums - case enumDef: SchemaDefinition.Enum => - val enumCaseDefs = Defn.RepeatedEnumCase( - List.empty, - enumDef.values.map { enumDefCaseValue => - Term.Name(enumDefCaseValue) - } - ) - List( - q"""enum ${typeName} derives JsonRW { ${enumCaseDefs} } """ - ) - case _: SchemaDefinition.Arr => - // TODO type alias ??? - List.empty - case oneOfSchema: SchemaDefinition.OneOf => - val oneOfCases = oneOfSchema.schemas.flatMap { - case SchemaDefinition.Ref(refName) => - openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { - case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) - case None => - println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") - None - } - case other => - println(s"Unsupported oneOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") - None - } - List( - q""" - @discriminator(${Lit.String(oneOfSchema.discriminatorPropertyName)}) - sealed trait ${typeName} derives JsonRW - """, - q""" object ${termName} { ..${oneOfCases} } """ - ) - case allOfSchema: SchemaDefinition.AllOf => - val allOfCases: List[SchemaDefinition] = allOfSchema.schemas.flatMap { - case SchemaDefinition.Ref(refName) => - openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName).map(_.schema) - case obj: SchemaDefinition.Obj => Some(obj) - case other => - println(s"Unsupported allOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") - None - } - val mergedSchemasProps: List[SchemaProperty] = allOfCases.flatMap { - case SchemaDefinition.Obj(props) => props - case other => - println(s"Unsupported allOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") - List.empty - } - - generateModelSources(SchemaDefinition.Named(namedSchemaName, SchemaDefinition.Obj(mergedSchemasProps)), superType) - } - generatedNamedSchemas += namedSchemaName - generatedModelSources - } - - private def resolveType( - schemaDef: SchemaDefinition, - propertyName: Option[String], - parentTypeName: Option[String], - // e.g. path enum cannot be null.. - allowNullable: Boolean, - context: String, - fallbackAnyType: Type - ): Type = schemaDef match { - case _: SchemaDefinition.Str => t"String" - case _: SchemaDefinition.Password => t"String" - case _: SchemaDefinition.Email => t"String" - case _: SchemaDefinition.Base64Bytes => t"String" // TODO use some kind of newtype.. ? - case _: SchemaDefinition.Int32 => t"Int" - case _: SchemaDefinition.Int64 => t"Long" - case _: SchemaDefinition.Num32 => t"Float" - case _: SchemaDefinition.Num64 => t"Double" - case _: SchemaDefinition.Bool => t"Boolean" - case _: SchemaDefinition.Uuid => t"UUID" - case _: SchemaDefinition.Date => t"LocalDate" - case _: SchemaDefinition.DateTime => t"Instant" - case SchemaDefinition.Opt(tpe) => - val coreTpe = - resolveType(tpe, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) - if (allowNullable) t"Option[${coreTpe}]" - else coreTpe - case arr: SchemaDefinition.Arr => - val coreTpe = - resolveType(arr.schema, propertyName, parentTypeName, allowNullable = allowNullable, context, fallbackAnyType) - if (arr.uniqueItems) t"Set[${coreTpe}]" - else t"Seq[${coreTpe}]" - case SchemaDefinition.Enum(_, _) => - (parentTypeName.zip(propertyName)) match { - case Some((parentType, propName)) => - Type.Name(generateEnumName(parentType, propName)) - case _ => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for unnamed 'enum' [${context}]") - } - case SchemaDefinition.Ref(name) => Type.Name(name) - case SchemaDefinition.Named(name, _) => Type.Name(name) - case SchemaDefinition.Obj(_) => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'object' [${context}]") - case _: SchemaDefinition.OneOf => - throw new UnsupportedSchemaException(s"Cannot make up an ad hoc type for 'oneOf' [${context}]") - case _: SchemaDefinition.Unknown => fallbackAnyType - } - - private def generateValidatorStmts(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { - val validationCalls = properties.flatMap { case (name, schema) => - val propName = Term.Name(name) - schema match { - case int: SchemaDefinition.Int32 => - List( - int.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Int(min)) }, - int.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case long: SchemaDefinition.Int64 => - List( - long.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Long(min)) }, - long.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Long(max)) } - ).flatten - case float: SchemaDefinition.Num32 => - List( - float.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Float(min)) }, - float.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Float(max)) } - ).flatten - case double: SchemaDefinition.Num64 => - List( - double.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Double(min)) }, - double.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Double(max)) } - ).flatten - case str: SchemaDefinition.Str => - List( - str.minLength.map { min => "minLength" -> List(q"_.${propName}", Lit.Int(min)) }, - str.maxLength.map { max => "maxLength" -> List(q"_.${propName}", Lit.Int(max)) }, - str.pattern.map { pattern => "matches" -> List(q"_.${propName}", Lit.String(pattern)) } - ).flatten - case arr: SchemaDefinition.Arr => - List( - arr.minItems.map { min => "minItems" -> List(q"_.${propName}", Lit.Int(min)) }, - arr.maxItems.map { max => "maxItems" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case _ => List.empty - } - } - Option - .when(validationCalls.nonEmpty) { - val init = q"Validator.derived[${typeName}]" - val body = validationCalls.foldLeft(init: Term) { case (a, (funName, funArgs)) => - Term.Apply( - Term.Select(a, Term.Name(funName)), - Term.ArgClause(funArgs) - ) - } - q"given Validator[${typeName}] = ${body}" - } - .toList - } - - private def generateEnumName(parentType: String, propName: String): String = { - val camelizedParentType = CaseUtils.toCamelCase(parentType, true, '_') - val camelizedPropName = CaseUtils.toCamelCase(propName, true, '_') - s"${camelizedParentType}${camelizedPropName}" - } - private def generatePkgSelect(pkg: String) = { val packageComponents = pkg.split("\\.").toList val firstSelect = q"${Term.Name(packageComponents(0))}.${Term.Name(packageComponents(1))}" diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala new file mode 100644 index 0000000..02f945c --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -0,0 +1,124 @@ +package ba.sake.openapi4s +package tupson + +import scala.meta.* +import ba.sake.openapi4s.exceptions.UnsupportedSchemaException + +class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { + + // keep track of done schemas (to avoid generating a subtype multiple times) + private var generatedNamedSchemas = Set.empty[String] + + def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { + val namedSchemaName = namedSchemaDef.name.capitalize + if (generatedNamedSchemas(namedSchemaName)) return List.empty + val typeName = Type.Name(namedSchemaName) + val termName = Term.Name(namedSchemaName) + val generatedModelSources = namedSchemaDef.schema match { + case obj: SchemaDefinition.Obj => + val params = obj.properties.flatMap { property => + try { + val propertyTpe = SchemaUtils.resolveType( + property.schema, + Some(property.name), + Some(namedSchemaName), + allowNullable = true, + context = s"${namedSchemaName}.${property.name}", + fallbackAnyType = t"JValue" + ) + Some(param"${Term.Name(property.name)}: ${propertyTpe}") + } catch { + case e: UnsupportedSchemaException => + println(e.toString) + None + } + } + // enums defined in-place, we invent a new name for them.. + val adHocEnums = obj.properties.flatMap { property => + val enumValuesOpt = property.schema match { + case SchemaDefinition.Enum(values, _) => Some(values) + case SchemaDefinition.Opt(SchemaDefinition.Enum(values, _)) => Some(values) + case SchemaDefinition.Arr(SchemaDefinition.Enum(values, _), _, _, _) => Some(values) + case _ => None + } + enumValuesOpt.flatMap { values => + val adhocEnumName = SchemaUtils.generateEnumName(namedSchemaName, property.name) + val adhocEnumType = Type.Name(adhocEnumName) + val enumCaseDefs = Defn.RepeatedEnumCase( + List.empty, + values.map { enumDefCaseValue => + Term.Name(enumDefCaseValue) + } + ) + Some( + q""" enum ${adhocEnumType} derives JsonRW { ${enumCaseDefs} }""" + ) + } + } + // validation + val validatorStmts = SchemaUtils.generateValidatorStmts(typeName, obj.properties.map(p => (p.name, p.schema))) + val classDefinition = superType match { + case Some(st) => + val extendsInit = init"${st}()" + q""" case class ${typeName}( ..${Term.ParamClause(params)} ) extends ${extendsInit}""" + case None => q""" case class ${typeName}( ..${Term.ParamClause(params)} ) derives JsonRW """ + } + val modelDefStats = List(classDefinition) ++ + Option.when(validatorStmts.nonEmpty)(q""" object ${termName} { ..${validatorStmts} } """).toList + + modelDefStats ++ adHocEnums + case enumDef: SchemaDefinition.Enum => + val enumCaseDefs = Defn.RepeatedEnumCase( + List.empty, + enumDef.values.map { enumDefCaseValue => + Term.Name(enumDefCaseValue) + } + ) + List( + q"""enum ${typeName} derives JsonRW { ${enumCaseDefs} } """ + ) + case _: SchemaDefinition.Arr => + // TODO type alias ??? + List.empty + case oneOfSchema: SchemaDefinition.OneOf => + val oneOfCases = oneOfSchema.schemas.flatMap { + case SchemaDefinition.Ref(refName) => + openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { + case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) + case None => + println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") + None + } + case other => + println(s"Unsupported oneOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") + None + } + List( + q""" + @discriminator(${Lit.String(oneOfSchema.discriminatorPropertyName)}) + sealed trait ${typeName} derives JsonRW + """, + q""" object ${termName} { ..${oneOfCases} } """ + ) + case allOfSchema: SchemaDefinition.AllOf => + val allOfCases: List[SchemaDefinition] = allOfSchema.schemas.flatMap { + case SchemaDefinition.Ref(refName) => + openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName).map(_.schema) + case obj: SchemaDefinition.Obj => Some(obj) + case other => + println(s"Unsupported allOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") + None + } + val mergedSchemasProps: List[SchemaProperty] = allOfCases.flatMap { + case SchemaDefinition.Obj(props) => props + case other => + println(s"Unsupported allOf sub-schema type: '${other.getClass}' [${namedSchemaName}]") + List.empty + } + + generateModelSources(SchemaDefinition.Named(namedSchemaName, SchemaDefinition.Obj(mergedSchemasProps)), superType) + } + generatedNamedSchemas += namedSchemaName + generatedModelSources + } +} From a1a0d26e422498ce136549a5b823d09701cadb5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 16:48:59 +0000 Subject: [PATCH 17/25] Fix Scala 2 wildcard import syntax in model generator extraction Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/6fc119e2-f6fd-4bdc-bb35-bf5809b291a4 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../src/main/scala/ba/sake/openapi4s/GenerationImports.scala | 2 +- .../src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala | 2 +- openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala | 2 +- .../scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala | 2 +- .../scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala index dbf3821..166feef 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala @@ -1,6 +1,6 @@ package ba.sake.openapi4s -import scala.meta.* +import scala.meta._ object GenerationImports { def modelWildcardImport(basePackage: String): Import = { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala index a308327..acfcd1d 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala @@ -1,6 +1,6 @@ package ba.sake.openapi4s -import scala.meta.* +import scala.meta._ case class ModelImportContract( modelFileImports: List[Import], diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala index d090091..79e1767 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala @@ -1,6 +1,6 @@ package ba.sake.openapi4s -import scala.meta.* +import scala.meta._ import org.apache.commons.text.CaseUtils import ba.sake.openapi4s.exceptions.UnsupportedSchemaException diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala index ec4cf0a..84d1843 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala @@ -1,7 +1,7 @@ package ba.sake.openapi4s package circe -import scala.meta.* +import scala.meta._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala index 02f945c..9db5f81 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -1,7 +1,7 @@ package ba.sake.openapi4s package tupson -import scala.meta.* +import scala.meta._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { From c0827f8cd3ae9963830f5b42d4df55767300b26e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 20:33:55 +0000 Subject: [PATCH 18/25] Fix remaining Scala 2 wildcard imports and add Scala34 dialect for GenerationImports Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/58840290-62d5-4069-8c16-7a4c5532d211 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../src/main/scala/ba/sake/openapi4s/GenerationImports.scala | 1 + openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala | 2 +- .../test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala index 166feef..7036447 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala @@ -1,6 +1,7 @@ package ba.sake.openapi4s import scala.meta._ +import scala.meta.dialects.Scala34 object GenerationImports { def modelWildcardImport(basePackage: String): Import = { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala index 020f3cd..7e589ab 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -1,6 +1,6 @@ package ba.sake.openapi4s -import ba.sake.openapi4s.OpenApiGenerator.* +import ba.sake.openapi4s.OpenApiGenerator._ import ba.sake.openapi4s.http4s.Http4sGenerator import ba.sake.openapi4s.sharaf.SharafGenerator import ba.sake.regenesca.GeneratedFileSource diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index f762d69..5dcff51 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -1,7 +1,7 @@ package ba.sake.openapi4s import java.nio.file.{Files, Path, Paths} -import scala.jdk.CollectionConverters.* +import scala.jdk.CollectionConverters._ class OpenApiGeneratorSuite extends munit.FunSuite { From 634de85364e2dacd5068601b7ca7e721ec2d8a24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:01:51 +0000 Subject: [PATCH 19/25] Fix missing Scala34 dialect and CaseUtils imports in extracted generator classes Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/6947431e-12e2-4271-9ac1-274de977b3f7 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala | 1 + .../main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala | 1 + .../main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala | 1 + .../main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala | 1 + .../scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala | 1 + 5 files changed, 5 insertions(+) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala index 79e1767..2267c0c 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala @@ -1,6 +1,7 @@ package ba.sake.openapi4s import scala.meta._ +import scala.meta.dialects.Scala34 import org.apache.commons.text.CaseUtils import ba.sake.openapi4s.exceptions.UnsupportedSchemaException diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala index 84d1843..7a85597 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala @@ -2,6 +2,7 @@ package ba.sake.openapi4s package circe import scala.meta._ +import scala.meta.dialects.Scala34 import ba.sake.openapi4s.exceptions.UnsupportedSchemaException class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index 0318c18..172979a 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -4,6 +4,7 @@ package http4s import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 +import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException import ba.sake.openapi4s.circe.CirceModelGenerator diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index 532e6d9..a411fd5 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -4,6 +4,7 @@ package sharaf import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 +import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException import ba.sake.openapi4s.tupson.TupsonModelGenerator diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala index 9db5f81..c623ee0 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -2,6 +2,7 @@ package ba.sake.openapi4s package tupson import scala.meta._ +import scala.meta.dialects.Scala34 import ba.sake.openapi4s.exceptions.UnsupportedSchemaException class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { From 1ef7b988222a1c52597ca1e1821c77c5b90ef32c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:14:02 +0000 Subject: [PATCH 20/25] Rename validator helper to validson-specific name Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/fe2d2faf-85c0-4666-83a9-ba7e00f991c5 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala | 2 +- .../main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala | 2 +- .../main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala | 2 +- .../scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala index 2267c0c..66842b3 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala @@ -60,7 +60,7 @@ object SchemaUtils { s"${camelizedParentType}${camelizedPropName}" } - def generateValidatorStmts(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { + def generateValidsonStms(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { val validationCalls = properties.flatMap { case (name, schema) => val propName = Term.Name(name) schema match { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index 172979a..a738537 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -133,7 +133,7 @@ class Http4sGenerator( // validation // TODO figure out how to validate Option-al nicely val validatedQPs = pathDef.queryParams.filter(_.required).map(qp => (qp.name, qp.schema)) - val validatorStmts = generateValidatorStmts(t"QP", validatedQPs) + val validatorStmts = SchemaUtils.generateValidsonStms(t"QP", validatedQPs) adhocEnums.flatten ++ List(q"case class QP(..${qpParams}) derives QueryStringRW") ++ Option.when(validatorStmts.nonEmpty)(q""" object QP { ..${validatorStmts} } """).toList ++ diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index a411fd5..12f9f63 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -121,7 +121,7 @@ class SharafGenerator( // validation // TODO figure out how to validate Option-al nicely val validatedQPs = pathDef.queryParams.filter(_.required).map(qp => (qp.name, qp.schema)) - val validatorStmts = SchemaUtils.generateValidatorStmts(t"QP", validatedQPs) + val validatorStmts = SchemaUtils.generateValidsonStms(t"QP", validatedQPs) adhocEnums.flatten ++ List(q"case class QP(..${qpParams}) derives QueryStringRW") ++ Option.when(validatorStmts.nonEmpty)(q""" object QP { ..${validatorStmts} } """).toList ++ diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala index c623ee0..f6199dd 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -57,7 +57,7 @@ class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { } } // validation - val validatorStmts = SchemaUtils.generateValidatorStmts(typeName, obj.properties.map(p => (p.name, p.schema))) + val validatorStmts = SchemaUtils.generateValidsonStms(typeName, obj.properties.map(p => (p.name, p.schema))) val classDefinition = superType match { case Some(st) => val extendsInit = init"${st}()" From 84420393ccfd58985aa594837f66c51ea808277f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:17:59 +0000 Subject: [PATCH 21/25] Use dedicated model generators in ModelBackends Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/793acd58-81f8-4cdb-bd1d-acd6a82fa789 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/ModelBackends.scala | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala index 7e589ab..7feb948 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -1,24 +1,38 @@ package ba.sake.openapi4s +import java.nio.file.Paths +import scala.meta._ import ba.sake.openapi4s.OpenApiGenerator._ -import ba.sake.openapi4s.http4s.Http4sGenerator -import ba.sake.openapi4s.sharaf.SharafGenerator +import ba.sake.openapi4s.circe.CirceModelGenerator +import ba.sake.openapi4s.tupson.TupsonModelGenerator import ba.sake.regenesca.GeneratedFileSource object ModelBackends { - private val ModelsPrefix = "models/" + private def generatePkgSelect(pkg: String): Term.Ref = { + pkg + .split("\\.") + .map(Term.Name) + .reduceLeft[Term.Ref](Term.Select(_, _)) + } val circe: ModelBackend = new ModelBackend { override val id: String = "circe" override val flavor: ModelFlavor = ModelFlavor.Circe override val imports: ModelImportContract = ModelImportContracts.circe override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new Http4sGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = imports.modelFileImports, - frameworkModelImports = List.empty - ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) + val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelGenerator = new CirceModelGenerator(openapiDefinition) + openapiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => + val namedSchemaName = namedSchemaDef.name.capitalize + val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) + val allStmts = imports.modelFileImports ++ modelSources + Option.when(modelSources.nonEmpty) { + GeneratedFileSource( + Paths.get(s"models/${namedSchemaName}.scala"), + source""" package ${modelsPkg} { ..${allStmts} } """ + ) + } + } } } @@ -27,12 +41,22 @@ object ModelBackends { override val flavor: ModelFlavor = ModelFlavor.Tupson override val imports: ModelImportContract = ModelImportContracts.tupson override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - new SharafGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = imports.modelFileImports, - frameworkModelImports = List.empty - ).generateSources.filter(_.file.toString.startsWith(ModelsPrefix)) + val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelGenerator = new TupsonModelGenerator(openapiDefinition) + openapiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => + val namedSchemaName = namedSchemaDef.name.capitalize + val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) + val allStmts = imports.modelFileImports ++ modelSources + Option.when(modelSources.nonEmpty) { + GeneratedFileSource( + Paths.get(s"models/${namedSchemaName}.scala"), + source""" + // generated with OpenApi4s + package ${modelsPkg} { ..${allStmts} } + """ + ) + } + } } } From 727115f8dacb680b825c6d68b8cf0c11ffcb017a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:23:08 +0000 Subject: [PATCH 22/25] Fix Scala Meta dialect scope in ModelBackends Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/7508145f-806f-4375-b3ea-aa47f16ec20f Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala index 7feb948..37d5218 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -2,12 +2,15 @@ package ba.sake.openapi4s import java.nio.file.Paths import scala.meta._ +import scala.meta.dialects.Scala34 import ba.sake.openapi4s.OpenApiGenerator._ import ba.sake.openapi4s.circe.CirceModelGenerator import ba.sake.openapi4s.tupson.TupsonModelGenerator import ba.sake.regenesca.GeneratedFileSource object ModelBackends { + private given Dialect = Scala34 + private def generatePkgSelect(pkg: String): Term.Ref = { pkg .split("\\.") From 898ab81ac0b0992237e867039f6316f994265c8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:29:10 +0000 Subject: [PATCH 23/25] Fix remaining compile issues and add FrameworkBackends object Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/2f79d8f3-5325-4007-bf9d-621c39579696 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/FrameworkBackends.scala | 49 +++++++++++++++++++ .../ba/sake/openapi4s/GenerationImports.scala | 2 + .../ba/sake/openapi4s/OpenApiGenerator.scala | 44 +---------------- .../openapi4s/circe/CirceModelGenerator.scala | 2 +- .../tupson/TupsonModelGenerator.scala | 2 +- 5 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala new file mode 100644 index 0000000..0007e21 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala @@ -0,0 +1,49 @@ +package ba.sake.openapi4s + +import ba.sake.openapi4s.OpenApiGenerator._ +import ba.sake.openapi4s.http4s.Http4sGenerator +import ba.sake.openapi4s.sharaf.SharafGenerator + +object FrameworkBackends { + private val RoutesPrefix = "routes/" + private val ControllersPrefix = "controllers/" + + val http4s: FrameworkBackend = new FrameworkBackend { + override val id: String = "http4s" + override val requiredModelFlavor: ModelFlavor = ModelFlavor.Circe + override def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[ba.sake.regenesca.GeneratedFileSource] = { + new Http4sGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = modelContract.imports.modelFileImports, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) + } + } + + val sharaf: FrameworkBackend = new FrameworkBackend { + override val id: String = "sharaf" + override val requiredModelFlavor: ModelFlavor = ModelFlavor.Tupson + override def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[ba.sake.regenesca.GeneratedFileSource] = { + new SharafGenerator( + config = config, + openApiDefinition = openapiDefinition, + modelFileImports = modelContract.imports.modelFileImports, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) + } + } + + val byId: Map[String, FrameworkBackend] = Map( + "http4s" -> http4s, + "sharaf" -> sharaf + ) +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala index 7036447..b477361 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala @@ -4,6 +4,8 @@ import scala.meta._ import scala.meta.dialects.Scala34 object GenerationImports { + private given Dialect = Scala34 + def modelWildcardImport(basePackage: String): Import = { val importer = s"${basePackage}.models.*".parse[Importer].get q"import ..${List(importer)}" diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 8e0b758..152b7f2 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -1,8 +1,6 @@ package ba.sake.openapi4s import java.nio.file.Path -import ba.sake.openapi4s.http4s.Http4sGenerator -import ba.sake.openapi4s.sharaf.SharafGenerator import ba.sake.regenesca.{GeneratedFileSource, RegenescaGenerator, SourceMerger} trait OpenApiGenerator { @@ -10,9 +8,6 @@ trait OpenApiGenerator { } object OpenApiGenerator { - private val RoutesPrefix = "routes/" - private val ControllersPrefix = "controllers/" - sealed trait ModelFlavor object ModelFlavor { case object Circe extends ModelFlavor @@ -50,45 +45,8 @@ object OpenApiGenerator { ): Seq[GeneratedFileSource] } - private object Http4sFrameworkBackend extends FrameworkBackend { - override val id: String = "http4s" - override val requiredModelFlavor: ModelFlavor = ModelFlavor.Circe - override def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] = { - new Http4sGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = modelContract.imports.modelFileImports, - frameworkModelImports = modelContract.imports.frameworkImports(id) - ).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) - } - } - - private object SharafFrameworkBackend extends FrameworkBackend { - override val id: String = "sharaf" - override val requiredModelFlavor: ModelFlavor = ModelFlavor.Tupson - override def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] = { - new SharafGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = modelContract.imports.modelFileImports, - frameworkModelImports = modelContract.imports.frameworkImports(id) - ).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) - } - } - private val modelBackends: Map[String, ModelBackend] = ModelBackends.byId - private val frameworkBackends: Map[String, FrameworkBackend] = Map( - "http4s" -> Http4sFrameworkBackend, - "sharaf" -> SharafFrameworkBackend - ) + private val frameworkBackends: Map[String, FrameworkBackend] = FrameworkBackends.byId @deprecated("Use OpenApiGenerator(config) with models/framework fields", since = "0.7.0") def apply(name: String, config: Config): OpenApiGenerator = { diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala index 7a85597..a5628eb 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala @@ -109,7 +109,7 @@ class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) case None => - println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") + println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}]") None } case other => diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala index f6199dd..190c3c1 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -87,7 +87,7 @@ class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { openApiDefinition.namedSchemaDefinitions.defs.find(_.name == refName) match { case Some(referencedNamedSchema) => generateModelSources(referencedNamedSchema, Some(typeName)) case None => - println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}}]") + println(s"Non-existing sub-schema type: '${refName}' [${namedSchemaName}]") None } case other => From 27158bbac371dffebf43aae8124ca171c3fa9322 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:54:39 +0000 Subject: [PATCH 24/25] Move ModelBackend/FrameworkBackend traits to their own files; fix Scala 2 dialect issues Agent-Logs-Url: https://github.com/sake92/openapi4s/sessions/fe736856-158f-46fa-970b-cdba1fd91626 Co-authored-by: sake92 <12010694+sake92@users.noreply.github.com> --- .../ba/sake/openapi4s/FrameworkBackends.scala | 17 ++++++++++++++--- .../ba/sake/openapi4s/GenerationImports.scala | 2 -- .../ba/sake/openapi4s/ModelBackends.scala | 11 +++++++++-- .../sake/openapi4s/ModelImportContracts.scala | 1 + .../ba/sake/openapi4s/OpenApiGenerator.scala | 18 ------------------ 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala index 0007e21..60506eb 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala @@ -1,8 +1,19 @@ package ba.sake.openapi4s -import ba.sake.openapi4s.OpenApiGenerator._ +import ba.sake.openapi4s.OpenApiGenerator.{Config, ModelFlavor, ModelContract} import ba.sake.openapi4s.http4s.Http4sGenerator import ba.sake.openapi4s.sharaf.SharafGenerator +import ba.sake.regenesca.GeneratedFileSource + +trait FrameworkBackend { + def id: String + def requiredModelFlavor: ModelFlavor + def generateSources( + config: Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): Seq[GeneratedFileSource] +} object FrameworkBackends { private val RoutesPrefix = "routes/" @@ -15,7 +26,7 @@ object FrameworkBackends { config: Config, openapiDefinition: OpenApiDefinition, modelContract: ModelContract - ): Seq[ba.sake.regenesca.GeneratedFileSource] = { + ): Seq[GeneratedFileSource] = { new Http4sGenerator( config = config, openApiDefinition = openapiDefinition, @@ -32,7 +43,7 @@ object FrameworkBackends { config: Config, openapiDefinition: OpenApiDefinition, modelContract: ModelContract - ): Seq[ba.sake.regenesca.GeneratedFileSource] = { + ): Seq[GeneratedFileSource] = { new SharafGenerator( config = config, openApiDefinition = openapiDefinition, diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala index b477361..7036447 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala @@ -4,8 +4,6 @@ import scala.meta._ import scala.meta.dialects.Scala34 object GenerationImports { - private given Dialect = Scala34 - def modelWildcardImport(basePackage: String): Import = { val importer = s"${basePackage}.models.*".parse[Importer].get q"import ..${List(importer)}" diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala index 37d5218..c732d82 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala @@ -3,13 +3,20 @@ package ba.sake.openapi4s import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 -import ba.sake.openapi4s.OpenApiGenerator._ +import ba.sake.openapi4s.OpenApiGenerator.{Config, ModelFlavor, ModelContract} import ba.sake.openapi4s.circe.CirceModelGenerator import ba.sake.openapi4s.tupson.TupsonModelGenerator import ba.sake.regenesca.GeneratedFileSource +trait ModelBackend { + def id: String + def flavor: ModelFlavor + def imports: ModelImportContract + def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] + def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor, imports) +} + object ModelBackends { - private given Dialect = Scala34 private def generatePkgSelect(pkg: String): Term.Ref = { pkg diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala index acfcd1d..04da7c1 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala @@ -1,6 +1,7 @@ package ba.sake.openapi4s import scala.meta._ +import scala.meta.dialects.Scala34 case class ModelImportContract( modelFileImports: List[Import], diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index 152b7f2..ebc01f7 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -27,24 +27,6 @@ object OpenApiGenerator { imports: ModelImportContract ) - trait ModelBackend { - def id: String - def flavor: ModelFlavor - def imports: ModelImportContract - def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] - def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor, imports) - } - - trait FrameworkBackend { - def id: String - def requiredModelFlavor: ModelFlavor - def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] - } - private val modelBackends: Map[String, ModelBackend] = ModelBackends.byId private val frameworkBackends: Map[String, FrameworkBackend] = FrameworkBackends.byId From d0672c9e21a551816a41fefbcf7709904baba76a Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 21 May 2026 16:52:37 +0200 Subject: [PATCH 25/25] simplify Separate OpenApiWriter that actually writes the files gitignore .deder --- .gitignore | 2 +- .../ba/sake/openapi4s/cli/OpenApi4sMain.scala | 34 ++--- .../ba/sake/openapi4s/FrameworkBackend.scala | 89 ++++++++++++ .../ba/sake/openapi4s/FrameworkBackends.scala | 60 -------- .../ba/sake/openapi4s/ModelBackend.scala | 80 +++++++++++ .../ba/sake/openapi4s/ModelBackends.scala | 77 ---------- .../sake/openapi4s/ModelImportContracts.scala | 30 ++-- .../ba/sake/openapi4s/OpenApiGenerator.scala | 131 +----------------- .../ba/sake/openapi4s/OpenApiWriter.scala | 71 ++++++++++ .../ba/sake/openapi4s/SchemaDefinition.scala | 4 +- .../scala/ba/sake/openapi4s/SchemaUtils.scala | 51 ------- .../openapi4s/circe/CirceModelGenerator.scala | 44 +++++- .../openapi4s/http4s/Http4sGenerator.scala | 38 +---- .../openapi4s/sharaf/SharafGenerator.scala | 44 +----- .../tupson/TupsonModelGenerator.scala | 47 ++++++- .../openapi4s/validson/ValidsonUtils.scala | 60 ++++++++ .../openapi4s/OpenApiGeneratorSuite.scala | 42 +++--- .../http4s/Http4sGeneratorSuite.scala | 41 ++++-- .../sharaf/SharafGeneratorSuite.scala | 52 ++++--- 19 files changed, 498 insertions(+), 499 deletions(-) create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackend.scala delete mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackend.scala delete mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiWriter.scala create mode 100644 openapi4s/src/main/scala/ba/sake/openapi4s/validson/ValidsonUtils.scala diff --git a/.gitignore b/.gitignore index b6f1427..a659307 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ out/ .scala-build/ - +.deder/ diff --git a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala index 5011b25..8832c51 100644 --- a/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala +++ b/cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala @@ -1,6 +1,6 @@ package ba.sake.openapi4s.cli -import ba.sake.openapi4s.OpenApiGenerator +import ba.sake.openapi4s.OpenApiWriter import java.nio.file.Paths import mainargs.{main, arg, ParserForMethods} @@ -9,12 +9,10 @@ object OpenApi4sMain { @main def run( - @arg(doc = "Model backend: 'circe', 'tupson' or 'none'. If unset, defaults to 'tupson' (or mapped by --generator).") - models: String = "", - @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. If unset, defaults to 'sharaf' (or mapped by --generator).") - framework: String = "", - @arg(doc = "Deprecated legacy generator mapping: 'sharaf' => models=tupson+framework=sharaf, 'http4s' => models=circe+framework=http4s") - generator: String = "", + @arg(doc = "Model backend: 'circe', 'tupson' or 'none'. If unset, defaults to 'tupson'.") + models: String = "tupson", + @arg(doc = "Framework backend: 'http4s', 'sharaf' or 'none'. If unset, defaults to 'sharaf'.") + framework: String = "sharaf", @arg(doc = "OpenAPI URL or file path. Default is 'openapi.json'") url: String = "openapi.json", @arg(doc = "Base folder for generated sources. Default is 'src/main/scala'") @@ -22,28 +20,16 @@ object OpenApi4sMain { @arg(doc = "Base package for generated sources") basePackage: String ) = { - val defaultMapped = ("tupson", "sharaf") - val (mappedModels, mappedFramework) = generator.toLowerCase match { - case "" | "sharaf" => defaultMapped - case "http4s" => ("circe", "http4s") - case other => - throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'") - } - val finalModels = if (models.nonEmpty) models else mappedModels - val finalFramework = if (framework.nonEmpty) framework else mappedFramework - if (generator.nonEmpty) { - System.err.println("WARNING: '--generator' is deprecated since 0.7.0. Prefer '--models' and '--framework'.") - } - val openApiGenerator = OpenApiGenerator( - config = OpenApiGenerator.Config( + val writer = OpenApiWriter( + config = OpenApiWriter.Config( url = url, baseFolder = Paths.get(baseFolder), basePackage = basePackage, - models = finalModels, - framework = finalFramework + models = models, + framework = framework ) ) - openApiGenerator.generate() + writer.write() } def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackend.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackend.scala new file mode 100644 index 0000000..40628bf --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackend.scala @@ -0,0 +1,89 @@ +package ba.sake.openapi4s + +import ba.sake.openapi4s.OpenApiWriter.Config +import ba.sake.openapi4s.http4s.Http4sGenerator +import ba.sake.openapi4s.sharaf.SharafGenerator +import ba.sake.regenesca.GeneratedFileSource + +sealed trait FrameworkBackendId +object FrameworkBackendId { + case object NoFramework extends FrameworkBackendId + case object Http4s extends FrameworkBackendId + case object Sharaf extends FrameworkBackendId + + val all: List[FrameworkBackendId] = List(NoFramework, Http4s, Sharaf) + + def fromString(s: String): FrameworkBackendId = s.toLowerCase match { + case "http4s" => Http4s + case "sharaf" => Sharaf + case "none" => NoFramework + case _ => + throw new RuntimeException( + s"Unknown framework backend '${s}'. Available framework backends: 'http4s', 'sharaf', 'none'" + ) + } +} + +trait FrameworkBackend { + def id: FrameworkBackendId + def supportedModelIds: Set[ModelBackendId] + def generator( + config: OpenApiWriter.Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): OpenApiGenerator +} + +object FrameworkBackend { + + val none: FrameworkBackend = new FrameworkBackend { + override val id: FrameworkBackendId = FrameworkBackendId.NoFramework + override val supportedModelIds: Set[ModelBackendId] = + Set(ModelBackendId.NoModel, ModelBackendId.Circe, ModelBackendId.Tupson) + override def generator( + config: OpenApiWriter.Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): OpenApiGenerator = new OpenApiGenerator { + override def generate(): Seq[GeneratedFileSource] = Seq.empty + } + } + + val http4s: FrameworkBackend = new FrameworkBackend { + override val id: FrameworkBackendId = FrameworkBackendId.Http4s + override val supportedModelIds: Set[ModelBackendId] = Set(ModelBackendId.Circe) + override def generator( + config: OpenApiWriter.Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): OpenApiGenerator = { + new Http4sGenerator( + config = config, + openApiDefinition = openapiDefinition, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ) + } + } + + val sharaf: FrameworkBackend = new FrameworkBackend { + override val id: FrameworkBackendId = FrameworkBackendId.Sharaf + override val supportedModelIds: Set[ModelBackendId] = Set(ModelBackendId.Tupson) + override def generator( + config: OpenApiWriter.Config, + openapiDefinition: OpenApiDefinition, + modelContract: ModelContract + ): OpenApiGenerator = { + new SharafGenerator( + config = config, + openApiDefinition = openapiDefinition, + frameworkModelImports = modelContract.imports.frameworkImports(id) + ) + } + } + + val byId: Map[FrameworkBackendId, FrameworkBackend] = Map( + FrameworkBackendId.NoFramework -> none, + FrameworkBackendId.Http4s -> http4s, + FrameworkBackendId.Sharaf -> sharaf + ) +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala deleted file mode 100644 index 60506eb..0000000 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackends.scala +++ /dev/null @@ -1,60 +0,0 @@ -package ba.sake.openapi4s - -import ba.sake.openapi4s.OpenApiGenerator.{Config, ModelFlavor, ModelContract} -import ba.sake.openapi4s.http4s.Http4sGenerator -import ba.sake.openapi4s.sharaf.SharafGenerator -import ba.sake.regenesca.GeneratedFileSource - -trait FrameworkBackend { - def id: String - def requiredModelFlavor: ModelFlavor - def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] -} - -object FrameworkBackends { - private val RoutesPrefix = "routes/" - private val ControllersPrefix = "controllers/" - - val http4s: FrameworkBackend = new FrameworkBackend { - override val id: String = "http4s" - override val requiredModelFlavor: ModelFlavor = ModelFlavor.Circe - override def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] = { - new Http4sGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = modelContract.imports.modelFileImports, - frameworkModelImports = modelContract.imports.frameworkImports(id) - ).generateSources.filter(_.file.toString.startsWith(RoutesPrefix)) - } - } - - val sharaf: FrameworkBackend = new FrameworkBackend { - override val id: String = "sharaf" - override val requiredModelFlavor: ModelFlavor = ModelFlavor.Tupson - override def generateSources( - config: Config, - openapiDefinition: OpenApiDefinition, - modelContract: ModelContract - ): Seq[GeneratedFileSource] = { - new SharafGenerator( - config = config, - openApiDefinition = openapiDefinition, - modelFileImports = modelContract.imports.modelFileImports, - frameworkModelImports = modelContract.imports.frameworkImports(id) - ).generateSources.filter(_.file.toString.startsWith(ControllersPrefix)) - } - } - - val byId: Map[String, FrameworkBackend] = Map( - "http4s" -> http4s, - "sharaf" -> sharaf - ) -} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackend.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackend.scala new file mode 100644 index 0000000..f991c74 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackend.scala @@ -0,0 +1,80 @@ +package ba.sake.openapi4s + +import scala.meta._ +import scala.meta.dialects.Scala34 +import ba.sake.openapi4s.OpenApiWriter.Config +import ba.sake.openapi4s.circe.CirceModelGenerator +import ba.sake.openapi4s.tupson.TupsonModelGenerator +import ba.sake.regenesca.GeneratedFileSource + +sealed trait ModelBackendId +object ModelBackendId { + case object Circe extends ModelBackendId + case object Tupson extends ModelBackendId + case object NoModel extends ModelBackendId + + val all: List[ModelBackendId] = List(Circe, Tupson, NoModel) + + def fromString(s: String): ModelBackendId = s.toLowerCase match { + case "circe" => Circe + case "tupson" => Tupson + case "none" => NoModel + case _ => + throw new RuntimeException( + s"Unknown model backend '${s}'. Available model backends: 'circe', 'tupson', 'none'" + ) + } +} + +case class ModelContract( + packageName: String, + id: ModelBackendId, + imports: ModelImportContract +) + +trait ModelBackend { + def id: ModelBackendId + def imports: ModelImportContract + def generator(config: Config, openapiDefinition: OpenApiDefinition): OpenApiGenerator + def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", id, imports) +} + +object ModelBackend { + + private def generatePkgSelect(pkg: String): Term.Ref = { + pkg + .split("\\.") + .map(Term.Name(_)) + .reduceLeft[Term.Ref](Term.Select(_, _)) + } + + val none: ModelBackend = new ModelBackend { + override val id: ModelBackendId = ModelBackendId.NoModel + override val imports: ModelImportContract = ModelImportContracts.none + override def generator(config: Config, openapiDefinition: OpenApiDefinition): OpenApiGenerator = + new OpenApiGenerator { + override def generate(): Seq[GeneratedFileSource] = Seq.empty + } + } + + val circe: ModelBackend = new ModelBackend { + override val id: ModelBackendId = ModelBackendId.Circe + override val imports: ModelImportContract = ModelImportContracts.circe + override def generator(config: Config, openapiDefinition: OpenApiDefinition): OpenApiGenerator = + new CirceModelGenerator(config, openapiDefinition) + + } + + val tupson: ModelBackend = new ModelBackend { + override val id: ModelBackendId = ModelBackendId.Tupson + override val imports: ModelImportContract = ModelImportContracts.tupson + override def generator(config: Config, openapiDefinition: OpenApiDefinition): OpenApiGenerator = + new TupsonModelGenerator(config, openapiDefinition) + } + + val byId: Map[ModelBackendId, ModelBackend] = Map( + ModelBackendId.NoModel -> none, + ModelBackendId.Circe -> circe, + ModelBackendId.Tupson -> tupson + ) +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala deleted file mode 100644 index c732d82..0000000 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackends.scala +++ /dev/null @@ -1,77 +0,0 @@ -package ba.sake.openapi4s - -import java.nio.file.Paths -import scala.meta._ -import scala.meta.dialects.Scala34 -import ba.sake.openapi4s.OpenApiGenerator.{Config, ModelFlavor, ModelContract} -import ba.sake.openapi4s.circe.CirceModelGenerator -import ba.sake.openapi4s.tupson.TupsonModelGenerator -import ba.sake.regenesca.GeneratedFileSource - -trait ModelBackend { - def id: String - def flavor: ModelFlavor - def imports: ModelImportContract - def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] - def contract(config: Config): ModelContract = ModelContract(s"${config.basePackage}.models", flavor, imports) -} - -object ModelBackends { - - private def generatePkgSelect(pkg: String): Term.Ref = { - pkg - .split("\\.") - .map(Term.Name) - .reduceLeft[Term.Ref](Term.Select(_, _)) - } - - val circe: ModelBackend = new ModelBackend { - override val id: String = "circe" - override val flavor: ModelFlavor = ModelFlavor.Circe - override val imports: ModelImportContract = ModelImportContracts.circe - override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelGenerator = new CirceModelGenerator(openapiDefinition) - openapiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => - val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) - val allStmts = imports.modelFileImports ++ modelSources - Option.when(modelSources.nonEmpty) { - GeneratedFileSource( - Paths.get(s"models/${namedSchemaName}.scala"), - source""" package ${modelsPkg} { ..${allStmts} } """ - ) - } - } - } - } - - val tupson: ModelBackend = new ModelBackend { - override val id: String = "tupson" - override val flavor: ModelFlavor = ModelFlavor.Tupson - override val imports: ModelImportContract = ModelImportContracts.tupson - override def generateSources(config: Config, openapiDefinition: OpenApiDefinition): Seq[GeneratedFileSource] = { - val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelGenerator = new TupsonModelGenerator(openapiDefinition) - openapiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => - val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) - val allStmts = imports.modelFileImports ++ modelSources - Option.when(modelSources.nonEmpty) { - GeneratedFileSource( - Paths.get(s"models/${namedSchemaName}.scala"), - source""" - // generated with OpenApi4s - package ${modelsPkg} { ..${allStmts} } - """ - ) - } - } - } - } - - val byId: Map[String, ModelBackend] = Map( - "circe" -> circe, - "tupson" -> tupson - ) -} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala index 04da7c1..c3d57fd 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/ModelImportContracts.scala @@ -4,41 +4,27 @@ import scala.meta._ import scala.meta.dialects.Scala34 case class ModelImportContract( - modelFileImports: List[Import], - frameworkImportsById: Map[String, List[Import]] = Map.empty + frameworkImportsById: Map[FrameworkBackendId, List[Import]] = Map.empty ) { - def frameworkImports(frameworkId: String): List[Import] = + def frameworkImports(frameworkId: FrameworkBackendId): List[Import] = frameworkImportsById.getOrElse(frameworkId, List.empty) } object ModelImportContracts { + val none = ModelImportContract( + frameworkImportsById = Map.empty.withDefault(_ => List.empty) + ) + val circe: ModelImportContract = ModelImportContract( - modelFileImports = List( - q"import java.time.*", - q"import java.util.UUID", - q"import io.circe.{Codec, Json}", - q"import io.circe.derivation.{Configuration, ConfiguredCodec, ConfiguredEnumCodec}" - ), frameworkImportsById = Map( - "http4s" -> List(q"import org.http4s.circe.CirceEntityCodec.*") + FrameworkBackendId.Http4s -> List(q"import org.http4s.circe.CirceEntityCodec.*") ) ) val tupson: ModelImportContract = ModelImportContract( - modelFileImports = List( - q"import java.time.*", - q"import java.util.UUID", - q"import org.typelevel.jawn.ast.JValue", - q"import ba.sake.tupson.*", - q"import ba.sake.validson.Validator" - ), frameworkImportsById = Map( - "sharaf" -> List.empty + FrameworkBackendId.Sharaf -> List.empty ) ) - val external: ModelImportContract = ModelImportContract( - modelFileImports = List.empty, - frameworkImportsById = Map.empty - ) } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala index ebc01f7..c961bb6 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala @@ -1,131 +1,10 @@ package ba.sake.openapi4s -import java.nio.file.Path -import ba.sake.regenesca.{GeneratedFileSource, RegenescaGenerator, SourceMerger} +import ba.sake.regenesca.GeneratedFileSource +/** Base trait for OpenApi generators. Each generator (model or framework) implements this trait to produce generated + * sources. + */ trait OpenApiGenerator { - def generate(): Unit -} - -object OpenApiGenerator { - sealed trait ModelFlavor - object ModelFlavor { - case object Circe extends ModelFlavor - case object Tupson extends ModelFlavor - case object External extends ModelFlavor - - def asString(modelFlavor: ModelFlavor): String = modelFlavor match { - case Circe => "circe" - case Tupson => "tupson" - case External => "external" - } - } - - case class ModelContract( - packageName: String, - flavor: ModelFlavor, - imports: ModelImportContract - ) - - private val modelBackends: Map[String, ModelBackend] = ModelBackends.byId - private val frameworkBackends: Map[String, FrameworkBackend] = FrameworkBackends.byId - - @deprecated("Use OpenApiGenerator(config) with models/framework fields", since = "0.7.0") - def apply(name: String, config: Config): OpenApiGenerator = { - name.toLowerCase match { - case "http4s" => apply(config.copy(models = "circe", framework = "http4s")) - case "sharaf" => apply(config.copy(models = "tupson", framework = "sharaf")) - case other => throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'") - } - } - - def apply(config: Config): OpenApiGenerator = { - val modelName = config.models.toLowerCase - val frameworkName = config.framework.toLowerCase - - if (modelName == "none" && frameworkName == "none") { - throw new RuntimeException("Invalid config: models=none and framework=none means nothing to generate.") - } - - val modelBackendOpt = if (modelName == "none") None else modelBackends.get(modelName) - if (modelName != "none" && modelBackendOpt.isEmpty) { - throw new RuntimeException( - s"Unknown models backend '${config.models}'. Available models backends: '${(modelBackends.keys.toList :+ "none").sorted.mkString("', '")}'" - ) - } - - val frameworkBackendOpt = if (frameworkName == "none") None else frameworkBackends.get(frameworkName) - if (frameworkName != "none" && frameworkBackendOpt.isEmpty) { - throw new RuntimeException( - s"Unknown framework backend '${config.framework}'. Available framework backends: '${(frameworkBackends.keys.toList :+ "none").sorted.mkString("', '")}'" - ) - } - - validateBackendCompatibility(config, modelBackendOpt, frameworkBackendOpt) - - new ComposedOpenApiGenerator(config, modelBackendOpt, frameworkBackendOpt) - } - - case class Config( - url: String, - baseFolder: Path, - basePackage: String, - models: String = "tupson", - framework: String = "sharaf" - ) - - private class ComposedOpenApiGenerator( - config: Config, - modelBackendOpt: Option[ModelBackend], - frameworkBackendOpt: Option[FrameworkBackend] - ) extends OpenApiGenerator { - private val openapiDefinition = OpenApiDefinition.parse(config.url) - private val merger = SourceMerger(mergeDefBodies = true) - private val regenescaGenerator = RegenescaGenerator(merger) - - override def generate(): Unit = { - println( - s"Started generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}' into '${config.baseFolder}' ..." - ) - val modelSources = modelBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition)) - val modelContract = modelBackendOpt - .map(_.contract(config)) - .getOrElse(ModelContract(s"${config.basePackage}.models", ModelFlavor.External, ModelImportContracts.external)) - val frameworkSources = - frameworkBackendOpt.toList.flatMap(_.generateSources(config, openapiDefinition, modelContract)) - val packagePath = config.basePackage.replaceAll("\\.", "/") - val adaptedGenSourceFiles = (modelSources ++ frameworkSources).map { gsf => - gsf.copy(file = config.baseFolder.resolve(packagePath).resolve(gsf.file.toString)) - } - regenescaGenerator.generate(adaptedGenSourceFiles) - println( - s"Finished generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}'." - ) - } - } - - private def validateBackendCompatibility( - config: Config, - modelBackendOpt: Option[ModelBackend], - frameworkBackendOpt: Option[FrameworkBackend] - ): Unit = { - val modelFlavor = modelBackendOpt.map(_.flavor).getOrElse(ModelFlavor.External) - frameworkBackendOpt.foreach { frameworkBackend => - if (modelFlavor == ModelFlavor.External) { - // with models=none we cannot infer compatibility, so emit explicit external-model warning - System.err.println( - s"WARNING: models=none with framework='${frameworkBackend.id}'. " + - s"Generation will reference expected model types under ${config.basePackage}.models that must already exist. " + - s"Ensure those model classes/instances are available or generation output may not compile." - ) - } else if (frameworkBackend.requiredModelFlavor != modelFlavor) { - System.err.println( - s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + - s"Framework '${frameworkBackend.id}' typically expects models flavor '${ModelFlavor.asString(frameworkBackend.requiredModelFlavor)}'. " + - s"Generated sources may require manual import/type adjustments; prefer matching model/framework flavors when possible." - ) - } - } - } - + def generate(): Seq[GeneratedFileSource] } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiWriter.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiWriter.scala new file mode 100644 index 0000000..1fd2776 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiWriter.scala @@ -0,0 +1,71 @@ +package ba.sake.openapi4s + +import java.nio.file.Path +import ba.sake.regenesca.{GeneratedFileSource, RegenescaGenerator, SourceMerger} +import scala.meta.contrib.implicits.TreeExtensions +import scala.meta.dialects.Scala34 + +class OpenApiWriter( + config: OpenApiWriter.Config, + modelBackend: ModelBackend, + frameworkBackend: FrameworkBackend +) { + private val openapiDefinition = OpenApiDefinition.parse(config.url) + private val merger = SourceMerger(mergeDefBodies = true) + private val regenescaGenerator = RegenescaGenerator(merger) + + def write(): Seq[GeneratedFileSource] = { + println( + s"Started generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}' into '${config.baseFolder}' ..." + ) + val modelSources = modelBackend.generator(config, openapiDefinition).generate() + val modelContract = modelBackend.contract(config) + val frameworkSources = frameworkBackend.generator(config, openapiDefinition, modelContract).generate() + val packagePath = config.basePackage.replaceAll("\\.", "/") + val adaptedGenSourceFiles = (modelSources ++ frameworkSources).map { gsf => + gsf.copy(file = config.baseFolder.resolve(packagePath).resolve(gsf.file.toString)) + } + regenescaGenerator.generate(adaptedGenSourceFiles) + println( + s"Finished generating OpenApi for '${config.url}' with models='${config.models}', framework='${config.framework}'." + ) + adaptedGenSourceFiles + } +} + +object OpenApiWriter { + + private val modelBackends = ModelBackend.byId + private val frameworkBackends = FrameworkBackend.byId + + def apply(config: Config): OpenApiWriter = { + val modelId = ModelBackendId.fromString(config.models) + val frameworkId = FrameworkBackendId.fromString(config.framework) + + if (modelId == ModelBackendId.NoModel && frameworkId == FrameworkBackendId.NoFramework) { + throw new RuntimeException("Invalid config: models=none and framework=none means nothing to generate.") + } + + val modelBackend = modelBackends(modelId) + val frameworkBackend = frameworkBackends(frameworkId) + + if (!frameworkBackend.supportedModelIds.contains(modelBackend.id)) { + System.err.println( + s"WARNING: potentially incompatible backend combination: models='${config.models}', framework='${config.framework}'. " + + s"Framework '${frameworkBackend.id}' may not fully support model backend '${modelBackend.id}'. " + + s"Generated sources may require manual import/type adjustments; prefer compatible model/framework combinations when possible." + ) + } + + new OpenApiWriter(config, modelBackend, frameworkBackend) + } + + case class Config( + url: String, + baseFolder: Path, + basePackage: String, + models: String, + framework: String + ) + +} diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaDefinition.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaDefinition.scala index a3e0c66..5fcb424 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaDefinition.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaDefinition.scala @@ -4,9 +4,11 @@ case class NamedSchemaDefinitions( defs: Seq[SchemaDefinition.Named] ) -trait NameableSchemaDefinition extends SchemaDefinition // marker trait sealed abstract class SchemaDefinition + +trait NameableSchemaDefinition extends SchemaDefinition // marker trait + object SchemaDefinition { case class Str(default: Option[String], minLength: Option[Int], maxLength: Option[Int], pattern: Option[String]) extends SchemaDefinition diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala index 66842b3..75fabf5 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/SchemaUtils.scala @@ -60,55 +60,4 @@ object SchemaUtils { s"${camelizedParentType}${camelizedPropName}" } - def generateValidsonStms(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { - val validationCalls = properties.flatMap { case (name, schema) => - val propName = Term.Name(name) - schema match { - case int: SchemaDefinition.Int32 => - List( - int.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Int(min)) }, - int.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case long: SchemaDefinition.Int64 => - List( - long.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Long(min)) }, - long.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Long(max)) } - ).flatten - case float: SchemaDefinition.Num32 => - List( - float.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Float(min)) }, - float.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Float(max)) } - ).flatten - case double: SchemaDefinition.Num64 => - List( - double.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Double(min)) }, - double.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Double(max)) } - ).flatten - case str: SchemaDefinition.Str => - List( - str.minLength.map { min => "minLength" -> List(q"_.${propName}", Lit.Int(min)) }, - str.maxLength.map { max => "maxLength" -> List(q"_.${propName}", Lit.Int(max)) }, - str.pattern.map { pattern => "matches" -> List(q"_.${propName}", Lit.String(pattern)) } - ).flatten - case arr: SchemaDefinition.Arr => - List( - arr.minItems.map { min => "minItems" -> List(q"_.${propName}", Lit.Int(min)) }, - arr.maxItems.map { max => "maxItems" -> List(q"_.${propName}", Lit.Int(max)) } - ).flatten - case _ => List.empty - } - } - Option - .when(validationCalls.nonEmpty) { - val init = q"Validator.derived[${typeName}]" - val body = validationCalls.foldLeft(init: Term) { case (a, (funName, funArgs)) => - Term.Apply( - Term.Select(a, Term.Name(funName)), - Term.ArgClause(funArgs) - ) - } - q"given Validator[${typeName}] = ${body}" - } - .toList - } } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala index a5628eb..fa8c57d 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/circe/CirceModelGenerator.scala @@ -1,16 +1,44 @@ package ba.sake.openapi4s package circe +import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 +import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException -class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { +class CirceModelGenerator(config: OpenApiWriter.Config, openApiDefinition: OpenApiDefinition) + extends OpenApiGenerator { // keep track of done schemas (to avoid generating a subtype multiple times) private var generatedNamedSchemas = Set.empty[String] - def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { + override def generate(): Seq[GeneratedFileSource] = { + val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelImports = List[Import]( + q"import java.time.*", + q"import java.util.UUID", + q"import io.circe.{Codec, Json}", + q"import io.circe.derivation.{Configuration, ConfiguredCodec, ConfiguredEnumCodec}" + ) + val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => + val namedSchemaName = namedSchemaDef.name.capitalize + val modelSources = generateModelSources(namedSchemaDef, None) + val allStmts = modelImports ++ modelSources + Option.when(modelSources.nonEmpty) { + GeneratedFileSource( + Paths.get(s"models/${namedSchemaName}.scala"), + source""" + // generated with OpenApi4s + package ${modelsPkg} { ..${allStmts} } + """ + ) + } + } + modelFileSources + } + + private def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { val namedSchemaName = namedSchemaDef.name.capitalize if (generatedNamedSchemas(namedSchemaName)) return List.empty val typeName = Type.Name(namedSchemaName) @@ -121,8 +149,8 @@ class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { q""" object ${termName} { given Configuration = Configuration.default.withDiscriminator(${Lit.String( - oneOfSchema.discriminatorPropertyName - )}) + oneOfSchema.discriminatorPropertyName + )}) given Codec[${typeName}] = ConfiguredCodec.derived ..${oneOfCases} } @@ -132,4 +160,12 @@ class CirceModelGenerator(openApiDefinition: OpenApiDefinition) { generatedNamedSchemas += namedSchemaName generatedModelSources } + + private def generatePkgSelect(pkg: String) = { + val packageComponents = pkg.split("\\.").toList + val firstSelect = q"${Term.Name(packageComponents(0))}.${Term.Name(packageComponents(1))}" + packageComponents.tail.tail.foldLeft(firstSelect) { (a, b) => + q"${a}.${Term.Name(b)}" + } + } } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala index a738537..45f8792 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/http4s/Http4sGenerator.scala @@ -10,44 +10,12 @@ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException import ba.sake.openapi4s.circe.CirceModelGenerator class Http4sGenerator( - config: OpenApiGenerator.Config, + config: OpenApiWriter.Config, openApiDefinition: OpenApiDefinition, - modelFileImports: List[Import] = ModelImportContracts.circe.modelFileImports, - frameworkModelImports: List[Import] = ModelImportContracts.circe.frameworkImports("http4s") + frameworkModelImports: List[Import] ) extends OpenApiGenerator { - private val merger = SourceMerger(mergeDefBodies = true) - private val regenescaGenerator = RegenescaGenerator(merger) - - override def generate(): Unit = { - println(s"Started generating Http4s server for '${config.url}' OpenApi into '${config.baseFolder}' ...") - val packagePath = config.basePackage.replaceAll("\\.", "/") - val adaptedGenSourceFiles = generateSources.map { gsf => - gsf.copy(file = config.baseFolder.resolve(packagePath).resolve(gsf.file.toString)) - } - regenescaGenerator.generate(adaptedGenSourceFiles) - println(s"Finished generating Http4s server for '${config.url}' OpenApi.") - } - - private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { - val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelGenerator = new CirceModelGenerator(openApiDefinition) - val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => - val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) - val allStmts = modelFileImports ++ modelSources - Option.when(modelSources.nonEmpty) { - GeneratedFileSource( - Paths.get(s"models/${namedSchemaName}.scala"), - source""" package ${modelsPkg} { ..${allStmts} } """ - ) - } - } - val routeFileSources = generateRoutesSources - modelFileSources ++ routeFileSources - } - - private def generateRoutesSources: List[GeneratedFileSource] = { + override def generate(): Seq[GeneratedFileSource] = { val groupedByTag = openApiDefinition.pathDefinitions.defs.groupBy(_.getTag) groupedByTag.flatMap { case (tag, pathDefinitions) => generateRouteSources(tag, pathDefinitions) diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala index 12f9f63..81e8a36 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/sharaf/SharafGenerator.scala @@ -8,49 +8,15 @@ import org.apache.commons.text.CaseUtils import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException import ba.sake.openapi4s.tupson.TupsonModelGenerator +import ba.sake.openapi4s.validson.ValidsonUtils class SharafGenerator( - config: OpenApiGenerator.Config, + config: OpenApiWriter.Config, openApiDefinition: OpenApiDefinition, - modelFileImports: List[Import] = ModelImportContracts.tupson.modelFileImports, - frameworkModelImports: List[Import] = ModelImportContracts.tupson.frameworkImports("sharaf") + frameworkModelImports: List[Import] ) extends OpenApiGenerator { - private val merger = SourceMerger(mergeDefBodies = true) - private val regenescaGenerator = RegenescaGenerator(merger) - - override def generate(): Unit = { - println(s"Started generating Sharaf server for '${config.url}' OpenApi into '${config.baseFolder}' ...") - val packagePath = config.basePackage.replaceAll("\\.", "/") - val adaptedGenSourceFiles = generateSources.map { gsf => - gsf.copy(file = config.baseFolder.resolve(packagePath).resolve(gsf.file.toString)) - } - regenescaGenerator.generate(adaptedGenSourceFiles) - println(s"Finished generating Sharaf server for '${config.url}' OpenApi.") - } - - private[openapi4s] def generateSources: Seq[GeneratedFileSource] = { - val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") - val modelGenerator = new TupsonModelGenerator(openApiDefinition) - val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => - val namedSchemaName = namedSchemaDef.name.capitalize - val modelSources = modelGenerator.generateModelSources(namedSchemaDef, None) - val allStmts = modelFileImports ++ modelSources - Option.when(modelSources.nonEmpty) { - GeneratedFileSource( - Paths.get(s"models/${namedSchemaName}.scala"), - source""" - // generated with OpenApi4s - package ${modelsPkg} { ..${allStmts} } - """ - ) - } - } - val controllerFileSources = generateControllersSources - modelFileSources ++ controllerFileSources - } - - private def generateControllersSources: List[GeneratedFileSource] = { + override def generate(): Seq[GeneratedFileSource] = { val groupedByTag = openApiDefinition.pathDefinitions.defs.groupBy(_.getTag) groupedByTag.flatMap { case (tag, pathDefinitions) => generateControllerSources(tag, pathDefinitions) @@ -121,7 +87,7 @@ class SharafGenerator( // validation // TODO figure out how to validate Option-al nicely val validatedQPs = pathDef.queryParams.filter(_.required).map(qp => (qp.name, qp.schema)) - val validatorStmts = SchemaUtils.generateValidsonStms(t"QP", validatedQPs) + val validatorStmts = ValidsonUtils.generateStms(t"QP", validatedQPs) adhocEnums.flatten ++ List(q"case class QP(..${qpParams}) derives QueryStringRW") ++ Option.when(validatorStmts.nonEmpty)(q""" object QP { ..${validatorStmts} } """).toList ++ diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala index 190c3c1..61b191b 100644 --- a/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/tupson/TupsonModelGenerator.scala @@ -1,15 +1,45 @@ package ba.sake.openapi4s package tupson +import java.nio.file.Paths import scala.meta._ import scala.meta.dialects.Scala34 +import ba.sake.regenesca._ import ba.sake.openapi4s.exceptions.UnsupportedSchemaException +import ba.sake.openapi4s.validson.ValidsonUtils -class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { +class TupsonModelGenerator(config: OpenApiWriter.Config, openApiDefinition: OpenApiDefinition) + extends OpenApiGenerator { // keep track of done schemas (to avoid generating a subtype multiple times) private var generatedNamedSchemas = Set.empty[String] + override def generate(): Seq[GeneratedFileSource] = { + val modelsPkg = generatePkgSelect(s"${config.basePackage}.models") + val modelImports = List[Import]( + q"import java.time.*", + q"import java.util.UUID", + q"import org.typelevel.jawn.ast.JValue", + q"import ba.sake.tupson.*", + q"import ba.sake.validson.Validator" + ) + val modelFileSources = openApiDefinition.namedSchemaDefinitions.defs.flatMap { namedSchemaDef => + val namedSchemaName = namedSchemaDef.name.capitalize + val modelSources = generateModelSources(namedSchemaDef, None) + val allStmts = modelImports ++ modelSources + Option.when(modelSources.nonEmpty) { + GeneratedFileSource( + Paths.get(s"models/${namedSchemaName}.scala"), + source""" + // generated with OpenApi4s + package ${modelsPkg} { ..${allStmts} } + """ + ) + } + } + modelFileSources + } + def generateModelSources(namedSchemaDef: SchemaDefinition.Named, superType: Option[Type]): List[Stat] = { val namedSchemaName = namedSchemaDef.name.capitalize if (generatedNamedSchemas(namedSchemaName)) return List.empty @@ -57,7 +87,7 @@ class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { } } // validation - val validatorStmts = SchemaUtils.generateValidsonStms(typeName, obj.properties.map(p => (p.name, p.schema))) + val validatorStmts = ValidsonUtils.generateStms(typeName, obj.properties.map(p => (p.name, p.schema))) val classDefinition = superType match { case Some(st) => val extendsInit = init"${st}()" @@ -117,9 +147,20 @@ class TupsonModelGenerator(openApiDefinition: OpenApiDefinition) { List.empty } - generateModelSources(SchemaDefinition.Named(namedSchemaName, SchemaDefinition.Obj(mergedSchemasProps)), superType) + generateModelSources( + SchemaDefinition.Named(namedSchemaName, SchemaDefinition.Obj(mergedSchemasProps)), + superType + ) } generatedNamedSchemas += namedSchemaName generatedModelSources } + + private def generatePkgSelect(pkg: String) = { + val packageComponents = pkg.split("\\.").toList + val firstSelect = q"${Term.Name(packageComponents(0))}.${Term.Name(packageComponents(1))}" + packageComponents.tail.tail.foldLeft(firstSelect) { (a, b) => + q"${a}.${Term.Name(b)}" + } + } } diff --git a/openapi4s/src/main/scala/ba/sake/openapi4s/validson/ValidsonUtils.scala b/openapi4s/src/main/scala/ba/sake/openapi4s/validson/ValidsonUtils.scala new file mode 100644 index 0000000..01fa1d6 --- /dev/null +++ b/openapi4s/src/main/scala/ba/sake/openapi4s/validson/ValidsonUtils.scala @@ -0,0 +1,60 @@ +package ba.sake.openapi4s.validson + +import scala.meta._ +import scala.meta.dialects.Scala34 +import org.apache.commons.text.CaseUtils +import ba.sake.openapi4s.SchemaDefinition + +object ValidsonUtils { + def generateStms(typeName: Type, properties: List[(String, SchemaDefinition)]): List[Stat] = { + val validationCalls = properties.flatMap { case (name, schema) => + val propName = Term.Name(name) + schema match { + case int: SchemaDefinition.Int32 => + List( + int.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Int(min)) }, + int.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Int(max)) } + ).flatten + case long: SchemaDefinition.Int64 => + List( + long.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Long(min)) }, + long.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Long(max)) } + ).flatten + case float: SchemaDefinition.Num32 => + List( + float.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Float(min)) }, + float.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Float(max)) } + ).flatten + case double: SchemaDefinition.Num64 => + List( + double.minimum.map { min => "min" -> List(q"_.${propName}", Lit.Double(min)) }, + double.maximum.map { max => "max" -> List(q"_.${propName}", Lit.Double(max)) } + ).flatten + case str: SchemaDefinition.Str => + List( + str.minLength.map { min => "minLength" -> List(q"_.${propName}", Lit.Int(min)) }, + str.maxLength.map { max => "maxLength" -> List(q"_.${propName}", Lit.Int(max)) }, + str.pattern.map { pattern => "matches" -> List(q"_.${propName}", Lit.String(pattern)) } + ).flatten + case arr: SchemaDefinition.Arr => + List( + arr.minItems.map { min => "minItems" -> List(q"_.${propName}", Lit.Int(min)) }, + arr.maxItems.map { max => "maxItems" -> List(q"_.${propName}", Lit.Int(max)) } + ).flatten + case _ => List.empty + } + } + Option + .when(validationCalls.nonEmpty) { + val init = q"Validator.derived[${typeName}]" + val body = validationCalls.foldLeft(init: Term) { case (a, (funName, funArgs)) => + Term.Apply( + Term.Select(a, Term.Name(funName)), + Term.ArgClause(funArgs) + ) + } + q"given Validator[${typeName}] = ${body}" + } + .toList + } +} diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala index 5dcff51..78bc1ef 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/OpenApiGeneratorSuite.scala @@ -7,33 +7,34 @@ class OpenApiGeneratorSuite extends munit.FunSuite { test("composed generator should support circe + http4s") { val baseFolder = Files.createTempDirectory("openapi4s-circe-http4s") - val config = OpenApiGenerator.Config( + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = baseFolder, basePackage = "pkg", models = "circe", framework = "http4s" ) - OpenApiGenerator(config).generate() + OpenApiWriter(config).write() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("routes/"))) assert(!generatedFiles.exists(_.startsWith("controllers/"))) val routesFile = readGeneratedFile(baseFolder.resolve("pkg"), "routes/") + println(routesFile) assert(routesFile.contains("import org.http4s.circe.CirceEntityCodec.*")) } test("composed generator should support tupson + none") { val baseFolder = Files.createTempDirectory("openapi4s-tupson-none") - val config = OpenApiGenerator.Config( + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = baseFolder, basePackage = "pkg", models = "tupson", framework = "none" ) - OpenApiGenerator(config).generate() + OpenApiWriter(config).write() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) @@ -43,14 +44,14 @@ class OpenApiGeneratorSuite extends munit.FunSuite { test("composed generator should support none + http4s") { val baseFolder = Files.createTempDirectory("openapi4s-none-http4s") - val config = OpenApiGenerator.Config( + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = baseFolder, basePackage = "pkg", models = "none", framework = "http4s" ) - OpenApiGenerator(config).generate() + OpenApiWriter(config).write() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) assert(generatedFiles.nonEmpty) assert(!generatedFiles.exists(_.startsWith("models/"))) @@ -62,8 +63,8 @@ class OpenApiGeneratorSuite extends munit.FunSuite { test("composed generator should reject none + none") { interceptMessage[RuntimeException]("Invalid config: models=none and framework=none means nothing to generate.") { - OpenApiGenerator( - OpenApiGenerator.Config( + OpenApiWriter( + OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = Paths.get("app"), basePackage = "pkg", @@ -76,40 +77,28 @@ class OpenApiGeneratorSuite extends munit.FunSuite { test("composed generator should allow circe + sharaf") { val baseFolder = Files.createTempDirectory("openapi4s-circe-sharaf") - val config = OpenApiGenerator.Config( + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = baseFolder, basePackage = "pkg", models = "circe", framework = "sharaf" ) - OpenApiGenerator(config).generate() + OpenApiWriter(config).write() val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) assert(generatedFiles.nonEmpty) assert(generatedFiles.exists(_.startsWith("models/"))) assert(generatedFiles.exists(_.startsWith("controllers/"))) } - test("legacy generator mapping should still work") { - val baseFolder = Files.createTempDirectory("openapi4s-legacy-http4s") - val config = OpenApiGenerator.Config( - url = TestUtils.getResourceUrl("petstore_3.0.0.json"), - baseFolder = baseFolder, - basePackage = "pkg" - ) - OpenApiGenerator("http4s", config).generate() - val generatedFiles = listScalaFiles(baseFolder.resolve("pkg")) - assert(generatedFiles.nonEmpty) - assert(generatedFiles.exists(_.startsWith("models/"))) - assert(generatedFiles.exists(_.startsWith("routes/"))) - } - private def listScalaFiles(base: Path): List[String] = { if (!Files.exists(base)) List.empty else { val stream = Files.walk(base) try { - stream.iterator().asScala + stream + .iterator() + .asScala .filter(path => Files.isRegularFile(path) && path.getFileName.toString.endsWith(".scala")) .map(path => base.relativize(path).toString.replace('\\', '/')) .toList @@ -119,7 +108,8 @@ class OpenApiGeneratorSuite extends munit.FunSuite { private def readGeneratedFile(base: Path, prefix: String): String = { val generatedFiles = listScalaFiles(base) - val relative = generatedFiles.find(_.startsWith(prefix)).getOrElse(fail(s"Expected generated file with prefix '$prefix'")) + val relative = + generatedFiles.find(_.startsWith(prefix)).getOrElse(fail(s"Expected generated file with prefix '$prefix'")) Files.readString(base.resolve(relative)) } } diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/http4s/Http4sGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/http4s/Http4sGeneratorSuite.scala index becd1ba..51f8261 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/http4s/Http4sGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/http4s/Http4sGeneratorSuite.scala @@ -1,22 +1,28 @@ package ba.sake.openapi4s.http4s -import ba.sake.openapi4s._ - import java.nio.file.Paths +import ba.sake.openapi4s._ class Http4sGeneratorSuite extends munit.FunSuite { - test("generateSources should generate from petstore_3.0.0.json") { - val config = OpenApiGenerator.Config( + test("generate() should generate from petstore_3.0.0.json") { + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = Paths.get("app"), - basePackage = "pkg" + basePackage = "mypkg", + models = "circe", + framework = "http4s" ) val openapiDefinition = OpenApiDefinition.parse(config.url) - val generator = new Http4sGenerator(config, openapiDefinition) - val sources = generator.generateSources + val modelImportContract = ModelImportContracts.circe + val generator = new Http4sGenerator( + config, + openapiDefinition, + modelImportContract.frameworkImportsById(FrameworkBackendId.Http4s) + ) + val sources = generator.generate() println("*" * 100) - println("Generated Sharaf sources for petstore_3.0.0.json :") + println("Generated Http4s sources for petstore_3.0.0.json :") sources.foreach { source => println("*" * 50) print("*" * 20) @@ -26,17 +32,24 @@ class Http4sGeneratorSuite extends munit.FunSuite { } } - test("generateSources should generate from oneOf.yaml") { - val config = OpenApiGenerator.Config( + test("generate() should generate from oneOf.yaml") { + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("oneOf.yaml"), baseFolder = Paths.get("app"), - basePackage = "pkg" + basePackage = "mypkg", + models = "circe", + framework = "http4s" ) val openapiDefinition = OpenApiDefinition.parse(config.url) - val generator = new Http4sGenerator(config, openapiDefinition) - val sources = generator.generateSources + val modelImportContract = ModelImportContracts.circe + val generator = new Http4sGenerator( + config, + openapiDefinition, + modelImportContract.frameworkImportsById(FrameworkBackendId.Http4s) + ) + val sources = generator.generate() println("*" * 100) - println("Generated Sharaf sources for oneOf.yaml :") + println("Generated Http4s sources for oneOf.yaml :") sources.foreach { source => println("*" * 50) print("*" * 20) diff --git a/openapi4s/src/test/scala/ba/sake/openapi4s/sharaf/SharafGeneratorSuite.scala b/openapi4s/src/test/scala/ba/sake/openapi4s/sharaf/SharafGeneratorSuite.scala index dba4979..9b85ffa 100644 --- a/openapi4s/src/test/scala/ba/sake/openapi4s/sharaf/SharafGeneratorSuite.scala +++ b/openapi4s/src/test/scala/ba/sake/openapi4s/sharaf/SharafGeneratorSuite.scala @@ -5,16 +5,22 @@ import ba.sake.openapi4s._ class SharafGeneratorSuite extends munit.FunSuite { - - test("generateSources should generate from petstore_3.0.0.json") { - val config = OpenApiGenerator.Config( + test("generate() should generate from petstore_3.0.0.json") { + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("petstore_3.0.0.json"), baseFolder = Paths.get("app"), - basePackage = "pkg" + basePackage = "mypkg", + models = "tupson", + framework = "sharaf" ) val openapiDefinition = OpenApiDefinition.parse(config.url) - val generator = new SharafGenerator(config, openapiDefinition) - val sources = generator.generateSources + val modelImportContract = ModelImportContracts.tupson + val generator = new SharafGenerator( + config, + openapiDefinition, + modelImportContract.frameworkImportsById(FrameworkBackendId.Sharaf) + ) + val sources = generator.generate() println("*" * 100) println("Generated Sharaf sources for petstore_3.0.0.json :") sources.foreach { source => @@ -26,15 +32,22 @@ class SharafGeneratorSuite extends munit.FunSuite { } } - test("generateSources should generate from oneOf.yaml") { - val config = OpenApiGenerator.Config( + test("generate() should generate from oneOf.yaml") { + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("oneOf.yaml"), baseFolder = Paths.get("app"), - basePackage = "pkg" + basePackage = "mypkg", + models = "tupson", + framework = "sharaf" ) val openapiDefinition = OpenApiDefinition.parse(config.url) - val generator = new SharafGenerator(config, openapiDefinition) - val sources = generator.generateSources + val modelImportContract = ModelImportContracts.tupson + val generator = new SharafGenerator( + config, + openapiDefinition, + modelImportContract.frameworkImportsById(FrameworkBackendId.Sharaf) + ) + val sources = generator.generate() println("*" * 100) println("Generated Sharaf sources for oneOf.yaml :") sources.foreach { source => @@ -46,15 +59,22 @@ class SharafGeneratorSuite extends munit.FunSuite { } } - test("generateSources should generate from spring_petclinic.yaml") { - val config = OpenApiGenerator.Config( + test("generate() should generate from spring_petclinic.yaml") { + val config = OpenApiWriter.Config( url = TestUtils.getResourceUrl("spring_petclinic.yaml"), baseFolder = Paths.get("app"), - basePackage = "pkg" + basePackage = "mypkg", + models = "tupson", + framework = "sharaf" ) val openapiDefinition = OpenApiDefinition.parse(config.url) - val generator = new SharafGenerator(config, openapiDefinition) - val sources = generator.generateSources + val modelImportContract = ModelImportContracts.tupson + val generator = new SharafGenerator( + config, + openapiDefinition, + modelImportContract.frameworkImportsById(FrameworkBackendId.Sharaf) + ) + val sources = generator.generate() println("*" * 100) println("Generated Sharaf sources for spring_petclinic.yaml :") sources.foreach { source =>