From 6529549289b4cfc8b1c9f07777a6ece6d815f7e3 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 29 Oct 2022 18:15:35 -0800 Subject: [PATCH] refactor: http interfaces (#114) * implement custom http handler interface * implement trace_id * normalize http method spacing for consistent logs * fix failing test * fix linter errors * cleanup old dead code * more route cleanup * cleanup some inconsistent errors * update and generate code * make taskfile more consistent * update task calls * run tidy * drop `@` tag for version * use relative paths * tidy * fix auto-setting variables * update build paths * add contributing guide * tidy --- .github/workflows/partial-backend.yaml | 4 +- .github/workflows/pull-requests.yaml | 4 +- .gitignore | 2 +- CONTRIBUTING.md | 51 +++++++ Taskfile.yml | 70 ++++++---- backend/app/api/handlers/v1/controller.go | 6 +- backend/app/api/handlers/v1/partials.go | 24 ++-- backend/app/api/handlers/v1/v1_ctrl_auth.go | 59 ++++---- backend/app/api/handlers/v1/v1_ctrl_group.go | 36 +++-- backend/app/api/handlers/v1/v1_ctrl_items.go | 75 +++++------ .../handlers/v1/v1_ctrl_items_attachments.go | 94 ++++++------- backend/app/api/handlers/v1/v1_ctrl_labels.go | 59 ++++---- .../app/api/handlers/v1/v1_ctrl_locations.go | 58 ++++---- backend/app/api/handlers/v1/v1_ctrl_user.go | 61 ++++----- backend/app/api/logger.go | 2 +- backend/app/api/main.go | 23 +++- backend/app/api/middleware.go | 123 +---------------- backend/app/api/routes.go | 127 +++++++----------- backend/app/api/static/docs/docs.go | 30 ++--- backend/app/api/static/docs/swagger.json | 30 ++--- backend/app/api/static/docs/swagger.yaml | 20 +-- backend/internal/sys/validate/errors.go | 119 ++++++++++++++++ backend/internal/web/mid/errors.go | 70 ++++++++++ backend/internal/web/mid/logger.go | 97 +++++++++++++ backend/internal/web/mid/panic.go | 33 +++++ backend/pkgs/server/errors.go | 23 ++++ backend/pkgs/server/handler.go | 25 ++++ backend/pkgs/server/middleware.go | 38 ++++++ backend/pkgs/server/mux.go | 103 ++++++++++++++ backend/pkgs/server/request.go | 10 +- backend/pkgs/server/response.go | 37 ++--- backend/pkgs/server/response_error_builder.go | 76 ----------- .../server/response_error_builder_test.go | 107 --------------- backend/pkgs/server/response_test.go | 45 +------ backend/pkgs/server/result.go | 12 -- backend/pkgs/server/server.go | 13 +- backend/pkgs/server/server_options.go | 7 + backend/pkgs/server/server_test.go | 7 +- frontend/lib/api/types/data-contracts.ts | 10 +- frontend/pages/index.vue | 2 +- 40 files changed, 984 insertions(+), 808 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 backend/internal/sys/validate/errors.go create mode 100644 backend/internal/web/mid/errors.go create mode 100644 backend/internal/web/mid/logger.go create mode 100644 backend/internal/web/mid/panic.go create mode 100644 backend/pkgs/server/errors.go create mode 100644 backend/pkgs/server/handler.go create mode 100644 backend/pkgs/server/middleware.go create mode 100644 backend/pkgs/server/mux.go delete mode 100644 backend/pkgs/server/response_error_builder.go delete mode 100644 backend/pkgs/server/response_error_builder_test.go diff --git a/.github/workflows/partial-backend.yaml b/.github/workflows/partial-backend.yaml index f01a0df..1bb0dce 100644 --- a/.github/workflows/partial-backend.yaml +++ b/.github/workflows/partial-backend.yaml @@ -28,7 +28,7 @@ jobs: args: --timeout=6m - name: Build API - run: task api:build + run: task go:build - name: Test - run: task api:coverage + run: task go:coverage diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index 1ccdec6..2debdbd 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -8,8 +8,8 @@ on: jobs: backend-tests: name: "Backend Server Tests" - uses: hay-kot/homebox/.github/workflows/partial-backend.yaml@main + uses: ./.github/workflows/partial-backend.yaml frontend-tests: name: "Frontend and End-to-End Tests" - uses: hay-kot/homebox/.github/workflows/partial-frontend.yaml@main + uses: ./.github/workflows/partial-frontend.yaml diff --git a/.gitignore b/.gitignore index 2aab999..7fbecd2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ backend/.data/* config.yml homebox.db .idea - .DS_Store test-mailer.json node_modules @@ -32,6 +31,7 @@ node_modules go.work .task/ backend/.env +build/* # Output Directory for Nuxt/Frontend during build step backend/app/api/public/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..90095ac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +## We Develop with Github + +We use github to host code, to track issues and feature requests, as well as accept pull requests. + +## Branch Flow + +We use the `main` branch as the development branch. All PRs should be made to the `main` branch from a feature branch. To create a pull request you can use the following steps: + +1. Fork the repository and create a new branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed API's, update the documentation. +4. Ensure that the test suite and linters pass +5. Issue your pull request + +## How To Get Started + +### Prerequisites + +There is a devcontainer available for this project. If you are using VSCode, you can use the devcontainer to get started. If you are not using VSCode, you can need to ensure that you have the following tools installed: + +- [Go 1.19+](https://golang.org/doc/install) +- [Swaggo](https://github.com/swaggo/swag) +- [Node.js 16+](https://nodejs.org/en/download/) +- [pnpm](https://pnpm.io/installation) +- [Taskfile](https://taskfile.dev/#/installation) (Optional but recommended) +- For code generation, you'll need to have `python3` available on your path. In most cases, this is already installed and available. + +If you're using `taskfile` you can run `task --list-all` for a list of all commands and their descriptions. + +### Setup + +If you're using the taskfile you can use the `task setup` command to run the required setup commands. Otherwise you can review the commands required in the `Taskfile.yml` file. + +Note that when installing dependencies with pnpm you must use the `--shamefully-hoist` flag. If you don't use this flag you will get an error when running the the frontend server. + +### API Development Notes + +start command `task go:run` + +1. API Server does not auto reload. You'll need to restart the server after making changes. +2. Unit tests should be written in Go, however end-to-end or user story tests should be written in TypeScript using the client library in the frontend directory. + +### Frontend Development Notes + +start command `task: ui:dev` + +1. The frontend is a Vue 3 app with Nuxt.js that uses Tailwind and DaisyUI for styling. +2. We're using Vitest for our automated testing. you can run these with `task ui:watch`. +3. Tests require the API server to be running and in some cases the first run will fail due to a race condition. If this happens just run the tests again and they should pass. \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 4462265..48044ee 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,11 +5,12 @@ env: UNSAFE_DISABLE_PASSWORD_PROJECTION: "yes_i_am_sure" tasks: setup: - desc: Install dependencies + desc: Install development dependencies cmds: - go install github.com/swaggo/swag/cmd/swag@latest - cd backend && go mod tidy - cd frontend && pnpm install --shamefully-hoist + generate: desc: | Generates collateral files from the backend project @@ -37,7 +38,7 @@ tasks: - "./backend/app/api/static/docs/swagger.json" - "./backend/app/api/static/docs/swagger.yaml" - api: + go:run: desc: Starts the backend api server (depends on generate task) deps: - generate @@ -45,42 +46,38 @@ tasks: - cd backend && go run ./app/api/ {{ .CLI_ARGS }} silent: false - api:build: + go:test: + desc: Runs all go tests using gotestsum - supports passing gotestsum args cmds: - - cd backend && go build ./app/api/ - silent: true + - cd backend && gotestsum {{ .CLI_ARGS }} ./... - api:test: - cmds: - - cd backend && go test ./app/api/ - silent: true - - api:watch: - cmds: - - cd backend && gotestsum --watch ./... - - api:coverage: + go:coverage: + desc: Runs all go tests with -race flag and generates a coverage report cmds: - cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover silent: true - test:ci: + go:tidy: + desc: Runs go mod tidy on the backend cmds: - - cd backend && go build ./app/api - - backend/api & - - sleep 5 - - cd frontend && pnpm run test:ci - silent: true + - cd backend && go mod tidy - frontend:watch: - desc: Starts the vitest test runner in watch mode + go:lint: + desc: Runs golangci-lint cmds: - - cd frontend && pnpm vitest --watch + - cd backend && golangci-lint run ./... - frontend: - desc: Run frontend development server + go:all: + desc: Runs all go test and lint related tasks cmds: - - cd frontend && pnpm dev + - task: go:tidy + - task: go:lint + - task: go:test + + go:build: + desc: Builds the backend binary + cmds: + - cd backend && go build -o ../build/backend ./app/api db:generate: desc: Run Entgo.io Code Generation @@ -99,3 +96,22 @@ tasks: - db:generate cmds: - cd backend && go run app/migrations/main.go {{ .CLI_ARGS }} + + ui:watch: + desc: Starts the vitest test runner in watch mode + cmds: + - cd frontend && pnpm vitest --watch + + ui:dev: + desc: Run frontend development server + cmds: + - cd frontend && pnpm dev + + test:ci: + desc: Runs end-to-end test on a live server (only for use in CI) + cmds: + - cd backend && go build ./app/api + - backend/api & + - sleep 5 + - cd frontend && pnpm run test:ci + silent: true diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index ed1f3f7..2c41bc1 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -76,9 +76,9 @@ func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) // @Produce json // @Success 200 {object} ApiSummary // @Router /v1/status [GET] -func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - server.Respond(w, http.StatusOK, ApiSummary{ +func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + return server.Respond(w, http.StatusOK, ApiSummary{ Healthy: ready(), Title: "Go API Template", Message: "Welcome to the Go API Template Application!", diff --git a/backend/app/api/handlers/v1/partials.go b/backend/app/api/handlers/v1/partials.go index 47249e6..763805f 100644 --- a/backend/app/api/handlers/v1/partials.go +++ b/backend/app/api/handlers/v1/partials.go @@ -5,17 +5,23 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/hay-kot/homebox/backend/pkgs/server" - "github.com/rs/zerolog/log" + "github.com/hay-kot/homebox/backend/internal/sys/validate" ) -func (ctrl *V1Controller) routeID(w http.ResponseWriter, r *http.Request) (uuid.UUID, error) { - ID, err := uuid.Parse(chi.URLParam(r, "id")) - if err != nil { - log.Err(err).Msg("failed to parse id") - server.RespondError(w, http.StatusBadRequest, err) - return uuid.Nil, err - } +// routeID extracts the ID from the request URL. If the ID is not in a valid +// format, an error is returned. If a error is returned, it can be directly returned +// from the handler. the validate.ErrInvalidID error is known by the error middleware +// and will be handled accordingly. +// +// Example: /api/v1/ac614db5-d8b8-4659-9b14-6e913a6eb18a -> uuid.UUID{ac614db5-d8b8-4659-9b14-6e913a6eb18a} +func (ctrl *V1Controller) routeID(r *http.Request) (uuid.UUID, error) { + return ctrl.routeUUID(r, "id") +} +func (ctrl *V1Controller) routeUUID(r *http.Request, key string) (uuid.UUID, error) { + ID, err := uuid.Parse(chi.URLParam(r, key)) + if err != nil { + return uuid.Nil, validate.NewInvalidRouteKeyError(key) + } return ID, nil } diff --git a/backend/app/api/handlers/v1/v1_ctrl_auth.go b/backend/app/api/handlers/v1/v1_ctrl_auth.go index d410a86..28ee0dc 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_auth.go +++ b/backend/app/api/handlers/v1/v1_ctrl_auth.go @@ -6,6 +6,7 @@ import ( "time" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -32,17 +33,15 @@ type ( // @Produce json // @Success 200 {object} TokenResponse // @Router /v1/users/login [POST] -func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleAuthLogin() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { loginForm := &LoginForm{} switch r.Header.Get("Content-Type") { case server.ContentFormUrlEncoded: err := r.ParseForm() if err != nil { - server.Respond(w, http.StatusBadRequest, server.Wrap(err)) - log.Error().Err(err).Msg("failed to parse form") - return + return server.Respond(w, http.StatusBadRequest, server.Wrap(err)) } loginForm.Username = r.PostFormValue("username") @@ -52,27 +51,31 @@ func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc { if err != nil { log.Err(err).Msg("failed to decode login form") - server.Respond(w, http.StatusBadRequest, server.Wrap(err)) - return } default: - server.Respond(w, http.StatusBadRequest, errors.New("invalid content type")) - return + return server.Respond(w, http.StatusBadRequest, errors.New("invalid content type")) } if loginForm.Username == "" || loginForm.Password == "" { - server.RespondError(w, http.StatusBadRequest, errors.New("username and password are required")) - return + return validate.NewFieldErrors( + validate.FieldError{ + Field: "username", + Error: "username or password is empty", + }, + validate.FieldError{ + Field: "password", + Error: "username or password is empty", + }, + ) } newToken, err := ctrl.svc.User.Login(r.Context(), loginForm.Username, loginForm.Password) if err != nil { - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(errors.New("authentication failed"), http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, TokenResponse{ + return server.Respond(w, http.StatusOK, TokenResponse{ Token: "Bearer " + newToken.Raw, ExpiresAt: newToken.ExpiresAt, }) @@ -85,23 +88,19 @@ func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc { // @Success 204 // @Router /v1/users/logout [POST] // @Security Bearer -func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleAuthLogout() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { token := services.UseTokenCtx(r.Context()) - if token == "" { - server.RespondError(w, http.StatusUnauthorized, errors.New("no token within request context")) - return + return validate.NewRequestError(errors.New("no token within request context"), http.StatusUnauthorized) } err := ctrl.svc.User.Logout(r.Context(), token) - if err != nil { - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } } @@ -113,22 +112,18 @@ func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc { // @Success 200 // @Router /v1/users/refresh [GET] // @Security Bearer -func (ctrl *V1Controller) HandleAuthRefresh() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleAuthRefresh() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { requestToken := services.UseTokenCtx(r.Context()) - if requestToken == "" { - server.RespondError(w, http.StatusUnauthorized, errors.New("no user token found")) - return + return validate.NewRequestError(errors.New("no token within request context"), http.StatusUnauthorized) } newToken, err := ctrl.svc.User.RenewToken(r.Context(), requestToken) - if err != nil { - server.RespondUnauthorized(w) - return + return validate.NewUnauthorizedError() } - server.Respond(w, http.StatusOK, newToken) + return server.Respond(w, http.StatusOK, newToken) } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_group.go b/backend/app/api/handlers/v1/v1_ctrl_group.go index fe87379..a403877 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_group.go +++ b/backend/app/api/handlers/v1/v1_ctrl_group.go @@ -7,6 +7,7 @@ import ( "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -31,7 +32,7 @@ type ( // @Success 200 {object} repo.Group // @Router /v1/groups [Get] // @Security Bearer -func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc { +func (ctrl *V1Controller) HandleGroupGet() server.HandlerFunc { return ctrl.handleGroupGeneral() } @@ -43,12 +44,12 @@ func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc { // @Success 200 {object} repo.Group // @Router /v1/groups [Put] // @Security Bearer -func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleGroupUpdate() server.HandlerFunc { return ctrl.handleGroupGeneral() } -func (ctrl *V1Controller) handleGroupGeneral() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) handleGroupGeneral() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { ctx := services.NewContext(r.Context()) switch r.Method { @@ -56,29 +57,28 @@ func (ctrl *V1Controller) handleGroupGeneral() http.HandlerFunc { group, err := ctrl.svc.Group.Get(ctx) if err != nil { log.Err(err).Msg("failed to get group") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseƍ - server.Respond(w, http.StatusOK, group) + return server.Respond(w, http.StatusOK, group) case http.MethodPut: data := repo.GroupUpdate{} if err := server.Decode(r, &data); err != nil { - server.RespondError(w, http.StatusBadRequest, err) - return + return validate.NewRequestError(err, http.StatusBadRequest) } group, err := ctrl.svc.Group.UpdateGroup(ctx, data) if err != nil { log.Err(err).Msg("failed to update group") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case - server.Respond(w, http.StatusOK, group) + return server.Respond(w, http.StatusOK, group) } + + return nil } } @@ -90,13 +90,12 @@ func (ctrl *V1Controller) handleGroupGeneral() http.HandlerFunc { // @Success 200 {object} GroupInvitation // @Router /v1/groups/invitations [Post] // @Security Bearer -func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleGroupInvitationsCreate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { data := GroupInvitationCreate{} if err := server.Decode(r, &data); err != nil { log.Err(err).Msg("failed to decode user registration data") - server.RespondError(w, http.StatusBadRequest, err) - return + return validate.NewRequestError(err, http.StatusBadRequest) } if data.ExpiresAt.IsZero() { @@ -108,11 +107,10 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc { token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt) if err != nil { log.Err(err).Msg("failed to create new token") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusCreated, GroupInvitation{ + return server.Respond(w, http.StatusCreated, GroupInvitation{ Token: token, ExpiresAt: data.ExpiresAt, Uses: data.Uses, diff --git a/backend/app/api/handlers/v1/v1_ctrl_items.go b/backend/app/api/handlers/v1/v1_ctrl_items.go index 49fe8b6..84591ca 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -25,7 +26,7 @@ import ( // @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{} // @Router /v1/items [GET] // @Security Bearer -func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemsGetAll() server.HandlerFunc { uuidList := func(params url.Values, key string) []uuid.UUID { var ids []uuid.UUID for _, id := range params[key] { @@ -58,15 +59,14 @@ func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc { } } - return func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) error { ctx := services.NewContext(r.Context()) items, err := ctrl.svc.Items.Query(ctx, extractQuery(r)) if err != nil { log.Err(err).Msg("failed to get items") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, items) + return server.Respond(w, http.StatusOK, items) } } @@ -78,24 +78,22 @@ func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc { // @Success 200 {object} repo.ItemSummary // @Router /v1/items [POST] // @Security Bearer -func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleItemsCreate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { createData := repo.ItemCreate{} if err := server.Decode(r, &createData); err != nil { log.Err(err).Msg("failed to decode request body") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } user := services.UseUserCtx(r.Context()) item, err := ctrl.svc.Items.Create(r.Context(), user.GroupID, createData) if err != nil { log.Err(err).Msg("failed to create item") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusCreated, item) + return server.Respond(w, http.StatusCreated, item) } } @@ -107,7 +105,7 @@ func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc { // @Success 200 {object} repo.ItemOut // @Router /v1/items/{id} [GET] // @Security Bearer -func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemGet() server.HandlerFunc { return ctrl.handleItemsGeneral() } @@ -119,7 +117,7 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc { // @Success 204 // @Router /v1/items/{id} [DELETE] // @Security Bearer -func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemDelete() server.HandlerFunc { return ctrl.handleItemsGeneral() } @@ -132,16 +130,16 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc { // @Success 200 {object} repo.ItemOut // @Router /v1/items/{id} [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemUpdate() server.HandlerFunc { return ctrl.handleItemsGeneral() } -func (ctrl *V1Controller) handleItemsGeneral() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) handleItemsGeneral() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { ctx := services.NewContext(r.Context()) - ID, err := ctrl.routeID(w, r) + ID, err := ctrl.routeID(r) if err != nil { - return + return err } switch r.Method { @@ -149,37 +147,32 @@ func (ctrl *V1Controller) handleItemsGeneral() http.HandlerFunc { items, err := ctrl.svc.Items.GetOne(r.Context(), ctx.GID, ID) if err != nil { log.Err(err).Msg("failed to get item") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, items) - return + return server.Respond(w, http.StatusOK, items) case http.MethodDelete: err = ctrl.svc.Items.Delete(r.Context(), ctx.GID, ID) if err != nil { log.Err(err).Msg("failed to delete item") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) - return + return server.Respond(w, http.StatusNoContent, nil) case http.MethodPut: body := repo.ItemUpdate{} if err := server.Decode(r, &body); err != nil { log.Err(err).Msg("failed to decode request body") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } body.ID = ID result, err := ctrl.svc.Items.Update(r.Context(), ctx.GID, body) if err != nil { log.Err(err).Msg("failed to update item") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, result) + return server.Respond(w, http.StatusOK, result) } + return nil } } @@ -191,29 +184,26 @@ func (ctrl *V1Controller) handleItemsGeneral() http.HandlerFunc { // @Param csv formData file true "Image to upload" // @Router /v1/items/import [Post] // @Security Bearer -func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleItemsImport() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { err := r.ParseMultipartForm(ctrl.maxUploadSize << 20) if err != nil { log.Err(err).Msg("failed to parse multipart form") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } file, _, err := r.FormFile("csv") if err != nil { log.Err(err).Msg("failed to get file from form") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } reader := csv.NewReader(file) data, err := reader.ReadAll() if err != nil { log.Err(err).Msg("failed to read csv") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } user := services.UseUserCtx(r.Context()) @@ -221,10 +211,9 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { _, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, data) if err != nil { log.Err(err).Msg("failed to import items") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go index d4e2981..5d90157 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go @@ -5,11 +5,10 @@ import ( "fmt" "net/http" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" "github.com/hay-kot/homebox/backend/ent/attachment" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -29,19 +28,19 @@ type ( // @Param type formData string true "Type of file" // @Param name formData string true "name of the file including extension" // @Success 200 {object} repo.ItemOut -// @Failure 422 {object} []server.ValidationError +// @Failure 422 {object} server.ErrorResponse // @Router /v1/items/{id}/attachments [POST] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleItemAttachmentCreate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { err := r.ParseMultipartForm(ctrl.maxUploadSize << 20) if err != nil { log.Err(err).Msg("failed to parse multipart form") - server.RespondError(w, http.StatusBadRequest, errors.New("failed to parse multipart form")) - return + return validate.NewRequestError(errors.New("failed to parse multipart form"), http.StatusBadRequest) + } - errs := make(server.ValidationErrors, 0) + errs := validate.NewFieldErrors() file, _, err := r.FormFile("file") if err != nil { @@ -51,8 +50,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { errs = errs.Append("file", "file is required") default: log.Err(err).Msg("failed to get file from form") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } } @@ -62,9 +60,8 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { errs = errs.Append("name", "name is required") } - if errs.HasErrors() { - server.Respond(w, http.StatusUnprocessableEntity, errs) - return + if !errs.Nil() { + return server.Respond(w, http.StatusUnprocessableEntity, errs) } attachmentType := r.FormValue("type") @@ -72,9 +69,9 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { attachmentType = attachment.TypeAttachment.String() } - id, err := ctrl.routeID(w, r) + id, err := ctrl.routeID(r) if err != nil { - return + return err } ctx := services.NewContext(r.Context()) @@ -89,11 +86,10 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { if err != nil { log.Err(err).Msg("failed to add attachment") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusCreated, item) + return server.Respond(w, http.StatusCreated, item) } } @@ -106,21 +102,21 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { // @Success 200 // @Router /v1/items/{id}/attachments/download [GET] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleItemAttachmentDownload() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { token := server.GetParam(r, "token", "") doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), token) if err != nil { log.Err(err).Msg("failed to get attachment") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", doc.Title)) w.Header().Set("Content-Type", "application/octet-stream") http.ServeFile(w, r, doc.Path) + return nil } } @@ -133,7 +129,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { // @Success 200 {object} ItemAttachmentToken // @Router /v1/items/{id}/attachments/{attachment_id} [GET] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemAttachmentToken() server.HandlerFunc { return ctrl.handleItemAttachmentsHandler } @@ -145,7 +141,7 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { // @Success 204 // @Router /v1/items/{id}/attachments/{attachment_id} [DELETE] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemAttachmentDelete() server.HandlerFunc { return ctrl.handleItemAttachmentsHandler } @@ -158,66 +154,60 @@ func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc { // @Success 200 {object} repo.ItemOut // @Router /v1/items/{id}/attachments/{attachment_id} [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleItemAttachmentUpdate() server.HandlerFunc { return ctrl.handleItemAttachmentsHandler } -func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r *http.Request) { - ID, err := ctrl.routeID(w, r) +func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r *http.Request) error { + ID, err := ctrl.routeID(r) if err != nil { - return + return err } - attachmentId, err := uuid.Parse(chi.URLParam(r, "attachment_id")) + attachmentID, err := ctrl.routeUUID(r, "attachment_id") if err != nil { - log.Err(err).Msg("failed to parse attachment_id param") - server.RespondError(w, http.StatusBadRequest, err) - return + return err } ctx := services.NewContext(r.Context()) - switch r.Method { - // Token Handler case http.MethodGet: - token, err := ctrl.svc.Items.AttachmentToken(ctx, ID, attachmentId) + token, err := ctrl.svc.Items.AttachmentToken(ctx, ID, attachmentID) if err != nil { switch err { case services.ErrNotFound: log.Err(err). - Str("id", attachmentId.String()). + Str("id", attachmentID.String()). Msg("failed to find attachment with id") - server.RespondError(w, http.StatusNotFound, err) + return validate.NewRequestError(err, http.StatusNotFound) case services.ErrFileNotFound: log.Err(err). - Str("id", attachmentId.String()). + Str("id", attachmentID.String()). Msg("failed to find file path for attachment with id") log.Warn().Msg("attachment with no file path removed from database") - server.RespondError(w, http.StatusNotFound, err) + return validate.NewRequestError(err, http.StatusNotFound) default: log.Err(err).Msg("failed to get attachment") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } } - server.Respond(w, http.StatusOK, ItemAttachmentToken{Token: token}) + return server.Respond(w, http.StatusOK, ItemAttachmentToken{Token: token}) // Delete Attachment Handler case http.MethodDelete: - err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GID, ID, attachmentId) + err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GID, ID, attachmentID) if err != nil { log.Err(err).Msg("failed to delete attachment") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) // Update Attachment Handler case http.MethodPut: @@ -225,18 +215,18 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r err = server.Decode(r, &attachment) if err != nil { log.Err(err).Msg("failed to decode attachment") - server.RespondError(w, http.StatusBadRequest, err) - return + return validate.NewRequestError(err, http.StatusBadRequest) } - attachment.ID = attachmentId + attachment.ID = attachmentID val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ID, &attachment) if err != nil { log.Err(err).Msg("failed to delete attachment") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, val) + return server.Respond(w, http.StatusOK, val) } + + return nil } diff --git a/backend/app/api/handlers/v1/v1_ctrl_labels.go b/backend/app/api/handlers/v1/v1_ctrl_labels.go index 7d3c00d..f02a929 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_labels.go +++ b/backend/app/api/handlers/v1/v1_ctrl_labels.go @@ -6,6 +6,7 @@ import ( "github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -17,16 +18,15 @@ import ( // @Success 200 {object} server.Results{items=[]repo.LabelOut} // @Router /v1/labels [GET] // @Security Bearer -func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleLabelsGetAll() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { user := services.UseUserCtx(r.Context()) labels, err := ctrl.svc.Labels.GetAll(r.Context(), user.GroupID) if err != nil { log.Err(err).Msg("error getting labels") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, server.Results{Items: labels}) + return server.Respond(w, http.StatusOK, server.Results{Items: labels}) } } @@ -38,24 +38,22 @@ func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc { // @Success 200 {object} repo.LabelSummary // @Router /v1/labels [POST] // @Security Bearer -func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleLabelsCreate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { createData := repo.LabelCreate{} if err := server.Decode(r, &createData); err != nil { log.Err(err).Msg("error decoding label create data") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } user := services.UseUserCtx(r.Context()) label, err := ctrl.svc.Labels.Create(r.Context(), user.GroupID, createData) if err != nil { log.Err(err).Msg("error creating label") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusCreated, label) + return server.Respond(w, http.StatusCreated, label) } } @@ -67,7 +65,7 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc { // @Success 204 // @Router /v1/labels/{id} [DELETE] // @Security Bearer -func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc { +func (ctrl *V1Controller) HandleLabelDelete() server.HandlerFunc { return ctrl.handleLabelsGeneral() } @@ -79,7 +77,7 @@ func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc { // @Success 200 {object} repo.LabelOut // @Router /v1/labels/{id} [GET] // @Security Bearer -func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc { +func (ctrl *V1Controller) HandleLabelGet() server.HandlerFunc { return ctrl.handleLabelsGeneral() } @@ -91,16 +89,16 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc { // @Success 200 {object} repo.LabelOut // @Router /v1/labels/{id} [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleLabelUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleLabelUpdate() server.HandlerFunc { return ctrl.handleLabelsGeneral() } -func (ctrl *V1Controller) handleLabelsGeneral() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) handleLabelsGeneral() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { ctx := services.NewContext(r.Context()) - ID, err := ctrl.routeID(w, r) + ID, err := ctrl.routeID(r) if err != nil { - return + return err } switch r.Method { @@ -111,40 +109,37 @@ func (ctrl *V1Controller) handleLabelsGeneral() http.HandlerFunc { log.Err(err). Str("id", ID.String()). Msg("label not found") - server.RespondError(w, http.StatusNotFound, err) - return + return validate.NewRequestError(err, http.StatusNotFound) } log.Err(err).Msg("error getting label") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, labels) + return server.Respond(w, http.StatusOK, labels) case http.MethodDelete: err = ctrl.svc.Labels.Delete(r.Context(), ctx.GID, ID) if err != nil { log.Err(err).Msg("error deleting label") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) case http.MethodPut: body := repo.LabelUpdate{} if err := server.Decode(r, &body); err != nil { log.Err(err).Msg("error decoding label update data") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } body.ID = ID result, err := ctrl.svc.Labels.Update(r.Context(), ctx.GID, body) if err != nil { log.Err(err).Msg("error updating label") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, result) + return server.Respond(w, http.StatusOK, result) } + + return nil } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_locations.go b/backend/app/api/handlers/v1/v1_ctrl_locations.go index 7a9e525..775cf76 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_locations.go +++ b/backend/app/api/handlers/v1/v1_ctrl_locations.go @@ -6,6 +6,7 @@ import ( "github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -17,17 +18,16 @@ import ( // @Success 200 {object} server.Results{items=[]repo.LocationOutCount} // @Router /v1/locations [GET] // @Security Bearer -func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleLocationGetAll() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { user := services.UseUserCtx(r.Context()) locations, err := ctrl.svc.Location.GetAll(r.Context(), user.GroupID) if err != nil { log.Err(err).Msg("failed to get locations") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, server.Results{Items: locations}) + return server.Respond(w, http.StatusOK, server.Results{Items: locations}) } } @@ -39,24 +39,22 @@ func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc { // @Success 200 {object} repo.LocationSummary // @Router /v1/locations [POST] // @Security Bearer -func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleLocationCreate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { createData := repo.LocationCreate{} if err := server.Decode(r, &createData); err != nil { log.Err(err).Msg("failed to decode location create data") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } user := services.UseUserCtx(r.Context()) location, err := ctrl.svc.Location.Create(r.Context(), user.GroupID, createData) if err != nil { log.Err(err).Msg("failed to create location") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusCreated, location) + return server.Respond(w, http.StatusCreated, location) } } @@ -68,7 +66,7 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc { // @Success 204 // @Router /v1/locations/{id} [DELETE] // @Security Bearer -func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc { +func (ctrl *V1Controller) HandleLocationDelete() server.HandlerFunc { return ctrl.handleLocationGeneral() } @@ -80,7 +78,7 @@ func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc { // @Success 200 {object} repo.LocationOut // @Router /v1/locations/{id} [GET] // @Security Bearer -func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc { +func (ctrl *V1Controller) HandleLocationGet() server.HandlerFunc { return ctrl.handleLocationGeneral() } @@ -93,16 +91,16 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc { // @Success 200 {object} repo.LocationOut // @Router /v1/locations/{id} [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleLocationUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleLocationUpdate() server.HandlerFunc { return ctrl.handleLocationGeneral() } -func (ctrl *V1Controller) handleLocationGeneral() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) handleLocationGeneral() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { ctx := services.NewContext(r.Context()) - ID, err := ctrl.routeID(w, r) + ID, err := ctrl.routeID(r) if err != nil { - return + return err } switch r.Method { @@ -115,21 +113,18 @@ func (ctrl *V1Controller) handleLocationGeneral() http.HandlerFunc { if ent.IsNotFound(err) { l.Msg("location not found") - server.RespondError(w, http.StatusNotFound, err) - return + return validate.NewRequestError(err, http.StatusNotFound) } l.Msg("failed to get location") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, location) + return server.Respond(w, http.StatusOK, location) case http.MethodPut: body := repo.LocationUpdate{} if err := server.Decode(r, &body); err != nil { log.Err(err).Msg("failed to decode location update data") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } body.ID = ID @@ -137,18 +132,17 @@ func (ctrl *V1Controller) handleLocationGeneral() http.HandlerFunc { result, err := ctrl.svc.Location.Update(r.Context(), ctx.GID, body) if err != nil { log.Err(err).Msg("failed to update location") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, result) + return server.Respond(w, http.StatusOK, result) case http.MethodDelete: err = ctrl.svc.Location.Delete(r.Context(), ctx.GID, ID) if err != nil { log.Err(err).Msg("failed to delete location") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } + return nil } } diff --git a/backend/app/api/handlers/v1/v1_ctrl_user.go b/backend/app/api/handlers/v1/v1_ctrl_user.go index 3b8fd44..b7bf5ee 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_user.go +++ b/backend/app/api/handlers/v1/v1_ctrl_user.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) @@ -17,29 +18,26 @@ import ( // @Param payload body services.UserRegistration true "User Data" // @Success 204 // @Router /v1/users/register [Post] -func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleUserRegistration() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { regData := services.UserRegistration{} if err := server.Decode(r, ®Data); err != nil { log.Err(err).Msg("failed to decode user registration data") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } if !ctrl.allowRegistration && regData.GroupToken == "" { - server.RespondError(w, http.StatusForbidden, nil) - return + return validate.NewRequestError(nil, http.StatusForbidden) } _, err := ctrl.svc.User.RegisterUser(r.Context(), regData) if err != nil { log.Err(err).Msg("failed to register user") - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } } @@ -50,17 +48,16 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc { // @Success 200 {object} server.Result{item=repo.UserOut} // @Router /v1/users/self [GET] // @Security Bearer -func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleUserSelf() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { token := services.UseTokenCtx(r.Context()) usr, err := ctrl.svc.User.GetSelf(r.Context(), token) if usr.ID == uuid.Nil || err != nil { log.Err(err).Msg("failed to get user") - server.RespondServerError(w) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, server.Wrap(usr)) + return server.Respond(w, http.StatusOK, server.Wrap(usr)) } } @@ -72,24 +69,22 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { // @Success 200 {object} server.Result{item=repo.UserUpdate} // @Router /v1/users/self [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleUserSelfUpdate() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { updateData := repo.UserUpdate{} if err := server.Decode(r, &updateData); err != nil { log.Err(err).Msg("failed to decode user update data") - server.RespondError(w, http.StatusBadRequest, err) - return + return validate.NewRequestError(err, http.StatusBadRequest) } actor := services.UseUserCtx(r.Context()) newData, err := ctrl.svc.User.UpdateSelf(r.Context(), actor.ID, updateData) if err != nil { - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusOK, server.Wrap(newData)) + return server.Respond(w, http.StatusOK, server.Wrap(newData)) } } @@ -100,20 +95,18 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc { // @Success 204 // @Router /v1/users/self [DELETE] // @Security Bearer -func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleUserSelfDelete() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { if ctrl.isDemo { - server.RespondError(w, http.StatusForbidden, nil) - return + return validate.NewRequestError(nil, http.StatusForbidden) } actor := services.UseUserCtx(r.Context()) if err := ctrl.svc.User.DeleteSelf(r.Context(), actor.ID); err != nil { - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } } @@ -131,11 +124,10 @@ type ( // @Param payload body ChangePassword true "Password Payload" // @Router /v1/users/change-password [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleUserSelfChangePassword() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (ctrl *V1Controller) HandleUserSelfChangePassword() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { if ctrl.isDemo { - server.RespondError(w, http.StatusForbidden, nil) - return + return validate.NewRequestError(nil, http.StatusForbidden) } var cp ChangePassword @@ -148,10 +140,9 @@ func (ctrl *V1Controller) HandleUserSelfChangePassword() http.HandlerFunc { ok := ctrl.svc.User.ChangePassword(ctx, cp.Current, cp.New) if !ok { - server.RespondError(w, http.StatusInternalServerError, err) - return + return validate.NewRequestError(err, http.StatusInternalServerError) } - server.Respond(w, http.StatusNoContent, nil) + return server.Respond(w, http.StatusNoContent, nil) } } diff --git a/backend/app/api/logger.go b/backend/app/api/logger.go index 6756ffc..86085a5 100644 --- a/backend/app/api/logger.go +++ b/backend/app/api/logger.go @@ -15,7 +15,7 @@ func (a *app) setupLogger() { // Logger Init // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix if a.conf.Log.Format != config.LogFormatJSON { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).With().Caller().Logger() } log.Level(getLevel(a.conf.Log.Level)) diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 9cbff98..e48ae04 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -15,6 +15,7 @@ import ( "github.com/hay-kot/homebox/backend/internal/migrations" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/web/mid" "github.com/hay-kot/homebox/backend/pkgs/server" _ "github.com/mattn/go-sqlite3" "github.com/rs/zerolog/log" @@ -114,17 +115,25 @@ func run(cfg *config.Config) error { app.services = services.New(app.repos) // ========================================================================= - // Start Server + // Start Server\ + logger := log.With().Caller().Logger() + + mwLogger := mid.Logger(logger) + if app.conf.Mode == config.ModeDevelopment { + mwLogger = mid.SugarLogger(logger) + } + app.server = server.NewServer( server.WithHost(app.conf.Web.Host), server.WithPort(app.conf.Web.Port), + server.WithMiddleware( + mwLogger, + mid.Errors(logger), + mid.Panic(app.conf.Mode == config.ModeDevelopment), + ), ) - routes := app.newRouter(app.repos) - - if app.conf.Mode != config.ModeDevelopment { - app.logRoutes(routes) - } + app.mountRoutes(app.repos) log.Info().Msgf("Starting HTTP Server on %s:%s", app.server.Host, app.server.Port) @@ -163,5 +172,5 @@ func run(cfg *config.Config) error { }() } - return app.server.Start(routes) + return app.server.Start() } diff --git a/backend/app/api/middleware.go b/backend/app/api/middleware.go index 7db65aa..68bfde1 100644 --- a/backend/app/api/middleware.go +++ b/backend/app/api/middleware.go @@ -1,143 +1,34 @@ package main import ( - "fmt" + "errors" "net/http" "strings" - "time" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/hay-kot/homebox/backend/internal/config" "github.com/hay-kot/homebox/backend/internal/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" - "github.com/rs/zerolog/log" ) -func (a *app) setGlobalMiddleware(r *chi.Mux) { - // ========================================================================= - // Middleware - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(mwStripTrailingSlash) - - // Use struct logger in production for requests, but use - // pretty console logger in development. - if a.conf.Mode == config.ModeDevelopment { - r.Use(a.mwSummaryLogger) - } else { - r.Use(a.mwStructLogger) - } - r.Use(middleware.Recoverer) - - // Set a timeout value on the request context (ctx), that will signal - // through ctx.Done() that the request has timed out and further - // processing should be stopped. - r.Use(middleware.Timeout(60 * time.Second)) -} - // mwAuthToken is a middleware that will check the database for a stateful token // and attach it to the request context with the user, or return a 401 if it doesn't exist. -func (a *app) mwAuthToken(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func (a *app) mwAuthToken(next server.Handler) server.Handler { + return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { requestToken := r.Header.Get("Authorization") if requestToken == "" { - server.RespondUnauthorized(w) - return + return validate.NewRequestError(errors.New("Authorization header is required"), http.StatusUnauthorized) } requestToken = strings.TrimPrefix(requestToken, "Bearer ") usr, err := a.services.User.GetSelf(r.Context(), requestToken) // Check the database for the token - if err != nil { - server.RespondUnauthorized(w) - return + return validate.NewRequestError(errors.New("Authorization header is required"), http.StatusUnauthorized) } r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken)) - - next.ServeHTTP(w, r) - }) -} - -// mqStripTrailingSlash is a middleware that will strip trailing slashes from the request path. -func mwStripTrailingSlash(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") - next.ServeHTTP(w, r) - }) -} - -type StatusRecorder struct { - http.ResponseWriter - Status int -} - -func (r *StatusRecorder) WriteHeader(status int) { - r.Status = status - r.ResponseWriter.WriteHeader(status) -} - -func (a *app) mwStructLogger(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK} - next.ServeHTTP(record, r) - - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - - url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto) - - log.Info(). - Str("id", middleware.GetReqID(r.Context())). - Str("method", r.Method). - Str("remote_addr", r.RemoteAddr). - Int("status", record.Status). - Msg(url) - }) -} - -func (a *app) mwSummaryLogger(next http.Handler) http.Handler { - bold := func(s string) string { return "\033[1m" + s + "\033[0m" } - orange := func(s string) string { return "\033[33m" + s + "\033[0m" } - aqua := func(s string) string { return "\033[36m" + s + "\033[0m" } - red := func(s string) string { return "\033[31m" + s + "\033[0m" } - green := func(s string) string { return "\033[32m" + s + "\033[0m" } - - fmtCode := func(code int) string { - switch { - case code >= 500: - return red(fmt.Sprintf("%d", code)) - case code >= 400: - return orange(fmt.Sprintf("%d", code)) - case code >= 300: - return aqua(fmt.Sprintf("%d", code)) - default: - return green(fmt.Sprintf("%d", code)) - } - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK} - next.ServeHTTP(record, r) // Blocks until the next handler returns. - - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - - url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto) - - log.Info(). - Msgf("%s %s %s", - bold(orange(""+r.Method+"")), - aqua(url), - bold(fmtCode(record.Status)), - ) + return next.ServeHTTP(w, r) }) } diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index f5d887a..02a7f74 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -10,11 +10,11 @@ import ( "path" "path/filepath" - "github.com/go-chi/chi/v5" "github.com/hay-kot/homebox/backend/app/api/handlers/debughandlers" v1 "github.com/hay-kot/homebox/backend/app/api/handlers/v1" _ "github.com/hay-kot/homebox/backend/app/api/static/docs" "github.com/hay-kot/homebox/backend/internal/repo" + "github.com/hay-kot/homebox/backend/pkgs/server" httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware ) @@ -35,101 +35,76 @@ func (a *app) debugRouter() *http.ServeMux { } // registerRoutes registers all the routes for the API -func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { +func (a *app) mountRoutes(repos *repo.AllRepos) { registerMimes() - r := chi.NewRouter() - a.setGlobalMiddleware(r) - - r.Get("/swagger/*", httpSwagger.Handler( + a.server.Get("/swagger/*", server.ToHandler(httpSwagger.Handler( httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)), - )) + ))) // ========================================================================= // API Version 1 v1Base := v1.BaseUrlFunc(prefix) - v1Ctrl := v1.NewControllerV1(a.services, + + v1Ctrl := v1.NewControllerV1( + a.services, v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithRegistration(a.conf.AllowRegistration), v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode ) - r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ + + a.server.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ Version: version, Commit: commit, BuildTime: buildTime, })) - r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration()) - r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) + a.server.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration()) + a.server.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) // Attachment download URl needs a `token` query param to be passed in the request. // and also needs to be outside of the `auth` middleware. - r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload()) + a.server.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload()) - r.Group(func(r chi.Router) { - r.Use(a.mwAuthToken) - r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf()) - r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate()) - r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete()) - r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout()) - r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh()) - r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword()) + a.server.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf(), a.mwAuthToken) + a.server.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate(), a.mwAuthToken) + a.server.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete(), a.mwAuthToken) + a.server.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout(), a.mwAuthToken) + a.server.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh(), a.mwAuthToken) + a.server.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword(), a.mwAuthToken) - r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate()) + a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), a.mwAuthToken) - // TODO: I don't like /groups being the URL for users - r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet()) - r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate()) + // TODO: I don't like /groups being the URL for users + a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken) + a.server.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate(), a.mwAuthToken) - r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll()) - r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate()) - r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet()) - r.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate()) - r.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete()) + a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), a.mwAuthToken) + a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), a.mwAuthToken) + a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), a.mwAuthToken) + a.server.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate(), a.mwAuthToken) + a.server.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete(), a.mwAuthToken) - r.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll()) - r.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate()) - r.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet()) - r.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate()) - r.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete()) + a.server.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll(), a.mwAuthToken) + a.server.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate(), a.mwAuthToken) + a.server.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet(), a.mwAuthToken) + a.server.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate(), a.mwAuthToken) + a.server.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete(), a.mwAuthToken) - r.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll()) - r.Post(v1Base("/items/import"), v1Ctrl.HandleItemsImport()) - r.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate()) - r.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet()) - r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate()) - r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete()) + a.server.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll(), a.mwAuthToken) + a.server.Post(v1Base("/items/import"), v1Ctrl.HandleItemsImport(), a.mwAuthToken) + a.server.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate(), a.mwAuthToken) + a.server.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet(), a.mwAuthToken) + a.server.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate(), a.mwAuthToken) + a.server.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete(), a.mwAuthToken) - r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate()) - r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken()) - r.Put(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentUpdate()) - r.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete()) - }) + a.server.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate(), a.mwAuthToken) + a.server.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken(), a.mwAuthToken) + a.server.Put(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentUpdate(), a.mwAuthToken) + a.server.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete(), a.mwAuthToken) - r.NotFound(notFoundHandler()) - return r -} - -// logRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging. -// See https://github.com/go-chi/chi/issues/332 for details and inspiration. -func (a *app) logRoutes(r *chi.Mux) { - desiredSpaces := 10 - - walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error { - text := "[" + method + "]" - - for len(text) < desiredSpaces { - text = text + " " - } - - fmt.Printf("Registered Route: %s%s\n", text, route) - return nil - } - - if err := chi.Walk(r, walkFunc); err != nil { - fmt.Printf("Logging err: %s\n", err.Error()) - } + a.server.NotFound(notFoundHandler()) } func registerMimes() { @@ -146,7 +121,7 @@ func registerMimes() { // notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that // the client side routing is handled correctly. -func notFoundHandler() http.HandlerFunc { +func notFoundHandler() server.HandlerFunc { tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error { f, err := fs.Open(path.Join(prefix, requestedPath)) if err != nil { @@ -165,14 +140,16 @@ func notFoundHandler() http.HandlerFunc { return err } - return func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) error { err := tryRead(public, "static/public", r.URL.Path, w) - if err == nil { - return - } - err = tryRead(public, "static/public", "index.html", w) if err != nil { - panic(err) + // Fallback to the index.html file. + // should succeed in all cases. + err = tryRead(public, "static/public", "index.html", w) + if err != nil { + return err + } } + return nil } } diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 2bd7e41..7cad594 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -395,10 +395,7 @@ const docTemplate = `{ "422": { "description": "Unprocessable Entity", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/server.ValidationError" - } + "$ref": "#/definitions/server.ErrorResponse" } } } @@ -1735,6 +1732,20 @@ const docTemplate = `{ } } }, + "server.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "server.Result": { "type": "object", "properties": { @@ -1754,17 +1765,6 @@ const docTemplate = `{ "items": {} } }, - "server.ValidationError": { - "type": "object", - "properties": { - "field": { - "type": "string" - }, - "reason": { - "type": "string" - } - } - }, "services.UserRegistration": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 139990c..b5e97e8 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -387,10 +387,7 @@ "422": { "description": "Unprocessable Entity", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/server.ValidationError" - } + "$ref": "#/definitions/server.ErrorResponse" } } } @@ -1727,6 +1724,20 @@ } } }, + "server.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "server.Result": { "type": "object", "properties": { @@ -1746,17 +1757,6 @@ "items": {} } }, - "server.ValidationError": { - "type": "object", - "properties": { - "field": { - "type": "string" - }, - "reason": { - "type": "string" - } - } - }, "services.UserRegistration": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index bda3d97..d2cf02a 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -394,6 +394,15 @@ definitions: name: type: string type: object + server.ErrorResponse: + properties: + error: + type: string + fields: + additionalProperties: + type: string + type: object + type: object server.Result: properties: details: {} @@ -407,13 +416,6 @@ definitions: properties: items: {} type: object - server.ValidationError: - properties: - field: - type: string - reason: - type: string - type: object services.UserRegistration: properties: email: @@ -708,9 +710,7 @@ paths: "422": description: Unprocessable Entity schema: - items: - $ref: '#/definitions/server.ValidationError' - type: array + $ref: '#/definitions/server.ErrorResponse' security: - Bearer: [] summary: imports items into the database diff --git a/backend/internal/sys/validate/errors.go b/backend/internal/sys/validate/errors.go new file mode 100644 index 0000000..d08a448 --- /dev/null +++ b/backend/internal/sys/validate/errors.go @@ -0,0 +1,119 @@ +package validate + +import ( + "encoding/json" + "errors" +) + +type UnauthorizedError struct { +} + +func (err *UnauthorizedError) Error() string { + return "unauthorized" +} + +func IsUnauthorizedError(err error) bool { + var re *UnauthorizedError + return errors.As(err, &re) +} + +func NewUnauthorizedError() error { + return &UnauthorizedError{} +} + +type InvalidRouteKeyError struct { + key string +} + +func (err *InvalidRouteKeyError) Error() string { + return "invalid route key: " + err.key +} + +func NewInvalidRouteKeyError(key string) error { + return &InvalidRouteKeyError{key} +} + +func IsInvalidRouteKeyError(err error) bool { + var re *InvalidRouteKeyError + return errors.As(err, &re) +} + +// ErrorResponse is the form used for API responses from failures in the API. +type ErrorResponse struct { + Error string `json:"error"` + Fields string `json:"fields,omitempty"` +} + +// RequestError is used to pass an error during the request through the +// application with web specific context. +type RequestError struct { + Err error + Status int + Fields error +} + +// NewRequestError wraps a provided error with an HTTP status code. This +// function should be used when handlers encounter expected errors. +func NewRequestError(err error, status int) error { + return &RequestError{err, status, nil} +} + +func (err *RequestError) Error() string { + return err.Err.Error() +} + +// IsRequestError checks if an error of type RequestError exists. +func IsRequestError(err error) bool { + var re *RequestError + return errors.As(err, &re) +} + +// FieldError is used to indicate an error with a specific request field. +type FieldError struct { + Field string `json:"field"` + Error string `json:"error"` +} + +// FieldErrors represents a collection of field errors. +type FieldErrors []FieldError + +func (fe FieldErrors) Append(field, reason string) FieldErrors { + return append(fe, FieldError{ + Field: field, + Error: reason, + }) +} + +func (fe FieldErrors) Nil() bool { + return len(fe) == 0 +} + +// Error implments the error interface. +func (fe FieldErrors) Error() string { + d, err := json.Marshal(fe) + if err != nil { + return err.Error() + } + return string(d) +} + +func NewFieldErrors(errs ...FieldError) FieldErrors { + return errs +} + +func IsFieldError(err error) bool { + v := FieldErrors{} + return errors.As(err, &v) +} + +// Cause iterates through all the wrapped errors until the root +// error value is reached. +func Cause(err error) error { + root := err + for { + if err = errors.Unwrap(root); err == nil { + return root + } + root = err + } +} diff --git a/backend/internal/web/mid/errors.go b/backend/internal/web/mid/errors.go new file mode 100644 index 0000000..0802a11 --- /dev/null +++ b/backend/internal/web/mid/errors.go @@ -0,0 +1,70 @@ +package mid + +import ( + "net/http" + + "github.com/hay-kot/homebox/backend/ent" + "github.com/hay-kot/homebox/backend/internal/sys/validate" + "github.com/hay-kot/homebox/backend/pkgs/server" + "github.com/rs/zerolog" +) + +func Errors(log zerolog.Logger) server.Middleware { + return func(h server.Handler) server.Handler { + return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + err := h.ServeHTTP(w, r) + + if err != nil { + var resp server.ErrorResponse + var code int + + log.Err(err). + Str("trace_id", server.GetTraceID(r.Context())). + Msg("ERROR occurred") + + switch { + case validate.IsUnauthorizedError(err): + code = http.StatusUnauthorized + resp = server.ErrorResponse{ + Error: "unauthorized", + } + case validate.IsInvalidRouteKeyError(err): + code = http.StatusBadRequest + resp = server.ErrorResponse{ + Error: err.Error(), + } + case validate.IsFieldError(err): + fieldErrors := err.(validate.FieldErrors) + resp.Error = "Validation Error" + resp.Fields = map[string]string{} + + for _, fieldError := range fieldErrors { + resp.Fields[fieldError.Field] = fieldError.Error + } + case validate.IsRequestError(err): + requestError := err.(*validate.RequestError) + resp.Error = requestError.Error() + code = requestError.Status + case ent.IsNotFound(err): + resp.Error = "Not Found" + code = http.StatusNotFound + default: + resp.Error = "Unknown Error" + code = http.StatusInternalServerError + + } + + if err := server.Respond(w, code, resp); err != nil { + return err + } + + // If Showdown error, return error + if server.IsShutdownError(err) { + return err + } + } + + return nil + }) + } +} diff --git a/backend/internal/web/mid/logger.go b/backend/internal/web/mid/logger.go new file mode 100644 index 0000000..86b8cdb --- /dev/null +++ b/backend/internal/web/mid/logger.go @@ -0,0 +1,97 @@ +package mid + +import ( + "fmt" + "net/http" + + "github.com/hay-kot/homebox/backend/pkgs/server" + "github.com/rs/zerolog" +) + +type statusRecorder struct { + http.ResponseWriter + Status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.Status = status + r.ResponseWriter.WriteHeader(status) +} + +func Logger(log zerolog.Logger) server.Middleware { + return func(next server.Handler) server.Handler { + return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + traceId := server.GetTraceID(r.Context()) + + log.Info(). + Str("trace_id", traceId). + Str("method", r.Method). + Str("path", r.URL.Path). + Str("remove_address", r.RemoteAddr). + Msg("request started") + + record := &statusRecorder{ResponseWriter: w, Status: http.StatusOK} + + err := next.ServeHTTP(record, r) + + log.Info(). + Str("trace_id", traceId). + Str("method", r.Method). + Str("url", r.URL.Path). + Str("remote_address", r.RemoteAddr). + Int("status_code", record.Status). + Msg("request completed") + + return err + }) + } +} + +func SugarLogger(log zerolog.Logger) server.Middleware { + orange := func(s string) string { return "\033[33m" + s + "\033[0m" } + aqua := func(s string) string { return "\033[36m" + s + "\033[0m" } + red := func(s string) string { return "\033[31m" + s + "\033[0m" } + green := func(s string) string { return "\033[32m" + s + "\033[0m" } + + fmtCode := func(code int) string { + switch { + case code >= 500: + return red(fmt.Sprintf("%d", code)) + case code >= 400: + return orange(fmt.Sprintf("%d", code)) + case code >= 300: + return aqua(fmt.Sprintf("%d", code)) + default: + return green(fmt.Sprintf("%d", code)) + } + } + bold := func(s string) string { return "\033[1m" + s + "\033[0m" } + + atLeast6 := func(s string) string { + for len(s) <= 6 { + s += " " + } + return s + } + + return func(next server.Handler) server.Handler { + return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + + record := &statusRecorder{ResponseWriter: w, Status: http.StatusOK} + + err := next.ServeHTTP(record, r) // Blocks until the next handler returns. + + url := fmt.Sprintf("%s %s", r.RequestURI, r.Proto) + + log.Info(). + Str("trace_id", server.GetTraceID(r.Context())). + Msgf("%s %s %s", + bold(fmtCode(record.Status)), + bold(orange(atLeast6(r.Method))), + aqua(url), + ) + + return err + }) + } +} diff --git a/backend/internal/web/mid/panic.go b/backend/internal/web/mid/panic.go new file mode 100644 index 0000000..9879bb8 --- /dev/null +++ b/backend/internal/web/mid/panic.go @@ -0,0 +1,33 @@ +package mid + +import ( + "fmt" + "net/http" + "runtime/debug" + + "github.com/hay-kot/homebox/backend/pkgs/server" +) + +// Panic is a middleware that recovers from panics anywhere in the chain and wraps the error. +// and returns it up the middleware chain. +func Panic(develop bool) server.Middleware { + return func(h server.Handler) server.Handler { + return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (err error) { + defer func() { + if rec := recover(); rec != nil { + trace := debug.Stack() + + if develop { + err = fmt.Errorf("PANIC [%v]", rec) + fmt.Printf("%s", string(trace)) + } else { + err = fmt.Errorf("PANIC [%v] TRACE[%s]", rec, string(trace)) + } + + } + }() + + return h.ServeHTTP(w, r) + }) + } +} diff --git a/backend/pkgs/server/errors.go b/backend/pkgs/server/errors.go new file mode 100644 index 0000000..5b1d60b --- /dev/null +++ b/backend/pkgs/server/errors.go @@ -0,0 +1,23 @@ +package server + +import "errors" + +type shutdownError struct { + message string +} + +func (e *shutdownError) Error() string { + return e.message +} + +// ShutdownError returns an error that indicates that the server has lost +// integrity and should be shut down. +func ShutdownError(message string) error { + return &shutdownError{message} +} + +// IsShutdownError returns true if the error is a shutdown error. +func IsShutdownError(err error) bool { + var e *shutdownError + return errors.As(err, &e) +} diff --git a/backend/pkgs/server/handler.go b/backend/pkgs/server/handler.go new file mode 100644 index 0000000..76ae131 --- /dev/null +++ b/backend/pkgs/server/handler.go @@ -0,0 +1,25 @@ +package server + +import ( + "net/http" +) + +type HandlerFunc func(w http.ResponseWriter, r *http.Request) error + +func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + return f(w, r) +} + +type Handler interface { + ServeHTTP(http.ResponseWriter, *http.Request) error +} + +// ToHandler converts a function to a customer implementation of the Handler interface. +// that returns an error. This wrapper around the handler function and simply +// returns the nil in all cases +func ToHandler(handler http.Handler) Handler { + return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + handler.ServeHTTP(w, r) + return nil + }) +} diff --git a/backend/pkgs/server/middleware.go b/backend/pkgs/server/middleware.go new file mode 100644 index 0000000..f24f06b --- /dev/null +++ b/backend/pkgs/server/middleware.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/http" + "strings" +) + +type Middleware func(Handler) Handler + +// wrapMiddleware creates a new handler by wrapping middleware around a final +// handler. The middlewares' Handlers will be executed by requests in the order +// they are provided. +func wrapMiddleware(mw []Middleware, handler Handler) Handler { + + // Loop backwards through the middleware invoking each one. Replace the + // handler with the new wrapped handler. Looping backwards ensures that the + // first middleware of the slice is the first to be executed by requests. + for i := len(mw) - 1; i >= 0; i-- { + h := mw[i] + if h != nil { + handler = h(handler) + } + } + + return handler +} + +// StripTrailingSlash is a middleware that will strip trailing slashes from the request path. +// +// Example: /api/v1/ -> /api/v1 +func StripTrailingSlash() Middleware { + return func(h Handler) Handler { + return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") + return h.ServeHTTP(w, r) + }) + } +} diff --git a/backend/pkgs/server/mux.go b/backend/pkgs/server/mux.go new file mode 100644 index 0000000..9e77e32 --- /dev/null +++ b/backend/pkgs/server/mux.go @@ -0,0 +1,103 @@ +package server + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type vkey int + +const ( + // Key is the key for the server in the request context. + key vkey = 1 +) + +type Values struct { + TraceID string +} + +func GetTraceID(ctx context.Context) string { + v, ok := ctx.Value(key).(Values) + if !ok { + return "" + } + return v.TraceID +} + +func (s *Server) toHttpHandler(handler Handler, mw ...Middleware) http.HandlerFunc { + handler = wrapMiddleware(mw, handler) + + handler = wrapMiddleware(s.mw, handler) + + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Add the trace ID to the context + ctx = context.WithValue(ctx, key, Values{ + TraceID: uuid.NewString(), + }) + + err := handler.ServeHTTP(w, r.WithContext(ctx)) + + if err != nil { + if IsShutdownError(err) { + _ = s.Shutdown("SIGTERM") + } + } + } +} + +func (s *Server) handle(method, pattern string, handler Handler, mw ...Middleware) { + h := s.toHttpHandler(handler, mw...) + + switch method { + case http.MethodGet: + s.mux.Get(pattern, h) + case http.MethodPost: + s.mux.Post(pattern, h) + case http.MethodPut: + s.mux.Put(pattern, h) + case http.MethodDelete: + s.mux.Delete(pattern, h) + case http.MethodPatch: + s.mux.Patch(pattern, h) + case http.MethodHead: + s.mux.Head(pattern, h) + case http.MethodOptions: + s.mux.Options(pattern, h) + } +} + +func (s *Server) Get(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodGet, pattern, handler, mw...) +} + +func (s *Server) Post(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodPost, pattern, handler, mw...) +} + +func (s *Server) Put(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodPut, pattern, handler, mw...) +} + +func (s *Server) Delete(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodDelete, pattern, handler, mw...) +} + +func (s *Server) Patch(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodPatch, pattern, handler, mw...) +} + +func (s *Server) Head(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodHead, pattern, handler, mw...) +} + +func (s *Server) Options(pattern string, handler Handler, mw ...Middleware) { + s.handle(http.MethodOptions, pattern, handler, mw...) +} + +func (s *Server) NotFound(handler Handler) { + s.mux.NotFound(s.toHttpHandler(handler)) +} diff --git a/backend/pkgs/server/request.go b/backend/pkgs/server/request.go index ffb76d1..38c3189 100644 --- a/backend/pkgs/server/request.go +++ b/backend/pkgs/server/request.go @@ -16,7 +16,7 @@ func Decode(r *http.Request, val interface{}) error { return nil } -// GetId is a shotcut to get the id from the request URL or return a default value +// GetId is a shortcut to get the id from the request URL or return a default value func GetParam(r *http.Request, key, d string) string { val := r.URL.Query().Get(key) @@ -27,22 +27,22 @@ func GetParam(r *http.Request, key, d string) string { return val } -// GetSkip is a shotcut to get the skip from the request URL parameters +// GetSkip is a shortcut to get the skip from the request URL parameters func GetSkip(r *http.Request, d string) string { return GetParam(r, "skip", d) } -// GetSkip is a shotcut to get the skip from the request URL parameters +// GetSkip is a shortcut to get the skip from the request URL parameters func GetId(r *http.Request, d string) string { return GetParam(r, "id", d) } -// GetLimit is a shotcut to get the limit from the request URL parameters +// GetLimit is a shortcut to get the limit from the request URL parameters func GetLimit(r *http.Request, d string) string { return GetParam(r, "limit", d) } -// GetQuery is a shotcut to get the sort from the request URL parameters +// GetQuery is a shortcut to get the sort from the request URL parameters func GetQuery(r *http.Request, d string) string { return GetParam(r, "query", d) } diff --git a/backend/pkgs/server/response.go b/backend/pkgs/server/response.go index cf14dd2..7d5880e 100644 --- a/backend/pkgs/server/response.go +++ b/backend/pkgs/server/response.go @@ -2,16 +2,20 @@ package server import ( "encoding/json" - "errors" "net/http" ) +type ErrorResponse struct { + Error string `json:"error"` + Fields map[string]string `json:"fields,omitempty"` +} + // Respond converts a Go value to JSON and sends it to the client. // Adapted from https://github.com/ardanlabs/service/tree/master/foundation/web -func Respond(w http.ResponseWriter, statusCode int, data interface{}) { +func Respond(w http.ResponseWriter, statusCode int, data interface{}) error { if statusCode == http.StatusNoContent { w.WriteHeader(statusCode) - return + return nil } // Convert the response value to JSON. @@ -28,31 +32,8 @@ func Respond(w http.ResponseWriter, statusCode int, data interface{}) { // Send the result back to the client. if _, err := w.Write(jsonData); err != nil { - panic(err) + return err } -} -// ResponseError is a helper function that sends a JSON response of an error message -func RespondError(w http.ResponseWriter, statusCode int, err error) { - eb := ErrorBuilder{} - eb.AddError(err) - eb.Respond(w, statusCode) -} - -// RespondServerError is a wrapper around RespondError that sends a 500 internal server error. Useful for -// Sending generic errors when everything went wrong. -func RespondServerError(w http.ResponseWriter) { - RespondError(w, http.StatusInternalServerError, errors.New("internal server error")) -} - -// RespondNotFound is a helper utility for responding with a generic -// "unauthorized" error. -func RespondUnauthorized(w http.ResponseWriter) { - RespondError(w, http.StatusUnauthorized, errors.New("unauthorized")) -} - -// RespondForbidden is a helper utility for responding with a generic -// "forbidden" error. -func RespondForbidden(w http.ResponseWriter) { - RespondError(w, http.StatusForbidden, errors.New("forbidden")) + return nil } diff --git a/backend/pkgs/server/response_error_builder.go b/backend/pkgs/server/response_error_builder.go deleted file mode 100644 index 4c6d80f..0000000 --- a/backend/pkgs/server/response_error_builder.go +++ /dev/null @@ -1,76 +0,0 @@ -package server - -import ( - "net/http" -) - -type ValidationError struct { - Field string `json:"field"` - Reason string `json:"reason"` -} - -type ValidationErrors []ValidationError - -func (ve *ValidationErrors) HasErrors() bool { - if (ve == nil) || (len(*ve) == 0) { - return false - } - - for _, err := range *ve { - if err.Field != "" { - return true - } - } - return false -} - -func (ve ValidationErrors) Append(field, reasons string) ValidationErrors { - return append(ve, ValidationError{ - Field: field, - Reason: reasons, - }) -} - -// ErrorBuilder is a helper type to build a response that contains an array of errors. -// Typical use cases are for returning an array of validation errors back to the user. -// -// Example: -// -// { -// "errors": [ -// "invalid id", -// "invalid name", -// "invalid description" -// ], -// "message": "Unprocessable Entity", -// "status": 422 -// } -type ErrorBuilder struct { - errs []string -} - -// HasErrors returns true if the ErrorBuilder has any errors. -func (eb *ErrorBuilder) HasErrors() bool { - if (eb.errs == nil) || (len(eb.errs) == 0) { - return false - } - return true -} - -// AddError adds an error to the ErrorBuilder if an error is not nil. If the -// Error is nil, then nothing is added. -func (eb *ErrorBuilder) AddError(err error) { - if err != nil { - if eb.errs == nil { - eb.errs = make([]string, 0) - } - - eb.errs = append(eb.errs, err.Error()) - } -} - -// Respond sends a JSON response with the ErrorBuilder's errors. If there are no errors, then -// the errors field will be an empty array. -func (eb *ErrorBuilder) Respond(w http.ResponseWriter, statusCode int) { - Respond(w, statusCode, Wrap(nil).AddError(http.StatusText(statusCode), eb.errs)) -} diff --git a/backend/pkgs/server/response_error_builder_test.go b/backend/pkgs/server/response_error_builder_test.go deleted file mode 100644 index 689faaa..0000000 --- a/backend/pkgs/server/response_error_builder_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/hay-kot/homebox/backend/pkgs/faker" - "github.com/stretchr/testify/assert" -) - -func Test_ErrorBuilder_HasErrors_NilList(t *testing.T) { - t.Parallel() - - var ebNilList = ErrorBuilder{} - assert.False(t, ebNilList.HasErrors(), "ErrorBuilder.HasErrors() should return false when list is nil") - -} - -func Test_ErrorBuilder_HasErrors_EmptyList(t *testing.T) { - t.Parallel() - - var ebEmptyList = ErrorBuilder{ - errs: []string{}, - } - assert.False(t, ebEmptyList.HasErrors(), "ErrorBuilder.HasErrors() should return false when list is empty") - -} - -func Test_ErrorBuilder_HasErrors_WithError(t *testing.T) { - t.Parallel() - - var ebList = ErrorBuilder{} - ebList.AddError(errors.New("test error")) - - assert.True(t, ebList.HasErrors(), "ErrorBuilder.HasErrors() should return true when list is not empty") - -} - -func Test_ErrorBuilder_AddError(t *testing.T) { - t.Parallel() - - randomError := make([]error, 10) - - f := faker.NewFaker() - - errorStrings := make([]string, 10) - - for i := 0; i < 10; i++ { - err := errors.New(f.Str(10)) - randomError[i] = err - errorStrings[i] = err.Error() - } - - // Check Results - var ebList = ErrorBuilder{} - - for _, err := range randomError { - ebList.AddError(err) - } - - assert.Equal(t, errorStrings, ebList.errs, "ErrorBuilder.AddError() should add an error to the list") -} - -func Test_ErrorBuilder_Respond(t *testing.T) { - t.Parallel() - - f := faker.NewFaker() - - randomError := make([]error, 5) - - for i := 0; i < 5; i++ { - err := errors.New(f.Str(5)) - randomError[i] = err - } - - // Check Results - var ebList = ErrorBuilder{} - - for _, err := range randomError { - ebList.AddError(err) - } - - fakeWriter := httptest.NewRecorder() - - ebList.Respond(fakeWriter, 422) - - assert.Equal(t, 422, fakeWriter.Code, "ErrorBuilder.Respond() should return a status code of 422") - - // Check errors payload is correct - - errorsStruct := struct { - Errors []string `json:"details"` - Message string `json:"message"` - Error bool `json:"error"` - }{ - Errors: ebList.errs, - Message: http.StatusText(http.StatusUnprocessableEntity), - Error: true, - } - - asJson, _ := json.Marshal(errorsStruct) - assert.JSONEq(t, string(asJson), fakeWriter.Body.String(), "ErrorBuilder.Respond() should return a JSON response with the errors") - -} diff --git a/backend/pkgs/server/response_test.go b/backend/pkgs/server/response_test.go index a6446d3..ef23d60 100644 --- a/backend/pkgs/server/response_test.go +++ b/backend/pkgs/server/response_test.go @@ -1,7 +1,6 @@ package server import ( - "errors" "net/http" "net/http/httptest" "testing" @@ -17,7 +16,8 @@ func Test_Respond_NoContent(t *testing.T) { Name: "dummy", } - Respond(recorder, http.StatusNoContent, dummystruct) + err := Respond(recorder, http.StatusNoContent, dummystruct) + assert.NoError(t, err) assert.Equal(t, http.StatusNoContent, recorder.Code) assert.Empty(t, recorder.Body.String()) @@ -31,48 +31,11 @@ func Test_Respond_JSON(t *testing.T) { Name: "dummy", } - Respond(recorder, http.StatusCreated, dummystruct) + err := Respond(recorder, http.StatusCreated, dummystruct) + assert.NoError(t, err) assert.Equal(t, http.StatusCreated, recorder.Code) assert.JSONEq(t, recorder.Body.String(), `{"name":"dummy"}`) assert.Equal(t, "application/json", recorder.Header().Get("Content-Type")) } - -func Test_RespondError(t *testing.T) { - recorder := httptest.NewRecorder() - var customError = errors.New("custom error") - - RespondError(recorder, http.StatusBadRequest, customError) - - assert.Equal(t, http.StatusBadRequest, recorder.Code) - assert.JSONEq(t, recorder.Body.String(), `{"details":["custom error"], "message":"Bad Request", "error":true}`) - -} -func Test_RespondInternalServerError(t *testing.T) { - recorder := httptest.NewRecorder() - - RespondServerError(recorder) - - assert.Equal(t, http.StatusInternalServerError, recorder.Code) - assert.JSONEq(t, recorder.Body.String(), `{"details":["internal server error"], "message":"Internal Server Error", "error":true}`) - -} -func Test_RespondUnauthorized(t *testing.T) { - recorder := httptest.NewRecorder() - - RespondUnauthorized(recorder) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) - assert.JSONEq(t, recorder.Body.String(), `{"details":["unauthorized"], "message":"Unauthorized", "error":true}`) - -} -func Test_RespondForbidden(t *testing.T) { - recorder := httptest.NewRecorder() - - RespondForbidden(recorder) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.JSONEq(t, recorder.Body.String(), `{"details":["forbidden"], "message":"Forbidden", "error":true}`) - -} diff --git a/backend/pkgs/server/result.go b/backend/pkgs/server/result.go index 656d17b..69dcf81 100644 --- a/backend/pkgs/server/result.go +++ b/backend/pkgs/server/result.go @@ -17,15 +17,3 @@ func Wrap(data interface{}) Result { Item: data, } } - -func (r Result) AddMessage(message string) Result { - r.Message = message - return r -} - -func (r Result) AddError(err string, details interface{}) Result { - r.Message = err - r.Details = details - r.Error = true - return r -} diff --git a/backend/pkgs/server/server.go b/backend/pkgs/server/server.go index 1cc7cf1..921c576 100644 --- a/backend/pkgs/server/server.go +++ b/backend/pkgs/server/server.go @@ -10,6 +10,8 @@ import ( "sync" "syscall" "time" + + "github.com/go-chi/chi/v5" ) var ( @@ -22,7 +24,11 @@ type Server struct { Port string Worker Worker - wg sync.WaitGroup + wg sync.WaitGroup + mux *chi.Mux + + // mw is the global middleware chain for the server. + mw []Middleware started bool activeServer *http.Server @@ -36,6 +42,7 @@ func NewServer(opts ...Option) *Server { s := &Server{ Host: "localhost", Port: "8080", + mux: chi.NewRouter(), Worker: NewSimpleWorker(), idleTimeout: 30 * time.Second, readTimeout: 10 * time.Second, @@ -75,14 +82,14 @@ func (s *Server) Shutdown(sig string) error { } -func (s *Server) Start(router http.Handler) error { +func (s *Server) Start() error { if s.started { return ErrServerAlreadyStarted } s.activeServer = &http.Server{ Addr: s.Host + ":" + s.Port, - Handler: router, + Handler: s.mux, IdleTimeout: s.idleTimeout, ReadTimeout: s.readTimeout, WriteTimeout: s.writeTimeout, diff --git a/backend/pkgs/server/server_options.go b/backend/pkgs/server/server_options.go index 1029b1f..93b7781 100644 --- a/backend/pkgs/server/server_options.go +++ b/backend/pkgs/server/server_options.go @@ -4,6 +4,13 @@ import "time" type Option = func(s *Server) error +func WithMiddleware(mw ...Middleware) Option { + return func(s *Server) error { + s.mw = append(s.mw, mw...) + return nil + } +} + func WithWorker(w Worker) Option { return func(s *Server) error { s.Worker = w diff --git a/backend/pkgs/server/server_test.go b/backend/pkgs/server/server_test.go index 1d8b105..95a93fe 100644 --- a/backend/pkgs/server/server_test.go +++ b/backend/pkgs/server/server_test.go @@ -12,8 +12,11 @@ import ( func testServer(t *testing.T, r http.Handler) *Server { svr := NewServer(WithHost("127.0.0.1"), WithPort("19245")) + if r != nil { + svr.mux.Mount("/", r) + } go func() { - err := svr.Start(r) + err := svr.Start() assert.NoError(t, err) }() @@ -42,7 +45,7 @@ func Test_ServerShutdown_Error(t *testing.T) { func Test_ServerStarts_Error(t *testing.T) { svr := testServer(t, nil) - err := svr.Start(nil) + err := svr.Start() assert.ErrorIs(t, err, ErrServerAlreadyStarted) err = svr.Shutdown("test") diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 8d5fef1..e1bc8dd 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -247,6 +247,11 @@ export interface UserUpdate { name: string; } +export interface ServerErrorResponse { + error: string; + fields: Record; +} + export interface ServerResult { details: any; error: boolean; @@ -258,11 +263,6 @@ export interface ServerResults { items: any; } -export interface ServerValidationError { - field: string; - reason: string; -} - export interface UserRegistration { email: string; name: string; diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index f2d05e1..44183c6 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -14,7 +14,7 @@ const { data: status } = useAsyncData(async () => { const { data } = await api.status(); - if (data) { + if (data.demo) { username.value = "demo@example.com"; password.value = "demo"; }