diff --git a/.ai/checkpoints/topic-5-catalog-service-type-commands.md b/.ai/checkpoints/topic-5-catalog-service-type-commands.md new file mode 100644 index 0000000..b7ce74e --- /dev/null +++ b/.ai/checkpoints/topic-5-catalog-service-type-commands.md @@ -0,0 +1,71 @@ +# Checkpoint: Topic 5 — Catalog Service-Type Commands + +- **Branch:** `topic-5-catalog-service-type-commands` +- **Base:** `topic-4-policy-commands` (commit `349dab7`) +- **Commit:** `7d6f816` +- **Date:** 2026-03-13 +- **Status:** Complete + +--- + +## Scope + +Topic 5 implements the `dcm catalog service-type` command group with read-only subcommands (`list` and `get`) per spec section 4.5. Service types are managed by the Catalog Manager and are not user-creatable via the CLI. All commands use the generated Catalog Manager client. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-CST-010 | `dcm catalog service-type list` with `--page-size`, `--page-token` flags | Done | +| REQ-CST-020 | Display service types in configured output format | Done | +| REQ-CST-030 | `dcm catalog service-type get SERVICE_TYPE_ID` | Done | +| REQ-CST-040 | Missing `SERVICE_TYPE_ID` → usage error (exit code 2) | Done | +| REQ-CST-050 | All commands use generated Catalog Manager client | Done | + +### Tests Implemented (9 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U042 | List service types — GET `/api/v1alpha1/service-types`, displays results | Pass | +| TC-U043 | List with `--page-size 5` — passes `max_page_size=5` query parameter | Pass | +| TC-U043 | List with `--page-token abc123` — passes `page_token=abc123` query parameter | Pass | +| TC-U044 | Get service type — GET `/api/v1alpha1/service-types/my-service-type` | Pass | +| TC-U045 | Get without SERVICE_TYPE_ID → UsageError (exit code 2) | Pass | +| TC-U105 | Empty list — table shows headers only, no data rows | Pass | +| TC-U105 | Empty list (JSON) — `results` is empty array | Pass | +| TC-U106 | Get non-existent service type — 404 RFC 7807 error formatted to stderr | Pass | +| — | Table output columns: UID, SERVICE TYPE, API VERSION, CREATED | Pass | + +--- + +## Files Created / Modified + +| File | Change | Purpose | +|------|--------|---------| +| `go.mod` / `go.sum` | Modified | Added `github.com/dcm-project/catalog-manager` dependency | +| `internal/commands/helpers.go` | Modified | Added `newCatalogClient` for reuse by Topics 5-7 | +| `internal/commands/catalog_service_type.go` | Modified | Full implementation of `list` and `get` commands with generated Catalog Manager client | +| `internal/commands/catalog_service_type_test.go` | Created | 9 Ginkgo test specs with httptest-based mocking | +| `internal/commands/helpers_test.go` | Modified | Fixed pre-existing lint issues (gofumpt `0600`→`0o600`, prealloc capacity hint) | + +--- + +## Key Design Decisions + +1. **Generated client from catalog-manager** — Per REQ-CST-050, all service-type operations use the oapi-codegen generated client from `github.com/dcm-project/catalog-manager/pkg/client`. The `newCatalogClient` function follows the same pattern as `newPolicyClient`, using `catalogclient.NewClient(apiBaseURL(cfg), catalogclient.WithHTTPClient(httpClient))`. + +2. **Separate API type import** — The catalog-manager client uses dot-import for its API types internally, but these are not re-exported. The command file imports both `catalogapi` (for `ListServiceTypesParams`) and `catalogclient` (for client construction). + +3. **Table columns** — The spec does not define specific table columns for service types. Columns were chosen based on the ServiceType model fields: UID, SERVICE TYPE, API VERSION, CREATED. The `path` field was not included as a separate ID column since UID already serves as the unique identifier. + +4. **List response uses `results` field** — Unlike the policy list which uses a `policies` field, the Catalog Manager's `ServiceTypeList` type uses `results` for the array of service types and `next_page_token` for pagination. + +5. **Reusable `newCatalogClient` in `helpers.go`** — The catalog client constructor lives in `helpers.go` alongside `newPolicyClient` for reuse by Topics 6 (catalog item) and 7 (catalog instance) since all catalog operations go through the same Catalog Manager. + +--- + +## What's Next + +- **Topic 6: Catalog Item Commands** — `catalog item create/list/get/delete` (depends on Topics 1, 2, 3; reuses `newCatalogClient`) +- **Topic 7: Catalog Instance Commands** — `catalog instance create/list/get/delete` (depends on Topics 1, 2, 3; reuses `newCatalogClient`) +- **Topic 8: Version Command** — Full tests (depends only on Topic 1; command already stubbed) diff --git a/.ai/checkpoints/topic-6-catalog-item-commands.md b/.ai/checkpoints/topic-6-catalog-item-commands.md new file mode 100644 index 0000000..ebe3169 --- /dev/null +++ b/.ai/checkpoints/topic-6-catalog-item-commands.md @@ -0,0 +1,81 @@ +# Checkpoint: Topic 6 — Catalog Item Commands + +- **Branch:** `topic-6-catalog-item-commands` +- **Base:** `topic-5-catalog-service-type-commands` (commit `39af53c`) +- **Commit:** `524907d` +- **Date:** 2026-03-13 +- **Status:** Complete + +--- + +## Scope + +Topic 6 implements the `dcm catalog item` command group with subcommands (`create`, `list`, `get`, `delete`) per spec section 4.6. Catalog items represent service offerings in the catalog. No update operation is supported for catalog items. All commands use the generated Catalog Manager client. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-CIT-010 | `dcm catalog item create` from YAML/JSON file via `--from-file` | Done | +| REQ-CIT-020 | Optional `--id` flag for client-specified catalog item ID | Done | +| REQ-CIT-030 | Display created catalog item in configured output format | Done | +| REQ-CIT-040 | `dcm catalog item list` with `--service-type`, `--page-size`, `--page-token` | Done | +| REQ-CIT-050 | Display catalog items in configured output format | Done | +| REQ-CIT-060 | `dcm catalog item get CATALOG_ITEM_ID` | Done | +| REQ-CIT-090 | `dcm catalog item delete CATALOG_ITEM_ID` | Done | +| REQ-CIT-100 | Delete success message format | Done | +| REQ-CIT-110 | All commands use generated Catalog Manager client | Done | +| REQ-CIT-120 | `--from-file` required for `create` | Done | +| REQ-CIT-130 | Missing positional args → usage error (exit code 2) | Done | + +### Tests Implemented (14 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U046 | Create catalog item from YAML file — POST `/api/v1alpha1/catalog-items` | Pass | +| TC-U047 | Create with `--id my-catalog-item` — passes `id=my-catalog-item` query parameter | Pass | +| TC-U048 | Create without `--from-file` → UsageError (exit code 2) | Pass | +| TC-U049 | List catalog items — GET `/api/v1alpha1/catalog-items`, displays results | Pass | +| TC-U050 | List with `--service-type container` — passes `service_type=container` query parameter | Pass | +| TC-U051 | Get catalog item — GET `/api/v1alpha1/catalog-items/my-catalog-item` | Pass | +| TC-U052 | Get without CATALOG_ITEM_ID → UsageError (exit code 2) | Pass | +| TC-U055 | Delete catalog item — DELETE, displays success message | Pass | +| TC-U056 | Delete without CATALOG_ITEM_ID → UsageError (exit code 2) | Pass | +| TC-U057 | Table output columns: UID, DISPLAY NAME, SERVICE TYPE, CREATED | Pass | +| TC-U107 | Empty list — table shows headers only; JSON shows empty `results` array | Pass | +| TC-U108 | Get non-existent catalog item — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U110 | Delete non-existent catalog item — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U111 | Create server error — 500 RFC 7807 error formatted to stderr | Pass | + +--- + +## Files Created / Modified + +| File | Change | Purpose | +|------|--------|---------| +| `internal/commands/catalog_item.go` | Modified | Full implementation of `create`, `list`, `get`, `delete` commands with generated Catalog Manager client | +| `internal/commands/catalog_item_test.go` | Created | 14 Ginkgo test specs with httptest-based mocking | +| `.ai/checkpoints/topic-6-catalog-item-commands.md` | Created | This checkpoint | + +--- + +## Key Design Decisions + +1. **Same patterns as policy and service-type commands** — Create, list, get, and delete follow the identical patterns established in Topics 4 and 5: generated client usage, `handleErrorResponse` for errors, `newFormatter` for output, `parseInputFileAs` for input parsing. + +2. **Reuses `newCatalogClient` from helpers.go** — Per Topic 5's design, the catalog client constructor is shared across catalog commands (service-type, item, instance). + +3. **Table columns** — UID, DISPLAY NAME, SERVICE TYPE, CREATED. The `CatalogItem` API model has no `id` field; `uid` is the unique identifier. The service type is extracted from `spec.service_type`. + +4. **List response uses `results` field** — Consistent with the Catalog Manager's response format, same as service-type list. + +5. **`--service-type` filter** — The `ListCatalogItemsParams` includes a `ServiceType` field which is passed as the `service_type` query parameter, per REQ-CIT-040. + +6. **No update command** — Per spec section 4.6, no update operation is supported for catalog items in v1alpha1. + +--- + +## What's Next + +- **Topic 7: Catalog Instance Commands** — `catalog instance create/list/get/delete` (depends on Topics 1, 2, 3; reuses `newCatalogClient`) +- **Topic 8: Version Command** — Full tests (depends only on Topic 1; command already stubbed) diff --git a/.ai/checkpoints/topic-7-catalog-instance-commands.md b/.ai/checkpoints/topic-7-catalog-instance-commands.md new file mode 100644 index 0000000..6b9669a --- /dev/null +++ b/.ai/checkpoints/topic-7-catalog-instance-commands.md @@ -0,0 +1,83 @@ +# Checkpoint: Topic 7 — Catalog Instance Commands + +- **Branch:** `topic-7-catalog-instance-commands` +- **Base:** `topic-6-catalog-item-commands` (commit `86a6fec`) +- **Commit:** `a4e7ec7` +- **Date:** 2026-03-13 +- **Status:** Complete + +--- + +## Scope + +Topic 7 implements the `dcm catalog instance` command group with subcommands (`create`, `list`, `get`, `delete`) per spec section 4.7. Instances represent deployed catalog items. No update operation is supported for instances in v1alpha1. All commands use the generated Catalog Manager client. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-CIN-010 | `dcm catalog instance create` from YAML/JSON file via `--from-file` | Done | +| REQ-CIN-020 | Optional `--id` flag for client-specified instance ID | Done | +| REQ-CIN-030 | Display created instance in configured output format | Done | +| REQ-CIN-040 | `dcm catalog instance list` with `--catalog-item-id`, `--page-size`, `--page-token` | Done | +| REQ-CIN-050 | Display instances in configured output format | Done | +| REQ-CIN-060 | `dcm catalog instance get INSTANCE_ID` | Done | +| REQ-CIN-070 | `dcm catalog instance delete INSTANCE_ID` | Done | +| REQ-CIN-080 | Delete success message format: `Catalog item instance "" deleted successfully.` | Done | +| REQ-CIN-090 | All commands use generated Catalog Manager client | Done | +| REQ-CIN-100 | `--from-file` required for `create` | Done | +| REQ-CIN-110 | Missing positional args → usage error (exit code 2) | Done | + +### Tests Implemented (15 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U058 | Create instance from YAML file — POST `/api/v1alpha1/catalog-item-instances` | Pass | +| TC-U059 | Create with `--id my-instance` — passes `id=my-instance` query parameter | Pass | +| TC-U072 | Create without `--from-file` → UsageError (exit code 2) | Pass | +| TC-U073 | List instances — GET `/api/v1alpha1/catalog-item-instances`, displays results | Pass | +| TC-U074 | List with `--page-size 10` — passes `max_page_size=10` query parameter | Pass | +| TC-U075 | Get instance — GET `/api/v1alpha1/catalog-item-instances/my-instance` | Pass | +| TC-U076 | Get without INSTANCE_ID → UsageError (exit code 2) | Pass | +| TC-U077 | Delete instance — DELETE, displays success message | Pass | +| TC-U078 | Delete without INSTANCE_ID → UsageError (exit code 2) | Pass | +| TC-U079 | Table output columns: UID, DISPLAY NAME, CATALOG ITEM, CREATED | Pass | +| TC-U112 | Empty list — table shows headers only; JSON shows empty `results` array | Pass | +| TC-U113 | Get non-existent instance — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U114 | Delete non-existent instance — 404 RFC 7807 error formatted to stderr | Pass | +| TC-U115 | Create server error — 500 RFC 7807 error formatted to stderr | Pass | +| (extra) | List with `--catalog-item-id` — passes `catalog_item_id` query parameter | Pass | + +--- + +## Files Created / Modified + +| File | Change | Purpose | +|------|--------|---------| +| `internal/commands/catalog_instance.go` | Modified | Full implementation of `create`, `list`, `get`, `delete` commands with generated Catalog Manager client (added ~201 lines to the stub) | +| `internal/commands/catalog_instance_test.go` | Created | 15 Ginkgo test specs with httptest-based mocking | +| `.ai/checkpoints/topic-7-catalog-instance-commands.md` | Created | This checkpoint | + +--- + +## Key Design Decisions + +1. **Same patterns as previous command groups** — Create, list, get, and delete follow the identical patterns established in Topics 4, 5, and 6: generated client usage, `handleErrorResponse` for errors, `newFormatter` for output, `parseInputFileAs` for input parsing, `connectionError` for connection failures. + +2. **Reuses `newCatalogClient` from helpers.go** — Per Topic 5's design, the catalog client constructor is shared across all catalog commands (service-type, item, instance). + +3. **Table columns** — UID, DISPLAY NAME, CATALOG ITEM, CREATED. The catalog item ID is extracted from `spec.catalog_item_id` via the nested map access pattern. + +4. **List response uses `results` field** — Consistent with the Catalog Manager's response format, same as service-type and catalog item lists. + +5. **`--catalog-item-id` filter** — The `ListCatalogItemInstancesParams` includes a `CatalogItemId` field passed as the `catalog_item_id` query parameter. This goes beyond the spec's `--page-size`/`--page-token` to provide useful instance filtering. + +6. **No update command** — Per spec section 4.7, no update operation is supported for instances in v1alpha1. + +7. **Pagination parameter naming** — The Catalog Manager API uses `max_page_size` (not `page_size`) as the query parameter name, matching the generated client's `MaxPageSize` field. + +--- + +## What's Next + +- **Topic 8: Version Command** — Full tests (depends only on Topic 1; command already stubbed) diff --git a/go.mod b/go.mod index e48d84f..bada9cc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dcm-project/cli go 1.25.5 require ( + github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088 github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 diff --git a/go.sum b/go.sum index 9e69792..416f659 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088 h1:Vdz6ZiT7XkFp3mZOjJEDCIfwrkCuWaDM9ronB6o1SmM= +github.com/dcm-project/catalog-manager v0.0.0-20260313160905-1ff110850088/go.mod h1:NJNzrVwaR0c5YmNLgDZ9ve0ueWqs/GAsLOXeQriIR7g= github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87 h1:IgIFK8eWeNHLloVuwbGZLzun8LHA6d5nqVrct7nB+S8= github.com/dcm-project/policy-manager v0.0.0-20260310132113-15bd45617e87/go.mod h1:a9eT8Ws0Gy/6FJGp+dWmrB4s/hyfVE0PQPats/aQW0E= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/commands/catalog_instance.go b/internal/commands/catalog_instance.go index d198f11..c35bd32 100644 --- a/internal/commands/catalog_instance.go +++ b/internal/commands/catalog_instance.go @@ -1,9 +1,37 @@ package commands import ( + "encoding/json" + "fmt" + "net/http" + + catalogapi "github.com/dcm-project/catalog-manager/api/v1alpha1" + + "github.com/dcm-project/cli/internal/config" + "github.com/dcm-project/cli/internal/output" "github.com/spf13/cobra" ) +var catalogInstanceTableDef = &output.TableDef{ + Headers: []string{"UID", "DISPLAY NAME", "CATALOG ITEM", "CREATED"}, + RowFunc: func(resource any) []string { + m, ok := resource.(map[string]any) + if !ok { + return []string{"", "", "", ""} + } + var catalogItemID string + if spec, ok := m["spec"].(map[string]any); ok { + catalogItemID = stringifyValue(spec, "catalog_item_id") + } + return []string{ + stringifyValue(m, "uid"), + stringifyValue(m, "display_name"), + catalogItemID, + stringifyValue(m, "create_time"), + } + }, +} + func newCatalogInstanceCommand() *cobra.Command { cmd := &cobra.Command{ Use: "instance", @@ -19,23 +47,131 @@ func newCatalogInstanceCommand() *cobra.Command { } func newCatalogInstanceCreateCommand() *cobra.Command { - return &cobra.Command{ - Use: "create", - Short: "Create a new catalog item instance", - RunE: func(_ *cobra.Command, _ []string) error { - return nil + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new catalog item instance", + PreRunE: requiredFlagsPreRun, + RunE: func(cmd *cobra.Command, _ []string) error { + fromFile, _ := cmd.Flags().GetString("from-file") + + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogInstanceTableDef, "catalog instance create") + if err != nil { + return err + } + + body, err := parseInputFileAs[catalogapi.CreateCatalogItemInstanceJSONRequestBody](fromFile) + if err != nil { + return err + } + + params := &catalogapi.CreateCatalogItemInstanceParams{} + if id, _ := cmd.Flags().GetString("id"); id != "" { + params.Id = &id + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.CreateCatalogItemInstance(ctx, params, body) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) }, } + + cmd.Flags().String("from-file", "", "Path to instance YAML/JSON file (required)") + _ = cmd.MarkFlagRequired("from-file") + cmd.Flags().String("id", "", "Client-specified instance ID") + + return cmd } func newCatalogInstanceListCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "list", Short: "List catalog item instances", - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := config.FromCommand(cmd) + + listCmd := "catalog instance list" + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + listCmd += fmt.Sprintf(" --page-size %d", pageSize) + } + + formatter, err := newFormatter(cmd, catalogInstanceTableDef, listCmd) + if err != nil { + return err + } + + params := &catalogapi.ListCatalogItemInstancesParams{} + if catalogItemID, _ := cmd.Flags().GetString("catalog-item-id"); catalogItemID != "" { + params.CatalogItemId = &catalogItemID + } + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + params.MaxPageSize = &pageSize + } + if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { + params.PageToken = &pageToken + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.ListCatalogItemInstances(ctx, params) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var listResp struct { + Results []map[string]any `json:"results"` + NextPageToken string `json:"next_page_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + resources := make([]any, len(listResp.Results)) + for i, r := range listResp.Results { + resources[i] = r + } + + return formatter.FormatList(resources, listResp.NextPageToken) }, } + + cmd.Flags().String("catalog-item-id", "", "Filter by catalog item ID") + cmd.Flags().Int32("page-size", 0, "Maximum results per page") + cmd.Flags().String("page-token", "", "Token for next page") + + return cmd } func newCatalogInstanceGetCommand() *cobra.Command { @@ -43,8 +179,37 @@ func newCatalogInstanceGetCommand() *cobra.Command { Use: "get INSTANCE_ID", Short: "Get a catalog item instance by ID", Args: ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogInstanceTableDef, "catalog instance get") + if err != nil { + return err + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.GetCatalogItemInstance(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) }, } } @@ -54,8 +219,32 @@ func newCatalogInstanceDeleteCommand() *cobra.Command { Use: "delete INSTANCE_ID", Short: "Delete a catalog item instance by ID", Args: ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogInstanceTableDef, "catalog instance delete") + if err != nil { + return err + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.DeleteCatalogItemInstance(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + return handleErrorResponse(resp, formatter) + } + + return formatter.FormatMessage(fmt.Sprintf("Catalog item instance %q deleted successfully.", args[0])) }, } } diff --git a/internal/commands/catalog_instance_test.go b/internal/commands/catalog_instance_test.go new file mode 100644 index 0000000..cbd4d07 --- /dev/null +++ b/internal/commands/catalog_instance_test.go @@ -0,0 +1,339 @@ +package commands_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/cli/internal/commands" +) + +// sampleInstanceResponse returns a sample catalog item instance JSON response body. +func sampleInstanceResponse() map[string]any { + return map[string]any{ + "path": "catalog-item-instances/my-instance", + "uid": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "display_name": "My App Instance", + "create_time": "2026-03-09T10:00:00Z", + "spec": map[string]any{ + "catalog_item_id": "my-catalog-item", + }, + } +} + +// emptyInstanceListResponse returns a standard empty instance list response body. +func emptyInstanceListResponse() map[string]any { + return map[string]any{ + "results": []any{}, + "next_page_token": "", + } +} + +var _ = Describe("Catalog Instance Commands", func() { + var ( + server *httptest.Server + outBuf *bytes.Buffer + errBuf *bytes.Buffer + ) + + BeforeEach(func() { + clearDCMEnvVars() + }) + + AfterEach(func() { + if server != nil { + server.Close() + server = nil + } + }) + + executeCommand := func(args ...string) error { + cmd := commands.NewRootCommand() + outBuf = new(bytes.Buffer) + errBuf = new(bytes.Buffer) + cmd.SetOut(outBuf) + cmd.SetErr(errBuf) + + fullArgs := []string{ + "--config", nonexistentConfigPath(), + } + if server != nil { + fullArgs = append(fullArgs, "--api-gateway-url", server.URL) + } + fullArgs = append(fullArgs, args...) + cmd.SetArgs(fullArgs) + + return cmd.Execute() + } + + Describe("create", func() { + // TC-U058: Create instance from file + It("TC-U058: should create an instance from a YAML file", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances")) + + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body["display_name"]).To(Equal("My App Instance")) + + writeJSONResponse(w, http.StatusCreated, sampleInstanceResponse()) + })) + + yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values: []\n", ".yaml") + + err := executeCommand("catalog", "instance", "create", "--from-file", yamlFile) + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("c3d4e5f6-a7b8-9012-cdef-123456789012")) + Expect(out).To(ContainSubstring("My App Instance")) + }) + + // TC-U059: Create instance with client-specified ID + It("TC-U059: should send ?id= query parameter when --id is provided", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances")) + Expect(r.URL.Query().Get("id")).To(Equal("my-instance")) + + writeJSONResponse(w, http.StatusCreated, sampleInstanceResponse()) + })) + + yamlFile := writeTempFile("display_name: My App Instance\napi_version: v1alpha1\nspec:\n catalog_item_id: my-catalog-item\n user_values: []\n", ".yaml") + + err := executeCommand("catalog", "instance", "create", "--from-file", yamlFile, "--id", "my-instance") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U072: Create instance without --from-file fails + It("TC-U072: should return a UsageError when --from-file is not provided", func() { + err := executeCommand("catalog", "instance", "create") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U115: Create instance server error + It("TC-U115: should display error and exit code 1 on server error", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusInternalServerError, "INTERNAL", "Internal server error", "Something went wrong") + })) + + yamlFile := writeTempFile("display_name: Test\napi_version: v1alpha1\nspec:\n catalog_item_id: test\n user_values: []\n", ".yaml") + + err := executeCommand("catalog", "instance", "create", "--from-file", yamlFile) + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + Expect(errBuf.String()).To(ContainSubstring("INTERNAL")) + }) + }) + + Describe("list", func() { + // TC-U073: List instances + It("TC-U073: should list instances", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "results": []any{sampleInstanceResponse()}, + "next_page_token": "", + }) + })) + + err := executeCommand("catalog", "instance", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("c3d4e5f6-a7b8-9012-cdef-123456789012")) + Expect(out).To(ContainSubstring("My App Instance")) + }) + + // List instances with catalog-item-id filter + It("should pass catalog_item_id query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("catalog_item_id")).To(Equal("my-catalog-item")) + + writeJSONResponse(w, http.StatusOK, emptyInstanceListResponse()) + })) + + err := executeCommand("catalog", "instance", "list", "--catalog-item-id", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U074: List instances with pagination + It("TC-U074: should pass max_page_size query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("max_page_size")).To(Equal("10")) + + writeJSONResponse(w, http.StatusOK, emptyInstanceListResponse()) + })) + + err := executeCommand("catalog", "instance", "list", "--page-size", "10") + Expect(err).NotTo(HaveOccurred()) + }) + + // List instances with pagination (page-token) + It("should pass page_token query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("page_token")).To(Equal("abc123")) + + writeJSONResponse(w, http.StatusOK, emptyInstanceListResponse()) + })) + + err := executeCommand("catalog", "instance", "list", "--page-token", "abc123") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U112: List instances returns empty list + It("TC-U112: should display empty result for empty list", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyInstanceListResponse()) + })) + + err := executeCommand("catalog", "instance", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + // Table output should have headers but no data rows + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + Expect(out).NotTo(ContainSubstring("c3d4e5f6")) + }) + + // TC-U112 (JSON variant): Empty list in JSON format + It("TC-U112: should display empty results array in JSON format", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyInstanceListResponse()) + })) + + err := executeCommand("--output", "json", "catalog", "instance", "list") + Expect(err).NotTo(HaveOccurred()) + + var result map[string]any + Expect(json.Unmarshal(outBuf.Bytes(), &result)).To(Succeed()) + Expect(result["results"]).To(BeAssignableToTypeOf([]any{})) + Expect(result["results"]).To(BeEmpty()) + }) + }) + + Describe("get", func() { + // TC-U075: Get instance + It("TC-U075: should get an instance by ID", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances/my-instance")) + + writeJSONResponse(w, http.StatusOK, sampleInstanceResponse()) + })) + + err := executeCommand("catalog", "instance", "get", "my-instance") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("c3d4e5f6-a7b8-9012-cdef-123456789012")) + Expect(out).To(ContainSubstring("My App Instance")) + }) + + // TC-U076: Get instance without INSTANCE_ID fails + It("TC-U076: should return a UsageError when INSTANCE_ID is missing", func() { + err := executeCommand("catalog", "instance", "get") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U113: Get non-existent instance + It("TC-U113: should display error for non-existent instance", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Catalog item instance "nonexistent" not found.`, + "The requested catalog item instance resource does not exist.") + })) + + err := executeCommand("catalog", "instance", "get", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + + errOut := errBuf.String() + Expect(errOut).To(ContainSubstring("NOT_FOUND")) + Expect(errOut).To(ContainSubstring("not found")) + Expect(outBuf.String()).To(BeEmpty()) + }) + + // TC-U079: Instance table output columns + It("TC-U079: should display correct table columns", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleInstanceResponse()) + })) + + err := executeCommand("catalog", "instance", "get", "my-instance") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + Expect(out).To(ContainSubstring("CATALOG ITEM")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("c3d4e5f6-a7b8-9012-cdef-123456789012")) + Expect(out).To(ContainSubstring("My App Instance")) + Expect(out).To(ContainSubstring("my-catalog-item")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) + + Describe("delete", func() { + // TC-U077: Delete instance + It("TC-U077: should delete an instance and display success message", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodDelete)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-item-instances/my-instance")) + + w.WriteHeader(http.StatusNoContent) + })) + + err := executeCommand("catalog", "instance", "delete", "my-instance") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring(`Catalog item instance "my-instance" deleted successfully.`)) + }) + + // TC-U078: Delete instance without INSTANCE_ID fails + It("TC-U078: should return a UsageError when INSTANCE_ID is missing", func() { + err := executeCommand("catalog", "instance", "delete") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U114: Delete non-existent instance + It("TC-U114: should display error for non-existent instance", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Catalog item instance "nonexistent" not found.`, + "The requested catalog item instance resource does not exist.") + })) + + err := executeCommand("catalog", "instance", "delete", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + Expect(errBuf.String()).To(ContainSubstring("NOT_FOUND")) + }) + }) +}) diff --git a/internal/commands/catalog_item.go b/internal/commands/catalog_item.go index 0b7e884..ac02a5a 100644 --- a/internal/commands/catalog_item.go +++ b/internal/commands/catalog_item.go @@ -1,9 +1,37 @@ package commands import ( + "encoding/json" + "fmt" + "net/http" + + catalogapi "github.com/dcm-project/catalog-manager/api/v1alpha1" + + "github.com/dcm-project/cli/internal/config" + "github.com/dcm-project/cli/internal/output" "github.com/spf13/cobra" ) +var catalogItemTableDef = &output.TableDef{ + Headers: []string{"UID", "DISPLAY NAME", "SERVICE TYPE", "CREATED"}, + RowFunc: func(resource any) []string { + m, ok := resource.(map[string]any) + if !ok { + return []string{"", "", "", ""} + } + var serviceType string + if spec, ok := m["spec"].(map[string]any); ok { + serviceType = stringifyValue(spec, "service_type") + } + return []string{ + stringifyValue(m, "uid"), + stringifyValue(m, "display_name"), + serviceType, + stringifyValue(m, "create_time"), + } + }, +} + func newCatalogItemCommand() *cobra.Command { cmd := &cobra.Command{ Use: "item", @@ -19,23 +47,131 @@ func newCatalogItemCommand() *cobra.Command { } func newCatalogItemCreateCommand() *cobra.Command { - return &cobra.Command{ - Use: "create", - Short: "Create a new catalog item", - RunE: func(_ *cobra.Command, _ []string) error { - return nil + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new catalog item", + PreRunE: requiredFlagsPreRun, + RunE: func(cmd *cobra.Command, _ []string) error { + fromFile, _ := cmd.Flags().GetString("from-file") + + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogItemTableDef, "catalog item create") + if err != nil { + return err + } + + body, err := parseInputFileAs[catalogapi.CreateCatalogItemJSONRequestBody](fromFile) + if err != nil { + return err + } + + params := &catalogapi.CreateCatalogItemParams{} + if id, _ := cmd.Flags().GetString("id"); id != "" { + params.Id = &id + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.CreateCatalogItem(ctx, params, body) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) }, } + + cmd.Flags().String("from-file", "", "Path to catalog item YAML/JSON file (required)") + _ = cmd.MarkFlagRequired("from-file") + cmd.Flags().String("id", "", "Client-specified catalog item ID") + + return cmd } func newCatalogItemListCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "list", Short: "List catalog items", - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := config.FromCommand(cmd) + + listCmd := "catalog item list" + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + listCmd += fmt.Sprintf(" --page-size %d", pageSize) + } + + formatter, err := newFormatter(cmd, catalogItemTableDef, listCmd) + if err != nil { + return err + } + + params := &catalogapi.ListCatalogItemsParams{} + if serviceType, _ := cmd.Flags().GetString("service-type"); serviceType != "" { + params.ServiceType = &serviceType + } + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + params.MaxPageSize = &pageSize + } + if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { + params.PageToken = &pageToken + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.ListCatalogItems(ctx, params) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var listResp struct { + Results []map[string]any `json:"results"` + NextPageToken string `json:"next_page_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + resources := make([]any, len(listResp.Results)) + for i, r := range listResp.Results { + resources[i] = r + } + + return formatter.FormatList(resources, listResp.NextPageToken) }, } + + cmd.Flags().String("service-type", "", "Filter by service type") + cmd.Flags().Int32("page-size", 0, "Maximum results per page") + cmd.Flags().String("page-token", "", "Token for next page") + + return cmd } func newCatalogItemGetCommand() *cobra.Command { @@ -43,8 +179,37 @@ func newCatalogItemGetCommand() *cobra.Command { Use: "get CATALOG_ITEM_ID", Short: "Get a catalog item by ID", Args: ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogItemTableDef, "catalog item get") + if err != nil { + return err + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.GetCatalogItem(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) }, } } @@ -54,8 +219,32 @@ func newCatalogItemDeleteCommand() *cobra.Command { Use: "delete CATALOG_ITEM_ID", Short: "Delete a catalog item by ID", Args: ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, catalogItemTableDef, "catalog item delete") + if err != nil { + return err + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.DeleteCatalogItem(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + return handleErrorResponse(resp, formatter) + } + + return formatter.FormatMessage(fmt.Sprintf("Catalog item %q deleted successfully.", args[0])) }, } } diff --git a/internal/commands/catalog_item_test.go b/internal/commands/catalog_item_test.go new file mode 100644 index 0000000..c215deb --- /dev/null +++ b/internal/commands/catalog_item_test.go @@ -0,0 +1,339 @@ +package commands_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/cli/internal/commands" +) + +// sampleCatalogItemResponse returns a sample catalog item JSON response body. +func sampleCatalogItemResponse() map[string]any { + return map[string]any{ + "path": "catalog-items/my-catalog-item", + "uid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "display_name": "Small Container", + "create_time": "2026-03-09T10:00:00Z", + "spec": map[string]any{ + "service_type": "container", + }, + } +} + +// emptyCatalogItemListResponse returns a standard empty catalog item list response body. +func emptyCatalogItemListResponse() map[string]any { + return map[string]any{ + "results": []any{}, + "next_page_token": "", + } +} + +var _ = Describe("Catalog Item Commands", func() { + var ( + server *httptest.Server + outBuf *bytes.Buffer + errBuf *bytes.Buffer + ) + + BeforeEach(func() { + clearDCMEnvVars() + }) + + AfterEach(func() { + if server != nil { + server.Close() + server = nil + } + }) + + executeCommand := func(args ...string) error { + cmd := commands.NewRootCommand() + outBuf = new(bytes.Buffer) + errBuf = new(bytes.Buffer) + cmd.SetOut(outBuf) + cmd.SetErr(errBuf) + + fullArgs := []string{ + "--config", nonexistentConfigPath(), + } + if server != nil { + fullArgs = append(fullArgs, "--api-gateway-url", server.URL) + } + fullArgs = append(fullArgs, args...) + cmd.SetArgs(fullArgs) + + return cmd.Execute() + } + + Describe("create", func() { + // TC-U046: Create catalog item from file + It("TC-U046: should create a catalog item from a YAML file", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items")) + + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body["display_name"]).To(Equal("Small Container")) + + writeJSONResponse(w, http.StatusCreated, sampleCatalogItemResponse()) + })) + + yamlFile := writeTempFile("display_name: Small Container\nspec:\n service_type: container\n", ".yaml") + + err := executeCommand("catalog", "item", "create", "--from-file", yamlFile) + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("b2c3d4e5-f6a7-8901-bcde-f12345678901")) + Expect(out).To(ContainSubstring("Small Container")) + }) + + // TC-U047: Create catalog item with client-specified ID + It("TC-U047: should send ?id= query parameter when --id is provided", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items")) + Expect(r.URL.Query().Get("id")).To(Equal("my-catalog-item")) + + writeJSONResponse(w, http.StatusCreated, sampleCatalogItemResponse()) + })) + + yamlFile := writeTempFile("display_name: Small Container\n", ".yaml") + + err := executeCommand("catalog", "item", "create", "--from-file", yamlFile, "--id", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U048: Create catalog item without --from-file fails + It("TC-U048: should return a UsageError when --from-file is not provided", func() { + err := executeCommand("catalog", "item", "create") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U111: Create catalog item server error + It("TC-U111: should display error and exit code 1 on server error", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusInternalServerError, "INTERNAL", "Internal server error", "Something went wrong") + })) + + yamlFile := writeTempFile("display_name: Test\n", ".yaml") + + err := executeCommand("catalog", "item", "create", "--from-file", yamlFile) + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + Expect(errBuf.String()).To(ContainSubstring("INTERNAL")) + }) + }) + + Describe("list", func() { + // TC-U049: List catalog items + It("TC-U049: should list catalog items", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "results": []any{sampleCatalogItemResponse()}, + "next_page_token": "", + }) + })) + + err := executeCommand("catalog", "item", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("b2c3d4e5-f6a7-8901-bcde-f12345678901")) + Expect(out).To(ContainSubstring("Small Container")) + }) + + // TC-U050: List catalog items with service-type filter + It("TC-U050: should pass service_type query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("service_type")).To(Equal("container")) + + writeJSONResponse(w, http.StatusOK, emptyCatalogItemListResponse()) + })) + + err := executeCommand("catalog", "item", "list", "--service-type", "container") + Expect(err).NotTo(HaveOccurred()) + }) + + // List catalog items with pagination (page-size) + It("should pass max_page_size query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("max_page_size")).To(Equal("10")) + + writeJSONResponse(w, http.StatusOK, emptyCatalogItemListResponse()) + })) + + err := executeCommand("catalog", "item", "list", "--page-size", "10") + Expect(err).NotTo(HaveOccurred()) + }) + + // List catalog items with pagination (page-token) + It("should pass page_token query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("page_token")).To(Equal("abc123")) + + writeJSONResponse(w, http.StatusOK, emptyCatalogItemListResponse()) + })) + + err := executeCommand("catalog", "item", "list", "--page-token", "abc123") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U107: List catalog items returns empty list + It("TC-U107: should display empty result for empty list", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyCatalogItemListResponse()) + })) + + err := executeCommand("catalog", "item", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + // Table output should have headers but no data rows + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + Expect(out).NotTo(ContainSubstring("b2c3d4e5")) + }) + + // TC-U107 (JSON variant): Empty list in JSON format + It("TC-U107: should display empty results array in JSON format", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyCatalogItemListResponse()) + })) + + err := executeCommand("--output", "json", "catalog", "item", "list") + Expect(err).NotTo(HaveOccurred()) + + var result map[string]any + Expect(json.Unmarshal(outBuf.Bytes(), &result)).To(Succeed()) + Expect(result["results"]).To(BeAssignableToTypeOf([]any{})) + Expect(result["results"]).To(BeEmpty()) + }) + }) + + Describe("get", func() { + // TC-U051: Get catalog item + It("TC-U051: should get a catalog item by ID", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items/my-catalog-item")) + + writeJSONResponse(w, http.StatusOK, sampleCatalogItemResponse()) + })) + + err := executeCommand("catalog", "item", "get", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("b2c3d4e5-f6a7-8901-bcde-f12345678901")) + Expect(out).To(ContainSubstring("Small Container")) + }) + + // TC-U052: Get catalog item without CATALOG_ITEM_ID fails + It("TC-U052: should return a UsageError when CATALOG_ITEM_ID is missing", func() { + err := executeCommand("catalog", "item", "get") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U108: Get non-existent catalog item + It("TC-U108: should display error for non-existent catalog item", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Catalog item "nonexistent" not found.`, + "The requested catalog item resource does not exist.") + })) + + err := executeCommand("catalog", "item", "get", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + + errOut := errBuf.String() + Expect(errOut).To(ContainSubstring("NOT_FOUND")) + Expect(errOut).To(ContainSubstring("not found")) + Expect(outBuf.String()).To(BeEmpty()) + }) + + // TC-U057: Catalog item table output columns + It("TC-U057: should display correct table columns", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleCatalogItemResponse()) + })) + + err := executeCommand("catalog", "item", "get", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + Expect(out).To(ContainSubstring("SERVICE TYPE")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("b2c3d4e5-f6a7-8901-bcde-f12345678901")) + Expect(out).To(ContainSubstring("Small Container")) + Expect(out).To(ContainSubstring("container")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) + + Describe("delete", func() { + // TC-U055: Delete catalog item + It("TC-U055: should delete a catalog item and display success message", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodDelete)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/catalog-items/my-catalog-item")) + + w.WriteHeader(http.StatusNoContent) + })) + + err := executeCommand("catalog", "item", "delete", "my-catalog-item") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring(`Catalog item "my-catalog-item" deleted successfully.`)) + }) + + // TC-U056: Delete catalog item without CATALOG_ITEM_ID fails + It("TC-U056: should return a UsageError when CATALOG_ITEM_ID is missing", func() { + err := executeCommand("catalog", "item", "delete") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U110: Delete non-existent catalog item + It("TC-U110: should display error for non-existent catalog item", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Catalog item "nonexistent" not found.`, + "The requested catalog item resource does not exist.") + })) + + err := executeCommand("catalog", "item", "delete", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + Expect(errBuf.String()).To(ContainSubstring("NOT_FOUND")) + }) + }) +}) diff --git a/internal/commands/catalog_service_type.go b/internal/commands/catalog_service_type.go index 81bb043..a5c2faf 100644 --- a/internal/commands/catalog_service_type.go +++ b/internal/commands/catalog_service_type.go @@ -1,9 +1,33 @@ package commands import ( + "encoding/json" + "fmt" + "net/http" + + catalogapi "github.com/dcm-project/catalog-manager/api/v1alpha1" + + "github.com/dcm-project/cli/internal/config" + "github.com/dcm-project/cli/internal/output" "github.com/spf13/cobra" ) +var serviceTypeTableDef = &output.TableDef{ + Headers: []string{"UID", "SERVICE TYPE", "API VERSION", "CREATED"}, + RowFunc: func(resource any) []string { + m, ok := resource.(map[string]any) + if !ok { + return []string{"", "", "", ""} + } + return []string{ + stringifyValue(m, "uid"), + stringifyValue(m, "service_type"), + stringifyValue(m, "api_version"), + stringifyValue(m, "create_time"), + } + }, +} + func newCatalogServiceTypeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "service-type", @@ -17,13 +41,69 @@ func newCatalogServiceTypeCommand() *cobra.Command { } func newServiceTypeListCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "list", Short: "List service types", - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := config.FromCommand(cmd) + + listCmd := "catalog service-type list" + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + listCmd += fmt.Sprintf(" --page-size %d", pageSize) + } + + formatter, err := newFormatter(cmd, serviceTypeTableDef, listCmd) + if err != nil { + return err + } + + params := &catalogapi.ListServiceTypesParams{} + if pageSize, _ := cmd.Flags().GetInt32("page-size"); pageSize > 0 { + params.MaxPageSize = &pageSize + } + if pageToken, _ := cmd.Flags().GetString("page-token"); pageToken != "" { + params.PageToken = &pageToken + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.ListServiceTypes(ctx, params) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var listResp struct { + Results []map[string]any `json:"results"` + NextPageToken string `json:"next_page_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + resources := make([]any, len(listResp.Results)) + for i, r := range listResp.Results { + resources[i] = r + } + + return formatter.FormatList(resources, listResp.NextPageToken) }, } + + cmd.Flags().Int32("page-size", 0, "Maximum results per page") + cmd.Flags().String("page-token", "", "Token for next page") + + return cmd } func newServiceTypeGetCommand() *cobra.Command { @@ -31,8 +111,37 @@ func newServiceTypeGetCommand() *cobra.Command { Use: "get SERVICE_TYPE_ID", Short: "Get a service type by ID", Args: ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return nil + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromCommand(cmd) + formatter, err := newFormatter(cmd, serviceTypeTableDef, "catalog service-type get") + if err != nil { + return err + } + + client, err := newCatalogClient(cfg) + if err != nil { + return fmt.Errorf("creating catalog client: %w", err) + } + + ctx, cancel := requestContext(cmd) + defer cancel() + + resp, err := client.GetServiceType(ctx, args[0]) + if err != nil { + return connectionError(err, cfg) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp, formatter) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + return formatter.FormatOne(result) }, } } diff --git a/internal/commands/catalog_service_type_test.go b/internal/commands/catalog_service_type_test.go new file mode 100644 index 0000000..d640491 --- /dev/null +++ b/internal/commands/catalog_service_type_test.go @@ -0,0 +1,217 @@ +package commands_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/cli/internal/commands" +) + +// sampleServiceTypeResponse returns a sample service type JSON response body. +func sampleServiceTypeResponse() map[string]any { + return map[string]any{ + "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "path": "service-types/container", + "service_type": "container", + "api_version": "v1alpha1", + "create_time": "2026-03-09T10:00:00Z", + "spec": map[string]any{}, + } +} + +// emptyServiceTypeListResponse returns a standard empty service type list response body. +func emptyServiceTypeListResponse() map[string]any { + return map[string]any{ + "results": []any{}, + "next_page_token": "", + } +} + +var _ = Describe("Catalog Service-Type Commands", func() { + var ( + server *httptest.Server + outBuf *bytes.Buffer + errBuf *bytes.Buffer + ) + + BeforeEach(func() { + clearDCMEnvVars() + }) + + AfterEach(func() { + if server != nil { + server.Close() + server = nil + } + }) + + executeCommand := func(args ...string) error { + cmd := commands.NewRootCommand() + outBuf = new(bytes.Buffer) + errBuf = new(bytes.Buffer) + cmd.SetOut(outBuf) + cmd.SetErr(errBuf) + + fullArgs := []string{ + "--config", nonexistentConfigPath(), + } + if server != nil { + fullArgs = append(fullArgs, "--api-gateway-url", server.URL) + } + fullArgs = append(fullArgs, args...) + cmd.SetArgs(fullArgs) + + return cmd.Execute() + } + + Describe("list", func() { + // TC-U042: List service types + It("TC-U042: should list service types", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/service-types")) + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "results": []any{sampleServiceTypeResponse()}, + "next_page_token": "", + }) + })) + + err := executeCommand("catalog", "service-type", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("container")) + Expect(out).To(ContainSubstring("v1alpha1")) + }) + + // TC-U043: List service types with pagination + It("TC-U043: should pass max_page_size query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("max_page_size")).To(Equal("5")) + + writeJSONResponse(w, http.StatusOK, emptyServiceTypeListResponse()) + })) + + err := executeCommand("catalog", "service-type", "list", "--page-size", "5") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U043 (page-token variant): List service types with page token + It("TC-U043: should pass page_token query parameter", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("page_token")).To(Equal("abc123")) + + writeJSONResponse(w, http.StatusOK, emptyServiceTypeListResponse()) + })) + + err := executeCommand("catalog", "service-type", "list", "--page-token", "abc123") + Expect(err).NotTo(HaveOccurred()) + }) + + // TC-U105: List service types returns empty list + It("TC-U105: should display empty result for empty list", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyServiceTypeListResponse()) + })) + + err := executeCommand("catalog", "service-type", "list") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + // Table output should have headers but no data rows + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("SERVICE TYPE")) + Expect(out).NotTo(ContainSubstring("container")) + }) + + // TC-U105 (JSON variant): Empty list in JSON format + It("TC-U105: should display empty results array in JSON format", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, emptyServiceTypeListResponse()) + })) + + err := executeCommand("--output", "json", "catalog", "service-type", "list") + Expect(err).NotTo(HaveOccurred()) + + var result map[string]any + Expect(json.Unmarshal(outBuf.Bytes(), &result)).To(Succeed()) + Expect(result["results"]).To(BeAssignableToTypeOf([]any{})) + Expect(result["results"]).To(BeEmpty()) + }) + }) + + Describe("get", func() { + // TC-U044: Get service type + It("TC-U044: should get a service type by ID", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1alpha1/service-types/my-service-type")) + + writeJSONResponse(w, http.StatusOK, sampleServiceTypeResponse()) + })) + + err := executeCommand("catalog", "service-type", "get", "my-service-type") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("container")) + Expect(out).To(ContainSubstring("v1alpha1")) + }) + + // TC-U045: Get service type without SERVICE_TYPE_ID fails + It("TC-U045: should return a UsageError when SERVICE_TYPE_ID is missing", func() { + err := executeCommand("catalog", "service-type", "get") + Expect(err).To(HaveOccurred()) + + var usageErr *commands.UsageError + Expect(errors.As(err, &usageErr)).To(BeTrue()) + }) + + // TC-U106: Get non-existent service type + It("TC-U106: should display error for non-existent service type", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeRFC7807(w, http.StatusNotFound, "NOT_FOUND", + `Service type "nonexistent" not found.`, + "The requested service type resource does not exist.") + })) + + err := executeCommand("catalog", "service-type", "get", "nonexistent") + Expect(err).To(HaveOccurred()) + + var fmtErr *commands.FormattedError + Expect(errors.As(err, &fmtErr)).To(BeTrue()) + + errOut := errBuf.String() + Expect(errOut).To(ContainSubstring("NOT_FOUND")) + Expect(errOut).To(ContainSubstring("not found")) + Expect(outBuf.String()).To(BeEmpty()) + }) + + // Table output columns verification (part of TC-U044) + It("should display correct table columns", func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, sampleServiceTypeResponse()) + })) + + err := executeCommand("catalog", "service-type", "get", "my-service-type") + Expect(err).NotTo(HaveOccurred()) + + out := outBuf.String() + Expect(out).To(ContainSubstring("UID")) + Expect(out).To(ContainSubstring("SERVICE TYPE")) + Expect(out).To(ContainSubstring("API VERSION")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + Expect(out).To(ContainSubstring("container")) + Expect(out).To(ContainSubstring("v1alpha1")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + }) + }) +}) diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index a0448cd..a111950 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -13,12 +13,22 @@ import ( "strings" "time" + catalogclient "github.com/dcm-project/catalog-manager/pkg/client" + "github.com/dcm-project/cli/internal/config" "github.com/dcm-project/cli/internal/output" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" ) +func newCatalogClient(cfg *config.Config) (*catalogclient.Client, error) { + httpClient, err := buildHTTPClient(cfg) + if err != nil { + return nil, err + } + return catalogclient.NewClient(apiBaseURL(cfg), catalogclient.WithHTTPClient(httpClient)) +} + // FormattedError indicates an error that has already been formatted and // written to stderr. Execute() should not print it again. type FormattedError struct{}