From 311b5607172b4c252fb1d332deb888abb55e9f57 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Fri, 19 Jun 2020 20:58:24 +0300 Subject: [PATCH] new feature: versioned controllers Former-commit-id: c797e23c78b1e74bbe9ba56673f3a98f17f5e2f7 --- HISTORY.md | 8 ++- _examples/README.md | 1 + _examples/mvc/versioned-controller/main.go | 52 +++++++++++++++++++ .../mvc/versioned-controller/main_test.go | 26 ++++++++++ _examples/sessions/securecookie/main_test.go | 4 +- context/handler.go | 21 ++++++++ core/router/api_builder.go | 25 +++------ hero/func_result.go | 1 + mvc/controller.go | 16 ++++++ mvc/controller_test.go | 29 ++++++++++- mvc/grpc.go | 2 + mvc/mvc.go | 26 +++++++--- mvc/versioning.go | 38 ++++++++++++++ 13 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 _examples/mvc/versioned-controller/main.go create mode 100644 _examples/mvc/versioned-controller/main_test.go create mode 100644 mvc/versioning.go diff --git a/HISTORY.md b/HISTORY.md index d7ae3227..113a5b3f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -371,13 +371,17 @@ Other Improvements: ![DBUG routes](https://iris-go.com/images/v12.2.0-dbug2.png?v=0) -- New [rollbar example](https://github.com/kataras/iris/tree/master/_examples/logging/rollbar/main.go). +- Versioned Controllers feature through the new `mvc.Version` option. See [_examples/mvc/versioned-controller](https://github.com/kataras/iris/blob/master/_examples/mvc/versioned-controller/main.go). + +- Fix [#1539](https://github.com/kataras/iris/issues/1539). + +- New [rollbar example](https://github.com/kataras/iris/blob/master/_examples/logging/rollbar/main.go). - New builtin [requestid](https://github.com/kataras/iris/tree/master/middleware/requestid) middleware. - New builtin [JWT](https://github.com/kataras/iris/tree/master/middleware/jwt) middleware based on [square/go-jose](https://github.com/square/go-jose) featured with optional encryption to set claims with sensitive data when necessary. -- New `iris.RouteOverlap` route registration rule. `Party.SetRegisterRule(iris.RouteOverlap)` to allow overlapping across multiple routes for the same request subdomain, method, path. See [1536#issuecomment-643719922](https://github.com/kataras/iris/issues/1536#issuecomment-643719922). +- New `iris.RouteOverlap` route registration rule. `Party.SetRegisterRule(iris.RouteOverlap)` to allow overlapping across multiple routes for the same request subdomain, method, path. See [1536#issuecomment-643719922](https://github.com/kataras/iris/issues/1536#issuecomment-643719922). This allows two or more **MVC Controllers** to listen on the same path based on one or more registered dependencies (see [_examples/mvc/authenticated-controller](https://github.com/kataras/iris/tree/master/_examples/mvc/authenticated-controller)). - `Context.ReadForm` now can return an `iris.ErrEmptyForm` instead of `nil` when the new `Configuration.FireEmptyFormError` is true (when `iris.WithEmptyFormError` is set) on missing form body to read from. diff --git a/_examples/README.md b/_examples/README.md index 139937fb..ddc96583 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -190,6 +190,7 @@ * [Regexp](mvc/regexp/main.go) * [Session Controller](mvc/session-controller/main.go) * [Authenticated Controller](mvc/authenticated-controller/main.go) + * [Versioned Controller](mvc/versioned-controller/main.go) * [Websocket Controller](mvc/websocket) * [Register Middleware](mvc/middleware) * [gRPC](mvc/grpc-compatible) diff --git a/_examples/mvc/versioned-controller/main.go b/_examples/mvc/versioned-controller/main.go new file mode 100644 index 00000000..2cf817bb --- /dev/null +++ b/_examples/mvc/versioned-controller/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/mvc" +) + +func main() { + app := newApp() + + // See main_test.go for request examples. + app.Listen(":8080") +} + +func newApp() *iris.Application { + app := iris.New() + + dataRouter := app.Party("/data") + { + m := mvc.New(dataRouter) + m.Handle(new(v1Controller), mvc.Version("1")) // 1 or 1.0, 1.0.0 ... + m.Handle(new(v2Controller), mvc.Version("2.3")) // 2.3 or 2.3.0 + m.Handle(new(v3Controller), mvc.Version(">=3, <4")) // 3, 3.x, 3.x.x ... + m.Handle(new(noVersionController)) + } + + return app +} + +type v1Controller struct{} + +func (c *v1Controller) Get() string { + return "data (v1.x)" +} + +type v2Controller struct{} + +func (c *v2Controller) Get() string { + return "data (v2.x)" +} + +type v3Controller struct{} + +func (c *v3Controller) Get() string { + return "data (v3.x)" +} + +type noVersionController struct{} + +func (c *noVersionController) Get() string { + return "data" +} diff --git a/_examples/mvc/versioned-controller/main_test.go b/_examples/mvc/versioned-controller/main_test.go new file mode 100644 index 00000000..1aa657c1 --- /dev/null +++ b/_examples/mvc/versioned-controller/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "testing" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/httptest" + "github.com/kataras/iris/v12/versioning" +) + +func TestVersionedController(t *testing.T) { + app := newApp() + + e := httptest.New(t, app) + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "1").Expect(). + Status(iris.StatusOK).Body().Equal("data (v1.x)") + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "2.3.0").Expect(). + Status(iris.StatusOK).Body().Equal("data (v2.x)") + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "3.1").Expect(). + Status(iris.StatusOK).Body().Equal("data (v3.x)") + // Test invalid version or no version at all. + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "4").Expect(). + Status(iris.StatusOK).Body().Equal("data") + e.GET("/data").Expect(). + Status(iris.StatusOK).Body().Equal("data") +} diff --git a/_examples/sessions/securecookie/main_test.go b/_examples/sessions/securecookie/main_test.go index d419cd75..775c698d 100644 --- a/_examples/sessions/securecookie/main_test.go +++ b/_examples/sessions/securecookie/main_test.go @@ -14,14 +14,14 @@ func TestSessionsEncodeDecode(t *testing.T) { es := e.GET("/set").Expect() es.Status(iris.StatusOK) es.Cookies().NotEmpty() - es.Body().Equal("All ok session set to: iris") + es.Body().Equal("All ok session set to: iris [isNew=true]") e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The name on the /set was: iris") // delete and re-get e.GET("/delete").Expect().Status(iris.StatusOK) e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The name on the /set was: ") // set, clear and re-get - e.GET("/set").Expect().Body().Equal("All ok session set to: iris") + e.GET("/set").Expect().Body().Equal("All ok session set to: iris [isNew=false]") e.GET("/clear").Expect().Status(iris.StatusOK) e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The name on the /set was: ") } diff --git a/context/handler.go b/context/handler.go index 00939234..fc7133cb 100644 --- a/context/handler.go +++ b/context/handler.go @@ -283,3 +283,24 @@ func NewConditionalHandler(filter Filter, handlers ...Handler) Handler { ctx.Next() } } + +// JoinHandlers returns a copy of "h1" and "h2" Handlers slice joined as one slice of Handlers. +func JoinHandlers(h1 Handlers, h2 Handlers) Handlers { + if len(h1) == 0 { + return h2 + } + + if len(h2) == 0 { + return h1 + } + + nowLen := len(h1) + totalLen := nowLen + len(h2) + // create a new slice of Handlers in order to merge the "h1" and "h2" + newHandlers := make(Handlers, totalLen) + // copy the already Handlers to the just created + copy(newHandlers, h1) + // start from there we finish, and store the new Handlers too + copy(newHandlers[nowLen:], h2) + return newHandlers +} diff --git a/core/router/api_builder.go b/core/router/api_builder.go index ed4c8276..06cd4fb8 100644 --- a/core/router/api_builder.go +++ b/core/router/api_builder.go @@ -118,7 +118,7 @@ func (repo *repository) register(route *Route, rule RouteRegisterRule) (*Route, var defaultOverlapFilter = func(ctx context.Context) bool { if ctx.IsStopped() { - // It's stopped and the response can be overriden by a new handler. + // It's stopped and the response can be overridden by a new handler. rs, ok := ctx.ResponseWriter().(context.ResponseWriterReseter) return ok && rs.Reset() } @@ -509,8 +509,8 @@ func (api *APIBuilder) createRoutes(errorCode int, methods []string, relativePat ) if errorCode == 0 { - beginHandlers = joinHandlers(api.middleware, beginHandlers) - doneHandlers = joinHandlers(api.doneHandlers, doneHandlers) + beginHandlers = context.JoinHandlers(api.middleware, beginHandlers) + doneHandlers = context.JoinHandlers(api.doneHandlers, doneHandlers) } mainHandlers := context.Handlers(handlers) @@ -528,9 +528,9 @@ func (api *APIBuilder) createRoutes(errorCode int, methods []string, relativePat // global begin handlers -> middleware that are registered before route registration // -> handlers that are passed to this Handle function. - routeHandlers := joinHandlers(beginHandlers, mainHandlers) + routeHandlers := context.JoinHandlers(beginHandlers, mainHandlers) // -> done handlers - routeHandlers = joinHandlers(routeHandlers, doneHandlers) + routeHandlers = context.JoinHandlers(routeHandlers, doneHandlers) // here we separate the subdomain and relative path subdomain, path := splitSubdomainAndPath(fullpath) @@ -618,7 +618,7 @@ func (api *APIBuilder) Party(relativePath string, handlers ...context.Handler) P fullpath := parentPath + relativePath // append the parent's + child's handlers - middleware := joinHandlers(api.middleware, handlers) + middleware := context.JoinHandlers(api.middleware, handlers) // the allow methods per party and its children. allowMethods := make([]string, len(api.allowMethods)) @@ -1060,19 +1060,6 @@ func (api *APIBuilder) Layout(tmplLayoutFile string) Party { return api } -// joinHandlers uses to create a copy of all Handlers and return them in order to use inside the node -func joinHandlers(h1 context.Handlers, h2 context.Handlers) context.Handlers { - nowLen := len(h1) - totalLen := nowLen + len(h2) - // create a new slice of Handlers in order to merge the "h1" and "h2" - newHandlers := make(context.Handlers, totalLen) - // copy the already Handlers to the just created - copy(newHandlers, h1) - // start from there we finish, and store the new Handlers too - copy(newHandlers[nowLen:], h2) - return newHandlers -} - // https://golang.org/doc/go1.9#callersframes func getCaller() (string, int) { var pcs [32]uintptr diff --git a/hero/func_result.go b/hero/func_result.go index c37a31cc..bfcf4e2d 100644 --- a/hero/func_result.go +++ b/hero/func_result.go @@ -271,6 +271,7 @@ func dispatchFuncResult(ctx context.Context, values []reflect.Value, handler Res contentType = value } else { // otherwise is content + contentType = context.ContentTextHeaderValue content = []byte(value) } diff --git a/mvc/controller.go b/mvc/controller.go index 4529c816..2cb55a02 100644 --- a/mvc/controller.go +++ b/mvc/controller.go @@ -78,6 +78,12 @@ type ControllerActivator struct { // End-devs can change some properties of the *Route on the `BeforeActivator` by using the // `GetRoute/GetRoutes(functionName)`. routes map[string][]*router.Route + // BeginHandlers is a slice of middleware for this controller. + // These handlers will be prependend to each one of + // the route that this controller will register(Handle/HandleMany/struct methods) + // to the targeted Party. + // Look the `Use` method too. + BeginHandlers context.Handlers // true if this controller listens and serves to websocket events. servesWebsocket bool @@ -199,6 +205,15 @@ func (c *ControllerActivator) GetRoutes(methodName string) []*router.Route { return nil } +// Use registers a middleware for this Controller. +// It appends one or more handlers to the `BeginHandlers`. +// It's like the `Party.Use` but specifically +// for the routes that this controller will register to the targeted `Party`. +func (c *ControllerActivator) Use(handlers ...context.Handler) *ControllerActivator { + c.BeginHandlers = append(c.BeginHandlers, handlers...) + return c +} + // Singleton returns new if all incoming clients' requests // have the same controller instance. // This is done automatically by iris to reduce the creation @@ -343,6 +358,7 @@ func (c *ControllerActivator) handleMany(method, path, funcName string, override } handler := c.handlerOf(path, funcName) + middleware = context.JoinHandlers(c.BeginHandlers, middleware) // register the handler now. routes := c.app.Router.HandleMany(method, path, append(middleware, handler)...) diff --git a/mvc/controller_test.go b/mvc/controller_test.go index 6ff0a7c7..535e5fd2 100644 --- a/mvc/controller_test.go +++ b/mvc/controller_test.go @@ -703,6 +703,31 @@ func TestControllerOverlapping(t *testing.T) { m.Handle(new(publicController)) e := httptest.New(t, app) - e.GET("/").WithQuery("name", "kataras").Expect().Status(httptest.StatusOK).JSON().Equal(iris.Map{"id": 1}) - e.GET("/").Expect().Status(httptest.StatusOK).JSON().Equal(iris.Map{"data": "things"}) + e.GET("/").WithQuery("name", "kataras").Expect().Status(httptest.StatusOK). + JSON().Equal(iris.Map{"id": 1}) + e.GET("/").Expect().Status(httptest.StatusOK). + JSON().Equal(iris.Map{"data": "things"}) +} + +type testControllerMethodHandlerBindStruct struct{} + +type queryData struct { + Name string `json:"name" url:"name"` +} + +func (*testControllerMethodHandlerBindStruct) Any(data queryData) queryData { + return data +} + +func TestControllerMethodHandlerBindStruct(t *testing.T) { + app := iris.New() + + New(app).Handle(new(testControllerMethodHandlerBindStruct)) + + data := iris.Map{"name": "kataras"} + + e := httptest.New(t, app) + e.GET("/").WithQueryObject(data).Expect().Status(httptest.StatusOK).JSON().Equal(data) + e.PATCH("/").WithJSON(data).Expect().Status(httptest.StatusOK).JSON().Equal(data) + // more tests inside the hero package itself. } diff --git a/mvc/grpc.go b/mvc/grpc.go index 963811e5..01ae04e1 100644 --- a/mvc/grpc.go +++ b/mvc/grpc.go @@ -34,6 +34,8 @@ type GRPC struct { Strict bool } +var _ Option = GRPC{} + // Apply parses the controller's methods and registers gRPC handlers to the application. func (g GRPC) Apply(c *ControllerActivator) { defer c.Activated() diff --git a/mvc/mvc.go b/mvc/mvc.go index 9c6944cc..72df3dc8 100644 --- a/mvc/mvc.go +++ b/mvc/mvc.go @@ -146,13 +146,25 @@ func (app *Application) Register(dependencies ...interface{}) *Application { return app } -// Option is an interface which does contain a single `Apply` method that accepts -// a `ControllerActivator`. It can be passed on `Application.Handle` method to -// mdoify the behavior right after the `BeforeActivation` state. -// -// See `GRPC` package-level structure too. -type Option interface { - Apply(*ControllerActivator) +type ( + // Option is an interface which does contain a single `Apply` method that accepts + // a `ControllerActivator`. It can be passed on `Application.Handle` method to + // mdoify the behavior right after the `BeforeActivation` state. + // + // See `GRPC` package-level structure + // and `Version` package-level function too. + Option interface { + Apply(*ControllerActivator) + } + + // OptionFunc is the functional type of `Option`. + // Read `Option` docs. + OptionFunc func(*ControllerActivator) +) + +// Apply completes the `Option` interface. +func (opt OptionFunc) Apply(c *ControllerActivator) { + opt(c) } // Handle serves a controller for the current mvc application's Router. diff --git a/mvc/versioning.go b/mvc/versioning.go new file mode 100644 index 00000000..d1640ae2 --- /dev/null +++ b/mvc/versioning.go @@ -0,0 +1,38 @@ +package mvc + +import ( + "github.com/kataras/iris/v12/context" + "github.com/kataras/iris/v12/core/router" + "github.com/kataras/iris/v12/versioning" +) + +// Version returns a valid `Option` that can be passed to the `Application.Handle` method. +// It requires a specific "version" constraint for a Controller, +// e.g. ">1, <=2" or just "1". +// +// +// Usage: +// m := mvc.New(dataRouter) +// m.Handle(new(v1Controller), mvc.Version("1")) +// m.Handle(new(v2Controller), mvc.Version("2.3")) +// m.Handle(new(v3Controller), mvc.Version(">=3, <4")) +// m.Handle(new(noVersionController)) +// +// See the `versioning` package's documentation for more information on +// how the version is extracted from incoming requests. +// +// Note that this Option will set the route register rule to `RouteOverlap`. +func Version(version string) OptionFunc { + return func(c *ControllerActivator) { + c.Router().SetRegisterRule(router.RouteOverlap) // required for this feature. + + c.Use(func(ctx context.Context) { + if !versioning.Match(ctx, version) { + ctx.StopExecution() + return + } + + ctx.Next() + }) + } +}