Manu Mtz-Almeida 10 лет назад
Родитель
Сommit
0a192fb0fa

+ 2 - 0
.gitignore

@@ -1,2 +1,4 @@
 Godeps/*
 !Godeps/Godeps.json
+coverage.out
+count.out

+ 4 - 0
auth_test.go

@@ -73,6 +73,10 @@ func TestBasicAuthSearchCredential(t *testing.T) {
 	user, found = pairs.searchCredential(authorizationHeader("foo", "bar "))
 	assert.Empty(t, user)
 	assert.False(t, found)
+
+	user, found = pairs.searchCredential("")
+	assert.Empty(t, user)
+	assert.False(t, found)
 }
 
 func TestBasicAuthAuthorizationHeader(t *testing.T) {

+ 7 - 0
binding/binding.go

@@ -50,3 +50,10 @@ func Default(method, contentType string) Binding {
 		}
 	}
 }
+
+func Validate(obj interface{}) error {
+	if err := _validator.ValidateStruct(obj); err != nil {
+		return error(err)
+	}
+	return nil
+}

+ 79 - 0
binding/binding_test.go

@@ -0,0 +1,79 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package binding
+
+import (
+	"bytes"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type FooStruct struct {
+	Foo string `json:"foo" form:"foo" xml:"foo" binding:"required"`
+}
+
+func TestBindingDefault(t *testing.T) {
+	assert.Equal(t, Default("GET", ""), GETForm)
+	assert.Equal(t, Default("GET", MIMEJSON), GETForm)
+
+	assert.Equal(t, Default("POST", MIMEJSON), JSON)
+	assert.Equal(t, Default("PUT", MIMEJSON), JSON)
+
+	assert.Equal(t, Default("POST", MIMEXML), XML)
+	assert.Equal(t, Default("PUT", MIMEXML2), XML)
+
+	assert.Equal(t, Default("POST", MIMEPOSTForm), POSTForm)
+	assert.Equal(t, Default("DELETE", MIMEPOSTForm), POSTForm)
+}
+
+func TestBindingJSON(t *testing.T) {
+	testBinding(t,
+		JSON, "json",
+		"/", "/",
+		`{"foo": "bar"}`, `{"bar": "foo"}`)
+}
+
+func TestBindingPOSTForm(t *testing.T) {
+	testBinding(t,
+		POSTForm, "post_form",
+		"/", "/",
+		"foo=bar", "bar=foo")
+}
+
+func TestBindingGETForm(t *testing.T) {
+	testBinding(t,
+		GETForm, "get_form",
+		"/?foo=bar", "/?bar=foo",
+		"", "")
+}
+
+func TestBindingXML(t *testing.T) {
+	testBinding(t,
+		XML, "xml",
+		"/", "/",
+		"<map><foo>bar</foo></map>", "<map><bar>foo</bar></map>")
+}
+
+func testBinding(t *testing.T, b Binding, name, path, badPath, body, badBody string) {
+	assert.Equal(t, b.Name(), name)
+
+	obj := FooStruct{}
+	req := requestWithBody(path, body)
+	err := b.Bind(req, &obj)
+	assert.NoError(t, err)
+	assert.Equal(t, obj.Foo, "bar")
+
+	obj = FooStruct{}
+	req = requestWithBody(badPath, badBody)
+	err = JSON.Bind(req, &obj)
+	assert.Error(t, err)
+}
+
+func requestWithBody(path, body string) (req *http.Request) {
+	req, _ = http.NewRequest("POST", path, bytes.NewBufferString(body))
+	return
+}

+ 1 - 4
binding/get_form.go

@@ -19,8 +19,5 @@ func (_ getFormBinding) Bind(req *http.Request, obj interface{}) error {
 	if err := mapForm(obj, req.Form); err != nil {
 		return err
 	}
-	if err := _validator.ValidateStruct(obj); err != nil {
-		return error(err)
-	}
-	return nil
+	return Validate(obj)
 }

+ 1 - 4
binding/json.go

@@ -21,8 +21,5 @@ func (_ jsonBinding) Bind(req *http.Request, obj interface{}) error {
 	if err := decoder.Decode(obj); err != nil {
 		return err
 	}
-	if err := _validator.ValidateStruct(obj); err != nil {
-		return error(err)
-	}
-	return nil
+	return Validate(obj)
 }

+ 1 - 4
binding/post_form.go

@@ -19,8 +19,5 @@ func (_ postFormBinding) Bind(req *http.Request, obj interface{}) error {
 	if err := mapForm(obj, req.PostForm); err != nil {
 		return err
 	}
-	if err := _validator.ValidateStruct(obj); err != nil {
-		return error(err)
-	}
-	return nil
+	return Validate(obj)
 }

+ 53 - 0
binding/validate_test.go

@@ -0,0 +1,53 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package binding
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type struct1 struct {
+	Value float64 `binding:"required"`
+}
+
+type struct2 struct {
+	RequiredValue string `binding:"required"`
+	Value         float64
+}
+
+type struct3 struct {
+	Integer    int
+	String     string
+	BasicSlice []int
+	Boolean    bool
+
+	RequiredInteger       int       `binding:"required"`
+	RequiredString        string    `binding:"required"`
+	RequiredAnotherStruct struct1   `binding:"required"`
+	RequiredBasicSlice    []int     `binding:"required"`
+	RequiredComplexSlice  []struct2 `binding:"required"`
+	RequiredBoolean       bool      `binding:"required"`
+}
+
+func createStruct() struct3 {
+	return struct3{
+		RequiredInteger:       2,
+		RequiredString:        "hello",
+		RequiredAnotherStruct: struct1{1.5},
+		RequiredBasicSlice:    []int{1, 2, 3, 4},
+		RequiredComplexSlice: []struct2{
+			{RequiredValue: "A"},
+			{RequiredValue: "B"},
+		},
+		RequiredBoolean: true,
+	}
+}
+
+func TestValidateGoodObject(t *testing.T) {
+	test := createStruct()
+	assert.Nil(t, Validate(&test))
+}

+ 1 - 4
binding/xml.go

@@ -20,8 +20,5 @@ func (_ xmlBinding) Bind(req *http.Request, obj interface{}) error {
 	if err := decoder.Decode(obj); err != nil {
 		return err
 	}
-	if err := _validator.ValidateStruct(obj); err != nil {
-		return error(err)
-	}
-	return nil
+	return Validate(obj)
 }

+ 4 - 1
context.go

@@ -61,6 +61,9 @@ func (c *Context) reset() {
 
 func (c *Context) Copy() *Context {
 	var cp Context = *c
+	cp.writermem.ResponseWriter = nil
+	cp.Writer = &cp.writermem
+	cp.Input.context = &cp
 	cp.index = AbortIndex
 	cp.handlers = nil
 	return &cp
@@ -161,7 +164,7 @@ func (c *Context) MustGet(key string) interface{} {
 	if value, exists := c.Get(key); exists {
 		return value
 	} else {
-		panic("Key " + key + " does not exist")
+		panic("Key \"" + key + "\" does not exist")
 	}
 }
 

+ 32 - 1
context_test.go

@@ -16,6 +16,11 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+// Unit tes TODO
+// func (c *Context) File(filepath string) {
+// func (c *Context) Negotiate(code int, config Negotiate) {
+// BAD case: func (c *Context) Render(code int, render render.Render, obj ...interface{}) {
+
 func createTestContext() (c *Context, w *httptest.ResponseRecorder, r *Engine) {
 	w = httptest.NewRecorder()
 	r = New()
@@ -64,6 +69,25 @@ func TestContextSetGet(t *testing.T) {
 	assert.Panics(t, func() { c.MustGet("no_exist") })
 }
 
+func TestContextCopy(t *testing.T) {
+	c, _, _ := createTestContext()
+	c.index = 2
+	c.Request, _ = http.NewRequest("POST", "/hola", nil)
+	c.handlers = []HandlerFunc{func(c *Context) {}}
+	c.Params = Params{Param{Key: "foo", Value: "bar"}}
+	c.Set("foo", "bar")
+
+	cp := c.Copy()
+	assert.Nil(t, cp.handlers)
+	assert.Equal(t, cp.Request, c.Request)
+	assert.Equal(t, cp.index, AbortIndex)
+	assert.Equal(t, &cp.writermem, cp.Writer.(*responseWriter))
+	assert.Equal(t, cp.Input.context, cp)
+	assert.Equal(t, cp.Keys, c.Keys)
+	assert.Equal(t, cp.Engine, c.Engine)
+	assert.Equal(t, cp.Params, c.Params)
+}
+
 // Tests that the response is serialized as JSON
 // and Content-Type is set to application/json
 func TestContextRenderJSON(t *testing.T) {
@@ -79,7 +103,7 @@ func TestContextRenderJSON(t *testing.T) {
 // and responds with Content-Type set to text/html
 func TestContextRenderHTML(t *testing.T) {
 	c, w, router := createTestContext()
-	templ, _ := template.New("t").Parse(`Hello {{.name}}`)
+	templ := template.Must(template.New("t").Parse(`Hello {{.name}}`))
 	router.SetHTMLTemplate(templ)
 
 	c.HTML(201, "t", H{"name": "alexandernyquist"})
@@ -160,6 +184,7 @@ func TestContextNegotiationFormat(t *testing.T) {
 	c, _, _ := createTestContext()
 	c.Request, _ = http.NewRequest("POST", "", nil)
 
+	assert.Panics(t, func() { c.NegotiateFormat() })
 	assert.Equal(t, c.NegotiateFormat(MIMEJSON, MIMEXML), MIMEJSON)
 	assert.Equal(t, c.NegotiateFormat(MIMEHTML, MIMEJSON), MIMEHTML)
 }
@@ -203,13 +228,19 @@ func TestContextAbortWithStatus(t *testing.T) {
 
 func TestContextError(t *testing.T) {
 	c, _, _ := createTestContext()
+	assert.Nil(t, c.LastError())
+	assert.Empty(t, c.Errors.String())
+
 	c.Error(errors.New("first error"), "some data")
 	assert.Equal(t, c.LastError().Error(), "first error")
 	assert.Len(t, c.Errors, 1)
+	assert.Equal(t, c.Errors.String(), "Error #01: first error\n     Meta: some data\n")
 
 	c.Error(errors.New("second error"), "some data 2")
 	assert.Equal(t, c.LastError().Error(), "second error")
 	assert.Len(t, c.Errors, 2)
+	assert.Equal(t, c.Errors.String(), "Error #01: first error\n     Meta: some data\n"+
+		"Error #02: second error\n     Meta: some data 2\n")
 
 	assert.Equal(t, c.Errors[0].Err, "first error")
 	assert.Equal(t, c.Errors[0].Meta, "some data")

+ 4 - 0
debug_test.go

@@ -10,6 +10,10 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+// TODO
+// func debugRoute(httpMethod, absolutePath string, handlers []HandlerFunc) {
+// func debugPrint(format string, values ...interface{}) {
+
 func TestIsDebugging(t *testing.T) {
 	SetMode(DebugMode)
 	assert.True(t, IsDebugging())

+ 1 - 1
errors.go

@@ -43,7 +43,7 @@ func (a errorMsgs) String() string {
 	}
 	var buffer bytes.Buffer
 	for i, msg := range a {
-		text := fmt.Sprintf("Error #%02d: %s \n     Meta: %v\n", (i + 1), msg.Err, msg.Meta)
+		text := fmt.Sprintf("Error #%02d: %s\n     Meta: %v\n", (i + 1), msg.Err, msg.Meta)
 		buffer.WriteString(text)
 	}
 	return buffer.String()

+ 0 - 49
examples/pluggable_renderer/example_pongo2.go

@@ -1,49 +0,0 @@
-package main
-
-import (
-	"net/http"
-
-	"github.com/flosch/pongo2"
-	"github.com/gin-gonic/gin"
-	"github.com/gin-gonic/gin/render"
-)
-
-func main() {
-	router := gin.Default()
-	router.HTMLRender = newPongoRender()
-
-	router.GET("/index", func(c *gin.Context) {
-		c.HTML(200, "index.html", gin.H{
-			"title": "Gin meets pongo2 !",
-			"name":  c.Input.Get("name"),
-		})
-	})
-	router.Run(":8080")
-}
-
-type pongoRender struct {
-	cache map[string]*pongo2.Template
-}
-
-func newPongoRender() *pongoRender {
-	return &pongoRender{map[string]*pongo2.Template{}}
-}
-
-func (p *pongoRender) Render(w http.ResponseWriter, code int, data ...interface{}) error {
-	file := data[0].(string)
-	ctx := data[1].(pongo2.Context)
-	var t *pongo2.Template
-
-	if tmpl, ok := p.cache[file]; ok {
-		t = tmpl
-	} else {
-		tmpl, err := pongo2.FromFile(file)
-		if err != nil {
-			return err
-		}
-		p.cache[file] = tmpl
-		t = tmpl
-	}
-	render.WriteHeader(w, code, "text/html")
-	return t.ExecuteWriter(ctx, w)
-}

+ 0 - 12
examples/pluggable_renderer/index.html

@@ -1,12 +0,0 @@
-<!DOCTYPE html>
-<html lang="ja">
-  <head>
-    <meta charset="utf-8">
-    <title>{{ title }}</title>
-    <meta name="keywords" content="">
-    <meta name="description" content="">
-  </head>
-  <body>
-    Hello {{ name }} ! 
-  </body>
-</html>

+ 7 - 0
gin.go

@@ -144,6 +144,13 @@ func (engine *Engine) handle(method, path string, handlers []HandlerFunc) {
 	if path[0] != '/' {
 		panic("path must begin with '/'")
 	}
+	if method == "" {
+		panic("HTTP method can not be empty")
+	}
+	if len(handlers) == 0 {
+		panic("there must be at least one handler")
+	}
+
 	root := engine.trees[method]
 	if root == nil {
 		root = new(node)

+ 9 - 3
gin_test.go

@@ -10,6 +10,12 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+//TODO
+// func (engine *Engine) LoadHTMLGlob(pattern string) {
+// func (engine *Engine) LoadHTMLFiles(files ...string) {
+// func (engine *Engine) Run(addr string) error {
+// func (engine *Engine) RunTLS(addr string, cert string, key string) error {
+
 func init() {
 	SetMode(TestMode)
 }
@@ -20,9 +26,9 @@ func TestCreateEngine(t *testing.T) {
 	assert.Equal(t, router.engine, router)
 	assert.Empty(t, router.Handlers)
 
-	// TODO
-	// assert.Equal(t, router.router.NotFound, router.handle404)
-	// assert.Equal(t, router.router.MethodNotAllowed, router.handle405)
+	assert.Panics(t, func() { router.handle("", "/", []HandlerFunc{func(_ *Context) {}}) })
+	assert.Panics(t, func() { router.handle("GET", "", []HandlerFunc{func(_ *Context) {}}) })
+	assert.Panics(t, func() { router.handle("GET", "/", []HandlerFunc{}) })
 }
 
 func TestCreateDefaultRouter(t *testing.T) {

+ 344 - 0
githubapi_test.go

@@ -0,0 +1,344 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package gin
+
+import (
+	"bytes"
+	"fmt"
+	"math/rand"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type route struct {
+	method string
+	path   string
+}
+
+// http://developer.github.com/v3/
+var githubAPI = []route{
+	// OAuth Authorizations
+	{"GET", "/authorizations"},
+	{"GET", "/authorizations/:id"},
+	{"POST", "/authorizations"},
+	//{"PUT", "/authorizations/clients/:client_id"},
+	//{"PATCH", "/authorizations/:id"},
+	{"DELETE", "/authorizations/:id"},
+	{"GET", "/applications/:client_id/tokens/:access_token"},
+	{"DELETE", "/applications/:client_id/tokens"},
+	{"DELETE", "/applications/:client_id/tokens/:access_token"},
+
+	// Activity
+	{"GET", "/events"},
+	{"GET", "/repos/:owner/:repo/events"},
+	{"GET", "/networks/:owner/:repo/events"},
+	{"GET", "/orgs/:org/events"},
+	{"GET", "/users/:user/received_events"},
+	{"GET", "/users/:user/received_events/public"},
+	{"GET", "/users/:user/events"},
+	{"GET", "/users/:user/events/public"},
+	{"GET", "/users/:user/events/orgs/:org"},
+	{"GET", "/feeds"},
+	{"GET", "/notifications"},
+	{"GET", "/repos/:owner/:repo/notifications"},
+	{"PUT", "/notifications"},
+	{"PUT", "/repos/:owner/:repo/notifications"},
+	{"GET", "/notifications/threads/:id"},
+	//{"PATCH", "/notifications/threads/:id"},
+	{"GET", "/notifications/threads/:id/subscription"},
+	{"PUT", "/notifications/threads/:id/subscription"},
+	{"DELETE", "/notifications/threads/:id/subscription"},
+	{"GET", "/repos/:owner/:repo/stargazers"},
+	{"GET", "/users/:user/starred"},
+	{"GET", "/user/starred"},
+	{"GET", "/user/starred/:owner/:repo"},
+	{"PUT", "/user/starred/:owner/:repo"},
+	{"DELETE", "/user/starred/:owner/:repo"},
+	{"GET", "/repos/:owner/:repo/subscribers"},
+	{"GET", "/users/:user/subscriptions"},
+	{"GET", "/user/subscriptions"},
+	{"GET", "/repos/:owner/:repo/subscription"},
+	{"PUT", "/repos/:owner/:repo/subscription"},
+	{"DELETE", "/repos/:owner/:repo/subscription"},
+	{"GET", "/user/subscriptions/:owner/:repo"},
+	{"PUT", "/user/subscriptions/:owner/:repo"},
+	{"DELETE", "/user/subscriptions/:owner/:repo"},
+
+	// Gists
+	{"GET", "/users/:user/gists"},
+	{"GET", "/gists"},
+	//{"GET", "/gists/public"},
+	//{"GET", "/gists/starred"},
+	{"GET", "/gists/:id"},
+	{"POST", "/gists"},
+	//{"PATCH", "/gists/:id"},
+	{"PUT", "/gists/:id/star"},
+	{"DELETE", "/gists/:id/star"},
+	{"GET", "/gists/:id/star"},
+	{"POST", "/gists/:id/forks"},
+	{"DELETE", "/gists/:id"},
+
+	// Git Data
+	{"GET", "/repos/:owner/:repo/git/blobs/:sha"},
+	{"POST", "/repos/:owner/:repo/git/blobs"},
+	{"GET", "/repos/:owner/:repo/git/commits/:sha"},
+	{"POST", "/repos/:owner/:repo/git/commits"},
+	//{"GET", "/repos/:owner/:repo/git/refs/*ref"},
+	{"GET", "/repos/:owner/:repo/git/refs"},
+	{"POST", "/repos/:owner/:repo/git/refs"},
+	//{"PATCH", "/repos/:owner/:repo/git/refs/*ref"},
+	//{"DELETE", "/repos/:owner/:repo/git/refs/*ref"},
+	{"GET", "/repos/:owner/:repo/git/tags/:sha"},
+	{"POST", "/repos/:owner/:repo/git/tags"},
+	{"GET", "/repos/:owner/:repo/git/trees/:sha"},
+	{"POST", "/repos/:owner/:repo/git/trees"},
+
+	// Issues
+	{"GET", "/issues"},
+	{"GET", "/user/issues"},
+	{"GET", "/orgs/:org/issues"},
+	{"GET", "/repos/:owner/:repo/issues"},
+	{"GET", "/repos/:owner/:repo/issues/:number"},
+	{"POST", "/repos/:owner/:repo/issues"},
+	//{"PATCH", "/repos/:owner/:repo/issues/:number"},
+	{"GET", "/repos/:owner/:repo/assignees"},
+	{"GET", "/repos/:owner/:repo/assignees/:assignee"},
+	{"GET", "/repos/:owner/:repo/issues/:number/comments"},
+	//{"GET", "/repos/:owner/:repo/issues/comments"},
+	//{"GET", "/repos/:owner/:repo/issues/comments/:id"},
+	{"POST", "/repos/:owner/:repo/issues/:number/comments"},
+	//{"PATCH", "/repos/:owner/:repo/issues/comments/:id"},
+	//{"DELETE", "/repos/:owner/:repo/issues/comments/:id"},
+	{"GET", "/repos/:owner/:repo/issues/:number/events"},
+	//{"GET", "/repos/:owner/:repo/issues/events"},
+	//{"GET", "/repos/:owner/:repo/issues/events/:id"},
+	{"GET", "/repos/:owner/:repo/labels"},
+	{"GET", "/repos/:owner/:repo/labels/:name"},
+	{"POST", "/repos/:owner/:repo/labels"},
+	//{"PATCH", "/repos/:owner/:repo/labels/:name"},
+	{"DELETE", "/repos/:owner/:repo/labels/:name"},
+	{"GET", "/repos/:owner/:repo/issues/:number/labels"},
+	{"POST", "/repos/:owner/:repo/issues/:number/labels"},
+	{"DELETE", "/repos/:owner/:repo/issues/:number/labels/:name"},
+	{"PUT", "/repos/:owner/:repo/issues/:number/labels"},
+	{"DELETE", "/repos/:owner/:repo/issues/:number/labels"},
+	{"GET", "/repos/:owner/:repo/milestones/:number/labels"},
+	{"GET", "/repos/:owner/:repo/milestones"},
+	{"GET", "/repos/:owner/:repo/milestones/:number"},
+	{"POST", "/repos/:owner/:repo/milestones"},
+	//{"PATCH", "/repos/:owner/:repo/milestones/:number"},
+	{"DELETE", "/repos/:owner/:repo/milestones/:number"},
+
+	// Miscellaneous
+	{"GET", "/emojis"},
+	{"GET", "/gitignore/templates"},
+	{"GET", "/gitignore/templates/:name"},
+	{"POST", "/markdown"},
+	{"POST", "/markdown/raw"},
+	{"GET", "/meta"},
+	{"GET", "/rate_limit"},
+
+	// Organizations
+	{"GET", "/users/:user/orgs"},
+	{"GET", "/user/orgs"},
+	{"GET", "/orgs/:org"},
+	//{"PATCH", "/orgs/:org"},
+	{"GET", "/orgs/:org/members"},
+	{"GET", "/orgs/:org/members/:user"},
+	{"DELETE", "/orgs/:org/members/:user"},
+	{"GET", "/orgs/:org/public_members"},
+	{"GET", "/orgs/:org/public_members/:user"},
+	{"PUT", "/orgs/:org/public_members/:user"},
+	{"DELETE", "/orgs/:org/public_members/:user"},
+	{"GET", "/orgs/:org/teams"},
+	{"GET", "/teams/:id"},
+	{"POST", "/orgs/:org/teams"},
+	//{"PATCH", "/teams/:id"},
+	{"DELETE", "/teams/:id"},
+	{"GET", "/teams/:id/members"},
+	{"GET", "/teams/:id/members/:user"},
+	{"PUT", "/teams/:id/members/:user"},
+	{"DELETE", "/teams/:id/members/:user"},
+	{"GET", "/teams/:id/repos"},
+	{"GET", "/teams/:id/repos/:owner/:repo"},
+	{"PUT", "/teams/:id/repos/:owner/:repo"},
+	{"DELETE", "/teams/:id/repos/:owner/:repo"},
+	{"GET", "/user/teams"},
+
+	// Pull Requests
+	{"GET", "/repos/:owner/:repo/pulls"},
+	{"GET", "/repos/:owner/:repo/pulls/:number"},
+	{"POST", "/repos/:owner/:repo/pulls"},
+	//{"PATCH", "/repos/:owner/:repo/pulls/:number"},
+	{"GET", "/repos/:owner/:repo/pulls/:number/commits"},
+	{"GET", "/repos/:owner/:repo/pulls/:number/files"},
+	{"GET", "/repos/:owner/:repo/pulls/:number/merge"},
+	{"PUT", "/repos/:owner/:repo/pulls/:number/merge"},
+	{"GET", "/repos/:owner/:repo/pulls/:number/comments"},
+	//{"GET", "/repos/:owner/:repo/pulls/comments"},
+	//{"GET", "/repos/:owner/:repo/pulls/comments/:number"},
+	{"PUT", "/repos/:owner/:repo/pulls/:number/comments"},
+	//{"PATCH", "/repos/:owner/:repo/pulls/comments/:number"},
+	//{"DELETE", "/repos/:owner/:repo/pulls/comments/:number"},
+
+	// Repositories
+	{"GET", "/user/repos"},
+	{"GET", "/users/:user/repos"},
+	{"GET", "/orgs/:org/repos"},
+	{"GET", "/repositories"},
+	{"POST", "/user/repos"},
+	{"POST", "/orgs/:org/repos"},
+	{"GET", "/repos/:owner/:repo"},
+	//{"PATCH", "/repos/:owner/:repo"},
+	{"GET", "/repos/:owner/:repo/contributors"},
+	{"GET", "/repos/:owner/:repo/languages"},
+	{"GET", "/repos/:owner/:repo/teams"},
+	{"GET", "/repos/:owner/:repo/tags"},
+	{"GET", "/repos/:owner/:repo/branches"},
+	{"GET", "/repos/:owner/:repo/branches/:branch"},
+	{"DELETE", "/repos/:owner/:repo"},
+	{"GET", "/repos/:owner/:repo/collaborators"},
+	{"GET", "/repos/:owner/:repo/collaborators/:user"},
+	{"PUT", "/repos/:owner/:repo/collaborators/:user"},
+	{"DELETE", "/repos/:owner/:repo/collaborators/:user"},
+	{"GET", "/repos/:owner/:repo/comments"},
+	{"GET", "/repos/:owner/:repo/commits/:sha/comments"},
+	{"POST", "/repos/:owner/:repo/commits/:sha/comments"},
+	{"GET", "/repos/:owner/:repo/comments/:id"},
+	//{"PATCH", "/repos/:owner/:repo/comments/:id"},
+	{"DELETE", "/repos/:owner/:repo/comments/:id"},
+	{"GET", "/repos/:owner/:repo/commits"},
+	{"GET", "/repos/:owner/:repo/commits/:sha"},
+	{"GET", "/repos/:owner/:repo/readme"},
+	//{"GET", "/repos/:owner/:repo/contents/*path"},
+	//{"PUT", "/repos/:owner/:repo/contents/*path"},
+	//{"DELETE", "/repos/:owner/:repo/contents/*path"},
+	//{"GET", "/repos/:owner/:repo/:archive_format/:ref"},
+	{"GET", "/repos/:owner/:repo/keys"},
+	{"GET", "/repos/:owner/:repo/keys/:id"},
+	{"POST", "/repos/:owner/:repo/keys"},
+	//{"PATCH", "/repos/:owner/:repo/keys/:id"},
+	{"DELETE", "/repos/:owner/:repo/keys/:id"},
+	{"GET", "/repos/:owner/:repo/downloads"},
+	{"GET", "/repos/:owner/:repo/downloads/:id"},
+	{"DELETE", "/repos/:owner/:repo/downloads/:id"},
+	{"GET", "/repos/:owner/:repo/forks"},
+	{"POST", "/repos/:owner/:repo/forks"},
+	{"GET", "/repos/:owner/:repo/hooks"},
+	{"GET", "/repos/:owner/:repo/hooks/:id"},
+	{"POST", "/repos/:owner/:repo/hooks"},
+	//{"PATCH", "/repos/:owner/:repo/hooks/:id"},
+	{"POST", "/repos/:owner/:repo/hooks/:id/tests"},
+	{"DELETE", "/repos/:owner/:repo/hooks/:id"},
+	{"POST", "/repos/:owner/:repo/merges"},
+	{"GET", "/repos/:owner/:repo/releases"},
+	{"GET", "/repos/:owner/:repo/releases/:id"},
+	{"POST", "/repos/:owner/:repo/releases"},
+	//{"PATCH", "/repos/:owner/:repo/releases/:id"},
+	{"DELETE", "/repos/:owner/:repo/releases/:id"},
+	{"GET", "/repos/:owner/:repo/releases/:id/assets"},
+	{"GET", "/repos/:owner/:repo/stats/contributors"},
+	{"GET", "/repos/:owner/:repo/stats/commit_activity"},
+	{"GET", "/repos/:owner/:repo/stats/code_frequency"},
+	{"GET", "/repos/:owner/:repo/stats/participation"},
+	{"GET", "/repos/:owner/:repo/stats/punch_card"},
+	{"GET", "/repos/:owner/:repo/statuses/:ref"},
+	{"POST", "/repos/:owner/:repo/statuses/:ref"},
+
+	// Search
+	{"GET", "/search/repositories"},
+	{"GET", "/search/code"},
+	{"GET", "/search/issues"},
+	{"GET", "/search/users"},
+	{"GET", "/legacy/issues/search/:owner/:repository/:state/:keyword"},
+	{"GET", "/legacy/repos/search/:keyword"},
+	{"GET", "/legacy/user/search/:keyword"},
+	{"GET", "/legacy/user/email/:email"},
+
+	// Users
+	{"GET", "/users/:user"},
+	{"GET", "/user"},
+	//{"PATCH", "/user"},
+	{"GET", "/users"},
+	{"GET", "/user/emails"},
+	{"POST", "/user/emails"},
+	{"DELETE", "/user/emails"},
+	{"GET", "/users/:user/followers"},
+	{"GET", "/user/followers"},
+	{"GET", "/users/:user/following"},
+	{"GET", "/user/following"},
+	{"GET", "/user/following/:user"},
+	{"GET", "/users/:user/following/:target_user"},
+	{"PUT", "/user/following/:user"},
+	{"DELETE", "/user/following/:user"},
+	{"GET", "/users/:user/keys"},
+	{"GET", "/user/keys"},
+	{"GET", "/user/keys/:id"},
+	{"POST", "/user/keys"},
+	//{"PATCH", "/user/keys/:id"},
+	{"DELETE", "/user/keys/:id"},
+}
+
+func TestGithubAPI(t *testing.T) {
+	router := New()
+
+	for _, route := range githubAPI {
+		router.Handle(route.method, route.path, []HandlerFunc{func(c *Context) {
+			output := H{"status": "good"}
+			for _, param := range c.Params {
+				output[param.Key] = param.Value
+			}
+			c.JSON(200, output)
+		}})
+	}
+
+	for _, route := range githubAPI {
+		path, values := exampleFromPath(route.path)
+		w := performRequest(router, route.method, path)
+
+		// TEST
+		assert.Contains(t, w.Body.String(), "\"status\":\"good\"")
+		for _, value := range values {
+			str := fmt.Sprintf("\"%s\":\"%s\"", value.Key, value.Value)
+			assert.Contains(t, w.Body.String(), str)
+		}
+	}
+}
+
+func exampleFromPath(path string) (string, Params) {
+	output := new(bytes.Buffer)
+	params := make(Params, 0, 6)
+	start := -1
+	for i, c := range path {
+		if c == ':' {
+			start = i + 1
+		}
+		if start >= 0 {
+			if c == '/' {
+				value := fmt.Sprint(rand.Intn(100000))
+				params = append(params, Param{
+					Key:   path[start:i],
+					Value: value,
+				})
+				output.WriteString(value)
+				output.WriteRune(c)
+				start = -1
+			}
+		} else {
+			output.WriteRune(c)
+		}
+	}
+	if start >= 0 {
+		value := fmt.Sprint(rand.Intn(100000))
+		params = append(params, Param{
+			Key:   path[start:len(path)],
+			Value: value,
+		})
+		output.WriteString(value)
+	}
+
+	return output.String(), params
+}

+ 35 - 0
logger_test.go

@@ -0,0 +1,35 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package gin
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+//TODO
+// func (engine *Engine) LoadHTMLGlob(pattern string) {
+// func (engine *Engine) LoadHTMLFiles(files ...string) {
+// func (engine *Engine) Run(addr string) error {
+// func (engine *Engine) RunTLS(addr string, cert string, key string) error {
+
+func init() {
+	SetMode(TestMode)
+}
+
+func TestLogger(t *testing.T) {
+	buffer := new(bytes.Buffer)
+	router := New()
+	router.Use(LoggerWithFile(buffer))
+	router.GET("/example", func(c *Context) {})
+
+	performRequest(router, "GET", "/example")
+
+	assert.Contains(t, buffer.String(), "200")
+	assert.Contains(t, buffer.String(), "GET")
+	assert.Contains(t, buffer.String(), "/example")
+}

+ 88 - 0
path_test.go

@@ -0,0 +1,88 @@
+// Copyright 2013 Julien Schmidt. All rights reserved.
+// Based on the path package, Copyright 2009 The Go Authors.
+// Use of this source code is governed by a BSD-style license that can be found
+// in the LICENSE file.
+
+package gin
+
+import (
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var cleanTests = []struct {
+	path, result string
+}{
+	// Already clean
+	{"/", "/"},
+	{"/abc", "/abc"},
+	{"/a/b/c", "/a/b/c"},
+	{"/abc/", "/abc/"},
+	{"/a/b/c/", "/a/b/c/"},
+
+	// missing root
+	{"", "/"},
+	{"abc", "/abc"},
+	{"abc/def", "/abc/def"},
+	{"a/b/c", "/a/b/c"},
+
+	// Remove doubled slash
+	{"//", "/"},
+	{"/abc//", "/abc/"},
+	{"/abc/def//", "/abc/def/"},
+	{"/a/b/c//", "/a/b/c/"},
+	{"/abc//def//ghi", "/abc/def/ghi"},
+	{"//abc", "/abc"},
+	{"///abc", "/abc"},
+	{"//abc//", "/abc/"},
+
+	// Remove . elements
+	{".", "/"},
+	{"./", "/"},
+	{"/abc/./def", "/abc/def"},
+	{"/./abc/def", "/abc/def"},
+	{"/abc/.", "/abc/"},
+
+	// Remove .. elements
+	{"..", "/"},
+	{"../", "/"},
+	{"../../", "/"},
+	{"../..", "/"},
+	{"../../abc", "/abc"},
+	{"/abc/def/ghi/../jkl", "/abc/def/jkl"},
+	{"/abc/def/../ghi/../jkl", "/abc/jkl"},
+	{"/abc/def/..", "/abc"},
+	{"/abc/def/../..", "/"},
+	{"/abc/def/../../..", "/"},
+	{"/abc/def/../../..", "/"},
+	{"/abc/def/../../../ghi/jkl/../../../mno", "/mno"},
+
+	// Combinations
+	{"abc/./../def", "/def"},
+	{"abc//./../def", "/def"},
+	{"abc/../../././../def", "/def"},
+}
+
+func TestPathClean(t *testing.T) {
+	for _, test := range cleanTests {
+		assert.Equal(t, CleanPath(test.path), test.result)
+		assert.Equal(t, CleanPath(test.result), test.result)
+	}
+}
+
+func TestPathCleanMallocs(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping malloc count in short mode")
+	}
+	if runtime.GOMAXPROCS(0) > 1 {
+		t.Log("skipping AllocsPerRun checks; GOMAXPROCS>1")
+		return
+	}
+
+	for _, test := range cleanTests {
+		allocs := testing.AllocsPerRun(100, func() { CleanPath(test.result) })
+		assert.Equal(t, allocs, 0)
+	}
+}

+ 79 - 0
render/render_test.go

@@ -0,0 +1,79 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package render
+
+import (
+	"html/template"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRenderJSON(t *testing.T) {
+	w := httptest.NewRecorder()
+	err := JSON.Render(w, 201, map[string]interface{}{
+		"foo": "bar",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, w.Code, 201)
+	assert.Equal(t, w.Body.String(), "{\"foo\":\"bar\"}\n")
+	assert.Equal(t, w.Header().Get("Content-Type"), "application/json; charset=utf-8")
+}
+
+func TestRenderIndentedJSON(t *testing.T) {
+	w := httptest.NewRecorder()
+	err := IndentedJSON.Render(w, 202, map[string]interface{}{
+		"foo": "bar",
+		"bar": "foo",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, w.Code, 202)
+	assert.Equal(t, w.Body.String(), "{\n    \"bar\": \"foo\",\n    \"foo\": \"bar\"\n}")
+	assert.Equal(t, w.Header().Get("Content-Type"), "application/json; charset=utf-8")
+}
+
+func TestRenderPlain(t *testing.T) {
+	w := httptest.NewRecorder()
+	err := Plain.Render(w, 400, "hola %s %d", []interface{}{"manu", 2})
+
+	assert.NoError(t, err)
+	assert.Equal(t, w.Code, 400)
+	assert.Equal(t, w.Body.String(), "hola manu 2")
+	assert.Equal(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8")
+}
+
+func TestRenderPlainHTML(t *testing.T) {
+	w := httptest.NewRecorder()
+	err := HTMLPlain.Render(w, 401, "hola %s %d", []interface{}{"manu", 2})
+
+	assert.NoError(t, err)
+	assert.Equal(t, w.Code, 401)
+	assert.Equal(t, w.Body.String(), "hola manu 2")
+	assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8")
+}
+
+func TestRenderHTMLTemplate(t *testing.T) {
+	w := httptest.NewRecorder()
+	templ := template.Must(template.New("t").Parse(`Hello {{.name}}`))
+	htmlRender := HTMLRender{Template: templ}
+	err := htmlRender.Render(w, 402, "t", map[string]interface{}{
+		"name": "alexandernyquist",
+	})
+
+	assert.NoError(t, err)
+	assert.Equal(t, w.Code, 402)
+	assert.Equal(t, w.Body.String(), "Hello alexandernyquist")
+	assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8")
+}
+
+func TestRenderJoinStrings(t *testing.T) {
+	assert.Equal(t, joinStrings("a", "BB", "c"), "aBBc")
+	assert.Equal(t, joinStrings("a", "", "c"), "ac")
+	assert.Equal(t, joinStrings("text/html", "; charset=utf-8"), "text/html; charset=utf-8")
+
+}

+ 5 - 0
response_writer_test.go

@@ -12,6 +12,11 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+// TODO
+// func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+// func (w *responseWriter) CloseNotify() <-chan bool {
+// func (w *responseWriter) Flush() {
+
 var _ ResponseWriter = &responseWriter{}
 var _ http.ResponseWriter = &responseWriter{}
 var _ http.ResponseWriter = ResponseWriter(&responseWriter{})

+ 4 - 3
routergroup.go

@@ -119,9 +119,10 @@ func (group *RouterGroup) createStaticHandler(absolutePath, root string) func(*C
 
 func (group *RouterGroup) combineHandlers(handlers []HandlerFunc) []HandlerFunc {
 	finalSize := len(group.Handlers) + len(handlers)
-	mergedHandlers := make([]HandlerFunc, 0, finalSize)
-	mergedHandlers = append(mergedHandlers, group.Handlers...)
-	return append(mergedHandlers, handlers...)
+	mergedHandlers := make([]HandlerFunc, finalSize)
+	copy(mergedHandlers, group.Handlers)
+	copy(mergedHandlers[len(group.Handlers):], handlers)
+	return mergedHandlers
 }
 
 func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {

+ 98 - 0
routergroup_test.go

@@ -0,0 +1,98 @@
+// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.
+// Use of this source code is governed by a MIT style
+// license that can be found in the LICENSE file.
+
+package gin
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func init() {
+	SetMode(TestMode)
+}
+
+func TestRouterGroupBasic(t *testing.T) {
+	router := New()
+	group := router.Group("/hola", func(c *Context) {})
+	group.Use(func(c *Context) {})
+
+	assert.Len(t, group.Handlers, 2)
+	assert.Equal(t, group.absolutePath, "/hola")
+	assert.Equal(t, group.engine, router)
+
+	group2 := group.Group("manu")
+	group2.Use(func(c *Context) {}, func(c *Context) {})
+
+	assert.Len(t, group2.Handlers, 4)
+	assert.Equal(t, group2.absolutePath, "/hola/manu")
+	assert.Equal(t, group2.engine, router)
+}
+
+func TestRouterGroupBasicHandle(t *testing.T) {
+	performRequestInGroup(t, "GET")
+	performRequestInGroup(t, "POST")
+	performRequestInGroup(t, "PUT")
+	performRequestInGroup(t, "PATCH")
+	performRequestInGroup(t, "DELETE")
+	performRequestInGroup(t, "HEAD")
+	performRequestInGroup(t, "OPTIONS")
+	performRequestInGroup(t, "LINK")
+	performRequestInGroup(t, "UNLINK")
+
+}
+
+func performRequestInGroup(t *testing.T, method string) {
+	router := New()
+	v1 := router.Group("v1", func(c *Context) {})
+	assert.Equal(t, v1.absolutePath, "/v1")
+
+	login := v1.Group("/login/", func(c *Context) {}, func(c *Context) {})
+	assert.Equal(t, login.absolutePath, "/v1/login/")
+
+	handler := func(c *Context) {
+		c.String(400, "the method was %s and index %d", c.Request.Method, c.index)
+	}
+
+	switch method {
+	case "GET":
+		v1.GET("/test", handler)
+		login.GET("/test", handler)
+	case "POST":
+		v1.POST("/test", handler)
+		login.POST("/test", handler)
+	case "PUT":
+		v1.PUT("/test", handler)
+		login.PUT("/test", handler)
+	case "PATCH":
+		v1.PATCH("/test", handler)
+		login.PATCH("/test", handler)
+	case "DELETE":
+		v1.DELETE("/test", handler)
+		login.DELETE("/test", handler)
+	case "HEAD":
+		v1.HEAD("/test", handler)
+		login.HEAD("/test", handler)
+	case "OPTIONS":
+		v1.OPTIONS("/test", handler)
+		login.OPTIONS("/test", handler)
+	case "LINK":
+		v1.LINK("/test", handler)
+		login.LINK("/test", handler)
+	case "UNLINK":
+		v1.UNLINK("/test", handler)
+		login.UNLINK("/test", handler)
+	default:
+		panic("unknown method")
+	}
+
+	w := performRequest(router, method, "/v1/login/test")
+	assert.Equal(t, w.Code, 400)
+	assert.Equal(t, w.Body.String(), "the method was "+method+" and index 3")
+
+	w = performRequest(router, method, "/v1/test")
+	assert.Equal(t, w.Code, 400)
+	assert.Equal(t, w.Body.String(), "the method was "+method+" and index 1")
+}

+ 608 - 0
tree_test.go

@@ -0,0 +1,608 @@
+// Copyright 2013 Julien Schmidt. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be found
+// in the LICENSE file.
+
+package gin
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func printChildren(n *node, prefix string) {
+	fmt.Printf(" %02d:%02d %s%s[%d] %v %t %d \r\n", n.priority, n.maxParams, prefix, n.path, len(n.children), n.handlers, n.wildChild, n.nType)
+	for l := len(n.path); l > 0; l-- {
+		prefix += " "
+	}
+	for _, child := range n.children {
+		printChildren(child, prefix)
+	}
+}
+
+// Used as a workaround since we can't compare functions or their adresses
+var fakeHandlerValue string
+
+func fakeHandler(val string) []HandlerFunc {
+	return []HandlerFunc{func(c *Context) {
+		fakeHandlerValue = val
+	}}
+}
+
+type testRequests []struct {
+	path       string
+	nilHandler bool
+	route      string
+	ps         Params
+}
+
+func checkRequests(t *testing.T, tree *node, requests testRequests) {
+	for _, request := range requests {
+		handler, ps, _ := tree.getValue(request.path, nil)
+
+		if handler == nil {
+			if !request.nilHandler {
+				t.Errorf("handle mismatch for route '%s': Expected non-nil handle", request.path)
+			}
+		} else if request.nilHandler {
+			t.Errorf("handle mismatch for route '%s': Expected nil handle", request.path)
+		} else {
+			handler[0](nil)
+			if fakeHandlerValue != request.route {
+				t.Errorf("handle mismatch for route '%s': Wrong handle (%s != %s)", request.path, fakeHandlerValue, request.route)
+			}
+		}
+
+		if !reflect.DeepEqual(ps, request.ps) {
+			t.Errorf("Params mismatch for route '%s'", request.path)
+		}
+	}
+}
+
+func checkPriorities(t *testing.T, n *node) uint32 {
+	var prio uint32
+	for i := range n.children {
+		prio += checkPriorities(t, n.children[i])
+	}
+
+	if n.handlers != nil {
+		prio++
+	}
+
+	if n.priority != prio {
+		t.Errorf(
+			"priority mismatch for node '%s': is %d, should be %d",
+			n.path, n.priority, prio,
+		)
+	}
+
+	return prio
+}
+
+func checkMaxParams(t *testing.T, n *node) uint8 {
+	var maxParams uint8
+	for i := range n.children {
+		params := checkMaxParams(t, n.children[i])
+		if params > maxParams {
+			maxParams = params
+		}
+	}
+	if n.nType != static && !n.wildChild {
+		maxParams++
+	}
+
+	if n.maxParams != maxParams {
+		t.Errorf(
+			"maxParams mismatch for node '%s': is %d, should be %d",
+			n.path, n.maxParams, maxParams,
+		)
+	}
+
+	return maxParams
+}
+
+func TestCountParams(t *testing.T) {
+	if countParams("/path/:param1/static/*catch-all") != 2 {
+		t.Fail()
+	}
+	if countParams(strings.Repeat("/:param", 256)) != 255 {
+		t.Fail()
+	}
+}
+
+func TestTreeAddAndGet(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/hi",
+		"/contact",
+		"/co",
+		"/c",
+		"/a",
+		"/ab",
+		"/doc/",
+		"/doc/go_faq.html",
+		"/doc/go1.html",
+		"/α",
+		"/β",
+	}
+	for _, route := range routes {
+		tree.addRoute(route, fakeHandler(route))
+	}
+
+	//printChildren(tree, "")
+
+	checkRequests(t, tree, testRequests{
+		{"/a", false, "/a", nil},
+		{"/", true, "", nil},
+		{"/hi", false, "/hi", nil},
+		{"/contact", false, "/contact", nil},
+		{"/co", false, "/co", nil},
+		{"/con", true, "", nil},  // key mismatch
+		{"/cona", true, "", nil}, // key mismatch
+		{"/no", true, "", nil},   // no matching child
+		{"/ab", false, "/ab", nil},
+		{"/α", false, "/α", nil},
+		{"/β", false, "/β", nil},
+	})
+
+	checkPriorities(t, tree)
+	checkMaxParams(t, tree)
+}
+
+func TestTreeWildcard(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/",
+		"/cmd/:tool/:sub",
+		"/cmd/:tool/",
+		"/src/*filepath",
+		"/search/",
+		"/search/:query",
+		"/user_:name",
+		"/user_:name/about",
+		"/files/:dir/*filepath",
+		"/doc/",
+		"/doc/go_faq.html",
+		"/doc/go1.html",
+		"/info/:user/public",
+		"/info/:user/project/:project",
+	}
+	for _, route := range routes {
+		tree.addRoute(route, fakeHandler(route))
+	}
+
+	//printChildren(tree, "")
+
+	checkRequests(t, tree, testRequests{
+		{"/", false, "/", nil},
+		{"/cmd/test/", false, "/cmd/:tool/", Params{Param{"tool", "test"}}},
+		{"/cmd/test", true, "", Params{Param{"tool", "test"}}},
+		{"/cmd/test/3", false, "/cmd/:tool/:sub", Params{Param{"tool", "test"}, Param{"sub", "3"}}},
+		{"/src/", false, "/src/*filepath", Params{Param{"filepath", "/"}}},
+		{"/src/some/file.png", false, "/src/*filepath", Params{Param{"filepath", "/some/file.png"}}},
+		{"/search/", false, "/search/", nil},
+		{"/search/someth!ng+in+ünìcodé", false, "/search/:query", Params{Param{"query", "someth!ng+in+ünìcodé"}}},
+		{"/search/someth!ng+in+ünìcodé/", true, "", Params{Param{"query", "someth!ng+in+ünìcodé"}}},
+		{"/user_gopher", false, "/user_:name", Params{Param{"name", "gopher"}}},
+		{"/user_gopher/about", false, "/user_:name/about", Params{Param{"name", "gopher"}}},
+		{"/files/js/inc/framework.js", false, "/files/:dir/*filepath", Params{Param{"dir", "js"}, Param{"filepath", "/inc/framework.js"}}},
+		{"/info/gordon/public", false, "/info/:user/public", Params{Param{"user", "gordon"}}},
+		{"/info/gordon/project/go", false, "/info/:user/project/:project", Params{Param{"user", "gordon"}, Param{"project", "go"}}},
+	})
+
+	checkPriorities(t, tree)
+	checkMaxParams(t, tree)
+}
+
+func catchPanic(testFunc func()) (recv interface{}) {
+	defer func() {
+		recv = recover()
+	}()
+
+	testFunc()
+	return
+}
+
+type testRoute struct {
+	path     string
+	conflict bool
+}
+
+func testRoutes(t *testing.T, routes []testRoute) {
+	tree := &node{}
+
+	for _, route := range routes {
+		recv := catchPanic(func() {
+			tree.addRoute(route.path, nil)
+		})
+
+		if route.conflict {
+			if recv == nil {
+				t.Errorf("no panic for conflicting route '%s'", route.path)
+			}
+		} else if recv != nil {
+			t.Errorf("unexpected panic for route '%s': %v", route.path, recv)
+		}
+	}
+
+	//printChildren(tree, "")
+}
+
+func TestTreeWildcardConflict(t *testing.T) {
+	routes := []testRoute{
+		{"/cmd/:tool/:sub", false},
+		{"/cmd/vet", true},
+		{"/src/*filepath", false},
+		{"/src/*filepathx", true},
+		{"/src/", true},
+		{"/src1/", false},
+		{"/src1/*filepath", true},
+		{"/src2*filepath", true},
+		{"/search/:query", false},
+		{"/search/invalid", true},
+		{"/user_:name", false},
+		{"/user_x", true},
+		{"/user_:name", false},
+		{"/id:id", false},
+		{"/id/:id", true},
+	}
+	testRoutes(t, routes)
+}
+
+func TestTreeChildConflict(t *testing.T) {
+	routes := []testRoute{
+		{"/cmd/vet", false},
+		{"/cmd/:tool/:sub", true},
+		{"/src/AUTHORS", false},
+		{"/src/*filepath", true},
+		{"/user_x", false},
+		{"/user_:name", true},
+		{"/id/:id", false},
+		{"/id:id", true},
+		{"/:id", true},
+		{"/*filepath", true},
+	}
+	testRoutes(t, routes)
+}
+
+func TestTreeDupliatePath(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/",
+		"/doc/",
+		"/src/*filepath",
+		"/search/:query",
+		"/user_:name",
+	}
+	for _, route := range routes {
+		recv := catchPanic(func() {
+			tree.addRoute(route, fakeHandler(route))
+		})
+		if recv != nil {
+			t.Fatalf("panic inserting route '%s': %v", route, recv)
+		}
+
+		// Add again
+		recv = catchPanic(func() {
+			tree.addRoute(route, nil)
+		})
+		if recv == nil {
+			t.Fatalf("no panic while inserting duplicate route '%s", route)
+		}
+	}
+
+	//printChildren(tree, "")
+
+	checkRequests(t, tree, testRequests{
+		{"/", false, "/", nil},
+		{"/doc/", false, "/doc/", nil},
+		{"/src/some/file.png", false, "/src/*filepath", Params{Param{"filepath", "/some/file.png"}}},
+		{"/search/someth!ng+in+ünìcodé", false, "/search/:query", Params{Param{"query", "someth!ng+in+ünìcodé"}}},
+		{"/user_gopher", false, "/user_:name", Params{Param{"name", "gopher"}}},
+	})
+}
+
+func TestEmptyWildcardName(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/user:",
+		"/user:/",
+		"/cmd/:/",
+		"/src/*",
+	}
+	for _, route := range routes {
+		recv := catchPanic(func() {
+			tree.addRoute(route, nil)
+		})
+		if recv == nil {
+			t.Fatalf("no panic while inserting route with empty wildcard name '%s", route)
+		}
+	}
+}
+
+func TestTreeCatchAllConflict(t *testing.T) {
+	routes := []testRoute{
+		{"/src/*filepath/x", true},
+		{"/src2/", false},
+		{"/src2/*filepath/x", true},
+	}
+	testRoutes(t, routes)
+}
+
+func TestTreeCatchAllConflictRoot(t *testing.T) {
+	routes := []testRoute{
+		{"/", false},
+		{"/*filepath", true},
+	}
+	testRoutes(t, routes)
+}
+
+func TestTreeDoubleWildcard(t *testing.T) {
+	const panicMsg = "only one wildcard per path segment is allowed"
+
+	routes := [...]string{
+		"/:foo:bar",
+		"/:foo:bar/",
+		"/:foo*bar",
+	}
+
+	for _, route := range routes {
+		tree := &node{}
+		recv := catchPanic(func() {
+			tree.addRoute(route, nil)
+		})
+
+		if rs, ok := recv.(string); !ok || rs != panicMsg {
+			t.Fatalf(`"Expected panic "%s" for route '%s', got "%v"`, panicMsg, route, recv)
+		}
+	}
+}
+
+/*func TestTreeDuplicateWildcard(t *testing.T) {
+    tree := &node{}
+
+    routes := [...]string{
+        "/:id/:name/:id",
+    }
+    for _, route := range routes {
+        ...
+    }
+}*/
+
+func TestTreeTrailingSlashRedirect(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/hi",
+		"/b/",
+		"/search/:query",
+		"/cmd/:tool/",
+		"/src/*filepath",
+		"/x",
+		"/x/y",
+		"/y/",
+		"/y/z",
+		"/0/:id",
+		"/0/:id/1",
+		"/1/:id/",
+		"/1/:id/2",
+		"/aa",
+		"/a/",
+		"/doc",
+		"/doc/go_faq.html",
+		"/doc/go1.html",
+		"/no/a",
+		"/no/b",
+		"/api/hello/:name",
+	}
+	for _, route := range routes {
+		recv := catchPanic(func() {
+			tree.addRoute(route, fakeHandler(route))
+		})
+		if recv != nil {
+			t.Fatalf("panic inserting route '%s': %v", route, recv)
+		}
+	}
+
+	//printChildren(tree, "")
+
+	tsrRoutes := [...]string{
+		"/hi/",
+		"/b",
+		"/search/gopher/",
+		"/cmd/vet",
+		"/src",
+		"/x/",
+		"/y",
+		"/0/go/",
+		"/1/go",
+		"/a",
+		"/doc/",
+	}
+	for _, route := range tsrRoutes {
+		handler, _, tsr := tree.getValue(route, nil)
+		if handler != nil {
+			t.Fatalf("non-nil handler for TSR route '%s", route)
+		} else if !tsr {
+			t.Errorf("expected TSR recommendation for route '%s'", route)
+		}
+	}
+
+	noTsrRoutes := [...]string{
+		"/",
+		"/no",
+		"/no/",
+		"/_",
+		"/_/",
+		"/api/world/abc",
+	}
+	for _, route := range noTsrRoutes {
+		handler, _, tsr := tree.getValue(route, nil)
+		if handler != nil {
+			t.Fatalf("non-nil handler for No-TSR route '%s", route)
+		} else if tsr {
+			t.Errorf("expected no TSR recommendation for route '%s'", route)
+		}
+	}
+}
+
+func TestTreeFindCaseInsensitivePath(t *testing.T) {
+	tree := &node{}
+
+	routes := [...]string{
+		"/hi",
+		"/b/",
+		"/ABC/",
+		"/search/:query",
+		"/cmd/:tool/",
+		"/src/*filepath",
+		"/x",
+		"/x/y",
+		"/y/",
+		"/y/z",
+		"/0/:id",
+		"/0/:id/1",
+		"/1/:id/",
+		"/1/:id/2",
+		"/aa",
+		"/a/",
+		"/doc",
+		"/doc/go_faq.html",
+		"/doc/go1.html",
+		"/doc/go/away",
+		"/no/a",
+		"/no/b",
+	}
+
+	for _, route := range routes {
+		recv := catchPanic(func() {
+			tree.addRoute(route, fakeHandler(route))
+		})
+		if recv != nil {
+			t.Fatalf("panic inserting route '%s': %v", route, recv)
+		}
+	}
+
+	// Check out == in for all registered routes
+	// With fixTrailingSlash = true
+	for _, route := range routes {
+		out, found := tree.findCaseInsensitivePath(route, true)
+		if !found {
+			t.Errorf("Route '%s' not found!", route)
+		} else if string(out) != route {
+			t.Errorf("Wrong result for route '%s': %s", route, string(out))
+		}
+	}
+	// With fixTrailingSlash = false
+	for _, route := range routes {
+		out, found := tree.findCaseInsensitivePath(route, false)
+		if !found {
+			t.Errorf("Route '%s' not found!", route)
+		} else if string(out) != route {
+			t.Errorf("Wrong result for route '%s': %s", route, string(out))
+		}
+	}
+
+	tests := []struct {
+		in    string
+		out   string
+		found bool
+		slash bool
+	}{
+		{"/HI", "/hi", true, false},
+		{"/HI/", "/hi", true, true},
+		{"/B", "/b/", true, true},
+		{"/B/", "/b/", true, false},
+		{"/abc", "/ABC/", true, true},
+		{"/abc/", "/ABC/", true, false},
+		{"/aBc", "/ABC/", true, true},
+		{"/aBc/", "/ABC/", true, false},
+		{"/abC", "/ABC/", true, true},
+		{"/abC/", "/ABC/", true, false},
+		{"/SEARCH/QUERY", "/search/QUERY", true, false},
+		{"/SEARCH/QUERY/", "/search/QUERY", true, true},
+		{"/CMD/TOOL/", "/cmd/TOOL/", true, false},
+		{"/CMD/TOOL", "/cmd/TOOL/", true, true},
+		{"/SRC/FILE/PATH", "/src/FILE/PATH", true, false},
+		{"/x/Y", "/x/y", true, false},
+		{"/x/Y/", "/x/y", true, true},
+		{"/X/y", "/x/y", true, false},
+		{"/X/y/", "/x/y", true, true},
+		{"/X/Y", "/x/y", true, false},
+		{"/X/Y/", "/x/y", true, true},
+		{"/Y/", "/y/", true, false},
+		{"/Y", "/y/", true, true},
+		{"/Y/z", "/y/z", true, false},
+		{"/Y/z/", "/y/z", true, true},
+		{"/Y/Z", "/y/z", true, false},
+		{"/Y/Z/", "/y/z", true, true},
+		{"/y/Z", "/y/z", true, false},
+		{"/y/Z/", "/y/z", true, true},
+		{"/Aa", "/aa", true, false},
+		{"/Aa/", "/aa", true, true},
+		{"/AA", "/aa", true, false},
+		{"/AA/", "/aa", true, true},
+		{"/aA", "/aa", true, false},
+		{"/aA/", "/aa", true, true},
+		{"/A/", "/a/", true, false},
+		{"/A", "/a/", true, true},
+		{"/DOC", "/doc", true, false},
+		{"/DOC/", "/doc", true, true},
+		{"/NO", "", false, true},
+		{"/DOC/GO", "", false, true},
+	}
+	// With fixTrailingSlash = true
+	for _, test := range tests {
+		out, found := tree.findCaseInsensitivePath(test.in, true)
+		if found != test.found || (found && (string(out) != test.out)) {
+			t.Errorf("Wrong result for '%s': got %s, %t; want %s, %t",
+				test.in, string(out), found, test.out, test.found)
+			return
+		}
+	}
+	// With fixTrailingSlash = false
+	for _, test := range tests {
+		out, found := tree.findCaseInsensitivePath(test.in, false)
+		if test.slash {
+			if found { // test needs a trailingSlash fix. It must not be found!
+				t.Errorf("Found without fixTrailingSlash: %s; got %s", test.in, string(out))
+			}
+		} else {
+			if found != test.found || (found && (string(out) != test.out)) {
+				t.Errorf("Wrong result for '%s': got %s, %t; want %s, %t",
+					test.in, string(out), found, test.out, test.found)
+				return
+			}
+		}
+	}
+}
+
+func TestTreeInvalidNodeType(t *testing.T) {
+	tree := &node{}
+	tree.addRoute("/", fakeHandler("/"))
+	tree.addRoute("/:page", fakeHandler("/:page"))
+
+	// set invalid node type
+	tree.children[0].nType = 42
+
+	// normal lookup
+	recv := catchPanic(func() {
+		tree.getValue("/test", nil)
+	})
+	if rs, ok := recv.(string); !ok || rs != "Invalid node type" {
+		t.Fatalf(`Expected panic "Invalid node type", got "%v"`, recv)
+	}
+
+	// case-insensitive lookup
+	recv = catchPanic(func() {
+		tree.findCaseInsensitivePath("/test", true)
+	})
+	if rs, ok := recv.(string); !ok || rs != "Invalid node type" {
+		t.Fatalf(`Expected panic "Invalid node type", got "%v"`, recv)
+	}
+}

+ 10 - 0
utils_test.go

@@ -45,7 +45,17 @@ func TestFilterFlags(t *testing.T) {
 	assert.Equal(t, result, "text/html")
 }
 
+func TestFunctionName(t *testing.T) {
+	assert.Equal(t, nameOfFunction(somefunction), "github.com/gin-gonic/gin.somefunction")
+}
+
+func somefunction() {
+
+}
+
 func TestJoinPaths(t *testing.T) {
+	assert.Equal(t, joinPaths("", ""), "")
+	assert.Equal(t, joinPaths("", "/"), "/")
 	assert.Equal(t, joinPaths("/a", ""), "/a")
 	assert.Equal(t, joinPaths("/a/", ""), "/a/")
 	assert.Equal(t, joinPaths("/a/", "/"), "/a/")