diff --git a/.deepsource.toml b/.deepsource.toml index b767baab..17377110 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -14,4 +14,3 @@ enabled = true [analyzers.meta] import_paths = ["github.com/kataras/iris"] - diff --git a/.gitignore b/.gitignore index 37739136..8ddb0de7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ coverage.out package-lock.json go.sum +access.log node_modules issue-*/ internalcode-*/ diff --git a/HISTORY.md b/HISTORY.md index 6a333341..307e4b65 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,29 @@ The codebase for Dependency Injection, Internationalization and localization and ## Fixes and Improvements +- New `versioning.Aliases` middleware and up to 80% faster version resolve. Example Code: + +```go +app := iris.New() + +api := app.Party("/api") +api.Use(Aliases(map[string]string{ + versioning.Empty: "1", // when no version was provided by the client. + "beta": "4.0.0", + "stage": "5.0.0-alpha" +})) + +v1 := NewGroup(api, ">=1.0.0 <2.0.0") +v1.Get/Post... + +v4 := NewGroup(api, ">=4.0.0 <5.0.0") +v4.Get/Post... + +stage := NewGroup(api, "5.0.0-alpha") +stage.Get/Post... +``` + +- New [Basic Authentication](https://github.com/kataras/iris/tree/master/middleware/basicauth) middleware. Its `Default` function has not changed, however, the rest, e.g. `New` contains breaking changes as the new middleware features new functionalities. - Add `iris.DirOptions.SPA bool` field to allow [Single Page Applications](https://github.com/kataras/iris/tree/master/_examples/file-server/single-page-application/basic/main.go) under a file server. - A generic User interface, see the `Context.SetUser/User` methods in the New Context Methods section for more. In-short, the basicauth middleware's stored user can now be retrieved through `Context.User()` which provides more information than the native `ctx.Request().BasicAuth()` method one. Third-party authentication middleware creators can benefit of these two methods, plus the Logout below. - A `Context.Logout` method is added, can be used to invalidate [basicauth](https://github.com/kataras/iris/blob/master/_examples/auth/basicauth/basic/main.go) or [jwt](https://github.com/kataras/iris/blob/master/_examples/auth/jwt/blocklist/main.go) client credentials. @@ -700,6 +723,7 @@ Response: ## Breaking Changes +- The `versioning.NewMatcher` has been removed entirely in favor of `NewGroup`. Strict versions format on `versioning.NewGroup` is required. E.g. `"1"` is not valid anymore, you have to specify `"1.0.0"`. Example: `NewGroup(api, ">=1.0.0 <2.0.0")`. The [routing/versioning](_examples/routing/versioning) examples have been updated. - Now that `RegisterView` can be used to register different view engines per-Party, there is no need to support registering multiple engines under the same Party. The `app.RegisterView` now upserts the given Engine instead of append. You can now render templates **without file extension**, e.g. `index` instead of `index.ace`, both forms are valid now. - The `Context.ContentType` does not accept filenames to resolve the mime type anymore (caused issues with vendor-specific(vnd) MIME types). - The `Configuration.RemoteAddrPrivateSubnets.IPRange.Start and End` are now type of `string` instead of `net.IP`. The `WithRemoteAddrPrivateSubnet` option remains as it is, already accepts `string`s. diff --git a/NOTICE b/NOTICE index f5f25113..aaf04975 100644 --- a/NOTICE +++ b/NOTICE @@ -44,9 +44,9 @@ Revision ID: 5fc50a00491616d5cd0cbce3abd8b699838e25ca easyjson 8ab5ff9cd8e4e43 https://github.com/mailru/easyjson 2e8b79f6c47d324 a31dd803cf - go-version 2b13044f5cdd383 https://github.com/hashicorp/go-version - 3370d41ce57d8bf - 3cec5e62b8 + semver 4487282d78122a2 https://github.com/blang/semver + 45e413d7515e7c5 + 16b70c33fd golog f7561df84e64ab9 https://github.com/kataras/golog 212f021923ce4ff db5df5594d diff --git a/README.md b/README.md index c8e78abb..f13cbefe 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ [![Black Lives Matter](https://iris-go.com/images/blacklivesmatter_banner.png)](https://support.eji.org/give/153413/#!/donation/checkout) - + + +# Happy New Year 🎉 + +Thank you for being with us every step of the way in 2020. I hope the next years brings you only good luck and great joy. @@ -31,6 +36,13 @@ With your help, we can improve Open Source web development for everyone! > Donations from **China** are now accepted!

+ Horst Ender + Pavithran + MULYAWAN SENTOSA + KIT UNITED + Ricardo Hernandez Lopez + ChinChuanKuo + Nikhar Saxena Servio Zambrano Nate Anderson Claude Muller diff --git a/_examples/auth/basicauth/basic/main.go b/_examples/auth/basicauth/basic/main.go index 36465501..7c663cfd 100644 --- a/_examples/auth/basicauth/basic/main.go +++ b/_examples/auth/basicauth/basic/main.go @@ -64,14 +64,11 @@ func main() { } func handler(ctx iris.Context) { - // username, password, _ := ctx.Request().BasicAuth() - // third parameter it will be always true because the middleware - // makes sure for that, otherwise this handler will not be executed. - // OR: - user := ctx.User() - // OR ctx.User().GetRaw() to get the underline value. - username, _ := user.GetUsername() - password, _ := user.GetPassword() + // user := ctx.User().(*myUserType) + // or ctx.User().GetRaw().(*myUserType) + // ctx.Writef("%s %s:%s", ctx.Path(), user.Username, user.Password) + // OR if you don't have registered custom User structs: + username, password, _ := ctx.Request().BasicAuth() ctx.Writef("%s %s:%s", ctx.Path(), username, password) } diff --git a/_examples/auth/jwt/basic/main.go b/_examples/auth/jwt/basic/main.go index f37ce681..ddcb9a58 100644 --- a/_examples/auth/jwt/basic/main.go +++ b/_examples/auth/jwt/basic/main.go @@ -4,16 +4,10 @@ import ( "time" "github.com/kataras/iris/v12" - "github.com/kataras/jwt" + "github.com/kataras/iris/v12/middleware/jwt" ) /* -Learn how to use any JWT 3rd-party package with Iris. -In this example we use the kataras/jwt one. - -Install with: - go get -u github.com/kataras/jwt - Documentation: https://github.com/kataras/jwt#table-of-contents */ @@ -71,6 +65,7 @@ func protected(ctx iris.Context) { // Just an example on how you can retrieve all the standard claims (set by jwt.MaxAge, "exp"). standardClaims := jwt.GetVerifiedToken(ctx).StandardClaims + expiresAtString := standardClaims.ExpiresAt().Format(ctx.Application().ConfigurationReadOnly().GetTimeFormat()) timeLeft := standardClaims.Timeleft() diff --git a/_examples/mvc/error-handler-preflight/main.go b/_examples/mvc/error-handler-preflight/main.go index 34289a95..e25ab5e2 100644 --- a/_examples/mvc/error-handler-preflight/main.go +++ b/_examples/mvc/error-handler-preflight/main.go @@ -29,7 +29,7 @@ type response struct { Timestamp int64 `json:"timestamp,omitempty"` } -func (r response) Preflight(ctx iris.Context) error { +func (r *response) Preflight(ctx iris.Context) error { if r.ID > 0 { r.Timestamp = time.Now().Unix() } @@ -64,15 +64,15 @@ type user struct { ID uint64 `json:"id"` } -func (c *controller) GetBy(userid uint64) response { +func (c *controller) GetBy(userid uint64) *response { if userid != 1 { - return response{ + return &response{ Code: iris.StatusNotFound, Message: "User Not Found", } } - return response{ + return &response{ ID: userid, Data: user{ID: userid}, } diff --git a/_examples/mvc/versioned-controller/main.go b/_examples/mvc/versioned-controller/main.go index 9c8bb950..56cedb47 100644 --- a/_examples/mvc/versioned-controller/main.go +++ b/_examples/mvc/versioned-controller/main.go @@ -28,10 +28,10 @@ func newApp() *iris.Application { { m := mvc.New(dataRouter) - m.Handle(new(v1Controller), mvc.Version("1"), mvc.Deprecated(opts)) // 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)) // or if missing it will respond with 501 version not found. + m.Handle(new(v1Controller), mvc.Version("1.0.0"), mvc.Deprecated(opts)) + m.Handle(new(v2Controller), mvc.Version("2.3.0")) + m.Handle(new(v3Controller), mvc.Version(">=3.0.0 <4.0.0")) + m.Handle(new(noVersionController)) // or if missing it will respond with 501 version not found. } return app diff --git a/_examples/mvc/versioned-controller/main_test.go b/_examples/mvc/versioned-controller/main_test.go index 309a14f1..33cedd42 100644 --- a/_examples/mvc/versioned-controller/main_test.go +++ b/_examples/mvc/versioned-controller/main_test.go @@ -12,21 +12,21 @@ func TestVersionedController(t *testing.T) { app := newApp() e := httptest.New(t, app) - e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "1").Expect(). + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "1.0.0").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(). + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "3.1.0").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(). + e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "4.0.0").Expect(). Status(iris.StatusOK).Body().Equal("data") e.GET("/data").Expect(). Status(iris.StatusOK).Body().Equal("data") // Test Deprecated (v1) - ex := e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "1.0").Expect() + ex := e.GET("/data").WithHeader(versioning.AcceptVersionHeaderKey, "1.0.0").Expect() ex.Status(iris.StatusOK).Body().Equal("data (v1.x)") ex.Header("X-API-Warn").Equal(opts.WarnMessage) expectedDateStr := opts.DeprecationDate.Format(app.ConfigurationReadOnly().GetTimeFormat()) diff --git a/_examples/routing/basic/main.go b/_examples/routing/basic/main.go index 1ba67a82..a22c1eb6 100644 --- a/_examples/routing/basic/main.go +++ b/_examples/routing/basic/main.go @@ -152,7 +152,7 @@ func newApp() *iris.Application { } // wildcard subdomains. - wildcardSubdomain := app.Party("*.") + wildcardSubdomain := app.WildcardSubdomain() { wildcardSubdomain.Get("/", func(ctx iris.Context) { ctx.Writef("Subdomain can be anything, now you're here from: %s", ctx.Subdomain()) diff --git a/_examples/routing/main.go b/_examples/routing/main.go index d7537ea4..5dd843c0 100644 --- a/_examples/routing/main.go +++ b/_examples/routing/main.go @@ -82,11 +82,11 @@ func registerGamesRoutes(app *iris.Application) { } func registerSubdomains(app *iris.Application) { - mysubdomain := app.Party("mysubdomain.") + mysubdomain := app.Subdomain("mysubdomain") // http://mysubdomain.myhost.com mysubdomain.Get("/", h) - willdcardSubdomain := app.Party("*.") + willdcardSubdomain := app.WildcardSubdomain() willdcardSubdomain.Get("/", h) willdcardSubdomain.Party("/party").Get("/", h) } diff --git a/_examples/routing/overview/main.go b/_examples/routing/overview/main.go index 2e327a2a..0c42bfcd 100644 --- a/_examples/routing/overview/main.go +++ b/_examples/routing/overview/main.go @@ -116,7 +116,7 @@ func main() { adminRoutes.Get("/settings", info) // Wildcard/dynamic subdomain - dynamicSubdomainRoutes := app.Party("*.") + dynamicSubdomainRoutes := app.WildcardSubdomain() // GET: http://any_thing_here.localhost:8080 dynamicSubdomainRoutes.Get("/", info) diff --git a/_examples/routing/subdomains/wildcard/main.go b/_examples/routing/subdomains/wildcard/main.go index 0eb3dc30..fb12f8f0 100644 --- a/_examples/routing/subdomains/wildcard/main.go +++ b/_examples/routing/subdomains/wildcard/main.go @@ -33,7 +33,7 @@ func main() { }*/ // no order, you can register subdomains at the end also. - dynamicSubdomains := app.Party("*.") + dynamicSubdomains := app.WildcardSubdomain() { dynamicSubdomains.Get("/", dynamicSubdomainHandler) diff --git a/_examples/routing/versioning/main.go b/_examples/routing/versioning/main.go index 3502173f..3e18aa7e 100644 --- a/_examples/routing/versioning/main.go +++ b/_examples/routing/versioning/main.go @@ -8,71 +8,95 @@ import ( func main() { app := iris.New() - examplePerRoute(app) - examplePerParty(app) + app.OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) { + ctx.WriteString(`Root not found handler. + This will be applied everywhere except the /api/* requests.`) + }) + + api := app.Party("/api") + // Optional, set version aliases (literal strings). + // We use `UseRouter` instead of `Use` + // to handle HTTP errors per version, but it's up to you. + api.UseRouter(versioning.Aliases(versioning.AliasMap{ + // If no version provided by the client, default it to the "1.0.0". + versioning.Empty: "1.0.0", + // If a "latest" version is provided by the client, + // set the version to be compared to "3.0.0". + "latest": "3.0.0", + })) + + /* + A version is extracted through the versioning.GetVersion function, + request headers: + - Accept-Version: 1.0.0 + - Accept: application/json; version=1.0.0 + You can customize it by setting a version based on the request context: + api.Use(func(ctx *context.Context) { + if version := ctx.URLParam("version"); version != "" { + versioning.SetVersion(ctx, version) + } + + ctx.Next() + }) + OR: api.Use(versioning.FromQuery("version", "")) + */ + + // |----------------| + // | The fun begins | + // |----------------| + + // Create a new Group, which is a compatible Party, + // based on version constraints. + v1 := versioning.NewGroup(api, ">=1.0.0 <2.0.0") + // To mark an API version as deprecated use the Deprecated method. + // v1.Deprecated(versioning.DefaultDeprecationOptions) + + // Optionally, set custom view engine and path + // for templates based on the version. + v1.RegisterView(iris.HTML("./v1", ".html")) + + // Optionally, set custom error handler(s) based on the version. + // Keep in mind that if you do this, you will + // have to register error handlers + // for the rest of the parties as well. + v1.OnErrorCode(iris.StatusNotFound, testError("v1")) + + // Register resources based on the version. + v1.Get("/", testHandler("v1")) + v1.Get("/render", testView) + + // Do the same for version 2 and version 3, + // for the sake of the example. + v2 := versioning.NewGroup(api, ">=2.0.0 <3.0.0") + v2.RegisterView(iris.HTML("./v2", ".html")) + v2.OnErrorCode(iris.StatusNotFound, testError("v2")) + v2.Get("/", testHandler("v2")) + v2.Get("/render", testView) + + v3 := versioning.NewGroup(api, ">=3.0.0 <4.0.0") + v3.RegisterView(iris.HTML("./v3", ".html")) + v3.OnErrorCode(iris.StatusNotFound, testError("v3")) + v3.Get("/", testHandler("v3")) + v3.Get("/render", testView) - // Read the README.md before any action. app.Listen(":8080") } -// How to test: -// Open Postman -// GET: localhost:8080/api/cats -// Headers[1] = Accept-Version: "1" and repeat with -// Headers[1] = Accept-Version: "2.5" -// or even "Accept": "application/json; version=2.5" -func examplePerRoute(app *iris.Application) { - app.Get("/api/cats", versioning.NewMatcher(versioning.Map{ - "1": catsVersionExactly1Handler, - ">= 2, < 3": catsV2Handler, - versioning.NotFound: versioning.NotFoundHandler, - })) +func testHandler(v string) iris.Handler { + return func(ctx iris.Context) { + ctx.JSON(iris.Map{ + "version": v, + "message": "Hello, world!", + }) + } } -// How to test: -// Open Postman -// GET: localhost:8080/api/users -// Headers[1] = Accept-Version: "1.9.9" and repeat with -// Headers[1] = Accept-Version: "2.5" -// -// POST: localhost:8080/api/users/new -// Headers[1] = Accept-Version: "1.8.3" -// -// POST: localhost:8080/api/users -// Headers[1] = Accept-Version: "2" -func examplePerParty(app *iris.Application) { - usersAPI := app.Party("/api/users") - // You can customize the way a version is extracting - // via middleware, for example: - // version url parameter, and, if it's missing we default it to "1". - usersAPI.Use(func(ctx iris.Context) { - versioning.SetVersion(ctx, ctx.URLParamDefault("version", "1")) - ctx.Next() - }) - - // version 1. - usersAPIV1 := versioning.NewGroup(usersAPI, ">= 1, < 2") - usersAPIV1.Get("/", func(ctx iris.Context) { - ctx.Writef("v1 resource: /api/users handler") - }) - usersAPIV1.Post("/new", func(ctx iris.Context) { - ctx.Writef("v1 resource: /api/users/new post handler") - }) - - // version 2. - usersAPIV2 := versioning.NewGroup(usersAPI, ">= 2, < 3") - usersAPIV2.Get("/", func(ctx iris.Context) { - ctx.Writef("v2 resource: /api/users handler") - }) - usersAPIV2.Post("/", func(ctx iris.Context) { - ctx.Writef("v2 resource: /api/users post handler") - }) +func testError(v string) iris.Handler { + return func(ctx iris.Context) { + ctx.Writef("not found: %s", v) + } } -func catsVersionExactly1Handler(ctx iris.Context) { - ctx.Writef("v1 exactly resource: /api/cats handler") -} - -func catsV2Handler(ctx iris.Context) { - ctx.Writef("v2 resource: /api/cats handler") +func testView(ctx iris.Context) { + ctx.View("index.html") } diff --git a/_examples/routing/versioning/v1/index.html b/_examples/routing/versioning/v1/index.html new file mode 100644 index 00000000..efe99490 --- /dev/null +++ b/_examples/routing/versioning/v1/index.html @@ -0,0 +1 @@ +

This is the directory for version 1 templates

\ No newline at end of file diff --git a/_examples/routing/versioning/v2/index.html b/_examples/routing/versioning/v2/index.html new file mode 100644 index 00000000..802d7081 --- /dev/null +++ b/_examples/routing/versioning/v2/index.html @@ -0,0 +1 @@ +

This is the directory for version 2 templates

\ No newline at end of file diff --git a/_examples/routing/versioning/v3/index.html b/_examples/routing/versioning/v3/index.html new file mode 100644 index 00000000..b1e3fdc7 --- /dev/null +++ b/_examples/routing/versioning/v3/index.html @@ -0,0 +1 @@ +

This is the directory for version 3 templates

\ No newline at end of file diff --git a/_examples/sessions/overview/example/example.go b/_examples/sessions/overview/example/example.go index e47624d7..7472e571 100644 --- a/_examples/sessions/overview/example/example.go +++ b/_examples/sessions/overview/example/example.go @@ -114,7 +114,7 @@ func NewApp(sess *sessions.Sessions) *iris.Application { app.Get("/delete", func(ctx iris.Context) { session := sessions.Get(ctx) // delete a specific key - session.Delete("name") + session.Delete("username") }) app.Get("/clear", func(ctx iris.Context) { diff --git a/_examples/sessions/securecookie/main_test.go b/_examples/sessions/securecookie/main_test.go index 775c698d..6b0304b1 100644 --- a/_examples/sessions/securecookie/main_test.go +++ b/_examples/sessions/securecookie/main_test.go @@ -16,12 +16,12 @@ func TestSessionsEncodeDecode(t *testing.T) { es.Cookies().NotEmpty() 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") + e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The username 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: ") + e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The username on the /set was: ") // set, clear and re-get 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: ") + e.GET("/get").Expect().Status(iris.StatusOK).Body().Equal("The username on the /set was: ") } diff --git a/_examples/testing/httptest/main.go b/_examples/testing/httptest/main.go index a2bb3101..35dcf78c 100644 --- a/_examples/testing/httptest/main.go +++ b/_examples/testing/httptest/main.go @@ -37,6 +37,11 @@ func h(ctx iris.Context) { // third parameter it will be always true because the middleware // makes sure for that, otherwise this handler will not be executed. // OR: + // + // user := ctx.User().(*myUserType) + // ctx.Writef("%s %s:%s", ctx.Path(), user.Username, user.Password) + // OR if you don't have registered custom User structs: + // // ctx.User().GetUsername() // ctx.User().GetPassword() ctx.Writef("%s %s:%s", ctx.Path(), username, password) diff --git a/_examples/view/template_html_4/main.go b/_examples/view/template_html_4/main.go index ef0b97b8..9c0e4b2b 100644 --- a/_examples/view/template_html_4/main.go +++ b/_examples/view/template_html_4/main.go @@ -27,7 +27,7 @@ func main() { // wildcard subdomain, will catch username1.... username2.... username3... username4.... username5... // that our below links are providing via page.html's first argument which is the subdomain. - subdomain := app.Party("*.") + subdomain := app.WildcardSubdomain() mypathRoute := subdomain.Get("/mypath", emptyHandler) mypathRoute.Name = "my-page1" diff --git a/configuration.go b/configuration.go index bc9f04e8..7e96aac9 100644 --- a/configuration.go +++ b/configuration.go @@ -764,9 +764,14 @@ type Configuration struct { // Defaults to "iris.locale.language.input". LanguageInputContextKey string `ini:"language_input_context_key" json:"languageInputContextKey,omitempty" yaml:"LanguageInputContextKey" toml:"LanguageInputContextKey"` // VersionContextKey is the context key which an API Version can be modified - // via a middleware through `SetVersion` method, e.g. `versioning.SetVersion(ctx, "1.0, 1.1")`. + // via a middleware through `SetVersion` method, e.g. `versioning.SetVersion(ctx, ">=1.0.0 <2.0.0")`. // Defaults to "iris.api.version". VersionContextKey string `ini:"version_context_key" json:"versionContextKey" yaml:"VersionContextKey" toml:"VersionContextKey"` + // VersionAliasesContextKey is the context key which the versioning feature + // can look up for alternative values of a version and fallback to that. + // Head over to the versioning package for more. + // Defaults to "iris.api.version.aliases" + VersionAliasesContextKey string `ini:"version_aliases_context_key" json:"versionAliasesContextKey" yaml:"VersionAliasesContextKey" toml:"VersionAliasesContextKey"` // ViewEngineContextKey is the context's values key // responsible to store and retrieve(view.Engine) the current view engine. // A middleware or a Party can modify its associated value to change @@ -974,6 +979,11 @@ func (c Configuration) GetVersionContextKey() string { return c.VersionContextKey } +// GetVersionAliasesContextKey returns the VersionAliasesContextKey field. +func (c Configuration) GetVersionAliasesContextKey() string { + return c.VersionAliasesContextKey +} + // GetViewEngineContextKey returns the ViewEngineContextKey field. func (c Configuration) GetViewEngineContextKey() string { return c.ViewEngineContextKey @@ -1132,6 +1142,10 @@ func WithConfiguration(c Configuration) Configurator { main.VersionContextKey = v } + if v := c.VersionAliasesContextKey; v != "" { + main.VersionAliasesContextKey = v + } + if v := c.ViewEngineContextKey; v != "" { main.ViewEngineContextKey = v } @@ -1205,16 +1219,17 @@ func DefaultConfiguration() Configuration { // The request body the size limit // can be set by the middleware `LimitRequestBodySize` // or `context#SetMaxRequestBodySize`. - PostMaxMemory: 32 << 20, // 32MB - LocaleContextKey: "iris.locale", - LanguageContextKey: "iris.locale.language", - LanguageInputContextKey: "iris.locale.language.input", - VersionContextKey: "iris.api.version", - ViewEngineContextKey: "iris.view.engine", - ViewLayoutContextKey: "iris.view.layout", - ViewDataContextKey: "iris.view.data", - RemoteAddrHeaders: nil, - RemoteAddrHeadersForce: false, + PostMaxMemory: 32 << 20, // 32MB + LocaleContextKey: "iris.locale", + LanguageContextKey: "iris.locale.language", + LanguageInputContextKey: "iris.locale.language.input", + VersionContextKey: "iris.api.version", + VersionAliasesContextKey: "iris.api.version.aliases", + ViewEngineContextKey: "iris.view.engine", + ViewLayoutContextKey: "iris.view.layout", + ViewDataContextKey: "iris.view.data", + RemoteAddrHeaders: nil, + RemoteAddrHeadersForce: false, RemoteAddrPrivateSubnets: []netutil.IPRange{ { Start: "10.0.0.0", diff --git a/context/configuration.go b/context/configuration.go index ea0e7f25..c2dcb6e6 100644 --- a/context/configuration.go +++ b/context/configuration.go @@ -53,6 +53,8 @@ type ConfigurationReadOnly interface { GetLanguageInputContextKey() string // GetVersionContextKey returns the VersionContextKey field. GetVersionContextKey() string + // GetVersionAliasesContextKey returns the VersionAliasesContextKey field. + GetVersionAliasesContextKey() string // GetViewEngineContextKey returns the ViewEngineContextKey field. GetViewEngineContextKey() string diff --git a/context/context.go b/context/context.go index b5e139b2..efedee3f 100644 --- a/context/context.go +++ b/context/context.go @@ -1974,6 +1974,13 @@ func (ctx *Context) UploadFormFiles(destDirectory string, before ...func(*Contex for _, files := range fhs { innerLoop: for _, file := range files { + // Fix an issue that net/http has, + // an attacker can push a filename + // which could lead to override existing system files + // by ../../$file. + // Reported by Frank through security reports. + file.Filename = strings.TrimLeft(file.Filename, "../") + file.Filename = strings.TrimLeft(file.Filename, "..\\") for _, b := range before { if !b(ctx, file) { diff --git a/context/context_user.go b/context/context_user.go index 794b78a9..d76ba6d4 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -81,15 +81,15 @@ to the end-developer's custom implementations. // SimpleUser is a simple implementation of the User interface. type SimpleUser struct { - Authorization string `json:"authorization,omitempty"` - AuthorizedAt time.Time `json:"authorized_at,omitempty"` - ID string `json:"id,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - Email string `json:"email,omitempty"` - Roles []string `json:"roles,omitempty"` - Token json.RawMessage `json:"token,omitempty"` - Fields Map `json:"fields,omitempty"` + Authorization string `json:"authorization,omitempty" db:"authorization"` + AuthorizedAt time.Time `json:"authorized_at,omitempty" db:"authorized_at"` + ID string `json:"id,omitempty" db:"id"` + Username string `json:"username,omitempty" db:"username"` + Password string `json:"password,omitempty" db:"password"` + Email string `json:"email,omitempty" db:"email"` + Roles []string `json:"roles,omitempty" db:"roles"` + Token json.RawMessage `json:"token,omitempty" db:"token"` + Fields Map `json:"fields,omitempty" db:"fields"` } var _ User = (*SimpleUser)(nil) diff --git a/context/handler.go b/context/handler.go index d8744ee6..13f94128 100644 --- a/context/handler.go +++ b/context/handler.go @@ -239,6 +239,10 @@ var ignoreMainHandlerNames = [...]string{ "iris.reCAPTCHA", "iris.profiling", "iris.recover", + "iris.accesslog", + "iris.grpc", + "iris.requestid", + "iris.rewrite", } // ingoreMainHandlerName reports whether a main handler of "name" should diff --git a/context/route.go b/context/route.go index 8f3db812..4cc4794e 100644 --- a/context/route.go +++ b/context/route.go @@ -62,6 +62,10 @@ type RouteReadOnly interface { // MainHandlerIndex returns the first registered handler's index for the route. MainHandlerIndex() int + // Property returns a specific property based on its "key" + // of this route's Party owner. + Property(key string) (interface{}, bool) + // Sitemap properties: https://www.sitemaps.org/protocol.html // GetLastMod returns the date of last modification of the file served by this route. diff --git a/core/router/api_builder.go b/core/router/api_builder.go index 34f3769a..4da5024d 100644 --- a/core/router/api_builder.go +++ b/core/router/api_builder.go @@ -164,7 +164,7 @@ func overlapRoute(r *Route, next *Route) { // Version was not found: // we need to be able to send the status on the last not found version // but reset the status code if a next available matched version was found. - // see: versioning.Handler. + // see the versioning package. if !errors.Is(ctx.GetErr(), context.ErrNotFound) { ctx.StatusCode(prevStatusCode) } @@ -196,6 +196,11 @@ type APIBuilder struct { // the api builder global macros registry macros *macro.Macros + // the per-party (and its children) values map + // that may help on building the API + // when source code is splitted between projects. + // Initialized on Properties method. + properties context.Map // the api builder global routes repository routes *repository // disables the debug logging of routes under a per-party and its children. @@ -624,7 +629,7 @@ func (api *APIBuilder) createRoutes(errorCode int, methods []string, relativePat routes := make([]*Route, len(methods)) for i, m := range methods { // single, empty method for error handlers. - route, err := NewRoute(errorCode, m, subdomain, path, routeHandlers, *api.macros) + route, err := NewRoute(api, errorCode, m, subdomain, path, routeHandlers, *api.macros) if err != nil { // template path parser errors: api.logger.Errorf("[%s:%d] %v -> %s:%s:%s", filename, line, err, m, subdomain, path) continue @@ -668,19 +673,21 @@ func removeDuplicates(elements []string) (result []string) { // Party returns a new child Party which inherites its // parent's options and middlewares. -// If "relativePath" matches the parent's one then it returns the current Party. // A Party groups routes which may have the same prefix or subdomain and share same middlewares. // // To create a group of routes for subdomains // use the `Subdomain` or `WildcardSubdomain` methods -// or pass a "relativePath" as "admin." or "*." respectfully. +// or pass a "relativePath" of "admin." or "*." respectfully. func (api *APIBuilder) Party(relativePath string, handlers ...context.Handler) Party { // if app.Party("/"), root party or app.Party("/user") == app.Party("/user") // then just add the middlewares and return itself. - if relativePath == "" || api.relativePath == relativePath { - api.Use(handlers...) - return api - } + // if relativePath == "" || api.relativePath == relativePath { + // api.Use(handlers...) + // return api + // } + // ^ No, this is wrong, let the developer do its job, if she/he wants a copy let have it, + // it's a pure check as well, a path can be the same even if it's the same as its parent, i.e. + // app.Party("/user").Party("/user") should result in a /user/user, not a /user. parentPath := api.relativePath dot := string(SubdomainPrefix[0]) @@ -712,10 +719,17 @@ func (api *APIBuilder) Party(relativePath string, handlers ...context.Handler) P allowMethods := make([]string, len(api.allowMethods)) copy(allowMethods, api.allowMethods) + // make a copy of the parent properties. + var properties context.Map + for k, v := range api.properties { + properties[k] = v + } + childAPI := &APIBuilder{ // global/api builder logger: api.logger, macros: api.macros, + properties: properties, routes: api.routes, routesNoLog: api.routesNoLog, beginGlobalHandlers: api.beginGlobalHandlers, @@ -808,6 +822,16 @@ func (api *APIBuilder) Macros() *macro.Macros { return api.macros } +// Properties returns the original Party's properties map, +// it can be modified before server startup but not afterwards. +func (api *APIBuilder) Properties() context.Map { + if api.properties == nil { + api.properties = make(context.Map) + } + + return api.properties +} + // GetRoutes returns the routes information, // some of them can be changed at runtime some others not. // @@ -1096,6 +1120,8 @@ func (api *APIBuilder) DoneGlobal(handlers ...context.Handler) { // RemoveHandler deletes a handler from begin and done handlers // based on its name or the handler pc function. +// Note that UseGlobal and DoneGlobal handlers cannot be removed +// through this method as they were registered to the routes already. // // As an exception, if one of the arguments is a pointer to an int, // then this is used to set the total amount of removed handlers. diff --git a/core/router/party.go b/core/router/party.go index 016c6d7e..06fe1f66 100644 --- a/core/router/party.go +++ b/core/router/party.go @@ -39,6 +39,10 @@ type Party interface { // Learn more at: https://github.com/kataras/iris/tree/master/_examples/routing/dynamic-path Macros() *macro.Macros + // Properties returns the original Party's properties map, + // it can be modified before server startup but not afterwards. + Properties() context.Map + // SetRoutesNoLog disables (true) the verbose logging for the next registered // routes under this Party and its children. // diff --git a/core/router/route.go b/core/router/route.go index 1208bf5d..6df3190f 100644 --- a/core/router/route.go +++ b/core/router/route.go @@ -19,6 +19,8 @@ import ( // If any of the following fields are changed then the // caller should Refresh the router. type Route struct { + // The Party which this Route was created and registered on. + Party Party Title string `json:"title"` // custom name to replace the method on debug logging. Name string `json:"name"` // "userRoute" Description string `json:"description"` // "lists a user" @@ -86,7 +88,7 @@ type Route struct { // handlers and the macro container which all routes should share. // It parses the path based on the "macros", // handlers are being changed to validate the macros at serve time, if needed. -func NewRoute(statusErrorCode int, method, subdomain, unparsedPath string, +func NewRoute(p Party, statusErrorCode int, method, subdomain, unparsedPath string, handlers context.Handlers, macros macro.Macros) (*Route, error) { tmpl, err := macro.Parse(unparsedPath, macros) if err != nil { @@ -110,6 +112,7 @@ func NewRoute(statusErrorCode int, method, subdomain, unparsedPath string, formattedPath := formatPath(path) route := &Route{ + Party: p, StatusCode: statusErrorCode, Name: defaultName, Method: method, @@ -583,6 +586,8 @@ type routeReadOnlyWrapper struct { *Route } +var _ context.RouteReadOnly = routeReadOnlyWrapper{} + func (rd routeReadOnlyWrapper) StatusErrorCode() int { return rd.Route.StatusCode } @@ -619,6 +624,17 @@ func (rd routeReadOnlyWrapper) MainHandlerIndex() int { return rd.Route.MainHandlerIndex } +func (rd routeReadOnlyWrapper) Property(key string) (interface{}, bool) { + properties := rd.Route.Party.Properties() + if properties != nil { + if property, ok := properties[key]; ok { + return property, true + } + } + + return nil, false +} + func (rd routeReadOnlyWrapper) GetLastMod() time.Time { return rd.Route.LastMod } diff --git a/go.mod b/go.mod index ba0f98a0..9b1ffea7 100644 --- a/go.mod +++ b/go.mod @@ -8,20 +8,20 @@ require ( github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 github.com/andybalholm/brotli v1.0.1 github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible + github.com/blang/semver/v4 v4.0.0 github.com/dgraph-io/badger/v2 v2.2007.2 github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/fatih/structs v1.1.0 github.com/flosch/pongo2/v4 v4.0.1 github.com/go-redis/redis/v8 v8.4.0 github.com/google/uuid v1.1.2 - github.com/hashicorp/go-version v1.2.1 github.com/iris-contrib/httpexpect/v2 v2.0.5 github.com/iris-contrib/jade v1.1.4 github.com/iris-contrib/schema v0.0.6 github.com/json-iterator/go v1.1.10 github.com/kataras/blocks v0.0.4 github.com/kataras/golog v0.1.6 - github.com/kataras/jwt v0.0.8 + github.com/kataras/jwt v0.0.9 github.com/kataras/neffos v0.0.18 github.com/kataras/pio v0.0.10 github.com/kataras/sitemap v0.0.5 diff --git a/hero/reflect.go b/hero/reflect.go index 64c53eba..4619c91b 100644 --- a/hero/reflect.go +++ b/hero/reflect.go @@ -18,7 +18,7 @@ func valueOf(v interface{}) reflect.Value { // indirectType returns the value of a pointer-type "typ". // If "typ" is a pointer, array, chan, map or slice it returns its Elem, -// otherwise returns the typ as it's. +// otherwise returns the "typ" as it is. func indirectType(typ reflect.Type) reflect.Type { switch typ.Kind() { case reflect.Ptr, reflect.Array, reflect.Chan, reflect.Map, reflect.Slice: diff --git a/middleware/accesslog/accesslog.go b/middleware/accesslog/accesslog.go index 195669b3..99b89415 100644 --- a/middleware/accesslog/accesslog.go +++ b/middleware/accesslog/accesslog.go @@ -323,6 +323,18 @@ func New(w io.Writer) *AccessLog { // // It panics on error. func File(path string) *AccessLog { + f := mustOpenFile(path) + return New(bufio.NewReadWriter(bufio.NewReader(f), bufio.NewWriter(f))) +} + +// FileUnbuffered same as File but it does not buffer the data, +// it flushes the loggers contents as soon as possible. +func FileUnbuffered(path string) *AccessLog { + f := mustOpenFile(path) + return New(f) +} + +func mustOpenFile(path string) *os.File { // Note: we add os.RDWR in order to be able to read from it, // some formatters (e.g. CSV) needs that. f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) @@ -330,7 +342,7 @@ func File(path string) *AccessLog { panic(err) } - return New(bufio.NewReadWriter(bufio.NewReader(f), bufio.NewWriter(f))) + return f } // Broker creates or returns the broker. diff --git a/mvc/versioning.go b/mvc/versioning.go index c16c2889..fcaf3078 100644 --- a/mvc/versioning.go +++ b/mvc/versioning.go @@ -8,14 +8,14 @@ import ( // 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". +// e.g. ">1.0.0 <=2.0.0". // // // Usage: // m := mvc.New(dataRouter) -// m.Handle(new(v1Controller), mvc.Version("1"), mvc.Deprecated(mvc.DeprecationOptions{})) -// m.Handle(new(v2Controller), mvc.Version("2.3")) -// m.Handle(new(v3Controller), mvc.Version(">=3, <4")) +// m.Handle(new(v1Controller), mvc.Version("1.0.0"), mvc.Deprecated(mvc.DeprecationOptions{})) +// m.Handle(new(v2Controller), mvc.Version("2.3.0")) +// m.Handle(new(v3Controller), mvc.Version(">=3.0.0 <4.0.0")) // m.Handle(new(noVersionController)) // // See the `versioning` package's documentation for more information on diff --git a/sessions/sessiondb/boltdb/database.go b/sessions/sessiondb/boltdb/database.go index a2b2335c..11b667e9 100644 --- a/sessions/sessiondb/boltdb/database.go +++ b/sessions/sessiondb/boltdb/database.go @@ -339,14 +339,14 @@ func (db *Database) Visit(sid string, cb func(key string, value interface{})) er } // Len returns the length of the session's entries (keys). -func (db *Database) Len(sid string) (n int64) { +func (db *Database) Len(sid string) (n int) { err := db.Service.View(func(tx *bolt.Tx) error { b := db.getBucketForSession(tx, sid) if b == nil { return nil } - n = int64(b.Stats().KeyN) + n = int(int64(b.Stats().KeyN)) return nil }) diff --git a/versioning/deprecation.go b/versioning/deprecation.go index 7e28d421..a67a9f1e 100644 --- a/versioning/deprecation.go +++ b/versioning/deprecation.go @@ -6,10 +6,17 @@ import ( "github.com/kataras/iris/v12/context" ) +// The response header keys when a resource is deprecated by the server. +const ( + APIWarnHeader = "X-Api-Warn" + APIDeprecationDateHeader = "X-Api-Deprecation-Date" + APIDeprecationInfoHeader = "X-Api-Deprecation-Info" +) + // DeprecationOptions describes the deprecation headers key-values. -// - "X-API-Warn": options.WarnMessage -// - "X-API-Deprecation-Date": context.FormatTime(ctx, options.DeprecationDate)) -// - "X-API-Deprecation-Info": options.DeprecationInfo +// - "X-Api-Warn": options.WarnMessage +// - "X-Api-Deprecation-Date": context.FormatTime(ctx, options.DeprecationDate)) +// - "X-Api-Deprecation-Info": options.DeprecationInfo type DeprecationOptions struct { WarnMessage string DeprecationDate time.Time @@ -37,14 +44,14 @@ func WriteDeprecated(ctx *context.Context, options DeprecationOptions) { options.WarnMessage = DefaultDeprecationOptions.WarnMessage } - ctx.Header("X-API-Warn", options.WarnMessage) + ctx.Header(APIWarnHeader, options.WarnMessage) if !options.DeprecationDate.IsZero() { - ctx.Header("X-API-Deprecation-Date", context.FormatTime(ctx, options.DeprecationDate)) + ctx.Header(APIDeprecationDateHeader, context.FormatTime(ctx, options.DeprecationDate)) } if options.DeprecationInfo != "" { - ctx.Header("X-API-Deprecation-Info", options.DeprecationInfo) + ctx.Header(APIDeprecationInfoHeader, options.DeprecationInfo) } } diff --git a/versioning/group.go b/versioning/group.go index 9e7f0e06..10390133 100644 --- a/versioning/group.go +++ b/versioning/group.go @@ -1,39 +1,107 @@ package versioning import ( + "strings" + "github.com/kataras/iris/v12/context" "github.com/kataras/iris/v12/core/router" + + "github.com/blang/semver/v4" ) -// Group is a group of version-based routes. -// One version per one or more routes. -type Group struct { - router.Party +// API is a type alias of router.Party. +// This is required in order for a Group instance +// to implement the Party interface without field conflict. +type API = router.Party - // Information not currently in-use. - version string +// Group represents a group of resources that should +// be handled based on a version requested by the client. +// See `NewGroup` for more. +type Group struct { + API + + validate semver.Range deprecation DeprecationOptions } -// NewGroup returns a ptr to Group based on the given "version". -// It sets the API Version for the "r" Party. +// NewGroup returns a version Group based on the given "version" constraint. +// Group completes the Party interface. +// The returned Group wraps a cloned Party of the given "r" Party therefore, +// any changes to its parent won't affect this one (e.g. register global middlewares afterwards). // -// See `Handle` for more. +// A version is extracted through the versioning.GetVersion function: +// Accept-Version: 1.0.0 +// Accept: application/json; version=1.0.0 +// You can customize it by setting a version based on the request context: +// api.Use(func(ctx *context.Context) { +// if version := ctx.URLParam("version"); version != "" { +// SetVersion(ctx, version) +// } // -// Example: _examples/routing/versioning +// ctx.Next() +// }) +// OR: +// api.Use(versioning.FromQuery("version", "")) +// +// Examples at: _examples/routing/versioning // Usage: -// api := versioning.NewGroup(Parent_Party, ">= 1, < 2") -// api.Get/Post/Put/Delete... -func NewGroup(r router.Party, version string) *Group { +// app := iris.New() +// api := app.Party("/api") +// v1 := versioning.NewGroup(api, ">=1.0.0 <2.0.0") +// v1.Get/Post/Put/Delete... +// +// Valid ranges are: +// - "<1.0.0" +// - "<=1.0.0" +// - ">1.0.0" +// - ">=1.0.0" +// - "1.0.0", "=1.0.0", "==1.0.0" +// - "!1.0.0", "!=1.0.0" +// +// A Range can consist of multiple ranges separated by space: +// Ranges can be linked by logical AND: +// - ">1.0.0 <2.0.0" would match between both ranges, so "1.1.1" and "1.8.7" +// but not "1.0.0" or "2.0.0" +// - ">1.0.0 <3.0.0 !2.0.3-beta.2" would match every version between 1.0.0 and 3.0.0 +// except 2.0.3-beta.2 +// +// Ranges can also be linked by logical OR: +// - "<2.0.0 || >=3.0.0" would match "1.x.x" and "3.x.x" but not "2.x.x" +// +// AND has a higher precedence than OR. It's not possible to use brackets. +// +// Ranges can be combined by both AND and OR +// +// - `>1.0.0 <2.0.0 || >3.0.0 !4.2.1` would match `1.2.3`, `1.9.9`, `3.1.1`, +// but not `4.2.1`, `2.1.1` +func NewGroup(r API, version string) *Group { + version = strings.ReplaceAll(version, ",", " ") + version = strings.TrimSpace(version) + + verRange, err := semver.ParseRange(version) + if err != nil { + r.Logger().Errorf("versioning: %s: %s", r.GetRelPath(), strings.ToLower(err.Error())) + return &Group{API: r} + } + + // Clone this one. + r = r.Party("/") + // Note that this feature alters the RouteRegisterRule to RouteOverlap // the RouteOverlap rule does not contain any performance downside // but it's good to know that if you registered other mode, this wanna change it. r.SetRegisterRule(router.RouteOverlap) - r.UseOnce(Handler(version)) // this is required in order to not populate this middleware to the next group. + + handler := makeHandler(verRange) + // This is required in order to not populate this middleware to the next group. + r.UseOnce(handler) + // This is required for versioned custom error handlers, + // of course if the parent registered one then this will do nothing. + r.UseError(handler) return &Group{ - Party: r, - version: version, + API: r, + validate: verRange, } } @@ -43,9 +111,27 @@ func (g *Group) Deprecated(options DeprecationOptions) *Group { // store it for future use, e.g. collect all deprecated APIs and notify the developer. g.deprecation = options - g.Party.UseOnce(func(ctx *context.Context) { + g.API.UseOnce(func(ctx *context.Context) { WriteDeprecated(ctx, options) ctx.Next() }) return g } + +func makeHandler(validate semver.Range) context.Handler { + return func(ctx *context.Context) { + if !matchVersionRange(ctx, validate) { + // The overlapped handler has an exception + // of a type of context.NotFound (which versioning.ErrNotFound wraps) + // to clear the status code + // and the error to ignore this + // when available match version exists (see `NewGroup`). + if h := NotFoundHandler; h != nil { + h(ctx) + return + } + } + + ctx.Next() + } +} diff --git a/versioning/group_test.go b/versioning/group_test.go new file mode 100644 index 00000000..8512bc16 --- /dev/null +++ b/versioning/group_test.go @@ -0,0 +1,56 @@ +package versioning_test + +import ( + "testing" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/httptest" + "github.com/kataras/iris/v12/versioning" +) + +const ( + v10Response = "v1.0 handler" + v2Response = "v2.x handler" +) + +func sendHandler(contents string) iris.Handler { + return func(ctx iris.Context) { + ctx.WriteString(contents) + } +} + +func TestNewGroup(t *testing.T) { + app := iris.New() + + userAPI := app.Party("/api/user") + // [... static serving, middlewares and etc goes here]. + + userAPIV10 := versioning.NewGroup(userAPI, "1.0.0").Deprecated(versioning.DefaultDeprecationOptions) + + userAPIV10.Get("/", sendHandler(v10Response)) + userAPIV2 := versioning.NewGroup(userAPI, ">= 2.0.0 < 3.0.0") + + userAPIV2.Get("/", sendHandler(v2Response)) + userAPIV2.Post("/", sendHandler(v2Response)) + userAPIV2.Put("/other", sendHandler(v2Response)) + + e := httptest.New(t, app) + + ex := e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "1.0.0").Expect() + ex.Status(iris.StatusOK).Body().Equal(v10Response) + ex.Header("X-API-Warn").Equal(versioning.DefaultDeprecationOptions.WarnMessage) + + e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.0.0").Expect(). + Status(iris.StatusOK).Body().Equal(v2Response) + e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.1.0").Expect(). + Status(iris.StatusOK).Body().Equal(v2Response) + e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.9.9").Expect(). + Status(iris.StatusOK).Body().Equal(v2Response) + e.POST("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.0.0").Expect(). + Status(iris.StatusOK).Body().Equal(v2Response) + e.PUT("/api/user/other").WithHeader(versioning.AcceptVersionHeaderKey, "2.9.0").Expect(). + Status(iris.StatusOK).Body().Equal(v2Response) + + e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "3.0").Expect(). + Status(iris.StatusNotImplemented).Body().Equal("version not found") +} diff --git a/versioning/version.go b/versioning/version.go index 26be5541..cf2adc87 100644 --- a/versioning/version.go +++ b/versioning/version.go @@ -5,31 +5,31 @@ import ( "strings" "github.com/kataras/iris/v12/context" + + "github.com/blang/semver/v4" ) const ( + // APIVersionResponseHeader the response header which its value contains + // the normalized semver matched version. + APIVersionResponseHeader = "X-Api-Version" // AcceptVersionHeaderKey is the header key of "Accept-Version". AcceptVersionHeaderKey = "Accept-Version" // AcceptHeaderKey is the header key of "Accept". AcceptHeaderKey = "Accept" // AcceptHeaderVersionValue is the Accept's header value search term the requested version. AcceptHeaderVersionValue = "version" - - // Key is the context key of the version, can be used to manually modify the "requested" version. - // Example of how you can change the default behavior to extract a requested version (which is by headers) - // from a "version" url parameter instead: - // func(ctx iris.Context) { // &version=1 - // ctx.Values().Set(versioning.Key, ctx.URLParamDefault("version", "1")) - // ctx.Next() - // } - // - // DEPRECATED: Use: - // version := ctx.URLParamDefault("version", "1") - // versioning.SetVersion(ctx, version) instead. - Key = "iris.api.version" // NotFound is the key that can be used inside a `Map` or inside `ctx.SetVersion(versioning.NotFound)` - // to tell that a version wasn't found, therefore the not found handler should handle the request instead. + // to tell that a version wasn't found, therefore the `NotFoundHandler` should handle the request instead. NotFound = "iris.api.version.notfound" + // Empty is just an empty string. Can be used as a key for a version alias + // when the requested version of a resource was not even specified by the client. + // The difference between NotFound and Empty is important when version aliases are registered: + // - A NotFound cannot be registered as version alias, it + // means that the client sent a version with its request + // but that version was not implemented by the server. + // - An Empty indicates that the client didn't send any version at all. + Empty = "" ) // ErrNotFound reports whether a requested version @@ -54,11 +54,89 @@ var NotFoundHandler = func(ctx *context.Context) { ctx.StopWithPlainError(501, ErrNotFound) } +// FromQuery is a simple helper which tries to +// set the version constraint from a given URL Query Parameter. +// The X-Api-Version is still valid. +func FromQuery(urlQueryParameterName string, defaultVersion string) context.Handler { + return func(ctx *context.Context) { + version := ctx.URLParam(urlQueryParameterName) + if version == "" { + version = defaultVersion + } + + if version != "" { + SetVersion(ctx, version) + } + + ctx.Next() + } +} + +// If reports whether the "got" matches the "expected" one. +// the "expected" can be a constraint like ">=1.0.0 <2.0.0". +// This function is just a helper, better use the Group instead. +func If(got string, expected string) bool { + v, err := semver.Make(got) + if err != nil { + return false + } + + validate, err := semver.ParseRange(expected) + if err != nil { + return false + } + + return validate(v) +} + +// Match reports whether the request matches the expected version. +// This function is just a helper, better use the Group instead. +func Match(ctx *context.Context, expectedVersion string) bool { + validate, err := semver.ParseRange(expectedVersion) + if err != nil { + return false + } + + return matchVersionRange(ctx, validate) +} + +func matchVersionRange(ctx *context.Context, validate semver.Range) bool { + gotVersion := GetVersion(ctx) + + alias, aliasFound := GetVersionAlias(ctx, gotVersion) + if aliasFound { + SetVersion(ctx, alias) // set the version so next routes have it already. + gotVersion = alias + } + + if gotVersion == "" { + return false + } + + v, err := semver.Make(gotVersion) + if err != nil { + return false + } + + if !validate(v) { + return false + } + + versionString := v.String() + + if !aliasFound { // don't lose any time to set if already set. + SetVersion(ctx, versionString) + } + + ctx.Header(APIVersionResponseHeader, versionString) + return true +} + // GetVersion returns the current request version. // // By default the `GetVersion` will try to read from: -// - "Accept" header, i.e Accept: "application/json; version=1.0" -// - "Accept-Version" header, i.e Accept-Version: "1.0" +// - "Accept" header, i.e Accept: "application/json; version=1.0.0" +// - "Accept-Version" header, i.e Accept-Version: "1.0.0" // // However, the end developer can also set a custom version for a handler via a middleware by using the context's store key // for versions (see `Key` for further details on that). @@ -107,7 +185,129 @@ func GetVersion(ctx *context.Context) string { // SetVersion force-sets the API Version. // It can be used inside a middleware. +// Example of how you can change the default behavior to extract a requested version (which is by headers) +// from a "version" url parameter instead: +// func(ctx iris.Context) { // &version=1 +// version := ctx.URLParamDefault("version", "1.0.0") +// versioning.SetVersion(ctx, version) +// ctx.Next() +// } // See `GetVersion` too. func SetVersion(ctx *context.Context, constraint string) { ctx.Values().Set(ctx.Application().ConfigurationReadOnly().GetVersionContextKey(), constraint) } + +// AliasMap is just a type alias of the standard map[string]string. +// Head over to the `Aliases` function below for more. +type AliasMap = map[string]string + +// Aliases is a middleware which registers version constraint aliases +// for the children Parties(routers). It's respected by versioning Groups. +// +// Example Code: +// app := iris.New() +// +// api := app.Party("/api") +// api.Use(Aliases(map[string]string{ +// versioning.Empty: "1.0.0", // when no version was provided by the client. +// "beta": "4.0.0", +// "stage": "5.0.0-alpha" +// })) +// +// v1 := NewGroup(api, ">=1.0.0 < 2.0.0") +// v1.Get/Post... +// +// v4 := NewGroup(api, ">=4.0.0 < 5.0.0") +// v4.Get/Post... +// +// stage := NewGroup(api, "5.0.0-alpha") +// stage.Get/Post... +func Aliases(aliases AliasMap) context.Handler { + cp := make(AliasMap, len(aliases)) // copy the map here so we are safe of later modifications by end-dev. + for k, v := range aliases { + cp[k] = v + } + + return func(ctx *context.Context) { + SetVersionAliases(ctx, cp, true) + ctx.Next() + } +} + +// Handler returns a handler which is only fired +// when the "version" is matched with the requested one. +// It is not meant to be used by end-developers +// (exported for version controller feature). +// Use `NewGroup` instead. +func Handler(version string) context.Handler { + validate, err := semver.ParseRange(version) + if err != nil { + return func(ctx *context.Context) { + ctx.StopWithError(500, err) + } + } + + return makeHandler(validate) +} + +// GetVersionAlias returns the version alias of the given "gotVersion" +// or empty. It Reports whether the alias was found. +// See `SetVersionAliases`, `Aliases` and `Match` for more. +func GetVersionAlias(ctx *context.Context, gotVersion string) (string, bool) { + key := ctx.Application().ConfigurationReadOnly().GetVersionAliasesContextKey() + if key == "" { + return "", false + } + + v := ctx.Values().Get(key) + if v == nil { + return "", false + } + + aliases, ok := v.(AliasMap) + if !ok { + return "", false + } + + version, ok := aliases[gotVersion] + if !ok { + return "", false + } + + return strings.TrimSpace(version), true +} + +// SetVersionAliases sets a map of version aliases when a requested +// version of a resource was not implemented by the server. +// Can be used inside a middleware to the parent Party +// and always before the child versioning groups (see `Aliases` function). +// +// The map's key (string) should be the "got version" (by the client) +// and the value should be the "version constraint to match" instead. +// The map's value(string) should be a registered version +// otherwise it will hit the NotFoundHandler (501, "version not found" by default). +// +// The given "aliases" is a type of standard map[string]string and +// should NOT be modified afterwards. +// +// The last "override" input argument indicates whether any +// existing aliases, registered by previous handlers in the chain, +// should be overridden or copied to the previous map one. +func SetVersionAliases(ctx *context.Context, aliases AliasMap, override bool) { + key := ctx.Application().ConfigurationReadOnly().GetVersionAliasesContextKey() + if key == "" { + return + } + + v := ctx.Values().Get(key) + if v == nil || override { + ctx.Values().Set(key, aliases) + return + } + + if existing, ok := v.(AliasMap); ok { + for k, v := range aliases { + existing[k] = v + } + } +} diff --git a/versioning/version_test.go b/versioning/version_test.go index eab10ea9..f403fae2 100644 --- a/versioning/version_test.go +++ b/versioning/version_test.go @@ -8,6 +8,15 @@ import ( "github.com/kataras/iris/v12/versioning" ) +func TestIf(t *testing.T) { + if expected, got := true, versioning.If("1.0.0", ">=1.0.0"); expected != got { + t.Fatalf("expected %s to be %s", "1.0.0", ">= 1.0.0") + } + if expected, got := true, versioning.If("1.2.3", "> 1.2.0"); expected != got { + t.Fatalf("expected %s to be %s", "1.2.3", "> 1.2.0") + } +} + func TestGetVersion(t *testing.T) { app := iris.New() @@ -23,16 +32,16 @@ func TestGetVersion(t *testing.T) { e := httptest.New(t, app) - e.GET("/").WithHeader(versioning.AcceptVersionHeaderKey, "1.0").Expect(). - Status(iris.StatusOK).Body().Equal("1.0") - e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1").Expect(). - Status(iris.StatusOK).Body().Equal("2.1") - e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1 ;other=dsa").Expect(). - Status(iris.StatusOK).Body().Equal("2.1") - e.GET("/").WithHeader(versioning.AcceptHeaderKey, "version=2.1").Expect(). - Status(iris.StatusOK).Body().Equal("2.1") - e.GET("/").WithHeader(versioning.AcceptHeaderKey, "version=1").Expect(). - Status(iris.StatusOK).Body().Equal("1") + e.GET("/").WithHeader(versioning.AcceptVersionHeaderKey, "1.0.0").Expect(). + Status(iris.StatusOK).Body().Equal("1.0.0") + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1.0").Expect(). + Status(iris.StatusOK).Body().Equal("2.1.0") + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1.0 ;other=dsa").Expect(). + Status(iris.StatusOK).Body().Equal("2.1.0") + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "version=2.1.0").Expect(). + Status(iris.StatusOK).Body().Equal("2.1.0") + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "version=1.0.0").Expect(). + Status(iris.StatusOK).Body().Equal("1.0.0") // unknown versions. e.GET("/").WithHeader(versioning.AcceptVersionHeaderKey, "").Expect(). @@ -46,3 +55,53 @@ func TestGetVersion(t *testing.T) { e.GET("/manual").Expect().Status(iris.StatusOK).Body().Equal("11.0.5") } + +func TestVersionAliases(t *testing.T) { + app := iris.New() + + api := app.Party("/api") + api.Use(versioning.Aliases(map[string]string{ + versioning.Empty: "1.0.0", + "stage": "2.0.0", + })) + + writeVesion := func(ctx iris.Context) { + ctx.WriteString(versioning.GetVersion(ctx)) + } + + // A group without registration order. + v3 := versioning.NewGroup(api, ">= 3.0.0 < 4.0.0") + v3.Get("/", writeVesion) + + v1 := versioning.NewGroup(api, ">= 1.0.0 < 2.0.0") + v1.Get("/", writeVesion) + + v2 := versioning.NewGroup(api, ">= 2.0.0 < 3.0.0") + v2.Get("/", writeVesion) + + api.Get("/manual", func(ctx iris.Context) { + versioning.SetVersion(ctx, "12.0.0") + ctx.Next() + }, writeVesion) + + e := httptest.New(t, app) + + // Make sure the SetVersion still works. + e.GET("/api/manual").Expect().Status(iris.StatusOK).Body().Equal("12.0.0") + + // Test Empty default. + e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "").Expect(). + Status(iris.StatusOK).Body().Equal("1.0.0") + // Test NotFound error, aliases are not responsible for that. + e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "4.0.0").Expect(). + Status(iris.StatusNotImplemented).Body().Equal("version not found") + // Test "stage" alias. + e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "stage").Expect(). + Status(iris.StatusOK).Body().Equal("2.0.0") + // Test version 2. + e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "2.0.0").Expect(). + Status(iris.StatusOK).Body().Equal("2.0.0") + // Test version 3 (registered first). + e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "3.1.0").Expect(). + Status(iris.StatusOK).Body().Equal("3.1.0") +} diff --git a/versioning/versioning.go b/versioning/versioning.go deleted file mode 100644 index 4041f304..00000000 --- a/versioning/versioning.go +++ /dev/null @@ -1,154 +0,0 @@ -package versioning - -import ( - "github.com/kataras/iris/v12/context" - - "github.com/hashicorp/go-version" -) - -// If reports whether the "version" is matching to the "is". -// the "is" can be a constraint like ">= 1, < 3". -func If(v string, is string) bool { - _, ok := check(v, is) - return ok -} - -func check(v string, is string) (string, bool) { - ver, err := version.NewVersion(v) - if err != nil { - return "", false - } - - constraints, err := version.NewConstraint(is) - if err != nil { - return "", false - } - - // return the extracted version from request, even if not matched. - return ver.String(), constraints.Check(ver) -} - -// Match acts exactly the same as `If` does but instead it accepts -// a Context, so it can be called by a handler to determinate the requested version. -// -// If matched then it sets the "X-API-Version" response header and -// stores the matched version into Context (see `GetVersion` too). -func Match(ctx *context.Context, expectedVersion string) bool { - versionString, matched := check(GetVersion(ctx), expectedVersion) - if !matched { - return false - } - - SetVersion(ctx, versionString) - ctx.Header("X-API-Version", versionString) - return true -} - -// Handler returns a handler which stop the execution -// when the given "version" does not match with the requested one. -func Handler(version string) context.Handler { - return func(ctx *context.Context) { - if !Match(ctx, version) { - // The overlapped handler has an exception - // of a type of context.NotFound (which versioning.ErrNotFound wraps) - // to clear the status code - // and the error to ignore this - // when available match version exists (see `NewGroup`). - NotFoundHandler(ctx) - return - } - - ctx.Next() - } -} - -// Map is a map of versions targets to a handlers, -// a handler per version or constraint, the key can be something like ">1, <=2" or just "1". -type Map map[string]context.Handler - -// NewMatcher creates a single handler which decides what handler -// should be executed based on the requested version. -// -// Use the `NewGroup` if you want to add many routes under a specific version. -// -// See `Map` and `NewGroup` too. -func NewMatcher(versions Map) context.Handler { - constraintsHandlers, notFoundHandler := buildConstraints(versions) - - return func(ctx *context.Context) { - versionString := GetVersion(ctx) - if versionString == "" || versionString == NotFound { - notFoundHandler(ctx) - return - } - - ver, err := version.NewVersion(versionString) - if err != nil { - notFoundHandler(ctx) - return - } - - for _, ch := range constraintsHandlers { - if ch.constraints.Check(ver) { - ctx.Header("X-API-Version", ver.String()) - ch.handler(ctx) - return - } - } - - // pass the not matched version so the not found handler can have knowedge about it. - // SetVersion(ctx, versionString) - // or let a manual cal of GetVersion(ctx) do that instead. - notFoundHandler(ctx) - } -} - -type constraintsHandler struct { - constraints version.Constraints - handler context.Handler -} - -func buildConstraints(versionsHandler Map) (constraintsHandlers []*constraintsHandler, notfoundHandler context.Handler) { - for v, h := range versionsHandler { - if v == NotFound { - notfoundHandler = h - continue - } - - constraints, err := version.NewConstraint(v) - if err != nil { - panic(err) - } - - constraintsHandlers = append(constraintsHandlers, &constraintsHandler{ - constraints: constraints, - handler: h, - }) - } - - if notfoundHandler == nil { - notfoundHandler = NotFoundHandler - } - - // no sort, the end-dev should declare - // all version constraint, i.e < 4.0 may be catch 1.0 if not something like - // >= 3.0, < 4.0. - // I can make it ordered but I do NOT like the final API of it: - /* - app.Get("/api/user", NewMatcher( // accepts an array, ordered, see last elem. - V("1.0", vHandler("v1 here")), - V("2.0", vHandler("v2 here")), - V("< 4.0", vHandler("v3.x here")), - )) - instead we have: - - app.Get("/api/user", NewMatcher(Map{ // accepts a map, unordered, see last elem. - "1.0": Deprecated(vHandler("v1 here")), - "2.0": vHandler("v2 here"), - ">= 3.0, < 4.0": vHandler("v3.x here"), - VersionUnknown: customHandlerForNotMatchingVersion, - })) - */ - - return -} diff --git a/versioning/versioning_test.go b/versioning/versioning_test.go deleted file mode 100644 index 536b39bc..00000000 --- a/versioning/versioning_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package versioning_test - -import ( - "testing" - - "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/httptest" - "github.com/kataras/iris/v12/versioning" -) - -func notFoundHandler(ctx iris.Context) { - ctx.NotFound() -} - -const ( - v10Response = "v1.0 handler" - v2Response = "v2.x handler" -) - -func sendHandler(contents string) iris.Handler { - return func(ctx iris.Context) { - ctx.WriteString(contents) - } -} - -func TestIf(t *testing.T) { - if expected, got := true, versioning.If("1.0", ">=1"); expected != got { - t.Fatalf("expected %s to be %s", "1.0", ">= 1") - } - if expected, got := true, versioning.If("1.2.3", "> 1.2"); expected != got { - t.Fatalf("expected %s to be %s", "1.2.3", "> 1.2") - } -} - -func TestNewMatcher(t *testing.T) { - app := iris.New() - - userAPI := app.Party("/api/user") - userAPI.Get("/", versioning.NewMatcher(versioning.Map{ - "1.0": sendHandler(v10Response), - ">= 2, < 3": sendHandler(v2Response), - versioning.NotFound: notFoundHandler, - })) - - // middleware as usual. - myMiddleware := func(ctx iris.Context) { - ctx.Header("X-Custom", "something") - ctx.Next() - } - myVersions := versioning.Map{ - "1.0": sendHandler(v10Response), - } - - userAPI.Get("/with_middleware", myMiddleware, versioning.NewMatcher(myVersions)) - - e := httptest.New(t, app) - - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "1").Expect(). - Status(iris.StatusOK).Body().Equal(v10Response) - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.0").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.1").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.9.9").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - - // middleware as usual. - ex := e.GET("/api/user/with_middleware").WithHeader(versioning.AcceptVersionHeaderKey, "1.0").Expect() - ex.Status(iris.StatusOK).Body().Equal(v10Response) - ex.Header("X-Custom").Equal("something") - - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "3.0").Expect(). - Status(iris.StatusNotFound).Body().Equal("Not Found") -} - -func TestNewGroup(t *testing.T) { - app := iris.New() - - userAPI := app.Party("/api/user") - // [... static serving, middlewares and etc goes here]. - - userAPIV10 := versioning.NewGroup(userAPI, "1.0").Deprecated(versioning.DefaultDeprecationOptions) - // V10middlewareResponse := "m1" - // userAPIV10.Use(func(ctx iris.Context) { - // println("exec userAPIV10.Use - midl1") - // sendHandler(V10middlewareResponse)(ctx) - // ctx.Next() - // }) - // userAPIV10.Use(func(ctx iris.Context) { - // println("exec userAPIV10.Use - midl2") - // sendHandler(V10middlewareResponse + "midl2")(ctx) - // ctx.Next() - // }) - // userAPIV10.Use(func(ctx iris.Context) { - // println("exec userAPIV10.Use - midl3") - // ctx.Next() - // }) - - userAPIV10.Get("/", sendHandler(v10Response)) - userAPIV2 := versioning.NewGroup(userAPI, ">= 2, < 3") - // V2middlewareResponse := "m2" - // userAPIV2.Use(func(ctx iris.Context) { - // println("exec userAPIV2.Use - midl1") - // sendHandler(V2middlewareResponse)(ctx) - // ctx.Next() - // }) - // userAPIV2.Use(func(ctx iris.Context) { - // println("exec userAPIV2.Use - midl2") - // ctx.Next() - // }) - - userAPIV2.Get("/", sendHandler(v2Response)) - userAPIV2.Post("/", sendHandler(v2Response)) - userAPIV2.Put("/other", sendHandler(v2Response)) - - e := httptest.New(t, app) - - ex := e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "1").Expect() - ex.Status(iris.StatusOK).Body().Equal(v10Response) - ex.Header("X-API-Warn").Equal(versioning.DefaultDeprecationOptions.WarnMessage) - - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.0").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.1").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2.9.9").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.POST("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "2").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - e.PUT("/api/user/other").WithHeader(versioning.AcceptVersionHeaderKey, "2.9").Expect(). - Status(iris.StatusOK).Body().Equal(v2Response) - - e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "3.0").Expect(). - Status(iris.StatusNotImplemented).Body().Equal("version not found") -}