From 3cecfc074f1a1db63df3765414165d25cc2683e1 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 22:43:58 +0200 Subject: [PATCH] feat: databases get/list via get_databases (with database_login filter) Adds internal/database read module and `kasapi-cli databases list|get` subcommand tree wrapping `get_databases`. List returns the full DatabaseList; `get ` reuses the same endpoint with a database_login filter and unwraps the single-entry array, matching the mail accounts / accounts pattern. Mapping tests cover both fixtures (testdata/database/get_databases_response_success.xml, get_database_response_success.xml). Refs #11. --- CHANGELOG.md | 11 ++ ROADMAP.md | 3 +- cmd/kasapi-cli/main.go | 1 + internal/cli/databases.go | 66 +++++++++ internal/cli/databases_test.go | 29 ++++ internal/database/database.go | 157 +++++++++++++++++++++ internal/database/database_test.go | 215 +++++++++++++++++++++++++++++ 7 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 internal/cli/databases.go create mode 100644 internal/cli/databases_test.go create mode 100644 internal/database/database.go create mode 100644 internal/database/database_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ae74265..be71d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/database` read module and `kasapi-cli databases list|get` + subcommand tree wrapping `get_databases`. The list variant decodes + the Array of Maps into a typed `DatabaseList`; `get ` + reuses the same endpoint with a `database_login` filter and unwraps + the single-entry result, mirroring the mail accounts / accounts + pattern. The list view reports `used_database_space` in MB; the + singular view uses a key/value table and omits `database_password` + (still available via `--output=json|yaml`). Mapping tests run against + `testdata/database/get_databases_response_success.xml` and + `get_database_response_success.xml`. Refs #11. + - `account.Client.Get(ctx, login)` and `kasapi-cli accounts get ` calling `get_accounts` with an `account_login` filter. The result is unwrapped from the single-entry array so the diff --git a/ROADMAP.md b/ROADMAP.md index f0137ed..bc7bdc8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl ## Hosting resources -- [ ] Databases (`get_databases`, `add_database`, `update_database`, `delete_database`) +- [x] `databases list` / `databases get ` (`get_databases`, with `database_login` filter) +- [ ] Database write paths (`add_database`, `update_database`, `delete_database`) - [ ] FTP users (`get_ftpusers`, `add_ftpuser`, `update_ftpuser`, `delete_ftpuser`) - [ ] Samba users (`get_sambausers`, `add_sambauser`, `update_sambauser`, `delete_sambauser`) - [ ] DDNS users (`get_ddnsusers`, `add_ddnsuser`, `update_ddnsuser`, `delete_ddnsuser`) diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 298c7e5..5f5467b 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -22,6 +22,7 @@ func main() { cli.NewTLDsCmd(opts), cli.NewDNSCmd(opts), cli.NewMailCmd(opts), + cli.NewDatabasesCmd(opts), cli.NewUsageCmd(opts), cli.NewConfigCmd(opts), ) diff --git a/internal/cli/databases.go b/internal/cli/databases.go new file mode 100644 index 0000000..5e03b53 --- /dev/null +++ b/internal/cli/databases.go @@ -0,0 +1,66 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/database" +) + +// NewDatabasesCmd returns the "kasapi-cli databases" subcommand tree: +// list (get_databases, no filter) and get +// (get_databases with a database_login filter). +func NewDatabasesCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "databases", + Short: "Inspect databases visible to the login (get_databases)", + } + cmd.AddCommand( + newDatabasesListCmd(opts), + newDatabasesGetCmd(opts), + ) + return cmd +} + +func newDatabasesListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all databases (get_databases, no filter)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := database.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_databases") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newDatabasesGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show details for a single database (get_databases with database_login)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + d, err := database.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_databases") + } + if err := Render(cmd.OutOrStdout(), opts.Output, d); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/cli/databases_test.go b/internal/cli/databases_test.go new file mode 100644 index 0000000..6f12911 --- /dev/null +++ b/internal/cli/databases_test.go @@ -0,0 +1,29 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/cli" +) + +func TestDatabasesCmdHelpListsSubcommands(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDatabasesCmd(opts)) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"databases", "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + out := buf.String() + for _, want := range []string{"list", "get"} { + if !strings.Contains(out, want) { + t.Errorf("--help output missing %q\n%s", want, out) + } + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..9549984 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,157 @@ +package database + +import ( + "context" + "fmt" + "strconv" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// Database is one entry of get_databases. The list and singular views +// (the latter being get_databases called with a database_login filter) +// return the same Map shape, so a single struct covers both. +type Database struct { + Name string `json:"database_name" yaml:"database_name"` + Login string `json:"database_login" yaml:"database_login"` + Password string `json:"database_password,omitempty" yaml:"database_password,omitempty"` + Comment string `json:"database_comment" yaml:"database_comment"` + AllowedHosts string `json:"database_allowed_hosts" yaml:"database_allowed_hosts"` + UsedDatabaseSpace float64 `json:"used_database_space" yaml:"used_database_space"` +} + +// DatabaseList is the typed payload of get_databases; satisfies +// cli.Tabular. +type DatabaseList []Database + +// Client groups the read endpoints scoped to databases: +// get_databases (list and singular). +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// List calls get_databases without parameters and decodes the response +// into a DatabaseList covering every database visible to the login. +func (c *Client) List(ctx context.Context) (DatabaseList, error) { + resp, err := c.API.Call(ctx, "get_databases", nil) + if err != nil { + return nil, err + } + list, err := DecodeDatabases(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("database: get_databases: %w", err) + } + return list, nil +} + +// Get calls get_databases with a database_login filter and returns the +// single matching Database. The KAS API still wraps the result in an +// array; we unwrap it here so callers do not have to. An empty array +// surfaces as a not-found error. +func (c *Client) Get(ctx context.Context, login string) (Database, error) { + if login == "" { + return Database{}, fmt.Errorf("database: login is required") + } + resp, err := c.API.Call(ctx, "get_databases", map[string]any{"database_login": login}) + if err != nil { + return Database{}, err + } + list, err := DecodeDatabases(resp.Body.ReturnInfo) + if err != nil { + return Database{}, fmt.Errorf("database: get_databases: %w", err) + } + if len(list) == 0 { + return Database{}, fmt.Errorf("database: %q not found", login) + } + return list[0], nil +} + +// DecodeDatabases maps the ReturnInfo of a get_databases response (an +// Array of Maps) into the typed DatabaseList. +func DecodeDatabases(returnInfo soap.Value) (DatabaseList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("database: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(DatabaseList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("database: ReturnInfo[%d] is not a Map", i) + } + out = append(out, Database{ + Name: getString(item, "database_name"), + Login: getString(item, "database_login"), + Password: getString(item, "database_password"), + Comment: getString(item, "database_comment"), + AllowedHosts: getString(item, "database_allowed_hosts"), + UsedDatabaseSpace: getFloat(item, "used_database_space"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getFloat(m soap.Value, key string) float64 { + v, ok := m.Get(key) + if !ok { + return 0 + } + return v.AsFloat() +} + +// TableHeaders returns the columns used by --output=table for +// DatabaseList. +func (DatabaseList) TableHeaders() []string { + return []string{"LOGIN", "NAME", "COMMENT", "ALLOWED_HOSTS", "USED_MB"} +} + +// TableRows emits one row per Database entry. used_database_space is +// reported in KiB by KAS; we convert to MB to match the units used in +// the accounts/mailaccounts list views. +func (l DatabaseList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, d := range l { + rows = append(rows, []string{ + d.Login, + d.Name, + d.Comment, + d.AllowedHosts, + strconv.FormatFloat(d.UsedDatabaseSpace/1024, 'f', 2, 64), + }) + } + return rows +} + +// TableHeaders for the singular Database view: a key/value layout to +// match the rest of the singular detail commands (mail accounts, accounts). +func (Database) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. database_password is intentionally +// omitted — consumers that need it should use --output=json|yaml. +func (d Database) TableRows() [][]string { + return [][]string{ + {"database_login", d.Login}, + {"database_name", d.Name}, + {"database_comment", d.Comment}, + {"database_allowed_hosts", d.AllowedHosts}, + {"used_database_space", strconv.FormatFloat(d.UsedDatabaseSpace/1024, 'f', 2, 64) + " MB"}, + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..24e4bc3 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,215 @@ +package database_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/database" + "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 decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "database", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeDatabases(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_databases_response_success.xml") + got, err := database.DecodeDatabases(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeDatabases: %v", err) + } + if len(got) != 4 { + t.Fatalf("len = %d, want 4", len(got)) + } + d := got[0] + if d.Login != "d0123450" { + t.Errorf("Login = %q, want d0123450", d.Login) + } + if d.Name != "d0123450" { + t.Errorf("Name = %q, want d0123450", d.Name) + } + if d.Comment != "my database comment" { + t.Errorf("Comment = %q", d.Comment) + } + if d.UsedDatabaseSpace == 0 { + t.Errorf("UsedDatabaseSpace = 0, want non-zero from xsd:float") + } + // d0123451 is the only entry with a non-empty allowed_hosts in the + // fixture; verify the empty-string default survives for the others. + if got[1].AllowedHosts != "localhost" { + t.Errorf("got[1].AllowedHosts = %q, want localhost", got[1].AllowedHosts) + } + if got[0].AllowedHosts != "" { + t.Errorf("got[0].AllowedHosts = %q, want empty", got[0].AllowedHosts) + } +} + +func TestDecodeDatabaseSingular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_database_response_success.xml") + got, err := database.DecodeDatabases(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeDatabases: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + d := got[0] + if d.Login != "d0123452" { + t.Errorf("Login = %q, want d0123452", d.Login) + } + if d.UsedDatabaseSpace == 0 { + t.Errorf("UsedDatabaseSpace = 0, want non-zero") + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_databases_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := database.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_databases" { + t.Errorf("action = %q, want get_databases", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 4 { + t.Errorf("len = %d, want 4", len(list)) + } +} + +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_database_response_success.xml") + fc := &fakeCaller{resp: resp} + d, err := database.NewClient(fc).Get(context.Background(), "d0123452") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_databases" { + t.Errorf("action = %q, want get_databases", fc.gotAction) + } + if got, _ := fc.gotParams["database_login"].(string); got != "d0123452" { + t.Errorf("params[database_login] = %v, want d0123452", fc.gotParams["database_login"]) + } + if d.Login != "d0123452" { + t.Errorf("Login = %q, want d0123452", d.Login) + } +} + +func TestClientGetEmptyLogin(t *testing.T) { + t.Parallel() + c := database.NewClient(&fakeCaller{}) + if _, err := c.Get(context.Background(), ""); err == nil { + t.Errorf("Get(\"\") err = nil, want validation error") + } +} + +func TestClientGetNotFound(t *testing.T) { + t.Parallel() + resp := &soap.Response{Body: soap.ResponseBody{ReturnInfo: soap.Value{Kind: soap.KindArray}}} + c := database.NewClient(&fakeCaller{resp: resp}) + if _, err := c.Get(context.Background(), "missing"); err == nil { + t.Errorf("Get on empty result err = nil, want not-found") + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := database.NewClient(&fakeCaller{err: want}) + if _, err := c.List(context.Background()); !errors.Is(err, want) { + t.Errorf("List err = %v, want %v wrapped", err, want) + } + if _, err := c.Get(context.Background(), "d0123452"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) + } +} + +func TestDatabaseListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_databases_response_success.xml") + list, _ := database.DecodeDatabases(resp.Body.ReturnInfo) + headers := list.TableHeaders() + if headers[0] != "LOGIN" { + t.Errorf("headers[0] = %q, want LOGIN", headers[0]) + } + rows := list.TableRows() + if len(rows) != 4 { + t.Fatalf("rows = %d, want 4", len(rows)) + } + if rows[0][0] != "d0123450" { + t.Errorf("rows[0][0] = %q, want d0123450", rows[0][0]) + } +} + +func TestDatabaseTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_database_response_success.xml") + list, _ := database.DecodeDatabases(resp.Body.ReturnInfo) + if len(list) != 1 { + t.Fatalf("len = %d, want 1", len(list)) + } + d := list[0] + headers := d.TableHeaders() + if headers[0] != "FIELD" || headers[1] != "VALUE" { + t.Errorf("headers = %v, want [FIELD VALUE]", headers) + } + rows := d.TableRows() + if rows[0][0] != "database_login" || rows[0][1] != "d0123452" { + t.Errorf("rows[0] = %v, want [database_login d0123452]", rows[0]) + } +}