new feature: versioned controllers

Former-commit-id: c797e23c78b1e74bbe9ba56673f3a98f17f5e2f7
This commit is contained in:
Gerasimos (Makis) Maropoulos 2020-06-19 20:58:24 +03:00
parent 3f98b39632
commit 311b560717
13 changed files with 217 additions and 32 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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"
}

View File

@ -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")
}

View File

@ -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: ")
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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)...)

View File

@ -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.
}

View File

@ -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()

View File

@ -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.

38
mvc/versioning.go Normal file
View File

@ -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()
})
}
}