Skip to content

Commit 8938780

Browse files
authored
feat: added api command to support a GET request against any octopus api endpoint (#586)
Adds a new command: `api` Will allow supporting OIDC login via the login command to access any endpoint URL from either Github or Octopus task. Only supports GET at the moment, can easily be expanded to support POST/PUT later `> octopus api /api/spaces/Spaces-1/tenants/Tenants-1` ```json { "Id": "Tenants-1", "Name": "eyore", "TenantTags": [ "tags/all" ], "CustomFields": [], "ProjectEnvironments": { "Projects-21": [ "Environments-2" ] }, "SpaceId": "Spaces-1", "ClonedFromTenantId": null, "Description": "", "Slug": "eyore", "IsDisabled": false, "Icon": null, "Links": { "Self": "/api/Spaces-1/tenants/Tenants-1", "Variables": "/api/Spaces-1/tenants/Tenants-1/variables", "Web": "/app#/Spaces-1/tenants/Tenants-1", "Logo": "/api/Spaces-1/tenants/Tenants-1/logo?cb=0.0.0-local" } } ``` ``` > octopus api /api/spaces/Spaces-1/tenants/Tenants-1 | jq -r '.Name' eyore ``` error handling: ``` > octopus api /api/spaces/Spaces-1/tenants/Tenants-11 { "ErrorMessage": "Resource is not found or it doesn't exist in the current space context. Please contact your administrator for more information." > echo $? 1 ```
1 parent 9c835d1 commit 8938780

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

pkg/cmd/api/api.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/MakeNowJust/heredoc/v2"
13+
"github.com/OctopusDeploy/cli/pkg/apiclient"
14+
"github.com/OctopusDeploy/cli/pkg/constants"
15+
"github.com/OctopusDeploy/cli/pkg/constants/annotations"
16+
"github.com/OctopusDeploy/cli/pkg/factory"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
func NewCmdAPI(f factory.Factory) *cobra.Command {
21+
cmd := &cobra.Command{
22+
Use: "api <url>",
23+
Short: "Execute a raw API GET request",
24+
Long: "Execute an authenticated GET request against the Octopus Server API and print the JSON response.",
25+
Example: heredoc.Docf(`
26+
$ %[1]s api /api
27+
$ %[1]s api /api/spaces
28+
$ %[1]s api /api/Spaces-1/projects
29+
`, constants.ExecutableName),
30+
Args: cobra.ExactArgs(1),
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
return apiRun(cmd, f, args[0])
33+
},
34+
Annotations: map[string]string{
35+
annotations.IsCore: "true",
36+
},
37+
}
38+
39+
return cmd
40+
}
41+
42+
func apiRun(cmd *cobra.Command, f factory.Factory, path string) error {
43+
if err := validateAPIPath(path); err != nil {
44+
return err
45+
}
46+
47+
client, err := f.GetSystemClient(apiclient.NewRequester(cmd))
48+
if err != nil {
49+
return err
50+
}
51+
52+
req, err := http.NewRequest("GET", path, nil)
53+
if err != nil {
54+
return err
55+
}
56+
57+
resp, err := client.HttpSession().DoRawRequest(req)
58+
if err != nil {
59+
return err
60+
}
61+
defer resp.Body.Close()
62+
63+
body, err := io.ReadAll(resp.Body)
64+
if err != nil {
65+
return err
66+
}
67+
68+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
69+
return errors.New(string(body))
70+
}
71+
72+
// Pretty-print if valid JSON, otherwise output raw
73+
var prettyJSON bytes.Buffer
74+
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
75+
cmd.Println(prettyJSON.String())
76+
} else {
77+
cmd.Print(string(body))
78+
}
79+
80+
return nil
81+
}
82+
83+
func validateAPIPath(path string) error {
84+
trimmed := strings.TrimLeft(path, "/")
85+
if !strings.HasPrefix(trimmed, "api") {
86+
return fmt.Errorf("the api command only supports paths prefixed with /api (e.g. /api/spaces)")
87+
}
88+
return nil
89+
}

pkg/cmd/api/api_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package api_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"testing"
8+
9+
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
10+
"github.com/OctopusDeploy/cli/test/testutil"
11+
"github.com/spf13/cobra"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// respondToSdkInit handles the two HTTP requests that the Octopus SDK makes
16+
// when initialising the system client: fetching the root resource and listing
17+
// spaces to find the default space.
18+
func respondToSdkInit(t *testing.T, api *testutil.MockHttpServer) {
19+
api.ExpectRequest(t, "GET", "/api/").RespondWith(testutil.NewRootResource())
20+
api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(map[string]any{
21+
"Items": []any{},
22+
"ItemsPerPage": 30,
23+
"TotalResults": 0,
24+
})
25+
}
26+
27+
func TestApiCommand(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
31+
}{
32+
{"prints pretty-printed JSON response", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
33+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
34+
defer api.Close()
35+
rootCmd.SetArgs([]string{"api", "/api"})
36+
return rootCmd.ExecuteC()
37+
})
38+
39+
respondToSdkInit(t, api)
40+
41+
api.ExpectRequest(t, "GET", "/api").RespondWithStatus(http.StatusOK, "200 OK", map[string]string{
42+
"Application": "Octopus Deploy",
43+
"Version": "2024.1.0",
44+
})
45+
46+
_, err := testutil.ReceivePair(cmdReceiver)
47+
assert.Nil(t, err)
48+
assert.Contains(t, stdOut.String(), `"Application": "Octopus Deploy"`)
49+
assert.Contains(t, stdOut.String(), `"Version": "2024.1.0"`)
50+
}},
51+
52+
{"prints error response body on non-2xx status", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
53+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
54+
defer api.Close()
55+
rootCmd.SetArgs([]string{"api", "/api/nonexistent"})
56+
return rootCmd.ExecuteC()
57+
})
58+
59+
respondToSdkInit(t, api)
60+
61+
api.ExpectRequest(t, "GET", "/api/nonexistent").RespondWithStatus(http.StatusNotFound, "404 Not Found", map[string]string{
62+
"ErrorMessage": "Not found",
63+
})
64+
65+
_, err := testutil.ReceivePair(cmdReceiver)
66+
assert.NotNil(t, err)
67+
assert.Contains(t, err.Error(), "Not found")
68+
}},
69+
70+
{"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) {
71+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
72+
defer api.Close()
73+
rootCmd.SetArgs([]string{"api", "/api/health"})
74+
return rootCmd.ExecuteC()
75+
})
76+
77+
respondToSdkInit(t, api)
78+
79+
r, _ := api.ReceiveRequest()
80+
assert.Equal(t, "GET", r.Method)
81+
assert.Equal(t, "/api/health", r.URL.Path)
82+
api.Respond(&http.Response{
83+
StatusCode: http.StatusOK,
84+
Status: "200 OK",
85+
Body: io.NopCloser(bytes.NewReader([]byte("OK"))),
86+
ContentLength: 2,
87+
}, nil)
88+
89+
_, err := testutil.ReceivePair(cmdReceiver)
90+
assert.Nil(t, err)
91+
assert.Equal(t, "OK", stdOut.String())
92+
}},
93+
94+
{"rejects path not prefixed with /api", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
95+
defer api.Close()
96+
rootCmd.SetArgs([]string{"api", "/some/other/path"})
97+
_, err := rootCmd.ExecuteC()
98+
assert.Error(t, err)
99+
assert.Contains(t, err.Error(), "only supports paths prefixed with /api")
100+
}},
101+
102+
{"requires an argument", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
103+
defer api.Close()
104+
rootCmd.SetArgs([]string{"api"})
105+
_, err := rootCmd.ExecuteC()
106+
assert.Error(t, err)
107+
}},
108+
}
109+
110+
for _, test := range tests {
111+
t.Run(test.name, func(t *testing.T) {
112+
stdOut, stdErr := &bytes.Buffer{}, &bytes.Buffer{}
113+
api := testutil.NewMockHttpServer()
114+
fac := testutil.NewMockFactory(api)
115+
rootCmd := cmdRoot.NewCmdRoot(fac, nil, nil)
116+
rootCmd.SetOut(stdOut)
117+
rootCmd.SetErr(stdErr)
118+
test.run(t, api, rootCmd, stdOut, stdErr)
119+
})
120+
}
121+
}

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)