From bd26a029dc7e74f5cc84727eb7704ff752f333c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Sat, 18 Apr 2026 14:23:38 -0300 Subject: [PATCH] refine v3 core and add modular validation --- .github/workflows/ci.yml | 119 +++++++ .github/workflows/go.yml | 25 -- .github/workflows/release.yml | 68 ++++ README.md | 268 ++++++++++---- examples/chi/go.mod | 7 +- examples/chi/go.sum | 6 +- examples/chi/main.go | 14 +- examples/gorillamux/go.mod | 14 +- examples/gorillamux/go.sum | 30 -- examples/gorillamux/main.go | 17 +- examples/restapi/go.mod | 25 ++ examples/{stdmux => restapi}/go.sum | 8 +- examples/restapi/main.go | 214 ++++++++++++ examples/stdmux/go.mod | 14 +- examples/stdmux/main.go | 17 +- go.mod | 21 +- go.work | 10 + go.work.sum | 17 + problem_builder.go | 66 ++++ problem_builder_test.go | 35 ++ problem_config.go | 88 +++++ problem_config_test.go | 40 +++ problem_details.go | 140 +------- problem_details_test.go | 158 +-------- problem_helpers.go | 29 ++ problem_helpers_test.go | 34 ++ request.go | 130 ++----- request_bind.go | 77 ++++ request_bind_test.go | 77 ++++ request_decode.go | 106 ++++++ request_decode_test.go | 77 ++++ request_internal.go | 151 ++++++++ request_test.go | 447 +++++++++++++++++------- request_test_helpers_test.go | 49 +++ request_types.go | 26 ++ request_validate.go | 35 ++ request_validate_test.go | 102 ++++++ response.go | 75 +--- response_benchmark_test.go | 17 + response_builder.go | 118 +++++++ response_builder_test.go | 92 +++++ response_helpers.go | 26 ++ response_helpers_test.go | 46 +++ response_meta.go | 49 +++ response_meta_test.go | 34 ++ response_test.go | 118 +++---- response_write.go | 69 ++++ validation.go | 66 ---- validation/playground/go.mod | 21 ++ go.sum => validation/playground/go.sum | 9 +- validation/playground/validator.go | 92 +++++ validation/playground/validator_test.go | 38 ++ validation_test.go | 109 ------ 53 files changed, 2736 insertions(+), 1004 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/release.yml create mode 100644 examples/restapi/go.mod rename examples/{stdmux => restapi}/go.sum (86%) create mode 100644 examples/restapi/main.go create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 problem_builder.go create mode 100644 problem_builder_test.go create mode 100644 problem_config.go create mode 100644 problem_config_test.go create mode 100644 problem_helpers.go create mode 100644 problem_helpers_test.go create mode 100644 request_bind.go create mode 100644 request_bind_test.go create mode 100644 request_decode.go create mode 100644 request_decode_test.go create mode 100644 request_internal.go create mode 100644 request_test_helpers_test.go create mode 100644 request_types.go create mode 100644 request_validate.go create mode 100644 request_validate_test.go create mode 100644 response_benchmark_test.go create mode 100644 response_builder.go create mode 100644 response_builder_test.go create mode 100644 response_helpers.go create mode 100644 response_helpers_test.go create mode 100644 response_meta.go create mode 100644 response_meta_test.go create mode 100644 response_write.go delete mode 100644 validation.go create mode 100644 validation/playground/go.mod rename go.sum => validation/playground/go.sum (71%) create mode 100644 validation/playground/validator.go create mode 100644 validation/playground/validator_test.go delete mode 100644 validation_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed33c0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + root: + name: Root checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify root tests + run: go test ./... + + - name: Verify race detector + run: go test -race ./... + + - name: Verify go vet + run: go vet ./... + + validation-playground: + name: Validation submodule + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: validation/playground/go.mod + cache: true + + - name: Download validation module dependencies + working-directory: validation/playground + env: + GOWORK: off + run: go mod download + + - name: Verify validation/playground + working-directory: validation/playground + env: + GOWORK: off + run: go test ./... + + examples: + name: Examples (${{ matrix.example }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + example: + - examples/stdmux + - examples/gorillamux + - examples/chi + - examples/restapi + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.example }}/go.mod + cache: true + + - name: Download example module dependencies + working-directory: ${{ matrix.example }} + env: + GOWORK: off + run: go mod download + + - name: Verify example module + working-directory: ${{ matrix.example }} + env: + GOWORK: off + run: go test ./... + + workspace: + name: Workspace sync + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Sync go.work + run: go work sync + + - name: Verify workspace is clean + run: git diff --exit-code -- go.work go.work.sum diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index dffa2cb..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,25 +0,0 @@ -# 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: [ "main" ] - pull_request: - branches: [ "main" ] - -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: Test - run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8ee2ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + verify: + name: Verify before release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Root tests + run: go test ./... + + - name: Root race tests + run: go test -race ./... + + - name: Download validation module dependencies + working-directory: validation/playground + env: + GOWORK: off + run: go mod download + + - name: Validation submodule tests + working-directory: validation/playground + env: + GOWORK: off + run: go test ./... + + - name: Download REST API example dependencies + working-directory: examples/restapi + env: + GOWORK: off + run: go mod download + + - name: REST API example tests + working-directory: examples/restapi + env: + GOWORK: off + run: go test ./... + + github-release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: verify + steps: + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/README.md b/README.md index ee42d09..fa45c11 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,253 @@ # httpsuite -httpsuite is a lightweight, idiomatic Go library that simplifies HTTP request parsing, validation, -and response handling in microservices. It’s designed to reduce boilerplate and promote clean, -maintainable, and testable code β€” all while staying framework-agnostic. +`httpsuite` is a Go library for request parsing, response writing, and RFC 9457 problem responses. -## ✨ Features +`v3` keeps the root module stdlib-only and moves validation to an optional submodule. -- 🧾 **Request Parsing**: Automatically extract and map JSON payloads and URL path parameters to Go structs. -- βœ… **Validation:** Centralized validation using struct tags, integrated with standard libraries like `go-playground/validator`. -- πŸ“¦ **Unified Responses:** Standardize your success and error responses (e.g., [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807)) for a consistent API experience. -- πŸ”Œ **Modular Design:** Use each component independently β€” ideal for custom setups, unit testing, or advanced use cases. -- πŸ§ͺ **Test-Friendly:** Decouple parsing and validation logic for simpler, more focused test cases. +## Features -### πŸ”Œ Supported routers +- Parse JSON request bodies with a default `1 MiB` limit +- Bind path params explicitly through a router-specific extractor +- Validate automatically during `ParseRequest` when a global validator is configured +- Keep `ParseRequest` panic-safe for invalid inputs and return regular errors instead +- Return consistent [RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457) +- Write success responses with optional generic metadata +- Support both direct helpers and optional builders + +## Supported routers - [Chi](https://github.com/go-chi/chi) -- [Gorilla MUX](https://github.com/gorilla/mux) +- [Gorilla Mux](https://github.com/gorilla/mux) - Go standard `http.ServeMux` -- ...and potentially more β€” [Submit a PR with an example!](https://github.com/rluders/httpsuite) -## πŸ›  Installation +## Installation -To install **httpsuite**, run: +Core: +```bash +go get github.com/rluders/httpsuite/v3 ``` -go get github.com/rluders/httpsuite/v2 + +Optional validation adapter: + +```bash +go get github.com/rluders/httpsuite/validation/playground ``` -## πŸš€ Usage +## Mental model + +- request in: `ParseRequest(...)` +- success out: `OK(...)`, `Created(...)`, `Reply().Meta(...).OK(...)` +- problem out: `ProblemResponse(...)`, `NewBadRequestProblem(...)`, `Problem(...).Build()` +- validation: configure once with `SetValidator(...)`, override locally with `ParseOptions.Validator` + +For simple handlers, prefer direct helpers. + +When a handler needs custom headers, meta, or problem composition, use the optional builders. + +`ParseRequest` never panics on invalid inputs such as a nil request, nil body, or nil path extractor. These cases return regular Go errors so callers can fail safely. + +## Quick start + +### Core only ```go +package main + import ( - "github.com/go-chi/chi/v5" - "github.com/rluders/httpsuite/v2" - "net/http" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/rluders/httpsuite/v3" ) -type SampleRequest struct { - ID int `json:"id" validate:"required"` - Name string `json:"name" validate:"required,min=3"` +type CreateUserRequest struct { + ID int `json:"id"` + Name string `json:"name"` } -func (r *SampleRequest) SetParam(fieldName, value string) error { - if fieldName == "id" { - id, err := strconv.Atoi(value) - if err != nil { - return err - } - r.ID = id - } - return nil +func (r *CreateUserRequest) SetParam(fieldName, value string) error { + if fieldName != "id" { + return nil + } + + id, err := strconv.Atoi(value) + if err != nil { + return err + } + r.ID = id + return nil } func main() { - r := chi.NewRouter() + router := chi.NewRouter() - r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { - req, err := httpsuite.ParseRequest[*SampleRequest](w, r, chi.URLParam, "id") - if err != nil { - return // ProblemDetails already sent - } + router.Post("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + req, err := httpsuite.ParseRequest[*CreateUserRequest](w, r, chi.URLParam, nil, "id") + if err != nil { + return + } - httpsuite.SendResponse(w, http.StatusOK, req, nil, nil) - }) + httpsuite.OK(w, req) + }) - http.ListenAndServe(":8080", r) + _ = http.ListenAndServe(":8080", router) } ``` -πŸ’‘ Try it: +Try it: + +```bash +curl -X POST http://localhost:8080/users/123 \ + -H "Content-Type: application/json" \ + -d '{"name":"Ada"}' +``` + +### Core + validation + +```go +validator := playground.NewWithValidator(nil, &httpsuite.ProblemConfig{ + BaseURL: "https://api.example.com", +}) + +httpsuite.SetValidator(validator) + +// Validation uses the problem status returned by the configured validator. +// If the validator returns 422, ParseRequest writes 422. + +req, err := httpsuite.ParseRequest[*CreateUserRequest]( + w, + r, + chi.URLParam, + &httpsuite.ParseOptions{ + MaxBodyBytes: 1 << 20, + }, + "id", +) +``` + +### Direct helpers + +```go +httpsuite.OK(w, user) +httpsuite.OKWithMeta(w, users, httpsuite.NewPageMeta(page, pageSize, totalItems)) +httpsuite.Created(w, user, "/users/42") +httpsuite.ProblemResponse(w, httpsuite.NewNotFoundProblem("user not found")) +``` + +### Fluent helpers + +```go +httpsuite.Reply(). + Meta(httpsuite.NewPageMeta(page, pageSize, totalItems)). + OK(w, users) + +httpsuite.Reply(). + Header("X-Request-ID", requestID). + Created(w, user, "/users/42") +``` + +### Builders + +```go +problem := httpsuite.Problem(http.StatusNotFound). + Type(httpsuite.GetProblemTypeURL("not_found_error")). + Title("User Not Found"). + Detail("user 42 does not exist"). + Instance("/users/42"). + Build() + +httpsuite.RespondProblem(problem). + Header("X-Trace-ID", traceID). + Write(w) +``` +## Architecture + +- root module: `github.com/rluders/httpsuite/v3` +- optional validation adapter: `github.com/rluders/httpsuite/validation/playground` +- root stays stdlib-only +- validation is opt-in at bootstrap, automatic at parse time when configured +- response metadata is generic and can use `PageMeta` or `CursorMeta` + +```mermaid +flowchart LR + A[HTTP handler] --> B[ParseRequest] + B --> C[Decode JSON body] + B --> D[Bind path params] + B --> E{validator configured?} + E -- yes --> F[Validate request] + E -- no --> G[typed request] + F --> G + G --> H[OK / Created / Reply] + G --> I[ProblemResponse / Problem builder] ``` -curl -X POST http://localhost:8080/submit/123 \ - -H "Content-Type: application/json" \ - -d '{"name":"John"}' + +```mermaid +flowchart TD + Core[httpsuite/v3 core] --> Request[request helpers] + Core --> Response[response helpers + builders] + Core --> Problem[problem details + config] + Adapter[validation/playground] -->|implements Validator| Core ``` -## πŸ“‚ Examples +## Migration from v2 to v3 + +- update imports from `github.com/rluders/httpsuite/v2` to `github.com/rluders/httpsuite/v3` +- update `ParseRequest` calls to pass `opts` before `pathParams` +- configure validation globally with `httpsuite.SetValidator(...)` or `playground.RegisterDefault()` +- `ParseRequest` now validates automatically when a validator is configured +- validator-provided `ProblemDetails.Status` is respected when valid +- use `ParseOptions.SkipValidation` to opt out per call +- use `ParseOptions.Validator` to override the global validator per call +- use `ProblemConfig` when you want custom problem type URLs + +## Examples + +Examples live in [`examples/`](/home/rluders/Projects/rluders/httpsuite/examples). + +- [`examples/stdmux`](/home/rluders/Projects/rluders/httpsuite/examples/stdmux/main.go): core-only with `http.ServeMux` +- [`examples/gorillamux`](/home/rluders/Projects/rluders/httpsuite/examples/gorillamux/main.go): path params with Gorilla Mux +- [`examples/chi`](/home/rluders/Projects/rluders/httpsuite/examples/chi/main.go): global validation with Chi +- [`examples/restapi`](/home/rluders/Projects/rluders/httpsuite/examples/restapi/main.go): fuller REST API example with pagination-style metadata and custom problems + +`examples/restapi` shows: + +- global validator setup with `playground` +- `ProblemConfig` with custom type URLs +- create, get, and list endpoints +- `PageMeta` and `CursorMeta` +- direct helpers and fluent helpers together +- custom `ProblemDetails` for domain-level `404`s + +## Notes for contributors + +- request faΓ§ade and helpers live in `request*.go` +- response faΓ§ade, helpers, builders, and write internals live in `response*.go` +- problem details, config, builders, and helpers live in `problem*.go` -Check out the `examples/` folder for a complete working project demonstrating: +## Release notes draft for `v3.0.0` -- Full request lifecycle -- Param parsing -- Validation -- ProblemDetails usage -- JSON response formatting +- root module is now stdlib-only +- validation moved to `github.com/rluders/httpsuite/validation/playground` +- request parsing supports configurable body-size limits +- problem type configuration is explicit via `ProblemConfig` +- global validator support added via `SetValidator` and `RegisterDefault` +- response metadata is generic, with optional `PageMeta` and `CursorMeta` -## πŸ“– Tutorial & Article +## Tutorial - [Improving Request Validation and Response Handling in Go Microservices](https://medium.com/@rluders/improving-request-validation-and-response-handling-in-go-microservices-cc54208123f2) -## 🀝 Contributing +## Contributing -All contributions are welcome! Whether it's a bug fix, feature proposal, or router integration example: +Contributions are welcome: -- Open an issue -- Submit a PR -- Join the discussion! +- open an issue +- submit a PR +- add a router example -## πŸͺͺ License +## License -The MIT License (MIT). Please see [License File](LICENSE) for more information. \ No newline at end of file +MIT. See [LICENSE](LICENSE). diff --git a/examples/chi/go.mod b/examples/chi/go.mod index a394eab..718a54d 100644 --- a/examples/chi/go.mod +++ b/examples/chi/go.mod @@ -4,7 +4,8 @@ go 1.23 require ( github.com/go-chi/chi/v5 v5.2.0 - github.com/rluders/httpsuite/v2 v2.1.0 + github.com/rluders/httpsuite/v3 v3.0.0 + github.com/rluders/httpsuite/validation/playground v0.0.0 ) require ( @@ -18,3 +19,7 @@ require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect ) + +replace github.com/rluders/httpsuite/v3 => ../.. + +replace github.com/rluders/httpsuite/validation/playground => ../../validation/playground diff --git a/examples/chi/go.sum b/examples/chi/go.sum index 6deb0cf..2e1428f 100644 --- a/examples/chi/go.sum +++ b/examples/chi/go.sum @@ -16,10 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= -github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= diff --git a/examples/chi/main.go b/examples/chi/main.go index 7b26757..3f994bf 100644 --- a/examples/chi/main.go +++ b/examples/chi/main.go @@ -3,7 +3,8 @@ package main import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/rluders/httpsuite/v2" + "github.com/rluders/httpsuite/v3" + "github.com/rluders/httpsuite/validation/playground" "log" "net/http" "strconv" @@ -51,14 +52,13 @@ func main() { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) - - // Define the ProblemBaseURL - doesn't create the handlers - httpsuite.SetProblemBaseURL("http://localhost:8080") + httpsuite.SetValidator(playground.NewWithValidator(nil, &httpsuite.ProblemConfig{ + BaseURL: "http://localhost:8080", + })) // Define the endpoint POST r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { - // Using the function for parameter extraction to the ParseRequest - req, err := httpsuite.ParseRequest[*SampleRequest](w, r, ChiParamExtractor, "id") + req, err := httpsuite.ParseRequest[*SampleRequest](w, r, ChiParamExtractor, nil, "id") if err != nil { log.Printf("Error parsing or validating request: %v", err) return @@ -76,5 +76,5 @@ func main() { // Starting the server log.Println("Starting server on :8080") - http.ListenAndServe(":8080", r) + log.Fatal(http.ListenAndServe(":8080", r)) } diff --git a/examples/gorillamux/go.mod b/examples/gorillamux/go.mod index a2e679f..3cc5d9c 100644 --- a/examples/gorillamux/go.mod +++ b/examples/gorillamux/go.mod @@ -4,17 +4,7 @@ go 1.23 require ( github.com/gorilla/mux v1.8.1 - github.com/rluders/httpsuite/v2 v2.1.0 + github.com/rluders/httpsuite/v3 v3.0.0 ) -require ( - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.24.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect -) +replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/examples/gorillamux/go.sum b/examples/gorillamux/go.sum index 327f7f7..7128337 100644 --- a/examples/gorillamux/go.sum +++ b/examples/gorillamux/go.sum @@ -1,32 +1,2 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= -github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= -github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/gorillamux/main.go b/examples/gorillamux/main.go index 5edaba5..0619811 100644 --- a/examples/gorillamux/main.go +++ b/examples/gorillamux/main.go @@ -2,16 +2,16 @@ package main import ( "github.com/gorilla/mux" - "github.com/rluders/httpsuite/v2" + "github.com/rluders/httpsuite/v3" "log" "net/http" "strconv" ) type SampleRequest struct { - ID int `json:"id" validate:"required"` - Name string `json:"name" validate:"required,min=3"` - Age int `json:"age" validate:"required,min=1"` + ID int `json:"id"` + Name string `json:"name"` + Age int `json:"age"` } type SampleResponse struct { @@ -42,14 +42,11 @@ func main() { // Creating the router with Gorilla Mux r := mux.NewRouter() - // Define the ProblemBaseURL - doesn't create the handlers - httpsuite.SetProblemBaseURL("http://localhost:8080") - r.HandleFunc("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { // Using the function for parameter extraction to the ParseRequest - req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, "id") + req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, nil, "id") if err != nil { - log.Printf("Error parsing or validating request: %v", err) + log.Printf("Error parsing request: %v", err) return } @@ -65,5 +62,5 @@ func main() { // Starting the server log.Println("Starting server on :8080") - http.ListenAndServe(":8080", r) + log.Fatal(http.ListenAndServe(":8080", r)) } diff --git a/examples/restapi/go.mod b/examples/restapi/go.mod new file mode 100644 index 0000000..358e0c8 --- /dev/null +++ b/examples/restapi/go.mod @@ -0,0 +1,25 @@ +module restapi_example + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/rluders/httpsuite/v3 v3.0.0 + github.com/rluders/httpsuite/validation/playground v0.0.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.24.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) + +replace github.com/rluders/httpsuite/v3 => ../.. + +replace github.com/rluders/httpsuite/validation/playground => ../../validation/playground diff --git a/examples/stdmux/go.sum b/examples/restapi/go.sum similarity index 86% rename from examples/stdmux/go.sum rename to examples/restapi/go.sum index a4eea73..2e1428f 100644 --- a/examples/stdmux/go.sum +++ b/examples/restapi/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -14,10 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rluders/httpsuite/v2 v2.1.0 h1:RV4nQo7eSQxoUB8ehowshsjYJkNXUWvzUuu2o7OBJto= -github.com/rluders/httpsuite/v2 v2.1.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= diff --git a/examples/restapi/main.go b/examples/restapi/main.go new file mode 100644 index 0000000..84fbd08 --- /dev/null +++ b/examples/restapi/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/rluders/httpsuite/v3" + "github.com/rluders/httpsuite/validation/playground" +) + +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateUserRequest struct { + Name string `json:"name" validate:"required,min=3"` + Email string `json:"email" validate:"required,email"` + Role string `json:"role" validate:"required,oneof=admin member viewer"` +} + +type GetUserRequest struct { + ID int `json:"id"` +} + +func (r *GetUserRequest) SetParam(fieldName, value string) error { + if fieldName != "id" { + return nil + } + + id, err := strconv.Atoi(value) + if err != nil { + return err + } + r.ID = id + return nil +} + +type UserStore struct { + mu sync.RWMutex + nextID int + users map[int]User +} + +func NewUserStore() *UserStore { + return &UserStore{ + nextID: 3, + users: map[int]User{ + 1: { + ID: 1, + Name: "Ada Lovelace", + Email: "ada@example.com", + Role: "admin", + CreatedAt: time.Now().Add(-72 * time.Hour), + }, + 2: { + ID: 2, + Name: "Grace Hopper", + Email: "grace@example.com", + Role: "member", + CreatedAt: time.Now().Add(-48 * time.Hour), + }, + }, + } +} + +func (s *UserStore) Create(req *CreateUserRequest) User { + s.mu.Lock() + defer s.mu.Unlock() + + user := User{ + ID: s.nextID, + Name: req.Name, + Email: req.Email, + Role: req.Role, + CreatedAt: time.Now().UTC(), + } + s.users[user.ID] = user + s.nextID++ + return user +} + +func (s *UserStore) Get(id int) (User, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + user, ok := s.users[id] + return user, ok +} + +func (s *UserStore) List() []User { + s.mu.RLock() + defer s.mu.RUnlock() + + users := make([]User, 0, len(s.users)) + for _, user := range s.users { + users = append(users, user) + } + sort.Slice(users, func(i, j int) bool { + return users[i].ID < users[j].ID + }) + return users +} + +func main() { + store := NewUserStore() + httpsuite.SetValidator(playground.NewWithValidator(nil, &httpsuite.ProblemConfig{ + BaseURL: "http://localhost:8080", + })) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Get("/users", func(w http.ResponseWriter, r *http.Request) { + page := readPositiveInt(r, "page", 1) + pageSize := readPositiveInt(r, "page_size", 2) + + users := store.List() + start := (page - 1) * pageSize + if start > len(users) { + start = len(users) + } + end := start + pageSize + if end > len(users) { + end = len(users) + } + + httpsuite.Reply(). + Meta(httpsuite.NewPageMeta(page, pageSize, len(users))). + OK(w, users[start:end]) + }) + + r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + req, err := httpsuite.ParseRequest[*GetUserRequest](w, r, chi.URLParam, nil, "id") + if err != nil { + return + } + + user, ok := store.Get(req.ID) + if !ok { + problem := httpsuite.ProblemNotFound(fmt.Sprintf("User %d does not exist", req.ID)). + Title("User Not Found"). + Instance(r.URL.Path). + Extension("request_id", middleware.GetReqID(r.Context())). + Build() + httpsuite.ProblemResponse(w, problem) + return + } + + httpsuite.OK(w, user) + }) + + r.Post("/users", func(w http.ResponseWriter, r *http.Request) { + req, err := httpsuite.ParseRequest[*CreateUserRequest](w, r, nilParamExtractor, &httpsuite.ParseOptions{ + MaxBodyBytes: 2 << 10, + }) + if err != nil { + return + } + + user := store.Create(req) + httpsuite.Reply(). + Created(w, user, fmt.Sprintf("/users/%d", user.ID)) + }) + + r.Get("/feed", func(w http.ResponseWriter, r *http.Request) { + cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) + items := []map[string]any{ + {"event": "user.created", "resource_id": 1}, + {"event": "user.updated", "resource_id": 2}, + } + + meta := httpsuite.NewCursorMeta("next-page-token", cursor, true, cursor != "") + httpsuite.Reply(). + Meta(meta). + OK(w, items) + }) + + log.Println("Starting REST API example on :8080") + log.Println("POST /users") + log.Println("GET /users?page=1&page_size=2") + log.Println("GET /users/{id}") + log.Println("GET /feed?cursor=next-page-token") + log.Fatal(http.ListenAndServe(":8080", r)) +} + +func readPositiveInt(r *http.Request, key string, fallback int) int { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return fallback + } + + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func nilParamExtractor(*http.Request, string) string { + return "" +} diff --git a/examples/stdmux/go.mod b/examples/stdmux/go.mod index f458afa..d117108 100644 --- a/examples/stdmux/go.mod +++ b/examples/stdmux/go.mod @@ -2,16 +2,6 @@ module stdmux_example go 1.23 -require github.com/rluders/httpsuite/v2 v2.1.0 +require github.com/rluders/httpsuite/v3 v3.0.0 -require ( - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.24.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect -) +replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/examples/stdmux/main.go b/examples/stdmux/main.go index 047847a..acd5fb7 100644 --- a/examples/stdmux/main.go +++ b/examples/stdmux/main.go @@ -1,16 +1,16 @@ package main import ( - "github.com/rluders/httpsuite/v2" + "github.com/rluders/httpsuite/v3" "log" "net/http" "strconv" ) type SampleRequest struct { - ID int `json:"id" validate:"required"` - Name string `json:"name" validate:"required,min=3"` - Age int `json:"age" validate:"required,min=1"` + ID int `json:"id"` + Name string `json:"name"` + Age int `json:"age"` } type SampleResponse struct { @@ -46,15 +46,12 @@ func main() { // Creating the router using the Go standard mux mux := http.NewServeMux() - // Define the ProblemBaseURL - doesn't create the handlers - httpsuite.SetProblemBaseURL("http://localhost:8080") - // Define the endpoint POST mux.HandleFunc("/submit/", func(w http.ResponseWriter, r *http.Request) { // Using the function for parameter extraction to the ParseRequest - req, err := httpsuite.ParseRequest[*SampleRequest](w, r, StdMuxParamExtractor, "id") + req, err := httpsuite.ParseRequest[*SampleRequest](w, r, StdMuxParamExtractor, nil, "id") if err != nil { - log.Printf("Error parsing or validating request: %v", err) + log.Printf("Error parsing request: %v", err) return } @@ -70,5 +67,5 @@ func main() { // Starting the server log.Println("Starting server on :8080") - http.ListenAndServe(":8080", mux) + log.Fatal(http.ListenAndServe(":8080", mux)) } diff --git a/go.mod b/go.mod index 5ce4ce4..6c3478f 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,3 @@ -module github.com/rluders/httpsuite/v2 +module github.com/rluders/httpsuite/v3 go 1.23 - -require ( - github.com/go-playground/validator/v10 v10.24.0 - github.com/stretchr/testify v1.10.0 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.work b/go.work new file mode 100644 index 0000000..7012834 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.23 + +use ( + . + ./examples/chi + ./examples/gorillamux + ./examples/restapi + ./examples/stdmux + ./validation/playground +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..23f3403 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,17 @@ +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= diff --git a/problem_builder.go b/problem_builder.go new file mode 100644 index 0000000..1c11d8f --- /dev/null +++ b/problem_builder.go @@ -0,0 +1,66 @@ +package httpsuite + +// ProblemBuilder builds ProblemDetails declaratively. +type ProblemBuilder struct { + problem *ProblemDetails +} + +// Problem starts a declarative ProblemDetails builder. +func Problem(status int) *ProblemBuilder { + return &ProblemBuilder{ + problem: NewProblemDetails(status, "", "", ""), + } +} + +// Type sets the problem type URL. +func (b *ProblemBuilder) Type(problemType string) *ProblemBuilder { + b.problem.Type = problemType + return b +} + +// Title sets the problem title. +func (b *ProblemBuilder) Title(title string) *ProblemBuilder { + b.problem.Title = title + return b +} + +// Detail sets the problem detail. +func (b *ProblemBuilder) Detail(detail string) *ProblemBuilder { + b.problem.Detail = detail + return b +} + +// Instance sets the problem instance. +func (b *ProblemBuilder) Instance(instance string) *ProblemBuilder { + b.problem.Instance = instance + return b +} + +// Extension sets a single problem extension. +func (b *ProblemBuilder) Extension(key string, value any) *ProblemBuilder { + if b.problem.Extensions == nil { + b.problem.Extensions = make(map[string]interface{}) + } + b.problem.Extensions[key] = value + return b +} + +// Extensions merges multiple problem extensions. +func (b *ProblemBuilder) Extensions(values map[string]any) *ProblemBuilder { + for key, value := range values { + b.Extension(key, value) + } + return b +} + +// Build returns the configured ProblemDetails. +func (b *ProblemBuilder) Build() *ProblemDetails { + clone := *b.problem + if b.problem.Extensions != nil { + clone.Extensions = make(map[string]interface{}, len(b.problem.Extensions)) + for key, value := range b.problem.Extensions { + clone.Extensions[key] = value + } + } + return &clone +} diff --git a/problem_builder_test.go b/problem_builder_test.go new file mode 100644 index 0000000..594ada5 --- /dev/null +++ b/problem_builder_test.go @@ -0,0 +1,35 @@ +package httpsuite + +import ( + "net/http" + "testing" +) + +func TestProblemBuilder(t *testing.T) { + t.Parallel() + + problem := Problem(http.StatusBadRequest). + Type(GetProblemTypeURL("bad_request_error")). + Title("Validation Error"). + Detail("invalid payload"). + Instance("/users"). + Extension("request_id", "req-123"). + Extensions(map[string]any{"trace_id": "trace-456"}). + Build() + + if problem.Status != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, problem.Status) + } + if problem.Type != GetProblemTypeURL("bad_request_error") { + t.Fatalf("unexpected type %q", problem.Type) + } + if problem.Instance != "/users" { + t.Fatalf("unexpected instance %q", problem.Instance) + } + if problem.Extensions["request_id"] != "req-123" { + t.Fatalf("expected request_id extension") + } + if problem.Extensions["trace_id"] != "trace-456" { + t.Fatalf("expected trace_id extension") + } +} diff --git a/problem_config.go b/problem_config.go new file mode 100644 index 0000000..a1787fd --- /dev/null +++ b/problem_config.go @@ -0,0 +1,88 @@ +package httpsuite + +import "strings" + +var defaultProblemConfig = NewProblemConfig() + +// ProblemConfig controls how problem type URLs are generated. +type ProblemConfig struct { + BaseURL string + ErrorTypePaths map[string]string +} + +// NewProblemConfig returns a config preloaded with the default problem type paths. +func NewProblemConfig() ProblemConfig { + return ProblemConfig{ + ErrorTypePaths: map[string]string{ + "validation_error": "/errors/validation-error", + "not_found_error": "/errors/not-found", + "server_error": "/errors/server-error", + "bad_request_error": "/errors/bad-request", + }, + } +} + +// DefaultProblemConfig returns a copy of the package default config. +func DefaultProblemConfig() ProblemConfig { + return defaultProblemConfig.Clone() +} + +func mergeProblemConfig(config *ProblemConfig) ProblemConfig { + merged := DefaultProblemConfig() + if config == nil { + return merged + } + + if config.BaseURL != "" { + merged.BaseURL = config.BaseURL + } + for key, value := range config.ErrorTypePaths { + merged.ErrorTypePaths[key] = value + } + return merged.Clone() +} + +// Clone returns a deep copy of the config. +func (c ProblemConfig) Clone() ProblemConfig { + clone := ProblemConfig{ + BaseURL: strings.TrimRight(c.BaseURL, "/"), + ErrorTypePaths: make(map[string]string, len(c.ErrorTypePaths)), + } + for key, value := range c.ErrorTypePaths { + clone.ErrorTypePaths[key] = normalizeProblemPath(value) + } + return clone +} + +// TypeURL builds the full type URL for a known error type. +func (c ProblemConfig) TypeURL(errorType string) string { + path, exists := c.ErrorTypePaths[errorType] + if !exists { + return BlankURL + } + + baseURL := strings.TrimRight(c.BaseURL, "/") + if baseURL == "" || baseURL == BlankURL { + return normalizeProblemPath(path) + } + + return baseURL + normalizeProblemPath(path) +} + +// GetProblemTypeURL returns the default problem type URL for a known error type. +func GetProblemTypeURL(errorType string) string { + return defaultProblemConfig.TypeURL(errorType) +} + +func normalizeProblemPath(path string) string { + if path == "" { + return BlankURL + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || path == BlankURL { + return path + } + if !strings.HasPrefix(path, "/") { + return "/" + path + } + return path +} diff --git a/problem_config_test.go b/problem_config_test.go new file mode 100644 index 0000000..d1add4c --- /dev/null +++ b/problem_config_test.go @@ -0,0 +1,40 @@ +package httpsuite + +import "testing" + +func TestProblemConfigTypeURL(t *testing.T) { + t.Parallel() + + config := ProblemConfig{ + BaseURL: "https://api.example.com/", + ErrorTypePaths: map[string]string{ + "validation_error": "errors/validation-error", + }, + } + + got := config.TypeURL("validation_error") + want := "https://api.example.com/errors/validation-error" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestProblemConfigTypeURLUnknown(t *testing.T) { + t.Parallel() + + config := NewProblemConfig() + if got := config.TypeURL("missing"); got != BlankURL { + t.Fatalf("expected %q, got %q", BlankURL, got) + } +} + +func TestDefaultProblemConfigReturnsCopy(t *testing.T) { + t.Parallel() + + config := DefaultProblemConfig() + config.ErrorTypePaths["validation_error"] = "/custom" + + if got := GetProblemTypeURL("validation_error"); got != "/errors/validation-error" { + t.Fatalf("expected default config to stay unchanged, got %q", got) + } +} diff --git a/problem_details.go b/problem_details.go index bf02c71..0b21364 100644 --- a/problem_details.go +++ b/problem_details.go @@ -1,37 +1,32 @@ package httpsuite -import ( - "net/http" - "sync" -) +import "net/http" -const BlankUrl = "about:blank" - -var ( - mu sync.RWMutex - problemBaseURL = BlankUrl - errorTypePaths = map[string]string{ - "validation_error": "/errors/validation-error", - "not_found_error": "/errors/not-found", - "server_error": "/errors/server-error", - "bad_request_error": "/errors/bad-request", - } -) +const BlankURL = "about:blank" // ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs. type ProblemDetails struct { - Type string `json:"type"` // A URI reference identifying the problem type. - Title string `json:"title"` // A short, human-readable summary of the problem. - Status int `json:"status"` // The HTTP status code. - Detail string `json:"detail,omitempty"` // Detailed explanation of the problem. - Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem. - Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details. + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` + Extensions map[string]interface{} `json:"extensions,omitempty"` +} + +// ValidationErrorDetail provides structured details about a single validation error. +type ValidationErrorDetail struct { + Field string `json:"field"` + Message string `json:"message"` } // NewProblemDetails creates a ProblemDetails instance with standard fields. func NewProblemDetails(status int, problemType, title, detail string) *ProblemDetails { + if status < 100 || status > 599 { + status = http.StatusInternalServerError + } if problemType == "" { - problemType = BlankUrl + problemType = BlankURL } if title == "" { title = http.StatusText(status) @@ -46,102 +41,3 @@ func NewProblemDetails(status int, problemType, title, detail string) *ProblemDe Detail: detail, } } - -// SetProblemBaseURL configures the base URL used in the "type" field for ProblemDetails. -// -// This function allows applications using httpsuite to provide a custom domain and structure -// for error documentation URLs. By setting this base URL, the library can generate meaningful -// and discoverable problem types. -// -// Parameters: -// - baseURL: The base URL where error documentation is hosted (e.g., "https://api.mycompany.com"). -// -// Example usage: -// -// httpsuite.SetProblemBaseURL("https://api.mycompany.com") -// -// Once configured, generated ProblemDetails will include a "type" such as: -// -// "https://api.mycompany.com/errors/validation-error" -// -// If the base URL is not set, the default value for the "type" field will be "about:blank". -func SetProblemBaseURL(baseURL string) { - mu.Lock() - defer mu.Unlock() - problemBaseURL = baseURL -} - -// SetProblemErrorTypePath sets or updates the path for a specific error type. -// -// This allows applications to define custom paths for error documentation. -// -// Parameters: -// - errorType: The unique key identifying the error type (e.g., "validation_error"). -// - path: The path under the base URL where the error documentation is located. -// -// Example usage: -// -// httpsuite.SetProblemErrorTypePath("validation_error", "/errors/validation-error") -// -// After setting this path, the generated problem type for "validation_error" will be: -// -// "https://api.mycompany.com/errors/validation-error" -func SetProblemErrorTypePath(errorType, path string) { - mu.Lock() - defer mu.Unlock() - errorTypePaths[errorType] = path -} - -// SetProblemErrorTypePaths sets or updates multiple paths for different error types. -// -// This allows applications to define multiple custom paths at once. -// -// Parameters: -// - paths: A map of error types to paths (e.g., {"validation_error": "/errors/validation-error"}). -// -// Example usage: -// -// paths := map[string]string{ -// "validation_error": "/errors/validation-error", -// "not_found_error": "/errors/not-found", -// } -// httpsuite.SetProblemErrorTypePaths(paths) -// -// This method overwrites any existing paths with the same keys. -func SetProblemErrorTypePaths(paths map[string]string) { - mu.Lock() - defer mu.Unlock() - for errorType, path := range paths { - errorTypePaths[errorType] = path - } -} - -// GetProblemTypeURL get the full problem type URL based on the error type. -// -// If the error type is not found in the predefined paths, it returns a default unknown error path. -// -// Parameters: -// - errorType: The unique key identifying the error type (e.g., "validation_error"). -// -// Example usage: -// -// problemTypeURL := GetProblemTypeURL("validation_error") -func GetProblemTypeURL(errorType string) string { - mu.RLock() - defer mu.RUnlock() - if path, exists := errorTypePaths[errorType]; exists { - return getProblemBaseURL() + path - } - - return BlankUrl -} - -// getProblemBaseURL just return the baseURL if it isn't "about:blank" -func getProblemBaseURL() string { - mu.RLock() - defer mu.RUnlock() - if problemBaseURL == BlankUrl { - return "" - } - return problemBaseURL -} diff --git a/problem_details_test.go b/problem_details_test.go index cb01a05..7d40462 100644 --- a/problem_details_test.go +++ b/problem_details_test.go @@ -1,156 +1,18 @@ package httpsuite -import ( - "testing" +import "testing" - "github.com/stretchr/testify/assert" -) +func TestNewProblemDetailsDefaults(t *testing.T) { + t.Parallel() -func Test_SetProblemBaseURL(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "Set valid base URL", - input: "https://api.example.com", - expected: "https://api.example.com", - }, - { - name: "Set base URL to blank", - input: BlankUrl, - expected: BlankUrl, - }, + details := NewProblemDetails(700, "", "", "broken") + if details.Status != 500 { + t.Fatalf("expected status 500, got %d", details.Status) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - SetProblemBaseURL(tt.input) - assert.Equal(t, tt.expected, problemBaseURL) - }) - } -} - -func Test_SetProblemErrorTypePath(t *testing.T) { - tests := []struct { - name string - errorKey string - path string - expected string - }{ - { - name: "Set custom error path", - errorKey: "custom_error", - path: "/errors/custom-error", - expected: "/errors/custom-error", - }, - { - name: "Override existing path", - errorKey: "validation_error", - path: "/errors/new-validation-error", - expected: "/errors/new-validation-error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - SetProblemErrorTypePath(tt.errorKey, tt.path) - assert.Equal(t, tt.expected, errorTypePaths[tt.errorKey]) - }) + if details.Type != BlankURL { + t.Fatalf("expected type %q, got %q", BlankURL, details.Type) } -} - -func Test_GetProblemTypeURL(t *testing.T) { - // Setup initial state - SetProblemBaseURL("https://api.example.com") - SetProblemErrorTypePath("validation_error", "/errors/validation-error") - - tests := []struct { - name string - errorType string - expectedURL string - }{ - { - name: "Valid error type", - errorType: "validation_error", - expectedURL: "https://api.example.com/errors/validation-error", - }, - { - name: "Unknown error type", - errorType: "unknown_error", - expectedURL: BlankUrl, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetProblemTypeURL(tt.errorType) - assert.Equal(t, tt.expectedURL, result) - }) - } -} - -func Test_getProblemBaseURL(t *testing.T) { - tests := []struct { - name string - baseURL string - expectedResult string - }{ - { - name: "Base URL is set", - baseURL: "https://api.example.com", - expectedResult: "https://api.example.com", - }, - { - name: "Base URL is about:blank", - baseURL: BlankUrl, - expectedResult: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - problemBaseURL = tt.baseURL - assert.Equal(t, tt.expectedResult, getProblemBaseURL()) - }) - } -} - -func Test_NewProblemDetails(t *testing.T) { - tests := []struct { - name string - status int - problemType string - title string - detail string - expectedType string - }{ - { - name: "All fields provided", - status: 400, - problemType: "https://api.example.com/errors/validation-error", - title: "Validation Error", - detail: "Invalid input", - expectedType: "https://api.example.com/errors/validation-error", - }, - { - name: "Empty problem type", - status: 404, - problemType: "", - title: "Not Found", - detail: "The requested resource was not found", - expectedType: BlankUrl, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - details := NewProblemDetails(tt.status, tt.problemType, tt.title, tt.detail) - assert.Equal(t, tt.status, details.Status) - assert.Equal(t, tt.title, details.Title) - assert.Equal(t, tt.detail, details.Detail) - assert.Equal(t, tt.expectedType, details.Type) - }) + if details.Title != "Internal Server Error" { + t.Fatalf("expected fallback title, got %q", details.Title) } } diff --git a/problem_helpers.go b/problem_helpers.go new file mode 100644 index 0000000..7179fe9 --- /dev/null +++ b/problem_helpers.go @@ -0,0 +1,29 @@ +package httpsuite + +import "net/http" + +// ProblemBadRequest returns a bad request problem builder. +func ProblemBadRequest(detail string) *ProblemBuilder { + return Problem(http.StatusBadRequest). + Type(GetProblemTypeURL("bad_request_error")). + Title("Bad Request"). + Detail(detail) +} + +// ProblemNotFound returns a not found problem builder. +func ProblemNotFound(detail string) *ProblemBuilder { + return Problem(http.StatusNotFound). + Type(GetProblemTypeURL("not_found_error")). + Title("Not Found"). + Detail(detail) +} + +// NewBadRequestProblem returns a ready-to-use bad request problem. +func NewBadRequestProblem(detail string) *ProblemDetails { + return ProblemBadRequest(detail).Build() +} + +// NewNotFoundProblem returns a ready-to-use not found problem. +func NewNotFoundProblem(detail string) *ProblemDetails { + return ProblemNotFound(detail).Build() +} diff --git a/problem_helpers_test.go b/problem_helpers_test.go new file mode 100644 index 0000000..39fe17e --- /dev/null +++ b/problem_helpers_test.go @@ -0,0 +1,34 @@ +package httpsuite + +import ( + "net/http" + "testing" +) + +func TestProblemBuilderHelpers(t *testing.T) { + t.Parallel() + + badRequest := ProblemBadRequest("invalid page").Build() + if badRequest.Status != http.StatusBadRequest { + t.Fatalf("expected bad request status, got %d", badRequest.Status) + } + + notFound := ProblemNotFound("user missing").Build() + if notFound.Status != http.StatusNotFound { + t.Fatalf("expected not found status, got %d", notFound.Status) + } +} + +func TestDirectProblemHelpers(t *testing.T) { + t.Parallel() + + badRequest := NewBadRequestProblem("invalid payload") + if badRequest.Status != http.StatusBadRequest { + t.Fatalf("expected bad request status, got %d", badRequest.Status) + } + + notFound := NewNotFoundProblem("user missing") + if notFound.Status != http.StatusNotFound { + t.Fatalf("expected not found status, got %d", notFound.Status) + } +} diff --git a/request.go b/request.go index 73a5426..a4f2ef7 100644 --- a/request.go +++ b/request.go @@ -1,122 +1,52 @@ package httpsuite import ( - "encoding/json" "errors" "net/http" - "reflect" ) -// RequestParamSetter defines the interface used to set the parameters to the HTTP request object by the request parser. -// Implementing this interface allows custom handling of URL parameters. -type RequestParamSetter interface { - // SetParam assigns a value to a specified field in the request struct. - // The fieldName parameter is the name of the field, and value is the value to set. - SetParam(fieldName, value string) error -} - -// ParamExtractor is a function type that extracts a URL parameter from the incoming HTTP request. -// It takes the `http.Request` and a `key` as arguments, and returns the value of the URL parameter -// as a string. This function allows flexibility for extracting parameters from different routers, -// such as Chi, Echo, Gorilla Mux, or the default Go router. -// -// Example usage: -// -// paramExtractor := func(r *http.Request, key string) string { -// return r.URL.Query().Get(key) -// } -type ParamExtractor func(r *http.Request, key string) string - // ParseRequest parses the incoming HTTP request into a specified struct type, -// handling JSON decoding and extracting URL parameters using the provided `paramExtractor` function. -// The `paramExtractor` allows flexibility to integrate with various routers (e.g., Chi, Echo, Gorilla Mux). -// It extracts the specified parameters from the URL and sets them on the struct. -// -// The `pathParams` variadic argument is used to specify which URL parameters to extract and set on the struct. -// -// The function also validates the parsed request. If the request fails validation or if any error occurs during -// JSON parsing or parameter extraction, it responds with an appropriate HTTP status and error message. -// -// Parameters: -// - `w`: The `http.ResponseWriter` used to send the response to the client. -// - `r`: The incoming HTTP request to be parsed. -// - `paramExtractor`: A function that extracts URL parameters from the request. This function allows custom handling -// of parameters based on the router being used. -// - `pathParams`: A variadic argument specifying which URL parameters to extract and set on the struct. -// -// Returns: -// - A parsed struct of the specified type `T`, if successful. -// - An error, if parsing, validation, or parameter extraction fails. -// -// Example usage: -// -// request, err := ParseRequest[MyRequestType](w, r, MyParamExtractor, "id", "name") -// if err != nil { -// // Handle error -// } -// -// // Continue processing the valid request... -func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, paramExtractor ParamExtractor, pathParams ...string) (T, error) { - var request T +// handling JSON decoding, request body limits, path parameter binding, and +// optional validation. Invalid inputs return regular errors instead of panicking. +func ParseRequest[T any](w http.ResponseWriter, r *http.Request, paramExtractor ParamExtractor, opts *ParseOptions, pathParams ...string) (T, error) { var empty T - defer func() { _ = r.Body.Close() }() - - // Decode JSON body if present - if r.Body != http.NoBody { - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - problem := NewProblemDetails( - http.StatusBadRequest, - GetProblemTypeURL("bad_request_error"), - "Invalid Request", - err.Error(), - ) - SendResponse[any](w, http.StatusBadRequest, nil, problem, nil) - return empty, err - } + if r == nil { + return empty, errNilHTTPRequest } - - // Ensure request object is properly initialized - if isRequestNil(request) { - request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T) + if r.Body != nil { + defer func() { _ = r.Body.Close() }() } - // Extract and set URL parameters - for _, key := range pathParams { - value := paramExtractor(r, key) - if value == "" { - problem := NewProblemDetails( - http.StatusBadRequest, - GetProblemTypeURL("bad_request_error"), - "Missing Parameter", - "Parameter "+key+" not found in request", - ) - SendResponse[any](w, http.StatusBadRequest, nil, problem, nil) - return empty, errors.New("missing parameter: " + key) + options := normalizeParseOptions(opts) + + request, err := DecodeRequestBody[T](r, options.MaxBodyBytes) + if err != nil { + var decodeErr *BodyDecodeError + if !errors.As(err, &decodeErr) { + return empty, err } + problem, status := problemFromDecodeError(err, options.Problems) + SendResponse[any](w, status, nil, problem, nil) + return empty, err + } - if err := request.SetParam(key, value); err != nil { - problem := NewProblemDetails( - http.StatusInternalServerError, - GetProblemTypeURL("server_error"), - "Parameter Error", - "Failed to set field "+key, - ) - problem.Extensions = map[string]interface{}{"error": err.Error()} - SendResponse[any](w, http.StatusInternalServerError, nil, problem, nil) + request, err = BindPathParams(request, r, paramExtractor, pathParams...) + if err != nil { + var pathErr *PathParamError + if !errors.As(err, &pathErr) { return empty, err } + problem, status := problemFromPathParamError(err, options.Problems) + SendResponse[any](w, status, nil, problem, nil) + return empty, err } - // Validate the request - if validationErr := IsRequestValid(request); validationErr != nil { - SendResponse[any](w, http.StatusBadRequest, nil, validationErr, nil) - return empty, errors.New("validation error") + if !options.SkipValidation { + if problem := ValidateRequest(request, options.Validator); problem != nil { + SendResponse[any](w, validationProblemStatus(problem), nil, problem, nil) + return empty, errValidationFailed + } } return request, nil } - -// isRequestNil checks if a request object is nil or an uninitialized pointer. -func isRequestNil(i interface{}) bool { - return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) -} diff --git a/request_bind.go b/request_bind.go new file mode 100644 index 0000000..c79572a --- /dev/null +++ b/request_bind.go @@ -0,0 +1,77 @@ +package httpsuite + +import ( + "errors" + "fmt" + "net/http" +) + +// PathParamError represents a path parameter binding error. +type PathParamError struct { + Param string + Missing bool + Err error +} + +func (e *PathParamError) Error() string { + if e.Missing { + return "missing parameter: " + e.Param + } + if e.Err != nil { + return fmt.Sprintf("invalid parameter %s: %v", e.Param, e.Err) + } + return "invalid parameter: " + e.Param +} + +func (e *PathParamError) Unwrap() error { + return e.Err +} + +// BindPathParams applies extracted path params to a request object without writing HTTP responses. +func BindPathParams[T any](request T, r *http.Request, paramExtractor ParamExtractor, pathParams ...string) (T, error) { + if len(pathParams) == 0 { + return request, nil + } + if r == nil { + var empty T + return empty, errNilHTTPRequest + } + if paramExtractor == nil { + var empty T + return empty, errNilParamExtractor + } + + var err error + request, err = ensureRequestInitialized(request) + if err != nil { + var empty T + return empty, err + } + + setter, ok := any(request).(RequestParamSetter) + if !ok { + var empty T + return empty, errors.Join(errInvalidRequestType, errors.New("request type does not implement RequestParamSetter")) + } + + for _, key := range pathParams { + value := paramExtractor(r, key) + if value == "" { + var empty T + return empty, &PathParamError{ + Param: key, + Missing: true, + } + } + + if err := setter.SetParam(key, value); err != nil { + var empty T + return empty, &PathParamError{ + Param: key, + Err: err, + } + } + } + + return request, nil +} diff --git a/request_bind_test.go b/request_bind_test.go new file mode 100644 index 0000000..a99c962 --- /dev/null +++ b/request_bind_test.go @@ -0,0 +1,77 @@ +package httpsuite + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBindPathParams(t *testing.T) { + t.Parallel() + + t.Run("nil request pointer target", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test/123", nil) + got, err := BindPathParams[*testRequest](nil, req, testParamExtractor, "id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.ID != 123 { + t.Fatalf("expected initialized request target, got %#v", got) + } + }) + + t.Run("unsupported request target", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test/123", nil) + _, err := BindPathParams[bodyOnlyRequest](bodyOnlyRequest{}, req, testParamExtractor, "id") + if err == nil { + t.Fatal("expected error, got nil") + } + if errors.Is(err, errNilHTTPRequest) || errors.Is(err, errNilParamExtractor) { + t.Fatalf("expected unsupported target error, got %v", err) + } + }) + + t.Run("nil extractor", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test/123", nil) + _, err := BindPathParams[*testRequest](&testRequest{}, req, nil, "id") + if !errors.Is(err, errNilParamExtractor) { + t.Fatalf("expected nil extractor error, got %v", err) + } + }) + + t.Run("valid parameter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test/123", nil) + got, err := BindPathParams[*testRequest](&testRequest{Name: "ok"}, req, testParamExtractor, "id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ID != 123 { + t.Fatalf("expected id 123, got %d", got.ID) + } + }) + + t.Run("missing parameter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + _, err := BindPathParams[*testRequest](&testRequest{}, req, testParamExtractor, "id") + var pathErr *PathParamError + if !errors.As(err, &pathErr) { + t.Fatalf("expected PathParamError, got %v", err) + } + if !pathErr.Missing { + t.Fatalf("expected missing parameter error") + } + }) + + t.Run("invalid parameter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test/nope", nil) + _, err := BindPathParams[*testRequest](&testRequest{}, req, testParamExtractor, "id") + var pathErr *PathParamError + if !errors.As(err, &pathErr) { + t.Fatalf("expected PathParamError, got %v", err) + } + if pathErr.Missing { + t.Fatalf("expected invalid parameter, got missing") + } + }) +} diff --git a/request_decode.go b/request_decode.go new file mode 100644 index 0000000..b41d337 --- /dev/null +++ b/request_decode.go @@ -0,0 +1,106 @@ +package httpsuite + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// BodyDecodeErrorKind identifies the decode failure category. +type BodyDecodeErrorKind string + +const ( + BodyDecodeErrorInvalidJSON BodyDecodeErrorKind = "invalid_json" + BodyDecodeErrorBodyTooLarge BodyDecodeErrorKind = "body_too_large" + BodyDecodeErrorMultipleDocuments BodyDecodeErrorKind = "multiple_documents" +) + +// BodyDecodeError represents a request body parsing error. +type BodyDecodeError struct { + Kind BodyDecodeErrorKind + Err error + Limit int64 +} + +func (e *BodyDecodeError) Error() string { + switch e.Kind { + case BodyDecodeErrorBodyTooLarge: + return fmt.Sprintf("request body exceeds the limit of %d bytes", e.Limit) + case BodyDecodeErrorMultipleDocuments: + return "request body must contain a single JSON document" + default: + if e.Err != nil { + return e.Err.Error() + } + return "invalid request body" + } +} + +func (e *BodyDecodeError) Unwrap() error { + return e.Err +} + +// DecodeRequestBody decodes a JSON request body into T without writing HTTP responses. +func DecodeRequestBody[T any](r *http.Request, maxBodyBytes int64) (T, error) { + var request T + if r == nil { + return request, errNilHTTPRequest + } + if r.Body == nil { + return request, errNilRequestBody + } + if r.Body == http.NoBody { + return request, nil + } + + limit := maxBodyBytes + if limit <= 0 { + limit = defaultMaxBodyBytes + } + + body := http.MaxBytesReader(nilResponseWriter{}, r.Body, limit) + decoder := json.NewDecoder(body) + + if err := decoder.Decode(&request); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return request, &BodyDecodeError{ + Kind: BodyDecodeErrorBodyTooLarge, + Err: err, + Limit: maxBytesErr.Limit, + } + } + + return request, &BodyDecodeError{ + Kind: BodyDecodeErrorInvalidJSON, + Err: err, + } + } + + var trailing json.RawMessage + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + err = errors.New("request body must contain a single JSON document") + } + return request, &BodyDecodeError{ + Kind: BodyDecodeErrorMultipleDocuments, + Err: err, + } + } + + return request, nil +} + +type nilResponseWriter struct{} + +func (nilResponseWriter) Header() http.Header { + return make(http.Header) +} + +func (nilResponseWriter) Write([]byte) (int, error) { + return 0, nil +} + +func (nilResponseWriter) WriteHeader(int) {} diff --git a/request_decode_test.go b/request_decode_test.go new file mode 100644 index 0000000..b234599 --- /dev/null +++ b/request_decode_test.go @@ -0,0 +1,77 @@ +package httpsuite + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDecodeRequestBody(t *testing.T) { + t.Parallel() + + t.Run("nil request", func(t *testing.T) { + _, err := DecodeRequestBody[*testRequest](nil, defaultMaxBodyBytes) + if !errors.Is(err, errNilHTTPRequest) { + t.Fatalf("expected nil request error, got %v", err) + } + }) + + t.Run("nil body", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Body = nil + + _, err := DecodeRequestBody[*testRequest](req, defaultMaxBodyBytes) + if !errors.Is(err, errNilRequestBody) { + t.Fatalf("expected nil body error, got %v", err) + } + }) + + t.Run("valid body", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(`{"id":42,"name":"OnlyBody"}`)) + got, err := DecodeRequestBody[*testRequest](req, defaultMaxBodyBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.ID != 42 || got.Name != "OnlyBody" { + t.Fatalf("unexpected decoded request: %#v", got) + } + }) + + t.Run("invalid json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(`{invalid-json}`)) + _, err := DecodeRequestBody[*testRequest](req, defaultMaxBodyBytes) + var decodeErr *BodyDecodeError + if !errors.As(err, &decodeErr) { + t.Fatalf("expected BodyDecodeError, got %v", err) + } + if decodeErr.Kind != BodyDecodeErrorInvalidJSON { + t.Fatalf("expected invalid json error, got %s", decodeErr.Kind) + } + }) + + t.Run("body too large", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewBufferString(`{"name":"TooLarge"}`)) + _, err := DecodeRequestBody[*testRequest](req, 8) + var decodeErr *BodyDecodeError + if !errors.As(err, &decodeErr) { + t.Fatalf("expected BodyDecodeError, got %v", err) + } + if decodeErr.Kind != BodyDecodeErrorBodyTooLarge { + t.Fatalf("expected body too large error, got %s", decodeErr.Kind) + } + }) +} + +func BenchmarkParseRequestBody(b *testing.B) { + body := []byte(`{"id":42,"name":"OnlyBody"}`) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(body)) + if _, err := DecodeRequestBody[*testRequest](req, defaultMaxBodyBytes); err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} diff --git a/request_internal.go b/request_internal.go new file mode 100644 index 0000000..59fe4c3 --- /dev/null +++ b/request_internal.go @@ -0,0 +1,151 @@ +package httpsuite + +import ( + "errors" + "net/http" + "reflect" +) + +var ( + errNilHTTPRequest = errors.New("nil http request") + errNilRequestBody = errors.New("nil request body") + errNilParamExtractor = errors.New("nil param extractor") + errInvalidRequestType = errors.New("invalid request target") + errValidationFailed = errors.New("validation error") +) + +func normalizeParseOptions(opts *ParseOptions) ParseOptions { + normalized := ParseOptions{ + MaxBodyBytes: defaultMaxBodyBytes, + Problems: nil, + Validator: DefaultValidator(), + } + if opts != nil { + if opts.MaxBodyBytes > 0 { + normalized.MaxBodyBytes = opts.MaxBodyBytes + } + if opts.Problems != nil { + problems := mergeProblemConfig(opts.Problems) + normalized.Problems = &problems + } + if opts.Validator != nil { + normalized.Validator = opts.Validator + } + normalized.SkipValidation = opts.SkipValidation + } + if normalized.Problems == nil { + problems := DefaultProblemConfig() + normalized.Problems = &problems + } + return normalized +} + +func problemFromDecodeError(err error, problems *ProblemConfig) (*ProblemDetails, int) { + status := http.StatusBadRequest + var decodeErr *BodyDecodeError + if errors.As(err, &decodeErr) { + switch decodeErr.Kind { + case BodyDecodeErrorBodyTooLarge: + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Request Body Too Large", + decodeErr.Error(), + ), status + case BodyDecodeErrorMultipleDocuments: + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Invalid Request", + "Request body must contain a single JSON document", + ), status + default: + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Invalid Request", + decodeErr.Error(), + ), status + } + } + + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Invalid Request", + err.Error(), + ), status +} + +func problemFromPathParamError(err error, problems *ProblemConfig) (*ProblemDetails, int) { + status := http.StatusBadRequest + var pathErr *PathParamError + if errors.As(err, &pathErr) { + if pathErr.Missing { + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Missing Parameter", + "Parameter "+pathErr.Param+" not found in request", + ), status + } + + problem := NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Invalid Parameter", + "Failed to bind parameter "+pathErr.Param, + ) + if pathErr.Err != nil { + problem.Extensions = map[string]interface{}{"error": pathErr.Err.Error()} + } + return problem, status + } + + return NewProblemDetails( + status, + problems.TypeURL("bad_request_error"), + "Invalid Parameter", + err.Error(), + ), status +} + +func isRequestNil(i interface{}) bool { + if i == nil { + return true + } + + value := reflect.ValueOf(i) + return value.Kind() == reflect.Ptr && value.IsNil() +} + +func ensureRequestInitialized[T any](request T) (T, error) { + if !isRequestNil(request) { + return request, nil + } + + value := reflect.ValueOf(request) + if !value.IsValid() || value.Kind() != reflect.Ptr { + var empty T + return empty, errInvalidRequestType + } + + elem := value.Type().Elem() + if elem == nil { + var empty T + return empty, errInvalidRequestType + } + + request = reflect.New(elem).Interface().(T) + return request, nil +} + +func validationProblemStatus(problem *ProblemDetails) int { + if problem == nil { + return http.StatusBadRequest + } + if problem.Status >= 400 && problem.Status <= 599 { + return problem.Status + } + return http.StatusBadRequest +} diff --git a/request_test.go b/request_test.go index 50931b5..dd62389 100644 --- a/request_test.go +++ b/request_test.go @@ -4,160 +4,369 @@ import ( "bytes" "encoding/json" "errors" - "fmt" - "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" - "strconv" "strings" "testing" ) -// TestRequest includes custom type annotation for UUID type. -type TestRequest struct { - ID int `json:"id" validate:"required,gt=0"` - Name string `json:"name" validate:"required"` -} +func TestParseRequest(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) -func (r *TestRequest) SetParam(fieldName, value string) error { - switch strings.ToLower(fieldName) { - case "id": - id, err := strconv.Atoi(value) - if err != nil { - return errors.New("invalid id") - } - r.ID = id - default: - fmt.Printf("Parameter %s cannot be set", fieldName) - } - return nil -} + tests := []struct { + name string + body string + path string + pathParams []string + opts *ParseOptions + want *testRequest + wantErr bool + wantStatus int + wantTitle string + wantDetailContains string + }{ + { + name: "successful request", + body: `{"name":"Test"}`, + path: "/test/123", + pathParams: []string{"id"}, + want: &testRequest{ID: 123, Name: "Test"}, + }, + { + name: "body only request", + body: `{"id":42,"name":"OnlyBody"}`, + path: "/test", + pathParams: nil, + want: &testRequest{ID: 42, Name: "OnlyBody"}, + }, + { + name: "invalid json body", + body: `{invalid-json}`, + path: "/test/123", + pathParams: []string{"id"}, + wantErr: true, + wantStatus: http.StatusBadRequest, + wantTitle: "Invalid Request", + wantDetailContains: "invalid character", + }, + { + name: "multiple json documents", + body: `{"name":"Test"}{"name":"Again"}`, + path: "/test/123", + pathParams: []string{"id"}, + wantErr: true, + wantStatus: http.StatusBadRequest, + wantTitle: "Invalid Request", + wantDetailContains: "single JSON document", + }, + { + name: "missing parameter", + body: `{"name":"Test"}`, + path: "/test", + pathParams: []string{"id"}, + wantErr: true, + wantStatus: http.StatusBadRequest, + wantTitle: "Missing Parameter", + wantDetailContains: "Parameter id not found", + }, + { + name: "invalid parameter", + body: `{"name":"Test"}`, + path: "/test/nope", + pathParams: []string{"id"}, + wantErr: true, + wantStatus: http.StatusBadRequest, + wantTitle: "Invalid Parameter", + wantDetailContains: "Failed to bind parameter id", + }, + { + name: "body exceeds configured limit", + body: `{"name":"TooLarge"}`, + path: "/test/123", + pathParams: []string{"id"}, + opts: &ParseOptions{MaxBodyBytes: 8}, + wantErr: true, + wantStatus: http.StatusBadRequest, + wantTitle: "Request Body Too Large", + wantDetailContains: "exceeds the limit", + }, + { + name: "custom problem config", + body: `{"name":"Test"}`, + path: "/test/123", + pathParams: []string{"id"}, + opts: &ParseOptions{ + Problems: &ProblemConfig{ + BaseURL: "https://api.example.com", + ErrorTypePaths: map[string]string{ + "bad_request_error": "/errors/bad-request", + "server_error": "/errors/server-error", + }, + }, + }, + want: &testRequest{ID: 123, Name: "Test"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var body *bytes.Buffer + if tt.body != "" { + body = bytes.NewBufferString(tt.body) + } else { + body = bytes.NewBuffer(nil) + } + + req := httptest.NewRequest(http.MethodPost, tt.path, body) + w := httptest.NewRecorder() + + got, err := ParseRequest[*testRequest](w, req, testParamExtractor, tt.opts, tt.pathParams...) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + + if w.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d", tt.wantStatus, w.Code) + } + + var problem ProblemDetails + if decodeErr := json.NewDecoder(w.Body).Decode(&problem); decodeErr != nil { + t.Fatalf("decode problem details: %v", decodeErr) + } + if problem.Title != tt.wantTitle { + t.Fatalf("expected title %q, got %q", tt.wantTitle, problem.Title) + } + if !strings.Contains(problem.Detail, tt.wantDetailContains) { + t.Fatalf("expected detail %q to contain %q", problem.Detail, tt.wantDetailContains) + } + return + } -// MyParamExtractor extracts parameters from the path, assuming the request URL follows a pattern like "/test/{id}". -func MyParamExtractor(r *http.Request, key string) string { - pathSegments := strings.Split(r.URL.Path, "/") - if len(pathSegments) > 2 && key == "ID" { - return pathSegments[2] + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatalf("expected request, got nil") + } + if *got != *tt.want { + t.Fatalf("expected %+v, got %+v", *tt.want, *got) + } + }) } - return "" } -func Test_ParseRequest(t *testing.T) { - type args struct { - w http.ResponseWriter - r *http.Request - pathParams []string +func TestParseRequestWithoutRequestParamSetter(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBufferString(`{"name":"Ada","age":36}`)) + w := httptest.NewRecorder() + + got, err := ParseRequest[*bodyOnlyRequest](w, req, testParamExtractor, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - type testCase[T any] struct { - name string - args args - want *TestRequest - wantErr assert.ErrorAssertionFunc - wantDetail *ProblemDetails + if got == nil { + t.Fatal("expected parsed request, got nil") } + if got.Name != "Ada" || got.Age != 36 { + t.Fatalf("unexpected parsed request: %#v", got) + } +} + +func TestParseRequestInvalidInputs(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) - tests := []testCase[TestRequest]{ + tests := []struct { + name string + makeReq func() *http.Request + extractor ParamExtractor + pathParams []string + wantErr error + }{ { - name: "Successful Request", - args: args{ - w: httptest.NewRecorder(), - r: func() *http.Request { - body, _ := json.Marshal(TestRequest{Name: "Test"}) - req := httptest.NewRequest("POST", "/test/123", bytes.NewBuffer(body)) - req.URL.Path = "/test/123" - return req - }(), - pathParams: []string{"ID"}, - }, - want: &TestRequest{ID: 123, Name: "Test"}, - wantErr: assert.NoError, - wantDetail: nil, + name: "nil request", + makeReq: func() *http.Request { return nil }, + extractor: testParamExtractor, + wantErr: errNilHTTPRequest, }, { - name: "Body Only - No URL Params", - args: args{ - w: httptest.NewRecorder(), - r: func() *http.Request { - body, _ := json.Marshal(TestRequest{ID: 42, Name: "OnlyBody"}) - req := httptest.NewRequest("POST", "/test", bytes.NewBuffer(body)) - return req - }(), - pathParams: []string{}, + name: "nil body", + makeReq: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test/123", nil) + req.Body = nil + return req }, - want: &TestRequest{ID: 42, Name: "OnlyBody"}, - wantErr: assert.NoError, + extractor: testParamExtractor, + wantErr: errNilRequestBody, }, { - name: "Body Only - Nil Path Params", - args: args{ - w: httptest.NewRecorder(), - r: func() *http.Request { - body, _ := json.Marshal(TestRequest{ID: 7, Name: "NilPathParams"}) - req := httptest.NewRequest("POST", "/test", bytes.NewBuffer(body)) - return req - }(), - pathParams: nil, + name: "nil extractor with params", + makeReq: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/test/123", bytes.NewBufferString(`{"name":"Test"}`)) }, - want: &TestRequest{ID: 7, Name: "NilPathParams"}, - wantErr: assert.NoError, + pathParams: []string{"id"}, + wantErr: errNilParamExtractor, }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + got, err := ParseRequest[*testRequest](w, tt.makeReq(), tt.extractor, nil, tt.pathParams...) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + if got != nil { + t.Fatalf("expected nil request, got %#v", got) + } + if w.Body.Len() != 0 { + t.Fatalf("expected no response body to be written, got %q", w.Body.String()) + } + }) + } +} + +func TestParseRequestUsesDefaultValidator(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + problem := &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "One or more fields failed validation.", + } + SetValidator(stubValidator{problem: problem}) + + req := httptest.NewRequest(http.MethodPost, "/test/123", bytes.NewBufferString(`{"name":""}`)) + w := httptest.NewRecorder() + + got, err := ParseRequest[*testRequest](w, req, testParamExtractor, nil, "id") + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if got != nil { + t.Fatalf("expected nil request, got %#v", got) + } + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestParseRequestValidatorOverride(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + SetValidator(stubValidator{ + problem: &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "global validator failed", + }, + }) + + override := stubValidator{ + problem: &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "override validator failed", + }, + } + + req := httptest.NewRequest(http.MethodPost, "/test/123", bytes.NewBufferString(`{"name":"ok"}`)) + w := httptest.NewRecorder() + + _, err := ParseRequest[*testRequest](w, req, testParamExtractor, &ParseOptions{Validator: override}, "id") + if err == nil { + t.Fatalf("expected validation error, got nil") + } + + var problem ProblemDetails + if decodeErr := json.NewDecoder(w.Body).Decode(&problem); decodeErr != nil { + t.Fatalf("decode problem details: %v", decodeErr) + } + if problem.Detail != "override validator failed" { + t.Fatalf("expected override validator detail, got %q", problem.Detail) + } +} + +func TestParseRequestValidationStatus(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + tests := []struct { + name string + problem *ProblemDetails + wantStatus int + }{ { - name: "Missing body", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("POST", "/test/123", nil), - pathParams: []string{"ID"}, + name: "custom status preserved", + problem: &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusUnprocessableEntity, + Detail: "unprocessable payload", }, - want: nil, - wantErr: assert.Error, - wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Validation Error", - "One or more fields failed validation."), + wantStatus: http.StatusUnprocessableEntity, }, { - name: "Invalid JSON Body", - args: args{ - w: httptest.NewRecorder(), - r: func() *http.Request { - req := httptest.NewRequest("POST", "/test/123", bytes.NewBufferString("{invalid-json}")) - req.URL.Path = "/test/123" - return req - }(), - pathParams: []string{"ID"}, + name: "invalid status falls back to bad request", + problem: &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: 0, + Detail: "bad payload", }, - want: nil, - wantErr: assert.Error, - wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Invalid Request", - "invalid character 'i' looking for beginning of object key string"), + wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Call the function under test. - w := tt.args.w - got, err := ParseRequest[*TestRequest](w, tt.args.r, MyParamExtractor, tt.args.pathParams...) + SetValidator(stubValidator{problem: tt.problem}) - // Validate the error response if applicable. - if !tt.wantErr(t, err, fmt.Sprintf("parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)) { - return - } + req := httptest.NewRequest(http.MethodPost, "/test/123", bytes.NewBufferString(`{"name":"ok"}`)) + w := httptest.NewRecorder() - // Check ProblemDetails if an error was expected. - if tt.wantDetail != nil { - rec := w.(*httptest.ResponseRecorder) - assert.Equal(t, "application/problem+json; charset=utf-8", - rec.Header().Get("Content-Type"), "Content-Type header mismatch") - - var pd ProblemDetails - decodeErr := json.NewDecoder(rec.Body).Decode(&pd) - assert.NoError(t, decodeErr, "Failed to decode problem details response") - assert.Equal(t, tt.wantDetail.Title, pd.Title, "Problem detail title mismatch") - assert.Equal(t, tt.wantDetail.Status, pd.Status, "Problem detail status mismatch") - assert.Contains(t, pd.Detail, tt.wantDetail.Detail, "Problem detail message mismatch") + _, err := ParseRequest[*testRequest](w, req, testParamExtractor, nil, "id") + if !errors.Is(err, errValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } + if w.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d", tt.wantStatus, w.Code) } - - // Validate successful response. - assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", w, tt.args.r, tt.args.pathParams) }) } } + +func TestParseRequestSkipValidation(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + SetValidator(stubValidator{ + problem: &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "global validator failed", + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/test/123", bytes.NewBufferString(`{"name":"ok"}`)) + w := httptest.NewRecorder() + + got, err := ParseRequest[*testRequest](w, req, testParamExtractor, &ParseOptions{SkipValidation: true}, "id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.ID != 123 { + t.Fatalf("expected parsed request, got %#v", got) + } +} diff --git a/request_test_helpers_test.go b/request_test_helpers_test.go new file mode 100644 index 0000000..69fe559 --- /dev/null +++ b/request_test_helpers_test.go @@ -0,0 +1,49 @@ +package httpsuite + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" +) + +type testRequest struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type bodyOnlyRequest struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func (r *testRequest) SetParam(fieldName, value string) error { + switch strings.ToLower(fieldName) { + case "id": + id, err := strconv.Atoi(value) + if err != nil { + return errors.New("invalid id") + } + r.ID = id + default: + return fmt.Errorf("parameter %s cannot be set", fieldName) + } + return nil +} + +func testParamExtractor(r *http.Request, key string) string { + pathSegments := strings.Split(r.URL.Path, "/") + if len(pathSegments) > 2 && strings.EqualFold(key, "id") { + return pathSegments[2] + } + return "" +} + +type stubValidator struct { + problem *ProblemDetails +} + +func (s stubValidator) Validate(any) *ProblemDetails { + return s.problem +} diff --git a/request_types.go b/request_types.go new file mode 100644 index 0000000..4b35c20 --- /dev/null +++ b/request_types.go @@ -0,0 +1,26 @@ +package httpsuite + +import "net/http" + +// RequestParamSetter defines custom path parameter binding for request structs. +type RequestParamSetter interface { + SetParam(fieldName, value string) error +} + +// ParamExtractor extracts a path parameter from a request. +type ParamExtractor func(r *http.Request, key string) string + +// Validator validates request payloads without coupling the core package to a validation library. +type Validator interface { + Validate(any) *ProblemDetails +} + +// ParseOptions configures request parsing behavior. +type ParseOptions struct { + MaxBodyBytes int64 + Problems *ProblemConfig + Validator Validator + SkipValidation bool +} + +const defaultMaxBodyBytes int64 = 1 << 20 diff --git a/request_validate.go b/request_validate.go new file mode 100644 index 0000000..e8afae3 --- /dev/null +++ b/request_validate.go @@ -0,0 +1,35 @@ +package httpsuite + +import "sync" + +var ( + defaultValidatorMu sync.RWMutex + defaultValidator Validator +) + +// ValidateRequest applies a validator without writing HTTP responses. +func ValidateRequest(request any, validator Validator) *ProblemDetails { + if validator == nil { + return nil + } + return validator.Validate(request) +} + +// SetValidator configures the package-level default validator used by ParseRequest. +func SetValidator(v Validator) { + defaultValidatorMu.Lock() + defer defaultValidatorMu.Unlock() + defaultValidator = v +} + +// ClearValidator removes the package-level default validator. +func ClearValidator() { + SetValidator(nil) +} + +// DefaultValidator returns the current package-level default validator. +func DefaultValidator() Validator { + defaultValidatorMu.RLock() + defer defaultValidatorMu.RUnlock() + return defaultValidator +} diff --git a/request_validate_test.go b/request_validate_test.go new file mode 100644 index 0000000..666c243 --- /dev/null +++ b/request_validate_test.go @@ -0,0 +1,102 @@ +package httpsuite + +import ( + "net/http" + "sync" + "testing" +) + +func TestValidateRequest(t *testing.T) { + t.Parallel() + + problem := &ProblemDetails{ + Type: GetProblemTypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "One or more fields failed validation.", + } + + if got := ValidateRequest(&testRequest{}, nil); got != nil { + t.Fatalf("expected nil validation problem, got %#v", got) + } + if got := ValidateRequest(&testRequest{}, stubValidator{problem: problem}); got != problem { + t.Fatalf("expected validation problem to be returned") + } +} + +func TestValidationProblemStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + problem *ProblemDetails + want int + }{ + { + name: "nil problem", + want: http.StatusBadRequest, + }, + { + name: "valid status", + problem: &ProblemDetails{ + Status: http.StatusUnprocessableEntity, + }, + want: http.StatusUnprocessableEntity, + }, + { + name: "invalid status", + problem: &ProblemDetails{ + Status: 0, + }, + want: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validationProblemStatus(tt.problem); got != tt.want { + t.Fatalf("expected status %d, got %d", tt.want, got) + } + }) + } +} + +func TestDefaultValidatorLifecycle(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + if DefaultValidator() != nil { + t.Fatalf("expected nil default validator") + } + + validator := stubValidator{} + SetValidator(validator) + if DefaultValidator() == nil { + t.Fatalf("expected default validator to be set") + } + + ClearValidator() + if DefaultValidator() != nil { + t.Fatalf("expected default validator to be cleared") + } +} + +func TestDefaultValidatorConcurrentAccess(t *testing.T) { + ClearValidator() + t.Cleanup(ClearValidator) + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + SetValidator(stubValidator{}) + } else { + _ = DefaultValidator() + ClearValidator() + } + }(i) + } + wg.Wait() +} diff --git a/response.go b/response.go index 05cdf3c..6ff6571 100644 --- a/response.go +++ b/response.go @@ -1,76 +1,13 @@ package httpsuite -import ( - "bytes" - "encoding/json" - "log" - "net/http" -) +import "net/http" -// Response represents the structure of an HTTP response, including a status code, message, and optional body. -// T represents the type of the `Data` field, allowing this structure to be used flexibly across different endpoints. -type Response[T any] struct { - Data T `json:"data,omitempty"` - Meta *Meta `json:"meta,omitempty"` -} - -// Meta provides additional information about the response, such as pagination details. -type Meta struct { - Page int `json:"page,omitempty"` - PageSize int `json:"page_size,omitempty"` - TotalPages int `json:"total_pages,omitempty"` - TotalItems int `json:"total_items,omitempty"` +// Reply starts a fluent response helper configuration. +func Reply() *ReplyBuilder { + return &ReplyBuilder{} } // SendResponse sends a JSON response to the client, supporting both success and error scenarios. -// -// Parameters: -// - w: The http.ResponseWriter to send the response. -// - code: HTTP status code to indicate success or failure. -// - data: The main payload of the response (only for successful responses). -// - problem: An optional ProblemDetails struct (used for error responses). -// - meta: Optional metadata for successful responses (e.g., pagination details). -func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta *Meta) { - - // Handle error responses - if code >= 400 && problem != nil { - writeProblemDetail(w, code, problem) - return - } - - // Construct and encode the success response - response := &Response[T]{ - Data: data, - Meta: meta, - } - - var buffer bytes.Buffer - if err := json.NewEncoder(&buffer).Encode(response); err != nil { - log.Printf("Error writing response: %v", err) - - // Internal server error fallback using ProblemDetails - internalError := NewProblemDetails( - http.StatusInternalServerError, - GetProblemTypeURL("server_error"), - "Internal Server Error", - err.Error(), - ) - writeProblemDetail(w, http.StatusInternalServerError, internalError) - return - } - - // Send the success response - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - if _, err := w.Write(buffer.Bytes()); err != nil { - log.Printf("Failed to write response body (status=%d): %v", code, err) - } -} - -func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails) { - w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") - w.WriteHeader(problem.Status) - if err := json.NewEncoder(w).Encode(problem); err != nil { - log.Printf("Failed to encode problem details: %v", err) - } +func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta any) { + writeResponse(w, code, data, problem, meta, nil) } diff --git a/response_benchmark_test.go b/response_benchmark_test.go new file mode 100644 index 0000000..b84edef --- /dev/null +++ b/response_benchmark_test.go @@ -0,0 +1,17 @@ +package httpsuite + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func BenchmarkSendResponse(b *testing.B) { + payload := testResponse{Key: "value"} + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + SendResponse(w, http.StatusOK, payload, nil, nil) + } +} diff --git a/response_builder.go b/response_builder.go new file mode 100644 index 0000000..005d17e --- /dev/null +++ b/response_builder.go @@ -0,0 +1,118 @@ +package httpsuite + +import "net/http" + +// ReplyBuilder configures metadata and headers before writing a response. +type ReplyBuilder struct { + meta any + headers http.Header +} + +// ResponseBuilder builds and writes HTTP responses declaratively. +type ResponseBuilder[T any] struct { + code int + data T + meta any + problem *ProblemDetails + headers http.Header +} + +// Respond starts a success response builder. +func Respond[T any](data T) *ResponseBuilder[T] { + return &ResponseBuilder[T]{ + code: http.StatusOK, + data: data, + } +} + +// RespondProblem starts a problem response builder. +func RespondProblem(problem *ProblemDetails) *ResponseBuilder[any] { + code := http.StatusInternalServerError + if problem != nil && problem.Status >= 400 && problem.Status <= 599 { + code = problem.Status + } + return &ResponseBuilder[any]{ + code: code, + problem: problem, + } +} + +// Meta sets response metadata for a fluent helper chain. +func (b *ReplyBuilder) Meta(meta any) *ReplyBuilder { + b.meta = meta + return b +} + +// Header sets a single response header for a fluent helper chain. +func (b *ReplyBuilder) Header(key, value string) *ReplyBuilder { + if b.headers == nil { + b.headers = make(http.Header) + } + b.headers.Set(key, value) + return b +} + +// Headers merges multiple response headers for a fluent helper chain. +func (b *ReplyBuilder) Headers(headers http.Header) *ReplyBuilder { + for key, values := range headers { + for _, value := range values { + b.Header(key, value) + } + } + return b +} + +// OK writes a 200 JSON response using the fluent helper configuration. +func (b *ReplyBuilder) OK(w http.ResponseWriter, data any) { + Respond(data).Meta(b.meta).Headers(b.headers).Write(w) +} + +// Created writes a 201 JSON response using the fluent helper configuration. +func (b *ReplyBuilder) Created(w http.ResponseWriter, data any, location string) { + builder := Respond(data).Status(http.StatusCreated).Meta(b.meta).Headers(b.headers) + if location != "" { + builder.Header("Location", location) + } + builder.Write(w) +} + +// Problem writes a problem response using the fluent helper configuration. +func (b *ReplyBuilder) Problem(w http.ResponseWriter, problem *ProblemDetails) { + RespondProblem(problem).Headers(b.headers).Write(w) +} + +// Status overrides the response status code. +func (b *ResponseBuilder[T]) Status(code int) *ResponseBuilder[T] { + b.code = code + return b +} + +// Meta sets response metadata. +func (b *ResponseBuilder[T]) Meta(meta any) *ResponseBuilder[T] { + b.meta = meta + return b +} + +// Header sets a single response header. +func (b *ResponseBuilder[T]) Header(key, value string) *ResponseBuilder[T] { + if b.headers == nil { + b.headers = make(http.Header) + } + b.headers.Set(key, value) + return b +} + +// Headers merges multiple response headers. +func (b *ResponseBuilder[T]) Headers(headers http.Header) *ResponseBuilder[T] { + for key, values := range headers { + for _, value := range values { + b.Header(key, value) + } + } + return b +} + +// Write writes the configured response. +func (b *ResponseBuilder[T]) Write(w http.ResponseWriter) { + writeResponse(w, b.code, b.data, b.problem, b.meta, b.headers) +} diff --git a/response_builder_test.go b/response_builder_test.go new file mode 100644 index 0000000..39af22e --- /dev/null +++ b/response_builder_test.go @@ -0,0 +1,92 @@ +package httpsuite + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResponseBuilderSuccess(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + Respond([]string{"a", "b"}). + Status(http.StatusCreated). + Meta(NewPageMeta(1, 10, 15)). + Header("X-Request-ID", "req-123"). + Write(w) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code) + } + if got := w.Header().Get("X-Request-ID"); got != "req-123" { + t.Fatalf("expected request id header, got %q", got) + } + if got := w.Header().Get("Content-Type"); got != "application/json; charset=utf-8" { + t.Fatalf("unexpected content type %q", got) + } + if !json.Valid(w.Body.Bytes()) { + t.Fatalf("expected valid json response") + } +} + +func TestResponseBuilderProblem(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + RespondProblem(ProblemNotFound("user missing").Build()). + Header("X-Trace-ID", "trace-123"). + Write(w) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + if got := w.Header().Get("X-Trace-ID"); got != "trace-123" { + t.Fatalf("expected trace id header, got %q", got) + } + + var problem ProblemDetails + if err := json.NewDecoder(w.Body).Decode(&problem); err != nil { + t.Fatalf("decode problem details: %v", err) + } + if problem.Status != http.StatusNotFound { + t.Fatalf("expected normalized problem status %d, got %d", http.StatusNotFound, problem.Status) + } +} + +func TestReplyBuilderHelpers(t *testing.T) { + t.Parallel() + + t.Run("meta then ok", func(t *testing.T) { + w := httptest.NewRecorder() + Reply(). + Meta(NewPageMeta(1, 10, 15)). + Header("X-Request-ID", "req-123"). + OK(w, []string{"a", "b"}) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) + } + if got := w.Header().Get("X-Request-ID"); got != "req-123" { + t.Fatalf("expected request id header, got %q", got) + } + }) + + t.Run("headers then created", func(t *testing.T) { + w := httptest.NewRecorder() + Reply(). + Headers(http.Header{"X-Trace-ID": []string{"trace-123"}}). + Created(w, testResponse{Key: "value"}, "/users/1") + + if w.Code != http.StatusCreated { + t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code) + } + if got := w.Header().Get("Location"); got != "/users/1" { + t.Fatalf("expected location header, got %q", got) + } + if got := w.Header().Get("X-Trace-ID"); got != "trace-123" { + t.Fatalf("expected trace header, got %q", got) + } + }) +} diff --git a/response_helpers.go b/response_helpers.go new file mode 100644 index 0000000..f7a3d8c --- /dev/null +++ b/response_helpers.go @@ -0,0 +1,26 @@ +package httpsuite + +import "net/http" + +// OK writes a 200 JSON response without metadata. +func OK[T any](w http.ResponseWriter, data T) { + Reply().OK(w, data) +} + +// OKWithMeta writes a 200 JSON response with metadata. +func OKWithMeta[T any](w http.ResponseWriter, data T, meta any) { + Reply().Meta(meta).OK(w, data) +} + +// Created writes a 201 JSON response and optionally sets the Location header. +func Created[T any](w http.ResponseWriter, data T, location string) { + Reply().Created(w, data, location) +} + +// ProblemResponse writes a problem response using the problem's status. +func ProblemResponse(w http.ResponseWriter, problem *ProblemDetails) { + if problem == nil { + problem = NewProblemDetails(http.StatusInternalServerError, GetProblemTypeURL("server_error"), "Internal Server Error", "") + } + Reply().Problem(w, problem) +} diff --git a/response_helpers_test.go b/response_helpers_test.go new file mode 100644 index 0000000..27d62c2 --- /dev/null +++ b/response_helpers_test.go @@ -0,0 +1,46 @@ +package httpsuite + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestResponseHelpers(t *testing.T) { + t.Parallel() + + t.Run("ok helper", func(t *testing.T) { + w := httptest.NewRecorder() + OK(w, testResponse{Key: "value"}) + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) + } + }) + + t.Run("ok with meta helper", func(t *testing.T) { + w := httptest.NewRecorder() + OKWithMeta(w, []string{"a"}, NewPageMeta(1, 10, 15)) + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) + } + }) + + t.Run("created helper", func(t *testing.T) { + w := httptest.NewRecorder() + Created(w, testResponse{Key: "value"}, "/users/1") + if w.Code != http.StatusCreated { + t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code) + } + if got := w.Header().Get("Location"); got != "/users/1" { + t.Fatalf("expected location header, got %q", got) + } + }) + + t.Run("problem helper", func(t *testing.T) { + w := httptest.NewRecorder() + ProblemResponse(w, NewNotFoundProblem("user missing")) + if w.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + }) +} diff --git a/response_meta.go b/response_meta.go new file mode 100644 index 0000000..5f8f330 --- /dev/null +++ b/response_meta.go @@ -0,0 +1,49 @@ +package httpsuite + +// Response represents the structure of an HTTP response, including an optional body and metadata. +type Response[T any] struct { + Data T `json:"data,omitempty"` + Meta any `json:"meta,omitempty"` +} + +// PageMeta provides page-based pagination details. +type PageMeta struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + TotalPages int `json:"total_pages,omitempty"` + TotalItems int `json:"total_items,omitempty"` +} + +// Meta is kept as a compatibility alias for page-based pagination metadata. +type Meta = PageMeta + +// CursorMeta provides cursor-based pagination details. +type CursorMeta struct { + NextCursor string `json:"next_cursor,omitempty"` + PrevCursor string `json:"prev_cursor,omitempty"` + HasNext bool `json:"has_next,omitempty"` + HasPrev bool `json:"has_prev,omitempty"` +} + +// NewPageMeta builds page-based metadata and derives total pages when possible. +func NewPageMeta(page, pageSize, totalItems int) *PageMeta { + meta := &PageMeta{ + Page: page, + PageSize: pageSize, + TotalItems: totalItems, + } + if pageSize > 0 && totalItems > 0 { + meta.TotalPages = (totalItems + pageSize - 1) / pageSize + } + return meta +} + +// NewCursorMeta builds cursor-based metadata. +func NewCursorMeta(nextCursor, prevCursor string, hasNext, hasPrev bool) *CursorMeta { + return &CursorMeta{ + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + } +} diff --git a/response_meta_test.go b/response_meta_test.go new file mode 100644 index 0000000..1fcedbd --- /dev/null +++ b/response_meta_test.go @@ -0,0 +1,34 @@ +package httpsuite + +import ( + "encoding/json" + "testing" +) + +func TestMetaSerialization(t *testing.T) { + t.Parallel() + + pageResponse := Response[[]string]{ + Data: []string{"a"}, + Meta: NewPageMeta(1, 10, 15), + } + pageBody, err := json.Marshal(pageResponse) + if err != nil { + t.Fatalf("marshal page response: %v", err) + } + if !json.Valid(pageBody) { + t.Fatalf("expected valid page response json") + } + + cursorResponse := Response[[]string]{ + Data: []string{"a"}, + Meta: NewCursorMeta("next", "", true, false), + } + cursorBody, err := json.Marshal(cursorResponse) + if err != nil { + t.Fatalf("marshal cursor response: %v", err) + } + if !json.Valid(cursorBody) { + t.Fatalf("expected valid cursor response json") + } +} diff --git a/response_test.go b/response_test.go index 16c7477..4eee12b 100644 --- a/response_test.go +++ b/response_test.go @@ -1,114 +1,94 @@ package httpsuite import ( + "encoding/json" "net/http" "net/http/httptest" "testing" - - "github.com/stretchr/testify/assert" ) -type TestResponse struct { +type testResponse struct { Key string `json:"key"` } -func Test_SendResponse(t *testing.T) { +func TestSendResponse(t *testing.T) { + t.Parallel() + tests := []struct { name string code int data any problem *ProblemDetails - meta *Meta + meta any expectedCode int - expectedJSON string + contentType string }{ { - name: "200 OK with TestResponse body", + name: "success response", code: http.StatusOK, - data: &TestResponse{Key: "value"}, + data: &testResponse{Key: "value"}, expectedCode: http.StatusOK, - expectedJSON: `{ - "data": { - "key": "value" - } - }`, + contentType: "application/json; charset=utf-8", }, { - name: "404 Not Found without body", + name: "problem response", code: http.StatusNotFound, problem: NewProblemDetails(http.StatusNotFound, "", "Not Found", "The requested resource was not found"), expectedCode: http.StatusNotFound, - expectedJSON: `{ - "type": "about:blank", - "title": "Not Found", - "status": 404, - "detail": "The requested resource was not found" - }`, + contentType: "application/problem+json; charset=utf-8", }, { - name: "200 OK with pagination metadata", + name: "success response with page meta", code: http.StatusOK, - data: &TestResponse{Key: "value"}, - meta: &Meta{TotalPages: 100, Page: 1, PageSize: 10}, + data: []string{"a", "b"}, + meta: NewPageMeta(2, 10, 25), expectedCode: http.StatusOK, - expectedJSON: `{ - "data": { - "key": "value" - }, - "meta": { - "total_pages": 100, - "page": 1, - "page_size": 10 - } - }`, + contentType: "application/json; charset=utf-8", }, { - name: "400 Bad Request with validation error", - code: http.StatusBadRequest, - problem: &ProblemDetails{ - Type: "https://example.com/validation-error", - Title: "Validation Error", - Status: http.StatusBadRequest, - Detail: "One or more fields failed validation.", - Extensions: map[string]interface{}{ - "errors": []ValidationErrorDetail{ - {Field: "email", Message: "Email is required"}, - {Field: "password", Message: "Password is required"}, - }, - }, - }, + name: "success response with cursor meta", + code: http.StatusOK, + data: []string{"a", "b"}, + meta: NewCursorMeta("next-1", "prev-1", true, true), + expectedCode: http.StatusOK, + contentType: "application/json; charset=utf-8", + }, + { + name: "problem response normalizes status", + code: http.StatusBadRequest, + problem: NewProblemDetails(http.StatusUnprocessableEntity, "", "Invalid Request", "invalid"), expectedCode: http.StatusBadRequest, - expectedJSON: `{ - "type": "https://example.com/validation-error", - "title": "Validation Error", - "status": 400, - "detail": "One or more fields failed validation.", - "extensions": { - "errors": [ - {"field": "email", "message": "Email is required"}, - {"field": "password", "message": "Password is required"} - ] - } - }`, + contentType: "application/problem+json; charset=utf-8", }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() + t.Parallel() - // Call SendResponse with the appropriate data or problem + w := httptest.NewRecorder() SendResponse[any](w, tt.code, tt.data, tt.problem, tt.meta) - // Assert response status code and content type - assert.Equal(t, tt.expectedCode, w.Code) - if w.Code >= 400 { - assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type")) - } else { - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + if w.Code != tt.expectedCode { + t.Fatalf("expected status %d, got %d", tt.expectedCode, w.Code) + } + if got := w.Header().Get("Content-Type"); got != tt.contentType { + t.Fatalf("expected content type %q, got %q", tt.contentType, got) + } + if !json.Valid(w.Body.Bytes()) { + t.Fatalf("expected valid json, got %q", w.Body.String()) + } + + if tt.problem != nil { + var problem ProblemDetails + if err := json.NewDecoder(w.Body).Decode(&problem); err != nil { + t.Fatalf("decode problem details: %v", err) + } + if problem.Status != tt.expectedCode { + t.Fatalf("expected problem status %d, got %d", tt.expectedCode, problem.Status) + } } - // Assert response body - assert.JSONEq(t, tt.expectedJSON, w.Body.String()) }) } } diff --git a/response_write.go b/response_write.go new file mode 100644 index 0000000..dc74a0b --- /dev/null +++ b/response_write.go @@ -0,0 +1,69 @@ +package httpsuite + +import ( + "bytes" + "encoding/json" + "log" + "net/http" +) + +func writeResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta any, headers http.Header) { + if code >= 400 && problem != nil { + writeProblemDetail(w, code, problem, headers) + return + } + + response := &Response[T]{ + Data: data, + Meta: meta, + } + + var buffer bytes.Buffer + if err := json.NewEncoder(&buffer).Encode(response); err != nil { + log.Printf("Error writing response: %v", err) + + internalError := NewProblemDetails( + http.StatusInternalServerError, + GetProblemTypeURL("server_error"), + "Internal Server Error", + err.Error(), + ) + writeProblemDetail(w, http.StatusInternalServerError, internalError, headers) + return + } + + applyHeaders(w, headers) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + if _, err := w.Write(buffer.Bytes()); err != nil { + log.Printf("Failed to write response body (status=%d): %v", code, err) + } +} + +func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails, headers http.Header) { + effectiveStatus := code + if effectiveStatus < 400 || effectiveStatus > 599 { + effectiveStatus = problem.Status + } + if effectiveStatus < 400 || effectiveStatus > 599 { + effectiveStatus = http.StatusInternalServerError + } + + normalized := *problem + normalized.Status = effectiveStatus + + applyHeaders(w, headers) + w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") + w.WriteHeader(effectiveStatus) + if err := json.NewEncoder(w).Encode(normalized); err != nil { + log.Printf("Failed to encode problem details: %v", err) + } +} + +func applyHeaders(w http.ResponseWriter, headers http.Header) { + for key, values := range headers { + for _, value := range values { + w.Header().Add(key, value) + } + } +} diff --git a/validation.go b/validation.go deleted file mode 100644 index b115001..0000000 --- a/validation.go +++ /dev/null @@ -1,66 +0,0 @@ -package httpsuite - -import ( - "errors" - "net/http" - - "github.com/go-playground/validator/v10" -) - -// Validator instance -var validate = validator.New() - -// ValidationErrorDetail provides structured details about a single validation error. -type ValidationErrorDetail struct { - Field string `json:"field"` // The name of the field that failed validation. - Message string `json:"message"` // A human-readable message describing the error. -} - -// NewValidationProblemDetails creates a ProblemDetails instance based on validation errors. -// It maps field-specific validation errors into structured details. -func NewValidationProblemDetails(err error) *ProblemDetails { - var validationErrors validator.ValidationErrors - if !errors.As(err, &validationErrors) { - // If the error is not of type ValidationErrors, return a generic problem response. - return NewProblemDetails( - http.StatusBadRequest, - GetProblemTypeURL("bad_request_error"), - "Invalid Request", - "Invalid data format or structure", - ) - } - - // Collect structured details about each validation error. - errorDetails := make([]ValidationErrorDetail, len(validationErrors)) - for i, vErr := range validationErrors { - errorDetails[i] = ValidationErrorDetail{ - Field: vErr.Field(), - Message: formatValidationMessage(vErr), - } - } - - return &ProblemDetails{ - Type: GetProblemTypeURL("validation_error"), - Title: "Validation Error", - Status: http.StatusBadRequest, - Detail: "One or more fields failed validation.", - Extensions: map[string]interface{}{ - "errors": errorDetails, - }, - } -} - -// formatValidationMessage generates a descriptive message for a validation error. -func formatValidationMessage(vErr validator.FieldError) string { - return vErr.Field() + " failed " + vErr.Tag() + " validation" -} - -// IsRequestValid validates the provided request struct using the go-playground/validator package. -// It returns a ProblemDetails instance if validation fails, or nil if the request is valid. -func IsRequestValid(request any) *ProblemDetails { - err := validate.Struct(request) - if err != nil { - return NewValidationProblemDetails(err) - } - return nil -} diff --git a/validation/playground/go.mod b/validation/playground/go.mod new file mode 100644 index 0000000..648688a --- /dev/null +++ b/validation/playground/go.mod @@ -0,0 +1,21 @@ +module github.com/rluders/httpsuite/validation/playground + +go 1.23 + +require ( + github.com/go-playground/validator/v10 v10.24.0 + github.com/rluders/httpsuite/v3 v3.0.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) + +replace github.com/rluders/httpsuite/v3 => ../.. diff --git a/go.sum b/validation/playground/go.sum similarity index 71% rename from go.sum rename to validation/playground/go.sum index 91d9c8e..7b54a5d 100644 --- a/go.sum +++ b/validation/playground/go.sum @@ -1,9 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -13,9 +11,7 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= @@ -24,7 +20,4 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/validation/playground/validator.go b/validation/playground/validator.go new file mode 100644 index 0000000..7480f49 --- /dev/null +++ b/validation/playground/validator.go @@ -0,0 +1,92 @@ +package playground + +import ( + "errors" + "net/http" + + playgroundvalidator "github.com/go-playground/validator/v10" + "github.com/rluders/httpsuite/v3" +) + +// Validator adapts go-playground/validator to the httpsuite.Validator interface. +type Validator struct { + validate *playgroundvalidator.Validate + problems httpsuite.ProblemConfig +} + +// New returns a validator with the default go-playground configuration. +func New() *Validator { + return NewWithValidator(playgroundvalidator.New(), nil) +} + +// RegisterDefault installs a playground validator as the package-level default in httpsuite. +func RegisterDefault() *Validator { + validator := New() + httpsuite.SetValidator(validator) + return validator +} + +// NewWithValidator returns a validator using a custom go-playground validator. +func NewWithValidator(validate *playgroundvalidator.Validate, problems *httpsuite.ProblemConfig) *Validator { + if validate == nil { + validate = playgroundvalidator.New() + } + + return &Validator{ + validate: validate, + problems: mergeProblems(problems), + } +} + +// Validate validates the request and converts errors into ProblemDetails. +func (v *Validator) Validate(request any) *httpsuite.ProblemDetails { + if err := v.validate.Struct(request); err != nil { + return v.problemDetails(err) + } + return nil +} + +func (v *Validator) problemDetails(err error) *httpsuite.ProblemDetails { + var validationErrors playgroundvalidator.ValidationErrors + if !errors.As(err, &validationErrors) { + return httpsuite.NewProblemDetails( + http.StatusBadRequest, + v.problems.TypeURL("bad_request_error"), + "Invalid Request", + "Invalid data format or structure", + ) + } + + errorDetails := make([]httpsuite.ValidationErrorDetail, len(validationErrors)) + for i, validationErr := range validationErrors { + errorDetails[i] = httpsuite.ValidationErrorDetail{ + Field: validationErr.Field(), + Message: validationErr.Field() + " failed " + validationErr.Tag() + " validation", + } + } + + return &httpsuite.ProblemDetails{ + Type: v.problems.TypeURL("validation_error"), + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": errorDetails, + }, + } +} + +func mergeProblems(problems *httpsuite.ProblemConfig) httpsuite.ProblemConfig { + config := httpsuite.DefaultProblemConfig() + if problems == nil { + return config + } + + if problems.BaseURL != "" { + config.BaseURL = problems.BaseURL + } + for key, value := range problems.ErrorTypePaths { + config.ErrorTypePaths[key] = value + } + return config +} diff --git a/validation/playground/validator_test.go b/validation/playground/validator_test.go new file mode 100644 index 0000000..973637b --- /dev/null +++ b/validation/playground/validator_test.go @@ -0,0 +1,38 @@ +package playground + +import ( + "testing" + + "github.com/rluders/httpsuite/v3" +) + +type request struct { + Name string `validate:"required"` + Age int `validate:"required,min=18"` +} + +func TestValidate(t *testing.T) { + t.Parallel() + + validator := New() + problem := validator.Validate(request{Age: 17}) + if problem == nil { + t.Fatal("expected validation problem, got nil") + } + if problem.Title != "Validation Error" { + t.Fatalf("expected title %q, got %q", "Validation Error", problem.Title) + } +} + +func TestRegisterDefault(t *testing.T) { + httpsuite.ClearValidator() + t.Cleanup(httpsuite.ClearValidator) + + validator := RegisterDefault() + if validator == nil { + t.Fatal("expected validator, got nil") + } + if httpsuite.DefaultValidator() == nil { + t.Fatal("expected default validator to be registered") + } +} diff --git a/validation_test.go b/validation_test.go deleted file mode 100644 index dd7d1c4..0000000 --- a/validation_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package httpsuite - -import ( - "testing" - - "github.com/go-playground/validator/v10" - "github.com/stretchr/testify/assert" -) - -type TestValidationRequest struct { - Name string `validate:"required"` - Age int `validate:"required,min=18"` -} - -func TestNewValidationProblemDetails(t *testing.T) { - validate := validator.New() - request := TestValidationRequest{} // Missing required fields to trigger validation errors - - err := validate.Struct(request) - if err == nil { - t.Fatal("Expected validation errors, but got none") - } - - SetProblemBaseURL("https://example.com") - validationProblem := NewValidationProblemDetails(err) - - expectedProblem := &ProblemDetails{ - Type: "https://example.com/errors/validation-error", - Title: "Validation Error", - Status: 400, - Detail: "One or more fields failed validation.", - Extensions: map[string]interface{}{ - "errors": []ValidationErrorDetail{ - {Field: "Name", Message: "Name failed required validation"}, - {Field: "Age", Message: "Age failed required validation"}, - }, - }, - } - - assert.Equal(t, expectedProblem.Type, validationProblem.Type) - assert.Equal(t, expectedProblem.Title, validationProblem.Title) - assert.Equal(t, expectedProblem.Status, validationProblem.Status) - assert.Equal(t, expectedProblem.Detail, validationProblem.Detail) - assert.ElementsMatch(t, expectedProblem.Extensions["errors"], validationProblem.Extensions["errors"]) -} - -func TestIsRequestValid(t *testing.T) { - tests := []struct { - name string - request TestValidationRequest - expectedProblem *ProblemDetails - }{ - { - name: "Valid request", - request: TestValidationRequest{Name: "Alice", Age: 25}, - expectedProblem: nil, // No errors expected for valid input - }, - { - name: "Missing Name and Age below minimum", - request: TestValidationRequest{Age: 17}, - expectedProblem: &ProblemDetails{ - Type: "https://example.com/errors/validation-error", - Title: "Validation Error", - Status: 400, - Detail: "One or more fields failed validation.", - Extensions: map[string]interface{}{ - "errors": []ValidationErrorDetail{ - {Field: "Name", Message: "Name failed required validation"}, - {Field: "Age", Message: "Age failed min validation"}, - }, - }, - }, - }, - { - name: "Missing Age", - request: TestValidationRequest{Name: "Alice"}, - expectedProblem: &ProblemDetails{ - Type: "https://example.com/errors/validation-error", - Title: "Validation Error", - Status: 400, - Detail: "One or more fields failed validation.", - Extensions: map[string]interface{}{ - "errors": []ValidationErrorDetail{ - {Field: "Age", Message: "Age failed required validation"}, - }, - }, - }, - }, - } - - SetProblemBaseURL("https://example.com") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - problem := IsRequestValid(tt.request) - - if tt.expectedProblem == nil { - assert.Nil(t, problem) - } else { - assert.NotNil(t, problem) - assert.Equal(t, tt.expectedProblem.Type, problem.Type) - assert.Equal(t, tt.expectedProblem.Title, problem.Title) - assert.Equal(t, tt.expectedProblem.Status, problem.Status) - assert.Equal(t, tt.expectedProblem.Detail, problem.Detail) - assert.ElementsMatch(t, tt.expectedProblem.Extensions["errors"], problem.Extensions["errors"]) - } - }) - } -}