diff --git a/cmd/cmd.go b/cmd/cmd.go index 16df6b9..49ebabb 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -39,6 +39,7 @@ func NewCommad() *cobra.Command { } command.AddCommand(NewImportCommand(&clientOpts)) + command.AddCommand(NewDeleteCommand(&clientOpts)) command.AddCommand(NewImportDirCommand(&clientOpts)) command.AddCommand(NewVersionCommand()) command.AddCommand(NewTestCommand(&clientOpts)) diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..b7aa7a0 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/spf13/cobra" +) + +func NewDeleteCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + + var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an API from Microcks server", + Long: "Delete an API (service + version) from Microcks server", + Args: cobra.ExactArgs(1), + + Run: func(cmd *cobra.Command, args []string) { + + input := args[0] + + // Validate input format + if !strings.Contains(input, ":") { + fmt.Println("delete requires ") + os.Exit(1) + } + + parts := strings.SplitN(input, ":", 2) + service := parts[0] + version := parts[1] + + if service == "" || version == "" { + fmt.Println("delete requires both serviceName and version (neither can be empty)") + os.Exit(1) + } + + // Load config (same as import) + config.InsecureTLS = globalClientOpts.InsecureTLS + config.CaCertPaths = globalClientOpts.CaCertPaths + config.Verbose = globalClientOpts.Verbose + + localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) + if err != nil { + fmt.Println(err) + return + } + + var mc connectors.MicrocksClient + + // Same auth logic as import (DO NOT DUPLICATE BADLY → reuse later) + if globalClientOpts.ServerAddr != "" && + globalClientOpts.ClientId != "" && + globalClientOpts.ClientSecret != "" { + + mc = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + + keycloakURL, err := mc.GetKeycloakURL() + if err != nil { + fmt.Printf("Error retrieving config: %s", err) + os.Exit(1) + } + + token := "unauthenticated-token" + + if keycloakURL != "null" { + kc := connectors.NewKeycloakClient( + keycloakURL, + globalClientOpts.ClientId, + globalClientOpts.ClientSecret, + ) + + token, err = kc.ConnectAndGetToken() + if err != nil { + fmt.Printf("Auth error: %s", err) + os.Exit(1) + } + } + + mc.SetOAuthToken(token) + + } else { + if localConfig == nil { + fmt.Println("Please login to perform operation...") + return + } + + mc, err = connectors.NewClient(*globalClientOpts) + if err != nil { + fmt.Printf("error %v", err) + return + } + } + + err = mc.DeleteService(service, version) + if err != nil { + fmt.Printf("Delete failed: %s\n", err) + os.Exit(1) + } + + fmt.Printf("Deleted service '%s:%s'\n", service, version) + }, + } + + return deleteCmd +} diff --git a/documentation/cmd/delete.md b/documentation/cmd/delete.md new file mode 100644 index 0000000..19eae38 --- /dev/null +++ b/documentation/cmd/delete.md @@ -0,0 +1,36 @@ +## `microcks delete` – Delete an API from Microcks +Deletes a specific API (service + version) from the Microcks server. + +### Usage +```bash +microcks delete [flags] +``` + +### Example +```bash +# Delete the local 'Simple' API version '1.1' +microcks delete "Simple:1.1" + +# Delete without previously logining to microcks +microcks delete "Simple:1.1" \ + --microcksURL \ + --keycloakClientId \ + --keycloakClientSecret +``` + +### Options +| Flag | Description | +| ---------------------- | ----------------------------------------------------------------------------------- | +| `-h, --help` | help for delete | + +### Options Inherited from Parent Commands +| Flag | Description | +| ------------------------ | ------------------------------------------- | +| `--config` | Path to Microcks config file | +| `--microcks-context` | Name of the Microcks context to use | +| `--verbose` | Produce dumps of HTTP exchanges | +| `--insecure-tls` | Allow insecure HTTPS connections | +| `--caCerts` | Comma-separated paths of CA cert files | +| `--keycloakClientId` | Keycloak Realm Service Account ClientId | +| `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | +| `--microcksURL` | Microcks API URL | diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index f76884b..9207665 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -52,6 +52,7 @@ type MicrocksClient interface { GetTestResult(testResultID string) (*TestResultSummary, error) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) + DeleteService(service string, version string) error } // TestResultSummary represents a simple view on Microcks TestResult @@ -170,6 +171,101 @@ func NewClient(opts ClientOptions) (MicrocksClient, error) { return &c, nil } +type serviceSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` +} + +func (c *microcksClient) DeleteService(service string, version string) error { + // First, search for the service ID + searchRel := &url.URL{Path: "services/search"} + searchU := c.APIURL.ResolveReference(searchRel) + + q := searchU.Query() + q.Set("name", service) + q.Set("version", version) + // We also set queryMap JSON string just in case Microcks uses a custom deserializer, + // but standard Spring Boot uses direct query params. + queryMap := map[string]string{ + "name": service, + "version": version, + } + queryMapBytes, _ := json.Marshal(queryMap) + q.Set("queryMap", string(queryMapBytes)) + searchU.RawQuery = q.Encode() + + searchReq, err := http.NewRequest("GET", searchU.String(), nil) + if err != nil { + return err + } + searchReq.Header.Set("Authorization", "Bearer "+c.AuthToken) + searchReq.Header.Set("Accept", "application/json") + + config.DumpRequestIfRequired("Microcks search service", searchReq, true) + + searchResp, err := c.httpClient.Do(searchReq) + if err != nil { + return err + } + defer searchResp.Body.Close() + + config.DumpResponseIfRequired("Microcks search service", searchResp, true) + + if searchResp.StatusCode != 200 { + body, _ := io.ReadAll(searchResp.Body) + return fmt.Errorf("failed to search service: %s", string(body)) + } + + body, _ := io.ReadAll(searchResp.Body) + var services []serviceSummary + if err := json.Unmarshal(body, &services); err != nil { + return fmt.Errorf("failed to parse search response: %v", err) + } + + var serviceID string + for _, s := range services { + if s.Name == service && s.Version == version { + serviceID = s.ID + break + } + } + + if serviceID == "" { + return fmt.Errorf("service '%s:%s' not found", service, version) + } + + // Now delete using the ID + deleteRel := &url.URL{Path: "services/" + serviceID} + deleteU := c.APIURL.ResolveReference(deleteRel) + + req, err := http.NewRequest("DELETE", deleteU.String(), nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + req.Header.Set("Accept", "application/json") + + config.DumpRequestIfRequired("Microcks delete service", req, true) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + config.DumpResponseIfRequired("Microcks delete service", resp, true) + + body, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != 200 && resp.StatusCode != 204 { + return fmt.Errorf("delete failed: %s", string(body)) + } + + return nil +} + // NewMicrocksClient builds a new headless MicrocksClient without any authtoken and all for general purposes func NewMicrocksClient(apiURL string) MicrocksClient { mc := microcksClient{} diff --git a/pkg/connectors/microcks_client_test.go b/pkg/connectors/microcks_client_test.go index 9e9b6c8..a765224 100644 --- a/pkg/connectors/microcks_client_test.go +++ b/pkg/connectors/microcks_client_test.go @@ -41,3 +41,54 @@ func TestDownloadArtifactReturnsResponseBody(t *testing.T) { t.Fatalf("expected response body %q, got %q", expectedBody, msg) } } + +func TestDeleteServiceReturnsNoErrorOnSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/services/search" { + if got := r.URL.Query().Get("name"); got != "Simple" { + t.Fatalf("unexpected service name: %s", got) + } + if got := r.URL.Query().Get("version"); got != "1.1" { + t.Fatalf("unexpected service version: %s", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"id": "test-id-123", "name": "Simple", "version": "1.1"}]`)) + return + } + if r.Method == http.MethodDelete && r.URL.Path == "/api/services/test-id-123" { + w.WriteHeader(http.StatusNoContent) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + client := NewMicrocksClient(server.URL) + + err := client.DeleteService("Simple", "1.1") + if err != nil { + t.Fatalf("DeleteService returned error: %v", err) + } +} + +func TestDeleteServiceReturnsErrorOnNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/services/search" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + client := NewMicrocksClient(server.URL) + + err := client.DeleteService("Simple", "1.1") + if err == nil { + t.Fatalf("expected DeleteService to return error on not found, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected error to contain 'not found', got: %v", err) + } +}