From b22a18da6b69978cce0106584346608d759265f7 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 10 Nov 2018 03:49:32 +0200 Subject: [PATCH] initialize support for versioning as requested, per route -- not finished yet Former-commit-id: ade66610125f06a0b5ce3e90bcafe349f216a616 --- versioning/deprecation.go | 36 ++++++++++++++ versioning/deprecation_test.go | 33 +++++++++++++ versioning/version.go | 72 ++++++++++++++++++++++++++++ versioning/version_test.go | 48 +++++++++++++++++++ versioning/versioning.go | 87 ++++++++++++++++++++++++++++++++++ versioning/versioning_test.go | 49 +++++++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 versioning/deprecation.go create mode 100644 versioning/deprecation_test.go create mode 100644 versioning/version.go create mode 100644 versioning/version_test.go create mode 100644 versioning/versioning.go create mode 100644 versioning/versioning_test.go diff --git a/versioning/deprecation.go b/versioning/deprecation.go new file mode 100644 index 00000000..afccc5f9 --- /dev/null +++ b/versioning/deprecation.go @@ -0,0 +1,36 @@ +package versioning + +import ( + "time" + + "github.com/kataras/iris/context" +) + +type DeprecationOptions struct { + WarnMessage string + DeprecationDate time.Time + DeprecationInfo string +} + +var DefaultDeprecationOptions = DeprecationOptions{ + WarnMessage: "WARNING! You are using a deprecated version of this API.", +} + +func Deprecated(handler context.Handler, options DeprecationOptions) context.Handler { + if options.WarnMessage == "" { + options.WarnMessage = DefaultDeprecationOptions.WarnMessage + } + + return func(ctx context.Context) { + handler(ctx) + ctx.Header("X-API-Warn", options.WarnMessage) + + if !options.DeprecationDate.IsZero() { + ctx.Header("X-API-Deprecation-Date", context.FormatTime(ctx, options.DeprecationDate)) + } + + if options.DeprecationInfo != "" { + ctx.Header("X-API-Deprecation-Info", options.DeprecationInfo) + } + } +} diff --git a/versioning/deprecation_test.go b/versioning/deprecation_test.go new file mode 100644 index 00000000..4cdcf83e --- /dev/null +++ b/versioning/deprecation_test.go @@ -0,0 +1,33 @@ +package versioning_test + +import ( + "testing" + "time" + + "github.com/kataras/iris" + "github.com/kataras/iris/httptest" + "github.com/kataras/iris/versioning" +) + +func TestDeprecated(t *testing.T) { + app := iris.New() + + writeVesion := func(ctx iris.Context) { + ctx.WriteString(versioning.GetVersion(ctx)) + } + + opts := versioning.DeprecationOptions{ + WarnMessage: "deprecated, see ", + DeprecationDate: time.Now().UTC(), + DeprecationInfo: "a bigger version is available, see for more information", + } + app.Get("/", versioning.Deprecated(writeVesion, opts)) + + e := httptest.New(t, app) + + ex := e.GET("/").WithHeader(versioning.AcceptVersionHeaderKey, "1.0").Expect() + ex.Status(iris.StatusOK).Body().Equal("1.0") + ex.Header("X-API-Warn").Equal(opts.WarnMessage) + expectedDateStr := opts.DeprecationDate.Format(app.ConfigurationReadOnly().GetTimeFormat()) + ex.Header("X-API-Deprecation-Date").Equal(expectedDateStr) +} diff --git a/versioning/version.go b/versioning/version.go new file mode 100644 index 00000000..1536ea2d --- /dev/null +++ b/versioning/version.go @@ -0,0 +1,72 @@ +package versioning + +import ( + "strings" + + "github.com/kataras/iris/context" +) + +const ( + AcceptVersionHeaderKey = "Accept-Version" + AcceptHeaderKey = "Accept" + AcceptHeaderVersionValue = "version" + + Key = "iris.api.version" // for use inside the ctx.Values(), not visible by the user. + NotFound = Key + ".notfound" +) + +var NotFoundHandler = func(ctx context.Context) { + // 303 is an option too, + // end-dev has the chance to change that behavior by using the NotFound in the map: + // + // https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + /* + 10.5.2 501 Not Implemented + + The server does not support the functionality required to fulfill the request. + This is the appropriate response when the server does not + recognize the request method and is not capable of supporting it for any resource. + */ + ctx.WriteString("version not found") + ctx.StatusCode(501) +} + +func GetVersion(ctx context.Context) string { + // firstly by context store, if manually set-ed by a middleware. + if version := ctx.Values().GetString(Key); version != "" { + return version + } + + // secondly by the "Accept-Version" header. + if version := ctx.GetHeader(AcceptVersionHeaderKey); version != "" { + return version + } + + // thirdly by the "Accept" header which is like"...; version=1.0" + acceptValue := ctx.GetHeader(AcceptHeaderKey) + if acceptValue != "" { + if idx := strings.Index(acceptValue, AcceptHeaderVersionValue); idx != -1 { + rem := acceptValue[idx:] + startVersion := strings.Index(rem, "=") + if startVersion == -1 || len(rem) < startVersion+1 { + return NotFound + } + + rem = rem[startVersion+1:] + + end := strings.Index(rem, " ") + if end == -1 { + end = strings.Index(rem, ";") + } + if end == -1 { + end = len(rem) + } + + if version := rem[:end]; version != "" { + return version + } + } + } + + return NotFound +} diff --git a/versioning/version_test.go b/versioning/version_test.go new file mode 100644 index 00000000..059fb310 --- /dev/null +++ b/versioning/version_test.go @@ -0,0 +1,48 @@ +package versioning_test + +import ( + "testing" + + "github.com/kataras/iris" + "github.com/kataras/iris/httptest" + "github.com/kataras/iris/versioning" +) + +func TestGetVersion(t *testing.T) { + app := iris.New() + + writeVesion := func(ctx iris.Context) { + ctx.WriteString(versioning.GetVersion(ctx)) + } + + app.Get("/", writeVesion) + app.Get("/manual", func(ctx iris.Context) { + ctx.Values().Set(versioning.Key, "11.0.5") + ctx.Next() + }, writeVesion) + + 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") + + // unknown versions. + e.GET("/").WithHeader(versioning.AcceptVersionHeaderKey, "").Expect(). + Status(iris.StatusOK).Body().Equal(versioning.NotFound) + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=").Expect(). + Status(iris.StatusOK).Body().Equal(versioning.NotFound) + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version= ;other=dsa").Expect(). + Status(iris.StatusOK).Body().Equal(versioning.NotFound) + e.GET("/").WithHeader(versioning.AcceptHeaderKey, "version=").Expect(). + Status(iris.StatusOK).Body().Equal(versioning.NotFound) + + e.GET("/manual").Expect().Status(iris.StatusOK).Body().Equal("11.0.5") +} diff --git a/versioning/versioning.go b/versioning/versioning.go new file mode 100644 index 00000000..8aaec792 --- /dev/null +++ b/versioning/versioning.go @@ -0,0 +1,87 @@ +package versioning + +import ( + "github.com/kataras/iris/context" + + "github.com/hashicorp/go-version" +) + +type Map map[string]context.Handler + +func Handler(versions Map) context.Handler { + constraintsHandlers, notFoundHandler := buildConstraints(versions) + + return func(ctx context.Context) { + versionString := GetVersion(ctx) + if versionString == NotFound { + notFoundHandler(ctx) + return + } + + version, err := version.NewVersion(versionString) + if err != nil { + notFoundHandler(ctx) + return + } + + for _, ch := range constraintsHandlers { + if ch.constraints.Check(version) { + ctx.Header("X-API-Version", version.String()) + ch.handler(ctx) + return + } + } + + 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", Handler( // 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", Handler(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 new file mode 100644 index 00000000..40dc41a3 --- /dev/null +++ b/versioning/versioning_test.go @@ -0,0 +1,49 @@ +package versioning_test + +import ( + "testing" + + "github.com/kataras/iris" + "github.com/kataras/iris/httptest" + "github.com/kataras/iris/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 TestHandler(t *testing.T) { + app := iris.New() + + userAPI := app.Party("/api/user") + userAPI.Get("/", versioning.Handler(versioning.Map{ + "1.0": sendHandler(v10Response), + ">= 2, < 3": sendHandler(v2Response), + versioning.NotFound: notFoundHandler, + })) + + 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) + + e.GET("/api/user").WithHeader(versioning.AcceptVersionHeaderKey, "3.0").Expect(). + Status(iris.StatusNotFound).Body().Equal("Not Found") +}