Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3c406b9
Add composed generator with models/framework backends
Copilot May 20, 2026
2c527b1
Add orchestration tests and README examples
Copilot May 20, 2026
85a0724
Address review feedback for warnings and tests
Copilot May 20, 2026
b99adf3
Strengthen generation matrix test assertions
Copilot May 20, 2026
d8956c4
Adjust deprecated CLI help text casing
Copilot May 20, 2026
b5cd814
Align flavor naming and CLI default docs
Copilot May 20, 2026
b44051d
Clarify compatibility validation flow
Copilot May 20, 2026
d1fbfd1
Finalize validation logic and CLI doc style
Copilot May 20, 2026
64af7c2
Extract model import contracts and parameterize framework imports
Copilot May 20, 2026
077cc53
Clarify explicit compatibility match branch
Copilot May 20, 2026
acb2de8
Simplify compatibility warning logic
Copilot May 20, 2026
5c6244b
Add shared import helper and clarify warnings
Copilot May 20, 2026
01cda42
Improve compatibility and deprecation warnings
Copilot May 20, 2026
96b7ff2
Decouple model generation from framework IDs
Copilot May 20, 2026
3a2ae82
Consolidate legacy generator default mapping
Copilot May 20, 2026
634461b
Extract TupsonModelGenerator and CirceModelGenerator into dedicated p…
Copilot May 20, 2026
a1a0d26
Fix Scala 2 wildcard import syntax in model generator extraction
Copilot May 20, 2026
c0827f8
Fix remaining Scala 2 wildcard imports and add Scala34 dialect for Ge…
Copilot May 20, 2026
634de85
Fix missing Scala34 dialect and CaseUtils imports in extracted genera…
Copilot May 20, 2026
1ef7b98
Rename validator helper to validson-specific name
Copilot May 20, 2026
8442039
Use dedicated model generators in ModelBackends
Copilot May 20, 2026
727115f
Fix Scala Meta dialect scope in ModelBackends
Copilot May 20, 2026
898ab81
Fix remaining compile issues and add FrameworkBackends object
Copilot May 20, 2026
27158bb
Move ModelBackend/FrameworkBackend traits to their own files; fix Sca…
Copilot May 20, 2026
d0672c9
simplify
sake92 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ out/

.scala-build/


.deder/
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
19 changes: 11 additions & 8 deletions cli/src/main/scala/ba/sake/openapi4s/cli/OpenApi4sMain.scala
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -9,24 +9,27 @@ 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'. 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'")
baseFolder: String = "src/main/scala",
@arg(doc = "Base package for generated sources")
basePackage: String
) = {
val openApiGenerator = OpenApiGenerator(
name = generator,
config = OpenApiGenerator.Config(
val writer = OpenApiWriter(
config = OpenApiWriter.Config(
url = url,
baseFolder = Paths.get(baseFolder),
basePackage = basePackage
basePackage = basePackage,
models = models,
framework = framework
)
)
openApiGenerator.generate()
writer.write()
}
def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args)
}
89 changes: 89 additions & 0 deletions openapi4s/src/main/scala/ba/sake/openapi4s/FrameworkBackend.scala
Original file line number Diff line number Diff line change
@@ -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
)
}
11 changes: 11 additions & 0 deletions openapi4s/src/main/scala/ba/sake/openapi4s/GenerationImports.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ba.sake.openapi4s

import scala.meta._
import scala.meta.dialects.Scala34

object GenerationImports {
def modelWildcardImport(basePackage: String): Import = {
val importer = s"${basePackage}.models.*".parse[Importer].get
q"import ..${List(importer)}"
}
}
80 changes: 80 additions & 0 deletions openapi4s/src/main/scala/ba/sake/openapi4s/ModelBackend.scala
Original file line number Diff line number Diff line change
@@ -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
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ba.sake.openapi4s

import scala.meta._
import scala.meta.dialects.Scala34

case class ModelImportContract(
frameworkImportsById: Map[FrameworkBackendId, List[Import]] = Map.empty
) {
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(
frameworkImportsById = Map(
FrameworkBackendId.Http4s -> List(q"import org.http4s.circe.CirceEntityCodec.*")
)
)

val tupson: ModelImportContract = ModelImportContract(
frameworkImportsById = Map(
FrameworkBackendId.Sharaf -> List.empty
)
)

}
28 changes: 5 additions & 23 deletions openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiGenerator.scala
Original file line number Diff line number Diff line change
@@ -1,28 +1,10 @@
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

/** Base trait for OpenApi generators. Each generator (model or framework) implements this trait to produce generated
* sources.
*/
trait OpenApiGenerator {
def generate(): Unit
}

object OpenApiGenerator {

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 other => throw new RuntimeException(s"Unknown generator '${other}'. Available generators: 'http4s', 'sharaf'")
}
}

case class Config(
url: String,
baseFolder: Path,
basePackage: String
)

def generate(): Seq[GeneratedFileSource]
}
71 changes: 71 additions & 0 deletions openapi4s/src/main/scala/ba/sake/openapi4s/OpenApiWriter.scala
Original file line number Diff line number Diff line change
@@ -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
)

}
Loading
Loading