Skip to content

Commit 650cca4

Browse files
authored
codegen: various minor fixes and improvements (#5374)
A few sundry things: - The discriminator of each child of a oneOf is reified as `def $discriminatorName: String = "..."`, but the parent type didn't declare the abstract method, which was irritating. Parent will now declare the method. - Fixes a reference bug when inheriting an enum using circe serdes, where parent didn't use enum in json bodies but the child does, and `seperateFilesForModels=true` - Adds a `openapiAlwaysGenerateParamSupport` boolean flag, so that enums can be defined in an inherited openapi and used in path position, without having to redeclare them (and everything that transitively contains them) - Fixes client encoding of non-exploded array params (was using a `.map` instead of a `.flatMap` and producing e.g. `?myParam=List(uuid1),List(uuid2)` instead of `?myParam=uuid1,uuid2` when encoding) - Remove redundant backticks from discriminator method names when the property name is not a reserved name - Tries harder to construct type aliases for inline definitions when 'deduplicating' between schemas - Creates val aliases for 'dedupped' case class definitions
1 parent 732614a commit 650cca4

25 files changed

Lines changed: 225 additions & 106 deletions

File tree

doc/generator/sbt-openapi-codegen.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ openapiPackageDependencies Map.empty[String, String] Allow
5757
aliases to the duplicates in the 'value' package. This is still experimental - significantly, the type is likely to change
5858
to a Map[String, Seq[String]] in the near future to permit multiple 'inheritance', and there may be bugs in the implementation.
5959
openapiSeperateFilesForModels false When true, models will be written to individual files under $pkg.models, with type aliases and helpers living under `package.scala` in a package object
60+
openapiAlwaysGenerateParamSupport false When true, all enums will be generated with param & json support, even if not used in those positions. This is useful for definitions that will be reused with `openapiPackageDependencies`
6061
===================================== ==================================== ==================================================================================================
6162
```
6263

openapi-codegen/cli/src/main/scala/sttp/tapir/codegen/GenScala.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import cats.implicits._
66

77
import com.monovore.decline._
88

9+
import sttp.tapir.codegen.dedup.PackageReuseContext
910
import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
1011
import sttp.tapir.codegen.{RootGenerator, YamlParser}
1112

@@ -145,7 +146,10 @@ object GenScala {
145146
maxSchemasPerFile.getOrElse(400),
146147
generateEndpointTypes,
147148
!disableValidatorGeneration,
148-
useCustomJsoniterSerdes
149+
useCustomJsoniterSerdes,
150+
PackageReuseContext.none,
151+
false,
152+
false
149153
)
150154
)
151155
destFiles <- contents.allFiles.toVector.traverse { case (fileName, content) => writeGeneratedFile(destDir, fileName, content) }

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import sttp.tapir.codegen.json.JsonSerdeLib.{Circe, Jsoniter}
77
import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
88
import sttp.tapir.codegen.openapi.models.{DefaultValueRenderer, OpenapiSchemaType, RenderConfig}
99
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
10-
import sttp.tapir.codegen.util.NameHelpers.indent
10+
import sttp.tapir.codegen.util.NameHelpers.{indent, safeVariableName}
1111
import sttp.tapir.codegen.util.{DocUtils, VersionedHelpers}
1212

1313
case class GeneratedClassDefinitions(
@@ -82,7 +82,8 @@ class ClassDefinitionGenerator {
8282
xmlParamRefs: Set[String] = Set.empty,
8383
useCustomJsoniterSerdes: Boolean = true,
8484
packageReuse: PackageReuseContext = PackageReuseContext.none,
85-
seperateFilesForModels: Boolean = false
85+
seperateFilesForModels: Boolean = false,
86+
alwaysGenerateParamSupport: Boolean = false
8687
): Option[GeneratedClassDefinitions] = {
8788
val allSchemas: Map[String, OpenapiSchemaType] = doc.components.toSeq.flatMap(_.schemas).toMap
8889
val allOneOfSchemas = allSchemas.collect { case (name, oneOf: OpenapiSchemaOneOf) => name -> oneOf }.toSeq
@@ -123,7 +124,7 @@ class ClassDefinitionGenerator {
123124
val generatesQueryOrPathParamEnums = enumsDefinedOnEndpointParams ||
124125
allSchemas
125126
.collect { case (name, _: OpenapiSchemaEnum) => name }
126-
.exists(queryOrPathParamRefs.contains)
127+
.exists(alwaysGenerateParamSupport || queryOrPathParamRefs.contains(_))
127128
val enumSerdeHelper = if (!generatesQueryOrPathParamEnums) "" else EnumGenerator.enumSerdeHelperDefn(targetScala3)
128129

129130
def fetchTransitiveParamRefs(initialSet: Set[String], toCheck: Seq[OpenapiSchemaType]): Set[String] = toCheck match {
@@ -136,14 +137,27 @@ class ClassDefinitionGenerator {
136137
jsonParamRefs.toSeq.flatMap(ref => allSchemas.get(ref.stripPrefix("#/components/schemas/")))
137138
)
138139

140+
def allChildrenDefineDiscriminator(d: String, s: OpenapiSchemaOneOf): Boolean =
141+
s.types.forall {
142+
case t: OpenapiSchemaRef =>
143+
t.maybeResolved(doc).exists {
144+
case o: OpenapiSchemaObject => o.properties.contains(d)
145+
case _ => false
146+
}
147+
case _ => false
148+
}
149+
139150
val adtTypes =
140151
adtInheritanceMap
141152
.flatMap(_._2)
142153
.toSeq
143-
.map(_._1)
154+
.map { s => s._1 -> s._2.discriminator.map(_.propertyName).filter(d => allChildrenDefineDiscriminator(d, s._2)) }
144155
.distinct
145-
.filterNot(PackageReuseContext.isReusedSchema(_, packageReuse))
146-
.map(name => s"sealed trait $name")
156+
.filterNot(s => PackageReuseContext.isReusedSchema(s._1, packageReuse))
157+
.map { case (name, maybeDiscriminator) =>
158+
val maybeBody = maybeDiscriminator.map(d => s" { def ${safeVariableName(d)}: String }").getOrElse("")
159+
s"sealed trait $name$maybeBody"
160+
}
147161
.sorted
148162
.mkString("", "\n", "\n")
149163
val schemasWithAny = allSchemas.filter { case (_, schema) => schemaContainsAny(schema) }
@@ -174,7 +188,8 @@ class ClassDefinitionGenerator {
174188
schemasContainAny,
175189
useCustomJsoniterSerdes,
176190
packageReuse,
177-
resolvableNonClassyOneOfSchemas
191+
resolvableNonClassyOneOfSchemas,
192+
seperateFilesForModels
178193
)
179194
val allTransitiveXmlParamRefs = fetchTransitiveParamRefs(
180195
xmlParamRefs,
@@ -206,12 +221,45 @@ class ClassDefinitionGenerator {
206221
_.schemas.map {
207222
case (name, _: OpenapiSchemaEnum) if PackageReuseContext.isReusedSchema(name, packageReuse) =>
208223
(name, (0, Seq(PackageReuseContext.enumAliasType(name, packageReuse, seperateFilesForModels))))
224+
case (name, obj: OpenapiSchemaObject) =>
225+
val isReusedSchema = PackageReuseContext.isReusedSchema(name, packageReuse)
226+
(
227+
name,
228+
(
229+
if (isReusedSchema) 0 else 2,
230+
generateClass(
231+
allSchemas,
232+
name,
233+
obj,
234+
allTransitiveJsonParamRefs,
235+
adtInheritanceMap,
236+
jsonSerdeLib,
237+
targetScala3,
238+
isReusedSchema,
239+
packageReuse,
240+
seperateFilesForModels,
241+
alwaysGenerateParamSupport
242+
)
243+
)
244+
)
209245
case (name, s) if PackageReuseContext.isReusedSchema(name, packageReuse) =>
210246
(name, (0, Seq(PackageReuseContext.aliasType(name, packageReuse, seperateFilesForModels))))
211-
case (name, obj: OpenapiSchemaObject) =>
212-
(name, (2, generateClass(allSchemas, name, obj, allTransitiveJsonParamRefs, adtInheritanceMap, jsonSerdeLib, targetScala3)))
213247
case (name, obj: OpenapiSchemaEnum) =>
214-
(name, (1, EnumGenerator.generateEnum(name, obj, targetScala3, queryOrPathParamRefs, jsonSerdeLib, allTransitiveJsonParamRefs)))
248+
(
249+
name,
250+
(
251+
1,
252+
EnumGenerator.generateEnum(
253+
name,
254+
obj,
255+
targetScala3,
256+
queryOrPathParamRefs,
257+
jsonSerdeLib,
258+
allTransitiveJsonParamRefs,
259+
alwaysGenerateParamSupport
260+
)
261+
)
262+
)
215263
case (name, OpenapiSchemaMap(valueSchema, _, _)) =>
216264
(name, (0, generateMap(name, valueSchema)))
217265
case (name, OpenapiSchemaArray(valueSchema, _, _, rs)) =>
@@ -256,14 +304,28 @@ class ClassDefinitionGenerator {
256304
addModel(name, defns.mkString("\n"))
257305
}
258306

259-
adtGroups.toSeq.filterNot(p => isTypeAlias(p._1)).foreach { case (fileName, parentTraits) =>
260-
val traits = parentTraits.map(p => s"sealed trait $p").mkString("\n")
307+
adtGroups.toSeq.foreach { case (fileName, parentTraits) =>
308+
val isReused = isTypeAlias(fileName)
309+
val traits =
310+
if (isReused) parentTraits.map(p => s"type $p = ${packageReuse.modelRoot(seperateFilesForModels)}.$p").mkString("\n")
311+
else parentTraits.map(p => s"sealed trait $p").mkString("\n")
261312
val children = childToAdtFile.filter(_._2 == fileName).keys.toSeq.sorted
262313
val childContent = children
263314
.flatMap { child =>
264315
allSchemas.get(child).collect { case obj: OpenapiSchemaObject =>
265-
generateClass(allSchemas, child, obj, allTransitiveJsonParamRefs, adtInheritanceMap, jsonSerdeLib, targetScala3)
266-
.mkString("\n")
316+
generateClass(
317+
allSchemas,
318+
child,
319+
obj,
320+
allTransitiveJsonParamRefs,
321+
adtInheritanceMap,
322+
jsonSerdeLib,
323+
targetScala3,
324+
isReused,
325+
packageReuse,
326+
seperateFilesForModels,
327+
alwaysGenerateParamSupport
328+
).mkString("\n")
267329
}
268330
}
269331
.mkString("\n")
@@ -357,7 +419,11 @@ class ClassDefinitionGenerator {
357419
jsonParamRefs: Set[String],
358420
adtInheritanceMap: Map[String, Seq[(String, OpenapiSchemaOneOf)]],
359421
jsonSerdeLib: JsonSerdeLib.JsonSerdeLib,
360-
targetScala3: Boolean
422+
targetScala3: Boolean,
423+
isReused: Boolean,
424+
packageReuse: PackageReuseContext,
425+
seperateFilesForModels: Boolean,
426+
alwaysGenerateParamSupport: Boolean
361427
): Seq[String] = try {
362428
val isJson = jsonParamRefs contains name
363429
def rec(className: String, schemaKey: String, obj: OpenapiSchemaObject, acc: List[String]): Seq[String] = {
@@ -395,7 +461,7 @@ class ClassDefinitionGenerator {
395461
val discriminatorDefBody = discriminatorDefFields.filter { case (n, _) => obj.properties.map(_._1).toSet.contains(n) } match {
396462
case Nil => ""
397463
case fields =>
398-
val fs = fields.map { case (k, v) => s"""def `$k`: String = "$v"""" }.mkString("\n")
464+
val fs = fields.map { case (k, v) => s"""def ${safeVariableName(k)}: String = "$v"""" }.mkString("\n")
399465
s""" {
400466
|${indent(2)(fs)}
401467
|}""".stripMargin
@@ -406,7 +472,7 @@ class ClassDefinitionGenerator {
406472
.map { case (key, OpenapiSchemaField(schemaType, maybeDefault, _)) =>
407473
val (tpe, maybeEnum) =
408474
mapSchemaTypeToType(className, key, obj.required.contains(key), schemaType, isJson, jsonSerdeLib, targetScala3)
409-
val fixedKey = fixKey(key)
475+
val fixedKey = safeVariableName(key)
410476
val optional = schemaType.nullable || !obj.required.contains(key)
411477
val maybeExplicitDefault =
412478
maybeDefault.map(
@@ -415,14 +481,24 @@ class ClassDefinitionGenerator {
415481
.render(allModels = allSchemas, thisType = schemaType, optional, RenderConfig(maybeEnum.map(_.enumName)))(_)
416482
)
417483
val default = maybeExplicitDefault getOrElse (if (optional) " = None" else "")
418-
s"$fixedKey: $tpe$default" -> maybeEnum.map(_.impl)
484+
if (isReused) "" -> maybeEnum.map { e =>
485+
val alias = s"${e.enumName} = ${packageReuse.modelRoot(seperateFilesForModels)}.${e.enumName}"
486+
s"type $alias\nval $alias"
487+
}
488+
else s"$fixedKey: $tpe$default" -> maybeEnum.map(_.impl)
419489
}
420490
.unzip
421491

422492
val enumDefn = maybeEnums.flatten.toList
423-
s"""|case class $className (
424-
|${indent(2)(properties.mkString(",\n"))}
425-
|)$parents$discriminatorDefBody""".stripMargin :: innerClasses ::: enumDefn ::: acc
493+
val modelDefn =
494+
if (isReused) {
495+
val alias = s"$className = ${packageReuse.modelRoot(seperateFilesForModels)}.$className"
496+
s"type $alias\nval $alias"
497+
} else
498+
s"""|case class $className (
499+
|${indent(2)(properties.mkString(",\n"))}
500+
|)$parents$discriminatorDefBody""".stripMargin
501+
modelDefn :: innerClasses ::: enumDefn ::: acc
426502
}
427503

428504
rec(addName("", name), name, obj, Nil)
@@ -485,7 +561,8 @@ class ClassDefinitionGenerator {
485561
targetScala3,
486562
Set.empty,
487563
jsonSerdeLib,
488-
if (isJson) Set(enumName) else Set.empty
564+
if (isJson) Set(enumName) else Set.empty,
565+
false
489566
)
490567
(enumName -> e.nullable, Some(InlineEnumDefn(enumName, enumDefn.mkString("\n"))))
491568

@@ -500,13 +577,6 @@ class ClassDefinitionGenerator {
500577

501578
private val reservedKeys = VersionedHelpers.reservedKeys
502579

503-
private def fixKey(key: String) = {
504-
if (reservedKeys.contains(key))
505-
s"`$key`"
506-
else
507-
key
508-
}
509-
510580
private def schemaContainsAny(schema: OpenapiSchemaType): Boolean = schema match {
511581
case _: OpenapiSchemaAny => true
512582
case OpenapiSchemaArray(items, _, _, _) => schemaContainsAny(items)

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ class EndpointGenerator {
419419
jsonSerdeLib: JsonSerdeLib,
420420
param: OpenapiParameter,
421421
e: OpenapiSchemaEnum,
422-
isArray: Boolean
422+
isArray: Boolean,
423423
): (String, Some[Seq[String]], String, String) = {
424424
val enumName = endpointName.capitalize + strippedToCamelCase(param.name).capitalize
425425
val enumParamRefs = if (param.in == "query" || param.in == "path") Set(enumName) else Set.empty[String]
@@ -429,7 +429,8 @@ class EndpointGenerator {
429429
targetScala3,
430430
enumParamRefs,
431431
jsonSerdeLib,
432-
Set.empty
432+
Set.empty,
433+
false
433434
)
434435

435436
def arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues"

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,16 @@ object EnumGenerator {
1414
targetScala3: Boolean,
1515
queryParamRefs: Set[String],
1616
jsonSerdeLib: JsonSerdeLib.JsonSerdeLib,
17-
jsonParamRefs: Set[String]
17+
jsonParamRefs: Set[String],
18+
alwaysGenerateParamSupport: Boolean
1819
): Seq[String] = {
1920
def maybeEscaped(s: String) = s match {
2021
case legalEnumName(l) => l
2122
case illegal => s"`$illegal`"
2223
}
2324
if (targetScala3) {
2425
val maybeCompanion =
25-
if (queryParamRefs contains name) {
26+
if (alwaysGenerateParamSupport || queryParamRefs.contains(name)) {
2627
def helperImpls =
2728
s""" given enumCodecSupport${name.capitalize}: ExtraParamSupport[$name] =
2829
| extraCodecSupport[$name]""".stripMargin
@@ -32,14 +33,22 @@ object EnumGenerator {
3233
|}""".stripMargin
3334
} else ""
3435
val maybeCodecExtensions = jsonSerdeLib match {
35-
case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => ""
36-
case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror"
37-
case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec"
38-
case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror"
39-
case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => ""
40-
case JsonSerdeLib.Jsoniter => " derives enumextensions.EnumMirror"
41-
case JsonSerdeLib.Zio if !queryParamRefs.contains(name) => " derives zio.json.JsonCodec"
42-
case JsonSerdeLib.Zio => " derives zio.json.JsonCodec, enumextensions.EnumMirror"
36+
case _ if !alwaysGenerateParamSupport && !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) =>
37+
""
38+
case _ if !alwaysGenerateParamSupport && !jsonParamRefs.contains(name) =>
39+
" derives enumextensions.EnumMirror"
40+
case JsonSerdeLib.Circe if !alwaysGenerateParamSupport && !queryParamRefs.contains(name) =>
41+
" derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec"
42+
case JsonSerdeLib.Circe =>
43+
" derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror"
44+
case JsonSerdeLib.Jsoniter if !alwaysGenerateParamSupport && !queryParamRefs.contains(name) =>
45+
""
46+
case JsonSerdeLib.Jsoniter =>
47+
" derives enumextensions.EnumMirror"
48+
case JsonSerdeLib.Zio if !alwaysGenerateParamSupport && !queryParamRefs.contains(name) =>
49+
" derives zio.json.JsonCodec"
50+
case JsonSerdeLib.Zio =>
51+
" derives zio.json.JsonCodec, enumextensions.EnumMirror"
4352
}
4453
s"""$maybeCompanion
4554
|enum $name$maybeCodecExtensions {
@@ -48,12 +57,15 @@ object EnumGenerator {
4857
} else {
4958
val members = obj.items.map { i => s"case object ${maybeEscaped(i.value)} extends $name" }
5059
val maybeCodecExtension = jsonSerdeLib match {
51-
case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => ""
52-
case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]"
53-
case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio => ""
60+
case _ if !alwaysGenerateParamSupport && !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) =>
61+
""
62+
case JsonSerdeLib.Circe =>
63+
s" with enumeratum.CirceEnum[$name]"
64+
case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio =>
65+
""
5466
}
5567
val maybeQueryCodecDefn =
56-
if (queryParamRefs contains name) {
68+
if (alwaysGenerateParamSupport || queryParamRefs.contains(name)) {
5769
s"""
5870
| implicit val enumCodecSupport${name.capitalize}: ExtraParamSupport[$name] =
5971
| extraCodecSupport[$name]("${name}", ${name})""".stripMargin

0 commit comments

Comments
 (0)