├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── chi │ ├── go.mod │ ├── go.sum │ └── main.go ├── gorillamux │ ├── go.mod │ ├── go.sum │ └── main.go └── stdmux │ ├── go.mod │ ├── go.sum │ └── main.go ├── go.mod ├── go.sum ├── problem_details.go ├── problem_details_test.go ├── request.go ├── request_test.go ├── response.go ├── response_test.go ├── validation.go └── validation_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rluders 4 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.23' 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ricardo Lüders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpsuite 2 | 3 | **httpsuite** is a Go library designed to simplify the handling of HTTP requests, validations, and responses 4 | in microservices. By providing a clear structure and modular approach, it helps developers write 5 | cleaner, more maintainable code with reduced boilerplate. 6 | 7 | ## Features 8 | 9 | - **Request Parsing**: Streamline the parsing of incoming HTTP requests, including URL parameters. 10 | - **Validation:** Centralize validation logic for easy reuse and consistency. 11 | - **Response Handling:** Standardize responses across your microservices for a unified client experience. 12 | - **Modular Design:** Each component (Request, Validation, Response) can be used independently, 13 | enhancing testability and flexibility. 14 | 15 | ### Supported routers 16 | 17 | - Gorilla MUX 18 | - Chi 19 | - Go Standard 20 | - ...maybe more? Submit a PR with an example. 21 | 22 | ## Installation 23 | 24 | To install **httpsuite**, run: 25 | 26 | ``` 27 | go get github.com/rluders/httpsuite/v2 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Request Parsing with URL Parameters 33 | 34 | Check out the [example folder for a complete project](./examples) demonstrating how to integrate **httpsuite** into 35 | your Go microservices. 36 | 37 | ## Contributing 38 | 39 | Contributions are welcome! Feel free to open issues, submit pull requests, and help improve **httpsuite**. 40 | 41 | ## License 42 | 43 | The MIT License (MIT). Please see [License File](LICENSE) for more information. -------------------------------------------------------------------------------- /examples/chi/go.mod: -------------------------------------------------------------------------------- 1 | module chi_example 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.2.0 7 | github.com/rluders/httpsuite/v2 v2.1.0 8 | ) 9 | 10 | require ( 11 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 12 | github.com/go-playground/locales v0.14.1 // indirect 13 | github.com/go-playground/universal-translator v0.18.1 // indirect 14 | github.com/go-playground/validator/v10 v10.24.0 // indirect 15 | github.com/leodido/go-urn v1.4.0 // indirect 16 | golang.org/x/crypto v0.32.0 // indirect 17 | golang.org/x/net v0.34.0 // indirect 18 | golang.org/x/sys v0.29.0 // indirect 19 | golang.org/x/text v0.21.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /examples/chi/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 4 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 5 | github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 6 | github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 7 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 8 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 9 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 10 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 11 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 12 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 13 | github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= 14 | github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 15 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 16 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= 20 | github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= 21 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 22 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 24 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 25 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 26 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 27 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 28 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 29 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 30 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /examples/chi/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/go-chi/chi/v5/middleware" 6 | "github.com/rluders/httpsuite/v2" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | type SampleRequest struct { 13 | ID int `json:"id" validate:"required"` 14 | Name string `json:"name" validate:"required,min=3"` 15 | Age int `json:"age" validate:"required,min=1"` 16 | } 17 | 18 | type SampleResponse struct { 19 | ID int `json:"id"` 20 | Name string `json:"name"` 21 | Age int `json:"age"` 22 | } 23 | 24 | func (r *SampleRequest) SetParam(fieldName, value string) error { 25 | switch fieldName { 26 | case "id": 27 | id, err := strconv.Atoi(value) 28 | if err != nil { 29 | return err 30 | } 31 | r.ID = id 32 | } 33 | return nil 34 | } 35 | 36 | func ChiParamExtractor(r *http.Request, key string) string { 37 | return chi.URLParam(r, key) 38 | } 39 | 40 | // You can test it using: 41 | // 42 | // curl -X POST http://localhost:8080/submit/123 \ 43 | // -H "Content-Type: application/json" \ 44 | // -d '{"name": "John Doe", "age": 30}' 45 | // 46 | // And you should get: 47 | // 48 | // {"data":{"id":123,"name":"John Doe","age":30}} 49 | func main() { 50 | // Creating the router with Chi 51 | r := chi.NewRouter() 52 | r.Use(middleware.Logger) 53 | r.Use(middleware.Recoverer) 54 | 55 | // Define the ProblemBaseURL - doesn't create the handlers 56 | httpsuite.SetProblemBaseURL("http://localhost:8080") 57 | 58 | // Define the endpoint POST 59 | r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { 60 | // Using the function for parameter extraction to the ParseRequest 61 | req, err := httpsuite.ParseRequest[*SampleRequest](w, r, ChiParamExtractor, "id") 62 | if err != nil { 63 | log.Printf("Error parsing or validating request: %v", err) 64 | return 65 | } 66 | 67 | resp := &SampleResponse{ 68 | ID: req.ID, 69 | Name: req.Name, 70 | Age: req.Age, 71 | } 72 | 73 | // Sending success response 74 | httpsuite.SendResponse[SampleResponse](w, http.StatusOK, *resp, nil, nil) 75 | }) 76 | 77 | // Starting the server 78 | log.Println("Starting server on :8080") 79 | http.ListenAndServe(":8080", r) 80 | } 81 | -------------------------------------------------------------------------------- /examples/gorillamux/go.mod: -------------------------------------------------------------------------------- 1 | module gorillamux_example 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/gorilla/mux v1.8.1 7 | github.com/rluders/httpsuite/v2 v2.1.0 8 | ) 9 | 10 | require ( 11 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 12 | github.com/go-playground/locales v0.14.1 // indirect 13 | github.com/go-playground/universal-translator v0.18.1 // indirect 14 | github.com/go-playground/validator/v10 v10.24.0 // indirect 15 | github.com/leodido/go-urn v1.4.0 // indirect 16 | golang.org/x/crypto v0.32.0 // indirect 17 | golang.org/x/net v0.34.0 // indirect 18 | golang.org/x/sys v0.29.0 // indirect 19 | golang.org/x/text v0.21.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /examples/gorillamux/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 4 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 5 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 6 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 7 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 8 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 9 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 10 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 11 | github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= 12 | github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 13 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 14 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 15 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 16 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= 20 | github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= 21 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 22 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 24 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 25 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 26 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 27 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 28 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 29 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 30 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /examples/gorillamux/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/rluders/httpsuite/v2" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | type SampleRequest struct { 12 | ID int `json:"id" validate:"required"` 13 | Name string `json:"name" validate:"required,min=3"` 14 | Age int `json:"age" validate:"required,min=1"` 15 | } 16 | 17 | type SampleResponse struct { 18 | ID int `json:"id"` 19 | Name string `json:"name"` 20 | Age int `json:"age"` 21 | } 22 | 23 | func (r *SampleRequest) SetParam(fieldName, value string) error { 24 | switch fieldName { 25 | case "id": 26 | id, err := strconv.Atoi(value) 27 | if err != nil { 28 | return err 29 | } 30 | r.ID = id 31 | } 32 | return nil 33 | } 34 | 35 | func GorillaMuxParamExtractor(r *http.Request, key string) string { 36 | return mux.Vars(r)[key] // Extracts parameter using Gorilla Mux 37 | } 38 | 39 | // Test the server using: 40 | // curl -X POST http://localhost:8080/submit/123 -H "Content-Type: application/json" -d '{"name": "John Doe", "age": 30}' 41 | func main() { 42 | // Creating the router with Gorilla Mux 43 | r := mux.NewRouter() 44 | 45 | // Define the ProblemBaseURL - doesn't create the handlers 46 | httpsuite.SetProblemBaseURL("http://localhost:8080") 47 | 48 | r.HandleFunc("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { 49 | // Using the function for parameter extraction to the ParseRequest 50 | req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, "id") 51 | if err != nil { 52 | log.Printf("Error parsing or validating request: %v", err) 53 | return 54 | } 55 | 56 | resp := &SampleResponse{ 57 | ID: req.ID, 58 | Name: req.Name, 59 | Age: req.Age, 60 | } 61 | 62 | // Sending success response 63 | httpsuite.SendResponse[SampleResponse](w, http.StatusOK, *resp, nil, nil) 64 | }).Methods("POST") 65 | 66 | // Starting the server 67 | log.Println("Starting server on :8080") 68 | http.ListenAndServe(":8080", r) 69 | } 70 | -------------------------------------------------------------------------------- /examples/stdmux/go.mod: -------------------------------------------------------------------------------- 1 | module stdmux_example 2 | 3 | go 1.23 4 | 5 | require github.com/rluders/httpsuite/v2 v2.1.0 6 | 7 | require ( 8 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 9 | github.com/go-playground/locales v0.14.1 // indirect 10 | github.com/go-playground/universal-translator v0.18.1 // indirect 11 | github.com/go-playground/validator/v10 v10.24.0 // indirect 12 | github.com/leodido/go-urn v1.4.0 // indirect 13 | golang.org/x/crypto v0.32.0 // indirect 14 | golang.org/x/net v0.34.0 // indirect 15 | golang.org/x/sys v0.29.0 // indirect 16 | golang.org/x/text v0.21.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /examples/stdmux/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 4 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 5 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 6 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 7 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 8 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 9 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 10 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 11 | github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= 12 | github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 13 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 14 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= 18 | github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= 19 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 22 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 23 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 24 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 25 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 26 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 27 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 28 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /examples/stdmux/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/rluders/httpsuite/v2" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | type SampleRequest struct { 11 | ID int `json:"id" validate:"required"` 12 | Name string `json:"name" validate:"required,min=3"` 13 | Age int `json:"age" validate:"required,min=1"` 14 | } 15 | 16 | type SampleResponse struct { 17 | ID int `json:"id"` 18 | Name string `json:"name"` 19 | Age int `json:"age"` 20 | } 21 | 22 | func (r *SampleRequest) SetParam(fieldName, value string) error { 23 | switch fieldName { 24 | case "id": 25 | id, err := strconv.Atoi(value) 26 | if err != nil { 27 | return err 28 | } 29 | r.ID = id 30 | } 31 | return nil 32 | } 33 | 34 | func StdMuxParamExtractor(r *http.Request, key string) string { 35 | // Remove "/submit/" (7 characters) from the URL path to get just the "id" 36 | // Example: /submit/123 -> 123 37 | return r.URL.Path[len("/submit/"):] // Skip the "/submit/" part 38 | } 39 | 40 | // You can test it using: 41 | // 42 | // curl -X POST http://localhost:8080/submit/123 \ 43 | // -H "Content-Type: application/json" \ 44 | // -d '{"name": "John Doe", "age": 30}' 45 | func main() { 46 | // Creating the router using the Go standard mux 47 | mux := http.NewServeMux() 48 | 49 | // Define the ProblemBaseURL - doesn't create the handlers 50 | httpsuite.SetProblemBaseURL("http://localhost:8080") 51 | 52 | // Define the endpoint POST 53 | mux.HandleFunc("/submit/", func(w http.ResponseWriter, r *http.Request) { 54 | // Using the function for parameter extraction to the ParseRequest 55 | req, err := httpsuite.ParseRequest[*SampleRequest](w, r, StdMuxParamExtractor, "id") 56 | if err != nil { 57 | log.Printf("Error parsing or validating request: %v", err) 58 | return 59 | } 60 | 61 | resp := &SampleResponse{ 62 | ID: req.ID, 63 | Name: req.Name, 64 | Age: req.Age, 65 | } 66 | 67 | // Sending success response 68 | httpsuite.SendResponse[SampleResponse](w, http.StatusOK, *resp, nil, nil) 69 | }) 70 | 71 | // Starting the server 72 | log.Println("Starting server on :8080") 73 | http.ListenAndServe(":8080", mux) 74 | } 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rluders/httpsuite/v2 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/go-playground/validator/v10 v10.24.0 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 13 | github.com/go-playground/locales v0.14.1 // indirect 14 | github.com/go-playground/universal-translator v0.18.1 // indirect 15 | github.com/leodido/go-urn v1.4.0 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | golang.org/x/crypto v0.32.0 // indirect 18 | golang.org/x/net v0.34.0 // indirect 19 | golang.org/x/sys v0.29.0 // indirect 20 | golang.org/x/text v0.21.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 4 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 5 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 6 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 7 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 8 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 9 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 10 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 11 | github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= 12 | github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 13 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 14 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 18 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 19 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 20 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 21 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 22 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 23 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 24 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 25 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 26 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /problem_details.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | ) 7 | 8 | const BlankUrl = "about:blank" 9 | 10 | var ( 11 | mu sync.RWMutex 12 | problemBaseURL = BlankUrl 13 | errorTypePaths = map[string]string{ 14 | "validation_error": "/errors/validation-error", 15 | "not_found_error": "/errors/not-found", 16 | "server_error": "/errors/server-error", 17 | "bad_request_error": "/errors/bad-request", 18 | } 19 | ) 20 | 21 | // ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs. 22 | type ProblemDetails struct { 23 | Type string `json:"type"` // A URI reference identifying the problem type. 24 | Title string `json:"title"` // A short, human-readable summary of the problem. 25 | Status int `json:"status"` // The HTTP status code. 26 | Detail string `json:"detail,omitempty"` // Detailed explanation of the problem. 27 | Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem. 28 | Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details. 29 | } 30 | 31 | // NewProblemDetails creates a ProblemDetails instance with standard fields. 32 | func NewProblemDetails(status int, problemType, title, detail string) *ProblemDetails { 33 | if problemType == "" { 34 | problemType = BlankUrl 35 | } 36 | if title == "" { 37 | title = http.StatusText(status) 38 | if title == "" { 39 | title = "Unknown error" 40 | } 41 | } 42 | return &ProblemDetails{ 43 | Type: problemType, 44 | Title: title, 45 | Status: status, 46 | Detail: detail, 47 | } 48 | } 49 | 50 | // SetProblemBaseURL configures the base URL used in the "type" field for ProblemDetails. 51 | // 52 | // This function allows applications using httpsuite to provide a custom domain and structure 53 | // for error documentation URLs. By setting this base URL, the library can generate meaningful 54 | // and discoverable problem types. 55 | // 56 | // Parameters: 57 | // - baseURL: The base URL where error documentation is hosted (e.g., "https://api.mycompany.com"). 58 | // 59 | // Example usage: 60 | // 61 | // httpsuite.SetProblemBaseURL("https://api.mycompany.com") 62 | // 63 | // Once configured, generated ProblemDetails will include a "type" such as: 64 | // 65 | // "https://api.mycompany.com/errors/validation-error" 66 | // 67 | // If the base URL is not set, the default value for the "type" field will be "about:blank". 68 | func SetProblemBaseURL(baseURL string) { 69 | mu.Lock() 70 | defer mu.Unlock() 71 | problemBaseURL = baseURL 72 | } 73 | 74 | // SetProblemErrorTypePath sets or updates the path for a specific error type. 75 | // 76 | // This allows applications to define custom paths for error documentation. 77 | // 78 | // Parameters: 79 | // - errorType: The unique key identifying the error type (e.g., "validation_error"). 80 | // - path: The path under the base URL where the error documentation is located. 81 | // 82 | // Example usage: 83 | // 84 | // httpsuite.SetProblemErrorTypePath("validation_error", "/errors/validation-error") 85 | // 86 | // After setting this path, the generated problem type for "validation_error" will be: 87 | // 88 | // "https://api.mycompany.com/errors/validation-error" 89 | func SetProblemErrorTypePath(errorType, path string) { 90 | mu.Lock() 91 | defer mu.Unlock() 92 | errorTypePaths[errorType] = path 93 | } 94 | 95 | // SetProblemErrorTypePaths sets or updates multiple paths for different error types. 96 | // 97 | // This allows applications to define multiple custom paths at once. 98 | // 99 | // Parameters: 100 | // - paths: A map of error types to paths (e.g., {"validation_error": "/errors/validation-error"}). 101 | // 102 | // Example usage: 103 | // 104 | // paths := map[string]string{ 105 | // "validation_error": "/errors/validation-error", 106 | // "not_found_error": "/errors/not-found", 107 | // } 108 | // httpsuite.SetProblemErrorTypePaths(paths) 109 | // 110 | // This method overwrites any existing paths with the same keys. 111 | func SetProblemErrorTypePaths(paths map[string]string) { 112 | mu.Lock() 113 | defer mu.Unlock() 114 | for errorType, path := range paths { 115 | errorTypePaths[errorType] = path 116 | } 117 | } 118 | 119 | // GetProblemTypeURL get the full problem type URL based on the error type. 120 | // 121 | // If the error type is not found in the predefined paths, it returns a default unknown error path. 122 | // 123 | // Parameters: 124 | // - errorType: The unique key identifying the error type (e.g., "validation_error"). 125 | // 126 | // Example usage: 127 | // 128 | // problemTypeURL := GetProblemTypeURL("validation_error") 129 | func GetProblemTypeURL(errorType string) string { 130 | mu.RLock() 131 | defer mu.RUnlock() 132 | if path, exists := errorTypePaths[errorType]; exists { 133 | return getProblemBaseURL() + path 134 | } 135 | 136 | return BlankUrl 137 | } 138 | 139 | // getProblemBaseURL just return the baseURL if it isn't "about:blank" 140 | func getProblemBaseURL() string { 141 | mu.RLock() 142 | defer mu.RUnlock() 143 | if problemBaseURL == BlankUrl { 144 | return "" 145 | } 146 | return problemBaseURL 147 | } 148 | -------------------------------------------------------------------------------- /problem_details_test.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_SetProblemBaseURL(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | expected string 14 | }{ 15 | { 16 | name: "Set valid base URL", 17 | input: "https://api.example.com", 18 | expected: "https://api.example.com", 19 | }, 20 | { 21 | name: "Set base URL to blank", 22 | input: BlankUrl, 23 | expected: BlankUrl, 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | SetProblemBaseURL(tt.input) 30 | assert.Equal(t, tt.expected, problemBaseURL) 31 | }) 32 | } 33 | } 34 | 35 | func Test_SetProblemErrorTypePath(t *testing.T) { 36 | tests := []struct { 37 | name string 38 | errorKey string 39 | path string 40 | expected string 41 | }{ 42 | { 43 | name: "Set custom error path", 44 | errorKey: "custom_error", 45 | path: "/errors/custom-error", 46 | expected: "/errors/custom-error", 47 | }, 48 | { 49 | name: "Override existing path", 50 | errorKey: "validation_error", 51 | path: "/errors/new-validation-error", 52 | expected: "/errors/new-validation-error", 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | SetProblemErrorTypePath(tt.errorKey, tt.path) 59 | assert.Equal(t, tt.expected, errorTypePaths[tt.errorKey]) 60 | }) 61 | } 62 | } 63 | 64 | func Test_GetProblemTypeURL(t *testing.T) { 65 | // Setup initial state 66 | SetProblemBaseURL("https://api.example.com") 67 | SetProblemErrorTypePath("validation_error", "/errors/validation-error") 68 | 69 | tests := []struct { 70 | name string 71 | errorType string 72 | expectedURL string 73 | }{ 74 | { 75 | name: "Valid error type", 76 | errorType: "validation_error", 77 | expectedURL: "https://api.example.com/errors/validation-error", 78 | }, 79 | { 80 | name: "Unknown error type", 81 | errorType: "unknown_error", 82 | expectedURL: BlankUrl, 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | result := GetProblemTypeURL(tt.errorType) 89 | assert.Equal(t, tt.expectedURL, result) 90 | }) 91 | } 92 | } 93 | 94 | func Test_getProblemBaseURL(t *testing.T) { 95 | tests := []struct { 96 | name string 97 | baseURL string 98 | expectedResult string 99 | }{ 100 | { 101 | name: "Base URL is set", 102 | baseURL: "https://api.example.com", 103 | expectedResult: "https://api.example.com", 104 | }, 105 | { 106 | name: "Base URL is about:blank", 107 | baseURL: BlankUrl, 108 | expectedResult: "", 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | problemBaseURL = tt.baseURL 115 | assert.Equal(t, tt.expectedResult, getProblemBaseURL()) 116 | }) 117 | } 118 | } 119 | 120 | func Test_NewProblemDetails(t *testing.T) { 121 | tests := []struct { 122 | name string 123 | status int 124 | problemType string 125 | title string 126 | detail string 127 | expectedType string 128 | }{ 129 | { 130 | name: "All fields provided", 131 | status: 400, 132 | problemType: "https://api.example.com/errors/validation-error", 133 | title: "Validation Error", 134 | detail: "Invalid input", 135 | expectedType: "https://api.example.com/errors/validation-error", 136 | }, 137 | { 138 | name: "Empty problem type", 139 | status: 404, 140 | problemType: "", 141 | title: "Not Found", 142 | detail: "The requested resource was not found", 143 | expectedType: BlankUrl, 144 | }, 145 | } 146 | 147 | for _, tt := range tests { 148 | t.Run(tt.name, func(t *testing.T) { 149 | details := NewProblemDetails(tt.status, tt.problemType, tt.title, tt.detail) 150 | assert.Equal(t, tt.status, details.Status) 151 | assert.Equal(t, tt.title, details.Title) 152 | assert.Equal(t, tt.detail, details.Detail) 153 | assert.Equal(t, tt.expectedType, details.Type) 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "reflect" 8 | ) 9 | 10 | // RequestParamSetter defines the interface used to set the parameters to the HTTP request object by the request parser. 11 | // Implementing this interface allows custom handling of URL parameters. 12 | type RequestParamSetter interface { 13 | // SetParam assigns a value to a specified field in the request struct. 14 | // The fieldName parameter is the name of the field, and value is the value to set. 15 | SetParam(fieldName, value string) error 16 | } 17 | 18 | // ParamExtractor is a function type that extracts a URL parameter from the incoming HTTP request. 19 | // It takes the `http.Request` and a `key` as arguments, and returns the value of the URL parameter 20 | // as a string. This function allows flexibility for extracting parameters from different routers, 21 | // such as Chi, Echo, Gorilla Mux, or the default Go router. 22 | // 23 | // Example usage: 24 | // 25 | // paramExtractor := func(r *http.Request, key string) string { 26 | // return r.URL.Query().Get(key) 27 | // } 28 | type ParamExtractor func(r *http.Request, key string) string 29 | 30 | // ParseRequest parses the incoming HTTP request into a specified struct type, 31 | // handling JSON decoding and extracting URL parameters using the provided `paramExtractor` function. 32 | // The `paramExtractor` allows flexibility to integrate with various routers (e.g., Chi, Echo, Gorilla Mux). 33 | // It extracts the specified parameters from the URL and sets them on the struct. 34 | // 35 | // The `pathParams` variadic argument is used to specify which URL parameters to extract and set on the struct. 36 | // 37 | // The function also validates the parsed request. If the request fails validation or if any error occurs during 38 | // JSON parsing or parameter extraction, it responds with an appropriate HTTP status and error message. 39 | // 40 | // Parameters: 41 | // - `w`: The `http.ResponseWriter` used to send the response to the client. 42 | // - `r`: The incoming HTTP request to be parsed. 43 | // - `paramExtractor`: A function that extracts URL parameters from the request. This function allows custom handling 44 | // of parameters based on the router being used. 45 | // - `pathParams`: A variadic argument specifying which URL parameters to extract and set on the struct. 46 | // 47 | // Returns: 48 | // - A parsed struct of the specified type `T`, if successful. 49 | // - An error, if parsing, validation, or parameter extraction fails. 50 | // 51 | // Example usage: 52 | // 53 | // request, err := ParseRequest[MyRequestType](w, r, MyParamExtractor, "id", "name") 54 | // if err != nil { 55 | // // Handle error 56 | // } 57 | // 58 | // // Continue processing the valid request... 59 | func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, paramExtractor ParamExtractor, pathParams ...string) (T, error) { 60 | var request T 61 | var empty T 62 | defer func() { _ = r.Body.Close() }() 63 | 64 | // Decode JSON body if present 65 | if r.Body != http.NoBody { 66 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 67 | problem := NewProblemDetails( 68 | http.StatusBadRequest, 69 | GetProblemTypeURL("bad_request_error"), 70 | "Invalid Request", 71 | err.Error(), 72 | ) 73 | SendResponse[any](w, http.StatusBadRequest, nil, problem, nil) 74 | return empty, err 75 | } 76 | } 77 | 78 | // Ensure request object is properly initialized 79 | if isRequestNil(request) { 80 | request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T) 81 | } 82 | 83 | // Extract and set URL parameters 84 | for _, key := range pathParams { 85 | value := paramExtractor(r, key) 86 | if value == "" { 87 | problem := NewProblemDetails( 88 | http.StatusBadRequest, 89 | GetProblemTypeURL("bad_request_error"), 90 | "Missing Parameter", 91 | "Parameter "+key+" not found in request", 92 | ) 93 | SendResponse[any](w, http.StatusBadRequest, nil, problem, nil) 94 | return empty, errors.New("missing parameter: " + key) 95 | } 96 | 97 | if err := request.SetParam(key, value); err != nil { 98 | problem := NewProblemDetails( 99 | http.StatusInternalServerError, 100 | GetProblemTypeURL("server_error"), 101 | "Parameter Error", 102 | "Failed to set field "+key, 103 | ) 104 | problem.Extensions = map[string]interface{}{"error": err.Error()} 105 | SendResponse[any](w, http.StatusInternalServerError, nil, problem, nil) 106 | return empty, err 107 | } 108 | } 109 | 110 | // Validate the request 111 | if validationErr := IsRequestValid(request); validationErr != nil { 112 | SendResponse[any](w, http.StatusBadRequest, nil, validationErr, nil) 113 | return empty, errors.New("validation error") 114 | } 115 | 116 | return request, nil 117 | } 118 | 119 | // isRequestNil checks if a request object is nil or an uninitialized pointer. 120 | func isRequestNil(i interface{}) bool { 121 | return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) 122 | } 123 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "net/http/httptest" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | ) 15 | 16 | // TestRequest includes custom type annotation for UUID type. 17 | type TestRequest struct { 18 | ID int `json:"id" validate:"required,gt=0"` 19 | Name string `json:"name" validate:"required"` 20 | } 21 | 22 | func (r *TestRequest) SetParam(fieldName, value string) error { 23 | switch strings.ToLower(fieldName) { 24 | case "id": 25 | id, err := strconv.Atoi(value) 26 | if err != nil { 27 | return errors.New("invalid id") 28 | } 29 | r.ID = id 30 | default: 31 | fmt.Printf("Parameter %s cannot be set", fieldName) 32 | } 33 | return nil 34 | } 35 | 36 | // MyParamExtractor extracts parameters from the path, assuming the request URL follows a pattern like "/test/{id}". 37 | func MyParamExtractor(r *http.Request, key string) string { 38 | pathSegments := strings.Split(r.URL.Path, "/") 39 | if len(pathSegments) > 2 && key == "ID" { 40 | return pathSegments[2] 41 | } 42 | return "" 43 | } 44 | 45 | func Test_ParseRequest(t *testing.T) { 46 | type args struct { 47 | w http.ResponseWriter 48 | r *http.Request 49 | pathParams []string 50 | } 51 | type testCase[T any] struct { 52 | name string 53 | args args 54 | want *TestRequest 55 | wantErr assert.ErrorAssertionFunc 56 | wantDetail *ProblemDetails 57 | } 58 | 59 | tests := []testCase[TestRequest]{ 60 | { 61 | name: "Successful Request", 62 | args: args{ 63 | w: httptest.NewRecorder(), 64 | r: func() *http.Request { 65 | body, _ := json.Marshal(TestRequest{Name: "Test"}) 66 | req := httptest.NewRequest("POST", "/test/123", bytes.NewBuffer(body)) 67 | req.URL.Path = "/test/123" 68 | return req 69 | }(), 70 | pathParams: []string{"ID"}, 71 | }, 72 | want: &TestRequest{ID: 123, Name: "Test"}, 73 | wantErr: assert.NoError, 74 | wantDetail: nil, 75 | }, 76 | { 77 | name: "Missing body", 78 | args: args{ 79 | w: httptest.NewRecorder(), 80 | r: httptest.NewRequest("POST", "/test/123", nil), 81 | pathParams: []string{"ID"}, 82 | }, 83 | want: nil, 84 | wantErr: assert.Error, 85 | wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Validation Error", "One or more fields failed validation."), 86 | }, 87 | { 88 | name: "Invalid JSON Body", 89 | args: args{ 90 | w: httptest.NewRecorder(), 91 | r: func() *http.Request { 92 | req := httptest.NewRequest("POST", "/test/123", bytes.NewBufferString("{invalid-json}")) 93 | req.URL.Path = "/test/123" 94 | return req 95 | }(), 96 | pathParams: []string{"ID"}, 97 | }, 98 | want: nil, 99 | wantErr: assert.Error, 100 | wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Invalid Request", "invalid character 'i' looking for beginning of object key string"), 101 | }, 102 | } 103 | 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | // Call the function under test. 107 | w := tt.args.w 108 | got, err := ParseRequest[*TestRequest](w, tt.args.r, MyParamExtractor, tt.args.pathParams...) 109 | 110 | // Validate the error response if applicable. 111 | if !tt.wantErr(t, err, fmt.Sprintf("parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)) { 112 | return 113 | } 114 | 115 | // Check ProblemDetails if an error was expected. 116 | if tt.wantDetail != nil { 117 | rec := w.(*httptest.ResponseRecorder) 118 | assert.Equal(t, "application/problem+json; charset=utf-8", rec.Header().Get("Content-Type"), "Content-Type header mismatch") 119 | var pd ProblemDetails 120 | decodeErr := json.NewDecoder(rec.Body).Decode(&pd) 121 | assert.NoError(t, decodeErr, "Failed to decode problem details response") 122 | assert.Equal(t, tt.wantDetail.Title, pd.Title, "Problem detail title mismatch") 123 | assert.Equal(t, tt.wantDetail.Status, pd.Status, "Problem detail status mismatch") 124 | assert.Contains(t, pd.Detail, tt.wantDetail.Detail, "Problem detail message mismatch") 125 | } 126 | 127 | // Validate successful response. 128 | assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", w, tt.args.r, tt.args.pathParams) 129 | }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // Response represents the structure of an HTTP response, including a status code, message, and optional body. 11 | // T represents the type of the `Data` field, allowing this structure to be used flexibly across different endpoints. 12 | type Response[T any] struct { 13 | Data T `json:"data,omitempty"` 14 | Meta *Meta `json:"meta,omitempty"` 15 | } 16 | 17 | // Meta provides additional information about the response, such as pagination details. 18 | type Meta struct { 19 | Page int `json:"page,omitempty"` 20 | PageSize int `json:"page_size,omitempty"` 21 | TotalPages int `json:"total_pages,omitempty"` 22 | TotalItems int `json:"total_items,omitempty"` 23 | } 24 | 25 | // SendResponse sends a JSON response to the client, supporting both success and error scenarios. 26 | // 27 | // Parameters: 28 | // - w: The http.ResponseWriter to send the response. 29 | // - code: HTTP status code to indicate success or failure. 30 | // - data: The main payload of the response (only for successful responses). 31 | // - problem: An optional ProblemDetails struct (used for error responses). 32 | // - meta: Optional metadata for successful responses (e.g., pagination details). 33 | func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta *Meta) { 34 | 35 | // Handle error responses 36 | if code >= 400 && problem != nil { 37 | writeProblemDetail(w, code, problem) 38 | return 39 | } 40 | 41 | // Construct and encode the success response 42 | response := &Response[T]{ 43 | Data: data, 44 | Meta: meta, 45 | } 46 | 47 | var buffer bytes.Buffer 48 | if err := json.NewEncoder(&buffer).Encode(response); err != nil { 49 | log.Printf("Error writing response: %v", err) 50 | 51 | // Internal server error fallback using ProblemDetails 52 | internalError := NewProblemDetails( 53 | http.StatusInternalServerError, 54 | GetProblemTypeURL("server_error"), 55 | "Internal Server Error", 56 | err.Error(), 57 | ) 58 | writeProblemDetail(w, http.StatusInternalServerError, internalError) 59 | return 60 | } 61 | 62 | // Send the success response 63 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 64 | w.WriteHeader(code) 65 | if _, err := w.Write(buffer.Bytes()); err != nil { 66 | log.Printf("Failed to write response body (status=%d): %v", code, err) 67 | } 68 | } 69 | 70 | func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails) { 71 | w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") 72 | w.WriteHeader(problem.Status) 73 | if err := json.NewEncoder(w).Encode(problem); err != nil { 74 | log.Printf("Failed to encode problem details: %v", err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type TestResponse struct { 12 | Key string `json:"key"` 13 | } 14 | 15 | func Test_SendResponse(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | code int 19 | data any 20 | problem *ProblemDetails 21 | meta *Meta 22 | expectedCode int 23 | expectedJSON string 24 | }{ 25 | { 26 | name: "200 OK with TestResponse body", 27 | code: http.StatusOK, 28 | data: &TestResponse{Key: "value"}, 29 | expectedCode: http.StatusOK, 30 | expectedJSON: `{ 31 | "data": { 32 | "key": "value" 33 | } 34 | }`, 35 | }, 36 | { 37 | name: "404 Not Found without body", 38 | code: http.StatusNotFound, 39 | problem: NewProblemDetails(http.StatusNotFound, "", "Not Found", "The requested resource was not found"), 40 | expectedCode: http.StatusNotFound, 41 | expectedJSON: `{ 42 | "type": "about:blank", 43 | "title": "Not Found", 44 | "status": 404, 45 | "detail": "The requested resource was not found" 46 | }`, 47 | }, 48 | { 49 | name: "200 OK with pagination metadata", 50 | code: http.StatusOK, 51 | data: &TestResponse{Key: "value"}, 52 | meta: &Meta{TotalPages: 100, Page: 1, PageSize: 10}, 53 | expectedCode: http.StatusOK, 54 | expectedJSON: `{ 55 | "data": { 56 | "key": "value" 57 | }, 58 | "meta": { 59 | "total_pages": 100, 60 | "page": 1, 61 | "page_size": 10 62 | } 63 | }`, 64 | }, 65 | { 66 | name: "400 Bad Request with validation error", 67 | code: http.StatusBadRequest, 68 | problem: &ProblemDetails{ 69 | Type: "https://example.com/validation-error", 70 | Title: "Validation Error", 71 | Status: http.StatusBadRequest, 72 | Detail: "One or more fields failed validation.", 73 | Extensions: map[string]interface{}{ 74 | "errors": []ValidationErrorDetail{ 75 | {Field: "email", Message: "Email is required"}, 76 | {Field: "password", Message: "Password is required"}, 77 | }, 78 | }, 79 | }, 80 | expectedCode: http.StatusBadRequest, 81 | expectedJSON: `{ 82 | "type": "https://example.com/validation-error", 83 | "title": "Validation Error", 84 | "status": 400, 85 | "detail": "One or more fields failed validation.", 86 | "extensions": { 87 | "errors": [ 88 | {"field": "email", "message": "Email is required"}, 89 | {"field": "password", "message": "Password is required"} 90 | ] 91 | } 92 | }`, 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | w := httptest.NewRecorder() 99 | 100 | // Call SendResponse with the appropriate data or problem 101 | SendResponse[any](w, tt.code, tt.data, tt.problem, tt.meta) 102 | 103 | // Assert response status code and content type 104 | assert.Equal(t, tt.expectedCode, w.Code) 105 | if w.Code >= 400 { 106 | assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type")) 107 | } else { 108 | assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) 109 | } 110 | // Assert response body 111 | assert.JSONEq(t, tt.expectedJSON, w.Body.String()) 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /validation.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/go-playground/validator/v10" 8 | ) 9 | 10 | // Validator instance 11 | var validate = validator.New() 12 | 13 | // ValidationErrorDetail provides structured details about a single validation error. 14 | type ValidationErrorDetail struct { 15 | Field string `json:"field"` // The name of the field that failed validation. 16 | Message string `json:"message"` // A human-readable message describing the error. 17 | } 18 | 19 | // NewValidationProblemDetails creates a ProblemDetails instance based on validation errors. 20 | // It maps field-specific validation errors into structured details. 21 | func NewValidationProblemDetails(err error) *ProblemDetails { 22 | var validationErrors validator.ValidationErrors 23 | if !errors.As(err, &validationErrors) { 24 | // If the error is not of type ValidationErrors, return a generic problem response. 25 | return NewProblemDetails( 26 | http.StatusBadRequest, 27 | GetProblemTypeURL("bad_request_error"), 28 | "Invalid Request", 29 | "Invalid data format or structure", 30 | ) 31 | } 32 | 33 | // Collect structured details about each validation error. 34 | errorDetails := make([]ValidationErrorDetail, len(validationErrors)) 35 | for i, vErr := range validationErrors { 36 | errorDetails[i] = ValidationErrorDetail{ 37 | Field: vErr.Field(), 38 | Message: formatValidationMessage(vErr), 39 | } 40 | } 41 | 42 | return &ProblemDetails{ 43 | Type: GetProblemTypeURL("validation_error"), 44 | Title: "Validation Error", 45 | Status: http.StatusBadRequest, 46 | Detail: "One or more fields failed validation.", 47 | Extensions: map[string]interface{}{ 48 | "errors": errorDetails, 49 | }, 50 | } 51 | } 52 | 53 | // formatValidationMessage generates a descriptive message for a validation error. 54 | func formatValidationMessage(vErr validator.FieldError) string { 55 | return vErr.Field() + " failed " + vErr.Tag() + " validation" 56 | } 57 | 58 | // IsRequestValid validates the provided request struct using the go-playground/validator package. 59 | // It returns a ProblemDetails instance if validation fails, or nil if the request is valid. 60 | func IsRequestValid(request any) *ProblemDetails { 61 | err := validate.Struct(request) 62 | if err != nil { 63 | return NewValidationProblemDetails(err) 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /validation_test.go: -------------------------------------------------------------------------------- 1 | package httpsuite 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-playground/validator/v10" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type TestValidationRequest struct { 11 | Name string `validate:"required"` 12 | Age int `validate:"required,min=18"` 13 | } 14 | 15 | func TestNewValidationProblemDetails(t *testing.T) { 16 | validate := validator.New() 17 | request := TestValidationRequest{} // Missing required fields to trigger validation errors 18 | 19 | err := validate.Struct(request) 20 | if err == nil { 21 | t.Fatal("Expected validation errors, but got none") 22 | } 23 | 24 | SetProblemBaseURL("https://example.com") 25 | validationProblem := NewValidationProblemDetails(err) 26 | 27 | expectedProblem := &ProblemDetails{ 28 | Type: "https://example.com/errors/validation-error", 29 | Title: "Validation Error", 30 | Status: 400, 31 | Detail: "One or more fields failed validation.", 32 | Extensions: map[string]interface{}{ 33 | "errors": []ValidationErrorDetail{ 34 | {Field: "Name", Message: "Name failed required validation"}, 35 | {Field: "Age", Message: "Age failed required validation"}, 36 | }, 37 | }, 38 | } 39 | 40 | assert.Equal(t, expectedProblem.Type, validationProblem.Type) 41 | assert.Equal(t, expectedProblem.Title, validationProblem.Title) 42 | assert.Equal(t, expectedProblem.Status, validationProblem.Status) 43 | assert.Equal(t, expectedProblem.Detail, validationProblem.Detail) 44 | assert.ElementsMatch(t, expectedProblem.Extensions["errors"], validationProblem.Extensions["errors"]) 45 | } 46 | 47 | func TestIsRequestValid(t *testing.T) { 48 | tests := []struct { 49 | name string 50 | request TestValidationRequest 51 | expectedProblem *ProblemDetails 52 | }{ 53 | { 54 | name: "Valid request", 55 | request: TestValidationRequest{Name: "Alice", Age: 25}, 56 | expectedProblem: nil, // No errors expected for valid input 57 | }, 58 | { 59 | name: "Missing Name and Age below minimum", 60 | request: TestValidationRequest{Age: 17}, 61 | expectedProblem: &ProblemDetails{ 62 | Type: "https://example.com/errors/validation-error", 63 | Title: "Validation Error", 64 | Status: 400, 65 | Detail: "One or more fields failed validation.", 66 | Extensions: map[string]interface{}{ 67 | "errors": []ValidationErrorDetail{ 68 | {Field: "Name", Message: "Name failed required validation"}, 69 | {Field: "Age", Message: "Age failed min validation"}, 70 | }, 71 | }, 72 | }, 73 | }, 74 | { 75 | name: "Missing Age", 76 | request: TestValidationRequest{Name: "Alice"}, 77 | expectedProblem: &ProblemDetails{ 78 | Type: "https://example.com/errors/validation-error", 79 | Title: "Validation Error", 80 | Status: 400, 81 | Detail: "One or more fields failed validation.", 82 | Extensions: map[string]interface{}{ 83 | "errors": []ValidationErrorDetail{ 84 | {Field: "Age", Message: "Age failed required validation"}, 85 | }, 86 | }, 87 | }, 88 | }, 89 | } 90 | 91 | SetProblemBaseURL("https://example.com") 92 | 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | problem := IsRequestValid(tt.request) 96 | 97 | if tt.expectedProblem == nil { 98 | assert.Nil(t, problem) 99 | } else { 100 | assert.NotNil(t, problem) 101 | assert.Equal(t, tt.expectedProblem.Type, problem.Type) 102 | assert.Equal(t, tt.expectedProblem.Title, problem.Title) 103 | assert.Equal(t, tt.expectedProblem.Status, problem.Status) 104 | assert.Equal(t, tt.expectedProblem.Detail, problem.Detail) 105 | assert.ElementsMatch(t, tt.expectedProblem.Extensions["errors"], problem.Extensions["errors"]) 106 | } 107 | }) 108 | } 109 | } 110 | --------------------------------------------------------------------------------