Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <database-login>`
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
<account-login>` calling `get_accounts` with an `account_login`
filter. The result is unwrapped from the single-entry array so the
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <database-login>` (`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`)
Expand Down
1 change: 1 addition & 0 deletions cmd/kasapi-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func main() {
cli.NewTLDsCmd(opts),
cli.NewDNSCmd(opts),
cli.NewMailCmd(opts),
cli.NewDatabasesCmd(opts),
cli.NewUsageCmd(opts),
cli.NewConfigCmd(opts),
)
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/databases.go
Original file line number Diff line number Diff line change
@@ -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 <database-login>
// (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 <database-login>",
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
},
}
}
29 changes: 29 additions & 0 deletions internal/cli/databases_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
157 changes: 157 additions & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
@@ -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"},
}
}
Loading
Loading