From a3f944b884b16b822b0086ec94dbe35cecd01dd5 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Mon, 16 Dec 2019 02:00:42 +0200 Subject: [PATCH] Add context.FindClosest(n) to find closest paths - useful for 404 pages to suggest valid pages Former-commit-id: 90ff7c9da5369df5bd99fbbecf9955a8c555fea5 --- _examples/README.md | 1 + _examples/i18n/main.go | 19 +++++--- _examples/routing/not-found-suggests/main.go | 37 +++++++++++++++ configuration.go | 16 +------ context/application.go | 4 ++ context/context.go | 15 +++++++ context/route.go | 4 ++ core/router/handler.go | 2 - core/router/route.go | 21 ++++++--- core/router/router.go | 47 +++++++++++++++++++- go.mod | 1 + 11 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 _examples/routing/not-found-suggests/main.go diff --git a/_examples/README.md b/_examples/README.md index 7522d839..694e8b14 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -128,6 +128,7 @@ Navigate through examples for a better understanding. - [Basic](routing/basic/main.go) - [Controllers](mvc) - [Custom HTTP Errors](routing/http-errors/main.go) +- [Not Found - Suggest Closest Paths](routing/not-found-suggests/main.go) **NEW** - [Dynamic Path](routing/dynamic-path/main.go) * [root level wildcard path](routing/dynamic-path/root-wildcard/main.go) - [Write your own custom parameter types](routing/macros/main.go) diff --git a/_examples/i18n/main.go b/_examples/i18n/main.go index fa2dc68c..d32806a8 100644 --- a/_examples/i18n/main.go +++ b/_examples/i18n/main.go @@ -54,7 +54,7 @@ func newApp() *iris.Application { // Note that, // Iris automatically adds a "tr" global template function as well, - // the only differene is the way you call it inside your templates and + // the only difference is the way you call it inside your templates and // that it accepts a language code as its first argument: {{ tr "el-GR" "hi" "iris"}} }) // @@ -65,10 +65,18 @@ func newApp() *iris.Application { func main() { app := newApp() - // go to http://localhost:8080/el-gr/some-path (by path prefix) - // or http://el.mydomain.com8080/some-path (by subdomain - test locally with the hosts file) - // or http://localhost:8080/zh-CN/templates (by path prefix with uppercase) - // or http://localhost:8080/some-path?lang=el-GR (by url parameter) + // go to http://localhost:8080/el-gr/some-path + // ^ (by path prefix) + // + // or http://el.mydomain.com8080/some-path + // ^ (by subdomain - test locally with the hosts file) + // + // or http://localhost:8080/zh-CN/templates + // ^ (by path prefix with uppercase) + // + // or http://localhost:8080/some-path?lang=el-GR + // ^ (by url parameter) + // // or http://localhost:8080 (default is en-US) // or http://localhost:8080/?lang=zh-CN // @@ -77,6 +85,5 @@ func main() { // or http://localhost:8080/other?lang=en-US // // or use cookies to set the language. - // app.Run(iris.Addr(":8080"), iris.WithSitemap("http://localhost:8080")) } diff --git a/_examples/routing/not-found-suggests/main.go b/_examples/routing/not-found-suggests/main.go new file mode 100644 index 00000000..e57002c9 --- /dev/null +++ b/_examples/routing/not-found-suggests/main.go @@ -0,0 +1,37 @@ +package main + +import "github.com/kataras/iris/v12" + +func main() { + app := iris.New() + app.OnErrorCode(iris.StatusNotFound, notFound) + + // [register some routes...] + app.Get("/home", handler) + app.Get("/news", handler) + app.Get("/news/politics", handler) + app.Get("/user/profile", handler) + app.Get("/user", handler) + app.Get("/newspaper", handler) + app.Get("/user/{id}", handler) + + app.Run(iris.Addr(":8080")) +} + +func notFound(ctx iris.Context) { + suggestPaths := ctx.FindClosest(3) + if len(suggestPaths) == 0 { + ctx.WriteString("404 not found") + return + } + + ctx.HTML("Did you mean?") +} + +func handler(ctx iris.Context) { + ctx.Writef("Path: %s", ctx.Path()) +} diff --git a/configuration.go b/configuration.go index 54d4a91c..b1c312d7 100644 --- a/configuration.go +++ b/configuration.go @@ -388,19 +388,7 @@ func WithSitemap(startURL string) Configurator { } for _, r := range app.GetRoutes() { - if !r.IsOnline() { - continue - } - - if r.Subdomain != "" { - continue - } - - if r.Method != MethodGet { - continue - } - - if len(r.Tmpl().Params) > 0 { + if !r.IsStatic() || r.Subdomain != "" { continue } @@ -478,7 +466,7 @@ func WithSitemap(startURL string) Configurator { } } } else { - app.HandleMany("GET HEAD", s.Path, handler) + app.HandleMany("GET HEAD OPTIONS", s.Path, handler) } } diff --git a/context/application.go b/context/application.go index 641d5fd0..9ad11227 100644 --- a/context/application.go +++ b/context/application.go @@ -60,4 +60,8 @@ type Application interface { // RouteExists reports whether a particular route exists // It will search from the current subdomain of context's host, if not inside the root domain. RouteExists(ctx Context, method, path string) bool + // FindClosestPaths returns a list of "n" paths close to "path" under the given "subdomain". + // + // Order may change. + FindClosestPaths(subdomain, searchPath string, n int) []string } diff --git a/context/context.go b/context/context.go index 168934ba..15c9a832 100644 --- a/context/context.go +++ b/context/context.go @@ -310,6 +310,12 @@ type Context interface { // Subdomain returns the subdomain of this request, if any. // Note that this is a fast method which does not cover all cases. Subdomain() (subdomain string) + // FindClosest returns a list of "n" paths close to + // this request based on subdomain and request path. + // + // Order may change. + // Example: https://github.com/kataras/iris/tree/master/_examples/routing/not-found-suggests + FindClosest(n int) []string // IsWWW returns true if the current subdomain (if any) is www. IsWWW() bool // FullRqeuestURI returns the full URI, @@ -1600,6 +1606,15 @@ func (ctx *context) Subdomain() (subdomain string) { return } +// FindClosest returns a list of "n" paths close to +// this request based on subdomain and request path. +// +// Order may change. +// Example: https://github.com/kataras/iris/tree/master/_examples/routing/not-found-suggests +func (ctx *context) FindClosest(n int) []string { + return ctx.Application().FindClosestPaths(ctx.Subdomain(), ctx.Path(), n) +} + // IsWWW returns true if the current subdomain (if any) is www. func (ctx *context) IsWWW() bool { host := ctx.Host() diff --git a/context/route.go b/context/route.go index f6776128..1927b9b1 100644 --- a/context/route.go +++ b/context/route.go @@ -31,6 +31,10 @@ type RouteReadOnly interface { // IsOnline returns true if the route is marked as "online" (state). IsOnline() bool + // IsStatic reports whether this route is a static route. + // Does not contain dynamic path parameters, + // is online and registered on GET HTTP Method. + IsStatic() bool // StaticPath returns the static part of the original, registered route path. // if /user/{id} it will return /user // if /user/{id}/friend/{friendid:uint64} it will return /user too diff --git a/core/router/handler.go b/core/router/handler.go index 03fd3a15..8c0c992a 100644 --- a/core/router/handler.go +++ b/core/router/handler.go @@ -77,8 +77,6 @@ func NewDefaultHandler() RequestHandler { type RoutesProvider interface { // api builder GetRoutes() []*Route GetRoute(routeName string) *Route - // GetStaticSites() []*StaticSite - // Macros() *macro.Macros } func (h *routerHandler) Build(provider RoutesProvider) error { diff --git a/core/router/route.go b/core/router/route.go index a1540b8e..d04f763a 100644 --- a/core/router/route.go +++ b/core/router/route.go @@ -161,7 +161,7 @@ func (r *Route) BuildHandlers() { } // String returns the form of METHOD, SUBDOMAIN, TMPL PATH. -func (r Route) String() string { +func (r *Route) String() string { return fmt.Sprintf("%s %s%s", r.Method, r.Subdomain, r.Tmpl().Src) } @@ -214,13 +214,13 @@ func (r *Route) SetPriority(prio float32) *Route { // Developer can get his registered path // via Tmpl().Src, Route.Path is the path // converted to match the underline router's specs. -func (r Route) Tmpl() macro.Template { +func (r *Route) Tmpl() macro.Template { return r.tmpl } // RegisteredHandlersLen returns the end-developer's registered handlers, all except the macro evaluator handler // if was required by the build process. -func (r Route) RegisteredHandlersLen() int { +func (r *Route) RegisteredHandlersLen() int { n := len(r.Handlers) if handler.CanMakeHandler(r.tmpl) { n-- @@ -230,7 +230,7 @@ func (r Route) RegisteredHandlersLen() int { } // IsOnline returns true if the route is marked as "online" (state). -func (r Route) IsOnline() bool { +func (r *Route) IsOnline() bool { return r.Method != MethodNone } @@ -270,11 +270,18 @@ func formatPath(path string) string { return path } +// IsStatic reports whether this route is a static route. +// Does not contain dynamic path parameters, +// is online and registered on GET HTTP Method. +func (r *Route) IsStatic() bool { + return r.IsOnline() && len(r.Tmpl().Params) == 0 && r.Method == "GET" +} + // StaticPath returns the static part of the original, registered route path. // if /user/{id} it will return /user // if /user/{id}/friend/{friendid:uint64} it will return /user too // if /assets/{filepath:path} it will return /assets. -func (r Route) StaticPath() string { +func (r *Route) StaticPath() string { src := r.tmpl.Src bidx := strings.IndexByte(src, '{') if bidx == -1 || len(src) <= bidx { @@ -289,7 +296,7 @@ func (r Route) StaticPath() string { } // ResolvePath returns the formatted path's %v replaced with the args. -func (r Route) ResolvePath(args ...string) string { +func (r *Route) ResolvePath(args ...string) string { rpath, formattedPath := r.Path, r.FormattedPath if rpath == formattedPath { // static, no need to pass args @@ -310,7 +317,7 @@ func (r Route) ResolvePath(args ...string) string { // Trace returns some debug infos as a string sentence. // Should be called after Build. -func (r Route) Trace() string { +func (r *Route) Trace() string { printfmt := fmt.Sprintf("[%s:%d] %s:", r.SourceFileName, r.SourceLineNumber, r.Method) if r.Subdomain != "" { printfmt += fmt.Sprintf(" %s", r.Subdomain) diff --git a/core/router/router.go b/core/router/router.go index b9649c9b..2cce0b01 100644 --- a/core/router/router.go +++ b/core/router/router.go @@ -6,6 +6,8 @@ import ( "sync" "github.com/kataras/iris/v12/context" + + "github.com/schollz/closestmatch" ) // Router is the "director". @@ -23,10 +25,16 @@ type Router struct { cPool *context.Pool // used on RefreshRouter routesProvider RoutesProvider + + // key = subdomain + // value = closest of static routes, filled on `BuildRouter/RefreshRouter`. + closestPaths map[string]*closestmatch.ClosestMatch } // NewRouter returns a new empty Router. -func NewRouter() *Router { return &Router{} } +func NewRouter() *Router { + return &Router{} +} // RefreshRouter re-builds the router. Should be called when a route's state // changed (i.e Method changed at serve-time). @@ -54,6 +62,28 @@ func (router *Router) AddRouteUnsafe(r *Route) error { return ErrNotRouteAdder } +// FindClosestPaths returns a list of "n" paths close to "path" under the given "subdomain". +// +// Order may change. +func (router *Router) FindClosestPaths(subdomain, searchPath string, n int) []string { + if router.closestPaths == nil { + return nil + } + + cm, ok := router.closestPaths[subdomain] + if !ok { + return nil + } + + list := cm.ClosestN(searchPath, n) + if len(list) == 1 && list[0] == "" { + // yes, it may return empty string as its first slice element when not found. + return nil + } + + return list +} + // BuildRouter builds the router based on // the context factory (explicit pool in this case), // the request handler which manages how the main handler will multiplexes the routes @@ -110,6 +140,21 @@ func (router *Router) BuildRouter(cPool *context.Pool, requestHandler RequestHan router.mainHandler = NewWrapper(router.wrapperFunc, router.mainHandler).ServeHTTP } + // build closest. + subdomainPaths := make(map[string][]string) + for _, r := range router.routesProvider.GetRoutes() { + if !r.IsStatic() { + continue + } + + subdomainPaths[r.Subdomain] = append(subdomainPaths[r.Subdomain], r.Path) + } + + router.closestPaths = make(map[string]*closestmatch.ClosestMatch) + for subdomain, paths := range subdomainPaths { + router.closestPaths[subdomain] = closestmatch.New(paths, []int{3, 4, 6}) + } + return nil } diff --git a/go.mod b/go.mod index a0b1fb10..845eb1ac 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/mediocregopher/radix/v3 v3.3.0 github.com/microcosm-cc/bluemonday v1.0.2 github.com/ryanuber/columnize v2.1.0+incompatible + github.com/schollz/closestmatch v2.1.0+incompatible golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 golang.org/x/text v0.3.0 gopkg.in/ini.v1 v1.51.0