snapshot of https://github.com/go-chi/chi.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Test / test (1.24.x, ubuntu-latest) (push) Successful in 2m56s
Test / test (1.25.x, ubuntu-latest) (push) Successful in 2m15s
Test / test (1.26.x, ubuntu-latest) (push) Successful in 2m23s
Test / test (1.27.x, ubuntu-latest) (push) Failing after 3m56s
Test / test (1.24.x, windows-latest) (push) Has been cancelled
Test / test (1.25.x, windows-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Test / test (1.27.x, windows-latest) (push) Has been cancelled
Some checks failed
Test / test (1.24.x, ubuntu-latest) (push) Successful in 2m56s
Test / test (1.25.x, ubuntu-latest) (push) Successful in 2m15s
Test / test (1.26.x, ubuntu-latest) (push) Successful in 2m23s
Test / test (1.27.x, ubuntu-latest) (push) Failing after 3m56s
Test / test (1.24.x, windows-latest) (push) Has been cancelled
Test / test (1.25.x, windows-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Test / test (1.27.x, windows-latest) (push) Has been cancelled
This commit is contained in:
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [pkieltyka] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
otechie: # Replace with a single Otechie username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: "**"
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
pull_request:
|
||||||
|
branches: "**"
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
|
||||||
|
name: Test
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
env:
|
||||||
|
GOPATH: ${{ github.workspace }}
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go-version: [1.24.x, 1.25.x, 1.26.x, 1.27.x]
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
check-latest: true
|
||||||
|
cache: false
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }}
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
go get -d -t ./...
|
||||||
|
make test
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.idea
|
||||||
|
*.sw?
|
||||||
|
.vscode
|
||||||
341
CHANGELOG.md
Normal file
341
CHANGELOG.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v5.0.12 (2024-02-16)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.11...v5.0.12
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.11 (2023-12-19)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.10...v5.0.11
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.10 (2023-07-13)
|
||||||
|
|
||||||
|
- Fixed small edge case in tests of v5.0.9 for older Go versions
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.9...v5.0.10
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.9 (2023-07-13)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.8...v5.0.9
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.8 (2022-12-07)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.7...v5.0.8
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.7 (2021-11-18)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.6...v5.0.7
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.6 (2021-11-15)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.5...v5.0.6
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.5 (2021-10-27)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.4...v5.0.5
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.4 (2021-08-29)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.3...v5.0.4
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.3 (2021-04-29)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.2...v5.0.3
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.2 (2021-03-25)
|
||||||
|
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.1...v5.0.2
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.1 (2021-03-10)
|
||||||
|
|
||||||
|
- Small improvements
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v5.0.0...v5.0.1
|
||||||
|
|
||||||
|
|
||||||
|
## v5.0.0 (2021-02-27)
|
||||||
|
|
||||||
|
- chi v5, `github.com/go-chi/chi/v5` introduces the adoption of Go's SIV to adhere to the current state-of-the-tools in Go.
|
||||||
|
- chi v1.5.x did not work out as planned, as the Go tooling is too powerful and chi's adoption is too wide.
|
||||||
|
The most responsible thing to do for everyone's benefit is to just release v5 with SIV, so I present to you all,
|
||||||
|
chi v5 at `github.com/go-chi/chi/v5`. I hope someday the developer experience and ergonomics I've been seeking
|
||||||
|
will still come to fruition in some form, see https://github.com/golang/go/issues/44550
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v1.5.4...v5.0.0
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.4 (2021-02-27)
|
||||||
|
|
||||||
|
- Undo prior retraction in v1.5.3 as we prepare for v5.0.0 release
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v1.5.3...v1.5.4
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.3 (2021-02-21)
|
||||||
|
|
||||||
|
- Update go.mod to go 1.16 with new retract directive marking all versions without prior go.mod support
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v1.5.2...v1.5.3
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.2 (2021-02-10)
|
||||||
|
|
||||||
|
- Reverting allocation optimization as a precaution as go test -race fails.
|
||||||
|
- Minor improvements, see history below
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v1.5.1...v1.5.2
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.1 (2020-12-06)
|
||||||
|
|
||||||
|
- Performance improvement: removing 1 allocation by foregoing context.WithValue, thank you @bouk for
|
||||||
|
your contribution (https://github.com/go-chi/chi/pull/555). Note: new benchmarks posted in README.
|
||||||
|
- `middleware.CleanPath`: new middleware that clean's request path of double slashes
|
||||||
|
- deprecate & remove `chi.ServerBaseContext` in favour of stdlib `http.Server#BaseContext`
|
||||||
|
- plus other tiny improvements, see full commit history below
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.1.2...v1.5.1
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.0 (2020-11-12) - now with go.mod support
|
||||||
|
|
||||||
|
`chi` dates back to 2016 with it's original implementation as one of the first routers to adopt the newly introduced
|
||||||
|
context.Context api to the stdlib -- set out to design a router that is faster, more modular and simpler than anything
|
||||||
|
else out there -- while not introducing any custom handler types or dependencies. Today, `chi` still has zero dependencies,
|
||||||
|
and in many ways is future proofed from changes, given it's minimal nature. Between versions, chi's iterations have been very
|
||||||
|
incremental, with the architecture and api being the same today as it was originally designed in 2016. For this reason it
|
||||||
|
makes chi a pretty easy project to maintain, as well thanks to the many amazing community contributions over the years
|
||||||
|
to who all help make chi better (total of 86 contributors to date -- thanks all!).
|
||||||
|
|
||||||
|
Chi has been a labour of love, art and engineering, with the goals to offer beautiful ergonomics, flexibility, performance
|
||||||
|
and simplicity when building HTTP services with Go. I've strived to keep the router very minimal in surface area / code size,
|
||||||
|
and always improving the code wherever possible -- and as of today the `chi` package is just 1082 lines of code (not counting
|
||||||
|
middlewares, which are all optional). As well, I don't have the exact metrics, but from my analysis and email exchanges from
|
||||||
|
companies and developers, chi is used by thousands of projects around the world -- thank you all as there is no better form of
|
||||||
|
joy for me than to have art I had started be helpful and enjoyed by others. And of course I use chi in all of my own projects too :)
|
||||||
|
|
||||||
|
For me, the aesthetics of chi's code and usage are very important. With the introduction of Go's module support
|
||||||
|
(which I'm a big fan of), chi's past versioning scheme choice to v2, v3 and v4 would mean I'd require the import path
|
||||||
|
of "github.com/go-chi/chi/v4", leading to the lengthy discussion at https://github.com/go-chi/chi/issues/462.
|
||||||
|
Haha, to some, you may be scratching your head why I've spent > 1 year stalling to adopt "/vXX" convention in the import
|
||||||
|
path -- which isn't horrible in general -- but for chi, I'm unable to accept it as I strive for perfection in it's API design,
|
||||||
|
aesthetics and simplicity. It just doesn't feel good to me given chi's simple nature -- I do not foresee a "v5" or "v6",
|
||||||
|
and upgrading between versions in the future will also be just incremental.
|
||||||
|
|
||||||
|
I do understand versioning is a part of the API design as well, which is why the solution for a while has been to "do nothing",
|
||||||
|
as Go supports both old and new import paths with/out go.mod. However, now that Go module support has had time to iron out kinks and
|
||||||
|
is adopted everywhere, it's time for chi to get with the times. Luckily, I've discovered a path forward that will make me happy,
|
||||||
|
while also not breaking anyone's app who adopted a prior versioning from tags in v2/v3/v4. I've made an experimental release of
|
||||||
|
v1.5.0 with go.mod silently, and tested it with new and old projects, to ensure the developer experience is preserved, and it's
|
||||||
|
largely unnoticed. Fortunately, Go's toolchain will check the tags of a repo and consider the "latest" tag the one with go.mod.
|
||||||
|
However, you can still request a specific older tag such as v4.1.2, and everything will "just work". But new users can just
|
||||||
|
`go get github.com/go-chi/chi` or `go get github.com/go-chi/chi@latest` and they will get the latest version which contains
|
||||||
|
go.mod support, which is v1.5.0+. `chi` will not change very much over the years, just like it hasn't changed much from 4 years ago.
|
||||||
|
Therefore, we will stay on v1.x from here on, starting from v1.5.0. Any breaking changes will bump a "minor" release and
|
||||||
|
backwards-compatible improvements/fixes will bump a "tiny" release.
|
||||||
|
|
||||||
|
For existing projects who want to upgrade to the latest go.mod version, run: `go get -u github.com/go-chi/chi@v1.5.0`,
|
||||||
|
which will get you on the go.mod version line (as Go's mod cache may still remember v4.x). Brand new systems can run
|
||||||
|
`go get -u github.com/go-chi/chi` or `go get -u github.com/go-chi/chi@latest` to install chi, which will install v1.5.0+
|
||||||
|
built with go.mod support.
|
||||||
|
|
||||||
|
My apologies to the developers who will disagree with the decisions above, but, hope you'll try it and see it's a very
|
||||||
|
minor request which is backwards compatible and won't break your existing installations.
|
||||||
|
|
||||||
|
Cheers all, happy coding!
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## v4.1.2 (2020-06-02)
|
||||||
|
|
||||||
|
- fix that handles MethodNotAllowed with path variables, thank you @caseyhadden for your contribution
|
||||||
|
- fix to replace nested wildcards correctly in RoutePattern, thank you @@unmultimedio for your contribution
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.1.1...v4.1.2
|
||||||
|
|
||||||
|
|
||||||
|
## v4.1.1 (2020-04-16)
|
||||||
|
|
||||||
|
- fix for issue https://github.com/go-chi/chi/issues/411 which allows for overlapping regexp
|
||||||
|
route to the correct handler through a recursive tree search, thanks to @Jahaja for the PR/fix!
|
||||||
|
- new middleware.RouteHeaders as a simple router for request headers with wildcard support
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.1.0...v4.1.1
|
||||||
|
|
||||||
|
|
||||||
|
## v4.1.0 (2020-04-1)
|
||||||
|
|
||||||
|
- middleware.LogEntry: Write method on interface now passes the response header
|
||||||
|
and an extra interface type useful for custom logger implementations.
|
||||||
|
- middleware.WrapResponseWriter: minor fix
|
||||||
|
- middleware.Recoverer: a bit prettier
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.0.4...v4.1.0
|
||||||
|
|
||||||
|
## v4.0.4 (2020-03-24)
|
||||||
|
|
||||||
|
- middleware.Recoverer: new pretty stack trace printing (https://github.com/go-chi/chi/pull/496)
|
||||||
|
- a few minor improvements and fixes
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.0.3...v4.0.4
|
||||||
|
|
||||||
|
|
||||||
|
## v4.0.3 (2020-01-09)
|
||||||
|
|
||||||
|
- core: fix regexp routing to include default value when param is not matched
|
||||||
|
- middleware: rewrite of middleware.Compress
|
||||||
|
- middleware: suppress http.ErrAbortHandler in middleware.Recoverer
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.0.2...v4.0.3
|
||||||
|
|
||||||
|
|
||||||
|
## v4.0.2 (2019-02-26)
|
||||||
|
|
||||||
|
- Minor fixes
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.0.1...v4.0.2
|
||||||
|
|
||||||
|
|
||||||
|
## v4.0.1 (2019-01-21)
|
||||||
|
|
||||||
|
- Fixes issue with compress middleware: #382 #385
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v4.0.0...v4.0.1
|
||||||
|
|
||||||
|
|
||||||
|
## v4.0.0 (2019-01-10)
|
||||||
|
|
||||||
|
- chi v4 requires Go 1.10.3+ (or Go 1.9.7+) - we have deprecated support for Go 1.7 and 1.8
|
||||||
|
- router: respond with 404 on router with no routes (#362)
|
||||||
|
- router: additional check to ensure wildcard is at the end of a url pattern (#333)
|
||||||
|
- middleware: deprecate use of http.CloseNotifier (#347)
|
||||||
|
- middleware: fix RedirectSlashes to include query params on redirect (#334)
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v3.3.4...v4.0.0
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.4 (2019-01-07)
|
||||||
|
|
||||||
|
- Minor middleware improvements. No changes to core library/router. Moving v3 into its
|
||||||
|
- own branch as a version of chi for Go 1.7, 1.8, 1.9, 1.10, 1.11
|
||||||
|
- History of changes: see https://github.com/go-chi/chi/compare/v3.3.3...v3.3.4
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.3 (2018-08-27)
|
||||||
|
|
||||||
|
- Minor release
|
||||||
|
- See https://github.com/go-chi/chi/compare/v3.3.2...v3.3.3
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.2 (2017-12-22)
|
||||||
|
|
||||||
|
- Support to route trailing slashes on mounted sub-routers (#281)
|
||||||
|
- middleware: new `ContentCharset` to check matching charsets. Thank you
|
||||||
|
@csucu for your community contribution!
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.1 (2017-11-20)
|
||||||
|
|
||||||
|
- middleware: new `AllowContentType` handler for explicit whitelist of accepted request Content-Types
|
||||||
|
- middleware: new `SetHeader` handler for short-hand middleware to set a response header key/value
|
||||||
|
- Minor bug fixes
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.0 (2017-10-10)
|
||||||
|
|
||||||
|
- New chi.RegisterMethod(method) to add support for custom HTTP methods, see _examples/custom-method for usage
|
||||||
|
- Deprecated LINK and UNLINK methods from the default list, please use `chi.RegisterMethod("LINK")` and `chi.RegisterMethod("UNLINK")` in an `init()` function
|
||||||
|
|
||||||
|
|
||||||
|
## v3.2.1 (2017-08-31)
|
||||||
|
|
||||||
|
- Add new `Match(rctx *Context, method, path string) bool` method to `Routes` interface
|
||||||
|
and `Mux`. Match searches the mux's routing tree for a handler that matches the method/path
|
||||||
|
- Add new `RouteMethod` to `*Context`
|
||||||
|
- Add new `Routes` pointer to `*Context`
|
||||||
|
- Add new `middleware.GetHead` to route missing HEAD requests to GET handler
|
||||||
|
- Updated benchmarks (see README)
|
||||||
|
|
||||||
|
|
||||||
|
## v3.1.5 (2017-08-02)
|
||||||
|
|
||||||
|
- Setup golint and go vet for the project
|
||||||
|
- As per golint, we've redefined `func ServerBaseContext(h http.Handler, baseCtx context.Context) http.Handler`
|
||||||
|
to `func ServerBaseContext(baseCtx context.Context, h http.Handler) http.Handler`
|
||||||
|
|
||||||
|
|
||||||
|
## v3.1.0 (2017-07-10)
|
||||||
|
|
||||||
|
- Fix a few minor issues after v3 release
|
||||||
|
- Move `docgen` sub-pkg to https://github.com/go-chi/docgen
|
||||||
|
- Move `render` sub-pkg to https://github.com/go-chi/render
|
||||||
|
- Add new `URLFormat` handler to chi/middleware sub-pkg to make working with url mime
|
||||||
|
suffixes easier, ie. parsing `/articles/1.json` and `/articles/1.xml`. See comments in
|
||||||
|
https://github.com/go-chi/chi/blob/master/middleware/url_format.go for example usage.
|
||||||
|
|
||||||
|
|
||||||
|
## v3.0.0 (2017-06-21)
|
||||||
|
|
||||||
|
- Major update to chi library with many exciting updates, but also some *breaking changes*
|
||||||
|
- URL parameter syntax changed from `/:id` to `/{id}` for even more flexible routing, such as
|
||||||
|
`/articles/{month}-{day}-{year}-{slug}`, `/articles/{id}`, and `/articles/{id}.{ext}` on the
|
||||||
|
same router
|
||||||
|
- Support for regexp for routing patterns, in the form of `/{paramKey:regExp}` for example:
|
||||||
|
`r.Get("/articles/{name:[a-z]+}", h)` and `chi.URLParam(r, "name")`
|
||||||
|
- Add `Method` and `MethodFunc` to `chi.Router` to allow routing definitions such as
|
||||||
|
`r.Method("GET", "/", h)` which provides a cleaner interface for custom handlers like
|
||||||
|
in `_examples/custom-handler`
|
||||||
|
- Deprecating `mux#FileServer` helper function. Instead, we encourage users to create their
|
||||||
|
own using file handler with the stdlib, see `_examples/fileserver` for an example
|
||||||
|
- Add support for LINK/UNLINK http methods via `r.Method()` and `r.MethodFunc()`
|
||||||
|
- Moved the chi project to its own organization, to allow chi-related community packages to
|
||||||
|
be easily discovered and supported, at: https://github.com/go-chi
|
||||||
|
- *NOTE:* please update your import paths to `"github.com/go-chi/chi"`
|
||||||
|
- *NOTE:* chi v2 is still available at https://github.com/go-chi/chi/tree/v2
|
||||||
|
|
||||||
|
|
||||||
|
## v2.1.0 (2017-03-30)
|
||||||
|
|
||||||
|
- Minor improvements and update to the chi core library
|
||||||
|
- Introduced a brand new `chi/render` sub-package to complete the story of building
|
||||||
|
APIs to offer a pattern for managing well-defined request / response payloads. Please
|
||||||
|
check out the updated `_examples/rest` example for how it works.
|
||||||
|
- Added `MethodNotAllowed(h http.HandlerFunc)` to chi.Router interface
|
||||||
|
|
||||||
|
|
||||||
|
## v2.0.0 (2017-01-06)
|
||||||
|
|
||||||
|
- After many months of v2 being in an RC state with many companies and users running it in
|
||||||
|
production, the inclusion of some improvements to the middlewares, we are very pleased to
|
||||||
|
announce v2.0.0 of chi.
|
||||||
|
|
||||||
|
|
||||||
|
## v2.0.0-rc1 (2016-07-26)
|
||||||
|
|
||||||
|
- Huge update! chi v2 is a large refactor targeting Go 1.7+. As of Go 1.7, the popular
|
||||||
|
community `"net/context"` package has been included in the standard library as `"context"` and
|
||||||
|
utilized by `"net/http"` and `http.Request` to managing deadlines, cancelation signals and other
|
||||||
|
request-scoped values. We're very excited about the new context addition and are proud to
|
||||||
|
introduce chi v2, a minimal and powerful routing package for building large HTTP services,
|
||||||
|
with zero external dependencies. Chi focuses on idiomatic design and encourages the use of
|
||||||
|
stdlib HTTP handlers and middlewares.
|
||||||
|
- chi v2 deprecates its `chi.Handler` interface and requires `http.Handler` or `http.HandlerFunc`
|
||||||
|
- chi v2 stores URL routing parameters and patterns in the standard request context: `r.Context()`
|
||||||
|
- chi v2 lower-level routing context is accessible by `chi.RouteContext(r.Context()) *chi.Context`,
|
||||||
|
which provides direct access to URL routing parameters, the routing path and the matching
|
||||||
|
routing patterns.
|
||||||
|
- Users upgrading from chi v1 to v2, need to:
|
||||||
|
1. Update the old chi.Handler signature, `func(ctx context.Context, w http.ResponseWriter, r *http.Request)` to
|
||||||
|
the standard http.Handler: `func(w http.ResponseWriter, r *http.Request)`
|
||||||
|
2. Use `chi.URLParam(r *http.Request, paramKey string) string`
|
||||||
|
or `URLParamFromCtx(ctx context.Context, paramKey string) string` to access a url parameter value
|
||||||
|
|
||||||
|
|
||||||
|
## v1.0.0 (2016-07-01)
|
||||||
|
|
||||||
|
- Released chi v1 stable https://github.com/go-chi/chi/tree/v1.0.0 for Go 1.6 and older.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.9.0 (2016-03-31)
|
||||||
|
|
||||||
|
- Reuse context objects via sync.Pool for zero-allocation routing [#33](https://github.com/go-chi/chi/pull/33)
|
||||||
|
- BREAKING NOTE: due to subtle API changes, previously `chi.URLParams(ctx)["id"]` used to access url parameters
|
||||||
|
has changed to: `chi.URLParam(ctx, "id")`
|
||||||
31
CONTRIBUTING.md
Normal file
31
CONTRIBUTING.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. [Install Go][go-install].
|
||||||
|
2. Download the sources and switch the working directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get -u -d github.com/go-chi/chi
|
||||||
|
cd $GOPATH/src/github.com/go-chi/chi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Submitting a Pull Request
|
||||||
|
|
||||||
|
A typical workflow is:
|
||||||
|
|
||||||
|
1. [Fork the repository.][fork]
|
||||||
|
2. [Create a topic branch.][branch]
|
||||||
|
3. Add tests for your change.
|
||||||
|
4. Run `go test`. If your tests pass, return to the step 3.
|
||||||
|
5. Implement the change and ensure the steps from the previous step pass.
|
||||||
|
6. Run `goimports -w .`, to ensure the new code conforms to Go formatting guideline.
|
||||||
|
7. [Add, commit and push your changes.][git-help]
|
||||||
|
8. [Submit a pull request.][pull-req]
|
||||||
|
|
||||||
|
[go-install]: https://golang.org/doc/install
|
||||||
|
[fork]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo
|
||||||
|
[branch]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches
|
||||||
|
[git-help]: https://docs.github.com/en
|
||||||
|
[pull-req]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
|
||||||
|
|
||||||
20
LICENSE
Normal file
20
LICENSE
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc.
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
22
Makefile
Normal file
22
Makefile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.PHONY: all
|
||||||
|
all:
|
||||||
|
@echo "**********************************************************"
|
||||||
|
@echo "** chi build tool **"
|
||||||
|
@echo "**********************************************************"
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go clean -testcache && $(MAKE) test-router && $(MAKE) test-middleware
|
||||||
|
|
||||||
|
.PHONY: test-router
|
||||||
|
test-router:
|
||||||
|
go test -race -v .
|
||||||
|
|
||||||
|
.PHONY: test-middleware
|
||||||
|
test-middleware:
|
||||||
|
go test -race -v ./middleware
|
||||||
|
|
||||||
|
.PHONY: docs
|
||||||
|
docs:
|
||||||
|
npx docsify-cli serve ./docs
|
||||||
577
README.md
Normal file
577
README.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# <img alt="chi" src="https://cdn.rawgit.com/go-chi/chi/master/_examples/chi.svg" width="220" />
|
||||||
|
|
||||||
|
|
||||||
|
[![GoDoc Widget]][GoDoc]
|
||||||
|
|
||||||
|
`chi` is a lightweight, idiomatic and composable router for building Go HTTP services. It's
|
||||||
|
especially good at helping you write large REST API services that are kept maintainable as your
|
||||||
|
project grows and changes. `chi` is built on the new `context` package introduced in Go 1.7 to
|
||||||
|
handle signaling, cancelation and request-scoped values across a handler chain.
|
||||||
|
|
||||||
|
The focus of the project has been to seek out an elegant and comfortable design for writing
|
||||||
|
REST API servers, written during the development of the Pressly API service that powers our
|
||||||
|
public API service, which in turn powers all of our client-side applications.
|
||||||
|
|
||||||
|
The key considerations of chi's design are: project structure, maintainability, standard http
|
||||||
|
handlers (stdlib-only), developer productivity, and deconstructing a large system into many small
|
||||||
|
parts. The core router `github.com/go-chi/chi` is quite small (less than 1000 LOC), but we've also
|
||||||
|
included some useful/optional subpackages: [middleware](/middleware), [render](https://github.com/go-chi/render)
|
||||||
|
and [docgen](https://github.com/go-chi/docgen). We hope you enjoy it too!
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get -u github.com/go-chi/chi/v5
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* **Lightweight** - cloc'd in ~1000 LOC for the chi router
|
||||||
|
* **Fast** - yes, see [benchmarks](#benchmarks)
|
||||||
|
* **100% compatible with net/http** - use any http or middleware pkg in the ecosystem that is also compatible with `net/http`
|
||||||
|
* **Designed for modular/composable APIs** - middlewares, inline middlewares, route groups and sub-router mounting
|
||||||
|
* **Context control** - built on new `context` package, providing value chaining, cancellations and timeouts
|
||||||
|
* **Robust** - in production at Pressly, Cloudflare, Heroku, 99Designs, and many others (see [discussion](https://github.com/go-chi/chi/issues/91))
|
||||||
|
* **Doc generation** - `docgen` auto-generates routing documentation from your source to JSON or Markdown
|
||||||
|
* **Go.mod support** - as of v5, go.mod support (see [CHANGELOG](https://github.com/go-chi/chi/blob/master/CHANGELOG.md))
|
||||||
|
* **No external dependencies** - plain ol' Go stdlib + net/http
|
||||||
|
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See [_examples/](https://github.com/go-chi/chi/blob/master/_examples/) for a variety of examples.
|
||||||
|
|
||||||
|
|
||||||
|
**As easy as:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("welcome"))
|
||||||
|
})
|
||||||
|
http.ListenAndServe(":3000", r)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**REST Preview:**
|
||||||
|
|
||||||
|
Here is a little preview of what routing looks like with chi. Also take a look at the generated routing docs
|
||||||
|
in JSON ([routes.json](https://github.com/go-chi/chi/blob/master/_examples/rest/routes.json)) and in
|
||||||
|
Markdown ([routes.md](https://github.com/go-chi/chi/blob/master/_examples/rest/routes.md)).
|
||||||
|
|
||||||
|
I highly recommend reading the source of the [examples](https://github.com/go-chi/chi/blob/master/_examples/) listed
|
||||||
|
above, they will show you all the features of chi and serve as a good form of documentation.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
//...
|
||||||
|
"context"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// A good base middleware stack
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.ClientIPFromRemoteAddr) // pick one ClientIPFrom* based on your infra, see below
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// Set a timeout value on the request context (ctx), that will signal
|
||||||
|
// through ctx.Done() that the request has timed out and further
|
||||||
|
// processing should be stopped.
|
||||||
|
r.Use(middleware.Timeout(60 * time.Second))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("hi"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// RESTy routes for "articles" resource
|
||||||
|
r.Route("/articles", func(r chi.Router) {
|
||||||
|
r.With(paginate).Get("/", listArticles) // GET /articles
|
||||||
|
r.With(paginate).Get("/{month}-{day}-{year}", listArticlesByDate) // GET /articles/01-16-2017
|
||||||
|
|
||||||
|
r.Post("/", createArticle) // POST /articles
|
||||||
|
r.Get("/search", searchArticles) // GET /articles/search
|
||||||
|
|
||||||
|
// Regexp url parameters:
|
||||||
|
r.Get("/{articleSlug:[a-z-]+}", getArticleBySlug) // GET /articles/home-is-toronto
|
||||||
|
|
||||||
|
// Subrouters:
|
||||||
|
r.Route("/{articleID}", func(r chi.Router) {
|
||||||
|
r.Use(ArticleCtx)
|
||||||
|
r.Get("/", getArticle) // GET /articles/123
|
||||||
|
r.Put("/", updateArticle) // PUT /articles/123
|
||||||
|
r.Delete("/", deleteArticle) // DELETE /articles/123
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mount the admin sub-router
|
||||||
|
r.Mount("/admin", adminRouter())
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ArticleCtx(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
articleID := chi.URLParam(r, "articleID")
|
||||||
|
article, err := dbGetArticle(articleID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(404), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), "article", article)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
article, ok := ctx.Value("article").(*Article)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, http.StatusText(422), 422)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(fmt.Sprintf("title:%s", article.Title)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A completely separate router for administrator routes
|
||||||
|
func adminRouter() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(AdminOnly)
|
||||||
|
r.Get("/", adminIndex)
|
||||||
|
r.Get("/accounts", adminListAccounts)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminOnly(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
perm, ok := ctx.Value("acl.permission").(YourPermissionType)
|
||||||
|
if !ok || !perm.IsAdmin() {
|
||||||
|
http.Error(w, http.StatusText(403), 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Router interface
|
||||||
|
|
||||||
|
chi's router is based on a kind of [Patricia Radix trie](https://en.wikipedia.org/wiki/Radix_tree).
|
||||||
|
The router is fully compatible with `net/http`.
|
||||||
|
|
||||||
|
Built on top of the tree is the `Router` interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Router consisting of the core routing methods used by chi's Mux,
|
||||||
|
// using only the standard net/http.
|
||||||
|
type Router interface {
|
||||||
|
http.Handler
|
||||||
|
Routes
|
||||||
|
|
||||||
|
// Use appends one or more middlewares onto the Router stack.
|
||||||
|
Use(middlewares ...func(http.Handler) http.Handler)
|
||||||
|
|
||||||
|
// With adds inline middlewares for an endpoint handler.
|
||||||
|
With(middlewares ...func(http.Handler) http.Handler) Router
|
||||||
|
|
||||||
|
// Group adds a new inline-Router along the current routing
|
||||||
|
// path, with a fresh middleware stack for the inline-Router.
|
||||||
|
Group(fn func(r Router)) Router
|
||||||
|
|
||||||
|
// Route mounts a sub-Router along a `pattern` string.
|
||||||
|
Route(pattern string, fn func(r Router)) Router
|
||||||
|
|
||||||
|
// Mount attaches another http.Handler along ./pattern/*
|
||||||
|
Mount(pattern string, h http.Handler)
|
||||||
|
|
||||||
|
// Handle and HandleFunc adds routes for `pattern` that matches
|
||||||
|
// all HTTP methods.
|
||||||
|
Handle(pattern string, h http.Handler)
|
||||||
|
HandleFunc(pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// Method and MethodFunc adds routes for `pattern` that matches
|
||||||
|
// the `method` HTTP method.
|
||||||
|
Method(method, pattern string, h http.Handler)
|
||||||
|
MethodFunc(method, pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// HTTP-method routing along `pattern`
|
||||||
|
Connect(pattern string, h http.HandlerFunc)
|
||||||
|
Delete(pattern string, h http.HandlerFunc)
|
||||||
|
Get(pattern string, h http.HandlerFunc)
|
||||||
|
Head(pattern string, h http.HandlerFunc)
|
||||||
|
Options(pattern string, h http.HandlerFunc)
|
||||||
|
Patch(pattern string, h http.HandlerFunc)
|
||||||
|
Post(pattern string, h http.HandlerFunc)
|
||||||
|
Put(pattern string, h http.HandlerFunc)
|
||||||
|
Query(pattern string, h http.HandlerFunc)
|
||||||
|
Trace(pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// NotFound defines a handler to respond whenever a route could
|
||||||
|
// not be found.
|
||||||
|
NotFound(h http.HandlerFunc)
|
||||||
|
|
||||||
|
// MethodNotAllowed defines a handler to respond whenever a method is
|
||||||
|
// not allowed.
|
||||||
|
MethodNotAllowed(h http.HandlerFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes interface adds two methods for router traversal, which is also
|
||||||
|
// used by the github.com/go-chi/docgen package to generate documentation for Routers.
|
||||||
|
type Routes interface {
|
||||||
|
// Routes returns the routing tree in an easily traversable structure.
|
||||||
|
Routes() []Route
|
||||||
|
|
||||||
|
// Middlewares returns the list of middlewares in use by the router.
|
||||||
|
Middlewares() Middlewares
|
||||||
|
|
||||||
|
// Match searches the routing tree for a handler that matches
|
||||||
|
// the method/path - similar to routing a http request, but without
|
||||||
|
// executing the handler thereafter.
|
||||||
|
Match(rctx *Context, method, path string) bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each routing method accepts a URL `pattern` and chain of `handlers`. The URL pattern
|
||||||
|
supports named params (ie. `/users/{userID}`) and wildcards (ie. `/admin/*`). URL parameters
|
||||||
|
can be fetched at runtime by calling `chi.URLParam(r, "userID")` for named parameters
|
||||||
|
and `chi.URLParam(r, "*")` for a wildcard parameter.
|
||||||
|
|
||||||
|
|
||||||
|
### Middleware handlers
|
||||||
|
|
||||||
|
chi's middlewares are just stdlib net/http middleware handlers. There is nothing special
|
||||||
|
about them, which means the router and all the tooling is designed to be compatible and
|
||||||
|
friendly with any middleware in the community. This offers much better extensibility and reuse
|
||||||
|
of packages and is at the heart of chi's purpose.
|
||||||
|
|
||||||
|
Here is an example of a standard net/http middleware where we assign a context key `"user"`
|
||||||
|
the value of `"123"`. This middleware sets a hypothetical user identifier on the request
|
||||||
|
context and calls the next handler in the chain.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// HTTP middleware setting a value on the request context
|
||||||
|
func MyMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// create new context from `r` request context, and assign key `"user"`
|
||||||
|
// to value of `"123"`
|
||||||
|
ctx := context.WithValue(r.Context(), "user", "123")
|
||||||
|
|
||||||
|
// call the next handler in the chain, passing the response writer and
|
||||||
|
// the updated request object with the new context value.
|
||||||
|
//
|
||||||
|
// note: context.Context values are nested, so any previously set
|
||||||
|
// values will be accessible as well, and the new `"user"` key
|
||||||
|
// will be accessible from this point forward.
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Request handlers
|
||||||
|
|
||||||
|
chi uses standard net/http request handlers. This little snippet is an example of a http.Handler
|
||||||
|
func that reads a user identifier from the request context - hypothetically, identifying
|
||||||
|
the user sending an authenticated request, validated+set by a previous middleware handler.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// HTTP handler accessing data from the request context.
|
||||||
|
func MyRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// here we read from the request context and fetch out `"user"` key set in
|
||||||
|
// the MyMiddleware example above.
|
||||||
|
user := r.Context().Value("user").(string)
|
||||||
|
|
||||||
|
// respond to the client
|
||||||
|
w.Write([]byte(fmt.Sprintf("hi %s", user)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### URL parameters
|
||||||
|
|
||||||
|
chi's router parses and stores URL parameters right onto the request context. Here is
|
||||||
|
an example of how to access URL params in your net/http handlers. And of course, middlewares
|
||||||
|
are able to access the same information.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// HTTP handler accessing the url routing parameters.
|
||||||
|
func MyRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// fetch the url parameter `"userID"` from the request of a matching
|
||||||
|
// routing pattern. An example routing pattern could be: /users/{userID}
|
||||||
|
userID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
|
// fetch `"key"` from the request context
|
||||||
|
ctx := r.Context()
|
||||||
|
key := ctx.Value("key").(string)
|
||||||
|
|
||||||
|
// respond to the client
|
||||||
|
w.Write([]byte(fmt.Sprintf("hi %v, %v", userID, key)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Middlewares
|
||||||
|
|
||||||
|
chi comes equipped with an optional `middleware` package, providing a suite of standard
|
||||||
|
`net/http` middlewares. Please note, any middleware in the ecosystem that is also compatible
|
||||||
|
with `net/http` can be used with chi's mux.
|
||||||
|
|
||||||
|
### Core middlewares
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
| chi/middleware Handler | description |
|
||||||
|
| :--------------------- | :---------------------------------------------------------------------- |
|
||||||
|
| [AllowContentEncoding] | Enforces a whitelist of request Content-Encoding headers |
|
||||||
|
| [AllowContentType] | Explicit whitelist of accepted request Content-Types |
|
||||||
|
| [BasicAuth] | Basic HTTP authentication |
|
||||||
|
| [Compress] | Gzip compression for clients that accept compressed responses |
|
||||||
|
| [ContentCharset] | Ensure charset for Content-Type request headers |
|
||||||
|
| [CleanPath] | Clean double slashes from request path |
|
||||||
|
| [GetHead] | Automatically route undefined HEAD requests to GET handlers |
|
||||||
|
| [Heartbeat] | Monitoring endpoint to check the servers pulse |
|
||||||
|
| [Logger] | Logs the start and end of each request with the elapsed processing time |
|
||||||
|
| [NoCache] | Sets response headers to prevent clients from caching |
|
||||||
|
| [Profiler] | Easily attach net/http/pprof to your routers |
|
||||||
|
| [ClientIPFromHeader] | Capture client IP from a trusted single-IP header (X-Real-IP, CF-Connecting-IP, ...) |
|
||||||
|
| [ClientIPFromXFF] | Capture client IP from X-Forwarded-For, skipping listed trusted CIDR prefixes |
|
||||||
|
| [ClientIPFromXFFTrustedProxies] | Capture client IP from X-Forwarded-For given a fixed number of trusted proxies |
|
||||||
|
| [ClientIPFromRemoteAddr] | Capture client IP from the TCP RemoteAddr (server directly on the public internet) |
|
||||||
|
| [RealIP] | Deprecated — vulnerable to IP spoofing; use [ClientIPFromXFF] or another ClientIPFrom\* middleware |
|
||||||
|
| [Recoverer] | Gracefully absorb panics and prints the stack trace |
|
||||||
|
| [RequestID] | Injects a request ID into the context of each request |
|
||||||
|
| [RedirectSlashes] | Redirect slashes on routing paths |
|
||||||
|
| [RouteHeaders] | Route handling for request headers |
|
||||||
|
| [SetHeader] | Short-hand middleware to set a response header key/value |
|
||||||
|
| [StripSlashes] | Strip slashes on routing paths |
|
||||||
|
| [Sunset] | Sunset set Deprecation/Sunset header to response |
|
||||||
|
| [Throttle] | Puts a ceiling on the number of concurrent requests |
|
||||||
|
| [Timeout] | Signals to the request context when the timeout deadline is reached |
|
||||||
|
| [URLFormat] | Parse extension from url and put it on request context |
|
||||||
|
| [WithValue] | Short-hand middleware to set a key/value on the request context |
|
||||||
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[AllowContentEncoding]: https://pkg.go.dev/github.com/go-chi/chi/middleware#AllowContentEncoding
|
||||||
|
[AllowContentType]: https://pkg.go.dev/github.com/go-chi/chi/middleware#AllowContentType
|
||||||
|
[BasicAuth]: https://pkg.go.dev/github.com/go-chi/chi/middleware#BasicAuth
|
||||||
|
[Compress]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Compress
|
||||||
|
[ContentCharset]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ContentCharset
|
||||||
|
[CleanPath]: https://pkg.go.dev/github.com/go-chi/chi/middleware#CleanPath
|
||||||
|
[GetHead]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetHead
|
||||||
|
[GetReqID]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetReqID
|
||||||
|
[Heartbeat]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Heartbeat
|
||||||
|
[Logger]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Logger
|
||||||
|
[NoCache]: https://pkg.go.dev/github.com/go-chi/chi/middleware#NoCache
|
||||||
|
[Profiler]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Profiler
|
||||||
|
[ClientIPFromHeader]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromHeader
|
||||||
|
[ClientIPFromXFF]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromXFF
|
||||||
|
[ClientIPFromXFFTrustedProxies]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromXFFTrustedProxies
|
||||||
|
[ClientIPFromRemoteAddr]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromRemoteAddr
|
||||||
|
[GetClientIP]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetClientIP
|
||||||
|
[GetClientIPAddr]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetClientIPAddr
|
||||||
|
[RealIP]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RealIP
|
||||||
|
[Recoverer]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Recoverer
|
||||||
|
[RedirectSlashes]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RedirectSlashes
|
||||||
|
[RequestLogger]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RequestLogger
|
||||||
|
[RequestID]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RequestID
|
||||||
|
[RouteHeaders]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RouteHeaders
|
||||||
|
[SetHeader]: https://pkg.go.dev/github.com/go-chi/chi/middleware#SetHeader
|
||||||
|
[StripSlashes]: https://pkg.go.dev/github.com/go-chi/chi/middleware#StripSlashes
|
||||||
|
[Sunset]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Sunset
|
||||||
|
[Throttle]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Throttle
|
||||||
|
[ThrottleBacklog]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleBacklog
|
||||||
|
[ThrottleWithOpts]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleWithOpts
|
||||||
|
[Timeout]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Timeout
|
||||||
|
[URLFormat]: https://pkg.go.dev/github.com/go-chi/chi/middleware#URLFormat
|
||||||
|
[WithLogEntry]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WithLogEntry
|
||||||
|
[WithValue]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WithValue
|
||||||
|
[Compressor]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Compressor
|
||||||
|
[DefaultLogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#DefaultLogFormatter
|
||||||
|
[EncoderFunc]: https://pkg.go.dev/github.com/go-chi/chi/middleware#EncoderFunc
|
||||||
|
[HeaderRoute]: https://pkg.go.dev/github.com/go-chi/chi/middleware#HeaderRoute
|
||||||
|
[HeaderRouter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#HeaderRouter
|
||||||
|
[LogEntry]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LogEntry
|
||||||
|
[LogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LogFormatter
|
||||||
|
[LoggerInterface]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LoggerInterface
|
||||||
|
[ThrottleOpts]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleOpts
|
||||||
|
[WrapResponseWriter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WrapResponseWriter
|
||||||
|
|
||||||
|
### Choosing a ClientIP middleware
|
||||||
|
|
||||||
|
The legacy [RealIP] middleware is deprecated — it is vulnerable to IP spoofing
|
||||||
|
(GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf) and mutates
|
||||||
|
`r.RemoteAddr`. Use one of the four `ClientIPFrom*` middlewares instead — pick
|
||||||
|
exactly one based on your network setup — and read the resulting IP with
|
||||||
|
[GetClientIP] (string) or [GetClientIPAddr] (`netip.Addr`):
|
||||||
|
|
||||||
|
| Your setup | Use |
|
||||||
|
|---|---|
|
||||||
|
| Directly on the public internet, no proxy | `middleware.ClientIPFromRemoteAddr` |
|
||||||
|
| Behind nginx (`X-Real-IP`), Cloudflare (`CF-Connecting-IP`), Apache (`X-Client-IP`) | `middleware.ClientIPFromHeader("<your-trusted-header>")` |
|
||||||
|
| Behind one or more proxies whose IP ranges you can list | `middleware.ClientIPFromXFF("10.0.0.0/8", ...)` |
|
||||||
|
| Behind a known, fixed number of proxies with dynamic IPs | `middleware.ClientIPFromXFFTrustedProxies(2)` |
|
||||||
|
|
||||||
|
```go
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
|
||||||
|
// Pick exactly one. Examples for common deployments:
|
||||||
|
|
||||||
|
// Direct internet exposure (no proxy):
|
||||||
|
// r.Use(middleware.ClientIPFromRemoteAddr)
|
||||||
|
|
||||||
|
// Behind Cloudflare:
|
||||||
|
// r.Use(middleware.ClientIPFromHeader("CF-Connecting-IP"))
|
||||||
|
|
||||||
|
// Behind AWS CloudFront (or any proxy fleet with known CIDRs):
|
||||||
|
r.Use(middleware.ClientIPFromXFF(
|
||||||
|
"13.32.0.0/15", // CloudFront IPv4
|
||||||
|
"52.46.0.0/18", // CloudFront IPv4
|
||||||
|
"2600:9000::/28", // CloudFront IPv6
|
||||||
|
))
|
||||||
|
|
||||||
|
// Behind a known number of proxies with dynamic IPs:
|
||||||
|
// r.Use(middleware.ClientIPFromXFFTrustedProxies(2))
|
||||||
|
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP := middleware.GetClientIP(r.Context()) // for logs, rate-limit keys, etc.
|
||||||
|
_ = clientIP
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
These middlewares never mutate `r.RemoteAddr`. They store a normalized
|
||||||
|
`netip.Addr` in the request context — IPv4-mapped IPv6 (`::ffff:a.b.c.d`)
|
||||||
|
is folded to plain IPv4, and IPv6 zone identifiers carried in headers are
|
||||||
|
stripped, so one logical client maps to a single canonical key for logs,
|
||||||
|
rate limits, and ACLs.
|
||||||
|
|
||||||
|
For `ClientIPFromXFFTrustedProxies`, `numTrustedProxies` is the total proxy
|
||||||
|
hops between client and server. Prefer `ClientIPFromXFF` with explicit CIDRs
|
||||||
|
when you can — CIDR-based trust cannot off-by-one. See the godoc for a
|
||||||
|
deployment recipe and a verify checklist.
|
||||||
|
|
||||||
|
See the per-function godoc for the full semantics of each middleware, and
|
||||||
|
[adam-p's "The perils of the 'real' client IP"](https://adam-p.ca/blog/2022/03/x-forwarded-for/)
|
||||||
|
for the underlying threat model.
|
||||||
|
|
||||||
|
### Extra middlewares & packages
|
||||||
|
|
||||||
|
Please see https://github.com/go-chi for additional packages.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------------------------------------------
|
||||||
|
| package | description |
|
||||||
|
|:---------------------------------------------------|:-------------------------------------------------------------
|
||||||
|
| [cors](https://github.com/go-chi/cors) | Cross-origin resource sharing (CORS) |
|
||||||
|
| [docgen](https://github.com/go-chi/docgen) | Print chi.Router routes at runtime |
|
||||||
|
| [jwtauth](https://github.com/go-chi/jwtauth) | JWT authentication |
|
||||||
|
| [hostrouter](https://github.com/go-chi/hostrouter) | Domain/host based request routing |
|
||||||
|
| [httplog](https://github.com/go-chi/httplog) | Small but powerful structured HTTP request logging |
|
||||||
|
| [httprate](https://github.com/go-chi/httprate) | HTTP request rate limiter |
|
||||||
|
| [httptracer](https://github.com/go-chi/httptracer) | HTTP request performance tracing library |
|
||||||
|
| [httpvcr](https://github.com/go-chi/httpvcr) | Write deterministic tests for external sources |
|
||||||
|
| [stampede](https://github.com/go-chi/stampede) | HTTP request coalescer |
|
||||||
|
--------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
## context?
|
||||||
|
|
||||||
|
`context` is a tiny pkg that provides simple interface to signal context across call stacks
|
||||||
|
and goroutines. It was originally written by [Sameer Ajmani](https://github.com/Sajmani)
|
||||||
|
and is available in stdlib since go1.7.
|
||||||
|
|
||||||
|
Learn more at https://blog.golang.org/context
|
||||||
|
|
||||||
|
and..
|
||||||
|
* Docs: https://golang.org/pkg/context
|
||||||
|
* Source: https://github.com/golang/go/tree/master/src/context
|
||||||
|
|
||||||
|
|
||||||
|
## Benchmarks
|
||||||
|
|
||||||
|
The benchmark suite: https://github.com/pkieltyka/go-http-routing-benchmark
|
||||||
|
|
||||||
|
Results as of Nov 29, 2020 with Go 1.15.5 on Linux AMD 3950x
|
||||||
|
|
||||||
|
```shell
|
||||||
|
BenchmarkChi_Param 3075895 384 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_Param5 2116603 566 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_Param20 964117 1227 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_ParamWrite 2863413 420 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GithubStatic 3045488 395 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GithubParam 2204115 540 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GithubAll 10000 113811 ns/op 81203 B/op 406 allocs/op
|
||||||
|
BenchmarkChi_GPlusStatic 3337485 359 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GPlusParam 2825853 423 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GPlus2Params 2471697 483 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_GPlusAll 194220 5950 ns/op 5200 B/op 26 allocs/op
|
||||||
|
BenchmarkChi_ParseStatic 3365324 356 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_ParseParam 2976614 404 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_Parse2Params 2638084 439 ns/op 400 B/op 2 allocs/op
|
||||||
|
BenchmarkChi_ParseAll 109567 11295 ns/op 10400 B/op 52 allocs/op
|
||||||
|
BenchmarkChi_StaticAll 16846 71308 ns/op 62802 B/op 314 allocs/op
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparison with other routers: https://gist.github.com/pkieltyka/123032f12052520aaccab752bd3e78cc
|
||||||
|
|
||||||
|
NOTE: the allocs in the benchmark above are from the calls to http.Request's
|
||||||
|
`WithContext(context.Context)` method that clones the http.Request, sets the `Context()`
|
||||||
|
on the duplicated (alloc'd) request and returns it the new request object. This is just
|
||||||
|
how setting context on a request in Go works.
|
||||||
|
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
* Carl Jackson for https://github.com/zenazn/goji
|
||||||
|
* Parts of chi's thinking comes from goji, and chi's middleware package
|
||||||
|
sources from [goji](https://github.com/zenazn/goji/tree/master/web/middleware).
|
||||||
|
* Please see goji's [LICENSE](https://github.com/zenazn/goji/blob/master/LICENSE) (MIT)
|
||||||
|
* Armon Dadgar for https://github.com/armon/go-radix
|
||||||
|
* Contributions: [@VojtechVitek](https://github.com/VojtechVitek)
|
||||||
|
|
||||||
|
We'll be more than happy to see [your contributions](./CONTRIBUTING.md)!
|
||||||
|
|
||||||
|
|
||||||
|
## Beyond REST
|
||||||
|
|
||||||
|
chi is just a http router that lets you decompose request handling into many smaller layers.
|
||||||
|
Many companies use chi to write REST services for their public APIs. But, REST is just a convention
|
||||||
|
for managing state via HTTP, and there's a lot of other pieces required to write a complete client-server
|
||||||
|
system or network of microservices.
|
||||||
|
|
||||||
|
Looking beyond REST, I also recommend some newer works in the field:
|
||||||
|
* [webrpc](https://github.com/webrpc/webrpc) - Web-focused RPC client+server framework with code-gen
|
||||||
|
* [gRPC](https://github.com/grpc/grpc-go) - Google's RPC framework via protobufs
|
||||||
|
* [graphql](https://github.com/99designs/gqlgen) - Declarative query language
|
||||||
|
* [NATS](https://nats.io) - lightweight pub-sub
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (c) 2015-present [Peter Kieltyka](https://github.com/pkieltyka)
|
||||||
|
|
||||||
|
Licensed under [MIT License](./LICENSE)
|
||||||
|
|
||||||
|
[GoDoc]: https://pkg.go.dev/github.com/go-chi/chi/v5
|
||||||
|
[GoDoc Widget]: https://godoc.org/github.com/go-chi/chi?status.svg
|
||||||
|
[Travis]: https://travis-ci.org/go-chi/chi
|
||||||
|
[Travis Widget]: https://travis-ci.org/go-chi/chi.svg?branch=master
|
||||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Reporting Security Issues
|
||||||
|
|
||||||
|
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/go-chi/chi/security/advisories/new) tab.
|
||||||
25
_examples/README.md
Normal file
25
_examples/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
chi examples
|
||||||
|
============
|
||||||
|
|
||||||
|
* [custom-handler](https://github.com/go-chi/chi/blob/master/_examples/custom-handler/main.go) - Use a custom handler function signature
|
||||||
|
* [custom-method](https://github.com/go-chi/chi/blob/master/_examples/custom-method/main.go) - Add a custom HTTP method
|
||||||
|
* [fileserver](https://github.com/go-chi/chi/blob/master/_examples/fileserver/main.go) - Easily serve static files
|
||||||
|
* [graceful](https://github.com/go-chi/chi/blob/master/_examples/graceful/main.go) - Graceful context signaling and server shutdown
|
||||||
|
* [hello-world](https://github.com/go-chi/chi/blob/master/_examples/hello-world/main.go) - Hello World!
|
||||||
|
* [limits](https://github.com/go-chi/chi/blob/master/_examples/limits/main.go) - Timeouts and Throttling
|
||||||
|
* [logging](https://github.com/go-chi/chi/blob/master/_examples/logging/main.go) - Easy structured logging for any backend
|
||||||
|
* [rest](https://github.com/go-chi/chi/blob/master/_examples/rest/main.go) - REST APIs made easy, productive and maintainable
|
||||||
|
* [router-walk](https://github.com/go-chi/chi/blob/master/_examples/router-walk/main.go) - Print to stdout a router's routes
|
||||||
|
* [todos-resource](https://github.com/go-chi/chi/blob/master/_examples/todos-resource/main.go) - Struct routers/handlers, an example of another code layout style
|
||||||
|
* [versions](https://github.com/go-chi/chi/blob/master/_examples/versions/main.go) - Demo of `chi/render` subpkg
|
||||||
|
* [pathvalue](https://github.com/go-chi/chi/blob/master/_examples/pathvalue/main.go) - Demonstrates `PathValue` usage for retrieving URL parameters
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. `go get -v -d -u ./...` - fetch example deps
|
||||||
|
2. `cd <example>/` ie. `cd rest/`
|
||||||
|
3. `go run *.go` - note, example services run on port 3333
|
||||||
|
4. Open another terminal and use curl to send some requests to your example service,
|
||||||
|
`curl -v http://localhost:3333/`
|
||||||
|
5. Read <example>/main.go source to learn how service works and read comments for usage
|
||||||
6
_examples/chi.svg
Normal file
6
_examples/chi.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.4835 183.994C49.265 184.012 43.1375 181.976 38.1326 179.467C25.868 173.318 19.7818 162.507 19.7818 147.233C19.7818 131.964 25.8983 121.219 38.1326 115.004C43.1705 112.56 49.2857 111.048 56.4835 110.475H72.8808C76.4783 110.475 79.2862 111.984 81.3002 115.004C83.3143 118.021 84.3213 121.542 84.3213 125.567C84.3213 129.592 83.3143 133.114 81.3002 136.132C79.2862 139.149 76.4783 140.659 72.8808 140.659H56.4835C52.1637 140.659 50.0065 142.852 50.0065 147.233C50.0065 151.619 52.1637 153.824 56.4835 153.812L114.737 153.538C118.335 153.529 121.143 155.05 123.157 158.067C125.168 161.084 126.179 164.605 126.179 168.63C126.179 172.655 125.171 176.177 123.157 179.195C121.143 182.212 118.333 183.714 114.737 183.722L56.4835 183.994Z" fill="#01933F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M206.93 172.348V120.747C206.93 117.154 208.442 114.388 211.463 112.377C214.487 110.361 218.013 109.357 222.042 109.357C226.071 109.357 229.597 110.361 232.622 112.377C235.643 114.388 237.155 117.154 237.155 120.747V172.348C237.155 175.941 235.643 178.709 232.622 180.721C229.597 182.732 226.071 183.739 222.042 183.739C218.013 183.739 214.487 182.732 211.463 180.721C208.442 178.709 206.93 175.941 206.93 172.348Z" fill="#01933F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M222.042 101.164C217.869 101.164 214.307 99.6901 211.356 96.7444C208.406 93.7973 206.93 90.2402 206.93 86.0717C206.93 81.9032 208.406 78.3461 211.356 75.399C214.307 72.4533 217.869 70.9818 222.042 70.9818C226.216 70.9818 229.778 72.4533 232.728 75.399C235.679 78.3461 237.155 81.9032 237.155 86.0717C237.155 90.2402 235.679 93.7973 232.728 96.7444C229.778 99.6901 226.216 101.164 222.042 101.164Z" fill="#83C21F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M163.754 172.312V146.441C163.754 142.13 161.594 139.972 157.277 139.972H135.047C119.793 139.972 109.068 133.864 102.88 121.646C100.434 116.617 98.9216 110.507 98.3452 103.321V86.0717C98.3452 82.4789 99.8585 79.6747 102.88 77.6632C105.903 75.6518 109.429 74.6461 113.459 74.6461C117.487 74.6461 121.013 75.6518 124.037 77.6632C127.059 79.6747 128.571 82.4789 128.571 86.0717V103.321C128.571 107.633 130.728 109.789 135.047 109.789H157.277C164.474 110.361 170.59 111.873 175.628 114.317C187.862 120.497 193.979 131.205 193.979 146.441V172.312C193.979 175.906 192.467 178.709 189.446 180.72C186.422 182.732 182.896 183.739 178.867 183.739C174.836 183.739 171.31 182.732 168.287 180.72C165.266 178.709 163.754 175.906 163.754 172.312Z" fill="#01933F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
35
_examples/custom-handler/main.go
Normal file
35
_examples/custom-handler/main.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler func(w http.ResponseWriter, r *http.Request) error
|
||||||
|
|
||||||
|
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h(w, r); err != nil {
|
||||||
|
// handle returned error here.
|
||||||
|
w.WriteHeader(503)
|
||||||
|
w.Write([]byte("bad"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Method("GET", "/", Handler(customHandler))
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func customHandler(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
q := r.URL.Query().Get("err")
|
||||||
|
|
||||||
|
if q != "" {
|
||||||
|
return errors.New(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("foo"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
_examples/custom-method/main.go
Normal file
33
_examples/custom-method/main.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
chi.RegisterMethod("LINK")
|
||||||
|
chi.RegisterMethod("UNLINK")
|
||||||
|
chi.RegisterMethod("WOOHOO")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("hello world"))
|
||||||
|
})
|
||||||
|
r.MethodFunc("LINK", "/link", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("custom link method"))
|
||||||
|
})
|
||||||
|
r.MethodFunc("WOOHOO", "/woo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("custom woohoo method"))
|
||||||
|
})
|
||||||
|
r.HandleFunc("/everything", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("capturing all standard http methods, as well as LINK, UNLINK and WOOHOO"))
|
||||||
|
})
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
1
_examples/fileserver/data/notes.txt
Normal file
1
_examples/fileserver/data/notes.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Notessszzz
|
||||||
65
_examples/fileserver/main.go
Normal file
65
_examples/fileserver/main.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// This example demonstrates how to serve static files from your filesystem.
|
||||||
|
//
|
||||||
|
// Boot the server:
|
||||||
|
//
|
||||||
|
// $ go run main.go
|
||||||
|
//
|
||||||
|
// Client requests:
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/files/
|
||||||
|
// <pre>
|
||||||
|
// <a href="notes.txt">notes.txt</a>
|
||||||
|
// </pre>
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/files/notes.txt
|
||||||
|
// Notessszzz
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
|
// Index handler
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("hi"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a route along /files that will serve contents from
|
||||||
|
// the ./data/ folder.
|
||||||
|
workDir, _ := os.Getwd()
|
||||||
|
filesDir := http.Dir(filepath.Join(workDir, "data"))
|
||||||
|
FileServer(r, "/files", filesDir)
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileServer conveniently sets up a http.FileServer handler to serve
|
||||||
|
// static files from a http.FileSystem.
|
||||||
|
func FileServer(r chi.Router, path string, root http.FileSystem) {
|
||||||
|
if strings.ContainsAny(path, "{}*") {
|
||||||
|
panic("FileServer does not permit any URL parameters.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if path != "/" && path[len(path)-1] != '/' {
|
||||||
|
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
|
||||||
|
path += "/"
|
||||||
|
}
|
||||||
|
path += "*"
|
||||||
|
|
||||||
|
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
||||||
|
fs := http.StripPrefix(pathPrefix, http.FileServer(root))
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
69
_examples/graceful/main.go
Normal file
69
_examples/graceful/main.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// The HTTP Server
|
||||||
|
server := &http.Server{Addr: "0.0.0.0:3333", Handler: service()}
|
||||||
|
|
||||||
|
// Create context that listens for the interrupt signal
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// Run server in the background
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Listen for the interrupt signal
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// Create shutdown context with 30-second timeout
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Trigger graceful shutdown
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func service() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("sup"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Simulates some hard work.
|
||||||
|
//
|
||||||
|
// We want this handler to complete successfully during a shutdown signal,
|
||||||
|
// so consider the work here as some background routine to fetch a long-running
|
||||||
|
// search query to find as many results as possible, but, instead we cut it short
|
||||||
|
// and respond with what we have so far. How a shutdown is handled is entirely
|
||||||
|
// up to the developer, as some code blocks are preemptible, and others are not.
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
w.Write([]byte(fmt.Sprintf("all done.\n")))
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
21
_examples/hello-world/main.go
Normal file
21
_examples/hello-world/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("hello world"))
|
||||||
|
})
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
92
_examples/limits/main.go
Normal file
92
_examples/limits/main.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// This example demonstrates the use of Timeout, and Throttle middlewares.
|
||||||
|
//
|
||||||
|
// Timeout: cancel a request if processing takes longer than 2.5 seconds,
|
||||||
|
// server will respond with a http.StatusGatewayTimeout.
|
||||||
|
//
|
||||||
|
// Throttle: limit the number of in-flight requests along a particular
|
||||||
|
// routing path and backlog the others.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root."))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("test")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slow handlers/operations.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
// Stop processing after 2.5 seconds.
|
||||||
|
r.Use(middleware.Timeout(2500 * time.Millisecond))
|
||||||
|
|
||||||
|
r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
|
||||||
|
// Processing will take 1-5 seconds.
|
||||||
|
processTime := time.Duration(rand.Intn(4)+1) * time.Second
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-time.After(processTime):
|
||||||
|
// The above channel simulates some hard work.
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte(fmt.Sprintf("Processed in %v seconds\n", processTime)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Throttle very expensive handlers/operations.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
// Stop processing after 30 seconds.
|
||||||
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
|
|
||||||
|
// Only one request will be processed at a time.
|
||||||
|
r.Use(middleware.Throttle(1))
|
||||||
|
|
||||||
|
r.Get("/throttled", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
switch r.Context().Err() {
|
||||||
|
case context.DeadlineExceeded:
|
||||||
|
w.WriteHeader(504)
|
||||||
|
w.Write([]byte("Processing too slow\n"))
|
||||||
|
default:
|
||||||
|
w.Write([]byte("Canceled\n"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// The above channel simulates some hard work.
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("Processed\n"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
9
_examples/logging/main.go
Normal file
9
_examples/logging/main.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Please see https://github.com/go-chi/httplog for a complete package
|
||||||
|
// and example for writing a structured logger on chi built on
|
||||||
|
// the Go 1.21+ "log/slog" package.
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// See https://github.com/go-chi/httplog/blob/master/_example/main.go
|
||||||
|
}
|
||||||
25
_examples/pathvalue/main.go
Normal file
25
_examples/pathvalue/main.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Registering a handler that retrieves a path parameter using PathValue
|
||||||
|
r.Get("/users/{userID}", pathValueHandler)
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathValueHandler retrieves a URL parameter using PathValue and writes it to the response.
|
||||||
|
func pathValueHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("userID")
|
||||||
|
|
||||||
|
// Respond with the extracted userID
|
||||||
|
w.Write([]byte(fmt.Sprintf("User ID: %s", userID)))
|
||||||
|
}
|
||||||
9
_examples/rest/go.mod
Normal file
9
_examples/rest/go.mod
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module rest-example
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.0.1
|
||||||
|
github.com/go-chi/docgen v1.2.0
|
||||||
|
github.com/go-chi/render v1.0.1
|
||||||
|
)
|
||||||
8
_examples/rest/go.sum
Normal file
8
_examples/rest/go.sum
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/go-chi/chi/v5 v5.0.1 h1:ALxjCrTf1aflOlkhMnCUP86MubbWFrzB3gkRPReLpTo=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-chi/docgen v1.2.0 h1:da0Nq2PKU9W9pSOTUfVrKI1vIgTGpauo9cfh4Iwivek=
|
||||||
|
github.com/go-chi/docgen v1.2.0/go.mod h1:G9W0G551cs2BFMSn/cnGwX+JBHEloAgo17MBhyrnhPI=
|
||||||
|
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
|
||||||
|
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
520
_examples/rest/main.go
Normal file
520
_examples/rest/main.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
// This example demonstrates a HTTP REST web service with some fixture data.
|
||||||
|
// Follow along the example and patterns.
|
||||||
|
//
|
||||||
|
// Also check routes.json for the generated docs from passing the -routes flag,
|
||||||
|
// to run yourself do: `go run . -routes`
|
||||||
|
//
|
||||||
|
// Boot the server:
|
||||||
|
//
|
||||||
|
// $ go run main.go
|
||||||
|
//
|
||||||
|
// Client requests:
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/
|
||||||
|
// root.
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/articles
|
||||||
|
// [{"id":"1","title":"Hi"},{"id":"2","title":"sup"}]
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/articles/1
|
||||||
|
// {"id":"1","title":"Hi"}
|
||||||
|
//
|
||||||
|
// $ curl -X DELETE http://localhost:3333/articles/1
|
||||||
|
// {"id":"1","title":"Hi"}
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/articles/1
|
||||||
|
// "Not Found"
|
||||||
|
//
|
||||||
|
// $ curl -X POST -d '{"id":"will-be-omitted","title":"awesomeness"}' http://localhost:3333/articles
|
||||||
|
// {"id":"97","title":"awesomeness"}
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/articles/97
|
||||||
|
// {"id":"97","title":"awesomeness"}
|
||||||
|
//
|
||||||
|
// $ curl http://localhost:3333/articles
|
||||||
|
// [{"id":"2","title":"sup"},{"id":"97","title":"awesomeness"}]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/docgen"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
var routes = flag.Bool("routes", false, "Generate router documentation")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.URLFormat)
|
||||||
|
r.Use(render.SetContentType(render.ContentTypeJSON))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root."))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("test")
|
||||||
|
})
|
||||||
|
|
||||||
|
// RESTy routes for "articles" resource
|
||||||
|
r.Route("/articles", func(r chi.Router) {
|
||||||
|
r.With(paginate).Get("/", ListArticles)
|
||||||
|
r.Post("/", CreateArticle) // POST /articles
|
||||||
|
r.Get("/search", SearchArticles) // GET /articles/search
|
||||||
|
|
||||||
|
r.Route("/{articleID}", func(r chi.Router) {
|
||||||
|
r.Use(ArticleCtx) // Load the *Article on the request context
|
||||||
|
r.Get("/", GetArticle) // GET /articles/123
|
||||||
|
r.Put("/", UpdateArticle) // PUT /articles/123
|
||||||
|
r.Delete("/", DeleteArticle) // DELETE /articles/123
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /articles/whats-up
|
||||||
|
r.With(ArticleCtx).Get("/{articleSlug:[a-z-]+}", GetArticle)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mount the admin sub-router, which btw is the same as:
|
||||||
|
// r.Route("/admin", func(r chi.Router) { admin routes here })
|
||||||
|
r.Mount("/admin", adminRouter())
|
||||||
|
|
||||||
|
// Passing -routes to the program will generate docs for the above
|
||||||
|
// router definition. See the `routes.json` file in this folder for
|
||||||
|
// the output.
|
||||||
|
if *routes {
|
||||||
|
// fmt.Println(docgen.JSONRoutesDoc(r))
|
||||||
|
fmt.Println(docgen.MarkdownRoutesDoc(r, docgen.MarkdownOpts{
|
||||||
|
ProjectPath: "github.com/go-chi/chi/v5",
|
||||||
|
Intro: "Welcome to the chi/_examples/rest generated docs.",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := render.RenderList(w, r, NewArticleListResponse(articles)); err != nil {
|
||||||
|
render.Render(w, r, ErrRender(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleCtx middleware is used to load an Article object from
|
||||||
|
// the URL parameters passed through as the request. In case
|
||||||
|
// the Article could not be found, we stop here and return a 404.
|
||||||
|
func ArticleCtx(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var article *Article
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if articleID := chi.URLParam(r, "articleID"); articleID != "" {
|
||||||
|
article, err = dbGetArticle(articleID)
|
||||||
|
} else if articleSlug := chi.URLParam(r, "articleSlug"); articleSlug != "" {
|
||||||
|
article, err = dbGetArticleBySlug(articleSlug)
|
||||||
|
} else {
|
||||||
|
render.Render(w, r, ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
render.Render(w, r, ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), "article", article)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchArticles searches the Articles data for a matching article.
|
||||||
|
// It's just a stub, but you get the idea.
|
||||||
|
func SearchArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render.RenderList(w, r, NewArticleListResponse(articles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArticle persists the posted Article and returns it
|
||||||
|
// back to the client as an acknowledgement.
|
||||||
|
func CreateArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := &ArticleRequest{}
|
||||||
|
if err := render.Bind(r, data); err != nil {
|
||||||
|
render.Render(w, r, ErrInvalidRequest(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
article := data.Article
|
||||||
|
dbNewArticle(article)
|
||||||
|
|
||||||
|
render.Status(r, http.StatusCreated)
|
||||||
|
render.Render(w, r, NewArticleResponse(article))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArticle returns the specific Article. You'll notice it just
|
||||||
|
// fetches the Article right off the context, as its understood that
|
||||||
|
// if we made it this far, the Article must be on the context. In case
|
||||||
|
// its not due to a bug, then it will panic, and our Recoverer will save us.
|
||||||
|
func GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Assume if we've reach this far, we can access the article
|
||||||
|
// context because this handler is a child of the ArticleCtx
|
||||||
|
// middleware. The worst case, the recoverer middleware will save us.
|
||||||
|
article := r.Context().Value("article").(*Article)
|
||||||
|
|
||||||
|
if err := render.Render(w, r, NewArticleResponse(article)); err != nil {
|
||||||
|
render.Render(w, r, ErrRender(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateArticle updates an existing Article in our persistent store.
|
||||||
|
func UpdateArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
article := r.Context().Value("article").(*Article)
|
||||||
|
|
||||||
|
data := &ArticleRequest{Article: article}
|
||||||
|
if err := render.Bind(r, data); err != nil {
|
||||||
|
render.Render(w, r, ErrInvalidRequest(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
article = data.Article
|
||||||
|
dbUpdateArticle(article.ID, article)
|
||||||
|
|
||||||
|
render.Render(w, r, NewArticleResponse(article))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArticle removes an existing Article from our persistent store.
|
||||||
|
func DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Assume if we've reach this far, we can access the article
|
||||||
|
// context because this handler is a child of the ArticleCtx
|
||||||
|
// middleware. The worst case, the recoverer middleware will save us.
|
||||||
|
article := r.Context().Value("article").(*Article)
|
||||||
|
|
||||||
|
article, err = dbRemoveArticle(article.ID)
|
||||||
|
if err != nil {
|
||||||
|
render.Render(w, r, ErrInvalidRequest(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Render(w, r, NewArticleResponse(article))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A completely separate router for administrator routes
|
||||||
|
func adminRouter() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(AdminOnly)
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("admin: index"))
|
||||||
|
})
|
||||||
|
r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("admin: list accounts.."))
|
||||||
|
})
|
||||||
|
r.Get("/users/{userId}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(fmt.Sprintf("admin: view user id %v", chi.URLParam(r, "userId"))))
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly middleware restricts access to just administrators.
|
||||||
|
func AdminOnly(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
isAdmin, ok := r.Context().Value("acl.admin").(bool)
|
||||||
|
if !ok || !isAdmin {
|
||||||
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate is a stub, but very possible to implement middleware logic
|
||||||
|
// to handle the request params for handling a paginated request.
|
||||||
|
func paginate(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// just a stub.. some ideas are to look at URL query params for something like
|
||||||
|
// the page number, or the limit, and send a query cursor down the chain
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is entirely optional, but I wanted to demonstrate how you could easily
|
||||||
|
// add your own logic to the render.Respond method.
|
||||||
|
func init() {
|
||||||
|
render.Respond = func(w http.ResponseWriter, r *http.Request, v interface{}) {
|
||||||
|
if err, ok := v.(error); ok {
|
||||||
|
|
||||||
|
// We set a default error status response code if one hasn't been set.
|
||||||
|
if _, ok := r.Context().Value(render.StatusCtxKey).(int); !ok {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We log the error
|
||||||
|
fmt.Printf("Logging err: %s\n", err.Error())
|
||||||
|
|
||||||
|
// We change the response to not reveal the actual error message,
|
||||||
|
// instead we can transform the message something more friendly or mapped
|
||||||
|
// to some code / language, etc.
|
||||||
|
render.DefaultResponder(w, r, render.M{"status": "error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.DefaultResponder(w, r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//--
|
||||||
|
// Request and Response payloads for the REST api.
|
||||||
|
//
|
||||||
|
// The payloads embed the data model objects an
|
||||||
|
//
|
||||||
|
// In a real-world project, it would make sense to put these payloads
|
||||||
|
// in another file, or another sub-package.
|
||||||
|
//--
|
||||||
|
|
||||||
|
type UserPayload struct {
|
||||||
|
*User
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserPayloadResponse(user *User) *UserPayload {
|
||||||
|
return &UserPayload{User: user}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind on UserPayload will run after the unmarshalling is complete, its
|
||||||
|
// a good time to focus some post-processing after a decoding.
|
||||||
|
func (u *UserPayload) Bind(r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UserPayload) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
u.Role = "collaborator"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleRequest is the request payload for Article data model.
|
||||||
|
//
|
||||||
|
// NOTE: It's good practice to have well defined request and response payloads
|
||||||
|
// so you can manage the specific inputs and outputs for clients, and also gives
|
||||||
|
// you the opportunity to transform data on input or output, for example
|
||||||
|
// on request, we'd like to protect certain fields and on output perhaps
|
||||||
|
// we'd like to include a computed field based on other values that aren't
|
||||||
|
// in the data model. Also, check out this awesome blog post on struct composition:
|
||||||
|
// http://attilaolah.eu/2014/09/10/json-and-struct-composition-in-go/
|
||||||
|
type ArticleRequest struct {
|
||||||
|
*Article
|
||||||
|
|
||||||
|
User *UserPayload `json:"user,omitempty"`
|
||||||
|
|
||||||
|
ProtectedID string `json:"id"` // override 'id' json to have more control
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArticleRequest) Bind(r *http.Request) error {
|
||||||
|
// a.Article is nil if no Article fields are sent in the request. Return an
|
||||||
|
// error to avoid a nil pointer dereference.
|
||||||
|
if a.Article == nil {
|
||||||
|
return errors.New("missing required Article fields.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a.User is nil if no Userpayload fields are sent in the request. In this app
|
||||||
|
// this won't cause a panic, but checks in this Bind method may be required if
|
||||||
|
// a.User or further nested fields like a.User.Name are accessed elsewhere.
|
||||||
|
|
||||||
|
// just a post-process after a decode..
|
||||||
|
a.ProtectedID = "" // unset the protected ID
|
||||||
|
a.Article.Title = strings.ToLower(a.Article.Title) // as an example, we down-case
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleResponse is the response payload for the Article data model.
|
||||||
|
// See NOTE above in ArticleRequest as well.
|
||||||
|
//
|
||||||
|
// In the ArticleResponse object, first a Render() is called on itself,
|
||||||
|
// then the next field, and so on, all the way down the tree.
|
||||||
|
// Render is called in top-down order, like a http handler middleware chain.
|
||||||
|
type ArticleResponse struct {
|
||||||
|
*Article
|
||||||
|
|
||||||
|
User *UserPayload `json:"user,omitempty"`
|
||||||
|
|
||||||
|
// We add an additional field to the response here.. such as this
|
||||||
|
// elapsed computed property
|
||||||
|
Elapsed int64 `json:"elapsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleResponse(article *Article) *ArticleResponse {
|
||||||
|
resp := &ArticleResponse{Article: article}
|
||||||
|
|
||||||
|
if resp.User == nil {
|
||||||
|
if user, _ := dbGetUser(resp.UserID); user != nil {
|
||||||
|
resp.User = NewUserPayloadResponse(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rd *ArticleResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// Pre-processing before a response is marshalled and sent across the wire
|
||||||
|
rd.Elapsed = 10
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleListResponse(articles []*Article) []render.Renderer {
|
||||||
|
list := []render.Renderer{}
|
||||||
|
for _, article := range articles {
|
||||||
|
list = append(list, NewArticleResponse(article))
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: as a thought, the request and response payloads for an Article could be the
|
||||||
|
// same payload type, perhaps will do an example with it as well.
|
||||||
|
// type ArticlePayload struct {
|
||||||
|
// *Article
|
||||||
|
// }
|
||||||
|
|
||||||
|
//--
|
||||||
|
// Error response payloads & renderers
|
||||||
|
//--
|
||||||
|
|
||||||
|
// ErrResponse renderer type for handling all sorts of errors.
|
||||||
|
//
|
||||||
|
// In the best case scenario, the excellent github.com/pkg/errors package
|
||||||
|
// helps reveal information on the error, setting it on Err, and in the Render()
|
||||||
|
// method, using it to set the application-specific error code in AppCode.
|
||||||
|
type ErrResponse struct {
|
||||||
|
Err error `json:"-"` // low-level runtime error
|
||||||
|
HTTPStatusCode int `json:"-"` // http response status code
|
||||||
|
|
||||||
|
StatusText string `json:"status"` // user-level status message
|
||||||
|
AppCode int64 `json:"code,omitempty"` // application-specific error code
|
||||||
|
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
render.Status(r, e.HTTPStatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrInvalidRequest(err error) render.Renderer {
|
||||||
|
return &ErrResponse{
|
||||||
|
Err: err,
|
||||||
|
HTTPStatusCode: 400,
|
||||||
|
StatusText: "Invalid request.",
|
||||||
|
ErrorText: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrRender(err error) render.Renderer {
|
||||||
|
return &ErrResponse{
|
||||||
|
Err: err,
|
||||||
|
HTTPStatusCode: 422,
|
||||||
|
StatusText: "Error rendering response.",
|
||||||
|
ErrorText: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = &ErrResponse{HTTPStatusCode: 404, StatusText: "Resource not found."}
|
||||||
|
|
||||||
|
//--
|
||||||
|
// Data model objects and persistence mocks:
|
||||||
|
//--
|
||||||
|
|
||||||
|
// User data model
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article data model. I suggest looking at https://upper.io for an easy
|
||||||
|
// and powerful data persistence adapter.
|
||||||
|
type Article struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"` // the author
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article fixture data
|
||||||
|
var articles = []*Article{
|
||||||
|
{ID: "1", UserID: 100, Title: "Hi", Slug: "hi"},
|
||||||
|
{ID: "2", UserID: 200, Title: "sup", Slug: "sup"},
|
||||||
|
{ID: "3", UserID: 300, Title: "alo", Slug: "alo"},
|
||||||
|
{ID: "4", UserID: 400, Title: "bonjour", Slug: "bonjour"},
|
||||||
|
{ID: "5", UserID: 500, Title: "whats up", Slug: "whats-up"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// User fixture data
|
||||||
|
var users = []*User{
|
||||||
|
{ID: 100, Name: "Peter"},
|
||||||
|
{ID: 200, Name: "Julia"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbNewArticle(article *Article) (string, error) {
|
||||||
|
article.ID = fmt.Sprintf("%d", rand.Intn(100)+10)
|
||||||
|
articles = append(articles, article)
|
||||||
|
return article.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbGetArticle(id string) (*Article, error) {
|
||||||
|
for _, a := range articles {
|
||||||
|
if a.ID == id {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("article not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbGetArticleBySlug(slug string) (*Article, error) {
|
||||||
|
for _, a := range articles {
|
||||||
|
if a.Slug == slug {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("article not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbUpdateArticle(id string, article *Article) (*Article, error) {
|
||||||
|
for i, a := range articles {
|
||||||
|
if a.ID == id {
|
||||||
|
articles[i] = article
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("article not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbRemoveArticle(id string) (*Article, error) {
|
||||||
|
for i, a := range articles {
|
||||||
|
if a.ID == id {
|
||||||
|
articles = append((articles)[:i], (articles)[i+1:]...)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("article not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbGetUser(id int64) (*User, error) {
|
||||||
|
for _, u := range users {
|
||||||
|
if u.ID == id {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("user not found.")
|
||||||
|
}
|
||||||
260
_examples/rest/routes.json
Normal file
260
_examples/rest/routes.json
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
{
|
||||||
|
"router": {
|
||||||
|
"middlewares": [
|
||||||
|
{
|
||||||
|
"pkg": "github.com/go-chi/chi/v5/middleware",
|
||||||
|
"func": "RequestID",
|
||||||
|
"comment": "RequestID is a middleware that injects a request ID into the context of each\nrequest. A request ID is a string of the form \"host.example.com/random-0001\",\nwhere \"random\" is a base62 random string that uniquely identifies this go\nprocess, and where the last number is an atomically incremented request\ncounter.\n",
|
||||||
|
"file": "github.com/go-chi/chi/middleware/request_id.go",
|
||||||
|
"line": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "github.com/go-chi/chi/v5/middleware",
|
||||||
|
"func": "Logger",
|
||||||
|
"comment": "Logger is a middleware that logs the start and end of each request, along\nwith some useful data about what was requested, what the response status was,\nand how long it took to return. When standard output is a TTY, Logger will\nprint in color, otherwise it will print in black and white. Logger prints a\nrequest ID if one is provided.\n\nAlternatively, look at https://github.com/pressly/lg and the `lg.RequestLogger`\nmiddleware pkg.\n",
|
||||||
|
"file": "github.com/go-chi/chi/middleware/logger.go",
|
||||||
|
"line": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "github.com/go-chi/chi/v5/middleware",
|
||||||
|
"func": "Recoverer",
|
||||||
|
"comment": "Recoverer is a middleware that recovers from panics, logs the panic (and a\nbacktrace), and returns a HTTP 500 (Internal Server Error) status if\npossible. Recoverer prints a request ID if one is provided.\n\nAlternatively, look at https://github.com/pressly/lg middleware pkgs.\n",
|
||||||
|
"file": "github.com/go-chi/chi/middleware/recoverer.go",
|
||||||
|
"line": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "github.com/go-chi/chi/v5/middleware",
|
||||||
|
"func": "URLFormat",
|
||||||
|
"comment": "URLFormat is a middleware that parses the url extension from a request path and stores it\non the context as a string under the key `middleware.URLFormatCtxKey`. The middleware will\ntrim the suffix from the routing path and continue routing.\n\nRouters should not include a url parameter for the suffix when using this middleware.\n\nSample usage.. for url paths: `/articles/1`, `/articles/1.json` and `/articles/1.xml`\n\n func routes() http.Handler {\n r := chi.NewRouter()\n r.Use(middleware.URLFormat)\n\n r.Get(\"/articles/{id}\", ListArticles)\n\n return r\n }\n\n func ListArticles(w http.ResponseWriter, r *http.Request) {\n\t urlFormat, _ := r.Context().Value(middleware.URLFormatCtxKey).(string)\n\n\t switch urlFormat {\n\t case \"json\":\n\t \trender.JSON(w, r, articles)\n\t case \"xml:\"\n\t \trender.XML(w, r, articles)\n\t default:\n\t \trender.JSON(w, r, articles)\n\t }\n}\n",
|
||||||
|
"file": "github.com/go-chi/chi/middleware/url_format.go",
|
||||||
|
"line": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "github.com/go-chi/render",
|
||||||
|
"func": "SetContentType.func1",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/render/content_type.go",
|
||||||
|
"line": 49,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": {
|
||||||
|
"/": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.main.func1",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 69,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/*": {
|
||||||
|
"router": {
|
||||||
|
"middlewares": [
|
||||||
|
{
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.AdminOnly",
|
||||||
|
"comment": "AdminOnly middleware restricts access to just administrators.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 238
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": {
|
||||||
|
"/": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.adminRouter.func1",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 225,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/accounts": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.adminRouter.func2",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 228,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/users/{userId}": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.adminRouter.func3",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 231,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/articles/*": {
|
||||||
|
"router": {
|
||||||
|
"middlewares": [],
|
||||||
|
"routes": {
|
||||||
|
"/": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [
|
||||||
|
{
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.paginate",
|
||||||
|
"comment": "paginate is a stub, but very possible to implement middleware logic\nto handle the request params for handling a paginated request.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 251
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.ListArticles",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 117
|
||||||
|
},
|
||||||
|
"POST": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "POST",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.CreateArticle",
|
||||||
|
"comment": "CreateArticle persists the posted Article and returns it\nback to the client as an acknowledgement.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 158
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/search": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.SearchArticles",
|
||||||
|
"comment": "SearchArticles searches the Articles data for a matching article.\nIt's just a stub, but you get the idea.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 152
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/{articleID}/*": {
|
||||||
|
"router": {
|
||||||
|
"middlewares": [
|
||||||
|
{
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.ArticleCtx",
|
||||||
|
"comment": "ArticleCtx middleware is used to load an Article object from\nthe URL parameters passed through as the request. In case\nthe Article could not be found, we stop here and return a 404.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 127
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": {
|
||||||
|
"/": {
|
||||||
|
"handlers": {
|
||||||
|
"DELETE": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "DELETE",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.DeleteArticle",
|
||||||
|
"comment": "DeleteArticle removes an existing Article from our persistent store.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 204
|
||||||
|
},
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.GetArticle",
|
||||||
|
"comment": "GetArticle returns the specific Article. You'll notice it just\nfetches the Article right off the context, as its understood that\nif we made it this far, the Article must be on the context. In case\nits not due to a bug, then it will panic, and our Recoverer will save us.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 176
|
||||||
|
},
|
||||||
|
"PUT": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "PUT",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.UpdateArticle",
|
||||||
|
"comment": "UpdateArticle updates an existing Article in our persistent store.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 189
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/{articleSlug:[a-z-]+}": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [
|
||||||
|
{
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.ArticleCtx",
|
||||||
|
"comment": "ArticleCtx middleware is used to load an Article object from\nthe URL parameters passed through as the request. In case\nthe Article could not be found, we stop here and return a 404.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 127
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.GetArticle",
|
||||||
|
"comment": "GetArticle returns the specific Article. You'll notice it just\nfetches the Article right off the context, as its understood that\nif we made it this far, the Article must be on the context. In case\nits not due to a bug, then it will panic, and our Recoverer will save us.\n",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 176
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/panic": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.main.func3",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 77,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/ping": {
|
||||||
|
"handlers": {
|
||||||
|
"GET": {
|
||||||
|
"middlewares": [],
|
||||||
|
"method": "GET",
|
||||||
|
"pkg": "",
|
||||||
|
"func": "main.main.func2",
|
||||||
|
"comment": "",
|
||||||
|
"file": "github.com/go-chi/chi/_examples/rest/main.go",
|
||||||
|
"line": 73,
|
||||||
|
"anonymous": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
_examples/rest/routes.md
Normal file
159
_examples/rest/routes.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# github.com/go-chi/chi
|
||||||
|
|
||||||
|
Welcome to the chi/_examples/rest generated docs.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>`/`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/**
|
||||||
|
- _GET_
|
||||||
|
- [main.main.func1](/_examples/rest/main.go#L69)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/admin/*`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/admin/***
|
||||||
|
- [main.AdminOnly](/_examples/rest/main.go#L238)
|
||||||
|
- **/**
|
||||||
|
- _GET_
|
||||||
|
- [main.adminRouter.func1](/_examples/rest/main.go#L225)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/admin/*/accounts`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/admin/***
|
||||||
|
- [main.AdminOnly](/_examples/rest/main.go#L238)
|
||||||
|
- **/accounts**
|
||||||
|
- _GET_
|
||||||
|
- [main.adminRouter.func2](/_examples/rest/main.go#L228)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/admin/*/users/{userId}`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/admin/***
|
||||||
|
- [main.AdminOnly](/_examples/rest/main.go#L238)
|
||||||
|
- **/users/{userId}**
|
||||||
|
- _GET_
|
||||||
|
- [main.adminRouter.func3](/_examples/rest/main.go#L231)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/articles/*`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/articles/***
|
||||||
|
- **/**
|
||||||
|
- _GET_
|
||||||
|
- [main.paginate](/_examples/rest/main.go#L251)
|
||||||
|
- [main.ListArticles](/_examples/rest/main.go#L117)
|
||||||
|
- _POST_
|
||||||
|
- [main.CreateArticle](/_examples/rest/main.go#L158)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/articles/*/search`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/articles/***
|
||||||
|
- **/search**
|
||||||
|
- _GET_
|
||||||
|
- [main.SearchArticles](/_examples/rest/main.go#L152)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/articles/*/{articleID}/*`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/articles/***
|
||||||
|
- **/{articleID}/***
|
||||||
|
- [main.ArticleCtx](/_examples/rest/main.go#L127)
|
||||||
|
- **/**
|
||||||
|
- _DELETE_
|
||||||
|
- [main.DeleteArticle](/_examples/rest/main.go#L204)
|
||||||
|
- _GET_
|
||||||
|
- [main.GetArticle](/_examples/rest/main.go#L176)
|
||||||
|
- _PUT_
|
||||||
|
- [main.UpdateArticle](/_examples/rest/main.go#L189)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/articles/*/{articleSlug:[a-z-]+}`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/articles/***
|
||||||
|
- **/{articleSlug:[a-z-]+}**
|
||||||
|
- _GET_
|
||||||
|
- [main.ArticleCtx](/_examples/rest/main.go#L127)
|
||||||
|
- [main.GetArticle](/_examples/rest/main.go#L176)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/panic`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/panic**
|
||||||
|
- _GET_
|
||||||
|
- [main.main.func3](/_examples/rest/main.go#L77)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>`/ping`</summary>
|
||||||
|
|
||||||
|
- [RequestID](/middleware/request_id.go#L63)
|
||||||
|
- [Logger](/middleware/logger.go#L26)
|
||||||
|
- [Recoverer](/middleware/recoverer.go#L18)
|
||||||
|
- [URLFormat](/middleware/url_format.go#L45)
|
||||||
|
- [SetContentType.func1](https://github.com/go-chi/render/content_type.go#L49)
|
||||||
|
- **/ping**
|
||||||
|
- _GET_
|
||||||
|
- [main.main.func2](/_examples/rest/main.go#L73)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
Total # of routes: 10
|
||||||
|
|
||||||
42
_examples/router-walk/main.go
Normal file
42
_examples/router-walk/main.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root."))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/road", func(r chi.Router) {
|
||||||
|
r.Get("/left", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("left road"))
|
||||||
|
})
|
||||||
|
r.Post("/right", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("right road"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Put("/ping", Ping)
|
||||||
|
|
||||||
|
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||||
|
route = strings.ReplaceAll(route, "/*/", "/")
|
||||||
|
fmt.Printf("%s %s\n", method, route)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := chi.Walk(r, walkFunc); err != nil {
|
||||||
|
fmt.Printf("Logging err: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping returns pong
|
||||||
|
func Ping(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
}
|
||||||
30
_examples/todos-resource/main.go
Normal file
30
_examples/todos-resource/main.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// This example demonstrates a project structure that defines a subrouter and its
|
||||||
|
// handlers on a struct, and mounting them as subrouters to a parent router.
|
||||||
|
// See also _examples/rest for an in-depth example of a REST service, and apply
|
||||||
|
// those same patterns to this structure.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("."))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Mount("/users", usersResource{}.Routes())
|
||||||
|
r.Mount("/todos", todosResource{}.Routes())
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
53
_examples/todos-resource/todos.go
Normal file
53
_examples/todos-resource/todos.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type todosResource struct{}
|
||||||
|
|
||||||
|
// Routes creates a REST router for the todos resource
|
||||||
|
func (rs todosResource) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
// r.Use() // some middleware..
|
||||||
|
|
||||||
|
r.Get("/", rs.List) // GET /todos - read a list of todos
|
||||||
|
r.Post("/", rs.Create) // POST /todos - create a new todo and persist it
|
||||||
|
r.Put("/", rs.Delete)
|
||||||
|
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
// r.Use(rs.TodoCtx) // lets have a todos map, and lets actually load/manipulate
|
||||||
|
r.Get("/", rs.Get) // GET /todos/{id} - read a single todo by :id
|
||||||
|
r.Put("/", rs.Update) // PUT /todos/{id} - update a single todo by :id
|
||||||
|
r.Delete("/", rs.Delete) // DELETE /todos/{id} - delete a single todo by :id
|
||||||
|
r.Get("/sync", rs.Sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todos list of stuff.."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todos create"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todo get"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todo update"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todo delete"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs todosResource) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("todo sync"))
|
||||||
|
}
|
||||||
48
_examples/todos-resource/users.go
Normal file
48
_examples/todos-resource/users.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type usersResource struct{}
|
||||||
|
|
||||||
|
// Routes creates a REST router for the todos resource
|
||||||
|
func (rs usersResource) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
// r.Use() // some middleware..
|
||||||
|
|
||||||
|
r.Get("/", rs.List) // GET /users - read a list of users
|
||||||
|
r.Post("/", rs.Create) // POST /users - create a new user and persist it
|
||||||
|
r.Put("/", rs.Delete)
|
||||||
|
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
// r.Use(rs.TodoCtx) // lets have a users map, and lets actually load/manipulate
|
||||||
|
r.Get("/", rs.Get) // GET /users/{id} - read a single user by :id
|
||||||
|
r.Put("/", rs.Update) // PUT /users/{id} - update a single user by :id
|
||||||
|
r.Delete("/", rs.Delete) // DELETE /users/{id} - delete a single user by :id
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs usersResource) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("users list of stuff.."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs usersResource) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("users create"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs usersResource) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("user get"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs usersResource) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("user update"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs usersResource) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("user delete"))
|
||||||
|
}
|
||||||
9
_examples/versions/data/article.go
Normal file
9
_examples/versions/data/article.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
// Article is runtime object, that's not meant to be sent via REST.
|
||||||
|
type Article struct {
|
||||||
|
ID int `db:"id" json:"id" xml:"id"`
|
||||||
|
Title string `db:"title" json:"title" xml:"title"`
|
||||||
|
Data []string `db:"data,stringarray" json:"data" xml:"data"`
|
||||||
|
CustomDataForAuthUsers string `db:"custom_data" json:"-" xml:"-"`
|
||||||
|
}
|
||||||
28
_examples/versions/data/errors.go
Normal file
28
_examples/versions/data/errors.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthorized = errors.New("Unauthorized")
|
||||||
|
ErrForbidden = errors.New("Forbidden")
|
||||||
|
ErrNotFound = errors.New("Resource not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
func PresentError(r *http.Request, err error) (*http.Request, interface{}) {
|
||||||
|
switch err {
|
||||||
|
case ErrUnauthorized:
|
||||||
|
render.Status(r, 401)
|
||||||
|
case ErrForbidden:
|
||||||
|
render.Status(r, 403)
|
||||||
|
case ErrNotFound:
|
||||||
|
render.Status(r, 404)
|
||||||
|
default:
|
||||||
|
render.Status(r, 500)
|
||||||
|
}
|
||||||
|
return r, map[string]string{"error": err.Error()}
|
||||||
|
}
|
||||||
10
_examples/versions/go.mod
Normal file
10
_examples/versions/go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module versions
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/go-chi/render v1.0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/ajg/form v1.5.1 // indirect
|
||||||
6
_examples/versions/go.sum
Normal file
6
_examples/versions/go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||||
|
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||||
|
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||||
154
_examples/versions/main.go
Normal file
154
_examples/versions/main.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// This example demonstrates the use of the render subpackage, with
|
||||||
|
// a quick concept for how to support multiple api versions.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/_examples/versions/data"
|
||||||
|
v1 "github.com/go-chi/chi/v5/_examples/versions/presenter/v1"
|
||||||
|
v2 "github.com/go-chi/chi/v5/_examples/versions/presenter/v2"
|
||||||
|
v3 "github.com/go-chi/chi/v5/_examples/versions/presenter/v3"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// API version 3.
|
||||||
|
r.Route("/v3", func(r chi.Router) {
|
||||||
|
r.Use(apiVersionCtx("v3"))
|
||||||
|
r.Mount("/articles", articleRouter())
|
||||||
|
})
|
||||||
|
|
||||||
|
// API version 2.
|
||||||
|
r.Route("/v2", func(r chi.Router) {
|
||||||
|
r.Use(apiVersionCtx("v2"))
|
||||||
|
r.Mount("/articles", articleRouter())
|
||||||
|
})
|
||||||
|
|
||||||
|
// API version 1.
|
||||||
|
r.Route("/v1", func(r chi.Router) {
|
||||||
|
r.Use(randomErrorMiddleware) // Simulate random error, ie. version 1 is buggy.
|
||||||
|
r.Use(apiVersionCtx("v1"))
|
||||||
|
r.Mount("/articles", articleRouter())
|
||||||
|
})
|
||||||
|
|
||||||
|
http.ListenAndServe(":3333", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiVersionCtx(version string) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "api.version", version))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleRouter() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", listArticles)
|
||||||
|
r.Route("/{articleID}", func(r chi.Router) {
|
||||||
|
r.Get("/", getArticle)
|
||||||
|
// r.Put("/", updateArticle)
|
||||||
|
// r.Delete("/", deleteArticle)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func listArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
articles := make(chan render.Renderer, 5)
|
||||||
|
|
||||||
|
// Load data asynchronously into the channel (simulate slow storage):
|
||||||
|
go func() {
|
||||||
|
for i := 1; i <= 10; i++ {
|
||||||
|
article := &data.Article{
|
||||||
|
ID: i,
|
||||||
|
Title: fmt.Sprintf("Article #%v", i),
|
||||||
|
Data: []string{"one", "two", "three", "four"},
|
||||||
|
CustomDataForAuthUsers: "secret data for auth'd users only",
|
||||||
|
}
|
||||||
|
|
||||||
|
apiVersion := r.Context().Value("api.version").(string)
|
||||||
|
switch apiVersion {
|
||||||
|
case "v1":
|
||||||
|
articles <- v1.NewArticleResponse(article)
|
||||||
|
case "v2":
|
||||||
|
articles <- v2.NewArticleResponse(article)
|
||||||
|
default:
|
||||||
|
articles <- v3.NewArticleResponse(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
close(articles)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start streaming data from the channel.
|
||||||
|
render.Respond(w, r, articles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Load article.
|
||||||
|
if chi.URLParam(r, "articleID") != "1" {
|
||||||
|
render.Respond(w, r, data.ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
article := &data.Article{
|
||||||
|
ID: 1,
|
||||||
|
Title: "Article #1",
|
||||||
|
Data: []string{"one", "two", "three", "four"},
|
||||||
|
CustomDataForAuthUsers: "secret data for auth'd users only",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate some context values:
|
||||||
|
// 1. ?auth=true simulates authenticated session/user.
|
||||||
|
// 2. ?error=true simulates random error.
|
||||||
|
if r.URL.Query().Get("auth") != "" {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "auth", true))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("error") != "" {
|
||||||
|
render.Respond(w, r, errors.New("error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload render.Renderer
|
||||||
|
|
||||||
|
apiVersion := r.Context().Value("api.version").(string)
|
||||||
|
switch apiVersion {
|
||||||
|
case "v1":
|
||||||
|
payload = v1.NewArticleResponse(article)
|
||||||
|
case "v2":
|
||||||
|
payload = v2.NewArticleResponse(article)
|
||||||
|
default:
|
||||||
|
payload = v3.NewArticleResponse(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Render(w, r, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomErrorMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
|
||||||
|
// One in three chance of random error.
|
||||||
|
if rand.Int31n(3) == 0 {
|
||||||
|
errors := []error{data.ErrUnauthorized, data.ErrForbidden, data.ErrNotFound}
|
||||||
|
render.Respond(w, r, errors[rand.Intn(len(errors))])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
22
_examples/versions/presenter/v1/article.go
Normal file
22
_examples/versions/presenter/v1/article.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/_examples/versions/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Article presented in API version 1.
|
||||||
|
type Article struct {
|
||||||
|
*data.Article
|
||||||
|
|
||||||
|
Data map[string]bool `json:"data" xml:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Article) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleResponse(article *data.Article) *Article {
|
||||||
|
return &Article{Article: article}
|
||||||
|
}
|
||||||
30
_examples/versions/presenter/v2/article.go
Normal file
30
_examples/versions/presenter/v2/article.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/_examples/versions/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Article presented in API version 2.
|
||||||
|
type Article struct {
|
||||||
|
// *v3.Article `json:",inline" xml:",inline"`
|
||||||
|
|
||||||
|
*data.Article
|
||||||
|
|
||||||
|
// Additional fields.
|
||||||
|
SelfURL string `json:"self_url" xml:"self_url"`
|
||||||
|
|
||||||
|
// Omitted fields.
|
||||||
|
URL interface{} `json:"url,omitempty" xml:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Article) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
a.SelfURL = fmt.Sprintf("http://localhost:3333/v2?id=%v", a.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleResponse(article *data.Article) *Article {
|
||||||
|
return &Article{Article: article}
|
||||||
|
}
|
||||||
39
_examples/versions/presenter/v3/article.go
Normal file
39
_examples/versions/presenter/v3/article.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package v3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/_examples/versions/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Article presented in API version 2.
|
||||||
|
type Article struct {
|
||||||
|
*data.Article `json:",inline" xml:",inline"`
|
||||||
|
|
||||||
|
// Additional fields.
|
||||||
|
URL string `json:"url" xml:"url"`
|
||||||
|
ViewsCount int64 `json:"views_count" xml:"views_count"`
|
||||||
|
APIVersion string `json:"api_version" xml:"api_version"`
|
||||||
|
|
||||||
|
// Omitted fields.
|
||||||
|
// Show custom_data explicitly for auth'd users only.
|
||||||
|
CustomDataForAuthUsers interface{} `json:"custom_data,omitempty" xml:"custom_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Article) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
a.ViewsCount = rand.Int63n(100000)
|
||||||
|
a.URL = fmt.Sprintf("http://localhost:3333/v3/?id=%v", a.ID)
|
||||||
|
|
||||||
|
// Only show to auth'd user.
|
||||||
|
if _, ok := r.Context().Value("auth").(bool); ok {
|
||||||
|
a.CustomDataForAuthUsers = a.Article.CustomDataForAuthUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleResponse(article *data.Article) *Article {
|
||||||
|
return &Article{Article: article}
|
||||||
|
}
|
||||||
49
chain.go
Normal file
49
chain.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package chi
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Chain returns a Middlewares type from a slice of middleware handlers.
|
||||||
|
func Chain(middlewares ...func(http.Handler) http.Handler) Middlewares {
|
||||||
|
return Middlewares(middlewares)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler builds and returns a http.Handler from the chain of middlewares,
|
||||||
|
// with `h http.Handler` as the final handler.
|
||||||
|
func (mws Middlewares) Handler(h http.Handler) http.Handler {
|
||||||
|
return &ChainHandler{h, chain(mws, h), mws}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerFunc builds and returns a http.Handler from the chain of middlewares,
|
||||||
|
// with `h http.Handler` as the final handler.
|
||||||
|
func (mws Middlewares) HandlerFunc(h http.HandlerFunc) http.Handler {
|
||||||
|
return &ChainHandler{h, chain(mws, h), mws}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChainHandler is a http.Handler with support for handler composition and
|
||||||
|
// execution.
|
||||||
|
type ChainHandler struct {
|
||||||
|
Endpoint http.Handler
|
||||||
|
chain http.Handler
|
||||||
|
Middlewares Middlewares
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.chain.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chain builds a http.Handler composed of an inline middleware stack and endpoint
|
||||||
|
// handler in the order they are passed.
|
||||||
|
func chain(middlewares []func(http.Handler) http.Handler, endpoint http.Handler) http.Handler {
|
||||||
|
// Return ahead of time if there aren't any middlewares for the chain
|
||||||
|
if len(middlewares) == 0 {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the end handler with the middleware chain
|
||||||
|
h := middlewares[len(middlewares)-1](endpoint)
|
||||||
|
for i := len(middlewares) - 2; i >= 0; i-- {
|
||||||
|
h = middlewares[i](h)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
140
chi.go
Normal file
140
chi.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package chi is a small, idiomatic and composable router for building HTTP services.
|
||||||
|
//
|
||||||
|
// chi supports the four most recent major versions of Go.
|
||||||
|
//
|
||||||
|
// # Example
|
||||||
|
//
|
||||||
|
// package main
|
||||||
|
//
|
||||||
|
// import (
|
||||||
|
// "net/http"
|
||||||
|
//
|
||||||
|
// "github.com/go-chi/chi/v5"
|
||||||
|
// "github.com/go-chi/chi/v5/middleware"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// func main() {
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// r.Use(middleware.Logger)
|
||||||
|
// r.Use(middleware.Recoverer)
|
||||||
|
//
|
||||||
|
// r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// w.Write([]byte("root."))
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// http.ListenAndServe(":3333", r)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// See [github.com/go-chi/chi/v5/_examples] for more in-depth examples.
|
||||||
|
//
|
||||||
|
// # URL patterns
|
||||||
|
//
|
||||||
|
// URL patterns allow for easy matching of path components in HTTP
|
||||||
|
// requests. The matching components can then be accessed using
|
||||||
|
// [chi.URLParam](). All patterns must begin with a slash.
|
||||||
|
//
|
||||||
|
// A simple named placeholder {name} matches any sequence of characters
|
||||||
|
// up to the next / or the end of the URL. Trailing slashes on paths must
|
||||||
|
// be handled explicitly.
|
||||||
|
//
|
||||||
|
// A placeholder with a name followed by a colon allows a regular
|
||||||
|
// expression match, for example {number:\\d+}. The regular expression
|
||||||
|
// syntax is Go's normal [regexp] RE2 syntax (see [regexp/syntax]), except
|
||||||
|
// that / will never be matched. An anonymous regexp pattern is allowed, using
|
||||||
|
// an empty string before the colon in the placeholder, such as {:\\d+}.
|
||||||
|
//
|
||||||
|
// The special placeholder of asterisk (*) matches the rest of the requested
|
||||||
|
// URL. Any trailing characters in the pattern are ignored. This is the only
|
||||||
|
// placeholder which will match / characters.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// "/user/{name}" matches "/user/jsmith" but not "/user/jsmith/info" or "/user/jsmith/"
|
||||||
|
// "/user/{name}/info" matches "/user/jsmith/info"
|
||||||
|
// "/page/*" matches "/page/intro/latest"
|
||||||
|
// "/page/{other}/latest" also matches "/page/intro/latest"
|
||||||
|
// "/date/{yyyy:\\d\\d\\d\\d}/{mm:\\d\\d}/{dd:\\d\\d}" matches "/date/2017/04/01"
|
||||||
|
package chi
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// NewRouter returns a new Mux object that implements the Router interface.
|
||||||
|
func NewRouter() *Mux {
|
||||||
|
return NewMux()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router consisting of the core routing methods used by chi's Mux,
|
||||||
|
// using only the standard net/http.
|
||||||
|
type Router interface {
|
||||||
|
http.Handler
|
||||||
|
Routes
|
||||||
|
|
||||||
|
// Use appends one or more middlewares onto the Router stack.
|
||||||
|
Use(middlewares ...func(http.Handler) http.Handler)
|
||||||
|
|
||||||
|
// With adds inline middlewares for an endpoint handler.
|
||||||
|
With(middlewares ...func(http.Handler) http.Handler) Router
|
||||||
|
|
||||||
|
// Group adds a new inline-Router along the current routing
|
||||||
|
// path, with a fresh middleware stack for the inline-Router.
|
||||||
|
Group(fn func(r Router)) Router
|
||||||
|
|
||||||
|
// Route mounts a sub-Router along a `pattern` string.
|
||||||
|
Route(pattern string, fn func(r Router)) Router
|
||||||
|
|
||||||
|
// Mount attaches another http.Handler along ./pattern/*
|
||||||
|
Mount(pattern string, h http.Handler)
|
||||||
|
|
||||||
|
// Handle and HandleFunc adds routes for `pattern` that matches
|
||||||
|
// all HTTP methods.
|
||||||
|
Handle(pattern string, h http.Handler)
|
||||||
|
HandleFunc(pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// Method and MethodFunc adds routes for `pattern` that matches
|
||||||
|
// the `method` HTTP method.
|
||||||
|
Method(method, pattern string, h http.Handler)
|
||||||
|
MethodFunc(method, pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// HTTP-method routing along `pattern`
|
||||||
|
Connect(pattern string, h http.HandlerFunc)
|
||||||
|
Delete(pattern string, h http.HandlerFunc)
|
||||||
|
Get(pattern string, h http.HandlerFunc)
|
||||||
|
Head(pattern string, h http.HandlerFunc)
|
||||||
|
Options(pattern string, h http.HandlerFunc)
|
||||||
|
Patch(pattern string, h http.HandlerFunc)
|
||||||
|
Post(pattern string, h http.HandlerFunc)
|
||||||
|
Put(pattern string, h http.HandlerFunc)
|
||||||
|
Query(pattern string, h http.HandlerFunc)
|
||||||
|
Trace(pattern string, h http.HandlerFunc)
|
||||||
|
|
||||||
|
// NotFound defines a handler to respond whenever a route could
|
||||||
|
// not be found.
|
||||||
|
NotFound(h http.HandlerFunc)
|
||||||
|
|
||||||
|
// MethodNotAllowed defines a handler to respond whenever a method is
|
||||||
|
// not allowed.
|
||||||
|
MethodNotAllowed(h http.HandlerFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes interface adds two methods for router traversal, which is also
|
||||||
|
// used by the `docgen` subpackage to generation documentation for Routers.
|
||||||
|
type Routes interface {
|
||||||
|
// Routes returns the routing tree in an easily traversable structure.
|
||||||
|
Routes() []Route
|
||||||
|
|
||||||
|
// Middlewares returns the list of middlewares in use by the router.
|
||||||
|
Middlewares() Middlewares
|
||||||
|
|
||||||
|
// Match searches the routing tree for a handler that matches
|
||||||
|
// the method/path - similar to routing a http request, but without
|
||||||
|
// executing the handler thereafter.
|
||||||
|
Match(rctx *Context, method, path string) bool
|
||||||
|
|
||||||
|
// Find searches the routing tree for the pattern that matches
|
||||||
|
// the method/path.
|
||||||
|
Find(rctx *Context, method, path string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middlewares type is a slice of standard middleware handlers with methods
|
||||||
|
// to compose middleware chains and http.Handler's.
|
||||||
|
type Middlewares []func(http.Handler) http.Handler
|
||||||
190
context.go
Normal file
190
context.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package chi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// URLParam returns the url parameter from a http.Request object.
|
||||||
|
func URLParam(r *http.Request, key string) string {
|
||||||
|
return URLParamFromCtx(r.Context(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLParamFromCtx returns the url parameter from a http.Request Context.
|
||||||
|
func URLParamFromCtx(ctx context.Context, key string) string {
|
||||||
|
if rctx := RouteContext(ctx); rctx != nil {
|
||||||
|
return rctx.URLParam(key)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteContext returns chi's routing Context object from a
|
||||||
|
// http.Request Context.
|
||||||
|
func RouteContext(ctx context.Context) *Context {
|
||||||
|
val, _ := ctx.Value(RouteCtxKey).(*Context)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouteContext returns a new routing Context object.
|
||||||
|
func NewRouteContext() *Context {
|
||||||
|
return &Context{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// RouteCtxKey is the context.Context key to store the request context.
|
||||||
|
RouteCtxKey = &contextKey{"RouteContext"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context is the default routing context set on the root node of a
|
||||||
|
// request context to track route patterns, URL parameters and
|
||||||
|
// an optional routing path.
|
||||||
|
//
|
||||||
|
// NOTE: New reference fields (slices, maps, pointers) must be deep-copied in Clone.
|
||||||
|
type Context struct {
|
||||||
|
Routes Routes
|
||||||
|
|
||||||
|
// parentCtx is the parent of this one, for using Context as a
|
||||||
|
// context.Context directly. This is an optimization that saves
|
||||||
|
// 1 allocation.
|
||||||
|
parentCtx context.Context
|
||||||
|
|
||||||
|
// Routing path/method override used during the route search.
|
||||||
|
// See Mux#routeHTTP method.
|
||||||
|
RoutePath string
|
||||||
|
RouteMethod string
|
||||||
|
|
||||||
|
// URLParams are the stack of routeParams captured during the
|
||||||
|
// routing lifecycle across a stack of sub-routers.
|
||||||
|
URLParams RouteParams
|
||||||
|
|
||||||
|
// Route parameters matched for the current sub-router. It is
|
||||||
|
// intentionally unexported so it can't be tampered.
|
||||||
|
routeParams RouteParams
|
||||||
|
|
||||||
|
// The endpoint routing pattern that matched the request URI path
|
||||||
|
// or `RoutePath` of the current sub-router. This value will update
|
||||||
|
// during the lifecycle of a request passing through a stack of
|
||||||
|
// sub-routers.
|
||||||
|
routePattern string
|
||||||
|
|
||||||
|
// Routing pattern stack throughout the lifecycle of the request,
|
||||||
|
// across all connected routers. It is a record of all matching
|
||||||
|
// patterns across a stack of sub-routers.
|
||||||
|
RoutePatterns []string
|
||||||
|
|
||||||
|
methodsAllowed []methodTyp // allowed methods in case of a 405
|
||||||
|
methodNotAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset a routing context to its initial state.
|
||||||
|
func (x *Context) Reset() {
|
||||||
|
x.Routes = nil
|
||||||
|
x.RoutePath = ""
|
||||||
|
x.RouteMethod = ""
|
||||||
|
x.RoutePatterns = x.RoutePatterns[:0]
|
||||||
|
x.URLParams.Keys = x.URLParams.Keys[:0]
|
||||||
|
x.URLParams.Values = x.URLParams.Values[:0]
|
||||||
|
|
||||||
|
x.routePattern = ""
|
||||||
|
x.routeParams.Keys = x.routeParams.Keys[:0]
|
||||||
|
x.routeParams.Values = x.routeParams.Values[:0]
|
||||||
|
x.methodNotAllowed = false
|
||||||
|
x.methodsAllowed = x.methodsAllowed[:0]
|
||||||
|
x.parentCtx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone a routing context so that it may be used outside of the request/response
|
||||||
|
// lifecycle, e.g. in a goroutine that outlives the request handler.
|
||||||
|
//
|
||||||
|
// Clone must be called before the request handler returns; after that, the
|
||||||
|
// original context is reset and put back into the pool for reuse by another
|
||||||
|
// request, racing with the copy.
|
||||||
|
func (x *Context) Clone() *Context {
|
||||||
|
clone := *x
|
||||||
|
|
||||||
|
// Detach from the request's context, which is canceled once the request finishes.
|
||||||
|
clone.parentCtx = nil
|
||||||
|
|
||||||
|
clone.URLParams.Keys = slices.Clone(x.URLParams.Keys)
|
||||||
|
clone.URLParams.Values = slices.Clone(x.URLParams.Values)
|
||||||
|
|
||||||
|
clone.routeParams.Keys = slices.Clone(x.routeParams.Keys)
|
||||||
|
clone.routeParams.Values = slices.Clone(x.routeParams.Values)
|
||||||
|
|
||||||
|
clone.RoutePatterns = slices.Clone(x.RoutePatterns)
|
||||||
|
clone.methodsAllowed = slices.Clone(x.methodsAllowed)
|
||||||
|
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLParam returns the corresponding URL parameter value from the request
|
||||||
|
// routing context.
|
||||||
|
func (x *Context) URLParam(key string) string {
|
||||||
|
for i, k := range slices.Backward(x.URLParams.Keys) {
|
||||||
|
if k == key {
|
||||||
|
return x.URLParams.Values[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoutePattern builds the routing pattern string for the particular
|
||||||
|
// request, at the particular point during routing. This means, the value
|
||||||
|
// will change throughout the execution of a request in a router. That is
|
||||||
|
// why it's advised to only use this value after calling the next handler.
|
||||||
|
//
|
||||||
|
// For example,
|
||||||
|
//
|
||||||
|
// func Instrument(next http.Handler) http.Handler {
|
||||||
|
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// next.ServeHTTP(w, r)
|
||||||
|
// routePattern := chi.RouteContext(r.Context()).RoutePattern()
|
||||||
|
// measure(w, r, routePattern)
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
func (x *Context) RoutePattern() string {
|
||||||
|
if x == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
routePattern := strings.Join(x.RoutePatterns, "")
|
||||||
|
routePattern = replaceWildcards(routePattern)
|
||||||
|
if routePattern != "/" {
|
||||||
|
routePattern = strings.TrimSuffix(routePattern, "//")
|
||||||
|
routePattern = strings.TrimSuffix(routePattern, "/")
|
||||||
|
}
|
||||||
|
return routePattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceWildcards takes a route pattern and replaces all occurrences of
|
||||||
|
// "/*/" with "/". It iteratively runs until no wildcards remain to
|
||||||
|
// correctly handle consecutive wildcards.
|
||||||
|
func replaceWildcards(p string) string {
|
||||||
|
for strings.Contains(p, "/*/") {
|
||||||
|
p = strings.ReplaceAll(p, "/*/", "/")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteParams is a structure to track URL routing parameters efficiently.
|
||||||
|
type RouteParams struct {
|
||||||
|
Keys, Values []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add will append a URL parameter to the end of the route param
|
||||||
|
func (s *RouteParams) Add(key, value string) {
|
||||||
|
s.Keys = append(s.Keys, key)
|
||||||
|
s.Values = append(s.Values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey is a value for use with context.WithValue. It's used as
|
||||||
|
// a pointer so it fits in an interface{} without allocation. This technique
|
||||||
|
// for defining context keys was copied from Go 1.7's new use of context in net/http.
|
||||||
|
type contextKey struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *contextKey) String() string {
|
||||||
|
return "chi context value " + k.name
|
||||||
|
}
|
||||||
155
context_test.go
Normal file
155
context_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package chi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRoutePattern tests correct in-the-middle wildcard removals.
|
||||||
|
// If user organizes a router like this:
|
||||||
|
//
|
||||||
|
// (router.go)
|
||||||
|
//
|
||||||
|
// r.Route("/v1", func(r chi.Router) {
|
||||||
|
// r.Mount("/resources", resourcesController{}.Router())
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// (resources_controller.go)
|
||||||
|
//
|
||||||
|
// r.Route("/", func(r chi.Router) {
|
||||||
|
// r.Get("/{resource_id}", getResource())
|
||||||
|
// // other routes...
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// This test checks how the route pattern is calculated
|
||||||
|
// "/v1/resources/{resource_id}" (right)
|
||||||
|
// "/v1/resources/*/{resource_id}" (wrong)
|
||||||
|
func TestRoutePattern(t *testing.T) {
|
||||||
|
routePatterns := []string{
|
||||||
|
"/v1/*",
|
||||||
|
"/resources/*",
|
||||||
|
"/{resource_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
x := &Context{
|
||||||
|
RoutePatterns: routePatterns,
|
||||||
|
}
|
||||||
|
|
||||||
|
if p := x.RoutePattern(); p != "/v1/resources/{resource_id}" {
|
||||||
|
t.Fatal("unexpected route pattern: " + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
x.RoutePatterns = []string{
|
||||||
|
"/v1/*",
|
||||||
|
"/resources/*",
|
||||||
|
// Additional wildcard, depending on the router structure of the user
|
||||||
|
"/*",
|
||||||
|
"/{resource_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correctly removes in-the-middle wildcards instead of "/v1/resources/*/{resource_id}"
|
||||||
|
if p := x.RoutePattern(); p != "/v1/resources/{resource_id}" {
|
||||||
|
t.Fatal("unexpected route pattern: " + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
x.RoutePatterns = []string{
|
||||||
|
"/v1/*",
|
||||||
|
"/resources/*",
|
||||||
|
// Even with many wildcards
|
||||||
|
"/*",
|
||||||
|
"/*",
|
||||||
|
"/*",
|
||||||
|
"/{resource_id}/*", // Keeping trailing wildcard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correctly removes in-the-middle wildcards instead of "/v1/resources/*/*/{resource_id}/*"
|
||||||
|
if p := x.RoutePattern(); p != "/v1/resources/{resource_id}/*" {
|
||||||
|
t.Fatal("unexpected route pattern: " + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
x.RoutePatterns = []string{
|
||||||
|
"/v1/*",
|
||||||
|
"/resources/*",
|
||||||
|
// And respects asterisks as part of the paths
|
||||||
|
"/*special_path/*",
|
||||||
|
"/with_asterisks*/*",
|
||||||
|
"/{resource_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correctly removes in-the-middle wildcards instead of "/v1/resourcesspecial_path/with_asterisks{resource_id}"
|
||||||
|
if p := x.RoutePattern(); p != "/v1/resources/*special_path/with_asterisks*/{resource_id}" {
|
||||||
|
t.Fatal("unexpected route pattern: " + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing for the root route pattern
|
||||||
|
x.RoutePatterns = []string{"/"}
|
||||||
|
// It should just return "/" as the pattern
|
||||||
|
if p := x.RoutePattern(); p != "/" {
|
||||||
|
t.Fatal("unexpected route pattern for root: " + p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing empty route pattern for nil context
|
||||||
|
var nilContext *Context
|
||||||
|
if p := nilContext.RoutePattern(); p != "" {
|
||||||
|
t.Fatalf("unexpected non-empty route pattern for nil context: %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplaceWildcardsConsecutive ensures multiple consecutive wildcards are
|
||||||
|
// collapsed into a single slash.
|
||||||
|
func TestReplaceWildcardsConsecutive(t *testing.T) {
|
||||||
|
if p := replaceWildcards("/foo/*/*/*/bar"); p != "/foo/bar" {
|
||||||
|
t.Fatalf("unexpected wildcard replacement: %s", p)
|
||||||
|
}
|
||||||
|
if p := replaceWildcards("/foo/*/*/*/bar/*"); p != "/foo/bar/*" {
|
||||||
|
t.Fatalf("unexpected trailing wildcard behavior: %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_Clone(t *testing.T) {
|
||||||
|
orig := &Context{
|
||||||
|
parentCtx: context.Background(),
|
||||||
|
RoutePatterns: []string{"/v1", "/resources/{id}"},
|
||||||
|
methodsAllowed: []methodTyp{mHEAD, mGET},
|
||||||
|
URLParams: RouteParams{
|
||||||
|
Keys: []string{"foo"},
|
||||||
|
Values: []string{"bar"},
|
||||||
|
},
|
||||||
|
routeParams: RouteParams{
|
||||||
|
Keys: []string{"id"},
|
||||||
|
Values: []string{"123"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := orig.Clone()
|
||||||
|
orig.Reset()
|
||||||
|
|
||||||
|
orig.URLParams.Keys = append(orig.URLParams.Keys, "bar")
|
||||||
|
orig.URLParams.Values = append(orig.URLParams.Values, "baz")
|
||||||
|
orig.routeParams.Keys = append(orig.routeParams.Keys, "name")
|
||||||
|
orig.routeParams.Values = append(orig.routeParams.Values, "foxmulder")
|
||||||
|
orig.RoutePatterns = append(orig.RoutePatterns, "/mutated")
|
||||||
|
orig.methodsAllowed = append(orig.methodsAllowed, mPOST)
|
||||||
|
|
||||||
|
if got := clone.URLParams.Keys[0]; got != "foo" {
|
||||||
|
t.Fatalf("clone URLParams.Keys was corrupted, want %q got %q", "foo", got)
|
||||||
|
}
|
||||||
|
if got := clone.URLParams.Values[0]; got != "bar" {
|
||||||
|
t.Fatalf("clone URLParams.Values was corrupted, want %q got %q", "bar", got)
|
||||||
|
}
|
||||||
|
if got := clone.routeParams.Keys[0]; got != "id" {
|
||||||
|
t.Fatalf("clone routeParams.Keys was corrupted, want %q got %q", "id", got)
|
||||||
|
}
|
||||||
|
if got := clone.routeParams.Values[0]; got != "123" {
|
||||||
|
t.Fatalf("clone routeParams.Values was corrupted, want %q got %q", "123", got)
|
||||||
|
}
|
||||||
|
if got := clone.RoutePatterns[0]; got != "/v1" {
|
||||||
|
t.Fatalf("clone RoutePatterns[0] was corrupted, want %q got %q", "/v1", got)
|
||||||
|
}
|
||||||
|
if got := clone.methodsAllowed[0]; got != mHEAD {
|
||||||
|
t.Fatalf("clone methodsAllowed[0] was corrupted, want %d got %d", mHEAD, got)
|
||||||
|
}
|
||||||
|
if clone.parentCtx != nil {
|
||||||
|
t.Fatalf("clone parentCtx should be detached, got %v", clone.parentCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/go-chi/chi/v5
|
||||||
|
|
||||||
|
// Chi supports the four most recent major versions of Go.
|
||||||
|
// See https://github.com/go-chi/chi/issues/963.
|
||||||
|
go 1.24
|
||||||
33
middleware/basic_auth.go
Normal file
33
middleware/basic_auth.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
|
||||||
|
func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
basicAuthFailed(w, realm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credPass, credUserOk := creds[user]
|
||||||
|
if !credUserOk || subtle.ConstantTimeCompare([]byte(pass), []byte(credPass)) != 1 {
|
||||||
|
basicAuthFailed(w, realm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicAuthFailed(w http.ResponseWriter, realm string) {
|
||||||
|
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
59
middleware/basic_auth_test.go
Normal file
59
middleware/basic_auth_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testAuthCreds = map[string]string{
|
||||||
|
"testUser": "testPassword",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuth(t *testing.T) {
|
||||||
|
endpoint := "/secure"
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(BasicAuth("localhost", testAuthCreds))
|
||||||
|
r.Get(endpoint, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("authentication accepted"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
digest string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no authorization header",
|
||||||
|
expected: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid basic auth credentials",
|
||||||
|
digest: "Basic dGVzdFVzZXI6d3JvbmdwYXNzd29yZA==",
|
||||||
|
expected: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid basic auth credentials",
|
||||||
|
digest: "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk",
|
||||||
|
expected: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
|
||||||
|
if c.digest != "" {
|
||||||
|
req.Header.Set("Authorization", c.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if got := w.Code; got != c.expected {
|
||||||
|
t.Errorf("status code = %d, expected %d", got, c.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
middleware/clean_path.go
Normal file
28
middleware/clean_path.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanPath middleware will clean out double slash mistakes from a user's request path.
|
||||||
|
// For example, if a user requests /users//1 or //users////1 will both be treated as: /users/1
|
||||||
|
func CleanPath(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
|
||||||
|
routePath := rctx.RoutePath
|
||||||
|
if routePath == "" {
|
||||||
|
if r.URL.RawPath != "" {
|
||||||
|
routePath = r.URL.RawPath
|
||||||
|
} else {
|
||||||
|
routePath = r.URL.Path
|
||||||
|
}
|
||||||
|
rctx.RoutePath = path.Clean(routePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
23
middleware/clean_path_test.go
Normal file
23
middleware/clean_path_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanPath(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(CleanPath)
|
||||||
|
r.Get("/users/1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/users////1", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("clean path: unexpected response code: %v", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
middleware/client_ip.go
Normal file
274
middleware/client_ip.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clientIPCtxKey stores the client IP set by any of the ClientIPFrom* middlewares.
|
||||||
|
var clientIPCtxKey = &contextKey{"clientIP"}
|
||||||
|
|
||||||
|
// xForwardedForHeader is the canonical form of the X-Forwarded-For header
|
||||||
|
// name, used by the XFF-based middlewares.
|
||||||
|
const xForwardedForHeader = "X-Forwarded-For"
|
||||||
|
|
||||||
|
// ClientIPFromHeader stores the client IP from a single-IP header set by
|
||||||
|
// your reverse proxy. Read it with [GetClientIP].
|
||||||
|
//
|
||||||
|
// Only safe with headers your proxy unconditionally OVERWRITES on every
|
||||||
|
// request, e.g.:
|
||||||
|
//
|
||||||
|
// - X-Real-IP — Nginx with ngx_http_realip_module
|
||||||
|
// - X-Client-IP — Apache with mod_remoteip
|
||||||
|
// - CF-Connecting-IP — Cloudflare
|
||||||
|
//
|
||||||
|
// True-Client-IP, X-Azure-ClientIP, and Fastly-Client-IP look similar but
|
||||||
|
// pass through from the client by default in those products; don't use them
|
||||||
|
// unless your edge strips the inbound value.
|
||||||
|
//
|
||||||
|
// If the header reaches us with multiple values (misconfigured proxy that
|
||||||
|
// appends, or a downstream proxy not stripping a client-supplied value),
|
||||||
|
// the LAST value wins — that's the one set by the hop closest to us, and
|
||||||
|
// therefore the most trusted. Fail-closed if the last value doesn't parse:
|
||||||
|
// no client IP is set rather than falling back to earlier (less-trusted)
|
||||||
|
// values.
|
||||||
|
//
|
||||||
|
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
|
||||||
|
// stripped before storage.
|
||||||
|
func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler {
|
||||||
|
header := http.CanonicalHeaderKey(trustedHeader)
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
values := r.Header.Values(header)
|
||||||
|
if len(values) > 0 {
|
||||||
|
if ip, ok := parseHeaderAddr(values[len(values)-1]); ok {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIPFromXFF stores the client IP read from the X-Forwarded-For header,
|
||||||
|
// walking the chain right-to-left and skipping any IP that falls within one
|
||||||
|
// of the given trusted CIDR prefixes. The first IP that is not trusted is
|
||||||
|
// the client. Read it with [GetClientIP].
|
||||||
|
//
|
||||||
|
// An unparseable entry mid-chain aborts the walk and leaves no client IP
|
||||||
|
// set (fail-closed) — we can't safely trust anything left of garbage.
|
||||||
|
//
|
||||||
|
// Use this when you sit behind one or more reverse proxies whose IP ranges
|
||||||
|
// you can enumerate as CIDRs. Most CDNs publish their IPs:
|
||||||
|
//
|
||||||
|
// Cloudflare: https://www.cloudflare.com/ips/
|
||||||
|
// AWS: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||||
|
// Fastly: https://api.fastly.com/public-ip-list
|
||||||
|
// Google Cloud: https://www.gstatic.com/ipranges/cloud.json
|
||||||
|
//
|
||||||
|
// Example (CloudFront):
|
||||||
|
//
|
||||||
|
// r.Use(middleware.ClientIPFromXFF(
|
||||||
|
// "13.32.0.0/15", // CloudFront IPv4
|
||||||
|
// "52.46.0.0/18", // CloudFront IPv4
|
||||||
|
// "2600:9000::/28", // CloudFront IPv6
|
||||||
|
// ))
|
||||||
|
//
|
||||||
|
// Calling with no arguments returns the rightmost XFF entry, or no IP if
|
||||||
|
// that entry doesn't parse (fail-closed) — safe only if you have exactly
|
||||||
|
// one trusted hop directly in front of this server (e.g., nginx on localhost).
|
||||||
|
//
|
||||||
|
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
|
||||||
|
// stripped before the prefix check and storage; otherwise an attacker
|
||||||
|
// could use either notation to alias a trusted IP past the check.
|
||||||
|
//
|
||||||
|
// If you know the number of trusted proxies but not their IPs, use
|
||||||
|
// [ClientIPFromXFFTrustedProxies] instead.
|
||||||
|
//
|
||||||
|
// Panics at startup if any prefix is invalid.
|
||||||
|
func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler {
|
||||||
|
prefixes := make([]netip.Prefix, len(trustedIPPrefixes))
|
||||||
|
for i, p := range trustedIPPrefixes {
|
||||||
|
prefixes[i] = netip.MustParsePrefix(p)
|
||||||
|
}
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var found netip.Addr
|
||||||
|
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
|
||||||
|
ip, ok := parseHeaderAddr(v)
|
||||||
|
if !ok {
|
||||||
|
return true // fail-closed; leave found unset
|
||||||
|
}
|
||||||
|
if inAnyPrefix(ip, prefixes) {
|
||||||
|
return false // trusted hop; keep walking left
|
||||||
|
}
|
||||||
|
found = ip
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if found.IsValid() {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found))
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIPFromXFFTrustedProxies stores the client IP read from
|
||||||
|
// X-Forwarded-For, given the exact number of trusted reverse proxies
|
||||||
|
// between this server and the public internet. Read it with [GetClientIP].
|
||||||
|
//
|
||||||
|
// PREFER [ClientIPFromXFF] with explicit CIDRs whenever you can — it
|
||||||
|
// cannot off-by-one and is robust to architecture changes. Most CDNs
|
||||||
|
// publish their IP ranges (Cloudflare, AWS, Fastly, Google Cloud). Use
|
||||||
|
// this counting variant only when proxy IPs are dynamic and unpublishable.
|
||||||
|
//
|
||||||
|
// numTrustedProxies = total proxy hops between the client and this server.
|
||||||
|
// Count every hop in the request path:
|
||||||
|
//
|
||||||
|
// Single proxy (one LB / nginx / Heroku / Fly.io / Render) ....... 1
|
||||||
|
// Two proxies (Cloudflare → ALB, CloudFront → ALB) .............. 2
|
||||||
|
// Three proxies (CDN → API gateway → LB) ......................... 3
|
||||||
|
//
|
||||||
|
// VERIFY BEFORE GOING LIVE: send a request from a known IP and confirm
|
||||||
|
// [GetClientIP] returns that IP. If it returns a proxy IP, your count is
|
||||||
|
// too LOW — a client can spoof their IP, fix immediately. If it returns
|
||||||
|
// "", your count is too HIGH — no leak, but no client IP either.
|
||||||
|
//
|
||||||
|
// This middleware reads ONLY X-Forwarded-For; it does not inspect
|
||||||
|
// r.RemoteAddr. Guarantee at the network layer (security group / firewall)
|
||||||
|
// that only your proxies can reach this server.
|
||||||
|
//
|
||||||
|
// If the XFF chain has fewer than numTrustedProxies entries, no client IP
|
||||||
|
// is set (fail-closed). Like [ClientIPFromXFF], v4-mapped IPv6 folds to v4
|
||||||
|
// and IPv6 zones are stripped before storage.
|
||||||
|
//
|
||||||
|
// Panics at startup if numTrustedProxies < 1.
|
||||||
|
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler {
|
||||||
|
if numTrustedProxies < 1 {
|
||||||
|
panic("middleware.ClientIPFromXFFTrustedProxies: numTrustedProxies must be >= 1")
|
||||||
|
}
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := numTrustedProxies
|
||||||
|
var entry string
|
||||||
|
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
|
||||||
|
n--
|
||||||
|
if n == 0 {
|
||||||
|
entry = v
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if entry != "" {
|
||||||
|
if ip, ok := parseHeaderAddr(entry); ok {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIPFromRemoteAddr stores the client IP read from the TCP RemoteAddr
|
||||||
|
// of the incoming request — the IP address of whoever opened the connection
|
||||||
|
// to this server. Read it with [GetClientIP].
|
||||||
|
//
|
||||||
|
// Use this when this server is directly connected to the public internet
|
||||||
|
// with NO reverse proxy in front of it. Behind a reverse proxy, RemoteAddr
|
||||||
|
// is the proxy's IP, not the client's — use [ClientIPFromHeader] or
|
||||||
|
// [ClientIPFromXFF] instead.
|
||||||
|
//
|
||||||
|
// IPv4 clients on a dual-stack listener surface as ::ffff:a.b.c.d; they
|
||||||
|
// fold to plain v4 before storage so one logical client maps to one key.
|
||||||
|
// IPv6 zones are preserved (link-local connections may legitimately have one).
|
||||||
|
func ClientIPFromRemoteAddr(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr // RemoteAddr may already be a bare IP (e.g. in tests).
|
||||||
|
}
|
||||||
|
if ip, err := netip.ParseAddr(host); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip.Unmap()))
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientIP returns the client IP as a string, as set by one of the
|
||||||
|
// ClientIPFrom* middlewares. Returns "" if no valid IP was set.
|
||||||
|
// Convenient for logging, rate-limit keys, etc.
|
||||||
|
func GetClientIP(ctx context.Context) string {
|
||||||
|
ip := GetClientIPAddr(ctx)
|
||||||
|
if !ip.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientIPAddr returns the client IP as a [netip.Addr], as set by one of
|
||||||
|
// the ClientIPFrom* middlewares. The returned Addr is the zero value if not
|
||||||
|
// set; use [netip.Addr.IsValid] to check. Useful when you need typed work —
|
||||||
|
// prefix containment, Is4/Is6, etc. — without re-parsing the string.
|
||||||
|
func GetClientIPAddr(ctx context.Context) netip.Addr {
|
||||||
|
ip, _ := ctx.Value(clientIPCtxKey).(netip.Addr)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkXFF walks the entries of the merged X-Forwarded-For chain
|
||||||
|
// RIGHT-TO-LEFT, invoking visit on each trimmed non-empty entry. visit
|
||||||
|
// returns true to stop the walk. Lazy walk, zero allocations (entries
|
||||||
|
// are substrings of the input headers).
|
||||||
|
//
|
||||||
|
// Multiple XFF headers are merged per RFC 2616 — each header's
|
||||||
|
// comma-separated entries in order received — so an attacker cannot pick
|
||||||
|
// which value security logic sees by sending a duplicate header.
|
||||||
|
func walkXFF(headers []string, visit func(entry string) bool) {
|
||||||
|
for _, h := range slices.Backward(headers) {
|
||||||
|
for h != "" {
|
||||||
|
var v string
|
||||||
|
if i := strings.LastIndexByte(h, ','); i >= 0 {
|
||||||
|
v, h = h[i+1:], h[:i]
|
||||||
|
} else {
|
||||||
|
v, h = h, ""
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if visit(v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inAnyPrefix reports whether ip falls within any of the given prefixes.
|
||||||
|
func inAnyPrefix(ip netip.Addr, prefixes []netip.Prefix) bool {
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if p.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeaderAddr parses s and normalizes for storage: v4-mapped IPv6
|
||||||
|
// (::ffff:a.b.c.d) folds to plain v4, IPv6 zone is stripped. Both defend the
|
||||||
|
// trust-prefix check against attacker-injected aliases — [netip.Prefix.Contains]
|
||||||
|
// returns false for v4-mapped addresses vs v4 prefixes and for any zoned
|
||||||
|
// address, so without folding/stripping an attacker could escape an
|
||||||
|
// otherwise valid trust list.
|
||||||
|
//
|
||||||
|
// Header-sourced IPs only. [ClientIPFromRemoteAddr] normalizes inline
|
||||||
|
// (Unmap, but zone preserved for legitimate link-local connections).
|
||||||
|
func parseHeaderAddr(s string) (netip.Addr, bool) {
|
||||||
|
ip, err := netip.ParseAddr(s)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, false
|
||||||
|
}
|
||||||
|
return ip.Unmap().WithZone(""), true
|
||||||
|
}
|
||||||
58
middleware/client_ip_bench_test.go
Normal file
58
middleware/client_ip_bench_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkWalkXFF measures the cost of walking a merged X-Forwarded-For
|
||||||
|
// chain end-to-end (visitor never stops). Exercises the worst case for
|
||||||
|
// ClientIPFromXFF when every entry is in the trusted prefix list, and
|
||||||
|
// bounds the absolute cost of a pathologically large attacker-supplied
|
||||||
|
// XFF header (subject to http.Server.MaxHeaderBytes).
|
||||||
|
//
|
||||||
|
// n varies the number of comma-separated entries in a single XFF header.
|
||||||
|
// All entries are valid IPs of identical length, so total header size
|
||||||
|
// scales linearly with n.
|
||||||
|
func BenchmarkWalkXFF(b *testing.B) {
|
||||||
|
for _, n := range []int{1, 10, 100, 1000, 10000} {
|
||||||
|
entries := make([]string, n)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = "1.2.3.4"
|
||||||
|
}
|
||||||
|
headers := []string{strings.Join(entries, ", ")}
|
||||||
|
|
||||||
|
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
walkXFF(headers, func(entry string) bool {
|
||||||
|
return false // walk to the leftmost entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkWalkXFF_RightmostStop measures the common case for both
|
||||||
|
// ClientIPFromXFF (no trusted prefixes => stop at the first / rightmost
|
||||||
|
// entry) and ClientIPFromXFFTrustedProxies(1). Should be near-constant
|
||||||
|
// regardless of n, since the walker stops after the first visit.
|
||||||
|
func BenchmarkWalkXFF_RightmostStop(b *testing.B) {
|
||||||
|
for _, n := range []int{1, 10, 100, 1000, 10000} {
|
||||||
|
entries := make([]string, n)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = "1.2.3.4"
|
||||||
|
}
|
||||||
|
headers := []string{strings.Join(entries, ", ")}
|
||||||
|
|
||||||
|
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
walkXFF(headers, func(entry string) bool {
|
||||||
|
return true // stop at the rightmost entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
108
middleware/client_ip_example_test.go
Normal file
108
middleware/client_ip_example_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Example_clientIP shows how to choose a ClientIPFrom* middleware. There is
|
||||||
|
// no safe default — pick exactly ONE based on your network setup:
|
||||||
|
//
|
||||||
|
// - [middleware.ClientIPFromRemoteAddr] — this server is directly on the
|
||||||
|
// public internet, with no reverse proxy in front.
|
||||||
|
//
|
||||||
|
// - [middleware.ClientIPFromHeader] — your reverse proxy sets a dedicated
|
||||||
|
// single-IP header (X-Real-IP, CF-Connecting-IP, X-Client-IP) and
|
||||||
|
// overwrites any client-supplied value for every request.
|
||||||
|
//
|
||||||
|
// - [middleware.ClientIPFromXFF] — you sit behind one or more reverse
|
||||||
|
// proxies whose IP ranges you can enumerate (your VPC CIDR, Cloudflare's
|
||||||
|
// published IP list, etc.).
|
||||||
|
//
|
||||||
|
// - [middleware.ClientIPFromXFFTrustedProxies] — you sit behind a known,
|
||||||
|
// fixed number of reverse proxies whose IPs are dynamic (autoscaling
|
||||||
|
// pools, ephemeral containers).
|
||||||
|
//
|
||||||
|
// Read the resulting IP with [middleware.GetClientIP] (string) or
|
||||||
|
// [middleware.GetClientIPAddr] ([net/netip.Addr]). These middlewares never
|
||||||
|
// mutate [net/http.Request.RemoteAddr].
|
||||||
|
//
|
||||||
|
// The legacy [middleware.RealIP] middleware is deprecated; it is vulnerable
|
||||||
|
// to IP spoofing (GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf).
|
||||||
|
//
|
||||||
|
// Background:
|
||||||
|
// https://github.com/go-chi/chi/pull/967
|
||||||
|
// https://adam-p.ca/blog/2022/03/x-forwarded-for/
|
||||||
|
func Example_clientIP() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Pick ONE; the others are shown commented for reference.
|
||||||
|
|
||||||
|
// (1) Behind a reverse proxy that sets a single-IP header for every
|
||||||
|
// request and overwrites any client-supplied value (Cloudflare, Nginx
|
||||||
|
// with ngx_http_realip_module, Apache with mod_remoteip, ...).
|
||||||
|
r.Use(middleware.ClientIPFromHeader("CF-Connecting-IP"))
|
||||||
|
|
||||||
|
// (2) Behind reverse proxies whose IP ranges you can enumerate as CIDRs.
|
||||||
|
// The middleware walks X-Forwarded-For right-to-left, skipping trusted
|
||||||
|
// entries; fail-closed on garbage.
|
||||||
|
//
|
||||||
|
// r.Use(middleware.ClientIPFromXFF(
|
||||||
|
// "13.32.0.0/15", // CloudFront IPv4.
|
||||||
|
// "2600:9000::/28", // CloudFront IPv6.
|
||||||
|
// ))
|
||||||
|
|
||||||
|
// (3) Behind a known number of trusted reverse proxies whose IPs are
|
||||||
|
// dynamic.
|
||||||
|
//
|
||||||
|
// r.Use(middleware.ClientIPFromXFFTrustedProxies(2))
|
||||||
|
|
||||||
|
// (4) Directly on the public internet, no reverse proxy in front.
|
||||||
|
//
|
||||||
|
// r.Use(middleware.ClientIPFromRemoteAddr)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, middleware.GetClientIP(r.Context()))
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("CF-Connecting-IP", "198.51.100.42")
|
||||||
|
req.Header.Set("X-Forwarded-For", "1.2.3.4") // attacker-supplied; ignored.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
fmt.Print(w.Body.String())
|
||||||
|
// Output: 198.51.100.42
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_clientIPFromXFFTrustedProxies shows that with two proxies the
|
||||||
|
// client sits at XFF[len-2] and any client-prepended entries to the left of
|
||||||
|
// that position are ignored.
|
||||||
|
func Example_clientIPFromXFFTrustedProxies() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.ClientIPFromXFFTrustedProxies(2)) // P1 (inner) + P2 (outer).
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, middleware.GetClientIP(r.Context()))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each chain ends in "<client>, <P1>"; P2's address is in RemoteAddr,
|
||||||
|
// not in X-Forwarded-For. Leading entries are client-controlled.
|
||||||
|
for _, xff := range []string{
|
||||||
|
"109.81.118.93, 3.172.119.73",
|
||||||
|
"8.8.8.8, 109.81.118.93, 3.172.119.73",
|
||||||
|
"8.8.8.8, 8.8.8.8, 8.8.8.8, 109.81.118.93, 3.172.119.73",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", xff)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
fmt.Print(w.Body.String())
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// 109.81.118.93
|
||||||
|
// 109.81.118.93
|
||||||
|
// 109.81.118.93
|
||||||
|
}
|
||||||
737
middleware/client_ip_test.go
Normal file
737
middleware/client_ip_test.go
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIPFromHeader(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"ipv4", "100.100.100.100", "100.100.100.100"},
|
||||||
|
{"ipv6_canonical", "2345:425:2ca1::567:5673:23b5", "2345:425:2ca1::567:5673:23b5"},
|
||||||
|
{"ipv6_uncompressed_normalized", "2345:0425:2CA1:0000:0000:0567:5673:23B5", "2345:425:2ca1::567:5673:23b5"},
|
||||||
|
|
||||||
|
// netip.ParseAddr rejects non-IPs and embedded ports.
|
||||||
|
{"invalid", "invalid", ""},
|
||||||
|
{"ipv4_with_port", "100.100.100.100:80", ""},
|
||||||
|
{"multiple_ips", "100.100.100.100,200.200.200.200", ""},
|
||||||
|
|
||||||
|
// Per the blog and the three GHSAs: we trust the user's choice of header.
|
||||||
|
// If their infra writes a private/loopback IP here, that IS the answer.
|
||||||
|
{"loopback_accepted", "127.0.0.1", "127.0.0.1"},
|
||||||
|
{"private_v4_accepted", "10.0.1.10", "10.0.1.10"},
|
||||||
|
{"private_v6_accepted", "fc00::1", "fc00::1"},
|
||||||
|
{"unspecified_accepted", "0.0.0.0", "0.0.0.0"},
|
||||||
|
|
||||||
|
// Normalization: v4-mapped IPv6 folds to plain v4, IPv6 zone is stripped.
|
||||||
|
// Same logical client → same canonical string regardless of upstream notation.
|
||||||
|
{"v4_mapped_ipv6_folded_to_v4", "::ffff:1.2.3.4", "1.2.3.4"},
|
||||||
|
{"ipv6_zone_stripped", "2001:db8::1%eth0", "2001:db8::1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
|
||||||
|
if tc.in != "" {
|
||||||
|
r.Header.Set("X-Real-IP", tc.in)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromXFF_NoTrustedPrefixes(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
xff []string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"missing", nil, ""},
|
||||||
|
{"empty", []string{""}, ""},
|
||||||
|
{"single", []string{"100.100.100.100"}, "100.100.100.100"},
|
||||||
|
{"comma_space", []string{"1.1.1.1, 2.2.2.2"}, "2.2.2.2"},
|
||||||
|
{"comma_no_space", []string{"1.1.1.1,2.2.2.2"}, "2.2.2.2"},
|
||||||
|
{"multi_header_merged", []string{"1.1.1.1", "2.2.2.2"}, "2.2.2.2"},
|
||||||
|
{"multi_header_with_commas", []string{"5.5.5.5, 6.6.6.6", "7.7.7.7, 4.4.4.4"}, "4.4.4.4"},
|
||||||
|
{"ipv6", []string{"2001:db8::1"}, "2001:db8::1"},
|
||||||
|
{"mixed_v4_v6_rightmost_wins", []string{"203.0.113.1, 2001:db8::1"}, "2001:db8::1"},
|
||||||
|
|
||||||
|
// Normalization at the entry point: v4-mapped IPv6 folds to v4, zone stripped.
|
||||||
|
{"v4_mapped_rightmost_folded", []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
|
||||||
|
{"zone_stripped_rightmost", []string{"2001:db8::1%eth0"}, "2001:db8::1"},
|
||||||
|
|
||||||
|
// A header like "oh, hi,,127.0.0.1,,,," can be injected by the client.
|
||||||
|
// The walker reaches 127.0.0.1 from the right before encountering the
|
||||||
|
// unparseable tokens, so fail-closed never trips here.
|
||||||
|
// See https://adam-p.ca/blog/2022/03/x-forwarded-for/ for more details.
|
||||||
|
{"weird_with_empties_then_valid_rightmost", []string{"oh, hi,,127.0.0.1,,,,"}, "127.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF(), func(r *http.Request) {
|
||||||
|
for _, v := range tc.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromXFF_TrustedPrefixes(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
prefixes []string
|
||||||
|
xff []string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single_trusted_proxy_skipped",
|
||||||
|
prefixes: []string{"203.0.113.0/24"},
|
||||||
|
xff: []string{"100.100.100.100, 203.0.113.50"},
|
||||||
|
out: "100.100.100.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_trusted_proxies_skipped",
|
||||||
|
prefixes: []string{"203.0.113.0/24", "198.51.100.0/24"},
|
||||||
|
xff: []string{"1.1.1.1, 203.0.113.10, 198.51.100.5"},
|
||||||
|
out: "1.1.1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all_trusted_returns_empty",
|
||||||
|
prefixes: []string{"203.0.113.0/24"},
|
||||||
|
xff: []string{"203.0.113.10, 203.0.113.20"},
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6_trusted_range",
|
||||||
|
prefixes: []string{"2606:4700::/32"},
|
||||||
|
xff: []string{"2001:db8::1, 2606:4700::1"},
|
||||||
|
out: "2001:db8::1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_v4_v6_trust_list",
|
||||||
|
prefixes: []string{"2606:4700::/32", "203.0.113.0/24"},
|
||||||
|
xff: []string{"8.8.8.8, 2606:4700::1, 203.0.113.5"},
|
||||||
|
out: "8.8.8.8",
|
||||||
|
},
|
||||||
|
// Adam-P's blog and rezmoss's advisory: we must NOT filter private/loopback
|
||||||
|
// values when they sit between trusted proxies. K8s nginx-ingress legitimately
|
||||||
|
// produces this shape.
|
||||||
|
{
|
||||||
|
name: "private_between_trusted_is_client",
|
||||||
|
prefixes: []string{"10.244.0.0/24"},
|
||||||
|
xff: []string{"10.244.1.50, 10.244.0.10"},
|
||||||
|
out: "10.244.1.50",
|
||||||
|
},
|
||||||
|
// /24 boundary tests (Saku0512's PR #1087).
|
||||||
|
{
|
||||||
|
name: "boundary_first_addr_in_prefix",
|
||||||
|
prefixes: []string{"203.0.113.0/24"},
|
||||||
|
xff: []string{"100.100.100.100, 203.0.113.0"},
|
||||||
|
out: "100.100.100.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boundary_last_addr_in_prefix",
|
||||||
|
prefixes: []string{"203.0.113.0/24"},
|
||||||
|
xff: []string{"100.100.100.100, 203.0.113.255"},
|
||||||
|
out: "100.100.100.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ip_just_outside_prefix_is_client",
|
||||||
|
prefixes: []string{"203.0.113.0/24"},
|
||||||
|
xff: []string{"203.0.114.1, 203.0.113.1"},
|
||||||
|
out: "203.0.114.1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
|
||||||
|
for _, v := range tc.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromXFF_PanicsOnBadPrefix(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("expected panic on invalid CIDR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ClientIPFromXFF("not-a-cidr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromXFFTrustedProxies(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
n int
|
||||||
|
xff []string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
// N=1 ⇒ rightmost (equivalent to ClientIPFromXFF() with no prefixes).
|
||||||
|
{"n1_rightmost", 1, []string{"1.1.1.1, 2.2.2.2"}, "2.2.2.2"},
|
||||||
|
{"n2_second_from_right", 2, []string{"1.1.1.1, 2.2.2.2, 3.3.3.3"}, "2.2.2.2"},
|
||||||
|
{"n3_third_from_right", 3, []string{"1.1.1.1, 2.2.2.2, 3.3.3.3, 4.4.4.4"}, "2.2.2.2"},
|
||||||
|
{"n2_exactly_matches_len", 2, []string{"1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
|
||||||
|
|
||||||
|
// XFF shorter than N: no IP set. This is intentionally fail-closed so a
|
||||||
|
// proxy-count mismatch doesn't silently fall through to attacker-controlled
|
||||||
|
// values. The index len(xff)-N is negative/out-of-range here; the walk
|
||||||
|
// must never panic or wrap around to an entry the client controls.
|
||||||
|
{"shorter_than_n", 3, []string{"1.1.1.1, 2.2.2.2"}, ""},
|
||||||
|
{"single_entry_n_two", 2, []string{"2.2.2.2"}, ""},
|
||||||
|
{"missing_header", 1, nil, ""},
|
||||||
|
|
||||||
|
// Spoofing: prepended attacker values are to the LEFT of the chosen
|
||||||
|
// position, so they're ignored.
|
||||||
|
{"spoof_prepend_ignored", 2, []string{"6.6.6.6, 1.1.1.1, 2.2.2.2, 3.3.3.3"}, "2.2.2.2"},
|
||||||
|
|
||||||
|
// Bad parse at the target position: no IP set (don't accept garbage as client IP).
|
||||||
|
{"bad_parse_at_position", 2, []string{"garbage, 2.2.2.2"}, ""},
|
||||||
|
|
||||||
|
// Merging across multiple header instances.
|
||||||
|
{"multi_header", 2, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}, "2.2.2.2"},
|
||||||
|
|
||||||
|
// Empty entries dropped by the walker must not shift the chosen
|
||||||
|
// position. The position is computed from the right, where trusted
|
||||||
|
// proxies append, so an attacker prepending commas can't slide their
|
||||||
|
// chosen value into the trusted position.
|
||||||
|
{"empties_dont_shift_position", 2, []string{",,, 3.3.3.3, 1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
|
||||||
|
|
||||||
|
// Normalization at the chosen position: v4-mapped IPv6 folds to v4, zone stripped.
|
||||||
|
{"v4_mapped_at_position_folded", 1, []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
|
||||||
|
{"zone_stripped_at_position", 1, []string{"2001:db8::1%eth0"}, "2001:db8::1"},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFFTrustedProxies(tc.n), func(r *http.Request) {
|
||||||
|
for _, v := range tc.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromXFFTrustedProxies_PanicsOnZero(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("expected panic on numTrustedProxies < 1")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ClientIPFromXFFTrustedProxies(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFromRemoteAddr(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"ipv4_with_port", "192.0.2.1:1234", "192.0.2.1"},
|
||||||
|
{"ipv6_with_port", "[2001:db8::1]:1234", "2001:db8::1"},
|
||||||
|
{"bare_ipv4", "192.0.2.1", "192.0.2.1"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"garbage", "not-an-ip", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromRemoteAddr, func(r *http.Request) {
|
||||||
|
r.RemoteAddr = tc.remoteAddr
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientIPLastWriteWins locks down the implementation property that each
|
||||||
|
// ClientIPFrom* middleware unconditionally overwrites the context value when
|
||||||
|
// it finds a valid IP, while leaving any previously stored value alone when
|
||||||
|
// it doesn't. Application code should pick exactly one ClientIPFrom*
|
||||||
|
// middleware; this test only guards against an accidental return to
|
||||||
|
// first-write-wins semantics.
|
||||||
|
func TestClientIPLastWriteWins(t *testing.T) {
|
||||||
|
t.Run("later_overrides_earlier", func(t *testing.T) {
|
||||||
|
got := runChain(t,
|
||||||
|
[]func(http.Handler) http.Handler{
|
||||||
|
ClientIPFromXFF(),
|
||||||
|
ClientIPFromHeader("CF-Connecting-IP"),
|
||||||
|
},
|
||||||
|
func(r *http.Request) {
|
||||||
|
r.Header.Set("CF-Connecting-IP", "1.1.1.1")
|
||||||
|
r.Header.Set("X-Forwarded-For", "2.2.2.2")
|
||||||
|
})
|
||||||
|
if got != "1.1.1.1" {
|
||||||
|
t.Errorf("want 1.1.1.1, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("earlier_persists_when_later_finds_nothing", func(t *testing.T) {
|
||||||
|
got := runChain(t,
|
||||||
|
[]func(http.Handler) http.Handler{
|
||||||
|
ClientIPFromXFF(),
|
||||||
|
ClientIPFromHeader("CF-Connecting-IP"), // missing → no-op.
|
||||||
|
},
|
||||||
|
func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", "8.8.8.8")
|
||||||
|
})
|
||||||
|
if got != "8.8.8.8" {
|
||||||
|
t.Errorf("want 8.8.8.8, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("earlier_persists_when_later_xff_all_trusted", func(t *testing.T) {
|
||||||
|
got := runChain(t,
|
||||||
|
[]func(http.Handler) http.Handler{
|
||||||
|
ClientIPFromRemoteAddr,
|
||||||
|
ClientIPFromXFF("10.0.0.0/8"), // all trusted → no-op.
|
||||||
|
},
|
||||||
|
func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
|
||||||
|
r.RemoteAddr = "192.0.2.1:1234"
|
||||||
|
})
|
||||||
|
if got != "192.0.2.1" {
|
||||||
|
t.Errorf("want 192.0.2.1, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClientIPAddr_Unset(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
if got := GetClientIP(req.Context()); got != "" {
|
||||||
|
t.Errorf("GetClientIP on empty ctx: want %q, got %q", "", got)
|
||||||
|
}
|
||||||
|
if got := GetClientIPAddr(req.Context()); got.IsValid() {
|
||||||
|
t.Errorf("GetClientIPAddr on empty ctx: want zero, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClientIPAddr_RoundTrip(t *testing.T) {
|
||||||
|
var gotStr string
|
||||||
|
var gotAddr netip.Addr
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(ClientIPFromHeader("X-Real-IP"))
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotStr = GetClientIP(r.Context())
|
||||||
|
gotAddr = GetClientIPAddr(r.Context())
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.42")
|
||||||
|
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if gotStr != "203.0.113.42" {
|
||||||
|
t.Errorf("GetClientIP: want 203.0.113.42, got %q", gotStr)
|
||||||
|
}
|
||||||
|
if !gotAddr.IsValid() || gotAddr.String() != "203.0.113.42" {
|
||||||
|
t.Errorf("GetClientIPAddr: want 203.0.113.42, got %v", gotAddr)
|
||||||
|
}
|
||||||
|
if gotAddr.Is6() {
|
||||||
|
t.Errorf("GetClientIPAddr: want IPv4, got IPv6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GHSA-3fxj-6jh8-hvhx (Saku0512): an admin endpoint gates on the client IP,
|
||||||
|
// and an attacker prepends a spoofed IP to X-Forwarded-For. middleware.RealIP
|
||||||
|
// would set r.RemoteAddr from the leftmost XFF value, bypassing the gate.
|
||||||
|
// The new ClientIPFrom* middlewares defeat this attack in both deployment
|
||||||
|
// shapes the application might choose.
|
||||||
|
func TestAdvisory_GHSA_3fxj_6jh8_hvhx(t *testing.T) {
|
||||||
|
// (a) Server directly on the internet: use ClientIPFromRemoteAddr.
|
||||||
|
// XFF is never consulted; the spoofed admin IP is ignored.
|
||||||
|
t.Run("direct_internet_uses_remoteaddr", func(t *testing.T) {
|
||||||
|
var clientIP string
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(ClientIPFromRemoteAddr)
|
||||||
|
r.Get("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP = GetClientIP(r.Context())
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
req.RemoteAddr = "99.99.99.99:1234"
|
||||||
|
req.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") // attacker's spoof.
|
||||||
|
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if clientIP == "1.2.3.4" {
|
||||||
|
t.Fatal("VULNERABLE: returned attacker-supplied admin IP")
|
||||||
|
}
|
||||||
|
if clientIP != "99.99.99.99" {
|
||||||
|
t.Errorf("want 99.99.99.99 (real TCP peer), got %q", clientIP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// (b) Server behind a trusted proxy: use ClientIPFromXFF with the proxy's
|
||||||
|
// CIDR. In a real deployment the trusted proxy APPENDS the attacker's
|
||||||
|
// real IP, so by the time the request reaches us the rightmost-untrusted
|
||||||
|
// entry is the genuine attacker IP, never the prepended spoof.
|
||||||
|
t.Run("behind_proxy_uses_xff", func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF("10.0.0.0/8"), func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 99.99.99.99")
|
||||||
|
})
|
||||||
|
if got == "1.2.3.4" {
|
||||||
|
t.Fatal("VULNERABLE: returned attacker-supplied admin IP")
|
||||||
|
}
|
||||||
|
if got != "99.99.99.99" {
|
||||||
|
t.Errorf("want 99.99.99.99 (rightmost-untrusted), got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GHSA-9g5q-2w5x-hmxf (convto): RealIP set r.RemoteAddr from the leftmost XFF
|
||||||
|
// IP. With ClientIPFromXFF the rightmost-untrusted IP is selected, and we
|
||||||
|
// never mutate r.RemoteAddr.
|
||||||
|
func TestAdvisory_GHSA_9g5q_2w5x_hmxf(t *testing.T) {
|
||||||
|
originalRemoteAddr := "203.0.113.99:1234"
|
||||||
|
|
||||||
|
var capturedClientIP, capturedRemoteAddr string
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(ClientIPFromXFF())
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedClientIP = GetClientIP(r.Context())
|
||||||
|
capturedRemoteAddr = r.RemoteAddr
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", "192.0.2.2, 192.0.2.1") // convto's PoC values.
|
||||||
|
req.RemoteAddr = originalRemoteAddr
|
||||||
|
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
// The rightmost value is the one added by our (single) trusted hop.
|
||||||
|
if capturedClientIP != "192.0.2.1" {
|
||||||
|
t.Errorf("client IP: want 192.0.2.1 (rightmost), got %q", capturedClientIP)
|
||||||
|
}
|
||||||
|
if capturedClientIP == "192.0.2.2" {
|
||||||
|
t.Errorf("VULNERABLE: returned attacker-supplied leftmost IP")
|
||||||
|
}
|
||||||
|
// Critical: r.RemoteAddr is never mutated, unlike middleware.RealIP.
|
||||||
|
if capturedRemoteAddr != originalRemoteAddr {
|
||||||
|
t.Errorf("r.RemoteAddr was mutated: want %q, got %q", originalRemoteAddr, capturedRemoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GHSA-rjr7-jggh-pgcp (rezmoss): RealIP trusted X-Real-IP / True-Client-IP /
|
||||||
|
// X-Forwarded-For unconditionally and used the leftmost XFF value, allowing
|
||||||
|
// "X-Forwarded-For: 127.0.0.1" to impersonate loopback. The new middleware
|
||||||
|
// (a) requires the user to opt into a specific source and (b) uses
|
||||||
|
// rightmost-untrusted for XFF.
|
||||||
|
func TestAdvisory_GHSA_rjr7_jggh_pgcp(t *testing.T) {
|
||||||
|
// Attacker sends "X-Forwarded-For: 127.0.0.1" to spoof loopback. In a real
|
||||||
|
// deployment the trusted proxy appends the attacker's actual IP, so the
|
||||||
|
// rightmost-untrusted entry is never the loopback spoof.
|
||||||
|
t.Run("xff_loopback_spoof_rejected", func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF("10.0.0.0/8"), func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", "127.0.0.1, 99.99.99.99")
|
||||||
|
})
|
||||||
|
if got == "127.0.0.1" {
|
||||||
|
t.Fatal("VULNERABLE: returned attacker-supplied loopback")
|
||||||
|
}
|
||||||
|
if got != "99.99.99.99" {
|
||||||
|
t.Errorf("want 99.99.99.99 (rightmost-untrusted), got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unlike RealIP, ClientIPFromHeader reads ONLY the header the user opted
|
||||||
|
// into. Attacker-supplied True-Client-IP / X-Forwarded-For are ignored.
|
||||||
|
t.Run("only_opted_in_header_is_read", func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
|
||||||
|
r.Header.Set("True-Client-IP", "1.1.1.1") // attacker-supplied; ignored.
|
||||||
|
r.Header.Set("X-Forwarded-For", "2.2.2.2") // attacker-supplied; ignored.
|
||||||
|
r.Header.Set("X-Real-IP", "203.0.113.7") // set by the trusted proxy.
|
||||||
|
})
|
||||||
|
if got != "203.0.113.7" {
|
||||||
|
t.Errorf("want 203.0.113.7 (the only trusted header), got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Multiple XFF headers" attack — Go's http.Header.Get returns only the first
|
||||||
|
// value, but the security-relevant rightmost IP is in the LAST instance after
|
||||||
|
// the proxy appends. We must merge all values; otherwise an attacker can pick
|
||||||
|
// which header the server sees by sending a duplicate.
|
||||||
|
//
|
||||||
|
// See https://adam-p.ca/blog/2022/03/x-forwarded-for/ "Multiple headers".
|
||||||
|
func TestXFF_MultipleHeadersMerged(t *testing.T) {
|
||||||
|
// Attacker sends two XFF headers; the trusted proxy appends to the second
|
||||||
|
// one (per RFC 2616). The genuine client is at the right of the merged
|
||||||
|
// chain. A naive http.Header.Get() implementation would only see the first
|
||||||
|
// header and return the attacker's prepended value.
|
||||||
|
got := run(t, ClientIPFromXFF("198.51.100.0/24"), func(r *http.Request) {
|
||||||
|
r.Header.Add("X-Forwarded-For", "127.0.0.1") // attacker-injected.
|
||||||
|
r.Header.Add("X-Forwarded-For", "8.8.8.8, 198.51.100.1") // appended by trusted proxy.
|
||||||
|
})
|
||||||
|
if got != "8.8.8.8" {
|
||||||
|
t.Errorf("want 8.8.8.8, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientIPFromHeader_MultiValueLastWins is a regression pin for the
|
||||||
|
// defense-in-depth gap fixed in a8e3dbf. The pre-fix implementation read
|
||||||
|
// the trusted single-IP header with r.Header.Get(), which returns only
|
||||||
|
// the first header value Go saw on the wire — so a misconfigured proxy
|
||||||
|
// that appended (instead of overwriting) the trusted header, or any
|
||||||
|
// chain where another proxy already set it, could surface an attacker-
|
||||||
|
// controlled values[0].
|
||||||
|
//
|
||||||
|
// Pinned post-fix contract: ClientIPFromHeader reads r.Header.Values()
|
||||||
|
// and uses the LAST entry (closest hop = most trusted, same
|
||||||
|
// rightmost-untrusted spirit as ClientIPFromXFF). Fail-closed on garbage
|
||||||
|
// at the last position; we do NOT fall back to earlier values — those
|
||||||
|
// came from hops further from us and are less trustworthy by definition.
|
||||||
|
func TestClientIPFromHeader_MultiValueLastWins(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
values []string // appended in order via Header.Add
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
// Baseline: single value (the well-configured case) still works.
|
||||||
|
{"single_value_unchanged", []string{"100.1.1.1"}, "100.1.1.1"},
|
||||||
|
|
||||||
|
// Attack: attacker sets X-Real-IP, then proxy appends (instead of
|
||||||
|
// overwriting) its own value. Last value (the proxy's) must win.
|
||||||
|
{"attacker_then_proxy", []string{"6.6.6.6", "100.2.2.2"}, "100.2.2.2"},
|
||||||
|
|
||||||
|
// Three hops; only the last is trusted.
|
||||||
|
{"three_values_last_wins", []string{"6.6.6.6", "5.5.5.5", "100.3.3.3"}, "100.3.3.3"},
|
||||||
|
|
||||||
|
// Fail-closed: last value is garbage. We do NOT fall back to the
|
||||||
|
// previous value — that value was set by someone further from us in
|
||||||
|
// the chain and is by definition less trustworthy than what our
|
||||||
|
// nearest hop produced (even if what they produced is garbage).
|
||||||
|
{"last_unparseable_no_fallback", []string{"100.4.4.4", "garbage"}, ""},
|
||||||
|
|
||||||
|
// Fail-closed: last value is empty. Same rationale.
|
||||||
|
{"last_empty_no_fallback", []string{"100.5.5.5", ""}, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
|
||||||
|
for _, v := range tc.values {
|
||||||
|
r.Header.Add("X-Real-IP", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("VULNERABLE or wrong: want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestXFF_V4MappedIPv6BypassesTrustedV4Prefix demonstrates a gap in the
|
||||||
|
// rightmost-untrusted walker: netip.Prefix.Contains returns false when an
|
||||||
|
// IPv4-mapped IPv6 address (::ffff:a.b.c.d) is checked against a plain v4
|
||||||
|
// prefix. So an attacker who can inject the v4-mapped form of an internal
|
||||||
|
// address into XFF — and whose own connecting IP happens to be in the
|
||||||
|
// trusted range (e.g. a compromised host inside an internal VPC) — gets a
|
||||||
|
// trusted-looking value returned as "the client IP". Any downstream code
|
||||||
|
// that treats internal IPs as privileged (rate-limit exemptions, internal
|
||||||
|
// admin allowlists, audit pipelines) then mis-classifies the request.
|
||||||
|
//
|
||||||
|
// Pinned post-fix behavior: parseHeaderAddr calls Unmap() before the prefix
|
||||||
|
// check, so ::ffff:10.0.0.5 is recognized as 10.0.0.5 and skipped along with
|
||||||
|
// the rest of the trusted chain — leaving no untrusted IP and an empty
|
||||||
|
// GetClientIP.
|
||||||
|
func TestXFF_V4MappedIPv6BypassesTrustedV4Prefix(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
prefixes []string
|
||||||
|
xff string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "internal_address_via_v4_mapped",
|
||||||
|
prefixes: []string{"10.0.0.0/8"},
|
||||||
|
xff: "::ffff:10.0.0.5, 10.0.0.1",
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loopback_via_v4_mapped",
|
||||||
|
prefixes: []string{"127.0.0.0/8", "10.0.0.0/8"},
|
||||||
|
xff: "::ffff:127.0.0.1, 10.0.0.1",
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("VULNERABLE: v4-mapped IPv6 escaped v4 trusted prefix — want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestXFF_IPv6ZoneIDBypassesTrustedV6Prefix demonstrates the same shape of
|
||||||
|
// gap for IPv6 trust prefixes: netip.Prefix.Contains returns false for any
|
||||||
|
// zoned IPv6 address, so an attacker-injected zone suffix escapes the trust
|
||||||
|
// check and the value is returned as "the client IP". Zone IDs are
|
||||||
|
// link-local concepts; they have no meaning on the wire and should never
|
||||||
|
// appear in a real XFF chain.
|
||||||
|
//
|
||||||
|
// Pinned post-fix behavior: parseHeaderAddr strips the zone before the
|
||||||
|
// prefix check, so 2606:4700::1%attacker is recognized as in 2606:4700::/32
|
||||||
|
// and skipped.
|
||||||
|
func TestXFF_IPv6ZoneIDBypassesTrustedV6Prefix(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF("2606:4700::/32"), func(r *http.Request) {
|
||||||
|
r.Header.Set("X-Forwarded-For", "2606:4700::1%attacker, 2606:4700::5")
|
||||||
|
})
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("VULNERABLE: zoned IPv6 escaped v6 trusted prefix — want %q, got %q", "", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientIPFromRemoteAddr_V4MappedIsUnmapped pins the desired
|
||||||
|
// normalization for v4 connections accepted on a dual-stack listener:
|
||||||
|
// Go's net/http surfaces those as ::ffff:a.b.c.d in r.RemoteAddr, and
|
||||||
|
// returning them in that v6 form splits one logical client across two
|
||||||
|
// rate-limit buckets, log keys, and prefix-check buckets depending on
|
||||||
|
// listener configuration. Pinned post-fix behavior: ClientIPFromRemoteAddr
|
||||||
|
// calls Unmap() before storing.
|
||||||
|
func TestClientIPFromRemoteAddr_V4MappedIsUnmapped(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"v4_mapped_with_port", "[::ffff:1.2.3.4]:1234", "1.2.3.4"},
|
||||||
|
{"v4_mapped_bare", "::ffff:1.2.3.4", "1.2.3.4"},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromRemoteAddr, func(r *http.Request) {
|
||||||
|
r.RemoteAddr = tc.remoteAddr
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q (unmapped v4 form), got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestXFF_FailClosedOnUnparseable pins the fail-closed contract for the
|
||||||
|
// rightmost-untrusted XFF walk (PR #967 review issue #6, adam-p): if the
|
||||||
|
// walker encounters an entry that does not parse as an IP, it MUST abandon
|
||||||
|
// the walk and leave no client IP set. Rightmost-untrusted relies on being
|
||||||
|
// able to read every hop to the right of the client; an unparseable hop is
|
||||||
|
// indistinguishable from a hostile or missing one, so we cannot safely keep
|
||||||
|
// walking past it.
|
||||||
|
//
|
||||||
|
// Empty / whitespace-only entries are NOT parse failures — they are dropped
|
||||||
|
// by trim before parsing, and do not trip fail-closed.
|
||||||
|
func TestXFF_FailClosedOnUnparseable(t *testing.T) {
|
||||||
|
tt := []struct {
|
||||||
|
name string
|
||||||
|
prefixes []string
|
||||||
|
xff []string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
// Rightmost entry is garbage — walker fails closed before reaching the
|
||||||
|
// otherwise-valid 1.1.1.1 to the left. Without fail-closed, garbage
|
||||||
|
// to the right of a real client could be silently elided.
|
||||||
|
{
|
||||||
|
name: "garbage_rightmost_no_prefixes",
|
||||||
|
xff: []string{"1.1.1.1, garbage"},
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
// Garbage between the client and a trusted proxy hop. Without fail-
|
||||||
|
// closed, the walker would skip the trusted hop, silently skip the
|
||||||
|
// garbage, and return 203.0.113.7 — a spoofable result.
|
||||||
|
{
|
||||||
|
name: "garbage_between_client_and_trusted_proxy",
|
||||||
|
prefixes: []string{"10.0.0.0/8"},
|
||||||
|
xff: []string{"203.0.113.7, garbage, 10.0.0.1"},
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
// Garbage past a multi-hop trusted chain. Same shape with more
|
||||||
|
// trusted hops in front; behaviour must be identical.
|
||||||
|
{
|
||||||
|
name: "garbage_past_trusted_chain",
|
||||||
|
prefixes: []string{"10.0.0.0/8"},
|
||||||
|
xff: []string{"203.0.113.7, garbage, 10.0.0.1, 10.0.0.2"},
|
||||||
|
out: "",
|
||||||
|
},
|
||||||
|
// Negative case: garbage sits in a left-hand header that the walker
|
||||||
|
// never reaches because it returns at a valid untrusted entry first.
|
||||||
|
// Fail-closed must NOT over-trigger here.
|
||||||
|
{
|
||||||
|
name: "garbage_in_unreachable_left_header",
|
||||||
|
prefixes: []string{"10.0.0.0/8"},
|
||||||
|
xff: []string{"garbage", "203.0.113.7, 10.0.0.1"},
|
||||||
|
out: "203.0.113.7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tt {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
|
||||||
|
for _, v := range tc.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("want %q, got %q", tc.out, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run invokes mw with the request constructed by buildReq and returns the
|
||||||
|
// client IP captured inside the handler (or "" if none was set).
|
||||||
|
func run(t *testing.T, mw func(http.Handler) http.Handler, buildReq func(*http.Request)) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
buildReq(req)
|
||||||
|
|
||||||
|
var got string
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(mw)
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = GetClientIP(r.Context())
|
||||||
|
})
|
||||||
|
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// runChain is like run but applies multiple middlewares in order.
|
||||||
|
func runChain(t *testing.T, mws []func(http.Handler) http.Handler, buildReq func(*http.Request)) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
buildReq(req)
|
||||||
|
|
||||||
|
var got string
|
||||||
|
r := chi.NewRouter()
|
||||||
|
for _, mw := range mws {
|
||||||
|
r.Use(mw)
|
||||||
|
}
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = GetClientIP(r.Context())
|
||||||
|
})
|
||||||
|
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
return got
|
||||||
|
}
|
||||||
412
middleware/compress.go
Normal file
412
middleware/compress.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"compress/flate"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultCompressibleContentTypes = []string{
|
||||||
|
"text/html",
|
||||||
|
"text/css",
|
||||||
|
"text/plain",
|
||||||
|
"text/javascript",
|
||||||
|
"text/markdown",
|
||||||
|
"text/csv",
|
||||||
|
"text/vtt",
|
||||||
|
"application/javascript",
|
||||||
|
"application/x-javascript",
|
||||||
|
"application/json",
|
||||||
|
"application/atom+xml",
|
||||||
|
"application/rss+xml",
|
||||||
|
"application/xml",
|
||||||
|
"text/xml",
|
||||||
|
"image/svg+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress is a middleware that compresses response
|
||||||
|
// body of a given content types to a data format based
|
||||||
|
// on Accept-Encoding request header. It uses a given
|
||||||
|
// compression level.
|
||||||
|
//
|
||||||
|
// NOTE: make sure to set the Content-Type header on your response
|
||||||
|
// otherwise this middleware will not compress the response body. For ex, in
|
||||||
|
// your handler you should set w.Header().Set("Content-Type", http.DetectContentType(yourBody))
|
||||||
|
// or set it manually.
|
||||||
|
//
|
||||||
|
// Passing a compression level of 5 is sensible value
|
||||||
|
func Compress(level int, types ...string) func(next http.Handler) http.Handler {
|
||||||
|
compressor := NewCompressor(level, types...)
|
||||||
|
return compressor.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compressor represents a set of encoding configurations.
|
||||||
|
type Compressor struct {
|
||||||
|
// The mapping of encoder names to encoder functions.
|
||||||
|
encoders map[string]EncoderFunc
|
||||||
|
// The mapping of pooled encoders to pools.
|
||||||
|
pooledEncoders map[string]*sync.Pool
|
||||||
|
// The set of content types allowed to be compressed.
|
||||||
|
allowedTypes map[string]struct{}
|
||||||
|
allowedWildcards map[string]struct{}
|
||||||
|
// The list of encoders in order of decreasing precedence.
|
||||||
|
encodingPrecedence []string
|
||||||
|
level int // The compression level.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCompressor creates a new Compressor that will handle encoding responses.
|
||||||
|
//
|
||||||
|
// The level should be one of the ones defined in the flate package.
|
||||||
|
// The types are the content types that are allowed to be compressed.
|
||||||
|
//
|
||||||
|
// Catch-all wildcards ("*/*", "/*") are rejected: compressing every response
|
||||||
|
// wastes CPU on already-compressed types like zip, jpeg or png. Pass explicit
|
||||||
|
// types instead, e.g. "text/html" or "application/*".
|
||||||
|
func NewCompressor(level int, types ...string) *Compressor {
|
||||||
|
// If types are provided, set those as the allowed types. If none are
|
||||||
|
// provided, use the default list.
|
||||||
|
allowedTypes := make(map[string]struct{})
|
||||||
|
allowedWildcards := make(map[string]struct{})
|
||||||
|
if len(types) > 0 {
|
||||||
|
for _, t := range types {
|
||||||
|
if strings.Contains(strings.TrimSuffix(t, "/*"), "*") {
|
||||||
|
panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '<type>/*' supported", t))
|
||||||
|
}
|
||||||
|
if before, ok := strings.CutSuffix(t, "/*"); ok {
|
||||||
|
if before == "" {
|
||||||
|
panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '<type>/*' supported", t))
|
||||||
|
}
|
||||||
|
allowedWildcards[before] = struct{}{}
|
||||||
|
} else {
|
||||||
|
allowedTypes[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, t := range defaultCompressibleContentTypes {
|
||||||
|
allowedTypes[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Compressor{
|
||||||
|
level: level,
|
||||||
|
encoders: make(map[string]EncoderFunc),
|
||||||
|
pooledEncoders: make(map[string]*sync.Pool),
|
||||||
|
allowedTypes: allowedTypes,
|
||||||
|
allowedWildcards: allowedWildcards,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the default encoders. The precedence order uses the reverse
|
||||||
|
// ordering that the encoders were added. This means adding new encoders
|
||||||
|
// will move them to the front of the order.
|
||||||
|
//
|
||||||
|
// TODO:
|
||||||
|
// lzma: Opera.
|
||||||
|
// sdch: Chrome, Android. Gzip output + dictionary header.
|
||||||
|
// br: Brotli, see https://github.com/go-chi/chi/pull/326
|
||||||
|
|
||||||
|
// HTTP 1.1 "deflate" (RFC 2616) stands for DEFLATE data (RFC 1951)
|
||||||
|
// wrapped with zlib (RFC 1950). The zlib wrapper uses Adler-32
|
||||||
|
// checksum compared to CRC-32 used in "gzip" and thus is faster.
|
||||||
|
//
|
||||||
|
// But.. some old browsers (MSIE, Safari 5.1) incorrectly expect
|
||||||
|
// raw DEFLATE data only, without the mentioned zlib wrapper.
|
||||||
|
// Because of this major confusion, most modern browsers try it
|
||||||
|
// both ways, first looking for zlib headers.
|
||||||
|
// Quote by Mark Adler: http://stackoverflow.com/a/9186091/385548
|
||||||
|
//
|
||||||
|
// The list of browsers having problems is quite big, see:
|
||||||
|
// http://zoompf.com/blog/2012/02/lose-the-wait-http-compression
|
||||||
|
// https://web.archive.org/web/20120321182910/http://www.vervestudios.co/projects/compression-tests/results
|
||||||
|
//
|
||||||
|
// That's why we prefer gzip over deflate. It's just more reliable
|
||||||
|
// and not significantly slower than deflate.
|
||||||
|
c.SetEncoder("deflate", encoderDeflate)
|
||||||
|
|
||||||
|
// TODO: Exception for old MSIE browsers that can't handle non-HTML?
|
||||||
|
// https://zoompf.com/blog/2012/02/lose-the-wait-http-compression
|
||||||
|
c.SetEncoder("gzip", encoderGzip)
|
||||||
|
|
||||||
|
// NOTE: Not implemented, intentionally:
|
||||||
|
// case "compress": // LZW. Deprecated.
|
||||||
|
// case "bzip2": // Too slow on-the-fly.
|
||||||
|
// case "zopfli": // Too slow on-the-fly.
|
||||||
|
// case "xz": // Too slow on-the-fly.
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEncoder can be used to set the implementation of a compression algorithm.
|
||||||
|
//
|
||||||
|
// The encoding should be a standardised identifier. See:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding
|
||||||
|
//
|
||||||
|
// For example, add the Brotli algorithm:
|
||||||
|
//
|
||||||
|
// import "github.com/google/brotli/go/cbrotli"
|
||||||
|
//
|
||||||
|
// compressor := middleware.NewCompressor(5, "text/html")
|
||||||
|
// compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer {
|
||||||
|
// return cbrotli.NewWriter(w, cbrotli.WriterOptions{Quality: level})
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// Alternatively, this Cgo-free Brotli module can be used:
|
||||||
|
//
|
||||||
|
// import "github.com/andybalholm/brotli"
|
||||||
|
//
|
||||||
|
// compressor := middleware.NewCompressor(5, "text/html")
|
||||||
|
//
|
||||||
|
// compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer {
|
||||||
|
// return brotli.NewWriterV2(w, level)
|
||||||
|
// })
|
||||||
|
func (c *Compressor) SetEncoder(encoding string, fn EncoderFunc) {
|
||||||
|
encoding = strings.ToLower(encoding)
|
||||||
|
if encoding == "" {
|
||||||
|
panic("the encoding can not be empty")
|
||||||
|
}
|
||||||
|
if fn == nil {
|
||||||
|
panic("attempted to set a nil encoder function")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are adding a new encoder that is already registered, we have to
|
||||||
|
// clear that one out first.
|
||||||
|
delete(c.pooledEncoders, encoding)
|
||||||
|
delete(c.encoders, encoding)
|
||||||
|
|
||||||
|
// If the encoder supports Resetting (IoReseterWriter), then it can be pooled.
|
||||||
|
encoder := fn(io.Discard, c.level)
|
||||||
|
if _, ok := encoder.(ioResetterWriter); ok {
|
||||||
|
pool := &sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return fn(io.Discard, c.level)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.pooledEncoders[encoding] = pool
|
||||||
|
}
|
||||||
|
// If the encoder is not in the pooledEncoders, add it to the normal encoders.
|
||||||
|
if _, ok := c.pooledEncoders[encoding]; !ok {
|
||||||
|
c.encoders[encoding] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range c.encodingPrecedence {
|
||||||
|
if v == encoding {
|
||||||
|
c.encodingPrecedence = append(c.encodingPrecedence[:i], c.encodingPrecedence[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.encodingPrecedence = append([]string{encoding}, c.encodingPrecedence...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns a new middleware that will compress the response based on the
|
||||||
|
// current Compressor.
|
||||||
|
func (c *Compressor) Handler(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
encoder, encoding, cleanup := c.selectEncoder(r.Header, w)
|
||||||
|
|
||||||
|
cw := &compressResponseWriter{
|
||||||
|
ResponseWriter: w,
|
||||||
|
w: w,
|
||||||
|
contentTypes: c.allowedTypes,
|
||||||
|
contentWildcards: c.allowedWildcards,
|
||||||
|
encoding: encoding,
|
||||||
|
compressible: false, // determined in post-handler
|
||||||
|
}
|
||||||
|
if encoder != nil {
|
||||||
|
cw.w = encoder
|
||||||
|
}
|
||||||
|
// Re-add the encoder to the pool if applicable.
|
||||||
|
defer cleanup()
|
||||||
|
defer cw.Close()
|
||||||
|
|
||||||
|
next.ServeHTTP(cw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectEncoder returns the encoder, the name of the encoder, and a closer function.
|
||||||
|
func (c *Compressor) selectEncoder(h http.Header, w io.Writer) (io.Writer, string, func()) {
|
||||||
|
header := h.Get("Accept-Encoding")
|
||||||
|
|
||||||
|
// Parse the names of all accepted algorithms from the header.
|
||||||
|
accepted := strings.Split(strings.ToLower(header), ",")
|
||||||
|
|
||||||
|
// Find supported encoder by accepted list by precedence
|
||||||
|
for _, name := range c.encodingPrecedence {
|
||||||
|
if matchAcceptEncoding(accepted, name) {
|
||||||
|
if pool, ok := c.pooledEncoders[name]; ok {
|
||||||
|
encoder := pool.Get().(ioResetterWriter)
|
||||||
|
cleanup := func() {
|
||||||
|
pool.Put(encoder)
|
||||||
|
}
|
||||||
|
encoder.Reset(w)
|
||||||
|
return encoder, name, cleanup
|
||||||
|
|
||||||
|
}
|
||||||
|
if fn, ok := c.encoders[name]; ok {
|
||||||
|
return fn(w, c.level), name, func() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// No encoder found to match the accepted encoding
|
||||||
|
return nil, "", func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAcceptEncoding(accepted []string, encoding string) bool {
|
||||||
|
for _, v := range accepted {
|
||||||
|
if strings.Contains(v, encoding) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EncoderFunc is a function that wraps the provided io.Writer with a
|
||||||
|
// streaming compression algorithm and returns it.
|
||||||
|
//
|
||||||
|
// In case of failure, the function should return nil.
|
||||||
|
type EncoderFunc func(w io.Writer, level int) io.Writer
|
||||||
|
|
||||||
|
// Interface for types that allow resetting io.Writers.
|
||||||
|
type ioResetterWriter interface {
|
||||||
|
io.Writer
|
||||||
|
Reset(w io.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
type compressResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
|
||||||
|
// The streaming encoder writer to be used if there is one. Otherwise,
|
||||||
|
// this is just the normal writer.
|
||||||
|
w io.Writer
|
||||||
|
contentTypes map[string]struct{}
|
||||||
|
contentWildcards map[string]struct{}
|
||||||
|
encoding string
|
||||||
|
wroteHeader bool
|
||||||
|
compressible bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) isCompressible() bool {
|
||||||
|
// Parse the first part of the Content-Type response header.
|
||||||
|
contentType := cw.Header().Get("Content-Type")
|
||||||
|
contentType, _, _ = strings.Cut(contentType, ";")
|
||||||
|
|
||||||
|
// Is the content type compressible?
|
||||||
|
if _, ok := cw.contentTypes[contentType]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contentType, _, hadSlash := strings.Cut(contentType, "/"); hadSlash {
|
||||||
|
_, ok := cw.contentWildcards[contentType]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) WriteHeader(code int) {
|
||||||
|
if cw.wroteHeader {
|
||||||
|
cw.ResponseWriter.WriteHeader(code) // Allow multiple calls to propagate.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cw.wroteHeader = true
|
||||||
|
defer cw.ResponseWriter.WriteHeader(code)
|
||||||
|
|
||||||
|
// Already compressed data?
|
||||||
|
if cw.Header().Get("Content-Encoding") != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cw.isCompressible() {
|
||||||
|
cw.compressible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cw.encoding != "" {
|
||||||
|
cw.compressible = true
|
||||||
|
cw.Header().Set("Content-Encoding", cw.encoding)
|
||||||
|
cw.Header().Add("Vary", "Accept-Encoding")
|
||||||
|
|
||||||
|
// The content-length after compression is unknown
|
||||||
|
cw.Header().Del("Content-Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
if !cw.wroteHeader {
|
||||||
|
cw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cw.writer().Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) writer() io.Writer {
|
||||||
|
if cw.compressible {
|
||||||
|
return cw.w
|
||||||
|
}
|
||||||
|
return cw.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
type compressFlusher interface {
|
||||||
|
Flush() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Flush() {
|
||||||
|
if f, ok := cw.writer().(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
// If the underlying writer has a compression flush signature,
|
||||||
|
// call this Flush() method instead
|
||||||
|
if f, ok := cw.writer().(compressFlusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
|
||||||
|
// Also flush the underlying response writer
|
||||||
|
if f, ok := cw.ResponseWriter.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
if hj, ok := cw.writer().(http.Hijacker); ok {
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
return nil, nil, errors.New("chi/middleware: http.Hijacker is unavailable on the writer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||||
|
if ps, ok := cw.writer().(http.Pusher); ok {
|
||||||
|
return ps.Push(target, opts)
|
||||||
|
}
|
||||||
|
return errors.New("chi/middleware: http.Pusher is unavailable on the writer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Close() error {
|
||||||
|
if c, ok := cw.writer().(io.WriteCloser); ok {
|
||||||
|
return c.Close()
|
||||||
|
}
|
||||||
|
return errors.New("chi/middleware: io.WriteCloser is unavailable on the writer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *compressResponseWriter) Unwrap() http.ResponseWriter {
|
||||||
|
return cw.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func encoderGzip(w io.Writer, level int) io.Writer {
|
||||||
|
gw, err := gzip.NewWriterLevel(w, level)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return gw
|
||||||
|
}
|
||||||
|
|
||||||
|
func encoderDeflate(w io.Writer, level int) io.Writer {
|
||||||
|
dw, err := flate.NewWriter(w, level)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dw
|
||||||
|
}
|
||||||
251
middleware/compress_test.go
Normal file
251
middleware/compress_test.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/flate"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompressor(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
compressor := NewCompressor(5, "text/html", "text/css")
|
||||||
|
if len(compressor.encoders) != 0 || len(compressor.pooledEncoders) != 2 {
|
||||||
|
t.Errorf("gzip and deflate should be pooled")
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor.SetEncoder("nop", func(w io.Writer, _ int) io.Writer {
|
||||||
|
return w
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(compressor.encoders) != 1 {
|
||||||
|
t.Errorf("nop encoder should be stored in the encoders map")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Use(compressor.Handler)
|
||||||
|
|
||||||
|
r.Get("/gethtml", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte("textstring"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/getcss", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte("textstring"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/getplain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte("textstring"))
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expectedEncoding string
|
||||||
|
acceptedEncodings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no expected encodings due to no accepted encodings",
|
||||||
|
path: "/gethtml",
|
||||||
|
acceptedEncodings: nil,
|
||||||
|
expectedEncoding: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no expected encodings due to content type",
|
||||||
|
path: "/getplain",
|
||||||
|
acceptedEncodings: nil,
|
||||||
|
expectedEncoding: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gzip is only encoding",
|
||||||
|
path: "/gethtml",
|
||||||
|
acceptedEncodings: []string{"gzip"},
|
||||||
|
expectedEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gzip is preferred over deflate",
|
||||||
|
path: "/getcss",
|
||||||
|
acceptedEncodings: []string{"gzip", "deflate"},
|
||||||
|
expectedEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deflate is used",
|
||||||
|
path: "/getcss",
|
||||||
|
acceptedEncodings: []string{"deflate"},
|
||||||
|
expectedEncoding: "deflate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
|
||||||
|
name: "nop is preferred",
|
||||||
|
path: "/getcss",
|
||||||
|
acceptedEncodings: []string{"nop, gzip, deflate"},
|
||||||
|
expectedEncoding: "nop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, respString := testRequestWithAcceptedEncodings(t, ts, "GET", tc.path, tc.acceptedEncodings...)
|
||||||
|
if respString != "textstring" {
|
||||||
|
t.Errorf("response text doesn't match; expected:%q, got:%q", "textstring", respString)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Content-Encoding"); got != tc.expectedEncoding {
|
||||||
|
t.Errorf("expected encoding %q but got %q", tc.expectedEncoding, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompressorDefaultTextTypes(t *testing.T) {
|
||||||
|
for _, contentType := range []string{"text/markdown", "text/csv", "text/vtt"} {
|
||||||
|
t.Run(contentType, func(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(Compress(5))
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Write([]byte("textstring"))
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, body := testRequestWithAcceptedEncodings(t, ts, "GET", "/", "gzip")
|
||||||
|
if body != "textstring" {
|
||||||
|
t.Errorf("response text doesn't match; expected:%q, got:%q", "textstring", body)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
||||||
|
t.Errorf("expected encoding %q but got %q", "gzip", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompressorWildcards(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
recover string
|
||||||
|
types []string
|
||||||
|
typesCount int
|
||||||
|
wcCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "defaults",
|
||||||
|
typesCount: len(defaultCompressibleContentTypes),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no wildcard",
|
||||||
|
types: []string{"text/plain", "text/html"},
|
||||||
|
typesCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid wildcard #1",
|
||||||
|
types: []string{"audio/*wav"},
|
||||||
|
recover: "middleware/compress: Unsupported content-type wildcard pattern 'audio/*wav'. Only '<type>/*' supported",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid wildcard #2",
|
||||||
|
types: []string{"application*/*"},
|
||||||
|
recover: "middleware/compress: Unsupported content-type wildcard pattern 'application*/*'. Only '<type>/*' supported",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catch-all wildcard #1",
|
||||||
|
types: []string{"*/*"},
|
||||||
|
recover: "middleware/compress: Unsupported content-type wildcard pattern '*/*'. Only '<type>/*' supported",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catch-all wildcard #2",
|
||||||
|
types: []string{"/*"},
|
||||||
|
recover: "middleware/compress: Unsupported content-type wildcard pattern '/*'. Only '<type>/*' supported",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid wildcard",
|
||||||
|
types: []string{"text/*"},
|
||||||
|
wcCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed",
|
||||||
|
types: []string{"audio/wav", "text/*"},
|
||||||
|
typesCount: 1,
|
||||||
|
wcCount: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if tt.recover == "" {
|
||||||
|
tt.recover = "<nil>"
|
||||||
|
}
|
||||||
|
if r := recover(); tt.recover != fmt.Sprintf("%v", r) {
|
||||||
|
t.Errorf("Unexpected value recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
compressor := NewCompressor(5, tt.types...)
|
||||||
|
if len(compressor.allowedTypes) != tt.typesCount {
|
||||||
|
t.Errorf("expected %d allowedTypes, got %d", tt.typesCount, len(compressor.allowedTypes))
|
||||||
|
}
|
||||||
|
if len(compressor.allowedWildcards) != tt.wcCount {
|
||||||
|
t.Errorf("expected %d allowedWildcards, got %d", tt.wcCount, len(compressor.allowedWildcards))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequestWithAcceptedEncodings(t *testing.T, ts *httptest.Server, method, path string, encodings ...string) (*http.Response, string) {
|
||||||
|
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if len(encodings) > 0 {
|
||||||
|
encodingsString := strings.Join(encodings, ",")
|
||||||
|
req.Header.Set("Accept-Encoding", encodingsString)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody := decodeResponseBody(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp, respBody
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponseBody(t *testing.T, resp *http.Response) string {
|
||||||
|
var reader io.ReadCloser
|
||||||
|
switch resp.Header.Get("Content-Encoding") {
|
||||||
|
case "gzip":
|
||||||
|
var err error
|
||||||
|
reader, err = gzip.NewReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case "deflate":
|
||||||
|
reader = flate.NewReader(resp.Body)
|
||||||
|
default:
|
||||||
|
reader = resp.Body
|
||||||
|
}
|
||||||
|
respBody, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
return string(respBody)
|
||||||
|
}
|
||||||
51
middleware/content_charset.go
Normal file
51
middleware/content_charset.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentCharset generates a handler that writes a 415 Unsupported Media Type response if none of the charsets match.
|
||||||
|
// An empty charset will allow requests with no Content-Type header or no specified charset.
|
||||||
|
// Requests without a body (ContentLength == 0) are always allowed.
|
||||||
|
func ContentCharset(charsets ...string) func(next http.Handler) http.Handler {
|
||||||
|
for i, c := range charsets {
|
||||||
|
charsets[i] = strings.ToLower(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength == 0 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contentEncoding(r.Header.Get("Content-Type"), charsets...) {
|
||||||
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the content encoding against a list of acceptable values.
|
||||||
|
func contentEncoding(ce string, charsets ...string) bool {
|
||||||
|
_, ce = split(strings.ToLower(ce), ";")
|
||||||
|
_, ce = split(ce, "charset=")
|
||||||
|
ce, _ = split(ce, ";")
|
||||||
|
return slices.Contains(charsets, ce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a string in two parts, cleaning any whitespace.
|
||||||
|
func split(str, sep string) (string, string) {
|
||||||
|
a, b, found := strings.Cut(str, sep)
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if found {
|
||||||
|
b = strings.TrimSpace(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
159
middleware/content_charset_test.go
Normal file
159
middleware/content_charset_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentCharset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
name string
|
||||||
|
inputValue string
|
||||||
|
inputContentCharset []string
|
||||||
|
body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"should accept requests with a matching charset",
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should be case-insensitive",
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with a matching charset with extra values",
|
||||||
|
"application/json; foo=bar; charset=UTF-8; spam=eggs",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with a matching charset when multiple charsets are supported",
|
||||||
|
"text/xml; charset=UTF-8",
|
||||||
|
[]string{"UTF-8", "Latin-1"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with no charset if empty charset headers are allowed",
|
||||||
|
"text/xml",
|
||||||
|
[]string{"UTF-8", ""},
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should not accept requests with no charset if empty charset headers are not allowed",
|
||||||
|
"text/xml",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should not accept requests with a mismatching charset",
|
||||||
|
"text/plain; charset=Latin-1",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"foobar",
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should not accept requests with a mismatching charset even if empty charsets are allowed",
|
||||||
|
"text/plain; charset=Latin-1",
|
||||||
|
[]string{"UTF-8", ""},
|
||||||
|
"foobar",
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should skip validation for requests without a body",
|
||||||
|
"text/plain; charset=Latin-1",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should skip validation for requests without a body and no Content-Type header",
|
||||||
|
"",
|
||||||
|
[]string{"UTF-8"},
|
||||||
|
"",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
var tt = tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var recorder = httptest.NewRecorder()
|
||||||
|
|
||||||
|
var r = chi.NewRouter()
|
||||||
|
r.Use(ContentCharset(tt.inputContentCharset...))
|
||||||
|
r.Post("/", func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
|
||||||
|
var body io.Reader
|
||||||
|
if tt.body != "" {
|
||||||
|
body = strings.NewReader(tt.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req, _ = http.NewRequest("POST", "/", body)
|
||||||
|
if tt.inputValue != "" {
|
||||||
|
req.Header.Set("Content-Type", tt.inputValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ServeHTTP(recorder, req)
|
||||||
|
var res = recorder.Result()
|
||||||
|
|
||||||
|
if res.StatusCode != tt.want {
|
||||||
|
t.Errorf("response is incorrect, got %d, want %d", recorder.Code, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var s1, s2 = split(" type1;type2 ", ";")
|
||||||
|
|
||||||
|
if s1 != "type1" || s2 != "type2" {
|
||||||
|
t.Errorf("Want type1, type2 got %s, %s", s1, s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
s1, s2 = split("type1 ", ";")
|
||||||
|
|
||||||
|
if s1 != "type1" {
|
||||||
|
t.Errorf("Want \"type1\" got \"%s\"", s1)
|
||||||
|
}
|
||||||
|
if s2 != "" {
|
||||||
|
t.Errorf("Want empty string got \"%s\"", s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentEncoding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if !contentEncoding("application/json; foo=bar; charset=utf-8; spam=eggs", []string{"utf-8"}...) {
|
||||||
|
t.Error("Want true, got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentEncoding("text/plain; charset=latin-1", []string{"utf-8"}...) {
|
||||||
|
t.Error("Want false, got true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contentEncoding("text/xml; charset=UTF-8", []string{"latin-1", "utf-8"}...) {
|
||||||
|
t.Error("Want true, got false")
|
||||||
|
}
|
||||||
|
}
|
||||||
34
middleware/content_encoding.go
Normal file
34
middleware/content_encoding.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowContentEncoding enforces a whitelist of request Content-Encoding otherwise responds
|
||||||
|
// with a 415 Unsupported Media Type status.
|
||||||
|
func AllowContentEncoding(contentEncoding ...string) func(next http.Handler) http.Handler {
|
||||||
|
allowedEncodings := make(map[string]struct{}, len(contentEncoding))
|
||||||
|
for _, encoding := range contentEncoding {
|
||||||
|
allowedEncodings[strings.TrimSpace(strings.ToLower(encoding))] = struct{}{}
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestEncodings := r.Header["Content-Encoding"]
|
||||||
|
// skip check for empty content body or no Content-Encoding
|
||||||
|
if r.ContentLength == 0 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// All encodings in the request must be allowed
|
||||||
|
for _, encoding := range requestEncodings {
|
||||||
|
if _, ok := allowedEncodings[strings.TrimSpace(strings.ToLower(encoding))]; !ok {
|
||||||
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
middleware/content_encoding_test.go
Normal file
82
middleware/content_encoding_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentEncodingMiddleware(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// support for:
|
||||||
|
// Content-Encoding: gzip
|
||||||
|
// Content-Encoding: deflate
|
||||||
|
// Content-Encoding: gzip, deflate
|
||||||
|
// Content-Encoding: deflate, gzip
|
||||||
|
middleware := AllowContentEncoding("deflate", "gzip")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
encodings []string
|
||||||
|
expectedStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Support no encoding",
|
||||||
|
encodings: []string{},
|
||||||
|
expectedStatus: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Support gzip encoding",
|
||||||
|
encodings: []string{"gzip"},
|
||||||
|
expectedStatus: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No support for br encoding",
|
||||||
|
encodings: []string{"br"},
|
||||||
|
expectedStatus: 415,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Support for gzip and deflate encoding",
|
||||||
|
encodings: []string{"gzip", "deflate"},
|
||||||
|
expectedStatus: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Support for deflate and gzip encoding",
|
||||||
|
encodings: []string{"deflate", "gzip"},
|
||||||
|
expectedStatus: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No support for deflate and br encoding",
|
||||||
|
encodings: []string{"deflate", "br"},
|
||||||
|
expectedStatus: 415,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
var tt = tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := []byte("This is my content. There are many like this but this one is mine")
|
||||||
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(body))
|
||||||
|
for _, encoding := range tt.encodings {
|
||||||
|
r.Header.Set("Content-Encoding", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(middleware)
|
||||||
|
router.Post("/", func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
res := w.Result()
|
||||||
|
if res.StatusCode != tt.expectedStatus {
|
||||||
|
t.Errorf("response is incorrect, got %d, want %d", w.Code, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
middleware/content_type.go
Normal file
45
middleware/content_type.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetHeader is a convenience handler to set a response header key/value
|
||||||
|
func SetHeader(key, value string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set(key, value)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowContentType enforces a whitelist of request Content-Types otherwise responds
|
||||||
|
// with a 415 Unsupported Media Type status.
|
||||||
|
func AllowContentType(contentTypes ...string) func(http.Handler) http.Handler {
|
||||||
|
allowedContentTypes := make(map[string]struct{}, len(contentTypes))
|
||||||
|
for _, ctype := range contentTypes {
|
||||||
|
allowedContentTypes[strings.TrimSpace(strings.ToLower(ctype))] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength == 0 {
|
||||||
|
// Skip check for empty content body
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
|
||||||
|
if _, ok := allowedContentTypes[s]; ok {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
82
middleware/content_type_test.go
Normal file
82
middleware/content_type_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
name string
|
||||||
|
inputValue string
|
||||||
|
allowedContentTypes []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"should accept requests with a matching content type",
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
[]string{"application/json"},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with a matching content type no charset",
|
||||||
|
"application/json",
|
||||||
|
[]string{"application/json"},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with a matching content-type with extra values",
|
||||||
|
"application/json; foo=bar; charset=UTF-8; spam=eggs",
|
||||||
|
[]string{"application/json"},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should accept requests with a matching content type when multiple content types are supported",
|
||||||
|
"text/xml; charset=UTF-8",
|
||||||
|
[]string{"application/json", "text/xml"},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should not accept requests with a mismatching content type",
|
||||||
|
"text/plain; charset=latin-1",
|
||||||
|
[]string{"application/json"},
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"should not accept requests with a mismatching content type even if multiple content types are allowed",
|
||||||
|
"text/plain; charset=Latin-1",
|
||||||
|
[]string{"application/json", "text/xml"},
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
var tt = tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(AllowContentType(tt.allowedContentTypes...))
|
||||||
|
r.Post("/", func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
|
||||||
|
body := []byte("This is my content. There are many like this but this one is mine")
|
||||||
|
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", tt.inputValue)
|
||||||
|
|
||||||
|
r.ServeHTTP(recorder, req)
|
||||||
|
res := recorder.Result()
|
||||||
|
|
||||||
|
if res.StatusCode != tt.want {
|
||||||
|
t.Errorf("response is incorrect, got %d, want %d", recorder.Code, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
39
middleware/get_head.go
Normal file
39
middleware/get_head.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHead automatically route undefined HEAD requests to GET handlers.
|
||||||
|
func GetHead(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "HEAD" {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
routePath := rctx.RoutePath
|
||||||
|
if routePath == "" {
|
||||||
|
if r.URL.RawPath != "" {
|
||||||
|
routePath = r.URL.RawPath
|
||||||
|
} else {
|
||||||
|
routePath = r.URL.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary routing context to look-ahead before routing the request
|
||||||
|
tctx := chi.NewRouteContext()
|
||||||
|
|
||||||
|
// Attempt to find a HEAD handler for the routing path, if not found, traverse
|
||||||
|
// the router as through its a GET route, but proceed with the request
|
||||||
|
// with the HEAD method.
|
||||||
|
if !rctx.Routes.Match(tctx, "HEAD", routePath) {
|
||||||
|
rctx.RouteMethod = "GET"
|
||||||
|
rctx.RoutePath = routePath
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
66
middleware/get_head_test.go
Normal file
66
middleware/get_head_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetHead(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(GetHead)
|
||||||
|
r.Get("/hi", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Test", "yes")
|
||||||
|
w.Write([]byte("bye"))
|
||||||
|
})
|
||||||
|
r.Route("/articles", func(r chi.Router) {
|
||||||
|
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
w.Header().Set("X-Article", id)
|
||||||
|
w.Write([]byte("article:" + id))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.Route("/users", func(r chi.Router) {
|
||||||
|
r.Head("/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-User", "-")
|
||||||
|
w.Write([]byte("user"))
|
||||||
|
})
|
||||||
|
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
w.Header().Set("X-User", id)
|
||||||
|
w.Write([]byte("user:" + id))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, body := testRequest(t, ts, "GET", "/hi", nil); body != "bye" {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
if req, body := testRequest(t, ts, "HEAD", "/hi", nil); body != "" || req.Header.Get("X-Test") != "yes" {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
if _, body := testRequest(t, ts, "GET", "/", nil); body != "404 page not found\n" {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
if req, body := testRequest(t, ts, "HEAD", "/", nil); body != "" || req.StatusCode != 404 {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, body := testRequest(t, ts, "GET", "/articles/5", nil); body != "article:5" {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
if req, body := testRequest(t, ts, "HEAD", "/articles/5", nil); body != "" || req.Header.Get("X-Article") != "5" {
|
||||||
|
t.Fatalf("expecting X-Article header '5' but got '%s'", req.Header.Get("X-Article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, body := testRequest(t, ts, "GET", "/users/1", nil); body != "user:1" {
|
||||||
|
t.Fatal(body)
|
||||||
|
}
|
||||||
|
if req, body := testRequest(t, ts, "HEAD", "/users/1", nil); body != "" || req.Header.Get("X-User") != "-" {
|
||||||
|
t.Fatalf("expecting X-User header '-' but got '%s'", req.Header.Get("X-User"))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
middleware/heartbeat.go
Normal file
26
middleware/heartbeat.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heartbeat endpoint middleware useful to setting up a path like
|
||||||
|
// `/ping` that load balancers or uptime testing external services
|
||||||
|
// can make a request before hitting any routes. It's also convenient
|
||||||
|
// to place this above ACL middlewares as well.
|
||||||
|
func Heartbeat(endpoint string) func(http.Handler) http.Handler {
|
||||||
|
f := func(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if (r.Method == "GET" || r.Method == "HEAD") && strings.EqualFold(r.URL.Path, endpoint) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
31
middleware/heartbeat_test.go
Normal file
31
middleware/heartbeat_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeartbeat(t *testing.T) {
|
||||||
|
endpoint := "/ping"
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(Heartbeat(endpoint))
|
||||||
|
r.Handle(endpoint, http.NotFoundHandler())
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("heartbeat: unexpected response code: %v", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "." {
|
||||||
|
t.Errorf("heartbeat: unexpected response body: %q", w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "text/plain" {
|
||||||
|
t.Errorf("heartbeat: unexpected Content-Type: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
middleware/logger.go
Normal file
178
middleware/logger.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// LogEntryCtxKey is the context.Context key to store the request log entry.
|
||||||
|
LogEntryCtxKey = &contextKey{"LogEntry"}
|
||||||
|
|
||||||
|
// DefaultLogger is called by the Logger middleware handler to log each request.
|
||||||
|
// Its made a package-level variable so that it can be reconfigured for custom
|
||||||
|
// logging configurations.
|
||||||
|
DefaultLogger func(next http.Handler) http.Handler
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger is a middleware that logs the start and end of each request, along
|
||||||
|
// with some useful data about what was requested, what the response status was,
|
||||||
|
// and how long it took to return. When standard output is a TTY, Logger will
|
||||||
|
// print in color, otherwise it will print in black and white. Logger prints a
|
||||||
|
// request ID if one is provided.
|
||||||
|
//
|
||||||
|
// Alternatively, look at https://github.com/goware/httplog for a more in-depth
|
||||||
|
// http logger with structured logging support.
|
||||||
|
//
|
||||||
|
// IMPORTANT NOTE: Logger should go before any other middleware that may change
|
||||||
|
// the response, such as middleware.Recoverer. Example:
|
||||||
|
//
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// r.Use(middleware.Logger) // <--<< Logger should come before Recoverer
|
||||||
|
// r.Use(middleware.Recoverer)
|
||||||
|
// r.Get("/", handler)
|
||||||
|
func Logger(next http.Handler) http.Handler {
|
||||||
|
return DefaultLogger(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLogger returns a logger handler using a custom LogFormatter.
|
||||||
|
func RequestLogger(f LogFormatter) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entry := f.NewLogEntry(r)
|
||||||
|
ww := NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
defer func() {
|
||||||
|
entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(ww, WithLogEntry(r, entry))
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFormatter initiates the beginning of a new LogEntry per request.
|
||||||
|
// See DefaultLogFormatter for an example implementation.
|
||||||
|
type LogFormatter interface {
|
||||||
|
NewLogEntry(r *http.Request) LogEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry records the final log when a request completes.
|
||||||
|
// See defaultLogEntry for an example implementation.
|
||||||
|
type LogEntry interface {
|
||||||
|
Write(status, bytes int, header http.Header, elapsed time.Duration, extra any)
|
||||||
|
Panic(v any, stack []byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogEntry returns the in-context LogEntry for a request.
|
||||||
|
func GetLogEntry(r *http.Request) LogEntry {
|
||||||
|
entry, _ := r.Context().Value(LogEntryCtxKey).(LogEntry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogEntry sets the in-context LogEntry for a request.
|
||||||
|
func WithLogEntry(r *http.Request, entry LogEntry) *http.Request {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), LogEntryCtxKey, entry))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggerInterface accepts printing to stdlib logger or compatible logger.
|
||||||
|
type LoggerInterface interface {
|
||||||
|
Print(v ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLogFormatter is a simple logger that implements a LogFormatter.
|
||||||
|
type DefaultLogFormatter struct {
|
||||||
|
Logger LoggerInterface
|
||||||
|
NoColor bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogEntry creates a new LogEntry for the request.
|
||||||
|
func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
useColor := !l.NoColor
|
||||||
|
entry := &defaultLogEntry{
|
||||||
|
DefaultLogFormatter: l,
|
||||||
|
request: r,
|
||||||
|
buf: &bytes.Buffer{},
|
||||||
|
useColor: useColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqID := GetReqID(ctx)
|
||||||
|
if reqID != "" {
|
||||||
|
cW(entry.buf, useColor, nYellow, "[%s] ", reqID)
|
||||||
|
}
|
||||||
|
cW(entry.buf, useColor, nCyan, "\"")
|
||||||
|
cW(entry.buf, useColor, bMagenta, "%s ", r.Method)
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
cW(entry.buf, useColor, nCyan, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto)
|
||||||
|
|
||||||
|
entry.buf.WriteString("from ")
|
||||||
|
clientIP := GetClientIP(ctx)
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
entry.buf.WriteString(clientIP)
|
||||||
|
entry.buf.WriteString(" - ")
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultLogEntry struct {
|
||||||
|
*DefaultLogFormatter
|
||||||
|
request *http.Request
|
||||||
|
buf *bytes.Buffer
|
||||||
|
useColor bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
|
||||||
|
switch {
|
||||||
|
case status < 200:
|
||||||
|
cW(l.buf, l.useColor, bBlue, "%03d", status)
|
||||||
|
case status < 300:
|
||||||
|
cW(l.buf, l.useColor, bGreen, "%03d", status)
|
||||||
|
case status < 400:
|
||||||
|
cW(l.buf, l.useColor, bCyan, "%03d", status)
|
||||||
|
case status < 500:
|
||||||
|
cW(l.buf, l.useColor, bYellow, "%03d", status)
|
||||||
|
default:
|
||||||
|
cW(l.buf, l.useColor, bRed, "%03d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
cW(l.buf, l.useColor, bBlue, " %dB", bytes)
|
||||||
|
|
||||||
|
l.buf.WriteString(" in ")
|
||||||
|
if elapsed < 500*time.Millisecond {
|
||||||
|
cW(l.buf, l.useColor, nGreen, "%s", elapsed)
|
||||||
|
} else if elapsed < 5*time.Second {
|
||||||
|
cW(l.buf, l.useColor, nYellow, "%s", elapsed)
|
||||||
|
} else {
|
||||||
|
cW(l.buf, l.useColor, nRed, "%s", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Logger.Print(l.buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogEntry) Panic(v any, stack []byte) {
|
||||||
|
printPrettyStack(v, l.useColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
color := true
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
color = false
|
||||||
|
}
|
||||||
|
DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color})
|
||||||
|
}
|
||||||
51
middleware/logger_test.go
Normal file
51
middleware/logger_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testLoggerWriter struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw testLoggerWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLogger(t *testing.T) {
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("http.Hijacker is unavailable on the writer. add the interface methods.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := testLoggerWriter{
|
||||||
|
ResponseRecorder: httptest.NewRecorder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := DefaultLogger(testHandler)
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggerReadFrom(t *testing.T) {
|
||||||
|
data := []byte("file data")
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeContent(w, r, "file", time.Time{}, bytes.NewReader(data))
|
||||||
|
})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler := DefaultLogger(testHandler)
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assertEqual(t, data, w.Body.Bytes())
|
||||||
|
}
|
||||||
18
middleware/maybe.go
Normal file
18
middleware/maybe.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Maybe middleware will allow you to change the flow of the middleware stack execution depending on return
|
||||||
|
// value of maybeFn(request). This is useful for example if you'd like to skip a middleware handler if
|
||||||
|
// a request does not satisfy the maybeFn logic.
|
||||||
|
func Maybe(mw func(http.Handler) http.Handler, maybeFn func(r *http.Request) bool) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if maybeFn(r) {
|
||||||
|
mw(next).ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
middleware/middleware.go
Normal file
23
middleware/middleware.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// New will create a new middleware handler from a http.Handler.
|
||||||
|
func New(h http.Handler) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey is a value for use with context.WithValue. It's used as
|
||||||
|
// a pointer so it fits in an interface{} without allocation. This technique
|
||||||
|
// for defining context keys was copied from Go 1.7's new use of context in net/http.
|
||||||
|
type contextKey struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *contextKey) String() string {
|
||||||
|
return "chi/middleware context value " + k.name
|
||||||
|
}
|
||||||
213
middleware/middleware_test.go
Normal file
213
middleware/middleware_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testdataDir string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
testdataDir = path.Join(path.Dir(filename), "/../testdata")
|
||||||
|
}
|
||||||
|
|
||||||
|
type customMiddleware struct{}
|
||||||
|
|
||||||
|
func (c *customMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
for name, headers := range req.Header {
|
||||||
|
if strings.ToLower(name) == "x-custom-header" {
|
||||||
|
if len(headers) == 1 {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
endpoint := "/"
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(New(&customMiddleware{}))
|
||||||
|
r.Get(endpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allows request with custom header", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
|
||||||
|
req.Header.Set("X-Custom-Header", "abc123")
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("new: unexpected response code: got %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects request without custom header", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("new: unexpected response code: got %d, want %d", w.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_contextKey_String(t *testing.T) {
|
||||||
|
k := &contextKey{
|
||||||
|
name: "test",
|
||||||
|
}
|
||||||
|
if got := k.String(); got != "chi/middleware context value test" {
|
||||||
|
t.Errorf("got: %q, expected: %q", got, "chi/middleware context value test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapWriterHTTP2(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Proto != "HTTP/2.0" {
|
||||||
|
t.Fatalf("request proto should be HTTP/2.0 but was %s", r.Proto)
|
||||||
|
}
|
||||||
|
_, fl := w.(http.Flusher)
|
||||||
|
if !fl {
|
||||||
|
t.Fatal("request should have been a http.Flusher")
|
||||||
|
}
|
||||||
|
_, hj := w.(http.Hijacker)
|
||||||
|
if hj {
|
||||||
|
t.Fatal("request should not have been a http.Hijacker")
|
||||||
|
}
|
||||||
|
_, rf := w.(io.ReaderFrom)
|
||||||
|
if rf {
|
||||||
|
t.Fatal("request should not have been an io.ReaderFrom")
|
||||||
|
}
|
||||||
|
_, ps := w.(http.Pusher)
|
||||||
|
if !ps {
|
||||||
|
t.Fatal("request should have been a http.Pusher")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
wmw := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next.ServeHTTP(NewWrapResponseWriter(w, r.ProtoMajor), r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
server := http.Server{
|
||||||
|
Addr: ":7072",
|
||||||
|
Handler: wmw(handler),
|
||||||
|
}
|
||||||
|
// By serving over TLS, we get HTTP2 requests
|
||||||
|
go server.ListenAndServeTLS(testdataDir+"/cert.pem", testdataDir+"/key.pem")
|
||||||
|
defer server.Close()
|
||||||
|
// We need the server to start before making the request
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
// The certificates we are using are self signed
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("https://localhost:7072")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not get server: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("non 200 response: %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequest(t *testing.T, ts *httptest.Server, method, path string, body io.Reader) (*http.Response, string) {
|
||||||
|
req, err := http.NewRequest(method, ts.URL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp, string(respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequestNoRedirect(t *testing.T, ts *httptest.Server, method, path string, body io.Reader) (*http.Response, string) {
|
||||||
|
req, err := http.NewRequest(method, ts.URL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// http client that doesn't redirect
|
||||||
|
httpClient := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp, string(respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoError(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expecting no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertError(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expecting error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, a, b any) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(a, b) {
|
||||||
|
t.Fatalf("expecting values to be equal but got: '%v' and '%v'", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
middleware/nocache.go
Normal file
59
middleware/nocache.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// Ported from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unix epoch time
|
||||||
|
var epoch = time.Unix(0, 0).UTC().Format(http.TimeFormat)
|
||||||
|
|
||||||
|
// Taken from https://github.com/mytrile/nocache
|
||||||
|
var noCacheHeaders = map[string]string{
|
||||||
|
"Expires": epoch,
|
||||||
|
"Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"X-Accel-Expires": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
var etagHeaders = []string{
|
||||||
|
"ETag",
|
||||||
|
"If-Modified-Since",
|
||||||
|
"If-Match",
|
||||||
|
"If-None-Match",
|
||||||
|
"If-Range",
|
||||||
|
"If-Unmodified-Since",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent
|
||||||
|
// a router (or subrouter) from being cached by an upstream proxy and/or client.
|
||||||
|
//
|
||||||
|
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
|
||||||
|
//
|
||||||
|
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
|
||||||
|
// Cache-Control: no-cache, private, max-age=0
|
||||||
|
// X-Accel-Expires: 0
|
||||||
|
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
|
||||||
|
func NoCache(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
// Delete any ETag headers that may have been set
|
||||||
|
for _, v := range etagHeaders {
|
||||||
|
if r.Header.Get(v) != "" {
|
||||||
|
r.Header.Del(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set our NoCache headers
|
||||||
|
for k, v := range noCacheHeaders {
|
||||||
|
w.Header().Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
20
middleware/page_route.go
Normal file
20
middleware/page_route.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PageRoute is a simple middleware which allows you to route a static GET request
|
||||||
|
// at the middleware stack level.
|
||||||
|
func PageRoute(path string, handler http.Handler) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && strings.EqualFold(r.URL.Path, path) {
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
16
middleware/path_rewrite.go
Normal file
16
middleware/path_rewrite.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathRewrite is a simple middleware which allows you to rewrite the request URL path.
|
||||||
|
func PathRewrite(old, new string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.URL.Path = strings.Replace(r.URL.Path, old, new, 1)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
26
middleware/path_rewrite_test.go
Normal file
26
middleware/path_rewrite_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathRewrite(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(PathRewrite("/endpoint", "/v1/endpoint"))
|
||||||
|
|
||||||
|
r.Get("/v1/endpoint", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/endpoint", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("path rewrite: unexpected response code: %v", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
middleware/profiler.go
Normal file
48
middleware/profiler.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//go:build !tinygo
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"expvar"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profiler is a convenient subrouter used for mounting net/http/pprof. ie.
|
||||||
|
//
|
||||||
|
// func MyService() http.Handler {
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// // ..middlewares
|
||||||
|
// r.Mount("/debug", middleware.Profiler())
|
||||||
|
// // ..routes
|
||||||
|
// return r
|
||||||
|
// }
|
||||||
|
func Profiler() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(NoCache)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, r.RequestURI+"/pprof/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
r.HandleFunc("/pprof", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, r.RequestURI+"/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.HandleFunc("/pprof/*", pprof.Index)
|
||||||
|
r.HandleFunc("/pprof/cmdline", pprof.Cmdline)
|
||||||
|
r.HandleFunc("/pprof/profile", pprof.Profile)
|
||||||
|
r.HandleFunc("/pprof/symbol", pprof.Symbol)
|
||||||
|
r.HandleFunc("/pprof/trace", pprof.Trace)
|
||||||
|
r.Handle("/vars", expvar.Handler())
|
||||||
|
|
||||||
|
r.Handle("/pprof/goroutine", pprof.Handler("goroutine"))
|
||||||
|
r.Handle("/pprof/threadcreate", pprof.Handler("threadcreate"))
|
||||||
|
r.Handle("/pprof/mutex", pprof.Handler("mutex"))
|
||||||
|
r.Handle("/pprof/heap", pprof.Handler("heap"))
|
||||||
|
r.Handle("/pprof/block", pprof.Handler("block"))
|
||||||
|
r.Handle("/pprof/allocs", pprof.Handler("allocs"))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
53
middleware/realip.go
Normal file
53
middleware/realip.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// Ported from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trueClientIP = http.CanonicalHeaderKey("True-Client-IP")
|
||||||
|
var xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For")
|
||||||
|
var xRealIP = http.CanonicalHeaderKey("X-Real-IP")
|
||||||
|
|
||||||
|
// RealIP is a middleware that sets a http.Request's RemoteAddr to the results
|
||||||
|
// of parsing either the True-Client-IP, X-Real-IP or the X-Forwarded-For headers
|
||||||
|
// (in that order).
|
||||||
|
//
|
||||||
|
// Deprecated: RealIP is vulnerable to IP spoofing — it mutates r.RemoteAddr
|
||||||
|
// to the leftmost X-Forwarded-For value, or to True-Client-IP / X-Real-IP
|
||||||
|
// whether or not your infrastructure actually sets them. See
|
||||||
|
// GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf.
|
||||||
|
//
|
||||||
|
// Use [ClientIPFromHeader], [ClientIPFromXFF], [ClientIPFromXFFTrustedProxies]
|
||||||
|
// or [ClientIPFromRemoteAddr] and read the IP with [GetClientIP] instead.
|
||||||
|
// These never mutate r.RemoteAddr.
|
||||||
|
func RealIP(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if rip := realIP(r); rip != "" {
|
||||||
|
r.RemoteAddr = rip
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func realIP(r *http.Request) string {
|
||||||
|
var ip string
|
||||||
|
|
||||||
|
if tcip := r.Header.Get(trueClientIP); tcip != "" {
|
||||||
|
ip = tcip
|
||||||
|
} else if xrip := r.Header.Get(xRealIP); xrip != "" {
|
||||||
|
ip = xrip
|
||||||
|
} else if xff := r.Header.Get(xForwardedFor); xff != "" {
|
||||||
|
ip, _, _ = strings.Cut(xff, ",")
|
||||||
|
}
|
||||||
|
if ip == "" || net.ParseIP(ip) == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
115
middleware/realip_test.go
Normal file
115
middleware/realip_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXRealIP(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Real-IP", "100.100.100.100")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(RealIP)
|
||||||
|
|
||||||
|
realIP := ""
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
realIP = r.RemoteAddr
|
||||||
|
w.Write([]byte("Hello World"))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP != "100.100.100.100" {
|
||||||
|
t.Fatal("Test get real IP error.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXForwardForIP(t *testing.T) {
|
||||||
|
xForwardedForIPs := []string{
|
||||||
|
"100.100.100.100",
|
||||||
|
"100.100.100.100, 200.200.200.200",
|
||||||
|
"100.100.100.100,200.200.200.200",
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(RealIP)
|
||||||
|
|
||||||
|
for _, v := range xForwardedForIPs {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Forwarded-For", v)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
realIP := ""
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
realIP = r.RemoteAddr
|
||||||
|
w.Write([]byte("Hello World"))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP != "100.100.100.100" {
|
||||||
|
t.Fatal("Test get real IP error.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXForwardForXRealIPPrecedence(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Forwarded-For", "0.0.0.0")
|
||||||
|
req.Header.Add("X-Real-IP", "100.100.100.100")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(RealIP)
|
||||||
|
|
||||||
|
realIP := ""
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
realIP = r.RemoteAddr
|
||||||
|
w.Write([]byte("Hello World"))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP != "100.100.100.100" {
|
||||||
|
t.Fatal("Test get real IP precedence error.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidIP(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Real-IP", "100.100.100.1000")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(RealIP)
|
||||||
|
|
||||||
|
realIP := ""
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
realIP = r.RemoteAddr
|
||||||
|
w.Write([]byte("Hello World"))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP != "" {
|
||||||
|
t.Fatal("Invalid IP used.")
|
||||||
|
}
|
||||||
|
}
|
||||||
209
middleware/recoverer.go
Normal file
209
middleware/recoverer.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// The original work was derived from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recoverer is a middleware that recovers from panics, logs the panic (and a
|
||||||
|
// backtrace), and returns a HTTP 500 (Internal Server Error) status if
|
||||||
|
// possible. Recoverer prints a request ID if one is provided.
|
||||||
|
//
|
||||||
|
// Alternatively, look at https://github.com/go-chi/httplog middleware pkgs.
|
||||||
|
func Recoverer(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rvr := recover(); rvr != nil {
|
||||||
|
if rvr == http.ErrAbortHandler {
|
||||||
|
// we don't recover http.ErrAbortHandler so the response
|
||||||
|
// to the client is aborted, this should not be logged
|
||||||
|
panic(rvr)
|
||||||
|
}
|
||||||
|
|
||||||
|
logEntry := GetLogEntry(r)
|
||||||
|
if logEntry != nil {
|
||||||
|
logEntry.Panic(rvr, debug.Stack())
|
||||||
|
} else {
|
||||||
|
PrintPrettyStack(rvr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Header.Get("Connection") != "Upgrade" {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// for ability to test the PrintPrettyStack function
|
||||||
|
var recovererErrorWriter io.Writer = os.Stderr
|
||||||
|
|
||||||
|
func PrintPrettyStack(rvr any) {
|
||||||
|
printPrettyStack(rvr, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPrettyStack prints a formatted stack trace to stderr. When useColor is
|
||||||
|
// false, ANSI colour codes are suppressed, which is useful for terminals that
|
||||||
|
// do not support them (e.g. on Windows) or when output is being captured.
|
||||||
|
func printPrettyStack(rvr any, useColor bool) {
|
||||||
|
debugStack := debug.Stack()
|
||||||
|
s := prettyStack{}
|
||||||
|
out, err := s.parse(debugStack, rvr, useColor)
|
||||||
|
if err == nil {
|
||||||
|
recovererErrorWriter.Write(out)
|
||||||
|
} else {
|
||||||
|
// print stdlib output as a fallback
|
||||||
|
os.Stderr.Write(debugStack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prettyStack struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s prettyStack) parse(debugStack []byte, rvr any, useColor bool) ([]byte, error) {
|
||||||
|
var err error
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
cW(buf, false, bRed, "\n")
|
||||||
|
cW(buf, useColor, bCyan, " panic: ")
|
||||||
|
cW(buf, useColor, bBlue, "%v", rvr)
|
||||||
|
cW(buf, false, bWhite, "\n \n")
|
||||||
|
|
||||||
|
// process debug stack info
|
||||||
|
stack := strings.Split(string(debugStack), "\n")
|
||||||
|
lines := []string{}
|
||||||
|
|
||||||
|
// locate panic line, as we may have nested panics
|
||||||
|
for i := len(stack) - 1; i > 0; i-- {
|
||||||
|
lines = append(lines, stack[i])
|
||||||
|
if strings.HasPrefix(stack[i], "panic(") {
|
||||||
|
lines = lines[0 : len(lines)-2] // remove boilerplate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse
|
||||||
|
for i := len(lines)/2 - 1; i >= 0; i-- {
|
||||||
|
opp := len(lines) - 1 - i
|
||||||
|
lines[i], lines[opp] = lines[opp], lines[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorate
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i], err = s.decorateLine(line, useColor, i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range lines {
|
||||||
|
fmt.Fprintf(buf, "%s", l)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s prettyStack) decorateLine(line string, useColor bool, num int) (string, error) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "\t") || strings.Contains(line, ".go:") {
|
||||||
|
return s.decorateSourceLine(line, useColor, num)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(line, ")") {
|
||||||
|
return s.decorateFuncCallLine(line, useColor, num)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "\t") {
|
||||||
|
return strings.Replace(line, "\t", " ", 1), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" %s\n", line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s prettyStack) decorateFuncCallLine(line string, useColor bool, num int) (string, error) {
|
||||||
|
idx := strings.LastIndex(line, "(")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", errors.New("not a func call line")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
pkg := line[0:idx]
|
||||||
|
// addr := line[idx:]
|
||||||
|
method := ""
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(pkg, string(os.PathSeparator)); idx < 0 {
|
||||||
|
if idx := strings.Index(pkg, "."); idx > 0 {
|
||||||
|
method = pkg[idx:]
|
||||||
|
pkg = pkg[0:idx]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
method = pkg[idx+1:]
|
||||||
|
pkg = pkg[0 : idx+1]
|
||||||
|
if idx := strings.Index(method, "."); idx > 0 {
|
||||||
|
pkg += method[0:idx]
|
||||||
|
method = method[idx:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pkgColor := nYellow
|
||||||
|
methodColor := bGreen
|
||||||
|
|
||||||
|
if num == 0 {
|
||||||
|
cW(buf, useColor, bRed, " -> ")
|
||||||
|
pkgColor = bMagenta
|
||||||
|
methodColor = bRed
|
||||||
|
} else {
|
||||||
|
cW(buf, useColor, bWhite, " ")
|
||||||
|
}
|
||||||
|
cW(buf, useColor, pkgColor, "%s", pkg)
|
||||||
|
cW(buf, useColor, methodColor, "%s\n", method)
|
||||||
|
// cW(buf, useColor, nBlack, "%s", addr)
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s prettyStack) decorateSourceLine(line string, useColor bool, num int) (string, error) {
|
||||||
|
idx := strings.LastIndex(line, ".go:")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", errors.New("not a source line")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
path := line[0 : idx+3]
|
||||||
|
lineno := line[idx+3:]
|
||||||
|
|
||||||
|
idx = strings.LastIndex(path, string(os.PathSeparator))
|
||||||
|
dir := path[0 : idx+1]
|
||||||
|
file := path[idx+1:]
|
||||||
|
|
||||||
|
idx = strings.Index(lineno, " ")
|
||||||
|
if idx > 0 {
|
||||||
|
lineno = lineno[0:idx]
|
||||||
|
}
|
||||||
|
fileColor := bCyan
|
||||||
|
lineColor := bGreen
|
||||||
|
|
||||||
|
if num == 1 {
|
||||||
|
cW(buf, useColor, bRed, " -> ")
|
||||||
|
fileColor = bRed
|
||||||
|
lineColor = bMagenta
|
||||||
|
} else {
|
||||||
|
cW(buf, false, bWhite, " ")
|
||||||
|
}
|
||||||
|
cW(buf, useColor, bWhite, "%s", dir)
|
||||||
|
cW(buf, useColor, fileColor, "%s", file)
|
||||||
|
cW(buf, useColor, lineColor, "%s", lineno)
|
||||||
|
if num == 1 {
|
||||||
|
cW(buf, false, bWhite, "\n")
|
||||||
|
}
|
||||||
|
cW(buf, false, bWhite, "\n")
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
101
middleware/recoverer_test.go
Normal file
101
middleware/recoverer_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func panickingHandler(http.ResponseWriter, *http.Request) { panic("foo") }
|
||||||
|
|
||||||
|
type testPrintLogger struct{}
|
||||||
|
|
||||||
|
func (testPrintLogger) Print(v ...any) {}
|
||||||
|
|
||||||
|
func TestRecoverer(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
oldRecovererErrorWriter := recovererErrorWriter
|
||||||
|
defer func() { recovererErrorWriter = oldRecovererErrorWriter }()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
recovererErrorWriter = buf
|
||||||
|
|
||||||
|
r.Use(Recoverer)
|
||||||
|
r.Get("/", panickingHandler)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
res, _ := testRequest(t, ts, "GET", "/", nil)
|
||||||
|
assertEqual(t, res.StatusCode, http.StatusInternalServerError)
|
||||||
|
|
||||||
|
lines := strings.Split(buf.String(), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "->") {
|
||||||
|
if !strings.Contains(line, "panickingHandler") {
|
||||||
|
t.Fatalf("First func call line should refer to panickingHandler, but actual line:\n%v\n", line)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("First func call line should start with ->.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecovererNoColor(t *testing.T) {
|
||||||
|
// Force IsTTY so cW would emit color codes if useColor were true; this is
|
||||||
|
// what makes the assertion below a real regression guard rather than a
|
||||||
|
// no-op on non-TTY test runners (e.g. CI).
|
||||||
|
oldIsTTY := IsTTY
|
||||||
|
IsTTY = true
|
||||||
|
defer func() { IsTTY = oldIsTTY }()
|
||||||
|
|
||||||
|
oldRecovererErrorWriter := recovererErrorWriter
|
||||||
|
defer func() { recovererErrorWriter = oldRecovererErrorWriter }()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
recovererErrorWriter = buf
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(RequestLogger(&DefaultLogFormatter{Logger: testPrintLogger{}, NoColor: true}))
|
||||||
|
r.Use(Recoverer)
|
||||||
|
r.Get("/", panickingHandler)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
res, _ := testRequest(t, ts, "GET", "/", nil)
|
||||||
|
assertEqual(t, res.StatusCode, http.StatusInternalServerError)
|
||||||
|
|
||||||
|
// ANSI escape sequences start with \x1b[
|
||||||
|
if strings.Contains(buf.String(), "\x1b[") {
|
||||||
|
t.Fatal("Output should not contain ANSI color codes when NoColor is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecovererAbortHandler(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
rcv := recover()
|
||||||
|
if rcv != http.ErrAbortHandler {
|
||||||
|
t.Fatalf("http.ErrAbortHandler should not be recovered")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(Recoverer)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
}
|
||||||
96
middleware/request_id.go
Normal file
96
middleware/request_id.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// Ported from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key to use when setting the request ID.
|
||||||
|
type ctxKeyRequestID int
|
||||||
|
|
||||||
|
// RequestIDKey is the key that holds the unique request ID in a request context.
|
||||||
|
const RequestIDKey ctxKeyRequestID = 0
|
||||||
|
|
||||||
|
// RequestIDHeader is the name of the HTTP Header which contains the request id.
|
||||||
|
// Exported so that it can be changed by developers
|
||||||
|
var RequestIDHeader = "X-Request-Id"
|
||||||
|
|
||||||
|
var prefix string
|
||||||
|
var reqid atomic.Uint64
|
||||||
|
|
||||||
|
// A quick note on the statistics here: we're trying to calculate the chance that
|
||||||
|
// two randomly generated base62 prefixes will collide. We use the formula from
|
||||||
|
// http://en.wikipedia.org/wiki/Birthday_problem
|
||||||
|
//
|
||||||
|
// P[m, n] \approx 1 - e^{-m^2/2n}
|
||||||
|
//
|
||||||
|
// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server
|
||||||
|
// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$
|
||||||
|
//
|
||||||
|
// For a $k$ character base-62 identifier, we have $n(k) = 62^k$
|
||||||
|
//
|
||||||
|
// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for
|
||||||
|
// our purposes, and is surely more than anyone would ever need in practice -- a
|
||||||
|
// process that is rebooted a handful of times a day for a hundred years has less
|
||||||
|
// than a millionth of a percent chance of generating two colliding IDs.
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if hostname == "" || err != nil {
|
||||||
|
hostname = "localhost"
|
||||||
|
}
|
||||||
|
var buf [12]byte
|
||||||
|
var b64 string
|
||||||
|
for len(b64) < 10 {
|
||||||
|
rand.Read(buf[:])
|
||||||
|
b64 = base64.StdEncoding.EncodeToString(buf[:])
|
||||||
|
b64 = strings.NewReplacer("+", "", "/", "").Replace(b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10])
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestID is a middleware that injects a request ID into the context of each
|
||||||
|
// request. A request ID is a string of the form "host.example.com/random-0001",
|
||||||
|
// where "random" is a base62 random string that uniquely identifies this go
|
||||||
|
// process, and where the last number is an atomically incremented request
|
||||||
|
// counter.
|
||||||
|
func RequestID(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
requestID := r.Header.Get(RequestIDHeader)
|
||||||
|
if requestID == "" {
|
||||||
|
myid := reqid.Add(1)
|
||||||
|
requestID = fmt.Sprintf("%s-%06d", prefix, myid)
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(ctx, RequestIDKey, requestID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReqID returns a request ID from the given context if one is present.
|
||||||
|
// Returns the empty string if a request ID cannot be found.
|
||||||
|
func GetReqID(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if reqID, ok := ctx.Value(RequestIDKey).(string); ok {
|
||||||
|
return reqID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextRequestID generates the next request ID in the sequence.
|
||||||
|
func NextRequestID() uint64 {
|
||||||
|
return reqid.Add(1)
|
||||||
|
}
|
||||||
71
middleware/request_id_test.go
Normal file
71
middleware/request_id_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func maintainDefaultRequestID() func() {
|
||||||
|
original := RequestIDHeader
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
RequestIDHeader = original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestID(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
requestIDHeader string
|
||||||
|
request func() *http.Request
|
||||||
|
expectedResponse string
|
||||||
|
}{
|
||||||
|
"Retrieves Request Id from default header": {
|
||||||
|
"X-Request-Id",
|
||||||
|
func() *http.Request {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Request-Id", "req-123456")
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
"RequestID: req-123456",
|
||||||
|
},
|
||||||
|
"Retrieves Request Id from custom header": {
|
||||||
|
"X-Trace-Id",
|
||||||
|
func() *http.Request {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Add("X-Trace-Id", "trace:abc123")
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
"RequestID: trace:abc123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
defer maintainDefaultRequestID()()
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
RequestIDHeader = test.requestIDHeader
|
||||||
|
|
||||||
|
r.Use(RequestID)
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestID := GetReqID(r.Context())
|
||||||
|
response := fmt.Sprintf("RequestID: %s", requestID)
|
||||||
|
|
||||||
|
w.Write([]byte(response))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, test.request())
|
||||||
|
|
||||||
|
if w.Body.String() != test.expectedResponse {
|
||||||
|
t.Fatalf("RequestID was not the expected value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
middleware/request_size.go
Normal file
18
middleware/request_size.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestSize is a middleware that will limit request sizes to a specified
|
||||||
|
// number of bytes. It uses MaxBytesReader to do so.
|
||||||
|
func RequestSize(bytes int64) func(http.Handler) http.Handler {
|
||||||
|
f := func(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, bytes)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
146
middleware/route_headers.go
Normal file
146
middleware/route_headers.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteHeaders is a neat little header-based router that allows you to direct
|
||||||
|
// the flow of a request through a middleware stack based on a request header.
|
||||||
|
//
|
||||||
|
// For example, lets say you'd like to setup multiple routers depending on the
|
||||||
|
// request Host header, you could then do something as so:
|
||||||
|
//
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// rSubdomain := chi.NewRouter()
|
||||||
|
// r.Use(middleware.RouteHeaders().
|
||||||
|
// Route("Host", "example.com", middleware.New(r)).
|
||||||
|
// Route("Host", "*.example.com", middleware.New(rSubdomain)).
|
||||||
|
// Handler)
|
||||||
|
// r.Get("/", h)
|
||||||
|
// rSubdomain.Get("/", h2)
|
||||||
|
//
|
||||||
|
// Another example, imagine you want to setup multiple CORS handlers, where for
|
||||||
|
// your origin servers you allow authorized requests, but for third-party public
|
||||||
|
// requests, authorization is disabled.
|
||||||
|
//
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// r.Use(middleware.RouteHeaders().
|
||||||
|
// Route("Origin", "https://app.skyweaver.net", cors.Handler(cors.Options{
|
||||||
|
// AllowedOrigins: []string{"https://api.skyweaver.net"},
|
||||||
|
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
// AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
|
// AllowCredentials: true, // <----------<<< allow credentials
|
||||||
|
// })).
|
||||||
|
// Route("Origin", "*", cors.Handler(cors.Options{
|
||||||
|
// AllowedOrigins: []string{"*"},
|
||||||
|
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
// AllowedHeaders: []string{"Accept", "Content-Type"},
|
||||||
|
// AllowCredentials: false, // <----------<<< do not allow credentials
|
||||||
|
// })).
|
||||||
|
// Handler)
|
||||||
|
func RouteHeaders() HeaderRouter {
|
||||||
|
return HeaderRouter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRouter map[string][]HeaderRoute
|
||||||
|
|
||||||
|
func (hr HeaderRouter) Route(header, match string, middlewareHandler func(next http.Handler) http.Handler) HeaderRouter {
|
||||||
|
header = strings.ToLower(header)
|
||||||
|
k := hr[header]
|
||||||
|
if k == nil {
|
||||||
|
hr[header] = []HeaderRoute{}
|
||||||
|
}
|
||||||
|
hr[header] = append(hr[header], HeaderRoute{MatchOne: NewPattern(match), Middleware: middlewareHandler})
|
||||||
|
return hr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr HeaderRouter) RouteAny(header string, match []string, middlewareHandler func(next http.Handler) http.Handler) HeaderRouter {
|
||||||
|
header = strings.ToLower(header)
|
||||||
|
k := hr[header]
|
||||||
|
if k == nil {
|
||||||
|
hr[header] = []HeaderRoute{}
|
||||||
|
}
|
||||||
|
patterns := []Pattern{}
|
||||||
|
for _, m := range match {
|
||||||
|
patterns = append(patterns, NewPattern(m))
|
||||||
|
}
|
||||||
|
hr[header] = append(hr[header], HeaderRoute{MatchAny: patterns, Middleware: middlewareHandler})
|
||||||
|
return hr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr HeaderRouter) RouteDefault(handler func(next http.Handler) http.Handler) HeaderRouter {
|
||||||
|
hr["*"] = []HeaderRoute{{Middleware: handler}}
|
||||||
|
return hr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr HeaderRouter) Handler(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if len(hr) == 0 {
|
||||||
|
// skip if no routes set
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// find first matching header route, and continue
|
||||||
|
for header, matchers := range hr {
|
||||||
|
headerValue := r.Header.Get(header)
|
||||||
|
if headerValue == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerValue = strings.ToLower(headerValue)
|
||||||
|
for _, matcher := range matchers {
|
||||||
|
if matcher.IsMatch(headerValue) {
|
||||||
|
matcher.Middleware(next).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no match, check for "*" default route
|
||||||
|
matcher, ok := hr["*"]
|
||||||
|
if !ok || matcher[0].Middleware == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matcher[0].Middleware(next).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRoute struct {
|
||||||
|
Middleware func(next http.Handler) http.Handler
|
||||||
|
MatchOne Pattern
|
||||||
|
MatchAny []Pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r HeaderRoute) IsMatch(value string) bool {
|
||||||
|
if len(r.MatchAny) > 0 {
|
||||||
|
for _, m := range r.MatchAny {
|
||||||
|
if m.Match(value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if r.MatchOne.Match(value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pattern struct {
|
||||||
|
prefix string
|
||||||
|
suffix string
|
||||||
|
wildcard bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPattern(value string) Pattern {
|
||||||
|
p := Pattern{}
|
||||||
|
p.prefix, p.suffix, p.wildcard = strings.Cut(value, "*")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Pattern) Match(v string) bool {
|
||||||
|
if !p.wildcard {
|
||||||
|
return p.prefix == v
|
||||||
|
}
|
||||||
|
return len(v) >= len(p.prefix+p.suffix) && strings.HasPrefix(v, p.prefix) && strings.HasSuffix(v, p.suffix)
|
||||||
|
}
|
||||||
212
middleware/route_headers_test.go
Normal file
212
middleware/route_headers_test.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouteHeaders(t *testing.T) {
|
||||||
|
t.Run("empty router should call next handler exactly once", func(t *testing.T) {
|
||||||
|
var callCount atomic.Int32
|
||||||
|
|
||||||
|
hr := RouteHeaders()
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if callCount.Load() != 1 {
|
||||||
|
t.Errorf("expected next handler to be called exactly once, but was called %d times", callCount.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matching header should route to correct middleware", func(t *testing.T) {
|
||||||
|
var matchedRoute string
|
||||||
|
|
||||||
|
hr := RouteHeaders().
|
||||||
|
Route("Host", "example.com", func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matchedRoute = "example.com"
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
Route("Host", "other.com", func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matchedRoute = "other.com"
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Host = "example.com"
|
||||||
|
req.Header.Set("Host", "example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if matchedRoute != "example.com" {
|
||||||
|
t.Errorf("expected matched route to be 'example.com', got '%s'", matchedRoute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wildcard pattern should match", func(t *testing.T) {
|
||||||
|
var matched bool
|
||||||
|
|
||||||
|
hr := RouteHeaders().
|
||||||
|
Route("Host", "*.example.com", func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matched = true
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("Host", "api.example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
t.Error("expected wildcard pattern to match")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default route should be used when no match", func(t *testing.T) {
|
||||||
|
var usedDefault bool
|
||||||
|
|
||||||
|
hr := RouteHeaders().
|
||||||
|
Route("Host", "example.com", func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
RouteDefault(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usedDefault = true
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("Host", "other.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !usedDefault {
|
||||||
|
t.Error("expected default route to be used when no match")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RouteAny should match any of the provided patterns", func(t *testing.T) {
|
||||||
|
var matched bool
|
||||||
|
|
||||||
|
hr := RouteHeaders().
|
||||||
|
RouteAny("Content-Type", []string{"application/json", "application/xml"}, func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matched = true
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Test with application/json
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
t.Error("expected RouteAny to match 'application/json'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and test with application/xml
|
||||||
|
matched = false
|
||||||
|
req = httptest.NewRequest("POST", "/", nil)
|
||||||
|
req.Header.Set("Content-Type", "application/xml")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
t.Error("expected RouteAny to match 'application/xml'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no match and no default should call next handler", func(t *testing.T) {
|
||||||
|
var nextCalled bool
|
||||||
|
|
||||||
|
hr := RouteHeaders().
|
||||||
|
Route("Host", "example.com", func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nextCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("Host", "other.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !nextCalled {
|
||||||
|
t.Error("expected next handler to be called when no match and no default")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPattern(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
value string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"example.com", "example.com", true},
|
||||||
|
{"example.com", "other.com", false},
|
||||||
|
{"*.example.com", "api.example.com", true},
|
||||||
|
{"*.example.com", "example.com", false},
|
||||||
|
{"api.*", "api.example.com", true},
|
||||||
|
{"*", "anything", true},
|
||||||
|
{"prefix*suffix", "prefixmiddlesuffix", true},
|
||||||
|
{"prefix*suffix", "prefixsuffix", true},
|
||||||
|
{"prefix*suffix", "wrongmiddlesuffix", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.pattern+"_"+tt.value, func(t *testing.T) {
|
||||||
|
p := NewPattern(tt.pattern)
|
||||||
|
if got := p.Match(tt.value); got != tt.expected {
|
||||||
|
t.Errorf("Pattern(%q).Match(%q) = %v, want %v", tt.pattern, tt.value, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
77
middleware/strip.go
Normal file
77
middleware/strip.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripSlashes is a middleware that will match request paths with a trailing
|
||||||
|
// slash, strip it from the path and continue routing through the mux, if a route
|
||||||
|
// matches, then it will serve the handler.
|
||||||
|
func StripSlashes(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var path string
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
if rctx != nil && rctx.RoutePath != "" {
|
||||||
|
path = rctx.RoutePath
|
||||||
|
} else {
|
||||||
|
path = r.URL.Path
|
||||||
|
}
|
||||||
|
if len(path) > 1 && path[len(path)-1] == '/' {
|
||||||
|
newPath := path[:len(path)-1]
|
||||||
|
if rctx == nil {
|
||||||
|
r.URL.Path = newPath
|
||||||
|
} else {
|
||||||
|
rctx.RoutePath = newPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedirectSlashes is a middleware that will match request paths with a trailing
|
||||||
|
// slash and redirect to the same path, less the trailing slash.
|
||||||
|
//
|
||||||
|
// NOTE: RedirectSlashes middleware is *incompatible* with http.FileServer,
|
||||||
|
// see https://github.com/go-chi/chi/issues/343
|
||||||
|
func RedirectSlashes(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var path string
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
if rctx != nil && rctx.RoutePath != "" {
|
||||||
|
path = rctx.RoutePath
|
||||||
|
} else {
|
||||||
|
path = r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(path) > 1 && path[len(path)-1] == '/' {
|
||||||
|
// Normalize backslashes to forward slashes to prevent "/\evil.com" style redirects
|
||||||
|
// that some clients may interpret as protocol-relative.
|
||||||
|
path = strings.ReplaceAll(path, `\`, `/`)
|
||||||
|
|
||||||
|
// Collapse leading/trailing slashes and force a single leading slash.
|
||||||
|
path := "/" + strings.Trim(path, "/")
|
||||||
|
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
path = fmt.Sprintf("%s?%s", path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, path, 301)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripPrefix is a middleware that will strip the provided prefix from the
|
||||||
|
// request path before handing the request over to the next handler.
|
||||||
|
func StripPrefix(prefix string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.StripPrefix(prefix, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
329
middleware/strip_test.go
Normal file
329
middleware/strip_test.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripSlashes(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// This middleware must be mounted at the top level of the router, not at the end-handler
|
||||||
|
// because then it'll be too late and will end up in a 404
|
||||||
|
r.Use(StripSlashes)
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
w.Write([]byte("nothing here"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/accounts/{accountID}", func(r chi.Router) {
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := chi.URLParam(r, "accountID")
|
||||||
|
w.Write([]byte(accountID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/", nil); resp != "root" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "//", nil); resp != "root" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/nothing-here", nil); resp != "nothing here" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripSlashesInRoute(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
w.Write([]byte("nothing here"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/hi", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("hi"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/accounts/{accountID}", func(r chi.Router) {
|
||||||
|
r.Use(StripSlashes)
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("accounts index"))
|
||||||
|
})
|
||||||
|
r.Get("/query", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := chi.URLParam(r, "accountID")
|
||||||
|
w.Write([]byte(accountID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/hi", nil); resp != "hi" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/hi/", nil); resp != "nothing here" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "accounts index" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "accounts index" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/query", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/query/", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectSlashes(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// This middleware must be mounted at the top level of the router, not at the end-handler
|
||||||
|
// because then it'll be too late and will end up in a 404
|
||||||
|
r.Use(RedirectSlashes)
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
w.Write([]byte("nothing here"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/accounts/{accountID}", func(r chi.Router) {
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := chi.URLParam(r, "accountID")
|
||||||
|
w.Write([]byte(accountID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if resp, body := testRequest(t, ts, "GET", "/", nil); body != "root" || resp.StatusCode != 200 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: the testRequest client will follow the redirection..
|
||||||
|
if resp, body := testRequest(t, ts, "GET", "//", nil); body != "root" || resp.StatusCode != 200 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, body := testRequest(t, ts, "GET", "/accounts/admin", nil); body != "admin" || resp.StatusCode != 200 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: the testRequest client will follow the redirection..
|
||||||
|
if resp, body := testRequest(t, ts, "GET", "/accounts/admin/", nil); body != "admin" || resp.StatusCode != 200 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, body := testRequest(t, ts, "GET", "/nothing-here", nil); body != "nothing here" || resp.StatusCode != 404 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure redirect Location url is correct
|
||||||
|
{
|
||||||
|
resp, body := testRequestNoRedirect(t, ts, "GET", "/accounts/someuser/", nil)
|
||||||
|
if resp.StatusCode != 301 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
if location != "/accounts/someuser" {
|
||||||
|
t.Fatalf("invalid redirection, should be /accounts/someuser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure query params are kept in tact upon redirecting a slash
|
||||||
|
{
|
||||||
|
resp, body := testRequestNoRedirect(t, ts, "GET", "/accounts/someuser/?a=1&b=2", nil)
|
||||||
|
if resp.StatusCode != 301 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
if location != "/accounts/someuser?a=1&b=2" {
|
||||||
|
t.Fatalf("invalid redirection, should be /accounts/someuser?a=1&b=2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that we don't redirect to 'evil.com', but rather to 'server.url/evil.com/'
|
||||||
|
{
|
||||||
|
paths := []string{"//evil.com/", "///evil.com/"}
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
resp, body := testRequest(t, ts, "GET", p, nil)
|
||||||
|
if u, err := url.Parse(ts.URL); err != nil && resp.Request.URL.Host != u.Host {
|
||||||
|
t.Fatalf("host should remain the same. got: %q, want: %q", resp.Request.URL.Host, ts.URL)
|
||||||
|
}
|
||||||
|
if body != "nothing here" || resp.StatusCode != 404 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that we don't redirect to 'evil.com', but rather to 'server.url/evil.com/'
|
||||||
|
{
|
||||||
|
resp, body := testRequest(t, ts, "GET", "//evil.com/", nil)
|
||||||
|
if u, err := url.Parse(ts.URL); err != nil && resp.Request.URL.Host != u.Host {
|
||||||
|
t.Fatalf("host should remain the same. got: %q, want: %q", resp.Request.URL.Host, ts.URL)
|
||||||
|
}
|
||||||
|
if body != "nothing here" || resp.StatusCode != 404 {
|
||||||
|
t.Fatal(body, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This tests a http.Handler that is not chi.Router
|
||||||
|
// In these cases, the routeContext is nil
|
||||||
|
func TestStripSlashesWithNilContext(t *testing.T) {
|
||||||
|
r := http.NewServeMux()
|
||||||
|
|
||||||
|
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("root"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.HandleFunc("/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("accounts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.HandleFunc("/accounts/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("admin"))
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(StripSlashes(r))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/", nil); resp != "root" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "//", nil); resp != "root" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts", nil); resp != "accounts" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/", nil); resp != "accounts" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "admin" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripPrefix(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(StripPrefix("/api"))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("api root"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("api accounts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/accounts/{accountID}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := chi.URLParam(r, "accountID")
|
||||||
|
w.Write([]byte(accountID))
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/api/", nil); resp != "api root" {
|
||||||
|
t.Fatalf("got: %q, want: %q", resp, "api root")
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/api/accounts", nil); resp != "api accounts" {
|
||||||
|
t.Fatalf("got: %q, want: %q", resp, "api accounts")
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/api/accounts/admin", nil); resp != "admin" {
|
||||||
|
t.Fatalf("got: %q, want: %q", resp, "admin")
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/api-nope/", nil); resp != "404 page not found\n" {
|
||||||
|
t.Fatalf("got: %q, want: %q", resp, "404 page not found\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectSlashes_PreventBackslashRelativeOpenRedirect(t *testing.T) {
|
||||||
|
h := RedirectSlashes(http.NotFoundHandler())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: `raw backslash: /\evil.com/`,
|
||||||
|
target: `/\evil.com/`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `encoded backslash: /%5Cevil.com/`,
|
||||||
|
target: "/%5Cevil.com/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://example.test"+tc.target, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
res := rr.Result()
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusMovedPermanently {
|
||||||
|
t.Fatalf("expected %d, got %d", http.StatusMovedPermanently, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := res.Header.Get("Location")
|
||||||
|
if loc == "" {
|
||||||
|
t.Fatalf("expected Location header to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core security assertions:
|
||||||
|
if strings.Contains(loc, `\`) {
|
||||||
|
t.Fatalf("Location must not contain backslashes: %q", loc)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(loc, "//") {
|
||||||
|
t.Fatalf("Location must not be protocol-relative: %q", loc)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(loc, "/") {
|
||||||
|
t.Fatalf("Location must be an absolute-path reference starting with '/': %q", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional stronger assertion if your middleware normalizes to /evil.com exactly:
|
||||||
|
// (Keep or remove depending on your chosen behavior.)
|
||||||
|
if loc != "/evil.com" {
|
||||||
|
t.Fatalf("expected Location %q, got %q", "/evil.com", loc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
middleware/sunset.go
Normal file
25
middleware/sunset.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sunset set Deprecation/Sunset header to response
|
||||||
|
// This can be used to enable Sunset in a route or a route group
|
||||||
|
// For more: https://www.rfc-editor.org/rfc/rfc8594.html
|
||||||
|
func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !sunsetAt.IsZero() {
|
||||||
|
w.Header().Set("Sunset", sunsetAt.Format(http.TimeFormat))
|
||||||
|
w.Header().Set("Deprecation", sunsetAt.Format(http.TimeFormat))
|
||||||
|
|
||||||
|
for _, link := range links {
|
||||||
|
w.Header().Add("Link", link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
104
middleware/sunset_test.go
Normal file
104
middleware/sunset_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSunset(t *testing.T) {
|
||||||
|
|
||||||
|
t.Run("Sunset without link", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
|
||||||
|
r.Use(Sunset(sunsetAt))
|
||||||
|
|
||||||
|
var sunset, deprecation string
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clonedHeader := w.Header().Clone()
|
||||||
|
sunset = clonedHeader.Get("Sunset")
|
||||||
|
deprecation = clonedHeader.Get("Deprecation")
|
||||||
|
w.Write([]byte("I'll be unavailable soon"))
|
||||||
|
})
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sunset != "Wed, 24 Dec 2025 10:20:00 GMT" {
|
||||||
|
t.Fatal("Test get sunset error.", sunset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
|
||||||
|
t.Fatal("Test get deprecation error.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Sunset with link", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
|
||||||
|
deprecationLink := "https://example.com/v1/deprecation-details"
|
||||||
|
r.Use(Sunset(sunsetAt, deprecationLink))
|
||||||
|
|
||||||
|
var sunset, deprecation, link string
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clonedHeader := w.Header().Clone()
|
||||||
|
sunset = clonedHeader.Get("Sunset")
|
||||||
|
deprecation = clonedHeader.Get("Deprecation")
|
||||||
|
link = clonedHeader.Get("Link")
|
||||||
|
|
||||||
|
w.Write([]byte("I'll be unavailable soon"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal("Response Code should be 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sunset != "Wed, 24 Dec 2025 10:20:00 GMT" {
|
||||||
|
t.Fatal("Test get sunset error.", sunset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
|
||||||
|
t.Fatal("Test get deprecation error.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if link != deprecationLink {
|
||||||
|
t.Fatal("Test get deprecation link error.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
EXAMPLE USAGES
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
|
||||||
|
r.Use(middleware.Sunset(sunsetAt))
|
||||||
|
|
||||||
|
// can provide additional link for updated resource
|
||||||
|
// r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/deprecation-details"))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("This endpoint will be removed soon"))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("Listening on port: 3000")
|
||||||
|
http.ListenAndServe(":3000", r)
|
||||||
|
}
|
||||||
|
**/
|
||||||
27
middleware/supress_notfound.go
Normal file
27
middleware/supress_notfound.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupressNotFound will quickly respond with a 404 if the route is not found
|
||||||
|
// and will not continue to the next middleware handler.
|
||||||
|
//
|
||||||
|
// This is handy to put at the top of your middleware stack to avoid unnecessary
|
||||||
|
// processing of requests that are not going to match any routes anyway. For
|
||||||
|
// example its super annoying to see a bunch of 404's in your logs from bots.
|
||||||
|
func SupressNotFound(router *chi.Mux) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
match := rctx.Routes.Match(rctx, r.Method, r.URL.Path)
|
||||||
|
if !match {
|
||||||
|
router.NotFoundHandler().ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
63
middleware/terminal.go
Normal file
63
middleware/terminal.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// Ported from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Normal colors
|
||||||
|
nBlack = []byte{'\033', '[', '3', '0', 'm'}
|
||||||
|
nRed = []byte{'\033', '[', '3', '1', 'm'}
|
||||||
|
nGreen = []byte{'\033', '[', '3', '2', 'm'}
|
||||||
|
nYellow = []byte{'\033', '[', '3', '3', 'm'}
|
||||||
|
nBlue = []byte{'\033', '[', '3', '4', 'm'}
|
||||||
|
nMagenta = []byte{'\033', '[', '3', '5', 'm'}
|
||||||
|
nCyan = []byte{'\033', '[', '3', '6', 'm'}
|
||||||
|
nWhite = []byte{'\033', '[', '3', '7', 'm'}
|
||||||
|
// Bright colors
|
||||||
|
bBlack = []byte{'\033', '[', '3', '0', ';', '1', 'm'}
|
||||||
|
bRed = []byte{'\033', '[', '3', '1', ';', '1', 'm'}
|
||||||
|
bGreen = []byte{'\033', '[', '3', '2', ';', '1', 'm'}
|
||||||
|
bYellow = []byte{'\033', '[', '3', '3', ';', '1', 'm'}
|
||||||
|
bBlue = []byte{'\033', '[', '3', '4', ';', '1', 'm'}
|
||||||
|
bMagenta = []byte{'\033', '[', '3', '5', ';', '1', 'm'}
|
||||||
|
bCyan = []byte{'\033', '[', '3', '6', ';', '1', 'm'}
|
||||||
|
bWhite = []byte{'\033', '[', '3', '7', ';', '1', 'm'}
|
||||||
|
|
||||||
|
reset = []byte{'\033', '[', '0', 'm'}
|
||||||
|
)
|
||||||
|
|
||||||
|
var IsTTY bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// This is sort of cheating: if stdout is a character device, we assume
|
||||||
|
// that means it's a TTY. Unfortunately, there are many non-TTY
|
||||||
|
// character devices, but fortunately stdout is rarely set to any of
|
||||||
|
// them.
|
||||||
|
//
|
||||||
|
// We could solve this properly by pulling in a dependency on
|
||||||
|
// code.google.com/p/go.crypto/ssh/terminal, for instance, but as a
|
||||||
|
// heuristic for whether to print in color or in black-and-white, I'd
|
||||||
|
// really rather not.
|
||||||
|
fi, err := os.Stdout.Stat()
|
||||||
|
if err == nil {
|
||||||
|
m := os.ModeDevice | os.ModeCharDevice
|
||||||
|
IsTTY = fi.Mode()&m == m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorWrite
|
||||||
|
func cW(w io.Writer, useColor bool, color []byte, s string, args ...any) {
|
||||||
|
if IsTTY && useColor {
|
||||||
|
w.Write(color)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, s, args...)
|
||||||
|
if IsTTY && useColor {
|
||||||
|
w.Write(reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
middleware/terminal_test.go
Normal file
36
middleware/terminal_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_cW(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
useColor bool
|
||||||
|
color []byte
|
||||||
|
s string
|
||||||
|
args []any
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "No color",
|
||||||
|
useColor: false,
|
||||||
|
color: nGreen,
|
||||||
|
s: "no color test",
|
||||||
|
expected: "no color test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
actual := &bytes.Buffer{}
|
||||||
|
cW(actual, c.useColor, c.color, c.s, c.args...)
|
||||||
|
|
||||||
|
if got := actual.String(); got != c.expected {
|
||||||
|
t.Errorf("output: %q, expected: %q", got, c.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
151
middleware/throttle.go
Normal file
151
middleware/throttle.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
errCapacityExceeded = "Server capacity exceeded."
|
||||||
|
errTimedOut = "Timed out while waiting for a pending request to complete."
|
||||||
|
errContextCanceled = "Context was canceled."
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultBacklogTimeout = time.Second * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThrottleOpts represents a set of throttling options.
|
||||||
|
type ThrottleOpts struct {
|
||||||
|
RetryAfterFn func(ctxDone bool) time.Duration
|
||||||
|
Limit int
|
||||||
|
BacklogLimit int
|
||||||
|
BacklogTimeout time.Duration
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle is a middleware that limits number of currently processed requests
|
||||||
|
// at a time across all users. Note: Throttle is not a rate-limiter per user,
|
||||||
|
// instead it just puts a ceiling on the number of current in-flight requests
|
||||||
|
// being processed from the point from where the Throttle middleware is mounted.
|
||||||
|
func Throttle(limit int) func(http.Handler) http.Handler {
|
||||||
|
return ThrottleWithOpts(ThrottleOpts{Limit: limit, BacklogTimeout: defaultBacklogTimeout})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThrottleBacklog is a middleware that limits number of currently processed
|
||||||
|
// requests at a time and provides a backlog for holding a finite number of
|
||||||
|
// pending requests.
|
||||||
|
func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler {
|
||||||
|
return ThrottleWithOpts(ThrottleOpts{Limit: limit, BacklogLimit: backlogLimit, BacklogTimeout: backlogTimeout})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThrottleWithOpts is a middleware that limits number of currently processed requests using passed ThrottleOpts.
|
||||||
|
func ThrottleWithOpts(opts ThrottleOpts) func(http.Handler) http.Handler {
|
||||||
|
if opts.Limit < 1 {
|
||||||
|
panic("chi/middleware: Throttle expects limit > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.BacklogLimit < 0 {
|
||||||
|
panic("chi/middleware: Throttle expects backlogLimit to be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := opts.StatusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
t := throttler{
|
||||||
|
tokens: make(chan token, opts.Limit),
|
||||||
|
backlogTokens: make(chan token, opts.Limit+opts.BacklogLimit),
|
||||||
|
backlogTimeout: opts.BacklogTimeout,
|
||||||
|
statusCode: statusCode,
|
||||||
|
retryAfterFn: opts.RetryAfterFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filling tokens.
|
||||||
|
for i := 0; i < opts.Limit+opts.BacklogLimit; i++ {
|
||||||
|
if i < opts.Limit {
|
||||||
|
t.tokens <- token{}
|
||||||
|
}
|
||||||
|
t.backlogTokens <- token{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
select {
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.setRetryAfterHeaderIfNeeded(w, true)
|
||||||
|
http.Error(w, errContextCanceled, t.statusCode)
|
||||||
|
return
|
||||||
|
|
||||||
|
case btok := <-t.backlogTokens:
|
||||||
|
defer func() {
|
||||||
|
t.backlogTokens <- btok
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Try to get a processing token immediately first
|
||||||
|
select {
|
||||||
|
case tok := <-t.tokens:
|
||||||
|
defer func() {
|
||||||
|
t.tokens <- tok
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// No immediate token available, need to wait with timer
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(t.backlogTimeout)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
t.setRetryAfterHeaderIfNeeded(w, false)
|
||||||
|
http.Error(w, errTimedOut, t.statusCode)
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
t.setRetryAfterHeaderIfNeeded(w, true)
|
||||||
|
http.Error(w, errContextCanceled, t.statusCode)
|
||||||
|
return
|
||||||
|
case tok := <-t.tokens:
|
||||||
|
defer func() {
|
||||||
|
timer.Stop()
|
||||||
|
t.tokens <- tok
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.setRetryAfterHeaderIfNeeded(w, false)
|
||||||
|
http.Error(w, errCapacityExceeded, t.statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// token represents a request that is being processed.
|
||||||
|
type token struct{}
|
||||||
|
|
||||||
|
// throttler limits number of currently processed requests at a time.
|
||||||
|
type throttler struct {
|
||||||
|
tokens chan token
|
||||||
|
backlogTokens chan token
|
||||||
|
retryAfterFn func(ctxDone bool) time.Duration
|
||||||
|
backlogTimeout time.Duration
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRetryAfterHeaderIfNeeded sets Retry-After HTTP header if corresponding retryAfterFn option of throttler is initialized.
|
||||||
|
func (t throttler) setRetryAfterHeaderIfNeeded(w http.ResponseWriter, ctxDone bool) {
|
||||||
|
if t.retryAfterFn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(t.retryAfterFn(ctxDone).Seconds())))
|
||||||
|
}
|
||||||
329
middleware/throttle_test.go
Normal file
329
middleware/throttle_test.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testContent = []byte("Hello world!")
|
||||||
|
|
||||||
|
func TestThrottleBacklog(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(ThrottleBacklog(10, 50, time.Second*10))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
time.Sleep(time.Second * 1) // Expensive operation.
|
||||||
|
w.Write(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: time.Second * 5, // Maximum waiting time.
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// The throttler processes 10 consecutive requests, each one of those
|
||||||
|
// requests lasts 1s. The maximum number of requests this can possible serve
|
||||||
|
// before the clients time out (5s) is 40.
|
||||||
|
for i := range 40 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
res, err := client.Get(server.URL)
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
assertEqual(t, http.StatusOK, res.StatusCode)
|
||||||
|
buf, err := io.ReadAll(res.Body)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, testContent, buf)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleClientTimeout(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(ThrottleBacklog(10, 50, time.Second*10))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
time.Sleep(time.Second * 5) // Expensive operation.
|
||||||
|
w.Write(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: time.Second * 3, // Maximum waiting time.
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := client.Get(server.URL)
|
||||||
|
assertError(t, err)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleTriggerGatewayTimeout(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(ThrottleBacklog(50, 100, time.Second*5))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
time.Sleep(time.Second * 10) // Expensive operation.
|
||||||
|
w.Write(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: time.Second * 60, // Maximum waiting time.
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// These requests will be processed normally until they finish.
|
||||||
|
for i := range 50 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
res, err := client.Get(server.URL)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, http.StatusOK, res.StatusCode)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second * 1)
|
||||||
|
|
||||||
|
// These requests will wait for the first batch to complete but it will take
|
||||||
|
// too much time, so they will eventually receive a timeout error.
|
||||||
|
for i := range 50 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
res, err := client.Get(server.URL)
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
buf, err := io.ReadAll(res.Body)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, http.StatusTooManyRequests, res.StatusCode)
|
||||||
|
assertEqual(t, errTimedOut, strings.TrimSpace(string(buf)))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleMaximum(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(ThrottleBacklog(10, 10, time.Second*5))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
time.Sleep(time.Second * 3) // Expensive operation.
|
||||||
|
w.Write(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: time.Second * 60, // Maximum waiting time.
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range 20 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
res, err := client.Get(server.URL)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
|
buf, err := io.ReadAll(res.Body)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, testContent, buf)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait less time than what the server takes to reply.
|
||||||
|
time.Sleep(time.Second * 2)
|
||||||
|
|
||||||
|
// At this point the server is still processing, all the following request
|
||||||
|
// will be beyond the server capacity.
|
||||||
|
for i := range 20 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
res, err := client.Get(server.URL)
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
buf, err := io.ReadAll(res.Body)
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, http.StatusTooManyRequests, res.StatusCode)
|
||||||
|
assertEqual(t, errCapacityExceeded, strings.TrimSpace(string(buf)))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleRetryAfter(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
retryAfterFn := func(ctxDone bool) time.Duration { return time.Hour }
|
||||||
|
|
||||||
|
r.Use(ThrottleWithOpts(ThrottleOpts{
|
||||||
|
Limit: 5,
|
||||||
|
BacklogLimit: 0,
|
||||||
|
RetryAfterFn: retryAfterFn,
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(time.Second * 1) // Expensive operation.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
client := http.Client{}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
status int
|
||||||
|
header http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
totalRequests := 10
|
||||||
|
resultsCh := make(chan result, totalRequests)
|
||||||
|
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
res, _ := client.Get(server.URL)
|
||||||
|
resultsCh <- result{status: res.StatusCode, header: res.Header}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(resultsCh)
|
||||||
|
|
||||||
|
count200 := 0
|
||||||
|
count429 := 0
|
||||||
|
for res := range resultsCh {
|
||||||
|
switch res.status {
|
||||||
|
case http.StatusOK:
|
||||||
|
count200++
|
||||||
|
continue
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
count429++
|
||||||
|
assertEqual(t, "3600", res.header.Get("Retry-After"))
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
t.Fatalf("Unexpected status code: %d", res.status)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, 5, count200)
|
||||||
|
assertEqual(t, 5, count429)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleCustomStatusCode(t *testing.T) {
|
||||||
|
const timeout = time.Second * 3
|
||||||
|
|
||||||
|
wait := make(chan struct{})
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(ThrottleWithOpts(ThrottleOpts{Limit: 1, StatusCode: http.StatusServiceUnavailable}))
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-wait:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(r)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
const totalRequestCount = 5
|
||||||
|
|
||||||
|
codes := make(chan int, totalRequestCount)
|
||||||
|
errs := make(chan error, totalRequestCount)
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
for range totalRequestCount {
|
||||||
|
go func() {
|
||||||
|
resp, err := client.Get(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codes <- resp.StatusCode
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
waitResponse := func(wantCode int) {
|
||||||
|
select {
|
||||||
|
case err := <-errs:
|
||||||
|
t.Fatal(err)
|
||||||
|
case code := <-codes:
|
||||||
|
assertEqual(t, wantCode, code)
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatalf("waiting %d code, timeout exceeded", wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range totalRequestCount - 1 {
|
||||||
|
waitResponse(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
close(wait) // Allow the last request to proceed.
|
||||||
|
waitResponse(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkThrottle(b *testing.B) {
|
||||||
|
throttleMiddleware := ThrottleBacklog(1000, 50, time.Second)
|
||||||
|
|
||||||
|
handler := throttleMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
middleware/timeout.go
Normal file
48
middleware/timeout.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timeout is a middleware that cancels ctx after a given timeout and return
|
||||||
|
// a 504 Gateway Timeout error to the client.
|
||||||
|
//
|
||||||
|
// It's required that you select the ctx.Done() channel to check for the signal
|
||||||
|
// if the context has reached its deadline and return, otherwise the timeout
|
||||||
|
// signal will be just ignored.
|
||||||
|
//
|
||||||
|
// ie. a route/handler may look like:
|
||||||
|
//
|
||||||
|
// r.Get("/long", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ctx := r.Context()
|
||||||
|
// processTime := time.Duration(rand.Intn(4)+1) * time.Second
|
||||||
|
//
|
||||||
|
// select {
|
||||||
|
// case <-ctx.Done():
|
||||||
|
// return
|
||||||
|
//
|
||||||
|
// case <-time.After(processTime):
|
||||||
|
// // The above channel simulates some hard work.
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// w.Write([]byte("done"))
|
||||||
|
// })
|
||||||
|
func Timeout(timeout time.Duration) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
w.WriteHeader(http.StatusGatewayTimeout)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
middleware/url_format.go
Normal file
77
middleware/url_format.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// URLFormatCtxKey is the context.Context key to store the URL format data
|
||||||
|
// for a request.
|
||||||
|
URLFormatCtxKey = &contextKey{"URLFormat"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// URLFormat is a middleware that parses the url extension from a request path and stores it
|
||||||
|
// on the context as a string under the key `middleware.URLFormatCtxKey`. The middleware will
|
||||||
|
// trim the suffix from the routing path and continue routing.
|
||||||
|
//
|
||||||
|
// Routers should not include a url parameter for the suffix when using this middleware.
|
||||||
|
//
|
||||||
|
// Sample usage for url paths `/articles/1`, `/articles/1.json` and `/articles/1.xml`:
|
||||||
|
//
|
||||||
|
// func routes() http.Handler {
|
||||||
|
// r := chi.NewRouter()
|
||||||
|
// r.Use(middleware.URLFormat)
|
||||||
|
//
|
||||||
|
// r.Get("/articles/{id}", ListArticles)
|
||||||
|
//
|
||||||
|
// return r
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// urlFormat, _ := r.Context().Value(middleware.URLFormatCtxKey).(string)
|
||||||
|
//
|
||||||
|
// switch urlFormat {
|
||||||
|
// case "json":
|
||||||
|
// render.JSON(w, r, articles)
|
||||||
|
// case "xml:"
|
||||||
|
// render.XML(w, r, articles)
|
||||||
|
// default:
|
||||||
|
// render.JSON(w, r, articles)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func URLFormat(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var format string
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
if rctx != nil && rctx.RoutePath != "" {
|
||||||
|
path = rctx.RoutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Index(path, ".") > 0 {
|
||||||
|
base := strings.LastIndex(path, "/")
|
||||||
|
idx := strings.LastIndex(path[base:], ".")
|
||||||
|
|
||||||
|
if idx > 0 {
|
||||||
|
idx += base
|
||||||
|
format = path[idx+1:]
|
||||||
|
|
||||||
|
if rctx != nil {
|
||||||
|
rctx.RoutePath = path[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = r.WithContext(context.WithValue(ctx, URLFormatCtxKey, format))
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
69
middleware/url_format_test.go
Normal file
69
middleware/url_format_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestURLFormat(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(URLFormat)
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
w.Write([]byte("nothing here"))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/samples/articles/samples.{articleID}", func(r chi.Router) {
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
articleID := chi.URLParam(r, "articleID")
|
||||||
|
w.Write([]byte(articleID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/articles/{articleID}", func(r chi.Router) {
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
articleID := chi.URLParam(r, "articleID")
|
||||||
|
w.Write([]byte(articleID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/articles/1.json", nil); resp != "1" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/articles/1.xml", nil); resp != "1" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/samples/articles/samples.1.json", nil); resp != "1" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/samples/articles/samples.1.xml", nil); resp != "1" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLFormatInSubRouter(t *testing.T) {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Route("/articles/{articleID}", func(r chi.Router) {
|
||||||
|
r.Use(URLFormat)
|
||||||
|
r.Get("/subroute", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
articleID := chi.URLParam(r, "articleID")
|
||||||
|
w.Write([]byte(articleID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
if _, resp := testRequest(t, ts, "GET", "/articles/1/subroute.json", nil); resp != "1" {
|
||||||
|
t.Fatal(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
middleware/value.go
Normal file
17
middleware/value.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithValue is a middleware that sets a given key/value in a context chain.
|
||||||
|
func WithValue(key, val any) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), key, val))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
middleware/value_test.go
Normal file
33
middleware/value_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithValue(t *testing.T) {
|
||||||
|
ctxKey := "customKey"
|
||||||
|
ctxValue := "customValue"
|
||||||
|
request := func() *http.Request {
|
||||||
|
req, _ := http.NewRequest("GET", "/", nil)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
val := r.Context().Value(ctxKey).(string)
|
||||||
|
w.Write([]byte(val))
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(WithValue(ctxKey, ctxValue))
|
||||||
|
r.Get("/", testHandler)
|
||||||
|
|
||||||
|
r.ServeHTTP(w, request())
|
||||||
|
|
||||||
|
if w.Body.String() != ctxValue {
|
||||||
|
t.Errorf("value: context did not contain expected value")
|
||||||
|
}
|
||||||
|
}
|
||||||
245
middleware/wrap_writer.go
Normal file
245
middleware/wrap_writer.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// The original work was derived from Goji's middleware, source:
|
||||||
|
// https://github.com/zenazn/goji/tree/master/web/middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to
|
||||||
|
// hook into various parts of the response process.
|
||||||
|
func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter {
|
||||||
|
_, fl := w.(http.Flusher)
|
||||||
|
|
||||||
|
bw := basicWriter{ResponseWriter: w}
|
||||||
|
|
||||||
|
if protoMajor == 2 {
|
||||||
|
_, ps := w.(http.Pusher)
|
||||||
|
if fl && ps {
|
||||||
|
return &http2FancyWriter{bw}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, hj := w.(http.Hijacker)
|
||||||
|
_, rf := w.(io.ReaderFrom)
|
||||||
|
if fl && hj && rf {
|
||||||
|
return &httpFancyWriter{bw}
|
||||||
|
}
|
||||||
|
if fl && hj {
|
||||||
|
return &flushHijackWriter{bw}
|
||||||
|
}
|
||||||
|
if hj {
|
||||||
|
return &hijackWriter{bw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fl {
|
||||||
|
return &flushWriter{bw}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bw
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook
|
||||||
|
// into various parts of the response process.
|
||||||
|
type WrapResponseWriter interface {
|
||||||
|
http.ResponseWriter
|
||||||
|
// Status returns the HTTP status of the request, or 0 if one has not
|
||||||
|
// yet been sent.
|
||||||
|
Status() int
|
||||||
|
// BytesWritten returns the total number of bytes sent to the client.
|
||||||
|
BytesWritten() int
|
||||||
|
// Tee causes the response body to be written to the given io.Writer in
|
||||||
|
// addition to proxying the writes through. Only one io.Writer can be
|
||||||
|
// tee'd to at once: setting a second one will overwrite the first.
|
||||||
|
// Writes will be sent to the proxy before being written to this
|
||||||
|
// io.Writer. It is illegal for the tee'd writer to be modified
|
||||||
|
// concurrently with writes.
|
||||||
|
Tee(io.Writer)
|
||||||
|
// Unwrap returns the original proxied target.
|
||||||
|
Unwrap() http.ResponseWriter
|
||||||
|
// Discard causes all writes to the original ResponseWriter be discarded,
|
||||||
|
// instead writing only to the tee'd writer if it's set.
|
||||||
|
// The caller is responsible for calling WriteHeader and Write on the
|
||||||
|
// original ResponseWriter once the processing is done.
|
||||||
|
Discard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicWriter wraps a http.ResponseWriter that implements the minimal
|
||||||
|
// http.ResponseWriter interface.
|
||||||
|
type basicWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
tee io.Writer
|
||||||
|
code int
|
||||||
|
bytes int
|
||||||
|
wroteHeader bool
|
||||||
|
discard bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) WriteHeader(code int) {
|
||||||
|
if code >= 100 && code <= 199 && code != http.StatusSwitchingProtocols {
|
||||||
|
if !b.discard {
|
||||||
|
b.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
} else if !b.wroteHeader {
|
||||||
|
b.code = code
|
||||||
|
b.wroteHeader = true
|
||||||
|
if !b.discard {
|
||||||
|
b.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) Write(buf []byte) (n int, err error) {
|
||||||
|
b.maybeWriteHeader()
|
||||||
|
if !b.discard {
|
||||||
|
n, err = b.ResponseWriter.Write(buf)
|
||||||
|
if b.tee != nil {
|
||||||
|
_, err2 := b.tee.Write(buf[:n])
|
||||||
|
// Prefer errors generated by the proxied writer.
|
||||||
|
if err == nil {
|
||||||
|
err = err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if b.tee != nil {
|
||||||
|
n, err = b.tee.Write(buf)
|
||||||
|
} else {
|
||||||
|
n, err = io.Discard.Write(buf)
|
||||||
|
}
|
||||||
|
b.bytes += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) maybeWriteHeader() {
|
||||||
|
if !b.wroteHeader {
|
||||||
|
b.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) Status() int {
|
||||||
|
return b.code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) BytesWritten() int {
|
||||||
|
return b.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) Tee(w io.Writer) {
|
||||||
|
b.tee = w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) Unwrap() http.ResponseWriter {
|
||||||
|
return b.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *basicWriter) Discard() {
|
||||||
|
b.discard = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushWriter ...
|
||||||
|
type flushWriter struct {
|
||||||
|
basicWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushWriter) Flush() {
|
||||||
|
f.basicWriter.maybeWriteHeader()
|
||||||
|
fl := f.basicWriter.ResponseWriter.(http.Flusher)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Flusher = &flushWriter{}
|
||||||
|
|
||||||
|
// hijackWriter ...
|
||||||
|
type hijackWriter struct {
|
||||||
|
basicWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Hijacker = &hijackWriter{}
|
||||||
|
|
||||||
|
// flushHijackWriter ...
|
||||||
|
type flushHijackWriter struct {
|
||||||
|
basicWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushHijackWriter) Flush() {
|
||||||
|
f.basicWriter.maybeWriteHeader()
|
||||||
|
fl := f.basicWriter.ResponseWriter.(http.Flusher)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Flusher = &flushHijackWriter{}
|
||||||
|
var _ http.Hijacker = &flushHijackWriter{}
|
||||||
|
|
||||||
|
// httpFancyWriter is a HTTP writer that additionally satisfies
|
||||||
|
// http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case
|
||||||
|
// of wrapping the http.ResponseWriter that package http gives you, in order to
|
||||||
|
// make the proxied object support the full method set of the proxied object.
|
||||||
|
type httpFancyWriter struct {
|
||||||
|
basicWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *httpFancyWriter) Flush() {
|
||||||
|
f.basicWriter.maybeWriteHeader()
|
||||||
|
fl := f.basicWriter.ResponseWriter.(http.Flusher)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error {
|
||||||
|
return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||||
|
if f.basicWriter.tee != nil || f.basicWriter.discard {
|
||||||
|
// Route through basicWriter.Write so that the tee and discard semantics
|
||||||
|
// are honored (the fast ReaderFrom path below would bypass both, writing
|
||||||
|
// straight to the original ResponseWriter). basicWriter.Write already
|
||||||
|
// increments basicWriter.bytes, so we must NOT add n again here (that
|
||||||
|
// would double-count).
|
||||||
|
n, err := io.Copy(&f.basicWriter, r)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
rf := f.basicWriter.ResponseWriter.(io.ReaderFrom)
|
||||||
|
f.basicWriter.maybeWriteHeader()
|
||||||
|
n, err := rf.ReadFrom(r)
|
||||||
|
f.basicWriter.bytes += int(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Flusher = &httpFancyWriter{}
|
||||||
|
var _ http.Hijacker = &httpFancyWriter{}
|
||||||
|
var _ http.Pusher = &http2FancyWriter{}
|
||||||
|
var _ io.ReaderFrom = &httpFancyWriter{}
|
||||||
|
|
||||||
|
// http2FancyWriter is a HTTP2 writer that additionally satisfies
|
||||||
|
// http.Flusher, and io.ReaderFrom. It exists for the common case
|
||||||
|
// of wrapping the http.ResponseWriter that package http gives you, in order to
|
||||||
|
// make the proxied object support the full method set of the proxied object.
|
||||||
|
type http2FancyWriter struct {
|
||||||
|
basicWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *http2FancyWriter) Flush() {
|
||||||
|
f.basicWriter.maybeWriteHeader()
|
||||||
|
fl := f.basicWriter.ResponseWriter.(http.Flusher)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Flusher = &http2FancyWriter{}
|
||||||
184
middleware/wrap_writer_test.go
Normal file
184
middleware/wrap_writer_test.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readerFromRecorder is a ResponseRecorder that also satisfies io.ReaderFrom,
|
||||||
|
// mirroring the real net/http response writer (which implements ReadFrom and
|
||||||
|
// is what httpFancyWriter.ReadFrom fast-paths to). Bytes copied via ReadFrom
|
||||||
|
// are recorded to the body so a test can observe whether they reached the
|
||||||
|
// original writer.
|
||||||
|
type readerFromRecorder struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerFromRecorder) ReadFrom(src io.Reader) (int64, error) {
|
||||||
|
return io.Copy(r.ResponseRecorder.Body, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapResponseWriterStatusWhenFlushed(t *testing.T) {
|
||||||
|
for _, writer := range []struct {
|
||||||
|
name string
|
||||||
|
wrap func(http.ResponseWriter) WrapResponseWriter
|
||||||
|
}{
|
||||||
|
{"flushWriter", func(w http.ResponseWriter) WrapResponseWriter {
|
||||||
|
return &flushWriter{basicWriter{ResponseWriter: w}}
|
||||||
|
}},
|
||||||
|
{"flushHijackWriter", func(w http.ResponseWriter) WrapResponseWriter {
|
||||||
|
return &flushHijackWriter{basicWriter{ResponseWriter: w}}
|
||||||
|
}},
|
||||||
|
{"httpFancyWriter", func(w http.ResponseWriter) WrapResponseWriter {
|
||||||
|
return &httpFancyWriter{basicWriter{ResponseWriter: w}}
|
||||||
|
}},
|
||||||
|
{"http2FancyWriter", func(w http.ResponseWriter) WrapResponseWriter {
|
||||||
|
return &http2FancyWriter{basicWriter{ResponseWriter: w}}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(writer.name, func(t *testing.T) {
|
||||||
|
for _, status := range []struct {
|
||||||
|
name string
|
||||||
|
code int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"implicit", 0, http.StatusOK},
|
||||||
|
{"explicit", http.StatusCreated, http.StatusCreated},
|
||||||
|
} {
|
||||||
|
t.Run(status.name, func(t *testing.T) {
|
||||||
|
original := httptest.NewRecorder()
|
||||||
|
wrap := writer.wrap(original)
|
||||||
|
if status.code != 0 {
|
||||||
|
wrap.WriteHeader(status.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.(http.Flusher).Flush()
|
||||||
|
assertEqual(t, status.want, wrap.Status())
|
||||||
|
assertEqual(t, status.want, original.Code)
|
||||||
|
assertEqual(t, true, original.Flushed)
|
||||||
|
assertEqual(t, 0, wrap.BytesWritten())
|
||||||
|
|
||||||
|
// Flushing commits the status, even before any body is written.
|
||||||
|
wrap.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err := wrap.Write([]byte("body"))
|
||||||
|
assertNoError(t, err)
|
||||||
|
wrap.(http.Flusher).Flush()
|
||||||
|
assertEqual(t, status.want, wrap.Status())
|
||||||
|
assertEqual(t, status.want, original.Code)
|
||||||
|
assertEqual(t, "body", original.Body.String())
|
||||||
|
assertEqual(t, 4, wrap.BytesWritten())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicWritesTeesWritesWithoutDiscard(t *testing.T) {
|
||||||
|
// explicitly create the struct instead of NewRecorder to control the value of Code
|
||||||
|
original := &httptest.ResponseRecorder{
|
||||||
|
HeaderMap: make(http.Header),
|
||||||
|
Body: new(bytes.Buffer),
|
||||||
|
}
|
||||||
|
wrap := &basicWriter{ResponseWriter: original}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
wrap.Tee(&buf)
|
||||||
|
|
||||||
|
_, err := wrap.Write([]byte("hello world"))
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
assertEqual(t, 200, original.Code)
|
||||||
|
assertEqual(t, []byte("hello world"), original.Body.Bytes())
|
||||||
|
assertEqual(t, []byte("hello world"), buf.Bytes())
|
||||||
|
assertEqual(t, 11, wrap.BytesWritten())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicWriterDiscardsWritesToOriginalResponseWriter(t *testing.T) {
|
||||||
|
t.Run("With Tee", func(t *testing.T) {
|
||||||
|
// explicitly create the struct instead of NewRecorder to control the value of Code
|
||||||
|
original := &httptest.ResponseRecorder{
|
||||||
|
HeaderMap: make(http.Header),
|
||||||
|
Body: new(bytes.Buffer),
|
||||||
|
}
|
||||||
|
wrap := &basicWriter{ResponseWriter: original}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
wrap.Tee(&buf)
|
||||||
|
wrap.Discard()
|
||||||
|
|
||||||
|
_, err := wrap.Write([]byte("hello world"))
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
assertEqual(t, 0, original.Code) // wrapper shouldn't call WriteHeader implicitly
|
||||||
|
assertEqual(t, 0, original.Body.Len())
|
||||||
|
assertEqual(t, []byte("hello world"), buf.Bytes())
|
||||||
|
assertEqual(t, 11, wrap.BytesWritten())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Without Tee", func(t *testing.T) {
|
||||||
|
// explicitly create the struct instead of NewRecorder to control the value of Code
|
||||||
|
original := &httptest.ResponseRecorder{
|
||||||
|
HeaderMap: make(http.Header),
|
||||||
|
Body: new(bytes.Buffer),
|
||||||
|
}
|
||||||
|
wrap := &basicWriter{ResponseWriter: original}
|
||||||
|
wrap.Discard()
|
||||||
|
|
||||||
|
_, err := wrap.Write([]byte("hello world"))
|
||||||
|
assertNoError(t, err)
|
||||||
|
|
||||||
|
assertEqual(t, 0, original.Code) // wrapper shouldn't call WriteHeader implicitly
|
||||||
|
assertEqual(t, 0, original.Body.Len())
|
||||||
|
assertEqual(t, 11, wrap.BytesWritten())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpFancyWriterReadFromByteCountWithTee is a regression test for
|
||||||
|
// https://github.com/go-chi/chi/issues/1067.
|
||||||
|
// httpFancyWriter.ReadFrom was adding n to basicWriter.bytes even when the
|
||||||
|
// write went through basicWriter.Write (which already increments the counter),
|
||||||
|
// resulting in double-counting the bytes when a Tee writer was set.
|
||||||
|
func TestHttpFancyWriterReadFromByteCountWithTee(t *testing.T) {
|
||||||
|
original := &httptest.ResponseRecorder{
|
||||||
|
HeaderMap: make(http.Header),
|
||||||
|
Body: new(bytes.Buffer),
|
||||||
|
}
|
||||||
|
f := &httpFancyWriter{basicWriter: basicWriter{ResponseWriter: original}}
|
||||||
|
|
||||||
|
var teeBuf bytes.Buffer
|
||||||
|
f.Tee(&teeBuf)
|
||||||
|
|
||||||
|
const input = "hello world"
|
||||||
|
n, err := f.ReadFrom(strings.NewReader(input))
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, int64(len(input)), n)
|
||||||
|
// Before the fix, BytesWritten() returned 22 (double-counted).
|
||||||
|
assertEqual(t, len(input), f.BytesWritten())
|
||||||
|
assertEqual(t, []byte(input), teeBuf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpFancyWriterReadFromHonorsDiscard verifies that Discard() suppresses
|
||||||
|
// the body even when it is written via ReadFrom/io.Copy. Before the fix, the
|
||||||
|
// non-tee ReadFrom path streamed straight to the original ResponseWriter's
|
||||||
|
// ReaderFrom, bypassing the discard flag entirely.
|
||||||
|
func TestHttpFancyWriterReadFromHonorsDiscard(t *testing.T) {
|
||||||
|
original := &readerFromRecorder{&httptest.ResponseRecorder{
|
||||||
|
HeaderMap: make(http.Header),
|
||||||
|
Body: new(bytes.Buffer),
|
||||||
|
}}
|
||||||
|
f := &httpFancyWriter{basicWriter: basicWriter{ResponseWriter: original}}
|
||||||
|
f.Discard()
|
||||||
|
|
||||||
|
const input = "hello world"
|
||||||
|
n, err := f.ReadFrom(strings.NewReader(input))
|
||||||
|
assertNoError(t, err)
|
||||||
|
assertEqual(t, int64(len(input)), n)
|
||||||
|
// The data must NOT reach the original writer once Discard() is set.
|
||||||
|
assertEqual(t, 0, original.Body.Len())
|
||||||
|
// BytesWritten still reflects the bytes consumed.
|
||||||
|
assertEqual(t, len(input), f.BytesWritten())
|
||||||
|
}
|
||||||
540
mux.go
Normal file
540
mux.go
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
package chi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ Router = &Mux{}
|
||||||
|
|
||||||
|
// Mux is a simple HTTP route multiplexer that parses a request path,
|
||||||
|
// records any URL params, and executes an end handler. It implements
|
||||||
|
// the http.Handler interface and is friendly with the standard library.
|
||||||
|
//
|
||||||
|
// Mux is designed to be fast, minimal and offer a powerful API for building
|
||||||
|
// modular and composable HTTP services with a large set of handlers. It's
|
||||||
|
// particularly useful for writing large REST API services that break a handler
|
||||||
|
// into many smaller parts composed of middlewares and end handlers.
|
||||||
|
type Mux struct {
|
||||||
|
// The computed mux handler made of the chained middleware stack and
|
||||||
|
// the tree router
|
||||||
|
handler http.Handler
|
||||||
|
|
||||||
|
// The radix trie router
|
||||||
|
tree *node
|
||||||
|
|
||||||
|
// Custom method not allowed handler
|
||||||
|
methodNotAllowedHandler http.HandlerFunc
|
||||||
|
|
||||||
|
// A reference to the parent mux used by subrouters when mounting
|
||||||
|
// to a parent mux
|
||||||
|
parent *Mux
|
||||||
|
|
||||||
|
// Routing context pool
|
||||||
|
pool *sync.Pool
|
||||||
|
|
||||||
|
// Custom route not found handler
|
||||||
|
notFoundHandler http.HandlerFunc
|
||||||
|
|
||||||
|
// The middleware stack
|
||||||
|
middlewares []func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
// Controls the behaviour of middleware chain generation when a mux
|
||||||
|
// is registered as an inline group inside another mux.
|
||||||
|
inline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMux returns a newly initialized Mux object that implements the Router
|
||||||
|
// interface.
|
||||||
|
func NewMux() *Mux {
|
||||||
|
mux := &Mux{tree: &node{}, pool: &sync.Pool{}}
|
||||||
|
mux.pool.New = func() any {
|
||||||
|
return NewRouteContext()
|
||||||
|
}
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP is the single method of the http.Handler interface that makes
|
||||||
|
// Mux interoperable with the standard library. It uses a sync.Pool to get and
|
||||||
|
// reuse routing contexts for each request.
|
||||||
|
func (mx *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Ensure the mux has some routes defined on the mux
|
||||||
|
if mx.handler == nil {
|
||||||
|
mx.NotFoundHandler().ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a routing context already exists from a parent router.
|
||||||
|
rctx, _ := r.Context().Value(RouteCtxKey).(*Context)
|
||||||
|
if rctx != nil {
|
||||||
|
mx.handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a RouteContext object from the sync pool, and call the computed
|
||||||
|
// mx.handler that is comprised of mx.middlewares + mx.routeHTTP.
|
||||||
|
// Once the request is finished, reset the routing context and put it back
|
||||||
|
// into the pool for reuse from another request.
|
||||||
|
rctx = mx.pool.Get().(*Context)
|
||||||
|
rctx.Reset()
|
||||||
|
rctx.Routes = mx
|
||||||
|
rctx.parentCtx = r.Context()
|
||||||
|
|
||||||
|
// NOTE: r.WithContext() causes 2 allocations and context.WithValue() causes 1 allocation
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), RouteCtxKey, rctx))
|
||||||
|
|
||||||
|
// Serve the request and once its done, put the request context back in the sync pool
|
||||||
|
mx.handler.ServeHTTP(w, r)
|
||||||
|
mx.pool.Put(rctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use appends a middleware handler to the Mux middleware stack.
|
||||||
|
//
|
||||||
|
// The middleware stack for any Mux will execute before searching for a matching
|
||||||
|
// route to a specific handler, which provides opportunity to respond early,
|
||||||
|
// change the course of the request execution, or set request-scoped values for
|
||||||
|
// the next http.Handler.
|
||||||
|
func (mx *Mux) Use(middlewares ...func(http.Handler) http.Handler) {
|
||||||
|
if mx.handler != nil {
|
||||||
|
panic("chi: all middlewares must be defined before routes on a mux")
|
||||||
|
}
|
||||||
|
mx.middlewares = append(mx.middlewares, middlewares...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle adds the route `pattern` that matches any http method to
|
||||||
|
// execute the `handler` http.Handler.
|
||||||
|
func (mx *Mux) Handle(pattern string, handler http.Handler) {
|
||||||
|
if i := strings.IndexAny(pattern, " \t"); i >= 0 {
|
||||||
|
method, rest := pattern[:i], strings.TrimLeft(pattern[i+1:], " \t")
|
||||||
|
mx.Method(method, rest, handler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mx.handle(mALL, pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFunc adds the route `pattern` that matches any http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) HandleFunc(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.Handle(pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method adds the route `pattern` that matches `method` http method to
|
||||||
|
// execute the `handler` http.Handler.
|
||||||
|
func (mx *Mux) Method(method, pattern string, handler http.Handler) {
|
||||||
|
m, ok := methodMap[strings.ToUpper(method)]
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("chi: '%s' http method is not supported.", method))
|
||||||
|
}
|
||||||
|
mx.handle(m, pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodFunc adds the route `pattern` that matches `method` http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) MethodFunc(method, pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.Method(method, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect adds the route `pattern` that matches a CONNECT http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Connect(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mCONNECT, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete adds the route `pattern` that matches a DELETE http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Delete(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mDELETE, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get adds the route `pattern` that matches a GET http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Get(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mGET, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Head adds the route `pattern` that matches a HEAD http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Head(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mHEAD, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options adds the route `pattern` that matches an OPTIONS http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Options(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mOPTIONS, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch adds the route `pattern` that matches a PATCH http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Patch(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mPATCH, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post adds the route `pattern` that matches a POST http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Post(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mPOST, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put adds the route `pattern` that matches a PUT http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Put(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mPUT, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query adds the route `pattern` that matches a QUERY http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Query(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mQUERY, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace adds the route `pattern` that matches a TRACE http method to
|
||||||
|
// execute the `handlerFn` http.HandlerFunc.
|
||||||
|
func (mx *Mux) Trace(pattern string, handlerFn http.HandlerFunc) {
|
||||||
|
mx.handle(mTRACE, pattern, handlerFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFound sets a custom http.HandlerFunc for routing paths that could
|
||||||
|
// not be found. The default 404 handler is `http.NotFound`.
|
||||||
|
func (mx *Mux) NotFound(handlerFn http.HandlerFunc) {
|
||||||
|
// Build NotFound handler chain
|
||||||
|
m := mx
|
||||||
|
hFn := handlerFn
|
||||||
|
if mx.inline && mx.parent != nil {
|
||||||
|
m = mx.parent
|
||||||
|
hFn = Chain(mx.middlewares...).HandlerFunc(hFn).ServeHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the notFoundHandler from this point forward
|
||||||
|
m.notFoundHandler = hFn
|
||||||
|
m.updateSubRoutes(func(subMux *Mux) {
|
||||||
|
if subMux.notFoundHandler == nil {
|
||||||
|
subMux.NotFound(hFn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodNotAllowed sets a custom http.HandlerFunc for routing paths where the
|
||||||
|
// method is unresolved. The default handler returns a 405 with an empty body.
|
||||||
|
func (mx *Mux) MethodNotAllowed(handlerFn http.HandlerFunc) {
|
||||||
|
// Build MethodNotAllowed handler chain
|
||||||
|
m := mx
|
||||||
|
hFn := handlerFn
|
||||||
|
if mx.inline && mx.parent != nil {
|
||||||
|
m = mx.parent
|
||||||
|
hFn = Chain(mx.middlewares...).HandlerFunc(hFn).ServeHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the methodNotAllowedHandler from this point forward
|
||||||
|
m.methodNotAllowedHandler = hFn
|
||||||
|
m.updateSubRoutes(func(subMux *Mux) {
|
||||||
|
if subMux.methodNotAllowedHandler == nil {
|
||||||
|
subMux.MethodNotAllowed(hFn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// With adds inline middlewares for an endpoint handler.
|
||||||
|
func (mx *Mux) With(middlewares ...func(http.Handler) http.Handler) Router {
|
||||||
|
// Similarly as in handle(), we must build the mux handler once additional
|
||||||
|
// middleware registration isn't allowed for this stack, like now.
|
||||||
|
if !mx.inline && mx.handler == nil {
|
||||||
|
mx.updateRouteHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy middlewares from parent inline muxs
|
||||||
|
var mws Middlewares
|
||||||
|
if mx.inline {
|
||||||
|
mws = make(Middlewares, len(mx.middlewares))
|
||||||
|
copy(mws, mx.middlewares)
|
||||||
|
}
|
||||||
|
mws = append(mws, middlewares...)
|
||||||
|
|
||||||
|
im := &Mux{
|
||||||
|
pool: mx.pool, inline: true, parent: mx, tree: mx.tree, middlewares: mws,
|
||||||
|
notFoundHandler: mx.notFoundHandler, methodNotAllowedHandler: mx.methodNotAllowedHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
return im
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group creates a new inline-Mux with a copy of middleware stack. It's useful
|
||||||
|
// for a group of handlers along the same routing path that use an additional
|
||||||
|
// set of middlewares. See _examples/.
|
||||||
|
func (mx *Mux) Group(fn func(r Router)) Router {
|
||||||
|
im := mx.With()
|
||||||
|
if fn != nil {
|
||||||
|
fn(im)
|
||||||
|
}
|
||||||
|
return im
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route creates a new Mux and mounts it along the `pattern` as a subrouter.
|
||||||
|
// Effectively, this is a short-hand call to Mount. See _examples/.
|
||||||
|
func (mx *Mux) Route(pattern string, fn func(r Router)) Router {
|
||||||
|
if fn == nil {
|
||||||
|
panic(fmt.Sprintf("chi: attempting to Route() a nil subrouter on '%s'", pattern))
|
||||||
|
}
|
||||||
|
subRouter := NewRouter()
|
||||||
|
fn(subRouter)
|
||||||
|
mx.Mount(pattern, subRouter)
|
||||||
|
return subRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount attaches another http.Handler or chi Router as a subrouter along a routing
|
||||||
|
// path. It's very useful to split up a large API as many independent routers and
|
||||||
|
// compose them as a single service using Mount. See _examples/.
|
||||||
|
//
|
||||||
|
// Note that Mount() simply sets a wildcard along the `pattern` that will continue
|
||||||
|
// routing at the `handler`, which in most cases is another chi.Router. As a result,
|
||||||
|
// if you define two Mount() routes on the exact same pattern the mount will panic.
|
||||||
|
func (mx *Mux) Mount(pattern string, handler http.Handler) {
|
||||||
|
if handler == nil {
|
||||||
|
panic(fmt.Sprintf("chi: attempting to Mount() a nil handler on '%s'", pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide runtime safety for ensuring a pattern isn't mounted on an existing
|
||||||
|
// routing pattern.
|
||||||
|
if mx.tree.findPattern(pattern+"*") || mx.tree.findPattern(pattern+"/*") {
|
||||||
|
panic(fmt.Sprintf("chi: attempting to Mount() a handler on an existing path, '%s'", pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign sub-Router's with the parent not found & method not allowed handler if not specified.
|
||||||
|
subr, ok := handler.(*Mux)
|
||||||
|
|
||||||
|
// Provide runtime safety for ensuring a router isn't mounted onto itself,
|
||||||
|
// which includes an inline mux derived from it via With()/Group() since
|
||||||
|
// that shares the same underlying tree. See issue #843.
|
||||||
|
if ok && subr.tree == mx.tree {
|
||||||
|
panic(fmt.Sprintf("chi: attempting to Mount() a router onto itself on '%s'", pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok && subr.notFoundHandler == nil && mx.notFoundHandler != nil {
|
||||||
|
subr.NotFound(mx.notFoundHandler)
|
||||||
|
}
|
||||||
|
if ok && subr.methodNotAllowedHandler == nil && mx.methodNotAllowedHandler != nil {
|
||||||
|
subr.MethodNotAllowed(mx.methodNotAllowedHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
mountHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rctx := RouteContext(r.Context())
|
||||||
|
|
||||||
|
// shift the url path past the previous subrouter
|
||||||
|
rctx.RoutePath = mx.nextRoutePath(rctx)
|
||||||
|
|
||||||
|
// reset the wildcard URLParam which connects the subrouter
|
||||||
|
n := len(rctx.URLParams.Keys) - 1
|
||||||
|
if n >= 0 && rctx.URLParams.Keys[n] == "*" && len(rctx.URLParams.Values) > n {
|
||||||
|
rctx.URLParams.Values[n] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
if pattern == "" || pattern[len(pattern)-1] != '/' {
|
||||||
|
mx.handle(mALL|mSTUB, pattern, mountHandler)
|
||||||
|
mx.handle(mALL|mSTUB, pattern+"/", mountHandler)
|
||||||
|
pattern += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
method := mALL
|
||||||
|
subroutes, _ := handler.(Routes)
|
||||||
|
if subroutes != nil {
|
||||||
|
method |= mSTUB
|
||||||
|
}
|
||||||
|
n := mx.handle(method, pattern+"*", mountHandler)
|
||||||
|
|
||||||
|
if subroutes != nil {
|
||||||
|
n.subroutes = subroutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns a slice of routing information from the tree,
|
||||||
|
// useful for traversing available routes of a router.
|
||||||
|
func (mx *Mux) Routes() []Route {
|
||||||
|
return mx.tree.routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middlewares returns a slice of middleware handler functions.
|
||||||
|
func (mx *Mux) Middlewares() Middlewares {
|
||||||
|
return mx.middlewares
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match searches the routing tree for a handler that matches the method/path.
|
||||||
|
// It's similar to routing a http request, but without executing the handler
|
||||||
|
// thereafter.
|
||||||
|
//
|
||||||
|
// Note: the *Context state is updated during execution, so manage
|
||||||
|
// the state carefully or make a NewRouteContext().
|
||||||
|
func (mx *Mux) Match(rctx *Context, method, path string) bool {
|
||||||
|
return mx.Find(rctx, method, path) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find searches the routing tree for the pattern that matches
|
||||||
|
// the method/path.
|
||||||
|
//
|
||||||
|
// Note: the *Context state is updated during execution, so manage
|
||||||
|
// the state carefully or make a NewRouteContext().
|
||||||
|
func (mx *Mux) Find(rctx *Context, method, path string) string {
|
||||||
|
m, ok := methodMap[method]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
node, _, _ := mx.tree.FindRoute(rctx, m, path)
|
||||||
|
pattern := rctx.routePattern
|
||||||
|
|
||||||
|
if node != nil {
|
||||||
|
if node.subroutes == nil {
|
||||||
|
e := node.endpoints[m]
|
||||||
|
return e.pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx.RoutePath = mx.nextRoutePath(rctx)
|
||||||
|
subPattern := node.subroutes.Find(rctx, method, rctx.RoutePath)
|
||||||
|
if subPattern == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = strings.TrimSuffix(pattern, "/*")
|
||||||
|
pattern += subPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFoundHandler returns the default Mux 404 responder whenever a route
|
||||||
|
// cannot be found.
|
||||||
|
func (mx *Mux) NotFoundHandler() http.HandlerFunc {
|
||||||
|
if mx.notFoundHandler != nil {
|
||||||
|
return mx.notFoundHandler
|
||||||
|
}
|
||||||
|
return http.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodNotAllowedHandler returns the default Mux 405 responder whenever
|
||||||
|
// a method cannot be resolved for a route.
|
||||||
|
func (mx *Mux) MethodNotAllowedHandler(methodsAllowed ...methodTyp) http.HandlerFunc {
|
||||||
|
if mx.methodNotAllowedHandler != nil {
|
||||||
|
return mx.methodNotAllowedHandler
|
||||||
|
}
|
||||||
|
return methodNotAllowedHandler(methodsAllowed...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle registers a http.Handler in the routing tree for a particular http method
|
||||||
|
// and routing pattern.
|
||||||
|
func (mx *Mux) handle(method methodTyp, pattern string, handler http.Handler) *node {
|
||||||
|
if len(pattern) == 0 || pattern[0] != '/' {
|
||||||
|
panic(fmt.Sprintf("chi: routing pattern must begin with '/' in '%s'", pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the computed routing handler for this routing pattern.
|
||||||
|
if !mx.inline && mx.handler == nil {
|
||||||
|
mx.updateRouteHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build endpoint handler with inline middlewares for the route
|
||||||
|
var h http.Handler
|
||||||
|
if mx.inline {
|
||||||
|
mx.handler = http.HandlerFunc(mx.routeHTTP)
|
||||||
|
h = Chain(mx.middlewares...).Handler(handler)
|
||||||
|
} else {
|
||||||
|
h = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the endpoint to the tree and return the node
|
||||||
|
return mx.tree.InsertRoute(method, pattern, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeHTTP routes a http.Request through the Mux routing tree to serve
|
||||||
|
// the matching handler for a particular http method.
|
||||||
|
func (mx *Mux) routeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Grab the route context object
|
||||||
|
rctx := r.Context().Value(RouteCtxKey).(*Context)
|
||||||
|
|
||||||
|
// The request routing path
|
||||||
|
routePath := rctx.RoutePath
|
||||||
|
if routePath == "" {
|
||||||
|
if r.URL.RawPath != "" {
|
||||||
|
routePath = r.URL.RawPath
|
||||||
|
} else {
|
||||||
|
routePath = r.URL.Path
|
||||||
|
}
|
||||||
|
if routePath == "" {
|
||||||
|
routePath = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if method is supported by chi
|
||||||
|
if rctx.RouteMethod == "" {
|
||||||
|
rctx.RouteMethod = r.Method
|
||||||
|
}
|
||||||
|
method, ok := methodMap[rctx.RouteMethod]
|
||||||
|
if !ok {
|
||||||
|
mx.MethodNotAllowedHandler().ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the route
|
||||||
|
if _, _, h := mx.tree.FindRoute(rctx, method, routePath); h != nil {
|
||||||
|
// Set http.Request path values from our request context
|
||||||
|
for i, key := range rctx.URLParams.Keys {
|
||||||
|
value := rctx.URLParams.Values[i]
|
||||||
|
r.SetPathValue(key, value)
|
||||||
|
}
|
||||||
|
r.Pattern = rctx.RoutePattern()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rctx.methodNotAllowed {
|
||||||
|
mx.MethodNotAllowedHandler(rctx.methodsAllowed...).ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
mx.NotFoundHandler().ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mx *Mux) nextRoutePath(rctx *Context) string {
|
||||||
|
routePath := "/"
|
||||||
|
nx := len(rctx.routeParams.Keys) - 1 // index of last param in list
|
||||||
|
if nx >= 0 && rctx.routeParams.Keys[nx] == "*" && len(rctx.routeParams.Values) > nx {
|
||||||
|
routePath = "/" + rctx.routeParams.Values[nx]
|
||||||
|
}
|
||||||
|
return routePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively update data on child routers.
|
||||||
|
func (mx *Mux) updateSubRoutes(fn func(subMux *Mux)) {
|
||||||
|
for _, r := range mx.tree.routes() {
|
||||||
|
subMux, ok := r.SubRoutes.(*Mux)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn(subMux)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRouteHandler builds the single mux handler that is a chain of the middleware
|
||||||
|
// stack, as defined by calls to Use(), and the tree router (Mux) itself. After this
|
||||||
|
// point, no other middlewares can be registered on this Mux's stack. But you can still
|
||||||
|
// compose additional middlewares via Group()'s or using a chained middleware handler.
|
||||||
|
func (mx *Mux) updateRouteHandler() {
|
||||||
|
mx.handler = chain(mx.middlewares, http.HandlerFunc(mx.routeHTTP))
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodNotAllowedHandler is a helper function to respond with a 405,
|
||||||
|
// method not allowed. It sets the Allow header with the list of allowed
|
||||||
|
// methods for the route.
|
||||||
|
func methodNotAllowedHandler(methodsAllowed ...methodTyp) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, m := range methodsAllowed {
|
||||||
|
w.Header().Add("Allow", reverseMethodMap[m])
|
||||||
|
}
|
||||||
|
w.WriteHeader(405)
|
||||||
|
w.Write(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
2161
mux_test.go
Normal file
2161
mux_test.go
Normal file
File diff suppressed because it is too large
Load Diff
74
path_value_test.go
Normal file
74
path_value_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package chi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathValue(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
method string
|
||||||
|
requestPath string
|
||||||
|
expectedBody string
|
||||||
|
pathKeys []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Basic path value",
|
||||||
|
pattern: "/hubs/{hubID}",
|
||||||
|
method: "GET",
|
||||||
|
pathKeys: []string{"hubID"},
|
||||||
|
requestPath: "/hubs/392",
|
||||||
|
expectedBody: "392",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Two path values",
|
||||||
|
pattern: "/users/{userID}/conversations/{conversationID}",
|
||||||
|
method: "POST",
|
||||||
|
pathKeys: []string{"userID", "conversationID"},
|
||||||
|
requestPath: "/users/Gojo/conversations/2948",
|
||||||
|
expectedBody: "Gojo 2948",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wildcard path",
|
||||||
|
pattern: "/users/{userID}/friends/*",
|
||||||
|
method: "POST",
|
||||||
|
pathKeys: []string{"userID", "*"},
|
||||||
|
requestPath: "/users/Gojo/friends/all-of-them/and/more",
|
||||||
|
expectedBody: "Gojo all-of-them/and/more",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := NewRouter()
|
||||||
|
|
||||||
|
r.Handle(tc.method+" "+tc.pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pathValues := []string{}
|
||||||
|
for _, pathKey := range tc.pathKeys {
|
||||||
|
pathValue := r.PathValue(pathKey)
|
||||||
|
if pathValue == "" {
|
||||||
|
pathValue = "NOT_FOUND:" + pathKey
|
||||||
|
}
|
||||||
|
|
||||||
|
pathValues = append(pathValues, pathValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.Join(pathValues, " ")
|
||||||
|
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts := httptest.NewServer(r)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, body := testRequest(t, ts, tc.method, tc.requestPath, nil)
|
||||||
|
if body != tc.expectedBody {
|
||||||
|
t.Fatalf("expecting %q, got %q", tc.expectedBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user