Skip to content
Merged
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
4 changes: 2 additions & 2 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,9 @@ func isIPTarget(command, target string) bool {
return false
}

urlData, err := parseUrlData(target)
targetData, err := parseHTTPTarget(target)

return err == nil && net.ParseIP(urlData.Host) != nil
return err == nil && net.ParseIP(targetData.Host) != nil
}

func parseTargets(input string) ([]string, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func Test_UpdateContext_ComparisonValidatesEveryTargetIPVersion(t *testing.T) {

assert.ErrorIs(t, err, ErrTargetIPVersionNotAllowed)
assert.True(t, isIPTarget("http", "https://[2001:db8::1]/path"))
assert.True(t, isIPTarget("http", "http2://[2001:db8::1]/path"))
}

func Test_UpdateContext(t *testing.T) {
Expand Down
50 changes: 31 additions & 19 deletions cmd/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"net"
"net/url"
"slices"
"strconv"
Expand All @@ -22,6 +23,8 @@ func (r *Root) initHTTP(measurementFlags *pflag.FlagSet, localFlags *pflag.FlagS
Long: `The http command sends an HTTP request to a host and can perform a HEAD, GET, or OPTIONS operations, returning detailed performance statistics for each request. Use it to test and assess the performance and availability of your website, API, or other web services.
Note that GET responses are limited to 10KB, with anything beyond this cut by the API.

Targets may be hostnames, bare IPv4 or IPv6 addresses, or URLs using http://, https://, or http2://. IPv6 addresses inside URLs must be enclosed in brackets.

The CLI tool supports two formats:
1. Full URL: The tool automatically parses the scheme, host, port, domain, path, and query. For example:
http https://www.jsdelivr.com:443/package/npm/test?nav=stats
Expand Down Expand Up @@ -146,7 +149,7 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (*
Timeout: r.ctx.Timeout,
InProgressUpdates: !r.ctx.CIMode,
}
urlData, err := parseUrlData(target)
targetData, err := parseHTTPTarget(target)

if err != nil {
return nil, err
Expand All @@ -165,12 +168,12 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (*
method = "GET"
}

opts.Target = urlData.Host
opts.Target = targetData.Host
opts.Options = &globalping.MeasurementOptions{
Protocol: urlData.Protocol,
Protocol: targetData.Protocol,
Request: &globalping.RequestOptions{
Path: overrideOpt(urlData.Path, r.ctx.Path),
Query: overrideOpt(urlData.Query, r.ctx.Query),
Path: overrideOpt(targetData.Path, r.ctx.Path),
Query: overrideOpt(targetData.Query, r.ctx.Query),
Host: r.ctx.Host,
Headers: headers,
Method: method,
Expand All @@ -188,8 +191,8 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (*
switch {
case portFlag != nil && portFlag.Changed:
opts.Options.Port = r.ctx.Port
case urlData.HasPort:
opts.Options.Port = urlData.Port
case targetData.HasPort:
opts.Options.Port = targetData.Port
case opts.Options.Protocol == "HTTP":
opts.Options.Port = 80
default:
Expand All @@ -215,7 +218,7 @@ func parseHttpHeaders(headerStrings []string) (map[string]string, error) {
return h, nil
}

type UrlData struct {
type httpTargetData struct {
Protocol string
Path string
Query string
Expand All @@ -224,12 +227,17 @@ type UrlData struct {
HasPort bool
}

// parse url data from user text input
func parseUrlData(input string) (*UrlData, error) {
var urlData UrlData
// parseHTTPTarget parses a hostname, IP address, or URL into HTTP target options.
func parseHTTPTarget(input string) (*httpTargetData, error) {
// Preserve bare IP addresses before URL parsing can interpret an IPv6 segment as a port.
if net.ParseIP(input) != nil {
return &httpTargetData{Protocol: "HTTPS", Host: input}, nil
}

var targetData httpTargetData

// add url scheme if missing
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") && !strings.HasPrefix(input, "http2://") {
input = "https://" + input
}

Expand All @@ -240,11 +248,15 @@ func parseUrlData(input string) (*UrlData, error) {
return nil, errors.Wrapf(err, "failed to parse url input")
}

urlData.Protocol = strings.ToUpper(u.Scheme)
urlData.Path = u.Path
urlData.Query = u.RawQuery
if strings.Contains(u.Hostname(), ":") && !strings.HasPrefix(u.Host, "[") {
return nil, errors.New("IPv6 URL hosts must be enclosed in brackets")
}

targetData.Protocol = strings.ToUpper(u.Scheme)
targetData.Path = u.EscapedPath()
targetData.Query = u.RawQuery

urlData.Host = u.Hostname()
targetData.Host = u.Hostname()
p := u.Port()

if p != "" {
Expand All @@ -255,11 +267,11 @@ func parseUrlData(input string) (*UrlData, error) {
return nil, errors.Wrapf(err, "failed to parse url port number: %s", p)
}

urlData.Port = uint16(port)
urlData.HasPort = true
targetData.Port = uint16(port)
targetData.HasPort = true
}

return &urlData, nil
return &targetData, nil
}

// Helper functions to override flags in command
Expand Down
118 changes: 88 additions & 30 deletions cmd/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/jsdelivr/globalping-go"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

Expand Down Expand Up @@ -205,34 +206,34 @@ func Test_Execute_HTTP_Invalid_Protocol(t *testing.T) {
assert.Empty(t, items)
}

func Test_ParseUrlData(t *testing.T) {
urlData, err := parseUrlData("https://cdn.jsdelivr.net:8080/npm/react/?query=3")
func Test_ParseHTTPTarget(t *testing.T) {
targetData, err := parseHTTPTarget("https://cdn.jsdelivr.net:8080/npm/react/?query=3")
assert.NoError(t, err)
assert.Equal(t, "cdn.jsdelivr.net", urlData.Host)
assert.Equal(t, "/npm/react/", urlData.Path)
assert.Equal(t, "HTTPS", urlData.Protocol)
assert.Equal(t, uint16(8080), urlData.Port)
assert.Equal(t, "query=3", urlData.Query)
assert.Equal(t, "cdn.jsdelivr.net", targetData.Host)
assert.Equal(t, "/npm/react/", targetData.Path)
assert.Equal(t, "HTTPS", targetData.Protocol)
assert.Equal(t, uint16(8080), targetData.Port)
assert.Equal(t, "query=3", targetData.Query)
}

func Test_ParseUrlData_NoScheme(t *testing.T) {
urlData, err := parseUrlData("cdn.jsdelivr.net/npm/react/?query=3")
func Test_ParseHTTPTarget_NoScheme(t *testing.T) {
targetData, err := parseHTTPTarget("cdn.jsdelivr.net/npm/react/?query=3")
assert.NoError(t, err)
assert.Equal(t, "cdn.jsdelivr.net", urlData.Host)
assert.Equal(t, "/npm/react/", urlData.Path)
assert.Equal(t, "HTTPS", urlData.Protocol)
assert.Equal(t, uint16(0), urlData.Port)
assert.Equal(t, "query=3", urlData.Query)
assert.Equal(t, "cdn.jsdelivr.net", targetData.Host)
assert.Equal(t, "/npm/react/", targetData.Path)
assert.Equal(t, "HTTPS", targetData.Protocol)
assert.Equal(t, uint16(0), targetData.Port)
assert.Equal(t, "query=3", targetData.Query)
}

func Test_ParseUrlData_HostOnly(t *testing.T) {
urlData, err := parseUrlData("cdn.jsdelivr.net")
func Test_ParseHTTPTarget_HostOnly(t *testing.T) {
targetData, err := parseHTTPTarget("cdn.jsdelivr.net")
assert.NoError(t, err)
assert.Equal(t, "cdn.jsdelivr.net", urlData.Host)
assert.Equal(t, "", urlData.Path)
assert.Equal(t, "HTTPS", urlData.Protocol)
assert.Equal(t, uint16(0), urlData.Port)
assert.Equal(t, "", urlData.Query)
assert.Equal(t, "cdn.jsdelivr.net", targetData.Host)
assert.Equal(t, "", targetData.Path)
assert.Equal(t, "HTTPS", targetData.Protocol)
assert.Equal(t, uint16(0), targetData.Port)
assert.Equal(t, "", targetData.Query)
}

func Test_ParseHttpHeaders_None(t *testing.T) {
Expand Down Expand Up @@ -269,6 +270,63 @@ func Test_ParseHttpHeaders_Invalid(t *testing.T) {
assert.ErrorContains(t, err, "invalid header")
}

func Test_BuildHttpMeasurementRequest_TargetParsing(t *testing.T) {
for _, test := range []struct {
name string
target string
wantTarget string
wantProtocol string
wantPort uint16
wantPath string
wantQuery string
wantErr bool
}{
{name: "bare IPv6 decimal segment", target: "2606:4700:4700::1111", wantTarget: "2606:4700:4700::1111", wantProtocol: "HTTPS", wantPort: 443},
{name: "bare IPv6 hexadecimal segment", target: "2001:db8::abcd", wantTarget: "2001:db8::abcd", wantProtocol: "HTTPS", wantPort: 443},
{name: "HTTP2 default port", target: "http2://example.com/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/x", wantQuery: "y=1"},
{name: "HTTP2 custom port", target: "http2://example.com:8443/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 8443, wantPath: "/x", wantQuery: "y=1"},
{name: "escaped path delimiters", target: "https://example.com/a%2Fb%3Fc%23d?x=%2F%3F%23", wantTarget: "example.com", wantProtocol: "HTTPS", wantPort: 443, wantPath: "/a%2Fb%3Fc%23d", wantQuery: "x=%2F%3F%23"},
{name: "encoded commas and percent signs", target: "http2://example.com/a%2Cb%252C?x=%2C&y=%252C&z=a+b", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/a%2Cb%252C", wantQuery: "x=%2C&y=%252C&z=a+b"},
{name: "HTTP IPv6 URL requires brackets", target: "http://2606:4700:4700::1111/x", wantErr: true},
{name: "HTTPS IPv6 URL requires brackets", target: "https://2606:4700:4700::1111/x", wantErr: true},
{name: "HTTP2 IPv6 URL requires brackets", target: "http2://2606:4700:4700::1111/x", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
ctx := createDefaultContext()
ctx.Port = 443
root := &Root{ctx: ctx}

measurement, err := root.buildHttpMeasurementRequest(&cobra.Command{}, test.target)

if test.wantErr {
require.Error(t, err)
assert.Nil(t, measurement)

return
}

require.NoError(t, err)
expected := &globalping.MeasurementCreate{
Type: "http",
Target: test.wantTarget,
Limit: 1,
InProgressUpdates: true,
Options: &globalping.MeasurementOptions{
Protocol: test.wantProtocol,
Port: test.wantPort,
Request: &globalping.RequestOptions{
Path: test.wantPath,
Query: test.wantQuery,
Headers: map[string]string{},
},
},
}

assert.Equal(t, expected, measurement)
})
}
}

func Test_BuildHttpMeasurementRequest_Full(t *testing.T) {
ctx := createDefaultContext()
printer := view.NewPrinter(nil, nil, nil)
Expand Down Expand Up @@ -398,21 +456,21 @@ func Test_BuildHttpMeasurementRequest_ExplicitFlagsOverrideEachURL(t *testing.T)
ctx := createDefaultContext()
ctx.Protocol = "HTTP2"
ctx.Port = 9443
ctx.Path = "/override"
ctx.Query = "override=1"
ctx.Path = "/override%2F%252C"
ctx.Query = "override=%2C%252C"
root := NewRoot(view.NewPrinter(nil, nil, nil), ctx, nil, nil, nil, nil, nil)
cmd, _, err := root.Cmd.Find([]string{"http"})
assert.NoError(t, err)
assert.NoError(t, cmd.Flags().Set("protocol", "HTTP2"))
assert.NoError(t, cmd.Flags().Set("port", "9443"))
require.NoError(t, err)
require.NoError(t, cmd.Flags().Set("protocol", "HTTP2"))
require.NoError(t, cmd.Flags().Set("port", "9443"))

for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2"} {
for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2", "http2://three.example/a%2Fb?z=%2C%252C"} {
measurement, err := root.buildHttpMeasurementRequest(cmd, target)

assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, "HTTP2", measurement.Options.Protocol)
assert.Equal(t, uint16(9443), measurement.Options.Port)
assert.Equal(t, "/override", measurement.Options.Request.Path)
assert.Equal(t, "override=1", measurement.Options.Request.Query)
assert.Equal(t, "/override%2F%252C", measurement.Options.Request.Path)
assert.Equal(t, "override=%2C%252C", measurement.Options.Request.Query)
}
}
12 changes: 6 additions & 6 deletions cmd/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) {
targetArgument := "one.example,two.example"

if measurementType == "http" {
targetArgument = "http://one.example:8080/first?x=1,https://two.example:8443/second?y=2"
targetArgument = "http2://one.example:8080/a%2Fb%3Fc%23d?x=%2C%252C,https://two.example:8443/second%2C%252C?y=a%2Cb&z=%252C"
first.Target = "one.example"
first.Options = &globalping.MeasurementOptions{
Protocol: "HTTP",
Protocol: "HTTP2",
Port: 8080,
Request: &globalping.RequestOptions{
Path: "/first",
Query: "x=1",
Path: "/a%2Fb%3Fc%23d",
Query: "x=%2C%252C",
Headers: map[string]string{},
},
}
Expand All @@ -55,8 +55,8 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) {
Protocol: "HTTPS",
Port: 8443,
Request: &globalping.RequestOptions{
Path: "/second",
Query: "y=2",
Path: "/second%2C%252C",
Query: "y=a%2Cb&z=%252C",
Headers: map[string]string{},
},
}
Expand Down
Loading