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 ❤️ - please check your github notifications
pull/1701/head
Gerasimos (Makis) Maropoulos 1 year ago
parent
commit
240fdb6dc3
No known key found for this signature in database GPG Key ID: 5DBE766BD26A54E7
  1. 1
      .gitignore
  2. 7
      HISTORY.md
  3. 6
      NOTICE
  4. 1
      README.md
  5. 9
      _examples/auth/jwt/basic/main.go
  6. 8
      _examples/mvc/versioned-controller/main.go
  7. 8
      _examples/mvc/versioned-controller/main_test.go
  8. 147
      _examples/routing/versioning/main.go
  9. 1
      _examples/routing/versioning/v1/index.html
  10. 1
      _examples/routing/versioning/v2/index.html
  11. 1
      _examples/routing/versioning/v3/index.html
  12. 2
      configuration.go
  13. 4
      core/router/api_builder.go
  14. 2
      go.mod
  15. 8
      mvc/versioning.go
  16. 108
      versioning/group.go
  17. 60
      versioning/group_test.go
  18. 111
      versioning/version.go
  19. 45
      versioning/version_test.go
  20. 173
      versioning/versioning.go
  21. 135
      versioning/versioning_test.go

1
.gitignore

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

7
HISTORY.md

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

6
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

1
README.md

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

9
_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()

8
_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

8
_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())

147
_examples/routing/versioning/main.go

@ -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
_examples/routing/versioning/v1/index.html

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

1
_examples/routing/versioning/v2/index.html

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

1
_examples/routing/versioning/v3/index.html

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

2
configuration.go

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

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

2
go.mod

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

8
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

108
versioning/group.go

@ -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
versioning/group_test.go

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

111
versioning/version.go

@ -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 == "" {

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

173
versioning/versioning.go

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

135
versioning/versioning_test.go

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