diff --git a/.travis.yml b/.travis.yml index b7edfce..40e7466 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,10 @@ +sudo: false language: go go: - - 1.1 - - 1.2.1 - 1.3 -# - tip + - 1.4 + - 1.5 + - 1.6 + - 1.7 + - 1.8 + - 1.9 diff --git a/LICENSE b/LICENSE index 64b7dca..7800c4b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2014 Antoine Imbert +Copyright (c) 2013-2016 Antoine Imbert The MIT License diff --git a/README.md b/README.md index 1fbcc5f..1efa406 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ *A quick and easy way to setup a RESTful JSON API* -[![Build Status](https://travis-ci.org/ant0ine/go-json-rest.png?branch=master)](https://travis-ci.org/ant0ine/go-json-rest) [![GoDoc](https://godoc.org/github.com/ant0ine/go-json-rest?status.png)](https://godoc.org/github.com/ant0ine/go-json-rest/rest) +[![godoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/ant0ine/go-json-rest/rest) [![license](https://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/ant0ine/go-json-rest/master/LICENSE) [![build](https://img.shields.io/travis/ant0ine/go-json-rest.svg?style=flat)](https://travis-ci.org/ant0ine/go-json-rest) -**Go-Json-Rest** is a thin layer on top of `net/http` that helps building RESTful JSON APIs easily. It provides fast URL routing using a Trie based implementation, helpers to deal with JSON requests and responses, and middlewares for additional functionalities like CORS, Auth, Gzip ... +**Go-Json-Rest** is a thin layer on top of `net/http` that helps building RESTful JSON APIs easily. It provides fast and scalable request routing using a Trie based implementation, helpers to deal with JSON requests and responses, and middlewares for functionalities like CORS, Auth, Gzip, Status ... ## Table of content @@ -14,31 +14,36 @@ - [Features](#features) - [Install](#install) - [Vendoring](#vendoring) +- [Middlewares](#middlewares) - [Examples](#examples) - [Basics](#basics) - [Hello World!](#hello-world) + - [Lookup](#lookup) - [Countries](#countries) - [Users](#users) - - [Lookup](#lookup) - [Applications](#applications) - [API and static files](#api-and-static-files) - [GORM](#gorm) - [CORS](#cors) - [JSONP](#jsonp) - [Basic Auth](#basic-auth) + - [Force HTTPS](#forcessl) - [Status](#status) - [Status Auth](#status-auth) - [Advanced](#advanced) + - [JWT](#jwt) - [Streaming](#streaming) - [Non JSON payload](#non-json-payload) - [API Versioning](#api-versioning) - [Statsd](#statsd) + - [NewRelic](#newrelic) + - [Graceful Shutdown](#graceful-shutdown) - [SPDY](#spdy) - [Google App Engine](#gae) - - [Basic Auth Custom](#basic-auth-custom) - - [CORS Custom](#cors-custom) + - [Websocket](#websocket) - [External Documentation](#external-documentation) -- [Options](#options) +- [Version 3 release notes](#version-3-release-notes) +- [Migration guide from v2 to v3](#migration-guide-from-v2-to-v3) - [Version 2 release notes](#version-2-release-notes) - [Migration guide from v1 to v2](#migration-guide-from-v1-to-v2) - [Thanks](#thanks) @@ -47,8 +52,9 @@ ## Features - Many examples. -- Fast and scalable URL routing. It implements the classic route description syntax using a scalable trie data structure. -- Use Middlewares in order to implement and extend the functionalities. (Logging, Gzip, CORS, Auth, ...) +- Fast and scalable URL routing. It implements the classic route description syntax using a Trie data structure. +- Architecture based on a router(App) sitting on top of a stack of Middlewares. +- The Middlewares implement functionalities like Logging, Gzip, CORS, Auth, Status, ... - Implemented as a `net/http` Handler. This standard interface allows combinations with other Handlers. - Test package to help writing tests for your API. - Monitoring statistics inspired by Memcached. @@ -65,7 +71,40 @@ This package is "go-gettable", just do: The recommended way of using this library in your project is to use the **"vendoring"** method, where this library code is copied in your repository at a specific revision. -[This page](http://nathany.com/go-packages/) is a good summary of package management in Go. +[This page](https://nathany.com/go-packages/) is a good summary of package management in Go. + + +## Middlewares + +Core Middlewares: + +| Name | Description | +|------|-------------| +| **AccessLogApache** | Access log inspired by Apache mod_log_config | +| **AccessLogJson** | Access log with records as JSON | +| **AuthBasic** | Basic HTTP auth | +| **ContentTypeChecker** | Verify the request content type | +| **Cors** | CORS server side implementation | +| **Gzip** | Compress the responses | +| **If** | Conditionally execute a Middleware at runtime | +| **JsonIndent** | Easy to read JSON | +| **Jsonp** | Response as JSONP | +| **PoweredBy** | Manage the X-Powered-By response header | +| **Recorder** | Record the status code and content length in the Env | +| **Status** | Memecached inspired stats about the requests | +| **Timer** | Keep track of the elapsed time in the Env | + +Third Party Middlewares: + +| Name | Description | +|------|-------------| +| **[Statsd](https://github.com/ant0ine/go-json-rest-middleware-statsd)** | Send stats to a statsd server | +| **[JWT](https://github.com/StephanDollberg/go-json-rest-middleware-jwt)** | Provides authentication via Json Web Tokens | +| **[AuthToken](https://github.com/grayj/go-json-rest-middleware-tokenauth)** | Provides a Token Auth implementation | +| **[ForceSSL](https://github.com/jadengore/go-json-rest-middleware-force-ssl)** | Forces SSL on requests | +| **[SecureRedirect](https://github.com/clyphub/go-json-rest-middleware)** | Redirect clients from HTTP to HTTPS | + +*If you have a Go-Json-Rest compatible middleware, feel free to submit a PR to add it in this list, and in the examples.* ## Examples @@ -80,13 +119,13 @@ First examples to try, as an introduction to go-json-rest. Tradition! -The curl demo: +curl demo: ``` sh -curl -i http://127.0.0.1:8080/message +curl -i http://127.0.0.1:8080/ ``` -Go code: +code: ``` go package main @@ -96,23 +135,57 @@ import ( "net/http" ) -type Message struct { - Body string +func main() { + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + })) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } +``` + +#### Lookup + +Demonstrate how to use the relaxed placeholder (notation `#paramName`). +This placeholder matches everything until the first `/`, including `.` + +curl demo: +``` +curl -i http://127.0.0.1:8080/lookup/google.com +curl -i http://127.0.0.1:8080/lookup/notadomain +``` + +code: +``` go +package main + +import ( + "github.com/ant0ine/go-json-rest/rest" + "log" + "net" + "net/http" +) + func main() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/message", func(w rest.ResponseWriter, req *rest.Request) { - w.WriteJson(&Message{ - Body: "Hello World!", - }) - }}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/lookup/#host", func(w rest.ResponseWriter, req *rest.Request) { + ip, err := net.LookupIP(req.PathParam("host")) + if err != nil { + rest.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteJson(&ip) + }), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` @@ -121,10 +194,12 @@ func main() { Demonstrate simple POST GET and DELETE operations -The curl demo: +curl demo: ``` -curl -i -d '{"Code":"FR","Name":"France"}' http://127.0.0.1:8080/countries -curl -i -d '{"Code":"US","Name":"United States"}' http://127.0.0.1:8080/countries +curl -i -H 'Content-Type: application/json' \ + -d '{"Code":"FR","Name":"France"}' http://127.0.0.1:8080/countries +curl -i -H 'Content-Type: application/json' \ + -d '{"Code":"US","Name":"United States"}' http://127.0.0.1:8080/countries curl -i http://127.0.0.1:8080/countries/FR curl -i http://127.0.0.1:8080/countries/US curl -i http://127.0.0.1:8080/countries @@ -134,7 +209,7 @@ curl -i -X DELETE http://127.0.0.1:8080/countries/US curl -i http://127.0.0.1:8080/countries ``` -Go code: +code: ``` go package main @@ -146,20 +221,19 @@ import ( ) func main() { - - handler := rest.ResourceHandler{ - EnableRelaxedContentType: true, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, - &rest.Route{"POST", "/countries", PostCountry}, - &rest.Route{"GET", "/countries/:code", GetCountry}, - &rest.Route{"DELETE", "/countries/:code", DeleteCountry}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/countries", GetAllCountries), + rest.Post("/countries", PostCountry), + rest.Get("/countries/:code", GetCountry), + rest.Delete("/countries/:code", DeleteCountry), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type Country struct { @@ -238,20 +312,20 @@ Demonstrate how to use Method Values. Method Values have been [introduced in Go 1.1](https://golang.org/doc/go1.1#method_values). -Until then `rest.RouteObjectMethod` was provided, this method is now deprecated. - -This shows how to map a Route to a method of an instantiated object (eg: receiver of the method) +This shows how to map a Route to a method of an instantiated object (i.e: receiver of the method) -The curl demo: +curl demo: ``` -curl -i -d '{"Name":"Antoine"}' http://127.0.0.1:8080/users +curl -i -H 'Content-Type: application/json' \ + -d '{"Name":"Antoine"}' http://127.0.0.1:8080/users curl -i http://127.0.0.1:8080/users/0 -curl -i -X PUT -d '{"Name":"Antoine Imbert"}' http://127.0.0.1:8080/users/0 +curl -i -X PUT -H 'Content-Type: application/json' \ + -d '{"Name":"Antoine Imbert"}' http://127.0.0.1:8080/users/0 curl -i -X DELETE http://127.0.0.1:8080/users/0 curl -i http://127.0.0.1:8080/users ``` -Go code: +code: ``` go package main @@ -269,20 +343,20 @@ func main() { Store: map[string]*User{}, } - handler := rest.ResourceHandler{ - EnableRelaxedContentType: true, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/users", users.GetAllUsers}, - &rest.Route{"POST", "/users", users.PostUser}, - &rest.Route{"GET", "/users/:id", users.GetUser}, - &rest.Route{"PUT", "/users/:id", users.PutUser}, - &rest.Route{"DELETE", "/users/:id", users.DeleteUser}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/users", users.GetAllUsers), + rest.Post("/users", users.PostUser), + rest.Get("/users/:id", users.GetUser), + rest.Put("/users/:id", users.PutUser), + rest.Delete("/users/:id", users.DeleteUser), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type User struct { @@ -369,52 +443,6 @@ func (u *Users) DeleteUser(w rest.ResponseWriter, r *rest.Request) { ``` -#### Lookup - -Demonstrate how to use the relaxed placeholder (notation #paramName). -This placeholder matches everything until the first `/`, including `.` - -The curl demo: -``` -curl -i http://127.0.0.1:8080/lookup/google.com -curl -i http://127.0.0.1:8080/lookup/notadomain -``` - -Go code: -``` go -package main - -import ( - "github.com/ant0ine/go-json-rest/rest" - "log" - "net" - "net/http" -) - -type Message struct { - Body string -} - -func main() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/lookup/#host", func(w rest.ResponseWriter, req *rest.Request) { - ip, err := net.LookupIP(req.PathParam("host")) - if err != nil { - rest.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteJson(&ip) - }}, - ) - if err != nil { - log.Fatal(err) - } - log.Fatal(http.ListenAndServe(":8080", &handler)) -} - -``` - ### Applications @@ -424,16 +452,16 @@ Common use cases, found in many applications. Combine Go-Json-Rest with other handlers. -`rest.ResourceHandler` is a valid `http.Handler`, and can be combined with other handlers. -In this example the ResourceHandler is used under the `/api/` prefix, while a FileServer is instantiated under the `/static/` prefix. +`api.MakeHandler()` is a valid `http.Handler`, and can be combined with other handlers. +In this example the api handler is used under the `/api/` prefix, while a FileServer is instantiated under the `/static/` prefix. -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/api/message curl -i http://127.0.0.1:8080/static/main.go ``` -Go code: +code: ``` go package main @@ -443,23 +471,21 @@ import ( "net/http" ) -type Message struct { - Body string -} - func main() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/message", func(w rest.ResponseWriter, req *rest.Request) { - w.WriteJson(&Message{ - Body: "Hello World!", - }) - }}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + + router, err := rest.MakeRouter( + rest.Get("/message", func(w rest.ResponseWriter, req *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + }), ) if err != nil { log.Fatal(err) } - http.Handle("/api/", http.StripPrefix("/api", &handler)) + api.SetApp(router) + + http.Handle("/api/", http.StripPrefix("/api", api.MakeHandler())) http.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir(".")))) @@ -475,16 +501,18 @@ Demonstrate basic CRUD operation using a store based on MySQL and GORM [GORM](https://github.com/jinzhu/gorm) is simple ORM library for Go. In this example the same struct is used both as the GORM model and as the JSON model. -The curl demo: +curl demo: ``` -curl -i -d '{"Message":"this is a test"}' http://127.0.0.1:8080/reminders +curl -i -H 'Content-Type: application/json' \ + -d '{"Message":"this is a test"}' http://127.0.0.1:8080/reminders curl -i http://127.0.0.1:8080/reminders/1 curl -i http://127.0.0.1:8080/reminders -curl -i -X PUT -d '{"Message":"is updated"}' http://127.0.0.1:8080/reminders/1 +curl -i -X PUT -H 'Content-Type: application/json' \ + -d '{"Message":"is updated"}' http://127.0.0.1:8080/reminders/1 curl -i -X DELETE http://127.0.0.1:8080/reminders/1 ``` -Go code: +code: ``` go package main @@ -499,24 +527,24 @@ import ( func main() { - api := Api{} - api.InitDB() - api.InitSchema() - - handler := rest.ResourceHandler{ - EnableRelaxedContentType: true, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/reminders", api.GetAllReminders}, - &rest.Route{"POST", "/reminders", api.PostReminder}, - &rest.Route{"GET", "/reminders/:id", api.GetReminder}, - &rest.Route{"PUT", "/reminders/:id", api.PutReminder}, - &rest.Route{"DELETE", "/reminders/:id", api.DeleteReminder}, + i := Impl{} + i.InitDB() + i.InitSchema() + + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/reminders", i.GetAllReminders), + rest.Post("/reminders", i.PostReminder), + rest.Get("/reminders/:id", i.GetReminder), + rest.Put("/reminders/:id", i.PutReminder), + rest.Delete("/reminders/:id", i.DeleteReminder), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type Reminder struct { @@ -527,57 +555,57 @@ type Reminder struct { DeletedAt time.Time `json:"-"` } -type Api struct { - DB gorm.DB +type Impl struct { + DB *gorm.DB } -func (api *Api) InitDB() { +func (i *Impl) InitDB() { var err error - api.DB, err = gorm.Open("mysql", "gorm:gorm@/gorm?charset=utf8&parseTime=True") + i.DB, err = gorm.Open("mysql", "gorm:gorm@/gorm?charset=utf8&parseTime=True") if err != nil { log.Fatalf("Got error when connect database, the error is '%v'", err) } - api.DB.LogMode(true) + i.DB.LogMode(true) } -func (api *Api) InitSchema() { - api.DB.AutoMigrate(Reminder{}) +func (i *Impl) InitSchema() { + i.DB.AutoMigrate(&Reminder{}) } -func (api *Api) GetAllReminders(w rest.ResponseWriter, r *rest.Request) { +func (i *Impl) GetAllReminders(w rest.ResponseWriter, r *rest.Request) { reminders := []Reminder{} - api.DB.Find(&reminders) + i.DB.Find(&reminders) w.WriteJson(&reminders) } -func (api *Api) GetReminder(w rest.ResponseWriter, r *rest.Request) { +func (i *Impl) GetReminder(w rest.ResponseWriter, r *rest.Request) { id := r.PathParam("id") reminder := Reminder{} - if api.DB.First(&reminder, id).Error != nil { + if i.DB.First(&reminder, id).Error != nil { rest.NotFound(w, r) return } w.WriteJson(&reminder) } -func (api *Api) PostReminder(w rest.ResponseWriter, r *rest.Request) { +func (i *Impl) PostReminder(w rest.ResponseWriter, r *rest.Request) { reminder := Reminder{} if err := r.DecodeJsonPayload(&reminder); err != nil { rest.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := api.DB.Save(&reminder).Error; err != nil { + if err := i.DB.Save(&reminder).Error; err != nil { rest.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteJson(&reminder) } -func (api *Api) PutReminder(w rest.ResponseWriter, r *rest.Request) { +func (i *Impl) PutReminder(w rest.ResponseWriter, r *rest.Request) { id := r.PathParam("id") reminder := Reminder{} - if api.DB.First(&reminder, id).Error != nil { + if i.DB.First(&reminder, id).Error != nil { rest.NotFound(w, r) return } @@ -590,21 +618,21 @@ func (api *Api) PutReminder(w rest.ResponseWriter, r *rest.Request) { reminder.Message = updated.Message - if err := api.DB.Save(&reminder).Error; err != nil { + if err := i.DB.Save(&reminder).Error; err != nil { rest.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteJson(&reminder) } -func (api *Api) DeleteReminder(w rest.ResponseWriter, r *rest.Request) { +func (i *Impl) DeleteReminder(w rest.ResponseWriter, r *rest.Request) { id := r.PathParam("id") reminder := Reminder{} - if api.DB.First(&reminder, id).Error != nil { + if i.DB.First(&reminder, id).Error != nil { rest.NotFound(w, r) return } - if err := api.DB.Delete(&reminder).Error; err != nil { + if err := i.DB.Delete(&reminder).Error; err != nil { rest.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -617,13 +645,12 @@ func (api *Api) DeleteReminder(w rest.ResponseWriter, r *rest.Request) { Demonstrate how to setup CorsMiddleware around all the API endpoints. -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/countries ``` - -Go code: +code: ``` go package main @@ -634,29 +661,27 @@ import ( ) func main() { - - handler := rest.ResourceHandler{ - PreRoutingMiddlewares: []rest.Middleware{ - &rest.CorsMiddleware{ - RejectNonCorsRequests: false, - OriginValidator: func(origin string, request *rest.Request) bool { - return origin == "http://my.other.host/" - }, - AllowedMethods: []string{"GET", "POST", "PUT"}, - AllowedHeaders: []string{ - "Accept", "Content-Type", "X-Custom-Header", "Origin"}, - AccessControlAllowCredentials: true, - AccessControlMaxAge: 3600, - }, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.Use(&rest.CorsMiddleware{ + RejectNonCorsRequests: false, + OriginValidator: func(origin string, request *rest.Request) bool { + return origin == "http://my.other.host/" }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, + AllowedMethods: []string{"GET", "POST", "PUT"}, + AllowedHeaders: []string{ + "Accept", "Content-Type", "X-Custom-Header", "Origin"}, + AccessControlAllowCredentials: true, + AccessControlMaxAge: 3600, + }) + router, err := rest.MakeRouter( + rest.Get("/countries", GetAllCountries), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type Country struct { @@ -685,14 +710,13 @@ func GetAllCountries(w rest.ResponseWriter, r *rest.Request) { Demonstrate how to use the JSONP middleware. -The curl demo: +curl demo: ``` sh -curl -i http://127.0.0.1:8080/message -curl -i http://127.0.0.1:8080/message?cb=parseResponse +curl -i http://127.0.0.1:8080/ +curl -i http://127.0.0.1:8080/?cb=parseResponse ``` - -Go code: +code: ``` go package main @@ -702,29 +726,16 @@ import ( "net/http" ) -type Message struct { - Body string -} - func main() { - handler := rest.ResourceHandler{ - PreRoutingMiddlewares: []rest.Middleware{ - &rest.JsonpMiddleware{ - CallbackNameKey: "cb", - }, - }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/message", func(w rest.ResponseWriter, req *rest.Request) { - w.WriteJson(&Message{ - Body: "Hello World!", - }) - }}, - ) - if err != nil { - log.Fatal(err) - } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.Use(&rest.JsonpMiddleware{ + CallbackNameKey: "cb", + }) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + })) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` @@ -733,13 +744,13 @@ func main() { Demonstrate how to setup AuthBasicMiddleware as a pre-routing middleware. -The curl demo: +curl demo: ``` -curl -i http://127.0.0.1:8080/countries -curl -i -u admin:admin http://127.0.0.1:8080/countries +curl -i http://127.0.0.1:8080/ +curl -i -u admin:admin http://127.0.0.1:8080/ ``` -Go code: +code: ``` go package main @@ -750,47 +761,64 @@ import ( ) func main() { - - handler := rest.ResourceHandler{ - PreRoutingMiddlewares: []rest.Middleware{ - &rest.AuthBasicMiddleware{ - Realm: "test zone", - Authenticator: func(userId string, password string) bool { - if userId == "admin" && password == "admin" { - return true - } - return false - }, - }, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.Use(&rest.AuthBasicMiddleware{ + Realm: "test zone", + Authenticator: func(userId string, password string) bool { + if userId == "admin" && password == "admin" { + return true + } + return false }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, - ) - if err != nil { - log.Fatal(err) - } - log.Fatal(http.ListenAndServe(":8080", &handler)) + }) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + })) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } -type Country struct { - Code string - Name string -} +``` -func GetAllCountries(w rest.ResponseWriter, r *rest.Request) { - w.WriteJson( - []Country{ - Country{ - Code: "FR", - Name: "France", - }, - Country{ - Code: "US", - Name: "United States", - }, - }, - ) +#### ForceSSL + +Demonstrate how to use the [ForceSSL Middleware](https://github.com/jadengore/go-json-rest-middleware-force-ssl) to force HTTPS on requests to a `go-json-rest` API. + +For the purposes of this demo, we are using HTTP for all requests and checking the `X-Forwarded-Proto` header to see if it is set to HTTPS (many routers set this to show what type of connection the client is using, such as Heroku). To do a true HTTPS test, make sure and use [`http.ListenAndServeTLS`](https://golang.org/pkg/net/http/#ListenAndServeTLS) with a valid certificate and key file. + +Additional documentation for the ForceSSL middleware can be found [here](https://github.com/jadengore/go-json-rest-middleware-force-ssl). + +curl demo: +``` sh +curl -i 127.0.0.1:8080/ +curl -H "X-Forwarded-Proto:https" -i 127.0.0.1:8080/ +``` + +code: +``` go +package main + +import ( + "github.com/ant0ine/go-json-rest/rest" + "github.com/jadengore/go-json-rest-middleware-force-ssl" + "log" + "net/http" +) + +func main() { + api := rest.NewApi() + api.Use(&forceSSL.Middleware{ + TrustXFPHeader: true, + Enable301Redirects: false, + }) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"body": "Hello World!"}) + })) + + // For the purposes of this demo, only HTTP connections accepted. + // For true HTTPS, use ListenAndServeTLS. + // https://golang.org/pkg/net/http/#ListenAndServeTLS + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` @@ -802,8 +830,7 @@ Demonstrate how to setup a `/.status` endpoint Inspired by memcached "stats", this optional feature can be enabled to help monitoring the service. This example shows how to enable the stats, and how to setup the `/.status` route. - -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/.status curl -i http://127.0.0.1:8080/.status @@ -830,7 +857,7 @@ Output example: } ``` -Go code: +code: ``` go package main @@ -841,20 +868,20 @@ import ( ) func main() { - handler := rest.ResourceHandler{ - EnableStatusService: true, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/.status", - func(w rest.ResponseWriter, r *rest.Request) { - w.WriteJson(handler.GetStatus()) - }, - }, + api := rest.NewApi() + statusMw := &rest.StatusMiddleware{} + api.Use(statusMw) + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/.status", func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(statusMw.GetStatus()) + }), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` @@ -865,7 +892,7 @@ Demonstrate how to setup a /.status endpoint protected with basic authentication This is a good use case of middleware applied to only one API endpoint. -The Curl Demo: +curl demo: ``` curl -i http://127.0.0.1:8080/countries curl -i http://127.0.0.1:8080/.status @@ -873,7 +900,7 @@ curl -i -u admin:admin http://127.0.0.1:8080/.status ... ``` -Go code: +code: ``` go package main @@ -884,9 +911,10 @@ import ( ) func main() { - handler := rest.ResourceHandler{ - EnableStatusService: true, - } + api := rest.NewApi() + statusMw := &rest.StatusMiddleware{} + api.Use(statusMw) + api.Use(rest.DefaultDevStack...) auth := &rest.AuthBasicMiddleware{ Realm: "test zone", Authenticator: func(userId string, password string) bool { @@ -896,20 +924,19 @@ func main() { return false }, } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, - &rest.Route{"GET", "/.status", - auth.MiddlewareFunc( - func(w rest.ResponseWriter, r *rest.Request) { - w.WriteJson(handler.GetStatus()) - }, - ), - }, + router, err := rest.MakeRouter( + rest.Get("/countries", GetAllCountries), + rest.Get("/.status", auth.MiddlewareFunc( + func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(statusMw.GetStatus()) + }, + )), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type Country struct { @@ -937,7 +964,68 @@ func GetAllCountries(w rest.ResponseWriter, r *rest.Request) { ### Advanced -Less common use cases. +More advanced use cases. + +#### JWT + +Demonstrates how to use the [Json Web Token Auth Middleware](https://github.com/StephanDollberg/go-json-rest-middleware-jwt) to authenticate via a JWT token. + +curl demo: +``` sh +curl -d '{"username": "admin", "password": "admin"}' -H "Content-Type:application/json" http://localhost:8080/api/login +curl -H "Authorization:Bearer TOKEN_RETURNED_FROM_ABOVE" http://localhost:8080/api/auth_test +curl -H "Authorization:Bearer TOKEN_RETURNED_FROM_ABOVE" http://localhost:8080/api/refresh_token +``` + +code: +``` go +package main + +import ( + "log" + "net/http" + "time" + + "github.com/StephanDollberg/go-json-rest-middleware-jwt" + "github.com/ant0ine/go-json-rest/rest" +) + +func handle_auth(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"authed": r.Env["REMOTE_USER"].(string)}) +} + +func main() { + jwt_middleware := &jwt.JWTMiddleware{ + Key: []byte("secret key"), + Realm: "jwt auth", + Timeout: time.Hour, + MaxRefresh: time.Hour * 24, + Authenticator: func(userId string, password string) bool { + return userId == "admin" && password == "admin" + }} + + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + // we use the IfMiddleware to remove certain paths from needing authentication + api.Use(&rest.IfMiddleware{ + Condition: func(request *rest.Request) bool { + return request.URL.Path != "/login" + }, + IfTrue: jwt_middleware, + }) + api_router, _ := rest.MakeRouter( + rest.Post("/login", jwt_middleware.LoginHandler), + rest.Get("/auth_test", handle_auth), + rest.Get("/refresh_token", jwt_middleware.RefreshHandler), + ) + api.SetApp(api_router) + + http.Handle("/api/", http.StripPrefix("/api", api.MakeHandler())) + + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +``` #### Streaming @@ -945,7 +1033,7 @@ Demonstrate a streaming REST API, where the data is "flushed" to the client ASAP The stream format is a Line Delimited JSON. -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/stream ``` @@ -962,7 +1050,7 @@ Transfer-Encoding: chunked {"Name":"thing #3"} ``` -Go code: +code: ``` go package main @@ -975,18 +1063,17 @@ import ( ) func main() { - - handler := rest.ResourceHandler{ - EnableRelaxedContentType: true, - DisableJsonIndent: true, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/stream", StreamThings}, + api := rest.NewApi() + api.Use(&rest.AccessLogApacheMiddleware{}) + api.Use(rest.DefaultCommonStack...) + router, err := rest.MakeRouter( + rest.Get("/stream", StreamThings), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } type Thing struct { @@ -1021,7 +1108,7 @@ to build JSON responses. In order to serve different kind of content, it is recommended to either: a) use another server and configure CORS (see the cors/ example) -b) combine the rest.ResourceHandler with another http.Handler +b) combine the api.MakeHandler() with another http.Handler (see api-and-static/ example) That been said, exceptionally, it can be convenient to return a @@ -1029,12 +1116,12 @@ different content type on a JSON endpoint. In this case, setting the Content-Type and using the type assertion to access the Write method is enough. As shown in this example. -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/message.txt ``` -Go code: +code: ``` go package main @@ -1045,17 +1132,19 @@ import ( ) func main() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/message.txt", func(w rest.ResponseWriter, req *rest.Request) { + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/message.txt", func(w rest.ResponseWriter, req *rest.Request) { w.Header().Set("Content-Type", "text/plain") w.(http.ResponseWriter).Write([]byte("Hello World!")) - }}, + }), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` @@ -1068,9 +1157,7 @@ That been said, here is an example of API versioning using [Semver](http://semve It defines a middleware that parses the version, checks a min and a max, and makes it available in the `request.Env`. -(TODO, there is an obvious need for PostRoutingMiddlewares here.) - -The curl demo: +curl demo: ``` sh curl -i http://127.0.0.1:8080/api/1.0.0/message curl -i http://127.0.0.1:8080/api/2.0.0/message @@ -1080,8 +1167,7 @@ curl -i http://127.0.0.1:8080/api/4.0.1/message ``` - -Go code: +code: ``` go package main @@ -1113,17 +1199,29 @@ func (mw *SemVerMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.Handle version, err := semver.NewVersion(request.PathParam("version")) if err != nil { - rest.Error(writer, "Invalid version: "+err.Error(), http.StatusBadRequest) + rest.Error( + writer, + "Invalid version: "+err.Error(), + http.StatusBadRequest, + ) return } if version.LessThan(*minVersion) { - rest.Error(writer, "Min supported version is "+minVersion.String(), http.StatusBadRequest) + rest.Error( + writer, + "Min supported version is "+minVersion.String(), + http.StatusBadRequest, + ) return } if maxVersion.LessThan(*version) { - rest.Error(writer, "Max supported version is "+maxVersion.String(), http.StatusBadRequest) + rest.Error( + writer, + "Max supported version is "+maxVersion.String(), + http.StatusBadRequest, + ) return } @@ -1132,33 +1230,36 @@ func (mw *SemVerMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.Handle } } -type Message struct { - Body string -} - func main() { - handler := rest.ResourceHandler{} + svmw := SemVerMiddleware{ MinVersion: "1.0.0", MaxVersion: "3.0.0", } - err := handler.SetRoutes( - &rest.Route{"GET", "/#version/message", svmw.MiddlewareFunc( + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/#version/message", svmw.MiddlewareFunc( func(w rest.ResponseWriter, req *rest.Request) { version := req.Env["VERSION"].(*semver.Version) if version.Major == 2 { - // http://en.wikipedia.org/wiki/Second-system_effect - w.WriteJson(&Message{"Hello broken World!"}) + // https://en.wikipedia.org/wiki/Second-system_effect + w.WriteJson(map[string]string{ + "Body": "Hello broken World!", + }) } else { - w.WriteJson(&Message{"Hello World!"}) + w.WriteJson(map[string]string{ + "Body": "Hello World!", + }) } }, - )}, + )), ) if err != nil { log.Fatal(err) } - http.Handle("/api/", http.StripPrefix("/api", &handler)) + api.SetApp(router) + http.Handle("/api/", http.StripPrefix("/api", api.MakeHandler())) log.Fatal(http.ListenAndServe(":8080", nil)) } @@ -1166,11 +1267,10 @@ func main() { #### Statsd -Demonstrate how to use OuterMiddlewares to do additional logging and reporting. - -Here `request.Env["STATUS_CODE"]` and `request.Env["ELAPSED_TIME"]` that are available to outer middlewares are used with the [g2s](https://github.com/peterbourgon/g2s) statsd client to send these metrics to statsd. +Demonstrate how to use the [Statsd Middleware](https://github.com/ant0ine/go-json-rest-middleware-statsd) to collect statistics about the requests/reponses. +This middleware is based on the [g2s](https://github.com/peterbourgon/g2s) statsd client. -The curl demo: +curl demo: ``` sh # start statsd server # monitor network @@ -1181,76 +1281,158 @@ curl -i http://127.0.0.1:8080/doesnotexist ``` -Go code: +code: ``` go package main import ( + "github.com/ant0ine/go-json-rest-middleware-statsd" "github.com/ant0ine/go-json-rest/rest" - "github.com/peterbourgon/g2s" "log" "net/http" - "strconv" "time" ) -type StatsdMiddleware struct { - IpPort string - Prefix string +func main() { + api := rest.NewApi() + api.Use(&statsd.StatsdMiddleware{}) + api.Use(rest.DefaultDevStack...) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, req *rest.Request) { + + // take more than 1ms so statsd can report it + time.Sleep(100 * time.Millisecond) + + w.WriteJson(map[string]string{"Body": "Hello World!"}) + })) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } -func (mw *StatsdMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.HandlerFunc { +``` - statsd, err := g2s.Dial("udp", mw.IpPort) - if err != nil { - panic(err) - } +#### NewRelic - keyBase := "" - if mw.Prefix != "" { - keyBase += mw.Prefix + "." - } - keyBase += "response." +NewRelic integration based on the GoRelic plugin: [github.com/yvasiyarov/gorelic](https://github.com/yvasiyarov/gorelic) + +curl demo: +``` sh +curl -i http://127.0.0.1:8080/ +``` + +code: +``` go +package main + +import ( + "github.com/ant0ine/go-json-rest/rest" + "github.com/yvasiyarov/go-metrics" + "github.com/yvasiyarov/gorelic" + "log" + "net/http" + "time" +) + +type NewRelicMiddleware struct { + License string + Name string + Verbose bool + agent *gorelic.Agent +} + +func (mw *NewRelicMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.HandlerFunc { + + mw.agent = gorelic.NewAgent() + mw.agent.NewrelicLicense = mw.License + mw.agent.HTTPTimer = metrics.NewTimer() + mw.agent.Verbose = mw.Verbose + mw.agent.NewrelicName = mw.Name + mw.agent.CollectHTTPStat = true + mw.agent.Run() return func(writer rest.ResponseWriter, request *rest.Request) { handler(writer, request) - statusCode := request.Env["STATUS_CODE"].(int) - statsd.Counter(1.0, keyBase+"status_code."+strconv.Itoa(statusCode), 1) - - elapsedTime := request.Env["ELAPSED_TIME"].(*time.Duration) - statsd.Timing(1.0, keyBase+"elapsed_time", *elapsedTime) + // the timer middleware keeps track of the time + startTime := request.Env["START_TIME"].(*time.Time) + mw.agent.HTTPTimer.UpdateSince(*startTime) } } -type Message struct { - Body string +func main() { + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.Use(&NewRelicMiddleware{ + License: "", + Name: "", + Verbose: true, + }) + api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + })) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } -func main() { - handler := rest.ResourceHandler{ - OuterMiddlewares: []rest.Middleware{ - &StatsdMiddleware{ - IpPort: "localhost:8125", - }, - }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/message", func(w rest.ResponseWriter, req *rest.Request) { +``` + +#### Graceful Shutdown + +This example uses [https://github.com/tylerb/graceful](https://github.com/tylerb/graceful) to try to be nice with the clients waiting for responses during a server shutdown (or restart). +The HTTP response takes 10 seconds to be completed, printing a message on the wire every second. +10 seconds is also the timeout set for the graceful shutdown. +You can play with these numbers to show that the server waits for the responses to complete. + +curl demo: +``` sh +curl -i http://127.0.0.1:8080/message +``` - // take more than 1ms so statsd can report it - time.Sleep(100 * time.Millisecond) +code: +``` go +package main - w.WriteJson(&Message{ - Body: "Hello World!", - }) - }}, +import ( + "fmt" + "github.com/ant0ine/go-json-rest/rest" + "gopkg.in/tylerb/graceful.v1" + "log" + "net/http" + "time" +) + +func main() { + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/message", func(w rest.ResponseWriter, req *rest.Request) { + for cpt := 1; cpt <= 10; cpt++ { + + // wait 1 second + time.Sleep(time.Duration(1) * time.Second) + + w.WriteJson(map[string]string{ + "Message": fmt.Sprintf("%d seconds", cpt), + }) + w.(http.ResponseWriter).Write([]byte("\n")) + + // Flush the buffer to client + w.(http.Flusher).Flush() + } + }), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) + api.SetApp(router) + + server := &graceful.Server{ + Timeout: 10 * time.Second, + Server: &http.Server{ + Addr: ":8080", + Handler: api.MakeHandler(), + }, + } + + log.Fatal(server.ListenAndServe()) } ``` @@ -1262,12 +1444,12 @@ Demonstrate how to use SPDY with https://github.com/shykes/spdy-go For a command line client, install spdycat from: https://github.com/tatsuhiro-t/spdylay -The spdycat demo: +spdycat demo: ``` spdycat -v --no-tls -2 http://localhost:8080/users/0 ``` -Go code: +code: ``` go package main @@ -1291,14 +1473,16 @@ func GetUser(w rest.ResponseWriter, req *rest.Request) { } func main() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/users/:id", GetUser}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + rest.Get("/users/:id", GetUser), ) if err != nil { log.Fatal(err) } - log.Fatal(spdy.ListenAndServeTCP(":8080", &handler)) + api.SetApp(router) + log.Fatal(spdy.ListenAndServeTCP(":8080", api.MakeHandler())) } ``` @@ -1321,12 +1505,12 @@ Setup: * rm -rf github.com/ant0ine/go-json-rest/examples/ * path/to/google_appengine/dev_appserver.py . -The curl demo: +curl demo: ``` curl -i http://127.0.0.1:8080/message ``` -Go code: +code: ``` go package gaehelloworld @@ -1336,297 +1520,144 @@ import ( "net/http" ) -type Message struct { - Body string -} - func init() { - handler := rest.ResourceHandler{} - err := handler.SetRoutes( - &rest.Route{"GET", "/message", func(w rest.ResponseWriter, req *rest.Request) { - w.WriteJson(&Message{ - Body: "Hello World!", - }) - }}, + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + router, err := rest.MakeRouter( + &rest.Get("/message", func(w rest.ResponseWriter, req *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) + }), ) if err != nil { log.Fatal(err) } - http.Handle("/", &handler) + api.SetApp(router) + http.Handle("/", api.MakeHandler()) } ``` -#### Basic Auth Custom - -Demonstrate how to implement a custom AuthBasic middleware, used to protect all endpoints. +#### Websocket -This is a very simple version supporting only one user. +Demonstrate how to run websocket in go-json-rest -The curl demo: -``` -curl -i http://127.0.0.1:8080/countries +go client demo: +```go +origin := "http://localhost:8080/" +url := "ws://localhost:8080/ws" +ws, err := websocket.Dial(url, "", origin) +if err != nil { + log.Fatal(err) +} +if _, err := ws.Write([]byte("hello, world\n")); err != nil { + log.Fatal(err) +} +var msg = make([]byte, 512) +var n int +if n, err = ws.Read(msg); err != nil { + log.Fatal(err) +} +log.Printf("Received: %s.", msg[:n]) ``` -Go code: +code: ``` go package main import ( - "encoding/base64" - "errors" - "github.com/ant0ine/go-json-rest/rest" + "io" "log" "net/http" - "strings" -) - -type MyAuthBasicMiddleware struct { - Realm string - UserId string - Password string -} -func (mw *MyAuthBasicMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.HandlerFunc { - return func(writer rest.ResponseWriter, request *rest.Request) { - - authHeader := request.Header.Get("Authorization") - if authHeader == "" { - mw.unauthorized(writer) - return - } - - providedUserId, providedPassword, err := mw.decodeBasicAuthHeader(authHeader) - - if err != nil { - rest.Error(writer, "Invalid authentication", http.StatusBadRequest) - return - } - - if !(providedUserId == mw.UserId && providedPassword == mw.Password) { - mw.unauthorized(writer) - return - } - - handler(writer, request) - } -} - -func (mw *MyAuthBasicMiddleware) unauthorized(writer rest.ResponseWriter) { - writer.Header().Set("WWW-Authenticate", "Basic realm="+mw.Realm) - rest.Error(writer, "Not Authorized", http.StatusUnauthorized) -} - -func (mw *MyAuthBasicMiddleware) decodeBasicAuthHeader(header string) (user string, password string, err error) { - - parts := strings.SplitN(header, " ", 2) - if !(len(parts) == 2 && parts[0] == "Basic") { - return "", "", errors.New("Invalid authentication") - } - - decoded, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - return "", "", errors.New("Invalid base64") - } - - creds := strings.SplitN(string(decoded), ":", 2) - if len(creds) != 2 { - return "", "", errors.New("Invalid authentication") - } - - return creds[0], creds[1], nil -} + "github.com/ant0ine/go-json-rest/rest" + "golang.org/x/net/websocket" +) func main() { - - handler := rest.ResourceHandler{ - PreRoutingMiddlewares: []rest.Middleware{ - &MyAuthBasicMiddleware{ - Realm: "Administration", - UserId: "admin", - Password: "admin", - }, - }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, + wsHandler := websocket.Handler(func(ws *websocket.Conn) { + io.Copy(ws, ws) + }) + + router, err := rest.MakeRouter( + rest.Get("/ws", func(w rest.ResponseWriter, r *rest.Request) { + wsHandler.ServeHTTP(w.(http.ResponseWriter), r.Request) + }), ) if err != nil { log.Fatal(err) } - log.Fatal(http.ListenAndServe(":8080", &handler)) -} -type Country struct { - Code string - Name string -} - -func GetAllCountries(w rest.ResponseWriter, r *rest.Request) { - w.WriteJson( - []Country{ - Country{ - Code: "FR", - Name: "France", - }, - Country{ - Code: "US", - Name: "United States", - }, - }, - ) + api := rest.NewApi() + api.Use(rest.DefaultDevStack...) + api.SetApp(router) + log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) } ``` -#### CORS Custom -Demonstrate how to implement a custom CORS middleware, used to on all endpoints. -The curl demo: -``` -curl -i http://127.0.0.1:8080/countries -``` - -Go code: -``` go -package main - -import ( - "github.com/ant0ine/go-json-rest/rest" - "log" - "net/http" -) - -type MyCorsMiddleware struct{} - -func (mw *MyCorsMiddleware) MiddlewareFunc(handler rest.HandlerFunc) rest.HandlerFunc { - return func(writer rest.ResponseWriter, request *rest.Request) { - - corsInfo := request.GetCorsInfo() +## External Documentation - // Be nice with non CORS requests, continue - // Alternatively, you may also chose to only allow CORS requests, and return an error. - if !corsInfo.IsCors { - // continure, execute the wrapped middleware - handler(writer, request) - return - } +- [Online Documentation (godoc.org)](https://godoc.org/github.com/ant0ine/go-json-rest/rest) - // Validate the Origin - // More sophisticated validations can be implemented, regexps, DB lookups, ... - if corsInfo.Origin != "http://my.other.host/" { - rest.Error(writer, "Invalid Origin", http.StatusForbidden) - return - } +Old v1 blog posts: - if corsInfo.IsPreflight { - // check the request methods - allowedMethods := map[string]bool{ - "GET": true, - "POST": true, - "PUT": true, - // don't allow DELETE, for instance - } - if !allowedMethods[corsInfo.AccessControlRequestMethod] { - rest.Error(writer, "Invalid Preflight Request", http.StatusForbidden) - return - } - // check the request headers - allowedHeaders := map[string]bool{ - "Accept": true, - "Content-Type": true, - "X-Custom-Header": true, - } - for _, requestedHeader := range corsInfo.AccessControlRequestHeaders { - if !allowedHeaders[requestedHeader] { - rest.Error(writer, "Invalid Preflight Request", http.StatusForbidden) - return - } - } +- [(Blog Post) Introducing Go-Json-Rest](https://www.ant0ine.com/post/introducing-go-json-rest.html) +- [(Blog Post) Better URL Routing ?](https://www.ant0ine.com/post/better-url-routing-golang.html) - for allowedMethod, _ := range allowedMethods { - writer.Header().Add("Access-Control-Allow-Methods", allowedMethod) - } - for allowedHeader, _ := range allowedHeaders { - writer.Header().Add("Access-Control-Allow-Headers", allowedHeader) - } - writer.Header().Set("Access-Control-Allow-Origin", corsInfo.Origin) - writer.Header().Set("Access-Control-Allow-Credentials", "true") - writer.Header().Set("Access-Control-Max-Age", "3600") - writer.WriteHeader(http.StatusOK) - return - } else { - writer.Header().Set("Access-Control-Expose-Headers", "X-Powered-By") - writer.Header().Set("Access-Control-Allow-Origin", corsInfo.Origin) - writer.Header().Set("Access-Control-Allow-Credentials", "true") - // continure, execute the wrapped middleware - handler(writer, request) - return - } - } -} -func main() { +## Version 3 release notes - handler := rest.ResourceHandler{ - PreRoutingMiddlewares: []rest.Middleware{ - &MyCorsMiddleware{}, - }, - } - err := handler.SetRoutes( - &rest.Route{"GET", "/countries", GetAllCountries}, - ) - if err != nil { - log.Fatal(err) - } - log.Fatal(http.ListenAndServe(":8080", &handler)) -} +### What's New in v3 -type Country struct { - Code string - Name string -} +* Public Middlewares. (12 included in the package) +* A new App interface. (the router being the provided App) +* A new Api object that manages the Middlewares and the App. +* Optional and interchangeable App/router. -func GetAllCountries(w rest.ResponseWriter, r *rest.Request) { - w.WriteJson( - []Country{ - Country{ - Code: "FR", - Name: "France", - }, - Country{ - Code: "US", - Name: "United States", - }, - }, - ) -} +### Here is for instance the new minimal "Hello World!" +```go +api := rest.NewApi() +api.Use(rest.DefaultDevStack...) +api.SetApp(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { + w.WriteJson(map[string]string{"Body": "Hello World!"}) +})) +http.ListenAndServe(":8080", api.MakeHandler()) ``` +*All 19 examples have been updated to use the new API. [See here](https://github.com/ant0ine/go-json-rest#examples)* +### Deprecating the ResourceHandler -## External Documentation +V3 is about deprecating the ResourceHandler in favor of a new API that exposes the Middlewares. As a consequence, all the Middlewares are now public, and the new Api object helps putting them together as a stack. Some default stack configurations are offered. The router is now an App that sits on top of the stack of Middlewares. Which means that the router is no longer required to use Go-Json-Rest. -- [Online Documentation (godoc.org)](http://godoc.org/github.com/ant0ine/go-json-rest/rest) - -Old v1 blog posts: +*Design ideas and discussion [See here](https://github.com/ant0ine/go-json-rest/issues/110)* -- [(Blog Post) Introducing Go-Json-Rest] (http://blog.ant0ine.com/typepad/2013/04/introducing-go-json-rest.html) -- [(Blog Post) Better URL Routing ?] (http://blog.ant0ine.com/typepad/2013/02/better-url-routing-golang-1.html) +## Migration guide from v2 to v3 -## Options +V3 introduces an API change (see [Semver](http://semver.org/)). But it was possible to maintain backward compatibility, and so, ResourceHandler still works. +ResourceHandler does the same thing as in V2, **but it is now considered as deprecated, and will be removed in a few months**. In the meantime, it logs a +deprecation warning. -Things to enable in production: -- Gzip compression (default: disabled) -- Custom Logger (default: Go default) +### How to map the ResourceHandler options to the new stack of middlewares ? -Things to enable in development: -- Json indentation (default: enabled) -- Relaxed ContentType (default: disabled) -- Error stack trace in the response body (default: disabled) +* `EnableGzip bool`: Just include GzipMiddleware in the stack of middlewares. +* `DisableJsonIndent bool`: Just don't include JsonIndentMiddleware in the stack of middlewares. +* `EnableStatusService bool`: Include StatusMiddleware in the stack and keep a reference to it to access GetStatus(). +* `EnableResponseStackTrace bool`: Same exact option but moved to RecoverMiddleware. +* `EnableLogAsJson bool`: Include AccessLogJsonMiddleware, and possibly remove AccessLogApacheMiddleware. +* `EnableRelaxedContentType bool`: Just don't include ContentTypeCheckerMiddleware. +* `OuterMiddlewares []Middleware`: You are now building the full stack, OuterMiddlewares are the first in the list. +* `PreRoutingMiddlewares []Middleware`: You are now building the full stack, OuterMiddlewares are the last in the list. +* `Logger *log.Logger`: Same option but moved to AccessLogApacheMiddleware and AccessLogJsonMiddleware. +* `LoggerFormat AccessLogFormat`: Same exact option but moved to AccessLogApacheMiddleware. +* `DisableLogger bool`: Just don't include any access log middleware. +* `ErrorLogger *log.Logger`: Same exact option but moved to RecoverMiddleware. +* `XPoweredBy string`: Same exact option but moved to PoweredByMiddleware. +* `DisableXPoweredBy bool`: Just don't include PoweredByMiddleware. ## Version 2 release notes @@ -1652,7 +1683,7 @@ In fact the internal code of **go-json-rest** is itself implemented with Middlew #### The import path has changed to `github.com/ant0ine/go-json-rest/rest` -This is more conform to Go style, and makes [goimports](https://godoc.org/code.google.com/p/go.tools/cmd/goimports) work. +This is more conform to Go style, and makes [goimports](https://godoc.org/golang.org/x/tools/cmd/goimports) work. This: ``` go @@ -1762,9 +1793,12 @@ Overall, they provide the same features, but with two methods instead of three, - [Yann Kerhervé](https://github.com/yannk) - [Ask Bjørn Hansen](https://github.com/abh) - [Paul Lam](https://github.com/Quantisan) +- [Thanabodee Charoenpiriyakij](https://github.com/wingyplus) +- [Sebastien Estienne](https://github.com/sebest) +- [Edward Bramanti](https://github.com/jadengore) -Copyright (c) 2013-2014 Antoine Imbert +Copyright (c) 2013-2016 Antoine Imbert [MIT License](https://github.com/ant0ine/go-json-rest/blob/master/LICENSE) diff --git a/perf/bench/bench-a53004e-2014-08-16.txt b/perf/bench/bench-a53004e-2014-08-16.txt deleted file mode 100644 index 692d80e..0000000 --- a/perf/bench/bench-a53004e-2014-08-16.txt +++ /dev/null @@ -1,15 +0,0 @@ -PASS -BenchmarkNoCompression 100000 17658 ns/op -BenchmarkCompression 200000 15520 ns/op -BenchmarkRegExpLoop 2000 930384 ns/op -ok github.com/ant0ine/go-json-rest/rest 7.198s -PASS -BenchmarkNoCompression 100000 16760 ns/op -BenchmarkCompression 200000 14842 ns/op -BenchmarkRegExpLoop 2000 935028 ns/op -ok github.com/ant0ine/go-json-rest/rest 6.982s -PASS -BenchmarkNoCompression 100000 16570 ns/op -BenchmarkCompression 200000 15455 ns/op -BenchmarkRegExpLoop 2000 933782 ns/op -ok github.com/ant0ine/go-json-rest/rest 7.088s diff --git a/perf/bench/bench-cd0663d-2014-08-17.txt b/perf/bench/bench-cd0663d-2014-08-17.txt deleted file mode 100644 index f7c920a..0000000 --- a/perf/bench/bench-cd0663d-2014-08-17.txt +++ /dev/null @@ -1,15 +0,0 @@ -PASS -BenchmarkNoCompression 200000 15274 ns/op -BenchmarkCompression 200000 13176 ns/op -BenchmarkRegExpLoop 2000 927813 ns/op -ok github.com/ant0ine/go-json-rest/rest 7.980s -PASS -BenchmarkNoCompression 100000 14752 ns/op -BenchmarkCompression 200000 12910 ns/op -BenchmarkRegExpLoop 2000 926397 ns/op -ok github.com/ant0ine/go-json-rest/rest 6.350s -PASS -BenchmarkNoCompression 100000 14903 ns/op -BenchmarkCompression 200000 14032 ns/op -BenchmarkRegExpLoop 2000 926376 ns/op -ok github.com/ant0ine/go-json-rest/rest 6.594s diff --git a/perf/pprof/cpu-a53004e-2014-08-16.txt b/perf/pprof/cpu-a53004e-2014-08-16.txt deleted file mode 100644 index d3fa4b0..0000000 --- a/perf/pprof/cpu-a53004e-2014-08-16.txt +++ /dev/null @@ -1,87 +0,0 @@ -Total: 308 samples - 51 16.6% 16.6% 111 36.0% runtime.mallocgc - 21 6.8% 23.4% 22 7.1% settype - 19 6.2% 29.5% 19 6.2% scanblock - 18 5.8% 35.4% 19 6.2% runtime.MSpan_Sweep - 18 5.8% 41.2% 26 8.4% runtime.mapaccess1_faststr - 16 5.2% 46.4% 16 5.2% ExternalCode - 12 3.9% 50.3% 143 46.4% github.com/ant0ine/go-json-rest/rest/trie.(*node).find - 11 3.6% 53.9% 19 6.2% net/url.escape - 9 2.9% 56.8% 45 14.6% hash_insert - 8 2.6% 59.4% 60 19.5% cnew - 8 2.6% 62.0% 8 2.6% net/url.shouldEscape - 7 2.3% 64.3% 9 2.9% hash_init - 7 2.3% 66.6% 13 4.2% strings.genSplit - 6 1.9% 68.5% 21 6.8% runtime.growslice - 6 1.9% 70.5% 6 1.9% runtime.memclr - 5 1.6% 72.1% 93 30.2% github.com/ant0ine/go-json-rest/rest/trie.func·002 - 5 1.6% 73.7% 5 1.6% runtime.markscan - 5 1.6% 75.3% 51 16.6% runtime.new - 4 1.3% 76.6% 189 61.4% github.com/ant0ine/go-json-rest/rest/trie.(*Trie).FindRoutesAndPathMatched - 4 1.3% 77.9% 4 1.3% github.com/ant0ine/go-json-rest/rest/trie.splitParam - 4 1.3% 79.2% 15 4.9% growslice1 - 4 1.3% 80.5% 37 12.0% makemap_c - 4 1.3% 81.8% 4 1.3% runtime.mapaccess1_fast32 - 4 1.3% 83.1% 4 1.3% runtime.markspan - 4 1.3% 84.4% 17 5.5% strings.SplitN - 3 1.0% 85.4% 21 6.8% MCentral_Grow - 3 1.0% 86.4% 4 1.3% assertE2Tret - 3 1.0% 87.3% 26 8.4% github.com/ant0ine/go-json-rest/rest.(*router).ofFirstDefinedRoute - 3 1.0% 88.3% 3 1.0% markonly - 3 1.0% 89.3% 3 1.0% runtime.memeqbody - 3 1.0% 90.3% 3 1.0% runtime.memhash - 2 0.6% 90.9% 2 0.6% flushptrbuf - 2 0.6% 91.6% 260 84.4% github.com/ant0ine/go-json-rest/rest.(*router).findRouteFromURL - 2 0.6% 92.2% 263 85.4% github.com/ant0ine/go-json-rest/rest.BenchmarkCompression - 2 0.6% 92.9% 39 12.7% github.com/ant0ine/go-json-rest/rest.escapedPath - 2 0.6% 93.5% 2 0.6% runtime.duffcopy - 2 0.6% 94.2% 2 0.6% runtime.fastrand1 - 2 0.6% 94.8% 6 1.9% runtime.makeslice - 2 0.6% 95.5% 2 0.6% runtime.memcopy32 - 2 0.6% 96.1% 2 0.6% runtime.memeq - 1 0.3% 96.4% 1 0.3% bgsweep - 1 0.3% 96.8% 20 6.5% net/url.(*URL).RequestURI - 1 0.3% 97.1% 24 7.8% runtime.cnew - 1 0.3% 97.4% 1 0.3% runtime.findfunc - 1 0.3% 97.7% 1 0.3% runtime.funcspdelta - 1 0.3% 98.1% 1 0.3% runtime.lock - 1 0.3% 98.4% 1 0.3% runtime.nanotime - 1 0.3% 98.7% 1 0.3% runtime.strcopy - 1 0.3% 99.0% 4 1.3% runtime.strhash - 1 0.3% 99.4% 1 0.3% runtime.stringiter2 - 1 0.3% 99.7% 1 0.3% runtime.xadd - 1 0.3% 100.0% 1 0.3% scanbitvector - 0 0.0% 100.0% 28 9.1% GC - 0 0.0% 100.0% 1 0.3% MCentral_ReturnToHeap - 0 0.0% 100.0% 10 3.2% MHeap_AllocLocked - 0 0.0% 100.0% 1 0.3% MHeap_FreeLocked - 0 0.0% 100.0% 10 3.2% MHeap_Reclaim - 0 0.0% 100.0% 16 5.2% System - 0 0.0% 100.0% 1 0.3% clearpools - 0 0.0% 100.0% 1 0.3% concatstring - 0 0.0% 100.0% 1 0.3% copyout - 0 0.0% 100.0% 1 0.3% github.com/ant0ine/go-json-rest/rest.(*router).start - 0 0.0% 100.0% 1 0.3% github.com/ant0ine/go-json-rest/rest/trie.(*Trie).Compress - 0 0.0% 100.0% 67 21.8% github.com/ant0ine/go-json-rest/rest/trie.(*findContext).paramsAsMap - 0 0.0% 100.0% 1 0.3% github.com/ant0ine/go-json-rest/rest/trie.(*node).compress - 0 0.0% 100.0% 1 0.3% gostringsize - 0 0.0% 100.0% 4 1.3% makeslice1 - 0 0.0% 100.0% 31 10.1% runtime.MCache_Refill - 0 0.0% 100.0% 31 10.1% runtime.MCentral_CacheSpan - 0 0.0% 100.0% 1 0.3% runtime.MCentral_FreeSpan - 0 0.0% 100.0% 14 4.5% runtime.MHeap_Alloc - 0 0.0% 100.0% 1 0.3% runtime.MHeap_Free - 0 0.0% 100.0% 4 1.3% runtime.assertE2T - 0 0.0% 100.0% 1 0.3% runtime.call16 - 0 0.0% 100.0% 37 12.0% runtime.cnewarray - 0 0.0% 100.0% 1 0.3% runtime.concatstring2 - 0 0.0% 100.0% 1 0.3% runtime.gc - 0 0.0% 100.0% 264 85.7% runtime.gosched0 - 0 0.0% 100.0% 37 12.0% runtime.makemap - 0 0.0% 100.0% 45 14.6% runtime.mapassign1 - 0 0.0% 100.0% 10 3.2% runtime.sweepone - 0 0.0% 100.0% 1 0.3% strings.Map - 0 0.0% 100.0% 1 0.3% strings.ToUpper - 0 0.0% 100.0% 1 0.3% sync.poolCleanup - 0 0.0% 100.0% 263 85.4% testing.(*B).launch - 0 0.0% 100.0% 263 85.4% testing.(*B).runN diff --git a/perf/pprof/cpu-cd0663d-2014-08-17.txt b/perf/pprof/cpu-cd0663d-2014-08-17.txt deleted file mode 100644 index b757656..0000000 --- a/perf/pprof/cpu-cd0663d-2014-08-17.txt +++ /dev/null @@ -1,77 +0,0 @@ -Total: 272 samples - 39 14.3% 14.3% 115 42.3% runtime.mallocgc - 30 11.0% 25.4% 31 11.4% runtime.MSpan_Sweep - 24 8.8% 34.2% 24 8.8% scanblock - 22 8.1% 42.3% 23 8.5% settype - 15 5.5% 47.8% 15 5.5% ExternalCode - 13 4.8% 52.6% 145 53.3% github.com/ant0ine/go-json-rest/rest/trie.(*node).find - 12 4.4% 57.0% 19 7.0% runtime.mapaccess1_faststr - 10 3.7% 60.7% 17 6.2% net/url.escape - 7 2.6% 63.2% 56 20.6% cnew - 7 2.6% 65.8% 7 2.6% net/url.shouldEscape - 6 2.2% 68.0% 6 2.2% runtime.markscan - 5 1.8% 69.9% 8 2.9% hash_init - 5 1.8% 71.7% 5 1.8% runtime.memhash - 4 1.5% 73.2% 4 1.5% flushptrbuf - 4 1.5% 74.6% 40 14.7% hash_insert - 4 1.5% 76.1% 23 8.5% runtime.growslice - 4 1.5% 77.6% 4 1.5% runtime.mapaccess1_fast32 - 4 1.5% 79.0% 4 1.5% runtime.markspan - 3 1.1% 80.1% 8 2.9% github.com/ant0ine/go-json-rest/rest.(*router).ofFirstDefinedRoute - 3 1.1% 81.2% 19 7.0% growslice1 - 3 1.1% 82.4% 3 1.1% runtime.duffcopy - 3 1.1% 83.5% 3 1.1% runtime.fastrand1 - 3 1.1% 84.6% 3 1.1% runtime.memclr - 3 1.1% 85.7% 3 1.1% runtime.memeqbody - 3 1.1% 86.8% 3 1.1% runtime.stringiter2 - 3 1.1% 87.9% 13 4.8% strings.genSplit - 3 1.1% 89.0% 3 1.1% unicode.ToUpper - 2 0.7% 89.7% 29 10.7% MCentral_Grow - 2 0.7% 90.4% 2 0.7% MHeap_ReclaimList - 2 0.7% 91.2% 227 83.5% github.com/ant0ine/go-json-rest/rest.(*router).findRouteFromURL - 2 0.7% 91.9% 70 25.7% github.com/ant0ine/go-json-rest/rest/trie.(*findContext).paramsAsMap - 1 0.4% 92.3% 18 6.6% MHeap_Reclaim - 1 0.4% 92.6% 2 0.7% assertE2Tret - 1 0.4% 93.0% 228 83.8% github.com/ant0ine/go-json-rest/rest.BenchmarkCompression - 1 0.4% 93.4% 103 37.9% github.com/ant0ine/go-json-rest/rest/trie.func·002 - 1 0.4% 93.8% 1 0.4% github.com/ant0ine/go-json-rest/rest/trie.splitParam - 1 0.4% 94.1% 9 3.3% makeslice1 - 1 0.4% 94.5% 48 17.6% runtime.MCache_Refill - 1 0.4% 94.9% 1 0.4% runtime.MSpanList_IsEmpty - 1 0.4% 95.2% 1 0.4% runtime.atomicstore - 1 0.4% 95.6% 41 15.1% runtime.cnewarray - 1 0.4% 96.0% 1 0.4% runtime.gentraceback - 1 0.4% 96.3% 1 0.4% runtime.lock - 1 0.4% 96.7% 25 9.2% runtime.makemap - 1 0.4% 97.1% 10 3.7% runtime.makeslice - 1 0.4% 97.4% 1 0.4% runtime.memcopy32 - 1 0.4% 97.8% 1 0.4% runtime.memeq - 1 0.4% 98.2% 51 18.8% runtime.new - 1 0.4% 98.5% 1 0.4% runtime.strcopy - 1 0.4% 98.9% 1 0.4% runtime.unmarkspan - 1 0.4% 99.3% 1 0.4% runtime.xchg - 1 0.4% 99.6% 7 2.6% strings.Map - 1 0.4% 100.0% 14 5.1% strings.SplitN - 0 0.0% 100.0% 29 10.7% GC - 0 0.0% 100.0% 1 0.4% MCentral_ReturnToHeap - 0 0.0% 100.0% 20 7.4% MHeap_AllocLocked - 0 0.0% 100.0% 15 5.5% System - 0 0.0% 100.0% 1 0.4% copyout - 0 0.0% 100.0% 31 11.4% github.com/ant0ine/go-json-rest/rest.escapedPath - 0 0.0% 100.0% 178 65.4% github.com/ant0ine/go-json-rest/rest/trie.(*Trie).FindRoutesAndPathMatched - 0 0.0% 100.0% 24 8.8% makemap_c - 0 0.0% 100.0% 17 6.2% net/url.(*URL).RequestURI - 0 0.0% 100.0% 46 16.9% runtime.MCentral_CacheSpan - 0 0.0% 100.0% 1 0.4% runtime.MCentral_FreeSpan - 0 0.0% 100.0% 1 0.4% runtime.MCentral_UncacheSpan - 0 0.0% 100.0% 23 8.5% runtime.MHeap_Alloc - 0 0.0% 100.0% 2 0.7% runtime.assertE2T - 0 0.0% 100.0% 16 5.9% runtime.cnew - 0 0.0% 100.0% 228 83.8% runtime.gosched0 - 0 0.0% 100.0% 40 14.7% runtime.mapassign1 - 0 0.0% 100.0% 5 1.8% runtime.strhash - 0 0.0% 100.0% 15 5.5% runtime.sweepone - 0 0.0% 100.0% 1 0.4% runtime.unlock - 0 0.0% 100.0% 7 2.6% strings.ToUpper - 0 0.0% 100.0% 228 83.8% testing.(*B).launch - 0 0.0% 100.0% 228 83.8% testing.(*B).runN diff --git a/perf/run.sh b/perf/run.sh deleted file mode 100755 index 27ad82a..0000000 --- a/perf/run.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -VERSION=`git log -1 --pretty=format:"%h-%ad" --date=short` - -cd ../rest && go test -c && ./rest.test -test.bench="BenchmarkCompression" -test.cpuprofile="cpu.prof" -cd ../rest && go tool pprof --text rest.test cpu.prof > ../perf/pprof/cpu-$VERSION.txt -cd ../rest && rm -f rest.test cpu.prof - -rm -f perf/bench/bench-$VERSION.txt -cd ../rest && go test -bench=. >> ../perf/bench/bench-$VERSION.txt -cd ../rest && go test -bench=. >> ../perf/bench/bench-$VERSION.txt -cd ../rest && go test -bench=. >> ../perf/bench/bench-$VERSION.txt diff --git a/rest/access_log_apache.go b/rest/access_log_apache.go new file mode 100644 index 0000000..d82894a --- /dev/null +++ b/rest/access_log_apache.go @@ -0,0 +1,236 @@ +package rest + +import ( + "bytes" + "fmt" + "log" + "net" + "os" + "strings" + "text/template" + "time" +) + +// TODO Future improvements: +// * support %{strftime}t ? +// * support %{
}o to print headers + +// AccessLogFormat defines the format of the access log record. +// This implementation is a subset of Apache mod_log_config. +// (See http://httpd.apache.org/docs/2.0/mod/mod_log_config.html) +// +// %b content length in bytes, - if 0 +// %B content length in bytes +// %D response elapsed time in microseconds +// %h remote address +// %H server protocol +// %l identd logname, not supported, - +// %m http method +// %P process id +// %q query string +// %r first line of the request +// %s status code +// %S status code preceeded by a terminal color +// %t time of the request +// %T response elapsed time in seconds, 3 decimals +// %u remote user, - if missing +// %{User-Agent}i user agent, - if missing +// %{Referer}i referer, - is missing +// +// Some predefined formats are provided as contants. +type AccessLogFormat string + +const ( + // CommonLogFormat is the Common Log Format (CLF). + CommonLogFormat = "%h %l %u %t \"%r\" %s %b" + + // CombinedLogFormat is the NCSA extended/combined log format. + CombinedLogFormat = "%h %l %u %t \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\"" + + // DefaultLogFormat is the default format, colored output and response time, convenient for development. + DefaultLogFormat = "%t %S\033[0m \033[36;1m%Dμs\033[0m \"%r\" \033[1;30m%u \"%{User-Agent}i\"\033[0m" +) + +// AccessLogApacheMiddleware produces the access log following a format inspired by Apache +// mod_log_config. It depends on TimerMiddleware and RecorderMiddleware that should be in the wrapped +// middlewares. It also uses request.Env["REMOTE_USER"].(string) set by the auth middlewares. +type AccessLogApacheMiddleware struct { + + // Logger points to the logger object used by this middleware, it defaults to + // log.New(os.Stderr, "", 0). + Logger *log.Logger + + // Format defines the format of the access log record. See AccessLogFormat for the details. + // It defaults to DefaultLogFormat. + Format AccessLogFormat + + textTemplate *template.Template +} + +// MiddlewareFunc makes AccessLogApacheMiddleware implement the Middleware interface. +func (mw *AccessLogApacheMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { + + // set the default Logger + if mw.Logger == nil { + mw.Logger = log.New(os.Stderr, "", 0) + } + + // set default format + if mw.Format == "" { + mw.Format = DefaultLogFormat + } + + mw.convertFormat() + + return func(w ResponseWriter, r *Request) { + + // call the handler + h(w, r) + + util := &accessLogUtil{w, r} + + mw.Logger.Print(mw.executeTextTemplate(util)) + } +} + +var apacheAdapter = strings.NewReplacer( + "%b", "{{.BytesWritten | dashIf0}}", + "%B", "{{.BytesWritten}}", + "%D", "{{.ResponseTime | microseconds}}", + "%h", "{{.ApacheRemoteAddr}}", + "%H", "{{.R.Proto}}", + "%l", "-", + "%m", "{{.R.Method}}", + "%P", "{{.Pid}}", + "%q", "{{.ApacheQueryString}}", + "%r", "{{.R.Method}} {{.R.URL.RequestURI}} {{.R.Proto}}", + "%s", "{{.StatusCode}}", + "%S", "\033[{{.StatusCode | statusCodeColor}}m{{.StatusCode}}", + "%t", "{{if .StartTime}}{{.StartTime.Format \"02/Jan/2006:15:04:05 -0700\"}}{{end}}", + "%T", "{{if .ResponseTime}}{{.ResponseTime.Seconds | printf \"%.3f\"}}{{end}}", + "%u", "{{.RemoteUser | dashIfEmptyStr}}", + "%{User-Agent}i", "{{.R.UserAgent | dashIfEmptyStr}}", + "%{Referer}i", "{{.R.Referer | dashIfEmptyStr}}", +) + +// Convert the Apache access log format into a text/template +func (mw *AccessLogApacheMiddleware) convertFormat() { + + tmplText := apacheAdapter.Replace(string(mw.Format)) + + funcMap := template.FuncMap{ + "dashIfEmptyStr": func(value string) string { + if value == "" { + return "-" + } + return value + }, + "dashIf0": func(value int64) string { + if value == 0 { + return "-" + } + return fmt.Sprintf("%d", value) + }, + "microseconds": func(dur *time.Duration) string { + if dur != nil { + return fmt.Sprintf("%d", dur.Nanoseconds()/1000) + } + return "" + }, + "statusCodeColor": func(statusCode int) string { + if statusCode >= 400 && statusCode < 500 { + return "1;33" + } else if statusCode >= 500 { + return "0;31" + } + return "0;32" + }, + } + + var err error + mw.textTemplate, err = template.New("accessLog").Funcs(funcMap).Parse(tmplText) + if err != nil { + panic(err) + } +} + +// Execute the text template with the data derived from the request, and return a string. +func (mw *AccessLogApacheMiddleware) executeTextTemplate(util *accessLogUtil) string { + buf := bytes.NewBufferString("") + err := mw.textTemplate.Execute(buf, util) + if err != nil { + panic(err) + } + return buf.String() +} + +// accessLogUtil provides a collection of utility functions that devrive data from the Request object. +// This object is used to provide data to the Apache Style template and the the JSON log record. +type accessLogUtil struct { + W ResponseWriter + R *Request +} + +// As stored by the auth middlewares. +func (u *accessLogUtil) RemoteUser() string { + if u.R.Env["REMOTE_USER"] != nil { + return u.R.Env["REMOTE_USER"].(string) + } + return "" +} + +// If qs exists then return it with a leadin "?", apache log style. +func (u *accessLogUtil) ApacheQueryString() string { + if u.R.URL.RawQuery != "" { + return "?" + u.R.URL.RawQuery + } + return "" +} + +// When the request entered the timer middleware. +func (u *accessLogUtil) StartTime() *time.Time { + if u.R.Env["START_TIME"] != nil { + return u.R.Env["START_TIME"].(*time.Time) + } + return nil +} + +// If remoteAddr is set then return is without the port number, apache log style. +func (u *accessLogUtil) ApacheRemoteAddr() string { + remoteAddr := u.R.RemoteAddr + if remoteAddr != "" { + if ip, _, err := net.SplitHostPort(remoteAddr); err == nil { + return ip + } + } + return "" +} + +// As recorded by the recorder middleware. +func (u *accessLogUtil) StatusCode() int { + if u.R.Env["STATUS_CODE"] != nil { + return u.R.Env["STATUS_CODE"].(int) + } + return 0 +} + +// As mesured by the timer middleware. +func (u *accessLogUtil) ResponseTime() *time.Duration { + if u.R.Env["ELAPSED_TIME"] != nil { + return u.R.Env["ELAPSED_TIME"].(*time.Duration) + } + return nil +} + +// Process id. +func (u *accessLogUtil) Pid() int { + return os.Getpid() +} + +// As recorded by the recorder middleware. +func (u *accessLogUtil) BytesWritten() int64 { + if u.R.Env["BYTES_WRITTEN"] != nil { + return u.R.Env["BYTES_WRITTEN"].(int64) + } + return 0 +} diff --git a/rest/access_log_apache_test.go b/rest/access_log_apache_test.go new file mode 100644 index 0000000..6412744 --- /dev/null +++ b/rest/access_log_apache_test.go @@ -0,0 +1,78 @@ +package rest + +import ( + "bytes" + "github.com/ant0ine/go-json-rest/rest/test" + "log" + "regexp" + "testing" +) + +func TestAccessLogApacheMiddleware(t *testing.T) { + + api := NewApi() + + // the middlewares stack + buffer := bytes.NewBufferString("") + api.Use(&AccessLogApacheMiddleware{ + Logger: log.New(buffer, "", 0), + Format: CommonLogFormat, + textTemplate: nil, + }) + api.Use(&TimerMiddleware{}) + api.Use(&RecorderMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + req.RemoteAddr = "127.0.0.1:1234" + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + + // log tests, eg: '127.0.0.1 - - 29/Nov/2014:22:28:34 +0000 "GET / HTTP/1.1" 200 12' + apacheCommon := regexp.MustCompile(`127.0.0.1 - - \d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+\-]\d{4}\ "GET / HTTP/1.1" 200 12`) + + if !apacheCommon.Match(buffer.Bytes()) { + t.Errorf("Got: %s", buffer.String()) + } +} + +func TestAccessLogApacheMiddlewareMissingData(t *testing.T) { + + api := NewApi() + + // the uncomplete middlewares stack + buffer := bytes.NewBufferString("") + api.Use(&AccessLogApacheMiddleware{ + Logger: log.New(buffer, "", 0), + Format: CommonLogFormat, + textTemplate: nil, + }) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + + // not much to log when the Env data is missing, but this should still work + apacheCommon := regexp.MustCompile(` - - "GET / HTTP/1.1" 0 -`) + + if !apacheCommon.Match(buffer.Bytes()) { + t.Errorf("Got: %s", buffer.String()) + } +} diff --git a/rest/access_log_json.go b/rest/access_log_json.go new file mode 100644 index 0000000..a6bc175 --- /dev/null +++ b/rest/access_log_json.go @@ -0,0 +1,88 @@ +package rest + +import ( + "encoding/json" + "log" + "os" + "time" +) + +// AccessLogJsonMiddleware produces the access log with records written as JSON. This middleware +// depends on TimerMiddleware and RecorderMiddleware that must be in the wrapped middlewares. It +// also uses request.Env["REMOTE_USER"].(string) set by the auth middlewares. +type AccessLogJsonMiddleware struct { + + // Logger points to the logger object used by this middleware, it defaults to + // log.New(os.Stderr, "", 0). + Logger *log.Logger +} + +// MiddlewareFunc makes AccessLogJsonMiddleware implement the Middleware interface. +func (mw *AccessLogJsonMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { + + // set the default Logger + if mw.Logger == nil { + mw.Logger = log.New(os.Stderr, "", 0) + } + + return func(w ResponseWriter, r *Request) { + + // call the handler + h(w, r) + + mw.Logger.Printf("%s", makeAccessLogJsonRecord(r).asJson()) + } +} + +// AccessLogJsonRecord is the data structure used by AccessLogJsonMiddleware to create the JSON +// records. (Public for documentation only, no public method uses it) +type AccessLogJsonRecord struct { + Timestamp *time.Time + StatusCode int + ResponseTime *time.Duration + HttpMethod string + RequestURI string + RemoteUser string + UserAgent string +} + +func makeAccessLogJsonRecord(r *Request) *AccessLogJsonRecord { + + var timestamp *time.Time + if r.Env["START_TIME"] != nil { + timestamp = r.Env["START_TIME"].(*time.Time) + } + + var statusCode int + if r.Env["STATUS_CODE"] != nil { + statusCode = r.Env["STATUS_CODE"].(int) + } + + var responseTime *time.Duration + if r.Env["ELAPSED_TIME"] != nil { + responseTime = r.Env["ELAPSED_TIME"].(*time.Duration) + } + + var remoteUser string + if r.Env["REMOTE_USER"] != nil { + remoteUser = r.Env["REMOTE_USER"].(string) + } + + return &AccessLogJsonRecord{ + Timestamp: timestamp, + StatusCode: statusCode, + ResponseTime: responseTime, + HttpMethod: r.Method, + RequestURI: r.URL.RequestURI(), + RemoteUser: remoteUser, + UserAgent: r.UserAgent(), + } +} + +func (r *AccessLogJsonRecord) asJson() []byte { + b, err := json.Marshal(r) + if err != nil { + panic(err) + } + return b +} diff --git a/rest/access_log_json_test.go b/rest/access_log_json_test.go new file mode 100644 index 0000000..9085fcb --- /dev/null +++ b/rest/access_log_json_test.go @@ -0,0 +1,53 @@ +package rest + +import ( + "bytes" + "encoding/json" + "github.com/ant0ine/go-json-rest/rest/test" + "log" + "testing" +) + +func TestAccessLogJsonMiddleware(t *testing.T) { + + api := NewApi() + + // the middlewares stack + buffer := bytes.NewBufferString("") + api.Use(&AccessLogJsonMiddleware{ + Logger: log.New(buffer, "", 0), + }) + api.Use(&TimerMiddleware{}) + api.Use(&RecorderMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + req.RemoteAddr = "127.0.0.1:1234" + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + + // log tests + decoded := &AccessLogJsonRecord{} + err := json.Unmarshal(buffer.Bytes(), decoded) + if err != nil { + t.Fatal(err) + } + + if decoded.StatusCode != 200 { + t.Errorf("StatusCode 200 expected, got %d", decoded.StatusCode) + } + if decoded.RequestURI != "/" { + t.Errorf("RequestURI / expected, got %s", decoded.RequestURI) + } + if decoded.HttpMethod != "GET" { + t.Errorf("HttpMethod GET expected, got %s", decoded.HttpMethod) + } +} diff --git a/rest/api.go b/rest/api.go new file mode 100644 index 0000000..6295430 --- /dev/null +++ b/rest/api.go @@ -0,0 +1,83 @@ +package rest + +import ( + "net/http" +) + +// Api defines a stack of Middlewares and an App. +type Api struct { + stack []Middleware + app App +} + +// NewApi makes a new Api object. The Middleware stack is empty, and the App is nil. +func NewApi() *Api { + return &Api{ + stack: []Middleware{}, + app: nil, + } +} + +// Use pushes one or multiple middlewares to the stack for middlewares +// maintained in the Api object. +func (api *Api) Use(middlewares ...Middleware) { + api.stack = append(api.stack, middlewares...) +} + +// SetApp sets the App in the Api object. +func (api *Api) SetApp(app App) { + api.app = app +} + +// MakeHandler wraps all the Middlewares of the stack and the App together, and returns an +// http.Handler ready to be used. If the Middleware stack is empty the App is used directly. If the +// App is nil, a HandlerFunc that does nothing is used instead. +func (api *Api) MakeHandler() http.Handler { + var appFunc HandlerFunc + if api.app != nil { + appFunc = api.app.AppFunc() + } else { + appFunc = func(w ResponseWriter, r *Request) {} + } + return http.HandlerFunc( + adapterFunc( + WrapMiddlewares(api.stack, appFunc), + ), + ) +} + +// Defines a stack of middlewares convenient for development. Among other things: +// console friendly logging, JSON indentation, error stack strace in the response. +var DefaultDevStack = []Middleware{ + &AccessLogApacheMiddleware{}, + &TimerMiddleware{}, + &RecorderMiddleware{}, + &PoweredByMiddleware{}, + &RecoverMiddleware{ + EnableResponseStackTrace: true, + }, + &JsonIndentMiddleware{}, + &ContentTypeCheckerMiddleware{}, +} + +// Defines a stack of middlewares convenient for production. Among other things: +// Apache CombinedLogFormat logging, gzip compression. +var DefaultProdStack = []Middleware{ + &AccessLogApacheMiddleware{ + Format: CombinedLogFormat, + }, + &TimerMiddleware{}, + &RecorderMiddleware{}, + &PoweredByMiddleware{}, + &RecoverMiddleware{}, + &GzipMiddleware{}, + &ContentTypeCheckerMiddleware{}, +} + +// Defines a stack of middlewares that should be common to most of the middleware stacks. +var DefaultCommonStack = []Middleware{ + &TimerMiddleware{}, + &RecorderMiddleware{}, + &PoweredByMiddleware{}, + &RecoverMiddleware{}, +} diff --git a/rest/api_test.go b/rest/api_test.go new file mode 100644 index 0000000..269edfc --- /dev/null +++ b/rest/api_test.go @@ -0,0 +1,97 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" +) + +func TestApiNoAppNoMiddleware(t *testing.T) { + + api := NewApi() + if api == nil { + t.Fatal("Api object must be instantiated") + } + + handler := api.MakeHandler() + if handler == nil { + t.Fatal("the http.Handler must have been created") + } + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) +} + +func TestApiSimpleAppNoMiddleware(t *testing.T) { + + api := NewApi() + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + handler := api.MakeHandler() + if handler == nil { + t.Fatal("the http.Handler must have been created") + } + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs(`{"Id":"123"}`) +} + +func TestDevStack(t *testing.T) { + + api := NewApi() + api.Use(DefaultDevStack...) + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + handler := api.MakeHandler() + if handler == nil { + t.Fatal("the http.Handler must have been created") + } + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\n \"Id\": \"123\"\n}") +} + +func TestProdStack(t *testing.T) { + + api := NewApi() + api.Use(DefaultProdStack...) + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + handler := api.MakeHandler() + if handler == nil { + t.Fatal("the http.Handler must have been created") + } + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.ContentEncodingIsGzip() +} + +func TestCommonStack(t *testing.T) { + + api := NewApi() + api.Use(DefaultCommonStack...) + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + handler := api.MakeHandler() + if handler == nil { + t.Fatal("the http.Handler must have been created") + } + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs(`{"Id":"123"}`) +} diff --git a/rest/auth_basic.go b/rest/auth_basic.go index 73aa6d6..dbf254c 100644 --- a/rest/auth_basic.go +++ b/rest/auth_basic.go @@ -8,31 +8,41 @@ import ( "strings" ) -// AuthBasicMiddleware provides a simple AuthBasic implementation. -// It can be used before routing to protect all the endpoints, see PreRoutingMiddlewares. -// Or it can be used to wrap a particular endpoint HandlerFunc. +// AuthBasicMiddleware provides a simple AuthBasic implementation. On failure, a 401 HTTP response +//is returned. On success, the wrapped middleware is called, and the userId is made available as +// request.Env["REMOTE_USER"].(string) type AuthBasicMiddleware struct { - // Realm name to display to the user. (Required) + // Realm name to display to the user. Required. Realm string - // Callback function that should perform the authentication of the user based on userId and password. - // Must return true on success, false on failure. (Required) + // Callback function that should perform the authentication of the user based on userId and + // password. Must return true on success, false on failure. Required. Authenticator func(userId string, password string) bool + + // Callback function that should perform the authorization of the authenticated user. Called + // only after an authentication success. Must return true on success, false on failure. + // Optional, default to success. + Authorizator func(userId string, request *Request) bool } -// MiddlewareFunc tries to authenticate the user. It sends a 401 on failure, -// and executes the wrapped handler on success. -// Note that, on success, the userId is made available in the environment at request.Env["REMOTE_USER"] +// MiddlewareFunc makes AuthBasicMiddleware implement the Middleware interface. func (mw *AuthBasicMiddleware) MiddlewareFunc(handler HandlerFunc) HandlerFunc { if mw.Realm == "" { log.Fatal("Realm is required") } + if mw.Authenticator == nil { log.Fatal("Authenticator is required") } + if mw.Authorizator == nil { + mw.Authorizator = func(userId string, request *Request) bool { + return true + } + } + return func(writer ResponseWriter, request *Request) { authHeader := request.Header.Get("Authorization") @@ -53,6 +63,11 @@ func (mw *AuthBasicMiddleware) MiddlewareFunc(handler HandlerFunc) HandlerFunc { return } + if !mw.Authorizator(providedUserId, request) { + mw.unauthorized(writer) + return + } + request.Env["REMOTE_USER"] = providedUserId handler(writer, request) diff --git a/rest/auth_basic_test.go b/rest/auth_basic_test.go index 11acfbc..8206ca0 100644 --- a/rest/auth_basic_test.go +++ b/rest/auth_basic_test.go @@ -8,45 +8,71 @@ import ( func TestAuthBasic(t *testing.T) { - handler := ResourceHandler{ - PreRoutingMiddlewares: []Middleware{ - &AuthBasicMiddleware{ - Realm: "test zone", - Authenticator: func(userId string, password string) bool { - if userId == "admin" && password == "admin" { - return true - } - return false - }, - }, + // the middleware to test + authMiddleware := &AuthBasicMiddleware{ + Realm: "test zone", + Authenticator: func(userId string, password string) bool { + if userId == "admin" && password == "admin" { + return true + } + return false }, - } - handler.SetRoutes( - &Route{"GET", "/r", - func(w ResponseWriter, r *Request) { - w.WriteJson(map[string]string{"Id": "123"}) - }, + Authorizator: func(userId string, request *Request) bool { + if request.Method == "GET" { + return true + } + return false }, - ) + } + + // api for testing failure + apiFailure := NewApi() + apiFailure.Use(authMiddleware) + apiFailure.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + t.Error("Should never be executed") + })) + handler := apiFailure.MakeHandler() // simple request fails - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil)) + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) recorded.CodeIs(401) recorded.ContentTypeIsJson() - // auth with wrong cred fails - wrongCredReq := test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil) + // auth with wrong cred and right method fails + wrongCredReq := test.MakeSimpleRequest("GET", "http://localhost/", nil) encoded := base64.StdEncoding.EncodeToString([]byte("admin:AdmIn")) wrongCredReq.Header.Set("Authorization", "Basic "+encoded) - recorded = test.RunRequest(t, &handler, wrongCredReq) + recorded = test.RunRequest(t, handler, wrongCredReq) recorded.CodeIs(401) recorded.ContentTypeIsJson() - // auth with right cred succeeds - rightCredReq := test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil) + // auth with right cred and wrong method fails + rightCredReq := test.MakeSimpleRequest("POST", "http://localhost/", nil) + encoded = base64.StdEncoding.EncodeToString([]byte("admin:admin")) + rightCredReq.Header.Set("Authorization", "Basic "+encoded) + recorded = test.RunRequest(t, handler, rightCredReq) + recorded.CodeIs(401) + recorded.ContentTypeIsJson() + + // api for testing success + apiSuccess := NewApi() + apiSuccess.Use(authMiddleware) + apiSuccess.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + if r.Env["REMOTE_USER"] == nil { + t.Error("REMOTE_USER is nil") + } + user := r.Env["REMOTE_USER"].(string) + if user != "admin" { + t.Error("REMOTE_USER is expected to be 'admin'") + } + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // auth with right cred and right method succeeds + rightCredReq = test.MakeSimpleRequest("GET", "http://localhost/", nil) encoded = base64.StdEncoding.EncodeToString([]byte("admin:admin")) rightCredReq.Header.Set("Authorization", "Basic "+encoded) - recorded = test.RunRequest(t, &handler, rightCredReq) + recorded = test.RunRequest(t, apiSuccess.MakeHandler(), rightCredReq) recorded.CodeIs(200) recorded.ContentTypeIsJson() } diff --git a/rest/content_type_checker.go b/rest/content_type_checker.go new file mode 100644 index 0000000..1d87877 --- /dev/null +++ b/rest/content_type_checker.go @@ -0,0 +1,40 @@ +package rest + +import ( + "mime" + "net/http" + "strings" +) + +// ContentTypeCheckerMiddleware verifies the request Content-Type header and returns a +// StatusUnsupportedMediaType (415) HTTP error response if it's incorrect. The expected +// Content-Type is 'application/json' if the content is non-null. Note: If a charset parameter +// exists, it MUST be UTF-8. +type ContentTypeCheckerMiddleware struct{} + +// MiddlewareFunc makes ContentTypeCheckerMiddleware implement the Middleware interface. +func (mw *ContentTypeCheckerMiddleware) MiddlewareFunc(handler HandlerFunc) HandlerFunc { + + return func(w ResponseWriter, r *Request) { + + mediatype, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + charset, ok := params["charset"] + if !ok { + charset = "UTF-8" + } + + // per net/http doc, means that the length is known and non-null + if r.ContentLength > 0 && + !(mediatype == "application/json" && strings.ToUpper(charset) == "UTF-8") { + + Error(w, + "Bad Content-Type or charset, expected 'application/json'", + http.StatusUnsupportedMediaType, + ) + return + } + + // call the wrapped handler + handler(w, r) + } +} diff --git a/rest/content_type_checker_test.go b/rest/content_type_checker_test.go new file mode 100644 index 0000000..0f819bc --- /dev/null +++ b/rest/content_type_checker_test.go @@ -0,0 +1,48 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" +) + +func TestContentTypeCheckerMiddleware(t *testing.T) { + + api := NewApi() + + // the middleware to test + api.Use(&ContentTypeCheckerMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + // no payload, no content length, no check + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + + // JSON payload with correct content type + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("POST", "http://localhost/", map[string]string{"Id": "123"})) + recorded.CodeIs(200) + + // JSON payload with correct content type specifying the utf-8 charset + req := test.MakeSimpleRequest("POST", "http://localhost/", map[string]string{"Id": "123"}) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + recorded = test.RunRequest(t, handler, req) + recorded.CodeIs(200) + + // JSON payload with incorrect content type + req = test.MakeSimpleRequest("POST", "http://localhost/", map[string]string{"Id": "123"}) + req.Header.Set("Content-Type", "text/x-json") + recorded = test.RunRequest(t, handler, req) + recorded.CodeIs(415) + + // JSON payload with correct content type but incorrect charset + req = test.MakeSimpleRequest("POST", "http://localhost/", map[string]string{"Id": "123"}) + req.Header.Set("Content-Type", "application/json; charset=ISO-8859-1") + recorded = test.RunRequest(t, handler, req) + recorded.CodeIs(415) +} diff --git a/rest/cors.go b/rest/cors.go index 5b9581b..5b00543 100644 --- a/rest/cors.go +++ b/rest/cors.go @@ -27,9 +27,9 @@ type CorsMiddleware struct { // For instance: simple equality, regexp, DB lookup, ... OriginValidator func(origin string, request *Request) bool - // List of allowed HTTP methods. Note that the comparison will be made in uppercase - // to avoid common mistakes. And that the Access-Control-Allow-Methods response header - // also uses uppercase. + // List of allowed HTTP methods. Note that the comparison will be made in + // uppercase to avoid common mistakes. And that the + // Access-Control-Allow-Methods response header also uses uppercase. // (see CorsInfo.AccessControlRequestMethod) AllowedMethods []string diff --git a/rest/cors_test.go b/rest/cors_test.go new file mode 100644 index 0000000..09bbbc4 --- /dev/null +++ b/rest/cors_test.go @@ -0,0 +1,43 @@ +package rest + +import ( + "net/http" + "testing" + + "github.com/ant0ine/go-json-rest/rest/test" +) + +func TestCorsMiddlewareEmptyAccessControlRequestHeaders(t *testing.T) { + api := NewApi() + + // the middleware to test + api.Use(&CorsMiddleware{ + OriginValidator: func(_ string, _ *Request) bool { + return true + }, + AllowedMethods: []string{ + "GET", + "POST", + "PUT", + }, + AllowedHeaders: []string{ + "Origin", + "Referer", + }, + }) + + // wrap all + handler := api.MakeHandler() + + req, _ := http.NewRequest("OPTIONS", "http://localhost/", nil) + req.Header.Set("Origin", "http://another.host/") + req.Header.Set("Access-Control-Request-Method", "PUT") + req.Header.Set("Access-Control-Request-Headers", "") + + recorded := test.RunRequest(t, handler, req) + t.Logf("recorded: %+v\n", recorded.Recorder) + recorded.CodeIs(200) + recorded.HeaderIs("Access-Control-Allow-Methods", "GET,POST,PUT") + recorded.HeaderIs("Access-Control-Allow-Headers", "Origin,Referer") + recorded.HeaderIs("Access-Control-Allow-Origin", "http://another.host/") +} diff --git a/rest/doc.go b/rest/doc.go index f9b2617..fa6f5b2 100644 --- a/rest/doc.go +++ b/rest/doc.go @@ -3,8 +3,9 @@ // http://ant0ine.github.io/go-json-rest/ // // Go-Json-Rest is a thin layer on top of net/http that helps building RESTful JSON APIs easily. -// It provides fast URL routing using a Trie based implementation, helpers to deal with JSON -// requests and responses, and middlewares for additional functionalities like CORS, Auth, Gzip ... +// It provides fast and scalable request routing using a Trie based implementation, helpers to deal +// with JSON requests and responses, and middlewares for functionalities like CORS, Auth, Gzip, +// Status, ... // // Example: // @@ -12,6 +13,7 @@ // // import ( // "github.com/ant0ine/go-json-rest/rest" +// "log" // "net/http" // ) // @@ -29,18 +31,17 @@ // } // // func main() { -// handler := rest.ResourceHandler{} -// handler.SetRoutes( -// rest.Route{"GET", "/users/:id", GetUser}, +// api := rest.NewApi() +// api.Use(rest.DefaultDevStack...) +// router, err := rest.MakeRouter( +// rest.Get("/users/:id", GetUser), // ) -// http.ListenAndServe(":8080", &handler) +// if err != nil { +// log.Fatal(err) +// } +// api.SetApp(router) +// log.Fatal(http.ListenAndServe(":8080", api.MakeHandler())) // } // // -// Note about the URL routing: Instead of using the usual -// "evaluate all the routes and return the first regexp that matches" strategy, -// it uses a Trie data structure to perform the routing. This is more efficient, -// and scales better for a large number of routes. -// It supports the :param and *splat placeholders in the route strings. -// package rest diff --git a/rest/gzip.go b/rest/gzip.go index 9b1f385..0fafc05 100644 --- a/rest/gzip.go +++ b/rest/gzip.go @@ -8,16 +8,25 @@ import ( "strings" ) -// gzipMiddleware is responsible for compressing the payload with gzip -// and setting the proper headers when supported by the client. -type gzipMiddleware struct{} - -func (mw *gzipMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { +// GzipMiddleware is responsible for compressing the payload with gzip and setting the proper +// headers when supported by the client. It must be wrapped by TimerMiddleware for the +// compression time to be captured. And It must be wrapped by RecorderMiddleware for the +// compressed BYTES_WRITTEN to be captured. +type GzipMiddleware struct{} + +// MiddlewareFunc makes GzipMiddleware implement the Middleware interface. +func (mw *GzipMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { return func(w ResponseWriter, r *Request) { // gzip support enabled canGzip := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") // client accepts gzip ? writer := &gzipResponseWriter{w, false, canGzip, nil} + defer func() { + // need to close gzip writer + if writer.gzipWriter != nil { + writer.gzipWriter.Close() + } + }() // call the handler with the wrapped writer h(writer, r) } @@ -59,7 +68,10 @@ func (w *gzipResponseWriter) WriteJson(v interface{}) error { if err != nil { return err } - w.Write(b) + _, err = w.Write(b) + if err != nil { + return err + } return nil } diff --git a/rest/gzip_test.go b/rest/gzip_test.go index e8a5e6e..06a7e6f 100644 --- a/rest/gzip_test.go +++ b/rest/gzip_test.go @@ -7,30 +7,36 @@ import ( func TestGzipEnabled(t *testing.T) { - handler := ResourceHandler{ - DisableJsonIndent: true, - EnableGzip: true, - } - handler.SetRoutes( - &Route{"GET", "/ok", - func(w ResponseWriter, r *Request) { - w.WriteJson(map[string]string{"Id": "123"}) - }, - }, - &Route{"GET", "/error", - func(w ResponseWriter, r *Request) { - Error(w, "gzipped error", 500) - }, - }, + api := NewApi() + + // the middleware to test + api.Use(&GzipMiddleware{}) + + // router app with success and error paths + router, err := MakeRouter( + Get("/ok", func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + }), + Get("/error", func(w ResponseWriter, r *Request) { + Error(w, "gzipped error", 500) + }), ) + if err != nil { + t.Fatal(err) + } + + api.SetApp(router) - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/ok", nil)) + // wrap all + handler := api.MakeHandler() + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/ok", nil)) recorded.CodeIs(200) recorded.ContentTypeIsJson() recorded.ContentEncodingIsGzip() recorded.HeaderIs("Vary", "Accept-Encoding") - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/error", nil)) + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/error", nil)) recorded.CodeIs(500) recorded.ContentTypeIsJson() recorded.ContentEncodingIsGzip() @@ -39,18 +45,22 @@ func TestGzipEnabled(t *testing.T) { func TestGzipDisabled(t *testing.T) { - handler := ResourceHandler{ - DisableJsonIndent: true, - } - handler.SetRoutes( - &Route{"GET", "/ok", - func(w ResponseWriter, r *Request) { - w.WriteJson(map[string]string{"Id": "123"}) - }, - }, + api := NewApi() + + // router app with success and error paths + router, err := MakeRouter( + Get("/ok", func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + }), ) + if err != nil { + t.Fatal(err) + } + + api.SetApp(router) + handler := api.MakeHandler() - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/ok", nil)) + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/ok", nil)) recorded.CodeIs(200) recorded.ContentTypeIsJson() recorded.HeaderIs("Content-Encoding", "") diff --git a/rest/handler.go b/rest/handler.go deleted file mode 100644 index d907714..0000000 --- a/rest/handler.go +++ /dev/null @@ -1,233 +0,0 @@ -package rest - -import ( - "log" - "mime" - "net/http" - "strings" -) - -// ResourceHandler implements the http.Handler interface and acts a router for the defined Routes. -// The defaults are intended to be developemnt friendly, for production you may want -// to turn on gzip and disable the JSON indentation for instance. -type ResourceHandler struct { - internalRouter *router - statusMiddleware *statusMiddleware - handlerFunc http.HandlerFunc - - // If true, and if the client accepts the Gzip encoding, the response payloads - // will be compressed using gzip, and the corresponding response header will set. - EnableGzip bool - - // If true, the JSON payload will be written in one line with no space. - DisableJsonIndent bool - - // If true, the status service will be enabled. Various stats and status will - // then be available at GET /.status in a JSON format. - EnableStatusService bool - - // If true, when a "panic" happens, the error string and the stack trace will be - // printed in the 500 response body. - EnableResponseStackTrace bool - - // If true, the records logged to the access log and the error log will be - // printed as JSON. Convenient for log parsing. - EnableLogAsJson bool - - // If true, the handler does NOT check the request Content-Type. Otherwise, it - // must be set to 'application/json' if the content is non-null. - // Note: If a charset parameter exists, it MUST be UTF-8 - EnableRelaxedContentType bool - - // Optional global middlewares that can be used to wrap the all REST endpoints. - // They are used in the defined order, the first wrapping the second, ... - // They are run first, wrapping all go-json-rest middlewares, - // * request.PathParams is not set yet - // * "panic" won't be caught and converted to 500 - // * request.Env["STATUS_CODE"] and request.Env["ELAPSED_TIME"] are set. - // They can be used for extra logging, or reporting. - // (see statsd example in in https://github.com/ant0ine/go-json-rest-examples) - OuterMiddlewares []Middleware - - // Optional global middlewares that can be used to wrap the all REST endpoints. - // They are used in the defined order, the first wrapping the second, ... - // They are run pre REST routing, request.PathParams is not set yet. - // They are run post auto error handling, "panic" will be converted to 500 errors. - // They can be used for instance to manage CORS or authentication. - // (see the CORS and Auth examples in https://github.com/ant0ine/go-json-rest-examples) - PreRoutingMiddlewares []Middleware - - // Custom logger for the access log, - // optional, defaults to log.New(os.Stderr, "", 0) - Logger *log.Logger - - // If true, the access log will be fully disabled. - // (the log middleware is not even instantiated, avoiding any performance penalty) - DisableLogger bool - - // Custom logger used for logging the panic errors, - // optional, defaults to log.New(os.Stderr, "", 0) - ErrorLogger *log.Logger - - // Custom X-Powered-By value, defaults to "go-json-rest". - XPoweredBy string - - // If true, the X-Powered-By header will NOT be set. - DisableXPoweredBy bool -} - -// SetRoutes defines the Routes. The order the Routes matters, -// if a request matches multiple Routes, the first one will be used. -func (rh *ResourceHandler) SetRoutes(routes ...*Route) error { - - // start the router - rh.internalRouter = &router{ - routes: routes, - } - err := rh.internalRouter.start() - if err != nil { - return err - } - - if rh.DisableXPoweredBy { - rh.XPoweredBy = "" - } else { - if len(rh.XPoweredBy) == 0 { - rh.XPoweredBy = xPoweredByDefault - } - } - - rh.instantiateMiddlewares() - - return nil -} - -// Instantiate all the middlewares. -func (rh *ResourceHandler) instantiateMiddlewares() { - - middlewares := []Middleware{} - - middlewares = append(middlewares, - rh.OuterMiddlewares..., - ) - - // log as the first, depends on timer and recorder. - if !rh.DisableLogger { - middlewares = append(middlewares, - &logMiddleware{ - rh.Logger, - rh.EnableLogAsJson, - }, - ) - } - - if rh.EnableGzip { - middlewares = append(middlewares, &gzipMiddleware{}) - } - - if rh.EnableStatusService { - // keep track of this middleware for GetStatus() - rh.statusMiddleware = newStatusMiddleware() - middlewares = append(middlewares, rh.statusMiddleware) - } - - middlewares = append(middlewares, - &timerMiddleware{}, - &recorderMiddleware{}, - &errorMiddleware{ - rh.ErrorLogger, - rh.EnableLogAsJson, - rh.EnableResponseStackTrace, - }, - ) - - middlewares = append(middlewares, - rh.PreRoutingMiddlewares..., - ) - - rh.handlerFunc = rh.adapter( - WrapMiddlewares(middlewares, rh.app()), - ) -} - -// Handle the transition between http and rest objects. -func (rh *ResourceHandler) adapter(handler HandlerFunc) http.HandlerFunc { - return func(origWriter http.ResponseWriter, origRequest *http.Request) { - - // instantiate the rest objects - request := Request{ - origRequest, - nil, - map[string]interface{}{}, - } - - isIndented := !rh.DisableJsonIndent - - writer := responseWriter{ - origWriter, - false, - isIndented, - rh.XPoweredBy, - } - - // call the wrapped handler - handler(&writer, &request) - } -} - -// Handle the REST routing and run the user code. -func (rh *ResourceHandler) app() HandlerFunc { - return func(writer ResponseWriter, request *Request) { - - // check the Content-Type - mediatype, params, _ := mime.ParseMediaType(request.Header.Get("Content-Type")) - charset, ok := params["charset"] - if !ok { - charset = "UTF-8" - } - - if rh.EnableRelaxedContentType == false && - request.ContentLength > 0 && // per net/http doc, means that the length is known and non-null - !(mediatype == "application/json" && strings.ToUpper(charset) == "UTF-8") { - - Error(writer, - "Bad Content-Type or charset, expected 'application/json'", - http.StatusUnsupportedMediaType, - ) - return - } - - // find the route - route, params, pathMatched := rh.internalRouter.findRouteFromURL(request.Method, request.URL) - if route == nil { - - if pathMatched { - // no route found, but path was matched: 405 Method Not Allowed - Error(writer, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // no route found, the path was not matched: 404 Not Found - NotFound(writer, request) - return - } - - // a route was found, set the PathParams - request.PathParams = params - - // run the user code - handler := route.Func - handler(writer, request) - } -} - -// This makes ResourceHandler implement the http.Handler interface. -// You probably don't want to use it directly. -func (rh *ResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - rh.handlerFunc(w, r) -} - -// GetStatus returns a Status object. EnableStatusService must be true. -func (rh *ResourceHandler) GetStatus() *Status { - return rh.statusMiddleware.getStatus() -} diff --git a/rest/handler_test.go b/rest/handler_test.go deleted file mode 100644 index 295672a..0000000 --- a/rest/handler_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package rest - -import ( - "github.com/ant0ine/go-json-rest/rest/test" - "io/ioutil" - "log" - "testing" -) - -func TestHandler(t *testing.T) { - - handler := ResourceHandler{ - DisableJsonIndent: true, - // make the test output less verbose by discarding the error log - ErrorLogger: log.New(ioutil.Discard, "", 0), - } - handler.SetRoutes( - &Route{"GET", "/r/:id", - func(w ResponseWriter, r *Request) { - id := r.PathParam("id") - w.WriteJson(map[string]string{"Id": id}) - }, - }, - &Route{"POST", "/r/:id", - func(w ResponseWriter, r *Request) { - // JSON echo - data := map[string]string{} - err := r.DecodeJsonPayload(&data) - if err != nil { - t.Fatal(err) - } - w.WriteJson(data) - }, - }, - &Route{"GET", "/auto-fails", - func(w ResponseWriter, r *Request) { - a := []int{} - _ = a[0] - }, - }, - &Route{"GET", "/user-error", - func(w ResponseWriter, r *Request) { - Error(w, "My error", 500) - }, - }, - &Route{"GET", "/user-notfound", - func(w ResponseWriter, r *Request) { - NotFound(w, r) - }, - }, - ) - - // valid get resource - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/r/123", nil)) - recorded.CodeIs(200) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Id":"123"}`) - - // valid post resource - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest( - "POST", "http://1.2.3.4/r/123", &map[string]string{"Test": "Test"})) - recorded.CodeIs(200) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Test":"Test"}`) - - // broken Content-Type post resource - request := test.MakeSimpleRequest("POST", "http://1.2.3.4/r/123", &map[string]string{"Test": "Test"}) - request.Header.Set("Content-Type", "text/html") - recorded = test.RunRequest(t, &handler, request) - recorded.CodeIs(415) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Bad Content-Type or charset, expected 'application/json'"}`) - - // broken Content-Type post resource - request = test.MakeSimpleRequest("POST", "http://1.2.3.4/r/123", &map[string]string{"Test": "Test"}) - request.Header.Set("Content-Type", "application/json; charset=ISO-8859-1") - recorded = test.RunRequest(t, &handler, request) - recorded.CodeIs(415) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Bad Content-Type or charset, expected 'application/json'"}`) - - // Content-Type post resource with charset - request = test.MakeSimpleRequest("POST", "http://1.2.3.4/r/123", &map[string]string{"Test": "Test"}) - request.Header.Set("Content-Type", "application/json;charset=UTF-8") - recorded = test.RunRequest(t, &handler, request) - recorded.CodeIs(200) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Test":"Test"}`) - - // auto 405 on undefined route (wrong method) - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("DELETE", "http://1.2.3.4/r/123", nil)) - recorded.CodeIs(405) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Method not allowed"}`) - - // auto 404 on undefined route (wrong path) - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/s/123", nil)) - recorded.CodeIs(404) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Resource not found"}`) - - // auto 500 on unhandled userecorder error - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/auto-fails", nil)) - recorded.CodeIs(500) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Internal Server Error"}`) - - // userecorder error - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/user-error", nil)) - recorded.CodeIs(500) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"My error"}`) - - // userecorder notfound - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/user-notfound", nil)) - recorded.CodeIs(404) - recorded.ContentTypeIsJson() - recorded.BodyIs(`{"Error":"Resource not found"}`) -} diff --git a/rest/if.go b/rest/if.go new file mode 100644 index 0000000..daa37d1 --- /dev/null +++ b/rest/if.go @@ -0,0 +1,53 @@ +package rest + +import ( + "log" +) + +// IfMiddleware evaluates at runtime a condition based on the current request, and decides to +// execute one of the other Middleware based on this boolean. +type IfMiddleware struct { + + // Runtime condition that decides of the execution of IfTrue of IfFalse. + Condition func(r *Request) bool + + // Middleware to run when the condition is true. Note that the middleware is initialized + // weather if will be used or not. (Optional, pass-through if not set) + IfTrue Middleware + + // Middleware to run when the condition is false. Note that the middleware is initialized + // weather if will be used or not. (Optional, pass-through if not set) + IfFalse Middleware +} + +// MiddlewareFunc makes TimerMiddleware implement the Middleware interface. +func (mw *IfMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { + + if mw.Condition == nil { + log.Fatal("IfMiddleware Condition is required") + } + + var ifTrueHandler HandlerFunc + if mw.IfTrue != nil { + ifTrueHandler = mw.IfTrue.MiddlewareFunc(h) + } else { + ifTrueHandler = h + } + + var ifFalseHandler HandlerFunc + if mw.IfFalse != nil { + ifFalseHandler = mw.IfFalse.MiddlewareFunc(h) + } else { + ifFalseHandler = h + } + + return func(w ResponseWriter, r *Request) { + + if mw.Condition(r) { + ifTrueHandler(w, r) + } else { + ifFalseHandler(w, r) + } + + } +} diff --git a/rest/if_test.go b/rest/if_test.go new file mode 100644 index 0000000..fca57f4 --- /dev/null +++ b/rest/if_test.go @@ -0,0 +1,51 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" +) + +func TestIfMiddleware(t *testing.T) { + + api := NewApi() + + // the middleware to test + api.Use(&IfMiddleware{ + Condition: func(r *Request) bool { + if r.URL.Path == "/true" { + return true + } + return false + }, + IfTrue: MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + r.Env["TRUE_MIDDLEWARE"] = true + handler(w, r) + } + }), + IfFalse: MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + r.Env["FALSE_MIDDLEWARE"] = true + handler(w, r) + } + }), + }) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(r.Env) + })) + + // wrap all + handler := api.MakeHandler() + + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\"FALSE_MIDDLEWARE\":true}") + + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/true", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\"TRUE_MIDDLEWARE\":true}") +} diff --git a/rest/json_indent.go b/rest/json_indent.go new file mode 100644 index 0000000..ad9a5ca --- /dev/null +++ b/rest/json_indent.go @@ -0,0 +1,113 @@ +package rest + +import ( + "bufio" + "encoding/json" + "net" + "net/http" +) + +// JsonIndentMiddleware provides JSON encoding with indentation. +// It could be convenient to use it during development. +// It works by "subclassing" the responseWriter provided by the wrapping middleware, +// replacing the writer.EncodeJson and writer.WriteJson implementations, +// and making the parent implementations ignored. +type JsonIndentMiddleware struct { + + // prefix string, as in json.MarshalIndent + Prefix string + + // indentation string, as in json.MarshalIndent + Indent string +} + +// MiddlewareFunc makes JsonIndentMiddleware implement the Middleware interface. +func (mw *JsonIndentMiddleware) MiddlewareFunc(handler HandlerFunc) HandlerFunc { + + if mw.Indent == "" { + mw.Indent = " " + } + + return func(w ResponseWriter, r *Request) { + + writer := &jsonIndentResponseWriter{w, false, mw.Prefix, mw.Indent} + // call the wrapped handler + handler(writer, r) + } +} + +// Private responseWriter intantiated by the middleware. +// It implements the following interfaces: +// ResponseWriter +// http.ResponseWriter +// http.Flusher +// http.CloseNotifier +// http.Hijacker +type jsonIndentResponseWriter struct { + ResponseWriter + wroteHeader bool + prefix string + indent string +} + +// Replace the parent EncodeJson to provide indentation. +func (w *jsonIndentResponseWriter) EncodeJson(v interface{}) ([]byte, error) { + b, err := json.MarshalIndent(v, w.prefix, w.indent) + if err != nil { + return nil, err + } + return b, nil +} + +// Make sure the local EncodeJson and local Write are called. +// Does not call the parent WriteJson. +func (w *jsonIndentResponseWriter) WriteJson(v interface{}) error { + b, err := w.EncodeJson(v) + if err != nil { + return err + } + _, err = w.Write(b) + if err != nil { + return err + } + return nil +} + +// Call the parent WriteHeader. +func (w *jsonIndentResponseWriter) WriteHeader(code int) { + w.ResponseWriter.WriteHeader(code) + w.wroteHeader = true +} + +// Make sure the local WriteHeader is called, and call the parent Flush. +// Provided in order to implement the http.Flusher interface. +func (w *jsonIndentResponseWriter) Flush() { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + flusher := w.ResponseWriter.(http.Flusher) + flusher.Flush() +} + +// Call the parent CloseNotify. +// Provided in order to implement the http.CloseNotifier interface. +func (w *jsonIndentResponseWriter) CloseNotify() <-chan bool { + notifier := w.ResponseWriter.(http.CloseNotifier) + return notifier.CloseNotify() +} + +// Provided in order to implement the http.Hijacker interface. +func (w *jsonIndentResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker := w.ResponseWriter.(http.Hijacker) + return hijacker.Hijack() +} + +// Make sure the local WriteHeader is called, and call the parent Write. +// Provided in order to implement the http.ResponseWriter interface. +func (w *jsonIndentResponseWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + writer := w.ResponseWriter.(http.ResponseWriter) + return writer.Write(b) +} diff --git a/rest/json_indent_test.go b/rest/json_indent_test.go new file mode 100644 index 0000000..58924e0 --- /dev/null +++ b/rest/json_indent_test.go @@ -0,0 +1,28 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" +) + +func TestJsonIndentMiddleware(t *testing.T) { + + api := NewApi() + + // the middleware to test + api.Use(&JsonIndentMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\n \"Id\": \"123\"\n}") +} diff --git a/rest/jsonp.go b/rest/jsonp.go index 8e23456..6071b50 100644 --- a/rest/jsonp.go +++ b/rest/jsonp.go @@ -70,8 +70,10 @@ func (w *jsonpResponseWriter) WriteJson(v interface{}) error { if err != nil { return err } - // TODO add "/**/" ? - w.Write([]byte(w.callbackName + "(")) + // JSONP security fix (http://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/) + w.Header().Set("Content-Disposition", "filename=f.txt") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Write([]byte("/**/" + w.callbackName + "(")) w.Write(b) w.Write([]byte(")")) return nil diff --git a/rest/jsonp_test.go b/rest/jsonp_test.go index 8737659..e556d8f 100644 --- a/rest/jsonp_test.go +++ b/rest/jsonp_test.go @@ -1,38 +1,47 @@ package rest import ( - "github.com/ant0ine/go-json-rest/rest/test" "testing" + + "github.com/ant0ine/go-json-rest/rest/test" ) -func TestJSONP(t *testing.T) { +func TestJsonpMiddleware(t *testing.T) { - handler := ResourceHandler{ - DisableJsonIndent: true, - PreRoutingMiddlewares: []Middleware{ - &JsonpMiddleware{}, - }, - } - handler.SetRoutes( - &Route{"GET", "/ok", - func(w ResponseWriter, r *Request) { - w.WriteJson(map[string]string{"Id": "123"}) - }, - }, - &Route{"GET", "/error", - func(w ResponseWriter, r *Request) { - Error(w, "gzipped error", 500) - }, - }, + api := NewApi() + + // the middleware to test + api.Use(&JsonpMiddleware{}) + + // router app with success and error paths + router, err := MakeRouter( + Get("/ok", func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + }), + Get("/error", func(w ResponseWriter, r *Request) { + Error(w, "jsonp error", 500) + }), ) + if err != nil { + t.Fatal(err) + } + + api.SetApp(router) + + // wrap all + handler := api.MakeHandler() - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/ok?callback=parseResponse", nil)) + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/ok?callback=parseResponse", nil)) recorded.CodeIs(200) recorded.HeaderIs("Content-Type", "text/javascript") - recorded.BodyIs("parseResponse({\"Id\":\"123\"})") + recorded.HeaderIs("Content-Disposition", "filename=f.txt") + recorded.HeaderIs("X-Content-Type-Options", "nosniff") + recorded.BodyIs("/**/parseResponse({\"Id\":\"123\"})") - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/error?callback=parseResponse", nil)) + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/error?callback=parseResponse", nil)) recorded.CodeIs(500) recorded.HeaderIs("Content-Type", "text/javascript") - recorded.BodyIs("parseResponse({\"Error\":\"gzipped error\"})") + recorded.HeaderIs("Content-Disposition", "filename=f.txt") + recorded.HeaderIs("X-Content-Type-Options", "nosniff") + recorded.BodyIs("/**/parseResponse({\"Error\":\"jsonp error\"})") } diff --git a/rest/log.go b/rest/log.go deleted file mode 100644 index d7c84c8..0000000 --- a/rest/log.go +++ /dev/null @@ -1,88 +0,0 @@ -package rest - -import ( - "encoding/json" - "log" - "os" - "time" -) - -// logMiddleware manages the Logger. -// It depends on request.Env["STATUS_CODE"] and request.Env["ELAPSED_TIME"]. -type logMiddleware struct { - Logger *log.Logger - EnableLogAsJson bool -} - -func (mw *logMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { - - // set the default Logger - if mw.Logger == nil { - mw.Logger = log.New(os.Stderr, "", 0) - } - - return func(w ResponseWriter, r *Request) { - - // call the handler - h(w, r) - - timestamp := time.Now() - - remoteUser := "" - if r.Env["REMOTE_USER"] != nil { - remoteUser = r.Env["REMOTE_USER"].(string) - } - - mw.logResponseRecord(&responseLogRecord{ - ×tamp, - r.Env["STATUS_CODE"].(int), - r.Env["ELAPSED_TIME"].(*time.Duration), - r.Method, - r.URL.RequestURI(), - remoteUser, - r.UserAgent(), - }) - } -} - -type responseLogRecord struct { - Timestamp *time.Time - StatusCode int - ResponseTime *time.Duration - HttpMethod string - RequestURI string - RemoteUser string - UserAgent string -} - -const dateLayout = "2006/01/02 15:04:05" - -func (mw *logMiddleware) logResponseRecord(record *responseLogRecord) { - if mw.EnableLogAsJson { - // The preferred format for machine readable logs. - b, err := json.Marshal(record) - if err != nil { - panic(err) - } - mw.Logger.Printf("%s", b) - } else { - // This format is designed to be easy to read, not easy to parse. - - statusCodeColor := "0;32" - if record.StatusCode >= 400 && record.StatusCode < 500 { - statusCodeColor = "1;33" - } else if record.StatusCode >= 500 { - statusCodeColor = "0;31" - } - mw.Logger.Printf("%s \033[%sm%d\033[0m \033[36;1m%.2fms\033[0m %s %s \033[1;30m%s \"%s\"\033[0m", - record.Timestamp.Format(dateLayout), - statusCodeColor, - record.StatusCode, - float64(record.ResponseTime.Nanoseconds()/1e4)/100.0, - record.HttpMethod, - record.RequestURI, - record.RemoteUser, - record.UserAgent, - ) - } -} diff --git a/rest/middleware.go b/rest/middleware.go index 32fd224..ba03fb8 100644 --- a/rest/middleware.go +++ b/rest/middleware.go @@ -1,14 +1,42 @@ package rest +import ( + "net/http" +) + // HandlerFunc defines the handler function. It is the go-json-rest equivalent of http.HandlerFunc. type HandlerFunc func(ResponseWriter, *Request) +// App defines the interface that an object should implement to be used as an app in this framework +// stack. The App is the top element of the stack, the other elements being middlewares. +type App interface { + AppFunc() HandlerFunc +} + +// AppSimple is an adapter type that makes it easy to write an App with a simple function. +// eg: rest.NewApi(rest.AppSimple(func(w rest.ResponseWriter, r *rest.Request) { ... })) +type AppSimple HandlerFunc + +// AppFunc makes AppSimple implement the App interface. +func (as AppSimple) AppFunc() HandlerFunc { + return HandlerFunc(as) +} + // Middleware defines the interface that objects must implement in order to wrap a HandlerFunc and // be used in the middleware stack. type Middleware interface { MiddlewareFunc(handler HandlerFunc) HandlerFunc } +// MiddlewareSimple is an adapter type that makes it easy to write a Middleware with a simple +// function. eg: api.Use(rest.MiddlewareSimple(func(h HandlerFunc) Handlerfunc { ... })) +type MiddlewareSimple func(handler HandlerFunc) HandlerFunc + +// MiddlewareFunc makes MiddlewareSimple implement the Middleware interface. +func (ms MiddlewareSimple) MiddlewareFunc(handler HandlerFunc) HandlerFunc { + return ms(handler) +} + // WrapMiddlewares calls the MiddlewareFunc methods in the reverse order and returns an HandlerFunc // ready to be executed. This can be used to wrap a set of middlewares, post routing, on a per Route // basis. @@ -19,3 +47,26 @@ func WrapMiddlewares(middlewares []Middleware, handler HandlerFunc) HandlerFunc } return wrapped } + +// Handle the transition between net/http and go-json-rest objects. +// It intanciates the rest.Request and rest.ResponseWriter, ... +func adapterFunc(handler HandlerFunc) http.HandlerFunc { + + return func(origWriter http.ResponseWriter, origRequest *http.Request) { + + // instantiate the rest objects + request := &Request{ + origRequest, + nil, + map[string]interface{}{}, + } + + writer := &responseWriter{ + origWriter, + false, + } + + // call the wrapped handler + handler(writer, request) + } +} diff --git a/rest/powered_by.go b/rest/powered_by.go new file mode 100644 index 0000000..3b22ccf --- /dev/null +++ b/rest/powered_by.go @@ -0,0 +1,29 @@ +package rest + +const xPoweredByDefault = "go-json-rest" + +// PoweredByMiddleware adds the "X-Powered-By" header to the HTTP response. +type PoweredByMiddleware struct { + + // If specified, used as the value for the "X-Powered-By" response header. + // Defaults to "go-json-rest". + XPoweredBy string +} + +// MiddlewareFunc makes PoweredByMiddleware implement the Middleware interface. +func (mw *PoweredByMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { + + poweredBy := xPoweredByDefault + if mw.XPoweredBy != "" { + poweredBy = mw.XPoweredBy + } + + return func(w ResponseWriter, r *Request) { + + w.Header().Add("X-Powered-By", poweredBy) + + // call the handler + h(w, r) + + } +} diff --git a/rest/powered_by_test.go b/rest/powered_by_test.go new file mode 100644 index 0000000..9d1ca34 --- /dev/null +++ b/rest/powered_by_test.go @@ -0,0 +1,30 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" +) + +func TestPoweredByMiddleware(t *testing.T) { + + api := NewApi() + + // the middleware to test + api.Use(&PoweredByMiddleware{ + XPoweredBy: "test", + }) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.HeaderIs("X-Powered-By", "test") +} diff --git a/rest/recorder.go b/rest/recorder.go index bca107d..20502e9 100644 --- a/rest/recorder.go +++ b/rest/recorder.go @@ -6,19 +6,23 @@ import ( "net/http" ) -// recorderMiddleware keeps a record of the HTTP status code of the response. -// The result is available to the wrapping handlers in request.Env["STATUS_CODE"] as an int. -type recorderMiddleware struct{} +// RecorderMiddleware keeps a record of the HTTP status code of the response, +// and the number of bytes written. +// The result is available to the wrapping handlers as request.Env["STATUS_CODE"].(int), +// and as request.Env["BYTES_WRITTEN"].(int64) +type RecorderMiddleware struct{} -func (mw *recorderMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { +// MiddlewareFunc makes RecorderMiddleware implement the Middleware interface. +func (mw *RecorderMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { return func(w ResponseWriter, r *Request) { - writer := &recorderResponseWriter{w, 0, false} + writer := &recorderResponseWriter{w, 0, false, 0} // call the handler h(writer, r) r.Env["STATUS_CODE"] = writer.statusCode + r.Env["BYTES_WRITTEN"] = writer.bytesWritten } } @@ -32,23 +36,32 @@ func (mw *recorderMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { // http.Hijacker type recorderResponseWriter struct { ResponseWriter - statusCode int - wroteHeader bool + statusCode int + wroteHeader bool + bytesWritten int64 } // Record the status code. func (w *recorderResponseWriter) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) + if w.wroteHeader { + return + } w.statusCode = code w.wroteHeader = true } -// Make sure the local WriteHeader is called, and call the parent WriteJson. +// Make sure the local Write is called. func (w *recorderResponseWriter) WriteJson(v interface{}) error { - if !w.wroteHeader { - w.WriteHeader(http.StatusOK) + b, err := w.EncodeJson(v) + if err != nil { + return err + } + _, err = w.Write(b) + if err != nil { + return err } - return w.ResponseWriter.WriteJson(v) + return nil } // Make sure the local WriteHeader is called, and call the parent Flush. @@ -81,5 +94,7 @@ func (w *recorderResponseWriter) Write(b []byte) (int, error) { w.WriteHeader(http.StatusOK) } writer := w.ResponseWriter.(http.ResponseWriter) - return writer.Write(b) + written, err := writer.Write(b) + w.bytesWritten += int64(written) + return written, err } diff --git a/rest/recorder_test.go b/rest/recorder_test.go new file mode 100644 index 0000000..c3dabd2 --- /dev/null +++ b/rest/recorder_test.go @@ -0,0 +1,134 @@ +package rest + +import ( + "testing" + + "github.com/ant0ine/go-json-rest/rest/test" +) + +func TestRecorderMiddleware(t *testing.T) { + + api := NewApi() + + // a middleware carrying the Env tests + api.Use(MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + + handler(w, r) + + if r.Env["STATUS_CODE"] == nil { + t.Error("STATUS_CODE is nil") + } + statusCode := r.Env["STATUS_CODE"].(int) + if statusCode != 200 { + t.Errorf("STATUS_CODE = 200 expected, got %d", statusCode) + } + + if r.Env["BYTES_WRITTEN"] == nil { + t.Error("BYTES_WRITTEN is nil") + } + bytesWritten := r.Env["BYTES_WRITTEN"].(int64) + // '{"Id":"123"}' => 12 chars + if bytesWritten != 12 { + t.Errorf("BYTES_WRITTEN 12 expected, got %d", bytesWritten) + } + } + })) + + // the middleware to test + api.Use(&RecorderMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() +} + +// See how many bytes are written when gzipping +func TestRecorderAndGzipMiddleware(t *testing.T) { + + api := NewApi() + + // a middleware carrying the Env tests + api.Use(MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + + handler(w, r) + + if r.Env["BYTES_WRITTEN"] == nil { + t.Error("BYTES_WRITTEN is nil") + } + bytesWritten := r.Env["BYTES_WRITTEN"].(int64) + // Yes, the gzipped version actually takes more space. + if bytesWritten != 41 { + t.Errorf("BYTES_WRITTEN 41 expected, got %d", bytesWritten) + } + } + })) + + // the middlewares to test + api.Use(&RecorderMiddleware{}) + api.Use(&GzipMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + // "Accept-Encoding", "gzip" is set by test.MakeSimpleRequest + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() +} + +//Underlying net/http only allows you to set the status code once +func TestRecorderMiddlewareReportsSameStatusCodeAsResponse(t *testing.T) { + api := NewApi() + const firstCode = 400 + const secondCode = 500 + + // a middleware carrying the Env tests + api.Use(MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + + handler(w, r) + + if r.Env["STATUS_CODE"] == nil { + t.Error("STATUS_CODE is nil") + } + statusCode := r.Env["STATUS_CODE"].(int) + if statusCode != firstCode { + t.Errorf("STATUS_CODE = %d expected, got %d", firstCode, statusCode) + } + } + })) + + // the middleware to test + api.Use(&RecorderMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteHeader(firstCode) + w.WriteHeader(secondCode) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(firstCode) + recorded.ContentTypeIsJson() +} diff --git a/rest/error.go b/rest/recover.go similarity index 58% rename from rest/error.go rename to rest/recover.go index 90c5146..99f1515 100644 --- a/rest/error.go +++ b/rest/recover.go @@ -9,14 +9,24 @@ import ( "runtime/debug" ) -// errorMiddleware catches the user panic errors and convert them to 500 -type errorMiddleware struct { - Logger *log.Logger - EnableLogAsJson bool +// RecoverMiddleware catches the panic errors that occur in the wrapped HandleFunc, +// and convert them to 500 responses. +type RecoverMiddleware struct { + + // Custom logger used for logging the panic errors, + // optional, defaults to log.New(os.Stderr, "", 0) + Logger *log.Logger + + // If true, the log records will be printed as JSON. Convenient for log parsing. + EnableLogAsJson bool + + // If true, when a "panic" happens, the error string and the stack trace will be + // printed in the 500 response body. EnableResponseStackTrace bool } -func (mw *errorMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { +// MiddlewareFunc makes RecoverMiddleware implement the Middleware interface. +func (mw *RecoverMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { // set the default Logger if mw.Logger == nil { @@ -48,7 +58,7 @@ func (mw *errorMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { } } -func (mw *errorMiddleware) logError(message string) { +func (mw *RecoverMiddleware) logError(message string) { if mw.EnableLogAsJson { record := map[string]string{ "error": message, diff --git a/rest/recover_test.go b/rest/recover_test.go new file mode 100644 index 0000000..953ae5a --- /dev/null +++ b/rest/recover_test.go @@ -0,0 +1,43 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "io/ioutil" + "log" + "testing" +) + +func TestRecoverMiddleware(t *testing.T) { + + api := NewApi() + + // the middleware to test + api.Use(&RecoverMiddleware{ + Logger: log.New(ioutil.Discard, "", 0), + EnableLogAsJson: false, + EnableResponseStackTrace: true, + }) + + // a simple app that fails + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + panic("test") + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(500) + recorded.ContentTypeIsJson() + + // payload + payload := map[string]string{} + err := recorded.DecodeJsonPayload(&payload) + if err != nil { + t.Fatal(err) + } + if payload["Error"] == "" { + t.Errorf("Expected an error message, got: %v", payload) + } +} diff --git a/rest/request.go b/rest/request.go index c05468b..c4eb381 100644 --- a/rest/request.go +++ b/rest/request.go @@ -2,12 +2,18 @@ package rest import ( "encoding/json" + "errors" "io/ioutil" "net/http" "net/url" "strings" ) +var ( + // ErrJsonPayloadEmpty is returned when the JSON payload is empty. + ErrJsonPayloadEmpty = errors.New("JSON payload is empty") +) + // Request inherits from http.Request, and provides additional methods. type Request struct { *http.Request @@ -15,7 +21,7 @@ type Request struct { // Map of parameters that have been matched in the URL Path. PathParams map[string]string - // Environement used by middlewares to communicate. + // Environment used by middlewares to communicate. Env map[string]interface{} } @@ -31,6 +37,9 @@ func (r *Request) DecodeJsonPayload(v interface{}) error { if err != nil { return err } + if len(content) == 0 { + return ErrJsonPayloadEmpty + } err = json.Unmarshal(content, v) if err != nil { return err @@ -46,6 +55,12 @@ func (r *Request) BaseUrl() *url.URL { scheme = "http" } + // HTTP sometimes gives the default scheme as HTTP even when used with TLS + // Check if TLS is not nil and given back https scheme + if scheme == "http" && r.TLS != nil { + scheme = "https" + } + host := r.Host if len(host) > 0 && host[len(host)-1] == '/' { host = host[:len(host)-1] @@ -111,6 +126,9 @@ func (r *Request) GetCorsInfo() *CorsInfo { reqHeaders := []string{} rawReqHeaders := r.Header[http.CanonicalHeaderKey("Access-Control-Request-Headers")] for _, rawReqHeader := range rawReqHeaders { + if len(rawReqHeader) == 0 { + continue + } // net/http does not handle comma delimited headers for us for _, reqHeader := range strings.Split(rawReqHeader, ",") { reqHeaders = append(reqHeaders, http.CanonicalHeaderKey(strings.TrimSpace(reqHeader))) diff --git a/rest/request_test.go b/rest/request_test.go index 6e2a098..1467c92 100644 --- a/rest/request_test.go +++ b/rest/request_test.go @@ -1,15 +1,17 @@ package rest import ( + "crypto/tls" "io" "net/http" + "strings" "testing" ) func defaultRequest(method string, urlStr string, body io.Reader, t *testing.T) *Request { origReq, err := http.NewRequest(method, urlStr, body) if err != nil { - t.Fatal() + t.Fatal(err) } return &Request{ origReq, @@ -18,6 +20,14 @@ func defaultRequest(method string, urlStr string, body io.Reader, t *testing.T) } } +func TestRequestEmptyJson(t *testing.T) { + req := defaultRequest("POST", "http://localhost/", strings.NewReader(""), t) + err := req.DecodeJsonPayload(nil) + if err != ErrJsonPayloadEmpty { + t.Error("Expected ErrJsonPayloadEmpty") + } +} + func TestRequestBaseUrl(t *testing.T) { req := defaultRequest("GET", "http://localhost/", nil, t) urlBase := req.BaseUrl() @@ -39,6 +49,30 @@ func TestRequestUrlScheme(t *testing.T) { } } +func TestRequestUrlSchemeHTTP(t *testing.T) { + req := defaultRequest("GET", "http://localhost/", nil, t) + urlBase := req.BaseUrl() + + expected := "http" + if urlBase.Scheme != expected { + t.Error(expected + " was the expected scheme, but instead got " + urlBase.Scheme) + } +} + +func TestRequestUrlSchemeHTTP2TLS(t *testing.T) { + req := defaultRequest("GET", "http://localhost/", nil, t) + req.Proto = "HTTP" + req.ProtoMajor = 2 + req.ProtoMinor = 0 + req.TLS = &tls.ConnectionState{} + urlBase := req.BaseUrl() + + expected := "https" + if urlBase.Scheme != expected { + t.Error(expected + " was the expected scheme, but instead got " + urlBase.Scheme) + } +} + func TestRequestUrlFor(t *testing.T) { req := defaultRequest("GET", "http://localhost/", nil, t) @@ -59,7 +93,7 @@ func TestRequestUrlForQueryString(t *testing.T) { req := defaultRequest("GET", "http://localhost/", nil, t) params := map[string][]string{ - "id": []string{"foo", "bar"}, + "id": {"foo", "bar"}, } urlObj := req.UrlFor("/foo/bar", params) @@ -139,3 +173,41 @@ func TestCorsInfoPreflightCors(t *testing.T) { t.Error("OriginUrl must be set") } } + +func TestCorsInfoEmptyAccessControlRequestHeaders(t *testing.T) { + req := defaultRequest("OPTIONS", "http://localhost/", nil, t) + req.Request.Header.Set("Origin", "http://another.host/") + + // make it a preflight request + req.Request.Header.Set("Access-Control-Request-Method", "PUT") + + // WebKit based browsers may send `Access-Control-Request-Headers:` with + // no value, in which case, the header will be present in requests + // Header map, but its value is an empty string. + req.Request.Header.Set("Access-Control-Request-Headers", "") + corsInfo := req.GetCorsInfo() + if corsInfo == nil { + t.Error("Expected non nil CorsInfo") + } + if corsInfo.IsCors == false { + t.Error("This is a CORS request") + } + if len(corsInfo.AccessControlRequestHeaders) > 0 { + t.Error("Access-Control-Request-Headers should have been removed") + } + + req.Request.Header.Set("Access-Control-Request-Headers", "") + corsInfo = req.GetCorsInfo() + if corsInfo == nil { + t.Error("Expected non nil CorsInfo") + } + if corsInfo.IsCors == false { + t.Error("This is a CORS request") + } + if corsInfo.IsPreflight == false { + t.Error("This is a Preflight request") + } + if len(corsInfo.AccessControlRequestHeaders) > 0 { + t.Error("Empty Access-Control-Request-Headers header should have been removed") + } +} diff --git a/rest/response.go b/rest/response.go index 8a2f8be..52529f1 100644 --- a/rest/response.go +++ b/rest/response.go @@ -7,34 +7,39 @@ import ( "net/http" ) -const xPoweredByDefault = "go-json-rest" - // A ResponseWriter interface dedicated to JSON HTTP response. -// Note that the object instantiated by the ResourceHandler that implements this interface, -// also happens to implement http.ResponseWriter, http.Flusher and http.CloseNotifier. +// Note, the responseWriter object instantiated by the framework also implements many other interfaces +// accessible by type assertion: http.ResponseWriter, http.Flusher, http.CloseNotifier, http.Hijacker. type ResponseWriter interface { // Identical to the http.ResponseWriter interface Header() http.Header - // Use EncodeJson to generate the payload, write the headers with http.StatusOK if they - // are not already written, then write the payload. + // Use EncodeJson to generate the payload, write the headers with http.StatusOK if + // they are not already written, then write the payload. // The Content-Type header is set to "application/json", unless already specified. WriteJson(v interface{}) error - // Encode the data structure to JSON, mainly used to wrap ResponseWriter in middlewares. + // Encode the data structure to JSON, mainly used to wrap ResponseWriter in + // middlewares. EncodeJson(v interface{}) ([]byte, error) - // Similar to the http.ResponseWriter interface, with additional JSON related headers set. + // Similar to the http.ResponseWriter interface, with additional JSON related + // headers set. WriteHeader(int) } +// This allows to customize the field name used in the error response payload. +// It defaults to "Error" for compatibility reason, but can be changed before starting the server. +// eg: rest.ErrorFieldName = "errorMessage" +var ErrorFieldName = "Error" + // Error produces an error response in JSON with the following structure, '{"Error":"My error message"}' // The standard plain text net/http Error helper can still be called like this: // http.Error(w, "error message", code) func Error(w ResponseWriter, error string, code int) { w.WriteHeader(code) - err := w.WriteJson(map[string]string{"Error": error}) + err := w.WriteJson(map[string]string{ErrorFieldName: error}) if err != nil { panic(err) } @@ -57,29 +62,22 @@ func NotFound(w ResponseWriter, r *Request) { type responseWriter struct { http.ResponseWriter wroteHeader bool - isIndented bool - xPoweredBy string } func (w *responseWriter) WriteHeader(code int) { if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - if len(w.xPoweredBy) > 0 { - w.Header().Add("X-Powered-By", w.xPoweredBy) + // Per spec, UTF-8 is the default, and the charset parameter should not + // be necessary. But some clients (eg: Chrome) think otherwise. + // Since json.Marshal produces UTF-8, setting the charset parameter is a + // safe option. + w.Header().Set("Content-Type", "application/json; charset=utf-8") } w.ResponseWriter.WriteHeader(code) w.wroteHeader = true } func (w *responseWriter) EncodeJson(v interface{}) ([]byte, error) { - var b []byte - var err error - if w.isIndented { - b, err = json.MarshalIndent(v, "", " ") - } else { - b, err = json.Marshal(v) - } + b, err := json.Marshal(v) if err != nil { return nil, err } diff --git a/rest/response_test.go b/rest/response_test.go index 6867d92..ba13f38 100644 --- a/rest/response_test.go +++ b/rest/response_test.go @@ -11,8 +11,6 @@ func TestResponseNotIndent(t *testing.T) { writer := responseWriter{ nil, false, - false, - xPoweredByDefault, } got, err := writer.EncodeJson(map[string]bool{"test": true}) @@ -26,29 +24,45 @@ func TestResponseNotIndent(t *testing.T) { } } -func TestResponseIndent(t *testing.T) { - testXPoweredBy(t, &ResourceHandler{}, xPoweredByDefault) -} +// The following tests could instantiate only the reponseWriter, +// but using the Api object allows to use the rest/test utilities, +// and make the tests easier to write. + +func TestWriteJsonResponse(t *testing.T) { -func TestXPoweredByCustom(t *testing.T) { - testXPoweredBy(t, &ResourceHandler{XPoweredBy: "foo"}, "foo") + api := NewApi() + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + recorded := test.RunRequest(t, api.MakeHandler(), test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\"Id\":\"123\"}") } -func TestXPoweredByDisabled(t *testing.T) { - testXPoweredBy(t, &ResourceHandler{DisableXPoweredBy: true}, "") +func TestErrorResponse(t *testing.T) { + + api := NewApi() + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + Error(w, "test", 500) + })) + + recorded := test.RunRequest(t, api.MakeHandler(), test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(500) + recorded.ContentTypeIsJson() + recorded.BodyIs("{\"Error\":\"test\"}") } -func testXPoweredBy(t *testing.T, rh *ResourceHandler, expected string) { - rh.SetRoutes( - &Route{"GET", "/r/:id", - func(w ResponseWriter, r *Request) { - id := r.PathParam("id") - w.WriteJson(map[string]string{"Id": id}) - }, - }, - ) - recorded := test.RunRequest(t, rh, test.MakeSimpleRequest("GET", "http://1.2.3.4/r/123", nil)) - recorded.CodeIs(200) +func TestNotFoundResponse(t *testing.T) { + + api := NewApi() + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + NotFound(w, r) + })) + + recorded := test.RunRequest(t, api.MakeHandler(), test.MakeSimpleRequest("GET", "http://localhost/", nil)) + recorded.CodeIs(404) recorded.ContentTypeIsJson() - recorded.HeaderIs("X-Powered-By", expected) + recorded.BodyIs("{\"Error\":\"Resource not found\"}") } diff --git a/rest/route.go b/rest/route.go index 72ae06f..efb94a7 100644 --- a/rest/route.go +++ b/rest/route.go @@ -1,12 +1,11 @@ package rest import ( - "fmt" - "reflect" "strings" ) -// Route defines a route. It's used with SetRoutes. +// Route defines a route as consumed by the router. It can be instantiated directly, or using one +// of the shortcut methods: rest.Get, rest.Post, rest.Put, rest.Patch and rest.Delete. type Route struct { // Any HTTP method. It will be used as uppercase to avoid common mistakes. @@ -24,38 +23,6 @@ type Route struct { Func HandlerFunc } -// RouteObjectMethod creates a Route that points to an object method. It can be convenient to point to -// an object method instead of a function, this helper makes it easy by passing the object instance and -// the method name as parameters. -// -// DEPRECATED: Since Go 1.1 and the introduction of the Method Values, this is now useless, and will probably -// be removed from the next major version of go-json-rest (v3) -// See: https://golang.org/doc/go1.1#method_values -func RouteObjectMethod(httpMethod string, pathExp string, objectInstance interface{}, objectMethod string) *Route { - - value := reflect.ValueOf(objectInstance) - funcValue := value.MethodByName(objectMethod) - if funcValue.IsValid() == false { - panic(fmt.Sprintf( - "Cannot find the object method %s on %s", - objectMethod, - value, - )) - } - routeFunc := func(w ResponseWriter, r *Request) { - funcValue.Call([]reflect.Value{ - reflect.ValueOf(w), - reflect.ValueOf(r), - }) - } - - return &Route{ - HttpMethod: httpMethod, - PathExp: pathExp, - Func: routeFunc, - } -} - // MakePath generates the path corresponding to this Route and the provided path parameters. // This is used for reverse route resolution. func (route *Route) MakePath(pathParams map[string]string) string { @@ -69,3 +36,72 @@ func (route *Route) MakePath(pathParams map[string]string) string { } return path } + +// Head is a shortcut method that instantiates a HEAD route. See the Route object the parameters definitions. +// Equivalent to &Route{"HEAD", pathExp, handlerFunc} +func Head(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "HEAD", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Get is a shortcut method that instantiates a GET route. See the Route object the parameters definitions. +// Equivalent to &Route{"GET", pathExp, handlerFunc} +func Get(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "GET", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Post is a shortcut method that instantiates a POST route. See the Route object the parameters definitions. +// Equivalent to &Route{"POST", pathExp, handlerFunc} +func Post(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "POST", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Put is a shortcut method that instantiates a PUT route. See the Route object the parameters definitions. +// Equivalent to &Route{"PUT", pathExp, handlerFunc} +func Put(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "PUT", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Patch is a shortcut method that instantiates a PATCH route. See the Route object the parameters definitions. +// Equivalent to &Route{"PATCH", pathExp, handlerFunc} +func Patch(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "PATCH", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Delete is a shortcut method that instantiates a DELETE route. Equivalent to &Route{"DELETE", pathExp, handlerFunc} +func Delete(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "DELETE", + PathExp: pathExp, + Func: handlerFunc, + } +} + +// Options is a shortcut method that instantiates an OPTIONS route. See the Route object the parameters definitions. +// Equivalent to &Route{"OPTIONS", pathExp, handlerFunc} +func Options(pathExp string, handlerFunc HandlerFunc) *Route { + return &Route{ + HttpMethod: "OPTIONS", + PathExp: pathExp, + Func: handlerFunc, + } +} diff --git a/rest/route_test.go b/rest/route_test.go index 434c4f0..5ea63b8 100644 --- a/rest/route_test.go +++ b/rest/route_test.go @@ -48,3 +48,41 @@ func TestReverseRouteResolution(t *testing.T) { t.Errorf("expected %s, got %s", expected, got) } } + +func TestShortcutMethods(t *testing.T) { + + r := Head("/", nil) + if r.HttpMethod != "HEAD" { + t.Errorf("expected HEAD, got %s", r.HttpMethod) + } + + r = Get("/", nil) + if r.HttpMethod != "GET" { + t.Errorf("expected GET, got %s", r.HttpMethod) + } + + r = Post("/", nil) + if r.HttpMethod != "POST" { + t.Errorf("expected POST, got %s", r.HttpMethod) + } + + r = Put("/", nil) + if r.HttpMethod != "PUT" { + t.Errorf("expected PUT, got %s", r.HttpMethod) + } + + r = Patch("/", nil) + if r.HttpMethod != "PATCH" { + t.Errorf("expected PATCH, got %s", r.HttpMethod) + } + + r = Delete("/", nil) + if r.HttpMethod != "DELETE" { + t.Errorf("expected DELETE, got %s", r.HttpMethod) + } + + r = Options("/", nil) + if r.HttpMethod != "OPTIONS" { + t.Errorf("expected OPTIONS, got %s", r.HttpMethod) + } +} diff --git a/rest/router.go b/rest/router.go index dedc54d..f7ab713 100644 --- a/rest/router.go +++ b/rest/router.go @@ -3,17 +3,60 @@ package rest import ( "errors" "github.com/ant0ine/go-json-rest/rest/trie" + "net/http" "net/url" "strings" ) type router struct { - routes []*Route + Routes []*Route + disableTrieCompression bool index map[*Route]int trie *trie.Trie } +// MakeRouter returns the router app. Given a set of Routes, it dispatches the request to the +// HandlerFunc of the first route that matches. The order of the Routes matters. +func MakeRouter(routes ...*Route) (App, error) { + r := &router{ + Routes: routes, + } + err := r.start() + if err != nil { + return nil, err + } + return r, nil +} + +// Handle the REST routing and run the user code. +func (rt *router) AppFunc() HandlerFunc { + return func(writer ResponseWriter, request *Request) { + + // find the route + route, params, pathMatched := rt.findRouteFromURL(request.Method, request.URL) + if route == nil { + + if pathMatched { + // no route found, but path was matched: 405 Method Not Allowed + Error(writer, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // no route found, the path was not matched: 404 Not Found + NotFound(writer, request) + return + } + + // a route was found, set the PathParams + request.PathParams = params + + // run the user code + handler := route.Func + handler(writer, request) + } +} + // This is run for each new request, perf is important. func escapedPath(urlObj *url.URL) string { // the escape method of url.URL should be public @@ -66,7 +109,7 @@ func (rt *router) start() error { rt.trie = trie.New() rt.index = map[*Route]int{} - for i, route := range rt.routes { + for i, route := range rt.Routes { // work with the PathExp urlencoded. pathExp, err := escapedPathExp(route.PathExp) diff --git a/rest/router_benchmark_test.go b/rest/router_benchmark_test.go index 00fefa1..59add29 100644 --- a/rest/router_benchmark_test.go +++ b/rest/router_benchmark_test.go @@ -58,7 +58,7 @@ func BenchmarkNoCompression(b *testing.B) { b.StopTimer() r := router{ - routes: routes(), + Routes: routes(), disableTrieCompression: true, } r.start() @@ -78,7 +78,7 @@ func BenchmarkCompression(b *testing.B) { b.StopTimer() r := router{ - routes: routes(), + Routes: routes(), } r.start() urlObjs := requestUrls() diff --git a/rest/router_test.go b/rest/router_test.go index 7831d9c..6dfc521 100644 --- a/rest/router_test.go +++ b/rest/router_test.go @@ -4,13 +4,15 @@ import ( "net/url" "strings" "testing" + + "github.com/ant0ine/go-json-rest/rest/test" ) func TestFindRouteAPI(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/", }, @@ -19,73 +21,73 @@ func TestFindRouteAPI(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } // full url string input := "http://example.org/" route, params, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route.PathExp != "/" { - t.Error() + t.Error("Expected PathExp to be /") } if len(params) != 0 { - t.Error() + t.Error("Expected 0 param") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } // part of the url string input = "/" route, params, pathMatched, err = r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route.PathExp != "/" { - t.Error() + t.Error("Expected PathExp to be /") } if len(params) != 0 { - t.Error() + t.Error("Expected 0 param") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } // url object urlObj, err := url.Parse("http://example.org/") if err != nil { - t.Fatal() + t.Fatal(err) } route, params, pathMatched = r.findRouteFromURL("GET", urlObj) if route.PathExp != "/" { - t.Error() + t.Error("Expected PathExp to be /") } if len(params) != 0 { - t.Error() + t.Error("Expected 0 param") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestNoRoute(t *testing.T) { r := router{ - routes: []*Route{}, + Routes: []*Route{}, } err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/notfound" route, params, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route != nil { @@ -95,15 +97,15 @@ func TestNoRoute(t *testing.T) { t.Error("params must be nil too") } if pathMatched != false { - t.Error() + t.Error("Expected pathMatched to be false") } } func TestEmptyPathExp(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "", }, @@ -119,8 +121,8 @@ func TestEmptyPathExp(t *testing.T) { func TestInvalidPathExp(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "invalid", }, @@ -136,8 +138,8 @@ func TestInvalidPathExp(t *testing.T) { func TestUrlEncodedFind(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/with space", // not urlencoded }, @@ -146,27 +148,27 @@ func TestUrlEncodedFind(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/with%20space" // urlencoded route, _, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route.PathExp != "/with space" { - t.Error() + t.Error("Expected PathExp to be /with space") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestWithQueryString(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/r/:id", }, @@ -175,13 +177,13 @@ func TestWithQueryString(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/r/123?arg=value" route, params, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route == nil { t.Fatal("Expected a match") @@ -190,15 +192,15 @@ func TestWithQueryString(t *testing.T) { t.Errorf("expected 123, got %s", params["id"]) } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestNonUrlEncodedFind(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/with%20space", // urlencoded }, @@ -207,31 +209,31 @@ func TestNonUrlEncodedFind(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/with%20space" // not urlencoded route, _, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route.PathExp != "/with%20space" { - t.Error() + t.Errorf("Expected PathExp to be %s", "/with20space") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestDuplicatedRoute(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/", }, - &Route{ + { HttpMethod: "GET", PathExp: "/", }, @@ -247,8 +249,8 @@ func TestDuplicatedRoute(t *testing.T) { func TestSplatUrlEncoded(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/r/*rest", }, @@ -257,34 +259,34 @@ func TestSplatUrlEncoded(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/r/123" route, params, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route == nil { t.Fatal("Expected a match") } if params["rest"] != "123" { - t.Error() + t.Error("Expected rest to be 123") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestRouteOrder(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/r/:id", }, - &Route{ + { HttpMethod: "GET", PathExp: "/r/*rest", }, @@ -308,22 +310,22 @@ func TestRouteOrder(t *testing.T) { t.Errorf("both match, expected the first defined, got %s", route.PathExp) } if params["id"] != "123" { - t.Error() + t.Error("Expected id to be 123") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestRelaxedPlaceholder(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/r/:id", }, - &Route{ + { HttpMethod: "GET", PathExp: "/r/#filename", }, @@ -332,7 +334,7 @@ func TestRelaxedPlaceholder(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/r/a.txt" @@ -347,22 +349,22 @@ func TestRelaxedPlaceholder(t *testing.T) { t.Errorf("expected the second route, got %s", route.PathExp) } if params["filename"] != "a.txt" { - t.Error() + t.Error("Expected filename to be a.txt") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } func TestSimpleExample(t *testing.T) { r := router{ - routes: []*Route{ - &Route{ + Routes: []*Route{ + { HttpMethod: "GET", PathExp: "/resources/:id", }, - &Route{ + { HttpMethod: "GET", PathExp: "/resources", }, @@ -371,22 +373,66 @@ func TestSimpleExample(t *testing.T) { err := r.start() if err != nil { - t.Fatal() + t.Fatal(err) } input := "http://example.org/resources/123" route, params, pathMatched, err := r.findRoute("GET", input) if err != nil { - t.Fatal() + t.Fatal(err) } if route.PathExp != "/resources/:id" { - t.Error() + t.Error("Expected PathExp to be /resources/:id") } if params["id"] != "123" { - t.Error() + t.Error("Expected id to be 123") } if pathMatched != true { - t.Error() + t.Error("Expected pathMatched to be true") } } + +func TestHttpResponseLayer(t *testing.T) { + + api := NewApi() + router, err := MakeRouter( + Get("/r/:id", func(w ResponseWriter, r *Request) { + id := r.PathParam("id") + w.WriteJson(map[string]string{"Id": id}) + }), + Post("/r/:id", func(w ResponseWriter, r *Request) { + // JSON echo + data := map[string]string{} + err := r.DecodeJsonPayload(&data) + if err != nil { + t.Fatal(err) + } + w.WriteJson(data) + }), + ) + if err != nil { + t.Fatal(err) + } + api.SetApp(router) + + handler := api.MakeHandler() + + // valid get resource + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/r/123", nil)) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() + recorded.BodyIs(`{"Id":"123"}`) + + // auto 405 on undefined route (wrong method) + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("DELETE", "http://1.2.3.4/r/123", nil)) + recorded.CodeIs(405) + recorded.ContentTypeIsJson() + recorded.BodyIs(`{"Error":"Method not allowed"}`) + + // auto 404 on undefined route (wrong path) + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/s/123", nil)) + recorded.CodeIs(404) + recorded.ContentTypeIsJson() + recorded.BodyIs(`{"Error":"Resource not found"}`) +} diff --git a/rest/status.go b/rest/status.go index 91e1170..6b6b5d1 100644 --- a/rest/status.go +++ b/rest/status.go @@ -2,14 +2,16 @@ package rest import ( "fmt" + "log" "os" "sync" "time" ) -// statusMiddleware keeps track of various stats about the processed requests. -// It depends on request.Env["STATUS_CODE"] and request.Env["ELAPSED_TIME"] -type statusMiddleware struct { +// StatusMiddleware keeps track of various stats about the processed requests. +// It depends on request.Env["STATUS_CODE"] and request.Env["ELAPSED_TIME"], +// recorderMiddleware and timerMiddleware must be in the wrapped middlewares. +type StatusMiddleware struct { lock sync.RWMutex start time.Time pid int @@ -17,32 +19,35 @@ type statusMiddleware struct { totalResponseTime time.Time } -func newStatusMiddleware() *statusMiddleware { - return &statusMiddleware{ - start: time.Now(), - pid: os.Getpid(), - responseCounts: map[string]int{}, - totalResponseTime: time.Time{}, - } -} +// MiddlewareFunc makes StatusMiddleware implement the Middleware interface. +func (mw *StatusMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { -func (mw *statusMiddleware) update(statusCode int, responseTime *time.Duration) { - mw.lock.Lock() - mw.responseCounts[fmt.Sprintf("%d", statusCode)]++ - mw.totalResponseTime = mw.totalResponseTime.Add(*responseTime) - mw.lock.Unlock() -} + mw.start = time.Now() + mw.pid = os.Getpid() + mw.responseCounts = map[string]int{} + mw.totalResponseTime = time.Time{} -func (mw *statusMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { return func(w ResponseWriter, r *Request) { // call the handler h(w, r) - mw.update( - r.Env["STATUS_CODE"].(int), - r.Env["ELAPSED_TIME"].(*time.Duration), - ) + if r.Env["STATUS_CODE"] == nil { + log.Fatal("StatusMiddleware: Env[\"STATUS_CODE\"] is nil, " + + "RecorderMiddleware may not be in the wrapped Middlewares.") + } + statusCode := r.Env["STATUS_CODE"].(int) + + if r.Env["ELAPSED_TIME"] == nil { + log.Fatal("StatusMiddleware: Env[\"ELAPSED_TIME\"] is nil, " + + "TimerMiddleware may not be in the wrapped Middlewares.") + } + responseTime := r.Env["ELAPSED_TIME"].(*time.Duration) + + mw.lock.Lock() + mw.responseCounts[fmt.Sprintf("%d", statusCode)]++ + mw.totalResponseTime = mw.totalResponseTime.Add(*responseTime) + mw.lock.Unlock() } } @@ -81,7 +86,9 @@ type Status struct { AverageResponseTimeSec float64 } -func (mw *statusMiddleware) getStatus() *Status { +// GetStatus computes and returns a Status object based on the request informations accumulated +// since the start of the process. +func (mw *StatusMiddleware) GetStatus() *Status { mw.lock.RLock() diff --git a/rest/status_test.go b/rest/status_test.go index 499400e..c2b93c4 100644 --- a/rest/status_test.go +++ b/rest/status_test.go @@ -5,36 +5,36 @@ import ( "testing" ) -func TestStatus(t *testing.T) { +func TestStatusMiddleware(t *testing.T) { - handler := ResourceHandler{ - EnableStatusService: true, - } - handler.SetRoutes( - &Route{"GET", "/r", - func(w ResponseWriter, r *Request) { - w.WriteJson(map[string]string{"Id": "123"}) - }, - }, - &Route{"GET", "/.status", - func(w ResponseWriter, r *Request) { - w.WriteJson(handler.GetStatus()) - }, - }, - ) - - // one request to the API - recorded := test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil)) + api := NewApi() + + // the middlewares + status := &StatusMiddleware{} + api.Use(status) + api.Use(&TimerMiddleware{}) + api.Use(&RecorderMiddleware{}) + + // an app that return the Status + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(status.GetStatus()) + })) + + // wrap all + handler := api.MakeHandler() + + // one request + recorded := test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/1", nil)) recorded.CodeIs(200) recorded.ContentTypeIsJson() - // check the status - recorded = test.RunRequest(t, &handler, test.MakeSimpleRequest("GET", "http://1.2.3.4/.status", nil)) + // another request + recorded = test.RunRequest(t, handler, test.MakeSimpleRequest("GET", "http://localhost/2", nil)) recorded.CodeIs(200) recorded.ContentTypeIsJson() + // payload payload := map[string]interface{}{} - err := recorded.DecodeJsonPayload(&payload) if err != nil { t.Fatal(err) diff --git a/rest/test/doc.go b/rest/test/doc.go index 306e608..f58d50e 100644 --- a/rest/test/doc.go +++ b/rest/test/doc.go @@ -5,25 +5,29 @@ // checks end up to be always the same. This test package tries to save // some typing by providing helpers for this particular use case. // -// package main +// package main // -// import ( -// "github.com/ant0ine/go-json-rest/rest/test" -// "testing" -// ) +// import ( +// "github.com/ant0ine/go-json-rest/rest" +// "github.com/ant0ine/go-json-rest/rest/test" +// "testing" +// ) // -// func TestSimpleRequest(t *testing.T) { -// handler := ResourceHandler{} -// handler.SetRoutes( -// &Route{"GET", "/r", -// func(w ResponseWriter, r *Request) { -// w.WriteJson(map[string]string{"Id": "123"}) -// }, -// }, -// ) -// recorded := test.RunRequest(t, &handler, -// test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil)) -// recorded.CodeIs(200) -// recorded.ContentTypeIsJson() -// } +// func TestSimpleRequest(t *testing.T) { +// api := rest.NewApi() +// api.Use(rest.DefaultDevStack...) +// router, err := rest.MakeRouter( +// rest.Get("/r", func(w rest.ResponseWriter, r *rest.Request) { +// w.WriteJson(map[string]string{"Id": "123"}) +// }), +// ) +// if err != nil { +// log.Fatal(err) +// } +// api.SetApp(router) +// recorded := test.RunRequest(t, api.MakeHandler(), +// test.MakeSimpleRequest("GET", "http://1.2.3.4/r", nil)) +// recorded.CodeIs(200) +// recorded.ContentTypeIsJson() +// } package test diff --git a/rest/test/util.go b/rest/test/util.go index 54fdda4..b022099 100644 --- a/rest/test/util.go +++ b/rest/test/util.go @@ -1,9 +1,13 @@ package test import ( + "bytes" + "compress/gzip" "encoding/json" "fmt" + "io" "io/ioutil" + "mime" "net/http" "net/http/httptest" "strings" @@ -56,7 +60,23 @@ func HeaderIs(t *testing.T, r *httptest.ResponseRecorder, headerKey, expectedVal } func ContentTypeIsJson(t *testing.T, r *httptest.ResponseRecorder) { - HeaderIs(t, r, "Content-Type", "application/json") + + mediaType, params, _ := mime.ParseMediaType(r.HeaderMap.Get("Content-Type")) + charset := params["charset"] + + if mediaType != "application/json" { + t.Errorf( + "Content-Type media type: application/json expected, got: %s", + mediaType, + ) + } + + if charset != "" && strings.ToUpper(charset) != "UTF-8" { + t.Errorf( + "Content-Type charset: must be empty or UTF-8, got: %s", + charset, + ) + } } func ContentEncodingIsGzip(t *testing.T, r *httptest.ResponseRecorder) { @@ -64,14 +84,17 @@ func ContentEncodingIsGzip(t *testing.T, r *httptest.ResponseRecorder) { } func BodyIs(t *testing.T, r *httptest.ResponseRecorder, expectedBody string) { - body := r.Body.String() - if body != expectedBody { + body, err := DecodedBody(r) + if err != nil { + t.Errorf("Body '%s' expected, got error: '%s'", expectedBody, err) + } + if string(body) != expectedBody { t.Errorf("Body '%s' expected, got: '%s'", expectedBody, body) } } func DecodeJsonPayload(r *httptest.ResponseRecorder, v interface{}) error { - content, err := ioutil.ReadAll(r.Body) + content, err := DecodedBody(r) if err != nil { return err } @@ -82,6 +105,26 @@ func DecodeJsonPayload(r *httptest.ResponseRecorder, v interface{}) error { return nil } +// DecodedBody returns the entire body read from r.Body, with it +// gunzipped if Content-Encoding is set to gzip +func DecodedBody(r *httptest.ResponseRecorder) ([]byte, error) { + if r.Header().Get("Content-Encoding") != "gzip" { + return ioutil.ReadAll(r.Body) + } + dec, err := gzip.NewReader(r.Body) + if err != nil { + return nil, err + } + b := new(bytes.Buffer) + if _, err = io.Copy(b, dec); err != nil { + return nil, err + } + if err = dec.Close(); err != nil { + return nil, err + } + return b.Bytes(), nil +} + type Recorded struct { T *testing.T Recorder *httptest.ResponseRecorder @@ -103,7 +146,7 @@ func (rd *Recorded) HeaderIs(headerKey, expectedValue string) { } func (rd *Recorded) ContentTypeIsJson() { - rd.HeaderIs("Content-Type", "application/json") + ContentTypeIsJson(rd.T, rd.Recorder) } func (rd *Recorded) ContentEncodingIsGzip() { @@ -117,3 +160,7 @@ func (rd *Recorded) BodyIs(expectedBody string) { func (rd *Recorded) DecodeJsonPayload(v interface{}) error { return DecodeJsonPayload(rd.Recorder, v) } + +func (rd *Recorded) DecodedBody() ([]byte, error) { + return DecodedBody(rd.Recorder) +} diff --git a/rest/test/util_test.go b/rest/test/util_test.go new file mode 100644 index 0000000..32fe099 --- /dev/null +++ b/rest/test/util_test.go @@ -0,0 +1,44 @@ +package test + +import ( + "compress/gzip" + "io" + "net/http/httptest" + "reflect" + "testing" +) + +func testDecodedBody(t *testing.T, zip bool) { + type Data struct { + N int + } + input := `{"N": 1}` + expectedData := Data{N: 1} + + w := httptest.NewRecorder() + + if zip { + w.Header().Set("Content-Encoding", "gzip") + enc := gzip.NewWriter(w) + io.WriteString(enc, input) + enc.Close() + } else { + io.WriteString(w, input) + } + + var gotData Data + if err := DecodeJsonPayload(w, &gotData); err != nil { + t.Errorf("DecodeJsonPayload error: %s", err) + } + if !reflect.DeepEqual(expectedData, gotData) { + t.Errorf("DecodeJsonPayload expected: %#v, got %#v", expectedData, gotData) + } +} + +func TestDecodedBodyUnzipped(t *testing.T) { + testDecodedBody(t, false) +} + +func TestDecodedBodyZipped(t *testing.T) { + testDecodedBody(t, true) +} diff --git a/rest/timer.go b/rest/timer.go index 8de0ad8..b2616c8 100644 --- a/rest/timer.go +++ b/rest/timer.go @@ -4,14 +4,17 @@ import ( "time" ) -// timerMiddleware computes the elapsed time spent during the execution of the wrapped handler. -// The result is available to the wrapping handlers in request.Env["ELAPSED_TIME"] as a time.Duration. -type timerMiddleware struct{} +// TimerMiddleware computes the elapsed time spent during the execution of the wrapped handler. +// The result is available to the wrapping handlers as request.Env["ELAPSED_TIME"].(*time.Duration), +// and as request.Env["START_TIME"].(*time.Time) +type TimerMiddleware struct{} -func (mw *timerMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { +// MiddlewareFunc makes TimerMiddleware implement the Middleware interface. +func (mw *TimerMiddleware) MiddlewareFunc(h HandlerFunc) HandlerFunc { return func(w ResponseWriter, r *Request) { start := time.Now() + r.Env["START_TIME"] = &start // call the handler h(w, r) diff --git a/rest/timer_test.go b/rest/timer_test.go new file mode 100644 index 0000000..b790fd5 --- /dev/null +++ b/rest/timer_test.go @@ -0,0 +1,58 @@ +package rest + +import ( + "github.com/ant0ine/go-json-rest/rest/test" + "testing" + "time" +) + +func TestTimerMiddleware(t *testing.T) { + + api := NewApi() + + // a middleware carrying the Env tests + api.Use(MiddlewareSimple(func(handler HandlerFunc) HandlerFunc { + return func(w ResponseWriter, r *Request) { + + handler(w, r) + + if r.Env["ELAPSED_TIME"] == nil { + t.Error("ELAPSED_TIME is nil") + } + elapsedTime := r.Env["ELAPSED_TIME"].(*time.Duration) + if elapsedTime.Nanoseconds() <= 0 { + t.Errorf( + "ELAPSED_TIME is expected to be at least 1 nanosecond %d", + elapsedTime.Nanoseconds(), + ) + } + + if r.Env["START_TIME"] == nil { + t.Error("START_TIME is nil") + } + start := r.Env["START_TIME"].(*time.Time) + if start.After(time.Now()) { + t.Errorf( + "START_TIME is expected to be in the past %s", + start.String(), + ) + } + } + })) + + // the middleware to test + api.Use(&TimerMiddleware{}) + + // a simple app + api.SetApp(AppSimple(func(w ResponseWriter, r *Request) { + w.WriteJson(map[string]string{"Id": "123"}) + })) + + // wrap all + handler := api.MakeHandler() + + req := test.MakeSimpleRequest("GET", "http://localhost/", nil) + recorded := test.RunRequest(t, handler, req) + recorded.CodeIs(200) + recorded.ContentTypeIsJson() +} diff --git a/rest/trie/impl_test.go b/rest/trie/impl_test.go index 05a1c89..d8fc8b5 100644 --- a/rest/trie/impl_test.go +++ b/rest/trie/impl_test.go @@ -8,22 +8,22 @@ func TestPathInsert(t *testing.T) { trie := New() if trie.root == nil { - t.Error() + t.Error("Expected to not be nil") } trie.AddRoute("GET", "/", "1") if trie.root.Children["/"] == nil { - t.Error() + t.Error("Expected to not be nil") } trie.AddRoute("GET", "/r", "2") if trie.root.Children["/"].Children["r"] == nil { - t.Error() + t.Error("Expected to not be nil") } trie.AddRoute("GET", "/r/", "3") if trie.root.Children["/"].Children["r"].Children["/"] == nil { - t.Error() + t.Error("Expected to not be nil") } } @@ -35,10 +35,10 @@ func TestTrieCompression(t *testing.T) { // before compression if trie.root.Children["/"].Children["a"].Children["b"].Children["c"] == nil { - t.Error() + t.Error("Expected to not be nil") } if trie.root.Children["/"].Children["a"].Children["d"].Children["c"] == nil { - t.Error() + t.Error("Expected to not be nil") } trie.Compress() @@ -57,24 +57,24 @@ func TestParamInsert(t *testing.T) { trie.AddRoute("GET", "/:id/", "") if trie.root.Children["/"].ParamChild.Children["/"] == nil { - t.Error() + t.Error("Expected to not be nil") } if trie.root.Children["/"].ParamName != "id" { - t.Error() + t.Error("Expected ParamName to be id") } trie.AddRoute("GET", "/:id/:property.:format", "") if trie.root.Children["/"].ParamChild.Children["/"].ParamChild.Children["."].ParamChild == nil { - t.Error() + t.Error("Expected to not be nil") } if trie.root.Children["/"].ParamName != "id" { - t.Error() + t.Error("Expected ParamName to be id") } if trie.root.Children["/"].ParamChild.Children["/"].ParamName != "property" { - t.Error() + t.Error("Expected ParamName to be property") } if trie.root.Children["/"].ParamChild.Children["/"].ParamChild.Children["."].ParamName != "format" { - t.Error() + t.Error("Expected ParamName to be format") } } @@ -83,10 +83,10 @@ func TestRelaxedInsert(t *testing.T) { trie.AddRoute("GET", "/#id/", "") if trie.root.Children["/"].RelaxedChild.Children["/"] == nil { - t.Error() + t.Error("Expected to not be nil") } if trie.root.Children["/"].RelaxedName != "id" { - t.Error() + t.Error("Expected RelaxedName to be id") } } @@ -94,7 +94,7 @@ func TestSplatInsert(t *testing.T) { trie := New() trie.AddRoute("GET", "/*splat", "") if trie.root.Children["/"].SplatChild == nil { - t.Error() + t.Error("Expected to not be nil") } } @@ -103,10 +103,10 @@ func TestDupeInsert(t *testing.T) { trie.AddRoute("GET", "/", "1") err := trie.AddRoute("GET", "/", "2") if err == nil { - t.Error() + t.Error("Expected to not be nil") } if trie.root.Children["/"].HttpMethodToRoute["GET"] != "1" { - t.Error() + t.Error("Expected to be 1") } } @@ -152,7 +152,7 @@ func TestFindRoute(t *testing.T) { t.Errorf("expected 'resource', got %+v", matches) } if matches[0].Params["id"] != "1" { - t.Error() + t.Error("Expected Params id to be 1") } matches = trie.FindRoutes("GET", "/r/1/property") @@ -163,7 +163,7 @@ func TestFindRoute(t *testing.T) { t.Error("expected 'property'") } if matches[0].Params["id"] != "1" { - t.Error() + t.Error("Expected Params id to be 1") } matches = trie.FindRoutes("GET", "/r/1/property.json") @@ -174,10 +174,10 @@ func TestFindRoute(t *testing.T) { t.Error("expected 'property_format'") } if matches[0].Params["id"] != "1" { - t.Error() + t.Error("Expected Params id to be 1") } if matches[0].Params["format"] != "json" { - t.Error() + t.Error("Expected Params format to be json") } matches = trie.FindRoutes("GET", "/user/antoine.imbert/property") @@ -188,7 +188,7 @@ func TestFindRoute(t *testing.T) { t.Error("expected 'user_property'") } if matches[0].Params["username"] != "antoine.imbert" { - t.Error() + t.Error("Expected Params username to be antoine.imbert") } } @@ -211,10 +211,10 @@ func TestFindRouteMultipleMatches(t *testing.T) { t.Errorf("expected two matches, got %d", len(matches)) } if !isInMatches("resource_generic", matches) { - t.Error() + t.Error("Expected resource_generic to match") } if !isInMatches("resource1", matches) { - t.Error() + t.Error("Expected resource1 to match") } matches = trie.FindRoutes("GET", "/s/1") @@ -222,13 +222,13 @@ func TestFindRouteMultipleMatches(t *testing.T) { t.Errorf("expected two matches, got %d", len(matches)) } if !isInMatches("special_all", matches) { - t.Error() + t.Error("Expected special_all to match") } if !isInMatches("special_generic", matches) { - t.Error() + t.Error("Expected special_generic to match") } if !isInMatches("special_relaxed", matches) { - t.Error() + t.Error("Expected special_relaxed to match") } }