diff --git a/go.mod b/go.mod index 426b70ab7a6..c2d7f1200f5 100644 --- a/go.mod +++ b/go.mod @@ -107,3 +107,5 @@ require ( ) replace github.com/apache/thrift => github.com/apache/thrift v0.14.1 + +replace gopkg.in/macaron.v1 v1.4.0 => ./pkg/macaron diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 4f7a4b8d090..b500639d15f 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -144,6 +144,9 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna // GET /api/snapshots/:key func GetDashboardSnapshot(c *models.ReqContext) response.Response { key := c.Params(":key") + if len(key) == 0 { + return response.Error(404, "Snapshot not found", nil) + } query := &models.GetDashboardSnapshotQuery{Key: key} err := bus.Dispatch(query) @@ -210,6 +213,9 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { // GET /api/snapshots-delete/:deleteKey func DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) response.Response { key := c.Params(":deleteKey") + if len(key) == 0 { + return response.Error(404, "Snapshot not found", nil) + } query := &models.GetDashboardSnapshotQuery{DeleteKey: key} @@ -240,6 +246,9 @@ func DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) response.Response // DELETE /api/snapshots/:key func DeleteDashboardSnapshot(c *models.ReqContext) response.Response { key := c.Params(":key") + if len(key) == 0 { + return response.Error(404, "Snapshot not found", nil) + } query := &models.GetDashboardSnapshotQuery{Key: key} diff --git a/pkg/macaron/LICENSE b/pkg/macaron/LICENSE new file mode 100755 index 00000000000..c8a16eb2eb9 --- /dev/null +++ b/pkg/macaron/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright 2014 The Macaron Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pkg/macaron/README.md b/pkg/macaron/README.md new file mode 100755 index 00000000000..22856be3524 --- /dev/null +++ b/pkg/macaron/README.md @@ -0,0 +1,96 @@ +# Macaron + +[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/go-macaron/macaron/Go?logo=github&style=for-the-badge)](https://github.com/go-macaron/macaron/actions?query=workflow%3AGo) +[![codecov](https://img.shields.io/codecov/c/github/go-macaron/macaron/master?logo=codecov&style=for-the-badge)](https://codecov.io/gh/go-macaron/macaron) +[![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/gopkg.in/macaron.v1?tab=doc) +[![Sourcegraph](https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?style=for-the-badge&logo=sourcegraph)](https://sourcegraph.com/github.com/go-macaron/macaron) + +![Macaron Logo](https://raw.githubusercontent.com/go-macaron/macaron/v1/macaronlogo.png) + +Package macaron is a high productive and modular web framework in Go. + +## Getting Started + +The minimum requirement of Go is **1.6**. + +To install Macaron: + + go get gopkg.in/macaron.v1 + +The very basic usage of Macaron: + +```go +package main + +import "gopkg.in/macaron.v1" + +func main() { + m := macaron.Classic() + m.Get("/", func() string { + return "Hello world!" + }) + m.Run() +} +``` + +## Features + +- Powerful routing with suburl. +- Flexible routes combinations. +- Unlimited nested group routers. +- Directly integrate with existing services. +- Dynamically change template files at runtime. +- Allow to use in-memory template and static files. +- Easy to plugin/unplugin features with modular design. +- Handy dependency injection powered by [inject](https://github.com/codegangsta/inject). +- Better router layer and less reflection make faster speed. + +## Middlewares + +Middlewares allow you easily plugin/unplugin features for your Macaron applications. + +There are already many [middlewares](https://github.com/go-macaron) to simplify your work: + +- render - Go template engine +- static - Serves static files +- [gzip](https://github.com/go-macaron/gzip) - Gzip compression to all responses +- [binding](https://github.com/go-macaron/binding) - Request data binding and validation +- [i18n](https://github.com/go-macaron/i18n) - Internationalization and Localization +- [cache](https://github.com/go-macaron/cache) - Cache manager +- [session](https://github.com/go-macaron/session) - Session manager +- [csrf](https://github.com/go-macaron/csrf) - Generates and validates csrf tokens +- [captcha](https://github.com/go-macaron/captcha) - Captcha service +- [pongo2](https://github.com/go-macaron/pongo2) - Pongo2 template engine support +- [sockets](https://github.com/go-macaron/sockets) - WebSockets channels binding +- [bindata](https://github.com/go-macaron/bindata) - Embed binary data as static and template files +- [toolbox](https://github.com/go-macaron/toolbox) - Health check, pprof, profile and statistic services +- [oauth2](https://github.com/go-macaron/oauth2) - OAuth 2.0 backend +- [authz](https://github.com/go-macaron/authz) - ACL/RBAC/ABAC authorization based on Casbin +- [switcher](https://github.com/go-macaron/switcher) - Multiple-site support +- [method](https://github.com/go-macaron/method) - HTTP method override +- [permissions2](https://github.com/xyproto/permissions2) - Cookies, users and permissions +- [renders](https://github.com/go-macaron/renders) - Beego-like render engine(Macaron has built-in template engine, this is another option) +- [piwik](https://github.com/veecue/piwik-middleware) - Server-side piwik analytics + +## Use Cases + +- [Gogs](https://gogs.io): A painless self-hosted Git Service +- [Grafana](http://grafana.org/): The open platform for beautiful analytics and monitoring +- [Peach](https://peachdocs.org): A modern web documentation server +- [Go Walker](https://gowalker.org): Go online API documentation +- [Critical Stack Intel](https://intel.criticalstack.com/): A 100% free intel marketplace from Critical Stack, Inc. + +## Getting Help + +- [API Reference](https://gowalker.org/gopkg.in/macaron.v1) +- [Documentation](https://go-macaron.com) +- [FAQs](https://go-macaron.com/docs/faqs) + +## Credits + +- Basic design of [Martini](https://github.com/go-martini/martini). +- Logo is modified by [@insionng](https://github.com/insionng) based on [Tribal Dragon](http://xtremeyamazaki.deviantart.com/art/Tribal-Dragon-27005087). + +## License + +This project is under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for the full license text. diff --git a/pkg/macaron/context.go b/pkg/macaron/context.go new file mode 100755 index 00000000000..05d09f334a9 --- /dev/null +++ b/pkg/macaron/context.go @@ -0,0 +1,562 @@ +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "crypto/sha256" + "encoding/hex" + "html/template" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "reflect" + "strconv" + "strings" + "time" + + "github.com/go-macaron/inject" + "github.com/unknwon/com" + "golang.org/x/crypto/pbkdf2" +) + +// Locale reprents a localization interface. +type Locale interface { + Language() string + Tr(string, ...interface{}) string +} + +// RequestBody represents a request body. +type RequestBody struct { + reader io.ReadCloser +} + +// Bytes reads and returns content of request body in bytes. +func (rb *RequestBody) Bytes() ([]byte, error) { + return ioutil.ReadAll(rb.reader) +} + +// String reads and returns content of request body in string. +func (rb *RequestBody) String() (string, error) { + data, err := rb.Bytes() + return string(data), err +} + +// ReadCloser returns a ReadCloser for request body. +func (rb *RequestBody) ReadCloser() io.ReadCloser { + return rb.reader +} + +// Request represents an HTTP request received by a server or to be sent by a client. +type Request struct { + *http.Request +} + +// Body returns a RequestBody for the request +func (r *Request) Body() *RequestBody { + return &RequestBody{r.Request.Body} +} + +// ContextInvoker is an inject.FastInvoker wrapper of func(ctx *Context). +type ContextInvoker func(ctx *Context) + +// Invoke implements inject.FastInvoker which simplifies calls of `func(ctx *Context)` function. +func (invoke ContextInvoker) Invoke(params []interface{}) ([]reflect.Value, error) { + invoke(params[0].(*Context)) + return nil, nil +} + +// Context represents the runtime context of current request of Macaron instance. +// It is the integration of most frequently used middlewares and helper methods. +type Context struct { + inject.Injector + handlers []Handler + action Handler + index int + + *Router + Req Request + Resp ResponseWriter + params Params + Render + Locale + Data map[string]interface{} +} + +func (ctx *Context) handler() Handler { + if ctx.index < len(ctx.handlers) { + return ctx.handlers[ctx.index] + } + if ctx.index == len(ctx.handlers) { + return ctx.action + } + panic("invalid index for context handler") +} + +// Next runs the next handler in the context chain +func (ctx *Context) Next() { + ctx.index++ + ctx.run() +} + +// Written returns whether the context response has been written to +func (ctx *Context) Written() bool { + return ctx.Resp.Written() +} + +func (ctx *Context) run() { + for ctx.index <= len(ctx.handlers) { + vals, err := ctx.Invoke(ctx.handler()) + if err != nil { + panic(err) + } + ctx.index++ + + // if the handler returned something, write it to the http response + if len(vals) > 0 { + ev := ctx.GetVal(reflect.TypeOf(ReturnHandler(nil))) + handleReturn := ev.Interface().(ReturnHandler) + handleReturn(ctx, vals) + } + + if ctx.Written() { + return + } + } +} + +// RemoteAddr returns more real IP address. +func (ctx *Context) RemoteAddr() string { + addr := ctx.Req.Header.Get("X-Real-IP") + if len(addr) == 0 { + addr = ctx.Req.Header.Get("X-Forwarded-For") + if addr == "" { + addr = ctx.Req.RemoteAddr + if i := strings.LastIndex(addr, ":"); i > -1 { + addr = addr[:i] + } + } + } + return addr +} + +func (ctx *Context) renderHTML(status int, setName, tplName string, data ...interface{}) { + if len(data) <= 0 { + ctx.Render.HTMLSet(status, setName, tplName, ctx.Data) + } else if len(data) == 1 { + ctx.Render.HTMLSet(status, setName, tplName, data[0]) + } else { + ctx.Render.HTMLSet(status, setName, tplName, data[0], data[1].(HTMLOptions)) + } +} + +// HTML renders the HTML with default template set. +func (ctx *Context) HTML(status int, name string, data ...interface{}) { + ctx.renderHTML(status, DEFAULT_TPL_SET_NAME, name, data...) +} + +// HTMLSet renders the HTML with given template set name. +func (ctx *Context) HTMLSet(status int, setName, tplName string, data ...interface{}) { + ctx.renderHTML(status, setName, tplName, data...) +} + +// Redirect sends a redirect response +func (ctx *Context) Redirect(location string, status ...int) { + code := http.StatusFound + if len(status) == 1 { + code = status[0] + } + + http.Redirect(ctx.Resp, ctx.Req.Request, location, code) +} + +// MaxMemory is the maximum amount of memory to use when parsing a multipart form. +// Set this to whatever value you prefer; default is 10 MB. +var MaxMemory = int64(1024 * 1024 * 10) + +func (ctx *Context) parseForm() { + if ctx.Req.Form != nil { + return + } + + contentType := ctx.Req.Header.Get(_CONTENT_TYPE) + if (ctx.Req.Method == "POST" || ctx.Req.Method == "PUT") && + len(contentType) > 0 && strings.Contains(contentType, "multipart/form-data") { + _ = ctx.Req.ParseMultipartForm(MaxMemory) + } else { + _ = ctx.Req.ParseForm() + } +} + +// Query querys form parameter. +func (ctx *Context) Query(name string) string { + ctx.parseForm() + return ctx.Req.Form.Get(name) +} + +// QueryTrim querys and trims spaces form parameter. +func (ctx *Context) QueryTrim(name string) string { + return strings.TrimSpace(ctx.Query(name)) +} + +// QueryStrings returns a list of results by given query name. +func (ctx *Context) QueryStrings(name string) []string { + ctx.parseForm() + + vals, ok := ctx.Req.Form[name] + if !ok { + return []string{} + } + return vals +} + +// QueryEscape returns escapred query result. +func (ctx *Context) QueryEscape(name string) string { + return template.HTMLEscapeString(ctx.Query(name)) +} + +// QueryBool returns query result in bool type. +func (ctx *Context) QueryBool(name string) bool { + v, _ := strconv.ParseBool(ctx.Query(name)) + return v +} + +// QueryInt returns query result in int type. +func (ctx *Context) QueryInt(name string) int { + return com.StrTo(ctx.Query(name)).MustInt() +} + +// QueryInt64 returns query result in int64 type. +func (ctx *Context) QueryInt64(name string) int64 { + return com.StrTo(ctx.Query(name)).MustInt64() +} + +// QueryFloat64 returns query result in float64 type. +func (ctx *Context) QueryFloat64(name string) float64 { + v, _ := strconv.ParseFloat(ctx.Query(name), 64) + return v +} + +// Params returns value of given param name. +// e.g. ctx.Params(":uid") or ctx.Params("uid") +func (ctx *Context) Params(name string) string { + if len(name) == 0 { + return "" + } + if len(name) > 1 && name[0] != ':' { + name = ":" + name + } + return ctx.params[name] +} + +// AllParams returns all params. +func (ctx *Context) AllParams() Params { + return ctx.params +} + +// SetParams sets value of param with given name. +func (ctx *Context) SetParams(name, val string) { + if name != "*" && !strings.HasPrefix(name, ":") { + name = ":" + name + } + ctx.params[name] = val +} + +// ReplaceAllParams replace all current params with given params +func (ctx *Context) ReplaceAllParams(params Params) { + ctx.params = params +} + +// ParamsEscape returns escapred params result. +// e.g. ctx.ParamsEscape(":uname") +func (ctx *Context) ParamsEscape(name string) string { + return template.HTMLEscapeString(ctx.Params(name)) +} + +// ParamsInt returns params result in int type. +// e.g. ctx.ParamsInt(":uid") +func (ctx *Context) ParamsInt(name string) int { + return com.StrTo(ctx.Params(name)).MustInt() +} + +// ParamsInt64 returns params result in int64 type. +// e.g. ctx.ParamsInt64(":uid") +func (ctx *Context) ParamsInt64(name string) int64 { + return com.StrTo(ctx.Params(name)).MustInt64() +} + +// ParamsFloat64 returns params result in int64 type. +// e.g. ctx.ParamsFloat64(":uid") +func (ctx *Context) ParamsFloat64(name string) float64 { + v, _ := strconv.ParseFloat(ctx.Params(name), 64) + return v +} + +// GetFile returns information about user upload file by given form field name. +func (ctx *Context) GetFile(name string) (multipart.File, *multipart.FileHeader, error) { + return ctx.Req.FormFile(name) +} + +// SaveToFile reads a file from request by field name and saves to given path. +func (ctx *Context) SaveToFile(name, savePath string) error { + fr, _, err := ctx.GetFile(name) + if err != nil { + return err + } + defer fr.Close() + + fw, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) + if err != nil { + return err + } + defer fw.Close() + + _, err = io.Copy(fw, fr) + return err +} + +// SetCookie sets given cookie value to response header. +// FIXME: IE support? http://golanghome.com/post/620#reply2 +func (ctx *Context) SetCookie(name string, value string, others ...interface{}) { + cookie := http.Cookie{} + cookie.Name = name + cookie.Value = url.QueryEscape(value) + + if len(others) > 0 { + switch v := others[0].(type) { + case int: + cookie.MaxAge = v + case int64: + cookie.MaxAge = int(v) + case int32: + cookie.MaxAge = int(v) + case func(*http.Cookie): + v(&cookie) + } + } + + cookie.Path = "/" + if len(others) > 1 { + if v, ok := others[1].(string); ok && len(v) > 0 { + cookie.Path = v + } else if v, ok := others[1].(func(*http.Cookie)); ok { + v(&cookie) + } + } + + if len(others) > 2 { + if v, ok := others[2].(string); ok && len(v) > 0 { + cookie.Domain = v + } else if v, ok := others[1].(func(*http.Cookie)); ok { + v(&cookie) + } + } + + if len(others) > 3 { + switch v := others[3].(type) { + case bool: + cookie.Secure = v + case func(*http.Cookie): + v(&cookie) + default: + if others[3] != nil { + cookie.Secure = true + } + } + } + + if len(others) > 4 { + if v, ok := others[4].(bool); ok && v { + cookie.HttpOnly = true + } else if v, ok := others[1].(func(*http.Cookie)); ok { + v(&cookie) + } + } + + if len(others) > 5 { + if v, ok := others[5].(time.Time); ok { + cookie.Expires = v + cookie.RawExpires = v.Format(time.UnixDate) + } else if v, ok := others[1].(func(*http.Cookie)); ok { + v(&cookie) + } + } + + if len(others) > 6 { + for _, other := range others[6:] { + if v, ok := other.(func(*http.Cookie)); ok { + v(&cookie) + } + } + } + + ctx.Resp.Header().Add("Set-Cookie", cookie.String()) +} + +// GetCookie returns given cookie value from request header. +func (ctx *Context) GetCookie(name string) string { + cookie, err := ctx.Req.Cookie(name) + if err != nil { + return "" + } + val, _ := url.QueryUnescape(cookie.Value) + return val +} + +// GetCookieInt returns cookie result in int type. +func (ctx *Context) GetCookieInt(name string) int { + return com.StrTo(ctx.GetCookie(name)).MustInt() +} + +// GetCookieInt64 returns cookie result in int64 type. +func (ctx *Context) GetCookieInt64(name string) int64 { + return com.StrTo(ctx.GetCookie(name)).MustInt64() +} + +// GetCookieFloat64 returns cookie result in float64 type. +func (ctx *Context) GetCookieFloat64(name string) float64 { + v, _ := strconv.ParseFloat(ctx.GetCookie(name), 64) + return v +} + +var defaultCookieSecret string + +// SetDefaultCookieSecret sets global default secure cookie secret. +func (m *Macaron) SetDefaultCookieSecret(secret string) { + defaultCookieSecret = secret +} + +// SetSecureCookie sets given cookie value to response header with default secret string. +func (ctx *Context) SetSecureCookie(name, value string, others ...interface{}) { + ctx.SetSuperSecureCookie(defaultCookieSecret, name, value, others...) +} + +// GetSecureCookie returns given cookie value from request header with default secret string. +func (ctx *Context) GetSecureCookie(key string) (string, bool) { + return ctx.GetSuperSecureCookie(defaultCookieSecret, key) +} + +// SetSuperSecureCookie sets given cookie value to response header with secret string. +func (ctx *Context) SetSuperSecureCookie(secret, name, value string, others ...interface{}) { + key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New) + text, err := com.AESGCMEncrypt(key, []byte(value)) + if err != nil { + panic("error encrypting cookie: " + err.Error()) + } + + ctx.SetCookie(name, hex.EncodeToString(text), others...) +} + +// GetSuperSecureCookie returns given cookie value from request header with secret string. +func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) { + val := ctx.GetCookie(name) + if val == "" { + return "", false + } + + text, err := hex.DecodeString(val) + if err != nil { + return "", false + } + + key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New) + text, err = com.AESGCMDecrypt(key, text) + return string(text), err == nil +} + +func (ctx *Context) setRawContentHeader() { + ctx.Resp.Header().Set("Content-Description", "Raw content") + ctx.Resp.Header().Set("Content-Type", "text/plain") + ctx.Resp.Header().Set("Expires", "0") + ctx.Resp.Header().Set("Cache-Control", "must-revalidate") + ctx.Resp.Header().Set("Pragma", "public") +} + +// ServeContent serves given content to response. +func (ctx *Context) ServeContent(name string, r io.ReadSeeker, params ...interface{}) { + modtime := time.Now() + for _, p := range params { + switch v := p.(type) { + case time.Time: + modtime = v + } + } + + ctx.setRawContentHeader() + http.ServeContent(ctx.Resp, ctx.Req.Request, name, modtime, r) +} + +// ServeFileContent serves given file as content to response. +func (ctx *Context) ServeFileContent(file string, names ...string) { + var name string + if len(names) > 0 { + name = names[0] + } else { + name = path.Base(file) + } + + f, err := os.Open(file) + if err != nil { + if Env == PROD { + http.Error(ctx.Resp, "Internal Server Error", 500) + } else { + http.Error(ctx.Resp, err.Error(), 500) + } + return + } + defer f.Close() + + ctx.setRawContentHeader() + http.ServeContent(ctx.Resp, ctx.Req.Request, name, time.Now(), f) +} + +// ServeFile serves given file to response. +func (ctx *Context) ServeFile(file string, names ...string) { + var name string + if len(names) > 0 { + name = names[0] + } else { + name = path.Base(file) + } + ctx.Resp.Header().Set("Content-Description", "File Transfer") + ctx.Resp.Header().Set("Content-Type", "application/octet-stream") + ctx.Resp.Header().Set("Content-Disposition", "attachment; filename="+name) + ctx.Resp.Header().Set("Content-Transfer-Encoding", "binary") + ctx.Resp.Header().Set("Expires", "0") + ctx.Resp.Header().Set("Cache-Control", "must-revalidate") + ctx.Resp.Header().Set("Pragma", "public") + http.ServeFile(ctx.Resp, ctx.Req.Request, file) +} + +// ChangeStaticPath changes static path from old to new one. +func (ctx *Context) ChangeStaticPath(oldPath, newPath string) { + if !filepath.IsAbs(oldPath) { + oldPath = filepath.Join(Root, oldPath) + } + dir := statics.Get(oldPath) + if dir != nil { + statics.Delete(oldPath) + + if !filepath.IsAbs(newPath) { + newPath = filepath.Join(Root, newPath) + } + *dir = http.Dir(newPath) + statics.Set(dir) + } +} diff --git a/pkg/macaron/context_test.go b/pkg/macaron/context_test.go new file mode 100755 index 00000000000..44bdd9285d3 --- /dev/null +++ b/pkg/macaron/context_test.go @@ -0,0 +1,423 @@ +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/unknwon/com" + "gopkg.in/macaron.v1/cookie" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_Context(t *testing.T) { + Convey("Do advanced encapsulation operations", t, func() { + m := Classic() + m.Use(Renderers(RenderOptions{ + Directory: "fixtures/basic", + }, "fixtures/basic2")) + + Convey("Get request body", func() { + m.Get("/body1", func(ctx *Context) { + data, err := ioutil.ReadAll(ctx.Req.Body().ReadCloser()) + So(err, ShouldBeNil) + So(string(data), ShouldEqual, "This is my request body") + }) + m.Get("/body2", func(ctx *Context) { + data, err := ctx.Req.Body().Bytes() + So(err, ShouldBeNil) + So(string(data), ShouldEqual, "This is my request body") + }) + m.Get("/body3", func(ctx *Context) { + data, err := ctx.Req.Body().String() + So(err, ShouldBeNil) + So(data, ShouldEqual, "This is my request body") + }) + m.Get("/body4", ContextInvoker(func(ctx *Context) { + data, err := ctx.Req.Body().String() + So(err, ShouldBeNil) + So(data, ShouldEqual, "This is my request body") + })) + + for i := 1; i <= 4; i++ { + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/body"+com.ToStr(i), nil) + req.Body = ioutil.NopCloser(bytes.NewBufferString("This is my request body")) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + } + }) + + Convey("Get remote IP address", func() { + m.Get("/remoteaddr", func(ctx *Context) string { + return ctx.RemoteAddr() + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/remoteaddr", nil) + req.RemoteAddr = "127.0.0.1:3333" + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "127.0.0.1") + }) + + Convey("Render HTML", func() { + + Convey("Normal HTML", func() { + m.Get("/html", func(ctx *Context) { + ctx.HTML(304, "hello", "Unknwon") // 304 for logger test. + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/html", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "

Hello Unknwon

") + }) + + Convey("HTML template set", func() { + m.Get("/html2", func(ctx *Context) { + ctx.Data["Name"] = "Unknwon" + ctx.HTMLSet(200, "basic2", "hello2") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/html2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "

Hello Unknwon

") + }) + + Convey("With layout", func() { + m.Get("/layout", func(ctx *Context) { + ctx.HTML(200, "hello", "Unknwon", HTMLOptions{"layout"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/layout", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "head

Hello Unknwon

foot") + }) + }) + + Convey("Parse from and query", func() { + m.Get("/query", func(ctx *Context) string { + var buf bytes.Buffer + buf.WriteString(ctx.QueryTrim("name") + " ") + buf.WriteString(ctx.QueryEscape("name") + " ") + buf.WriteString(com.ToStr(ctx.QueryBool("bool")) + " ") + buf.WriteString(com.ToStr(ctx.QueryInt("int")) + " ") + buf.WriteString(com.ToStr(ctx.QueryInt64("int64")) + " ") + buf.WriteString(com.ToStr(ctx.QueryFloat64("float64")) + " ") + return buf.String() + }) + m.Get("/query2", func(ctx *Context) string { + var buf bytes.Buffer + buf.WriteString(strings.Join(ctx.QueryStrings("list"), ",") + " ") + buf.WriteString(strings.Join(ctx.QueryStrings("404"), ",") + " ") + return buf.String() + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/query?name=Unknwon&bool=t&int=12&int64=123&float64=1.25", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Unknwon Unknwon true 12 123 1.25 ") + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/query2?list=item1&list=item2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "item1,item2 ") + }) + + Convey("URL parameter", func() { + m.Get("/:name/:int/:int64/:float64", func(ctx *Context) string { + var buf bytes.Buffer + ctx.SetParams("name", ctx.Params("name")) + buf.WriteString(ctx.Params("")) + buf.WriteString(ctx.Params(":name") + " ") + buf.WriteString(ctx.ParamsEscape(":name") + " ") + buf.WriteString(com.ToStr(ctx.ParamsInt(":int")) + " ") + buf.WriteString(com.ToStr(ctx.ParamsInt64(":int64")) + " ") + buf.WriteString(com.ToStr(ctx.ParamsFloat64(":float64")) + " ") + return buf.String() + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/user/1/13/1.24", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "user user 1 13 1.24 ") + }) + + Convey("Get all URL paramaters", func() { + m.Get("/:arg/:param/:flag", func(ctx *Context) string { + kvs := make([]string, 0, len(ctx.AllParams())) + for k, v := range ctx.AllParams() { + kvs = append(kvs, k+"="+v) + } + sort.Strings(kvs) + return strings.Join(kvs, ",") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/1/2/3", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, ":arg=1,:flag=3,:param=2") + }) + + Convey("Get file", func() { + m.Post("/getfile", func(ctx *Context) { + ctx.Query("") + _, _, _ = ctx.GetFile("hi") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/getfile", nil) + So(err, ShouldBeNil) + req.Header.Set("Content-Type", "multipart/form-data") + m.ServeHTTP(resp, req) + }) + + Convey("Set and get cookie", func() { + m.Get("/set", func(ctx *Context) { + t, err := time.Parse(time.RFC1123, "Sun, 13 Mar 2016 01:29:26 UTC") + So(err, ShouldBeNil) + ctx.SetCookie("user", "Unknwon", 1, "/", "localhost", true, true, t) + ctx.SetCookie("user", "Unknwon", int32(1), "/", "localhost", 1) + called := false + ctx.SetCookie("user", "Unknwon", int64(1), func(c *http.Cookie) { + called = true + }) + So(called, ShouldBeTrue) + ctx.SetCookie("user", "Unknown", + cookie.Secure(true), + cookie.HttpOnly(true), + cookie.Path("/"), + cookie.MaxAge(1), + cookie.Domain("localhost"), + ) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/set", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Header().Get("Set-Cookie"), ShouldEqual, "user=Unknwon; Path=/; Domain=localhost; Expires=Sun, 13 Mar 2016 01:29:26 GMT; Max-Age=1; HttpOnly; Secure") + + m.Get("/get", func(ctx *Context) string { + ctx.GetCookie("404") + So(ctx.GetCookieInt("uid"), ShouldEqual, 1) + So(ctx.GetCookieInt64("uid"), ShouldEqual, 1) + So(ctx.GetCookieFloat64("balance"), ShouldEqual, 1.25) + return ctx.GetCookie("user") + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/get", nil) + So(err, ShouldBeNil) + req.Header.Set("Cookie", "user=Unknwon; uid=1; balance=1.25") + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Unknwon") + }) + + Convey("Set and get secure cookie", func() { + m.SetDefaultCookieSecret("macaron") + m.Get("/set", func(ctx *Context) { + ctx.SetSecureCookie("user", "Unknwon", 1) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/set", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + cookie := resp.Header().Get("Set-Cookie") + + m.Get("/get", func(ctx *Context) string { + name, ok := ctx.GetSecureCookie("user") + So(ok, ShouldBeTrue) + return name + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/get", nil) + So(err, ShouldBeNil) + req.Header.Set("Cookie", cookie) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Unknwon") + }) + + Convey("Serve files", func() { + m.Get("/file", func(ctx *Context) { + ctx.ServeFile("fixtures/custom_funcs/index.tmpl") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/file", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "{{ myCustomFunc }}") + + m.Get("/file2", func(ctx *Context) { + ctx.ServeFile("fixtures/custom_funcs/index.tmpl", "ok.tmpl") + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/file2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "{{ myCustomFunc }}") + }) + + Convey("Serve file content", func() { + m.Get("/file", func(ctx *Context) { + ctx.ServeFileContent("fixtures/custom_funcs/index.tmpl") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/file", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "{{ myCustomFunc }}") + + m.Get("/file2", func(ctx *Context) { + ctx.ServeFileContent("fixtures/custom_funcs/index.tmpl", "ok.tmpl") + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/file2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "{{ myCustomFunc }}") + + m.Get("/file3", func(ctx *Context) { + ctx.ServeFileContent("404.tmpl") + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/file3", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + if runtime.GOOS == "windows" { + So(resp.Body.String(), ShouldEqual, "open 404.tmpl: The system cannot find the file specified.\n") + } else { + So(resp.Body.String(), ShouldEqual, "open 404.tmpl: no such file or directory\n") + } + So(resp.Code, ShouldEqual, 500) + }) + + Convey("Serve content", func() { + m.Get("/content", func(ctx *Context) { + ctx.ServeContent("content1", bytes.NewReader([]byte("Hello world!"))) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/content", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Hello world!") + + m.Get("/content2", func(ctx *Context) { + ctx.ServeContent("content1", bytes.NewReader([]byte("Hello world!")), time.Now()) + }) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/content2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Hello world!") + }) + }) +} + +func Test_Context_Render(t *testing.T) { + Convey("Invalid render", t, func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + + m := New() + + m.Get("/", func(ctx *Context) { + ctx.HTML(200, "hey") + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + m.Get("/f", ContextInvoker(func(ctx *Context) { + ctx.HTML(200, "hey") + })) + req, err = http.NewRequest("GET", "/f", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + }) +} + +func Test_Context_Redirect(t *testing.T) { + Convey("Context with default redirect", t, func() { + url, err := url.Parse("http://localhost/path/one") + So(err, ShouldBeNil) + resp := httptest.NewRecorder() + req := http.Request{ + Method: "GET", + URL: url, + } + ctx := &Context{ + Req: Request{&req}, + Resp: NewResponseWriter(req.Method, resp), + Data: make(map[string]interface{}), + } + ctx.Redirect("two") + + So(resp.Code, ShouldEqual, http.StatusFound) + So(resp.Result().Header["Location"][0], ShouldEqual, "/path/two") + }) + + Convey("Context with custom redirect", t, func() { + url, err := url.Parse("http://localhost/path/one") + So(err, ShouldBeNil) + resp := httptest.NewRecorder() + req := http.Request{ + Method: "GET", + URL: url, + } + ctx := &Context{ + Req: Request{&req}, + Resp: NewResponseWriter(req.Method, resp), + Data: make(map[string]interface{}), + } + ctx.Redirect("two", 307) + + So(resp.Code, ShouldEqual, http.StatusTemporaryRedirect) + So(resp.Result().Header["Location"][0], ShouldEqual, "/path/two") + }) +} diff --git a/pkg/macaron/cookie/helper.go b/pkg/macaron/cookie/helper.go new file mode 100755 index 00000000000..c5f8eb4b9f0 --- /dev/null +++ b/pkg/macaron/cookie/helper.go @@ -0,0 +1,78 @@ +// Copyright 2020 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Package cookie contains helper functions for setting cookie values. +package cookie + +import ( + "net/http" + "time" +) + +// MaxAge sets the maximum age for a provided cookie +func MaxAge(maxAge int) func(*http.Cookie) { + return func(c *http.Cookie) { + c.MaxAge = maxAge + } +} + +// Path sets the path for a provided cookie +func Path(path string) func(*http.Cookie) { + return func(c *http.Cookie) { + c.Path = path + } +} + +// Domain sets the domain for a provided cookie +func Domain(domain string) func(*http.Cookie) { + return func(c *http.Cookie) { + c.Domain = domain + } +} + +// Secure sets the secure setting for a provided cookie +func Secure(secure bool) func(*http.Cookie) { + return func(c *http.Cookie) { + c.Secure = secure + } +} + +// HttpOnly sets the HttpOnly setting for a provided cookie +func HttpOnly(httpOnly bool) func(*http.Cookie) { + return func(c *http.Cookie) { + c.HttpOnly = httpOnly + } +} + +// HTTPOnly sets the HttpOnly setting for a provided cookie +func HTTPOnly(httpOnly bool) func(*http.Cookie) { + return func(c *http.Cookie) { + c.HttpOnly = httpOnly + } +} + +// Expires sets the expires and rawexpires for a provided cookie +func Expires(expires time.Time) func(*http.Cookie) { + return func(c *http.Cookie) { + c.Expires = expires + c.RawExpires = expires.Format(time.UnixDate) + } +} + +// SameSite sets the SameSite for a provided cookie +func SameSite(sameSite http.SameSite) func(*http.Cookie) { + return func(c *http.Cookie) { + c.SameSite = sameSite + } +} diff --git a/pkg/macaron/fixtures/basic/admin/index.tmpl b/pkg/macaron/fixtures/basic/admin/index.tmpl new file mode 100755 index 00000000000..b6a910cee07 --- /dev/null +++ b/pkg/macaron/fixtures/basic/admin/index.tmpl @@ -0,0 +1 @@ +

Admin {{.}}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/another_layout.tmpl b/pkg/macaron/fixtures/basic/another_layout.tmpl new file mode 100755 index 00000000000..66f698e0443 --- /dev/null +++ b/pkg/macaron/fixtures/basic/another_layout.tmpl @@ -0,0 +1 @@ +another head{{ yield }}another foot \ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/content.tmpl b/pkg/macaron/fixtures/basic/content.tmpl new file mode 100755 index 00000000000..0dbb9734907 --- /dev/null +++ b/pkg/macaron/fixtures/basic/content.tmpl @@ -0,0 +1 @@ +

{{ . }}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/current_layout.tmpl b/pkg/macaron/fixtures/basic/current_layout.tmpl new file mode 100755 index 00000000000..5076a587bb1 --- /dev/null +++ b/pkg/macaron/fixtures/basic/current_layout.tmpl @@ -0,0 +1 @@ +{{ current }} head{{ yield }}{{ current }} foot \ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/custom/hello.tmpl b/pkg/macaron/fixtures/basic/custom/hello.tmpl new file mode 100755 index 00000000000..b366bbe941b --- /dev/null +++ b/pkg/macaron/fixtures/basic/custom/hello.tmpl @@ -0,0 +1 @@ +

This is custom version of: Hello {{.}}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/delims.tmpl b/pkg/macaron/fixtures/basic/delims.tmpl new file mode 100755 index 00000000000..c6fc9c05c07 --- /dev/null +++ b/pkg/macaron/fixtures/basic/delims.tmpl @@ -0,0 +1 @@ +

Hello {[{.}]}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/hello.tmpl b/pkg/macaron/fixtures/basic/hello.tmpl new file mode 100755 index 00000000000..85a52cb8cb4 --- /dev/null +++ b/pkg/macaron/fixtures/basic/hello.tmpl @@ -0,0 +1 @@ +

Hello {{.}}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/hypertext.html b/pkg/macaron/fixtures/basic/hypertext.html new file mode 100755 index 00000000000..3a82e2ad6d3 --- /dev/null +++ b/pkg/macaron/fixtures/basic/hypertext.html @@ -0,0 +1 @@ +Hypertext! \ No newline at end of file diff --git a/pkg/macaron/fixtures/basic/layout.tmpl b/pkg/macaron/fixtures/basic/layout.tmpl new file mode 100755 index 00000000000..7c2c4ea6029 --- /dev/null +++ b/pkg/macaron/fixtures/basic/layout.tmpl @@ -0,0 +1 @@ +head{{ yield }}foot \ No newline at end of file diff --git a/pkg/macaron/fixtures/basic2/hello.tmpl b/pkg/macaron/fixtures/basic2/hello.tmpl new file mode 100755 index 00000000000..118e16c2eaa --- /dev/null +++ b/pkg/macaron/fixtures/basic2/hello.tmpl @@ -0,0 +1 @@ +

What's up, {{.}}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/basic2/hello2.tmpl b/pkg/macaron/fixtures/basic2/hello2.tmpl new file mode 100755 index 00000000000..45122e3c969 --- /dev/null +++ b/pkg/macaron/fixtures/basic2/hello2.tmpl @@ -0,0 +1 @@ +

Hello {{.Name}}

\ No newline at end of file diff --git a/pkg/macaron/fixtures/custom_funcs/index.tmpl b/pkg/macaron/fixtures/custom_funcs/index.tmpl new file mode 100755 index 00000000000..936b6e913a1 --- /dev/null +++ b/pkg/macaron/fixtures/custom_funcs/index.tmpl @@ -0,0 +1 @@ +{{ myCustomFunc }} \ No newline at end of file diff --git a/pkg/macaron/go.mod b/pkg/macaron/go.mod new file mode 100755 index 00000000000..e82c778464e --- /dev/null +++ b/pkg/macaron/go.mod @@ -0,0 +1,13 @@ +module gopkg.in/macaron.v1 + +go 1.12 + +require ( + github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect + github.com/smartystreets/assertions v1.0.1 // indirect + github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 + github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e + golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 + gopkg.in/ini.v1 v1.46.0 +) diff --git a/pkg/macaron/go.sum b/pkg/macaron/go.sum new file mode 100755 index 00000000000..23cb914eb5b --- /dev/null +++ b/pkg/macaron/go.sum @@ -0,0 +1,32 @@ +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI= +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e h1:GSGeB9EAKY2spCABz6xOX5DbxZEXolK+nBSvmsQwRjM= +github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/ini.v1 v1.46.0 h1:VeDZbLYGaupuvIrsYCEOe/L/2Pcs5n7hdO1ZTjporag= +gopkg.in/ini.v1 v1.46.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/pkg/macaron/logger.go b/pkg/macaron/logger.go new file mode 100755 index 00000000000..34178d78a6a --- /dev/null +++ b/pkg/macaron/logger.go @@ -0,0 +1,73 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "fmt" + "log" + "net/http" + "reflect" + "runtime" + "time" +) + +var ( + ColorLog = true + LogTimeFormat = "2006-01-02 15:04:05" +) + +func init() { + ColorLog = runtime.GOOS != "windows" +} + +// LoggerInvoker is an inject.FastInvoker wrapper of func(ctx *Context, log *log.Logger). +type LoggerInvoker func(ctx *Context, log *log.Logger) + +func (invoke LoggerInvoker) Invoke(params []interface{}) ([]reflect.Value, error) { + invoke(params[0].(*Context), params[1].(*log.Logger)) + return nil, nil +} + +// Logger returns a middleware handler that logs the request as it goes in and the response as it goes out. +func Logger() Handler { + return func(ctx *Context, log *log.Logger) { + start := time.Now() + + log.Printf("%s: Started %s %s for %s", time.Now().Format(LogTimeFormat), ctx.Req.Method, ctx.Req.RequestURI, ctx.RemoteAddr()) + + rw := ctx.Resp.(ResponseWriter) + ctx.Next() + + content := fmt.Sprintf("%s: Completed %s %s %v %s in %v", time.Now().Format(LogTimeFormat), ctx.Req.Method, ctx.Req.RequestURI, rw.Status(), http.StatusText(rw.Status()), time.Since(start)) + if ColorLog { + switch rw.Status() { + case 200, 201, 202: + content = fmt.Sprintf("\033[1;32m%s\033[0m", content) + case 301, 302: + content = fmt.Sprintf("\033[1;37m%s\033[0m", content) + case 304: + content = fmt.Sprintf("\033[1;33m%s\033[0m", content) + case 401, 403: + content = fmt.Sprintf("\033[4;31m%s\033[0m", content) + case 404: + content = fmt.Sprintf("\033[1;31m%s\033[0m", content) + case 500: + content = fmt.Sprintf("\033[1;36m%s\033[0m", content) + } + } + log.Println(content) + } +} diff --git a/pkg/macaron/logger_test.go b/pkg/macaron/logger_test.go new file mode 100755 index 00000000000..6c45915ad6a --- /dev/null +++ b/pkg/macaron/logger_test.go @@ -0,0 +1,67 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/unknwon/com" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_Logger(t *testing.T) { + Convey("Global logger", t, func() { + buf := bytes.NewBufferString("") + m := New() + m.Map(log.New(buf, "[Macaron] ", 0)) + m.Use(Logger()) + m.Use(func(res http.ResponseWriter) { + res.WriteHeader(http.StatusNotFound) + }) + m.Get("/", func() {}) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusNotFound) + So(len(buf.String()), ShouldBeGreaterThan, 0) + }) + + if ColorLog { + Convey("Color console output", t, func() { + m := Classic() + m.Get("/:code:int", func(ctx *Context) (int, string) { + return ctx.ParamsInt(":code"), "" + }) + + // Just for testing if logger would capture. + codes := []int{200, 201, 202, 301, 302, 304, 401, 403, 404, 500} + for _, code := range codes { + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/"+com.ToStr(code), nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, code) + } + }) + } +} diff --git a/pkg/macaron/macaron.go b/pkg/macaron/macaron.go new file mode 100755 index 00000000000..8b9297a3a9e --- /dev/null +++ b/pkg/macaron/macaron.go @@ -0,0 +1,334 @@ +// +build go1.3 + +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Package macaron is a high productive and modular web framework in Go. +package macaron // import "gopkg.in/macaron.v1" + +import ( + "io" + "log" + "net/http" + "os" + "reflect" + "strings" + "sync" + + "github.com/unknwon/com" + "gopkg.in/ini.v1" + + "github.com/go-macaron/inject" +) + +const _VERSION = "1.3.4.0805" + +func Version() string { + return _VERSION +} + +// Handler can be any callable function. +// Macaron attempts to inject services into the handler's argument list, +// and panics if an argument could not be fullfilled via dependency injection. +type Handler interface{} + +// handlerFuncInvoker is an inject.FastInvoker wrapper of func(http.ResponseWriter, *http.Request). +type handlerFuncInvoker func(http.ResponseWriter, *http.Request) + +func (invoke handlerFuncInvoker) Invoke(params []interface{}) ([]reflect.Value, error) { + invoke(params[0].(http.ResponseWriter), params[1].(*http.Request)) + return nil, nil +} + +// internalServerErrorInvoker is an inject.FastInvoker wrapper of func(rw http.ResponseWriter, err error). +type internalServerErrorInvoker func(rw http.ResponseWriter, err error) + +func (invoke internalServerErrorInvoker) Invoke(params []interface{}) ([]reflect.Value, error) { + invoke(params[0].(http.ResponseWriter), params[1].(error)) + return nil, nil +} + +// validateAndWrapHandler makes sure a handler is a callable function, it panics if not. +// When the handler is also potential to be any built-in inject.FastInvoker, +// it wraps the handler automatically to have some performance gain. +func validateAndWrapHandler(h Handler) Handler { + if reflect.TypeOf(h).Kind() != reflect.Func { + panic("Macaron handler must be a callable function") + } + + if !inject.IsFastInvoker(h) { + switch v := h.(type) { + case func(*Context): + return ContextInvoker(v) + case func(*Context, *log.Logger): + return LoggerInvoker(v) + case func(http.ResponseWriter, *http.Request): + return handlerFuncInvoker(v) + case func(http.ResponseWriter, error): + return internalServerErrorInvoker(v) + } + } + return h +} + +// validateAndWrapHandlers preforms validation and wrapping for each input handler. +// It accepts an optional wrapper function to perform custom wrapping on handlers. +func validateAndWrapHandlers(handlers []Handler, wrappers ...func(Handler) Handler) []Handler { + var wrapper func(Handler) Handler + if len(wrappers) > 0 { + wrapper = wrappers[0] + } + + wrappedHandlers := make([]Handler, len(handlers)) + for i, h := range handlers { + h = validateAndWrapHandler(h) + if wrapper != nil && !inject.IsFastInvoker(h) { + h = wrapper(h) + } + wrappedHandlers[i] = h + } + + return wrappedHandlers +} + +// Macaron represents the top level web application. +// inject.Injector methods can be invoked to map services on a global level. +type Macaron struct { + inject.Injector + befores []BeforeHandler + handlers []Handler + action Handler + + hasURLPrefix bool + urlPrefix string // For suburl support. + *Router + + logger *log.Logger +} + +// NewWithLogger creates a bare bones Macaron instance. +// Use this method if you want to have full control over the middleware that is used. +// You can specify logger output writer with this function. +func NewWithLogger(out io.Writer) *Macaron { + m := &Macaron{ + Injector: inject.New(), + action: func() {}, + Router: NewRouter(), + logger: log.New(out, "[Macaron] ", 0), + } + m.Router.m = m + m.Map(m.logger) + m.Map(defaultReturnHandler()) + m.NotFound(http.NotFound) + m.InternalServerError(func(rw http.ResponseWriter, err error) { + http.Error(rw, err.Error(), 500) + }) + return m +} + +// New creates a bare bones Macaron instance. +// Use this method if you want to have full control over the middleware that is used. +func New() *Macaron { + return NewWithLogger(os.Stdout) +} + +// Classic creates a classic Macaron with some basic default middleware: +// macaron.Logger, macaron.Recovery and macaron.Static. +func Classic() *Macaron { + m := New() + m.Use(Logger()) + m.Use(Recovery()) + m.Use(Static("public")) + return m +} + +// Handlers sets the entire middleware stack with the given Handlers. +// This will clear any current middleware handlers, +// and panics if any of the handlers is not a callable function +func (m *Macaron) Handlers(handlers ...Handler) { + m.handlers = make([]Handler, 0) + for _, handler := range handlers { + m.Use(handler) + } +} + +// Action sets the handler that will be called after all the middleware has been invoked. +// This is set to macaron.Router in a macaron.Classic(). +func (m *Macaron) Action(handler Handler) { + handler = validateAndWrapHandler(handler) + m.action = handler +} + +// BeforeHandler represents a handler executes at beginning of every request. +// Macaron stops future process when it returns true. +type BeforeHandler func(rw http.ResponseWriter, req *http.Request) bool + +func (m *Macaron) Before(handler BeforeHandler) { + m.befores = append(m.befores, handler) +} + +// Use adds a middleware Handler to the stack, +// and panics if the handler is not a callable func. +// Middleware Handlers are invoked in the order that they are added. +func (m *Macaron) Use(handler Handler) { + handler = validateAndWrapHandler(handler) + m.handlers = append(m.handlers, handler) +} + +func (m *Macaron) createContext(rw http.ResponseWriter, req *http.Request) *Context { + c := &Context{ + Injector: inject.New(), + handlers: m.handlers, + action: m.action, + index: 0, + Router: m.Router, + Req: Request{req}, + Resp: NewResponseWriter(req.Method, rw), + Render: &DummyRender{rw}, + Data: make(map[string]interface{}), + } + c.SetParent(m) + c.Map(c) + c.MapTo(c.Resp, (*http.ResponseWriter)(nil)) + c.Map(req) + return c +} + +// ServeHTTP is the HTTP Entry point for a Macaron instance. +// Useful if you want to control your own HTTP server. +// Be aware that none of middleware will run without registering any router. +func (m *Macaron) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if m.hasURLPrefix { + req.URL.Path = strings.TrimPrefix(req.URL.Path, m.urlPrefix) + } + for _, h := range m.befores { + if h(rw, req) { + return + } + } + m.Router.ServeHTTP(rw, req) +} + +func GetDefaultListenInfo() (string, int) { + host := os.Getenv("HOST") + if len(host) == 0 { + host = "0.0.0.0" + } + port := com.StrTo(os.Getenv("PORT")).MustInt() + if port == 0 { + port = 4000 + } + return host, port +} + +// Run the http server. Listening on os.GetEnv("PORT") or 4000 by default. +func (m *Macaron) Run(args ...interface{}) { + host, port := GetDefaultListenInfo() + if len(args) == 1 { + switch arg := args[0].(type) { + case string: + host = arg + case int: + port = arg + } + } else if len(args) >= 2 { + if arg, ok := args[0].(string); ok { + host = arg + } + if arg, ok := args[1].(int); ok { + port = arg + } + } + + addr := host + ":" + com.ToStr(port) + logger := m.GetVal(reflect.TypeOf(m.logger)).Interface().(*log.Logger) + logger.Printf("listening on %s (%s)\n", addr, safeEnv()) + logger.Fatalln(http.ListenAndServe(addr, m)) +} + +// SetURLPrefix sets URL prefix of router layer, so that it support suburl. +func (m *Macaron) SetURLPrefix(prefix string) { + m.urlPrefix = prefix + m.hasURLPrefix = len(m.urlPrefix) > 0 +} + +// ____ ____ .__ ___. .__ +// \ \ / /____ _______|__|____ \_ |__ | | ____ ______ +// \ Y /\__ \\_ __ \ \__ \ | __ \| | _/ __ \ / ___/ +// \ / / __ \| | \/ |/ __ \| \_\ \ |_\ ___/ \___ \ +// \___/ (____ /__| |__(____ /___ /____/\___ >____ > +// \/ \/ \/ \/ \/ + +const ( + DEV = "development" + PROD = "production" + TEST = "test" +) + +var ( + // Env is the environment that Macaron is executing in. + // The MACARON_ENV is read on initialization to set this variable. + Env = DEV + envLock sync.Mutex + + // Path of work directory. + Root string + + // Flash applies to current request. + FlashNow bool + + // Configuration convention object. + cfg *ini.File +) + +func setENV(e string) { + envLock.Lock() + defer envLock.Unlock() + + if len(e) > 0 { + Env = e + } +} + +func safeEnv() string { + envLock.Lock() + defer envLock.Unlock() + + return Env +} + +func init() { + setENV(os.Getenv("MACARON_ENV")) + + var err error + Root, err = os.Getwd() + if err != nil { + panic("error getting work directory: " + err.Error()) + } +} + +// SetConfig sets data sources for configuration. +func SetConfig(source interface{}, others ...interface{}) (_ *ini.File, err error) { + cfg, err = ini.Load(source, others...) + return Config(), err +} + +// Config returns configuration convention object. +// It returns an empty object if there is no one available. +func Config() *ini.File { + if cfg == nil { + return ini.Empty() + } + return cfg +} diff --git a/pkg/macaron/macaron_test.go b/pkg/macaron/macaron_test.go new file mode 100755 index 00000000000..3add52896ba --- /dev/null +++ b/pkg/macaron/macaron_test.go @@ -0,0 +1,218 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_Version(t *testing.T) { + Convey("Get version", t, func() { + So(Version(), ShouldEqual, _VERSION) + }) +} + +func Test_New(t *testing.T) { + Convey("Initialize a new instance", t, func() { + So(New(), ShouldNotBeNil) + }) + + Convey("Just test that Run doesn't bomb", t, func() { + go New().Run() + time.Sleep(1 * time.Second) + os.Setenv("PORT", "4001") + go New().Run("0.0.0.0") + go New().Run(4002) + go New().Run("0.0.0.0", 4003) + }) +} + +func Test_Macaron_Before(t *testing.T) { + Convey("Register before handlers", t, func() { + m := New() + m.Before(func(rw http.ResponseWriter, req *http.Request) bool { + return false + }) + m.Before(func(rw http.ResponseWriter, req *http.Request) bool { + return true + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + }) +} + +func Test_Macaron_ServeHTTP(t *testing.T) { + Convey("Serve HTTP requests", t, func() { + result := "" + m := New() + m.Use(func(c *Context) { + result += "foo" + c.Next() + result += "ban" + }) + m.Use(func(c *Context) { + result += "bar" + c.Next() + result += "baz" + }) + m.Get("/", func() {}) + m.Action(func(res http.ResponseWriter, req *http.Request) { + result += "bat" + res.WriteHeader(http.StatusBadRequest) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(result, ShouldEqual, "foobarbatbazban") + So(resp.Code, ShouldEqual, http.StatusBadRequest) + }) +} + +func Test_Macaron_Handlers(t *testing.T) { + Convey("Add custom handlers", t, func() { + result := "" + batman := func(c *Context) { + result += "batman!" + } + + m := New() + m.Use(func(c *Context) { + result += "foo" + c.Next() + result += "ban" + }) + m.Handlers( + batman, + batman, + batman, + ) + + Convey("Add not callable function", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.Use("shit") + }) + + m.Get("/", func() {}) + m.Action(func(res http.ResponseWriter, req *http.Request) { + result += "bat" + res.WriteHeader(http.StatusBadRequest) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(result, ShouldEqual, "batman!batman!batman!bat") + So(resp.Code, ShouldEqual, http.StatusBadRequest) + }) +} + +func Test_Macaron_EarlyWrite(t *testing.T) { + Convey("Write early content to response", t, func() { + result := "" + m := New() + m.Use(func(res http.ResponseWriter) { + result += "foobar" + _, _ = res.Write([]byte("Hello world")) + }) + m.Use(func() { + result += "bat" + }) + m.Get("/", func() {}) + m.Action(func(res http.ResponseWriter) { + result += "baz" + res.WriteHeader(http.StatusBadRequest) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(result, ShouldEqual, "foobar") + So(resp.Code, ShouldEqual, http.StatusOK) + }) +} + +func Test_Macaron_Written(t *testing.T) { + Convey("Written sign", t, func() { + resp := httptest.NewRecorder() + m := New() + m.Handlers(func(res http.ResponseWriter) { + res.WriteHeader(http.StatusOK) + }) + + ctx := m.createContext(resp, &http.Request{Method: "GET"}) + So(ctx.Written(), ShouldBeFalse) + + ctx.run() + So(ctx.Written(), ShouldBeTrue) + }) +} + +func Test_Macaron_Basic_NoRace(t *testing.T) { + Convey("Make sure no race between requests", t, func() { + m := New() + handlers := []Handler{func() {}, func() {}} + // Ensure append will not realloc to trigger the race condition + m.handlers = handlers[:1] + m.Get("/", func() {}) + for i := 0; i < 2; i++ { + go func() { + req, _ := http.NewRequest("GET", "/", nil) + resp := httptest.NewRecorder() + m.ServeHTTP(resp, req) + }() + } + }) +} + +func Test_SetENV(t *testing.T) { + Convey("Get and save environment variable", t, func() { + tests := []struct { + in string + out string + }{ + {"", "development"}, + {"not_development", "not_development"}, + } + + for _, test := range tests { + setENV(test.in) + So(Env, ShouldEqual, test.out) + } + }) +} + +func Test_Config(t *testing.T) { + Convey("Set and get configuration object", t, func() { + So(Config(), ShouldNotBeNil) + cfg, err := SetConfig([]byte("")) + So(err, ShouldBeNil) + So(cfg, ShouldNotBeNil) + }) +} diff --git a/pkg/macaron/macaronlogo.png b/pkg/macaron/macaronlogo.png new file mode 100755 index 00000000000..399759769a8 Binary files /dev/null and b/pkg/macaron/macaronlogo.png differ diff --git a/pkg/macaron/recovery.go b/pkg/macaron/recovery.go new file mode 100755 index 00000000000..c45c54c94b2 --- /dev/null +++ b/pkg/macaron/recovery.go @@ -0,0 +1,163 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "net/http" + "runtime" + + "github.com/go-macaron/inject" +) + +const ( + panicHtml = ` +PANIC: %s + + + +

PANIC

+
%s
+
%s
+ +` +) + +var ( + dunno = []byte("???") + centerDot = []byte("·") + dot = []byte(".") + slash = []byte("/") +) + +// stack returns a nicely formated stack frame, skipping skip frames +func stack(skip int) []byte { + buf := new(bytes.Buffer) // the returned data + // As we loop, we open files and read them. These variables record the currently + // loaded file. + var lines [][]byte + var lastFile string + for i := skip; ; i++ { // Skip the expected number of frames + pc, file, line, ok := runtime.Caller(i) + if !ok { + break + } + // Print this much at least. If we can't find the source, it won't show. + fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) + if file != lastFile { + data, err := ioutil.ReadFile(file) + if err != nil { + continue + } + lines = bytes.Split(data, []byte{'\n'}) + lastFile = file + } + fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line)) + } + return buf.Bytes() +} + +// source returns a space-trimmed slice of the n'th line. +func source(lines [][]byte, n int) []byte { + n-- // in stack trace, lines are 1-indexed but our array is 0-indexed + if n < 0 || n >= len(lines) { + return dunno + } + return bytes.TrimSpace(lines[n]) +} + +// function returns, if possible, the name of the function containing the PC. +func function(pc uintptr) []byte { + fn := runtime.FuncForPC(pc) + if fn == nil { + return dunno + } + name := []byte(fn.Name()) + // The name includes the path name to the package, which is unnecessary + // since the file name is already included. Plus, it has center dots. + // That is, we see + // runtime/debug.*T·ptrmethod + // and want + // *T.ptrmethod + // Also the package path might contains dot (e.g. code.google.com/...), + // so first eliminate the path prefix + if lastslash := bytes.LastIndex(name, slash); lastslash >= 0 { + name = name[lastslash+1:] + } + if period := bytes.Index(name, dot); period >= 0 { + name = name[period+1:] + } + name = bytes.Replace(name, centerDot, dot, -1) + return name +} + +// Recovery returns a middleware that recovers from any panics and writes a 500 if there was one. +// While Martini is in development mode, Recovery will also output the panic as HTML. +func Recovery() Handler { + return func(c *Context, log *log.Logger) { + defer func() { + if err := recover(); err != nil { + stack := stack(3) + log.Printf("PANIC: %s\n%s", err, stack) + + // Lookup the current responsewriter + val := c.GetVal(inject.InterfaceOf((*http.ResponseWriter)(nil))) + res := val.Interface().(http.ResponseWriter) + + // respond with panic message while in development mode + var body []byte + if Env == DEV { + res.Header().Set("Content-Type", "text/html") + body = []byte(fmt.Sprintf(panicHtml, err, err, stack)) + } + + res.WriteHeader(http.StatusInternalServerError) + if nil != body { + _, _ = res.Write(body) + } + } + }() + + c.Next() + } +} diff --git a/pkg/macaron/recovery_test.go b/pkg/macaron/recovery_test.go new file mode 100755 index 00000000000..239c8d5920c --- /dev/null +++ b/pkg/macaron/recovery_test.go @@ -0,0 +1,74 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_Recovery(t *testing.T) { + Convey("Recovery from panic", t, func() { + buf := bytes.NewBufferString("") + setENV(DEV) + + m := New() + m.Map(log.New(buf, "[Macaron] ", 0)) + m.Use(func(res http.ResponseWriter, req *http.Request) { + res.Header().Set("Content-Type", "unpredictable") + }) + m.Use(Recovery()) + m.Use(func(res http.ResponseWriter, req *http.Request) { + panic("here is a panic!") + }) + m.Get("/", func() {}) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusInternalServerError) + So(resp.Header().Get("Content-Type"), ShouldEqual, "text/html") + So(buf.String(), ShouldNotBeEmpty) + }) + + Convey("Revocery panic to another response writer", t, func() { + resp := httptest.NewRecorder() + resp2 := httptest.NewRecorder() + setENV(DEV) + + m := New() + m.Use(Recovery()) + m.Use(func(c *Context) { + c.MapTo(resp2, (*http.ResponseWriter)(nil)) + panic("here is a panic!") + }) + m.Get("/", func() {}) + + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp2.Code, ShouldEqual, http.StatusInternalServerError) + So(resp2.Header().Get("Content-Type"), ShouldEqual, "text/html") + So(resp2.Body.Len(), ShouldBeGreaterThan, 0) + }) +} diff --git a/pkg/macaron/render.go b/pkg/macaron/render.go new file mode 100755 index 00000000000..04687c4f403 --- /dev/null +++ b/pkg/macaron/render.go @@ -0,0 +1,724 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "html/template" + "io" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/unknwon/com" +) + +const ( + _CONTENT_TYPE = "Content-Type" + _CONTENT_BINARY = "application/octet-stream" + _CONTENT_JSON = "application/json" + _CONTENT_HTML = "text/html" + _CONTENT_PLAIN = "text/plain" + _CONTENT_XHTML = "application/xhtml+xml" + _CONTENT_XML = "text/xml" + _DEFAULT_CHARSET = "UTF-8" +) + +var ( + // Provides a temporary buffer to execute templates into and catch errors. + bufpool = sync.Pool{ + New: func() interface{} { return new(bytes.Buffer) }, + } + + // Included helper functions for use when rendering html + helperFuncs = template.FuncMap{ + "yield": func() (string, error) { + return "", fmt.Errorf("yield called with no layout defined") + }, + "current": func() (string, error) { + return "", nil + }, + } +) + +type ( + // TemplateFile represents a interface of template file that has name and can be read. + TemplateFile interface { + Name() string + Data() []byte + Ext() string + } + // TemplateFileSystem represents a interface of template file system that able to list all files. + TemplateFileSystem interface { + ListFiles() []TemplateFile + Get(string) (io.Reader, error) + } + + // Delims represents a set of Left and Right delimiters for HTML template rendering + Delims struct { + // Left delimiter, defaults to {{ + Left string + // Right delimiter, defaults to }} + Right string + } + + // RenderOptions represents a struct for specifying configuration options for the Render middleware. + RenderOptions struct { + // Directory to load templates. Default is "templates". + Directory string + // Addtional directories to overwite templates. + AppendDirectories []string + // Layout template name. Will not render a layout if "". Default is to "". + Layout string + // Extensions to parse template files from. Defaults are [".tmpl", ".html"]. + Extensions []string + // Funcs is a slice of FuncMaps to apply to the template upon compilation. This is useful for helper functions. Default is []. + Funcs []template.FuncMap + // Delims sets the action delimiters to the specified strings in the Delims struct. + Delims Delims + // Appends the given charset to the Content-Type header. Default is "UTF-8". + Charset string + // Outputs human readable JSON. + IndentJSON bool + // Outputs human readable XML. + IndentXML bool + // Prefixes the JSON output with the given bytes. + PrefixJSON []byte + // Prefixes the XML output with the given bytes. + PrefixXML []byte + // Allows changing of output to XHTML instead of HTML. Default is "text/html" + HTMLContentType string + // TemplateFileSystem is the interface for supporting any implmentation of template file system. + TemplateFileSystem + } + + // HTMLOptions is a struct for overriding some rendering Options for specific HTML call + HTMLOptions struct { + // Layout template name. Overrides Options.Layout. + Layout string + } + + Render interface { + http.ResponseWriter + SetResponseWriter(http.ResponseWriter) + + JSON(int, interface{}) + JSONString(interface{}) (string, error) + RawData(int, []byte) // Serve content as binary + PlainText(int, []byte) // Serve content as plain text + HTML(int, string, interface{}, ...HTMLOptions) + HTMLSet(int, string, string, interface{}, ...HTMLOptions) + HTMLSetString(string, string, interface{}, ...HTMLOptions) (string, error) + HTMLString(string, interface{}, ...HTMLOptions) (string, error) + HTMLSetBytes(string, string, interface{}, ...HTMLOptions) ([]byte, error) + HTMLBytes(string, interface{}, ...HTMLOptions) ([]byte, error) + XML(int, interface{}) + Error(int, ...string) + Status(int) + SetTemplatePath(string, string) + HasTemplateSet(string) bool + } +) + +// TplFile implements TemplateFile interface. +type TplFile struct { + name string + data []byte + ext string +} + +// NewTplFile cerates new template file with given name and data. +func NewTplFile(name string, data []byte, ext string) *TplFile { + return &TplFile{name, data, ext} +} + +func (f *TplFile) Name() string { + return f.name +} + +func (f *TplFile) Data() []byte { + return f.data +} + +func (f *TplFile) Ext() string { + return f.ext +} + +// TplFileSystem implements TemplateFileSystem interface. +type TplFileSystem struct { + files []TemplateFile +} + +// NewTemplateFileSystem creates new template file system with given options. +func NewTemplateFileSystem(opt RenderOptions, omitData bool) TplFileSystem { + fs := TplFileSystem{} + fs.files = make([]TemplateFile, 0, 10) + + // Directories are composed in reverse order because later one overwrites previous ones, + // so once found, we can directly jump out of the loop. + dirs := make([]string, 0, len(opt.AppendDirectories)+1) + for i := len(opt.AppendDirectories) - 1; i >= 0; i-- { + dirs = append(dirs, opt.AppendDirectories[i]) + } + dirs = append(dirs, opt.Directory) + + var err error + for i := range dirs { + // Skip ones that does not exists for symlink test, + // but allow non-symlink ones added after start. + if !com.IsExist(dirs[i]) { + continue + } + + dirs[i], err = filepath.EvalSymlinks(dirs[i]) + if err != nil { + panic("EvalSymlinks(" + dirs[i] + "): " + err.Error()) + } + } + lastDir := dirs[len(dirs)-1] + + // We still walk the last (original) directory because it's non-sense we load templates not exist in original directory. + if err = filepath.Walk(lastDir, func(path string, info os.FileInfo, _ error) error { + r, err := filepath.Rel(lastDir, path) + if err != nil { + return err + } + + ext := GetExt(r) + + for _, extension := range opt.Extensions { + if ext != extension { + continue + } + + var data []byte + if !omitData { + // Loop over candidates of directory, break out once found. + // The file always exists because it's inside the walk function, + // and read original file is the worst case. + for i := range dirs { + path = filepath.Join(dirs[i], r) + if !com.IsFile(path) { + continue + } + + data, err = ioutil.ReadFile(path) + if err != nil { + return err + } + break + } + } + + name := filepath.ToSlash((r[0 : len(r)-len(ext)])) + fs.files = append(fs.files, NewTplFile(name, data, ext)) + } + + return nil + }); err != nil { + panic("NewTemplateFileSystem: " + err.Error()) + } + + return fs +} + +func (fs TplFileSystem) ListFiles() []TemplateFile { + return fs.files +} + +func (fs TplFileSystem) Get(name string) (io.Reader, error) { + for i := range fs.files { + if fs.files[i].Name()+fs.files[i].Ext() == name { + return bytes.NewReader(fs.files[i].Data()), nil + } + } + return nil, fmt.Errorf("file '%s' not found", name) +} + +func PrepareCharset(charset string) string { + if len(charset) != 0 { + return "; charset=" + charset + } + + return "; charset=" + _DEFAULT_CHARSET +} + +func GetExt(s string) string { + index := strings.Index(s, ".") + if index == -1 { + return "" + } + return s[index:] +} + +func compile(opt RenderOptions) *template.Template { + t := template.New(opt.Directory) + t.Delims(opt.Delims.Left, opt.Delims.Right) + // Parse an initial template in case we don't have any. + template.Must(t.Parse("Macaron")) + + if opt.TemplateFileSystem == nil { + opt.TemplateFileSystem = NewTemplateFileSystem(opt, false) + } + + for _, f := range opt.TemplateFileSystem.ListFiles() { + tmpl := t.New(f.Name()) + for _, funcs := range opt.Funcs { + tmpl.Funcs(funcs) + } + // Bomb out if parse fails. We don't want any silent server starts. + template.Must(tmpl.Funcs(helperFuncs).Parse(string(f.Data()))) + } + + return t +} + +const ( + DEFAULT_TPL_SET_NAME = "DEFAULT" +) + +// TemplateSet represents a template set of type *template.Template. +type TemplateSet struct { + lock sync.RWMutex + sets map[string]*template.Template + dirs map[string]string +} + +// NewTemplateSet initializes a new empty template set. +func NewTemplateSet() *TemplateSet { + return &TemplateSet{ + sets: make(map[string]*template.Template), + dirs: make(map[string]string), + } +} + +func (ts *TemplateSet) Set(name string, opt *RenderOptions) *template.Template { + t := compile(*opt) + + ts.lock.Lock() + defer ts.lock.Unlock() + + ts.sets[name] = t + ts.dirs[name] = opt.Directory + return t +} + +func (ts *TemplateSet) Get(name string) *template.Template { + ts.lock.RLock() + defer ts.lock.RUnlock() + + return ts.sets[name] +} + +func (ts *TemplateSet) GetDir(name string) string { + ts.lock.RLock() + defer ts.lock.RUnlock() + + return ts.dirs[name] +} + +func prepareRenderOptions(options []RenderOptions) RenderOptions { + var opt RenderOptions + if len(options) > 0 { + opt = options[0] + } + + // Defaults. + if len(opt.Directory) == 0 { + opt.Directory = "templates" + } + if len(opt.Extensions) == 0 { + opt.Extensions = []string{".tmpl", ".html"} + } + if len(opt.HTMLContentType) == 0 { + opt.HTMLContentType = _CONTENT_HTML + } + + return opt +} + +func ParseTplSet(tplSet string) (tplName string, tplDir string) { + tplSet = strings.TrimSpace(tplSet) + if len(tplSet) == 0 { + panic("empty template set argument") + } + infos := strings.Split(tplSet, ":") + if len(infos) == 1 { + tplDir = infos[0] + tplName = path.Base(tplDir) + } else { + tplName = infos[0] + tplDir = infos[1] + } + + if !com.IsDir(tplDir) { + panic("template set path does not exist or is not a directory") + } + return tplName, tplDir +} + +func renderHandler(opt RenderOptions, tplSets []string) Handler { + cs := PrepareCharset(opt.Charset) + ts := NewTemplateSet() + ts.Set(DEFAULT_TPL_SET_NAME, &opt) + + var tmpOpt RenderOptions + for _, tplSet := range tplSets { + tplName, tplDir := ParseTplSet(tplSet) + tmpOpt = opt + tmpOpt.Directory = tplDir + ts.Set(tplName, &tmpOpt) + } + + return func(ctx *Context) { + r := &TplRender{ + ResponseWriter: ctx.Resp, + TemplateSet: ts, + Opt: &opt, + CompiledCharset: cs, + } + ctx.Data["TmplLoadTimes"] = func() string { + if r.startTime.IsZero() { + return "" + } + return fmt.Sprint(time.Since(r.startTime).Nanoseconds()/1e6) + "ms" + } + + ctx.Render = r + ctx.MapTo(r, (*Render)(nil)) + } +} + +// Renderer is a Middleware that maps a macaron.Render service into the Macaron handler chain. +// An single variadic macaron.RenderOptions struct can be optionally provided to configure +// HTML rendering. The default directory for templates is "templates" and the default +// file extension is ".tmpl" and ".html". +// +// If MACARON_ENV is set to "" or "development" then templates will be recompiled on every request. For more performance, set the +// MACARON_ENV environment variable to "production". +func Renderer(options ...RenderOptions) Handler { + return renderHandler(prepareRenderOptions(options), []string{}) +} + +func Renderers(options RenderOptions, tplSets ...string) Handler { + return renderHandler(prepareRenderOptions([]RenderOptions{options}), tplSets) +} + +type TplRender struct { + http.ResponseWriter + *TemplateSet + Opt *RenderOptions + CompiledCharset string + + startTime time.Time +} + +func (r *TplRender) SetResponseWriter(rw http.ResponseWriter) { + r.ResponseWriter = rw +} + +func (r *TplRender) JSON(status int, v interface{}) { + var ( + result []byte + err error + ) + if r.Opt.IndentJSON { + result, err = json.MarshalIndent(v, "", " ") + } else { + result, err = json.Marshal(v) + } + if err != nil { + http.Error(r, err.Error(), 500) + return + } + + // json rendered fine, write out the result + r.Header().Set(_CONTENT_TYPE, _CONTENT_JSON+r.CompiledCharset) + r.WriteHeader(status) + if len(r.Opt.PrefixJSON) > 0 { + _, _ = r.Write(r.Opt.PrefixJSON) + } + _, _ = r.Write(result) +} + +func (r *TplRender) JSONString(v interface{}) (string, error) { + var result []byte + var err error + if r.Opt.IndentJSON { + result, err = json.MarshalIndent(v, "", " ") + } else { + result, err = json.Marshal(v) + } + if err != nil { + return "", err + } + return string(result), nil +} + +func (r *TplRender) XML(status int, v interface{}) { + var result []byte + var err error + if r.Opt.IndentXML { + result, err = xml.MarshalIndent(v, "", " ") + } else { + result, err = xml.Marshal(v) + } + if err != nil { + http.Error(r, err.Error(), 500) + return + } + + // XML rendered fine, write out the result + r.Header().Set(_CONTENT_TYPE, _CONTENT_XML+r.CompiledCharset) + r.WriteHeader(status) + if len(r.Opt.PrefixXML) > 0 { + _, _ = r.Write(r.Opt.PrefixXML) + } + _, _ = r.Write(result) +} + +func (r *TplRender) data(status int, contentType string, v []byte) { + if r.Header().Get(_CONTENT_TYPE) == "" { + r.Header().Set(_CONTENT_TYPE, contentType) + } + r.WriteHeader(status) + _, _ = r.Write(v) +} + +func (r *TplRender) RawData(status int, v []byte) { + r.data(status, _CONTENT_BINARY, v) +} + +func (r *TplRender) PlainText(status int, v []byte) { + r.data(status, _CONTENT_PLAIN, v) +} + +func (r *TplRender) execute(t *template.Template, name string, data interface{}) (*bytes.Buffer, error) { + buf := bufpool.Get().(*bytes.Buffer) + return buf, t.ExecuteTemplate(buf, name, data) +} + +func (r *TplRender) addYield(t *template.Template, tplName string, data interface{}) { + funcs := template.FuncMap{ + "yield": func() (template.HTML, error) { + buf, err := r.execute(t, tplName, data) + // return safe html here since we are rendering our own template + return template.HTML(buf.String()), err + }, + "current": func() (string, error) { + return tplName, nil + }, + } + t.Funcs(funcs) +} + +func (r *TplRender) renderBytes(setName, tplName string, data interface{}, htmlOpt ...HTMLOptions) (*bytes.Buffer, error) { + t := r.TemplateSet.Get(setName) + if Env == DEV { + opt := *r.Opt + opt.Directory = r.TemplateSet.GetDir(setName) + t = r.TemplateSet.Set(setName, &opt) + } + if t == nil { + return nil, fmt.Errorf("html/template: template \"%s\" is undefined", tplName) + } + + opt := r.prepareHTMLOptions(htmlOpt) + + if len(opt.Layout) > 0 { + r.addYield(t, tplName, data) + tplName = opt.Layout + } + + out, err := r.execute(t, tplName, data) + if err != nil { + return nil, err + } + + return out, nil +} + +func (r *TplRender) renderHTML(status int, setName, tplName string, data interface{}, htmlOpt ...HTMLOptions) { + r.startTime = time.Now() + + out, err := r.renderBytes(setName, tplName, data, htmlOpt...) + if err != nil { + http.Error(r, err.Error(), http.StatusInternalServerError) + return + } + + r.Header().Set(_CONTENT_TYPE, r.Opt.HTMLContentType+r.CompiledCharset) + r.WriteHeader(status) + + if _, err := out.WriteTo(r); err != nil { + out.Reset() + } + bufpool.Put(out) +} + +func (r *TplRender) HTML(status int, name string, data interface{}, htmlOpt ...HTMLOptions) { + r.renderHTML(status, DEFAULT_TPL_SET_NAME, name, data, htmlOpt...) +} + +func (r *TplRender) HTMLSet(status int, setName, tplName string, data interface{}, htmlOpt ...HTMLOptions) { + r.renderHTML(status, setName, tplName, data, htmlOpt...) +} + +func (r *TplRender) HTMLSetBytes(setName, tplName string, data interface{}, htmlOpt ...HTMLOptions) ([]byte, error) { + out, err := r.renderBytes(setName, tplName, data, htmlOpt...) + if err != nil { + return []byte(""), err + } + return out.Bytes(), nil +} + +func (r *TplRender) HTMLBytes(name string, data interface{}, htmlOpt ...HTMLOptions) ([]byte, error) { + return r.HTMLSetBytes(DEFAULT_TPL_SET_NAME, name, data, htmlOpt...) +} + +func (r *TplRender) HTMLSetString(setName, tplName string, data interface{}, htmlOpt ...HTMLOptions) (string, error) { + p, err := r.HTMLSetBytes(setName, tplName, data, htmlOpt...) + return string(p), err +} + +func (r *TplRender) HTMLString(name string, data interface{}, htmlOpt ...HTMLOptions) (string, error) { + p, err := r.HTMLBytes(name, data, htmlOpt...) + return string(p), err +} + +// Error writes the given HTTP status to the current ResponseWriter +func (r *TplRender) Error(status int, message ...string) { + r.WriteHeader(status) + if len(message) > 0 { + _, _ = r.Write([]byte(message[0])) + } +} + +func (r *TplRender) Status(status int) { + r.WriteHeader(status) +} + +func (r *TplRender) prepareHTMLOptions(htmlOpt []HTMLOptions) HTMLOptions { + if len(htmlOpt) > 0 { + return htmlOpt[0] + } + + return HTMLOptions{ + Layout: r.Opt.Layout, + } +} + +func (r *TplRender) SetTemplatePath(setName, dir string) { + if len(setName) == 0 { + setName = DEFAULT_TPL_SET_NAME + } + opt := *r.Opt + opt.Directory = dir + r.TemplateSet.Set(setName, &opt) +} + +func (r *TplRender) HasTemplateSet(name string) bool { + return r.TemplateSet.Get(name) != nil +} + +// DummyRender is used when user does not choose any real render to use. +// This way, we can print out friendly message which asks them to register one, +// instead of ugly and confusing 'nil pointer' panic. +type DummyRender struct { + http.ResponseWriter +} + +func renderNotRegistered() { + panic("middleware render hasn't been registered") +} + +func (r *DummyRender) SetResponseWriter(http.ResponseWriter) { + renderNotRegistered() +} + +func (r *DummyRender) JSON(int, interface{}) { + renderNotRegistered() +} + +func (r *DummyRender) JSONString(interface{}) (string, error) { + renderNotRegistered() + return "", nil +} + +func (r *DummyRender) RawData(int, []byte) { + renderNotRegistered() +} + +func (r *DummyRender) PlainText(int, []byte) { + renderNotRegistered() +} + +func (r *DummyRender) HTML(int, string, interface{}, ...HTMLOptions) { + renderNotRegistered() +} + +func (r *DummyRender) HTMLSet(int, string, string, interface{}, ...HTMLOptions) { + renderNotRegistered() +} + +func (r *DummyRender) HTMLSetString(string, string, interface{}, ...HTMLOptions) (string, error) { + renderNotRegistered() + return "", nil +} + +func (r *DummyRender) HTMLString(string, interface{}, ...HTMLOptions) (string, error) { + renderNotRegistered() + return "", nil +} + +func (r *DummyRender) HTMLSetBytes(string, string, interface{}, ...HTMLOptions) ([]byte, error) { + renderNotRegistered() + return nil, nil +} + +func (r *DummyRender) HTMLBytes(string, interface{}, ...HTMLOptions) ([]byte, error) { + renderNotRegistered() + return nil, nil +} + +func (r *DummyRender) XML(int, interface{}) { + renderNotRegistered() +} + +func (r *DummyRender) Error(int, ...string) { + renderNotRegistered() +} + +func (r *DummyRender) Status(int) { + renderNotRegistered() +} + +func (r *DummyRender) SetTemplatePath(string, string) { + renderNotRegistered() +} + +func (r *DummyRender) HasTemplateSet(string) bool { + renderNotRegistered() + return false +} diff --git a/pkg/macaron/render_test.go b/pkg/macaron/render_test.go new file mode 100755 index 00000000000..fa46caba698 --- /dev/null +++ b/pkg/macaron/render_test.go @@ -0,0 +1,742 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "encoding/xml" + "html/template" + "net/http" + "net/http/httptest" + "runtime" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +type Greeting struct { + One string `json:"one"` + Two string `json:"two"` +} + +type GreetingXML struct { + XMLName xml.Name `xml:"greeting"` + One string `xml:"one,attr"` + Two string `xml:"two,attr"` +} + +func Test_Render_JSON(t *testing.T) { + Convey("Render JSON", t, func() { + m := Classic() + m.Use(Renderer()) + m.Get("/foobar", func(r Render) { + r.JSON(300, Greeting{"hello", "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_JSON+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, `{"one":"hello","two":"world"}`) + }) + + Convey("Render JSON with prefix", t, func() { + m := Classic() + prefix := ")]}',\n" + m.Use(Renderer(RenderOptions{ + PrefixJSON: []byte(prefix), + })) + m.Get("/foobar", func(r Render) { + r.JSON(300, Greeting{"hello", "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_JSON+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, prefix+`{"one":"hello","two":"world"}`) + }) + + Convey("Render Indented JSON", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + IndentJSON: true, + })) + m.Get("/foobar", func(r Render) { + r.JSON(300, Greeting{"hello", "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_JSON+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, `{ + "one": "hello", + "two": "world" +}`) + }) + + Convey("Render JSON and return string", t, func() { + m := Classic() + m.Use(Renderer()) + m.Get("/foobar", func(r Render) { + result, err := r.JSONString(Greeting{"hello", "world"}) + So(err, ShouldBeNil) + So(result, ShouldEqual, `{"one":"hello","two":"world"}`) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + }) + + Convey("Render with charset JSON", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Charset: "foobar", + })) + m.Get("/foobar", func(r Render) { + r.JSON(300, Greeting{"hello", "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_JSON+"; charset=foobar") + So(resp.Body.String(), ShouldEqual, `{"one":"hello","two":"world"}`) + }) +} + +func Test_Render_XML(t *testing.T) { + Convey("Render XML", t, func() { + m := Classic() + m.Use(Renderer()) + m.Get("/foobar", func(r Render) { + r.XML(300, GreetingXML{One: "hello", Two: "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_XML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, ``) + }) + + Convey("Render XML with prefix", t, func() { + m := Classic() + prefix := ")]}',\n" + m.Use(Renderer(RenderOptions{ + PrefixXML: []byte(prefix), + })) + m.Get("/foobar", func(r Render) { + r.XML(300, GreetingXML{One: "hello", Two: "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_XML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, prefix+``) + }) + + Convey("Render Indented XML", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + IndentXML: true, + })) + m.Get("/foobar", func(r Render) { + r.XML(300, GreetingXML{One: "hello", Two: "world"}) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusMultipleChoices) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_XML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, ``) + }) +} + +func Test_Render_HTML(t *testing.T) { + Convey("Render HTML", t, func() { + m := Classic() + m.Use(Renderers(RenderOptions{ + Directory: "fixtures/basic", + }, "fixtures/basic2")) + m.Get("/foobar", func(r Render) { + r.SetResponseWriter(r.(*TplRender).ResponseWriter) + r.HTML(200, "hello", "jeremy") + r.SetTemplatePath("", "fixtures/basic2") + }) + m.Get("/foobar2", func(r Render) { + if r.HasTemplateSet("basic2") { + r.HTMLSet(200, "basic2", "hello", "jeremy") + } + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

Hello jeremy

") + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/foobar2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

What's up, jeremy

") + + Convey("Change render templates path", func() { + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

What's up, jeremy

") + }) + }) + + Convey("Render HTML and return string", t, func() { + m := Classic() + m.Use(Renderers(RenderOptions{ + Directory: "fixtures/basic", + }, "basic2:fixtures/basic2")) + m.Get("/foobar", func(r Render) { + result, err := r.HTMLString("hello", "jeremy") + So(err, ShouldBeNil) + So(result, ShouldEqual, "

Hello jeremy

") + }) + m.Get("/foobar2", func(r Render) { + result, err := r.HTMLSetString("basic2", "hello", "jeremy") + So(err, ShouldBeNil) + So(result, ShouldEqual, "

What's up, jeremy

") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/foobar2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + }) + + Convey("Render with nested HTML", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "admin/index", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

Admin jeremy

") + }) + + Convey("Render bad HTML", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "nope", nil) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusInternalServerError) + So(resp.Body.String(), ShouldEqual, "html/template: \"nope\" is undefined\n") + }) + + Convey("Invalid template set", t, func() { + Convey("Empty template set argument", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m := Classic() + m.Use(Renderers(RenderOptions{ + Directory: "fixtures/basic", + }, "")) + }) + + Convey("Bad template set path", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m := Classic() + m.Use(Renderers(RenderOptions{ + Directory: "fixtures/basic", + }, "404")) + }) + }) +} + +func Test_Render_XHTML(t *testing.T) { + Convey("Render XHTML", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + HTMLContentType: _CONTENT_XHTML, + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "hello", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_XHTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

Hello jeremy

") + }) +} + +func Test_Render_Extensions(t *testing.T) { + Convey("Render with extensions", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + Extensions: []string{".tmpl", ".html"}, + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "hypertext", nil) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "Hypertext!") + }) +} + +func Test_Render_Funcs(t *testing.T) { + Convey("Render with functions", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/custom_funcs", + Funcs: []template.FuncMap{ + { + "myCustomFunc": func() string { + return "My custom function" + }, + }, + }, + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "index", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "My custom function") + }) +} + +func Test_Render_Layout(t *testing.T) { + Convey("Render with layout", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + Layout: "layout", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "content", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "head

jeremy

foot") + }) + + Convey("Render with current layout", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + Layout: "current_layout", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "content", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "content head

jeremy

content foot") + }) + + Convey("Render with override layout", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + Layout: "layout", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "content", "jeremy", HTMLOptions{ + Layout: "another_layout", + }) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "another head

jeremy

another foot") + }) +} + +func Test_Render_Delimiters(t *testing.T) { + Convey("Render with delimiters", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Delims: Delims{"{[{", "}]}"}, + Directory: "fixtures/basic", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "delims", "jeremy") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_HTML+"; charset=UTF-8") + So(resp.Body.String(), ShouldEqual, "

Hello jeremy

") + }) +} + +func Test_Render_BinaryData(t *testing.T) { + Convey("Render binary data", t, func() { + m := Classic() + m.Use(Renderer()) + m.Get("/foobar", func(r Render) { + r.RawData(200, []byte("hello there")) + }) + m.Get("/foobar2", func(r Render) { + r.PlainText(200, []byte("hello there")) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_BINARY) + So(resp.Body.String(), ShouldEqual, "hello there") + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/foobar2", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, _CONTENT_PLAIN) + So(resp.Body.String(), ShouldEqual, "hello there") + }) + + Convey("Render binary data with mime type", t, func() { + m := Classic() + m.Use(Renderer()) + m.Get("/foobar", func(r Render) { + r.(*TplRender).ResponseWriter.Header().Set(_CONTENT_TYPE, "image/jpeg") + r.RawData(200, []byte("..jpeg data..")) + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get(_CONTENT_TYPE), ShouldEqual, "image/jpeg") + So(resp.Body.String(), ShouldEqual, "..jpeg data..") + }) +} + +func Test_Render_Status(t *testing.T) { + Convey("Render with status 204", t, func() { + resp := httptest.NewRecorder() + r := TplRender{resp, NewTemplateSet(), &RenderOptions{}, "", time.Now()} + r.Status(204) + So(resp.Code, ShouldEqual, http.StatusNoContent) + }) + + Convey("Render with status 404", t, func() { + resp := httptest.NewRecorder() + r := TplRender{resp, NewTemplateSet(), &RenderOptions{}, "", time.Now()} + r.Error(404) + So(resp.Code, ShouldEqual, http.StatusNotFound) + }) + + Convey("Render with status 500", t, func() { + resp := httptest.NewRecorder() + r := TplRender{resp, NewTemplateSet(), &RenderOptions{}, "", time.Now()} + r.Error(500) + So(resp.Code, ShouldEqual, http.StatusInternalServerError) + }) +} + +func Test_Render_NoRace(t *testing.T) { + Convey("Make sure render has no race", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "hello", "world") + }) + + done := make(chan bool) + doreq := func() { + resp := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/foobar", nil) + m.ServeHTTP(resp, req) + done <- true + } + // Run two requests to check there is no race condition + go doreq() + go doreq() + <-done + <-done + }) +} + +func Test_Render_Symlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping testing on Windows") + } + + Convey("Render can follow symlinks", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/symlink", + })) + m.Get("/foobar", func(r Render) { + r.HTML(200, "hello", "world") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foobar", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusOK) + }) +} + +func Test_Render_AppendDirectories(t *testing.T) { + Convey("Render with additional templates", t, func() { + m := Classic() + m.Use(Renderer(RenderOptions{ + Directory: "fixtures/basic", + AppendDirectories: []string{"fixtures/basic/custom"}, + })) + + Convey("Request normal template", func() { + m.Get("/normal", func(r Render) { + r.HTML(200, "content", "Macaron") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/normal", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "

Macaron

") + So(resp.Code, ShouldEqual, http.StatusOK) + }) + + Convey("Request overwritten template", func() { + m.Get("/custom", func(r Render) { + r.HTML(200, "hello", "world") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/custom", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "

This is custom version of: Hello world

") + So(resp.Code, ShouldEqual, http.StatusOK) + }) + + }) +} + +func Test_GetExt(t *testing.T) { + Convey("Get extension", t, func() { + So(GetExt("test"), ShouldBeBlank) + So(GetExt("test.tmpl"), ShouldEqual, ".tmpl") + So(GetExt("test.go.tmpl"), ShouldEqual, ".go.tmpl") + }) +} + +func Test_dummyRender(t *testing.T) { + shouldPanic := func() { So(recover(), ShouldNotBeNil) } + + Convey("Use dummy render to gracefully handle panic", t, func() { + m := New() + + performRequest := func(method, path string) { + resp := httptest.NewRecorder() + req, err := http.NewRequest(method, path, nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + } + + m.Get("/set_response_writer", func(ctx *Context) { + defer shouldPanic() + ctx.SetResponseWriter(nil) + }) + m.Get("/json", func(ctx *Context) { + defer shouldPanic() + ctx.JSON(0, nil) + }) + m.Get("/jsonstring", func(ctx *Context) { + defer shouldPanic() + _, _ = ctx.JSONString(nil) + }) + m.Get("/rawdata", func(ctx *Context) { + defer shouldPanic() + ctx.RawData(0, nil) + }) + m.Get("/plaintext", func(ctx *Context) { + defer shouldPanic() + ctx.PlainText(0, nil) + }) + m.Get("/html", func(ctx *Context) { + defer shouldPanic() + ctx.Render.HTML(0, "", nil) + }) + m.Get("/htmlset", func(ctx *Context) { + defer shouldPanic() + ctx.Render.HTMLSet(0, "", "", nil) + }) + m.Get("/htmlsetstring", func(ctx *Context) { + defer shouldPanic() + _, _ = ctx.Render.HTMLSetString("", "", nil) + }) + m.Get("/htmlstring", func(ctx *Context) { + defer shouldPanic() + _, _ = ctx.Render.HTMLString("", nil) + }) + m.Get("/htmlsetbytes", func(ctx *Context) { + defer shouldPanic() + _, _ = ctx.Render.HTMLSetBytes("", "", nil) + }) + m.Get("/htmlbytes", func(ctx *Context) { + defer shouldPanic() + _, _ = ctx.Render.HTMLBytes("", nil) + }) + m.Get("/xml", func(ctx *Context) { + defer shouldPanic() + ctx.XML(0, nil) + }) + m.Get("/error", func(ctx *Context) { + defer shouldPanic() + ctx.Error(0) + }) + m.Get("/status", func(ctx *Context) { + defer shouldPanic() + ctx.Status(0) + }) + m.Get("/settemplatepath", func(ctx *Context) { + defer shouldPanic() + ctx.SetTemplatePath("", "") + }) + m.Get("/hastemplateset", func(ctx *Context) { + defer shouldPanic() + ctx.HasTemplateSet("") + }) + + performRequest("GET", "/set_response_writer") + performRequest("GET", "/json") + performRequest("GET", "/jsonstring") + performRequest("GET", "/rawdata") + performRequest("GET", "/jsonstring") + performRequest("GET", "/plaintext") + performRequest("GET", "/html") + performRequest("GET", "/htmlset") + performRequest("GET", "/htmlsetstring") + performRequest("GET", "/htmlstring") + performRequest("GET", "/htmlsetbytes") + performRequest("GET", "/htmlbytes") + performRequest("GET", "/xml") + performRequest("GET", "/error") + performRequest("GET", "/status") + performRequest("GET", "/settemplatepath") + performRequest("GET", "/hastemplateset") + }) +} diff --git a/pkg/macaron/response_writer.go b/pkg/macaron/response_writer.go new file mode 100755 index 00000000000..eeb35f642e6 --- /dev/null +++ b/pkg/macaron/response_writer.go @@ -0,0 +1,124 @@ +// Copyright 2013 Martini Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bufio" + "errors" + "net" + "net/http" +) + +// ResponseWriter is a wrapper around http.ResponseWriter that provides extra information about +// the response. It is recommended that middleware handlers use this construct to wrap a responsewriter +// if the functionality calls for it. +type ResponseWriter interface { + http.ResponseWriter + http.Flusher + http.Pusher + // Status returns the status code of the response or 0 if the response has not been written. + Status() int + // Written returns whether or not the ResponseWriter has been written. + Written() bool + // Size returns the size of the response body. + Size() int + // Before allows for a function to be called before the ResponseWriter has been written to. This is + // useful for setting headers or any other operations that must happen before a response has been written. + Before(BeforeFunc) +} + +// BeforeFunc is a function that is called before the ResponseWriter has been written to. +type BeforeFunc func(ResponseWriter) + +// NewResponseWriter creates a ResponseWriter that wraps an http.ResponseWriter +func NewResponseWriter(method string, rw http.ResponseWriter) ResponseWriter { + return &responseWriter{method, rw, 0, 0, nil} +} + +type responseWriter struct { + method string + http.ResponseWriter + status int + size int + beforeFuncs []BeforeFunc +} + +func (rw *responseWriter) WriteHeader(s int) { + rw.callBefore() + rw.ResponseWriter.WriteHeader(s) + rw.status = s +} + +func (rw *responseWriter) Write(b []byte) (size int, err error) { + if !rw.Written() { + // The status will be StatusOK if WriteHeader has not been called yet + rw.WriteHeader(http.StatusOK) + } + if rw.method != "HEAD" { + size, err = rw.ResponseWriter.Write(b) + rw.size += size + } + return size, err +} + +func (rw *responseWriter) Status() int { + return rw.status +} + +func (rw *responseWriter) Size() int { + return rw.size +} + +func (rw *responseWriter) Written() bool { + return rw.status != 0 +} + +func (rw *responseWriter) Before(before BeforeFunc) { + rw.beforeFuncs = append(rw.beforeFuncs, before) +} + +func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := rw.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("the ResponseWriter doesn't support the Hijacker interface") + } + return hijacker.Hijack() +} + +//nolint +func (rw *responseWriter) CloseNotify() <-chan bool { + return rw.ResponseWriter.(http.CloseNotifier).CloseNotify() +} + +func (rw *responseWriter) callBefore() { + for i := len(rw.beforeFuncs) - 1; i >= 0; i-- { + rw.beforeFuncs[i](rw) + } +} + +func (rw *responseWriter) Flush() { + flusher, ok := rw.ResponseWriter.(http.Flusher) + if ok { + flusher.Flush() + } +} + +func (rw *responseWriter) Push(target string, opts *http.PushOptions) error { + pusher, ok := rw.ResponseWriter.(http.Pusher) + if !ok { + return errors.New("the ResponseWriter doesn't support the Pusher interface") + } + return pusher.Push(target, opts) +} diff --git a/pkg/macaron/response_writer_test.go b/pkg/macaron/response_writer_test.go new file mode 100755 index 00000000000..c5e7b2edec5 --- /dev/null +++ b/pkg/macaron/response_writer_test.go @@ -0,0 +1,195 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bufio" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +type closeNotifyingRecorder struct { + *httptest.ResponseRecorder + closed chan bool +} + +func newCloseNotifyingRecorder() *closeNotifyingRecorder { + return &closeNotifyingRecorder{ + httptest.NewRecorder(), + make(chan bool, 1), + } +} + +func (c *closeNotifyingRecorder) close() { + c.closed <- true +} + +func (c *closeNotifyingRecorder) CloseNotify() <-chan bool { + return c.closed +} + +type hijackableResponse struct { + Hijacked bool +} + +func newHijackableResponse() *hijackableResponse { + return &hijackableResponse{} +} + +func (h *hijackableResponse) Header() http.Header { return nil } +func (h *hijackableResponse) Write(buf []byte) (int, error) { return 0, nil } +func (h *hijackableResponse) WriteHeader(code int) {} +func (h *hijackableResponse) Flush() {} +func (h *hijackableResponse) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h.Hijacked = true + return nil, nil, nil +} + +func Test_ResponseWriter(t *testing.T) { + Convey("Write string to response writer", t, func() { + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + _, _ = rw.Write([]byte("Hello world")) + + So(resp.Code, ShouldEqual, rw.Status()) + So(resp.Body.String(), ShouldEqual, "Hello world") + So(rw.Status(), ShouldEqual, http.StatusOK) + So(rw.Size(), ShouldEqual, 11) + So(rw.Written(), ShouldBeTrue) + }) + + Convey("Write strings to response writer", t, func() { + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + _, _ = rw.Write([]byte("Hello world")) + _, _ = rw.Write([]byte("foo bar bat baz")) + + So(resp.Code, ShouldEqual, rw.Status()) + So(resp.Body.String(), ShouldEqual, "Hello worldfoo bar bat baz") + So(rw.Status(), ShouldEqual, http.StatusOK) + So(rw.Size(), ShouldEqual, 26) + So(rw.Written(), ShouldBeTrue) + }) + + Convey("Write header to response writer", t, func() { + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + rw.WriteHeader(http.StatusNotFound) + + So(resp.Code, ShouldEqual, rw.Status()) + So(resp.Body.String(), ShouldBeBlank) + So(rw.Status(), ShouldEqual, http.StatusNotFound) + So(rw.Size(), ShouldEqual, 0) + }) + + Convey("Write before response write", t, func() { + result := "" + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + rw.Before(func(ResponseWriter) { + result += "foo" + }) + rw.Before(func(ResponseWriter) { + result += "bar" + }) + rw.WriteHeader(http.StatusNotFound) + + So(resp.Code, ShouldEqual, rw.Status()) + So(resp.Body.String(), ShouldBeBlank) + So(rw.Status(), ShouldEqual, http.StatusNotFound) + So(rw.Size(), ShouldEqual, 0) + So(result, ShouldEqual, "barfoo") + }) + + Convey("Response writer with Hijack", t, func() { + hijackable := newHijackableResponse() + rw := NewResponseWriter("GET", hijackable) + hijacker, ok := rw.(http.Hijacker) + So(ok, ShouldBeTrue) + _, _, err := hijacker.Hijack() + So(err, ShouldBeNil) + So(hijackable.Hijacked, ShouldBeTrue) + }) + + Convey("Response writer with bad Hijack", t, func() { + hijackable := new(http.ResponseWriter) + rw := NewResponseWriter("GET", *hijackable) + hijacker, ok := rw.(http.Hijacker) + So(ok, ShouldBeTrue) + _, _, err := hijacker.Hijack() + So(err, ShouldNotBeNil) + }) + + Convey("Response writer with close notify", t, func() { + resp := newCloseNotifyingRecorder() + rw := NewResponseWriter("GET", resp) + closed := false + notifier := rw.(http.CloseNotifier).CloseNotify() //nolint + resp.close() + select { + case <-notifier: + closed = true + case <-time.After(time.Second): + } + So(closed, ShouldBeTrue) + }) + + Convey("Response writer with flusher", t, func() { + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + _, ok := rw.(http.Flusher) + So(ok, ShouldBeTrue) + }) + + Convey("Response writer with flusher handler", t, func() { + m := Classic() + m.Get("/events", func(w http.ResponseWriter, r *http.Request) { + f, ok := w.(http.Flusher) + So(ok, ShouldBeTrue) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + for i := 0; i < 2; i++ { + time.Sleep(10 * time.Millisecond) + _, _ = io.WriteString(w, "data: Hello\n\n") + f.Flush() + } + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/events", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Body.String(), ShouldEqual, "data: Hello\n\ndata: Hello\n\n") + }) + + Convey("Response writer with http/2 push", t, func() { + resp := httptest.NewRecorder() + rw := NewResponseWriter("GET", resp) + _, ok := rw.(http.Pusher) + So(ok, ShouldBeTrue) + }) +} diff --git a/pkg/macaron/return_handler.go b/pkg/macaron/return_handler.go new file mode 100755 index 00000000000..33b7fd2be30 --- /dev/null +++ b/pkg/macaron/return_handler.go @@ -0,0 +1,76 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "net/http" + "reflect" + + "github.com/go-macaron/inject" +) + +// ReturnHandler is a service that Martini provides that is called +// when a route handler returns something. The ReturnHandler is +// responsible for writing to the ResponseWriter based on the values +// that are passed into this function. +type ReturnHandler func(*Context, []reflect.Value) + +func canDeref(val reflect.Value) bool { + return val.Kind() == reflect.Interface || val.Kind() == reflect.Ptr +} + +func isError(val reflect.Value) bool { + _, ok := val.Interface().(error) + return ok +} + +func isByteSlice(val reflect.Value) bool { + return val.Kind() == reflect.Slice && val.Type().Elem().Kind() == reflect.Uint8 +} + +func defaultReturnHandler() ReturnHandler { + return func(ctx *Context, vals []reflect.Value) { + rv := ctx.GetVal(inject.InterfaceOf((*http.ResponseWriter)(nil))) + resp := rv.Interface().(http.ResponseWriter) + var respVal reflect.Value + if len(vals) > 1 && vals[0].Kind() == reflect.Int { + resp.WriteHeader(int(vals[0].Int())) + respVal = vals[1] + } else if len(vals) > 0 { + respVal = vals[0] + + if isError(respVal) { + err := respVal.Interface().(error) + if err != nil { + ctx.internalServerError(ctx, err) + } + return + } else if canDeref(respVal) { + if respVal.IsNil() { + return // Ignore nil error + } + } + } + if canDeref(respVal) { + respVal = respVal.Elem() + } + if isByteSlice(respVal) { + _, _ = resp.Write(respVal.Bytes()) + } else { + _, _ = resp.Write([]byte(respVal.String())) + } + } +} diff --git a/pkg/macaron/return_handler_test.go b/pkg/macaron/return_handler_test.go new file mode 100755 index 00000000000..da5ba933e85 --- /dev/null +++ b/pkg/macaron/return_handler_test.go @@ -0,0 +1,127 @@ +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +type r1Invoker func() (int, string) + +func (l r1Invoker) Invoke(p []interface{}) ([]reflect.Value, error) { + ret, str := l() + return []reflect.Value{reflect.ValueOf(ret), reflect.ValueOf(str)}, nil +} + +func Test_Return_Handler(t *testing.T) { + Convey("Return with status and body", t, func() { + m := New() + m.Get("/", func() (int, string) { + return 418, "i'm a teapot" + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusTeapot) + So(resp.Body.String(), ShouldEqual, "i'm a teapot") + }) + + Convey("Return with status and body-FastInvoke", t, func() { + m := New() + m.Get("/", r1Invoker(func() (int, string) { + return 418, "i'm a teapot" + })) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusTeapot) + So(resp.Body.String(), ShouldEqual, "i'm a teapot") + }) + + Convey("Return with error", t, func() { + m := New() + //revive:disable + m.Get("/", func() error { + return errors.New("what the hell!!!") + }) + //revive:enable + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusInternalServerError) + So(resp.Body.String(), ShouldEqual, "what the hell!!!\n") + + Convey("Return with nil error", func() { + m := New() + m.Get("/", func() error { + return nil + }, func() (int, string) { + return 200, "Awesome" + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Body.String(), ShouldEqual, "Awesome") + }) + }) + + Convey("Return with pointer", t, func() { + m := New() + m.Get("/", func() *string { + str := "hello world" + return &str + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "hello world") + }) + + Convey("Return with byte slice", t, func() { + m := New() + m.Get("/", func() []byte { + return []byte("hello world") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Body.String(), ShouldEqual, "hello world") + }) +} diff --git a/pkg/macaron/router.go b/pkg/macaron/router.go new file mode 100755 index 00000000000..673a37d1eea --- /dev/null +++ b/pkg/macaron/router.go @@ -0,0 +1,382 @@ +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "net/http" + "strings" + "sync" +) + +var ( + // Known HTTP methods. + _HTTP_METHODS = map[string]bool{ + "GET": true, + "POST": true, + "PUT": true, + "DELETE": true, + "PATCH": true, + "OPTIONS": true, + "HEAD": true, + } +) + +// routeMap represents a thread-safe map for route tree. +type routeMap struct { + lock sync.RWMutex + routes map[string]map[string]*Leaf +} + +// NewRouteMap initializes and returns a new routeMap. +func NewRouteMap() *routeMap { + rm := &routeMap{ + routes: make(map[string]map[string]*Leaf), + } + for m := range _HTTP_METHODS { + rm.routes[m] = make(map[string]*Leaf) + } + return rm +} + +// getLeaf returns Leaf object if a route has been registered. +func (rm *routeMap) getLeaf(method, pattern string) *Leaf { + rm.lock.RLock() + defer rm.lock.RUnlock() + + return rm.routes[method][pattern] +} + +// add adds new route to route tree map. +func (rm *routeMap) add(method, pattern string, leaf *Leaf) { + rm.lock.Lock() + defer rm.lock.Unlock() + + rm.routes[method][pattern] = leaf +} + +type group struct { + pattern string + handlers []Handler +} + +// Router represents a Macaron router layer. +type Router struct { + m *Macaron + autoHead bool + routers map[string]*Tree + *routeMap + namedRoutes map[string]*Leaf + + groups []group + notFound http.HandlerFunc + internalServerError func(*Context, error) + + // handlerWrapper is used to wrap arbitrary function from Handler to inject.FastInvoker. + handlerWrapper func(Handler) Handler +} + +func NewRouter() *Router { + return &Router{ + routers: make(map[string]*Tree), + routeMap: NewRouteMap(), + namedRoutes: make(map[string]*Leaf), + } +} + +// SetAutoHead sets the value who determines whether add HEAD method automatically +// when GET method is added. +func (r *Router) SetAutoHead(v bool) { + r.autoHead = v +} + +type Params map[string]string + +// Handle is a function that can be registered to a route to handle HTTP requests. +// Like http.HandlerFunc, but has a third parameter for the values of wildcards (variables). +type Handle func(http.ResponseWriter, *http.Request, Params) + +// Route represents a wrapper of leaf route and upper level router. +type Route struct { + router *Router + leaf *Leaf +} + +// Name sets name of route. +func (r *Route) Name(name string) { + if len(name) == 0 { + panic("route name cannot be empty") + } else if r.router.namedRoutes[name] != nil { + panic("route with given name already exists: " + name) + } + r.router.namedRoutes[name] = r.leaf +} + +// handle adds new route to the router tree. +func (r *Router) handle(method, pattern string, handle Handle) *Route { + method = strings.ToUpper(method) + + var leaf *Leaf + // Prevent duplicate routes. + if leaf = r.getLeaf(method, pattern); leaf != nil { + return &Route{r, leaf} + } + + // Validate HTTP methods. + if !_HTTP_METHODS[method] && method != "*" { + panic("unknown HTTP method: " + method) + } + + // Generate methods need register. + methods := make(map[string]bool) + if method == "*" { + for m := range _HTTP_METHODS { + methods[m] = true + } + } else { + methods[method] = true + } + + // Add to router tree. + for m := range methods { + if t, ok := r.routers[m]; ok { + leaf = t.Add(pattern, handle) + } else { + t := NewTree() + leaf = t.Add(pattern, handle) + r.routers[m] = t + } + r.add(m, pattern, leaf) + } + return &Route{r, leaf} +} + +// Handle registers a new request handle with the given pattern, method and handlers. +func (r *Router) Handle(method string, pattern string, handlers []Handler) *Route { + if len(r.groups) > 0 { + groupPattern := "" + h := make([]Handler, 0) + for _, g := range r.groups { + groupPattern += g.pattern + h = append(h, g.handlers...) + } + + pattern = groupPattern + pattern + h = append(h, handlers...) + handlers = h + } + handlers = validateAndWrapHandlers(handlers, r.handlerWrapper) + + return r.handle(method, pattern, func(resp http.ResponseWriter, req *http.Request, params Params) { + c := r.m.createContext(resp, req) + c.params = params + c.handlers = make([]Handler, 0, len(r.m.handlers)+len(handlers)) + c.handlers = append(c.handlers, r.m.handlers...) + c.handlers = append(c.handlers, handlers...) + c.run() + }) +} + +func (r *Router) Group(pattern string, fn func(), h ...Handler) { + r.groups = append(r.groups, group{pattern, h}) + fn() + r.groups = r.groups[:len(r.groups)-1] +} + +// Get is a shortcut for r.Handle("GET", pattern, handlers) +func (r *Router) Get(pattern string, h ...Handler) (leaf *Route) { + leaf = r.Handle("GET", pattern, h) + if r.autoHead { + r.Head(pattern, h...) + } + return leaf +} + +// Patch is a shortcut for r.Handle("PATCH", pattern, handlers) +func (r *Router) Patch(pattern string, h ...Handler) *Route { + return r.Handle("PATCH", pattern, h) +} + +// Post is a shortcut for r.Handle("POST", pattern, handlers) +func (r *Router) Post(pattern string, h ...Handler) *Route { + return r.Handle("POST", pattern, h) +} + +// Put is a shortcut for r.Handle("PUT", pattern, handlers) +func (r *Router) Put(pattern string, h ...Handler) *Route { + return r.Handle("PUT", pattern, h) +} + +// Delete is a shortcut for r.Handle("DELETE", pattern, handlers) +func (r *Router) Delete(pattern string, h ...Handler) *Route { + return r.Handle("DELETE", pattern, h) +} + +// Options is a shortcut for r.Handle("OPTIONS", pattern, handlers) +func (r *Router) Options(pattern string, h ...Handler) *Route { + return r.Handle("OPTIONS", pattern, h) +} + +// Head is a shortcut for r.Handle("HEAD", pattern, handlers) +func (r *Router) Head(pattern string, h ...Handler) *Route { + return r.Handle("HEAD", pattern, h) +} + +// Any is a shortcut for r.Handle("*", pattern, handlers) +func (r *Router) Any(pattern string, h ...Handler) *Route { + return r.Handle("*", pattern, h) +} + +// Route is a shortcut for same handlers but different HTTP methods. +// +// Example: +// m.Route("/", "GET,POST", h) +func (r *Router) Route(pattern, methods string, h ...Handler) (route *Route) { + for _, m := range strings.Split(methods, ",") { + route = r.Handle(strings.TrimSpace(m), pattern, h) + } + return route +} + +// Combo returns a combo router. +func (r *Router) Combo(pattern string, h ...Handler) *ComboRouter { + return &ComboRouter{r, pattern, h, map[string]bool{}, nil} +} + +// NotFound configurates http.HandlerFunc which is called when no matching route is +// found. If it is not set, http.NotFound is used. +// Be sure to set 404 response code in your handler. +func (r *Router) NotFound(handlers ...Handler) { + handlers = validateAndWrapHandlers(handlers) + r.notFound = func(rw http.ResponseWriter, req *http.Request) { + c := r.m.createContext(rw, req) + c.handlers = make([]Handler, 0, len(r.m.handlers)+len(handlers)) + c.handlers = append(c.handlers, r.m.handlers...) + c.handlers = append(c.handlers, handlers...) + c.run() + } +} + +// InternalServerError configurates handler which is called when route handler returns +// error. If it is not set, default handler is used. +// Be sure to set 500 response code in your handler. +func (r *Router) InternalServerError(handlers ...Handler) { + handlers = validateAndWrapHandlers(handlers) + r.internalServerError = func(c *Context, err error) { + c.index = 0 + c.handlers = handlers + c.Map(err) + c.run() + } +} + +// SetHandlerWrapper sets handlerWrapper for the router. +func (r *Router) SetHandlerWrapper(f func(Handler) Handler) { + r.handlerWrapper = f +} + +func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if t, ok := r.routers[req.Method]; ok { + if !strings.ContainsAny(req.URL.Path, ":*") { + // Fast match for static routes + leaf := r.getLeaf(req.Method, req.URL.Path) + if leaf != nil { + leaf.handle(rw, req, nil) + return + } + } + + h, p, ok := t.Match(req.URL.EscapedPath()) + if ok { + if splat, ok := p["*0"]; ok { + p["*"] = splat // Easy name. + } + h(rw, req, p) + return + } + } + + r.notFound(rw, req) +} + +// URLFor builds path part of URL by given pair values. +func (r *Router) URLFor(name string, pairs ...string) string { + leaf, ok := r.namedRoutes[name] + if !ok { + panic("route with given name does not exists: " + name) + } + return leaf.URLPath(pairs...) +} + +// ComboRouter represents a combo router. +type ComboRouter struct { + router *Router + pattern string + handlers []Handler + methods map[string]bool // Registered methods. + + lastRoute *Route +} + +func (cr *ComboRouter) checkMethod(name string) { + if cr.methods[name] { + panic("method '" + name + "' has already been registered") + } + cr.methods[name] = true +} + +func (cr *ComboRouter) route(fn func(string, ...Handler) *Route, method string, h ...Handler) *ComboRouter { + cr.checkMethod(method) + cr.lastRoute = fn(cr.pattern, append(cr.handlers, h...)...) + return cr +} + +func (cr *ComboRouter) Get(h ...Handler) *ComboRouter { + if cr.router.autoHead { + cr.Head(h...) + } + return cr.route(cr.router.Get, "GET", h...) +} + +func (cr *ComboRouter) Patch(h ...Handler) *ComboRouter { + return cr.route(cr.router.Patch, "PATCH", h...) +} + +func (cr *ComboRouter) Post(h ...Handler) *ComboRouter { + return cr.route(cr.router.Post, "POST", h...) +} + +func (cr *ComboRouter) Put(h ...Handler) *ComboRouter { + return cr.route(cr.router.Put, "PUT", h...) +} + +func (cr *ComboRouter) Delete(h ...Handler) *ComboRouter { + return cr.route(cr.router.Delete, "DELETE", h...) +} + +func (cr *ComboRouter) Options(h ...Handler) *ComboRouter { + return cr.route(cr.router.Options, "OPTIONS", h...) +} + +func (cr *ComboRouter) Head(h ...Handler) *ComboRouter { + return cr.route(cr.router.Head, "HEAD", h...) +} + +// Name sets name of ComboRouter route. +func (cr *ComboRouter) Name(name string) { + if cr.lastRoute == nil { + panic("no corresponding route to be named") + } + cr.lastRoute.Name(name) +} diff --git a/pkg/macaron/router_test.go b/pkg/macaron/router_test.go new file mode 100755 index 00000000000..3e38b984e34 --- /dev/null +++ b/pkg/macaron/router_test.go @@ -0,0 +1,347 @@ +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_Router_Handle(t *testing.T) { + test_Router_Handle(t, false) +} +func Test_Router_FastInvoker_Handle(t *testing.T) { + test_Router_Handle(t, true) +} + +// handlerFunc0Invoker func()string Invoker Handler +type handlerFunc0Invoker func() string + +// Invoke handlerFunc0Invoker +func (l handlerFunc0Invoker) Invoke(p []interface{}) ([]reflect.Value, error) { + ret := l() + return []reflect.Value{reflect.ValueOf(ret)}, nil +} + +func test_Router_Handle(t *testing.T, isFast bool) { + Convey("Register all HTTP methods routes", t, func() { + m := New() + + if isFast { + // FastInvoker Handler Wrap Action + m.Router.SetHandlerWrapper(func(h Handler) Handler { + switch v := h.(type) { + case func() string: + return handlerFunc0Invoker(v) + } + return h + }) + } + + m.Get("/get", func() string { + return "GET" + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/get", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "GET") + + m.Patch("/patch", func() string { + return "PATCH" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("PATCH", "/patch", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "PATCH") + + m.Post("/post", func() string { + return "POST" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("POST", "/post", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "POST") + + m.Put("/put", func() string { + return "PUT" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("PUT", "/put", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "PUT") + + m.Delete("/delete", func() string { + return "DELETE" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("DELETE", "/delete", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "DELETE") + + m.Options("/options", func() string { + return "OPTIONS" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("OPTIONS", "/options", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "OPTIONS") + + m.Head("/head", func() string { + return "HEAD" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("HEAD", "/head", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldHaveLength, 0) + + m.Any("/any", func() string { + return "ANY" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "/any", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "ANY") + + m.Route("/route", "GET,POST", func() string { + return "ROUTE" + }) + resp = httptest.NewRecorder() + req, err = http.NewRequest("POST", "/route", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "ROUTE") + + if isFast { + //remove Handler Wrap Action + m.Router.SetHandlerWrapper(nil) + } + }) + + Convey("Register with or without auto head", t, func() { + Convey("Without auto head", func() { + m := New() + m.Get("/", func() string { + return "GET" + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("HEAD", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, 404) + }) + + Convey("With auto head", func() { + m := New() + m.SetAutoHead(true) + m.Get("/", func() string { + return "GET" + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("HEAD", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, 200) + }) + }) + + Convey("Register all HTTP methods routes with combo", t, func() { + m := New() + m.SetURLPrefix("/prefix") + m.Use(Renderer()) + m.Combo("/", func(ctx *Context) { + ctx.Data["prefix"] = "Prefix_" + }). + Get(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "GET" }). + Patch(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "PATCH" }). + Post(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "POST" }). + Put(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "PUT" }). + Delete(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "DELETE" }). + Options(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "OPTIONS" }). + Head(func(ctx *Context) string { return ctx.Data["prefix"].(string) + "HEAD" }) + + for name := range _HTTP_METHODS { + resp := httptest.NewRecorder() + req, err := http.NewRequest(name, "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + if name == "HEAD" { + So(resp.Body.String(), ShouldHaveLength, 0) + } else { + So(resp.Body.String(), ShouldEqual, "Prefix_"+name) + } + } + + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.Combo("/").Get(func() {}).Get(nil) + }) + + Convey("Register duplicated routes", t, func() { + r := NewRouter() + r.Get("/") + r.Get("/") + }) + + Convey("Register invalid HTTP method", t, func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + r := NewRouter() + r.Handle("404", "/", nil) + }) +} + +func Test_Route_Name(t *testing.T) { + Convey("Set route name", t, func() { + m := New() + m.Get("/", func() {}).Name("home") + + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.Get("/", func() {}).Name("home") + }) + + Convey("Set combo router name", t, func() { + m := New() + m.Combo("/").Get(func() {}).Name("home") + + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.Combo("/").Name("home") + }) +} + +func Test_Router_URLFor(t *testing.T) { + Convey("Build URL path", t, func() { + m := New() + m.Get("/user/:id", func() {}).Name("user_id") + m.Get("/user/:id/:name", func() {}).Name("user_id_name") + m.Get("cms_:id_:page.html", func() {}).Name("id_page") + + So(m.URLFor("user_id", "id", "12"), ShouldEqual, "/user/12") + So(m.URLFor("user_id_name", "id", "12", "name", "unknwon"), ShouldEqual, "/user/12/unknwon") + So(m.URLFor("id_page", "id", "12", "page", "profile"), ShouldEqual, "/cms_12_profile.html") + + Convey("Number of pair values does not match", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.URLFor("user_id", "id") + }) + + Convey("Empty pair value", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.URLFor("user_id", "", "") + }) + + Convey("Empty route name", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.Get("/user/:id", func() {}).Name("") + }) + + Convey("Invalid route name", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + m.URLFor("404") + }) + }) +} + +func Test_Router_Group(t *testing.T) { + Convey("Register route group", t, func() { + m := New() + m.Group("/api", func() { + m.Group("/v1", func() { + m.Get("/list", func() string { + return "Well done!" + }) + }) + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/api/v1/list", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Well done!") + }) +} + +func Test_Router_NotFound(t *testing.T) { + Convey("Custom not found handler", t, func() { + m := New() + m.Get("/", func() {}) + m.NotFound(func() string { + return "Custom not found" + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/404", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "Custom not found") + }) +} + +func Test_Router_InternalServerError(t *testing.T) { + Convey("Custom internal server error handler", t, func() { + m := New() + m.Get("/", func() error { + return errors.New("Custom internal server error") + }) + m.InternalServerError(func(rw http.ResponseWriter, err error) { + rw.WriteHeader(500) + _, _ = rw.Write([]byte(err.Error())) + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, 500) + So(resp.Body.String(), ShouldEqual, "Custom internal server error") + }) +} + +func Test_Router_splat(t *testing.T) { + Convey("Register router with glob", t, func() { + m := New() + m.Get("/*", func(ctx *Context) string { + return ctx.Params("*") + }) + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/hahaha", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Body.String(), ShouldEqual, "hahaha") + }) +} diff --git a/pkg/macaron/static.go b/pkg/macaron/static.go new file mode 100755 index 00000000000..04d91d8c9a8 --- /dev/null +++ b/pkg/macaron/static.go @@ -0,0 +1,231 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "encoding/base64" + "fmt" + "log" + "net/http" + "path" + "path/filepath" + "strings" + "sync" +) + +// StaticOptions is a struct for specifying configuration options for the macaron.Static middleware. +type StaticOptions struct { + // Prefix is the optional prefix used to serve the static directory content + Prefix string + // SkipLogging will disable [Static] log messages when a static file is served. + SkipLogging bool + // IndexFile defines which file to serve as index if it exists. + IndexFile string + // Expires defines which user-defined function to use for producing a HTTP Expires Header + // https://developers.google.com/speed/docs/insights/LeverageBrowserCaching + Expires func() string + // ETag defines if we should add an ETag header + // https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#validating-cached-responses-with-etags + ETag bool + // FileSystem is the interface for supporting any implmentation of file system. + FileSystem http.FileSystem +} + +// FIXME: to be deleted. +type staticMap struct { + lock sync.RWMutex + data map[string]*http.Dir +} + +func (sm *staticMap) Set(dir *http.Dir) { + sm.lock.Lock() + defer sm.lock.Unlock() + + sm.data[string(*dir)] = dir +} + +func (sm *staticMap) Get(name string) *http.Dir { + sm.lock.RLock() + defer sm.lock.RUnlock() + + return sm.data[name] +} + +func (sm *staticMap) Delete(name string) { + sm.lock.Lock() + defer sm.lock.Unlock() + + delete(sm.data, name) +} + +var statics = staticMap{sync.RWMutex{}, map[string]*http.Dir{}} + +// staticFileSystem implements http.FileSystem interface. +type staticFileSystem struct { + dir *http.Dir +} + +func newStaticFileSystem(directory string) staticFileSystem { + if !filepath.IsAbs(directory) { + directory = filepath.Join(Root, directory) + } + dir := http.Dir(directory) + statics.Set(&dir) + return staticFileSystem{&dir} +} + +func (fs staticFileSystem) Open(name string) (http.File, error) { + return fs.dir.Open(name) +} + +func prepareStaticOption(dir string, opt StaticOptions) StaticOptions { + // Defaults + if len(opt.IndexFile) == 0 { + opt.IndexFile = "index.html" + } + // Normalize the prefix if provided + if opt.Prefix != "" { + // Ensure we have a leading '/' + if opt.Prefix[0] != '/' { + opt.Prefix = "/" + opt.Prefix + } + // Remove any trailing '/' + opt.Prefix = strings.TrimRight(opt.Prefix, "/") + } + if opt.FileSystem == nil { + opt.FileSystem = newStaticFileSystem(dir) + } + return opt +} + +func prepareStaticOptions(dir string, options []StaticOptions) StaticOptions { + var opt StaticOptions + if len(options) > 0 { + opt = options[0] + } + return prepareStaticOption(dir, opt) +} + +func staticHandler(ctx *Context, log *log.Logger, opt StaticOptions) bool { + if ctx.Req.Method != "GET" && ctx.Req.Method != "HEAD" { + return false + } + + file := ctx.Req.URL.Path + // if we have a prefix, filter requests by stripping the prefix + if opt.Prefix != "" { + if !strings.HasPrefix(file, opt.Prefix) { + return false + } + file = file[len(opt.Prefix):] + if file != "" && file[0] != '/' { + return false + } + } + + f, err := opt.FileSystem.Open(file) + if err != nil { + return false + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return true // File exists but fail to open. + } + + // Try to serve index file + if fi.IsDir() { + redirPath := path.Clean(ctx.Req.URL.Path) + // path.Clean removes the trailing slash, so we need to add it back when + // the original path has it. + if strings.HasSuffix(ctx.Req.URL.Path, "/") { + redirPath = redirPath + "/" + } + // Redirect if missing trailing slash. + if !strings.HasSuffix(redirPath, "/") { + http.Redirect(ctx.Resp, ctx.Req.Request, redirPath+"/", http.StatusFound) + return true + } + + file = path.Join(file, opt.IndexFile) + f, err = opt.FileSystem.Open(file) + if err != nil { + return false // Discard error. + } + defer f.Close() + + fi, err = f.Stat() + if err != nil || fi.IsDir() { + return true + } + } + + if !opt.SkipLogging { + log.Println("[Static] Serving " + file) + } + + // Add an Expires header to the static content + if opt.Expires != nil { + ctx.Resp.Header().Set("Expires", opt.Expires()) + } + + if opt.ETag { + tag := `"` + GenerateETag(fmt.Sprintf("%d", fi.Size()), fi.Name(), fi.ModTime().UTC().Format(http.TimeFormat)) + `"` + ctx.Resp.Header().Set("ETag", tag) + if ctx.Req.Header.Get("If-None-Match") == tag { + ctx.Resp.WriteHeader(http.StatusNotModified) + return true + } + } + + http.ServeContent(ctx.Resp, ctx.Req.Request, file, fi.ModTime(), f) + return true +} + +// GenerateETag generates an ETag based on size, filename and file modification time +func GenerateETag(fileSize, fileName, modTime string) string { + etag := fileSize + fileName + modTime + return base64.StdEncoding.EncodeToString([]byte(etag)) +} + +// Static returns a middleware handler that serves static files in the given directory. +func Static(directory string, staticOpt ...StaticOptions) Handler { + opt := prepareStaticOptions(directory, staticOpt) + + return func(ctx *Context, log *log.Logger) { + staticHandler(ctx, log, opt) + } +} + +// Statics registers multiple static middleware handlers all at once. +func Statics(opt StaticOptions, dirs ...string) Handler { + if len(dirs) == 0 { + panic("no static directory is given") + } + opts := make([]StaticOptions, len(dirs)) + for i := range dirs { + opts[i] = prepareStaticOption(dirs[i], opt) + } + + return func(ctx *Context, log *log.Logger) { + for i := range opts { + if staticHandler(ctx, log, opts[i]) { + return + } + } + } +} diff --git a/pkg/macaron/static_test.go b/pkg/macaron/static_test.go new file mode 100755 index 00000000000..26de6e31796 --- /dev/null +++ b/pkg/macaron/static_test.go @@ -0,0 +1,305 @@ +// Copyright 2013 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +var currentRoot, _ = os.Getwd() + +func Test_Static(t *testing.T) { + Convey("Serve static files", t, func() { + m := New() + m.Use(Static("./")) + + resp := httptest.NewRecorder() + resp.Body = new(bytes.Buffer) + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get("Expires"), ShouldBeBlank) + So(resp.Body.Len(), ShouldBeGreaterThan, 0) + + Convey("Change static path", func() { + m.Get("/", func(ctx *Context) { + ctx.ChangeStaticPath("./", "fixtures/basic2") + }) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + resp = httptest.NewRecorder() + resp.Body = new(bytes.Buffer) + req, err = http.NewRequest("GET", "http://localhost:4000/hello.tmpl", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get("Expires"), ShouldBeBlank) + So(resp.Body.Len(), ShouldBeGreaterThan, 0) + }) + }) + + Convey("Serve static files with local path", t, func() { + Root = os.TempDir() + f, err := ioutil.TempFile(Root, "static_content") + So(err, ShouldBeNil) + _, _ = f.WriteString("Expected Content") + f.Close() + + m := New() + m.Use(Static(".")) + + resp := httptest.NewRecorder() + resp.Body = new(bytes.Buffer) + req, err := http.NewRequest("GET", "http://localhost:4000/"+path.Base(strings.Replace(f.Name(), "\\", "/", -1)), nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Header().Get("Expires"), ShouldBeBlank) + So(resp.Body.String(), ShouldEqual, "Expected Content") + }) + + Convey("Serve static files with head", t, func() { + m := New() + m.Use(Static(currentRoot)) + + resp := httptest.NewRecorder() + resp.Body = new(bytes.Buffer) + req, err := http.NewRequest("HEAD", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusOK) + So(resp.Body.Len(), ShouldEqual, 0) + }) + + Convey("Serve static files as post", t, func() { + m := New() + m.Use(Static(currentRoot)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("POST", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldEqual, http.StatusNotFound) + }) + + Convey("Serve static files with bad directory", t, func() { + m := Classic() + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + So(resp.Code, ShouldNotEqual, http.StatusOK) + }) +} + +func Test_Static_Options(t *testing.T) { + Convey("Serve static files with options logging", t, func() { + var buf bytes.Buffer + m := NewWithLogger(&buf) + opt := StaticOptions{} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.String(), ShouldEqual, "[Macaron] [Static] Serving /macaron.go\n") + + // Not disable logging. + m.Handlers() + buf.Reset() + opt.SkipLogging = true + m.Use(Static(currentRoot, opt)) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.Len(), ShouldEqual, 0) + }) + + Convey("Serve static files with options serve index", t, func() { + var buf bytes.Buffer + m := NewWithLogger(&buf) + opt := StaticOptions{IndexFile: "macaron.go"} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.String(), ShouldEqual, "[Macaron] [Static] Serving /macaron.go\n") + }) + + Convey("Serve static files with options prefix", t, func() { + var buf bytes.Buffer + m := NewWithLogger(&buf) + opt := StaticOptions{Prefix: "public"} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/public/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.String(), ShouldEqual, "[Macaron] [Static] Serving /macaron.go\n") + }) + + Convey("Serve static files with options expires", t, func() { + m := New() + opt := StaticOptions{Expires: func() string { return "46" }} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Header().Get("Expires"), ShouldEqual, "46") + }) + + Convey("Serve static files with options ETag", t, func() { + m := New() + opt := StaticOptions{ETag: true} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + tag := GenerateETag(fmt.Sprintf("%d", resp.Body.Len()), "macaron.go", resp.Header().Get("last-modified")) + + So(resp.Header().Get("ETag"), ShouldEqual, `"`+tag+`"`) + }) + + Convey("Serve static files with ETag in If-None-Match", t, func() { + m := New() + opt := StaticOptions{ETag: true} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + tag := GenerateETag(fmt.Sprintf("%d", resp.Body.Len()), "macaron.go", resp.Header().Get("last-modified")) + + // Second request with ETag in If-None-Match + resp = httptest.NewRecorder() + req.Header.Add("If-None-Match", `"`+tag+`"`) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusNotModified) + So(len(resp.Body.Bytes()), ShouldEqual, 0) + }) +} + +func Test_Static_Redirect(t *testing.T) { + Convey("Serve static files with prefix without redirect", t, func() { + m := New() + opt := StaticOptions{Prefix: "/public"} + m.Use(Static(currentRoot, opt)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/public/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusNotFound) + }) + + Convey("Serve static files with redirect", t, func() { + m := New() + m.Use(Static(currentRoot, StaticOptions{Prefix: "/public"})) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/public", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusFound) + So(resp.Header().Get("Location"), ShouldEqual, "/public/") + }) + + Convey("Serve static files with improper request", t, func() { + m := New() + m.Use(Static(currentRoot)) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", `http://localhost:4000//example.com%2f..`, nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusNotFound) + }) +} + +func Test_Statics(t *testing.T) { + Convey("Serve multiple static routers", t, func() { + Convey("Register empty directory", func() { + defer func() { + So(recover(), ShouldNotBeNil) + }() + + m := New() + m.Use(Statics(StaticOptions{})) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + }) + + Convey("Serve normally", func() { + var buf bytes.Buffer + m := NewWithLogger(&buf) + m.Use(Statics(StaticOptions{}, currentRoot, currentRoot+"/fixtures/basic")) + + resp := httptest.NewRecorder() + req, err := http.NewRequest("GET", "http://localhost:4000/macaron.go", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.String(), ShouldEqual, "[Macaron] [Static] Serving /macaron.go\n") + + resp = httptest.NewRecorder() + req, err = http.NewRequest("GET", "http://localhost:4000/admin/index.tmpl", nil) + So(err, ShouldBeNil) + m.ServeHTTP(resp, req) + + So(resp.Code, ShouldEqual, http.StatusOK) + So(buf.String(), ShouldEndWith, "[Macaron] [Static] Serving /admin/index.tmpl\n") + }) + }) +} diff --git a/pkg/macaron/tree.go b/pkg/macaron/tree.go new file mode 100755 index 00000000000..0ab094dd680 --- /dev/null +++ b/pkg/macaron/tree.go @@ -0,0 +1,390 @@ +// Copyright 2015 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "regexp" + "strings" + + "github.com/unknwon/com" +) + +type patternType int8 + +const ( + _PATTERN_STATIC patternType = iota // /home + _PATTERN_REGEXP // /:id([0-9]+) + _PATTERN_PATH_EXT // /*.* + _PATTERN_HOLDER // /:user + _PATTERN_MATCH_ALL // /* +) + +// Leaf represents a leaf route information. +type Leaf struct { + parent *Tree + + typ patternType + pattern string + rawPattern string // Contains wildcard instead of regexp + wildcards []string + reg *regexp.Regexp + optional bool + + handle Handle +} + +var wildcardPattern = regexp.MustCompile(`:[a-zA-Z0-9]+`) + +func isSpecialRegexp(pattern, regStr string, pos []int) bool { + return len(pattern) >= pos[1]+len(regStr) && pattern[pos[1]:pos[1]+len(regStr)] == regStr +} + +// getNextWildcard tries to find next wildcard and update pattern with corresponding regexp. +func getNextWildcard(pattern string) (wildcard, _ string) { + pos := wildcardPattern.FindStringIndex(pattern) + if pos == nil { + return "", pattern + } + wildcard = pattern[pos[0]:pos[1]] + + // Reach last character or no regexp is given. + if len(pattern) == pos[1] { + return wildcard, strings.Replace(pattern, wildcard, `(.+)`, 1) + } else if pattern[pos[1]] != '(' { + switch { + case isSpecialRegexp(pattern, ":int", pos): + pattern = strings.Replace(pattern, ":int", "([0-9]+)", 1) + case isSpecialRegexp(pattern, ":string", pos): + pattern = strings.Replace(pattern, ":string", "([\\w]+)", 1) + default: + return wildcard, strings.Replace(pattern, wildcard, `(.+)`, 1) + } + } + + // Cut out placeholder directly. + return wildcard, pattern[:pos[0]] + pattern[pos[1]:] +} + +func getWildcards(pattern string) (string, []string) { + wildcards := make([]string, 0, 2) + + // Keep getting next wildcard until nothing is left. + var wildcard string + for { + wildcard, pattern = getNextWildcard(pattern) + if len(wildcard) > 0 { + wildcards = append(wildcards, wildcard) + } else { + break + } + } + + return pattern, wildcards +} + +// getRawPattern removes all regexp but keeps wildcards for building URL path. +func getRawPattern(rawPattern string) string { + rawPattern = strings.Replace(rawPattern, ":int", "", -1) + rawPattern = strings.Replace(rawPattern, ":string", "", -1) + + for { + startIdx := strings.Index(rawPattern, "(") + if startIdx == -1 { + break + } + + closeIdx := strings.Index(rawPattern, ")") + if closeIdx > -1 { + rawPattern = rawPattern[:startIdx] + rawPattern[closeIdx+1:] + } + } + return rawPattern +} + +func checkPattern(pattern string) (typ patternType, rawPattern string, wildcards []string, reg *regexp.Regexp) { + pattern = strings.TrimLeft(pattern, "?") + rawPattern = getRawPattern(pattern) + + if pattern == "*" { + typ = _PATTERN_MATCH_ALL + } else if pattern == "*.*" { + typ = _PATTERN_PATH_EXT + } else if strings.Contains(pattern, ":") { + typ = _PATTERN_REGEXP + pattern, wildcards = getWildcards(pattern) + if pattern == "(.+)" { + typ = _PATTERN_HOLDER + } else { + reg = regexp.MustCompile(pattern) + } + } + return typ, rawPattern, wildcards, reg +} + +func NewLeaf(parent *Tree, pattern string, handle Handle) *Leaf { + typ, rawPattern, wildcards, reg := checkPattern(pattern) + optional := false + if len(pattern) > 0 && pattern[0] == '?' { + optional = true + } + return &Leaf{parent, typ, pattern, rawPattern, wildcards, reg, optional, handle} +} + +// URLPath build path part of URL by given pair values. +func (l *Leaf) URLPath(pairs ...string) string { + if len(pairs)%2 != 0 { + panic("number of pairs does not match") + } + + urlPath := l.rawPattern + parent := l.parent + for parent != nil { + urlPath = parent.rawPattern + "/" + urlPath + parent = parent.parent + } + for i := 0; i < len(pairs); i += 2 { + if len(pairs[i]) == 0 { + panic("pair value cannot be empty: " + com.ToStr(i)) + } else if pairs[i][0] != ':' && pairs[i] != "*" && pairs[i] != "*.*" { + pairs[i] = ":" + pairs[i] + } + urlPath = strings.Replace(urlPath, pairs[i], pairs[i+1], 1) + } + return urlPath +} + +// Tree represents a router tree in Macaron. +type Tree struct { + parent *Tree + + typ patternType + pattern string + rawPattern string + wildcards []string + reg *regexp.Regexp + + subtrees []*Tree + leaves []*Leaf +} + +func NewSubtree(parent *Tree, pattern string) *Tree { + typ, rawPattern, wildcards, reg := checkPattern(pattern) + return &Tree{parent, typ, pattern, rawPattern, wildcards, reg, make([]*Tree, 0, 5), make([]*Leaf, 0, 5)} +} + +func NewTree() *Tree { + return NewSubtree(nil, "") +} + +func (t *Tree) addLeaf(pattern string, handle Handle) *Leaf { + for i := 0; i < len(t.leaves); i++ { + if t.leaves[i].pattern == pattern { + return t.leaves[i] + } + } + + leaf := NewLeaf(t, pattern, handle) + + // Add exact same leaf to grandparent/parent level without optional. + if leaf.optional { + parent := leaf.parent + if parent.parent != nil { + parent.parent.addLeaf(parent.pattern, handle) + } else { + parent.addLeaf("", handle) // Root tree can add as empty pattern. + } + } + + i := 0 + for ; i < len(t.leaves); i++ { + if leaf.typ < t.leaves[i].typ { + break + } + } + + if i == len(t.leaves) { + t.leaves = append(t.leaves, leaf) + } else { + t.leaves = append(t.leaves[:i], append([]*Leaf{leaf}, t.leaves[i:]...)...) + } + return leaf +} + +func (t *Tree) addSubtree(segment, pattern string, handle Handle) *Leaf { + for i := 0; i < len(t.subtrees); i++ { + if t.subtrees[i].pattern == segment { + return t.subtrees[i].addNextSegment(pattern, handle) + } + } + + subtree := NewSubtree(t, segment) + i := 0 + for ; i < len(t.subtrees); i++ { + if subtree.typ < t.subtrees[i].typ { + break + } + } + + if i == len(t.subtrees) { + t.subtrees = append(t.subtrees, subtree) + } else { + t.subtrees = append(t.subtrees[:i], append([]*Tree{subtree}, t.subtrees[i:]...)...) + } + return subtree.addNextSegment(pattern, handle) +} + +func (t *Tree) addNextSegment(pattern string, handle Handle) *Leaf { + pattern = strings.TrimPrefix(pattern, "/") + + i := strings.Index(pattern, "/") + if i == -1 { + return t.addLeaf(pattern, handle) + } + return t.addSubtree(pattern[:i], pattern[i+1:], handle) +} + +func (t *Tree) Add(pattern string, handle Handle) *Leaf { + pattern = strings.TrimSuffix(pattern, "/") + return t.addNextSegment(pattern, handle) +} + +func (t *Tree) matchLeaf(globLevel int, url string, params Params) (Handle, bool) { + url, err := PathUnescape(url) + if err != nil { + return nil, false + } + for i := 0; i < len(t.leaves); i++ { + switch t.leaves[i].typ { + case _PATTERN_STATIC: + if t.leaves[i].pattern == url { + return t.leaves[i].handle, true + } + case _PATTERN_REGEXP: + results := t.leaves[i].reg.FindStringSubmatch(url) + // Number of results and wildcasrd should be exact same. + if len(results)-1 != len(t.leaves[i].wildcards) { + break + } + + for j := 0; j < len(t.leaves[i].wildcards); j++ { + params[t.leaves[i].wildcards[j]] = results[j+1] + } + return t.leaves[i].handle, true + case _PATTERN_PATH_EXT: + j := strings.LastIndex(url, ".") + if j > -1 { + params[":path"] = url[:j] + params[":ext"] = url[j+1:] + } else { + params[":path"] = url + } + return t.leaves[i].handle, true + case _PATTERN_HOLDER: + params[t.leaves[i].wildcards[0]] = url + return t.leaves[i].handle, true + case _PATTERN_MATCH_ALL: + params["*"] = url + params["*"+com.ToStr(globLevel)] = url + return t.leaves[i].handle, true + } + } + return nil, false +} + +func (t *Tree) matchSubtree(globLevel int, segment, url string, params Params) (Handle, bool) { + unescapedSegment, err := PathUnescape(segment) + if err != nil { + return nil, false + } + for i := 0; i < len(t.subtrees); i++ { + switch t.subtrees[i].typ { + case _PATTERN_STATIC: + if t.subtrees[i].pattern == unescapedSegment { + if handle, ok := t.subtrees[i].matchNextSegment(globLevel, url, params); ok { + return handle, true + } + } + case _PATTERN_REGEXP: + results := t.subtrees[i].reg.FindStringSubmatch(unescapedSegment) + if len(results)-1 != len(t.subtrees[i].wildcards) { + break + } + + for j := 0; j < len(t.subtrees[i].wildcards); j++ { + params[t.subtrees[i].wildcards[j]] = results[j+1] + } + if handle, ok := t.subtrees[i].matchNextSegment(globLevel, url, params); ok { + return handle, true + } + case _PATTERN_HOLDER: + if handle, ok := t.subtrees[i].matchNextSegment(globLevel+1, url, params); ok { + params[t.subtrees[i].wildcards[0]] = unescapedSegment + return handle, true + } + case _PATTERN_MATCH_ALL: + if handle, ok := t.subtrees[i].matchNextSegment(globLevel+1, url, params); ok { + params["*"+com.ToStr(globLevel)] = unescapedSegment + return handle, true + } + } + } + + if len(t.leaves) > 0 { + leaf := t.leaves[len(t.leaves)-1] + unescapedURL, err := PathUnescape(segment + "/" + url) + if err != nil { + return nil, false + } + if leaf.typ == _PATTERN_PATH_EXT { + j := strings.LastIndex(unescapedURL, ".") + if j > -1 { + params[":path"] = unescapedURL[:j] + params[":ext"] = unescapedURL[j+1:] + } else { + params[":path"] = unescapedURL + } + return leaf.handle, true + } else if leaf.typ == _PATTERN_MATCH_ALL { + params["*"] = unescapedURL + params["*"+com.ToStr(globLevel)] = unescapedURL + return leaf.handle, true + } + } + return nil, false +} + +func (t *Tree) matchNextSegment(globLevel int, url string, params Params) (Handle, bool) { + i := strings.Index(url, "/") + if i == -1 { + return t.matchLeaf(globLevel, url, params) + } + return t.matchSubtree(globLevel, url[:i], url[i+1:], params) +} + +func (t *Tree) Match(url string) (Handle, Params, bool) { + url = strings.TrimPrefix(url, "/") + url = strings.TrimSuffix(url, "/") + params := make(Params) + handle, ok := t.matchNextSegment(0, url, params) + return handle, params, ok +} + +// MatchTest returns true if given URL is matched by given pattern. +func MatchTest(pattern, url string) bool { + t := NewTree() + t.Add(pattern, nil) + _, _, ok := t.Match(url) + return ok +} diff --git a/pkg/macaron/tree_test.go b/pkg/macaron/tree_test.go new file mode 100755 index 00000000000..5166aaa7dab --- /dev/null +++ b/pkg/macaron/tree_test.go @@ -0,0 +1,243 @@ +// Copyright 2015 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import ( + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_getWildcards(t *testing.T) { + type result struct { + pattern string + wildcards string + } + cases := map[string]result{ + "admin": {"admin", ""}, + ":id": {"(.+)", ":id"}, + ":id:int": {"([0-9]+)", ":id"}, + ":id([0-9]+)": {"([0-9]+)", ":id"}, + ":id([0-9]+)_:name": {"([0-9]+)_(.+)", ":id :name"}, + "article_:id_:page.html": {"article_(.+)_(.+).html", ":id :page"}, + "article_:id:int_:page:string.html": {"article_([0-9]+)_([\\w]+).html", ":id :page"}, + "*": {"*", ""}, + "*.*": {"*.*", ""}, + } + Convey("Get wildcards", t, func() { + for key, result := range cases { + pattern, wildcards := getWildcards(key) + So(pattern, ShouldEqual, result.pattern) + So(strings.Join(wildcards, " "), ShouldEqual, result.wildcards) + } + }) +} + +func Test_getRawPattern(t *testing.T) { + cases := map[string]string{ + "admin": "admin", + ":id": ":id", + ":id:int": ":id", + ":id([0-9]+)": ":id", + ":id([0-9]+)_:name": ":id_:name", + "article_:id_:page.html": "article_:id_:page.html", + "article_:id:int_:page:string.html": "article_:id_:page.html", + "article_:id([0-9]+)_:page([\\w]+).html": "article_:id_:page.html", + "*": "*", + "*.*": "*.*", + } + Convey("Get raw pattern", t, func() { + for k, v := range cases { + So(getRawPattern(k), ShouldEqual, v) + } + }) +} + +func Test_Tree_Match(t *testing.T) { + Convey("Match route in tree", t, func() { + Convey("Match static routes", func() { + t := NewTree() + So(t.Add("/", nil), ShouldNotBeNil) + So(t.Add("/user", nil), ShouldNotBeNil) + So(t.Add("/user/unknwon", nil), ShouldNotBeNil) + So(t.Add("/user/unknwon/profile", nil), ShouldNotBeNil) + + So(t.Add("/", nil), ShouldNotBeNil) + + _, _, ok := t.Match("/") + So(ok, ShouldBeTrue) + _, _, ok = t.Match("/user") + So(ok, ShouldBeTrue) + _, _, ok = t.Match("/user/unknwon") + So(ok, ShouldBeTrue) + _, _, ok = t.Match("/user/unknwon/profile") + So(ok, ShouldBeTrue) + + _, _, ok = t.Match("/404") + So(ok, ShouldBeFalse) + }) + + Convey("Match optional routes", func() { + t := NewTree() + So(t.Add("/?:user", nil), ShouldNotBeNil) + So(t.Add("/user/?:name", nil), ShouldNotBeNil) + So(t.Add("/user/list/?:page:int", nil), ShouldNotBeNil) + + _, params, ok := t.Match("/") + So(ok, ShouldBeTrue) + So(params[":user"], ShouldBeEmpty) + _, params, ok = t.Match("/unknwon") + So(ok, ShouldBeTrue) + So(params[":user"], ShouldEqual, "unknwon") + _, params, ok = t.Match("/hello%2Fworld") + So(ok, ShouldBeTrue) + So(params[":user"], ShouldEqual, "hello/world") + + _, params, ok = t.Match("/user") + So(ok, ShouldBeTrue) + So(params[":name"], ShouldBeEmpty) + _, params, ok = t.Match("/user/unknwon") + So(ok, ShouldBeTrue) + So(params[":name"], ShouldEqual, "unknwon") + _, params, ok = t.Match("/hello%20world") + So(ok, ShouldBeTrue) + So(params[":user"], ShouldEqual, "hello world") + + _, params, ok = t.Match("/user/list/") + So(ok, ShouldBeTrue) + So(params[":page"], ShouldBeEmpty) + _, params, ok = t.Match("/user/list/123") + So(ok, ShouldBeTrue) + So(params[":page"], ShouldEqual, "123") + }) + + Convey("Match with regexp", func() { + t := NewTree() + So(t.Add("/v1/:year:int/6/23", nil), ShouldNotBeNil) + So(t.Add("/v2/2015/:month:int/23", nil), ShouldNotBeNil) + So(t.Add("/v3/2015/6/:day:int", nil), ShouldNotBeNil) + + _, params, ok := t.Match("/v1/2015/6/23") + So(ok, ShouldBeTrue) + So(MatchTest("/v1/:year:int/6/23", "/v1/2015/6/23"), ShouldBeTrue) + So(params[":year"], ShouldEqual, "2015") + _, _, ok = t.Match("/v1/year/6/23") + So(ok, ShouldBeFalse) + So(MatchTest("/v1/:year:int/6/23", "/v1/year/6/23"), ShouldBeFalse) + + _, params, ok = t.Match("/v2/2015/6/23") + So(ok, ShouldBeTrue) + So(params[":month"], ShouldEqual, "6") + _, _, ok = t.Match("/v2/2015/month/23") + So(ok, ShouldBeFalse) + + _, params, ok = t.Match("/v3/2015/6/23") + So(ok, ShouldBeTrue) + So(params[":day"], ShouldEqual, "23") + _, _, ok = t.Match("/v2/2015/6/day") + So(ok, ShouldBeFalse) + + So(t.Add("/v1/shop/cms_:id(.+)_:page(.+).html", nil), ShouldNotBeNil) + So(t.Add("/v1/:v/cms/aaa_:id(.+)_:page(.+).html", nil), ShouldNotBeNil) + So(t.Add("/v1/:v/cms_:id(.+)_:page(.+).html", nil), ShouldNotBeNil) + So(t.Add("/v1/:v(.+)_cms/ttt_:id(.+)_:page:string.html", nil), ShouldNotBeNil) + + _, params, ok = t.Match("/v1/shop/cms_123_1.html") + So(ok, ShouldBeTrue) + So(params[":id"], ShouldEqual, "123") + So(params[":page"], ShouldEqual, "1") + + _, params, ok = t.Match("/v1/2/cms/aaa_124_2.html") + So(ok, ShouldBeTrue) + So(params[":v"], ShouldEqual, "2") + So(params[":id"], ShouldEqual, "124") + So(params[":page"], ShouldEqual, "2") + + _, params, ok = t.Match("/v1/3/cms_125_3.html") + So(ok, ShouldBeTrue) + So(params[":v"], ShouldEqual, "3") + So(params[":id"], ShouldEqual, "125") + So(params[":page"], ShouldEqual, "3") + + _, params, ok = t.Match("/v1/4_cms/ttt_126_4.html") + So(ok, ShouldBeTrue) + So(params[":v"], ShouldEqual, "4") + So(params[":id"], ShouldEqual, "126") + So(params[":page"], ShouldEqual, "4") + }) + + Convey("Match with path and extension", func() { + t := NewTree() + So(t.Add("/*.*", nil), ShouldNotBeNil) + So(t.Add("/docs/*.*", nil), ShouldNotBeNil) + + _, params, ok := t.Match("/profile.html") + So(ok, ShouldBeTrue) + So(params[":path"], ShouldEqual, "profile") + So(params[":ext"], ShouldEqual, "html") + + _, params, ok = t.Match("/profile") + So(ok, ShouldBeTrue) + So(params[":path"], ShouldEqual, "profile") + So(params[":ext"], ShouldBeEmpty) + + _, params, ok = t.Match("/docs/framework/manual.html") + So(ok, ShouldBeTrue) + So(params[":path"], ShouldEqual, "framework/manual") + So(params[":ext"], ShouldEqual, "html") + + _, params, ok = t.Match("/docs/framework/manual") + So(ok, ShouldBeTrue) + So(params[":path"], ShouldEqual, "framework/manual") + So(params[":ext"], ShouldBeEmpty) + }) + + Convey("Match all", func() { + t := NewTree() + So(t.Add("/*", nil), ShouldNotBeNil) + So(t.Add("/*/123", nil), ShouldNotBeNil) + So(t.Add("/*/123/*", nil), ShouldNotBeNil) + So(t.Add("/*/*/123", nil), ShouldNotBeNil) + + _, params, ok := t.Match("/1/2/3") + So(ok, ShouldBeTrue) + So(params["*0"], ShouldEqual, "1/2/3") + + _, params, ok = t.Match("/4/123") + So(ok, ShouldBeTrue) + So(params["*0"], ShouldEqual, "4") + + _, params, ok = t.Match("/5/123/6") + So(ok, ShouldBeTrue) + So(params["*0"], ShouldEqual, "5") + So(params["*1"], ShouldEqual, "6") + + _, params, ok = t.Match("/7/8/123") + So(ok, ShouldBeTrue) + So(params["*0"], ShouldEqual, "7") + So(params["*1"], ShouldEqual, "8") + }) + + Convey("Complex tests", func() { + t := NewTree() + So(t.Add("/:username/:reponame/commit/*", nil), ShouldNotBeNil) + + _, params, ok := t.Match("/unknwon/com/commit/d855b6c9dea98c619925b7b112f3c4e64b17bfa8") + So(ok, ShouldBeTrue) + So(params["*"], ShouldEqual, "d855b6c9dea98c619925b7b112f3c4e64b17bfa8") + }) + }) +} diff --git a/pkg/macaron/util_go17.go b/pkg/macaron/util_go17.go new file mode 100755 index 00000000000..a80c696c7c4 --- /dev/null +++ b/pkg/macaron/util_go17.go @@ -0,0 +1,25 @@ +// +build !go1.8 + +// Copyright 2017 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import "net/url" + +// PathUnescape unescapes a path. Ideally, this function would use +// url.PathUnescape(..), but the function was not introduced until go1.8. +func PathUnescape(s string) (string, error) { + return url.QueryUnescape(s) +} diff --git a/pkg/macaron/util_go18.go b/pkg/macaron/util_go18.go new file mode 100755 index 00000000000..d5eb1dfb284 --- /dev/null +++ b/pkg/macaron/util_go18.go @@ -0,0 +1,24 @@ +// +build go1.8 + +// Copyright 2017 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package macaron + +import "net/url" + +// PathUnescape unescapes a path. +func PathUnescape(s string) (string, error) { + return url.PathUnescape(s) +}