support virtual file system for static files

This commit is contained in:
Martin Riedl 2021-03-17 20:45:32 +01:00
parent 9bf43315b3
commit 70865d5289
Signed by: martinr92
GPG key ID: FB68DA65516A804C
7 changed files with 356 additions and 9 deletions

32
README.md Executable file → Normal file
View file

@ -1,11 +1,11 @@
# GoHTTPRouter # goHTTPRouter
[![GoDoc](https://godoc.org/gitlab.com/martinr92/gohttprouter?status.svg)](https://godoc.org/gitlab.com/martinr92/gohttprouter) [![GoDoc](https://godoc.org/gitlab.com/martinr92/gohttprouter?status.svg)](https://godoc.org/gitlab.com/martinr92/gohttprouter)
[![pipeline status](https://gitlab.com/martinr92/gohttprouter/badges/master/pipeline.svg)](https://gitlab.com/martinr92/gohttprouter/commits/master) [![pipeline status](https://gitlab.com/martinr92/gohttprouter/badges/master/pipeline.svg)](https://gitlab.com/martinr92/gohttprouter/commits/master)
[![coverage report](https://gitlab.com/martinr92/gohttprouter/badges/master/coverage.svg)](https://gitlab.com/martinr92/gohttprouter/commits/master) [![coverage report](https://gitlab.com/martinr92/gohttprouter/badges/master/coverage.svg)](https://gitlab.com/martinr92/gohttprouter/commits/master)
[![codecov](https://codecov.io/gl/martinr92/gohttprouter/branch/master/graph/badge.svg)](https://codecov.io/gl/martinr92/gohttprouter) [![codecov](https://codecov.io/gl/martinr92/gohttprouter/branch/master/graph/badge.svg)](https://codecov.io/gl/martinr92/gohttprouter)
[![Go Report Card](https://goreportcard.com/badge/gitlab.com/martinr92/gohttprouter)](https://goreportcard.com/report/gitlab.com/martinr92/gohttprouter) [![Go Report Card](https://goreportcard.com/badge/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
View 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
View 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
View file

@ -1 +1,3 @@
module gitlab.com/martinr92/gohttprouter module gitlab.com/martinr92/gohttprouter
go 1.16

27
route.go Executable file → Normal file
View 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
View 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
View 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
} }