Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions pkg/cmd/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package api

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"

"github.com/MakeNowJust/heredoc/v2"
"github.com/OctopusDeploy/cli/pkg/apiclient"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/constants/annotations"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/spf13/cobra"
)

func NewCmdAPI(f factory.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "api <url>",
Short: "Execute a raw API GET request",
Long: "Execute an authenticated GET request against the Octopus Server API and print the JSON response.",
Example: heredoc.Docf(`
$ %[1]s api /api
$ %[1]s api /api/spaces
$ %[1]s api /api/Spaces-1/projects
`, constants.ExecutableName),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiRun(cmd, f, args[0])
},
Annotations: map[string]string{
annotations.IsCore: "true",
},
}

return cmd
}

func apiRun(cmd *cobra.Command, f factory.Factory, path string) error {
if err := validateAPIPath(path); err != nil {
return err
}

client, err := f.GetSystemClient(apiclient.NewRequester(cmd))
if err != nil {
return err
}

req, err := http.NewRequest("GET", path, nil)
if err != nil {
return err
}

resp, err := client.HttpSession().DoRawRequest(req)
if err != nil {
return err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New(string(body))
}

// Pretty-print if valid JSON, otherwise output raw
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
cmd.Println(prettyJSON.String())
} else {
cmd.Print(string(body))
}

return nil
}

func validateAPIPath(path string) error {
trimmed := strings.TrimLeft(path, "/")
if !strings.HasPrefix(trimmed, "api") {
return fmt.Errorf("the api command only supports paths prefixed with /api (e.g. /api/spaces)")
}
return nil
}
121 changes: 121 additions & 0 deletions pkg/cmd/api/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package api_test

import (
"bytes"
"io"
"net/http"
"testing"

cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
"github.com/OctopusDeploy/cli/test/testutil"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

// respondToSdkInit handles the two HTTP requests that the Octopus SDK makes
// when initialising the system client: fetching the root resource and listing
// spaces to find the default space.
func respondToSdkInit(t *testing.T, api *testutil.MockHttpServer) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(testutil.NewRootResource())
api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(map[string]any{
"Items": []any{},
"ItemsPerPage": 30,
"TotalResults": 0,
})
}

func TestApiCommand(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
}{
{"prints pretty-printed JSON response", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"api", "/api"})
return rootCmd.ExecuteC()
})

respondToSdkInit(t, api)

api.ExpectRequest(t, "GET", "/api").RespondWithStatus(http.StatusOK, "200 OK", map[string]string{
"Application": "Octopus Deploy",
"Version": "2024.1.0",
})

_, err := testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
assert.Contains(t, stdOut.String(), `"Application": "Octopus Deploy"`)
assert.Contains(t, stdOut.String(), `"Version": "2024.1.0"`)
}},

{"prints error response body on non-2xx status", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"api", "/api/nonexistent"})
return rootCmd.ExecuteC()
})

respondToSdkInit(t, api)

api.ExpectRequest(t, "GET", "/api/nonexistent").RespondWithStatus(http.StatusNotFound, "404 Not Found", map[string]string{
"ErrorMessage": "Not found",
})

_, err := testutil.ReceivePair(cmdReceiver)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "Not found")
}},

{"outputs raw body when response is not valid JSON", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"api", "/api/health"})
return rootCmd.ExecuteC()
})

respondToSdkInit(t, api)

r, _ := api.ReceiveRequest()
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/api/health", r.URL.Path)
api.Respond(&http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(bytes.NewReader([]byte("OK"))),
ContentLength: 2,
}, nil)

_, err := testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
assert.Equal(t, "OK", stdOut.String())
}},

{"rejects path not prefixed with /api", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
defer api.Close()
rootCmd.SetArgs([]string{"api", "/some/other/path"})
_, err := rootCmd.ExecuteC()
assert.Error(t, err)
assert.Contains(t, err.Error(), "only supports paths prefixed with /api")
}},

{"requires an argument", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
defer api.Close()
rootCmd.SetArgs([]string{"api"})
_, err := rootCmd.ExecuteC()
assert.Error(t, err)
}},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stdOut, stdErr := &bytes.Buffer{}, &bytes.Buffer{}
api := testutil.NewMockHttpServer()
fac := testutil.NewMockFactory(api)
rootCmd := cmdRoot.NewCmdRoot(fac, nil, nil)
rootCmd.SetOut(stdOut)
rootCmd.SetErr(stdErr)
test.run(t, api, rootCmd, stdOut, stdErr)
})
}
}
5 changes: 4 additions & 1 deletion pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package root
import (
"github.com/OctopusDeploy/cli/pkg/apiclient"
accountCmd "github.com/OctopusDeploy/cli/pkg/cmd/account"
apiCmd "github.com/OctopusDeploy/cli/pkg/cmd/api"
buildInfoCmd "github.com/OctopusDeploy/cli/pkg/cmd/buildinformation"
channelCmd "github.com/OctopusDeploy/cli/pkg/cmd/channel"
configCmd "github.com/OctopusDeploy/cli/pkg/cmd/config"
Expand Down Expand Up @@ -76,6 +77,8 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
cmd.AddCommand(releaseCmd.NewCmdRelease(f))
cmd.AddCommand(runbookCmd.NewCmdRunbook(f))

cmd.AddCommand(apiCmd.NewCmdAPI(f))

// ----- Configuration -----

// commands are expected to print their own errors to avoid double-ups
Expand All @@ -94,7 +97,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
cmdPFlags.BoolP(constants.FlagNoPrompt, "", false, "Disable prompting in interactive mode")

// Enable service messages flag is hidden as it's intended for internal CI/CD use only
cmdPFlags.BoolP(constants.FlagEnableServiceMessages,"", false, "Enable service messages for integration with Octopus CI/CD")
cmdPFlags.BoolP(constants.FlagEnableServiceMessages, "", false, "Enable service messages for integration with Octopus CI/CD")
cmdPFlags.MarkHidden(constants.FlagEnableServiceMessages)
// Legacy flags brought across from the .NET CLI.
// Consumers of these flags will have to explicitly check for them as well as the new
Expand Down
Loading