Skip to content

Commit 774dfc9

Browse files
committed
fix: use wrapper composition for shared oneOf members in sttp4 client
When a schema is referenced by multiple oneOf parents (e.g., Dog and Cat used by both Pet and Animal), the generator cannot inline them into any single parent. Previously this produced broken code: sealed traits with no subtypes, causing circe derivation and json4s serialization to fail. This fix introduces wrapper composition for shared members: each parent generates wrapper case classes (e.g., DogPet, CatAnimal) inside its companion object that delegate to the standalone type. Hand-written codecs replace semiauto derivation when wrappers are present. Exclusive members (used by only one parent) continue to be inlined directly, preserving the existing behavior. Fixes compilation errors reported in PR #22916.
1 parent ac1b84e commit 774dfc9

3 files changed

Lines changed: 174 additions & 18 deletions

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
315315
// Collect child models for inline generation
316316
// Only inline if they are used exclusively by this oneOf parent
317317
List<CodegenModel> childModels = new ArrayList<>();
318+
// Collect shared child models that need wrapper composition
319+
List<Map<String, Object>> wrappedMembers = new ArrayList<>();
318320

319321
for (String childName : cModel.oneOf) {
320322
CodegenModel childModel = ModelUtils.getModelByName(childName, processed);
@@ -355,9 +357,36 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
355357
}
356358

357359
childModels.add(childModel);
360+
} else if (childModel != null) {
361+
// This child is shared across multiple oneOf parents.
362+
// Use wrapper composition: generate a case class wrapper inside the parent.
363+
Map<String, Object> wrappedMember = new HashMap<>();
364+
wrappedMember.put("classname", childName);
365+
wrappedMember.put("wrapperClassname", childName + cModel.classname);
366+
wrappedMember.put("parentClassname", cModel.classname);
367+
368+
// Resolve discriminator value if applicable
369+
if (cModel.discriminator != null) {
370+
String discriminatorValue = childName; // default: class name
371+
if (cModel.discriminator.getMappedModels() != null) {
372+
for (CodegenDiscriminator.MappedModel mappedModel : cModel.discriminator.getMappedModels()) {
373+
if (mappedModel.getModelName().equals(childName)) {
374+
discriminatorValue = mappedModel.getMappingName();
375+
break;
376+
}
377+
}
378+
}
379+
wrappedMember.put("discriminatorValue", discriminatorValue);
380+
}
381+
382+
wrappedMembers.add(wrappedMember);
358383
}
359384
}
360385
cModel.getVendorExtensions().put("x-oneOfMembers", childModels);
386+
cModel.getVendorExtensions().put("x-wrappedOneOfMembers", wrappedMembers);
387+
if (!wrappedMembers.isEmpty()) {
388+
cModel.getVendorExtensions().put("x-hasWrappedOneOfMembers", true);
389+
}
361390
} else if (cModel.isEnum) {
362391
cModel.getVendorExtensions().put("x-isEnum", true);
363392
} else {

modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ sealed trait {{classname}} {
3232
sealed trait {{classname}}
3333
{{/vendorExtensions.x-hasParentProps}}
3434

35-
{{! Generate inline case classes for oneOf members }}
35+
{{! Generate inline case classes for exclusive oneOf members }}
3636
{{#vendorExtensions.x-oneOfMembers}}
3737
case class {{classname}}(
3838
{{#allVars}}
@@ -58,9 +58,15 @@ object {{classname}} {
5858

5959
{{/vendorExtensions.x-oneOfMembers}}
6060
object {{classname}} {
61+
{{! Generate wrapper case classes for shared oneOf members }}
62+
{{#vendorExtensions.x-wrappedOneOfMembers}}
63+
case class {{wrapperClassname}}(value: {{classname}}) extends {{parentClassname}}
64+
{{/vendorExtensions.x-wrappedOneOfMembers}}
65+
6166
{{#json4s}}
6267
import org.json4s._
6368

69+
{{^vendorExtensions.x-hasWrappedOneOfMembers}}
6470
{{^vendorExtensions.x-use-discr}}
6571
// oneOf without discriminator - json4s custom serializer
6672
implicit object {{classname}}Serializer extends Serializer[{{classname}}] {
@@ -103,8 +109,72 @@ object {{classname}} {
103109
}
104110
}
105111
{{/vendorExtensions.x-use-discr}}
112+
{{/vendorExtensions.x-hasWrappedOneOfMembers}}
113+
{{#vendorExtensions.x-hasWrappedOneOfMembers}}
114+
{{^vendorExtensions.x-use-discr}}
115+
// oneOf without discriminator - json4s custom serializer (with wrapper composition)
116+
implicit object {{classname}}Serializer extends Serializer[{{classname}}] {
117+
private def tryDeserialize(json: JValue)(implicit format: Formats): Option[{{classname}}] = {
118+
{{#vendorExtensions.x-oneOfMembers}}
119+
scala.util.Try(Extraction.extract[{{classname}}](json)).toOption match {
120+
case Some(x) => return Some(x)
121+
case None =>
122+
}
123+
{{/vendorExtensions.x-oneOfMembers}}
124+
{{#vendorExtensions.x-wrappedOneOfMembers}}
125+
scala.util.Try(Extraction.extract[{{classname}}](json)).toOption match {
126+
case Some(x) => return Some({{wrapperClassname}}(x))
127+
case None =>
128+
}
129+
{{/vendorExtensions.x-wrappedOneOfMembers}}
130+
None
131+
}
132+
133+
def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = {
134+
case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) =>
135+
tryDeserialize(json).getOrElse(throw new MappingException(s"Can't convert $json to {{classname}}"))
136+
}
137+
138+
def serialize(implicit format: Formats): PartialFunction[Any, JValue] = {
139+
{{#vendorExtensions.x-oneOfMembers}}
140+
case x: {{classname}} => Extraction.decompose(x)
141+
{{/vendorExtensions.x-oneOfMembers}}
142+
{{#vendorExtensions.x-wrappedOneOfMembers}}
143+
case {{wrapperClassname}}(v) => Extraction.decompose(v)
144+
{{/vendorExtensions.x-wrappedOneOfMembers}}
145+
}
146+
}
147+
{{/vendorExtensions.x-use-discr}}
148+
{{#vendorExtensions.x-use-discr}}
149+
// oneOf with discriminator (with wrapper composition)
150+
implicit object {{classname}}Serializer extends Serializer[{{classname}}] {
151+
def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = {
152+
case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) =>
153+
(json \ "{{discriminator.propertyName}}") match {
154+
{{#vendorExtensions.x-oneOfMembers}}
155+
case JString("{{vendorExtensions.x-discriminator-value}}") => Extraction.extract[{{classname}}](json)
156+
{{/vendorExtensions.x-oneOfMembers}}
157+
{{#vendorExtensions.x-wrappedOneOfMembers}}
158+
case JString("{{discriminatorValue}}") => {{wrapperClassname}}(Extraction.extract[{{classname}}](json))
159+
{{/vendorExtensions.x-wrappedOneOfMembers}}
160+
case _ => throw new MappingException(s"Unknown discriminator value in $json")
161+
}
162+
}
163+
164+
def serialize(implicit format: Formats): PartialFunction[Any, JValue] = {
165+
{{#vendorExtensions.x-oneOfMembers}}
166+
case x: {{classname}} => Extraction.decompose(x).merge(JObject("{{discriminator.propertyName}}" -> JString("{{vendorExtensions.x-discriminator-value}}")))
167+
{{/vendorExtensions.x-oneOfMembers}}
168+
{{#vendorExtensions.x-wrappedOneOfMembers}}
169+
case {{wrapperClassname}}(v) => Extraction.decompose(v).merge(JObject("{{discriminator.propertyName}}" -> JString("{{discriminatorValue}}")))
170+
{{/vendorExtensions.x-wrappedOneOfMembers}}
171+
}
172+
}
173+
{{/vendorExtensions.x-use-discr}}
174+
{{/vendorExtensions.x-hasWrappedOneOfMembers}}
106175
{{/json4s}}
107176
{{#circe}}
177+
{{^vendorExtensions.x-hasWrappedOneOfMembers}}
108178
{{^vendorExtensions.x-use-discr}}
109179
// oneOf without discriminator - using semiauto derivation
110180
import io.circe.{Encoder, Decoder}
@@ -131,6 +201,54 @@ object {{classname}} {
131201
implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder
132202
implicit val decoder: Decoder[{{classname}}] = deriveConfiguredDecoder
133203
{{/vendorExtensions.x-use-discr}}
204+
{{/vendorExtensions.x-hasWrappedOneOfMembers}}
205+
{{#vendorExtensions.x-hasWrappedOneOfMembers}}
206+
import io.circe.{Encoder, Decoder}
207+
import io.circe.syntax._
208+
{{^vendorExtensions.x-use-discr}}
209+
// oneOf without discriminator (with wrapper composition)
210+
implicit val encoder: Encoder[{{classname}}] = Encoder.instance {
211+
{{#vendorExtensions.x-oneOfMembers}}
212+
case x: {{classname}} => {{classname}}.encoder(x)
213+
{{/vendorExtensions.x-oneOfMembers}}
214+
{{#vendorExtensions.x-wrappedOneOfMembers}}
215+
case {{wrapperClassname}}(v) => Encoder[{{classname}}].apply(v)
216+
{{/vendorExtensions.x-wrappedOneOfMembers}}
217+
}
218+
219+
implicit val decoder: Decoder[{{classname}}] = List[Decoder[{{classname}}]](
220+
{{#vendorExtensions.x-oneOfMembers}}
221+
Decoder[{{classname}}].widen,
222+
{{/vendorExtensions.x-oneOfMembers}}
223+
{{#vendorExtensions.x-wrappedOneOfMembers}}
224+
Decoder[{{classname}}].map({{wrapperClassname}}.apply),
225+
{{/vendorExtensions.x-wrappedOneOfMembers}}
226+
).reduceLeft(_ or _)
227+
{{/vendorExtensions.x-use-discr}}
228+
{{#vendorExtensions.x-use-discr}}
229+
// oneOf with discriminator (with wrapper composition)
230+
implicit val encoder: Encoder[{{classname}}] = Encoder.instance {
231+
{{#vendorExtensions.x-oneOfMembers}}
232+
case x: {{classname}} => {{classname}}.encoder(x)
233+
{{/vendorExtensions.x-oneOfMembers}}
234+
{{#vendorExtensions.x-wrappedOneOfMembers}}
235+
case {{wrapperClassname}}(v) => Encoder[{{classname}}].apply(v)
236+
{{/vendorExtensions.x-wrappedOneOfMembers}}
237+
}
238+
239+
implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c =>
240+
c.get[String]("{{discriminator.propertyName}}").flatMap {
241+
{{#vendorExtensions.x-oneOfMembers}}
242+
case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}]({{classname}}.decoder).map(x => x: {{parentClassname}})
243+
{{/vendorExtensions.x-oneOfMembers}}
244+
{{#vendorExtensions.x-wrappedOneOfMembers}}
245+
case "{{discriminatorValue}}" => c.as[{{classname}}]({{classname}}.decoder).map({{wrapperClassname}}.apply)
246+
{{/vendorExtensions.x-wrappedOneOfMembers}}
247+
case other => Left(io.circe.DecodingFailure(s"Unknown {{discriminator.propertyName}}: $$other", c.history))
248+
}
249+
}
250+
{{/vendorExtensions.x-use-discr}}
251+
{{/vendorExtensions.x-hasWrappedOneOfMembers}}
134252
{{/circe}}
135253
}
136254
{{/vendorExtensions.x-isSealedTrait}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,25 +81,27 @@ public void verifyOneOfSupportWithCirce() throws IOException {
8181
generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false");
8282
generator.opts(input).generate();
8383

84-
// Test oneOf without discriminator generates sealed trait with semiauto
84+
// Test oneOf without discriminator generates sealed trait with wrapper composition
85+
// (Dog and Cat are shared across Pet and Animal)
8586
Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala");
8687
assertFileContains(petPath, "sealed trait Pet");
8788
assertFileContains(petPath, "object Pet {");
88-
assertFileContains(petPath, "import io.circe.generic.semiauto._");
89-
assertFileContains(petPath, "// oneOf without discriminator - using semiauto derivation");
90-
assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder");
91-
assertFileContains(petPath, "implicit val decoder: Decoder[Pet] = deriveDecoder");
92-
93-
// Test oneOf with discriminator uses semiauto with Configuration
89+
assertFileContains(petPath, "case class DogPet(value: Dog) extends Pet");
90+
assertFileContains(petPath, "case class CatPet(value: Cat) extends Pet");
91+
assertFileContains(petPath, "// oneOf without discriminator (with wrapper composition)");
92+
assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = Encoder.instance");
93+
assertFileContains(petPath, "Decoder[Dog].map(DogPet.apply)");
94+
assertFileContains(petPath, "Decoder[Cat].map(CatPet.apply)");
95+
96+
// Test oneOf with discriminator uses wrapper composition (shared members)
9497
Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala");
9598
assertFileContains(animalPath, "sealed trait Animal");
9699
assertFileContains(animalPath, "object Animal {");
97-
assertFileContains(animalPath, "import io.circe.generic.extras.semiauto._");
98-
assertFileContains(animalPath, "// oneOf with discriminator - using semiauto derivation with Configuration");
99-
assertFileContains(animalPath,
100-
"private implicit val config: Configuration = Configuration.default.withDiscriminator(\"petType\")");
101-
assertFileContains(animalPath, "implicit val encoder: Encoder[Animal] = deriveConfiguredEncoder");
102-
assertFileContains(animalPath, "implicit val decoder: Decoder[Animal] = deriveConfiguredDecoder");
100+
assertFileContains(animalPath, "case class DogAnimal(value: Dog) extends Animal");
101+
assertFileContains(animalPath, "case class CatAnimal(value: Cat) extends Animal");
102+
assertFileContains(animalPath, "// oneOf with discriminator (with wrapper composition)");
103+
assertFileContains(animalPath, "implicit val encoder: Encoder[Animal] = Encoder.instance");
104+
assertFileContains(animalPath, "c.get[String](\"petType\")");
103105

104106
// Test oneOf with discriminator mapping
105107
Path vehiclePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Vehicle.scala");
@@ -144,20 +146,27 @@ public void verifyOneOfSupportWithJson4s() throws IOException {
144146
generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false");
145147
generator.opts(input).generate();
146148

147-
// Test oneOf without discriminator generates sealed trait with json4s
149+
// Test oneOf without discriminator generates sealed trait with json4s wrapper composition
150+
// (Dog and Cat are shared across Pet and Animal)
148151
Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala");
149152
assertFileContains(petPath, "sealed trait Pet");
150153
assertFileContains(petPath, "object Pet {");
154+
assertFileContains(petPath, "case class DogPet(value: Dog) extends Pet");
155+
assertFileContains(petPath, "case class CatPet(value: Cat) extends Pet");
151156
assertFileContains(petPath, "import org.json4s._");
152-
assertFileContains(petPath, "// oneOf without discriminator - json4s custom serializer");
157+
assertFileContains(petPath, "// oneOf without discriminator - json4s custom serializer (with wrapper composition)");
153158
assertFileContains(petPath, "implicit object PetSerializer extends Serializer[Pet]");
159+
assertFileContains(petPath, "Some(DogPet(x))");
160+
assertFileContains(petPath, "Some(CatPet(x))");
154161
assertFileContains(petPath, "Extraction.extract[Dog](json)");
155162
assertFileContains(petPath, "Extraction.extract[Cat](json)");
156163

157-
// Test oneOf with discriminator
164+
// Test oneOf with discriminator uses wrapper composition (shared members)
158165
Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala");
159166
assertFileContains(animalPath, "sealed trait Animal");
160-
assertFileContains(animalPath, "// oneOf with discriminator");
167+
assertFileContains(animalPath, "case class DogAnimal(value: Dog) extends Animal");
168+
assertFileContains(animalPath, "case class CatAnimal(value: Cat) extends Animal");
169+
assertFileContains(animalPath, "// oneOf with discriminator (with wrapper composition)");
161170
assertFileContains(animalPath, "petType");
162171
}
163172

0 commit comments

Comments
 (0)