Skip to content

terraform: missing BoolNull() else-branch when nullable nested struct is flattened to top-level Computed attributes #2031

Description

@highb

Summary

When the OpenAPI spec defines a nullable nested struct whose primitive fields are flattened to top-level Computed attributes on a Terraform resource, Speakeasy's generated RefreshFromShared* SDK conversion code populates the fields when the parent struct is non-nil but omits the else branch that nulls them when the parent is nil. The Terraform attributes stay at their zero types.Bool{} value (Unknown), which terraform-plugin-framework rejects as Provider returned invalid result object after apply.

This is structurally the same bug class as #1770 (use attribute-defined plan modifier for x-speakeasy-terraform-plan-only) — both are nullable-handling regressions in the Terraform target's generated conversion code. The plan-only side was fixed there; the flatten/null side remains.

Reproduction

Speakeasy version: 1.761.10. Confirmed present on every release we've tried in the 1.7xx series.

Minimal OpenAPI (openapi.yaml):

openapi: 3.0.3
info:
  title: minimal-repro
  version: 0.1.0
paths:
  /resources/{id}:
    get:
      operationId: getResource
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResourceView"
components:
  schemas:
    ResourceView:
      type: object
      x-speakeasy-entity: Resource
      properties:
        resource:
          $ref: "#/components/schemas/Resource"
        permissions:
          $ref: "#/components/schemas/Permissions"
    Resource:
      type: object
      properties:
        id: { type: string }
        displayName: { type: string }
    Permissions:
      type: object
      nullable: true
      properties:
        delete: { type: boolean }
        edit:   { type: boolean }
        read:   { type: boolean }

gen.yaml:

configVersion: 2.0.0
generation:
  sdkClassName: minimalAPI
terraform:
  packageName: minimal
  version: 0.1.0

Run speakeasy generate sdk -s openapi.yaml -o . -l terraform.

What Speakeasy generates (broken)

// internal/provider/resource_resource_sdk.go
func (r *ResourceResourceModel) RefreshFromSharedResourceView(ctx context.Context, resp *shared.ResourceView) diag.Diagnostics {
    var diags diag.Diagnostics
    if resp != nil {
        if resp.Permissions != nil {
            r.Delete = types.BoolPointerValue(resp.Permissions.Delete)
            r.Edit   = types.BoolPointerValue(resp.Permissions.Edit)
            r.Read   = types.BoolPointerValue(resp.Permissions.Read)
        }
        // ... other fields
    }
    return diags
}

When the API returns permissions: null (legal per nullable: true), the inner block is skipped. r.Delete / r.Edit / r.Read stay at their zero value (types.Bool{} = Unknown, not Null).

What it should generate

        if resp.Permissions != nil {
            r.Delete = types.BoolPointerValue(resp.Permissions.Delete)
            r.Edit   = types.BoolPointerValue(resp.Permissions.Edit)
            r.Read   = types.BoolPointerValue(resp.Permissions.Read)
        } else {
            r.Delete = types.BoolNull()
            r.Edit   = types.BoolNull()
            r.Read   = types.BoolNull()
        }

Runtime failure

Error: Provider returned invalid result object after apply

After the apply operation, the provider still indicated an unknown value for
example_resource.test.delete. All values must be known after apply, so this is
always a bug in the provider and should be reported to the provider developers.

terraform-plugin-framework rejects Unknown values after Refresh / Read — the attribute must be either a known value or Null. The same flatten-with-no-else pattern appears in 6 SDK files in our generated provider. The flatten variant where the create response uses a different shape that doesn't include the nullable nested struct at all (in our case, CreateManuallyManagedAppResource returns the bare Resource, not ResourceView) also needs unconditional BoolNull() at the end of the populate block.

Things we tried that did NOT fix it

  • Setting terraform.respectRequiredFields: true in gen.yaml. This DID improve nullable handling for ordinary nested struct fields (flipping to a cleaner if X == nil { r.X = nil } else { r.X = &tfTypes.X{}; ... } form), but the flatten-promotion case where bools are promoted to siblings on r was unchanged — the bad pattern persisted.

Our workaround

We hand-patch the 6 affected files post-regen on every Speakeasy bot PR. The patch shape is the } else { r.X = types.BoolNull() ... } block above. References:

We have a TestNullableNestedStructsHaveElseBranch regression-tripwire test that fails CI when a regen ships without the patch; happy to share if useful for adding to your own integration suite.

Why we think it's the generator template

The pattern is deterministic across regens and identical across all 6 of our affected files:

  • Always if resp.X != nil { ... } with no else.
  • Always for nullable nested structs whose primitive fields are promoted to siblings of r.

This strongly suggests a templating omission in the Terraform target's RefreshFromShared* generator (presumably in your private openapi-generation repo). Filing here since the public PR #1770 thread is the only public artifact for the related plan-only fix.

Environment

  • Speakeasy CLI: 1.761.10
  • terraform-plugin-framework: 1.19.0
  • terraform-plugin-go: 0.31.0
  • Go: 1.25.8
  • gen.yaml fixes: nameResolutionDec2023: true, nameResolutionFeb2025: false, parameterOrderingFeb2024: true, requestResponseComponentNamesFeb2024: true, securityFeb2025: false, sharedErrorComponentsApr2025: false, sharedNestedComponentsJan2026: false, nameOverrideFeb2026: false
  • gen.yaml terraform.respectRequiredFields: false (true did not fix this issue)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions