Browse Source

API versioning improvements

Replace the go-version package with a regex-free alternative semver

the result: versioned apis have almost zero performance cost now

thanks @motogo for your kind donation :heart: - please check your github notifications
tags/v12.2.0-alpha2
Gerasimos (Makis) Maropoulos 6 months ago
parent
commit
240fdb6dc3
No known key found for this signature in database GPG Key ID: 5DBE766BD26A54E7
21 changed files with 378 additions and 460 deletions
  1. +1
    -0
      .gitignore
  2. +4
    -3
      HISTORY.md
  3. +3
    -3
      NOTICE
  4. +1
    -0
      README.md
  5. +2
    -7
      _examples/auth/jwt/basic/main.go
  6. +4
    -4
      _examples/mvc/versioned-controller/main.go
  7. +4
    -4
      _examples/mvc/versioned-controller/main_test.go
  8. +78
    -69
      _examples/routing/versioning/main.go
  9. +1
    -0
      _examples/routing/versioning/v1/index.html
  10. +1
    -0
      _examples/routing/versioning/v2/index.html
  11. +1
    -0
      _examples/routing/versioning/v3/index.html
  12. +1
    -1
      configuration.go
  13. +2
    -2
      core/router/api_builder.go
  14. +1
    -1
      go.mod
  15. +4
    -4
      mvc/versioning.go
  16. +79
    -29
      versioning/group.go
  17. +60
    -0
      versioning/group_test.go
  18. +104
    -7
      versioning/version.go
  19. +27
    -18
      versioning/version_test.go
  20. +0
    -173
      versioning/versioning.go
  21. +0
    -135
      versioning/versioning_test.go

+ 1
- 0
.gitignore View File

@@ -3,6 +3,7 @@
coverage.out
package-lock.json
go.sum
access.log
node_modules
issue-*/
internalcode-*/


+ 4
- 3
HISTORY.md View File

@@ -28,7 +28,7 @@ The codebase for Dependency Injection, Internationalization and localization and
## Fixes and Improvements
- New `versioning.Aliases` middleware. Example Code:
- New `versioning.Aliases` middleware and up to 80% faster version resolve. Example Code:
```go
app := iris.New()
@@ -40,10 +40,10 @@ api.Use(Aliases(map[string]string{
"stage": "5.0.0-alpha"
}))
v1 := NewGroup(api, ">= 1, < 2")
v1 := NewGroup(api, ">=1.0.0 <2.0.0")
v1.Get/Post...
v4 := NewGroup(api, ">= 4, < 5")
v4 := NewGroup(api, ">=4.0.0 <5.0.0")
v4.Get/Post...
stage := NewGroup(api, "5.0.0-alpha")
@@ -723,6 +723,7 @@ Response:
## Breaking Changes
- 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.


+ 3
- 3
NOTICE View File

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


+ 1
- 0
README.md View File

@@ -36,6 +36,7 @@ With your help, we can improve Open Source web development for everyone!
> Donations from **China** are now accepted!

<p>
<a href="https://github.com/motogo"><img src="https://avatars1.githubusercontent.com/u/1704958?v=4" alt ="Horst Ender" title="motogo" with="75" style="width:75px;max-width:75px;height:75px" height="75" /></a>
<a href="https://github.com/remopavithran"><img src="https://avatars1.githubusercontent.com/u/50388068?v=4" alt ="Pavithran" title="remopavithran" with="75" style="width:75px;max-width:75px;height:75px" height="75" /></a>
<a href="https://github.com/mulyawansentosa"><img src="https://avatars1.githubusercontent.com/u/29946673?v=4" alt ="MULYAWAN SENTOSA" title="mulyawansentosa" with="75" style="width:75px;max-width:75px;height:75px" height="75" /></a>
<a href="https://github.com/TianJIANG"><img src="https://avatars1.githubusercontent.com/u/158459?v=4" alt ="KIT UNITED" title="TianJIANG" with="75" style="width:75px;max-width:75px;height:75px" height="75" /></a>


+ 2
- 7
_examples/auth/jwt/basic/main.go View File

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



+ 4
- 4
_examples/mvc/versioned-controller/main.go View File

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


+ 4
- 4
_examples/mvc/versioned-controller/main_test.go View File

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


+ 78
- 69
_examples/routing/versioning/main.go View File

@@ -8,84 +8,93 @@ import (
func main() {
app := iris.New()

examplePerRoute(app)
examplePerParty(app)

// Read the README.md before any action.
app.Listen(":8080")
}
app.OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) {
ctx.WriteString(`Root not found handler.
This will be applied everywhere except the /api/* requests.`)
})

// 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,
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",
}))
}

// 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()
// })
// OR:
usersAPI.Use(versioning.FromQuery("version", "1"))

// 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")
})
/*
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 != "" {
SetVersion(ctx, version)
}

// 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")
})
ctx.Next()
})
OR: api.Use(versioning.FromQuery("version", ""))
*/

// version 3, pass it as a common iris.Party.
usersAPIV3 := versioning.NewGroup(usersAPI, ">= 3, < 4")
registerAPIV3(usersAPIV3)
// |----------------|
// | 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")

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

app.Listen(":8080")
}

func catsVersionExactly1Handler(ctx iris.Context) {
ctx.Writef("v1 exactly resource: /api/cats handler")
func testHandler(v string) iris.Handler {
return func(ctx iris.Context) {
ctx.JSON(iris.Map{
"version": v,
"message": "Hello, world!",
})
}
}

func catsV2Handler(ctx iris.Context) {
ctx.Writef("v2 resource: /api/cats handler")
func testError(v string) iris.Handler {
return func(ctx iris.Context) {
ctx.Writef("not found: %s", v)
}
}

func registerAPIV3(p iris.Party) {
p.Get("/", func(ctx iris.Context) {
ctx.Writef("v3 resource: /api/users handler")
})
// [...]
func testView(ctx iris.Context) {
ctx.View("index.html")
}

+ 1
- 0
_examples/routing/versioning/v1/index.html View File

@@ -0,0 +1 @@
<h1>This is the directory for version 1 templates</h1>

+ 1
- 0
_examples/routing/versioning/v2/index.html View File

@@ -0,0 +1 @@
<h1>This is the directory for version 2 templates</h1>

+ 1
- 0
_examples/routing/versioning/v3/index.html View File

@@ -0,0 +1 @@
<h1>This is the directory for version 3 templates</h1>

+ 1
- 1
configuration.go View File

@@ -764,7 +764,7 @@ 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


+ 2
- 2
core/router/api_builder.go View File

@@ -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)
}
@@ -720,7 +720,7 @@ func (api *APIBuilder) Party(relativePath string, handlers ...context.Handler) P
copy(allowMethods, api.allowMethods)

// make a copy of the parent properties.
var properties map[string]interface{}
var properties context.Map
for k, v := range api.properties {
properties[k] = v
}


+ 1
- 1
go.mod View File

@@ -8,13 +8,13 @@ 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


+ 4
- 4
mvc/versioning.go View File

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


+ 79
- 29
versioning/group.go View File

@@ -1,57 +1,107 @@
package versioning

import (
"strings"

"github.com/kataras/iris/v12/context"
"github.com/kataras/iris/v12/core/router"
)

// Property to be defined inside the registered
// Party on NewGroup, useful for a party to know its (optional) version
// when the versioning feature is used.
const Property = "iris.party.version"
"github.com/blang/semver/v4"
)

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

// Group is a group of version-based routes.
// One version per one or more routes.
// 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

// Information not currently in-use.
version string
validate semver.Range
deprecation DeprecationOptions
}

// NewGroup returns a ptr to Group based on the given "version" constraint.
// 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).
//
// 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)
// }
//
// ctx.Next()
// })
// OR:
// api.Use(versioning.FromQuery("version", ""))
//
// Examples at: _examples/routing/versioning
// Usage:
// app := iris.New()
// api := app.Party("/api")
// v1 := versioning.NewGroup(api, ">= 1, < 2")
// v1 := versioning.NewGroup(api, ">=1.0.0 <2.0.0")
// v1.Get/Post/Put/Delete...
//
// See the `GetVersion` function to learn how
// a version is extracted and matched over this.
func NewGroup(r router.Party, version string) *Group {
// 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("/")
r.Properties()[Property] = version

// 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{
API: r,
version: version,
API: r,
validate: verRange,
}
}

@@ -68,18 +118,18 @@ func (g *Group) Deprecated(options DeprecationOptions) *Group {
return g
}

// 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 {
func makeHandler(validate semver.Range) context.Handler {
return func(ctx *context.Context) {
version := ctx.URLParam(urlQueryParameterName)
if version == "" {
version = defaultVersion
}

if version != "" {
SetVersion(ctx, version)
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()


+ 60
- 0
versioning/group_test.go View File

@@ -0,0 +1,60 @@
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 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")
}

+ 104
- 7
versioning/version.go View File

@@ -5,6 +5,8 @@ import (
"strings"

"github.com/kataras/iris/v12/context"

"github.com/blang/semver/v4"
)

const (
@@ -52,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).
@@ -108,7 +188,7 @@ func GetVersion(ctx *context.Context) string {
// 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")
// version := ctx.URLParamDefault("version", "1.0.0")
// versioning.SetVersion(ctx, version)
// ctx.Next()
// }
@@ -129,15 +209,15 @@ type AliasMap = map[string]string
//
// api := app.Party("/api")
// api.Use(Aliases(map[string]string{
// versioning.Empty: "1", // when no version was provided by the client.
// 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, < 2")
// v1 := NewGroup(api, ">=1.0.0 < 2.0.0")
// v1.Get/Post...
//
// v4 := NewGroup(api, ">= 4, < 5")
// v4 := NewGroup(api, ">=4.0.0 < 5.0.0")
// v4.Get/Post...
//
// stage := NewGroup(api, "5.0.0-alpha")
@@ -154,6 +234,23 @@ func Aliases(aliases AliasMap) context.Handler {
}
}

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

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.
@@ -196,7 +293,7 @@ func GetVersionAlias(ctx *context.Context, gotVersion string) (string, bool) {
//
// The last "override" input argument indicates whether any
// existing aliases, registered by previous handlers in the chain,
// should be overriden or copied to the previous map one.
// 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 == "" {


+ 27
- 18
versioning/version_test.go View File

@@ -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().
@@ -52,8 +61,8 @@ func TestVersionAliases(t *testing.T) {

api := app.Party("/api")
api.Use(versioning.Aliases(map[string]string{
versioning.Empty: "1",
"stage": "2",
versioning.Empty: "1.0.0",
"stage": "2.0.0",
}))

writeVesion := func(ctx iris.Context) {
@@ -61,13 +70,13 @@ func TestVersionAliases(t *testing.T) {
}

// A group without registration order.
v3 := versioning.NewGroup(api, ">= 3, < 4")
v3 := versioning.NewGroup(api, ">= 3.0.0 < 4.0.0")
v3.Get("/", writeVesion)

v1 := versioning.NewGroup(api, ">= 1, < 2")
v1 := versioning.NewGroup(api, ">= 1.0.0 < 2.0.0")
v1.Get("/", writeVesion)

v2 := versioning.NewGroup(api, ">= 2, < 3")
v2 := versioning.NewGroup(api, ">= 2.0.0 < 3.0.0")
v2.Get("/", writeVesion)

api.Get("/manual", func(ctx iris.Context) {
@@ -84,15 +93,15 @@ func TestVersionAliases(t *testing.T) {
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").Expect().
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").Expect().
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").Expect().
e.GET("/api").WithHeader(versioning.AcceptVersionHeaderKey, "3.1.0").Expect().
Status(iris.StatusOK).Body().Equal("3.1.0")
}

+ 0
- 173
versioning/versioning.go View File

@@ -1,173 +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) {
if v == "" {
return "", false
}

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).
//
// See the `Aliases` function to register version constraint
// aliases for a versioning Party, extremely useful when a Group is used.
func Match(ctx *context.Context, expectedVersion string) 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
}

versionString, matched := check(gotVersion, expectedVersion)
if !matched {
return false
}

if !aliasFound { // don't lose any time to set if already set.
SetVersion(ctx, versionString)
}

ctx.Header(APIVersionResponseHeader, 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(APIVersionResponseHeader, 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
}

+ 0
- 135
versioning/versioning_test.go View File

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

Loading…
Cancel
Save