Add inline fields support on union type entries#961
Conversation
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds support for defining union type members inline with their fields, providing a more concise way to declare union types without requiring separate model declarations. When a union type entry includes a fields property, API Builder automatically generates a model using the type name with those fields.
Changes:
- Added optional
fieldsproperty to union type entries in both API spec files - Implemented synthetic model generation from union types with inline fields
- Added comprehensive test coverage with 9 new tests covering various scenarios
- Updated documentation to describe the new inline fields feature
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| spec/apibuilder-spec.json | Added optional fields property definition to union_type model |
| spec/apibuilder-api-json.json | Added optional fields property definition to union_type in API JSON spec |
| core/test/core/UnionTypeInlineFieldsSpec.scala | New test file with 9 comprehensive tests covering inline fields functionality |
| core/app/builder/api_json/InternalApiJsonForm.scala | Implemented synthetic model generation from union type inline fields |
| app/app/views/doc/apiJson.scala.html | Updated documentation to describe the new inline fields feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| default = JsonUtil.asOptBoolean(json \ "default"), | ||
| discriminatorValue = JsonUtil.asOptString(json \ "discriminator_value"), | ||
| fields = fields, | ||
| attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]), |
There was a problem hiding this comment.
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.
| attributes = InternalAttributeForm.fromJson((value \ "attributes").asOpt[JsArray]), | |
| attributes = InternalAttributeForm.fromJson((json \ "attributes").asOpt[JsArray]), |
| 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, |
There was a problem hiding this comment.
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.
| 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 | ||
| ) | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
Summary
fieldspropertyfieldsis present, the type will support the specified fieldsfieldsis absent, existing behavior is preservedExample
{ "unions": { "task_type": { "discriminator": "discriminator", "types": [ { "type": "game" }, { "type": "merge_person", "fields": [ { "name": "user_id", "type": "string" }, { "name": "person_id", "type": "string" } ]} ] } } }