Repository: labstack/echo
Pull request #2064 (2022-01-08 20:50:11)

Improve filesystem support
Improve filesystem support.  Relates to #2059 I have taken some code from `v5` and made it `v4` compatible

Usable if you have Go 1.16+, older Go versions will use old implementation

* Add field echo.Filesystem, default filesystem that is set on `echo.New()` emulates how previously `os.Open` worked 
* Add methods: 
  * echo.FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc), 
  * echo.StaticFS(pathPrefix string, fileSystem fs.FS), 
  * group.FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc), 
  * group.StaticFS(pathPrefix string, fileSystem fs.FS). 
  * MustSubFS(currentFs fs.FS, fsRoot string) fs.FS
* Following methods will use internally echo.Filesystem to serve files: 
  * echo.File, 
  * echo.Static, 
  * group.File, 
  * group.Static, 
  * Context.File


Note: `embed.FS` embedds files with full paths that include that same embedded directory name as prefix. In that case you can use helper function to create sub fs with that prefix `echo.MustSubFS(embedded, rootDirectory)`.  That "root prefix" was not added to the method signature as there could be `fs.FS` implementations that do not act that way.

Example with `embed.FS`:
```go
//go:embed testdata
var embedded embed.FS

// assuming we have directory structure
// ./  <- execution dir
// ./testdata/  <- directory we are embedding
// ./testdata/index.html   <- on of the embedded files

func main() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.StaticFS("/assets", echo.MustSubFS(embedded, "testdata"))

	log.Fatal(e.Start(":8080"))
}
```

aldas: well well, I forgot already that `fs.Fs` was introduced in Go 1.16.  If `echo.Filesystem` part is removed and only `*FS` methods are left this could be made work with build tags for Go 1.16+.
lammel: > well well, I forgot already that `fs.FS` was introduced in Go 1.16. If `echo.Filesystem` part is removed and only `*FS` methods are left this could be made work with build tags for Go 1.16+.

I'd suggest we tag this PR as v5 and merge on the v5 branch where Go < 1.16 is dropped, so we can use fs.FS directly from Go. What do you think @aldas ?
aldas: There is not point tag it as `v5` as most of it is taken from `v5` branch. 

I can make it work with Go 1.16+ versions with build constraints. I would create additional struct to be embedded into `echo.Echo` struct in separate file along with those `fs.FS` related stuff and create version for pre and after 1.16+

Add it to `echo.Echo` along with common
https://github.com/labstack/echo/blob/296c31358a08f0b83d785942778b5e1bc0ea9d5c/echo.go#L68-L70

and create version specific implementations in that versioned file
```go
// +build go1.16

type versioned struct {
	Filesystem fs.FS
}

// ... methods requiring fs.FS interface
```

```go
// +build !go1.16
type versioned struct {}
```
lammel: 
> I can make it work with Go 1.16+ versions with build constraints. I would create additional struct to be embedded into `echo.Echo` struct in separate file along with those `fs.FS` related stuff and create version for pre and after 1.16+

If it is not too much effort, great. Than we can merge it for v4.6. Lovely!
codecov[bot]: # [Codecov](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
> Merging [#2064](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (f182524) into [master](https://codecov.io/gh/labstack/echo/commit/6f6befe555e9f076b8f4a4c060ad100f1c7e46b4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (6f6befe) will **increase** coverage by `0.18%`.
> The diff coverage is `92.30%`.

[![Impacted file tree graph](https://codecov.io/gh/labstack/echo/pull/2064/graphs/tree.svg?width=650&height=150&src=pr&token=vqnT76l79i&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)

```diff
@@            Coverage Diff             @@
##           master    #2064      +/-   ##
==========================================
+ Coverage   91.57%   91.75%   +0.18%     
==========================================
  Files          33       36       +3     
  Lines        2921     2961      +40     
==========================================
+ Hits         2675     2717      +42     
+ Misses        157      156       -1     
+ Partials       89       88       -1     
```


| [Impacted Files](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [context.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Y29udGV4dC5nbw==) | `88.04% <ø> (+1.10%)` | :arrow_up: |
| [group.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Z3JvdXAuZ28=) | `100.00% <ø> (ø)` | |
| [context\_fs\_go1.16.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Y29udGV4dF9mc19nbzEuMTYuZ28=) | `80.00% <80.00%> (ø)` | |
| [echo\_fs\_go1.16.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-ZWNob19mc19nbzEuMTYuZ28=) | `95.55% <95.55%> (ø)` | |
| [echo.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-ZWNoby5nbw==) | `95.14% <100.00%> (+0.93%)` | :arrow_up: |
| [group\_fs\_go1.16.go](https://codecov.io/gh/labstack/echo/pull/2064/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Z3JvdXBfZnNfZ28xLjE2Lmdv) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Last update [6f6befe...f182524](https://codecov.io/gh/labstack/echo/pull/2064?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f52f264..372ed13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
 # Changelog
 
+## v4.6.3 - 2022-01-10
+
+**Fixes**
+
+* Fixed Echo version number in greeting message which was not incremented to `4.6.2` [#2066](https://github.com/labstack/echo/issues/2066)
+
+
+## v4.6.2 - 2022-01-08
+
+**Fixes**
+
+* Fixed route containing escaped colon should be matchable but is not matched to request path [#2047](https://github.com/labstack/echo/pull/2047)
+* Fixed a problem that returned wrong content-encoding when the gzip compressed content was empty. [#1921](https://github.com/labstack/echo/pull/1921)
+* Update (test) dependencies [#2021](https://github.com/labstack/echo/pull/2021)
+
+
+**Enhancements**
+
+* Add support for configurable target header for the request_id middleware [#2040](https://github.com/labstack/echo/pull/2040)
+* Change decompress middleware to use stream decompression instead of buffering [#2018](https://github.com/labstack/echo/pull/2018)
+* Documentation updates
+
+
 ## v4.6.1 - 2021-09-26
 
 **Enhancements**
diff --git a/README.md b/README.md
index 885c7bd..8b2321f 100644
--- a/README.md
+++ b/README.md
@@ -93,13 +93,15 @@ func hello(c echo.Context) error {
 
 # Third-party middlewares
 
-| Repository | Description                                                                                                                                                                                                                                                                                                                                  |
-|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Repository | Description                                                                                                                                                                                                                                                                                                                                                                                                    |
+|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | (by Echo team) [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | 
-| [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) | Automatically generate RESTful API documentation with [OpenAPI](https://swagger.io/specification/) Client and Server Code Generator                                                                                                                                                                                                          |
-| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger) | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0.                                                                                                                                                                                                                                                    |
-| [github.com/ziflex/lecho](https://github.com/ziflex/lecho) | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                  |
-| [github.com/brpaz/echozap](https://github.com/brpaz/echozap) | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                              |
+| [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) | Automatically generate RESTful API documentation with [OpenAPI](https://swagger.io/specification/) Client and Server Code Generator                                                                                                                                                                                                                       |
+| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger) | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0.                                                                                                                                                                                                                                                        |
+| [github.com/ziflex/lecho](https://github.com/ziflex/lecho) | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                                    |
+| [github.com/brpaz/echozap](https://github.com/brpaz/echozap) | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                              |
+| [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache system based on [Souin](https://github.com/darkweak/souin) to automatically get your endpoints cached. It supports some distributed and non-distributed storage systems depending your needs.                                                                                                             |
+| [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda) | Rapid, easy full-stack web development starter kit built with Echo.
 
 Please send a PR to add your own library here.
 
diff --git a/bind.go b/bind.go
index fdf0524..c841ca0 100644
--- a/bind.go
+++ b/bind.go
@@ -111,11 +111,11 @@ func (b *DefaultBinder) Bind(i interface{}, c Context) (err error) {
 	if err := b.BindPathParams(c, i); err != nil {
 		return err
 	}
-	// Issue #1670 - Query params are binded only for GET/DELETE and NOT for usual request with body (POST/PUT/PATCH)
-	// Reasoning here is that parameters in query and bind destination struct could have UNEXPECTED matches and results due that.
-	// i.e. is `&id=1&lang=en` from URL same as `{"id":100,"lang":"de"}` request body and which one should have priority when binding.
-	// This HTTP method check restores pre v4.1.11 behavior and avoids different problems when query is mixed with body
-	if c.Request().Method == http.MethodGet || c.Request().Method == http.MethodDelete {
+	// Only bind query parameters for GET/DELETE/HEAD to avoid unexpected behavior with destination struct binding from body.
+	// For example a request URL `&id=1&lang=en` with body `{"id":100,"lang":"de"}` would lead to precedence issues.
+	// The HTTP method check restores pre-v4.1.11 behavior to avoid these problems (see issue #1670)
+  method := c.Request().Method
+	if method == http.MethodGet || method == http.MethodDelete || method == http.MethodHead {
 		if err = b.BindQueryParams(c, i); err != nil {
 			return err
 		}
diff --git a/context.go b/context.go
index f2421d7..a4ecfad 100644
--- a/context.go
+++ b/context.go
@@ -9,8 +9,6 @@ import (
 	"net"
 	"net/http"
 	"net/url"
-	"os"
-	"path/filepath"
 	"strings"
 	"sync"
 )
@@ -569,29 +567,6 @@ func (c *context) Stream(code int, contentType string, r io.Reader) (err error)
 	return
 }
 
-func (c *context) File(file string) (err error) {
-	f, err := os.Open(file)
-	if err != nil {
-		return NotFoundHandler(c)
-	}
-	defer f.Close()
-
-	fi, _ := f.Stat()
-	if fi.IsDir() {
-		file = filepath.Join(file, indexPage)
-		f, err = os.Open(file)
-		if err != nil {
-			return NotFoundHandler(c)
-		}
-		defer f.Close()
-		if fi, err = f.Stat(); err != nil {
-			return
-		}
-	}
-	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), f)
-	return
-}
-
 func (c *context) Attachment(file, name string) error {
 	return c.contentDisposition(file, name, "attachment")
 }
diff --git a/context_fs.go b/context_fs.go
new file mode 100644
index 0000000..11ee84b
--- /dev/null
+++ b/context_fs.go
@@ -0,0 +1,33 @@
+//go:build !go1.16
+// +build !go1.16
+
+package echo
+
+import (
+	"net/http"
+	"os"
+	"path/filepath"
+)
+
+func (c *context) File(file string) (err error) {
+	f, err := os.Open(file)
+	if err != nil {
+		return NotFoundHandler(c)
+	}
+	defer f.Close()
+
+	fi, _ := f.Stat()
+	if fi.IsDir() {
+		file = filepath.Join(file, indexPage)
+		f, err = os.Open(file)
+		if err != nil {
+			return NotFoundHandler(c)
+		}
+		defer f.Close()
+		if fi, err = f.Stat(); err != nil {
+			return
+		}
+	}
+	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), f)
+	return
+}
diff --git a/context_fs_go1.16.go b/context_fs_go1.16.go
new file mode 100644
index 0000000..c1c724a
--- /dev/null
+++ b/context_fs_go1.16.go
@@ -0,0 +1,52 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"errors"
+	"io"
+	"io/fs"
+	"net/http"
+	"path/filepath"
+)
+
+func (c *context) File(file string) error {
+	return fsFile(c, file, c.echo.Filesystem)
+}
+
+// FileFS serves file from given file system.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (c *context) FileFS(file string, filesystem fs.FS) error {
+	return fsFile(c, file, filesystem)
+}
+
+func fsFile(c Context, file string, filesystem fs.FS) error {
+	f, err := filesystem.Open(file)
+	if err != nil {
+		return ErrNotFound
+	}
+	defer f.Close()
+
+	fi, _ := f.Stat()
+	if fi.IsDir() {
+		file = filepath.ToSlash(filepath.Join(file, indexPage)) // ToSlash is necessary for Windows. fs.Open and os.Open are different in that aspect.
+		f, err = filesystem.Open(file)
+		if err != nil {
+			return ErrNotFound
+		}
+		defer f.Close()
+		if fi, err = f.Stat(); err != nil {
+			return err
+		}
+	}
+	ff, ok := f.(io.ReadSeeker)
+	if !ok {
+		return errors.New("file does not implement io.ReadSeeker")
+	}
+	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
+	return nil
+}
diff --git a/echo.go b/echo.go
index 4278982..56255c6 100644
--- a/echo.go
+++ b/echo.go
@@ -47,9 +47,6 @@ import (
 	stdLog "log"
 	"net"
 	"net/http"
-	"net/url"
-	"os"
-	"path/filepath"
 	"reflect"
 	"runtime"
 	"sync"
@@ -66,6 +63,7 @@ import (
 type (
 	// Echo is the top-level framework instance.
 	Echo struct {
+		filesystem
 		common
 		// startupMutex is mutex to lock Echo instance access during server configuration and startup. Useful for to get
 		// listener address info (on which interface/port was listener binded) without having data races.
@@ -207,6 +205,7 @@ const (
 	HeaderIfModifiedSince     = "If-Modified-Since"
 	HeaderLastModified        = "Last-Modified"
 	HeaderLocation            = "Location"
+	HeaderRetryAfter          = "Retry-After"
 	HeaderUpgrade             = "Upgrade"
 	HeaderVary                = "Vary"
 	HeaderWWWAuthenticate     = "WWW-Authenticate"
@@ -246,7 +245,7 @@ const (
 
 const (
 	// Version of Echo
-	Version = "4.6.1"
+	Version = "4.6.3"
 	website = "https://echo.labstack.com"
 	// http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo
 	banner = `
@@ -319,8 +318,9 @@ var (
 // New creates an instance of Echo.
 func New() (e *Echo) {
 	e = &Echo{
-		Server:    new(http.Server),
-		TLSServer: new(http.Server),
+		filesystem: createFilesystem(),
+		Server:     new(http.Server),
+		TLSServer:  new(http.Server),
 		AutoTLSManager: autocert.Manager{
 			Prompt: autocert.AcceptTOS,
 		},
@@ -499,50 +499,6 @@ func (e *Echo) Match(methods []string, path string, handler HandlerFunc, middlew
 	return routes
 }
 
-// Static registers a new route with path prefix to serve static files from the
-// provided root directory.
-func (e *Echo) Static(prefix, root string) *Route {
-	if root == "" {
-		root = "." // For security we want to restrict to CWD.
-	}
-	return e.static(prefix, root, e.GET)
-}
-
-func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route {
-	h := func(c Context) error {
-		p, err := url.PathUnescape(c.Param("*"))
-		if err != nil {
-			return err
-		}
-
-		name := filepath.Join(root, filepath.Clean("/"+p)) // "/"+ for security
-		fi, err := os.Stat(name)
-		if err != nil {
-			// The access path does not exist
-			return NotFoundHandler(c)
-		}
-
-		// If the request is for a directory and does not end with "/"
-		p = c.Request().URL.Path // path must not be empty.
-		if fi.IsDir() && p[len(p)-1] != '/' {
-			// Redirect to ends with "/"
-			return c.Redirect(http.StatusMovedPermanently, p+"/")
-		}
-		return c.File(name)
-	}
-	// Handle added routes based on trailing slash:
-	// 	/prefix  => exact route "/prefix" + any route "/prefix/*"
-	// 	/prefix/ => only any route "/prefix/*"
-	if prefix != "" {
-		if prefix[len(prefix)-1] == '/' {
-			// Only add any route for intentional trailing slash
-			return get(prefix+"*", h)
-		}
-		get(prefix, h)
-	}
-	return get(prefix+"/*", h)
-}
-
 func (common) file(path, file string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route,
 	m ...MiddlewareFunc) *Route {
 	return get(path, func(c Context) error {
@@ -653,7 +609,7 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// Acquire context
 	c := e.pool.Get().(*context)
 	c.Reset(r, w)
-	h := NotFoundHandler
+	var h func(Context) error
 
 	if e.premiddleware == nil {
 		e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
diff --git a/echo_fs.go b/echo_fs.go
new file mode 100644
index 0000000..c379054
--- /dev/null
+++ b/echo_fs.go
@@ -0,0 +1,62 @@
+//go:build !go1.16
+// +build !go1.16
+
+package echo
+
+import (
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+)
+
+type filesystem struct {
+}
+
+func createFilesystem() filesystem {
+	return filesystem{}
+}
+
+// Static registers a new route with path prefix to serve static files from the
+// provided root directory.
+func (e *Echo) Static(prefix, root string) *Route {
+	if root == "" {
+		root = "." // For security we want to restrict to CWD.
+	}
+	return e.static(prefix, root, e.GET)
+}
+
+func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route {
+	h := func(c Context) error {
+		p, err := url.PathUnescape(c.Param("*"))
+		if err != nil {
+			return err
+		}
+
+		name := filepath.Join(root, filepath.Clean("/"+p)) // "/"+ for security
+		fi, err := os.Stat(name)
+		if err != nil {
+			// The access path does not exist
+			return NotFoundHandler(c)
+		}
+
+		// If the request is for a directory and does not end with "/"
+		p = c.Request().URL.Path // path must not be empty.
+		if fi.IsDir() && p[len(p)-1] != '/' {
+			// Redirect to ends with "/"
+			return c.Redirect(http.StatusMovedPermanently, p+"/")
+		}
+		return c.File(name)
+	}
+	// Handle added routes based on trailing slash:
+	// 	/prefix  => exact route "/prefix" + any route "/prefix/*"
+	// 	/prefix/ => only any route "/prefix/*"
+	if prefix != "" {
+		if prefix[len(prefix)-1] == '/' {
+			// Only add any route for intentional trailing slash
+			return get(prefix+"*", h)
+		}
+		get(prefix, h)
+	}
+	return get(prefix+"/*", h)
+}
diff --git a/echo_fs_go1.16.go b/echo_fs_go1.16.go
new file mode 100644
index 0000000..435459d
--- /dev/null
+++ b/echo_fs_go1.16.go
@@ -0,0 +1,145 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"fmt"
+	"io/fs"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+type filesystem struct {
+	// Filesystem is file system used by Static and File handlers to access files.
+	// Defaults to os.DirFS(".")
+	//
+	// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+	// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+	// including `assets/images` as their prefix.
+	Filesystem fs.FS
+}
+
+func createFilesystem() filesystem {
+	return filesystem{
+		Filesystem: newDefaultFS(),
+	}
+}
+
+// Static registers a new route with path prefix to serve static files from the provided root directory.
+func (e *Echo) Static(pathPrefix, fsRoot string) *Route {
+	subFs := MustSubFS(e.Filesystem, fsRoot)
+	return e.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(subFs, false),
+	)
+}
+
+// StaticFS registers a new route with path prefix to serve static files from the provided file system.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS) *Route {
+	return e.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(filesystem, false),
+	)
+}
+
+// StaticDirectoryHandler creates handler function to serve files from provided file system
+// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
+func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
+	return func(c Context) error {
+		p := c.Param("*")
+		if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
+			tmpPath, err := url.PathUnescape(p)
+			if err != nil {
+				return fmt.Errorf("failed to unescape path variable: %w", err)
+			}
+			p = tmpPath
+		}
+
+		// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
+		name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
+		fi, err := fs.Stat(fileSystem, name)
+		if err != nil {
+			return ErrNotFound
+		}
+
+		// If the request is for a directory and does not end with "/"
+		p = c.Request().URL.Path // path must not be empty.
+		if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
+			// Redirect to ends with "/"
+			return c.Redirect(http.StatusMovedPermanently, p+"/")
+		}
+		return fsFile(c, name, fileSystem)
+	}
+}
+
+// FileFS registers a new route with path to serve file from the provided file system.
+func (e *Echo) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
+	return e.GET(path, StaticFileHandler(file, filesystem), m...)
+}
+
+// StaticFileHandler creates handler function to serve file from provided file system
+func StaticFileHandler(file string, filesystem fs.FS) HandlerFunc {
+	return func(c Context) error {
+		return fsFile(c, file, filesystem)
+	}
+}
+
+// defaultFS emulates os.Open behaviour with filesystem opened by `os.DirFs`. Difference between `os.Open` and `fs.Open`
+// is that FS does not allow to open path that start with `..` or `/` etc. For example previously you could have `../images`
+// in your application but `fs := os.DirFS("./")` would not allow you to use `fs.Open("../images")` and this would break
+// all old applications that rely on being able to traverse up from current executable run path.
+// NB: private because you really should use fs.FS implementation instances
+type defaultFS struct {
+	prefix string
+	fs     fs.FS
+}
+
+func newDefaultFS() *defaultFS {
+	dir, _ := os.Getwd()
+	return &defaultFS{
+		prefix: dir,
+		fs:     os.DirFS(dir),
+	}
+}
+
+func (fs defaultFS) Open(name string) (fs.File, error) {
+	return fs.fs.Open(name)
+}
+
+func subFS(currentFs fs.FS, root string) (fs.FS, error) {
+	root = filepath.ToSlash(filepath.Clean(root)) // note: fs.FS operates only with slashes. `ToSlash` is necessary for Windows
+	if dFS, ok := currentFs.(*defaultFS); ok {
+		// we need to make exception for `defaultFS` instances as it interprets root prefix differently from fs.FS to
+		// allow cases when root is given as `../somepath` which is not valid for fs.FS
+		root = filepath.Join(dFS.prefix, root)
+		return &defaultFS{
+			prefix: root,
+			fs:     os.DirFS(root),
+		}, nil
+	}
+	return fs.Sub(currentFs, root)
+}
+
+// MustSubFS creates sub FS from current filesystem or panic on failure.
+// Panic happens when `fsRoot` contains invalid path according to `fs.ValidPath` rules.
+//
+// MustSubFS is helpful when dealing with `embed.FS` because for example `//go:embed assets/images` embeds files with
+// paths including `assets/images` as their prefix. In that case use `fs := echo.MustSubFS(fs, "rootDirectory") to
+// create sub fs which uses necessary prefix for directory path.
+func MustSubFS(currentFs fs.FS, fsRoot string) fs.FS {
+	subFs, err := subFS(currentFs, fsRoot)
+	if err != nil {
+		panic(fmt.Errorf("can not create sub FS, invalid root given, err: %w", err))
+	}
+	return subFs
+}
diff --git a/go.mod b/go.mod
index e5fa0d5..80087d6 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/stretchr/testify v1.7.0
 	github.com/valyala/fasttemplate v1.2.1
 	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
-	golang.org/x/net v0.0.0-20210913180222-943fd674d43e
+	golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
 )
diff --git a/go.sum b/go.sum
index 8a1ec2f..f667342 100644
--- a/go.sum
+++ b/go.sum
@@ -20,18 +20,13 @@ github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52
 github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210913180222-943fd674d43e h1:+b/22bPvDYt4NPDcy4xAGCmON713ONAWFeY3Z7I3tR8=
-golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211104170005-ce137452f963 h1:8gJUadZl+kWvZBqG/LautX0X6qe5qTC2VI/3V3NBRAY=
-golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -42,8 +37,6 @@ golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
-golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/group.go b/group.go
index 426bef9..bba470c 100644
--- a/group.go
+++ b/group.go
@@ -102,11 +102,6 @@ func (g *Group) Group(prefix string, middleware ...MiddlewareFunc) (sg *Group) {
 	return
 }
 
-// Static implements `Echo#Static()` for sub-routes within the Group.
-func (g *Group) Static(prefix, root string) {
-	g.static(prefix, root, g.GET)
-}
-
 // File implements `Echo#File()` for sub-routes within the Group.
 func (g *Group) File(path, file string) {
 	g.file(path, file, g.GET)
diff --git a/group_fs.go b/group_fs.go
new file mode 100644
index 0000000..0a1ce4a
--- /dev/null
+++ b/group_fs.go
@@ -0,0 +1,9 @@
+//go:build !go1.16
+// +build !go1.16
+
+package echo
+
+// Static implements `Echo#Static()` for sub-routes within the Group.
+func (g *Group) Static(prefix, root string) {
+	g.static(prefix, root, g.GET)
+}
diff --git a/group_fs_go1.16.go b/group_fs_go1.16.go
new file mode 100644
index 0000000..2ba52b5
--- /dev/null
+++ b/group_fs_go1.16.go
@@ -0,0 +1,33 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"io/fs"
+	"net/http"
+)
+
+// Static implements `Echo#Static()` for sub-routes within the Group.
+func (g *Group) Static(pathPrefix, fsRoot string) {
+	subFs := MustSubFS(g.echo.Filesystem, fsRoot)
+	g.StaticFS(pathPrefix, subFs)
+}
+
+// StaticFS implements `Echo#StaticFS()` for sub-routes within the Group.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS) {
+	g.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(filesystem, false),
+	)
+}
+
+// FileFS implements `Echo#FileFS()` for sub-routes within the Group.
+func (g *Group) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
+	return g.GET(path, StaticFileHandler(file, filesystem), m...)
+}
diff --git a/middleware/recover.go b/middleware/recover.go
index 0dbe740..a621a9e 100644
--- a/middleware/recover.go
+++ b/middleware/recover.go
@@ -9,6 +9,9 @@ import (
 )
 
 type (
+	// LogErrorFunc defines a function for custom logging in the middleware.
+	LogErrorFunc func(c echo.Context, err error, stack []byte) error
+
 	// RecoverConfig defines the config for Recover middleware.
 	RecoverConfig struct {
 		// Skipper defines a function to skip middleware.
@@ -30,6 +33,10 @@ type (
 		// LogLevel is log level to printing stack trace.
 		// Optional. Default value 0 (Print).
 		LogLevel log.Lvl
+
+		// LogErrorFunc defines a function for custom logging in the middleware.
+		// If it's set you don't need to provide LogLevel for config.
+		LogErrorFunc LogErrorFunc
 	}
 )
 
@@ -41,6 +48,7 @@ var (
 		DisableStackAll:   false,
 		DisablePrintStack: false,
 		LogLevel:          0,
+		LogErrorFunc:      nil,
 	}
 )
 
@@ -73,9 +81,18 @@ func RecoverWithConfig(config RecoverConfig) echo.MiddlewareFunc {
 					if !ok {
 						err = fmt.Errorf("%v", r)
 					}
-					stack := make([]byte, config.StackSize)
-					length := runtime.Stack(stack, !config.DisableStackAll)
+					var stack []byte
+					var length int
+
 					if !config.DisablePrintStack {
+						stack = make([]byte, config.StackSize)
+						length = runtime.Stack(stack, !config.DisableStackAll)
+						stack = stack[:length]
+					}
+
+					if config.LogErrorFunc != nil {
+						err = config.LogErrorFunc(c, err, stack)
+					} else if !config.DisablePrintStack {
 						msg := fmt.Sprintf("[PANIC RECOVER] %v %s\n", err, stack[:length])
 						switch config.LogLevel {
 						case log.DEBUG:
diff --git a/router.go b/router.go
index 1a2ce56..a1de2d6 100644
--- a/router.go
+++ b/router.go
@@ -624,6 +624,4 @@ func (r *Router) Find(method, path string, c Context) {
 	}
 	ctx.path = currentNode.ppath
 	ctx.pnames = currentNode.pnames
-
-	return
 }


Test patch:
diff --git a/context_fs_go1.16_test.go b/context_fs_go1.16_test.go
new file mode 100644
index 0000000..027d1c4
--- /dev/null
+++ b/context_fs_go1.16_test.go
@@ -0,0 +1,135 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"github.com/stretchr/testify/assert"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+)
+
+func TestContext_File(t *testing.T) {
+	var testCases = []struct {
+		name             string
+		whenFile         string
+		whenFS           fs.FS
+		expectStatus     int
+		expectStartsWith []byte
+		expectError      string
+	}{
+		{
+			name:             "ok, from default file system",
+			whenFile:         "_fixture/images/walle.png",
+			whenFS:           nil,
+			expectStatus:     http.StatusOK,
+			expectStartsWith: []byte{0x89, 0x50, 0x4e},
+		},
+		{
+			name:             "ok, from custom file system",
+			whenFile:         "walle.png",
+			whenFS:           os.DirFS("_fixture/images"),
+			expectStatus:     http.StatusOK,
+			expectStartsWith: []byte{0x89, 0x50, 0x4e},
+		},
+		{
+			name:             "nok, not existent file",
+			whenFile:         "not.png",
+			whenFS:           os.DirFS("_fixture/images"),
+			expectStatus:     http.StatusOK,
+			expectStartsWith: nil,
+			expectError:      "code=404, message=Not Found",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			if tc.whenFS != nil {
+				e.Filesystem = tc.whenFS
+			}
+
+			handler := func(ec Context) error {
+				return ec.(*context).File(tc.whenFile)
+			}
+
+			req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec)
+
+			err := handler(c)
+
+			assert.Equal(t, tc.expectStatus, rec.Code)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			body := rec.Body.Bytes()
+			if len(body) > len(tc.expectStartsWith) {
+				body = body[:len(tc.expectStartsWith)]
+			}
+			assert.Equal(t, tc.expectStartsWith, body)
+		})
+	}
+}
+
+func TestContext_FileFS(t *testing.T) {
+	var testCases = []struct {
+		name             string
+		whenFile         string
+		whenFS           fs.FS
+		expectStatus     int
+		expectStartsWith []byte
+		expectError      string
+	}{
+		{
+			name:             "ok",
+			whenFile:         "walle.png",
+			whenFS:           os.DirFS("_fixture/images"),
+			expectStatus:     http.StatusOK,
+			expectStartsWith: []byte{0x89, 0x50, 0x4e},
+		},
+		{
+			name:             "nok, not existent file",
+			whenFile:         "not.png",
+			whenFS:           os.DirFS("_fixture/images"),
+			expectStatus:     http.StatusOK,
+			expectStartsWith: nil,
+			expectError:      "code=404, message=Not Found",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+
+			handler := func(ec Context) error {
+				return ec.(*context).FileFS(tc.whenFile, tc.whenFS)
+			}
+
+			req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec)
+
+			err := handler(c)
+
+			assert.Equal(t, tc.expectStatus, rec.Code)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			body := rec.Body.Bytes()
+			if len(body) > len(tc.expectStartsWith) {
+				body = body[:len(tc.expectStartsWith)]
+			}
+			assert.Equal(t, tc.expectStartsWith, body)
+		})
+	}
+}
diff --git a/echo_fs_go1.16_test.go b/echo_fs_go1.16_test.go
new file mode 100644
index 0000000..07e5165
--- /dev/null
+++ b/echo_fs_go1.16_test.go
@@ -0,0 +1,265 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"github.com/stretchr/testify/assert"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestEcho_StaticFS(t *testing.T) {
+	var testCases = []struct {
+		name                 string
+		givenPrefix          string
+		givenFs              fs.FS
+		givenFsRoot          string
+		whenURL              string
+		expectStatus         int
+		expectHeaderLocation string
+		expectBodyStartsWith string
+	}{
+		{
+			name:                 "ok",
+			givenPrefix:          "/images",
+			givenFs:              os.DirFS("./_fixture/images"),
+			whenURL:              "/images/walle.png",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: string([]byte{0x89, 0x50, 0x4e, 0x47}),
+		},
+		{
+			name:                 "ok, from sub fs",
+			givenPrefix:          "/images",
+			givenFs:              MustSubFS(os.DirFS("./_fixture/"), "images"),
+			whenURL:              "/images/walle.png",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: string([]byte{0x89, 0x50, 0x4e, 0x47}),
+		},
+		{
+			name:                 "No file",
+			givenPrefix:          "/images",
+			givenFs:              os.DirFS("_fixture/scripts"),
+			whenURL:              "/images/bolt.png",
+			expectStatus:         http.StatusNotFound,
+			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
+		},
+		{
+			name:                 "Directory",
+			givenPrefix:          "/images",
+			givenFs:              os.DirFS("_fixture/images"),
+			whenURL:              "/images/",
+			expectStatus:         http.StatusNotFound,
+			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
+		},
+		{
+			name:                 "Directory Redirect",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture/"),
+			whenURL:              "/folder",
+			expectStatus:         http.StatusMovedPermanently,
+			expectHeaderLocation: "/folder/",
+			expectBodyStartsWith: "",
+		},
+		{
+			name:                 "Directory Redirect with non-root path",
+			givenPrefix:          "/static",
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/static",
+			expectStatus:         http.StatusMovedPermanently,
+			expectHeaderLocation: "/static/",
+			expectBodyStartsWith: "",
+		},
+		{
+			name:                 "Prefixed directory 404 (request URL without slash)",
+			givenPrefix:          "/folder/", // trailing slash will intentionally not match "/folder"
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/folder", // no trailing slash
+			expectStatus:         http.StatusNotFound,
+			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
+		},
+		{
+			name:                 "Prefixed directory redirect (without slash redirect to slash)",
+			givenPrefix:          "/folder", // no trailing slash shall match /folder and /folder/*
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/folder", // no trailing slash
+			expectStatus:         http.StatusMovedPermanently,
+			expectHeaderLocation: "/folder/",
+			expectBodyStartsWith: "",
+		},
+		{
+			name:                 "Directory with index.html",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: "<!doctype html>",
+		},
+		{
+			name:                 "Prefixed directory with index.html (prefix ending with slash)",
+			givenPrefix:          "/assets/",
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/assets/",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: "<!doctype html>",
+		},
+		{
+			name:                 "Prefixed directory with index.html (prefix ending without slash)",
+			givenPrefix:          "/assets",
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/assets/",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: "<!doctype html>",
+		},
+		{
+			name:                 "Sub-directory with index.html",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture"),
+			whenURL:              "/folder/",
+			expectStatus:         http.StatusOK,
+			expectBodyStartsWith: "<!doctype html>",
+		},
+		{
+			name:                 "do not allow directory traversal (backslash - windows separator)",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture/"),
+			whenURL:              `/..\\middleware/basic_auth.go`,
+			expectStatus:         http.StatusNotFound,
+			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
+		},
+		{
+			name:                 "do not allow directory traversal (slash - unix separator)",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture/"),
+			whenURL:              `/../middleware/basic_auth.go`,
+			expectStatus:         http.StatusNotFound,
+			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+
+			tmpFs := tc.givenFs
+			if tc.givenFsRoot != "" {
+				tmpFs = MustSubFS(tmpFs, tc.givenFsRoot)
+			}
+			e.StaticFS(tc.givenPrefix, tmpFs)
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectStatus, rec.Code)
+			body := rec.Body.String()
+			if tc.expectBodyStartsWith != "" {
+				assert.True(t, strings.HasPrefix(body, tc.expectBodyStartsWith))
+			} else {
+				assert.Equal(t, "", body)
+			}
+
+			if tc.expectHeaderLocation != "" {
+				assert.Equal(t, tc.expectHeaderLocation, rec.Result().Header["Location"][0])
+			} else {
+				_, ok := rec.Result().Header["Location"]
+				assert.False(t, ok)
+			}
+		})
+	}
+}
+
+func TestEcho_FileFS(t *testing.T) {
+	var testCases = []struct {
+		name             string
+		whenPath         string
+		whenFile         string
+		whenFS           fs.FS
+		givenURL         string
+		expectCode       int
+		expectStartsWith []byte
+	}{
+		{
+			name:             "ok",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "walle.png",
+			givenURL:         "/walle",
+			expectCode:       http.StatusOK,
+			expectStartsWith: []byte{0x89, 0x50, 0x4e},
+		},
+		{
+			name:             "nok, requesting invalid path",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "walle.png",
+			givenURL:         "/walle.png",
+			expectCode:       http.StatusNotFound,
+			expectStartsWith: []byte(`{"message":"Not Found"}`),
+		},
+		{
+			name:             "nok, serving not existent file from filesystem",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "not-existent.png",
+			givenURL:         "/walle",
+			expectCode:       http.StatusNotFound,
+			expectStartsWith: []byte(`{"message":"Not Found"}`),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			e.FileFS(tc.whenPath, tc.whenFile, tc.whenFS)
+
+			req := httptest.NewRequest(http.MethodGet, tc.givenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+
+			body := rec.Body.Bytes()
+			if len(body) > len(tc.expectStartsWith) {
+				body = body[:len(tc.expectStartsWith)]
+			}
+			assert.Equal(t, tc.expectStartsWith, body)
+		})
+	}
+}
+
+func TestEcho_StaticPanic(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		givenRoot   string
+		expectError string
+	}{
+		{
+			name:        "panics for ../",
+			givenRoot:   "../assets",
+			expectError: "can not create sub FS, invalid root given, err: sub ../assets: invalid name",
+		},
+		{
+			name:        "panics for /",
+			givenRoot:   "/assets",
+			expectError: "can not create sub FS, invalid root given, err: sub /assets: invalid name",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			e.Filesystem = os.DirFS("./")
+
+			assert.PanicsWithError(t, tc.expectError, func() {
+				e.Static("../assets", tc.givenRoot)
+			})
+		})
+	}
+}
diff --git a/echo_test.go b/echo_test.go
index 13a51b6..f175d76 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -211,7 +211,6 @@ func TestEchoStatic(t *testing.T) {
 }
 
 func TestEchoStaticRedirectIndex(t *testing.T) {
-	assert := assert.New(t)
 	e := New()
 
 	// HandlerFunc
@@ -220,23 +219,25 @@ func TestEchoStaticRedirectIndex(t *testing.T) {
 	errCh := make(chan error)
 
 	go func() {
-		errCh <- e.Start("127.0.0.1:1323")
+		errCh <- e.Start(":0")
 	}()
 
-	time.Sleep(200 * time.Millisecond)
+	err := waitForServerStart(e, errCh, false)
+	assert.NoError(t, err)
 
-	if resp, err := http.Get("http://127.0.0.1:1323/static"); err == nil {
+	addr := e.ListenerAddr().String()
+	if resp, err := http.Get("http://" + addr + "/static"); err == nil { // http.Get follows redirects by default
 		defer resp.Body.Close()
-		assert.Equal(http.StatusOK, resp.StatusCode)
+		assert.Equal(t, http.StatusOK, resp.StatusCode)
 
 		if body, err := ioutil.ReadAll(resp.Body); err == nil {
-			assert.Equal(true, strings.HasPrefix(string(body), "<!doctype html>"))
+			assert.Equal(t, true, strings.HasPrefix(string(body), "<!doctype html>"))
 		} else {
-			assert.Fail(err.Error())
+			assert.Fail(t, err.Error())
 		}
 
 	} else {
-		assert.Fail(err.Error())
+		assert.NoError(t, err)
 	}
 
 	if err := e.Close(); err != nil {
diff --git a/group_fs_go1.16_test.go b/group_fs_go1.16_test.go
new file mode 100644
index 0000000..d0caa33
--- /dev/null
+++ b/group_fs_go1.16_test.go
@@ -0,0 +1,106 @@
+//go:build go1.16
+// +build go1.16
+
+package echo
+
+import (
+	"github.com/stretchr/testify/assert"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+)
+
+func TestGroup_FileFS(t *testing.T) {
+	var testCases = []struct {
+		name             string
+		whenPath         string
+		whenFile         string
+		whenFS           fs.FS
+		givenURL         string
+		expectCode       int
+		expectStartsWith []byte
+	}{
+		{
+			name:             "ok",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "walle.png",
+			givenURL:         "/assets/walle",
+			expectCode:       http.StatusOK,
+			expectStartsWith: []byte{0x89, 0x50, 0x4e},
+		},
+		{
+			name:             "nok, requesting invalid path",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "walle.png",
+			givenURL:         "/assets/walle.png",
+			expectCode:       http.StatusNotFound,
+			expectStartsWith: []byte(`{"message":"Not Found"}`),
+		},
+		{
+			name:             "nok, serving not existent file from filesystem",
+			whenPath:         "/walle",
+			whenFS:           os.DirFS("_fixture/images"),
+			whenFile:         "not-existent.png",
+			givenURL:         "/assets/walle",
+			expectCode:       http.StatusNotFound,
+			expectStartsWith: []byte(`{"message":"Not Found"}`),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			g := e.Group("/assets")
+			g.FileFS(tc.whenPath, tc.whenFile, tc.whenFS)
+
+			req := httptest.NewRequest(http.MethodGet, tc.givenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+
+			body := rec.Body.Bytes()
+			if len(body) > len(tc.expectStartsWith) {
+				body = body[:len(tc.expectStartsWith)]
+			}
+			assert.Equal(t, tc.expectStartsWith, body)
+		})
+	}
+}
+
+func TestGroup_StaticPanic(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		givenRoot   string
+		expectError string
+	}{
+		{
+			name:        "panics for ../",
+			givenRoot:   "../images",
+			expectError: "can not create sub FS, invalid root given, err: sub ../images: invalid name",
+		},
+		{
+			name:        "panics for /",
+			givenRoot:   "/images",
+			expectError: "can not create sub FS, invalid root given, err: sub /images: invalid name",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			e.Filesystem = os.DirFS("./")
+
+			g := e.Group("/assets")
+
+			assert.PanicsWithError(t, tc.expectError, func() {
+				g.Static("/images", tc.givenRoot)
+			})
+		})
+	}
+}
diff --git a/middleware/recover_test.go b/middleware/recover_test.go
index 6443329..9ac4fee 100644
--- a/middleware/recover_test.go
+++ b/middleware/recover_test.go
@@ -2,6 +2,7 @@ package middleware
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -81,3 +82,55 @@ func TestRecoverWithConfig_LogLevel(t *testing.T) {
 		})
 	}
 }
+
+func TestRecoverWithConfig_LogErrorFunc(t *testing.T) {
+	e := echo.New()
+	e.Logger.SetLevel(log.DEBUG)
+
+	buf := new(bytes.Buffer)
+	e.Logger.SetOutput(buf)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec)
+
+	testError := errors.New("test")
+	config := DefaultRecoverConfig
+	config.LogErrorFunc = func(c echo.Context, err error, stack []byte) error {
+		msg := fmt.Sprintf("[PANIC RECOVER] %v %s\n", err, stack)
+		if errors.Is(err, testError) {
+			c.Logger().Debug(msg)
+		} else {
+			c.Logger().Error(msg)
+		}
+		return err
+	}
+
+	t.Run("first branch case for LogErrorFunc", func(t *testing.T) {
+		buf.Reset()
+		h := RecoverWithConfig(config)(echo.HandlerFunc(func(c echo.Context) error {
+			panic(testError)
+		}))
+
+		h(c)
+		assert.Equal(t, http.StatusInternalServerError, rec.Code)
+
+		output := buf.String()
+		assert.Contains(t, output, "PANIC RECOVER")
+		assert.Contains(t, output, `"level":"DEBUG"`)
+	})
+
+	t.Run("else branch case for LogErrorFunc", func(t *testing.T) {
+		buf.Reset()
+		h := RecoverWithConfig(config)(echo.HandlerFunc(func(c echo.Context) error {
+			panic("other")
+		}))
+
+		h(c)
+		assert.Equal(t, http.StatusInternalServerError, rec.Code)
+
+		output := buf.String()
+		assert.Contains(t, output, "PANIC RECOVER")
+		assert.Contains(t, output, `"level":"ERROR"`)
+	})
+}