From 69d220c57f7d67a965d1b7fbe44e5b66b0bfdf10 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 12 Aug 2026 16:57:42 +0100 Subject: [PATCH] feat(cli): add integration JSON output Signed-off-by: caesarsage --- README.md | 2 + cmd/capabilities.go | 104 ++++++++++++ cmd/capabilities_test.go | 88 +++++++++++ cmd/cmd.go | 2 + cmd/command_client.go | 81 ++++++++++ cmd/context.go | 145 +++++++++++++---- cmd/context_test.go | 44 ++++++ cmd/import.go | 48 ++++-- cmd/importDir.go | 21 +-- cmd/importURL.go | 58 +------ cmd/service.go | 152 ++++++++++++++++++ cmd/service_test.go | 112 +++++++++++++ cmd/start.go | 71 +++++++-- cmd/test.go | 72 +-------- cmd/testDryRun.go | 200 +++++++++++++++++++---- cmd/testDryRunEvents.go | 60 +++++++ cmd/testDryRunEvents_test.go | 48 ++++++ cmd/testExecutor.go | 16 +- cmd/testQuery.go | 127 +++++++++++++++ cmd/test_query_test.go | 76 +++++++++ documentation/cmd/capabilities.md | 97 ++++++++++++ documentation/cmd/context.md | 11 +- documentation/cmd/import.md | 1 + documentation/cmd/service.md | 54 +++++++ documentation/cmd/start.md | 12 +- documentation/cmd/test.md | 33 ++++ pkg/connectors/container_client.go | 26 ++- pkg/connectors/microcks_client.go | 209 +++++++++++++++++-------- pkg/connectors/microcks_client_test.go | 192 +++++++++++++++++++++++ pkg/output/json.go | 38 +++++ 30 files changed, 1901 insertions(+), 299 deletions(-) create mode 100644 cmd/capabilities.go create mode 100644 cmd/capabilities_test.go create mode 100644 cmd/command_client.go create mode 100644 cmd/service.go create mode 100644 cmd/service_test.go create mode 100644 cmd/testDryRunEvents.go create mode 100644 cmd/testDryRunEvents_test.go create mode 100644 cmd/testQuery.go create mode 100644 cmd/test_query_test.go create mode 100644 documentation/cmd/capabilities.md create mode 100644 documentation/cmd/service.md create mode 100644 pkg/output/json.go diff --git a/README.md b/README.md index 8530e3ae..64d8e014 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,13 @@ microcks [command] [flags] | `login` | Log in to a Microcks instance using Keycloak credentials | [`login`](documentation/cmd/login.md) | | `logout` | Log out and remove authentication from a given context | [`logout`](documentation/cmd/logout.md) | | `context` | Manage CLI contexts (list, use, delete) | [`context`](documentation/cmd/context.md) | +| `capabilities` | List machine-readable CLI capabilities | [`capabilities`](documentation/cmd/capabilities.md) | | `start` | Start a local Microcks instance via Docker/Podman | [`start`](documentation/cmd/start.md) | | `stop` | Stop a local Microcks instance | [`stop`](documentation/cmd/stop.md) | | `import` | Import API spec files from local filesystem | [`import`](documentation/cmd/import.md) | | `import-dir` | Scan a directory and import API spec files. | [`import-dir`](documentation/cmd/importDir.md) | | `import-url` | Import API spec files directly from a remote URL | [`import-url`](documentation/cmd/importUrl.md) | +| `service` | List and inspect Microcks services | [`service`](documentation/cmd/service.md) | | `test` | Run tests against a deployed API using selected runner | [`test`](documentation/cmd/test.md) | | `version` | Print Microcks CLI version | [`version`](documentation/cmd/version.md) | diff --git a/cmd/capabilities.go b/cmd/capabilities.go new file mode 100644 index 00000000..81339046 --- /dev/null +++ b/cmd/capabilities.go @@ -0,0 +1,104 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "fmt" + "os" + + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" + "github.com/microcks/microcks-cli/version" + "github.com/spf13/cobra" +) + +const capabilitiesSchemaVersion = "v1" + +var supportedCapabilities = []string{ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json", +} + +type capabilitiesDocument struct { + SchemaVersion string `json:"schemaVersion"` + CLIVersion string `json:"cliVersion"` + Capabilities []string `json:"capabilities"` +} + +func NewCapabilitiesCommand() *cobra.Command { + var outputFormat string + + command := &cobra.Command{ + Use: "capabilities", + Short: "List machine-readable Microcks CLI capabilities", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + document := capabilitiesDocument{ + SchemaVersion: capabilitiesSchemaVersion, + CLIVersion: version.Version, + Capabilities: supportedCapabilities, + } + if outputFormat == "json" { + return errors.Wrap( + errors.KindGeneric, + output.WriteJSON(os.Stdout, document), + ) + } + + for _, capability := range document.Capabilities { + if _, err := fmt.Fprintln(os.Stdout, capability); err != nil { + return errors.Wrap( + errors.KindEnvironment, + fmt.Errorf("writing capabilities output: %w", err), + ) + } + } + return nil + }, + } + command.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return command +} diff --git a/cmd/capabilities_test.go b/cmd/capabilities_test.go new file mode 100644 index 00000000..6fa3679f --- /dev/null +++ b/cmd/capabilities_test.go @@ -0,0 +1,88 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "encoding/json" + "slices" + "testing" +) + +func TestCapabilitiesCommandOutputsJSON(t *testing.T) { + out, err := executeCLIForTest(t, "capabilities", "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + + var document capabilitiesDocument + if err := json.Unmarshal([]byte(out), &document); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if document.SchemaVersion != capabilitiesSchemaVersion { + t.Fatalf("unexpected schema version: %s", document.SchemaVersion) + } + if document.CLIVersion == "" { + t.Fatal("expected a CLI version") + } + expectedCapabilities := []string{ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json", + } + if !slices.Equal(document.Capabilities, expectedCapabilities) { + t.Fatalf("unexpected capabilities:\n got: %#v\nwant: %#v", document.Capabilities, expectedCapabilities) + } + + seen := make(map[string]struct{}, len(document.Capabilities)) + for _, capability := range document.Capabilities { + if _, duplicate := seen[capability]; duplicate { + t.Errorf("duplicate capability %q", capability) + } + seen[capability] = struct{}{} + } +} + +func TestCapabilitiesCommandRejectsUnsupportedOutput(t *testing.T) { + _, err := executeCLIForTest(t, "capabilities", "--output", "yaml") + if err == nil { + t.Fatal("expected unsupported output format to fail") + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index ec20c97c..5f2050ba 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -43,7 +43,9 @@ func NewCommand() (*cobra.Command, error) { command.AddCommand(NewImportCommand(&clientOpts)) command.AddCommand(NewImportDirCommand(&clientOpts)) command.AddCommand(NewVersionCommand()) + command.AddCommand(NewCapabilitiesCommand()) command.AddCommand(NewTestCommand(&clientOpts)) + command.AddCommand(NewServiceCommand(&clientOpts)) command.AddCommand(NewImportURLCommand(&clientOpts)) command.AddCommand(NewStartCommand(&clientOpts)) command.AddCommand(NewStopCommand(&clientOpts)) diff --git a/cmd/command_client.go b/cmd/command_client.go new file mode 100644 index 00000000..6379e989 --- /dev/null +++ b/cmd/command_client.go @@ -0,0 +1,81 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" +) + +func newCommandClient(globalClientOpts *connectors.ClientOptions) (connectors.MicrocksClient, string, error) { + config.InsecureTLS = globalClientOpts.InsecureTLS + config.CaCertPaths = globalClientOpts.CaCertPaths + config.Verbose = globalClientOpts.Verbose + + if globalClientOpts.ServerAddr != "" { + mc, err := connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + if err != nil { + return nil, "", err + } + + if globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { + keycloakURL, err := mc.GetKeycloakURL() + if err != nil { + return nil, "", err + } + + oauthToken := "unauthenticated-token" + if keycloakURL != "null" { + kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + if err != nil { + return nil, "", err + } + + oauthToken, err = kc.ConnectAndGetToken() + if err != nil { + return nil, "", err + } + } + mc.SetOAuthToken(oauthToken) + } + return mc, globalClientOpts.ServerAddr, nil + } + + localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) + if err != nil { + return nil, "", err + } + if localConfig == nil { + return nil, "", errors.Wrapf(errors.KindUsage, "please login to perform this operation") + } + + clientOpts := *globalClientOpts + if clientOpts.Context == "" { + clientOpts.Context = localConfig.CurrentContext + } + + mc, err := connectors.NewClient(clientOpts) + if err != nil { + return nil, "", err + } + + ctx, err := localConfig.ResolveContext(clientOpts.Context) + if err != nil { + return nil, "", errors.Wrap(errors.KindNotFound, err) + } + return mc, ctx.Server.Server, nil +} diff --git a/cmd/context.go b/cmd/context.go index 19796ca1..6a9b0e95 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -19,17 +19,20 @@ package cmd import ( "fmt" "os" + "slices" "strings" "text/tabwriter" "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/spf13/cobra" ) func NewContextCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var delete bool + var outputFormat string ctxCmd := &cobra.Command{ Use: "context [CONTEXT]", Aliases: []string{"ctx"}, @@ -43,20 +46,43 @@ microcks context http://localhost:8080 # Delete Microcks context microcks context http://localhost:8080 --delete`, RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } configPath := globalClientOpts.ConfigPath localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if delete { if len(args) == 0 { return errors.Wrapf(errors.KindUsage, "context --delete requires a CONTEXT argument") } - return deleteContext(args[0], configPath) + if err := deleteContext(args[0], configPath); err != nil { + return err + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contextMutationResult{ + Name: args[0], + Action: "deleted", + })) + } + _, err = fmt.Printf("Context '%s' deleted\n", args[0]) + return errors.Wrap(errors.KindEnvironment, err) } if len(args) == 0 { - return printMicrocksContexts(configPath) + contexts, err := listMicrocksContexts(configPath) + if err != nil { + return err + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contexts)) + } + if len(contexts) == 0 { + return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + } + return printMicrocksContexts(contexts) } ctxName := args[0] @@ -64,22 +90,21 @@ microcks context http://localhost:8080 --delete`, return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) } if localCfg.CurrentContext == ctxName { - fmt.Printf("Already at context '%s'\n", localCfg.CurrentContext) - return nil + return writeContextSelection(outputFormat, localCfg, ctxName, "unchanged") } if _, err = localCfg.ResolveContext(ctxName); err != nil { return errors.Wrap(errors.KindNotFound, err) } localCfg.CurrentContext = ctxName if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } - fmt.Printf("Switched to context '%s'\n", localCfg.CurrentContext) - return nil + return writeContextSelection(outputFormat, localCfg, ctxName, "selected") }, } ctxCmd.Flags().BoolVarP(&delete, "delete", "d", false, "Delete a context") + ctxCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return ctxCmd } @@ -87,61 +112,103 @@ microcks context http://localhost:8080 --delete`, func deleteContext(context, configPath string) error { localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if localCfg == nil { return errors.Wrapf(errors.KindUsage, "nothing to delete") } + contextIndex := slices.IndexFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.Name == context + }) + if contextIndex < 0 { + return errors.Wrapf(errors.KindNotFound, "context %q does not exist", context) + } + resolved, err := localCfg.ResolveContext(context) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } serverName, ok := localCfg.RemoveContext(context) if !ok { - return errors.Wrapf(errors.KindNotFound, "context %q does not exist", context) + return errors.Wrapf(errors.KindAPI, "context %q disappeared while deleting it", context) + } + userStillReferenced := slices.ContainsFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.User == resolved.User.Name + }) + if !userStillReferenced && !localCfg.RemoveUser(resolved.User.Name) { + return errors.Wrapf(errors.KindAPI, "user %q referenced by context %q does not exist", resolved.User.Name, context) + } + serverStillReferenced := slices.ContainsFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.Server == serverName + }) + if !serverStillReferenced && !localCfg.RemoveServer(serverName) { + return errors.Wrapf(errors.KindAPI, "server %q referenced by context %q does not exist", serverName, context) } - localCfg.RemoveUser(context) - localCfg.RemoveServer(serverName) if localCfg.IsEmpty() { if err := localCfg.DeleteLocalConfig(configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } else { if localCfg.CurrentContext == context { localCfg.CurrentContext = "" } if err := config.ValidateLocalConfig(*localCfg); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } - fmt.Printf("Context '%s' deleted\n", context) return nil } -func printMicrocksContexts(configPath string) error { +type contextSummary struct { + Name string `json:"name"` + Server string `json:"server"` + Current bool `json:"current"` +} + +type contextMutationResult struct { + Name string `json:"name"` + Server string `json:"server,omitempty"` + Action string `json:"action"` +} + +func listMicrocksContexts(configPath string) ([]contextSummary, error) { localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return nil, errors.Wrap(errors.KindEnvironment, err) } if localCfg == nil { - return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + return []contextSummary{}, nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - columnNames := []string{"CURRENT", "NAME", "SERVER"} - if _, err = fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")); err != nil { - return errors.Wrap(errors.KindEnvironment, fmt.Errorf("writing contexts output: %w", err)) - } - + contexts := make([]contextSummary, 0, len(localCfg.Contexts)) for _, contextRef := range localCfg.Contexts { - context, err := localCfg.ResolveContext(contextRef.Name) + resolved, err := localCfg.ResolveContext(contextRef.Name) if err != nil { - return errors.Wrap(errors.KindUsage, fmt.Errorf("context %q is invalid: %w", contextRef.Name, err)) + return nil, errors.Wrap(errors.KindEnvironment, fmt.Errorf("resolving context %q: %w", contextRef.Name, err)) } + contexts = append(contexts, contextSummary{ + Name: resolved.Name, + Server: resolved.Server.Server, + Current: localCfg.CurrentContext == resolved.Name, + }) + } + return contexts, nil +} + +func printMicrocksContexts(contexts []contextSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + columnNames := []string{"CURRENT", "NAME", "SERVER"} + if _, err := fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + for _, context := range contexts { prefix := " " - if localCfg.CurrentContext == context.Name { + if context.Current { prefix = "*" } - if _, err = fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server.Server); err != nil { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server); err != nil { return errors.Wrap(errors.KindEnvironment, fmt.Errorf("writing contexts output: %w", err)) } } @@ -150,3 +217,23 @@ func printMicrocksContexts(configPath string) error { } return nil } + +func writeContextSelection(outputFormat string, localCfg *config.LocalConfig, name, action string) error { + resolved, err := localCfg.ResolveContext(name) + if err != nil { + return errors.Wrap(errors.KindNotFound, err) + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contextMutationResult{ + Name: resolved.Name, + Server: resolved.Server.Server, + Action: action, + })) + } + if action == "unchanged" { + _, err = fmt.Printf("Already at context '%s'\n", name) + } else { + _, err = fmt.Printf("Switched to context '%s'\n", name) + } + return errors.Wrap(errors.KindEnvironment, err) +} diff --git a/cmd/context_test.go b/cmd/context_test.go index 2802f09c..2b8a2b89 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -17,6 +17,7 @@ package cmd import ( + "encoding/json" "os" "testing" @@ -25,6 +26,49 @@ import ( "github.com/stretchr/testify/require" ) +func TestContextListOutputsJSON(t *testing.T) { + configPath := t.TempDir() + "/config.yaml" + require.NoError(t, os.WriteFile(configPath, []byte(testConfig), 0o600)) + + out, err := executeCLIForTest(t, "context", "--output", "json", "--config", configPath) + require.NoError(t, err) + + var contexts []contextSummary + require.NoError(t, json.Unmarshal([]byte(out), &contexts)) + require.Len(t, contexts, 2) + assert.Equal(t, "http://localhost:8083", contexts[1].Name) + assert.True(t, contexts[1].Current) +} + +func TestContextListOutputsEmptyJSONArrayWithoutConfig(t *testing.T) { + configPath := t.TempDir() + "/missing-config.yaml" + + out, err := executeCLIForTest(t, "context", "--output", "json", "--config", configPath) + require.NoError(t, err) + assert.JSONEq(t, "[]", out) +} + +func TestContextUseOutputsJSON(t *testing.T) { + configPath := t.TempDir() + "/config.yaml" + require.NoError(t, os.WriteFile(configPath, []byte(testConfig), 0o600)) + + out, err := executeCLIForTest( + t, + "context", + "http://localhost:8080", + "--output", + "json", + "--config", + configPath, + ) + require.NoError(t, err) + + var result contextMutationResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "selected", result.Action) + assert.Equal(t, "http://localhost:8080", result.Server) +} + const testConfig = `current-context: http://localhost:8083 contexts: - name: http://localhost:8080 diff --git a/cmd/import.go b/cmd/import.go index 5ff6bf42..99bbcc6c 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -17,18 +17,21 @@ package cmd import ( "fmt" + "os" "strconv" "strings" "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/microcks/microcks-cli/pkg/watcher" "github.com/spf13/cobra" ) func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var watch bool + var outputFormat string var importCmd = &cobra.Command{ Use: "import", @@ -36,6 +39,12 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command Long: `import API artifacts on Microcks server`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if watch && outputFormat == "json" { + return errors.Wrapf(errors.KindUsage, "--output json is not supported with --watch") + } // Parse subcommand args first. if len(args) == 0 { return usageErrorf(cmd, "import requires a argument") @@ -51,7 +60,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Read local config file in case we need some context info. localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } // Prepare Microcks client. @@ -115,6 +124,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Handle multiple specification files separated by comma. sepSpecificationFiles := strings.Split(specificationFiles, ",") + results := make([]artifactImportResult, 0, len(sepSpecificationFiles)) for _, f := range sepSpecificationFiles { mainArtifact := true var err error @@ -125,7 +135,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command f = pathAndMainArtifact[0] mainArtifact, err = strconv.ParseBool(pathAndMainArtifact[1]) if err != nil { - fmt.Printf("Cannot parse '%s' as Bool, default to true\n", pathAndMainArtifact[1]) + return errors.Wrapf(errors.KindUsage, "cannot parse %q as artifact primary flag", pathAndMainArtifact[1]) } } @@ -138,18 +148,25 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command if !mainArtifact { action = "completed" } - fmt.Printf("Microcks has %s '%s'\n", action, msg) + results = append(results, artifactImportResult{ + File: f, ID: msg, Primary: mainArtifact, Action: action, + }) + if outputFormat == "text" { + if _, err := fmt.Printf("Microcks has %s '%s'\n", action, msg); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } // If watch flag is provided, update watch config. if watch { watchFile, err := config.DefaultLocalWatchPath() if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } watchCfg, err := config.ReadLocalWatchConfig(watchFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if watchCfg == nil { watchCfg = &config.WatchConfig{} @@ -169,7 +186,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Write watch file. if err := config.WriteLocalWatchConfig(*watchCfg, watchFile); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } } @@ -178,21 +195,34 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command if watch { watchFile, err := config.DefaultLocalWatchPath() if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } wm, err := watcher.NewWatchManger(watchFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } - fmt.Println("Watch mode enabled - microcks-watcher started...") + if _, err := fmt.Println("Watch mode enabled - microcks-watcher started..."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } wm.Run() } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, results)) + } return nil }, } importCmd.Flags().BoolVar(&watch, "watch", false, "Keep watch on file changes and re-import it on change") + importCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return importCmd } + +type artifactImportResult struct { + File string `json:"file"` + ID string `json:"id"` + Primary bool `json:"primary"` + Action string `json:"action"` +} diff --git a/cmd/importDir.go b/cmd/importDir.go index a38c0a62..0fbff13a 100644 --- a/cmd/importDir.go +++ b/cmd/importDir.go @@ -21,7 +21,6 @@ import ( "path/filepath" "strings" - "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" @@ -123,25 +122,7 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm dirPath := args[0] - config.InsecureTLS = globalClientOpts.InsecureTLS - config.CaCertPaths = globalClientOpts.CaCertPaths - config.Verbose = globalClientOpts.Verbose - - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - // Create client - mc, err := connectors.NewClient(*globalClientOpts) + mc, _, err := newCommandClient(globalClientOpts) if err != nil { return err } diff --git a/cmd/importURL.go b/cmd/importURL.go index 0d36eb63..343145a2 100644 --- a/cmd/importURL.go +++ b/cmd/importURL.go @@ -21,7 +21,6 @@ import ( "strconv" "strings" - "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" @@ -40,60 +39,9 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm specificationFiles := args[0] - config.InsecureTLS = globalClientOpts.InsecureTLS - config.CaCertPaths = globalClientOpts.CaCertPaths - config.Verbose = globalClientOpts.Verbose - - var mc connectors.MicrocksClient - - if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { - // create client with server address - var err error - mc, err = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) - if err != nil { - return err - } - - keycloakURL, err := mc.GetKeycloakURL() - if err != nil { - return err - } - - oauthToken := "unauthenticated-token" - if keycloakURL != "null" { - // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - if err != nil { - return err - } - - oauthToken, err = kc.ConnectAndGetToken() - if err != nil { - return err - } - } - - //Set Auth token - mc.SetOAuthToken(oauthToken) - } else { - - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - mc, err = connectors.NewClient(*globalClientOpts) - if err != nil { - return err - } + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err } sepSpecificationFiles := strings.Split(specificationFiles, ",") for _, f := range sepSpecificationFiles { diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 00000000..8bfd9ecc --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,152 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" + "github.com/spf13/cobra" +) + +func NewServiceCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + serviceCmd := &cobra.Command{ + Use: "service", + Short: "List and inspect Microcks services", + } + + serviceCmd.AddCommand(newServiceListCommand(globalClientOpts)) + serviceCmd.AddCommand(newServiceGetCommand(globalClientOpts)) + + return serviceCmd +} + +func newServiceListCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var ( + page int + size int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Microcks services", + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if page < 0 { + return errors.Wrapf(errors.KindUsage, "--page must be greater than or equal to 0") + } + if size <= 0 { + return errors.Wrapf(errors.KindUsage, "--size must be greater than 0") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + services, err := mc.ListServices(page, size) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, services) + } + return printServices(services) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page index to fetch") + cmd.Flags().IntVar(&size, "size", 50, "Number of services to fetch") + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func newServiceGetCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get Microcks service details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + service, err := mc.GetService(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, service) + } + return printServiceDetail(service) + }, + } + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func printServices(services []connectors.Service) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "ID\tNAME\tVERSION\tTYPE"); err != nil { + return err + } + for _, service := range services { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", service.ID, service.Name, service.Version, service.Type); err != nil { + return err + } + } + return w.Flush() +} + +func printServiceDetail(detail *connectors.ServiceDetail) error { + service := detail.Service + if _, err := fmt.Printf("%s:%s %s\n", service.Name, service.Version, service.Type); err != nil { + return err + } + if len(service.Operations) == 0 { + return nil + } + for _, operation := range service.Operations { + parts := []string{operation.Name} + if operation.Method != "" { + parts = append(parts, operation.Method) + } + if len(operation.ResourcePaths) > 0 { + parts = append(parts, strings.Join(operation.ResourcePaths, ",")) + } + if _, err := fmt.Printf("- %s\n", strings.Join(parts, " ")); err != nil { + return err + } + } + return nil +} diff --git a/cmd/service_test.go b/cmd/service_test.go new file mode 100644 index 00000000..081a5dff --- /dev/null +++ b/cmd/service_test.go @@ -0,0 +1,112 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "slices" + "strings" + "testing" +) + +func TestServiceListCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewEncoder(w).Encode([]map[string]string{{ + "id": "svc-1", + "name": "Catalog API", + "version": "1.0.0", + "type": "REST", + }}); err != nil { + t.Fatalf("failed to encode services response: %v", err) + } + })) + defer server.Close() + + out, err := executeCLIForTest(t, "service", "list", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "svc-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestServiceGetCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services/svc-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewEncoder(w).Encode(map[string]any{ + "service": map[string]string{ + "id": "svc-1", + "name": "Catalog API", + "version": "1.0.0", + "type": "REST", + }, + }); err != nil { + t.Fatalf("failed to encode service response: %v", err) + } + })) + defer server.Close() + + out, err := executeCLIForTest(t, "service", "get", "svc-1", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"name": "Catalog API"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func executeCLIForTest(t *testing.T, args ...string) (string, error) { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe returned error: %v", err) + } + os.Stdout = w + + command, err := NewCommand() + if err != nil { + t.Fatalf("NewCommand returned error: %v", err) + } + if !slices.Contains(args, "--config") { + args = append(args, "--config", t.TempDir()+"/config.yaml") + } + command.SetArgs(args) + + execErr := command.Execute() + if err := w.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + os.Stdout = oldStdout + + out, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("ReadAll returned error: %v", readErr) + } + return string(out), execErr +} diff --git a/cmd/start.go b/cmd/start.go index 6e216cf0..ee5bf711 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -19,11 +19,13 @@ package cmd import ( "fmt" "net/http" + "os" "time" "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/spf13/cobra" ) @@ -36,6 +38,7 @@ func NewStartCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command driver string readyTimeout time.Duration noWait bool + outputFormat string ) var startCmd = &cobra.Command{ Use: "start", @@ -52,11 +55,15 @@ microcks start --driver [driver you wnat either 'docker' or 'podman'] # Define name of your microcks container/instance microcks start --name [name of you container/instance]`, RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + progress := progressWriter(outputFormat) configFile := globalClientOpts.ConfigPath localConfig, err := config.ReadLocalConfig(configFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if localConfig == nil { @@ -81,12 +88,17 @@ microcks start --name [name of you container/instance]`, return errors.Wrap(errors.KindEnvironment, err) } exists, err := containerClient.ContainerExists(instance.ContainerID) - containerClient.CloseClient() + closeErr := containerClient.CloseClient() if err != nil { return errors.Wrap(errors.KindEnvironment, err) } + if closeErr != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", closeErr)) + } if !exists { - fmt.Printf("Container for instance %s no longer exists, recreating it\n", name) + if _, err := fmt.Fprintf(progress, "Container for instance %s no longer exists, recreating it\n", name); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } instance.Status = "" instance.ContainerID = "" } @@ -94,39 +106,53 @@ microcks start --name [name of you container/instance]`, switch instance.Status { case "Running": - fmt.Printf("Microcks instance with name %s is already running", name) - return nil + server := fmt.Sprintf("http://localhost:%s", instance.Port) + return writeStartResult(outputFormat, instanceStartResult{ + Name: name, Server: server, Context: server, Status: "running", + }) case "Exited": containerClient, err := connectors.NewContainerClient(instance.Driver) if err != nil { return errors.Wrap(errors.KindEnvironment, err) } - defer containerClient.CloseClient() - if err := containerClient.StartContainer(instance.ContainerID); err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to start container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } + if err := containerClient.CloseClient(); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", err)) + } instance.Status = "Running" default: containerClient, err := connectors.NewContainerClient(driver) if err != nil { return errors.Wrap(errors.KindEnvironment, err) } - defer containerClient.CloseClient() - containerId, err := containerClient.CreateContainer(connectors.ContainerOpts{ Image: imageName, Port: hostPort, Name: name, AutoRemove: autoRemove, + Output: progress, }) if err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to create container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create container: %w", err)) } if err := containerClient.StartContainer(containerId); err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to start container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } + if err := containerClient.CloseClient(); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", err)) + } instance.ContainerID = containerId instance.AutoRemove = autoRemove @@ -179,22 +205,25 @@ microcks start --name [name of you container/instance]`, // Save configs to config file if err := config.WriteLocalConfig(*localConfig, configFile); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } // The container being up doesn't mean the Microcks server inside // is serving traffic yet: wait until HTTP is actually answering // so chained commands (import, test) don't race the boot. if !noWait { - fmt.Printf("Waiting for Microcks to be ready at %s ...\n", server) + if _, err := fmt.Fprintf(progress, "Waiting for Microcks to be ready at %s ...\n", server); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if err := waitForReady(server, readyTimeout); err != nil { return errors.Wrapf(errors.KindEnvironment, "Microcks container is started but the server is not ready: %v. "+ "It may still be booting — retry shortly or raise --ready-timeout", err) } } - fmt.Printf("Microcks started successfully at %s\n", server) - return nil + return writeStartResult(outputFormat, instanceStartResult{ + Name: name, Server: server, Context: server, Status: "running", + }) }, } startCmd.Flags().StringVar(&name, "name", "microcks", "name for your Microcks instance") @@ -204,9 +233,25 @@ microcks start --name [name of you container/instance]`, startCmd.Flags().StringVar(&driver, "driver", "docker", "use --driver to change driver from docker to podman") startCmd.Flags().DurationVar(&readyTimeout, "ready-timeout", 60*time.Second, "how long to wait for the Microcks server to be ready before failing") startCmd.Flags().BoolVar(&noWait, "no-wait", false, "return as soon as the container is started, without waiting for the Microcks server to be ready") + startCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return startCmd } +type instanceStartResult struct { + Name string `json:"name"` + Server string `json:"server"` + Context string `json:"context"` + Status string `json:"status"` +} + +func writeStartResult(outputFormat string, result instanceStartResult) error { + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, result)) + } + _, err := fmt.Printf("Microcks started successfully at %s\n", result.Server) + return errors.Wrap(errors.KindEnvironment, err) +} + // waitForReady polls the Microcks API until it answers with 200 or the // timeout elapses. HTTP being up is the signal users care about — the // Spring Boot app inside the container takes a while after the container diff --git a/cmd/test.go b/cmd/test.go index 97d53e36..c2bb880e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -21,7 +21,6 @@ import ( "strings" "time" - "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/microcks/microcks-cli/pkg/output" @@ -82,11 +81,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json, yaml, github-actions") } - // Collect optional HTTPS transport flags. - config.InsecureTLS = globalClientOpts.InsecureTLS - config.CaCertPaths = globalClientOpts.CaCertPaths - config.Verbose = globalClientOpts.Verbose - // Compute time to wait in milliseconds. var waitForMilliseconds int64 if strings.HasSuffix(waitFor, "milli") { @@ -146,66 +140,9 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { }) } - var mc connectors.MicrocksClient - var serverAddr string - - if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { - - // create client with server address - serverAddr = globalClientOpts.ServerAddr - var err error - mc, err = connectors.NewMicrocksClient(serverAddr) - if err != nil { - return err - } - - keycloakURL, err := mc.GetKeycloakURL() - if err != nil { - return err - } - - oauthToken := "unauthenticated-token" - if keycloakURL != "null" { - // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - if err != nil { - return err - } - - oauthToken, err = kc.ConnectAndGetToken() - if err != nil { - return err - } - } - - // Then - launch the test on Microcks Server. - mc.SetOAuthToken(oauthToken) - - } else { - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - mc, err = connectors.NewClient(*globalClientOpts) - if err != nil { - return err - } - - ctx, err := localConfig.ResolveContext(globalClientOpts.Context) - if err != nil { - return errors.Wrap(errors.KindNotFound, err) - } - - serverAddr = ctx.Server.Server + mc, serverAddr, err := newCommandClient(globalClientOpts) + if err != nil { + return err } success, testResultID, err := runTestAndWait(mc, params) @@ -235,5 +172,8 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testCmd.Flags().StringVar(&driver, "driver", "", "Container runtime for --dry-run: 'docker' or 'podman' (default: auto-detect)") testCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text, json, yaml, or github-actions") + testCmd.AddCommand(newTestListCommand(globalClientOpts)) + testCmd.AddCommand(newTestGetCommand(globalClientOpts)) + return testCmd } diff --git a/cmd/testDryRun.go b/cmd/testDryRun.go index 92809141..1e3e7bd2 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -32,6 +32,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/testcontainers/testcontainers-go" microcks "microcks.io/testcontainers-go" ) @@ -141,10 +142,25 @@ func rewriteLocalEndpoint(testEndpoint string) (string, int, bool) { return u.String(), port, true } -func runDryRunTest(opts dryRunOptions) error { +func runDryRunTest(opts dryRunOptions) (resultErr error) { // Progress/diagnostics go to stderr for machine output formats so stdout // carries only the formatted result. progress := progressWriter(opts.params.outputFormat) + eventMode := opts.watch && opts.params.outputFormat == string(output.FormatJSON) + var events *dryRunEventWriter + if eventMode { + events = newDryRunEventWriter(os.Stdout) + defer func() { + if err := events.emit(dryRunWatchEvent{Type: "stopped"}); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "writing dry-run stopped event: %v (previous error: %v)", + err, + resultErr, + ) + } + }() + } if err := validateDryRunOptions(opts); err != nil { return err @@ -166,30 +182,62 @@ func runDryRunTest(opts dryRunOptions) error { // A localhost test endpoint refers to the user's machine, not the // container: expose the port and point Microcks at the host gateway. if rewritten, hostPort, ok := rewriteLocalEndpoint(opts.params.testEndpoint); ok { - fmt.Fprintf(progress, "Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten) + if _, err := fmt.Fprintf(progress, "Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } opts.params.testEndpoint = rewritten containerOpts = append(containerOpts, testcontainers.WithHostPortAccess(hostPort)) } - fmt.Fprintf(progress, "Starting ephemeral Microcks container (%s)...\n", opts.image) + if _, err := fmt.Fprintf(progress, "Starting ephemeral Microcks container (%s)...\n", opts.image); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } startCtx, startCancel := context.WithTimeout(ctx, opts.readyTimeout) defer startCancel() container, err := microcks.Run(startCtx, opts.image, containerOpts...) if err != nil { if container != nil { - terminateContainer(container, progress) + if terminateErr := terminateContainer(container, progress); terminateErr != nil { + return errors.Wrapf( + errors.KindEnvironment, + "failed to start ephemeral Microcks container: %v; cleanup also failed: %v", + err, + terminateErr, + ) + } } return errors.Wrapf(errors.KindEnvironment, "failed to start ephemeral Microcks container: %v. "+ "Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout)", err) } - defer terminateContainer(container, progress) + defer func() { + if err := terminateContainer(container, progress); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "tearing down ephemeral Microcks container: %v (previous error: %v)", + err, + resultErr, + ) + } + }() endpoint, err := container.HttpEndpoint(ctx) if err != nil { return errors.Wrapf(errors.KindEnvironment, "failed to resolve ephemeral Microcks endpoint: %v", err) } - fmt.Fprintf(progress, "Ephemeral Microcks is ready at %s\n", endpoint) + if _, err := fmt.Fprintf(progress, "Ephemeral Microcks is ready at %s\n", endpoint); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if events != nil { + if err := events.emit(dryRunWatchEvent{Type: "ready", Endpoint: endpoint}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if err := events.emit(dryRunWatchEvent{ + Type: "imported", Artifact: opts.artifact, Service: opts.params.serviceRef, + }); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } // The uber-native image runs without Keycloak: a headless client with // the unauthenticated token is enough. @@ -199,10 +247,23 @@ func runDryRunTest(opts dryRunOptions) error { } mc.SetOAuthToken("unauthenticated-token") - success, testResultID, err := runTestAndWait(mc, opts.params) + params := opts.params + params.suppressOutput = eventMode + success, testResultID, err := runTestAndWait(mc, params) if err != nil { + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } return err } + if events != nil { + if err := events.emitTestResult(mc, testResultID); err != nil { + return errors.Wrap(errors.KindAPI, err) + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } if !opts.watch { if success { @@ -210,27 +271,52 @@ func runDryRunTest(opts dryRunOptions) error { } return errors.ErrTestFailed } - printDetailsLink(progress, endpoint, testResultID) - return watchAndRerun(ctx, mc, endpoint, opts) + if err := printDetailsLink(progress, endpoint, testResultID); err != nil { + return err + } + return watchAndRerun(ctx, mc, endpoint, opts, events) } -func terminateContainer(container *microcks.MicrocksContainer, progress io.Writer) { +func terminateContainer(container *microcks.MicrocksContainer, progress io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - fmt.Fprintln(progress, "Tearing down ephemeral Microcks container...") + if _, err := fmt.Fprintln(progress, "Tearing down ephemeral Microcks container..."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if err := container.Terminate(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Failed to terminate container %s: %s\n", container.GetContainerID(), err) + return errors.Wrapf( + errors.KindEnvironment, + "failed to terminate container %s: %v", + container.GetContainerID(), + err, + ) } + return nil } -func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr string, opts dryRunOptions) error { +func watchAndRerun( + ctx context.Context, + mc connectors.MicrocksClient, + serverAddr string, + opts dryRunOptions, + events *dryRunEventWriter, +) (resultErr error) { progress := progressWriter(opts.params.outputFormat) watcher, err := fsnotify.NewWatcher() if err != nil { return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create file watcher: %w", err)) } - defer watcher.Close() + defer func() { + if err := watcher.Close(); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "closing file watcher: %v (previous error: %v)", + err, + resultErr, + ) + } + }() // Watch the directory, not the file: editors replace files on save // (rename + create), which silently drops a watch set on the file itself. @@ -242,7 +328,9 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err)) } - fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) + if _, err := fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } rerun := make(chan struct{}, 1) var debounce *time.Timer @@ -250,7 +338,9 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr for { select { case <-ctx.Done(): - fmt.Fprintln(progress, "\nStopping watch mode.") + if _, err := fmt.Fprintln(progress, "\nStopping watch mode."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } return nil case event, ok := <-watcher.Events: @@ -279,32 +369,88 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr if !ok { return nil } - fmt.Fprintf(os.Stderr, "Watch error: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Watch error: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } case <-rerun: - fmt.Fprintln(progress, strings.Repeat("-", 60)) - fmt.Fprintf(progress, "Artifact changed, re-importing %s ...\n", opts.artifact) + if _, err := fmt.Fprintln(progress, strings.Repeat("-", 60)); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if _, err := fmt.Fprintf(progress, "Artifact changed, re-importing %s ...\n", opts.artifact); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if _, err := mc.UploadArtifact(opts.artifact, true); err != nil { // Invalid spec mid-edit is normal in a TDD loop: report and // keep watching, the next valid save recovers. - fmt.Fprintf(os.Stderr, "Re-import failed, waiting for next change: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Re-import failed, waiting for next change: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } continue } - success, testResultID, err := runTestAndWait(mc, opts.params) + if events != nil { + if err := events.emit(dryRunWatchEvent{ + Type: "imported", Artifact: opts.artifact, Service: opts.params.serviceRef, + }); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } + params := opts.params + params.suppressOutput = events != nil + success, testResultID, err := runTestAndWait(mc, params) if err != nil { - fmt.Fprintf(os.Stderr, "Test run failed, waiting for next change: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Test run failed, waiting for next change: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } continue } - printDetailsLink(progress, serverAddr, testResultID) + if events != nil { + if err := events.emitTestResult(mc, testResultID); err != nil { + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } + continue + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } + if err := printDetailsLink(progress, serverAddr, testResultID); err != nil { + return err + } if success { - fmt.Fprintln(progress, "Contract test PASSED — waiting for next change.") + if _, err := fmt.Fprintln(progress, "Contract test PASSED — waiting for next change."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } } else { - fmt.Fprintln(progress, "Contract test FAILED — waiting for next change.") + if _, err := fmt.Fprintln(progress, "Contract test FAILED — waiting for next change."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } } } } } -func printDetailsLink(progress io.Writer, serverAddr, testResultID string) { - fmt.Fprintf(progress, "Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) +func emitDryRunError(events *dryRunEventWriter, sourceErr error) error { + if events == nil { + return nil + } + if err := events.emit(dryRunWatchEvent{Type: "error", Message: sourceErr.Error()}); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("writing dry-run error event: %w", err)) + } + return nil +} + +func printDetailsLink(progress io.Writer, serverAddr, testResultID string) error { + _, err := fmt.Fprintf(progress, "Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) + return errors.Wrap(errors.KindEnvironment, err) } diff --git a/cmd/testDryRunEvents.go b/cmd/testDryRunEvents.go new file mode 100644 index 00000000..a713f019 --- /dev/null +++ b/cmd/testDryRunEvents.go @@ -0,0 +1,60 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "encoding/json" + "io" + "time" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +type dryRunWatchEvent struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Endpoint string `json:"endpoint,omitempty"` + Artifact string `json:"artifact,omitempty"` + Service string `json:"service,omitempty"` + TestResultID string `json:"testResultId,omitempty"` + Result *connectors.TestResult `json:"result,omitempty"` + Message string `json:"message,omitempty"` +} + +type dryRunEventWriter struct { + encoder *json.Encoder +} + +func newDryRunEventWriter(w io.Writer) *dryRunEventWriter { + return &dryRunEventWriter{encoder: json.NewEncoder(w)} +} + +func (w *dryRunEventWriter) emit(event dryRunWatchEvent) error { + event.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + return w.encoder.Encode(event) +} + +func (w *dryRunEventWriter) emitTestResult(mc connectors.MicrocksClient, testResultID string) error { + result, err := mc.GetFullTestResult(testResultID) + if err != nil { + return err + } + return w.emit(dryRunWatchEvent{ + Type: "test-result", + TestResultID: testResultID, + Result: result, + }) +} diff --git a/cmd/testDryRunEvents_test.go b/cmd/testDryRunEvents_test.go new file mode 100644 index 00000000..38758b8e --- /dev/null +++ b/cmd/testDryRunEvents_test.go @@ -0,0 +1,48 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestDryRunEventWriterEmitsOneJSONDocumentPerLine(t *testing.T) { + var buffer bytes.Buffer + events := newDryRunEventWriter(&buffer) + if err := events.emit(dryRunWatchEvent{Type: "ready", Endpoint: "http://localhost:1234"}); err != nil { + t.Fatalf("emit returned error: %v", err) + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + t.Fatalf("emit returned error: %v", err) + } + + lines := strings.Split(strings.TrimSpace(buffer.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2: %q", len(lines), buffer.String()) + } + for _, line := range lines { + var event dryRunWatchEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("line is not JSON: %v", err) + } + if event.Timestamp == "" { + t.Fatal("event timestamp is empty") + } + } +} diff --git a/cmd/testExecutor.go b/cmd/testExecutor.go index 6334340a..fa4de4f5 100644 --- a/cmd/testExecutor.go +++ b/cmd/testExecutor.go @@ -22,6 +22,7 @@ import ( "time" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/microcks/microcks-cli/pkg/output" ) @@ -38,6 +39,7 @@ type testParams struct { oAuth2Context string outputFormat string artifactPath string + suppressOutput bool } // progressWriter returns where human progress/diagnostics should go. For @@ -78,18 +80,24 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri } success = testResultSummary.Success inProgress := testResultSummary.InProgress - fmt.Fprintf(progress, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) + if _, err := fmt.Fprintf(progress, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)); err != nil { + return false, testResultID, errors.Wrap(errors.KindEnvironment, err) + } if !inProgress { break } - fmt.Fprintln(progress, "MicrocksTester waiting for 2 seconds before checking again or exiting.") + if _, err := fmt.Fprintln(progress, "MicrocksTester waiting for 2 seconds before checking again or exiting."); err != nil { + return false, testResultID, errors.Wrap(errors.KindEnvironment, err) + } time.Sleep(2 * time.Second) } - if err := renderTestResult(mc, testResultID, params.outputFormat, params.artifactPath); err != nil { - return false, testResultID, err + if !params.suppressOutput { + if err := renderTestResult(mc, testResultID, params.outputFormat, params.artifactPath); err != nil { + return false, testResultID, err + } } return success, testResultID, nil diff --git a/cmd/testQuery.go b/cmd/testQuery.go new file mode 100644 index 00000000..1cd066d6 --- /dev/null +++ b/cmd/testQuery.go @@ -0,0 +1,127 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" + "github.com/spf13/cobra" +) + +func newTestListCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var ( + serviceID string + page int + size int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Microcks test results", + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if page < 0 { + return errors.Wrapf(errors.KindUsage, "--page must be greater than or equal to 0") + } + if size <= 0 { + return errors.Wrapf(errors.KindUsage, "--size must be greater than 0") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + tests, err := mc.ListTestResults(serviceID, page, size) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, tests) + } + return printTestResults(tests) + }, + } + cmd.Flags().StringVar(&serviceID, "serviceId", "", "Service id to filter tests") + cmd.Flags().IntVar(&page, "page", 0, "Page index to fetch") + cmd.Flags().IntVar(&size, "size", 50, "Number of test results to fetch") + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func newTestGetCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a Microcks test result", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + result, err := mc.GetFullTestResult(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, result) + } + return printTestResults([]connectors.TestResultSummary{{ + ID: result.ID, + Version: result.Version, + TestNumber: result.TestNumber, + TestDate: result.TestDate, + TestedEndpoint: result.TestedEndpoint, + ServiceID: result.ServiceID, + ElapsedTime: result.ElapsedTime, + Success: result.Success, + InProgress: result.InProgress, + }}) + }, + } + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func printTestResults(results []connectors.TestResultSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "ID\tSERVICE ID\tSUCCESS\tIN PROGRESS\tELAPSED"); err != nil { + return err + } + for _, result := range results { + if _, err := fmt.Fprintf(w, "%s\t%s\t%t\t%t\t%dms\n", result.ID, result.ServiceID, result.Success, result.InProgress, result.ElapsedTime); err != nil { + return err + } + } + return w.Flush() +} diff --git a/cmd/test_query_test.go b/cmd/test_query_test.go new file mode 100644 index 00000000..0cb24d03 --- /dev/null +++ b/cmd/test_query_test.go @@ -0,0 +1,76 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTestListCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("serviceId"); got != "svc-1" { + t.Fatalf("unexpected serviceId: %s", got) + } + if err := json.NewEncoder(w).Encode([]map[string]any{{ + "id": "test-1", + "serviceId": "svc-1", + "success": true, + }}); err != nil { + t.Fatalf("failed to encode test results response: %v", err) + } + })) + defer server.Close() + + out, err := executeCLIForTest(t, "test", "list", "--microcksURL", server.URL, "--serviceId", "svc-1", "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "test-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestTestGetCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests/test-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewEncoder(w).Encode(map[string]any{ + "id": "test-1", + "serviceId": "svc-1", + "success": false, + }); err != nil { + t.Fatalf("failed to encode test result response: %v", err) + } + })) + defer server.Close() + + out, err := executeCLIForTest(t, "test", "get", "test-1", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "test-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} diff --git a/documentation/cmd/capabilities.md b/documentation/cmd/capabilities.md new file mode 100644 index 00000000..2f3746e8 --- /dev/null +++ b/documentation/cmd/capabilities.md @@ -0,0 +1,97 @@ +## `microcks capabilities` - List CLI capabilities + +Lists stable capability identifiers that integrations can use to detect +whether a Microcks CLI release supports the commands they require. +Capability identifiers describe public workflows and machine-readable +contracts; they are not a copy of every CLI flag. +The command runs locally and does not require a Microcks server or a configured +context. + +```sh +microcks capabilities --output json +``` + +Example output: + +```json +{ + "schemaVersion": "v1", + "cliVersion": "1.0.3", + "capabilities": [ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json" + ] +} +``` + +### Capability identifiers + +| Capability | Available workflow or contract | +| --- | --- | +| `auth.login` | Log in with username and password | +| `auth.login.sso` | Log in through the browser-based SSO flow | +| `auth.logout` | Remove authentication for a context | +| `context.list` | List configured contexts as text | +| `context.list.json` | List configured contexts using the stable JSON contract | +| `context.use` | Select the current context | +| `context.use.json` | Select a context and return the selection as JSON | +| `context.delete` | Delete a configured context | +| `context.delete.json` | Delete a context and return the result as JSON | +| `instance.start` | Start a local Microcks container | +| `instance.start.json` | Start an instance and return its server/context as JSON | +| `instance.stop` | Stop a local Microcks container | +| `artifact.import.file` | Import one or more local artifact files | +| `artifact.import.file.json` | Import local artifacts and return their identifiers as JSON | +| `artifact.import.file.watch` | Re-import local artifacts when files change | +| `artifact.import.directory` | Import artifacts discovered in a directory | +| `artifact.import.url` | Import artifacts from remote URLs | +| `service.list.json` | List services using the stable JSON contract | +| `service.get.json` | Retrieve service details using the stable JSON contract | +| `test.run` | Run a test against a target endpoint | +| `test.run.output.json` | Render a test result as JSON | +| `test.run.output.yaml` | Render a test result as YAML | +| `test.run.output.github-actions` | Render annotations for GitHub Actions | +| `test.dry-run` | Run a test with an ephemeral Microcks container | +| `test.dry-run.watch` | Re-run an ephemeral test when its artifact changes | +| `test.dry-run.watch.events.json` | Emit NDJSON lifecycle and result events while watching | +| `test.list.json` | List test results using the stable JSON contract | +| `test.get.json` | Retrieve a test result using the stable JSON contract | + +`test.dry-run.watch` describes the interactive text workflow. +`test.dry-run.watch.events.json` guarantees newline-delimited `ready`, +`imported`, `test-result`, `waiting`, `error`, and `stopped` events. + +Capabilities describe the behavior of the installed CLI binary. They do not +report optional features enabled by a particular Microcks server. + +### Options + +| Flag | Description | +| --- | --- | +| `--output` | Output format: `text` or `json` (default: `text`) | diff --git a/documentation/cmd/context.md b/documentation/cmd/context.md index 9a082737..4ea74704 100644 --- a/documentation/cmd/context.md +++ b/documentation/cmd/context.md @@ -16,12 +16,21 @@ microcks context/ctx http://localhost:8080 # Delete the context microcks context/ctx http://localhost:8080 --delete/-d + +# List contexts for editor and automation integrations +microcks context --output json ``` + +JSON mode writes an array of `{name, server, current}` objects. When no local +config exists yet, it writes `[]`; this is a valid disconnected state for +editor and automation consumers. + ### Options | Flag | Description | | -------------- | ---------------------------- | | `-d, --delete` | Delete the specified context | | `-h, --help` | help for context | +| `--output` | Output format: `text` or `json` | ### Options Inherited from Parent Commands | Flag | Description | @@ -35,5 +44,3 @@ microcks context/ctx http://localhost:8080 --delete/-d | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | - - diff --git a/documentation/cmd/import.md b/documentation/cmd/import.md index 31eb189f..16e30d09 100644 --- a/documentation/cmd/import.md +++ b/documentation/cmd/import.md @@ -32,6 +32,7 @@ microcks import ./api.yaml --microcksURL | ----------- | --------------------------------------------------- | | `-h, --help`| help for import | | `--watch` | Watch the file(s) and auto-reimport them on changes | +| `--output` | Output format: `text` or `json` (`json` cannot be combined with `--watch`) | ### Options Inherited from Parent Commands | Flag | Description | diff --git a/documentation/cmd/service.md b/documentation/cmd/service.md new file mode 100644 index 00000000..433d7f28 --- /dev/null +++ b/documentation/cmd/service.md @@ -0,0 +1,54 @@ +## `microcks service` - List and Inspect Microcks Services +Lists services known by the selected Microcks server and retrieves service details. + +### Usage +```bash +microcks service list [flags] +microcks service get [flags] +``` + +### Examples +```bash +# List services from the current context +microcks service list + +# List services as JSON for tools and editor integrations +microcks service list --output json + +# Get service details by id +microcks service get 64f1d8c9e4b02c1c4d6c7a90 --output json + +# Get service details by name and version +microcks service get "E-Commerce Platform API:2.0.0" --output json +``` + +### Options +| Flag | Description | +| ---------- | ------------------------------------------------ | +| `-h, --help` | help for service | +| `--output` | Output format: `text` (default) or `json` | +| `--page` | Page index to fetch for `service list` | +| `--size` | Number of services to fetch for `service list` | + +### 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 | + +### JSON contracts + +`service list --output json` writes a JSON array of service summaries. Each +summary includes `id`, `name`, `version`, and `type`, and may include +`operations`. + +`service get --output json` writes an object containing `service` and, when +available, `messagesMap`. Integrations should check for the +`service.list.json` and `service.get.json` capabilities before depending on +these contracts. diff --git a/documentation/cmd/start.md b/documentation/cmd/start.md index 65d8e899..e9c8dac1 100644 --- a/documentation/cmd/start.md +++ b/documentation/cmd/start.md @@ -15,15 +15,21 @@ microcks start microcks start --port [Port you want] # Define your driver (by default docker) -microcks start --driver [driver you wnat either 'docker' or 'podman'] +microcks start --driver [docker-or-podman] # Define name of your microcks container/instance microcks start --name [name of you container/instance] # Auto remove the container on exit microcks start --rm + +# Start and return the selected local context as JSON +microcks start --output json ``` +In JSON mode, stdout contains only the structured start result. Image-pull and +readiness progress is written to stderr. + ### Options | Flag | Description | | ----------- | -------------------------------------------------------------------------------- | @@ -33,6 +39,9 @@ microcks start --rm | `--image` | Container image to use (default: `quay.io/microcks/microcks-uber:latest-native`) | | `--rm` | Auto-remove the container when it exits (like Docker `--rm`) | | `--driver` | Container driver to use (`docker` or `podman`, default: `docker`) | +| `--ready-timeout` | How long to wait for Microcks to answer before failing (default: `1m`) | +| `--no-wait` | Return after the container starts without waiting for Microcks readiness | +| `--output` | Output format: `text` or `json` | ### Options Inherited from Parent Commands | Flag | Description | @@ -45,4 +54,3 @@ microcks start --rm | `--keycloakClientId` | Keycloak Realm Service Account ClientId | | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | - diff --git a/documentation/cmd/test.md b/documentation/cmd/test.md index 6cbe7d18..7212b684 100644 --- a/documentation/cmd/test.md +++ b/documentation/cmd/test.md @@ -4,6 +4,8 @@ Runs contract or integration tests against a deployed API using the selected run ### Usage ```bash microcks test [flags] +microcks test list [flags] +microcks test get [flags] ``` ### Example @@ -19,6 +21,15 @@ microcks test Beer Catalog API:0.9 http://localhost:9090/api/ POSTMAN \ --microcksURL \ --keycloakClientId \ --keycloakClientSecret \ + +# List recent test results as JSON +microcks test list --output json + +# List recent test results for a service +microcks test list --serviceId --output json + +# Get a full test result as JSON +microcks test get --output json ``` ### Runner Options @@ -36,6 +47,14 @@ One of: | `--oAuth2Context` | OAuth2 client context as JSON string | | `--output` | Output format: `text` (default), `json`, `yaml`, or `github-actions` | +### `test list` and `test get` Options +| Flag | Description | +| ------------- | ------------------------------------------------ | +| `--output` | Output format: `text` (default) or `json` | +| `--serviceId` | Service id to filter `test list` results | +| `--page` | Page index to fetch for `test list` | +| `--size` | Number of test results to fetch for `test list` | + ### Options Inherited from Parent Commands | Flag | Description | @@ -48,3 +67,17 @@ One of: | `--keycloakClientId` | Keycloak Realm Service Account ClientId | | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | + +### Structured output contracts + +`test list --output json` writes a JSON array of test-result summaries. +`test get --output json` writes one complete test result, including its +`testCaseResults` when present. Integrations should check for the +`test.list.json` and `test.get.json` capabilities before depending on these +contracts. + +`microcks test ... --output json` and one-shot dry-run tests write the completed +test result to stdout while progress and diagnostics go to stderr. Dry-run +watch mode writes one JSON event per line. Consumers should require +`test.dry-run.watch.events.json` and handle `ready`, `imported`, `test-result`, +`waiting`, `error`, and `stopped`. diff --git a/pkg/connectors/container_client.go b/pkg/connectors/container_client.go index 49c4ef2f..d9c4cdba 100644 --- a/pkg/connectors/container_client.go +++ b/pkg/connectors/container_client.go @@ -19,6 +19,7 @@ package connectors import ( "context" "fmt" + "io" "os" "os/exec" "runtime" @@ -51,6 +52,7 @@ type ContainerOpts struct { Port string AutoRemove bool Name string + Output io.Writer } const ( @@ -138,7 +140,7 @@ func NewPodmanClient() (*containerClient, error) { return &containerClient{cli: cli}, nil } -func (cli *containerClient) CreateContainer(opts ContainerOpts) (string, error) { +func (cli *containerClient) CreateContainer(opts ContainerOpts) (containerID string, resultErr error) { ctx := context.Background() // Define exposed port and bindings @@ -159,13 +161,29 @@ func (cli *containerClient) CreateContainer(opts ContainerOpts) (string, error) if err != nil { return "", err } - defer out.Close() + defer func() { + if err := out.Close(); err != nil { + if resultErr == nil { + resultErr = fmt.Errorf("closing image pull stream: %w", err) + } else { + resultErr = fmt.Errorf("%v; closing image pull stream: %w", resultErr, err) + } + } + }() - fd, isTerminal := term.GetFdInfo(os.Stdout) + progress := opts.Output + if progress == nil { + progress = os.Stdout + } + var fd uintptr + var isTerminal bool + if outputFile, ok := progress.(*os.File); ok { + fd, isTerminal = term.GetFdInfo(outputFile) + } err = jsonmessage.DisplayJSONMessagesStream( out, - os.Stdout, + progress, fd, isTerminal, nil, diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index d3b34099..05804aee 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -43,11 +43,16 @@ var ( grantTypeChoices = map[string]bool{"PASSWORD": true, "CLIENT_CREDENTIALS": true, "REFRESH_TOKEN": true} ) +const serviceLookupPageSize = 100 + // MicrocksClient allows interacting with Microcks APIs type MicrocksClient interface { HttpClient() *http.Client GetKeycloakURL() (string, error) SetOAuthToken(oauthToken string) + ListServices(page int, size int) ([]Service, error) + GetService(ref string) (*ServiceDetail, error) + ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) GetTestResult(testResultID string) (*TestResultSummary, error) GetFullTestResult(testResultID string) (*TestResult, error) @@ -55,6 +60,28 @@ type MicrocksClient interface { DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) } +// Service represents a Microcks service summary. +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Operations []Operation `json:"operations,omitempty"` +} + +// Operation represents a Microcks service operation. +type Operation struct { + Name string `json:"name"` + Method string `json:"method,omitempty"` + ResourcePaths []string `json:"resourcePaths,omitempty"` +} + +// ServiceDetail represents the Microcks service detail response used by the UI. +type ServiceDetail struct { + Service Service `json:"service"` + MessagesMap map[string][]json.RawMessage `json:"messagesMap,omitempty"` +} + // TestResultSummary represents a simple view on Microcks TestResult type TestResultSummary struct { ID string `json:"id"` @@ -380,6 +407,116 @@ func (c *microcksClient) SetOAuthToken(oauthToken string) { c.AuthToken = oauthToken } +func (c *microcksClient) ListServices(page int, size int) ([]Service, error) { + values := url.Values{} + values.Set("page", strconv.Itoa(page)) + values.Set("size", strconv.Itoa(size)) + + var services []Service + if err := c.getJSON("services", values, &services, "Microcks for listing services"); err != nil { + return nil, err + } + return services, nil +} + +func (c *microcksClient) GetService(ref string) (*ServiceDetail, error) { + id := ref + if strings.Contains(ref, ":") { + serviceID, err := c.resolveServiceID(ref) + if err != nil { + return nil, err + } + id = serviceID + } + + var detail ServiceDetail + if err := c.getJSON("services/"+id, nil, &detail, "Microcks for getting service detail"); err != nil { + return nil, err + } + return &detail, nil +} + +func (c *microcksClient) ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error) { + values := url.Values{} + values.Set("page", strconv.Itoa(page)) + values.Set("size", strconv.Itoa(size)) + if serviceID != "" { + values.Set("serviceId", serviceID) + } + + var tests []TestResultSummary + if err := c.getJSON("tests", values, &tests, "Microcks for listing tests"); err != nil { + return nil, err + } + return tests, nil +} + +func (c *microcksClient) resolveServiceID(ref string) (string, error) { + name, version, ok := strings.Cut(ref, ":") + if !ok || name == "" || version == "" { + return "", errors.Wrapf(errors.KindUsage, "service reference %q must be :", ref) + } + for page := 0; ; page++ { + services, err := c.ListServices(page, serviceLookupPageSize) + if err != nil { + return "", err + } + for _, service := range services { + if service.Name == name && service.Version == version { + return service.ID, nil + } + } + if len(services) < serviceLookupPageSize { + break + } + } + return "", errors.Wrapf(errors.KindNotFound, "service %q does not exist", ref) +} + +func (c *microcksClient) getJSON(path string, query url.Values, out any, dumpLabel string) error { + rel := &url.URL{Path: path} + if len(query) > 0 { + rel.RawQuery = query.Encode() + } + u := c.APIURL.ResolveReference(rel) + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + config.DumpRequestIfRequired(dumpLabel, req, false) + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(errors.KindConnection, err) + } + defer resp.Body.Close() + + config.DumpResponseIfRequired(dumpLabel, resp, true) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(errors.KindConnection, fmt.Errorf("reading Microcks response: %w", err)) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + kind := errors.KindAPI + if resp.StatusCode == http.StatusNotFound { + kind = errors.KindNotFound + } + return errors.Wrapf(kind, "Microcks returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + if err := json.Unmarshal(body, out); err != nil { + return errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Microcks response: %w", err)) + } + return nil +} + func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) { // Ensure we have a correct URL. rel := &url.URL{Path: "tests"} @@ -463,84 +600,20 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, } func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, error) { - // Ensure we have a correct URL. - rel := &url.URL{Path: "tests/" + testResultID} - u := c.APIURL.ResolveReference(rel) - - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, errors.Wrap(errors.KindGeneric, fmt.Errorf("creating test result request: %w", err)) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+c.AuthToken) - - // Dump request if verbose required. - config.DumpRequestIfRequired("Microcks for getting status", req, false) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, err) - } - defer resp.Body.Close() - - // Dump response if verbose required. - config.DumpResponseIfRequired("Microcks for getting status test", resp, true) - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, fmt.Errorf("reading test result response: %w", err)) - } - result := TestResultSummary{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, errors.Wrap(errors.KindAPI, fmt.Errorf("failed to parse test result response: %w", err)) + if err := c.getJSON("tests/"+testResultID, nil, &result, "Microcks for getting status"); err != nil { + return nil, err } - return &result, nil } // GetFullTestResult fetches the complete TestResult including per-operation // (testCaseResults) detail, used by the richer --output formatters. func (c *microcksClient) GetFullTestResult(testResultID string) (*TestResult, error) { - rel := &url.URL{Path: "tests/" + testResultID} - u := c.APIURL.ResolveReference(rel) - - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, errors.Wrap(errors.KindGeneric, fmt.Errorf("creating full test result request: %w", err)) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+c.AuthToken) - - config.DumpRequestIfRequired("Microcks for getting full test result", req, false) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, err) - } - defer resp.Body.Close() - - config.DumpResponseIfRequired("Microcks for getting full test result", resp, true) - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, fmt.Errorf("reading full test result response: %w", err)) - } - if resp.StatusCode != http.StatusOK { - kind := errors.KindAPI - if resp.StatusCode == http.StatusNotFound { - kind = errors.KindNotFound - } - return nil, errors.Wrapf(kind, "Microcks returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - result := TestResult{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, errors.Wrap(errors.KindAPI, fmt.Errorf("failed to parse full test result response: %w", err)) + if err := c.getJSON("tests/"+testResultID, nil, &result, "Microcks for getting full test result"); err != nil { + return nil, err } - return &result, nil } diff --git a/pkg/connectors/microcks_client_test.go b/pkg/connectors/microcks_client_test.go index 70bc4c80..d35edc0c 100644 --- a/pkg/connectors/microcks_client_test.go +++ b/pkg/connectors/microcks_client_test.go @@ -17,6 +17,7 @@ package connectors import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -271,3 +272,194 @@ func TestGetFullTestResultChecksStatusBeforeParsing(t *testing.T) { t.Fatalf("error %q does not mention HTTP 404", err.Error()) } } + +func TestListServicesFetchesServicesEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("unexpected page: %s", got) + } + if got := r.URL.Query().Get("size"); got != "25" { + t.Fatalf("unexpected size: %s", got) + } + if err := json.NewEncoder(w).Encode([]Service{{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }}); err != nil { + t.Fatalf("failed to encode services response: %v", err) + } + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + services, err := client.ListServices(1, 25) + if err != nil { + t.Fatalf("ListServices returned error: %v", err) + } + if len(services) != 1 || services[0].ID != "svc-1" { + t.Fatalf("unexpected services: %#v", services) + } +} + +func TestGetServiceResolvesNameVersionReference(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/services": + if err := json.NewEncoder(w).Encode([]Service{{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }}); err != nil { + t.Fatalf("failed to encode services response: %v", err) + } + case "/api/services/svc-1": + if err := json.NewEncoder(w).Encode(ServiceDetail{ + Service: Service{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }, + }); err != nil { + t.Fatalf("failed to encode service detail response: %v", err) + } + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + detail, err := client.GetService("Catalog API:1.0.0") + if err != nil { + t.Fatalf("GetService returned error: %v", err) + } + if detail.Service.ID != "svc-1" { + t.Fatalf("unexpected service detail: %#v", detail) + } +} + +func TestGetServiceResolvesNameVersionAcrossPages(t *testing.T) { + firstPage := make([]Service, serviceLookupPageSize) + for i := range firstPage { + firstPage[i] = Service{ + ID: "filler", + Name: "Other API", + Version: "1.0.0", + Type: "REST", + } + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/services": + if got := r.URL.Query().Get("size"); got != "100" { + t.Fatalf("unexpected size: %s", got) + } + switch r.URL.Query().Get("page") { + case "0": + if err := json.NewEncoder(w).Encode(firstPage); err != nil { + t.Fatalf("failed to encode first page response: %v", err) + } + case "1": + if err := json.NewEncoder(w).Encode([]Service{{ + ID: "svc-2", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }}); err != nil { + t.Fatalf("failed to encode second page response: %v", err) + } + default: + t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) + } + case "/api/services/svc-2": + if err := json.NewEncoder(w).Encode(ServiceDetail{ + Service: Service{ + ID: "svc-2", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }, + }); err != nil { + t.Fatalf("failed to encode service detail response: %v", err) + } + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + detail, err := client.GetService("Catalog API:1.0.0") + if err != nil { + t.Fatalf("GetService returned error: %v", err) + } + if detail.Service.ID != "svc-2" { + t.Fatalf("unexpected service detail: %#v", detail) + } +} + +func TestListTestResultsFetchesTestsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("serviceId"); got != "svc-1" { + t.Fatalf("unexpected serviceId: %s", got) + } + if err := json.NewEncoder(w).Encode([]TestResultSummary{{ + ID: "test-1", + ServiceID: "svc-1", + Success: true, + }}); err != nil { + t.Fatalf("failed to encode test results response: %v", err) + } + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + results, err := client.ListTestResults("svc-1", 0, 50) + if err != nil { + t.Fatalf("ListTestResults returned error: %v", err) + } + if len(results) != 1 || results[0].ID != "test-1" { + t.Fatalf("unexpected test results: %#v", results) + } +} + +func TestGetFullTestResultClassifiesNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "missing", http.StatusNotFound) + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + _, err = client.GetFullTestResult("missing") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := microckserrors.KindOf(err); got != microckserrors.KindNotFound { + t.Fatalf("KindOf = %v, want KindNotFound", got) + } +} diff --git a/pkg/output/json.go b/pkg/output/json.go new file mode 100644 index 00000000..b484e327 --- /dev/null +++ b/pkg/output/json.go @@ -0,0 +1,38 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package output + +import ( + "encoding/json" + "fmt" + "io" +) + +// WriteJSON writes value as indented JSON followed by a newline. +func WriteJSON(w io.Writer, value any) error { + b, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(b)) + return err +} + +// IsTextOrJSON reports whether s is a supported output value for list/get +// commands that have not opted into the full test-result formatter set. +func IsTextOrJSON(s string) bool { + return s == "text" || s == "json" +}