Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/app/views/doc/apiJson.scala.html
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ <h2>UnionType declaration</h2>
"description": <em>string (optional)</em>,
"default": <em>boolean (optional)</em>,
"discriminator_value": <em>string (optional)</em>,
"fields": <em>JSON Array of <a href="#field">Field</a> (optional)</em>,
"attributes": <em>JSON Array of <a href="#attribute">Attribute</a> (optional)</em>,
"deprecation": <em>JSON Object of <a href="#deprecation">Deprecation</a> (optional)</em>
}
Expand All @@ -460,6 +461,7 @@ <h2>UnionType declaration</h2>
@description("type")
<li><em>default</em> 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.</li>
<li><em>discriminator_value</em> 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.</li>
<li><em>fields</em> 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 <a href="#field">Field</a>.</li>
<li><em>attributes</em> JSON array defining additional meta data about this union type for use by generators. See <a href="#attribute">Attribute</a></li>
<li><em>deprecation</em> JSON Object that indicates that this object is deprecated.</li>
</ul>
Expand Down
28 changes: 27 additions & 1 deletion core/app/builder/api_json/InternalApiJsonForm.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -274,6 +275,7 @@ case class InternalUnionTypeForm(
attributes: Seq[InternalAttributeForm],
default: Option[Boolean],
discriminatorValue: Option[String],
fields: Option[Seq[InternalFieldForm]],
warnings: ValidatedNec[String, Unit]
)

Expand Down Expand Up @@ -448,20 +450,44 @@ 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,
Comment on lines +453 to +467
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the new fields support, the code will currently attempt to auto-generate models for any type value (including primitives like string or container types like [guest] / map[foo]) when fields is present. This can create invalid or conflicting models (e.g., model named string) while the union member still resolves as a primitive/list. Add validation that fields can only be used with a plain, non-primitive singleton type name (i.e., not a primitive, list, or map), and surface a clear error when violated.

Copilot uses AI. Check for mistakes.
interfaces = Nil,
templates = Nil,
warnings = ().validNec
)
)
}
}
Comment on lines +458 to +474
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline union member models are only synthesized when the parsed fields list is non-empty (fields.filter(_.nonEmpty)), which contradicts the new spec/docs/tests that say fields: [] should generate an empty model for no-data types. Create the dynamic model whenever fields is present (even if empty), not only when it’s non-empty.

Copilot uses AI. Check for mistakes.

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]),
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InternalUnionTypeForm.attributes is being parsed from the parent union object (value \ "attributes") instead of the union type entry object (json \ "attributes"). This will incorrectly apply union-level attributes to every member type and ignore per-type attributes. Parse attributes from the union type JSON object.

Suggested change
attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]),
attributes = InternalAttributeForm.fromJson((json \ "attributes").asOpt[JsArray]),

Copilot uses AI. Check for mistakes.
warnings = JsonUtil.validate(
json,
anys = Seq("type"),
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("")}]")
)
)
Expand Down
2 changes: 2 additions & 0 deletions core/app/builder/api_json/InternalDatatype.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/app/builder/api_json/ServiceBuilder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading