Skip to content
Merged
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
4 changes: 0 additions & 4 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ Copyright (c) 2013 Dario Castañé. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.
License - https://github.com/darccio/mergo/blob/master/LICENSE

gorilla/mux - https://github.com/gorilla/mux
Copyright (c) 2023 The Gorilla Authors. All rights reserved.
License - https://github.com/gorilla/mux/blob/main/LICENSE

palantir/pkg - https://github.com/palantir/pkg
Copyright (c) 2016, Palantir Technologies, Inc.
License - https://github.com/palantir/pkg/blob/master/LICENSE
Expand Down
2 changes: 1 addition & 1 deletion acceptance/bundle/invariant/test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ Pattern = "POST /api/2.0/sql/statements/"
Response.Body = '{"status": {"state": "SUCCEEDED"}, "manifest": {"schema": {"columns": []}}}'

[[Server]]
Pattern = "DELETE /api/2.1/unity-catalog/tables/{name}"
Pattern = "DELETE /api/2.1/unity-catalog/tables/{full_name}"
Response.Body = '{"status": "OK"}'
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ Pattern = "POST /api/2.0/sql/statements/"
Response.Body = '{"status": {"state": "SUCCEEDED"}, "manifest": {"schema": {"columns": []}}}'

[[Server]]
Pattern = "DELETE /api/2.1/unity-catalog/tables/{name}"
Pattern = "DELETE /api/2.1/unity-catalog/tables/{full_name}"
Response.Body = '{"status": "OK"}'
7 changes: 4 additions & 3 deletions acceptance/internal/prepare_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ func startLocalServer(t *testing.T,
killCountersMu := &sync.Mutex{}

for ind := range stubs {
// We want later stubs takes precedence, because then leaf configs take precedence over parent directory configs
// In gorilla/mux earlier handlers take precedence, so we need to reverse the order
// Later stubs take precedence over earlier ones (leaf configs override parent configs).
// The first handler registered for a given pattern wins, so we reverse the order.
stub := stubs[len(stubs)-1-ind]
require.NotEmpty(t, stub.Pattern)
items := strings.Split(stub.Pattern, " ")
Expand Down Expand Up @@ -226,7 +226,8 @@ func startLocalServer(t *testing.T,
})
}

// The earliest handlers take precedence, add default handlers last
// The first handler registered for a given pattern wins, so default
// handlers registered last serve as fallbacks.
testserver.AddDefaultHandlers(s)
return s.URL
}
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ require (
github.com/databricks/databricks-sdk-go v0.128.0 // Apache-2.0
github.com/google/jsonschema-go v0.4.3 // MIT
github.com/google/uuid v1.6.0 // BSD-3-Clause
github.com/gorilla/mux v1.8.1 // BSD-3-Clause
github.com/gorilla/websocket v1.5.3 // BSD-2-Clause
github.com/hashicorp/go-version v1.9.0 // MPL-2.0
github.com/hashicorp/hc-install v0.9.3 // MPL-2.0
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
Expand Down
14 changes: 7 additions & 7 deletions libs/testserver/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func AddDefaultHandlers(server *Server) {
return ""
})

server.Handle("POST", "/api/2.0/workspace-files/import-file/{path:.*}", func(req Request) any {
server.Handle("POST", "/api/2.0/workspace-files/import-file/{path...}", func(req Request) any {
path := req.Vars["path"]
overwrite := req.URL.Query().Get("overwrite") == "true"
return req.Workspace.WorkspaceFilesImportFile(path, req.Body, overwrite)
Expand Down Expand Up @@ -145,12 +145,12 @@ func AddDefaultHandlers(server *Server) {
return req.Workspace.WorkspaceFilesImportFile(request.Path, decoded, request.Overwrite)
})

server.Handle("GET", "/api/2.0/workspace-files/{path:.*}", func(req Request) any {
server.Handle("GET", "/api/2.0/workspace-files/{path...}", func(req Request) any {
path := req.Vars["path"]
return req.Workspace.WorkspaceFilesExportFile(path)
})

server.Handle("HEAD", "/api/2.0/fs/directories/{path:.*}", func(req Request) any {
server.Handle("HEAD", "/api/2.0/fs/directories/{path...}", func(req Request) any {
dirPath := req.Vars["path"]
if !strings.HasPrefix(dirPath, "/") {
dirPath = "/" + dirPath
Expand All @@ -165,15 +165,15 @@ func AddDefaultHandlers(server *Server) {
return Response{StatusCode: 404}
})

server.Handle("HEAD", "/api/2.0/fs/files/{path:.*}", func(req Request) any {
server.Handle("HEAD", "/api/2.0/fs/files/{path...}", func(req Request) any {
path := req.Vars["path"]
if req.Workspace.FileExists(path) {
return Response{StatusCode: 200}
}
return Response{StatusCode: 404}
})

server.Handle("PUT", "/api/2.0/fs/directories/{path:.*}", func(req Request) any {
server.Handle("PUT", "/api/2.0/fs/directories/{path...}", func(req Request) any {
dirPath := req.Vars["path"]
if !strings.HasPrefix(dirPath, "/") {
dirPath = "/" + dirPath
Expand All @@ -194,13 +194,13 @@ func AddDefaultHandlers(server *Server) {
return Response{}
})

server.Handle("PUT", "/api/2.0/fs/files/{path:.*}", func(req Request) any {
server.Handle("PUT", "/api/2.0/fs/files/{path...}", func(req Request) any {
path := req.Vars["path"]
overwrite := req.URL.Query().Get("overwrite") == "true"
return req.Workspace.WorkspaceFilesImportFile(path, req.Body, overwrite)
})

server.Handle("GET", "/api/2.0/fs/files/{path:.*}", func(req Request) any {
server.Handle("GET", "/api/2.0/fs/files/{path...}", func(req Request) any {
path := req.Vars["path"]
data := req.Workspace.WorkspaceFilesExportFile(path)
if data == nil {
Expand Down
124 changes: 124 additions & 0 deletions libs/testserver/router.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package testserver

import (
"net/http"
"strings"
)

// HandlerFunc is the test-server handler signature.
type HandlerFunc func(req Request) any

// Router maps method+path to a HandlerFunc. Wildcards use Go 1.22 ServeMux
// placeholder syntax ({name} or {name...}).
//
// # Why a custom router
//
// Go 1.22 added method matching ("GET /path") and {name}/{name...}
// placeholders to http.ServeMux, covering most of what we previously used
// gorilla/mux for. But two ServeMux behaviors make it inconvenient to use
// directly in the test server:
//
// - Exact and wildcard patterns conflict when they cover the same
// request under different methods. ServeMux treats `GET /x` as
// matching both GET and HEAD, so it overlaps with `HEAD /{path...}`
// and panics at registration. Test fixtures register both kinds of
// routes side by side, so we keep exact paths in our own map and
// only delegate wildcards to ServeMux. Exact lookup runs first;
// misses fall through to ServeMux, which also lets a wildcard
// handler serve methods that the exact registration doesn't cover.
//
// - ServeMux panics on duplicate pattern registration. Router silently
// drops the later registration — first wins. Two callers rely on this:
// AddDefaultHandlers (libs/testserver/handlers.go) installs fallback
// handlers that any test stub for the same pattern can override, and
// startLocalServer (acceptance/internal/prepare_server.go) iterates
// test.toml stubs in reverse so leaf-directory configs register first
// and win over inherited parent stubs.
//
// Router also clears req.URL.RawPath before dispatch so percent-encoded
// slashes (%2F) match literal slashes in patterns; workspace file paths
// in tests routinely contain encoded slashes.
type Router struct {
mux *http.ServeMux
exact map[string]map[string]HandlerFunc
wildcard map[string]bool

// Dispatch is invoked when a route matches. vars holds the path values for
// wildcard routes and is nil for exact routes.
Dispatch func(w http.ResponseWriter, r *http.Request, h HandlerFunc, vars map[string]string)
// NotFound is invoked when no route matches.
NotFound http.HandlerFunc
}

func NewRouter() *Router {
r := &Router{
mux: http.NewServeMux(),
exact: map[string]map[string]HandlerFunc{},
wildcard: map[string]bool{},
}
r.mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if r.NotFound != nil {
r.NotFound(w, req)
}
})
return r
}

// Handle registers a handler for method+path. First registration wins;
// duplicate (method, path) registrations are ignored.
func (r *Router) Handle(method, path string, handler HandlerFunc) {
if !strings.Contains(path, "{") {
if r.exact[path] == nil {
r.exact[path] = map[string]HandlerFunc{}
}
if _, ok := r.exact[path][method]; !ok {
r.exact[path][method] = handler
}
return
}
pattern := method + " " + path
if r.wildcard[pattern] {
return
}
r.wildcard[pattern] = true
names := wildcardNames(path)
r.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
vars := make(map[string]string, len(names))
for _, name := range names {
vars[name] = req.PathValue(name)
}
if r.Dispatch != nil {
r.Dispatch(w, req, handler, vars)
}
})
}

// ServeHTTP routes a request to the registered handler, falling back to
// NotFound if no route matches.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Force ServeMux to match against the decoded path; see the type doc.
req.URL.RawPath = ""
if methods, ok := r.exact[req.URL.Path]; ok {
if h, ok := methods[req.Method]; ok {
if r.Dispatch != nil {
r.Dispatch(w, req, h, nil)
}
return
}
}
r.mux.ServeHTTP(w, req)
}

// wildcardNames extracts wildcard parameter names from a path pattern,
// e.g. "/api/{id}/files/{path...}" returns ["id", "path"].
func wildcardNames(path string) []string {
var names []string
for part := range strings.SplitSeq(path, "/") {
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
name := part[1 : len(part)-1]
name = strings.TrimSuffix(name, "...")
names = append(names, name)
}
}
return names
}
137 changes: 137 additions & 0 deletions libs/testserver/router_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package testserver_test

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

"github.com/databricks/cli/libs/testserver"
"github.com/stretchr/testify/assert"
)

type capture struct {
handler string
vars map[string]string
notFound bool
}

func newRouter(t *testing.T) (*testserver.Router, *capture) {
t.Helper()
c := &capture{}
r := testserver.NewRouter()
r.Dispatch = func(w http.ResponseWriter, req *http.Request, h testserver.HandlerFunc, vars map[string]string) {
c.vars = vars
c.handler = h(testserver.Request{}).(string)
}
r.NotFound = func(w http.ResponseWriter, req *http.Request) {
c.notFound = true
}
return r, c
}

func handlerNamed(name string) testserver.HandlerFunc {
return func(req testserver.Request) any { return name }
}

func TestRouterExactMatch(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/foo", handlerNamed("foo-get"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/foo", nil))
assert.Equal(t, "foo-get", c.handler)
assert.Nil(t, c.vars)
}

func TestRouterWildcardMatch(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/items/{id}", handlerNamed("item-get"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/items/42", nil))
assert.Equal(t, "item-get", c.handler)
assert.Equal(t, map[string]string{"id": "42"}, c.vars)
}

func TestRouterCatchAllWildcard(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/files/{path...}", handlerNamed("files-get"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/files/a/b/c", nil))
assert.Equal(t, "files-get", c.handler)
assert.Equal(t, map[string]string{"path": "a/b/c"}, c.vars)
}

func TestRouterMultipleWildcards(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/items/{id}/files/{path...}", handlerNamed("nested"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/items/42/files/a/b", nil))
assert.Equal(t, "nested", c.handler)
assert.Equal(t, map[string]string{"id": "42", "path": "a/b"}, c.vars)
}

func TestRouterExactBeforeWildcard(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/foo", handlerNamed("exact"))
r.Handle("HEAD", "/{path...}", handlerNamed("wildcard-head"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/foo", nil))
assert.Equal(t, "exact", c.handler)

c.handler = ""
r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/foo", nil))
assert.Equal(t, "wildcard-head", c.handler)
}

func TestRouterFirstRegistrationWins(t *testing.T) {
t.Run("exact", func(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/foo", handlerNamed("first"))
r.Handle("GET", "/foo", handlerNamed("second"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/foo", nil))
assert.Equal(t, "first", c.handler)
})

t.Run("wildcard", func(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/items/{id}", handlerNamed("first"))
r.Handle("GET", "/items/{id}", handlerNamed("second"))

r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/items/42", nil))
assert.Equal(t, "first", c.handler)
})
}

func TestRouterNotFound(t *testing.T) {
r, c := newRouter(t)
r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/missing", nil))
assert.True(t, c.notFound)
}

func TestRouterMethodNotAllowed(t *testing.T) {
t.Run("exact", func(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/foo", handlerNamed("foo-get"))
r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/foo", nil))
assert.True(t, c.notFound, "wrong method on exact path should hit NotFound")
assert.Empty(t, c.handler)
})

t.Run("wildcard", func(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/items/{id}", handlerNamed("item-get"))
r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/items/42", nil))
assert.True(t, c.notFound, "wrong method on wildcard path should hit NotFound")
assert.Empty(t, c.handler)
})
}

func TestRouterPercentEncodedSlash(t *testing.T) {
r, c := newRouter(t)
r.Handle("GET", "/files/{path...}", handlerNamed("files-get"))

req := httptest.NewRequest(http.MethodGet, "/files/a%2Fb%2Fc", nil)
r.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, "files-get", c.handler)
assert.Equal(t, "a/b/c", c.vars["path"])
}
Loading
Loading