diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 24a0872..17af262 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -274,6 +274,15 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/types.ItemOut" } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.ValidationError" + } + } } } } @@ -1025,6 +1034,17 @@ const docTemplate = `{ } } }, + "server.ValidationError": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "types.ApiSummary": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index af7ca1e..87ae636 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -266,6 +266,15 @@ "schema": { "$ref": "#/definitions/types.ItemOut" } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.ValidationError" + } + } } } } @@ -1017,6 +1026,17 @@ } } }, + "server.ValidationError": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "types.ApiSummary": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index b528576..1749031 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -14,6 +14,13 @@ definitions: items: type: any type: object + server.ValidationError: + properties: + field: + type: string + reason: + type: string + type: object types.ApiSummary: properties: build: @@ -543,6 +550,12 @@ paths: description: OK schema: $ref: '#/definitions/types.ItemOut' + "422": + description: Unprocessable Entity + schema: + items: + $ref: '#/definitions/server.ValidationError' + type: array security: - Bearer: [] summary: imports items into the database diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 284aecd..1ed1668 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -43,7 +43,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { // API Version 1 v1Base := v1.BaseUrlFunc(prefix) - v1Ctrl := v1.NewControllerV1(a.services) + v1Ctrl := v1.NewControllerV1(a.services, v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize)) { r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, types.Build{ Version: Version, diff --git a/backend/app/api/v1/controller.go b/backend/app/api/v1/controller.go index eb157af..ac135ef 100644 --- a/backend/app/api/v1/controller.go +++ b/backend/app/api/v1/controller.go @@ -8,8 +8,15 @@ import ( "github.com/hay-kot/homebox/backend/pkgs/server" ) +func WithMaxUploadSize(maxUploadSize int64) func(*V1Controller) { + return func(ctrl *V1Controller) { + ctrl.maxUploadSize = maxUploadSize + } +} + type V1Controller struct { - svc *services.AllServices + svc *services.AllServices + maxUploadSize int64 } func BaseUrlFunc(prefix string) func(s string) string { @@ -21,7 +28,7 @@ func BaseUrlFunc(prefix string) func(s string) string { return prefixFunc } -func NewControllerV1(svc *services.AllServices) *V1Controller { +func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller { ctrl := &V1Controller{ svc: svc, } diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index 359ff31..7de278e 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -2,10 +2,8 @@ package v1 import ( "encoding/csv" - "errors" "net/http" - "github.com/hay-kot/homebox/backend/ent/attachment" "github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/internal/types" "github.com/hay-kot/homebox/backend/pkgs/server" @@ -191,65 +189,3 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { server.Respond(w, http.StatusNoContent, nil) } } - -// HandleItemsImport godocs -// @Summary imports items into the database -// @Tags Items -// @Produce json -// @Param id path string true "Item ID" -// @Param file formData file true "File attachment" -// @Param type formData string true "Type of file" -// @Param name formData string true "name of the file including extension" -// @Success 200 {object} types.ItemOut -// @Router /v1/items/{id}/attachments [POST] -// @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Max upload size of 10 MB - TODO: Set via config - err := r.ParseMultipartForm(10 << 20) - if err != nil { - log.Err(err).Msg("failed to parse multipart form") - server.RespondServerError(w) - return - } - file, _, err := r.FormFile("file") - if err != nil { - log.Err(err).Msg("failed to get file from form") - server.RespondServerError(w) - return - } - attachmentName := r.FormValue("name") - if attachmentName == "" { - log.Err(err).Msg("failed to get name from form") - server.RespondError(w, http.StatusBadRequest, errors.New("name is required")) - } - - attachmentType := r.FormValue("type") - if attachmentType == "" { - attachmentName = "attachment" - } - - uid, _, err := ctrl.partialParseIdAndUser(w, r) - if err != nil { - return - } - - ctx := services.NewContext(r.Context()) - - item, err := ctrl.svc.Items.AttachmentAdd( - ctx, - uid, - attachmentName, - attachment.Type(attachmentType), - file, - ) - - if err != nil { - log.Err(err).Msg("failed to add attachment") - server.RespondServerError(w) - return - } - - server.Respond(w, http.StatusCreated, item) - } -} diff --git a/backend/app/api/v1/v1_ctrl_items_attachments.go b/backend/app/api/v1/v1_ctrl_items_attachments.go index b3b2d10..b373ead 100644 --- a/backend/app/api/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/v1/v1_ctrl_items_attachments.go @@ -1,18 +1,97 @@ package v1 import ( + "errors" "fmt" "net/http" "path/filepath" "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/services" "github.com/hay-kot/homebox/backend/internal/types" "github.com/hay-kot/homebox/backend/pkgs/server" "github.com/rs/zerolog/log" ) +// HandleItemsImport godocs +// @Summary imports items into the database +// @Tags Items +// @Produce json +// @Param id path string true "Item ID" +// @Param file formData file true "File attachment" +// @Param type formData string true "Type of file" +// @Param name formData string true "name of the file including extension" +// @Success 200 {object} types.ItemOut +// @Failure 422 {object} []server.ValidationError +// @Router /v1/items/{id}/attachments [POST] +// @Security Bearer +func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + 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 + } + + errs := make(server.ValidationErrors, 0) + + file, _, err := r.FormFile("file") + if err != nil { + switch { + case errors.Is(err, http.ErrMissingFile): + log.Debug().Msg("file for attachment is missing") + errs = errs.Append("file", "file is required") + default: + log.Err(err).Msg("failed to get file from form") + server.RespondServerError(w) + return + } + } + + attachmentName := r.FormValue("name") + if attachmentName == "" { + log.Debug().Msg("failed to get name from form") + errs = errs.Append("name", "name is required") + } + + if errs.HasErrors() { + server.Respond(w, http.StatusUnprocessableEntity, errs) + return + } + + attachmentType := r.FormValue("type") + if attachmentType == "" { + attachmentType = attachment.TypeAttachment.String() + } + + id, _, err := ctrl.partialParseIdAndUser(w, r) + if err != nil { + return + } + + ctx := services.NewContext(r.Context()) + + item, err := ctrl.svc.Items.AttachmentAdd( + ctx, + id, + attachmentName, + attachment.Type(attachmentType), + file, + ) + + if err != nil { + log.Err(err).Msg("failed to add attachment") + server.RespondServerError(w) + return + } + + server.Respond(w, http.StatusCreated, item) + } +} + // HandleItemAttachmentGet godocs // @Summary retrieves an attachment for an item // @Tags Items diff --git a/backend/internal/services/service_items_attachments.go b/backend/internal/services/service_items_attachments.go index 37b79d9..9969ee9 100644 --- a/backend/internal/services/service_items_attachments.go +++ b/backend/internal/services/service_items_attachments.go @@ -58,7 +58,7 @@ func (svc *ItemService) AttachmentToken(ctx Context, itemId, attachmentId uuid.U } if _, err := os.Stat(attachment.Edges.Document.Path); os.IsNotExist(err) { - svc.AttachmentDelete(ctx, ctx.GID, itemId, attachmentId) + _ = svc.AttachmentDelete(ctx, ctx.GID, itemId, attachmentId) return "", ErrNotFound } diff --git a/backend/pkgs/server/response_error_builder.go b/backend/pkgs/server/response_error_builder.go index ac8d34d..4c6d80f 100644 --- a/backend/pkgs/server/response_error_builder.go +++ b/backend/pkgs/server/response_error_builder.go @@ -4,22 +4,47 @@ 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 -// } -// +// { +// "errors": [ +// "invalid id", +// "invalid name", +// "invalid description" +// ], +// "message": "Unprocessable Entity", +// "status": 422 +// } type ErrorBuilder struct { errs []string } diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index d6c1d98..80a6367 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -21,6 +21,11 @@ export interface ServerResults { items: any; } +export interface ServerValidationError { + field: string; + reason: string; +} + export interface ApiSummary { build: Build; health: boolean;