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
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..d9d12c8e4 100644
--- a/core/app/builder/api_json/InternalApiJsonForm.scala
+++ b/core/app/builder/api_json/InternalApiJsonForm.scala
@@ -52,6 +52,7 @@ private[api_json] case class InternalApiJsonForm(
}
}
+
def unions: Seq[InternalUnionForm] = declaredUnions ++ internalDatatypeBuilder.unionForms
private def parseModels(js: JsValue, prefix: Option[String]): Seq[InternalModelForm] = {
@@ -274,6 +275,7 @@ case class InternalUnionTypeForm(
attributes: Seq[InternalAttributeForm],
default: Option[Boolean],
discriminatorValue: Option[String],
+ fields: Option[Seq[InternalFieldForm]],
warnings: ValidatedNec[String, Unit]
)
@@ -448,12 +450,36 @@ object InternalUnionForm {
val internalDatatype = internalDatatypeBuilder.parseTypeFromObject(json)
val datatypeName = internalDatatype.toOption.map(_.name)
+ val fields: Option[Seq[InternalFieldForm]] =
+ (json \ "fields").asOpt[JsArray].map { _ =>
+ InternalFieldForm.parse(internalDatatypeBuilder, json)
+ }
+
+ fields.filter(_.nonEmpty).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"),
+ fields = fields,
attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]),
warnings = JsonUtil.validate(
json,
@@ -461,7 +487,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/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/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/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))
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." }