Skip to content

Commit

Permalink
Merge pull request #73 from darklynx/path_prefix
Browse files Browse the repository at this point in the history
Service URL path prefix
  • Loading branch information
darklynx committed Jun 2, 2022
2 parents 3bd34b0 + f2745bd commit ca6c44b
Show file tree
Hide file tree
Showing 17 changed files with 290 additions and 108 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Expand Up @@ -5,7 +5,7 @@ jobs:
strategy:
fail-fast: false
matrix:
go: ["1.15", "1.16", "1.17"]
go: ["1.16", "1.17", "1.18"]

# Service containers to run with `container-job`
services:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Expand Up @@ -102,7 +102,9 @@ Usage of bin/request-baskets:
-token string
Master token, random token is generated if not provided
-basket value
Name of a basket to auto-create during service startup (can be specified multiple times)
Name of a basket to auto-create during service startup (can be specified multiple times)
-prefix string
Service URL path prefix
```

### Parameters
Expand All @@ -116,6 +118,7 @@ Usage of bin/request-baskets:
* `-file` *location* - location of Bolt database file, only relevant if appropriate storage type is chosen
* `-conn` *connection* - database connection string for SQL databases, if undefined `-file` argument is considered
* `-basket` *value* - name of a basket to auto-create during service startup, this parameter can be specified multiple times
* `-prefix` *URL path prefix* - allows to host API and web-UI of baskets service under a sub-path instead of domain ROOT

## Usage

Expand Down
14 changes: 12 additions & 2 deletions config.go
Expand Up @@ -34,6 +34,7 @@ type ServerConfig struct {
DbFile string
DbConnection string
Baskets []string
PathPrefix string
}

type arrayFlags []string
Expand All @@ -59,10 +60,10 @@ func CreateConfig() *ServerConfig {
"Baskets storage type: %s - in-memory, %s - Bolt DB, %s - SQL database", DbTypeMemory, DbTypeBolt, DbTypeSQL))
var dbFile = flag.String("file", "./baskets.db", "Database location, only applicable for file or SQL databases")
var dbConnection = flag.String("conn", "", "Database connection string for SQL databases, if undefined \"file\" argument is considered")
var prefix = flag.String("prefix", "", "Service URL path prefix")

var baskets arrayFlags
flag.Var(&baskets, "basket", "Name of a basket to auto-create during service startup (can be specified multiple times)")

flag.Parse()

var token = *masterToken
Expand All @@ -81,5 +82,14 @@ func CreateConfig() *ServerConfig {
DbType: *dbType,
DbFile: *dbFile,
DbConnection: *dbConnection,
Baskets: baskets}
Baskets: baskets,
PathPrefix: normalizePrefix(*prefix)}
}

func normalizePrefix(prefix string) string {
if (len(prefix) > 0) && (prefix[0] != '/') {
return "/" + prefix
} else {
return prefix
}
}
8 changes: 8 additions & 0 deletions config_test.go
Expand Up @@ -28,3 +28,11 @@ func TestArrayFlags(t *testing.T) {

assert.Equal(t, "123,abc,xyz,abc", flags.String(), "unexpected list of flags")
}

func TestNormalizePrefix(t *testing.T) {
assert.Empty(t, normalizePrefix(""), "expected empty prefix after normalization")
assert.Equal(t, "/xyz", normalizePrefix("/xyz"), "unexpected result of normalization")
assert.Equal(t, "/abc", normalizePrefix("abc"), "unexpected result of normalization")
assert.Equal(t, "/services/baskets", normalizePrefix("services/baskets"), "unexpected result of normalization")
assert.Equal(t, "/abc/def/ghi", normalizePrefix("/abc/def/ghi"), "unexpected result of normalization")
}
4 changes: 4 additions & 0 deletions docker/entrypoint.sh
Expand Up @@ -42,6 +42,10 @@ if [ -n "$BASKET" ]; then
args="$args -basket $BASKET"
fi

if [ -n "$PATHPREFIX" ]; then
args="$args -prefix $PATHPREFIX"
fi

cmd="/bin/rbaskets $args"
echo "Executing: $cmd"
exec $cmd
4 changes: 2 additions & 2 deletions go.mod
Expand Up @@ -6,9 +6,9 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-sql-driver/mysql v1.6.0
github.com/julienschmidt/httprouter v1.3.0
github.com/lib/pq v1.10.3
github.com/lib/pq v1.10.6
github.com/stretchr/testify v1.7.0
go.etcd.io/bbolt v1.3.6
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
8 changes: 4 additions & 4 deletions go.sum
Expand Up @@ -5,8 +5,8 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand All @@ -15,8 +15,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 h1:SeSEfdIxyvwGJliREIJhRPPXvW6sDlLT+UQ3B0hD0NA=
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
49 changes: 41 additions & 8 deletions handlers.go
Expand Up @@ -343,13 +343,20 @@ func ClearBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

// ForwardToWeb handels HTTP forwarding to /web
func ForwardToWeb(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
http.Redirect(w, r, "/"+serviceUIPath, http.StatusFound)
http.Redirect(w, r, pathPrefix+"/"+serviceUIPath, http.StatusFound)
}

type TemplateData struct {
Prefix string
Version *Version
Basket string
Data interface{}
}

// WebIndexPage handles HTTP request to render index page
func WebIndexPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
indexPageTemplate.Execute(w, version)
indexPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version})
}

// WebBasketPage handles HTTP request to render basket details page
Expand All @@ -359,9 +366,9 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params)
case serviceOldAPIPath:
// admin page to access all baskets
w.Header().Set("Content-Type", "text/html; charset=utf-8")
basketsPageTemplate.Execute(w, version)
basketsPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version})
default:
basketPageTemplate.Execute(w, name)
basketPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version, Basket: name})
}
} else {
http.Error(w, "Basket name does not match pattern: "+validBasketName.String(), http.StatusBadRequest)
Expand All @@ -370,10 +377,10 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params)

// AcceptBasketRequests accepts and handles HTTP requests passed to different baskets
func AcceptBasketRequests(w http.ResponseWriter, r *http.Request) {
name := strings.Split(r.URL.Path, "/")[1]

if !validBasketName.MatchString(name) {
http.Error(w, "invalid basket name; the name does not match pattern: "+validBasketName.String(), http.StatusBadRequest)
name, publicErr, err := getBasketNameOfAcceptedRequest(r, pathPrefix)
if err != nil {
log.Printf("[error] %s", err)
http.Error(w, publicErr, http.StatusBadRequest)
} else if basket := basketsDb.Get(name); basket != nil {
request := basket.Add(r)

Expand All @@ -394,6 +401,26 @@ func AcceptBasketRequests(w http.ResponseWriter, r *http.Request) {
}
}

func getBasketNameOfAcceptedRequest(r *http.Request, prefix string) (string, string, error) {
path := r.URL.Path
if len(prefix) > 0 {
if strings.HasPrefix(path, prefix) {
path = strings.TrimPrefix(path, prefix)
} else {
publicErr := "incoming request is outside of configured path prefix: " + prefix
return "", publicErr, fmt.Errorf("%s; request: %s %s", publicErr, r.Method, sanitizeForLog(r.URL.Path))
}
}

name := sanitizeForLog(strings.Split(path, "/")[1])
if !validBasketName.MatchString(name) {
publicErr := "invalid basket name; the name does not match pattern: " + validBasketName.String()
return "", publicErr, fmt.Errorf("%s; request: %s %s", publicErr, r.Method, sanitizeForLog(r.URL.Path))
}

return name, "", nil
}

func forwardAndForget(request *RequestData, config BasketConfig, name string) {
// forward request and discard the response
response, err := request.Forward(getHTTPClient(config.InsecureTLS), config, name)
Expand Down Expand Up @@ -460,3 +487,9 @@ func writeBasketResponse(w http.ResponseWriter, r *http.Request, name string, ba
w.Write([]byte(response.Body))
}
}

func sanitizeForLog(raw string) string {
sanitized := strings.ReplaceAll(raw, "\n", "^n")
sanitized = strings.ReplaceAll(sanitized, "\r", "^r")
return sanitized
}
75 changes: 75 additions & 0 deletions handlers_test.go
Expand Up @@ -1846,3 +1846,78 @@ func TestAcceptBasketRequests_WithProxyResponse_InternalServerError(t *testing.T
}
}
}

func TestGetBasketNameOfAcceptedRequest_NoPrefix_Valid(t *testing.T) {
r, err := http.NewRequest("GET", "http://localhost:55555/basket200", strings.NewReader(""))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "")
assert.Equal(t, "basket200", name, "unexpected basket name")
assert.Empty(t, pubErr)
assert.Nil(t, err)
}
}

func TestGetBasketNameOfAcceptedRequest_NoPrefix_ValidWithSubpath(t *testing.T) {
r, err := http.NewRequest("DELETE", "http://localhost:55555/basket210/api/users/123", strings.NewReader(""))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "")
assert.Equal(t, "basket210", name, "unexpected basket name")
assert.Empty(t, pubErr)
assert.Nil(t, err)
}
}

func TestGetBasketNameOfAcceptedRequest_NoPrefix_Invalid(t *testing.T) {
r, err := http.NewRequest("PUT", "http://localhost:55555/basket~220/objects/404", strings.NewReader("{}"))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "")
assert.Empty(t, name, "basket name is invalid, hence unexpected")
assert.Equal(t, pubErr, "invalid basket name; the name does not match pattern: "+basketNamePattern)
if assert.NotNil(t, err) {
assert.Equal(t, err.Error(), "invalid basket name; the name does not match pattern: "+
basketNamePattern+"; request: PUT /basket~220/objects/404")
}
}
}

func TestGetBasketNameOfAcceptedRequest_WithPrefix_Valid(t *testing.T) {
r, err := http.NewRequest("GET", "http://localhost:55555/abc/basket300", strings.NewReader(""))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/abc")
assert.Equal(t, "basket300", name, "unexpected basket name")
assert.Empty(t, pubErr)
assert.Nil(t, err)
}
}

func TestGetBasketNameOfAcceptedRequest_WithPrefix_ValidWithSubpath(t *testing.T) {
r, err := http.NewRequest("PATCH", "http://localhost:55555/xyz/basket310/api/users/123", strings.NewReader("{}"))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/xyz")
assert.Equal(t, "basket310", name, "unexpected basket name")
assert.Empty(t, pubErr)
assert.Nil(t, err)
}
}

func TestGetBasketNameOfAcceptedRequest_WithPrefix_OutsideOfContext(t *testing.T) {
r, err := http.NewRequest("POST", "http://localhost:55555/api/objects", strings.NewReader("{}"))
if assert.NoError(t, err) {
name, pubErr, err := getBasketNameOfAcceptedRequest(r, "/baskets")
assert.Empty(t, name, "URL is out of context, hence no basket name is expected")
assert.Equal(t, pubErr, "incoming request is outside of configured path prefix: /baskets")
if assert.NotNil(t, err) {
assert.Equal(t, err.Error(), "incoming request is outside of configured path prefix: /baskets"+
"; request: POST /api/objects")
}
}
}

func TestSanitizeForLog(t *testing.T) {
assert.Equal(t, "basket2346", sanitizeForLog("basket2346"), "unexpected result of sanitizing")
assert.Equal(t, "abc~!@#$%09381", sanitizeForLog("abc~!@#$%09381"), "unexpected result of sanitizing")
assert.Equal(t, "new line^n injection", sanitizeForLog("new line\n injection"), "unexpected result of sanitizing")
assert.Equal(t, "another^rnew line", sanitizeForLog("another\rnew line"), "unexpected result of sanitizing")
assert.Equal(t, "multi-^n^r^n^r^rmulti-^nmulti-^r^nlines", sanitizeForLog("multi-\n\r\n\r\rmulti-\nmulti-\r\nlines"),
"unexpected result of sanitizing")
}
58 changes: 34 additions & 24 deletions server.go
Expand Up @@ -16,6 +16,7 @@ var basketsDb BasketsDatabase
var httpClient *http.Client
var httpInsecureClient *http.Client
var version *Version
var pathPrefix string

// CreateServer creates an instance of Request Baskets server
func CreateServer(config *ServerConfig) *http.Server {
Expand All @@ -42,45 +43,47 @@ func CreateServer(config *ServerConfig) *http.Server {
insecureTransport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
httpInsecureClient = &http.Client{Transport: insecureTransport}

setPathPrefix(config.PathPrefix)

// configure service HTTP router
router := httprouter.New()

//// Old API mapping ////
// basket names
router.GET("/"+serviceOldAPIPath, GetBaskets)
router.GET(pathPrefix+"/"+serviceOldAPIPath, GetBaskets)
// basket management
router.GET("/"+serviceOldAPIPath+"/:basket", GetBasket)
router.POST("/"+serviceOldAPIPath+"/:basket", CreateBasket)
router.PUT("/"+serviceOldAPIPath+"/:basket", UpdateBasket)
router.DELETE("/"+serviceOldAPIPath+"/:basket", DeleteBasket)
router.GET("/"+serviceOldAPIPath+"/:basket/responses/:method", GetBasketResponse)
router.PUT("/"+serviceOldAPIPath+"/:basket/responses/:method", UpdateBasketResponse)
router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket", GetBasket)
router.POST(pathPrefix+"/"+serviceOldAPIPath+"/:basket", CreateBasket)
router.PUT(pathPrefix+"/"+serviceOldAPIPath+"/:basket", UpdateBasket)
router.DELETE(pathPrefix+"/"+serviceOldAPIPath+"/:basket", DeleteBasket)
router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket/responses/:method", GetBasketResponse)
router.PUT(pathPrefix+"/"+serviceOldAPIPath+"/:basket/responses/:method", UpdateBasketResponse)
// requests management
router.GET("/"+serviceOldAPIPath+"/:basket/requests", GetBasketRequests)
router.DELETE("/"+serviceOldAPIPath+"/:basket/requests", ClearBasket)
router.GET(pathPrefix+"/"+serviceOldAPIPath+"/:basket/requests", GetBasketRequests)
router.DELETE(pathPrefix+"/"+serviceOldAPIPath+"/:basket/requests", ClearBasket)

//// New API mapping ////
// service details
router.GET("/"+serviceAPIPath+"/stats", GetStats)
router.GET("/"+serviceAPIPath+"/version", GetVersion)
router.GET(pathPrefix+"/"+serviceAPIPath+"/stats", GetStats)
router.GET(pathPrefix+"/"+serviceAPIPath+"/version", GetVersion)
// basket names
router.GET("/"+serviceAPIPath+"/baskets", GetBaskets)
router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets", GetBaskets)
// basket management
router.GET("/"+serviceAPIPath+"/baskets/:basket", GetBasket)
router.POST("/"+serviceAPIPath+"/baskets/:basket", CreateBasket)
router.PUT("/"+serviceAPIPath+"/baskets/:basket", UpdateBasket)
router.DELETE("/"+serviceAPIPath+"/baskets/:basket", DeleteBasket)
router.GET("/"+serviceAPIPath+"/baskets/:basket/responses/:method", GetBasketResponse)
router.PUT("/"+serviceAPIPath+"/baskets/:basket/responses/:method", UpdateBasketResponse)
router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", GetBasket)
router.POST(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", CreateBasket)
router.PUT(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", UpdateBasket)
router.DELETE(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket", DeleteBasket)
router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/responses/:method", GetBasketResponse)
router.PUT(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/responses/:method", UpdateBasketResponse)
// requests management
router.GET("/"+serviceAPIPath+"/baskets/:basket/requests", GetBasketRequests)
router.DELETE("/"+serviceAPIPath+"/baskets/:basket/requests", ClearBasket)
router.GET(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/requests", GetBasketRequests)
router.DELETE(pathPrefix+"/"+serviceAPIPath+"/baskets/:basket/requests", ClearBasket)

// web pages
router.GET("/", ForwardToWeb)
router.GET("/"+serviceUIPath, WebIndexPage)
router.GET("/"+serviceUIPath+"/:basket", WebBasketPage)
//router.ServeFiles("/"+serviceUIPath+"/*filepath", http.Dir("./src/github.com/darklynx/request-baskets/web"))
router.GET(pathPrefix+"/", ForwardToWeb)
router.GET(pathPrefix+"/"+serviceUIPath, WebIndexPage)
router.GET(pathPrefix+"/"+serviceUIPath+"/:basket", WebBasketPage)
//router.ServeFiles(pathPrefix+"/"+serviceUIPath+"/*filepath", http.Dir("./web"))

// basket requests
router.NotFound = http.HandlerFunc(AcceptBasketRequests)
Expand Down Expand Up @@ -109,6 +112,13 @@ func createBasketsDatabase(dbtype string, file string, conn string) BasketsDatab
}
}

func setPathPrefix(prefix string) {
pathPrefix = prefix
if len(pathPrefix) > 0 {
log.Printf("[info] service path prefix: %s", pathPrefix)
}
}

func shutdownHook() {
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
Expand Down
8 changes: 8 additions & 0 deletions server_test.go
Expand Up @@ -78,3 +78,11 @@ func TestCreateDefaultBaskets(t *testing.T) {

assert.Equal(t, serverConfig.InitCapacity, db.Get("abc").Config().Capacity, "unexpected basket capacity")
}

func TestSetPathPrefix(t *testing.T) {
setPathPrefix("/abc")
assert.Equal(t, "/abc", pathPrefix, "unexpected prefix")

setPathPrefix("")
assert.Empty(t, pathPrefix, "prefix is not expected")
}

0 comments on commit ca6c44b

Please sign in to comment.