RFC 3986 URL Resolution: Understanding the Problem and Solution¶
Relay's Dual-Path Strategy for APIs and Web Standards
Table of Contents¶
- The Problem
- Why It Matters
- The Solution
- Normalization Strategies
- Real-World Scenarios
- Performance Analysis
- Best Practices
- Migration Guide
- FAQ
- Technical Deep Dive
The Problem¶
RFC 3986's url.ResolveReference() treats / in a path as an absolute reference, replacing the entire base path. This is correct for document servers but breaks API endpoints that have path components in the base URL.
Example¶
base := "https://api.example.com/v1"
path := "/Products"
// Expected: https://api.example.com/v1/Products
// Actual: https://api.example.com/Products // lost /v1!
Why This Happens¶
RFC 3986 Section 5.3 defines path resolution as:
If the reference path begins with "/", it is a reference to an absolute path and we use it as-is.
This works for relative paths between documents but breaks for API endpoints:
- Document servers: base
https://example.com/docs/+ path/manual- replaces docs path correctly - API servers: base
https://api.com/v1+ path/users- should append to/v1, not replace it
Why It Matters¶
API Version Loss¶
client := relay.New(relay.Config{BaseURL: "https://api.example.com/v1"})
resp, err := client.R().GET(ctx, "/users")
// Without fix: GET /users (lost /v1)
// With fix: GET /v1/users
Microservice Paths¶
client := relay.New(relay.Config{BaseURL: "https://gateway.local/payment-service"})
resp, err := client.R().GET(ctx, "/checkout")
// Without fix: GET /checkout (lost /payment-service)
// With fix: GET /payment-service/checkout
OData Endpoints¶
client := relay.New(relay.Config{BaseURL: "https://data.example.com/odata/v4"})
resp, err := client.R().GET(ctx, "/Users")
// Without fix: GET /Users (lost /odata/v4)
// With fix: GET /odata/v4/Users
The Solution¶
relay uses a dual-path strategy: it detects whether the base URL looks like an API endpoint and applies the appropriate resolution strategy automatically.
Smart Detection¶
relay recognises common API base URL patterns:
- Version prefixes:
/v1,/v2,/v3,/v4,/v5 - Service patterns:
/api,/odata,/rest,/graphql,/soap,/sap,/data,/service,/services - Multi-segment paths: any path with two or more slashes
Strategy Selection¶
RFC 3986 resolution - used when the base URL is host-only:
Safe path normalization - used when an API base path is detected:
// base: https://api.example.com/v1
// path: /users
// auto-detects /v1, uses safe normalization
// result: https://api.example.com/v1/users
Normalization Strategies¶
relay exposes three normalization modes for when you need explicit control:
| Mode | Behaviour |
|---|---|
NormalizationAuto (default) | Smart detection: API bases use safe normalization, host-only bases use RFC 3986 |
NormalizationRFC3986 | Always use url.ResolveReference() - correct for document/web servers |
NormalizationAPI | Always use safe path appending - correct for versioned/prefixed APIs |
// Force API mode for an unusual base path that auto-detection might miss
client := relay.New(relay.Config{
BaseURL: "https://unusual.host/custom-prefix",
URLNormalization: relay.NormalizationAPI,
})
Auto-normalization of trailing slashes¶
relay automatically adds a trailing slash to API base URLs to prevent double-slash issues:
// Before: "https://api.example.com/v1"
// After: "https://api.example.com/v1/"
// Benefit: consistent path joining regardless of whether the caller
// writes "/users" or "users"
Real-World Scenarios¶
Versioned REST API¶
client := relay.New(relay.Config{BaseURL: "https://api.stripe.com/v1"})
// Auto-detects /v1 pattern - uses safe normalization
resp, err := client.R().GET(ctx, "/customers")
// GET https://api.stripe.com/v1/customers
OData Service¶
client := relay.New(relay.Config{BaseURL: "https://data.example.com/odata/v4"})
// Auto-detects /odata pattern
resp, err := client.R().GET(ctx, "/Products")
// GET https://data.example.com/odata/v4/Products
Microservice Mesh¶
client := relay.New(relay.Config{BaseURL: "https://kubernetes.local/payment-service"})
// Detects multi-segment path
resp, err := client.R().GET(ctx, "/checkout")
// GET https://kubernetes.local/payment-service/checkout
CDN With Base Path¶
client := relay.New(relay.Config{BaseURL: "https://cdn.example.com/assets/v2"})
// Detects /assets/v2 (multi-segment + version)
resp, err := client.R().GET(ctx, "/images/logo.png")
// GET https://cdn.example.com/assets/v2/images/logo.png
Performance Analysis¶
Both resolution strategies have equivalent performance at the nanosecond scale:
RFC 3986 path strategy: ~350 ns/op (url.ResolveReference)
Safe string building: ~350 ns/op (strings.Builder with pre-sized capacity)
Smart detection adds negligible overhead - it performs direct string prefix comparisons with zero allocations.
Allocation profile¶
| Operation | Allocations |
|---|---|
isAPIBase() detection | 0 |
| URL building (safe normalization) | 1 (pre-sized strings.Builder) |
| Per-request overhead vs standard HTTP | < 1% |
Best Practices¶
Use explicit trailing slashes on base URLs¶
// Fine - relay auto-normalizes
client := relay.New(relay.Config{BaseURL: "https://api.example.com/v1"})
// Also fine - explicit is clearer in team codebases
client := relay.New(relay.Config{BaseURL: "https://api.example.com/v1/"})
Prefer paths without leading slashes for clarity¶
// Works - but the leading slash can be confusing when the base already has a path
resp, err := client.R().GET(ctx, "/users")
// Clearer intent: this is relative to the base
resp, err := client.R().GET(ctx, "users")
Use PathBuilder for dynamic path construction¶
// Error-prone: manual string concatenation
path := "/api/v1/users/" + userID + "/posts"
// Clearer and safe: PathBuilder handles slashes
path := relay.NewPathBuilder("users").Add(userID).Add("posts").String()
// "users/{id}/posts" - relative to base URL
Configure explicitly when auto-detection might not work¶
// Unusual pattern that auto-detection might not recognise
client := relay.New(relay.Config{
BaseURL: "https://internal.corp/svc-payments-prod",
URLNormalization: relay.NormalizationAPI,
})
Migration Guide¶
From manual URL construction¶
Before:
// Manually ensuring the path was correct
base := "https://api.example.com/v1/"
resp, err := http.Get(base + "users")
After:
client := relay.New(relay.Config{BaseURL: "https://api.example.com/v1"})
resp, err := client.R().GET(ctx, "/users")
// relay handles path joining correctly
From manual slash juggling¶
Before:
basePath := "/api/v1"
path := "users"
fullPath := strings.TrimRight(basePath, "/") + "/" + strings.TrimLeft(path, "/")
After:
FAQ¶
Will this break my existing code?
No. Smart detection is 100% backward compatible. All existing code continues to work without changes.
What if I have a host-only URL like https://example.com?
Smart detection recognises host-only URLs and uses RFC 3986 resolution, which is correct in this case.
What if my API has an unusual path like /svc/api/v1?
Smart detection recognises multi-segment paths (2+ slashes). If detection still fails for an unusual pattern, use explicit configuration:
client := relay.New(relay.Config{
BaseURL: "https://example.com/svc/api/v1",
URLNormalization: relay.NormalizationAPI,
})
How can I debug URL resolution without making a real HTTP call?
Use relay.ResolveURL():
resolved, strategy := relay.ResolveURL(config, "/users")
fmt.Printf("URL: %s, strategy: %s\n", resolved, strategy)
Does this affect streaming, WebSocket, or batch requests?
No. URL resolution behaviour is identical for all request types.
What about query parameters and fragments?
Auto-normalization only adds trailing slashes to the domain + path component. Query parameters and fragments are preserved correctly.
Technical Deep Dive¶
Detection algorithm¶
func isAPIBase(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
path := u.Path
// Common API service prefixes
for _, prefix := range []string{
"/api", "/v1", "/v2", "/v3", "/v4", "/v5",
"/odata", "/rest", "/graphql", "/soap",
"/sap", "/data", "/service", "/services",
} {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Multi-segment paths (2+ slashes beyond the root) are treated as API bases
return strings.Count(path, "/") > 1
}
Strategy selection¶
func (r *Request) resolveURL(path string) (string, error) {
cfg := r.client.config
switch cfg.URLNormalization {
case NormalizationRFC3986:
return resolveRFC3986(cfg.BaseURL, path)
case NormalizationAPI:
return resolveAPISafe(cfg.BaseURL, path)
default: // NormalizationAuto
if isAPIBase(cfg.BaseURL) {
return resolveAPISafe(cfg.BaseURL, path)
}
return resolveRFC3986(cfg.BaseURL, path)
}
}
Zero-allocation detection¶
The detection loop uses direct strings.HasPrefix comparisons rather than building a slice on each call, keeping allocations at zero for this hot path.