support virtual file system for static files
This commit is contained in:
parent
9bf43315b3
commit
70865d5289
7 changed files with 356 additions and 9 deletions
32
README.md
Executable file → Normal file
32
README.md
Executable file → Normal file
|
@ -1,11 +1,11 @@
|
||||||
# GoHTTPRouter
|
# goHTTPRouter
|
||||||
[](https://godoc.org/gitlab.com/martinr92/gohttprouter)
|
[](https://godoc.org/gitlab.com/martinr92/gohttprouter)
|
||||||
[](https://gitlab.com/martinr92/gohttprouter/commits/master)
|
[](https://gitlab.com/martinr92/gohttprouter/commits/master)
|
||||||
[](https://gitlab.com/martinr92/gohttprouter/commits/master)
|
[](https://gitlab.com/martinr92/gohttprouter/commits/master)
|
||||||
[](https://codecov.io/gl/martinr92/gohttprouter)
|
[](https://codecov.io/gl/martinr92/gohttprouter)
|
||||||
[](https://goreportcard.com/report/gitlab.com/martinr92/gohttprouter)
|
[](https://goreportcard.com/report/gitlab.com/martinr92/gohttprouter)
|
||||||
|
|
||||||
GoHTTPRouter is a framework used for HTTP request routing.
|
goHTTPRouter is a framework used for HTTP request routing.
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
## Simple Routing
|
## Simple Routing
|
||||||
|
@ -22,15 +22,37 @@ err := http.ListenAndServe("localhost:8080", router)
|
||||||
A path can also contain placeholder (like :id). If then a request is sent to the url "/user/123" the method gets executed and the URL part (in this case "123") is passed as parameter into your handler function.
|
A path can also contain placeholder (like :id). If then a request is sent to the url "/user/123" the method gets executed and the URL part (in this case "123") is passed as parameter into your handler function.
|
||||||
```golang
|
```golang
|
||||||
router := gohttprouter.New()
|
router := gohttprouter.New()
|
||||||
router.HandleFunc(http.MethodGet, "/user/:id", func(response http.ResponseWriter, request *http.Request, info gohttprouter.RoutingInfo) {
|
router.HandleFunc(http.MethodGet, "/user/:id", handleUserPages)
|
||||||
|
router.HandleFunc(http.MethodGet, "/user/:id/settings", handleUserPages)
|
||||||
|
func handleUserPages(response http.ResponseWriter, request *http.Request, info gohttprouter.RoutingInfo) {
|
||||||
id := info.Parameters["id"]
|
id := info.Parameters["id"]
|
||||||
})
|
response.Write([]byte(id))
|
||||||
|
}
|
||||||
err := http.ListenAndServe("localhost:8080", router)
|
err := http.ListenAndServe("localhost:8080", router)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Serve Static Content
|
||||||
|
Static files (like JavaScript or CSS) can be served automatically (including caching header and MIME type). It uses the `embed.FS` (since go 1.16) to serve static content.
|
||||||
|
```golang
|
||||||
|
//go:embed files/statc/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
```
|
||||||
|
```golang
|
||||||
|
staticFS := gohttprouter.NewFS(&staticFS)
|
||||||
|
router := gohttprouter.New()
|
||||||
|
router.Handle(http.MethodGet, "/static/*", staticFS)
|
||||||
|
```
|
||||||
|
|
||||||
|
For development purpose you can enable the local file serving additionally. The framework checks first, if the file exists locally and serves it directly. If not, the file is served from the `embed.FS`. This helps you during local development so you can modify a CSS file without recompiling everything.
|
||||||
|
```golang
|
||||||
|
staticFS := gohttprouter.NewFS(&staticFS)
|
||||||
|
staticFS.UseLocalFolder = true
|
||||||
|
staticFS.LocalFolderPrefix = "some/folder" // optional
|
||||||
|
```
|
||||||
|
|
||||||
# License
|
# License
|
||||||
```
|
```
|
||||||
Copyright 2019 Martin Riedl
|
Copyright 2018-2021 Martin Riedl
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|
149
fs.go
Normal file
149
fs.go
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
// Copyright 2021 Martin Riedl
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package gohttprouter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FS file system for serving static content.
|
||||||
|
type FS struct {
|
||||||
|
StaticFiles *embed.FS
|
||||||
|
FolderPrefix string
|
||||||
|
UseLocalFolder bool
|
||||||
|
LocalFolderPrefix string
|
||||||
|
HeaderHeandler HeaderHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFS creates a new instance of the http file system used for serving static files.
|
||||||
|
func NewFS(staticFiles *embed.FS) *FS {
|
||||||
|
return &FS{
|
||||||
|
StaticFiles: staticFiles,
|
||||||
|
HeaderHeandler: DefaultHeaderHandler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) ServeHTTP(w http.ResponseWriter, r *http.Request, info RoutingInfo) {
|
||||||
|
// use wildcard placeholder value for the path (if available)
|
||||||
|
url, found := info.Parameters["*"]
|
||||||
|
if !found {
|
||||||
|
url = r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// search for file in static file system
|
||||||
|
file, err := fs.findFile(url)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// serve file content
|
||||||
|
fs.serve(w, r, info, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) findFile(url string) (fs.File, error) {
|
||||||
|
// build file path
|
||||||
|
filePath := path.Join(fs.FolderPrefix, url)
|
||||||
|
if strings.HasPrefix(filePath, "/") {
|
||||||
|
filePath = filePath[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// check, if file exists in local folder
|
||||||
|
if fs.UseLocalFolder {
|
||||||
|
localFilePath := path.Join(fs.LocalFolderPrefix, filePath)
|
||||||
|
if strings.HasPrefix(localFilePath, "/") {
|
||||||
|
localFilePath = localFilePath[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localFilePath)
|
||||||
|
if err == nil {
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// use static file system
|
||||||
|
return fs.StaticFiles.Open(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) serve(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
// execute header handler
|
||||||
|
if fs.HeaderHeandler != nil {
|
||||||
|
fs.HeaderHeandler(w, r, info, file)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// check for head request
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
// return no content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// send file content
|
||||||
|
io.Copy(w, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderHandler for custom http header handling
|
||||||
|
type HeaderHandler func(http.ResponseWriter, *http.Request, RoutingInfo, fs.File)
|
||||||
|
|
||||||
|
// DefaultHeaderHandler is the default implementation for file headers
|
||||||
|
func DefaultHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
ContentLengthHeaderHandler(w, r, info, file)
|
||||||
|
ContentTypeHeaderHandler(w, r, info, file)
|
||||||
|
CacheControlHeaderHandler(w, r, info, file)
|
||||||
|
ContentTypeOptionHeaderHandler(w, r, info, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentLengthHeaderHandler sends the file size
|
||||||
|
func ContentLengthHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
// send file size as header
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprint(stat.Size()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentTypeHeaderHandler sends content type based on the file name extension
|
||||||
|
func ContentTypeHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeHeader := mime.TypeByExtension(path.Ext(stat.Name()))
|
||||||
|
if mimeHeader != "" {
|
||||||
|
w.Header().Set("Content-Type", mimeHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheControlHeaderHandler send cache control header of 7 days
|
||||||
|
func CacheControlHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", 60*60*24*7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentTypeOptionHeaderHandler for nosniff content type option
|
||||||
|
func ContentTypeOptionHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
}
|
135
fs_test.go
Normal file
135
fs_test.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
package gohttprouter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed README.md LICENSE.txt
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
type testWriter struct {
|
||||||
|
header http.Header
|
||||||
|
written []byte
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *testWriter) Header() http.Header {
|
||||||
|
if tw.header == nil {
|
||||||
|
tw.header = make(http.Header)
|
||||||
|
}
|
||||||
|
return tw.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *testWriter) Write(bytes []byte) (int, error) {
|
||||||
|
tw.written = append(tw.written, bytes...)
|
||||||
|
return len(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *testWriter) WriteHeader(statusCode int) {
|
||||||
|
tw.statusCode = statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSReadmeFile(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/README.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusOK {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSReadmeFileLocal(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
fs.UseLocalFolder = true
|
||||||
|
fs.LocalFolderPrefix = "/"
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/README.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusOK {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSReadmeFileHead(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodHead,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/README.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusOK {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSLicense(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodHead,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/LICENSE.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusOK {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSNotFound(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/not-exists.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusNotFound {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
2
go.mod
2
go.mod
|
@ -1 +1,3 @@
|
||||||
module gitlab.com/martinr92/gohttprouter
|
module gitlab.com/martinr92/gohttprouter
|
||||||
|
|
||||||
|
go 1.16
|
27
route.go
Executable file → Normal file
27
route.go
Executable file → Normal file
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2018-2019 Martin Riedl
|
// Copyright 2018-2021 Martin Riedl
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
@ -21,11 +21,15 @@ import (
|
||||||
type route struct {
|
type route struct {
|
||||||
name string
|
name string
|
||||||
placeholderName string
|
placeholderName string
|
||||||
|
isWildcard bool
|
||||||
handler Handler
|
handler Handler
|
||||||
routes map[string]*route
|
routes map[string]*route
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameterPrefix = ":"
|
const (
|
||||||
|
parameterPrefix = ":"
|
||||||
|
wildcard = "*"
|
||||||
|
)
|
||||||
|
|
||||||
func newRoute() *route {
|
func newRoute() *route {
|
||||||
return &route{routes: make(map[string]*route)}
|
return &route{routes: make(map[string]*route)}
|
||||||
|
@ -41,6 +45,13 @@ func (r *route) parsePath(path string, create bool) (*route, bool, map[string]st
|
||||||
parameters[r.placeholderName] = parts[0]
|
parameters[r.placeholderName] = parts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check for wildcard match
|
||||||
|
if !create && r.isWildcard {
|
||||||
|
parameters = make(map[string]string)
|
||||||
|
parameters[wildcard] = path
|
||||||
|
return r, false, parameters
|
||||||
|
}
|
||||||
|
|
||||||
// more path is available
|
// more path is available
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
// parse next part element
|
// parse next part element
|
||||||
|
@ -61,6 +72,11 @@ func (r *route) parsePath(path string, create bool) (*route, bool, map[string]st
|
||||||
subRoute, ok = r.routes[parameterPrefix]
|
subRoute, ok = r.routes[parameterPrefix]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// route still not found, try to use wildcard path
|
||||||
|
if !create && !ok {
|
||||||
|
subRoute, ok = r.routes[wildcard]
|
||||||
|
}
|
||||||
|
|
||||||
// check for creation
|
// check for creation
|
||||||
subRouteCreated := false
|
subRouteCreated := false
|
||||||
if !ok && !create {
|
if !ok && !create {
|
||||||
|
@ -86,6 +102,9 @@ func (r *route) parseName(name string) {
|
||||||
if isParameter(name) {
|
if isParameter(name) {
|
||||||
r.name = parameterPrefix
|
r.name = parameterPrefix
|
||||||
r.placeholderName = name[1:]
|
r.placeholderName = name[1:]
|
||||||
|
} else if isWildcard(name) {
|
||||||
|
r.name = wildcard
|
||||||
|
r.isWildcard = true
|
||||||
} else {
|
} else {
|
||||||
r.name = name
|
r.name = name
|
||||||
}
|
}
|
||||||
|
@ -108,3 +127,7 @@ func mergeParameterMaps(m1, m2 map[string]string) map[string]string {
|
||||||
func isParameter(s string) bool {
|
func isParameter(s string) bool {
|
||||||
return strings.Index(s, parameterPrefix) == 0 && len(s) > 1
|
return strings.Index(s, parameterPrefix) == 0 && len(s) > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWildcard(s string) bool {
|
||||||
|
return s == wildcard
|
||||||
|
}
|
||||||
|
|
14
route_test.go
Executable file → Normal file
14
route_test.go
Executable file → Normal file
|
@ -68,3 +68,17 @@ func TestRoutePlaceholder(t *testing.T) {
|
||||||
t.Error("wrong parameter value parsed")
|
t.Error("wrong parameter value parsed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouteWildcard(t *testing.T) {
|
||||||
|
route := newRoute()
|
||||||
|
route.parsePath("/static/*", true)
|
||||||
|
|
||||||
|
// check path
|
||||||
|
foundRoute, _, foundParameters := route.parsePath("/static/js/my.js", false)
|
||||||
|
if foundRoute == nil {
|
||||||
|
t.Error("no route found for wildcard path")
|
||||||
|
}
|
||||||
|
if foundParameters["*"] != "js/my.js" {
|
||||||
|
t.Error("wrong parameter value for wildcard parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
6
router.go
Executable file → Normal file
6
router.go
Executable file → Normal file
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2018-2019 Martin Riedl
|
// Copyright 2018-2021 Martin Riedl
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
@ -115,7 +115,9 @@ func (router *Router) ServeHTTP(response http.ResponseWriter, request *http.Requ
|
||||||
// check for exact matching
|
// check for exact matching
|
||||||
route, _, parameter := router.findRoute(request.Method, request.URL.Path, false)
|
route, _, parameter := router.findRoute(request.Method, request.URL.Path, false)
|
||||||
if route != nil && route.handler != nil {
|
if route != nil && route.handler != nil {
|
||||||
route.handler.ServeHTTP(response, request, RoutingInfo{Parameters: parameter})
|
route.handler.ServeHTTP(response, request, RoutingInfo{
|
||||||
|
Parameters: parameter,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue