From 1bd15af8e116bf2e866d2bf3aba6ef1c3cb6bb3d Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Thu, 26 Feb 2026 15:09:24 -0500 Subject: [PATCH 1/4] Add inline fields support on union type entries Union type entries now support an optional `fields` property. When present, a model is auto-generated using the type name with the specified fields. Use an empty array for types that carry no data. This provides a compact way to define union member types inline without separate model declarations. Co-Authored-By: Claude Opus 4.6 --- app/app/views/doc/apiJson.scala.html | 2 + .../api_json/InternalApiJsonForm.scala | 35 +- .../test/core/UnionTypeInlineFieldsSpec.scala | 366 ++++++++++++++++++ spec/apibuilder-api-json.json | 1 + spec/apibuilder-spec.json | 1 + 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 core/test/core/UnionTypeInlineFieldsSpec.scala diff --git a/app/app/views/doc/apiJson.scala.html b/app/app/views/doc/apiJson.scala.html index c133bc67c..05ac66e25 100644 --- a/app/app/views/doc/apiJson.scala.html +++ b/app/app/views/doc/apiJson.scala.html @@ -447,6 +447,7 @@

UnionType declaration

"description": string (optional), "default": boolean (optional), "discriminator_value": string (optional), + "fields": JSON Array of Field (optional), "attributes": JSON Array of Attribute (optional), "deprecation": JSON Object of Deprecation (optional) } @@ -460,6 +461,7 @@

UnionType declaration

@description("type")
  • default If true, indicates that this type should be used as the default when deserializing union types. This field is only used by union types that require a discriminator and sets the default value for that disciminator during deserialization.
  • discriminator_value The discriminator value defines the string to use in the discriminator field to identify this type. If not specified, the discriminator value will default to the name of the type itself.
  • +
  • fields When present, a model is automatically generated using the type name with the specified fields. The generated model becomes a member of the union. Use an empty array for types that carry no data. This provides a compact way to define union member types inline without separate model declarations. See Field.
  • attributes JSON array defining additional meta data about this union type for use by generators. See Attribute
  • deprecation JSON Object that indicates that this object is deprecated.
  • diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index 8cf9ca4f6..e14d9eee4 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -52,6 +52,30 @@ private[api_json] case class InternalApiJsonForm( } } + // Models synthesized from union type entries that have inline fields. + // Field-level warnings are carried on each InternalFieldForm and validated via validateModels. + private lazy val syntheticModelsFromUnions: Seq[InternalModelForm] = { + declaredUnions.flatMap { union => + union.types.flatMap { ut => + ut.inlineFields.flatMap { fields => + ut.datatype.toOption.map { dt => + InternalModelForm( + name = dt.name, + plural = Text.pluralize(dt.name), + description = ut.description, + deprecation = ut.deprecation, + fields = fields, + attributes = Nil, + interfaces = Nil, + templates = Nil, + warnings = ().validNec + ) + } + } + } + } + } + def unions: Seq[InternalUnionForm] = declaredUnions ++ internalDatatypeBuilder.unionForms private def parseModels(js: JsValue, prefix: Option[String]): Seq[InternalModelForm] = { @@ -96,7 +120,7 @@ private[api_json] case class InternalApiJsonForm( } } ++ internalDatatypeBuilder.interfaceForms - def models: Seq[InternalModelForm] = declaredModels ++ internalDatatypeBuilder.modelForms + def models: Seq[InternalModelForm] = declaredModels ++ internalDatatypeBuilder.modelForms ++ syntheticModelsFromUnions private lazy val declaredEnums: Seq[InternalEnumForm] = { (json \ "enums").asOpt[JsValue] match { @@ -274,6 +298,7 @@ case class InternalUnionTypeForm( attributes: Seq[InternalAttributeForm], default: Option[Boolean], discriminatorValue: Option[String], + inlineFields: Option[Seq[InternalFieldForm]], warnings: ValidatedNec[String, Unit] ) @@ -448,12 +473,18 @@ object InternalUnionForm { val internalDatatype = internalDatatypeBuilder.parseTypeFromObject(json) val datatypeName = internalDatatype.toOption.map(_.name) + val inlineFields: Option[Seq[InternalFieldForm]] = + (json \ "fields").asOpt[JsArray].map { _ => + InternalFieldForm.parse(internalDatatypeBuilder, json) + } + InternalUnionTypeForm( datatype = internalDatatype, description = JsonUtil.asOptString(json \ "description"), deprecation = InternalDeprecationForm.fromJsValue(json), default = JsonUtil.asOptBoolean(json \ "default"), discriminatorValue = JsonUtil.asOptString(json \ "discriminator_value"), + inlineFields = inlineFields, attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]), warnings = JsonUtil.validate( json, @@ -461,7 +492,7 @@ object InternalUnionForm { optionalStrings = Seq("description", "discriminator_value"), optionalBooleans = Seq("default"), optionalObjects = Seq("deprecation"), - optionalArraysOfObjects = Seq("attributes"), + optionalArraysOfObjects = Seq("attributes", "fields"), prefix = Some(s"Union[$name] type[${datatypeName.getOrElse("")}]") ) ) diff --git a/core/test/core/UnionTypeInlineFieldsSpec.scala b/core/test/core/UnionTypeInlineFieldsSpec.scala new file mode 100644 index 000000000..623b1e68e --- /dev/null +++ b/core/test/core/UnionTypeInlineFieldsSpec.scala @@ -0,0 +1,366 @@ +package core + +import helpers.ApiJsonHelpers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class UnionTypeInlineFieldsSpec extends AnyFunSpec with Matchers with ApiJsonHelpers { + + it("union type with inline fields generates a model") { + val json = + """ + { + "name": "API Builder", + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { + "type": "merge_person", + "fields": [ + { "name": "user_id", "type": "string" }, + { "name": "person_id", "type": "string" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + + val model = service.models.find(_.name == "merge_person").getOrElse { + sys.error("No merge_person model created") + } + model.fields.map(_.name) should equal(Seq("user_id", "person_id")) + model.fields.map(_.`type`) should equal(Seq("string", "string")) + + val union = service.unions.find(_.name == "task_type").getOrElse { + sys.error("No task_type union created") + } + union.types.map(_.`type`) should equal(Seq("merge_person")) + } + + it("union type with empty fields generates an empty model") { + val json = + """ + { + "name": "API Builder", + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { "type": "password_reset", "fields": [] } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + + val model = service.models.find(_.name == "password_reset").getOrElse { + sys.error("No password_reset model created") + } + model.fields should be(empty) + + val union = service.unions.find(_.name == "task_type").getOrElse { + sys.error("No task_type union created") + } + union.types.map(_.`type`) should equal(Seq("password_reset")) + } + + it("discriminator value defaults to the type name") { + val json = + """ + { + "name": "API Builder", + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { "type": "game", "fields": [] }, + { + "type": "merge_person", + "fields": [ + { "name": "user_id", "type": "string" }, + { "name": "person_id", "type": "string" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val union = service.unions.find(_.name == "task_type").getOrElse { + sys.error("No task_type union created") + } + + val gameType = union.types.find(_.`type` == "game").getOrElse { + sys.error("No game type found") + } + gameType.discriminatorValue should be(Some("game")) + + val mergeType = union.types.find(_.`type` == "merge_person").getOrElse { + sys.error("No merge_person type found") + } + mergeType.discriminatorValue should be(Some("merge_person")) + } + + it("explicit discriminator_value is preserved") { + val json = + """ + { + "name": "API Builder", + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { "type": "game", "discriminator_value": "custom_game", "fields": [] } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val union = service.unions.find(_.name == "task_type").getOrElse { + sys.error("No task_type union created") + } + union.types.head.discriminatorValue should be(Some("custom_game")) + } + + it("mixed inline and pre-existing model references") { + val json = + """ + { + "name": "API Builder", + + "models": { + "existing_model": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "my_union": { + "discriminator": "discriminator", + "types": [ + { "type": "existing_model" }, + { + "type": "inline_type", + "fields": [ + { "name": "value", "type": "string" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + + service.models.find(_.name == "existing_model").getOrElse { + sys.error("No existing_model found") + }.fields.map(_.name) should equal(Seq("id")) + + service.models.find(_.name == "inline_type").getOrElse { + sys.error("No inline_type model created") + }.fields.map(_.name) should equal(Seq("value")) + + val union = service.unions.find(_.name == "my_union").getOrElse { + sys.error("No my_union union created") + } + union.types.map(_.`type`) should equal(Seq("existing_model", "inline_type")) + } + + it("inline fields with various types") { + val json = + """ + { + "name": "API Builder", + + "enums": { + "operation": { + "values": [ + { "name": "insert" }, + { "name": "update" }, + { "name": "delete" } + ] + } + }, + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { + "type": "notify", + "description": "Notify directors of contact change", + "fields": [ + { "name": "contact_id", "type": "string" }, + { "name": "operation", "type": "operation" }, + { "name": "changes", "type": "[string]", "required": false } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + + val model = service.models.find(_.name == "notify").getOrElse { + sys.error("No notify model created") + } + model.fields.map(_.name) should equal(Seq("contact_id", "operation", "changes")) + model.fields.find(_.name == "operation").get.`type` should equal("operation") + model.fields.find(_.name == "changes").get.`type` should equal("[string]") + model.fields.find(_.name == "changes").get.required should be(false) + model.description should be(Some("Notify directors of contact change")) + } + + it("backward compatible - unions without fields work as before") { + val json = + """ + { + "name": "API Builder", + + "models": { + "registered": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + }, + "guest": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "user": { + "types": [ + { "type": "registered" }, + { "type": "guest" } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val union = service.unions.find(_.name == "user").getOrElse { + sys.error("No user union created") + } + union.types.map(_.`type`) should equal(Seq("registered", "guest")) + + service.models.map(_.name).sorted should equal(Seq("guest", "registered")) + } + + it("duplicate name between inline fields and declared model produces error") { + val json = + """ + { + "name": "API Builder", + + "models": { + "merge_person": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "task_type": { + "discriminator": "discriminator", + "types": [ + { + "type": "merge_person", + "fields": [ + { "name": "user_id", "type": "string" } + ] + } + ] + } + } + } + """ + + TestHelper.expectSingleError(json) should be("Model[merge_person] appears more than once") + } + + it("integration: multi-union spec with data and no-data types") { + val json = + """ + { + "name": "API Builder", + + "enums": { + "operation": { + "values": [ + { "name": "insert" }, + { "name": "update" } + ] + } + }, + + "unions": { + "task_type_global": { + "discriminator": "discriminator", + "types": [ + { "type": "password_reset", "fields": [] }, + { "type": "email_verification", "fields": [] }, + { "type": "optin_request", "fields": [ + { "name": "person_id", "type": "string" } + ]} + ] + }, + "task_type_rallyd": { + "discriminator": "discriminator", + "types": [ + { "type": "game", "fields": [] }, + { "type": "merge_person", "fields": [ + { "name": "user_id", "type": "string" }, + { "name": "person_id", "type": "string" } + ]}, + { "type": "notify", "fields": [ + { "name": "contact_id", "type": "string" }, + { "name": "op", "type": "operation" } + ]} + ] + } + } + } + """ + + val service = setupValidApiJson(json) + + service.unions.map(_.name).sorted should equal(Seq("task_type_global", "task_type_rallyd")) + + // no-data types generate empty models + service.models.find(_.name == "password_reset").get.fields should be(empty) + service.models.find(_.name == "game").get.fields should be(empty) + + // data types generate models with fields + service.models.find(_.name == "merge_person").get.fields.map(_.name) should equal(Seq("user_id", "person_id")) + service.models.find(_.name == "optin_request").get.fields.map(_.name) should equal(Seq("person_id")) + service.models.find(_.name == "notify").get.fields.find(_.name == "op").get.`type` should equal("operation") + + // 6 types total = 6 models + service.models should have size 6 + } +} diff --git a/spec/apibuilder-api-json.json b/spec/apibuilder-api-json.json index a8b61ab20..82049d3fa 100644 --- a/spec/apibuilder-api-json.json +++ b/spec/apibuilder-api-json.json @@ -203,6 +203,7 @@ { "name": "description", "type": "string", "required": false }, { "name": "default", "type": "boolean", "required": false, "default": false }, { "name": "discriminator_value", "type": "string", "required": false }, + { "name": "fields", "type": "[field]", "required": false, "description": "When present, API Builder auto-generates a model with these fields using the type name. The generated model becomes a member of the union. Use an empty array for types that carry no data." }, { "name": "attributes", "type": "[attribute]", "required": false }, { "name": "deprecation", "type": "deprecation", "required": false } ] diff --git a/spec/apibuilder-spec.json b/spec/apibuilder-spec.json index a3f7d4bda..397a6334a 100644 --- a/spec/apibuilder-spec.json +++ b/spec/apibuilder-spec.json @@ -147,6 +147,7 @@ { "name": "type", "type": "string", "description": "The name of a type (a primitive, model name, or enum name) that makes up this union type" }, { "name": "description", "type": "string", "required": false }, { "name": "deprecation", "type": "deprecation", "required": false }, + { "name": "fields", "type": "[field]", "required": false, "description": "When present, a model is automatically generated using the type name with the specified fields. The generated model becomes a member of the union. Use an empty array for types that carry no data." }, { "name": "attributes", "type": "[attribute]", "default": "[]" }, { "name": "default", "type": "boolean", "required": false, "description": "If true, indicates that this type should be used as the default when deserializing union types. This field is only used by union types that require a discriminator and sets the default value for that discriminator during deserialization." }, { "name": "discriminator_value", "type": "string", "required": false, "description": "The discriminator value defines the string to use in the discriminator field to identify this type. If not specified, the discriminator value will default to the name of the type itself." } From f54ef169933c8c8a815291773ab5e413e7512420 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Thu, 26 Feb 2026 15:32:14 -0500 Subject: [PATCH 2/4] Fix CI: add sbt/setup-sbt action since ubuntu-latest no longer ships sbt Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f583db7cf..e676fd0e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: with: java-version: ${{ matrix.java }} distribution: 'zulu' + - name: Set up sbt + uses: sbt/setup-sbt@v1 - name: Build run: | docker run -d -p 127.0.0.1:5432:5432 flowcommerce/apibuilder-postgresql:latest-pg15 From d12ccc8e871bf92781f16eae503c4b58d0354fd9 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Thu, 26 Feb 2026 16:10:52 -0500 Subject: [PATCH 3/4] wip --- .../api_json/InternalApiJsonForm.scala | 49 +++++++++---------- .../builder/api_json/InternalDatatype.scala | 2 + .../app/builder/api_json/ServiceBuilder.scala | 1 + .../ApicollectiveApibuilderSpecV0Client.scala | 4 +- .../ApicollectiveApibuilderSpecV0Models.scala | 8 ++- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index e14d9eee4..fc47e6a89 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -52,29 +52,6 @@ private[api_json] case class InternalApiJsonForm( } } - // Models synthesized from union type entries that have inline fields. - // Field-level warnings are carried on each InternalFieldForm and validated via validateModels. - private lazy val syntheticModelsFromUnions: Seq[InternalModelForm] = { - declaredUnions.flatMap { union => - union.types.flatMap { ut => - ut.inlineFields.flatMap { fields => - ut.datatype.toOption.map { dt => - InternalModelForm( - name = dt.name, - plural = Text.pluralize(dt.name), - description = ut.description, - deprecation = ut.deprecation, - fields = fields, - attributes = Nil, - interfaces = Nil, - templates = Nil, - warnings = ().validNec - ) - } - } - } - } - } def unions: Seq[InternalUnionForm] = declaredUnions ++ internalDatatypeBuilder.unionForms @@ -120,7 +97,7 @@ private[api_json] case class InternalApiJsonForm( } } ++ internalDatatypeBuilder.interfaceForms - def models: Seq[InternalModelForm] = declaredModels ++ internalDatatypeBuilder.modelForms ++ syntheticModelsFromUnions + def models: Seq[InternalModelForm] = declaredModels ++ internalDatatypeBuilder.modelForms private lazy val declaredEnums: Seq[InternalEnumForm] = { (json \ "enums").asOpt[JsValue] match { @@ -298,7 +275,7 @@ case class InternalUnionTypeForm( attributes: Seq[InternalAttributeForm], default: Option[Boolean], discriminatorValue: Option[String], - inlineFields: Option[Seq[InternalFieldForm]], + fields: Option[Seq[InternalFieldForm]], warnings: ValidatedNec[String, Unit] ) @@ -473,18 +450,36 @@ object InternalUnionForm { val internalDatatype = internalDatatypeBuilder.parseTypeFromObject(json) val datatypeName = internalDatatype.toOption.map(_.name) - val inlineFields: Option[Seq[InternalFieldForm]] = + val fields: Option[Seq[InternalFieldForm]] = (json \ "fields").asOpt[JsArray].map { _ => InternalFieldForm.parse(internalDatatypeBuilder, json) } + fields.foreach { fieldForms => + datatypeName.foreach { name => + internalDatatypeBuilder.addDynamicModel( + InternalModelForm( + name = name, + plural = Text.pluralize(name), + description = JsonUtil.asOptString(json \ "description"), + deprecation = InternalDeprecationForm.fromJsValue(json), + fields = fieldForms, + attributes = Nil, + interfaces = Nil, + templates = Nil, + warnings = ().validNec + ) + ) + } + } + InternalUnionTypeForm( datatype = internalDatatype, description = JsonUtil.asOptString(json \ "description"), deprecation = InternalDeprecationForm.fromJsValue(json), default = JsonUtil.asOptBoolean(json \ "default"), discriminatorValue = JsonUtil.asOptString(json \ "discriminator_value"), - inlineFields = inlineFields, + fields = fields, attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]), warnings = JsonUtil.validate( json, diff --git a/core/app/builder/api_json/InternalDatatype.scala b/core/app/builder/api_json/InternalDatatype.scala index fe62c5791..8d2aeeea1 100644 --- a/core/app/builder/api_json/InternalDatatype.scala +++ b/core/app/builder/api_json/InternalDatatype.scala @@ -63,6 +63,8 @@ private[api_json] case class InternalDatatypeBuilder() { def interfaceForms: List[InternalInterfaceForm] = dynamicInterfaces.toList def unionForms: List[InternalUnionForm] = dynamicUnions.toList + def addDynamicModel(model: InternalModelForm): Unit = dynamicModels.append(model) + private val ListRx = "^\\[(.*)\\]$".r private val MapRx = "^map\\[(.*)\\]$".r private val DefaultMapRx = "^map$".r diff --git a/core/app/builder/api_json/ServiceBuilder.scala b/core/app/builder/api_json/ServiceBuilder.scala index 5177e0adc..df33e8939 100644 --- a/core/app/builder/api_json/ServiceBuilder.scala +++ b/core/app/builder/api_json/ServiceBuilder.scala @@ -374,6 +374,7 @@ case class ServiceBuilder( `type` = typ.label, description = it.description, deprecation = it.deprecation.map(DeprecationBuilder(_)), + fields = it.fields.map(_.map(FieldBuilder(_))), default = it.default, discriminatorValue = Some( it.discriminatorValue.getOrElse(typ.name) diff --git a/generated/app/ApicollectiveApibuilderSpecV0Client.scala b/generated/app/ApicollectiveApibuilderSpecV0Client.scala index 0b7997e39..126f77119 100644 --- a/generated/app/ApicollectiveApibuilderSpecV0Client.scala +++ b/generated/app/ApicollectiveApibuilderSpecV0Client.scala @@ -320,6 +320,7 @@ package io.apibuilder.spec.v0.models { `type`: String, description: _root_.scala.Option[String] = None, deprecation: _root_.scala.Option[io.apibuilder.spec.v0.models.Deprecation] = None, + fields: _root_.scala.Option[Seq[io.apibuilder.spec.v0.models.Field]] = None, attributes: Seq[io.apibuilder.spec.v0.models.Attribute] = Nil, default: _root_.scala.Option[Boolean] = None, discriminatorValue: _root_.scala.Option[String] = None @@ -1351,10 +1352,11 @@ package io.apibuilder.spec.v0.models { `type` <- (__ \ "type").read[String] description <- (__ \ "description").readNullable[String] deprecation <- (__ \ "deprecation").readNullable[io.apibuilder.spec.v0.models.Deprecation] + fields <- (__ \ "fields").readNullable[Seq[io.apibuilder.spec.v0.models.Field]] attributes <- (__ \ "attributes").read[Seq[io.apibuilder.spec.v0.models.Attribute]] default <- (__ \ "default").readNullable[Boolean] discriminatorValue <- (__ \ "discriminator_value").readNullable[String] - } yield UnionType(`type`, description, deprecation, attributes, default, discriminatorValue) + } yield UnionType(`type`, description, deprecation, fields, attributes, default, discriminatorValue) } def jsObjectUnionType(obj: io.apibuilder.spec.v0.models.UnionType): play.api.libs.json.JsObject = { diff --git a/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala b/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala index c6f32fb6a..c9d5a1fb2 100644 --- a/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala +++ b/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala @@ -320,6 +320,7 @@ package io.apibuilder.spec.v0.models { `type`: String, description: _root_.scala.Option[String] = None, deprecation: _root_.scala.Option[io.apibuilder.spec.v0.models.Deprecation] = None, + fields: _root_.scala.Option[Seq[io.apibuilder.spec.v0.models.Field]] = None, attributes: Seq[io.apibuilder.spec.v0.models.Attribute] = Nil, default: _root_.scala.Option[Boolean] = None, discriminatorValue: _root_.scala.Option[String] = None @@ -1351,10 +1352,11 @@ package io.apibuilder.spec.v0.models { `type` <- (__ \ "type").read[String] description <- (__ \ "description").readNullable[String] deprecation <- (__ \ "deprecation").readNullable[io.apibuilder.spec.v0.models.Deprecation] + fields <- (__ \ "fields").readNullable[Seq[io.apibuilder.spec.v0.models.Field]] attributes <- (__ \ "attributes").read[Seq[io.apibuilder.spec.v0.models.Attribute]] default <- (__ \ "default").readNullable[Boolean] discriminatorValue <- (__ \ "discriminator_value").readNullable[String] - } yield UnionType(`type`, description, deprecation, attributes, default, discriminatorValue) + } yield UnionType(`type`, description, deprecation, fields, attributes, default, discriminatorValue) } def jsObjectUnionType(obj: io.apibuilder.spec.v0.models.UnionType): play.api.libs.json.JsObject = { @@ -1369,6 +1371,10 @@ package io.apibuilder.spec.v0.models { case None => play.api.libs.json.Json.obj() case Some(x) => play.api.libs.json.Json.obj("deprecation" -> io.apibuilder.spec.v0.models.json.jsObjectDeprecation(x)) }) ++ + (obj.fields match { + case None => play.api.libs.json.Json.obj() + case Some(x) => play.api.libs.json.Json.obj("fields" -> play.api.libs.json.Json.toJson(x)) + }) ++ (obj.default match { case None => play.api.libs.json.Json.obj() case Some(x) => play.api.libs.json.Json.obj("default" -> play.api.libs.json.JsBoolean(x)) From 9db11035587d059e37063da73ba8bbfcc9f4c037 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 12:21:49 -0500 Subject: [PATCH 4/4] wip --- core/app/builder/api_json/InternalApiJsonForm.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index fc47e6a89..d9d12c8e4 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -455,7 +455,7 @@ object InternalUnionForm { InternalFieldForm.parse(internalDatatypeBuilder, json) } - fields.foreach { fieldForms => + fields.filter(_.nonEmpty).foreach { fieldForms => datatypeName.foreach { name => internalDatatypeBuilder.addDynamicModel( InternalModelForm(