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
82 changes: 56 additions & 26 deletions cmd/kurl/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
)
Expand Down Expand Up @@ -80,10 +81,11 @@ func applyEnvironment(opts *cliOptions) error {
}

// 1. Process URL
hasScheme := strings.HasPrefix(opts.url, "http://") ||
strings.HasPrefix(opts.url, "https://") ||
strings.HasPrefix(opts.url, "ws://") ||
strings.HasPrefix(opts.url, "wss://")
scheme, _, hasSeparator := strings.Cut(opts.url, "://")
hasScheme := hasSeparator && (strings.EqualFold(scheme, "http") ||
strings.EqualFold(scheme, "https") ||
strings.EqualFold(scheme, "ws") ||
strings.EqualFold(scheme, "wss"))

if !hasScheme {
opts.url = joinURL(envConfig.BaseURL, opts.url)
Expand All @@ -100,37 +102,65 @@ func joinURL(baseURL, path string) string {
return path
}
baseURL = strings.TrimSuffix(baseURL, "/")
path = strings.TrimPrefix(path, "/")
if path == "" {
return baseURL
}
return baseURL + "/" + path
fallback := baseURL + "/" + strings.TrimPrefix(path, "/")
base, err := url.Parse(baseURL)
if err != nil {
return fallback
}
relative, err := url.Parse(path)
if err != nil {
return fallback
}
// Join paths independently of query strings and fragments.
target := base.JoinPath(relative.EscapedPath())
if relative.RawQuery != "" || relative.ForceQuery {
target.RawQuery = relative.RawQuery
target.ForceQuery = relative.ForceQuery
}
if strings.Contains(path, "#") {
target.Fragment = relative.Fragment
target.RawFragment = relative.RawFragment
}
return target.String()
}

func mergeHeaders(profileHeaders []string, cliHeaders []string) []string {
result := append([]string(nil), profileHeaders...)

for _, cliHeader := range cliHeaders {
cliName, _, ok := strings.Cut(cliHeader, ":")
if !ok {
result = append(result, cliHeader)
nameOf := func(header string) (string, bool) {
name, _, ok := strings.Cut(header, ":")
return strings.ToLower(strings.TrimSpace(name)), ok
}
overrides := map[string][]string{}
for _, header := range cliHeaders {
if name, ok := nameOf(header); ok {
overrides[name] = append(overrides[name], header)
}
}
result := make([]string, 0, len(profileHeaders)+len(cliHeaders))
emitted := map[string]bool{}
for _, header := range profileHeaders {
name, ok := nameOf(header)
replacement, exists := overrides[name]
if !ok || !exists {
result = append(result, header)
continue
}
cliNameClean := strings.ToLower(strings.TrimSpace(cliName))

// Check if this header already exists in result
found := false
for idx, resHeader := range result {
resName, _, ok := strings.Cut(resHeader, ":")
if ok && strings.ToLower(strings.TrimSpace(resName)) == cliNameClean {
// Replace it!
result[idx] = cliHeader
found = true
break
}
if !emitted[name] {
result = append(result, replacement...)
emitted[name] = true
}
}
for _, header := range cliHeaders {
name, ok := nameOf(header)
if !ok {
result = append(result, header)
continue
}
if !found {
result = append(result, cliHeader)
if !emitted[name] {
result = append(result, overrides[name]...)
emitted[name] = true
}
}
return result
Expand Down
30 changes: 30 additions & 0 deletions cmd/kurl/env_scheme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestEnvironmentPreservesExplicitMixedCaseSchemes(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
if err := os.Mkdir(filepath.Join(home, ".kurl"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, ".kurl", "environments.json"), []byte(`{"test":{"base_url":"https://example.invalid/v1"}}`), 0600); err != nil {
t.Fatal(err)
}
for _, target := range []string{"HTTP://localhost/path", "HTTPS://localhost/path", "Ws://localhost/socket", "WSS://localhost/socket"} {
t.Run(target, func(t *testing.T) {
opts := cliOptions{env: "test", url: target}
if err := applyEnvironment(&opts); err != nil {
t.Fatal(err)
}
if opts.url != target {
t.Fatalf("URL = %q, want explicit URL %q", opts.url, target)
}
})
}
}
19 changes: 19 additions & 0 deletions cmd/kurl/environment_headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package main

import (
"reflect"
"testing"
)

func TestMergeHeadersOverridesAllProfileValues(t *testing.T) {
profile := []string{"Accept: old-one", "X-Other: keep", "accept: old-two"}
cli := []string{"ACCEPT: new-one", "Accept: new-two"}
got := mergeHeaders(profile, cli)
want := []string{"ACCEPT: new-one", "Accept: new-two", "X-Other: keep"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
if profile[0] != "Accept: old-one" {
t.Fatal("mutated profile")
}
}
18 changes: 18 additions & 0 deletions cmd/kurl/environment_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import "testing"

func TestJoinURLKeepsQueryAndFragmentOutOfPath(t *testing.T) {
for _, tc := range []struct{ base, path, want string }{
{"https://api.test/v1?tenant=one", "users", "https://api.test/v1/users?tenant=one"},
{"https://api.test/v1?tenant=one", "users?limit=2", "https://api.test/v1/users?limit=2"},
{"https://api.test/v1#old", "users#new", "https://api.test/v1/users#new"},
{"https://api.test/v1", "?limit=2", "https://api.test/v1?limit=2"},
{"https://api.test/v1?old=1", "users?", "https://api.test/v1/users?"},
{"https://api.test/v1", "a%2Fb", "https://api.test/v1/a%2Fb"},
} {
if got := joinURL(tc.base, tc.path); got != tc.want {
t.Errorf("joinURL(%q,%q)=%q want %q", tc.base, tc.path, got, tc.want)
}
}
}
Loading