Compare commits
37 commits
Author | SHA1 | Date | |
---|---|---|---|
0a0fc645fc | |||
107c873b4b | |||
851ecb345a | |||
f4ceca90ab | |||
b4cff38dca | |||
7b5e1d0254 | |||
18a84b9b92 | |||
8d8f8b0c99 | |||
203438dc8c | |||
ceb7d04edd | |||
2fb1b35713 | |||
c0e04495a4 | |||
ac2d0bd827 | |||
1613a02b8e | |||
ff36e5eb1d | |||
a6d67cf60a | |||
aa0a11f3c5 | |||
773209071c | |||
144d640578 | |||
85ab140761 | |||
fda5e96ceb | |||
f7dc11aabc | |||
8225706f4c | |||
e491412f07 | |||
78a018b4f6 | |||
5c80024022 | |||
b7dc1d7f6e | |||
36efa38103 | |||
f3555a603d | |||
8ecd10eff8 | |||
cdee276905 | |||
01030a52ba | |||
dd14b201b1 | |||
b312a51c62 | |||
50d3c1be28 | |||
d8985d5a74 | |||
70865d5289 |
14 changed files with 718 additions and 77 deletions
81
.forgejo/workflows/build.yml
Normal file
81
.forgejo/workflows/build.yml
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- beta
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
name: Checks
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23.x'
|
||||||
|
check-latest: true
|
||||||
|
- name: Run go fmt and go vet
|
||||||
|
run: |
|
||||||
|
go fmt $(go list ./...)
|
||||||
|
go vet $(go list ./...)
|
||||||
|
|
||||||
|
code-coverage:
|
||||||
|
name: Code Coverage
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23.x'
|
||||||
|
check-latest: true
|
||||||
|
- name: Run tests and generate coverage report
|
||||||
|
run: |
|
||||||
|
go test -covermode=count -coverprofile coverage.cov $(go list ./...)
|
||||||
|
go tool cover -func=coverage.cov
|
||||||
|
go tool cover -html=coverage.cov -o coverage.html
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-reports
|
||||||
|
path: |
|
||||||
|
coverage.cov
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: docker
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go:
|
||||||
|
- GOOS: darwin
|
||||||
|
GOARCH: amd64
|
||||||
|
- GOOS: darwin
|
||||||
|
GOARCH: arm64
|
||||||
|
- GOOS: linux
|
||||||
|
GOARCH: amd64
|
||||||
|
- GOOS: linux
|
||||||
|
GOARCH: arm64
|
||||||
|
- GOOS: windows
|
||||||
|
GOARCH: amd64
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23.x'
|
||||||
|
check-latest: true
|
||||||
|
- name: Set environment variables
|
||||||
|
run: |
|
||||||
|
echo "GOOS=${{ matrix.go.GOOS }}" >> $GITHUB_ENV
|
||||||
|
echo "GOARCH=${{ matrix.go.GOARCH }}" >> $GITHUB_ENV
|
||||||
|
- name: Build
|
||||||
|
run: go build .
|
20
.forgejo/workflows/release.yml
Normal file
20
.forgejo/workflows/release.yml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- beta
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Semantic Release
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Semantic Release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npm install -g semantic-release@23 conventional-changelog-conventionalcommits@7
|
||||||
|
semantic-release
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea
|
|
@ -1,38 +0,0 @@
|
||||||
image: golang:latest
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
|
|
||||||
validate lint:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- go get -u golang.org/x/lint/golint
|
|
||||||
- golint -set_exit_status ./...
|
|
||||||
|
|
||||||
execute tests:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- go test ./...
|
|
||||||
|
|
||||||
race detection:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- go test -race ./...
|
|
||||||
|
|
||||||
code coverage:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- go test -covermode=count -coverprofile coverage.cov ./...
|
|
||||||
- go tool cover -func=coverage.cov
|
|
||||||
- mkdir $CI_PROJECT_DIR/report
|
|
||||||
- go tool cover -html=coverage.cov -o $CI_PROJECT_DIR/report/coverage.html
|
|
||||||
coverage: '/\(statements\)\W+\d+\.\d+%/'
|
|
||||||
artifacts:
|
|
||||||
paths:
|
|
||||||
- report/
|
|
||||||
|
|
||||||
codecov.io:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
|
||||||
- bash <(curl -s https://codecov.io/bash)
|
|
72
.releaserc
Normal file
72
.releaserc
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"main",
|
||||||
|
{
|
||||||
|
"name": "beta",
|
||||||
|
"prerelease": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"releaseRules": [
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"release": "patch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "build",
|
||||||
|
"release": "patch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ci",
|
||||||
|
"release": "patch"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"presetConfig": {
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"type": "feat",
|
||||||
|
"section": "Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "feature",
|
||||||
|
"section": "Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"section": "Bug Fixes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "perf",
|
||||||
|
"section": "Performance Improvements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "revert",
|
||||||
|
"section": "Reverts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"section": "Miscellaneous Chores"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/github",
|
||||||
|
{
|
||||||
|
"successCommentCondition": false,
|
||||||
|
"failTitle": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
65
README.md
Executable file → Normal file
65
README.md
Executable file → Normal file
|
@ -1,36 +1,77 @@
|
||||||
# GoHTTPRouter
|
# httprouter
|
||||||
|
|
||||||
|
[](https://git.martin-riedl.de/golang/httprouter/tags)
|
||||||
|
[](https://git.martin-riedl.de/golang/httprouter/actions)
|
||||||
[](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://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.
|
httprouter is a framework used for HTTP request routing.
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
|
|
||||||
## Simple Routing
|
## Simple Routing
|
||||||
|
|
||||||
Just replace the standard router of golang with this one:
|
Just replace the standard router of golang with this one:
|
||||||
```golang
|
```golang
|
||||||
router := gohttprouter.New()
|
import "git.martin-riedl.de/golang/httprouter/v4"
|
||||||
router.HandleFunc(http.MethodGet, "/home", func(response http.ResponseWriter, request *http.Request, info gohttprouter.RoutingInfo) {
|
```
|
||||||
|
```golang
|
||||||
|
router := httprouter.New()
|
||||||
|
router.HandleFunc(http.MethodGet, "/home", func(response http.ResponseWriter, request *http.Request, info httprouter.RoutingInfo) {
|
||||||
response.Write([]byte("Home"))
|
response.Write([]byte("Home"))
|
||||||
})
|
})
|
||||||
err := http.ListenAndServe("localhost:8080", router)
|
err := http.ListenAndServe("localhost:8080", router)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Routing with placeholder
|
## Routing with placeholder
|
||||||
|
|
||||||
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()
|
import "git.martin-riedl.de/golang/httprouter/v4"
|
||||||
router.HandleFunc(http.MethodGet, "/user/:id", func(response http.ResponseWriter, request *http.Request, info gohttprouter.RoutingInfo) {
|
```
|
||||||
|
```golang
|
||||||
|
router := httprouter.New()
|
||||||
|
router.HandleFunc(http.MethodGet, "/user/:id", handleUserPages)
|
||||||
|
router.HandleFunc(http.MethodGet, "/user/:id/settings", handleUserPages)
|
||||||
|
func handleUserPages(response http.ResponseWriter, request *http.Request, info httprouter.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)
|
||||||
```
|
```
|
||||||
|
|
||||||
# License
|
## 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
|
||||||
|
import "git.martin-riedl.de/golang/httprouter/v4"
|
||||||
|
|
||||||
|
//go:embed files/static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
```
|
```
|
||||||
Copyright 2019 Martin Riedl
|
```golang
|
||||||
|
staticFS := httprouter.NewFS(&staticFiles)
|
||||||
|
router := httprouter.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
|
||||||
|
import "git.martin-riedl.de/golang/httprouter/v4"
|
||||||
|
```
|
||||||
|
```golang
|
||||||
|
staticFS := httprouter.NewFS(&staticFiles)
|
||||||
|
staticFS.UseLocalFolder = true
|
||||||
|
staticFS.LocalFolderPrefix = "some/folder" // optional
|
||||||
|
```
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright 2018-2025 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.
|
||||||
|
|
185
fs.go
Normal file
185
fs.go
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
// Copyright 2021-2025 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 httprouter
|
||||||
|
|
||||||
|
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
|
||||||
|
HeaderHandler HeaderHandler
|
||||||
|
IndexFileNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
HeaderHandler: 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
|
||||||
|
}
|
||||||
|
// check, if the file is a folder
|
||||||
|
file, ok := fs.checkFolder(file, url)
|
||||||
|
if !ok {
|
||||||
|
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)
|
||||||
|
filePath = strings.TrimPrefix(filePath, "/")
|
||||||
|
|
||||||
|
// check, if file exists in local folder
|
||||||
|
if fs.UseLocalFolder {
|
||||||
|
localFilePath := path.Join(fs.LocalFolderPrefix, filePath)
|
||||||
|
localFilePath = strings.TrimPrefix(localFilePath, "/")
|
||||||
|
if localFilePath == "" {
|
||||||
|
localFilePath = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localFilePath)
|
||||||
|
if err == nil {
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// use static file system
|
||||||
|
if filePath == "" {
|
||||||
|
filePath = "."
|
||||||
|
}
|
||||||
|
file, err := fs.StaticFiles.Open(filePath)
|
||||||
|
return file, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) checkFolder(file fs.File, url string) (fs.File, bool) {
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return file, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for folder / root location
|
||||||
|
if fileInfo.IsDir() {
|
||||||
|
// close folder handler (we don't need it anymore)
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
// check for index files
|
||||||
|
for _, indexFileName := range fs.IndexFileNames {
|
||||||
|
newFullPath := path.Join(url, indexFileName)
|
||||||
|
if newFile, err := fs.findFile(newFullPath); err == nil {
|
||||||
|
return newFile, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) serve(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
// execute header handler
|
||||||
|
if fs.HeaderHandler != nil {
|
||||||
|
fs.HeaderHandler(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 for the browser and 30 days for the CDN
|
||||||
|
func CacheControlHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) {
|
||||||
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, s-maxage=%d, max-age=%d", 60*60*24*30, 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")
|
||||||
|
}
|
212
fs_test.go
Normal file
212
fs_test.go
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
// Copyright 2025 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 httprouter
|
||||||
|
|
||||||
|
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 TestFSFolder(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodHead,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusNotFound {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSFolderLocal(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
fs.UseLocalFolder = true
|
||||||
|
fs.LocalFolderPrefix = "/"
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodHead,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(tw, tr, RoutingInfo{})
|
||||||
|
|
||||||
|
// check data
|
||||||
|
if tw.statusCode != http.StatusNotFound {
|
||||||
|
t.Error("received invalid http status code", tw.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSFolderIndex(t *testing.T) {
|
||||||
|
// create new FS
|
||||||
|
fs := NewFS(&staticFiles)
|
||||||
|
fs.IndexFileNames = []string{"README.md"}
|
||||||
|
|
||||||
|
// serve the request
|
||||||
|
tw := &testWriter{}
|
||||||
|
tr := &http.Request{
|
||||||
|
Method: http.MethodHead,
|
||||||
|
URL: &url.URL{
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
4
go.mod
4
go.mod
|
@ -1 +1,3 @@
|
||||||
module gitlab.com/martinr92/gohttprouter
|
module git.martin-riedl.de/golang/httprouter/v4
|
||||||
|
|
||||||
|
go 1.19
|
9
renovate.json
Normal file
9
renovate.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"local>ci/renovate//configs/base"
|
||||||
|
],
|
||||||
|
"baseBranches": [
|
||||||
|
"develop"
|
||||||
|
]
|
||||||
|
}
|
33
route.go
Executable file → Normal file
33
route.go
Executable file → Normal file
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2018-2019 Martin Riedl
|
// Copyright 2018-2025 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.
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
package gohttprouter
|
package httprouter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -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,12 +72,17 @@ 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 {
|
||||||
// no route found
|
// no route found
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
} else if !ok && create {
|
} else if !ok {
|
||||||
subRoute = newRoute()
|
subRoute = newRoute()
|
||||||
subRoute.parseName(subName)
|
subRoute.parseName(subName)
|
||||||
r.routes[subRoute.name] = subRoute
|
r.routes[subRoute.name] = subRoute
|
||||||
|
@ -75,7 +91,7 @@ func (r *route) parsePath(path string, create bool) (*route, bool, map[string]st
|
||||||
|
|
||||||
// parse sub-route
|
// parse sub-route
|
||||||
matchingRoute, created, subParameters := subRoute.parsePath(parts[1], create)
|
matchingRoute, created, subParameters := subRoute.parsePath(parts[1], create)
|
||||||
return matchingRoute, (subRouteCreated || created), mergeParameterMaps(parameters, subParameters)
|
return matchingRoute, subRouteCreated || created, mergeParameterMaps(parameters, subParameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// last element in path
|
// last element in path
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
|
30
route_test.go
Executable file → Normal file
30
route_test.go
Executable file → Normal file
|
@ -1,4 +1,18 @@
|
||||||
package gohttprouter
|
// Copyright 2025 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 httprouter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -68,3 +82,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
10
router.go
Executable file → Normal file
10
router.go
Executable file → Normal file
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2018-2019 Martin Riedl
|
// Copyright 2018-2025 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.
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
package gohttprouter
|
package httprouter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -85,7 +85,7 @@ func (router *Router) findRoute(method string, path string, create bool) (route
|
||||||
methodRoute, ok := router.registry[methodUpper]
|
methodRoute, ok := router.registry[methodUpper]
|
||||||
if !ok && !create {
|
if !ok && !create {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
} else if !ok && create {
|
} else if !ok {
|
||||||
methodRoute = newRoute()
|
methodRoute = newRoute()
|
||||||
router.registry[methodUpper] = methodRoute
|
router.registry[methodUpper] = methodRoute
|
||||||
created = true
|
created = true
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,18 @@
|
||||||
package gohttprouter
|
// Copyright 2025 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 httprouter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
@ -7,17 +21,6 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testWebServer(t *testing.T) {
|
|
||||||
router := New()
|
|
||||||
Must(router.HandleFunc(http.MethodGet, "/:name", func(response http.ResponseWriter, request *http.Request, info RoutingInfo) {
|
|
||||||
response.Write([]byte("name: " + info.Parameters["name"]))
|
|
||||||
}))
|
|
||||||
err := http.ListenAndServe("localhost:8080", router)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterInvalidPath(t *testing.T) {
|
func TestRouterInvalidPath(t *testing.T) {
|
||||||
router := New()
|
router := New()
|
||||||
if err := router.HandleFunc(http.MethodGet, "missing/slash", emptyHandlerFunction); err == nil {
|
if err := router.HandleFunc(http.MethodGet, "missing/slash", emptyHandlerFunction); err == nil {
|
||||||
|
@ -74,7 +77,7 @@ func TestRouterServer(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for completion
|
// wait for completion
|
||||||
_ = <-quitChan
|
<-quitChan
|
||||||
listener.Close()
|
listener.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,7 +99,7 @@ func TestRouterServerHandler(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for completion
|
// wait for completion
|
||||||
_ = <-testHandler.CloseChannel
|
<-testHandler.CloseChannel
|
||||||
listener.Close()
|
listener.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue