A Terraform provider for Dokploy
Plugin-framework provider covering projects, environments, applications, Compose stacks, managed databases, domains, mounts, ports, redirects, basic auth, registries, SSH keys, certificates and backup destinations, over Dokploy's tRPC-over-REST API. The shim package exposes the provider to other Go modules, which is how pulumi-dokploy bridges it.
This commit is contained in:
555
internal/tfmap/tfmap.go
Normal file
555
internal/tfmap/tfmap.go
Normal file
@@ -0,0 +1,555 @@
|
||||
// Package tfmap converts between Terraform Plugin Framework model structs and
|
||||
// the flat JSON maps that the Dokploy API consumes and returns.
|
||||
//
|
||||
// Mapping is driven by a `dokploy:"..."` struct tag alongside the usual
|
||||
// `tfsdk:"..."` tag:
|
||||
//
|
||||
// Name types.String `tfsdk:"name" dokploy:"name"`
|
||||
// Description types.String `tfsdk:"description" dokploy:"description,nullable"`
|
||||
// ID types.String `tfsdk:"id" dokploy:"projectId,id"`
|
||||
// CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
|
||||
//
|
||||
// Options:
|
||||
//
|
||||
// id this field carries the resource's primary key
|
||||
// ro read-only; never included in a request body
|
||||
// create included only in create bodies
|
||||
// update included only in update bodies
|
||||
// nullable send an explicit JSON null when the value is null, instead of
|
||||
// omitting the key (only valid for API fields that accept null)
|
||||
//
|
||||
// Fields with no create/update option are sent in both. Null values are
|
||||
// omitted by default, because Dokploy's Zod schemas reject null for columns
|
||||
// that are NOT NULL, but accept a missing key as "leave unchanged".
|
||||
package tfmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
|
||||
"github.com/hashicorp/terraform-plugin-framework/attr"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
|
||||
)
|
||||
|
||||
// Phase selects which subset of fields to serialize.
|
||||
type Phase int
|
||||
|
||||
const (
|
||||
// PhaseCreate builds a body for a `*.create` procedure.
|
||||
PhaseCreate Phase = iota
|
||||
// PhaseUpdate builds a body for an `*.update` procedure.
|
||||
PhaseUpdate
|
||||
)
|
||||
|
||||
type fieldSpec struct {
|
||||
apiName string
|
||||
index int
|
||||
isID bool
|
||||
readOnly bool
|
||||
createOnly bool
|
||||
updateOnly bool
|
||||
nullable bool
|
||||
noRead bool
|
||||
}
|
||||
|
||||
func specsFor(model any) ([]fieldSpec, error) {
|
||||
v := reflect.ValueOf(model)
|
||||
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("tfmap: model must be a pointer to a struct, got %T", model)
|
||||
}
|
||||
t := v.Elem().Type()
|
||||
|
||||
specs := make([]fieldSpec, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag := t.Field(i).Tag.Get("dokploy")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(tag, ",")
|
||||
spec := fieldSpec{apiName: parts[0], index: i}
|
||||
for _, opt := range parts[1:] {
|
||||
switch strings.TrimSpace(opt) {
|
||||
case "id":
|
||||
spec.isID = true
|
||||
case "ro":
|
||||
spec.readOnly = true
|
||||
case "create":
|
||||
spec.createOnly = true
|
||||
case "update":
|
||||
spec.updateOnly = true
|
||||
case "nullable":
|
||||
spec.nullable = true
|
||||
case "noread":
|
||||
spec.noRead = true
|
||||
default:
|
||||
return nil, fmt.Errorf("tfmap: unknown option %q on field %s", opt, t.Field(i).Name)
|
||||
}
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// ToAPI serializes a model into a Dokploy request body for the given phase.
|
||||
//
|
||||
// The primary-key field is included for updates (the API needs it to address
|
||||
// the row) and omitted for creates (the server generates it).
|
||||
func ToAPI(model any, phase Phase) (map[string]any, error) {
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elem := reflect.ValueOf(model).Elem()
|
||||
|
||||
body := map[string]any{}
|
||||
for _, spec := range specs {
|
||||
if spec.readOnly {
|
||||
continue
|
||||
}
|
||||
if spec.isID {
|
||||
if phase == PhaseUpdate {
|
||||
if s, ok := elem.Field(spec.index).Interface().(types.String); ok && !s.IsNull() {
|
||||
body[spec.apiName] = s.ValueString()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if spec.createOnly && phase != PhaseCreate {
|
||||
continue
|
||||
}
|
||||
if spec.updateOnly && phase != PhaseUpdate {
|
||||
continue
|
||||
}
|
||||
|
||||
value, known, isNull, err := attrToAny(elem.Field(spec.index))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", spec.apiName, err)
|
||||
}
|
||||
if !known {
|
||||
// Unknown (computed at apply time) values must never be sent.
|
||||
continue
|
||||
}
|
||||
if isNull {
|
||||
if spec.nullable {
|
||||
body[spec.apiName] = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
body[spec.apiName] = value
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// IDValue returns the value of the model's `id`-tagged field.
|
||||
func IDValue(model any) (string, error) {
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
elem := reflect.ValueOf(model).Elem()
|
||||
for _, spec := range specs {
|
||||
if !spec.isID {
|
||||
continue
|
||||
}
|
||||
s, ok := elem.Field(spec.index).Interface().(types.String)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName)
|
||||
}
|
||||
return s.ValueString(), nil
|
||||
}
|
||||
return "", fmt.Errorf("tfmap: model has no field tagged `id`")
|
||||
}
|
||||
|
||||
// SetID writes id into the model's `id`-tagged field.
|
||||
func SetID(model any, id string) error {
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
elem := reflect.ValueOf(model).Elem()
|
||||
for _, spec := range specs {
|
||||
if !spec.isID {
|
||||
continue
|
||||
}
|
||||
if _, ok := elem.Field(spec.index).Interface().(types.String); !ok {
|
||||
return fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName)
|
||||
}
|
||||
elem.Field(spec.index).Set(reflect.ValueOf(types.StringValue(id)))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tfmap: model has no field tagged `id`")
|
||||
}
|
||||
|
||||
// IDAPIName returns the API field name of the model's primary key,
|
||||
// e.g. "projectId".
|
||||
func IDAPIName(model any) (string, error) {
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if spec.isID {
|
||||
return spec.apiName, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tfmap: model has no field tagged `id`")
|
||||
}
|
||||
|
||||
// FromAPI populates a model from a Dokploy JSON response.
|
||||
//
|
||||
// Keys absent from the response leave the corresponding model field untouched,
|
||||
// so partial responses do not clobber known state.
|
||||
func FromAPI(raw json.RawMessage, model any) error {
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return fmt.Errorf("tfmap: decoding response: %w", err)
|
||||
}
|
||||
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
elem := reflect.ValueOf(model).Elem()
|
||||
|
||||
for _, spec := range specs {
|
||||
if spec.noRead {
|
||||
// The API returns a transformed value (a password hash, say) that
|
||||
// must not overwrite what the practitioner configured.
|
||||
continue
|
||||
}
|
||||
payload, present := decoded[spec.apiName]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if err := anyToAttr(payload, elem.Field(spec.index)); err != nil {
|
||||
return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NullifyUnknown replaces any attribute still marked unknown with a null of
|
||||
// the same type.
|
||||
//
|
||||
// Terraform requires every value to be known once apply finishes. An attribute
|
||||
// stays unknown when it was Optional+Computed in the plan and the API response
|
||||
// simply omitted the corresponding key, which Dokploy does for several columns.
|
||||
// Null is the honest representation: the server did not report a value.
|
||||
func NullifyUnknown(model any) error {
|
||||
specs, err := specsFor(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
elem := reflect.ValueOf(model).Elem()
|
||||
|
||||
for _, spec := range specs {
|
||||
field := elem.Field(spec.index)
|
||||
value, ok := field.Interface().(attr.Value)
|
||||
if !ok || !value.IsUnknown() {
|
||||
continue
|
||||
}
|
||||
if err := setNull(field); err != nil {
|
||||
return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setNull(field reflect.Value) error {
|
||||
switch field.Interface().(type) {
|
||||
case jsontypes.Normalized:
|
||||
field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull()))
|
||||
case types.String:
|
||||
field.Set(reflect.ValueOf(types.StringNull()))
|
||||
case types.Bool:
|
||||
field.Set(reflect.ValueOf(types.BoolNull()))
|
||||
case types.Int64:
|
||||
field.Set(reflect.ValueOf(types.Int64Null()))
|
||||
case types.Float64:
|
||||
field.Set(reflect.ValueOf(types.Float64Null()))
|
||||
case types.List:
|
||||
field.Set(reflect.ValueOf(types.ListNull(types.StringType)))
|
||||
case types.Set:
|
||||
field.Set(reflect.ValueOf(types.SetNull(types.StringType)))
|
||||
case types.Map:
|
||||
field.Set(reflect.ValueOf(types.MapNull(types.StringType)))
|
||||
default:
|
||||
return fmt.Errorf("unsupported model field type %s", field.Type())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attrToAny converts a framework value into a plain Go value suitable for JSON.
|
||||
// It reports whether the value is known and whether it is null.
|
||||
func attrToAny(field reflect.Value) (value any, known bool, isNull bool, err error) {
|
||||
switch v := field.Interface().(type) {
|
||||
case jsontypes.Normalized:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
// Re-marshal so the API receives a real JSON object, not a string.
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(v.ValueString()), &parsed); err != nil {
|
||||
return nil, false, false, fmt.Errorf("value is not valid JSON: %w", err)
|
||||
}
|
||||
return parsed, true, false, nil
|
||||
|
||||
case types.String:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
return v.ValueString(), true, false, nil
|
||||
|
||||
case types.Bool:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
return v.ValueBool(), true, false, nil
|
||||
|
||||
case types.Int64:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
return v.ValueInt64(), true, false, nil
|
||||
|
||||
case types.Float64:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
return v.ValueFloat64(), true, false, nil
|
||||
|
||||
case types.List:
|
||||
return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements())
|
||||
|
||||
case types.Set:
|
||||
return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements())
|
||||
|
||||
case types.Map:
|
||||
if v.IsUnknown() {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if v.IsNull() {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
for key, el := range v.Elements() {
|
||||
s, ok := el.(types.String)
|
||||
if !ok {
|
||||
return nil, false, false, fmt.Errorf("map elements must be strings")
|
||||
}
|
||||
if s.IsNull() || s.IsUnknown() {
|
||||
continue
|
||||
}
|
||||
out[key] = s.ValueString()
|
||||
}
|
||||
return out, true, false, nil
|
||||
|
||||
default:
|
||||
return nil, false, false, fmt.Errorf("unsupported model field type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func elementsToAny(unknown, null bool, elements []attr.Value) (any, bool, bool, error) {
|
||||
if unknown {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if null {
|
||||
return nil, true, true, nil
|
||||
}
|
||||
out := make([]any, 0, len(elements))
|
||||
for _, el := range elements {
|
||||
switch e := el.(type) {
|
||||
case types.String:
|
||||
if e.IsNull() || e.IsUnknown() {
|
||||
continue
|
||||
}
|
||||
out = append(out, e.ValueString())
|
||||
case types.Int64:
|
||||
if e.IsNull() || e.IsUnknown() {
|
||||
continue
|
||||
}
|
||||
out = append(out, e.ValueInt64())
|
||||
default:
|
||||
return nil, false, false, fmt.Errorf("unsupported collection element type %T", el)
|
||||
}
|
||||
}
|
||||
return out, true, false, nil
|
||||
}
|
||||
|
||||
// anyToAttr decodes a raw JSON value into a framework model field.
|
||||
func anyToAttr(payload json.RawMessage, field reflect.Value) error {
|
||||
isNull := string(payload) == "null"
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case jsontypes.Normalized:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull()))
|
||||
return nil
|
||||
}
|
||||
field.Set(reflect.ValueOf(jsontypes.NewNormalizedValue(string(payload))))
|
||||
return nil
|
||||
|
||||
case types.String:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.StringNull()))
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(payload, &s); err != nil {
|
||||
// Tolerate scalars the API returns untyped (e.g. numeric strings).
|
||||
var scalar any
|
||||
if err2 := json.Unmarshal(payload, &scalar); err2 != nil {
|
||||
return err
|
||||
}
|
||||
s = fmt.Sprintf("%v", scalar)
|
||||
}
|
||||
field.Set(reflect.ValueOf(types.StringValue(s)))
|
||||
return nil
|
||||
|
||||
case types.Bool:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.BoolNull()))
|
||||
return nil
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(payload, &b); err != nil {
|
||||
return err
|
||||
}
|
||||
field.Set(reflect.ValueOf(types.BoolValue(b)))
|
||||
return nil
|
||||
|
||||
case types.Int64:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.Int64Null()))
|
||||
return nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(payload, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
i, err := n.Int64()
|
||||
if err != nil {
|
||||
f, ferr := n.Float64()
|
||||
if ferr != nil {
|
||||
return err
|
||||
}
|
||||
i = int64(f)
|
||||
}
|
||||
field.Set(reflect.ValueOf(types.Int64Value(i)))
|
||||
return nil
|
||||
|
||||
case types.Float64:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.Float64Null()))
|
||||
return nil
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(payload, &f); err != nil {
|
||||
return err
|
||||
}
|
||||
field.Set(reflect.ValueOf(types.Float64Value(f)))
|
||||
return nil
|
||||
|
||||
case types.List:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.ListNull(types.StringType)))
|
||||
return nil
|
||||
}
|
||||
var items []any
|
||||
if err := json.Unmarshal(payload, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
values := make([]attr.Value, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, types.StringValue(scalarToString(item)))
|
||||
}
|
||||
list, diags := types.ListValue(types.StringType, values)
|
||||
if diags.HasError() {
|
||||
return fmt.Errorf("building list: %v", diags.Errors())
|
||||
}
|
||||
field.Set(reflect.ValueOf(list))
|
||||
return nil
|
||||
|
||||
case types.Set:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.SetNull(types.StringType)))
|
||||
return nil
|
||||
}
|
||||
var items []any
|
||||
if err := json.Unmarshal(payload, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
values := make([]attr.Value, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, types.StringValue(scalarToString(item)))
|
||||
}
|
||||
set, diags := types.SetValue(types.StringType, values)
|
||||
if diags.HasError() {
|
||||
return fmt.Errorf("building set: %v", diags.Errors())
|
||||
}
|
||||
field.Set(reflect.ValueOf(set))
|
||||
return nil
|
||||
|
||||
case types.Map:
|
||||
if isNull {
|
||||
field.Set(reflect.ValueOf(types.MapNull(types.StringType)))
|
||||
return nil
|
||||
}
|
||||
var items map[string]any
|
||||
if err := json.Unmarshal(payload, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
values := map[string]attr.Value{}
|
||||
for key, item := range items {
|
||||
values[key] = types.StringValue(scalarToString(item))
|
||||
}
|
||||
m, diags := types.MapValue(types.StringType, values)
|
||||
if diags.HasError() {
|
||||
return fmt.Errorf("building map: %v", diags.Errors())
|
||||
}
|
||||
field.Set(reflect.ValueOf(m))
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported model field type %s", field.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func scalarToString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
// JSON numbers decode as float64; render integers without a decimal.
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
return fmt.Sprintf("%v", t)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure basetypes stays referenced for the type-switch cases above.
|
||||
var _ = basetypes.NewStringNull
|
||||
134
internal/tfmap/tfmap_test.go
Normal file
134
internal/tfmap/tfmap_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package tfmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
type sample struct {
|
||||
ID types.String `tfsdk:"id" dokploy:"thingId,id"`
|
||||
Name types.String `tfsdk:"name" dokploy:"name"`
|
||||
Desc types.String `tfsdk:"description" dokploy:"description,nullable"`
|
||||
Networks types.List `tfsdk:"network_ids" dokploy:"networkIds"`
|
||||
Detach types.Bool `tfsdk:"detach" dokploy:"detach"`
|
||||
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
|
||||
Secret types.String `tfsdk:"secret" dokploy:"secret,noread"`
|
||||
ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"`
|
||||
Status types.String `tfsdk:"status" dokploy:"status,ro"`
|
||||
}
|
||||
|
||||
// A JSON null in the response must produce a null attribute, never leave the
|
||||
// attribute unknown: Terraform rejects unknown values after apply.
|
||||
func TestFromAPIConvertsJSONNullToNullAttribute(t *testing.T) {
|
||||
model := &sample{
|
||||
Networks: types.ListUnknown(types.StringType),
|
||||
Detach: types.BoolUnknown(),
|
||||
Replicas: types.Int64Unknown(),
|
||||
}
|
||||
|
||||
raw := json.RawMessage(`{"networkIds":null,"detach":null,"replicas":null}`)
|
||||
if err := FromAPI(raw, model); err != nil {
|
||||
t.Fatalf("FromAPI returned an error: %v", err)
|
||||
}
|
||||
|
||||
if model.Networks.IsUnknown() {
|
||||
t.Error("networkIds stayed unknown; want null")
|
||||
}
|
||||
if !model.Networks.IsNull() {
|
||||
t.Errorf("networkIds = %v; want null", model.Networks)
|
||||
}
|
||||
if model.Detach.IsUnknown() || !model.Detach.IsNull() {
|
||||
t.Errorf("detach = %v; want null", model.Detach)
|
||||
}
|
||||
if model.Replicas.IsUnknown() || !model.Replicas.IsNull() {
|
||||
t.Errorf("replicas = %v; want null", model.Replicas)
|
||||
}
|
||||
}
|
||||
|
||||
// Keys absent from the response must not clobber existing values, but they
|
||||
// must also not survive as unknown once NullifyUnknown runs.
|
||||
func TestFromAPILeavesAbsentKeysUntouched(t *testing.T) {
|
||||
model := &sample{
|
||||
Name: types.StringValue("keep-me"),
|
||||
Networks: types.ListUnknown(types.StringType),
|
||||
}
|
||||
|
||||
if err := FromAPI(json.RawMessage(`{"description":"hi"}`), model); err != nil {
|
||||
t.Fatalf("FromAPI returned an error: %v", err)
|
||||
}
|
||||
|
||||
if got := model.Name.ValueString(); got != "keep-me" {
|
||||
t.Errorf("name = %q; want %q", got, "keep-me")
|
||||
}
|
||||
if got := model.Desc.ValueString(); got != "hi" {
|
||||
t.Errorf("description = %q; want %q", got, "hi")
|
||||
}
|
||||
if !model.Networks.IsUnknown() {
|
||||
t.Error("networkIds should still be unknown before NullifyUnknown runs")
|
||||
}
|
||||
|
||||
if err := NullifyUnknown(model); err != nil {
|
||||
t.Fatalf("NullifyUnknown returned an error: %v", err)
|
||||
}
|
||||
if model.Networks.IsUnknown() {
|
||||
t.Error("networkIds stayed unknown after NullifyUnknown")
|
||||
}
|
||||
}
|
||||
|
||||
// noread fields must never be overwritten by the API response.
|
||||
func TestFromAPISkipsNoReadFields(t *testing.T) {
|
||||
model := &sample{Secret: types.StringValue("plaintext")}
|
||||
|
||||
if err := FromAPI(json.RawMessage(`{"secret":"$2b$10$hashed"}`), model); err != nil {
|
||||
t.Fatalf("FromAPI returned an error: %v", err)
|
||||
}
|
||||
if got := model.Secret.ValueString(); got != "plaintext" {
|
||||
t.Errorf("secret = %q; want the configured value to survive", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToAPIPhaseSelection(t *testing.T) {
|
||||
model := &sample{
|
||||
ID: types.StringValue("abc123"),
|
||||
Name: types.StringValue("thing"),
|
||||
Desc: types.StringNull(),
|
||||
ServerID: types.StringValue("srv1"),
|
||||
Status: types.StringValue("running"),
|
||||
Networks: types.ListUnknown(types.StringType),
|
||||
}
|
||||
|
||||
create, err := ToAPI(model, PhaseCreate)
|
||||
if err != nil {
|
||||
t.Fatalf("ToAPI(create) returned an error: %v", err)
|
||||
}
|
||||
if _, ok := create["thingId"]; ok {
|
||||
t.Error("create body must not carry the server-generated ID")
|
||||
}
|
||||
if create["serverId"] != "srv1" {
|
||||
t.Errorf("create body serverId = %v; want srv1", create["serverId"])
|
||||
}
|
||||
if _, ok := create["status"]; ok {
|
||||
t.Error("read-only fields must never be sent")
|
||||
}
|
||||
if _, ok := create["networkIds"]; ok {
|
||||
t.Error("unknown values must never be sent")
|
||||
}
|
||||
// description is null but tagged nullable, so it is sent explicitly.
|
||||
value, ok := create["description"]
|
||||
if !ok || value != nil {
|
||||
t.Errorf("description = %v (present %v); want an explicit null", value, ok)
|
||||
}
|
||||
|
||||
update, err := ToAPI(model, PhaseUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("ToAPI(update) returned an error: %v", err)
|
||||
}
|
||||
if update["thingId"] != "abc123" {
|
||||
t.Errorf("update body thingId = %v; want abc123", update["thingId"])
|
||||
}
|
||||
if _, ok := update["serverId"]; ok {
|
||||
t.Error("create-only fields must not be sent on update")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user