Repository: bnkamalesh/webgo Branch: master Commit: 8ee6c6e23f3b Files: 52 Total size: 150.0 KB Directory structure: gitextract_26pnjm80/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── go.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _config.yml ├── cmd/ │ ├── README.md │ ├── certs/ │ │ ├── CA.key │ │ ├── CA.pem │ │ ├── CA.srl │ │ ├── localhost.crt │ │ ├── localhost.csr │ │ ├── localhost.decrypted.key │ │ ├── localhost.ext │ │ └── localhost.key │ ├── handlers.go │ ├── main.go │ └── static/ │ ├── css/ │ │ ├── main.css │ │ └── normalize.css │ ├── index.html │ └── js/ │ ├── main.js │ └── sse.js ├── config.go ├── config_test.go ├── errors.go ├── errors_test.go ├── extensions/ │ └── sse/ │ ├── README.md │ ├── client.go │ ├── message.go │ └── sse.go ├── go.mod ├── go.sum ├── middleware/ │ ├── accesslog/ │ │ ├── accesslog.go │ │ └── accesslog_test.go │ └── cors/ │ ├── cors.go │ └── cors_test.go ├── responses.go ├── responses_test.go ├── route.go ├── route_test.go ├── router.go ├── router_test.go ├── tests/ │ ├── config.json │ └── ssl/ │ ├── server.crt │ ├── server.csr │ └── server.key ├── webgo.go └── webgo_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [bnkamalesh] # 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 lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/go.yml ================================================ # This workflow will build a golang project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go name: Go on: push: branches: ["master"] pull_request: branches: ["master"] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: go-version: "1.23" - name: Build run: go build -v ./... - name: Tests run: | go install github.com/mattn/goveralls@latest go test -covermode atomic -coverprofile=covprofile $(go list ./... | grep -v /cmd | grep -v /extensions/) - name: Send coverage uses: shogo82148/actions-goveralls@v1 with: path-to-profile: covprofile - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: version: v1.60 ================================================ FILE: .gitignore ================================================ # Created by https://www.gitignore.io/api/go,osx,linux,windows ### Go ### # Binaries for programs and plugins *.exe *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ # Golang project vendor packages which should be ignored vendor/ ### Linux ### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### OSX ### *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Windows ### # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk # End of https://www.gitignore.io/api/go,osx,linux,windows .vscode ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at bn_kamalesh@yahoo.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ Contributions are welcome from everyone. Please adhere to the [code of conduct](https://github.com/naughtygopher/webgo/blob/master/CODE_OF_CONDUCT.md) of the project, and be respectful to all. Please follow the guidelines provided for contribution 1. Updates to the project are only accepted via Pull Requests (PR) 2. Pull requests will be reviewed & tested 3. Every PR should be accompanied by its test wherever applicable 4. While creating an issue 1. Mention the steps to reproduce the issue 2. Mention the environment in which it was run 3. Include your 1st level of troubleshooting results 5. Provide meaningful commit messages ### Versioning & PR messages WebGo tries to use [semantic versioning](https://semver.org/) and starting recently, have decided to adhere to the following syntax in PR description. List down the changes as bulleted list, as follows: ```markdown [major] any backward incompatible or breaking change [minor] any new feature [patch] enhancements of existing features, refactor, bug fix etc. [-] for changes which does not require a version number update ``` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Naughty Gopher 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. ================================================ FILE: README.md ================================================

webgo gopher

[![](https://github.com/naughtygopher/webgo/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/naughtygopher/webgo/actions) [![Go Reference](https://pkg.go.dev/badge/github.com/naughtygopher/webgo.svg)](https://pkg.go.dev/github.com/naughtygopher/webgo) [![Go Report Card](https://goreportcard.com/badge/github.com/naughtygopher/webgo)](https://goreportcard.com/report/github.com/naughtygopher/webgo) [![Coverage Status](https://coveralls.io/repos/github/naughtygopher/webgo/badge.svg?branch=master)](https://coveralls.io/github/naughtygopher/webgo?branch=master) [![](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go#web-frameworks) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/creativecreature/sturdyc/blob/master/LICENSE) # WebGo v7.0.4 WebGo is a minimalistic router for [Go](https://golang.org) to build web applications (server side) with no 3rd party dependencies. WebGo will always be Go standard library compliant; with the HTTP handlers having the same signature as [http.HandlerFunc](https://golang.org/pkg/net/http/#HandlerFunc). ### Contents 1. [Router](https://github.com/naughtygopher/webgo#router) 2. [Handler chaining](https://github.com/naughtygopher/webgo#handler-chaining) 3. [Middleware](https://github.com/naughtygopher/webgo#middleware) 4. [Error handling](https://github.com/naughtygopher/webgo#error-handling) 5. [Helper functions](https://github.com/naughtygopher/webgo#helper-functions) 6. [HTTPS ready](https://github.com/naughtygopher/webgo#https-ready) 7. [Graceful shutdown](https://github.com/naughtygopher/webgo#graceful-shutdown) 8. [Logging](https://github.com/naughtygopher/webgo#logging) 9. [Server-Sent Events](https://github.com/naughtygopher/webgo#server-sent-events) 10. [Usage](https://github.com/naughtygopher/webgo#usage) ## Router Webgo has a simplistic, linear path matching router and supports defining [URI](https://developer.mozilla.org/en-US/docs/Glossary/URI)s with the following patterns 1. `/api/users` - URI with no dynamic values 2. `/api/users/:userID` - URI with a named parameter, `userID` - If TrailingSlash is set to true, it will accept the URI ending with a '/', refer to [sample](https://github.com/naughtygopher/webgo#sample) 3. `/api/users/:misc*` - Named URI parameter `misc`, with a wildcard suffix '\*' - This matches everything after `/api/users`. e.g. `/api/users/a/b/c/d` When there are multiple handlers matching the same URI, only the first occurring handler will handle the request. Refer to the [sample](https://github.com/naughtygopher/webgo#sample) to see how routes are configured. You can access named parameters of the URI using the `Context` function. Note: webgo Context is **not** available inside the special handlers (not found & method not implemented) ```golang func helloWorld(w http.ResponseWriter, r *http.Request) { // WebGo context wctx := webgo.Context(r) // URI paramaters, map[string]string params := wctx.Params() // route, the webgo.Route which is executing this request route := wctx.Route webgo.R200( w, fmt.Sprintf( "Route name: '%s', params: '%s'", route.Name, params, ), ) } ``` ## Handler chaining Handler chaining lets you execute multiple handlers for a given route. Execution of a chain can be configured to run even after a handler has written a response to the HTTP request, if you set `FallThroughPostResponse` to `true` (refer [sample](https://github.com/naughtygopher/webgo/blob/master/cmd/main.go#L70)). ## Middleware WebGo [middlware](https://godoc.org/github.com/naughtygopher/webgo#Middleware) lets you wrap all the routes with a middleware unlike handler chaining. The router exposes a method [Use](https://godoc.org/github.com/naughtygopher/webgo#Router.Use) && [UseOnSpecialHandlers](https://godoc.org/github.com/naughtygopher/webgo#Router.UseOnSpecialHandlers) to add a Middleware to the router. NotFound && NotImplemented are considered `Special` handlers. `webgo.Context(r)` within special handlers will return `nil`. Any number of middleware can be added to the router, the order of execution of middleware would be [LIFO]() (Last In First Out). i.e. in case of the following code ```golang func main() { router.Use(accesslog.AccessLog, cors.CORS(nil)) router.Use() } ``` **_CorsWrap_** would be executed first, followed by **_AccessLog_**. ## Error handling Webgo context has 2 methods to [set](https://github.com/naughtygopher/webgo/blob/master/webgo.go#L60) & [get](https://github.com/naughtygopher/webgo/blob/master/webgo.go#L66) erro within a request context. It enables Webgo to implement a single middleware where you can handle error returned within an HTTP handler. [set error](https://github.com/naughtygopher/webgo/blob/master/cmd/main.go#L45), [get error](https://github.com/naughtygopher/webgo/blob/master/cmd/main.go#L51). ## Helper functions WebGo provides a few helper functions. When using `Send` or `SendResponse` (other Rxxx responder functions), the response is wrapped in WebGo's [response struct](https://github.com/naughtygopher/webgo/blob/master/responses.go#L17) and is serialized as JSON. ```json { "data": "", "status": "" } ``` When using `SendError`, the response is wrapped in WebGo's [error response struct](https://github.com/naughtygopher/webgo/blob/master/responses.go#L23) and is serialzied as JSON. ```json { "errors": "", "status": "" } ``` ## HTTPS ready HTTPS server can be started easily, by providing the key & cert file. You can also have both HTTP & HTTPS servers running side by side. Start HTTPS server ```golang cfg := &webgo.Config{ Port: "80", HTTPSPort: "443", CertFile: "/path/to/certfile", KeyFile: "/path/to/keyfile", } router := webgo.NewRouter(cfg, routes()...) router.StartHTTPS() ``` Starting both HTTP & HTTPS server ```golang cfg := &webgo.Config{ Port: "80", HTTPSPort: "443", CertFile: "/path/to/certfile", KeyFile: "/path/to/keyfile", } router := webgo.NewRouter(cfg, routes()...) go router.StartHTTPS() router.Start() ``` ## Graceful shutdown Graceful shutdown lets you shutdown the server without affecting any live connections/clients connected to the server. Any new connection request after initiating a shutdown would be ignored. Sample code to show how to use shutdown ```golang func main() { osSig := make(chan os.Signal, 5) cfg := &webgo.Config{ Host: "", Port: "8080", ReadTimeout: 15 * time.Second, WriteTimeout: 60 * time.Second, ShutdownTimeout: 15 * time.Second, } router := webgo.NewRouter(cfg, routes()...) go func() { <-osSig // Initiate HTTP server shutdown err := router.Shutdown() if err != nil { fmt.Println(err) os.Exit(1) } else { fmt.Println("shutdown complete") os.Exit(0) } // If you have HTTPS server running, you can use the following code // err := router.ShutdownHTTPS() // if err != nil { // fmt.Println(err) // os.Exit(1) // } else { // fmt.Println("shutdown complete") // os.Exit(0) // } }() go func(){ time.Sleep(time.Second*15) signal.Notify(osSig, os.Interrupt, syscall.SIGTERM) }() router.Start() } ``` ## Logging WebGo exposes a singleton & global scoped logger variable [LOGHANDLER](https://godoc.org/github.com/naughtygopher/webgo#Logger) with which you can plug in your custom logger by implementing the [Logger](https://godoc.org/github.com/naughtygopher/webgo#Logger) interface. ### Configuring the default Logger The default logger uses Go standard library's `log.Logger` with `os.Stdout` (for debug and info logs) & `os.Stderr` (for warning, error, fatal) as default io.Writers. You can set the io.Writer as well as disable specific types of logs using the `GlobalLoggerConfig(stdout, stderr, cfgs...)` function. ## Server-Sent Events [MDN has a very good documentation of what SSE (Server-Sent Events)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) are. The sample app provided shows how to use the SSE extension of webgo. ## Usage A fully functional sample is provided [here](https://github.com/naughtygopher/webgo/blob/master/cmd/main.go). ### Benchmark 1. [the-benchmarker](https://github.com/the-benchmarker/web-frameworks) 2. [go-web-framework-benchmark](https://github.com/smallnest/go-web-framework-benchmark) ### Contributing Refer [here](https://github.com/naughtygopher/webgo/blob/master/CONTRIBUTING.md) to find out details about making a contribution ### Credits Thanks to all the [contributors](https://github.com/naughtygopher/webgo/graphs/contributors) ## The gopher The gopher used here was created using [Gopherize.me](https://gopherize.me/). WebGo stays out of developers' way, so sitback and enjoy a cup of coffee. ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-cayman ================================================ FILE: cmd/README.md ================================================ # Webgo Sample ### Server Sent Events ![sse-demo](https://user-images.githubusercontent.com/1092882/158047065-447eb868-1efd-4a8d-b748-7caee2b3fcfd.png) This picture shows the sample SSE implementation provided with this application. In the sample app, the server is sending timestamp every second, to all the clients. **Important**: _[SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) is a live connection between server & client. So a short WriteTimeout duration in webgo.Config will keep dropping the connection. If you have any middleware which is setting deadlines or timeouts on the request.Context, will also effect these connections._ ## How to run If you have Go installed on your computer, open the terminal and: ```bash $ cd $GOPATH/src $ git clone https://github.com/naughtygopher/webgo.git $ cd webgo/cmd $ go run *.go Info 2023/02/05 08:51:26 HTTP server, listening on :8080 Info 2023/02/05 08:51:26 HTTPS server, listening on :9595 ``` Or if you have [Docker](https://www.docker.com/), open the terminal and: ```bash $ git clone https://github.com/naughtygopher/webgo.git $ cd webgo $ docker run \ -p 8080:8080 \ -p 9595:9595 \ -v ${PWD}:/go/src/github.com/naughtygopher/webgo/ \ -w /go/src/github.com/naughtygopher/webgo/cmd \ --rm -ti golang:latest go run *.go Info 2023/02/05 08:51:26 HTTP server, listening on :8080 Info 2023/02/05 08:51:26 HTTPS server, listening on :9595 ``` You can try the following API calls with the sample app. It also uses all the features provided by webgo 1. `http://localhost:8080/` - Loads an HTML page 2. `http://localhost:8080/matchall/` - Route with wildcard parameter configured - All URIs which begin with `/matchall` will be matched because it has a wildcard variable - e.g. - http://localhost:8080/matchall/hello - http://localhost:8080/matchall/hello/world - http://localhost:8080/matchall/hello/world/user 3. `http://localhost:8080/api/` - Route with a named 'param' configured - It will match all requests which match `/api/` - e.g. - http://localhost:8080/api/hello - http://localhost:8080/api/world 4. `http://localhost:8080/error-setter` - Route which sets an error and sets response status 500 5. `http://localhost:8080/v7.0.0/api/` - Route with a named 'param' configured - It will match all requests which match `/v7.0.0/api/` - e.g. - http://localhost:8080/v7.0.0/api/hello - http://localhost:8080/v7.0.0/api/world ================================================ FILE: cmd/certs/CA.key ================================================ -----BEGIN RSA PRIVATE KEY----- Proc-Type: 4,ENCRYPTED DEK-Info: DES-EDE3-CBC,3172865781AB630D b65fTCSGMadtvR5GnVfTsssu9qrQgm5XNCXRJ+PLpuXuWdywORTOEM4/FtRA62AN f41Dl/OO7nUNTTF4J+0fNiLMmnkiYvGSa5lcjRqBikk6waZjYkSUoo4TdGh502F/ jFzbGzqXlX0qt+pj0HY/fhKxpr45bOkBr9S3ucMbgyuREX1HShxF759Mc+sJFx7w Ghs+byTL3cZABHRn/0xpC9S/PhEnsW3dPPJItCkEUsLumwTFCuaGskpP3chuVhZh 36p74An+Tg9wlgcaCaUSUHWs22rnLBHAjv9JzVuoLJWdUwIYrcQTJz2+8mVlun24 Qns7dhRafOHlIeOCxI/fmKlXE/S9S7tuXaMsHtqGK/22MAXJx/AOymjfgXfTLnF1 XmLK0FTyp9BB1pfW9P+D6JEp7bj1fRHZeCOkesJOjyDEb+v+oYMaha6IVfQCMKQf P1+okMwBPGQIAr5d4Ov82mwpDyO1+rHAFn3b3zuro4rfHiHPDTo9Oa7EtMBIWZgj Ln0KLeaRkPht6wSUubCSl8Ypg27xbHwbQbWcQsr/OwLgWMJ40/1QKaqYIDV4LytZ mSzwwo4kQASKI1jwFWff+4yVqd3SuyW5uGcPNnkbneyKZzFHd/rAWnsT9cIIOX0v US5LEn9r+qjYk+ZVCSgqmrwUHyPfbx/BeARJTDBzo2jJC+ZiR7UkzD54r5pzs7R4 9N3hVVmocS9BNPj45ioLw/Fhbu62N34NkRdDAisPhlQ+yM7klKBciQsEsKj1c6iw QwJNScvgW6x0+47J+tp21KLuxdfVP+Bq9wSb2B4kR3l93/ZAXR1S0cg0cuez1frd 8g1kpQMzxhWVMzB6NHG7wAIo7p947mueP3Ggh1rgA6WLTltvH11ywadglG23HB7R zuxcQqoPm7Tzcift316DE5Q+qipHDA2UmiWZ83ZVQCshiJxILsPkGbh7k+mRwRyh e08TLEJMtCWiqvCmxHbebx7y8+oX035QIUVIOxHGier2CyZtgpaZyeEa4DHZCwuH ZrTMfigGDSXnonCrVtsC9zSYGQav/tRVxahKsM/TA+O1gOWNTncEe2ESKWaBzJpQ wUo7u/e/hbyPxMd2ezmeYVWwRSy9J/uZOsH0DsUlGsUzTWj+qrbmrdDYGVI2sgA1 xfTfv7vdLFpESDRV4eRWscblusHffjCFA9oC9Y9qj2M8X+HLa/EELMb5CCWGZm3E HMZKwg6cah5rc82FFk0nDTzNpw57rTorPOGDe3oUif2NR+A4gxyepVrNDBFxDzbd PT2YMPr2IsHgxnRwFyoAPGG3rKCO+rFNJKcfrxvdeTj7mZ0Yzo93f9ycgNhqDKnR +n8vCWtWnRuDu8hk3d5YjhaZux/SmOuF9G6VX0jnJA534aLhEo0mzddGKM0bnOGN J1aMc65s4rPqUOQiase/3w7fNDu6szF/tcUMEWPRCDymZpZ9yznK122ajQVrKyOq HDcftg1rrKli8I/AljJMgC9ACZ9YfOfZ3qymcY7X7ZJyMucKi822ajIggi630aJQ sEqwxl5coM/N+Rhcp2/NiYyq3MXQBibKhq00OBLq76QQeNsJbRhUDw== -----END RSA PRIVATE KEY----- ================================================ FILE: cmd/certs/CA.pem ================================================ -----BEGIN CERTIFICATE----- MIIDijCCAnICCQCImGKiTiq7ITANBgkqhkiG9w0BAQsFADCBhjELMAkGA1UEBhMC SU4xCzAJBgNVBAgMAktBMRIwEAYDVQQHDAlCZW5nYWx1cnUxFTATBgNVBAoMDERl bGl2ZXJ5SGVybzETMBEGA1UECwwKUS1Db21tZXJjZTESMBAGA1UEAwwJbG9jYWxo b3N0MRYwFAYJKoZIhvcNAQkBFgdrQGIuY29tMB4XDTIyMTIwOTE2MDQxMFoXDTMy MTIwNjE2MDQxMFowgYYxCzAJBgNVBAYTAklOMQswCQYDVQQIDAJLQTESMBAGA1UE BwwJQmVuZ2FsdXJ1MRUwEwYDVQQKDAxEZWxpdmVyeUhlcm8xEzARBgNVBAsMClEt Q29tbWVyY2UxEjAQBgNVBAMMCWxvY2FsaG9zdDEWMBQGCSqGSIb3DQEJARYHa0Bi LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM83cerEFzgRxu/l NrFOGOCn1bwexXoP1xawzVaj/vmTL4+bN114e4KnJrmon9k9NHUxhJJGfNxM7cSG 7lYlIwIDdMt1FNm1iWjPycvcxWZppEnjbwlOGYn0miekr+SSh18AbCZ+kcZFx6Cp O4wEb29kXCxInGEdgj5M23EpQdi5qXCmfmrIl/ueiNnJPQezFir8UizRKG5xHnZK BaGcT0E4lOJLLKGqpvN5v0cO1Vwu2nxFmXlcV5dWsPOjxvPPSCYuu0EHhJ2jucQW MNtc6cucG70rOJwkQi6JMbS5XU9pboux2O/H0WUwFSYjI40opWjKpXE5eo6GIgl/ eHCiNJ8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAH9a3WOMgGvDn80O8hvhZzXO8 6XPBDqjW7Zk/l1GiZYpNuDvAuqiBIDxKQZdRtnbRBTCLGO6yyHt974WStJud1/sN Loam78+GMEMJq0tUNUNXuOVNLo/Zz/4tN2cDosnB8k+Atm+c3m5TSHaOayOy+PJL OiDi7RP5IPpiEYtdGvE2eoYfqjSnY00kIV5ea57PIc3gkFO9FXP+M6UXzkxo2xC7 fvVhYEVjQ7uvdWLGKYMvF/PRV2OKRnAdFasga3k8PyC/ToxjN/87ypeUy1VZzEm7 3zsbislIKL36CCOYmGaUgTPfxXWN/MvUgb87lWfrqPbSM7ooSQGHFe1eP5Wd2g== -----END CERTIFICATE----- ================================================ FILE: cmd/certs/CA.srl ================================================ FDC7B23B140FA79F ================================================ FILE: cmd/certs/localhost.crt ================================================ -----BEGIN CERTIFICATE----- MIIEgjCCA2qgAwIBAgIJAP3HsjsUD6efMA0GCSqGSIb3DQEBCwUAMIGGMQswCQYD VQQGEwJJTjELMAkGA1UECAwCS0ExEjAQBgNVBAcMCUJlbmdhbHVydTEVMBMGA1UE CgwMRGVsaXZlcnlIZXJvMRMwEQYDVQQLDApRLUNvbW1lcmNlMRIwEAYDVQQDDAls b2NhbGhvc3QxFjAUBgkqhkiG9w0BCQEWB2tAYi5jb20wHhcNMjIxMjA5MTYwOTQ2 WhcNMzIxMjA2MTYwOTQ2WjCBlzELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxp bjEWMBQGA1UEBwwNUmVpbmlja2VuZG9yZjEWMBQGA1UECgwNRGVsaXZlcnkgSGVy bzETMBEGA1UECwwKUS1Db21tZXJjZTEZMBcGA1UEAwwQZGVsaXZlcnloZXJvLmNv bTEXMBUGCSqGSIb3DQEJARYIa0Bibi5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB DwAwggEKAoIBAQDRT6l39GZ3vSAi1eLt8oauseH4uNwijzcaqHot45f3087eqSej hfqmTfhga+MSDtbKIo73O4wq12klCbTtil4UpRT4dVJwKQLXLriFCiq40Wzcyhqy E0qGoZG1TCoy3PLUCwkxXlixAdEimhuZPIVPDQIY0fs1c8GxdFfhxMQ88WEqs0Rp rygYp+hD18Hk0VYhPmqZXb0m3BG7/eTYHrYDVAdk9f2OYMR925idwk94iHvTjOqC bOpVOzF4FM+jkT7r4hfa2UuCF4sYNhAP2DEZWHnoYjb7cxRDKYshMqCH5WhlbB+v rAllLs+4GEu1yOs0VYqt2TzXNr/6KK1G77SRAgMBAAGjgd8wgdwwgaUGA1UdIwSB nTCBmqGBjKSBiTCBhjELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAktBMRIwEAYDVQQH DAlCZW5nYWx1cnUxFTATBgNVBAoMDERlbGl2ZXJ5SGVybzETMBEGA1UECwwKUS1D b21tZXJjZTESMBAGA1UEAwwJbG9jYWxob3N0MRYwFAYJKoZIhvcNAQkBFgdrQGIu Y29tggkAiJhiok4quyEwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0RBBMw EYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQA2P0I8ujEPS2GB 7cMUAE3cB4fxEwI2t89JGcmeK0BUyVBlvdwZGEIVs2Tn0FFbs0VpcgeY3YkV2ogs ekrUHQzmnRX9EtTMucGM6gX4JeDJWWthehVIB6Jp1iDLtAAbyCGph5nrdArkA0tR ANkyrXKTcMAx3giBzSZrpxguF+fnASZ+p99c57FnRXjMv5NkQnSCgRQkmaHtUIKJ dDAlEIyPrpfe2bbw7BtUt2UiW9KPz/CG9TDrpWzh5jRyoJRzXUhlOPgFPCBvH1AC DGl2ciAGfScEY+HZp+YPdTzln5TSc4w/REuWDqBIydJwytUQX+EcTA0936TQq3ec lYKaXexW -----END CERTIFICATE----- ================================================ FILE: cmd/certs/localhost.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIIC3TCCAcUCAQAwgZcxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xFjAU BgNVBAcMDVJlaW5pY2tlbmRvcmYxFjAUBgNVBAoMDURlbGl2ZXJ5IEhlcm8xEzAR BgNVBAsMClEtQ29tbWVyY2UxGTAXBgNVBAMMEGRlbGl2ZXJ5aGVyby5jb20xFzAV BgkqhkiG9w0BCQEWCGtAYm4uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEA0U+pd/Rmd70gItXi7fKGrrHh+LjcIo83Gqh6LeOX99PO3qkno4X6pk34 YGvjEg7WyiKO9zuMKtdpJQm07YpeFKUU+HVScCkC1y64hQoquNFs3MoashNKhqGR tUwqMtzy1AsJMV5YsQHRIpobmTyFTw0CGNH7NXPBsXRX4cTEPPFhKrNEaa8oGKfo Q9fB5NFWIT5qmV29JtwRu/3k2B62A1QHZPX9jmDEfduYncJPeIh704zqgmzqVTsx eBTPo5E+6+IX2tlLgheLGDYQD9gxGVh56GI2+3MUQymLITKgh+VoZWwfr6wJZS7P uBhLtcjrNFWKrdk81za/+iitRu+0kQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEB ADGo51Y/2/uJhndzitmBLM9yvwXmsD7JQvT3y+8xSne0p+jniwHhzqLww6SLLtIQ LRUzbXQlnJnPzj2fhdPYM9238Cxyd/w59/cg/RXnkjMnoaiH/9FZpqwIwnMFugkp +BcqszZat70OjdhZPkI/WzImNHdtSzUFhI3OACXqhdSM2wGkzHQCWxMzRmKsE8XF iMmuvFVBExXLoG/PqoRS5W3Op1SZdYvKybhmrgM+XeHlvTv8VOFgUBmBEolhZtvU 9eT3ommzMbyZSbf6eXlTj+OrBTlN7n8et42TyDaf03kZhhiSpiq+lNz7+eHcF64Z 8u4AOXi22aPkwy2fffYKr4E= -----END CERTIFICATE REQUEST----- ================================================ FILE: cmd/certs/localhost.decrypted.key ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA0U+pd/Rmd70gItXi7fKGrrHh+LjcIo83Gqh6LeOX99PO3qkn o4X6pk34YGvjEg7WyiKO9zuMKtdpJQm07YpeFKUU+HVScCkC1y64hQoquNFs3Moa shNKhqGRtUwqMtzy1AsJMV5YsQHRIpobmTyFTw0CGNH7NXPBsXRX4cTEPPFhKrNE aa8oGKfoQ9fB5NFWIT5qmV29JtwRu/3k2B62A1QHZPX9jmDEfduYncJPeIh704zq gmzqVTsxeBTPo5E+6+IX2tlLgheLGDYQD9gxGVh56GI2+3MUQymLITKgh+VoZWwf r6wJZS7PuBhLtcjrNFWKrdk81za/+iitRu+0kQIDAQABAoIBAQCwHW5Df0HkiB6N ERiTC9ilDwlKxQh8j7JW3OGI0RJiNTbABOZUYfwHiF1vi/eQjynNBIz0m4cR2RQg VO2GXUFR76EYeWb29prsQeSCFI7j2VrW37rckPzJERNPz5lGGMC9B9ghUPghX5z/ l1mXcuPcIt7b0XqkfBTC4li7n1taxluvKpxtFoN7XuV7QyhpQtR74M9CAmmtxAjY 3HSY3fC7kSbT+8mii29stm3/zJSdnuSdlfrHRv+cXsrWfN5isjpvDb7HCcvm7OHB QyUC89zy/WM8xReKEStkRX+/+zW2//y7aXMzI/YmiPdX+XoIY+MdxqvMjzbATZF+ haBeM5ttAoGBAP5GhhmuvKSURJTfoEroIxtXnfpvE4T/+TQmR1Wc9m/Ecc6iVuHF RgHpkKhOFl5P457Y1oRpYkmECxZ4KuvHwIqe9rRofGByxbxilvA69p8RkV8oI/gc qP5+VN2Sx77tTTKVX7S6SFrROSmB9YAE7/83Vo1oD9absl47SeR9+EBbAoGBANK7 EhIYE12q5nQBDhjjAt9Xa6N+ROjonjW1cWq/v6FWgoBXiQtsh1MSK2KZPi7F05kP 9oYM//4oMy/mFsKbS8HTy+Y7HDYvh7Gz3gAxUWrabIFrJlJMXMj9VIgYDZsnQiBD 3j2sUOpr+5B4laHD5oM8rYjR10F1AN7oAuTdDzKDAoGAfmkHH9t70wIW+kAWi0bO tTggxLDV7mfnNyLUkd5fsX7i6UxRjxoozKiWDuYLPsXOrli0hM1zXIL1lC0XgXIj 6YZPta7ALp7AaQBGc5WMp9XvBHSLNTziUurxO9pNzUBiAYS7OLjnYabkGRuPth4+ Rg33zILwZMuwqCIngR2S/kMCgYB9ssCQsnO6x5o3T/nMtnycJFU8bLFGDJtyhgxl FIOGBUhKrew9ODtwPcJLSgVhePdCsdbnFxIL1IbT53dkFaYWs/NIHbIyUB+szBF8 I+7gwfE/MV7mcE5YRWQK2e4jwkMbY+BJAWQysL6Z6pO2rlftqGAK4MB5dwVR8Sro wUOzaQKBgQDZ3ohj61drnkkdRRRX5115ne3AMctx1rn7Ss6AByDn21wYUrOP0qAy i4kmah0kibqz8+N6PM7G7gA5uXHk0bwFfgU/TPQdz6DfzSbTmO7lEEJ7ejI9ml0C 1Y16Rya8Ny3fLjCFWKYReFhXUWNzuWPHsQezUiwnMeHRka0c9QFVSA== -----END RSA PRIVATE KEY----- ================================================ FILE: cmd/certs/localhost.ext ================================================ authorityKeyIdentifier = keyid,issuer basicConstraints = CA:FALSE keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = localhost IP.1 = 127.0.0.1 ================================================ FILE: cmd/certs/localhost.key ================================================ -----BEGIN RSA PRIVATE KEY----- Proc-Type: 4,ENCRYPTED DEK-Info: DES-EDE3-CBC,9D6364828FCEE2E0 2RhK7oE6kVWBNDFGjRUuxPO5XI3qarcJTswXNk7YFqmyQpgt9cFXUdRI36Ppbpcs Sw6h2gmBaqsS7sbL3wWZyZL+PcVVCy7TRTBI/mU1Cvj1iRe9ms3+b7uwnuSGh0DF G40X+VACjwmP+hAKQivcPtog+c5laAx/QQ456kNQMl9yYCdbJCTCBNKVS8ycIB54 0moAxCv8lgO0mllh4vvMl5RbfOqPVr0Ky8EWJJqs9CWCy7eKLe8BIzy7nDGcVhx+ BZ24r8h59OIUWCFmJ/FXhDTLXrPr2xpF0COTKuPrTtU7LMaCdYljyilfkCo4l+Pk ctawRwKLUd4/IE26P90WaoV85y9tne1145pcKQVQa0NdfI6rRA2jRqzv6iFrVYBM AUeybrX+HnaHABwJYNunXBCk3YUWk5NuMPsTNGlPqMPQyVC/D196YSU16sDx2zlT Z886GqLfr5SqwInvlb7zQn72+1beoh2Du7NQ6oXuVZsc5w1sGiv7B7axOEznJ8KT QxTst9pCLj2zpm45fRW8cKscC9g/fXMYQrRwcSeeuWvXtI9csljpWKpolvepKFmx bUrYUIqFyfCZsLBHaXf3vsbPF/h3q0ZGDadVTWioiA9jpi+y5rcb8mFDl9qiRBsC RDafd0aoJPXiWotO58Fo4VXRLWqEUhyHF9oVN5SD5CWSWos/YfkRXAVmElEK+dbM TpumlQvt6gbXculag7c/OtHJtVfLuhqdW4yGXvwY1z07U+EdYLrMomKffYaxjTi5 2c1bNA7t2cF4mQk++DH9jvj1Xfuxdh5p84g1tvSYrVvJWt4JHzbzknylteGMSJ5w dkuDXdBS9qrwjvc6nLNAn+qXduzAyzf2GR5obe/mFwZSvm33o6W/DFWkhQDdHjSC o7QuyNvHYYMCau7CKcpgoBycBrkktQ6gFAGR1HsT4Xdsk60XkLi8ctUiLPdFL6zc tRcmZt+ddzJA2qaNCxNopj2J8Qkav208zQ9f7P6DZNe3pWI09SrUisnAb0cuYASm XfAcBP9vBfKx9pvxfpVgp6DYFaVllgXDM+Z1lmKzZMOAb0RFcyWsYtILIgLnTDeG tMeHygAevAAT1N1uKlQMvXvOtmehOGVfBAaJVt1U4xTWjCxBGfWed+bs9NkL2zSU 0/kEacWEbq0c23Wsiv8IHKxqZLPyeiyHvZgDWLt4v1Y+t9l+q+HZIQxXmFhb96qt +tOv7pY6dig2fLRAApp9Q2kKfimQBtO7sxafK3qBmT75Y86gBdmSgjREviec9Bb2 VeZeqf/TPhiBVbRpYAe4RixF5cal3pxC9N9GmEMk+Qfr/MEonkUgX8w4tlP3hkXx HS+5GpltrIag5GEaKbFgX/FYcHbDQNMXmIqV6ieavN+04MbHuC4Cm1joHVTEQJUn II0mfCAgxFOYKqr2Sex86zxhury5O1oYS0ETCTCLVgab1dIin4+cElrGW42Sw3Bm tGChrevGYi2hBQkBOHfnw2lFLtJmQ0R0pke+CICVWLriTV5N183f5zosdjJyYpxa JL7p/ePLjLRCvMpY185/mbvc8h1AtIsm6N67r8xK250oZBid9LZXa6G449RnPku7 -----END RSA PRIVATE KEY----- ================================================ FILE: cmd/handlers.go ================================================ package main import ( "context" "errors" "fmt" "log" "net/http" "os" "strings" "github.com/naughtygopher/webgo/v7" "github.com/naughtygopher/webgo/v7/extensions/sse" ) // StaticFilesHandler is used to serve static files func StaticFilesHandler(rw http.ResponseWriter, r *http.Request) { wctx := webgo.Context(r) // '..' is replaced to prevent directory traversal which could go out of static directory path := strings.ReplaceAll(wctx.Params()["w"], "..", "-") path = strings.ReplaceAll(path, "~", "-") rw.Header().Set("Last-Modified", lastModified) http.ServeFile(rw, r, fmt.Sprintf("./static/%s", path)) } func OriginalResponseWriterHandler(w http.ResponseWriter, r *http.Request) { rw := webgo.OriginalResponseWriter(w) if rw == nil { webgo.Send(w, "text/html", "got nil", http.StatusPreconditionFailed) return } webgo.Send(w, "text/html", "success", http.StatusOK) } func HomeHandler(w http.ResponseWriter, r *http.Request) { fs, err := os.OpenFile("./static/index.html", os.O_RDONLY, 0600) if err != nil { webgo.SendError(w, err.Error(), http.StatusInternalServerError) return } info, err := fs.Stat() if err != nil { webgo.SendError(w, err.Error(), http.StatusInternalServerError) return } out := make([]byte, info.Size()) _, err = fs.Read(out) if err != nil { webgo.SendError(w, err.Error(), http.StatusInternalServerError) return } pushHomepage(r, w) _, _ = w.Write(out) } func pushCSS(pusher http.Pusher, r *http.Request, path string) { cssOpts := &http.PushOptions{ Header: http.Header{ "Accept-Encoding": r.Header["Accept-Encoding"], "Content-Type": []string{"text/css; charset=UTF-8"}, }, } err := pusher.Push(path, cssOpts) if err != nil { webgo.LOGHANDLER.Error(err) } } func pushJS(pusher http.Pusher, r *http.Request, path string) { cssOpts := &http.PushOptions{ Header: http.Header{ "Accept-Encoding": r.Header["Accept-Encoding"], "Content-Type": []string{"application/javascript"}, }, } err := pusher.Push(path, cssOpts) if err != nil { webgo.LOGHANDLER.Error(err) } } func pushHomepage(r *http.Request, w http.ResponseWriter) { pusher, ok := w.(http.Pusher) if !ok { return } cp, _ := r.Cookie("pusher") if cp != nil { return } cookie := &http.Cookie{ Name: "pusher", Value: "css,js", MaxAge: 300, } http.SetCookie(w, cookie) pushCSS(pusher, r, "/static/css/main.css") pushCSS(pusher, r, "/static/css/normalize.css") pushJS(pusher, r, "/static/js/main.js") pushJS(pusher, r, "/static/js/sse.js") } func SSEHandler(sse *sse.SSE) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { params := webgo.Context(r).Params() r.Header.Set(sse.ClientIDHeader, params["clientID"]) err := sse.Handler(w, r) if err != nil && !errors.Is(err, context.Canceled) { log.Println("errorLogger:", err.Error()) return } } } func ErrorSetterHandler(w http.ResponseWriter, r *http.Request) { err := errors.New("oh no, server error") webgo.SetError(r, err) webgo.R500(w, err.Error()) } func ParamHandler(w http.ResponseWriter, r *http.Request) { // WebGo context wctx := webgo.Context(r) // URI parameters, map[string]string params := wctx.Params() // route, the webgo.Route which is executing this request route := wctx.Route webgo.R200( w, map[string]interface{}{ "route_name": route.Name, "route_pattern": route.Pattern, "params": params, "chained": r.Header.Get("chained"), }, ) } func InvalidJSONHandler(w http.ResponseWriter, r *http.Request) { webgo.R200(w, make(chan int)) } ================================================ FILE: cmd/main.go ================================================ package main import ( "context" "fmt" "log" "net/http" "os" "strings" "time" "github.com/naughtygopher/webgo/v7" "github.com/naughtygopher/webgo/v7/extensions/sse" "github.com/naughtygopher/webgo/v7/middleware/accesslog" "github.com/naughtygopher/webgo/v7/middleware/cors" ) var ( lastModified = time.Now().Format(http.TimeFormat) ) func chain(w http.ResponseWriter, r *http.Request) { r.Header.Set("chained", "true") } // errLogger is a middleware which will log all errors returned/set by a handler func errLogger(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { next(w, r) err := webgo.GetError(r) if err != nil { // log only server errors if webgo.ResponseStatus(w) > 499 { log.Println("errorLogger:", err.Error()) } } } func routegroupMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { w.Header().Add("routegroup", "true") next(w, r) } func getRoutes(sse *sse.SSE) []*webgo.Route { return []*webgo.Route{ { Name: "root", Method: http.MethodGet, Pattern: "/", Handlers: []http.HandlerFunc{HomeHandler}, TrailingSlash: true, }, { Name: "matchall", Method: http.MethodGet, Pattern: "/matchall/:wildcard*", Handlers: []http.HandlerFunc{ParamHandler}, TrailingSlash: true, }, { Name: "api", Method: http.MethodGet, Pattern: "/api/:param", Handlers: []http.HandlerFunc{chain, ParamHandler}, TrailingSlash: true, FallThroughPostResponse: true, }, { Name: "invalidjson", Method: http.MethodGet, Pattern: "/invalidjson", Handlers: []http.HandlerFunc{InvalidJSONHandler}, TrailingSlash: true, }, { Name: "error-setter", Method: http.MethodGet, Pattern: "/error-setter", Handlers: []http.HandlerFunc{ErrorSetterHandler}, TrailingSlash: true, }, { Name: "original-responsewriter", Method: http.MethodGet, Pattern: "/original-responsewriter", Handlers: []http.HandlerFunc{OriginalResponseWriterHandler}, TrailingSlash: true, }, { Name: "static", Method: http.MethodGet, Pattern: "/static/:w*", Handlers: []http.HandlerFunc{StaticFilesHandler}, TrailingSlash: true, }, { Name: "sse", Method: http.MethodGet, Pattern: "/sse/:clientID", Handlers: []http.HandlerFunc{SSEHandler(sse)}, TrailingSlash: true, }, } } func setup() (*webgo.Router, *sse.SSE) { port := strings.TrimSpace(os.Getenv("HTTP_PORT")) if port == "" { port = "8080" } cfg := &webgo.Config{ Host: "", Port: port, HTTPSPort: "9595", ReadTimeout: 15 * time.Second, WriteTimeout: 1 * time.Hour, CertFile: "./certs/localhost.crt", KeyFile: "./certs/localhost.decrypted.key", } webgo.GlobalLoggerConfig( nil, nil, webgo.LogCfgDisableDebug, ) routeGroup := webgo.NewRouteGroup("/v7.0.0", false) routeGroup.Add(webgo.Route{ Name: "router-group-prefix-v7.0.0_api", Method: http.MethodGet, Pattern: "/api/:param", Handlers: []http.HandlerFunc{chain, ParamHandler}, }) routeGroup.Use(routegroupMiddleware) sseService := sse.New() sseService.OnRemoveClient = func(ctx context.Context, clientID string, count int) { log.Printf("\nClient %q removed, active client(s): %d\n", clientID, count) } sseService.OnCreateClient = func(ctx context.Context, client *sse.Client, count int) { log.Printf("\nClient %q added, active client(s): %d\n", client.ID, count) } routes := getRoutes(sseService) routes = append(routes, routeGroup.Routes()...) router := webgo.NewRouter(cfg, routes...) router.UseOnSpecialHandlers(accesslog.AccessLog) router.Use( errLogger, cors.CORS(nil), accesslog.AccessLog, ) return router, sseService } func main() { router, sseService := setup() clients := []*sse.Client{} sseService.OnCreateClient = func(ctx context.Context, client *sse.Client, count int) { clients = append(clients, client) } // broadcast server time to all SSE listeners go func() { retry := time.Millisecond * 500 for { now := time.Now().Format(time.RFC1123Z) sseService.Broadcast(sse.Message{ Data: now + fmt.Sprintf(" (%d)", sseService.ActiveClients()), Retry: retry, }) time.Sleep(time.Second) } }() go router.StartHTTPS() router.Start() } ================================================ FILE: cmd/static/css/main.css ================================================ * { transition: color 0.25s, margin 0.25s, padding 0.25s, width 0.25s, height 0.25s, background-color 0.25s; } html, body { font-family: sans-serif; font-size: 16px; line-height: 1.5em; font-weight: 400; background: #efefef; color: #444; } p { margin: 0 0 1em; } a { color: #999; } a:hover { color: #222; } section.main { background: #fff; width: 90%; max-width: 370px; margin: 10vw auto; padding: 0 2em; border-radius: 4px; overflow: hidden; } table { width: 100%; font-size: 12px; line-height: 1.5em; border: 1px solid #eee; border-collapse: collapse; } tr, td { border: 1px solid #eee; } td { padding: 0.25rem; text-align: right; } td:nth-child(1) { text-align: left; background-color: rgba(0,0,0,0.02); } ================================================ FILE: cmd/static/css/normalize.css ================================================ /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ /* Document ========================================================================== */ /** * 1. Correct the line height in all browsers. * 2. Prevent adjustments of font size after orientation changes in iOS. */ html { line-height: 1.15; /* 1 */ -webkit-text-size-adjust: 100%; /* 2 */ } /* Sections ========================================================================== */ /** * Remove the margin in all browsers. */ body { margin: 0; } /** * Render the `main` element consistently in IE. */ main { display: block; } /** * Correct the font size and margin on `h1` elements within `section` and * `article` contexts in Chrome, Firefox, and Safari. */ h1 { font-size: 2em; margin: 0.67em 0; } /* Grouping content ========================================================================== */ /** * 1. Add the correct box sizing in Firefox. * 2. Show the overflow in Edge and IE. */ hr { box-sizing: content-box; /* 1 */ height: 0; /* 1 */ overflow: visible; /* 2 */ } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ pre { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /* Text-level semantics ========================================================================== */ /** * Remove the gray background on active links in IE 10. */ a { background-color: transparent; } /** * 1. Remove the bottom border in Chrome 57- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. */ abbr[title] { border-bottom: none; /* 1 */ text-decoration: underline; /* 2 */ text-decoration: underline dotted; /* 2 */ } /** * Add the correct font weight in Chrome, Edge, and Safari. */ b, strong { font-weight: bolder; } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ code, kbd, samp { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /** * Add the correct font size in all browsers. */ small { font-size: 80%; } /** * Prevent `sub` and `sup` elements from affecting the line height in * all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } /* Embedded content ========================================================================== */ /** * Remove the border on images inside links in IE 10. */ img { border-style: none; } /* Forms ========================================================================== */ /** * 1. Change the font styles in all browsers. * 2. Remove the margin in Firefox and Safari. */ button, input, optgroup, select, textarea { font-family: inherit; /* 1 */ font-size: 100%; /* 1 */ line-height: 1.15; /* 1 */ margin: 0; /* 2 */ } /** * Show the overflow in IE. * 1. Show the overflow in Edge. */ button, input { /* 1 */ overflow: visible; } /** * Remove the inheritance of text transform in Edge, Firefox, and IE. * 1. Remove the inheritance of text transform in Firefox. */ button, select { /* 1 */ text-transform: none; } /** * Correct the inability to style clickable types in iOS and Safari. */ button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } /** * Remove the inner border and padding in Firefox. */ button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { border-style: none; padding: 0; } /** * Restore the focus styles unset by the previous rule. */ button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { outline: 1px dotted ButtonText; } /** * Correct the padding in Firefox. */ fieldset { padding: 0.35em 0.75em 0.625em; } /** * 1. Correct the text wrapping in Edge and IE. * 2. Correct the color inheritance from `fieldset` elements in IE. * 3. Remove the padding so developers are not caught out when they zero out * `fieldset` elements in all browsers. */ legend { box-sizing: border-box; /* 1 */ color: inherit; /* 2 */ display: table; /* 1 */ max-width: 100%; /* 1 */ padding: 0; /* 3 */ white-space: normal; /* 1 */ } /** * Add the correct vertical alignment in Chrome, Firefox, and Opera. */ progress { vertical-align: baseline; } /** * Remove the default vertical scrollbar in IE 10+. */ textarea { overflow: auto; } /** * 1. Add the correct box sizing in IE 10. * 2. Remove the padding in IE 10. */ [type="checkbox"], [type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } /** * Correct the cursor style of increment and decrement buttons in Chrome. */ [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } /** * 1. Correct the odd appearance in Chrome and Safari. * 2. Correct the outline style in Safari. */ [type="search"] { -webkit-appearance: textfield; /* 1 */ outline-offset: -2px; /* 2 */ } /** * Remove the inner padding in Chrome and Safari on macOS. */ [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /** * 1. Correct the inability to style clickable types in iOS and Safari. * 2. Change font properties to `inherit` in Safari. */ ::-webkit-file-upload-button { -webkit-appearance: button; /* 1 */ font: inherit; /* 2 */ } /* Interactive ========================================================================== */ /* * Add the correct display in Edge, IE 10+, and Firefox. */ details { display: block; } /* * Add the correct display in all browsers. */ summary { display: list-item; } /* Misc ========================================================================== */ /** * Add the correct display in IE 10+. */ template { display: none; } /** * Add the correct display in IE 10. */ [hidden] { display: none; } ================================================ FILE: cmd/static/index.html ================================================ Webgo - Sample

webgo gopher

coverage

WebGo

SSE Client ID
SSE data
Active clients

================================================ FILE: cmd/static/js/main.js ================================================ const webgo = async () => { const clientID = Math.random() .toString(36) .replace(/[^a-z]+/g, "") .substring(0, 16); const sseDOM = document.getElementById("sse"); const sseClientsDOM = document.getElementById("sse-clients"); const sseClientIDDOM = document.getElementById("sse-client-id"); const formatBackoff = (backoff, precision = 2) => { let boff = `${backoff}ms`; if (backoff >= 1000) { boff = `${parseFloat(backoff / 1000).toFixed(precision)}s`; } return boff; }; const config = { url: `/sse/${clientID}`, onMessage: (data) => { const parts = data?.split?.("("); if (!parts || !parts.length) { return; } const date = new Date(parts[0]); const activeClients = parts[1].replace(")", ""); sseDOM.innerText = date.toLocaleString(); sseClientsDOM.innerText = activeClients; sseClientIDDOM.innerText = clientID; }, onError: (err, { backoff }) => { sseClientsDOM.innerText = "N/A"; let interval = null; interval = window.setInterval(() => { sseDOM.innerHTML = `SSE failed, attempting reconnect in ${formatBackoff( backoff, 0 )}`; backoff -= 1000; if (backoff < 0) { sseDOM.innerHTML = `SSE failed, attempting reconnect in 0s`; window.clearInterval(interval); } }, 1000); console.log(err); }, initialBackoff: 1000, backoffStep: 1000, }; const sseworker = new Worker("/static/js/sse.js"); sseworker.onerror = (e) => { sseworker.terminate(); }; sseworker.onmessage = (e) => { if (e?.data?.error) { config.onError("SSE failed", e?.data); } else { config.onMessage(e?.data); } }; sseworker.postMessage({ url: config.url, initialBackoff: config.initialBackoff, backoffStep: config.backoffStep, }); }; webgo(); ================================================ FILE: cmd/static/js/sse.js ================================================ const sse = (url, config = {}) => { const { onMessage, onError, initialBackoff = 10, // milliseconds maxBackoff = 15 * 1000, // 15 seconds backoffStep = 50, // milliseconds } = config; let backoff = initialBackoff, sseRetryTimeout = null; const start = () => { const source = new EventSource(url); const configState = { initialBackoff, maxBackoff, backoffStep, backoff }; source.onopen = () => { clearTimeout(sseRetryTimeout); // reset backoff to initial, so further failures will again start with initial backoff // instead of previous duration backoff = initialBackoff; configState.backoff = backoff; }; source.onmessage = (event) => { onMessage && onMessage(event, configState); }; source.onerror = (err) => { source.close(); if (!backoffStep) { onError && onError(err, configState); return; } clearTimeout(sseRetryTimeout); // reattempt connecting with *linear* backoff sseRetryTimeout = self.setTimeout(() => { start(url, onMessage); if (backoff < maxBackoff) { backoff += backoffStep; if (backoff > maxBackoff) { backoff = maxBackoff; } } }, backoff); onError && onError(err, configState); }; }; return start; }; onmessage = (e) => { sse(e?.data?.url, { onMessage: (event) => { postMessage(event?.data); }, onError: (err, attrs) => { postMessage({ error: "SSE failed", ...attrs }); }, })(); }; ================================================ FILE: config.go ================================================ package webgo import ( "encoding/json" "os" "strconv" "time" ) // Config is used for reading app's configuration from json file type Config struct { // Host is the host on which the server is listening Host string `json:"host,omitempty"` // Port is the port number where the server has to listen for the HTTP requests Port string `json:"port,omitempty"` // CertFile is the TLS/SSL certificate file path, required for HTTPS CertFile string `json:"certFile,omitempty"` // KeyFile is the filepath of private key of the certificate KeyFile string `json:"keyFile,omitempty"` // HTTPSPort is the port number where the server has to listen for the HTTP requests HTTPSPort string `json:"httpsPort,omitempty"` // ReadTimeout is the maximum duration for which the server would read a request ReadTimeout time.Duration `json:"readTimeout,omitempty"` // WriteTimeout is the maximum duration for which the server would try to respond WriteTimeout time.Duration `json:"writeTimeout,omitempty"` // InsecureSkipVerify is the HTTP certificate verification InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // ShutdownTimeout is the duration in which graceful shutdown is completed ShutdownTimeout time.Duration // ReverseMiddleware if true, will reverse the order of execution middleware // from the order of it was added. e.g. router.Use(m1,m2), m2 will execute first // if ReverseMiddleware is true ReverseMiddleware bool } // Load config file from the provided filepath and validate func (cfg *Config) Load(filepath string) { file, err := os.ReadFile(filepath) if err != nil { LOGHANDLER.Fatal(err) } err = json.Unmarshal(file, cfg) if err != nil { LOGHANDLER.Fatal(err) } err = cfg.Validate() if err != nil { LOGHANDLER.Fatal(ErrInvalidPort) } } // Validate the config parsed into the Config struct func (cfg *Config) Validate() error { i, err := strconv.Atoi(cfg.Port) if err != nil { return ErrInvalidPort } if i <= 0 || i > 65535 { return ErrInvalidPort } return nil } ================================================ FILE: config_test.go ================================================ package webgo import ( "bytes" "testing" "time" ) func TestConfig_LoadInvalid(t *testing.T) { t.Parallel() tl := &testLogger{ out: bytes.Buffer{}, } LOGHANDLER = tl cfg := &Config{} cfg.Load("") str := tl.out.String() want := "open : no such file or directoryunexpected end of JSON inputport number not provided or is invalid (should be between 0 - 65535)" got := str if got != want { t.Errorf( "Expected '%s', got '%s'", want, got, ) } tl.out.Reset() } func TestConfig_LoadValid(t *testing.T) { t.Parallel() cfg := Config{} cfg.Load("tests/config.json") cfg.Port = "a" if cfg.Validate() != ErrInvalidPort { t.Error("Port validation failed") } cfg.Port = "65536" if cfg.Validate() != ErrInvalidPort { t.Error("Port validation failed") } } func TestConfig_Validate(t *testing.T) { t.Parallel() type fields struct { Host string Port string CertFile string KeyFile string HTTPSPort string ReadTimeout time.Duration WriteTimeout time.Duration InsecureSkipVerify bool ShutdownTimeout time.Duration } tests := []struct { name string fields fields wantErr bool }{ { name: "invalid port", fields: fields{ Port: "-12", }, wantErr: true, }, { name: "valid port", fields: fields{ Port: "9000", }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &Config{ Host: tt.fields.Host, Port: tt.fields.Port, CertFile: tt.fields.CertFile, KeyFile: tt.fields.KeyFile, HTTPSPort: tt.fields.HTTPSPort, ReadTimeout: tt.fields.ReadTimeout, WriteTimeout: tt.fields.WriteTimeout, InsecureSkipVerify: tt.fields.InsecureSkipVerify, ShutdownTimeout: tt.fields.ShutdownTimeout, } if err := cfg.Validate(); (err != nil) != tt.wantErr { t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } ================================================ FILE: errors.go ================================================ package webgo import ( "errors" "io" "log" "os" ) var ( // ErrInvalidPort is the error returned when the port number provided in the config file is invalid ErrInvalidPort = errors.New("port number not provided or is invalid (should be between 0 - 65535)") lh *logHandler ) type logCfg string const ( // LogCfgDisableDebug is used to disable debug logs LogCfgDisableDebug = logCfg("disable-debug") // LogCfgDisableInfo is used to disable info logs LogCfgDisableInfo = logCfg("disable-info") // LogCfgDisableWarn is used to disable warning logs LogCfgDisableWarn = logCfg("disable-warn") // LogCfgDisableError is used to disable error logs LogCfgDisableError = logCfg("disable-err") // LogCfgDisableFatal is used to disable fatal logs LogCfgDisableFatal = logCfg("disable-fatal") ) // Logger defines all the logging methods to be implemented type Logger interface { Debug(data ...interface{}) Info(data ...interface{}) Warn(data ...interface{}) Error(data ...interface{}) Fatal(data ...interface{}) } // logHandler has all the log writer handlers type logHandler struct { debug *log.Logger info *log.Logger warn *log.Logger err *log.Logger fatal *log.Logger } // Debug prints log of severity 5 func (lh *logHandler) Debug(data ...interface{}) { if lh.debug == nil { return } lh.debug.Println(data...) } // Info prints logs of severity 4 func (lh *logHandler) Info(data ...interface{}) { if lh.info == nil { return } lh.info.Println(data...) } // Warn prints log of severity 3 func (lh *logHandler) Warn(data ...interface{}) { if lh.warn == nil { return } lh.warn.Println(data...) } // Error prints log of severity 2 func (lh *logHandler) Error(data ...interface{}) { if lh.err == nil { return } lh.err.Println(data...) } // Fatal prints log of severity 1 func (lh *logHandler) Fatal(data ...interface{}) { if lh.fatal == nil { return } lh.fatal.Fatalln(data...) } // LOGHANDLER is a global variable which webgo uses to log messages var LOGHANDLER Logger func init() { GlobalLoggerConfig(nil, nil) } func loggerWithCfg(stdout io.Writer, stderr io.Writer, cfgs ...logCfg) *logHandler { lh = &logHandler{ debug: log.New(stdout, "Debug ", log.LstdFlags), info: log.New(stdout, "Info ", log.LstdFlags), warn: log.New(stderr, "Warning ", log.LstdFlags), err: log.New(stderr, "Error ", log.LstdFlags), fatal: log.New(stderr, "Fatal ", log.LstdFlags|log.Llongfile), } for _, c := range cfgs { switch c { case LogCfgDisableDebug: { lh.debug = nil } case LogCfgDisableInfo: { lh.info = nil } case LogCfgDisableWarn: { lh.warn = nil } case LogCfgDisableError: { lh.err = nil } case LogCfgDisableFatal: { lh.fatal = nil } } } return lh } // GlobalLoggerConfig is used to configure the global/default logger of webgo // IMPORTANT: This is not concurrent safe func GlobalLoggerConfig(stdout io.Writer, stderr io.Writer, cfgs ...logCfg) { if stdout == nil { stdout = os.Stdout } if stderr == nil { stderr = os.Stderr } LOGHANDLER = loggerWithCfg(stdout, stderr, cfgs...) } ================================================ FILE: errors_test.go ================================================ package webgo import ( "testing" ) func Test_loggerWithCfg(t *testing.T) { t.Parallel() cfgs := []logCfg{ LogCfgDisableDebug, LogCfgDisableInfo, LogCfgDisableWarn, LogCfgDisableError, LogCfgDisableFatal, } l := loggerWithCfg(nil, nil, cfgs...) if l.debug != nil { t.Errorf("expected debug to be nil, got %v", l.debug) } if l.err != nil { t.Errorf("expected err to be nil, got %v", l.err) } if l.fatal != nil { t.Errorf("expected fatal to be nil, got %v", l.fatal) } if l.info != nil { t.Errorf("expected info to be nil, got %v", l.info) } if l.warn != nil { t.Errorf("expected warn to be nil, got %v", l.warn) } } ================================================ FILE: extensions/sse/README.md ================================================ # Server-Sent Events This extension provides support for [Server-Sent](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) Events for any net/http compliant http server. It provides the following hooks for customizing the workflows: 1. `OnCreateClient func(ctx context.Context, client *Client, count int)` 2. `OnRemoveClient func(ctx context.Context, clientID string, count int)` 3. `OnSend func(ctx context.Context, client *Client, err error)` 4. `BeforeSend func(ctx context.Context, client *Client)` ```golang import ( "github.com/naughtygopher/webgo/extensions/sse" ) func main() { sseService := sse.New() // broadcast to all active clients sseService.Broadcast(Message{ Data: "Hello world", Retry: time.MilliSecond, }) // You can replace the ClientManager with your custom implementation, and override the default one // sseService.Clients = // send message to an individual client clientID := "cli123" cli := sseService.Client(clientID) if cli != nil { cli.Message <- &Message{Data: fmt.Sprintf("Hello %s",clientID), Retry: time.MilliSecond } } } ``` ## Client Manager Client manager is an interface which is required for SSE to function, since this is an interface it's easier for you to replace if required. The default implementation is a simple one using mutex. If you have a custom implementation which is faster/better, you can easily swap out the default one. ```golang type ClientManager interface { // New should return a new client, and the total number of active clients after adding this new one New(ctx context.Context, w http.ResponseWriter, clientID string) (*Client, int) // Range should iterate through all the active clients Range(func(*Client)) // Remove should remove the active client given a clientID, and close the connection Remove(clientID string) int // Active returns the number of active clients Active() int // Clients returns a list of all active clients Clients() []*Client // Client returns *Client if clientID is active Client(clientID string) *Client } ``` ================================================ FILE: extensions/sse/client.go ================================================ package sse import ( "context" "net/http" ) type ClientManager interface { // New should return a new client, and the total number of active clients after adding this new one New(ctx context.Context, w http.ResponseWriter, clientID string) (*Client, int) // Range should iterate through all the active clients Range(func(*Client)) // Remove should remove the active client given a clientID, and close the connection Remove(clientID string) int // Active returns the number of active clients Active() int // Clients returns a list of all active clients Clients() []*Client // Client returns *Client if clientID is active Client(clientID string) *Client } type Client struct { ID string Msg chan *Message ResponseWriter http.ResponseWriter Ctx context.Context } type eventType int const ( eTypeNewClient eventType = iota eTypeClientList eTypeRemoveClient eTypeActiveClientCount eTypeClient ) func (et eventType) String() string { switch et { case eTypeNewClient: return "new_client" case eTypeClientList: return "client_list" case eTypeRemoveClient: return "remove_client" case eTypeActiveClientCount: return "active_client_count" } return "unknown" } type event struct { Type eventType ClientID string Client *Client Response chan *eventResponse } type eventResponse struct { Clients []*Client RemainingClients int Client *Client } type Clients struct { clients map[string]*Client MsgBuffer int events chan<- event } func (cs *Clients) listener(events <-chan event) { for ev := range events { switch ev.Type { case eTypeNewClient: cs.clients[ev.Client.ID] = ev.Client case eTypeClientList: copied := make([]*Client, 0, len(cs.clients)) for clientID := range cs.clients { copied = append(copied, cs.clients[clientID]) } ev.Response <- &eventResponse{ Clients: copied, } case eTypeRemoveClient: cli := cs.clients[ev.ClientID] if cli == nil { ev.Response <- nil continue } // Ctx.Done() is needed to close its streaming handler cli.Ctx.Done() delete(cs.clients, ev.ClientID) ev.Response <- nil case eTypeClient: ev.Response <- &eventResponse{ Client: cs.clients[ev.ClientID], } } } } func (cs *Clients) New(ctx context.Context, w http.ResponseWriter, clientID string) (*Client, int) { mchan := make(chan *Message, cs.MsgBuffer) cli := &Client{ ID: clientID, Msg: mchan, ResponseWriter: w, Ctx: ctx, } cs.events <- event{ Type: eTypeNewClient, Client: cli, } return cli, len(cs.clients) } func (cs *Clients) Range(f func(cli *Client)) { rch := make(chan *eventResponse) cs.events <- event{ Type: eTypeClientList, Response: rch, } response := <-rch for i := range response.Clients { f(response.Clients[i]) } } func (cs *Clients) Remove(clientID string) int { rch := make(chan *eventResponse) cs.events <- event{ Type: eTypeRemoveClient, ClientID: clientID, Response: rch, } <-rch return len(cs.clients) } func (cs *Clients) Active() int { return len(cs.clients) } // MessageChannels returns a slice of message channels of all clients // which you can then use to send message concurrently func (cs *Clients) Clients() []*Client { rch := make(chan *eventResponse) cs.events <- event{ Type: eTypeClientList, Response: rch, } response := <-rch return response.Clients } func (cs *Clients) Client(clientID string) *Client { rch := make(chan *eventResponse) cs.events <- event{ Type: eTypeClientList, Response: rch, } cli := <-rch return cli.Client } func NewClientManager() ClientManager { const buffer = 10 events := make(chan event, buffer) cli := &Clients{ clients: make(map[string]*Client), events: events, MsgBuffer: buffer, } go cli.listener(events) return cli } ================================================ FILE: extensions/sse/message.go ================================================ package sse import ( "bytes" "net/http" "strconv" "time" ) // Message represents a valid SSE message // ref: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events type Message struct { // Event is a string identifying the type of event described. If this is specified, an event will be dispatched on the browser to the listener for the specified event name; the website source code should use addEventListener() to listen for named events. The onmessage handler is called if no event name is specified for a message. Event string // Data field for the message. When the EventSource receives multiple consecutive lines that begin with data:, it concatenates them, inserting a newline character between each one. Trailing newlines are removed. Data string // ID to set the EventSource object's last event ID value. ID string // Retry is the reconnection time. If the connection to the server is lost, the browser will wait for the specified time before attempting to reconnect. This must be an integer, specifying the reconnection time in milliseconds. If a non-integer value is specified, the field is ignored. Retry time.Duration } func (m *Message) Bytes() []byte { // The event stream is a simple stream of text data which must be encoded using UTF-8. // Messages in the event stream are separated by a pair of newline characters. // A colon as the first character of a line is in essence a comment, and is ignored. buff := bytes.NewBufferString("") if m.Event != "" { buff.WriteString("event:" + m.Event + "\n") } if m.ID != "" { buff.WriteString("id:" + m.ID + "\n") } if m.Data != "" { buff.WriteString("data:" + m.Data + "\n") } if m.Retry != 0 { buff.WriteString("retry:" + strconv.Itoa(int(m.Retry.Milliseconds())) + "\n") } buff.WriteString("\n") return buff.Bytes() } func DefaultUnsupportedMessageHandler(w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusNotImplemented) _, err := w.Write([]byte("Streaming not supported")) return err } ================================================ FILE: extensions/sse/sse.go ================================================ // Package sse implements Server-Sent Events(SSE) // This extension is compliant with any net/http implementation, and is not limited to WebGo. package sse import ( "context" "net/http" ) var closeMessage = (&Message{ Data: `{"error":"close"}`, }).Bytes() type SSE struct { // ClientIDHeader is the HTTP request header in which the client ID is set. Default is `sse-clientid` ClientIDHeader string // UnsupportedMessage is used to send the error response to client if the // server doesn't support SSE UnsupportedMessage func(http.ResponseWriter, *http.Request) error // OnCreateClient is a hook, for when a client is added to the active clients. count is the number // of active clients after adding the latest client OnCreateClient func(ctx context.Context, client *Client, count int) // OnRemoveClient is a hook, for when a client is removed from the active clients. count is the number // of active clients after removing a client OnRemoveClient func(ctx context.Context, clientID string, count int) // OnSend is a hook, which is called *after* a message is sent to a client OnSend func(ctx context.Context, client *Client, err error) // BeforeSend is a hook, which is called before starting to listen for messages to send BeforeSend func(ctx context.Context, client *Client) Clients ClientManager } // Handler returns an error rather than being directly used as an http.HandlerFunc, // to let the user handle error. e.g. if the error has to be logged func (sse *SSE) Handler(w http.ResponseWriter, r *http.Request) error { flusher, hasFlusher := w.(http.Flusher) if !hasFlusher { return sse.UnsupportedMessage(w, r) } header := w.Header() header.Set("Content-Type", "text/event-stream") header.Set("Connection", "keep-alive") header.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) ctx := r.Context() clientID := r.Header.Get(sse.ClientIDHeader) client := sse.NewClient(ctx, w, clientID) defer func() { w.WriteHeader(http.StatusNoContent) sse.RemoveClient(ctx, clientID) }() sse.BeforeSend(ctx, client) for { select { case payload, ok := <-client.Msg: if !ok { return nil } _, err := w.Write(payload.Bytes()) sse.OnSend(ctx, client, err) if err != nil { return err } case <-ctx.Done(): { _, err := w.Write(closeMessage) sse.OnSend(ctx, client, err) return err } } flusher.Flush() } } // HandlerFunc is a convenience function which can be directly used with net/http implementations. // Important: You cannot handle any error returned by the Handler func (sse *SSE) HandlerFunc(w http.ResponseWriter, r *http.Request) { _ = sse.Handler(w, r) } // Broadcast sends the message to all active clients func (sse *SSE) Broadcast(msg Message) { sse.Clients.Range(func(cli *Client) { cli.Msg <- &msg }) } func (sse *SSE) NewClient(ctx context.Context, w http.ResponseWriter, clientID string) *Client { cli, count := sse.Clients.New(ctx, w, clientID) sse.OnCreateClient(ctx, cli, count) return cli } func (sse *SSE) ActiveClients() int { return sse.Clients.Active() } func (sse *SSE) RemoveClient(ctx context.Context, clientID string) { sse.OnRemoveClient( ctx, clientID, sse.Clients.Remove(clientID), ) } func (sse *SSE) Client(id string) *Client { return sse.Clients.Client(id) } func DefaultCreateHook(ctx context.Context, client *Client, count int) {} func DefaultRemoveHook(ctx context.Context, clientID string, count int) {} func DefaultOnSend(ctx context.Context, client *Client, err error) {} func DefaultBeforeSend(ctx context.Context, client *Client) {} func New() *SSE { s := &SSE{ ClientIDHeader: "sse-clientid", Clients: NewClientManager(), UnsupportedMessage: DefaultUnsupportedMessageHandler, OnRemoveClient: DefaultRemoveHook, OnCreateClient: DefaultCreateHook, OnSend: DefaultOnSend, BeforeSend: DefaultBeforeSend, } return s } ================================================ FILE: go.mod ================================================ module github.com/naughtygopher/webgo/v7 go 1.22 ================================================ FILE: go.sum ================================================ ================================================ FILE: middleware/accesslog/accesslog.go ================================================ /* Package accesslogs provides a simple straight forward access log middleware. The logs are of the following format: */ package accesslog import ( "fmt" "net/http" "time" "github.com/naughtygopher/webgo/v7" ) // AccessLog is a middleware which prints access log to stdout func AccessLog(rw http.ResponseWriter, req *http.Request, next http.HandlerFunc) { start := time.Now() next(rw, req) end := time.Now() webgo.LOGHANDLER.Info( fmt.Sprintf( "%s %s %s %d", req.Method, req.URL.String(), end.Sub(start).String(), webgo.ResponseStatus(rw), ), ) } ================================================ FILE: middleware/accesslog/accesslog_test.go ================================================ /* Package accesslogs provides a simple straight forward access log middleware. The logs are of the following format: */ package accesslog import ( "bytes" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/naughtygopher/webgo/v7" ) func TestAccessLog(t *testing.T) { stdout := bytes.NewBuffer([]byte(``)) stderr := bytes.NewBuffer([]byte(``)) webgo.GlobalLoggerConfig(stdout, stderr) port := "9696" router, err := setup(port) if err != nil { t.Error(err.Error()) return } router.Use(AccessLog) router.SetupMiddleware() url := fmt.Sprintf("http://localhost:%s/hello", port) w := httptest.NewRecorder() req := httptest.NewRequest( http.MethodGet, url, nil, ) router.ServeHTTP(w, req) parts := strings.Split(stdout.String(), " ") if len(parts) != 7 { t.Errorf( "Expected log to have %d parts, got %d", 7, len(parts), ) return } if parts[0] != "Info" { t.Errorf("expected log type 'Info', got '%s'", parts[0]) } if parts[3] != http.MethodGet { t.Errorf("expected HTTP method %s, got %s", http.MethodGet, parts[3]) } if parts[4] != url { t.Errorf("expected HTTP full URL '%s', got '%s'", url, parts[4]) } if parts[6][0:3] != "200" { t.Errorf("expected HTTP status code '%d', got '%s'", http.StatusOK, parts[6][0:3]) } } func handler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`hello`)) } func setup(port string) (*webgo.Router, error) { cfg := &webgo.Config{ Port: "9696", ReadTimeout: time.Second * 1, WriteTimeout: time.Second * 1, ShutdownTimeout: time.Second * 10, CertFile: "tests/ssl/server.crt", KeyFile: "tests/ssl/server.key", } router := webgo.NewRouter(cfg, &webgo.Route{ Name: "hello", Pattern: "/hello", Method: http.MethodGet, Handlers: []http.HandlerFunc{handler}, }) return router, nil } ================================================ FILE: middleware/cors/cors.go ================================================ /* Package cors sets the appropriate CORS(https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) response headers, and lets you customize. Following customizations are allowed: - provide a list of allowed domains - provide a list of headers - set the max-age of CORS headers The list of allowed methods are */ package cors import ( "fmt" "net/http" "regexp" "sort" "strings" "github.com/naughtygopher/webgo/v7" ) const ( headerOrigin = "Access-Control-Allow-Origin" headerMethods = "Access-Control-Allow-Methods" headerCreds = "Access-Control-Allow-Credentials" headerAllowHeaders = "Access-Control-Allow-Headers" headerReqHeaders = "Access-Control-Request-Headers" headerAccessControlAge = "Access-Control-Max-Age" allowHeaders = "Accept,Content-Type,Content-Length,Accept-Encoding,Access-Control-Request-Headers," ) var ( defaultAllowMethods = "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS" ) func allowedDomains() []string { // The domains mentioned here are default domains := []string{"*"} return domains } func getReqOrigin(r *http.Request) string { return r.Header.Get("Origin") } func allowedOriginsRegex(allowedOrigins ...string) []regexp.Regexp { if len(allowedOrigins) == 0 { allowedOrigins = []string{"*"} } else { // If "*" is one of the allowed domains, i.e. all domains, then rest of the values are ignored for _, val := range allowedOrigins { val = strings.TrimSpace(val) if val == "*" { allowedOrigins = []string{"*"} break } } } allowedOriginRegex := make([]regexp.Regexp, 0, len(allowedOrigins)) for _, ao := range allowedOrigins { parts := strings.Split(ao, ":") str := strings.TrimSpace(parts[0]) if str == "" { continue } if str == "*" { allowedOriginRegex = append( allowedOriginRegex, *(regexp.MustCompile(".+")), ) break } regStr := fmt.Sprintf(`^(http)?(https)?(:\/\/)?(.+\.)?%s(:[0-9]+)?$`, str) allowedOriginRegex = append( allowedOriginRegex, // Allow any port number of the specified domain *(regexp.MustCompile(regStr)), ) } return allowedOriginRegex } func allowedMethods(routes []*webgo.Route) string { if len(routes) == 0 { return defaultAllowMethods } methods := make([]string, 0, len(routes)) for _, r := range routes { found := false for _, m := range methods { if m == r.Method { found = true break } } if found { continue } methods = append(methods, r.Method) } sort.Strings(methods) return strings.Join(methods, ",") } // Config holds all the configurations which is available for customizing this middleware type Config struct { TimeoutSecs int Routes []*webgo.Route AllowedOrigins []string AllowedHeaders []string } func allowedHeaders(headers []string) string { if len(headers) == 0 { return allowHeaders } allowedHeaders := strings.Join(headers, ",") if allowedHeaders[len(allowedHeaders)-1] != ',' { allowedHeaders += "," } return allowedHeaders } func allowOrigin(reqOrigin string, allowedOriginRegex []regexp.Regexp) bool { for _, o := range allowedOriginRegex { // Set appropriate response headers required for CORS if o.MatchString(reqOrigin) || reqOrigin == "" { return true } } return false } // Middleware can be used as well, it lets the user use this middleware without webgo func Middleware(allowedOriginRegex []regexp.Regexp, corsTimeout, allowedMethods, allowedHeaders string) webgo.Middleware { return func(rw http.ResponseWriter, req *http.Request, next http.HandlerFunc) { reqOrigin := getReqOrigin(req) allowed := allowOrigin(reqOrigin, allowedOriginRegex) if !allowed { // If CORS failed, no respective headers are set. But the execution is allowed to continue // Earlier this middleware blocked access altogether, which was considered an added // security measure despite it being outside the scope of this middelware. Though, such // restrictions create unnecessary complexities during inter-app communication. next(rw, req) return } // Set appropriate response headers required for CORS rw.Header().Set(headerOrigin, reqOrigin) rw.Header().Set(headerAccessControlAge, corsTimeout) rw.Header().Set(headerCreds, "true") rw.Header().Set(headerMethods, allowedMethods) rw.Header().Set(headerAllowHeaders, allowedHeaders+req.Header.Get(headerReqHeaders)) if req.Method == http.MethodOptions { webgo.SendHeader(rw, http.StatusOK) return } next(rw, req) } } // AddOptionsHandlers appends OPTIONS handler for all the routes // The response body would be empty for all the new handlers added func AddOptionsHandlers(routes []*webgo.Route) []*webgo.Route { dummyHandler := func(w http.ResponseWriter, r *http.Request) {} if len(routes) == 0 { return []*webgo.Route{ { Name: "cors", Pattern: "/:w*", Method: http.MethodOptions, TrailingSlash: true, Handlers: []http.HandlerFunc{dummyHandler}, }, } } list := make([]*webgo.Route, 0, len(routes)) list = append(list, routes...) for _, r := range routes { list = append(list, &webgo.Route{ Name: fmt.Sprintf("%s-CORS", r.Name), Method: http.MethodOptions, Pattern: r.Pattern, TrailingSlash: true, Handlers: []http.HandlerFunc{dummyHandler}, }) } return list } // CORS is a single CORS middleware which can be applied to the whole app at once func CORS(cfg *Config) webgo.Middleware { if cfg == nil { cfg = new(Config) // 30 minutes cfg.TimeoutSecs = 30 * 60 } allowedOrigins := cfg.AllowedOrigins if len(allowedOrigins) == 0 { allowedOrigins = allowedDomains() } allowedOriginRegex := allowedOriginsRegex(allowedOrigins...) allowedmethods := allowedMethods(cfg.Routes) allowedHeaders := allowedHeaders(cfg.AllowedHeaders) corsTimeout := fmt.Sprintf("%d", cfg.TimeoutSecs) return Middleware( allowedOriginRegex, corsTimeout, allowedmethods, allowedHeaders, ) } ================================================ FILE: middleware/cors/cors_test.go ================================================ /* Package cors sets the appropriate CORS(https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) response headers, and lets you customize. Following customizations are allowed: - provide a list of allowed domains - provide a list of headers - set the max-age of CORS headers The list of allowed methods are */ package cors import ( "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/naughtygopher/webgo/v7" ) func TestCORSEmptyconfig(t *testing.T) { port := "9696" routes := getRoutes() routes = append(routes, AddOptionsHandlers(nil)...) router, err := setup(port, routes) if err != nil { t.Error(err.Error()) return } router.Use(CORS(&Config{TimeoutSecs: 50})) router.SetupMiddleware() url := fmt.Sprintf("http://localhost:%s/hello", port) w := httptest.NewRecorder() req := httptest.NewRequest( http.MethodGet, url, nil, ) router.ServeHTTP(w, req) body, _ := io.ReadAll(w.Body) str := string(body) if str != "hello" { t.Errorf( "Expected body '%s', got '%s'", "hello", str, ) } if w.Header().Get(headerMethods) != defaultAllowMethods { t.Errorf( "Expected header %s to be '%s', got '%s'", headerMethods, defaultAllowMethods, w.Header().Get(headerMethods), ) } if w.Header().Get(headerCreds) != "true" { t.Errorf( "Expected header %s to be 'true', got '%s'", headerCreds, w.Header().Get(headerCreds), ) } if w.Header().Get(headerAccessControlAge) != "50" { t.Errorf( "Expected '%s' to be '50', got '%s'", headerAccessControlAge, w.Header().Get(headerAccessControlAge), ) } if w.Header().Get(headerAllowHeaders) != allowHeaders { t.Errorf( "Expected '%s' to be '%s', got '%s'", headerAllowHeaders, allowHeaders, w.Header().Get(headerAllowHeaders), ) } // check OPTIONS method w = httptest.NewRecorder() req = httptest.NewRequest( http.MethodOptions, url, nil, ) router.ServeHTTP(w, req) body, _ = io.ReadAll(w.Body) str = string(body) if str != "" { t.Errorf( "Expected empty body, got '%s'", str, ) } } func TestCORSWithConfig(t *testing.T) { port := "9696" routes := AddOptionsHandlers(getRoutes()) router, err := setup(port, routes) if err != nil { t.Error(err.Error()) return } cfg := &Config{ Routes: routes, AllowedOrigins: []string{"example.com", fmt.Sprintf("localhost:%s", port)}, AllowedHeaders: []string{"x-custom"}, } router.Use(CORS(cfg)) baseAPI := fmt.Sprintf("http://localhost:%s", port) url := fmt.Sprintf("%s/hello", baseAPI) w := httptest.NewRecorder() req := httptest.NewRequest( http.MethodGet, url, nil, ) router.SetupMiddleware() router.ServeHTTP(w, req) if w.Header().Get(headerMethods) != "GET,OPTIONS" { t.Errorf( "Expected value for %s header is 'GET', got '%s'", headerMethods, w.Header().Get(headerMethods), ) } want := strings.Join(cfg.AllowedHeaders, ",") + "," if w.Header().Get(headerAllowHeaders) != want { t.Errorf( "Expected value for %s header is '%s', got '%s'", headerAllowHeaders, want, w.Header().Get(headerAllowHeaders), ) } // test OPTIONS request w = httptest.NewRecorder() req = httptest.NewRequest( http.MethodOptions, url, nil, ) req.Header.Set("Origin", "helloworld.com") router.ServeHTTP(w, req) body, _ := io.ReadAll(w.Body) str := string(body) if str != "" { t.Errorf( "Expected empty body, got '%s'", str, ) } // since origin is set as "helloworld.com", which is not in the allowed list of origins // CORS headers should NOT be set if w.Header().Get(headerOrigin) != "" { t.Errorf( "Expected empty value for header '%s', got '%s'", headerOrigin, w.Header().Get(headerOrigin), ) } if w.Header().Get(headerAccessControlAge) != "" { t.Errorf( "Expected empty value for header '%s', got '%s'", headerAccessControlAge, w.Header().Get(headerAccessControlAge), ) } if w.Header().Get(headerCreds) != "" { t.Errorf( "Expected empty value for header '%s', got '%s'", headerCreds, w.Header().Get(headerCreds), ) } if w.Header().Get(headerMethods) != "" { t.Errorf( "Expected empty value for header '%s', got '%s'", headerMethods, w.Header().Get(headerMethods), ) } if w.Header().Get(headerAllowHeaders) != "" { t.Errorf( "Expected empty value for header '%s', got '%s'", headerAllowHeaders, w.Header().Get(headerAllowHeaders), ) } } func handler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`hello`)) } func getRoutes() []*webgo.Route { return []*webgo.Route{ { Name: "hello", Pattern: "/hello", Method: http.MethodGet, Handlers: []http.HandlerFunc{handler}, }, } } func setup(port string, routes []*webgo.Route) (*webgo.Router, error) { cfg := &webgo.Config{ Port: "9696", ReadTimeout: time.Second * 1, WriteTimeout: time.Second * 1, ShutdownTimeout: time.Second * 10, CertFile: "tests/ssl/server.crt", KeyFile: "tests/ssl/server.key", } router := webgo.NewRouter(cfg, routes...) return router, nil } ================================================ FILE: responses.go ================================================ package webgo import ( "encoding/json" "fmt" "html/template" "net/http" ) var ( jsonErrPayload = []byte{} ) // ErrorData used to render the error page type ErrorData struct { ErrCode int ErrDescription string } // dOutput is the standard/valid output wrapped in `{data: , status: }` type dOutput struct { Data interface{} `json:"data"` Status int `json:"status"` } // errOutput is the error output wrapped in `{errors:, status: }` type errOutput struct { Errors interface{} `json:"errors"` Status int `json:"status"` } const ( // HeaderContentType is the key for mentioning the response header content type HeaderContentType = "Content-Type" // JSONContentType is the MIME type when the response is JSON JSONContentType = "application/json" // HTMLContentType is the MIME type when the response is HTML HTMLContentType = "text/html; charset=UTF-8" // ErrInternalServer to send when there's an internal server error ErrInternalServer = "Internal server error" ) // SendHeader is used to send only a response header, i.e no response body func SendHeader(w http.ResponseWriter, rCode int) { w.WriteHeader(rCode) } func crwAsserter(w http.ResponseWriter, rCode int) http.ResponseWriter { if crw, ok := w.(*customResponseWriter); ok { crw.statusCode = rCode return crw } return newCRW(w, rCode) } // Send sends a completely custom response without wrapping in the // `{data: , status: ` struct func Send(w http.ResponseWriter, contentType string, data interface{}, rCode int) { w = crwAsserter(w, rCode) w.Header().Set(HeaderContentType, contentType) _, err := fmt.Fprint(w, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(ErrInternalServer)) LOGHANDLER.Error(err) } } // SendResponse is used to respond to any request (JSON response) based on the code, data etc. func SendResponse(w http.ResponseWriter, data interface{}, rCode int) { w = crwAsserter(w, rCode) w.Header().Add(HeaderContentType, JSONContentType) err := json.NewEncoder(w).Encode(dOutput{Data: data, Status: rCode}) if err == nil { return } // assuming the error was related to JSON encoding, so reattempting to respond // with a static payload. This could still fail in case of network write or other error(s) w = crwAsserter(w, http.StatusInternalServerError) _, _ = w.Write(jsonErrPayload) LOGHANDLER.Error(err) } // SendError is used to respond to any request with an error func SendError(w http.ResponseWriter, data interface{}, rCode int) { w = crwAsserter(w, rCode) w.Header().Add(HeaderContentType, JSONContentType) err := json.NewEncoder(w).Encode(errOutput{data, rCode}) if err == nil { return } // assuming the error was related to JSON encoding, so reattempting to respond // with a static payload. This could still fail in case of network write or other error(s) w = crwAsserter(w, http.StatusInternalServerError) _, _ = w.Write(jsonErrPayload) LOGHANDLER.Error(err) } // Render is used for rendering templates (HTML) func Render(w http.ResponseWriter, data interface{}, rCode int, tpl *template.Template) { w = crwAsserter(w, rCode) // In case of HTML response, setting appropriate header type for text/HTML response w.Header().Set(HeaderContentType, HTMLContentType) // Rendering an HTML template with appropriate data err := tpl.Execute(w, data) if err != nil { Send(w, "text/plain", ErrInternalServer, http.StatusInternalServerError) LOGHANDLER.Error(err.Error()) } } // R200 - Successful/OK response func R200(w http.ResponseWriter, data interface{}) { SendResponse(w, data, http.StatusOK) } // R201 - New item created func R201(w http.ResponseWriter, data interface{}) { SendResponse(w, data, http.StatusCreated) } // R204 - empty, no content func R204(w http.ResponseWriter) { SendHeader(w, http.StatusNoContent) } // R302 - Temporary redirect func R302(w http.ResponseWriter, data interface{}) { SendResponse(w, data, http.StatusFound) } // R400 - Invalid request, any incorrect/erraneous value in the request body func R400(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusBadRequest) } // R403 - Unauthorized access func R403(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusForbidden) } // R404 - Resource not found func R404(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusNotFound) } // R406 - Unacceptable header. For any error related to values set in header func R406(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusNotAcceptable) } // R451 - Resource taken down because of a legal request func R451(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusUnavailableForLegalReasons) } // R500 - Internal server error func R500(w http.ResponseWriter, data interface{}) { SendError(w, data, http.StatusInternalServerError) } ================================================ FILE: responses_test.go ================================================ package webgo import ( "encoding/json" "html/template" "io" "net/http" "net/http/httptest" "reflect" "testing" ) func TestSendHeader(t *testing.T) { t.Parallel() w := httptest.NewRecorder() SendHeader(w, http.StatusNoContent) if w.Result().StatusCode != http.StatusNoContent { t.Errorf("Expected code '%d', got '%d'", http.StatusNoContent, w.Result().StatusCode) } } func TestSendError(t *testing.T) { t.Parallel() w := httptest.NewRecorder() payload := map[string]string{"message": "hello world"} SendError(w, payload, http.StatusBadRequest) resp := struct { Errors map[string]string }{} body, err := io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if !reflect.DeepEqual(payload, resp.Errors) { t.Errorf( "Expected '%v', got '%v'. Raw response: '%s'", payload, resp.Errors, string(body), ) } if w.Result().StatusCode != http.StatusBadRequest { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusBadRequest, w.Result().StatusCode, string(body), ) } // testing invalid response body w = httptest.NewRecorder() invResp := struct { Errors string }{} invalidPayload := make(chan int) SendError(w, invalidPayload, http.StatusBadRequest) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &invResp) if err != nil { t.Error(err.Error()) return } if invResp.Errors != `Internal server error` { t.Errorf( "Expected 'Internal server error', got '%v'. Raw response: '%s'", invResp.Errors, string(body), ) } if w.Result().StatusCode != http.StatusInternalServerError { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusInternalServerError, w.Result().StatusCode, string(body), ) } } func TestSendResponse(t *testing.T) { t.Parallel() w := httptest.NewRecorder() payload := map[string]string{"hello": "world"} SendResponse(w, payload, http.StatusOK) body, err := io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } resp := struct { Data map[string]string }{} err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) } if !reflect.DeepEqual(payload, resp.Data) { t.Errorf( "Expected '%v', got '%v'. Raw response: '%s'", payload, resp.Data, string(body), ) } if w.Result().StatusCode != http.StatusOK { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusOK, w.Result().StatusCode, string(body), ) } // testing invalid response payload w = httptest.NewRecorder() SendResponse(w, make(chan int), http.StatusOK) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } invalidresp := struct { Errors string }{} err = json.Unmarshal(body, &invalidresp) if err != nil { t.Error(err.Error()) } if !reflect.DeepEqual(`Internal server error`, invalidresp.Errors) { t.Errorf( "Expected '%v', got '%v'. Raw response: '%s'", payload, invalidresp.Errors, string(body), ) } if w.Result().StatusCode != http.StatusInternalServerError { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusInternalServerError, w.Result().StatusCode, string(body), ) } } func TestSend(t *testing.T) { t.Parallel() w := httptest.NewRecorder() payload := map[string]string{"hello": "world"} reqBody, _ := json.Marshal(payload) Send(w, JSONContentType, string(reqBody), http.StatusOK) body, err := io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } resp := map[string]string{} err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) } if !reflect.DeepEqual(payload, resp) { t.Errorf( "Expected '%v', got '%v'. Raw response: '%s'", payload, resp, string(body), ) } if w.Result().StatusCode != http.StatusOK { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusOK, w.Result().StatusCode, string(body), ) } } func TestRender(t *testing.T) { t.Parallel() w := httptest.NewRecorder() data := struct { Hello string }{ Hello: "world", } tpl := template.New("txttemp") tpl, err := tpl.Parse(`{{.Hello}}`) if err != nil { t.Error(err.Error()) return } Render(w, data, http.StatusOK, tpl) body, err := io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } if w.Code != http.StatusOK { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusOK, w.Code, string(body), ) } w = httptest.NewRecorder() invaliddata := 0 tpl = template.New("invalid") tpl, err = tpl.Parse(`{{.Hello}}`) if err != nil { t.Error(err.Error()) return } Render(w, invaliddata, http.StatusOK, tpl) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } str := string(body) want := `Internal server error` if str != want { t.Errorf( "Expected '%s', got '%s'. Raw response: '%s'", want, str, str, ) } if w.Code != http.StatusInternalServerError { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusInternalServerError, w.Code, string(body), ) } } func TestResponsehelpers(t *testing.T) { t.Parallel() w := httptest.NewRecorder() want := "hello world" resp := struct { Data string Errors string Status int }{} R200(w, want) body, err := io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Data != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Data, ) } if w.Code != http.StatusOK { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusOK, w.Code, string(body), ) } // R201 w = httptest.NewRecorder() resp.Data = "" R201(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Data != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Data, ) } if w.Code != http.StatusCreated { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusCreated, w.Code, string(body), ) } // R204 w = httptest.NewRecorder() resp.Data = "" R204(w) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } if string(body) != "" { t.Errorf( "Expected empty response, got '%s'", string(body), ) } if w.Code != http.StatusNoContent { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusNoContent, w.Code, string(body), ) } // R302 w = httptest.NewRecorder() resp.Data = "" R302(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Data != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Data, ) } if w.Code != http.StatusFound { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusFound, w.Code, string(body), ) } // R400 w = httptest.NewRecorder() resp.Data = "" resp.Errors = "" R400(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Errors != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Errors, ) } if w.Code != http.StatusBadRequest { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusBadRequest, w.Code, string(body), ) } // R403 w = httptest.NewRecorder() resp.Data = "" resp.Errors = "" R403(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Errors != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Errors, ) } if w.Code != http.StatusForbidden { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusForbidden, w.Code, string(body), ) } // R404 w = httptest.NewRecorder() resp.Data = "" resp.Errors = "" R404(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Errors != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Errors, ) } if w.Code != http.StatusNotFound { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusNotFound, w.Code, string(body), ) } // R406 w = httptest.NewRecorder() resp.Data = "" resp.Errors = "" R406(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Errors != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Errors, ) } if w.Code != http.StatusNotAcceptable { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusNotAcceptable, w.Code, string(body), ) } // R451 w = httptest.NewRecorder() resp.Data = "" resp.Errors = "" R451(w, want) body, err = io.ReadAll(w.Body) if err != nil { t.Error(err.Error()) return } err = json.Unmarshal(body, &resp) if err != nil { t.Error(err.Error()) return } if resp.Errors != want { t.Errorf( "Expected '%s', got '%s'", want, resp.Errors, ) } if w.Code != http.StatusUnavailableForLegalReasons { t.Errorf( "Expected response status code %d, got %d. Raw response: '%s'", http.StatusUnavailableForLegalReasons, w.Code, string(body), ) } } ================================================ FILE: route.go ================================================ package webgo import ( "bytes" "fmt" "net/http" "strings" ) // Route defines a route for each API type Route struct { // Name is unique identifier for the route Name string // Method is the HTTP request method/type Method string // Pattern is the URI pattern to match Pattern string // TrailingSlash if set to true, the URI will be matched with or without // a trailing slash. IMPORTANT: It does not redirect. TrailingSlash bool // FallThroughPostResponse if enabled will execute all the handlers even if a response was already sent to the client FallThroughPostResponse bool // Handlers is a slice of http.HandlerFunc which can be middlewares or anything else. Though only 1 of them will be allowed to respond to client. // subsequent writes from the following handlers will be ignored Handlers []http.HandlerFunc hasWildcard bool fragments []uriFragment paramsCount int // skipMiddleware if true, middleware added using `router` will not be applied to this Route. // This is used only when a Route is set using the RouteGroup, which can have its own set of middleware skipMiddleware bool // middlewareList is used at the last stage, i.e. right before starting the server middlewarelist []Middleware initialized bool serve http.HandlerFunc } type uriFragment struct { isVariable bool hasWildcard bool // fragment will be the key name, if it's a variable/named URI parameter fragment string } func (r *Route) parseURIWithParams() { // if there are no URI params, then there's no need to set route parts if !strings.Contains(r.Pattern, ":") { return } fragments := strings.Split(r.Pattern, "/") if len(fragments) == 1 { return } rFragments := make([]uriFragment, 0, len(fragments)) for _, fragment := range fragments[1:] { hasParam := false hasWildcard := false if strings.Contains(fragment, ":") { hasParam = true r.paramsCount++ } if strings.Contains(fragment, "*") { r.hasWildcard = true hasWildcard = true } key := strings.ReplaceAll(fragment, ":", "") key = strings.ReplaceAll(key, "*", "") rFragments = append( rFragments, uriFragment{ isVariable: hasParam, hasWildcard: hasWildcard, fragment: key, }) } r.fragments = rFragments } func (r *Route) setupMiddleware(reverse bool) { if reverse { for i := range r.middlewarelist { m := r.middlewarelist[i] srv := r.serve r.serve = func(rw http.ResponseWriter, req *http.Request) { m(rw, req, srv) } } } else { for i := len(r.middlewarelist) - 1; i >= 0; i-- { m := r.middlewarelist[i] srv := r.serve r.serve = func(rw http.ResponseWriter, req *http.Request) { m(rw, req, srv) } } } // clear middlewarelist since it's already setup for the route r.middlewarelist = nil } // init does all the initializations required for the route func (r *Route) init() error { if r.initialized { return nil } r.initialized = true r.parseURIWithParams() r.serve = defaultRouteServe(r) return nil } // matchPath matches the requestURI with the URI pattern of the route func (r *Route) matchPath(requestURI string) (bool, map[string]string) { p := bytes.NewBufferString(r.Pattern) if r.TrailingSlash { p.WriteString("/") } else { if requestURI[len(requestURI)-1] == '/' { return false, nil } } if r.Pattern == requestURI || p.String() == requestURI { return true, nil } return r.matchWithWildcard(requestURI) } func (r *Route) matchWithWildcard(requestURI string) (bool, map[string]string) { // if r.fragments is empty, it means there are no variables in the URI pattern // hence no point checking if len(r.fragments) == 0 { return false, nil } params := make(map[string]string, r.paramsCount) uriFragments := strings.Split(requestURI, "/")[1:] fragmentsLastIdx := len(r.fragments) - 1 fragmentIdx := 0 uriParameter := make([]string, 0, len(uriFragments)) for idx, fragment := range uriFragments { // if part is empty, it means it's end of URI with trailing slash if fragment == "" { break } if fragmentIdx > fragmentsLastIdx { return false, nil } currentFragment := r.fragments[fragmentIdx] if !currentFragment.isVariable && currentFragment.fragment != fragment { return false, nil } uriParameter = append(uriParameter, fragment) if currentFragment.isVariable { params[currentFragment.fragment] = strings.Join(uriParameter, "/") } if !currentFragment.hasWildcard { uriParameter = make([]string, 0, len(uriFragments)-idx) fragmentIdx++ continue } nextIdx := fragmentIdx + 1 if nextIdx > fragmentsLastIdx { continue } nextPart := r.fragments[nextIdx] // if the URI has more fragments/params after wildcard, // the immediately following part after wildcard cannot be a variable or another wildcard. if !nextPart.isVariable && nextPart.fragment == fragment { // remove the last added 'part' from parameters, as it's part of the static URI params[currentFragment.fragment] = strings.Join(uriParameter[:len(uriParameter)-1], "/") uriParameter = make([]string, 0, len(uriFragments)-idx) fragmentIdx += 2 } } if len(params) != r.paramsCount { return false, nil } return true, params } func (r *Route) use(mm ...Middleware) { if r.middlewarelist == nil { r.middlewarelist = make([]Middleware, 0, len(mm)) } r.middlewarelist = append(r.middlewarelist, mm...) } func routeServeChainedHandlers(r *Route) http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { crw, ok := rw.(*customResponseWriter) if !ok { crw = newCRW(rw, http.StatusOK) } for _, handler := range r.Handlers { if crw.written && !r.FallThroughPostResponse { break } handler(crw, req) } } } func defaultRouteServe(r *Route) http.HandlerFunc { if len(r.Handlers) > 1 { return routeServeChainedHandlers(r) } // when there is only 1 handler, custom response writer is not required to check if response // is already written or fallthrough is enabled return r.Handlers[0] } type RouteGroup struct { routes []*Route // skipRouterMiddleware if set to true, middleware applied to the router will not be applied // to this route group. skipRouterMiddleware bool // PathPrefix is the URI prefix for all routes in this group PathPrefix string } func (rg *RouteGroup) Add(rr ...Route) { for idx := range rr { route := rr[idx] route.skipMiddleware = rg.skipRouterMiddleware route.Pattern = fmt.Sprintf("%s%s", rg.PathPrefix, route.Pattern) rg.routes = append(rg.routes, &route) } } func (rg *RouteGroup) Use(mm ...Middleware) { for _, route := range rg.routes { route.use(mm...) } } func (rg *RouteGroup) Routes() []*Route { return rg.routes } func NewRouteGroup(pathPrefix string, skipRouterMiddleware bool, rr ...Route) *RouteGroup { rg := RouteGroup{ PathPrefix: pathPrefix, skipRouterMiddleware: skipRouterMiddleware, } rg.Add(rr...) return &rg } ================================================ FILE: route_test.go ================================================ package webgo import ( "fmt" "net/http" "reflect" "testing" ) func TestRouteGroupsPathPrefix(t *testing.T) { t.Parallel() routes := []Route{ { Name: "r1", Pattern: "/a", Method: http.MethodGet, Handlers: []http.HandlerFunc{dummyHandler}, }, { Name: "r2", Pattern: "/b/:c", Method: http.MethodGet, Handlers: []http.HandlerFunc{dummyHandler}, }, { Name: "r3", Pattern: "/:w*", Method: http.MethodGet, Handlers: []http.HandlerFunc{dummyHandler}, }, } const prefix = "/v7.0.0" expectedSkipMiddleware := true rg := NewRouteGroup("/v7.0.0", expectedSkipMiddleware, routes...) list := rg.Routes() for idx := range list { route := list[idx] originalRoute := routes[idx] expectedPattern := fmt.Sprintf("%s%s", prefix, originalRoute.Pattern) if route.Pattern != expectedPattern { t.Errorf("Expected pattern %q, got %q", expectedPattern, route.Pattern) } if route.skipMiddleware != expectedSkipMiddleware { t.Errorf("Expected skip %v, got %v", expectedSkipMiddleware, route.skipMiddleware) } } } func dummyHandler(w http.ResponseWriter, r *http.Request) {} func BenchmarkMatchWithWildcard(b *testing.B) { route := Route{ Name: "widlcard", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/:w*/static1/:myvar/:w2*", Handlers: []http.HandlerFunc{dummyHandler}, } uri := "/hello/world/how/are/you/static1/hello2/world2/how2/are2/you2/static2" err := route.init() if err != nil { b.Error(err) return } for i := 0; i < b.N; i++ { ok, _ := route.matchPath(uri) if !ok { b.Errorf("Expected match, got no match") break } } } func TestMatchWithWildcard(t *testing.T) { route := Route{ Name: "widlcard", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/:w*/static1/:myvar/:w2*/static2", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } uri := "/hello/world/how/are/you/static1/hello2/world2/how2/are2/you2/static2" wantParams := map[string]string{ "w": "hello/world/how/are/you", "myvar": "hello2", "w2": "world2/how2/are2/you2", } matched, params := route.matchPath(uri) if !matched { t.Errorf("Expected match, got no match") return } if !reflect.DeepEqual(params, wantParams) { t.Errorf("Expected params %v, got %v", wantParams, params) return } t.Run("no match", func(t *testing.T) { route := Route{ Name: "widlcard", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/:w*/static1/:myvar/:w2*/static2", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } uri := "/hello/world/how/are/you/static2/hello2/world2/how2/are2/you2/static2" matched, params := route.matchPath(uri) if matched { t.Errorf("Expected no match, got match") return } if params != nil { t.Errorf("Expected params %v, got %v", nil, params) return } }) t.Run("match with more params", func(t *testing.T) { route := Route{ Name: "widlcard", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/:w*/static1/:myvar/:w2*/static2/:myvar2/:w3*/static3", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } uri := "/hello/world/how/are/you/static1/hello2/world2/how2/are2/you2/static2/hello3/world3/how3/are3/you3/static3" wantParams := map[string]string{ "w": "hello/world/how/are/you", "myvar": "hello2", "w2": "world2/how2/are2/you2", "myvar2": "hello3", "w3": "world3/how3/are3/you3", } matched, params := route.matchPath(uri) if !matched { t.Errorf("Expected match, got no match") return } if !reflect.DeepEqual(params, wantParams) { t.Errorf("Expected params %v, got %v", wantParams, params) return } }) t.Run("match - end with wildcard", func(t *testing.T) { route := Route{ Name: "widlcard", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/:w*/static1/:myvar/:w2*", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } uri := "/hello/world/how/are/you/static1/hello2/world2/how2/are2/you2/static2" wantParams := map[string]string{ "w": "hello/world/how/are/you", "myvar": "hello2", "w2": "world2/how2/are2/you2/static2", } matched, params := route.matchPath(uri) if !matched { t.Errorf("Expected match, got no match") return } if !reflect.DeepEqual(params, wantParams) { t.Errorf("Expected params %v, got %v", wantParams, params) return } }) t.Run("root URI, no match", func(t *testing.T) { route := Route{ Name: "", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/-/health", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } matched, _ := route.matchPath("/") if matched { t.Errorf("Expected no match, got match") return } }) t.Run("root URI, should match", func(t *testing.T) { route := Route{ Name: "", Method: http.MethodGet, TrailingSlash: true, FallThroughPostResponse: true, Pattern: "/", Handlers: []http.HandlerFunc{dummyHandler}, } err := route.init() if err != nil { t.Error(err) return } matched, _ := route.matchPath("/") if !matched { t.Errorf("Expected match, got no match") return } }) } ================================================ FILE: router.go ================================================ package webgo import ( "bufio" "context" "encoding/json" "errors" "fmt" "net" "net/http" "sync" ) // httpResponseWriter has all the functions to be implemented by the custom // responsewriter used type httpResponseWriter interface { http.ResponseWriter http.Flusher http.Hijacker http.Pusher } func init() { var err error jsonErrPayload, err = json.Marshal(errOutput{ Errors: ErrInternalServer, Status: http.StatusInternalServerError, }) if err != nil { panic(err) } // ensure the custom response writer implements all the required functions crw := &customResponseWriter{} _ = httpResponseWriter(crw) } var ( validHTTPMethods = []string{ http.MethodOptions, http.MethodHead, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, } ctxPool = &sync.Pool{ New: func() interface{} { return new(ContextPayload) }, } crwPool = &sync.Pool{ New: func() interface{} { return new(customResponseWriter) }, } ) // customResponseWriter is a custom HTTP response writer type customResponseWriter struct { http.ResponseWriter statusCode int written bool headerWritten bool } // WriteHeader is the interface implementation to get HTTP response code and add // it to the custom response writer func (crw *customResponseWriter) WriteHeader(code int) { if crw.headerWritten { return } crw.headerWritten = true crw.statusCode = code crw.ResponseWriter.WriteHeader(code) } // Write is the interface implementation to respond to the HTTP request, // but check if a response was already sent. func (crw *customResponseWriter) Write(body []byte) (int, error) { crw.WriteHeader(crw.statusCode) crw.written = true return crw.ResponseWriter.Write(body) } // Flush calls the http.Flusher to clear/flush the buffer func (crw *customResponseWriter) Flush() { if rw, ok := crw.ResponseWriter.(http.Flusher); ok { rw.Flush() } } // Hijack implements the http.Hijacker interface func (crw *customResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := crw.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() } return nil, nil, errors.New("unable to create hijacker") } func (crw *customResponseWriter) Push(target string, opts *http.PushOptions) error { if n, ok := crw.ResponseWriter.(http.Pusher); ok { return n.Push(target, opts) } return errors.New("pusher not implemented") } func (crw *customResponseWriter) reset() { crw.statusCode = 0 crw.written = false crw.headerWritten = false crw.ResponseWriter = nil } // Middleware is the signature of WebGo's middleware type Middleware func(http.ResponseWriter, *http.Request, http.HandlerFunc) // discoverRoute returns the correct 'route', for the given request func discoverRoute(path string, routes []*Route) (*Route, map[string]string) { for _, route := range routes { if ok, params := route.matchPath(path); ok { return route, params } } return nil, nil } // Router is the HTTP router type Router struct { optHandlers []*Route headHandlers []*Route getHandlers []*Route postHandlers []*Route putHandlers []*Route patchHandlers []*Route deleteHandlers []*Route allHandlers map[string][]*Route // NotFound is the generic handler for 404 resource not found response NotFound http.HandlerFunc // NotImplemented is the generic handler for 501 method not implemented NotImplemented http.HandlerFunc // config has all the app config config *Config // httpServer is the server handler for the active HTTP server httpServer *http.Server // httpsServer is the server handler for the active HTTPS server httpsServer *http.Server } // methodRoutes returns the list of Routes handling the HTTP method given the request func (rtr *Router) methodRoutes(method string) (routes []*Route) { switch method { case http.MethodOptions: return rtr.optHandlers case http.MethodHead: return rtr.headHandlers case http.MethodGet: return rtr.getHandlers case http.MethodPost: return rtr.postHandlers case http.MethodPut: return rtr.putHandlers case http.MethodPatch: return rtr.patchHandlers case http.MethodDelete: return rtr.deleteHandlers } return nil } func (rtr *Router) ServeHTTP(rw http.ResponseWriter, r *http.Request) { // a custom response writer is used to set appropriate HTTP status code in case of // encoding errors. i.e. if there's a JSON encoding issue while responding, // the HTTP status code would say 200, and and the JSON payload {"status": 500} crw := newCRW(rw, http.StatusOK) routes := rtr.methodRoutes(r.Method) if routes == nil { // serve 501 when HTTP method is not implemented crw.statusCode = http.StatusNotImplemented rtr.NotImplemented(crw, r) releaseCRW(crw) return } path := r.URL.EscapedPath() route, params := discoverRoute(path, routes) if route == nil { // serve 404 when there are no matching routes crw.statusCode = http.StatusNotFound rtr.NotFound(crw, r) releaseCRW(crw) return } ctxPayload := newContext() ctxPayload.Route = route ctxPayload.URIParams = params // webgo context is injected to the HTTP request context *r = *r.WithContext( context.WithValue( r.Context(), wgoCtxKey, ctxPayload, ), ) defer releasePoolResources(crw, ctxPayload) route.serve(crw, r) } // Use adds a middleware layer func (rtr *Router) Use(mm ...Middleware) { for _, handlers := range rtr.allHandlers { for idx := range handlers { route := handlers[idx] if route.skipMiddleware { continue } route.use(mm...) } } } // UseOnSpecialHandlers adds middleware to the 2 special handlers of webgo func (rtr *Router) UseOnSpecialHandlers(mm ...Middleware) { // v3.2.1 introduced the feature of adding middleware to both notfound & not implemented // handlers /* - It was added considering an `accesslog` middleware, where all requests should be logged # This is now being moved to a separate function considering an authentication middleware, where all requests including 404 & 501 would respond with `not authenticated` if you do not have special handling within the middleware. It is a cleaner implementation to avoid this and let users add their middleware separately to NOTFOUND & NOTIMPLEMENTED handlers */ for idx := range mm { m := mm[idx] nf := rtr.NotFound rtr.NotFound = func(rw http.ResponseWriter, req *http.Request) { m(rw, req, nf) } ni := rtr.NotImplemented rtr.NotImplemented = func(rw http.ResponseWriter, req *http.Request) { m(rw, req, ni) } } } // Add is a convenience method used to add a new route to an already initialized router // Important: `.Use` should be used only after all routes are added func (rtr *Router) Add(routes ...*Route) { hmap := httpHandlers(routes) rtr.optHandlers = append(rtr.optHandlers, hmap[http.MethodOptions]...) rtr.headHandlers = append(rtr.headHandlers, hmap[http.MethodHead]...) rtr.getHandlers = append(rtr.getHandlers, hmap[http.MethodGet]...) rtr.postHandlers = append(rtr.postHandlers, hmap[http.MethodPost]...) rtr.putHandlers = append(rtr.putHandlers, hmap[http.MethodPut]...) rtr.patchHandlers = append(rtr.patchHandlers, hmap[http.MethodPatch]...) rtr.deleteHandlers = append(rtr.deleteHandlers, hmap[http.MethodDelete]...) all := rtr.allHandlers if all == nil { all = map[string][]*Route{} } for _, key := range supportedHTTPMethods { newlist, hasKey := hmap[key] if !hasKey { continue } if all[key] == nil { all[key] = make([]*Route, 0, len(hmap)) } all[key] = append(all[key], newlist...) } rtr.allHandlers = all } func newCRW(rw http.ResponseWriter, rCode int) *customResponseWriter { crw := crwPool.Get().(*customResponseWriter) crw.ResponseWriter = rw crw.statusCode = rCode return crw } func releaseCRW(crw *customResponseWriter) { crw.reset() crwPool.Put(crw) } func newContext() *ContextPayload { return ctxPool.Get().(*ContextPayload) } func releaseContext(cp *ContextPayload) { cp.reset() ctxPool.Put(cp) } func releasePoolResources(crw *customResponseWriter, cp *ContextPayload) { releaseCRW(crw) releaseContext(cp) } // NewRouter initializes & returns a new router instance with all the configurations and routes set func NewRouter(cfg *Config, routes ...*Route) *Router { r := &Router{ NotFound: http.NotFound, NotImplemented: func(rw http.ResponseWriter, req *http.Request) { Send(rw, "", "501 Not Implemented", http.StatusNotImplemented) }, config: cfg, } r.Add(routes...) return r } // checkDuplicateRoutes checks if any of the routes have duplicate name or URI pattern func checkDuplicateRoutes(idx int, route *Route, routes []*Route) { // checking if the URI pattern is duplicated for i := 0; i < idx; i++ { rt := routes[i] if rt.Name == route.Name { LOGHANDLER.Info( fmt.Sprintf( "Duplicate route name('%s') detected", rt.Name, ), ) } if rt.Method != route.Method { continue } // regex pattern match if ok, _ := rt.matchPath(route.Pattern); !ok { continue } LOGHANDLER.Warn( fmt.Sprintf( "Duplicate URI pattern detected.\nPattern: '%s'\nDuplicate pattern: '%s'", rt.Pattern, route.Pattern, ), ) LOGHANDLER.Warn("Only the first route to match the URI pattern would handle the request") } } // httpHandlers returns all the handlers in a map, for each HTTP method func httpHandlers(routes []*Route) map[string][]*Route { handlers := map[string][]*Route{} handlers[http.MethodHead] = []*Route{} handlers[http.MethodGet] = []*Route{} for idx, route := range routes { found := false for _, validMethod := range validHTTPMethods { if route.Method == validMethod { found = true break } } if !found { LOGHANDLER.Fatal( fmt.Sprintf( "Unsupported HTTP method provided. Method: '%s'", route.Method, ), ) return nil } if len(route.Handlers) == 0 { LOGHANDLER.Fatal( fmt.Sprintf( "No handlers provided for the route '%s', method '%s'", route.Pattern, route.Method, ), ) return nil } err := route.init() if err != nil { LOGHANDLER.Fatal("Unsupported URI pattern.", route.Pattern, err) return nil } checkDuplicateRoutes(idx, route, routes) handlers[route.Method] = append(handlers[route.Method], route) } return handlers } ================================================ FILE: router_test.go ================================================ package webgo import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestRouter_ServeHTTP(t *testing.T) { t.Parallel() port := "9696" router, err := setup(t, port) if err != nil { t.Error(err.Error()) return } m := func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { w.Header().Add("middleware", "true") next(w, r) } router.Use(m) router.UseOnSpecialHandlers(m) router.SetupMiddleware() list := testTable() baseAPI := fmt.Sprintf("http://localhost:%s", port) for _, l := range list { url := baseAPI if l.Path != "" { switch l.TestType { case "checkpath", "checkpathnotrailingslash", "chaining", "notfound", "chaining-nofallthrough": { url = strings.Join([]string{url, l.Path}, "") } case "checkparams", "widlcardwithouttrailingslash": { for idx, key := range l.ParamKeys { // in case of wildcard params, they have to be replaced first for proper URL construction l.Path = strings.Replace(l.Path, ":"+key+"*", l.Params[idx], 1) l.Path = strings.Replace(l.Path, ":"+key, l.Params[idx], 1) } url = strings.Join([]string{url, l.Path}, "") } } } respRec := httptest.NewRecorder() req := httptest.NewRequest( l.Method, url, l.Body, ) router.ServeHTTP(respRec, req) switch l.TestType { case "checkpath", "checkpathnotrailingslash": { err = checkPath(req, respRec) } case "widlcardwithouttrailingslash": { err = checkPathWildCard(req, respRec) } case "chaining": { err = checkChaining(req, respRec) } case "checkparams": { err = checkParams(req, respRec, l.ParamKeys, l.Params) } case "notimplemented": { err = checkNotImplemented(req, respRec) } case "notfound": { err = checkNotFound(req, respRec) } } if err != nil && !l.WantErr { t.Errorf( "'%s' (%s '%s'): %s", l.Name, l.Method, url, err.Error(), ) if l.Err != nil { if !errors.Is(err, l.Err) { t.Errorf( "expected error '%s', got %s", l.Err.Error(), err.Error(), ) } } } else if err == nil && l.WantErr { t.Errorf( "'%s' (%s '%s') expected error, but received nil", l.Name, l.Method, url, ) } err = checkMiddleware(req, respRec) if err != nil { t.Error(err.Error()) } } } func setup(t *testing.T, port string) (*Router, error) { t.Helper() cfg := &Config{ Port: port, ReadTimeout: time.Second * 1, WriteTimeout: time.Second * 1, ShutdownTimeout: time.Second * 10, CertFile: "tests/ssl/server.crt", KeyFile: "tests/ssl/server.key", } router := NewRouter(cfg, getRoutes(t)...) return router, nil } func getRoutes(t *testing.T) []*Route { t.Helper() list := testTable() rr := make([]*Route, 0, len(list)) for _, l := range list { switch l.TestType { case "checkpath", "checkparams", "checkparamswildcard": { rr = append(rr, &Route{ Name: l.Name, Method: l.Method, Pattern: l.Path, TrailingSlash: true, FallThroughPostResponse: false, Handlers: []http.HandlerFunc{successHandler}, }, ) } case "checkpathnotrailingslash", "widlcardwithouttrailingslash": { rr = append(rr, &Route{ Name: l.Name, Method: l.Method, Pattern: l.Path, TrailingSlash: false, FallThroughPostResponse: false, Handlers: []http.HandlerFunc{successHandler}, }, ) } case "chaining": { rr = append( rr, &Route{ Name: l.Name, Method: l.Method, Pattern: l.Path, TrailingSlash: false, FallThroughPostResponse: false, Handlers: []http.HandlerFunc{chainHandler, successHandler}, }, ) } case "chaining-nofallthrough": { { rr = append( rr, &Route{ Name: l.Name, Method: l.Method, Pattern: l.Path, TrailingSlash: false, FallThroughPostResponse: false, Handlers: []http.HandlerFunc{chainHandler, chainNoFallthroughHandler, successHandler}, }, ) } } } } return rr } func chainHandler(w http.ResponseWriter, r *http.Request) { w.Header().Add("chained", "true") } func chainNoFallthroughHandler(w http.ResponseWriter, r *http.Request) { w.Header().Add("chained", "true") _, _ = w.Write([]byte(`yay, blocked!`)) } func successHandler(w http.ResponseWriter, r *http.Request) { wctx := Context(r) params := wctx.Params() R200( w, map[string]interface{}{ "path": r.URL.Path, "params": params, }, ) } func checkPath(req *http.Request, resp *httptest.ResponseRecorder) error { want := req.URL.EscapedPath() rbody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("error reading response, '%s'", err.Error()) } body := struct { Data struct { Path string Params map[string]string } }{} err = json.Unmarshal(rbody, &body) if err != nil { return fmt.Errorf( "json decode failed '%s', got response: '%s'", err.Error(), string(rbody), ) } if want != body.Data.Path { return fmt.Errorf("wanted URI path '%s', got '%s'", want, body.Data.Path) } return nil } func checkPathWildCard(req *http.Request, resp *httptest.ResponseRecorder) error { want := req.URL.EscapedPath() rbody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("error reading response, '%s'", err.Error()) } body := struct { Data struct { Path string Params map[string]string } }{} err = json.Unmarshal(rbody, &body) if err != nil { return fmt.Errorf("json decode failed '%s', got response: '%s'", err.Error(), string(rbody)) } if want != body.Data.Path { return fmt.Errorf("wanted URI path '%s', got '%s'", want, body.Data.Path) } if len(body.Data.Params) != 1 { return fmt.Errorf("expected no.of params: %d, got %d. response: '%s'", 1, len(body.Data.Params), string(rbody)) } wantWildcardParamValue := "" parts := strings.Split(want, "/")[2:] wantWildcardParamValue = strings.Join(parts, "/") if body.Data.Params["a"] != wantWildcardParamValue { return fmt.Errorf( "wildcard value\nexpected: %s\ngot: %s", wantWildcardParamValue, body.Data.Params["a"], ) } return nil } func checkParams(req *http.Request, resp *httptest.ResponseRecorder, keys []string, expected []string) error { rbody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("error reading response, '%s'", err.Error()) } body := struct { Data struct { Params map[string]string } }{} err = json.Unmarshal(rbody, &body) if err != nil { return fmt.Errorf("json decode failed '%s', for response '%s'", err.Error(), string(rbody)) } for idx, key := range keys { want := expected[idx] if body.Data.Params[key] != want { return fmt.Errorf( "expected value for '%s' is '%s', got '%s'", key, want, body.Data.Params[key], ) } } return nil } func checkNotImplemented(req *http.Request, resp *httptest.ResponseRecorder) error { if resp.Result().StatusCode != http.StatusNotImplemented { return fmt.Errorf( "expected code %d, got %d", http.StatusNotImplemented, resp.Code, ) } return nil } func checkNotFound(req *http.Request, resp *httptest.ResponseRecorder) error { if resp.Result().StatusCode != http.StatusNotFound { return fmt.Errorf( "expected code %d, got %d", http.StatusNotFound, resp.Code, ) } return nil } func checkChaining(req *http.Request, resp *httptest.ResponseRecorder) error { if resp.Header().Get("chained") != "true" { return fmt.Errorf( "Expected header value for 'chained', to be 'true', got '%s'", resp.Header().Get("chained"), ) } return nil } func checkMiddleware(req *http.Request, resp *httptest.ResponseRecorder) error { if resp.Header().Get("middleware") != "true" { return fmt.Errorf( "Expected header value for 'middleware', to be 'true', got '%s'", resp.Header().Get("middleware"), ) } return nil } func testTable() []struct { Name string TestType string Path string Method string Want interface{} WantErr bool Err error ParamKeys []string Params []string Body io.Reader } { return []struct { Name string TestType string Path string Method string Want interface{} WantErr bool Err error ParamKeys []string Params []string Body io.Reader }{ { Name: "Check root path without params", TestType: "checkpath", Path: "/", Method: http.MethodGet, WantErr: false, }, { Name: "Check root path without params - duplicate", TestType: "checkpath", Path: "/", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 1", TestType: "checkpath", Path: "/a", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 2", TestType: "checkpath", Path: "/a/b", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 3", TestType: "checkpath", Path: "/a/b/-/c", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 4", TestType: "checkpath", Path: "/a/b/-/c/~/d", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 5", TestType: "checkpath", Path: "/a/b/-/c/~/d/./e", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - 5", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e/notrail", Method: http.MethodGet, WantErr: false, }, { Name: "Check nested path without params - OPTION", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodOptions, WantErr: false, }, { Name: "Check nested path without params - HEAD", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodHead, WantErr: false, }, { Name: "Check nested path without params - POST", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodPost, WantErr: false, }, { Name: "Check nested path without params - PUT", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodPut, WantErr: false, }, { Name: "Check nested path without params - PATCH", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodPatch, WantErr: false, }, { Name: "Check nested path without params - DELETE", TestType: "checkpathnotrailingslash", Path: "/a/b/-/c/~/d/./e", Method: http.MethodDelete, WantErr: false, }, { Name: "Check with params - 1", TestType: "checkparams", Path: "/params/:a", Method: http.MethodGet, ParamKeys: []string{"a"}, Params: []string{"hello"}, WantErr: false, }, { Name: "Check with params - 2", TestType: "checkparams", Path: "/params/:a/:b", Method: http.MethodGet, ParamKeys: []string{"a", "b"}, Params: []string{"hello", "world"}, WantErr: false, }, { Name: "Check with wildcard", TestType: "checkparams", Path: "/wildcard/:a*", Method: http.MethodGet, ParamKeys: []string{"a"}, Params: []string{"w1/hello/world/hi/there"}, WantErr: false, }, { Name: "Check with wildcard - 2", TestType: "checkparams", Path: "/wildcard2/:a*", Method: http.MethodGet, ParamKeys: []string{"a"}, Params: []string{"w2/hello/world/hi/there/-/~/./again"}, WantErr: false, }, { Name: "Check with wildcard - 3", TestType: "widlcardwithouttrailingslash", Path: "/wildcard3/:a*", Method: http.MethodGet, ParamKeys: []string{"a"}, Params: []string{"w3/hello/world/hi/there/-/~/./again/"}, WantErr: true, }, { Name: "Check with wildcard - 4", TestType: "widlcardwithouttrailingslash", Path: "/wildcard3/:a*", Method: http.MethodGet, ParamKeys: []string{"a"}, Params: []string{"w4/hello/world/hi/there/-/~/./again"}, WantErr: false, }, { Name: "Check not implemented", TestType: "notimplemented", Path: "/notimplemented", Method: "HELLO", WantErr: false, }, { Name: "Check not found", TestType: "notfound", Path: "/notfound", Method: http.MethodGet, WantErr: false, }, { Name: "Check chaining", TestType: "chaining", Path: "/chained", Method: http.MethodGet, WantErr: false, }, { Name: "Check chaining", TestType: "chaining-nofallthrough", Path: "/chained/nofallthrough", Method: http.MethodGet, WantErr: false, }, } } type testLogger struct { out bytes.Buffer } func (tl *testLogger) Debug(data ...interface{}) { tl.out.Write([]byte(fmt.Sprint(data...))) } func (tl *testLogger) Info(data ...interface{}) { tl.out.Write([]byte(fmt.Sprint(data...))) } func (tl *testLogger) Warn(data ...interface{}) { tl.out.Write([]byte(fmt.Sprint(data...))) } func (tl *testLogger) Error(data ...interface{}) { tl.out.Write([]byte(fmt.Sprint(data...))) } func (tl *testLogger) Fatal(data ...interface{}) { tl.out.Write([]byte(fmt.Sprint(data...))) } func Test_httpHandlers(t *testing.T) { // t.Parallel() tl := &testLogger{ out: bytes.Buffer{}, } LOGHANDLER = tl // test invalid method httpHandlers( []*Route{ { Name: "invalid method", Pattern: "/hello/world", Method: "HELLO", }, }) got := tl.out.String() want := "Unsupported HTTP method provided. Method: 'HELLO'" if got != want { t.Errorf( "Expected the error to end with '%s', got '%s'", want, got, ) } tl.out.Reset() // test empty handlers httpHandlers( []*Route{ { Name: "empty handlers", Pattern: "/hello/world", Method: http.MethodGet, }, }) str := tl.out.String() want = "provided for the route '/hello/world', method 'GET'" got = str[len(str)-len(want):] if got != want { t.Errorf( "Expected the error to end with '%s', got '%s'", want, got, ) } tl.out.Reset() } func TestWildcardMadness(t *testing.T) { port := "9696" t.Helper() cfg := &Config{ Port: port, ReadTimeout: time.Second * 1, WriteTimeout: time.Second * 1, ShutdownTimeout: time.Second * 10, CertFile: "tests/ssl/server.crt", KeyFile: "tests/ssl/server.key", } router := NewRouter(cfg, []*Route{ { Name: "wildcard madness", Pattern: "/hello/:w*/world/:p1/:w2*/hi/there", Handlers: []http.HandlerFunc{successHandler}, Method: http.MethodGet, TrailingSlash: true, }, }...) baseAPI := fmt.Sprintf("http://localhost:%s", port) url := fmt.Sprintf( "%s%s", baseAPI, "/hello/a/b/c/-d/~/e/world/fgh/i/j/k~/l-/hi/there/", ) req, _ := http.NewRequest(http.MethodGet, url, nil) respRec := httptest.NewRecorder() router.ServeHTTP(respRec, req) rbody, err := io.ReadAll(respRec.Body) if err != nil { t.Error(err) } if respRec.Code != http.StatusOK { t.Errorf("expected status code: %d, got: %d. response: '%s'", http.StatusOK, respRec.Code, string(rbody)) } url = fmt.Sprintf( "%s%s", baseAPI, "/hello/a/b/c/-d/~/e/world/fgh/i/j/k~/l-/hi/there", ) req, _ = http.NewRequest(http.MethodGet, url, nil) respRec = httptest.NewRecorder() router.ServeHTTP(respRec, req) if respRec.Code != http.StatusOK { t.Errorf("expected status code: %d, got: %d", http.StatusOK, respRec.Code) } err = checkParams( req, respRec, []string{"w", "p1", "w2"}, []string{ "a/b/c/-d/~/e", "fgh", "i/j/k~/l-", }) if err != nil { t.Error(err) } } ================================================ FILE: tests/config.json ================================================ { "host": "127.0.0.1", "port": "9696", "httpsPort": "8443", "certFile": "./ssl/server.crt", "keyFile": "./ssl/server.key", "readTimeout": 15000000000, "writeTimeout": 60000000000, "insecureSkipVerify": true } ================================================ FILE: tests/ssl/server.crt ================================================ -----BEGIN CERTIFICATE----- MIIDkjCCAnoCCQDhnAb7Y802KzANBgkqhkiG9w0BAQsFADCBiTELMAkGA1UEBhMC SU4xDjAMBgNVBAgMBUluZGlhMQ4wDAYDVQQHDAVJbmRpYTEMMAoGA1UECgwDS0JO MRMwEQYDVQQLDApPcGVuU291cmNlMRIwEAYDVQQDDAkxMjcuMC4wLjExIzAhBgkq hkiG9w0BCQEWFGJua2FtYWxlc2hAZ21haWwuY29tMCAXDTE4MDEzMDEwMDQwMVoY DzIyOTExMTE0MTAwNDAxWjCBiTELMAkGA1UEBhMCSU4xDjAMBgNVBAgMBUluZGlh MQ4wDAYDVQQHDAVJbmRpYTEMMAoGA1UECgwDS0JOMRMwEQYDVQQLDApPcGVuU291 cmNlMRIwEAYDVQQDDAkxMjcuMC4wLjExIzAhBgkqhkiG9w0BCQEWFGJua2FtYWxl c2hAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2t0D kDIjlAh/kTzu2u8tIhpBQyjXUbw0Kv8T11eWlumkvIBKuNCdei7hkFxLop9Ei0Jt 019uFpRzrjtyXtZ67XDDiBXdLiT1YW7Z/UysNz6FIAt1jPkxEnrX2WbP16puZmmL a0/vCvwj4xCDlc3bosUkaVknwzaxf4Lb3m9oMKIRQcgovVRnKrq5YJaaPmjZG1Th AGuazRuR/S1OF4sImNwmGoiLDvgra3TeEyLGb1j3eVysqmEulaa2zHVEQPI3OtSJ E8Pp7sYqjguAMTWk2HsTMv42z1ITR9KX9JXvTapv15WMV4LB/0iHbGLQPT6uggCV Eklndtf2q0jGFYjvSQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQALS5ZQ0CjbXlZc AX0vGxSlQ4jYoaDjrIqH5CizYGGVnmBkX/+2n1a+xp8kJp/76hiPoKiYBHX5I3tN XQxU/1DDrjn8y7M5pS2PU/B5q+Uy3FgGnIU9J41hCdagUKeTupUvVtxqVuInpX5w 5JGW1eqLcCK5E5XBY85VpjdOWnOaZXuQmfgye/jKO4XtqDB20jtno7Jo3EIhllFT SzxDk/Hfr8jeW9rd1/Q4/UCNOXPP2TllDnsRJtQzkY3h1sLtNRlGYMn5gFI3CbT/ xfbkKRmC8OmEyxe2m/qzMS2tJMadZEubuXtheA6W7YB76cmmVKIxL+h1ektVERTA 8doRA1AK -----END CERTIFICATE----- ================================================ FILE: tests/ssl/server.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIICzzCCAbcCAQAwgYkxCzAJBgNVBAYTAklOMQ4wDAYDVQQIDAVJbmRpYTEOMAwG A1UEBwwFSW5kaWExDDAKBgNVBAoMA0tCTjETMBEGA1UECwwKT3BlblNvdXJjZTES MBAGA1UEAwwJMTI3LjAuMC4xMSMwIQYJKoZIhvcNAQkBFhRibmthbWFsZXNoQGdt YWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrdA5AyI5QI f5E87trvLSIaQUMo11G8NCr/E9dXlpbppLyASrjQnXou4ZBcS6KfRItCbdNfbhaU c647cl7Weu1ww4gV3S4k9WFu2f1MrDc+hSALdYz5MRJ619lmz9eqbmZpi2tP7wr8 I+MQg5XN26LFJGlZJ8M2sX+C295vaDCiEUHIKL1UZyq6uWCWmj5o2RtU4QBrms0b kf0tTheLCJjcJhqIiw74K2t03hMixm9Y93lcrKphLpWmtsx1REDyNzrUiRPD6e7G Ko4LgDE1pNh7EzL+Ns9SE0fSl/SV702qb9eVjFeCwf9Ih2xi0D0+roIAlRJJZ3bX 9qtIxhWI70kCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQC2Jj9EyBFSzyQQLSu8 3MFbHHbaAcvZpjAmPuPFLlaY3j7iXwsGbK4JRHR4fKKZA/M9RTNq+v7QtMkIEyAK dNKHjjK/zHfZbXCOYORFmfuR7xfG6FvjvLj2QtHasS0ogjDduqX/wt2aapR+2Q9W vRgJVc4nCnyboA/f/u96hk45UxrA87g73bnaAiPgYcx4wUvhWqZOglw7nC38oj5g CfelDqu+i4paf4pglfo8r/Dx3OAKIwFO4uHtGLPDNu27USwEO0/89dDNF5c6MmwI cSCaEMY1AyRAOHm2jiPYEOtobRH07/SIhskNUszn6FwSOQuUDN00Cn3NsE2ZiPMd 4Gao -----END CERTIFICATE REQUEST----- ================================================ FILE: tests/ssl/server.key ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEA2t0DkDIjlAh/kTzu2u8tIhpBQyjXUbw0Kv8T11eWlumkvIBK uNCdei7hkFxLop9Ei0Jt019uFpRzrjtyXtZ67XDDiBXdLiT1YW7Z/UysNz6FIAt1 jPkxEnrX2WbP16puZmmLa0/vCvwj4xCDlc3bosUkaVknwzaxf4Lb3m9oMKIRQcgo vVRnKrq5YJaaPmjZG1ThAGuazRuR/S1OF4sImNwmGoiLDvgra3TeEyLGb1j3eVys qmEulaa2zHVEQPI3OtSJE8Pp7sYqjguAMTWk2HsTMv42z1ITR9KX9JXvTapv15WM V4LB/0iHbGLQPT6uggCVEklndtf2q0jGFYjvSQIDAQABAoIBACcKCVqtNt9u7KJR hiGTTC+rEz0RiebQdVW+DiH5Q6lDn9jn4Ww5+f0TY7TGYc9uLWHRxZlQimiIrmHD xNDZ3S+BT790dvGGMibhCQ0/ofBwvHpM0PkGchRjySDEUAqeIfcumGnZ5j/FXflg trf/8k+EbsxD1O3jUaH3C5UPtjwGOBsXnKUQiDIqd27QXCFCecDts2AVIP4uejPY TqzdnK18k6WvCLs776RZmhDa0tWE0vgDuqYkAQgoYyRRtpWsQ1MyK2dUA9GVnenv Q7NgOSaEEGRNx5Ko4uXQkNnW7xHxsQT45iIoDvw/GF5Mgii9ZweZoTou6EXNop58 udVa2akCgYEA91p2LeYyICq3vVLF3MWlytxs+asKTtja3NdwVZUMRGMat6GWO7D4 Eqzjt9lSow0d+JhVE9/hyS4MytmVhvRDAnw+cfmfhxlG2CSldeETjKFfAU8YkJQ+ pmx1lgbXqe9N2rUPqBMadMcQcJ/iTzvR1XmnPeKeG7sZdutJR3UVlWMCgYEA4oOZ HR6WLvCJUnvA0hvli98s1dGrB3gNiwGuX+Qz1n5YbDByGpWnNgoBACe/rUvmoThk QlYcmw3ItqGFq7TGImeoeOoQhTlpyMOYz28wDf5+8jdUJ5nNGFrfEsg2hUtsl3EO CSp5iHjGmDxMow2cVHpA4hL6eQyCh7f0GaBkTmMCgYB70m4MhgqbraazAIeJ/+sB xRxMU0HivI27NaHHRciRR2cte5dAJFPazW9lLkY+1yckteUJAO7/Da1bslY263nL +bQsy//+2jlro9SsUNK/eFydxCGQ5pUCLJMkWiKFsASyMic3RPDeenQRXQgmD9T3 32FICnSJfzy9GgVh3wvB7wKBgFFRvV5e5LvlTud11juYGEimzonUw/nid7o32EpE uvd+VHBC1DQHFgiofsN3gbDNVvb6L8RA9fQUdsJaKosCUz92x1zhaxzpB7kzv2B5 Il9jxl9eza+J37+mn/82MZyY/1s/EzLnNMpx0ZpFy52d/Um2uiRve8yJWTMwL0oj 8t7RAoGAcsazEWd6eJz1vmaeMT5YBUQeD82y9IHHFyp6h02wy2ackEjqNTiqjVWG rzRDWJZWjTJkB+EY7/g7GqiEnAIJ4YqJccew9tJlaM2q58fvGCV/OLNYzzhBJkWo 6YpvCa+uQVJMFejL0WnSiewd9g9HCVi1EHNasb1Zp+BTgyfE1rs= -----END RSA PRIVATE KEY----- ================================================ FILE: webgo.go ================================================ /* Package webgo is a lightweight framework for building web apps. It has a multiplexer, middleware plugging mechanism & context management of its own. The primary goal of webgo is to get out of the developer's way as much as possible. i.e. it does not enforce you to build your app in any particular pattern, instead just helps you get all the trivial things done faster and easier. e.g. 1. Getting named URI parameters. 2. Multiplexer for regex matching of URI and such. 3. Inject special app level configurations or any such objects to the request context as required. */ package webgo import ( "context" "crypto/tls" "net/http" ) var supportedHTTPMethods = []string{ http.MethodOptions, http.MethodHead, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, } // ctxkey is a custom string type to store the WebGo context inside HTTP request context type ctxkey string const wgoCtxKey = ctxkey("webgocontext") // ContextPayload is the WebgoContext. A new instance of ContextPayload is injected inside every request's context object type ContextPayload struct { Route *Route Err error URIParams map[string]string } // Params returns the URI parameters of the respective route func (cp *ContextPayload) Params() map[string]string { return cp.URIParams } func (cp *ContextPayload) reset() { cp.Route = nil cp.Err = nil } // SetError sets the err within the context func (cp *ContextPayload) SetError(err error) { cp.Err = err } // Error returns the error set within the context func (cp *ContextPayload) Error() error { return cp.Err } // Context returns the ContextPayload injected inside the HTTP request context func Context(r *http.Request) *ContextPayload { return r.Context().Value(wgoCtxKey).(*ContextPayload) } // SetError is a helper function to set the error in webgo context func SetError(r *http.Request, err error) { ctx := Context(r) ctx.SetError(err) } // GetError is a helper function to get the error from webgo context func GetError(r *http.Request) error { return Context(r).Error() } // ResponseStatus returns the response status code. It works only if the http.ResponseWriter // is not wrapped in another response writer before calling ResponseStatus func ResponseStatus(rw http.ResponseWriter) int { crw, ok := rw.(*customResponseWriter) if !ok { return http.StatusOK } return crw.statusCode } func (router *Router) setupServer() { cfg := router.config router.httpsServer = &http.Server{ Addr: "", Handler: router, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, TLSConfig: &tls.Config{ InsecureSkipVerify: cfg.InsecureSkipVerify, }, } router.httpServer = &http.Server{ Addr: "", Handler: router, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, } router.SetupMiddleware() } // SetupMiddleware initializes all the middleware added using "Use". // This function need not be called explicitly, if using router.Start() // or router.StartHTTPS(). Instead if the router is being passed to an external server // then the SetupMiddleware function should be called func (router *Router) SetupMiddleware() { // load middleware for all routes for _, routes := range router.allHandlers { for _, route := range routes { route.setupMiddleware(router.config.ReverseMiddleware) } } } // StartHTTPS starts the server with HTTPS enabled func (router *Router) StartHTTPS() { cfg := router.config if cfg.CertFile == "" { LOGHANDLER.Fatal("No certificate provided for HTTPS") } if cfg.KeyFile == "" { LOGHANDLER.Fatal("No key file provided for HTTPS") } router.setupServer() host := cfg.Host if len(cfg.HTTPSPort) > 0 { host += ":" + cfg.HTTPSPort } router.httpsServer.Addr = host LOGHANDLER.Info("HTTPS server, listening on", router.httpsServer.Addr) err := router.httpsServer.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile) if err != nil && err != http.ErrServerClosed { LOGHANDLER.Error("HTTPS server exited with error:", err.Error()) } } // Start starts the HTTP server with the appropriate configurations func (router *Router) Start() { router.setupServer() cfg := router.config host := cfg.Host if len(cfg.Port) > 0 { host += ":" + cfg.Port } router.httpServer.Addr = host LOGHANDLER.Info("HTTP server, listening on", router.httpServer.Addr) err := router.httpServer.ListenAndServe() if err != nil && err != http.ErrServerClosed { LOGHANDLER.Error("HTTP server exited with error:", err.Error()) } } // Shutdown gracefully shuts down HTTP server func (router *Router) Shutdown() error { if router.httpServer == nil { return nil } timer := router.config.ShutdownTimeout ctx, cancel := context.WithTimeout(context.TODO(), timer) defer cancel() err := router.httpServer.Shutdown(ctx) if err != nil { LOGHANDLER.Error(err) } return err } // ShutdownHTTPS gracefully shuts down HTTPS server func (router *Router) ShutdownHTTPS() error { if router.httpsServer == nil { return nil } timer := router.config.ShutdownTimeout ctx, cancel := context.WithTimeout(context.TODO(), timer) defer cancel() err := router.httpsServer.Shutdown(ctx) if err != nil && err != http.ErrServerClosed { LOGHANDLER.Error(err) } return err } // OriginalResponseWriter returns the Go response writer stored within the webgo custom response // writer func OriginalResponseWriter(rw http.ResponseWriter) http.ResponseWriter { crw, ok := rw.(*customResponseWriter) if !ok { return nil } return crw.ResponseWriter } ================================================ FILE: webgo_test.go ================================================ /* Package webgo is a lightweight framework for building web apps. It has a multiplexer, middleware plugging mechanism & context management of its own. The primary goal of webgo is to get out of the developer's way as much as possible. i.e. it does not enforce you to build your app in any particular pattern, instead just helps you get all the trivial things done faster and easier. e.g. 1. Getting named URI parameters. 2. Multiplexer for regex matching of URI and such. 3. Inject special app level configurations or any such objects to the request context as required. */ package webgo import ( "errors" "net/http" "net/http/httptest" "testing" "time" ) func TestResponseStatus(t *testing.T) { t.Parallel() w := newCRW(httptest.NewRecorder(), http.StatusOK) SendError(w, nil, http.StatusNotFound) if http.StatusNotFound != ResponseStatus(w) { t.Errorf( "Expected status '%d', got '%d'", http.StatusNotFound, ResponseStatus(w), ) } // ideally we should get 200 from ResponseStatus; but it can get accurate status code only // when `customresponsewriter` is used rw := httptest.NewRecorder() SendError(rw, nil, http.StatusNotFound) if http.StatusOK != ResponseStatus(rw) { t.Errorf( "Expected status '%d', got '%d'", http.StatusOK, ResponseStatus(rw), ) } } func TestStart(t *testing.T) { t.Parallel() router, _ := setup(t, "9696") go router.Start() time.Sleep(time.Second * 2) err := router.Shutdown() if err != nil { t.Fatal(err) } } func TestStartHTTPS(t *testing.T) { t.Parallel() router, _ := setup(t, "8443") go router.StartHTTPS() time.Sleep(time.Second * 2) err := router.ShutdownHTTPS() if err != nil { t.Fatal(err) } } func TestErrorHandling(t *testing.T) { t.Parallel() err := errors.New("hello world, failed") router, _ := setup(t, "7878") w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) router.ServeHTTP(w, r) SetError(r, err) gotErr := GetError(r) if !errors.Is(err, gotErr) { t.Fatalf("expected err %v, got %v", err, gotErr) } } func BenchmarkRouter(b *testing.B) { GlobalLoggerConfig(nil, nil, LogCfgDisableDebug, LogCfgDisableInfo, LogCfgDisableWarn) t := &testing.T{} router, err := setup(t, "1595") if err != nil { b.Error(err) return } w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/a/b/-/c/~/d/./e", nil) for i := 0; i < b.N; i++ { router.ServeHTTP(w, r) if w.Result().StatusCode != http.StatusOK { b.Error("expected status 200, got", w.Result().StatusCode) return } } }