Merge branch 'master' into master

This commit is contained in:
田欧 2019-02-28 09:22:45 +08:00 committed by GitHub
commit 1855d76d70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 263 additions and 56 deletions

View File

@ -7,6 +7,7 @@ go:
- 1.9.x - 1.9.x
- 1.10.x - 1.10.x
- 1.11.x - 1.11.x
- 1.12.x
- master - master
matrix: matrix:
@ -14,6 +15,8 @@ matrix:
include: include:
- go: 1.11.x - go: 1.11.x
env: GO111MODULE=on env: GO111MODULE=on
- go: 1.12.x
env: GO111MODULE=on
git: git:
depth: 10 depth: 10

View File

@ -21,7 +21,10 @@ test:
exit 1; \ exit 1; \
elif grep -q "build failed" tmp.out; then \ elif grep -q "build failed" tmp.out; then \
rm tmp.out; \ rm tmp.out; \
exit; \ exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \ fi; \
if [ -f profile.out ]; then \ if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \ cat profile.out | grep -v "mode:" >> coverage.out; \

View File

@ -215,9 +215,6 @@ $ go build -tags=jsoniter .
```go ```go
func main() { func main() {
// Disable Console Color
// gin.DisableConsoleColor()
// Creates a gin router with default middleware: // Creates a gin router with default middleware:
// logger and recovery (crash-free) middleware // logger and recovery (crash-free) middleware
router := gin.Default() router := gin.Default()
@ -570,6 +567,48 @@ func main() {
::1 - [Fri, 07 Dec 2018 17:04:38 JST] "GET /ping HTTP/1.1 200 122.767µs "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36" " ::1 - [Fri, 07 Dec 2018 17:04:38 JST] "GET /ping HTTP/1.1 200 122.767µs "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36" "
``` ```
### Controlling Log output coloring
By default, logs output on console should be colorized depending on the detected TTY.
Never colorize logs:
```go
func main() {
// Disable log's color
gin.DisableConsoleColor()
// Creates a gin router with default middleware:
// logger and recovery (crash-free) middleware
router := gin.Default()
router.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
router.Run(":8080")
}
```
Always colorize logs:
```go
func main() {
// Force log's color
gin.ForceConsoleColor()
// Creates a gin router with default middleware:
// logger and recovery (crash-free) middleware
router := gin.Default()
router.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
router.Run(":8080")
}
```
### Model binding and validation ### Model binding and validation
To bind a request body into a type, use model binding. We currently support binding of JSON, XML, YAML and standard form values (foo=bar&boo=baz). To bind a request body into a type, use model binding. We currently support binding of JSON, XML, YAML and standard form values (foo=bar&boo=baz).
@ -1673,6 +1712,11 @@ func main() {
if err := srv.Shutdown(ctx); err != nil { if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err) log.Fatal("Server Shutdown:", err)
} }
// catching ctx.Done(). timeout of 5 seconds.
select {
case <-ctx.Done():
log.Println("timeout of 5 seconds.")
}
log.Println("Server exiting") log.Println("Server exiting")
} }
``` ```

View File

@ -61,6 +61,10 @@ type FooStructForMapType struct {
MapFoo map[string]interface{} `form:"map_foo"` MapFoo map[string]interface{} `form:"map_foo"`
} }
type FooStructForIgnoreFormTag struct {
Foo *string `form:"-"`
}
type InvalidNameType struct { type InvalidNameType struct {
TestName string `invalid_name:"test_name"` TestName string `invalid_name:"test_name"`
} }
@ -278,6 +282,12 @@ func TestBindingFormForTime2(t *testing.T) {
"", "") "", "")
} }
func TestFormBindingIgnoreField(t *testing.T) {
testFormBindingIgnoreField(t, "POST",
"/", "/",
"-=bar", "")
}
func TestBindingFormInvalidName(t *testing.T) { func TestBindingFormInvalidName(t *testing.T) {
testFormBindingInvalidName(t, "POST", testFormBindingInvalidName(t, "POST",
"/", "/", "/", "/",
@ -860,6 +870,21 @@ func testFormBindingForTimeFailLocation(t *testing.T, method, path, badPath, bod
assert.Error(t, err) assert.Error(t, err)
} }
func testFormBindingIgnoreField(t *testing.T, method, path, badPath, body, badBody string) {
b := Form
assert.Equal(t, "form", b.Name())
obj := FooStructForIgnoreFormTag{}
req := requestWithBody(method, path, body)
if method == "POST" {
req.Header.Add("Content-Type", MIMEPOSTForm)
}
err := b.Bind(req, &obj)
assert.NoError(t, err)
assert.Nil(t, obj.Foo)
}
func testFormBindingInvalidName(t *testing.T, method, path, badPath, body, badBody string) { func testFormBindingInvalidName(t *testing.T, method, path, badPath, body, badBody string) {
b := Form b := Form
assert.Equal(t, "form", b.Name()) assert.Equal(t, "form", b.Name())

View File

@ -41,6 +41,9 @@ func mapFormByTag(ptr interface{}, form map[string][]string, tag string) error {
defaultValue = defaultList[1] defaultValue = defaultList[1]
} }
} }
if inputFieldName == "-" {
continue
}
if inputFieldName == "" { if inputFieldName == "" {
inputFieldName = typeField.Name inputFieldName = typeField.Name

View File

@ -82,6 +82,10 @@ func (c *Context) Copy() *Context {
cp.Writer = &cp.writermem cp.Writer = &cp.writermem
cp.index = abortIndex cp.index = abortIndex
cp.handlers = nil cp.handlers = nil
cp.Keys = map[string]interface{}{}
for k, v := range c.Keys {
cp.Keys[k] = v
}
return &cp return &cp
} }
@ -91,6 +95,16 @@ func (c *Context) HandlerName() string {
return nameOfFunction(c.handlers.Last()) return nameOfFunction(c.handlers.Last())
} }
// HandlerNames returns a list of all registered handlers for this context in descending order,
// following the semantics of HandlerName()
func (c *Context) HandlerNames() []string {
hn := make([]string, 0, len(c.handlers))
for _, val := range c.handlers {
hn = append(hn, nameOfFunction(val))
}
return hn
}
// Handler returns the main handler. // Handler returns the main handler.
func (c *Context) Handler() HandlerFunc { func (c *Context) Handler() HandlerFunc {
return c.handlers.Last() return c.handlers.Last()

View File

@ -331,6 +331,8 @@ func TestContextCopy(t *testing.T) {
assert.Equal(t, cp.Keys, c.Keys) assert.Equal(t, cp.Keys, c.Keys)
assert.Equal(t, cp.engine, c.engine) assert.Equal(t, cp.engine, c.engine)
assert.Equal(t, cp.Params, c.Params) assert.Equal(t, cp.Params, c.Params)
cp.Set("foo", "notBar")
assert.False(t, cp.Keys["foo"] == c.Keys["foo"])
} }
func TestContextHandlerName(t *testing.T) { func TestContextHandlerName(t *testing.T) {
@ -340,10 +342,26 @@ func TestContextHandlerName(t *testing.T) {
assert.Regexp(t, "^(.*/vendor/)?github.com/gin-gonic/gin.handlerNameTest$", c.HandlerName()) assert.Regexp(t, "^(.*/vendor/)?github.com/gin-gonic/gin.handlerNameTest$", c.HandlerName())
} }
func TestContextHandlerNames(t *testing.T) {
c, _ := CreateTestContext(httptest.NewRecorder())
c.handlers = HandlersChain{func(c *Context) {}, handlerNameTest, func(c *Context) {}, handlerNameTest2}
names := c.HandlerNames()
assert.True(t, len(names) == 4)
for _, name := range names {
assert.Regexp(t, `^(.*/vendor/)?(github\.com/gin-gonic/gin\.){1}(TestContextHandlerNames\.func.*){0,1}(handlerNameTest.*){0,1}`, name)
}
}
func handlerNameTest(c *Context) { func handlerNameTest(c *Context) {
} }
func handlerNameTest2(c *Context) {
}
var handlerTest HandlerFunc = func(c *Context) { var handlerTest HandlerFunc = func(c *Context) {
} }

View File

@ -48,5 +48,10 @@ func main() {
if err := srv.Shutdown(ctx); err != nil { if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err) log.Fatal("Server Shutdown:", err)
} }
// catching ctx.Done(). timeout of 5 seconds.
select {
case <-ctx.Done():
log.Println("timeout of 5 seconds.")
}
log.Println("Server exiting") log.Println("Server exiting")
} }

15
gin.go
View File

@ -10,6 +10,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"path"
"sync" "sync"
"github.com/gin-gonic/gin/render" "github.com/gin-gonic/gin/render"
@ -318,6 +319,7 @@ func (engine *Engine) RunUnix(file string) (err error) {
return return
} }
defer listener.Close() defer listener.Close()
os.Chmod(file, 0777)
err = http.Serve(listener, engine) err = http.Serve(listener, engine)
return return
} }
@ -437,17 +439,20 @@ func serveError(c *Context, code int, defaultMessage []byte) {
func redirectTrailingSlash(c *Context) { func redirectTrailingSlash(c *Context) {
req := c.Request req := c.Request
path := req.URL.Path p := req.URL.Path
if prefix := path.Clean(c.Request.Header.Get("X-Forwarded-Prefix")); prefix != "." {
p = prefix + "/" + req.URL.Path
}
code := http.StatusMovedPermanently // Permanent redirect, request with GET method code := http.StatusMovedPermanently // Permanent redirect, request with GET method
if req.Method != "GET" { if req.Method != "GET" {
code = http.StatusTemporaryRedirect code = http.StatusTemporaryRedirect
} }
req.URL.Path = path + "/" req.URL.Path = p + "/"
if length := len(path); length > 1 && path[length-1] == '/' { if length := len(p); length > 1 && p[length-1] == '/' {
req.URL.Path = path[:length-1] req.URL.Path = p[:length-1]
} }
debugPrint("redirecting request %d: %s --> %s", code, path, req.URL.String()) debugPrint("redirecting request %d: %s --> %s", code, p, req.URL.String())
http.Redirect(c.Writer, req, req.URL.String(), code) http.Redirect(c.Writer, req, req.URL.String(), code)
c.writermem.WriteHeaderNow() c.writermem.WriteHeaderNow()
} }

View File

@ -346,6 +346,29 @@ func TestBindUriError(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w1.Code) assert.Equal(t, http.StatusBadRequest, w1.Code)
} }
func TestRaceContextCopy(t *testing.T) {
DefaultWriter = os.Stdout
router := Default()
router.GET("/test/copy/race", func(c *Context) {
c.Set("1", 0)
c.Set("2", 0)
// Sending a copy of the Context to two separate routines
go readWriteKeys(c.Copy())
go readWriteKeys(c.Copy())
c.String(http.StatusOK, "run OK, no panics")
})
w := performRequest(router, "GET", "/test/copy/race")
assert.Equal(t, "run OK, no panics", w.Body.String())
}
func readWriteKeys(c *Context) {
for {
c.Set("1", rand.Int())
c.Set("2", c.Value("1"))
}
}
func githubConfigRouter(router *Engine) { func githubConfigRouter(router *Engine) {
for _, route := range githubAPI { for _, route := range githubAPI {
router.Handle(route.method, route.path, func(c *Context) { router.Handle(route.method, route.path, func(c *Context) {

View File

@ -24,6 +24,7 @@ var (
cyan = string([]byte{27, 91, 57, 55, 59, 52, 54, 109}) cyan = string([]byte{27, 91, 57, 55, 59, 52, 54, 109})
reset = string([]byte{27, 91, 48, 109}) reset = string([]byte{27, 91, 48, 109})
disableColor = false disableColor = false
forceColor = false
) )
// LoggerConfig defines the config for Logger middleware. // LoggerConfig defines the config for Logger middleware.
@ -63,15 +64,62 @@ type LogFormatterParams struct {
ErrorMessage string ErrorMessage string
// IsTerm shows whether does gin's output descriptor refers to a terminal. // IsTerm shows whether does gin's output descriptor refers to a terminal.
IsTerm bool IsTerm bool
// BodySize is the size of the Response Body
BodySize int
}
// StatusCodeColor is the ANSI color for appropriately logging http status code to a terminal.
func (p *LogFormatterParams) StatusCodeColor() string {
code := p.StatusCode
switch {
case code >= http.StatusOK && code < http.StatusMultipleChoices:
return green
case code >= http.StatusMultipleChoices && code < http.StatusBadRequest:
return white
case code >= http.StatusBadRequest && code < http.StatusInternalServerError:
return yellow
default:
return red
}
}
// MethodColor is the ANSI color for appropriately logging http method to a terminal.
func (p *LogFormatterParams) MethodColor() string {
method := p.Method
switch method {
case "GET":
return blue
case "POST":
return cyan
case "PUT":
return yellow
case "DELETE":
return red
case "PATCH":
return green
case "HEAD":
return magenta
case "OPTIONS":
return white
default:
return reset
}
}
// ResetColor resets all escape attributes.
func (p *LogFormatterParams) ResetColor() string {
return reset
} }
// defaultLogFormatter is the default log format function Logger middleware uses. // defaultLogFormatter is the default log format function Logger middleware uses.
var defaultLogFormatter = func(param LogFormatterParams) string { var defaultLogFormatter = func(param LogFormatterParams) string {
var statusColor, methodColor, resetColor string var statusColor, methodColor, resetColor string
if param.IsTerm { if param.IsTerm {
statusColor = colorForStatus(param.StatusCode) statusColor = param.StatusCodeColor()
methodColor = colorForMethod(param.Method) methodColor = param.MethodColor()
resetColor = reset resetColor = param.ResetColor()
} }
return fmt.Sprintf("[GIN] %v |%s %3d %s| %13v | %15s |%s %-7s %s %s\n%s", return fmt.Sprintf("[GIN] %v |%s %3d %s| %13v | %15s |%s %-7s %s %s\n%s",
@ -90,6 +138,11 @@ func DisableConsoleColor() {
disableColor = true disableColor = true
} }
// ForceConsoleColor force color output in the console.
func ForceConsoleColor() {
forceColor = true
}
// ErrorLogger returns a handlerfunc for any error type. // ErrorLogger returns a handlerfunc for any error type.
func ErrorLogger() HandlerFunc { func ErrorLogger() HandlerFunc {
return ErrorLoggerT(ErrorTypeAny) return ErrorLoggerT(ErrorTypeAny)
@ -144,9 +197,9 @@ func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
isTerm := true isTerm := true
if w, ok := out.(*os.File); !ok || if w, ok := out.(*os.File); (!ok ||
(os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd()))) || (os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd()))) ||
disableColor { disableColor) && !forceColor {
isTerm = false isTerm = false
} }
@ -185,6 +238,8 @@ func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
param.StatusCode = c.Writer.Status() param.StatusCode = c.Writer.Status()
param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String() param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String()
param.BodySize = c.Writer.Size()
if raw != "" { if raw != "" {
path = path + "?" + raw path = path + "?" + raw
} }
@ -195,37 +250,3 @@ func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
} }
} }
} }
func colorForStatus(code int) string {
switch {
case code >= http.StatusOK && code < http.StatusMultipleChoices:
return green
case code >= http.StatusMultipleChoices && code < http.StatusBadRequest:
return white
case code >= http.StatusBadRequest && code < http.StatusInternalServerError:
return yellow
default:
return red
}
}
func colorForMethod(method string) string {
switch method {
case "GET":
return blue
case "POST":
return cyan
case "PUT":
return yellow
case "DELETE":
return red
case "PATCH":
return green
case "HEAD":
return magenta
case "OPTIONS":
return white
default:
return reset
}
}

View File

@ -257,6 +257,13 @@ func TestDefaultLogFormatter(t *testing.T) {
} }
func TestColorForMethod(t *testing.T) { func TestColorForMethod(t *testing.T) {
colorForMethod := func(method string) string {
p := LogFormatterParams{
Method: method,
}
return p.MethodColor()
}
assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 52, 109}), colorForMethod("GET"), "get should be blue") assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 52, 109}), colorForMethod("GET"), "get should be blue")
assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 54, 109}), colorForMethod("POST"), "post should be cyan") assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 54, 109}), colorForMethod("POST"), "post should be cyan")
assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 51, 109}), colorForMethod("PUT"), "put should be yellow") assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 51, 109}), colorForMethod("PUT"), "put should be yellow")
@ -268,12 +275,24 @@ func TestColorForMethod(t *testing.T) {
} }
func TestColorForStatus(t *testing.T) { func TestColorForStatus(t *testing.T) {
colorForStatus := func(code int) string {
p := LogFormatterParams{
StatusCode: code,
}
return p.StatusCodeColor()
}
assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 50, 109}), colorForStatus(http.StatusOK), "2xx should be green") assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 50, 109}), colorForStatus(http.StatusOK), "2xx should be green")
assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 55, 109}), colorForStatus(http.StatusMovedPermanently), "3xx should be white") assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 55, 109}), colorForStatus(http.StatusMovedPermanently), "3xx should be white")
assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 51, 109}), colorForStatus(http.StatusNotFound), "4xx should be yellow") assert.Equal(t, string([]byte{27, 91, 57, 48, 59, 52, 51, 109}), colorForStatus(http.StatusNotFound), "4xx should be yellow")
assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 49, 109}), colorForStatus(2), "other things should be red") assert.Equal(t, string([]byte{27, 91, 57, 55, 59, 52, 49, 109}), colorForStatus(2), "other things should be red")
} }
func TestResetColor(t *testing.T) {
p := LogFormatterParams{}
assert.Equal(t, string([]byte{27, 91, 48, 109}), p.ResetColor())
}
func TestErrorLogger(t *testing.T) { func TestErrorLogger(t *testing.T) {
router := New() router := New()
router.Use(ErrorLogger()) router.Use(ErrorLogger())
@ -340,3 +359,10 @@ func TestDisableConsoleColor(t *testing.T) {
DisableConsoleColor() DisableConsoleColor()
assert.True(t, disableColor) assert.True(t, disableColor)
} }
func TestForceConsoleColor(t *testing.T) {
New()
assert.False(t, forceColor)
ForceConsoleColor()
assert.True(t, forceColor)
}

View File

@ -11,8 +11,8 @@ import (
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
) )
// ENV_GIN_MODE indicates environment name for gin mode. // EnvGinMode indicates environment name for gin mode.
const ENV_GIN_MODE = "GIN_MODE" const EnvGinMode = "GIN_MODE"
const ( const (
// DebugMode indicates gin mode is debug. // DebugMode indicates gin mode is debug.
@ -44,7 +44,7 @@ var ginMode = debugCode
var modeName = DebugMode var modeName = DebugMode
func init() { func init() {
mode := os.Getenv(ENV_GIN_MODE) mode := os.Getenv(EnvGinMode)
SetMode(mode) SetMode(mode)
} }

View File

@ -13,13 +13,13 @@ import (
) )
func init() { func init() {
os.Setenv(ENV_GIN_MODE, TestMode) os.Setenv(EnvGinMode, TestMode)
} }
func TestSetMode(t *testing.T) { func TestSetMode(t *testing.T) {
assert.Equal(t, testCode, ginMode) assert.Equal(t, testCode, ginMode)
assert.Equal(t, TestMode, Mode()) assert.Equal(t, TestMode, Mode())
os.Unsetenv(ENV_GIN_MODE) os.Unsetenv(EnvGinMode)
SetMode("") SetMode("")
assert.Equal(t, debugCode, ginMode) assert.Equal(t, debugCode, ginMode)

View File

@ -36,8 +36,8 @@ func (r Reader) WriteContentType(w http.ResponseWriter) {
func (r Reader) writeHeaders(w http.ResponseWriter, headers map[string]string) { func (r Reader) writeHeaders(w http.ResponseWriter, headers map[string]string) {
header := w.Header() header := w.Header()
for k, v := range headers { for k, v := range headers {
if val := header[k]; len(val) == 0 { if header.Get(k) == "" {
header[k] = []string{v} header.Set(k, v)
} }
} }
} }

View File

@ -470,6 +470,7 @@ func TestRenderReader(t *testing.T) {
body := "#!PNG some raw data" body := "#!PNG some raw data"
headers := make(map[string]string) headers := make(map[string]string)
headers["Content-Disposition"] = `attachment; filename="filename.png"` headers["Content-Disposition"] = `attachment; filename="filename.png"`
headers["x-request-id"] = "requestId"
err := (Reader{ err := (Reader{
ContentLength: int64(len(body)), ContentLength: int64(len(body)),
@ -483,4 +484,5 @@ func TestRenderReader(t *testing.T) {
assert.Equal(t, "image/png", w.Header().Get("Content-Type")) assert.Equal(t, "image/png", w.Header().Get("Content-Type"))
assert.Equal(t, strconv.Itoa(len(body)), w.Header().Get("Content-Length")) assert.Equal(t, strconv.Itoa(len(body)), w.Header().Get("Content-Length"))
assert.Equal(t, headers["Content-Disposition"], w.Header().Get("Content-Disposition")) assert.Equal(t, headers["Content-Disposition"], w.Header().Get("Content-Disposition"))
assert.Equal(t, headers["x-request-id"], w.Header().Get("x-request-id"))
} }

View File

@ -16,8 +16,16 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func performRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { type header struct {
Key string
Value string
}
func performRequest(r http.Handler, method, path string, headers ...header) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil) req, _ := http.NewRequest(method, path, nil)
for _, h := range headers {
req.Header.Add(h.Key, h.Value)
}
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
return w return w
@ -170,6 +178,13 @@ func TestRouteRedirectTrailingSlash(t *testing.T) {
w = performRequest(router, "PUT", "/path4/") w = performRequest(router, "PUT", "/path4/")
assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, http.StatusOK, w.Code)
w = performRequest(router, "GET", "/path2", header{Key: "X-Forwarded-Prefix", Value: "/api"})
assert.Equal(t, "/api/path2/", w.Header().Get("Location"))
assert.Equal(t, 301, w.Code)
w = performRequest(router, "GET", "/path2/", header{Key: "X-Forwarded-Prefix", Value: "/api/"})
assert.Equal(t, 200, w.Code)
router.RedirectTrailingSlash = false router.RedirectTrailingSlash = false
w = performRequest(router, "GET", "/path/") w = performRequest(router, "GET", "/path/")