From be021d4818dd4440c79b1795aaf5959cb567ff29 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 12:27:47 -0500 Subject: [PATCH 1/9] wip --- spec/apibuilder-spec.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/apibuilder-spec.json b/spec/apibuilder-spec.json index a3f7d4bda..1e0979082 100644 --- a/spec/apibuilder-spec.json +++ b/spec/apibuilder-spec.json @@ -113,7 +113,7 @@ { "name": "description", "type": "string", "required": false }, { "name": "deprecation", "type": "deprecation", "required": false }, { "name": "attributes", "type": "[attribute]", "default": "[]" }, - { "name": "value", "type": "string", "required": false, "description": "The actual string representation of this value. If not specified, defaults to 'name'" } + { "name": "value", "type": "string", "required": false, "description": "The actual string representation of this value. If not specified, defaults to 'name'" } ] }, @@ -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 }, { "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." } @@ -311,7 +312,7 @@ { "name": "deprecation", "type": "deprecation", "required": false } ] }, - + "annotation": { "description": "Used to indicate an API concern for a field that is specific to the field's usage but not necessarily its data type. For example, you might use annotations to mark that certain fields contain PII or PCI data and thus should not be stored once processing is complete. Annotations communicate meaning to consumers of an API and may also be used within an implementation or tooling; for example, using static analysis tools to detect logging of sensitive data.", "fields": [ From 6281be3b92fc4edf456007e48f18945f908ba567 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 12:28:41 -0500 Subject: [PATCH 2/9] wip --- .devops/config | 1 + .devops/config~ | 1 + .../app/ApicollectiveApibuilderSpecV0Client.scala | 12 +++++++++--- .../ApicollectiveApibuilderSpecV0MockClient.scala | 3 ++- .../ApicollectiveApibuilderSpecV0Models.scala | 12 +++++++++--- 5 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 .devops/config create mode 100644 .devops/config~ diff --git a/.devops/config b/.devops/config new file mode 100644 index 000000000..6f8bfa6af --- /dev/null +++ b/.devops/config @@ -0,0 +1 @@ +apibuilder_organization=apicollective \ No newline at end of file diff --git a/.devops/config~ b/.devops/config~ new file mode 100644 index 000000000..fad0b1360 --- /dev/null +++ b/.devops/config~ @@ -0,0 +1 @@ +apibuilder_organization=bryzek \ No newline at end of file diff --git a/generated/app/ApicollectiveApibuilderSpecV0Client.scala b/generated/app/ApicollectiveApibuilderSpecV0Client.scala index 0b7997e39..80d853787 100644 --- a/generated/app/ApicollectiveApibuilderSpecV0Client.scala +++ b/generated/app/ApicollectiveApibuilderSpecV0Client.scala @@ -1,6 +1,6 @@ /** * Generated by API Builder - https://www.apibuilder.io - * Service version: 0.16.53 + * Service version: 0.16.74 * User agent: apibuilder app.apibuilder.io/apicollective/apibuilder-spec/latest/play_2_9_scala_3_client */ package io.apibuilder.spec.v0.models { @@ -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)) @@ -1575,7 +1581,7 @@ package io.apibuilder.spec.v0 { val Namespace = "io.apibuilder.spec.v0" val UserAgent = "apibuilder app.apibuilder.io/apicollective/apibuilder-spec/latest/play_2_9_scala_3_client" - val Version = "0.16.53" + val Version = "0.16.74" val VersionMajor = 0 } diff --git a/generated/app/ApicollectiveApibuilderSpecV0MockClient.scala b/generated/app/ApicollectiveApibuilderSpecV0MockClient.scala index 464e0983e..3dc4d9ffe 100644 --- a/generated/app/ApicollectiveApibuilderSpecV0MockClient.scala +++ b/generated/app/ApicollectiveApibuilderSpecV0MockClient.scala @@ -1,6 +1,6 @@ /** * Generated by API Builder - https://www.apibuilder.io - * Service version: 0.16.50 + * Service version: 0.16.74 * User agent: apibuilder app.apibuilder.io/apicollective/apibuilder-spec/latest/play_2_8_mock_client */ package io.apibuilder.spec.v0.mock { @@ -222,6 +222,7 @@ package io.apibuilder.spec.v0.mock { `type` = Factories.randomString(24), description = None, deprecation = None, + fields = None, attributes = Nil, default = None, discriminatorValue = None diff --git a/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala b/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala index c6f32fb6a..098227e5e 100644 --- a/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala +++ b/lib/src/main/scala/generated/ApicollectiveApibuilderSpecV0Models.scala @@ -1,7 +1,7 @@ /** * Generated by API Builder - https://www.apibuilder.io - * Service version: 0.16.53 - * User agent: apibuilder localhost 9000/apicollective/apibuilder-spec/latest/play_2_x_standalone_json + * Service version: 0.16.74 + * User agent: apibuilder app.apibuilder.io/apicollective/apibuilder-spec/latest/play_2_x_standalone_json */ package io.apibuilder.spec.v0.models { @@ -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 c543f4384678c4f9fd8da149397e9029035331e9 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 12:41:02 -0500 Subject: [PATCH 3/9] wip --- .../api_json/InternalApiJsonForm.scala | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index 8cf9ca4f6..4e786662a 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -42,10 +42,8 @@ private[api_json] case class InternalApiJsonForm( private lazy val declaredUnions: Seq[InternalUnionForm] = { (json \ "unions").asOpt[JsValue] match { case Some(unions: JsObject) => { - unions.fields.flatMap { v => - v match { - case(k, value) => value.asOpt[JsObject].map(InternalUnionForm(internalDatatypeBuilder, k, _)) - } + unions.fields.flatMap { + case (k, value) => value.asOpt[JsObject].map(InternalUnionForm(internalDatatypeBuilder, k, _)) } }.toSeq case _ => Seq.empty @@ -57,10 +55,8 @@ private[api_json] case class InternalApiJsonForm( private def parseModels(js: JsValue, prefix: Option[String]): Seq[InternalModelForm] = { (js \ "models").asOpt[JsObject] match { case Some(models) => { - models.fields.flatMap { v => - v match { - case (k, value) => value.asOpt[JsObject].map(InternalModelForm(internalDatatypeBuilder, k, _, prefix = prefix)) - } + models.fields.flatMap { + case (k, value) => value.asOpt[JsObject].map(InternalModelForm(internalDatatypeBuilder, k, _, prefix = prefix)) } }.toSeq case _ => Seq.empty @@ -86,25 +82,29 @@ private[api_json] case class InternalApiJsonForm( def interfaces: Seq[InternalInterfaceForm] = { (json \ "interfaces").asOpt[JsValue] match { case Some(interfaces: JsObject) => { - interfaces.fields.flatMap { v => - v match { - case(k, value) => value.asOpt[JsObject].map(InternalInterfaceForm(internalDatatypeBuilder, k, _)) - } + interfaces.fields.flatMap { + case (k, value) => value.asOpt[JsObject].map(InternalInterfaceForm(internalDatatypeBuilder, k, _)) } }.toSeq case _ => Seq.empty } } ++ internalDatatypeBuilder.interfaceForms - def models: Seq[InternalModelForm] = declaredModels ++ internalDatatypeBuilder.modelForms + def models: Seq[InternalModelForm] = { + val knownModels = declaredModels ++ internalDatatypeBuilder.modelForms + knownModels ++ createModelsForUnionTypes(knownModels) + } + + private def createModelsForUnionTypes(known: Seq[InternalModelForm]): Seq[InternalModelForm] = { + println(s"CREATE MODELS") + known + } private lazy val declaredEnums: Seq[InternalEnumForm] = { (json \ "enums").asOpt[JsValue] match { case Some(enums: JsObject) => { - enums.fields.flatMap { v => - v match { - case(k, value) => value.asOpt[JsObject].map(InternalEnumForm(k, _)) - } + enums.fields.flatMap { + case (k, value) => value.asOpt[JsObject].map(InternalEnumForm(k, _)) } }.toSeq case _ => Seq.empty @@ -131,11 +131,9 @@ private[api_json] case class InternalApiJsonForm( case None => Seq.empty case Some(resources: JsObject) => { - resources.fields.flatMap { v => - v match { - case (typeName, value) => { - value.asOpt[JsObject].map(InternalResourceForm(internalDatatypeBuilder, typeName, declaredModels, declaredEnums, unions, _)) - } + resources.fields.flatMap { + case (typeName, value) => { + value.asOpt[JsObject].map(InternalResourceForm(internalDatatypeBuilder, typeName, declaredModels, declaredEnums, unions, _)) } } }.toSeq @@ -269,6 +267,7 @@ case class InternalUnionForm( case class InternalUnionTypeForm( datatype: ValidatedNec[String, InternalDatatype], + fields: Seq[InternalFieldForm], description: Option[String], deprecation: Option[InternalDeprecationForm], attributes: Seq[InternalAttributeForm], @@ -450,6 +449,7 @@ object InternalUnionForm { InternalUnionTypeForm( datatype = internalDatatype, + fields = InternalFieldForm.parse(internalDatatypeBuilder, value), description = JsonUtil.asOptString(json \ "description"), deprecation = InternalDeprecationForm.fromJsValue(json), default = JsonUtil.asOptBoolean(json \ "default"), @@ -461,7 +461,7 @@ object InternalUnionForm { optionalStrings = Seq("description", "discriminator_value"), optionalBooleans = Seq("default"), optionalObjects = Seq("deprecation"), - optionalArraysOfObjects = Seq("attributes"), + optionalArraysOfObjects = Seq("fields", "attributes"), prefix = Some(s"Union[$name] type[${datatypeName.getOrElse("")}]") ) ) @@ -614,36 +614,34 @@ object InternalEnumForm { object InternalHeaderForm { def apply(internalDatatypeBuilder: InternalDatatypeBuilder, json: JsValue): Seq[InternalHeaderForm] = { - (json \ "headers").asOpt[JsArray].map(_.value).getOrElse(Seq.empty).flatMap { el => - el match { - case o: JsObject => { - val datatype = internalDatatypeBuilder.parseTypeFromObject(o) - - val headerName = JsonUtil.asOptString(o \ "name") - Some( - InternalHeaderForm( - name = headerName, - datatype = datatype, - required = InternalDatatype.isRequired(datatype), - description = JsonUtil.asOptString(o \ "description"), - deprecation = InternalDeprecationForm.fromJsValue(o), - default = JsonUtil.asOptString(o \ "default"), - attributes = InternalAttributeForm.fromJson((o \ "attributes").asOpt[JsArray]), - warnings = JsonUtil.validate( - o, - strings = Seq("name"), - anys = Seq("type"), - optionalBooleans = Seq("required"), - optionalObjects = Seq("deprecation"), - optionalStrings = Seq("default", "description"), - optionalArraysOfObjects = Seq("attributes"), - prefix = Some(s"Header[${headerName.getOrElse("")}]".trim) - ) + (json \ "headers").asOpt[JsArray].map(_.value).getOrElse(Seq.empty).flatMap { + case o: JsObject => { + val datatype = internalDatatypeBuilder.parseTypeFromObject(o) + + val headerName = JsonUtil.asOptString(o \ "name") + Some( + InternalHeaderForm( + name = headerName, + datatype = datatype, + required = InternalDatatype.isRequired(datatype), + description = JsonUtil.asOptString(o \ "description"), + deprecation = InternalDeprecationForm.fromJsValue(o), + default = JsonUtil.asOptString(o \ "default"), + attributes = InternalAttributeForm.fromJson((o \ "attributes").asOpt[JsArray]), + warnings = JsonUtil.validate( + o, + strings = Seq("name"), + anys = Seq("type"), + optionalBooleans = Seq("required"), + optionalObjects = Seq("deprecation"), + optionalStrings = Seq("default", "description"), + optionalArraysOfObjects = Seq("attributes"), + prefix = Some(s"Header[${headerName.getOrElse("")}]".trim) ) ) - } - case _ => None + ) } + case _ => None } }.toSeq } From 58ec130579a91134242436b614884013ce0319ef Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 13:15:13 -0500 Subject: [PATCH 4/9] wip --- .../api_json/InternalApiJsonForm.scala | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index 4e786662a..aee9e3f58 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -97,9 +97,30 @@ private[api_json] case class InternalApiJsonForm( private def createModelsForUnionTypes(known: Seq[InternalModelForm]): Seq[InternalModelForm] = { println(s"CREATE MODELS") + unions.flatMap { u => + u.types.filterNot(isTypeKnown).map { t => + println(s" - ${t.datatype}") + } + } known } + private def isTypeKnown(ut: InternalUnionTypeForm): Boolean = { + ut.datatype match { + case Invalid(_) => true + case Valid(ut) => { + true + } + } + ut.datatype.foreach { t => + if (t.name == "ping") { + sys.error("STACK") + } + } + println(s"Union isTypeKnown[${ut.datatype}]") + true + } + private lazy val declaredEnums: Seq[InternalEnumForm] = { (json \ "enums").asOpt[JsValue] match { case Some(enums: JsObject) => { @@ -146,10 +167,6 @@ private[api_json] case class InternalApiJsonForm( lazy val attributes: Seq[InternalAttributeForm] = InternalAttributeForm.fromJson((json \ "attributes").asOpt[JsArray]) - lazy val typeResolver: TypeResolver = TypeResolver( - defaultNamespace = namespace, - RecursiveTypesProvider(this) - ) } case class InternalImportForm( From c1d0e73a93cb2905d7e5c341c760c749cad52e7e Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 13:32:49 -0500 Subject: [PATCH 5/9] wip --- .../api_json/InternalApiJsonForm.scala | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index aee9e3f58..4e9c5604b 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -1,13 +1,13 @@ package builder.api_json -import cats.implicits._ +import cats.implicits.* import cats.data.ValidatedNec import builder.JsonUtil import builder.api_json.upgrades.AllUpgrades import cats.data.Validated.{Invalid, Valid} import core.ServiceFetcher -import lib.{Text, ValidatedHelpers} -import play.api.libs.json._ +import lib.{DatatypeResolver, Text, ValidatedHelpers} +import play.api.libs.json.* /** * Just parses json with minimal validation - build to provide a way to @@ -92,35 +92,18 @@ private[api_json] case class InternalApiJsonForm( def models: Seq[InternalModelForm] = { val knownModels = declaredModels ++ internalDatatypeBuilder.modelForms - knownModels ++ createModelsForUnionTypes(knownModels) - } - private def createModelsForUnionTypes(known: Seq[InternalModelForm]): Seq[InternalModelForm] = { - println(s"CREATE MODELS") - unions.flatMap { u => - u.types.filterNot(isTypeKnown).map { t => - println(s" - ${t.datatype}") - } - } - known - } + val datatypeResolver: DatatypeResolver = DatatypeResolver( + enumNames = enums.map(_.name), + interfaceNames = interfaces.map(_.name), + unionNames = unions.map(_.name), + modelNames = knownModels.map(_.name) + ) - private def isTypeKnown(ut: InternalUnionTypeForm): Boolean = { - ut.datatype match { - case Invalid(_) => true - case Valid(ut) => { - true - } - } - ut.datatype.foreach { t => - if (t.name == "ping") { - sys.error("STACK") - } - } - println(s"Union isTypeKnown[${ut.datatype}]") - true + knownModels ++ AutoCreateUnionTypeModels.createModelsForUnionTypes(datatypeResolver, unions) } + private lazy val declaredEnums: Seq[InternalEnumForm] = { (json \ "enums").asOpt[JsValue] match { case Some(enums: JsObject) => { @@ -218,7 +201,7 @@ case class InternalTemplateDeclarationForm( warnings: ValidatedNec[String, Unit] ) -object InternalTemplateDeclarationForm { +private object InternalTemplateDeclarationForm { def apply(json: JsObject): InternalTemplateDeclarationForm = { InternalTemplateDeclarationForm( name = JsonUtil.asOptString(json \ "name"), From d7da4416517a77ed5008a630e0d60c6dbd8b927d Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 13:33:57 -0500 Subject: [PATCH 6/9] wip --- .devops/config~ | 1 - .../api_json/AutoCreateUnionTypeModels.scala | 44 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) delete mode 100644 .devops/config~ create mode 100644 core/app/builder/api_json/AutoCreateUnionTypeModels.scala diff --git a/.devops/config~ b/.devops/config~ deleted file mode 100644 index fad0b1360..000000000 --- a/.devops/config~ +++ /dev/null @@ -1 +0,0 @@ -apibuilder_organization=bryzek \ No newline at end of file diff --git a/core/app/builder/api_json/AutoCreateUnionTypeModels.scala b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala new file mode 100644 index 000000000..9f99aa1bc --- /dev/null +++ b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala @@ -0,0 +1,44 @@ +package builder.api_json + +import cats.implicits.* +import lib.{DatatypeResolver, Text} + +/** + * Create default models for types in a union type if the user has not already created that + * model. Works as syntactic sugar for defining a model as a type in a union. + */ +object AutoCreateUnionTypeModels { + + def createModelsForUnionTypes(resolver: DatatypeResolver, unions: Seq[InternalUnionForm]): Seq[InternalModelForm] = { + unions.flatMap { u => + u.types.flatMap { t => + t.datatype.toOption.map(_.name).flatMap { typ => + if (isImported(typ)) { + None + } else { + resolver.parse(typ) match { + case None => Some(createModelForUnionType(typ, t.fields)) + case Some(_) => None + } + } + } + } + } + } + + private def isImported(typ: String): Boolean = typ.indexOf(".") > 0 + + private def createModelForUnionType(typ: String, fields: Seq[InternalFieldForm]): InternalModelForm = { + InternalModelForm( + name = typ, + plural = Text.pluralize(typ), + description = None, + deprecation = None, + fields = fields, + attributes = Nil, + interfaces = Nil, + templates = Nil, + warnings = ().validNec + ) + } +} From a96991aeb8439d75087b317c154dd9e9eb057d3b Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 18:12:23 -0500 Subject: [PATCH 7/9] wip --- app/app/views/doc/apiJson.scala.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/app/views/doc/apiJson.scala.html b/app/app/views/doc/apiJson.scala.html index c133bc67c..3678aed72 100644 --- a/app/app/views/doc/apiJson.scala.html +++ b/app/app/views/doc/apiJson.scala.html @@ -413,6 +413,7 @@

Union declaration

{ "name": { "plural": string (optional), + "fields": JSON Array of Field (optional), "discriminator": string (optional), "description": string (optional), "interfaces": JSON Array of type string where each value indicates the name of a declared interface (optional), @@ -426,6 +427,8 @@

Union declaration

where:
  • name specifies the name of the interface. Names must be alphanumeric and start with a letter. Valid characters are a-z, A-Z, 0-9 and _ characters. The name must be unique in the set of names assigned to enums, unions, or models. Note you may define an interface and a union of the same name, but in this case it is required to list that interface in the interfaces field.
  • +
  • fields
  • +
  • fields Optional JSON Array of 0 or more Fields. If specified, API Builder will create a model named after the type with these specified fields. This is syntactic sugar for creating the model yourself and then referencing here as the type.
  • plural specifies the optional, plural form of the name. By default, we will pluralize the name using a basic set of english heuristics. The plural is used as a default in cases where it is more natural to specify web services. For example, the default path for a resource will be the plural.
  • discriminator specifies an optional, but recommended, name for a type discriminator field which can then be used in serialization / deserialization to identify the type of object. For example, if not specified, a code generator may serialize the union type into a JSON structure of { "type" => object }. If a discriminator is provided, the same code generator can flatten the JSON representation to, for example: { "discriminator" => "xxx", "field1" => "yyy" }. If provided, the name of the discriminator field must be unique across all of the fields across all of the types of this union. See Play Union Types for more information and examples.
  • @description("union") From cdfe5cb3b60bdc4411b65322ce88db0489cf0899 Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Fri, 27 Feb 2026 21:32:20 -0500 Subject: [PATCH 8/9] Address PR review feedback for union type fields - Fix attributes parsing to use json instead of value for consistency - Change InternalUnionTypeForm.fields to Option[Seq] to distinguish absent fields (no auto-creation) from empty fields (create empty model) - Only auto-create models when fields is explicitly provided, preserving existing behavior for unknown/typoed types (they still fail validation) - Add validation rejecting fields on primitive, list, and map types - Add distinctBy to prevent duplicate auto-created models - Split match arms in AutoCreateUnionTypeModels for clarity - Pass fields through to service spec UnionType - Add comprehensive test coverage (9 new tests) Co-Authored-By: Claude Opus 4.6 --- .../api_json/ApiJsonServiceValidator.scala | 21 +- .../api_json/AutoCreateUnionTypeModels.scala | 9 +- .../api_json/InternalApiJsonForm.scala | 6 +- .../app/builder/api_json/ServiceBuilder.scala | 1 + core/test/core/UnionTypeFieldsSpec.scala | 292 ++++++++++++++++++ 5 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 core/test/core/UnionTypeFieldsSpec.scala diff --git a/core/app/builder/api_json/ApiJsonServiceValidator.scala b/core/app/builder/api_json/ApiJsonServiceValidator.scala index ca84cdd72..9791cac5e 100644 --- a/core/app/builder/api_json/ApiJsonServiceValidator.scala +++ b/core/app/builder/api_json/ApiJsonServiceValidator.scala @@ -139,12 +139,31 @@ case class ApiJsonServiceValidator( union.types.map(_.warnings) ++ union.types.map { typ => typ.datatype match { case Invalid(errors) => (s"Union[${union.name}] type[] " + errors.toNonEmptyList.toList.mkString(", ")).invalidNec - case Valid(dt) => validateAttributes(s"Union[${union.name}] type[${dt.name}]", typ.attributes) + case Valid(dt) => sequenceUnique(Seq( + validateAttributes(s"Union[${union.name}] type[${dt.name}]", typ.attributes), + validateUnionTypeFields(union.name, dt, typ.fields), + )) } } ) } + private def validateUnionTypeFields(unionName: String, dt: InternalDatatype, fields: Option[Seq[InternalFieldForm]]): ValidatedNec[String, Unit] = { + fields match { + case None => ().validNec + case Some(_) => + dt match { + case _: InternalDatatype.List => + s"Union[$unionName] type[${dt.name}] fields cannot be specified for list types".invalidNec + case _: InternalDatatype.Map => + s"Union[$unionName] type[${dt.name}] fields cannot be specified for map types".invalidNec + case _: InternalDatatype.Singleton if lib.Primitives(dt.name).isDefined => + s"Union[$unionName] type[${dt.name}] fields cannot be specified for primitive types".invalidNec + case _ => ().validNec + } + } + } + private def validateAnnotations(annotations: Seq[InternalAnnotationForm]): ValidatedNec[String, Unit] = { sequenceUnique( annotations.map(_.warnings) ++ annotations.filter(_.name.isEmpty).map(_ => diff --git a/core/app/builder/api_json/AutoCreateUnionTypeModels.scala b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala index 9f99aa1bc..cd759dc94 100644 --- a/core/app/builder/api_json/AutoCreateUnionTypeModels.scala +++ b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala @@ -16,14 +16,15 @@ object AutoCreateUnionTypeModels { if (isImported(typ)) { None } else { - resolver.parse(typ) match { - case None => Some(createModelForUnionType(typ, t.fields)) - case Some(_) => None + t.fields match { + case None => None + case Some(_) if resolver.parse(typ).isDefined => None + case Some(fields) => Some(createModelForUnionType(typ, fields)) } } } } - } + }.distinctBy(_.name) } private def isImported(typ: String): Boolean = typ.indexOf(".") > 0 diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index 4e9c5604b..07bcb6e83 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -267,7 +267,7 @@ case class InternalUnionForm( case class InternalUnionTypeForm( datatype: ValidatedNec[String, InternalDatatype], - fields: Seq[InternalFieldForm], + fields: Option[Seq[InternalFieldForm]], description: Option[String], deprecation: Option[InternalDeprecationForm], attributes: Seq[InternalAttributeForm], @@ -449,12 +449,12 @@ object InternalUnionForm { InternalUnionTypeForm( datatype = internalDatatype, - fields = InternalFieldForm.parse(internalDatatypeBuilder, value), + fields = (json \ "fields").asOpt[JsArray].map(_ => InternalFieldForm.parse(internalDatatypeBuilder, json)), description = JsonUtil.asOptString(json \ "description"), deprecation = InternalDeprecationForm.fromJsValue(json), default = JsonUtil.asOptBoolean(json \ "default"), discriminatorValue = JsonUtil.asOptString(json \ "discriminator_value"), - attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]), + attributes = InternalAttributeForm.fromJson((json \ "attributes").asOpt[JsArray]), warnings = JsonUtil.validate( json, anys = Seq("type"), 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/core/test/core/UnionTypeFieldsSpec.scala b/core/test/core/UnionTypeFieldsSpec.scala new file mode 100644 index 000000000..8752badef --- /dev/null +++ b/core/test/core/UnionTypeFieldsSpec.scala @@ -0,0 +1,292 @@ +package core + +import helpers.ApiJsonHelpers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class UnionTypeFieldsSpec extends AnyFunSpec with Matchers with ApiJsonHelpers { + + it("creates model from union type with fields") { + val json = """ + { + "name": "Union Type Fields Test", + + "unions": { + "user": { + "discriminator": "discriminator", + "types": [ + { + "type": "guest", + "fields": [ + { "name": "id", "type": "uuid" }, + { "name": "nickname", "type": "string" } + ] + }, + { + "type": "registered", + "fields": [ + { "name": "id", "type": "uuid" }, + { "name": "email", "type": "string" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val guestModel = service.models.find(_.name == "guest").getOrElse { + sys.error("No guest model created") + } + guestModel.fields.map(_.name) should equal(Seq("id", "nickname")) + + val registeredModel = service.models.find(_.name == "registered").getOrElse { + sys.error("No registered model created") + } + registeredModel.fields.map(_.name) should equal(Seq("id", "email")) + + service.unions.head.types.map(_.`type`) should equal(Seq("guest", "registered")) + } + + it("creates empty model when fields is empty array") { + val json = """ + { + "name": "Union Type Fields Test", + + "unions": { + "event": { + "discriminator": "discriminator", + "types": [ + { + "type": "started", + "fields": [] + }, + { + "type": "completed", + "fields": [ + { "name": "result", "type": "string" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val startedModel = service.models.find(_.name == "started").getOrElse { + sys.error("No started model created") + } + startedModel.fields should be(empty) + + val completedModel = service.models.find(_.name == "completed").getOrElse { + sys.error("No completed model created") + } + completedModel.fields.map(_.name) should equal(Seq("result")) + } + + it("rejects unknown type when fields is absent") { + val json = """ + { + "name": "Union Type Fields Test", + + "unions": { + "user": { + "types": [ + { "type": "unknown_type" } + ] + } + } + } + """ + + TestHelper.expectSingleError(json) should be( + "Union[user] type[unknown_type] not found" + ) + } + + it("rejects fields on primitive types") { + val json = """ + { + "name": "Union Type Fields Test", + + "unions": { + "content": { + "types": [ + { + "type": "string", + "fields": [ + { "name": "value", "type": "string" } + ] + } + ] + } + } + } + """ + + TestHelper.expectSingleError(json) should be( + "Union[content] type[string] fields cannot be specified for primitive types" + ) + } + + it("rejects fields on list types") { + val json = """ + { + "name": "Union Type Fields Test", + + "models": { + "item": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "content": { + "types": [ + { + "type": "[item]", + "fields": [ + { "name": "value", "type": "string" } + ] + } + ] + } + } + } + """ + + TestHelper.expectSingleError(json) should be( + "Union[content] type[item] fields cannot be specified for list types" + ) + } + + it("rejects fields on map types") { + val json = """ + { + "name": "Union Type Fields Test", + + "models": { + "item": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "content": { + "types": [ + { + "type": "map[item]", + "fields": [ + { "name": "value", "type": "string" } + ] + } + ] + } + } + } + """ + + TestHelper.expectSingleError(json) should be( + "Union[content] type[item] fields cannot be specified for map types" + ) + } + + it("allows fields alongside existing model") { + val json = """ + { + "name": "Union Type Fields Test", + + "models": { + "registered": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "user": { + "discriminator": "discriminator", + "types": [ + { "type": "registered" }, + { + "type": "guest", + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + service.models.map(_.name).sorted should equal(Seq("guest", "registered")) + service.unions.head.types.map(_.`type`) should equal(Seq("registered", "guest")) + } + + it("passes fields through to service spec union type") { + val json = """ + { + "name": "Union Type Fields Test", + + "unions": { + "user": { + "discriminator": "discriminator", + "types": [ + { + "type": "guest", + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + val unionType = service.unions.head.types.head + unionType.`type` should equal("guest") + val fields = unionType.fields.getOrElse(sys.error("Expected fields")) + fields.map(_.name) should equal(Seq("id")) + fields.head.`type` should equal("uuid") + } + + it("union type fields is None when not specified") { + val json = """ + { + "name": "Union Type Fields Test", + + "models": { + "guest": { + "fields": [ + { "name": "id", "type": "uuid" } + ] + } + }, + + "unions": { + "user": { + "discriminator": "discriminator", + "types": [ + { "type": "guest" } + ] + } + } + } + """ + + val service = setupValidApiJson(json) + service.unions.head.types.head.fields should be(None) + } + +} From 65ec9eabb5f784c8bd33a0a0de5ac408e4f2c55c Mon Sep 17 00:00:00 2001 From: Michael Bryzek Date: Sun, 1 Mar 2026 15:40:13 -0500 Subject: [PATCH 9/9] wip --- .../api_json/AutoCreateUnionTypeModels.scala | 16 +++------------- .../builder/api_json/InternalApiJsonForm.scala | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/core/app/builder/api_json/AutoCreateUnionTypeModels.scala b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala index cd759dc94..2f499fd99 100644 --- a/core/app/builder/api_json/AutoCreateUnionTypeModels.scala +++ b/core/app/builder/api_json/AutoCreateUnionTypeModels.scala @@ -11,24 +11,14 @@ object AutoCreateUnionTypeModels { def createModelsForUnionTypes(resolver: DatatypeResolver, unions: Seq[InternalUnionForm]): Seq[InternalModelForm] = { unions.flatMap { u => - u.types.flatMap { t => - t.datatype.toOption.map(_.name).flatMap { typ => - if (isImported(typ)) { - None - } else { - t.fields match { - case None => None - case Some(_) if resolver.parse(typ).isDefined => None - case Some(fields) => Some(createModelForUnionType(typ, fields)) - } - } + u.types.filter(_.fields.nonEmpty).flatMap { t => + t.datatype.toOption.map(_.name).map { typ => + createModelForUnionType(typ, t.fields.getOrElse(Nil)) } } }.distinctBy(_.name) } - private def isImported(typ: String): Boolean = typ.indexOf(".") > 0 - private def createModelForUnionType(typ: String, fields: Seq[InternalFieldForm]): InternalModelForm = { InternalModelForm( name = typ, diff --git a/core/app/builder/api_json/InternalApiJsonForm.scala b/core/app/builder/api_json/InternalApiJsonForm.scala index 07bcb6e83..a0cda9a33 100644 --- a/core/app/builder/api_json/InternalApiJsonForm.scala +++ b/core/app/builder/api_json/InternalApiJsonForm.scala @@ -90,7 +90,7 @@ private[api_json] case class InternalApiJsonForm( } } ++ internalDatatypeBuilder.interfaceForms - def models: Seq[InternalModelForm] = { + lazy val models: Seq[InternalModelForm] = { val knownModels = declaredModels ++ internalDatatypeBuilder.modelForms val datatypeResolver: DatatypeResolver = DatatypeResolver(