Skip to content

RFC 3986 URL Resolution: Understanding the Problem and Solution

Relay's Dual-Path Strategy for APIs and Web Standards

Table of Contents

  1. The Problem
  2. Why It Matters
  3. The Solution
  4. Normalization Strategies
  5. Real-World Scenarios
  6. Performance Analysis
  7. Best Practices
  8. Migration Guide
  9. FAQ
  10. 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:

// base: https://api.example.com
// path: /users
// result: https://api.example.com/users

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:

fullPath := relay.NewPathBuilder("/api/v1").Add("users").String()
// "/api/v1/users"

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.

See also