Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
108 changes: 108 additions & 0 deletions cmd/delete.go
Original file line number Diff line number Diff line change
@@ -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 <serviceName:version>",
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 <serviceName:version>")
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
}
36 changes: 36 additions & 0 deletions documentation/cmd/delete.md
Original file line number Diff line number Diff line change
@@ -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 <serviceName:version> [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 <microcks-url> \
--keycloakClientId <client-id> \
--keycloakClientSecret <client-secret>
```

### 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 |
96 changes: 96 additions & 0 deletions pkg/connectors/microcks_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down
51 changes: 51 additions & 0 deletions pkg/connectors/microcks_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading