From ce26f665ea2b1e83ce093ba0f9fe86d27b055e11 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Mon, 30 Jan 2017 14:43:23 -0800 Subject: [PATCH] Middleware (#502) * API endpoint extensions working. extensions example. extensions example. * Added server.NewEnv and some docs for the API extensions example. extensions example. extensions example. * Uncommented special handler stuff. * First example of middleware. easier to use. * Added a special Middleware context to make middleware easier to use. * Fix tests. * Cleanup based on PR comments. --- api/server/middleware.go | 102 +++++++++++++++++++++++++++++ api/server/runner_async_test.go | 2 +- api/server/server.go | 7 +- api/server/server_test.go | 2 +- api/server/special_handler_test.go | 2 +- docs/operating/extending.md | 61 ++++++----------- examples/extensions/README.md | 6 +- examples/middleware/.gitignore | 2 + examples/middleware/README.md | 27 ++++++++ examples/middleware/main.go | 50 ++++++++++++++ 10 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 api/server/middleware.go create mode 100644 examples/middleware/.gitignore create mode 100644 examples/middleware/README.md create mode 100644 examples/middleware/main.go diff --git a/api/server/middleware.go b/api/server/middleware.go new file mode 100644 index 000000000..cafd2f954 --- /dev/null +++ b/api/server/middleware.go @@ -0,0 +1,102 @@ +// TODO: it would be nice to move these into the top level folder so people can use these with the "functions" package, eg: functions.AddMiddleware(...) +package server + +import ( + "context" + "net/http" + + "github.com/Sirupsen/logrus" + "github.com/gin-gonic/gin" + "github.com/iron-io/functions/api/models" +) + +// Middleware is the interface required for implementing functions middlewar +type Middleware interface { + // Serve is what the Middleware must implement. Can modify the request, write output, etc. + // todo: should we abstract the HTTP out of this? In case we want to support other protocols. + Serve(ctx MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error +} + +// MiddlewareFunc func form of Middleware +type MiddlewareFunc func(ctx MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error + +// Serve wrapper +func (f MiddlewareFunc) Serve(ctx MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error { + return f(ctx, w, r, app) +} + +// MiddlewareContext extends context.Context for Middleware +type MiddlewareContext interface { + context.Context + // Middleware can call Next() explicitly to call the next middleware in the chain. If Next() is not called and an error is not returned, Next() will automatically be called. + Next() + // Index returns the index of where we're at in the chain + Index() int +} + +type middlewareContextImpl struct { + context.Context + + ginContext *gin.Context + nextCalled bool + index int + middlewares []Middleware +} + +func (c *middlewareContextImpl) Next() { + c.nextCalled = true + c.index++ + c.serveNext() +} + +func (c *middlewareContextImpl) serveNext() { + if c.Index() >= len(c.middlewares) { + return + } + // make shallow copy: + fctx2 := *c + fctx2.nextCalled = false + r := c.ginContext.Request.WithContext(fctx2) + err := c.middlewares[c.Index()].Serve(&fctx2, c.ginContext.Writer, r, nil) + if err != nil { + logrus.WithError(err).Warnln("Middleware error") + // todo: might be a good idea to check if anything is written yet, and if not, output the error: simpleError(err) + // see: http://stackoverflow.com/questions/39415827/golang-http-check-if-responsewriter-has-been-written + c.ginContext.Abort() + return + } + if !fctx2.nextCalled { + // then we automatically call next + fctx2.Next() + } + +} + +func (c *middlewareContextImpl) Index() int { + return c.index +} + +func (s *Server) middlewareWrapperFunc(ctx context.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if len(s.middlewares) == 0 { + return + } + ctx = c.MustGet("ctx").(context.Context) + fctx := &middlewareContextImpl{Context: ctx} + fctx.index = -1 + fctx.ginContext = c + fctx.middlewares = s.middlewares + // start the chain: + fctx.Next() + } +} + +// AddAppEndpoint adds an endpoints to /v1/apps/:app/x +func (s *Server) AddMiddleware(m Middleware) { + s.middlewares = append(s.middlewares, m) +} + +// AddAppEndpoint adds an endpoints to /v1/apps/:app/x +func (s *Server) AddMiddlewareFunc(m func(ctx MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error) { + s.AddMiddleware(MiddlewareFunc(m)) +} diff --git a/api/server/runner_async_test.go b/api/server/runner_async_test.go index b0bc3913b..cd098f153 100644 --- a/api/server/runner_async_test.go +++ b/api/server/runner_async_test.go @@ -32,7 +32,7 @@ func testRouterAsync(ds models.Datastore, mq models.MessageQueue, rnr *runner.Ru r.Use(gin.Logger()) r.Use(prepareMiddleware(ctx)) - s.bindHandlers() + s.bindHandlers(ctx) return r } diff --git a/api/server/server.go b/api/server/server.go index 210d7bdd4..e7fce2764 100644 --- a/api/server/server.go +++ b/api/server/server.go @@ -44,6 +44,7 @@ type Server struct { specialHandlers []SpecialHandler appListeners []AppListener + middlewares []Middleware runnerListeners []RunnerListener mu sync.Mutex // protects hotroutes @@ -95,7 +96,7 @@ func New(ctx context.Context, ds models.Datastore, mq models.MessageQueue, apiUR } s.Router.Use(prepareMiddleware(ctx)) - s.bindHandlers() + s.bindHandlers(ctx) for _, opt := range opts { opt(s) @@ -103,6 +104,7 @@ func New(ctx context.Context, ds models.Datastore, mq models.MessageQueue, apiUR return s } +// todo: remove this or change name func prepareMiddleware(ctx context.Context) gin.HandlerFunc { return func(c *gin.Context) { ctx, _ := common.LoggerWithFields(ctx, extractFields(c)) @@ -239,7 +241,7 @@ func (s *Server) startGears(ctx context.Context) { svr.Serve(ctx) } -func (s *Server) bindHandlers() { +func (s *Server) bindHandlers(ctx context.Context) { engine := s.Router engine.GET("/", handlePing) @@ -247,6 +249,7 @@ func (s *Server) bindHandlers() { engine.GET("/stats", s.handleStats) v1 := engine.Group("/v1") + v1.Use(s.middlewareWrapperFunc(ctx)) { v1.GET("/apps", s.handleAppList) v1.POST("/apps", s.handleAppCreate) diff --git a/api/server/server_test.go b/api/server/server_test.go index a848121be..cd9e8a4b0 100644 --- a/api/server/server_test.go +++ b/api/server/server_test.go @@ -39,7 +39,7 @@ func testServer(ds models.Datastore, mq models.MessageQueue, rnr *runner.Runner, r.Use(gin.Logger()) s.Router.Use(prepareMiddleware(ctx)) - s.bindHandlers() + s.bindHandlers(ctx) return s } diff --git a/api/server/special_handler_test.go b/api/server/special_handler_test.go index eb8a61111..b43414da5 100644 --- a/api/server/special_handler_test.go +++ b/api/server/special_handler_test.go @@ -10,7 +10,7 @@ func (h *testSpecialHandler) Handle(c HandlerContext) error { } func TestSpecialHandlerSet(t *testing.T) { - // temporarily commented until we figure out if we want this anymore + // todo: temporarily commented as we may remove special handlers // ctx := context.Background() // tasks := make(chan task.Request) diff --git a/docs/operating/extending.md b/docs/operating/extending.md index 846b04553..473f616ea 100644 --- a/docs/operating/extending.md +++ b/docs/operating/extending.md @@ -2,6 +2,13 @@ IronFunctions is extensible so you can add custom functionality and extend the project without needing to modify the core. +There are 4 different ways to extend the functionality of IronFunctions. + +1. Listeners - listen to API events such as a route getting updated and react accordingly. +1. Middleware - a chain of middleware is executed before an API handler is called. +1. Add API Endpoints - extend the default IronFunctions API. +1. Special Handlers - TODO: DO WE NEED THIS ANYMORE?? + ## Listeners Listeners are the main way to extend IronFunctions. @@ -34,10 +41,8 @@ func (c *myCustomListener) BeforeAppDelete(ctx context.Context, app *models.App) function main () { srv := server.New(/* Here all required parameters to initialize the server */) - srv.AddAppCreateListener(myCustomListener) - srv.AddAppUpdateListener(myCustomListener) - srv.AddAppDeleteListener(myCustomListener) - + srv.AddAppListener(myCustomListener) + srv.Run() } ``` @@ -48,45 +53,11 @@ These are all available listeners: #### App Listeners -To be a valid listener your struct should respect interfaces combined or alone found [in this file](/iron-io/functions/blob/master/api/server/apps_listeners.go) - -##### AppCreateListener - -_Triggers before and after every app creation that happens in the API_ - -Triggered on requests to the following routes: - -- POST /v1/apps -- POST /v1/apps/:app/routes - -##### AppUpdateListener - -_Triggers before and after every app updates that happens in the API_ - -Triggered during requests to the following routes: - -- PUT /v1/apps - -##### AppDeleteListener - -_Triggers before and after every app deletion that happens in the API_ - -Triggered during requests to the following routes: - -- DELETE /v1/apps/:app +See the godoc for AppListener [in this file](/iron-io/functions/blob/master/api/server/apps_listeners.go) #### Runner Listeners -To be a valid listener your struct should respect interfaces combined or alone found [in this file](/iron-io/functions/blob/master/api/server/runner_listeners.go). - -##### RunnerListener - -_Triggers before and after every function run_ - -Triggered during requests to the following routes: - -- GET /r/:app/:route -- POST /r/:app/:route +See the godoc for RunnerListner [in this file](/iron-io/functions/blob/master/api/server/runner_listeners.go). ## Adding API Endpoints @@ -94,6 +65,16 @@ You can add API endpoints by using the `AddEndpoint` and `AddEndpointFunc` metho See examples of this in [/examples/extensions/main.go](/examples/extensions/main.go). +## Middleware + +Middleware enables you to add functionality to every API request. For every request, the chain of Middleware will be called +in order allowing you to modify or reject requests, as well as write output and cancel the chain. + +NOTES: + +* middleware is responsible for writing output if it's going to cancel the chain. +* cancel the chain by returning an error from your Middleware's Serve method. + ## Special Handlers To understand how **Special Handlers** works you need to understand what are **Special Routes**. diff --git a/examples/extensions/README.md b/examples/extensions/README.md index bc6021cb5..ced9b26db 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -1,12 +1,12 @@ # Extensions Example -This example adds extra endpoints to the API. +This example adds extra endpoints to the API. See [main.go](main.go) for example code. ## Building and Running ```sh -go build -o functions -./functions +go build +./extensions ``` Then test with: diff --git a/examples/middleware/.gitignore b/examples/middleware/.gitignore new file mode 100644 index 000000000..f73736d3c --- /dev/null +++ b/examples/middleware/.gitignore @@ -0,0 +1,2 @@ +/middleware +/middleware.exe diff --git a/examples/middleware/README.md b/examples/middleware/README.md new file mode 100644 index 000000000..eb549cbd6 --- /dev/null +++ b/examples/middleware/README.md @@ -0,0 +1,27 @@ +# Middleware Example + +This example adds a simple authentication middleware to IronFunctions. See [main.go](main.go) for example code. + +## Building and Running + +```sh +go build +./middleware +``` + +Then test with: + +```sh +# First, create an app +fn apps create myapp +# And test +curl http://localhost:8080/v1/apps +``` + +You should get a 401 error. + +Add an auth header and it should go through successfully: + +```sh +curl -X GET -H "Authorization: Bearer KlaatuBaradaNikto" http://localhost:8080/v1/apps +``` diff --git a/examples/middleware/main.go b/examples/middleware/main.go new file mode 100644 index 000000000..3ac72fcb8 --- /dev/null +++ b/examples/middleware/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/iron-io/functions/api/models" + "github.com/iron-io/functions/api/server" +) + +func main() { + ctx := context.Background() + + funcServer := server.NewEnv(ctx) + + funcServer.AddMiddlewareFunc(func(ctx server.MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error { + start := time.Now() + fmt.Println("CustomMiddlewareFunc called at:", start) + // TODO: probably need a way to let the chain go forward here and return back to the middleware, for things like timing, etc. + ctx.Next() + fmt.Println("Duration:", (time.Now().Sub(start))) + return nil + }) + funcServer.AddMiddleware(&CustomMiddleware{}) + + funcServer.Start(ctx) +} + +type CustomMiddleware struct { +} + +func (h *CustomMiddleware) Serve(ctx server.MiddlewareContext, w http.ResponseWriter, r *http.Request, app *models.App) error { + fmt.Println("CustomMiddleware called") + + // check auth header + tokenHeader := strings.SplitN(r.Header.Get("Authorization"), " ", 3) + if len(tokenHeader) < 2 || tokenHeader[1] != "KlaatuBaradaNikto" { + w.WriteHeader(http.StatusUnauthorized) + m := map[string]string{"error": "Invalid Authorization token. Sorry!"} + json.NewEncoder(w).Encode(m) + return errors.New("Invalid authorization token.") + } + fmt.Println("auth succeeded!") + return nil +}