Skip to content
207 changes: 207 additions & 0 deletions commands/api_curl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package commands

import (
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/spf13/cobra"

"github.com/platformsh/cli/internal/auth"
"github.com/platformsh/cli/internal/config"
)

// newAPICurlCommand creates the `api:curl` command which performs an authenticated HTTP request
// against the configured API, using OAuth2 tokens from the credentials store and retrying once on 401.
func newAPICurlCommand(_ *config.Config) *cobra.Command {
var (
method string
data string
jsonBody string
includeHeaders bool
headOnly bool
disableCompression bool
enableGlob bool // accepted for compatibility; no effect
failNoOutput bool
headerFlags []string
)

cmd := &cobra.Command{
Use: "api:curl [flags] [path]",
Short: "Run an authenticated cURL request on the Upsun API",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note - this is good for testing, but the old command actually invoked curl, whereas this is pure Go, so it may need a different name if it's ultimately wanted as a feature.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! This is only to be used as an example command to test the client created in #260 - I won't keep this afterwards.

Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
cfg := config.FromContext(ctx)

// Determine path/URL.
var target string
if len(args) > 0 {
target = args[0]
} else {
target = "/"
}

// Build absolute URL if a path was provided.
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
base := strings.TrimRight(cfg.API.BaseURL, "/")
if !strings.HasPrefix(target, "/") {
target = "/" + target
}
target = base + target
}

// Resolve method.
m := strings.ToUpper(strings.TrimSpace(method))
if m == "" {
m = "GET"
}
if headOnly {
m = "HEAD"
}
if m == "GET" && (data != "" || jsonBody != "") {
m = "POST"
}
if data != "" && jsonBody != "" {
return fmt.Errorf("cannot use --data and --json together")
}

// Base transport: optionally disable compression.
baseRT := http.DefaultTransport
if t, ok := http.DefaultTransport.(*http.Transport); ok && disableCompression {
clone := t.Clone()
clone.DisableCompression = true
baseRT = clone
}

var httpClient *http.Client
// Use our retrying transport via NewClient and inject baseRT via context.
ctxWithRT := auth.WithTransport(ctx, baseRT)
legacyCLI := makeLegacyCLIWrapper(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin())
c, err := auth.NewLegacyCLIClient(ctxWithRT, legacyCLI)
if err != nil {
return err
}
httpClient = c

// Build request.
var body io.Reader
if jsonBody != "" {
body = strings.NewReader(jsonBody)
} else if data != "" {
body = strings.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, m, target, body)
if err != nil {
return err
}

// Set headers.
req.Header.Set("User-Agent", cfg.UserAgent())
if jsonBody != "" {
req.Header.Set("Content-Type", "application/json")
} else if data != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// Apply -H headers.
for _, h := range headerFlags {
h = strings.TrimSpace(h)
if h == "" {
continue
}
// Support "Name: value" and "Name=value" forms.
var name, value string
switch {
case strings.Contains(h, ":"):
parts := strings.SplitN(h, ":", 2)
name = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
case strings.Contains(h, "="):
parts := strings.SplitN(h, "=", 2)
name = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
default:
return fmt.Errorf("invalid header format: %q", h)
}
if name == "" {
return fmt.Errorf("invalid header: empty name in %q", h)
}
req.Header.Add(name, value)
}

// Execute request.
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()

// Handle -f/--fail behavior.
if failNoOutput && resp.StatusCode >= 400 {
return httpStatusError(target, resp)
}

// Output.
out := cmd.OutOrStdout()
// For HEAD requests, always show headers (like curl -I). For --include, add headers before body.
if includeHeaders || headOnly || strings.EqualFold(m, "HEAD") {
// Status line.
fmt.Fprintf(out, "%s %s\r\n", resp.Proto, resp.Status)
// Headers.
for k, vs := range resp.Header {
for _, v := range vs {
fmt.Fprintf(out, "%s: %s\r\n", k, v)
}
}
fmt.Fprint(out, "\r\n")
}

if !headOnly && !strings.EqualFold(m, "HEAD") {
if _, err := io.Copy(out, resp.Body); err != nil {
// Swallow broken pipe errors when piping output.
if !isBrokenPipe(err) {
return err
}
}
}

return nil
},
}

cmd.Flags().StringVarP(&method, "request", "X", "", "The request method to use")
cmd.Flags().StringVarP(&data, "data", "d", "", "Data to send")
cmd.Flags().StringVar(&jsonBody, "json", "", "JSON data to send")
cmd.Flags().BoolVarP(&includeHeaders, "include", "i", false, "Include headers in the output")
cmd.Flags().BoolVarP(&headOnly, "head", "I", false, "Fetch headers only")
cmd.Flags().BoolVar(&disableCompression, "disable-compression", false, "Do not request compressed responses")
cmd.Flags().BoolVar(&enableGlob, "enable-glob", false, "Enable curl globbing (no effect)")
cmd.Flags().BoolVarP(&failNoOutput, "fail", "f", false, "Fail with no output on an error response")
cmd.Flags().StringArrayVarP(&headerFlags, "header", "H", nil, "Extra header(s) (multiple values allowed)")

return cmd
}

func isBrokenPipe(err error) bool {
if err == nil {
return false
}
// This is a heuristic; on macOS broken pipe often contains this substring.
return strings.Contains(strings.ToLower(err.Error()), "broken pipe")
}

// httpStatusError renders a minimal error similar to curl -f behavior.
func httpStatusError(u string, resp *http.Response) error {
// Try to display a concise error with status and URL path.
parsed, _ := url.Parse(u)
target := u
if parsed != nil {
target = parsed.String()
}
// Do not dump body.
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return fmt.Errorf("server returned HTTP %d for %s", resp.StatusCode, target)
}
1 change: 1 addition & 0 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
// Add subcommands.
cmd.AddCommand(
newConfigInstallCommand(),
newAPICurlCommand(cnf),
newCompletionCommand(cnf),
newHelpCommand(cnf),
newListCommand(cnf),
Expand Down
11 changes: 6 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ require (
github.com/stretchr/testify v1.10.0
github.com/symfony-cli/terminal v1.0.7
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/crypto v0.39.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -62,7 +63,7 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
)
22 changes: 12 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,20 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -161,18 +163,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
40 changes: 40 additions & 0 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package auth

import (
"context"
"fmt"
"log"
"net/http"
"os"

"golang.org/x/oauth2"

"github.com/platformsh/cli/internal/legacy"
)

func NewLegacyCLIClient(ctx context.Context, wrapper *legacy.CLIWrapper) (*http.Client, error) {
ts, err := NewLegacyCLITokenSource(ctx, wrapper)
if err != nil {
return nil, fmt.Errorf("oauth2: create token source: %w", err)
}

refresher, ok := ts.(refresher)
if !ok {
return nil, fmt.Errorf("token source does not implement refresher")
}
baseRT := http.DefaultTransport
if rt, ok := TransportFromContext(ctx); ok && rt != nil {
baseRT = rt
}
return &http.Client{
Transport: &Transport{
refresher: refresher,
base: &oauth2.Transport{
Source: ts,
Base: baseRT,
},
wrapper: wrapper,
logger: log.New(os.Stderr, "", 0),
},
}, nil
}
42 changes: 42 additions & 0 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package auth

import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)

// unsafeGetJWTExpiry parses a JWT without verifying its signature and returns its expiry time.
// WARNING: This is intentionally unsafe and must not be used for trust decisions.
func unsafeGetJWTExpiry(token string) (time.Time, error) {
if token == "" {
return time.Time{}, errors.New("jwt: empty token")
}
parts := strings.Split(token, ".")
if len(parts) < 2 {
return time.Time{}, fmt.Errorf("jwt: malformed token, expected 3 parts, got %d", len(parts))
}
payloadSeg := parts[1]

// Base64 URL decode without padding as per RFC 7515.
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSeg)
if err != nil {
return time.Time{}, fmt.Errorf("jwt: decode payload: %w", err)
}

var claims struct {
ExpiresAt *int64 `json:"exp,omitempty"`
}
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return time.Time{}, fmt.Errorf("jwt: unmarshal claims: %w", err)
}

if claims.ExpiresAt == nil {
return time.Time{}, errors.New("jwt: no expiry time found")
}

return time.Unix(*claims.ExpiresAt, 0), nil
}
Loading