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
105 changes: 105 additions & 0 deletions cmd/connect_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package cmd

import (
"context"
"errors"

"github.com/amp-labs/cli/clerk"
"github.com/amp-labs/cli/flags"
"github.com/amp-labs/cli/logger"
"github.com/amp-labs/cli/request"
"github.com/spf13/cobra"
)

type oauthClient interface {
GenerateOAuthAuthorizationURL(
ctx context.Context,
params *request.OAuthAuthorizationURLParams,
) (string, error)
}

type oauthClientFactory func(projectId string, apiKey string) oauthClient

type connectProjectResolver func() string

type browserDetector func() bool

type browserOpener func(url string)

func newConnectProviderCmd(
newClient oauthClientFactory,
getProjectId connectProjectResolver,
hasBrowser browserDetector,
openURL browserOpener,
) *cobra.Command {
var (
groupRef string
consumerRef string
providerAppId string
)

cmd := &cobra.Command{
Use: "connect:provider <provider>",
Short: "Start a provider OAuth connection",
Long: "Generate an OAuth authorization URL. Open it in a browser when available; " +
"otherwise, print it.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
projectId := getProjectId()
client := newClient(projectId, flags.GetAPIKey())

url, err := client.GenerateOAuthAuthorizationURL(
cmd.Context(),
&request.OAuthAuthorizationURLParams{
ProjectIdOrName: projectId,
Provider: args[0],
GroupRef: groupRef,
ConsumerRef: consumerRef,
ProviderAppId: providerAppId,
},
)
if err != nil {
if errors.Is(err, clerk.ErrNoSessions) {
logger.FatalErr("Authenticated session has expired, please log in using amp login", err)
} else {
logger.FatalErr("Unable to generate OAuth authorization URL", err)
}
}

if hasBrowser() {
openURL(url)
logger.Info("Opened the authorization URL in your browser.")

return
}

logger.Infof("Authorization URL: %s", url)
},
}

cmd.Flags().StringVar(&groupRef, "group-ref", "", "Identifier for the organization or workspace")
cmd.Flags().StringVar(&consumerRef, "consumer-ref", "", "Identifier for the user authorizing the connection")
cmd.Flags().StringVar(&providerAppId, "provider-app", "", "Provider app ID (uses the project default if omitted)")

for _, flag := range []string{"group-ref", "consumer-ref"} {
err := cmd.MarkFlagRequired(flag)
if err != nil {
logger.FatalErr("unable to require "+flag+" flag", err)
}
}

return cmd
}

var connectProviderCmd = newConnectProviderCmd( //nolint:gochecknoglobals
func(projectId string, apiKey string) oauthClient {
return request.NewAPIClient(projectId, &apiKey)
},
flags.GetProjectOrFail,
canOpenBrowser,
openBrowser,
)

func init() {
rootCmd.AddCommand(connectProviderCmd)
}
97 changes: 97 additions & 0 deletions cmd/connect_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cmd

import (
"context"
"testing"

"github.com/amp-labs/cli/request"
)

const (
testAuthorizationURL = "https://provider.example/authorize"
testOAuthProject = "test-project"
)

type fakeOAuthClient struct {
requestedWith *request.OAuthAuthorizationURLParams
}

func (f *fakeOAuthClient) GenerateOAuthAuthorizationURL(
_ context.Context,
params *request.OAuthAuthorizationURLParams,
) (string, error) {
f.requestedWith = params

return testAuthorizationURL, nil
}

func TestConnectProviderCommandGeneratesAndOpensAuthorizationURL(t *testing.T) {
t.Parallel()

client := &fakeOAuthClient{}
openedURL := ""
cmd := newConnectProviderCmd(
func(projectId string, _ string) oauthClient {
if projectId != testOAuthProject {
t.Fatalf("project ID = %q, want test-project", projectId)
}

return client
},
func() string { return testOAuthProject },
func() bool { return true },
func(url string) { openedURL = url },
)
cmd.SetArgs([]string{
"asana",
"--group-ref", "test-group",
"--consumer-ref", "test-consumer",
"--provider-app", "provider-app-id",
})

err := cmd.Execute()
if err != nil {
t.Fatalf("execute connect provider command: %v", err)
}

want := request.OAuthAuthorizationURLParams{
ProjectIdOrName: testOAuthProject,
Provider: "asana",
GroupRef: "test-group",
ConsumerRef: "test-consumer",
ProviderAppId: "provider-app-id",
}
if client.requestedWith == nil || *client.requestedWith != want {
t.Fatalf("authorization params = %#v, want %#v", client.requestedWith, want)
}

if openedURL != testAuthorizationURL {
t.Fatalf("opened URL = %q, want %q", openedURL, testAuthorizationURL)
}
}

func TestConnectProviderCommandDoesNotOpenBrowserWhenUnavailable(t *testing.T) {
t.Parallel()

client := &fakeOAuthClient{}
cmd := newConnectProviderCmd(
func(_ string, _ string) oauthClient { return client },
func() string { return testOAuthProject },
func() bool { return false },
func(url string) { t.Fatalf("opened URL in headless mode: %s", url) },
)
cmd.SetArgs([]string{
"asana",
"--group-ref", "test-group",
"--consumer-ref", "test-consumer",
})

err := cmd.Execute()
if err != nil {
t.Fatalf("execute connect provider command: %v", err)
}

if client.requestedWith == nil {
t.Fatal("authorization URL was not requested")
}
}
21 changes: 21 additions & 0 deletions request/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ func (c *APIClient) GetMyInfo(ctx context.Context) (map[string]any, error) {
return myInfo, nil
}

func (c *APIClient) GenerateOAuthAuthorizationURL(
ctx context.Context,
params *OAuthAuthorizationURLParams,
) (string, error) {
oauthURL := c.Root + "/oauth-connect"

auth, err := c.getAuthHeader(ctx)
if err != nil {
return "", err
}

var authorizationURL string

_, err = c.Client.Post(ctx, oauthURL, params, &authorizationURL, auth) //nolint:bodyclose
if err != nil {
return "", err
}

return authorizationURL, nil
}

func (c *APIClient) DeleteIntegration(ctx context.Context, integrationId string) error {
delURL := fmt.Sprintf("%s/projects/%s/integrations/%s", c.Root, c.ProjectId, integrationId)

Expand Down
63 changes: 63 additions & 0 deletions request/oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package request

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestGenerateOAuthAuthorizationURL(t *testing.T) {
t.Parallel()

wantParams := OAuthAuthorizationURLParams{
ProjectIdOrName: "test-project",
Provider: "asana",
GroupRef: "test-group",
ConsumerRef: "test-consumer",
ProviderAppId: "provider-app-id",
}
wantURL := "https://provider.example/authorize"

server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost || req.URL.Path != "/v1/oauth-connect" {
t.Errorf("request = %s %s, want POST /v1/oauth-connect", req.Method, req.URL.Path)
}

if req.Header.Get("X-Api-Key") != "test-api-key" {
t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key"))
}

var params OAuthAuthorizationURLParams

err := json.NewDecoder(req.Body).Decode(&params)
if err != nil {
t.Errorf("decode request: %v", err)
}

if params != wantParams {
t.Errorf("request body = %#v, want %#v", params, wantParams)
}

writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = writer.Write([]byte(wantURL))
}))
defer server.Close()

apiKey := "test-api-key"
client := &APIClient{
Root: server.URL + "/v1",
ProjectId: "test-project",
APIKey: &apiKey,
Client: &Client{Client: server.Client()},
}

url, err := client.GenerateOAuthAuthorizationURL(t.Context(), &wantParams)
if err != nil {
t.Fatalf("generate OAuth authorization URL: %v", err)
}

if url != wantURL {
t.Fatalf("authorization URL = %q, want %q", url, wantURL)
}
}
17 changes: 15 additions & 2 deletions request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ func (c *Client) Delete(ctx context.Context,
}

var (
ErrNon200Status = errors.New("error response from API")
ErrNotFound = errors.New("HTTP Status 404")
ErrNon200Status = errors.New("error response from API")
ErrNotFound = errors.New("HTTP Status 404")
errPlainTextResultTarget = errors.New("plain text response requires a string result")
)

func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (*http.Response, error) { //nolint:cyclop
Expand Down Expand Up @@ -235,6 +236,18 @@ func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (*
}
}

contentType, _, contentTypeErr := mime.ParseMediaType(res.Header.Get("Content-Type"))
if contentTypeErr == nil && contentType == "text/plain" {
textResult, ok := result.(*string)
if !ok {
return nil, errPlainTextResultTarget
}

*textResult = string(payload)

return res, nil
}

err = json.Unmarshal(payload, result)
if err != nil {
return nil, err
Expand Down
8 changes: 8 additions & 0 deletions request/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ type ProviderApp struct {
ProjectId string `json:"projectId"`
}

type OAuthAuthorizationURLParams struct {
ProjectIdOrName string `json:"projectIdOrName"`
Provider string `json:"provider"`
GroupRef string `json:"groupRef"`
ConsumerRef string `json:"consumerRef"`
ProviderAppId string `json:"providerAppId,omitempty"`
}

type Config struct {
Id string `json:"id"`
RevisionId string `json:"revisionId"`
Expand Down
Loading