// 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