Skip to content
Open
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 pkg/config/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package config

import (
"go/types"
"strings"

fwresource "github.com/hashicorp/terraform-plugin-framework/resource"
Expand Down Expand Up @@ -95,6 +96,7 @@ func DefaultResource(name string, terraformSchema *schema.Resource, terraformPlu
Conversions: []conversion.Conversion{conversion.NewIdentityConversionExpandPaths(conversion.AllVersions, conversion.AllVersions, nil)},
OverrideFieldNames: map[string]string{},
listConversionPaths: make(map[string]string),
overrideGeneratedFieldType: map[string]types.Type{},
}
for _, f := range opts {
f(r)
Expand Down
33 changes: 33 additions & 0 deletions pkg/config/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package config
import (
"context"
"fmt"
"go/types"
"strings"
"time"

Expand Down Expand Up @@ -707,6 +708,11 @@ type Resource struct {
// the Terraform Plugin SDKv2 client.
useTerraformPluginFrameworkClient bool

// overrideGeneratedFieldType allows to manually override the type for the
// generated field of a Resource at the specified Terraform path.
// We only support type overrides for scalar fields currently.
overrideGeneratedFieldType map[string]types.Type

// OverrideFieldNames allows to manually override the relevant field name to
// avoid possible Go struct name conflicts that may occur after Multiversion
// CRDs support. During field generation, there may be fields with the same
Expand Down Expand Up @@ -1180,6 +1186,33 @@ func (r *Resource) RemoveSingletonListConversion(tfPath string) bool {
return false
}

// OverrideScalarFieldType allows to manually override the type for the
// generated scalar field of a Resource at the specified Terraform path.
// The path is a Terraform field path without the wildcard segments, e.g.,
// "x.y", even if "x" is a collection type.
// We only support overriding types for scalar fields as of now.
// Trying to override the type generated for a non-scalar path will result in
// a generation-time error.
func (r *Resource) OverrideScalarFieldType(path string, t types.Type) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One thing that came to mind: should we validate that path actually exists in the Terraform schema? Right now a typo would silently become a no-op. It may never match anything during code generation. If we intentionally want to keep this like that, it might at least be worth documenting that invalid paths fail. What do you think?

@ulucinar ulucinar Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

One thing that came to mind: should we validate that path actually exists in the Terraform schema?

I definitely agree we should do that. I considered implementing a validation in addition to the scalar type check we already have but the issue is the available canonical paths are not readily available to us. This is also the reason we don't have such checks for other similar configuration options like config.Resource.AddSingletonListConversion and others. I was thinking we could try to address this cross-cutting validation concern in a future PR, because this already surfaced in this PR's discussions, let me check what we can do about it...

r.overrideGeneratedFieldType[path] = t
}

// FieldTypeOverrideConfiguration represents a configuration for a set of type
// overrides at a specific Terraform path.
type FieldTypeOverrideConfiguration struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One thing I thought about is exposing FieldTypeOverrideConfiguration as part of the public API. The The doc of the FieldTypeOverride field is says that it is only to be used by the code generator, but the returned type is exported. With this state, I am not sure this is just a internal plumbing point. It seems a general use API. What do you think?

I didn't think the alternative for this but still wanted to discuss do we really want to export them for general use instead of really generation internal?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I was initially thinking exposing the config.Resource.overrideGeneratedFieldType (as config.Resource.OverrideGeneratedFieldType) so that we would not need a separate config.Resource.OverrideScalarFieldType configuration method. However, in the future, we may need to support overriding types of non-scalar (complex/collection) fields and if we simply expose overrideGeneratedFieldType as our configuration API, it also directly exposes upjet code generation pipeline's implementation details.

Currently, the only "intended" and exposed configuration API is the config.Resource.OverrideScalarFieldType, which is what a provider needs to specify to be able to override the generated type for a scalar field, the field's path and the type to be used for the override. Supporting overrides for complex types or collection types in more involved because such fields can differ across the forProvider, initProvider and atProvider API trees. For scalars, they always have the same exact type (and that's the reason the OverrideScalarFieldType method accepts a single type parameter, not separate types for the 3 API trees we have). If we had exposed overrideGeneratedFieldType directly, it would not be future proof but the OverrideScalarFieldType is future proof because it's not a generic override API, i.e., it's not expected to handle non-scalar field type overrides. Also having the configured performed via OverrideScalarFieldType (instead of directly setting the configuration data structures) would allow us to do some validation in the future.

The cost of hiding the implementation detail from the provider authors is then we need to expose that configuration via an accessor because the code generation pipeline that consumes this configuration lives in the package pkg/types. Go does not have something like a "friend package" concept and an internal package won't help because overrideGeneratedFieldType is not exported. FieldTypeOverrideConfiguration and FieldTypeOverride should be of no use to the provider authors but we cannot also hide them from the provider authors. That's why I just added a Go doc that tells FieldTypeOverride is meant to be used by the code generator.

ParameterTypeOverride types.Type
Comment thread
ulucinar marked this conversation as resolved.
}

// FieldTypeOverride returns the type override configuration for the specified
// path. The path is a Terraform field path without the wildcard segments,
// e.g., "x.y", even if "x" is a collection type.
// Note: This accessor is meant to be only used by the code generator.
func (r *Resource) FieldTypeOverride(path string) FieldTypeOverrideConfiguration {
return FieldTypeOverrideConfiguration{
ParameterTypeOverride: r.overrideGeneratedFieldType[path],
}
}

// SetEmbeddedObject sets the EmbeddedObject for the specified key.
// The key is a Terraform field path without the wildcard segments.
func (m SchemaElementOptions) SetEmbeddedObject(el string) {
Expand Down
78 changes: 78 additions & 0 deletions pkg/config/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package config
import (
"context"
"fmt"
"go/types"
"testing"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
Expand Down Expand Up @@ -296,3 +297,80 @@ func TestRemoveSingletonListConversion(t *testing.T) {
})
}
}

func TestFieldTypeOverride(t *testing.T) {
strType := types.Typ[types.String]
type args struct {
r func() *Resource
path string
}
type want struct {
// parameterType is the expected ParameterTypeOverride.String(), or the
// empty string when no usable override is expected for the path.
parameterType string
}
cases := map[string]struct {
reason string
args
want
}{
"OverrideConfigured": {
reason: "A scalar type override set for a path is returned for that path.",
args: args{
path: "x.y",
r: func() *Resource {
r := DefaultResource("test_resource", nil, nil, nil)
r.OverrideScalarFieldType("x.y", strType)
return r
},
},
want: want{parameterType: "string"},
},
"DifferentPath": {
reason: "A path without a configured override has no parameter type override.",
args: args{
path: "a.b",
r: func() *Resource {
r := DefaultResource("test_resource", nil, nil, nil)
r.OverrideScalarFieldType("x.y", strType)
return r
},
},
want: want{parameterType: ""},
},
"NoOverrides": {
reason: "A resource with no configured overrides has no parameter type override for any path.",
args: args{
path: "x.y",
r: func() *Resource {
return DefaultResource("test_resource", nil, nil, nil)
},
},
want: want{parameterType: ""},
},
"NilOverrideIgnored": {
reason: "A nil type override is indistinguishable from no override for the path.",
args: args{
path: "x.y",
r: func() *Resource {
r := DefaultResource("test_resource", nil, nil, nil)
r.OverrideScalarFieldType("x.y", nil)
return r
},
},
want: want{parameterType: ""},
},
}
for n, tc := range cases {
t.Run(n, func(t *testing.T) {
got := tc.args.r().FieldTypeOverride(tc.args.path).ParameterTypeOverride
gotStr := ""
if got != nil {
gotStr = got.String()
}
if diff := cmp.Diff(tc.want.parameterType, gotStr); diff != "" {
t.Errorf("%s\nFieldTypeOverride(%q): -want, +got:\n%s", tc.reason, tc.args.path, diff)
}
})
}
}
13 changes: 12 additions & 1 deletion pkg/types/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
"go/types"
"sort"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
twtypes "github.com/muvaf/typewriter/pkg/types"
"github.com/pkg/errors"
"k8s.io/utils/ptr"

"github.com/crossplane/upjet/v2/pkg/config"
Expand Down Expand Up @@ -219,6 +219,17 @@ func (g *Builder) AddToBuilder(typeNames *TypeNames, r *resource) (*types.Named,
}

func (g *Builder) buildSchema(f *Field, cfg *config.Resource, names []string, cpath string, r *resource) (types.Type, types.Type, error) { //nolint:gocyclo
if o := cfg.FieldTypeOverride(cpath); o.ParameterTypeOverride != nil {
switch f.Schema.Type { //nolint:exhaustive // The default case handles the error cases (non-scalar paths) already.
case schema.TypeBool, schema.TypeFloat, schema.TypeInt, schema.TypeString:
return o.ParameterTypeOverride, nil, nil
default:
return nil, nil, errors.Errorf(
"field at path %q with Terraform type %s specified for OverrideScalarFieldType is not scalar, only scalar field types can be overridden",
cpath, f.Schema.Type.String())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

switch f.Schema.Type {
case schema.TypeBool:
return types.NewPointer(types.Universe.Lookup("bool").Type()), nil, nil
Expand Down
94 changes: 93 additions & 1 deletion pkg/types/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import (
"strings"
"testing"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/crossplane/crossplane-runtime/v2/pkg/test"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/pkg/errors"

"github.com/crossplane/upjet/v2/pkg/config"
)
Expand Down Expand Up @@ -873,3 +873,95 @@ func TestBuild(t *testing.T) {
})
}
}

func TestBuildFieldTypeOverride(t *testing.T) {
type want struct {
forProvider string
initProvider string
observation string
errContains string
}
cases := map[string]struct {
reason string
cfg func() *config.Resource
want want
}{
"ScalarOverrideAppliesToAllAPIs": {
reason: "Overriding a scalar field's type replaces it across forProvider, initProvider and observation.",
cfg: func() *config.Resource {
r := config.DefaultResource("test_resource", &schema.Resource{
Schema: map[string]*schema.Schema{
"enabled": {
Type: schema.TypeBool,
Optional: true,
},
},
}, nil, nil)
r.Kind = ""
r.OverrideScalarFieldType("enabled", NewStringOrBoolType())
return r
},
want: want{
forProvider: `type example.Parameters struct{Enabled *github.com/crossplane/upjet/v2/pkg/types.StringOrBool "json:\"enabled,omitempty\" tf:\"enabled,omitempty\""}`,
initProvider: `type example.InitParameters struct{Enabled *github.com/crossplane/upjet/v2/pkg/types.StringOrBool "json:\"enabled,omitempty\" tf:\"enabled,omitempty\""}`,
observation: `type example.Observation struct{Enabled *github.com/crossplane/upjet/v2/pkg/types.StringOrBool "json:\"enabled,omitempty\" tf:\"enabled,omitempty\""}`,
},
},
"NonScalarOverrideErrors": {
reason: "Overriding a non-scalar (collection) field's type is a generation-time error.",
cfg: func() *config.Resource {
r := config.DefaultResource("test_resource", &schema.Resource{
Schema: map[string]*schema.Schema{
"settings": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"size": {
Type: schema.TypeInt,
Optional: true,
},
},
},
},
},
}, nil, nil)
r.Kind = ""
r.OverrideScalarFieldType("settings", NewStringOrBoolType())
return r
},
want: want{
errContains: `field at path "settings" with Terraform type TypeList specified for OverrideScalarFieldType is not scalar, only scalar field types can be overridden`,
},
},
}
for n, tc := range cases {
t.Run(n, func(t *testing.T) {
builder := NewBuilder(types.NewPackage("example", ""), CRDScopeCluster)
g, err := builder.Build(tc.cfg())

if tc.want.errContains != "" {
if err == nil {
t.Fatalf("%s\nBuild(...): expected an error containing %q, got nil", tc.reason, tc.want.errContains)
}
if !strings.Contains(err.Error(), tc.want.errContains) {
t.Errorf("%s\nBuild(...): error %q does not contain %q", tc.reason, err.Error(), tc.want.errContains)
}
return
}

if err != nil {
t.Fatalf("%s\nBuild(...): unexpected error: %v", tc.reason, err)
}
if diff := cmp.Diff(tc.want.forProvider, g.ForProviderType.Obj().String()); diff != "" {
t.Errorf("%s\nBuild(...): -want forProvider, +got forProvider:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.initProvider, g.InitProviderType.Obj().String()); diff != "" {
t.Errorf("%s\nBuild(...): -want initProvider, +got initProvider:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.observation, g.AtProviderType.Obj().String()); diff != "" {
t.Errorf("%s\nBuild(...): -want observation, +got observation:\n%s", tc.reason, diff)
}
})
}
}
51 changes: 51 additions & 0 deletions pkg/types/internal/string.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2026 The Crossplane Authors <https://crossplane.io>
//
// SPDX-License-Identifier: Apache-2.0

package internal

import (
"encoding/json"
"fmt"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
)

const (
errInvalidValue = "value must be a JSON string or %T, got %s"
)

// Primitive is a type constraint that represents the supported primitive types
// for StringOrPrimitive.
type Primitive interface {
~bool |
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// StringOrPrimitive is stored canonically as a string, but can decode
// from other primitive values such as ints and bools.
type StringOrPrimitive[T Primitive] string

func (b *StringOrPrimitive[T]) UnmarshalJSON(data []byte) error {
// first try as a string value.
var s string
if err := json.Unmarshal(data, &s); err == nil {
*b = StringOrPrimitive[T](s)
return nil
}

// if not a string value, try as a value of the specified type parameter.
var v T
if err := json.Unmarshal(data, &v); err == nil {
*b = StringOrPrimitive[T](fmt.Sprint(v))
return nil
}

return errors.Errorf(errInvalidValue, v, string(data))
}

func (b *StringOrPrimitive[T]) MarshalJSON() ([]byte, error) {
// Always write back as string in the new canonical format.
return json.Marshal(*b)
}
Loading
Loading