From a89ae169730baa20c17a1c2579eb458a57077b90 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Mon, 26 Jan 2026 08:58:53 -0800 Subject: [PATCH] Improve and document scheme selection logic --- docs/USAGE.md | 23 ++++++++++++++++++++ internal/client/client.go | 20 +++++++++++++---- internal/client/client_test.go | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 internal/client/client_test.go diff --git a/docs/USAGE.md b/docs/USAGE.md index d646ea3..c79741b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -10,6 +10,29 @@ To make a GET request to a URL: fetch example.com ``` +### URL Schemes + +When no scheme is provided, `fetch` defaults to HTTPS: + +```sh +fetch example.com # Uses https://example.com +fetch 192.168.1.1:8080 # Uses https://192.168.1.1:8080 +``` + +Loopback addresses default to HTTP for local development convenience: + +```sh +fetch localhost:3000 # Uses http://localhost:3000 +fetch 127.0.0.1:8080 # Uses http://127.0.0.1:8080 +``` + +You can always specify the scheme explicitly: + +```sh +fetch http://example.com # Force HTTP +fetch https://localhost # Force HTTPS for localhost +``` + ## Authentication Options ### AWS Signature V4 diff --git a/internal/client/client.go b/internal/client/client.go index bf4ea44..702473d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -283,11 +283,10 @@ func (c *Client) NewRequest(ctx context.Context, cfg RequestConfig) (*http.Reque body = cfg.Multipart } - // If no scheme was provided, use various heuristics to choose between - // http and https. + // If no scheme was provided, default to HTTPS except for loopback + // addresses (localhost, 127.x.x.x, ::1) which default to HTTP. if cfg.URL.Scheme == "" { - host := cfg.URL.Hostname() - if !strings.Contains(host, ".") || net.ParseIP(host) != nil { + if isLoopback(cfg.URL.Hostname()) { cfg.URL.Scheme = "http" } else { cfg.URL.Scheme = "https" @@ -469,3 +468,16 @@ func (r *zstdReader) Close() error { r.Decoder.Close() return r.c.Close() } + +// isLoopback returns true if the host is a loopback address. +// This includes "localhost" and IP addresses in the loopback range +// (127.0.0.0/8 for IPv4, ::1 for IPv6). +func isLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..820a501 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,39 @@ +package client + +import "testing" + +func TestIsLoopback(t *testing.T) { + tests := []struct { + host string + want bool + }{ + // Loopback addresses (should return true) + {"localhost", true}, + {"LOCALHOST", true}, + {"Localhost", true}, + {"127.0.0.1", true}, + {"127.255.255.255", true}, + {"127.0.0.100", true}, + {"::1", true}, + + // Non-loopback addresses (should return false) + {"myserver", false}, + {"192.168.1.1", false}, + {"10.0.0.1", false}, + {"example.com", false}, + {"0.0.0.0", false}, + {"172.16.0.1", false}, + {"::2", false}, + {"2001:db8::1", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + got := isLoopback(tt.host) + if got != tt.want { + t.Errorf("isLoopback(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +}