Skip to content

Commit

Permalink
contrib/net/http: support for path params from go 1.22 proposal
Browse files Browse the repository at this point in the history
Signed-off-by: Eliott Bouhana <[email protected]>
  • Loading branch information
eliottness committed Jan 10, 2025
1 parent 27624f6 commit 9727b94
Show file tree
Hide file tree
Showing 3 changed files with 293 additions and 22 deletions.
32 changes: 10 additions & 22 deletions contrib/net/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package http // import "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http"

import (
"net/http"
"strings"

"gopkg.in/DataDog/dd-trace-go.v1/contrib/internal/httptrace"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace"
Expand Down Expand Up @@ -60,26 +59,14 @@ func (mux *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
copy(so, mux.cfg.spanOpts)
so = append(so, httptrace.HeaderTagsFromRequest(r, mux.cfg.headerTags))
TraceAndServe(mux.ServeMux, w, r, &ServeConfig{
Service: mux.cfg.serviceName,
Resource: resource,
SpanOpts: so,
Route: route,
Service: mux.cfg.serviceName,
Resource: resource,
SpanOpts: so,
Route: route,
RouteParams: patternValues(r),
})
}

// patternRoute returns the route part of a go1.22 style ServeMux pattern. I.e.
// it returns "/foo" for the pattern "/foo" as well as the pattern "GET /foo".
func patternRoute(s string) string {
// Support go1.22 serve mux patterns: [METHOD ][HOST]/[PATH]
// Consider any text before a space or tab to be the method of the pattern.
// See net/http.parsePattern and the link below for more information.
// https://pkg.go.dev/net/http#hdr-Patterns
if i := strings.IndexAny(s, " \t"); i > 0 && len(s) >= i+1 {
return strings.TrimLeft(s[i+1:], " \t")
}
return s
}

// WrapHandler wraps an http.Handler with tracing using the given service and resource.
// If the WithResourceNamer option is provided as part of opts, it will take precedence over the resource argument.
func WrapHandler(h http.Handler, service, resource string, opts ...Option) http.Handler {
Expand All @@ -104,10 +91,11 @@ func WrapHandler(h http.Handler, service, resource string, opts ...Option) http.
copy(so, cfg.spanOpts)
so = append(so, httptrace.HeaderTagsFromRequest(req, cfg.headerTags))
TraceAndServe(h, w, req, &ServeConfig{
Service: service,
Resource: resc,
FinishOpts: cfg.finishOpts,
SpanOpts: so,
Service: service,
Resource: resc,
FinishOpts: cfg.finishOpts,
SpanOpts: so,
RouteParams: patternValues(req),
})
})
}
150 changes: 150 additions & 0 deletions contrib/net/http/pattern.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025 Datadog, Inc.

package http

import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"unicode"

"gopkg.in/DataDog/dd-trace-go.v1/internal/log"
)

// patternRoute returns the route part of a go1.22 style ServeMux pattern. I.e.
// it returns "/foo" for the pattern "/foo" as well as the pattern "GET /foo".
func patternRoute(s string) string {
// Support go1.22 serve mux patterns: [METHOD ][HOST]/[PATH]
// Consider any text before a space or tab to be the method of the pattern.
// See net/http.parsePattern and the link below for more information.
// https://pkg.go.dev/net/http#hdr-Patterns-ServeMux
if i := strings.IndexAny(s, " \t"); i > 0 && len(s) >= i+1 {
return strings.TrimLeft(s[i+1:], " \t")
}
return s
}

var patternSegmentsCache sync.Map // map[string][]string

// patternValues return the path parameter values and names from the request.
func patternValues(r *http.Request) map[string]string {
if r.Pattern == "" { // using <=1.21 serve mux behavior, aborting

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-contrib

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 36 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-contrib

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)
return nil
}
names := getPatternNames(r.Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-core

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-contrib

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)

Check failure on line 39 in contrib/net/http/pattern.go

View workflow job for this annotation

GitHub Actions / PR Unit and Integration Tests / test-contrib

r.Pattern undefined (type *"net/http".Request has no field or method Pattern)
res := make(map[string]string, len(names))
for _, name := range names {
res[name] = r.PathValue(name)
}
return res
}

func getPatternNames(pattern string) []string {
if v, ok := patternSegmentsCache.Load(pattern); ok {
return v.([]string)
}

segments, err := patternNames(pattern)
if err != nil {
log.Debug("contrib/net/http: failed to parse mux path pattern %q: %v", pattern, err)
return nil
}

v, _ := patternSegmentsCache.LoadOrStore(pattern, segments)
return v.([]string)
}

// patternNames returns the names of the wildcards in the pattern.
// Based on https://cs.opensource.google/go/go/+/refs/tags/go1.23.4:src/net/http/pattern.go;l=84
func patternNames(s string) ([]string, error) {
if len(s) == 0 {
return nil, errors.New("empty pattern")
}
method, rest, found := s, "", false
if i := strings.IndexAny(s, " \t"); i >= 0 {
method, rest, found = s[:i], strings.TrimLeft(s[i+1:], " \t"), true
}
if !found {
rest = method
method = ""
}

i := strings.IndexByte(rest, '/')
if i < 0 {
return nil, errors.New("host/path missing /")
}
host := rest[:i]
rest = rest[i:]
if j := strings.IndexByte(host, '{'); j >= 0 {
return nil, errors.New("host contains '{' (missing initial '/'?)")
}

// At this point, rest is the path.
var names []string
seenNames := make(map[string]bool)
for len(rest) > 0 {
// Invariant: rest[0] == '/'.
rest = rest[1:]
if len(rest) == 0 {
// Trailing slash.
break
}
i := strings.IndexByte(rest, '/')
if i < 0 {
i = len(rest)
}
var seg string
seg, rest = rest[:i], rest[i:]
if i := strings.IndexByte(seg, '{'); i >= 0 {
// Wildcard.
if i != 0 {
return nil, errors.New("bad wildcard segment (must start with '{')")
}
if seg[len(seg)-1] != '}' {
return nil, errors.New("bad wildcard segment (must end with '}')")
}
name := seg[1 : len(seg)-1]
if name == "$" {
if len(rest) != 0 {
return nil, errors.New("{$} not at end")
}
break
}
name, multi := strings.CutSuffix(name, "...")
if multi && len(rest) != 0 {
return nil, errors.New("{...} wildcard not at end")
}
if name == "" {
return nil, errors.New("empty wildcard name")
}
if !isValidWildcardName(name) {
return nil, fmt.Errorf("bad wildcard name %q", name)
}
if seenNames[name] {
return nil, fmt.Errorf("duplicate wildcard name %q", name)
}
seenNames[name] = true
names = append(names, name)
}
}

return names, nil
}

func isValidWildcardName(s string) bool {
if s == "" {
return false
}
// Valid Go identifier.
for i, c := range s {
if !unicode.IsLetter(c) && c != '_' && (i == 0 || !unicode.IsDigit(c)) {
return false
}
}
return true
}
133 changes: 133 additions & 0 deletions contrib/net/http/pattern_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025 Datadog, Inc.

package http

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestPathParams(t *testing.T) {
for _, tt := range []struct {
name string
pattern string
url string
expected map[string]string
}{
{
name: "simple",
pattern: "/foo/{bar}",
url: "/foo/123",
expected: map[string]string{"bar": "123"},
},
{
name: "multiple",
pattern: "/foo/{bar}/{baz}",
url: "/foo/123/456",
expected: map[string]string{"bar": "123", "baz": "456"},
},
{
name: "nested",
pattern: "/foo/{bar}/baz/{qux}",
url: "/foo/123/baz/456",
expected: map[string]string{"bar": "123", "qux": "456"},
},
{
name: "empty",
pattern: "/foo/{bar}",
url: "/foo/",
expected: map[string]string{"bar": ""},
},
{
name: "http method",
pattern: "GET /foo/{bar}",
url: "/foo/123",
expected: map[string]string{"bar": "123"},
},
{
name: "host",
pattern: "example.com/foo/{bar}",
url: "http://example.com/foo/123",
expected: map[string]string{"bar": "123"},
},
{
name: "host and method",
pattern: "GET example.com/foo/{bar}",
url: "http://example.com/foo/123",
expected: map[string]string{"bar": "123"},
},
} {
t.Run(tt.name, func(t *testing.T) {
mux := NewServeMux()
mux.HandleFunc(tt.pattern, func(_ http.ResponseWriter, r *http.Request) {
params := patternValues(r)
assert.Equal(t, tt.expected, params)
})

r := httptest.NewRequest("GET", tt.url, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
})
}
}

func TestPatternNames(t *testing.T) {
tests := []struct {
pattern string
expected []string
err bool
}{
{"/foo/{bar}", []string{"bar"}, false},
{"/foo/{bar}/{baz}", []string{"bar", "baz"}, false},
{"/foo/{bar}/{bar}", nil, true},
{"/foo/{bar}...", nil, true},
{"/foo/{bar}.../baz", nil, true},
{"/foo/{bar}/{baz}...", nil, true},
{"/foo/{bar", nil, true},
{"/foo/{bar{baz}}", nil, true},
{"/foo/{bar!}", nil, true},
{"/foo/{}", nil, true},
{"{}", nil, true},
{"GET /foo/{bar}", []string{"bar"}, false},
{"POST /foo/{bar}/{baz}", []string{"bar", "baz"}, false},
{"PUT /foo/{bar}/{bar}", nil, true},
{"DELETE /foo/{bar}...", nil, true},
{"PATCH /foo/{bar}.../baz", nil, true},
{"OPTIONS /foo/{bar}/{baz}...", nil, true},
{"GET /foo/{bar", nil, true},
{"POST /foo/{bar{baz}}", nil, true},
{"PUT /foo/{bar!}", nil, true},
{"DELETE /foo/{}", nil, true},
{"OPTIONS {}", nil, true},
{"GET example.com/foo/{bar}", []string{"bar"}, false},
{"POST example.com/foo/{bar}/{baz}", []string{"bar", "baz"}, false},
{"PUT example.com/foo/{bar}/{bar}", nil, true},
{"DELETE example.com/foo/{bar}...", nil, true},
{"PATCH example.com/foo/{bar}.../baz", nil, true},
{"OPTIONS example.com/foo/{bar}/{baz}...", nil, true},
{"GET example.com/foo/{bar", nil, true},
{"POST example.com/foo/{bar{baz}}", nil, true},
{"PUT example.com/foo/{bar!}", nil, true},
{"DELETE example.com/foo/{}", nil, true},
{"OPTIONS example.com/{}", nil, true},
}

for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
names, err := patternNames(tt.pattern)
if tt.err {
assert.Error(t, err)
assert.Nil(t, names)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, names)
}
})
}
}

0 comments on commit 9727b94

Please sign in to comment.