Skip to content

Commit 1b571a6

Browse files
committed
added api command to support a GET request against any octopus api endpoint
1 parent f2871d1 commit 1b571a6

3 files changed

Lines changed: 204 additions & 1 deletion

File tree

pkg/cmd/api/api.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
11+
"github.com/MakeNowJust/heredoc/v2"
12+
"github.com/OctopusDeploy/cli/pkg/constants"
13+
"github.com/OctopusDeploy/cli/pkg/constants/annotations"
14+
"github.com/OctopusDeploy/cli/pkg/factory"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// OsExit is a variable so tests can stub it to avoid terminating the process.
19+
var OsExit = os.Exit
20+
21+
func NewCmdAPI(f factory.Factory) *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "api <url>",
24+
Short: "Execute a raw API request",
25+
Long: "Execute an authenticated GET request against the Octopus Server API and print the JSON response.",
26+
Example: heredoc.Docf(`
27+
$ %[1]s api /api
28+
$ %[1]s api /api/spaces
29+
$ %[1]s api /api/Spaces-1/projects
30+
`, constants.ExecutableName),
31+
Args: cobra.ExactArgs(1),
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
return apiRun(cmd, f, args[0])
34+
},
35+
Annotations: map[string]string{
36+
annotations.IsCore: "true",
37+
},
38+
}
39+
40+
return cmd
41+
}
42+
43+
func apiRun(cmd *cobra.Command, f factory.Factory, path string) error {
44+
host := f.GetCurrentHost()
45+
fullURL := host + path
46+
47+
httpClient, err := f.GetHttpClient()
48+
if err != nil {
49+
return err
50+
}
51+
if httpClient == nil {
52+
httpClient = &http.Client{}
53+
}
54+
55+
req, err := http.NewRequest("GET", fullURL, nil)
56+
if err != nil {
57+
return err
58+
}
59+
60+
configProvider, err := f.GetConfigProvider()
61+
if err != nil {
62+
return err
63+
}
64+
65+
apiKey := configProvider.Get(constants.ConfigApiKey)
66+
accessToken := configProvider.Get(constants.ConfigAccessToken)
67+
if apiKey != "" {
68+
req.Header.Set("X-Octopus-ApiKey", apiKey)
69+
} else if accessToken != "" {
70+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
71+
}
72+
73+
resp, err := httpClient.Do(req)
74+
if err != nil {
75+
return err
76+
}
77+
defer resp.Body.Close()
78+
79+
body, err := io.ReadAll(resp.Body)
80+
if err != nil {
81+
return err
82+
}
83+
84+
// Pretty-print if valid JSON, otherwise output raw
85+
var prettyJSON bytes.Buffer
86+
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
87+
cmd.Println(prettyJSON.String())
88+
} else {
89+
cmd.Print(string(body))
90+
}
91+
92+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
93+
OsExit(resp.StatusCode)
94+
}
95+
96+
return nil
97+
}

pkg/cmd/api/api_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package api_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"testing"
8+
9+
apiPkg "github.com/OctopusDeploy/cli/pkg/cmd/api"
10+
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
11+
"github.com/OctopusDeploy/cli/test/testutil"
12+
"github.com/spf13/cobra"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func TestApiCommand(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
20+
}{
21+
{"prints pretty-printed JSON response", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
22+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
23+
defer api.Close()
24+
rootCmd.SetArgs([]string{"api", "/api"})
25+
return rootCmd.ExecuteC()
26+
})
27+
28+
api.ExpectRequest(t, "GET", "/api").RespondWithStatus(http.StatusOK, "200 OK", map[string]string{
29+
"Application": "Octopus Deploy",
30+
"Version": "2024.1.0",
31+
})
32+
33+
_, err := testutil.ReceivePair(cmdReceiver)
34+
assert.Nil(t, err)
35+
assert.Contains(t, stdOut.String(), `"Application": "Octopus Deploy"`)
36+
assert.Contains(t, stdOut.String(), `"Version": "2024.1.0"`)
37+
}},
38+
39+
{"prints error response body on non-2xx status", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
40+
// Stub os.Exit so the test doesn't terminate the process
41+
origExit := apiPkg.OsExit
42+
var exitCode int
43+
apiPkg.OsExit = func(code int) { exitCode = code }
44+
defer func() { apiPkg.OsExit = origExit }()
45+
46+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
47+
defer api.Close()
48+
rootCmd.SetArgs([]string{"api", "/api/nonexistent"})
49+
return rootCmd.ExecuteC()
50+
})
51+
52+
api.ExpectRequest(t, "GET", "/api/nonexistent").RespondWithStatus(http.StatusNotFound, "404 Not Found", map[string]string{
53+
"ErrorMessage": "Not found",
54+
})
55+
56+
_, err := testutil.ReceivePair(cmdReceiver)
57+
assert.Nil(t, err)
58+
assert.Equal(t, http.StatusNotFound, exitCode)
59+
assert.Contains(t, stdOut.String(), `"ErrorMessage": "Not found"`)
60+
}},
61+
62+
{"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) {
63+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
64+
defer api.Close()
65+
rootCmd.SetArgs([]string{"api", "/api/health"})
66+
return rootCmd.ExecuteC()
67+
})
68+
69+
r, _ := api.ReceiveRequest()
70+
assert.Equal(t, "GET", r.Method)
71+
assert.Equal(t, "/api/health", r.URL.Path)
72+
api.Respond(&http.Response{
73+
StatusCode: http.StatusOK,
74+
Status: "200 OK",
75+
Body: io.NopCloser(bytes.NewReader([]byte("OK"))),
76+
ContentLength: 2,
77+
}, nil)
78+
79+
_, err := testutil.ReceivePair(cmdReceiver)
80+
assert.Nil(t, err)
81+
assert.Equal(t, "OK", stdOut.String())
82+
}},
83+
84+
{"requires an argument", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
85+
defer api.Close()
86+
rootCmd.SetArgs([]string{"api"})
87+
_, err := rootCmd.ExecuteC()
88+
assert.Error(t, err)
89+
}},
90+
}
91+
92+
for _, test := range tests {
93+
t.Run(test.name, func(t *testing.T) {
94+
stdOut, stdErr := &bytes.Buffer{}, &bytes.Buffer{}
95+
api := testutil.NewMockHttpServer()
96+
fac := testutil.NewMockFactory(api)
97+
rootCmd := cmdRoot.NewCmdRoot(fac, nil, nil)
98+
rootCmd.SetOut(stdOut)
99+
rootCmd.SetErr(stdErr)
100+
test.run(t, api, rootCmd, stdOut, stdErr)
101+
})
102+
}
103+
}

pkg/cmd/root/root.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package root
33
import (
44
"github.com/OctopusDeploy/cli/pkg/apiclient"
55
accountCmd "github.com/OctopusDeploy/cli/pkg/cmd/account"
6+
apiCmd "github.com/OctopusDeploy/cli/pkg/cmd/api"
67
buildInfoCmd "github.com/OctopusDeploy/cli/pkg/cmd/buildinformation"
78
channelCmd "github.com/OctopusDeploy/cli/pkg/cmd/channel"
89
configCmd "github.com/OctopusDeploy/cli/pkg/cmd/config"
@@ -76,6 +77,8 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
7677
cmd.AddCommand(releaseCmd.NewCmdRelease(f))
7778
cmd.AddCommand(runbookCmd.NewCmdRunbook(f))
7879

80+
cmd.AddCommand(apiCmd.NewCmdAPI(f))
81+
7982
// ----- Configuration -----
8083

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

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

0 commit comments

Comments
 (0)