From 6edce5a584a4361c7dd1acadbf51c9bc33182112 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Sun, 3 May 2026 11:50:26 +0200 Subject: [PATCH] feat: SOAP codec for KAS-API envelope (ns2:Map decoder + JSON request encoder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3. internal/soap exposes: - Value: discriminated union over xsi:type (string/int/float/boolean, ns2:Map, SOAP-ENC:Array). Map preserves insertion order via []KV. - Decode: parses a KasApi SOAP envelope; returns *Response on success and *FaultError on SOAP-ENV:Fault. - Response.Body exposes typed shortcuts (KasFloodDelay, ReturnString, ReturnInfo, Msg) and the full ordered Raw map. - EncodeRequest: serializes a typed Request as JSON inside the envelope; json.Marshal HTML-escapes <, > and & so the body is always XML-safe. Tests are table-driven over every response fixture under testdata/ (471 files), dispatched by content: envelopes containing SOAP-ENV:Fault must surface as *FaultError, others must decode into a populated Response. testdata/session/ is excluded — those are KasAuth responses covered by issue #5. Notable real-world quirks pinned by tests: - testdata/ftpuser/get_ftpuser_response_failed_empty_list.xml is named "failed" but is a success envelope with empty ReturnInfo array; testdata/ddns/get_ddnsusers_response_failed_empty_list.xml for the same logical case is a Fault. KAS is inconsistent here. --- CHANGELOG.md | 8 + internal/soap/doc.go | 14 +- internal/soap/envelope.go | 196 +++++++++++++++++++++++ internal/soap/request.go | 67 ++++++++ internal/soap/soap_test.go | 242 +++++++++++++++++++++++++++++ internal/soap/value.go | 310 +++++++++++++++++++++++++++++++++++++ 6 files changed, 835 insertions(+), 2 deletions(-) create mode 100644 internal/soap/envelope.go create mode 100644 internal/soap/request.go create mode 100644 internal/soap/soap_test.go create mode 100644 internal/soap/value.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fef61d..0e752a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/soap` codec for the KAS-API envelope: `Value` discriminated + union mirroring the Apache xml-soap `ns2:Map` shape (xsi:type: + string/int/float/boolean, ns2:Map, SOAP-ENC:Array), `Decode` for + `KasApiResponse`/`SOAP-ENV:Fault` envelopes returning `*Response` or + `*FaultError`, and `EncodeRequest` for the JSON-in-`` request + envelope. Table-driven tests cover 471 response fixtures plus shape + pins and encoder validation. (`testdata/session/` is left for the + KasAuth client in issue #5.) - Bootstrap Go module `github.com/chmmou/kasapi-cli` (Go 1.23). - `cmd/kasapi-cli` entry point with build-stamped `--version`. - `internal/` package skeleton mirroring the clean-architecture layering in diff --git a/internal/soap/doc.go b/internal/soap/doc.go index 989d549..33b70f2 100644 --- a/internal/soap/doc.go +++ b/internal/soap/doc.go @@ -1,3 +1,13 @@ -// Package soap implements the Apache xml-soap ns2:Map codec used by every -// KAS-API response. See issue #3. +// Package soap implements the codec for the KAS-API SOAP shape. +// +// Responses use the Apache xml-soap "ns2:Map" representation: every value +// carries an explicit xsi:type discriminator (xsd:string / xsd:int / +// xsd:float / xsd:boolean / ns2:Map / SOAP-ENC:Array). The package exposes +// a Value type that mirrors that shape and a Decode entry point for the +// SOAP envelope. SOAP-ENV:Fault bodies surface as *FaultError. +// +// Requests are a JSON payload wrapped in {json}. +// EncodeRequest produces a valid envelope from the typed Request struct. +// +// See issue #3 for the original design. package soap diff --git a/internal/soap/envelope.go b/internal/soap/envelope.go new file mode 100644 index 0000000..35a3e03 --- /dev/null +++ b/internal/soap/envelope.go @@ -0,0 +1,196 @@ +package soap + +import ( + "encoding/xml" + "errors" + "fmt" + "io" +) + +// Response is the parsed body of a successful KasApi SOAP envelope. It +// keeps both the typed shortcuts (KasFloodDelay, ReturnString, ReturnInfo) +// and the full ordered Map for callers needing extras. +type Response struct { + Request Value + Body ResponseBody + RawReturn Value +} + +// ResponseBody mirrors the canonical fields under . +type ResponseBody struct { + KasFloodDelay float64 + ReturnString string + ReturnInfo Value + Msg Value + Raw []KV +} + +// Fault is the parsed payload of a SOAP-ENV:Fault element. +type Fault struct { + Code string + String string + Actor string + Detail string +} + +// FaultError wraps a Fault so it can be returned as a Go error. +type FaultError struct { + Fault Fault +} + +func (e *FaultError) Error() string { + if e.Fault.Detail != "" { + return fmt.Sprintf("kas-api fault %q: %s", e.Fault.String, e.Fault.Detail) + } + return fmt.Sprintf("kas-api fault %q", e.Fault.String) +} + +// Decode parses a KasApi SOAP envelope. It returns a typed Response on +// success and a *FaultError when the body contained a SOAP-ENV:Fault. +func Decode(r io.Reader) (*Response, error) { + dec := xml.NewDecoder(r) + for { + tok, err := dec.Token() + if err == io.EOF { + return nil, errors.New("soap: empty document") + } + if err != nil { + return nil, err + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local == "Body" { + return decodeBody(dec, start) + } + } +} + +func decodeBody(d *xml.Decoder, parent xml.StartElement) (*Response, error) { + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "KasApiResponse": + return decodeKasApiResponse(d, t) + case "Fault": + fault, err := decodeFault(d, t) + if err != nil { + return nil, err + } + return nil, &FaultError{Fault: *fault} + default: + if err := d.Skip(); err != nil { + return nil, err + } + } + case xml.EndElement: + if t.Name == parent.Name { + return nil, errors.New("soap: empty Body") + } + } + } +} + +func decodeKasApiResponse(d *xml.Decoder, parent xml.StartElement) (*Response, error) { + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "return" { + var v Value + if err := v.UnmarshalXML(d, t); err != nil { + return nil, err + } + return buildResponse(v) + } + if err := d.Skip(); err != nil { + return nil, err + } + case xml.EndElement: + if t.Name == parent.Name { + return nil, errors.New("soap: missing element") + } + } + } +} + +func buildResponse(top Value) (*Response, error) { + if top.Kind != KindMap { + return nil, fmt.Errorf("soap: is not a Map (kind=%d)", top.Kind) + } + out := &Response{RawReturn: top} + for _, kv := range top.Map { + switch kv.Key { + case "Request": + out.Request = kv.Value + case "Response": + body, err := buildResponseBody(kv.Value) + if err != nil { + return nil, err + } + out.Body = body + } + } + return out, nil +} + +func buildResponseBody(v Value) (ResponseBody, error) { + var out ResponseBody + if v.Kind != KindMap { + return out, fmt.Errorf("soap: Response is not a Map (kind=%d)", v.Kind) + } + out.Raw = v.Map + for _, kv := range v.Map { + switch kv.Key { + case "KasFloodDelay": + out.KasFloodDelay = kv.Value.AsFloat() + case "ReturnString": + out.ReturnString = kv.Value.AsString() + case "ReturnInfo": + out.ReturnInfo = kv.Value + case "Msg": + out.Msg = kv.Value + } + } + return out, nil +} + +func decodeFault(d *xml.Decoder, parent xml.StartElement) (*Fault, error) { + out := &Fault{} + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + s, err := readCharData(d, t) + if err != nil { + return nil, err + } + switch t.Name.Local { + case "faultcode": + out.Code = s + case "faultstring": + out.String = s + case "faultactor": + out.Actor = s + case "detail": + out.Detail = s + } + case xml.EndElement: + if t.Name == parent.Name { + return out, nil + } + } + } +} diff --git a/internal/soap/request.go b/internal/soap/request.go new file mode 100644 index 0000000..b336600 --- /dev/null +++ b/internal/soap/request.go @@ -0,0 +1,67 @@ +package soap + +import ( + "encoding/json" + "fmt" + "io" +) + +// AuthType enumerates the kas_auth_type values the KAS API accepts. +type AuthType string + +// Auth types per https://kasapi.kasserver.com/dokumentation/phpdoc/. +const ( + AuthPlain AuthType = "plain" + AuthSession AuthType = "session" +) + +// Request is the typed payload for a KasApi call. It is encoded as JSON +// inside the element of the SOAP envelope. +type Request struct { + Login string + AuthType AuthType + AuthData string + Action string + Params map[string]any +} + +const requestTemplate = ` + + + + %s + + +` + +// EncodeRequest writes a SOAP request envelope for a KasApi call. The +// payload is serialized as JSON; json.Marshal HTML-escapes <, > and &, so +// the JSON body is always safe inside the XML element. +func EncodeRequest(w io.Writer, r Request) error { + if r.Action == "" { + return fmt.Errorf("soap: Request.Action is required") + } + if r.Login == "" { + return fmt.Errorf("soap: Request.Login is required") + } + if r.AuthType == "" { + return fmt.Errorf("soap: Request.AuthType is required") + } + params := r.Params + if params == nil { + params = map[string]any{} + } + payload := map[string]any{ + "KasRequestParams": params, + "kas_action": r.Action, + "kas_auth_data": r.AuthData, + "kas_auth_type": string(r.AuthType), + "kas_login": r.Login, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("soap: marshal params: %w", err) + } + _, err = fmt.Fprintf(w, requestTemplate, body) + return err +} diff --git a/internal/soap/soap_test.go b/internal/soap/soap_test.go new file mode 100644 index 0000000..9fec250 --- /dev/null +++ b/internal/soap/soap_test.go @@ -0,0 +1,242 @@ +package soap_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func isFaultEnvelope(t *testing.T, path string) bool { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.Contains(string(b), "SOAP-ENV:Fault") +} + +func decodeFile(t *testing.T, path string) (*soap.Response, error) { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer func() { _ = f.Close() }() + return soap.Decode(f) +} + +// TestDecodeAllResponseFixtures walks every response fixture under +// testdata/ (excluding testdata/session/, which is KasAuth) and dispatches +// by content: fixtures that contain SOAP-ENV:Fault must produce a +// *FaultError; everything else must decode into a populated Response. +func TestDecodeAllResponseFixtures(t *testing.T) { + root := filepath.Join(repoRoot(t), "testdata") + sessionPath := filepath.Join(root, "session") + string(filepath.Separator) + var paths []string + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if strings.HasPrefix(p, sessionPath) { + return nil + } + base := filepath.Base(p) + if !strings.HasSuffix(base, ".xml") { + return nil + } + if !strings.Contains(base, "_response_") && !strings.HasPrefix(base, "response_failed_") { + return nil + } + paths = append(paths, p) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + if len(paths) == 0 { + t.Fatal("no response fixtures found") + } + for _, p := range paths { + rel, _ := filepath.Rel(repoRoot(t), p) + t.Run(rel, func(t *testing.T) { + expectFault := isFaultEnvelope(t, p) + resp, err := decodeFile(t, p) + if expectFault { + var fe *soap.FaultError + if !errors.As(err, &fe) { + t.Fatalf("expected *FaultError, got %T: %v", err, err) + } + if fe.Fault.String == "" { + t.Errorf("faultstring is empty") + } + return + } + if err != nil { + t.Fatalf("Decode: %v", err) + } + if resp.Body.KasFloodDelay <= 0 { + t.Errorf("KasFloodDelay = %v, want > 0", resp.Body.KasFloodDelay) + } + if resp.Body.ReturnString == "" { + t.Errorf("ReturnString empty") + } + if resp.Request.Kind != soap.KindMap { + t.Errorf("Request kind = %d, want Map", resp.Request.Kind) + } + }) + } +} + +// TestDecodeGetAccountsShape pins the most-used response fixture: it must +// produce a 4-element array of account maps with the documented columns. +func TestDecodeGetAccountsShape(t *testing.T) { + resp, err := decodeFile(t, filepath.Join(repoRoot(t), "testdata/account/get_accounts_response_success.xml")) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if got, want := resp.Body.ReturnString, "TRUE"; got != want { + t.Errorf("ReturnString = %q, want %q", got, want) + } + if resp.Body.ReturnInfo.Kind != soap.KindArray { + t.Fatalf("ReturnInfo kind = %d, want Array", resp.Body.ReturnInfo.Kind) + } + if got := len(resp.Body.ReturnInfo.Array); got != 4 { + t.Fatalf("len(ReturnInfo) = %d, want 4", got) + } + first := resp.Body.ReturnInfo.Array[0] + login, ok := first.Get("account_login") + if !ok || login.Kind != soap.KindString || login.String != "w0000001" { + t.Errorf("first.account_login = %+v, want xsd:string w0000001", login) + } +} + +// TestDecodeGetServerInformationShape exercises the array-of-maps shape +// where ReturnInfo lists installed services. +func TestDecodeGetServerInformationShape(t *testing.T) { + resp, err := decodeFile(t, filepath.Join(repoRoot(t), "testdata/account/get_server_information_response_success.xml")) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if resp.Body.ReturnInfo.Kind != soap.KindArray { + t.Fatalf("ReturnInfo kind = %d, want Array", resp.Body.ReturnInfo.Kind) + } + if got := len(resp.Body.ReturnInfo.Array); got != 8 { + t.Errorf("len(ReturnInfo) = %d, want 8", got) + } + mysql := resp.Body.ReturnInfo.Array[0] + svc, _ := mysql.Get("service") + if svc.AsString() != "mysql" { + t.Errorf("first.service = %q, want mysql", svc.AsString()) + } +} + +// TestDecodeFaultDetail verifies that fault fixtures expose faultstring and +// detail correctly. +func TestDecodeFaultDetail(t *testing.T) { + _, err := decodeFile(t, filepath.Join(repoRoot(t), "testdata/response_failed_no_auth.xml")) + var fe *soap.FaultError + if !errors.As(err, &fe) { + t.Fatalf("expected *FaultError, got %v", err) + } + if fe.Fault.String != "no_auth" { + t.Errorf("faultstring = %q, want %q", fe.Fault.String, "no_auth") + } + if !strings.Contains(fe.Fault.Detail, "kas_login") { + t.Errorf("detail = %q, want it to mention kas_login", fe.Fault.Detail) + } +} + +// TestDecodeEmptyArray covers the self-closing +// +// case (empty KasRequestParams in the echoed request). +func TestDecodeEmptyArray(t *testing.T) { + resp, err := decodeFile(t, filepath.Join(repoRoot(t), "testdata/account/get_accounts_response_success.xml")) + if err != nil { + t.Fatalf("Decode: %v", err) + } + params, ok := resp.Request.Get("KasRequestParams") + if !ok { + t.Fatal("KasRequestParams not found") + } + if params.Kind != soap.KindArray { + t.Errorf("KasRequestParams kind = %d, want Array", params.Kind) + } + if len(params.Array) != 0 { + t.Errorf("expected empty array, got %d elements", len(params.Array)) + } +} + +// TestEncodeRequestRoundtrip verifies the encoder produces a parseable +// envelope and that the JSON payload contains the expected fields. +func TestEncodeRequestRoundtrip(t *testing.T) { + var buf bytes.Buffer + err := soap.EncodeRequest(&buf, soap.Request{ + Login: "w0000000", + AuthType: soap.AuthSession, + AuthData: "REDACTED", + Action: "get_accounts", + Params: nil, + }) + if err != nil { + t.Fatalf("EncodeRequest: %v", err) + } + out := buf.String() + for _, want := range []string{ + ``, + `"kas_login":"w0000000"`, + `"kas_action":"get_accounts"`, + `"kas_auth_type":"session"`, + `"kas_auth_data":"REDACTED"`, + } { + if !strings.Contains(out, want) { + t.Errorf("encoded request missing %q\n--- output ---\n%s", want, out) + } + } +} + +// TestEncodeRequestRequiresFields verifies that EncodeRequest rejects +// malformed input rather than silently producing an unauthenticated call. +func TestEncodeRequestRequiresFields(t *testing.T) { + cases := []struct { + name string + req soap.Request + }{ + {"no action", soap.Request{Login: "w0000000", AuthType: soap.AuthSession}}, + {"no login", soap.Request{Action: "x", AuthType: soap.AuthSession}}, + {"no auth type", soap.Request{Action: "x", Login: "w0000000"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := soap.EncodeRequest(&bytes.Buffer{}, tc.req) + if err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/internal/soap/value.go b/internal/soap/value.go new file mode 100644 index 0000000..157414c --- /dev/null +++ b/internal/soap/value.go @@ -0,0 +1,310 @@ +package soap + +import ( + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +// Kind identifies the runtime shape of a Value. +type Kind uint8 + +// Kinds enumerate the discriminator values KAS uses on xsi:type. +const ( + KindNil Kind = iota + KindString + KindInt + KindFloat + KindBool + KindMap + KindArray +) + +// KV is one entry of an ordered Map. The Apache xml-soap ns2:Map preserves +// insertion order; we keep that order so callers can render or compare +// without surprise. +type KV struct { + Key string + Value Value +} + +// Value is the discriminated union for ns2:Map / SOAP-ENC:Array / xsd:* +// scalars in a KAS response. +type Value struct { + Kind Kind + String string + Int int64 + Float float64 + Bool bool + Map []KV + Array []Value +} + +// UnmarshalXML reads a single typed (or ) element. The +// xsi:type attribute on start drives the dispatch. +func (v *Value) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + xsiType, isNil := readTypeAttrs(start.Attr) + if isNil { + v.Kind = KindNil + return d.Skip() + } + switch classifyType(xsiType) { + case KindString: + s, err := readCharData(d, start) + if err != nil { + return err + } + v.Kind = KindString + v.String = s + return nil + case KindInt: + s, err := readCharData(d, start) + if err != nil { + return err + } + s = strings.TrimSpace(s) + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("soap: invalid xsd:int %q: %w", s, err) + } + v.Kind = KindInt + v.Int = n + return nil + case KindFloat: + s, err := readCharData(d, start) + if err != nil { + return err + } + s = strings.TrimSpace(s) + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("soap: invalid xsd:float %q: %w", s, err) + } + v.Kind = KindFloat + v.Float = f + return nil + case KindBool: + s, err := readCharData(d, start) + if err != nil { + return err + } + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "true", "1": + v.Bool = true + case "false", "0", "": + v.Bool = false + default: + return fmt.Errorf("soap: invalid xsd:boolean %q", s) + } + v.Kind = KindBool + return nil + case KindMap: + v.Kind = KindMap + return decodeMapItems(d, start, &v.Map) + case KindArray: + v.Kind = KindArray + return decodeArrayItems(d, start, &v.Array) + default: + return fmt.Errorf("soap: unknown xsi:type %q on <%s>", xsiType, start.Name.Local) + } +} + +func decodeMapItems(d *xml.Decoder, parent xml.StartElement, out *[]KV) error { + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local != "item" { + if err := d.Skip(); err != nil { + return err + } + continue + } + kv, err := decodeMapItem(d, t) + if err != nil { + return err + } + *out = append(*out, kv) + case xml.EndElement: + if t.Name == parent.Name { + return nil + } + } + } +} + +func decodeMapItem(d *xml.Decoder, start xml.StartElement) (KV, error) { + var kv KV + for { + tok, err := d.Token() + if err != nil { + return kv, err + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "key": + s, err := readCharData(d, t) + if err != nil { + return kv, err + } + kv.Key = strings.TrimSpace(s) + case "value": + var inner Value + if err := inner.UnmarshalXML(d, t); err != nil { + return kv, err + } + kv.Value = inner + default: + if err := d.Skip(); err != nil { + return kv, err + } + } + case xml.EndElement: + if t.Name == start.Name { + return kv, nil + } + } + } +} + +func decodeArrayItems(d *xml.Decoder, parent xml.StartElement, out *[]Value) error { + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local != "item" { + if err := d.Skip(); err != nil { + return err + } + continue + } + var v Value + if err := v.UnmarshalXML(d, t); err != nil { + return err + } + *out = append(*out, v) + case xml.EndElement: + if t.Name == parent.Name { + return nil + } + } + } +} + +func readCharData(d *xml.Decoder, start xml.StartElement) (string, error) { + var sb strings.Builder + for { + tok, err := d.Token() + if err != nil { + return "", err + } + switch t := tok.(type) { + case xml.CharData: + sb.Write(t) + case xml.EndElement: + if t.Name == start.Name { + return sb.String(), nil + } + case xml.StartElement: + if err := d.Skip(); err != nil { + return "", err + } + } + } +} + +func readTypeAttrs(attrs []xml.Attr) (xsiType string, isNil bool) { + for _, a := range attrs { + switch a.Name.Local { + case "type": + xsiType = a.Value + case "nil": + if a.Value == "true" || a.Value == "1" { + isNil = true + } + } + } + return xsiType, isNil +} + +func classifyType(t string) Kind { + if t == "" { + return KindNil + } + _, local, found := strings.Cut(t, ":") + if !found { + local = t + } + switch local { + case "string": + return KindString + case "int", "integer", "long", "short": + return KindInt + case "float", "double", "decimal": + return KindFloat + case "boolean": + return KindBool + case "Map": + return KindMap + case "Array": + return KindArray + } + return Kind(255) +} + +// Get looks up a key in a Map Value. It returns the zero Value and false if +// v is not a Map or the key is absent. +func (v Value) Get(key string) (Value, bool) { + if v.Kind != KindMap { + return Value{}, false + } + for _, kv := range v.Map { + if kv.Key == key { + return kv.Value, true + } + } + return Value{}, false +} + +// AsString coerces scalar kinds to their textual form. Maps and Arrays +// return the empty string. +func (v Value) AsString() string { + switch v.Kind { + case KindString: + return v.String + case KindInt: + return strconv.FormatInt(v.Int, 10) + case KindFloat: + return strconv.FormatFloat(v.Float, 'g', -1, 64) + case KindBool: + return strconv.FormatBool(v.Bool) + } + return "" +} + +// AsFloat coerces numeric kinds to float64. Strings are parsed when they +// represent a valid number; everything else returns 0. +func (v Value) AsFloat() float64 { + switch v.Kind { + case KindFloat: + return v.Float + case KindInt: + return float64(v.Int) + case KindString: + f, err := strconv.ParseFloat(strings.TrimSpace(v.String), 64) + if err != nil { + return 0 + } + return f + } + return 0 +}