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

Filter by extension

Filter by extension

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

import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"

"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"
)

// OsExit is a variable so tests can stub it to avoid terminating the process.
var OsExit = os.Exit

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 {
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
}

// 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))
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
OsExit(resp.StatusCode)
}

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"

apiPkg "github.com/OctopusDeploy/cli/pkg/cmd/api"
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) {
// Stub os.Exit so the test doesn't terminate the process
origExit := apiPkg.OsExit
var exitCode int
apiPkg.OsExit = func(code int) { exitCode = code }
defer func() { apiPkg.OsExit = origExit }()

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.Nil(t, err)
assert.Equal(t, http.StatusNotFound, exitCode)
assert.Contains(t, stdOut.String(), `"ErrorMessage": "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())
}},

{"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