From 9d91c4071574cb86efe300327e22be33ccb60cb7 Mon Sep 17 00:00:00 2001 From: Dean Efrati Date: Sun, 13 Oct 2024 20:27:14 -0400 Subject: [PATCH] feat: Added PUT and PATCH routes (#38) - Added PUT and PATCH routes - Improved request error handling - Reorganized code Closes #5 --- .gitignore | 1 + cmd/server.go | 18 +- config/migrations/mysql/01__base.yaml | 3 +- config/migrations/postgresql/01__base.yaml | 3 +- config/migrations/sqlite/01__base.yaml | 3 +- features/todo/todoService.go | 5 + go.mod | 2 + go.sum | 4 + internal/errors/errors.go | 25 +++ internal/middlewares/error_handler.go | 64 ++++++ internal/storage/dynamodb.go | 6 +- internal/storage/memory.go | 11 ++ internal/storage/sql.go | 5 + internal/storage/storage.go | 1 + internal/types/types.go | 62 ++++++ routes/formatter.go | 37 ---- routes/todo.go | 218 ++++++++++++++++++--- types/errors.go | 30 --- types/todo.go | 10 + 19 files changed, 401 insertions(+), 107 deletions(-) create mode 100644 internal/errors/errors.go create mode 100644 internal/middlewares/error_handler.go create mode 100644 internal/types/types.go delete mode 100644 routes/formatter.go delete mode 100644 types/errors.go diff --git a/.gitignore b/.gitignore index f1ec106..1c38500 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ Temporary Items !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets +__debug* # Local History for Visual Studio Code .history/ diff --git a/cmd/server.go b/cmd/server.go index 0883f5b..8f937b9 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -18,12 +18,13 @@ import ( "github.com/spf13/viper" openapigodoc "github.com/tink3rlabs/openapi-godoc" + "todo-service/internal/errors" "todo-service/internal/health" "todo-service/internal/leadership" "todo-service/internal/logger" + "todo-service/internal/middlewares" "todo-service/internal/storage" "todo-service/routes" - "todo-service/types" ) var serverCommand = &cobra.Command{ @@ -45,7 +46,7 @@ func initRoutes() *chi.Mux { middleware.Recoverer, // Recover from panics without crashing server cors.Handler(cors.Options{ AllowedOrigins: []string{"https://*", "http://*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, @@ -179,21 +180,18 @@ func runServer(cmd *cobra.Command, args []string) error { //health check - readiness healthChecker := health.NewHealthChecker() - router.Get("/health/readiness", func(w http.ResponseWriter, r *http.Request) { + h := middlewares.ErrorHandler{} + router.Get("/health/readiness", h.Wrap(func(w http.ResponseWriter, r *http.Request) error { err := healthChecker.Check(viper.GetBool("health.storage"), viper.GetStringSlice("health.dependencies")) if err != nil { slog.Error("health check readiness failed", slog.Any("error", err.Error())) - response := types.ErrorResponse{ - Status: http.StatusText(http.StatusServiceUnavailable), - Error: err.Error(), - } - render.Status(r, http.StatusServiceUnavailable) - render.JSON(w, r, response) + return &errors.ServiceUnavailable{Message: err.Error()} } else { render.Status(r, http.StatusNoContent) render.NoContent(w, r) + return nil } - }) + })) port := viper.GetString("service.port") listenAddress := fmt.Sprintf(":%s", port) diff --git a/config/migrations/mysql/01__base.yaml b/config/migrations/mysql/01__base.yaml index 0b617c0..534e7ad 100644 --- a/config/migrations/mysql/01__base.yaml +++ b/config/migrations/mysql/01__base.yaml @@ -4,6 +4,7 @@ migrations: - migrate: > CREATE TABLE IF NOT EXISTS todos ( id VARCHAR(50) PRIMARY KEY, - summary TEXT + summary TEXT, + done BOOLEAN ) rollback: DROP TABLE IF EXISTS todos diff --git a/config/migrations/postgresql/01__base.yaml b/config/migrations/postgresql/01__base.yaml index a5416b4..3bfbce1 100644 --- a/config/migrations/postgresql/01__base.yaml +++ b/config/migrations/postgresql/01__base.yaml @@ -4,6 +4,7 @@ migrations: - migrate: > CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY, - summary TEXT + summary TEXT, + done BOOLEAN ) rollback: DROP TABLE IF EXISTS todos diff --git a/config/migrations/sqlite/01__base.yaml b/config/migrations/sqlite/01__base.yaml index a5416b4..3f6e8ee 100644 --- a/config/migrations/sqlite/01__base.yaml +++ b/config/migrations/sqlite/01__base.yaml @@ -4,6 +4,7 @@ migrations: - migrate: > CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY, - summary TEXT + summary TEXT, + done INTEGER ) rollback: DROP TABLE IF EXISTS todos diff --git a/features/todo/todoService.go b/features/todo/todoService.go index fee57b0..3f5148f 100644 --- a/features/todo/todoService.go +++ b/features/todo/todoService.go @@ -41,6 +41,10 @@ func (t *TodoService) DeleteTodo(id string) error { return t.storage.Delete(&types.Todo{}, "Id", id) } +func (t *TodoService) UpdateTodo(todoToUpdate types.Todo) error { + return t.storage.Update(todoToUpdate, "Id", todoToUpdate.Id) +} + func (t *TodoService) CreateTodo(todoToCreate types.TodoUpdate) (types.Todo, error) { todo := types.Todo{} @@ -66,6 +70,7 @@ func (t *TodoService) CreateTodo(todoToCreate types.TodoUpdate) (types.Todo, err todo.Id = id.String() todo.Summary = todoToCreate.Summary + todo.Done = todoToCreate.Done err = t.storage.Create(todo) return todo, err diff --git a/go.mod b/go.mod index c4a3978..d119df8 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.4 require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.27 + github.com/evanphx/json-patch/v5 v5.9.0 github.com/getkin/kin-openapi v0.125.0 github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 @@ -35,6 +36,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/pkg/errors v0.9.1 // indirect ) require ( diff --git a/go.sum b/go.sum index 87401e6..12aba66 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -114,6 +116,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..b9f7c2b --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,25 @@ +package errors + +type BadRequest struct { + Message string +} + +func (e *BadRequest) Error() string { + return e.Message +} + +type NotFound struct { + Message string +} + +func (e *NotFound) Error() string { + return e.Message +} + +type ServiceUnavailable struct { + Message string +} + +func (e *ServiceUnavailable) Error() string { + return e.Message +} diff --git a/internal/middlewares/error_handler.go b/internal/middlewares/error_handler.go new file mode 100644 index 0000000..3dd070a --- /dev/null +++ b/internal/middlewares/error_handler.go @@ -0,0 +1,64 @@ +package middlewares + +import ( + "errors" + "net/http" + + "github.com/go-chi/render" + + serviceErrors "todo-service/internal/errors" + "todo-service/internal/storage" + "todo-service/internal/types" +) + +type ErrorHandler struct{} + +func (e *ErrorHandler) Wrap(handler func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var notFoundError *serviceErrors.NotFound + var badRequestError *serviceErrors.BadRequest + var serviceUnavailable *serviceErrors.ServiceUnavailable + + err := handler(w, r) + + if (errors.As(err, ¬FoundError)) || (errors.Is(err, storage.ErrNotFound)) { + render.Status(r, http.StatusNotFound) + response := types.ErrorResponse{ + Status: http.StatusText(http.StatusNotFound), + Error: err.Error(), + } + render.JSON(w, r, response) + return + } + + if errors.As(err, &badRequestError) { + render.Status(r, http.StatusBadRequest) + response := types.ErrorResponse{ + Status: http.StatusText(http.StatusBadRequest), + Error: err.Error(), + } + render.JSON(w, r, response) + return + } + + if errors.As(err, &serviceUnavailable) { + render.Status(r, http.StatusServiceUnavailable) + response := types.ErrorResponse{ + Status: http.StatusText(http.StatusServiceUnavailable), + Error: err.Error(), + } + render.JSON(w, r, response) + return + } + + if err != nil { + render.Status(r, http.StatusInternalServerError) + response := types.ErrorResponse{ + Status: http.StatusText(http.StatusInternalServerError), + Error: "Encountered an unexpected server error: " + err.Error(), + } + render.JSON(w, r, response) + return + } + } +} diff --git a/internal/storage/dynamodb.go b/internal/storage/dynamodb.go index 3433b0c..6cd9553 100644 --- a/internal/storage/dynamodb.go +++ b/internal/storage/dynamodb.go @@ -84,7 +84,7 @@ func (s *DynamoDBAdapter) Create(item any) error { }) if err != nil { - return fmt.Errorf("failed to create item: %v", err) + return fmt.Errorf("failed to create or update item: %v", err) } return nil @@ -117,6 +117,10 @@ func (s *DynamoDBAdapter) Get(dest any, itemKey string, itemValue string) error } } +func (s *DynamoDBAdapter) Update(item any, itemKey string, itemValue string) error { + return s.Create(item) +} + func (s *DynamoDBAdapter) Delete(item any, itemKey string, itemValue string) error { key, err := attributevalue.MarshalMap(map[string]string{strings.ToLower(itemKey): itemValue}) if err != nil { diff --git a/internal/storage/memory.go b/internal/storage/memory.go index 3825a51..ce7d4ec 100644 --- a/internal/storage/memory.go +++ b/internal/storage/memory.go @@ -57,6 +57,17 @@ func (m *MemoryAdapter) Get(dest any, itemKey string, itemValue string) error { return ErrNotFound } +func (m *MemoryAdapter) Update(item any, itemKey string, itemValue string) error { + t := reflect.TypeOf(item).String() + for i, existingItem := range m.store[t] { + if reflect.ValueOf(existingItem).FieldByName(itemKey).String() == itemValue { + m.store[t][i] = item + return nil + } + } + return ErrNotFound +} + func (m *MemoryAdapter) Delete(item any, itemKey string, itemValue string) error { t := strings.ReplaceAll(reflect.TypeOf(item).String(), "*", "") for k, v := range m.store[t] { diff --git a/internal/storage/sql.go b/internal/storage/sql.go index b40f615..b82f65c 100644 --- a/internal/storage/sql.go +++ b/internal/storage/sql.go @@ -106,6 +106,11 @@ func (s *SQLAdapter) Get(dest any, itemKey string, itemValue string) error { return result.Error } +func (s *SQLAdapter) Update(item any, itemKey string, itemValue string) error { + result := s.DB.Where(itemKey+" = ?", itemValue).Save(item) + return result.Error +} + func (s *SQLAdapter) Delete(item any, itemKey string, itemValue string) error { result := s.DB.Where(itemKey+" = ?", itemValue).Delete(item) return result.Error diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 8d3f524..fc10fc4 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -15,6 +15,7 @@ type StorageAdapter interface { Ping() error Create(item any) error Get(dest any, itemKey string, itemValue string) error + Update(item any, itemKey string, itemValue string) error Delete(item any, itemKey string, itemValue string) error List(items any, itemKey string, limit int, cursor string) (string, error) } diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..eb6a912 --- /dev/null +++ b/internal/types/types.go @@ -0,0 +1,62 @@ +package types + +// @openapi +// components: +// +// responses: +// NotFound: +// description: The specified resource was not found +// content: +// application/json: +// schema: +// $ref: '#/components/schemas/Error' +// Unauthorized: +// description: Unauthorized +// content: +// application/json: +// schema: +// $ref: '#/components/schemas/Error' +// schemas: +// Error: +// type: object +// properties: +// status: +// type: string +// error: +// type: string +type ErrorResponse struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// @openapi +// components: +// +// schemas: +// PatchBody: +// type: object +// description: A JSONPatch document as defined by RFC 6902 +// additionalProperties: false +// required: +// - op +// - path +// properties: +// op: +// type: string +// description: The operation to be performed +// enum: +// - add +// - remove +// - replace +// - move +// - copy +// - test +// path: +// type: string +// description: A JSON-Pointer +// value: +// description: The value to be used within the operations. +// from: +// type: string +// description: A string containing a JSON Pointer value. +type PatchBody struct{} diff --git a/routes/formatter.go b/routes/formatter.go deleted file mode 100644 index cf69ce3..0000000 --- a/routes/formatter.go +++ /dev/null @@ -1,37 +0,0 @@ -package routes - -import ( - "errors" - "net/http" - - "github.com/go-chi/render" - - "todo-service/internal/storage" - "todo-service/types" -) - -type Formatter struct{} - -func (f *Formatter) Respond(object interface{}, err error, w http.ResponseWriter, r *http.Request) { - if errors.Is(err, storage.ErrNotFound) { - render.Status(r, 404) - response := types.ErrorResponse{ - Status: "NOT_FOUND", - Error: "Todo not found", - } - render.JSON(w, r, response) - return - } - - if err != nil { - render.Status(r, 500) - response := types.ErrorResponse{ - Status: "SERVER_ERROR", - Error: "Encountered an unexpected server error: " + err.Error(), - } - render.JSON(w, r, response) - return - } - - render.JSON(w, r, object) -} diff --git a/routes/todo.go b/routes/todo.go index 41e1dba..b79018e 100644 --- a/routes/todo.go +++ b/routes/todo.go @@ -2,13 +2,16 @@ package routes import ( "encoding/json" + "io" "net/http" "strconv" + jsonpatch "github.com/evanphx/json-patch/v5" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "todo-service/features/todo" + "todo-service/internal/errors" "todo-service/internal/middlewares" "todo-service/types" ) @@ -16,7 +19,6 @@ import ( type TodoRouter struct { Router *chi.Mux service *todo.TodoService - formatter Formatter validator middlewares.Validator } @@ -50,13 +52,33 @@ var createSchema = map[string]string{ "body": `{ "type": "object", "properties": { - "summary": { "type": "string" } + "summary": { "type": "string" }, + "done": { "type": "boolean" } }, "required": ["summary"], "additionalProperties": false }`, } +var replaceSchema = map[string]string{ + "body": `{ + "type": "object", + "properties": { + "summary": { "type": "string" }, + "done": { "type": "boolean" } + }, + "required": ["summary", "done"], + "additionalProperties": false + }`, + "params": `{ + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + }`, +} + var idSchema = map[string]string{ "params": `{ "type": "object", @@ -69,12 +91,14 @@ var idSchema = map[string]string{ func NewTodoRouter() *TodoRouter { t := TodoRouter{} - + h := middlewares.ErrorHandler{} router := chi.NewRouter() - router.Get("/{id}", t.validator.ValidateRequest(idSchema, t.GetTodo)) - router.Delete("/{id}", t.validator.ValidateRequest(idSchema, t.DeleteTodo)) - router.Post("/", t.validator.ValidateRequest(createSchema, t.CreateTodo)) - router.Get("/", t.ListTodos) + router.Get("/{id}", t.validator.ValidateRequest(idSchema, h.Wrap(t.GetTodo))) + router.Delete("/{id}", t.validator.ValidateRequest(idSchema, h.Wrap(t.DeleteTodo))) + router.Put("/{id}", t.validator.ValidateRequest(replaceSchema, h.Wrap(t.ReplaceTodo))) + router.Patch("/{id}", t.validator.ValidateRequest(idSchema, h.Wrap(t.UpdateTodo))) + router.Post("/", t.validator.ValidateRequest(createSchema, h.Wrap(t.CreateTodo))) + router.Get("/", h.Wrap(t.ListTodos)) t.Router = router t.service = todo.NewTodoService() @@ -111,7 +135,7 @@ func NewTodoRouter() *TodoRouter { // application/json: // schema: // $ref: '#/components/schemas/TodoList' -func (t *TodoRouter) ListTodos(w http.ResponseWriter, r *http.Request) { +func (t *TodoRouter) ListTodos(w http.ResponseWriter, r *http.Request) error { cursor := r.URL.Query().Get("next") limit, err := strconv.ParseInt(r.URL.Query().Get("limit"), 10, 64) @@ -120,7 +144,11 @@ func (t *TodoRouter) ListTodos(w http.ResponseWriter, r *http.Request) { } todos, next, err := t.service.ListTodos(int(limit), cursor) - t.formatter.Respond(types.TodoList{Todos: todos, Next: next}, err, w, r) + if err != nil { + return err + } + render.JSON(w, r, types.TodoList{Todos: todos, Next: next}) + return nil } // @openapi @@ -136,7 +164,7 @@ func (t *TodoRouter) ListTodos(w http.ResponseWriter, r *http.Request) { // parameters: // - name: id // in: path -// description: The identifier of the Todo to retrieve +// description: The identifier of the Todo // required: true // schema: // type: string @@ -149,10 +177,14 @@ func (t *TodoRouter) ListTodos(w http.ResponseWriter, r *http.Request) { // $ref: '#/components/schemas/Todo' // '404': // $ref: '#/components/responses/NotFound' -func (t *TodoRouter) GetTodo(w http.ResponseWriter, r *http.Request) { +func (t *TodoRouter) GetTodo(w http.ResponseWriter, r *http.Request) error { id := chi.URLParam(r, "id") todo, err := t.service.GetTodo(id) - t.formatter.Respond(todo, err, w, r) + if err != nil { + return err + } + render.JSON(w, r, todo) + return nil } // @openapi @@ -168,22 +200,21 @@ func (t *TodoRouter) GetTodo(w http.ResponseWriter, r *http.Request) { // parameters: // - name: id // in: path -// description: The identifier of the Todo to delete +// description: The identifier of the Todo // required: true // schema: // type: string // responses: // '204': // description: successful operation -func (t *TodoRouter) DeleteTodo(w http.ResponseWriter, r *http.Request) { +func (t *TodoRouter) DeleteTodo(w http.ResponseWriter, r *http.Request) error { id := chi.URLParam(r, "id") err := t.service.DeleteTodo(id) if err != nil { - t.formatter.Respond(nil, err, w, r) - } else { - render.Status(r, 204) - render.NoContent(w, r) + return err } + render.NoContent(w, r) + return nil } // @openapi @@ -195,9 +226,9 @@ func (t *TodoRouter) DeleteTodo(w http.ResponseWriter, r *http.Request) { // - todos // summary: Create a Todo // description: Create a new Todo -// operationId: addTodo +// operationId: createTodo // requestBody: -// description: Create a new pet in the store +// description: Create a new Todo // content: // application/json: // schema: @@ -205,18 +236,153 @@ func (t *TodoRouter) DeleteTodo(w http.ResponseWriter, r *http.Request) { // responses: // '201': // description: successful operation -func (t *TodoRouter) CreateTodo(w http.ResponseWriter, r *http.Request) { +func (t *TodoRouter) CreateTodo(w http.ResponseWriter, r *http.Request) error { var todoToCreate types.TodoUpdate + decodeErr := json.NewDecoder(r.Body).Decode(&todoToCreate) if decodeErr != nil { - t.formatter.Respond(nil, decodeErr, w, r) + return decodeErr } todo, err := t.service.CreateTodo(todoToCreate) if err != nil { - t.formatter.Respond(nil, err, w, r) - } else { - render.Status(r, 201) - render.JSON(w, r, todo) + return err + } + + render.Status(r, http.StatusCreated) + render.JSON(w, r, todo) + return nil +} + +// @openapi +// paths: +// +// /todos/{id}: +// put: +// tags: +// - todos +// summary: Replace a Todo +// description: Replace a Todo +// operationId: replaceTodo +// parameters: +// - name: id +// in: path +// description: The identifier of the Todo +// required: true +// schema: +// type: string +// requestBody: +// description: Updated Todo +// content: +// application/json: +// schema: +// $ref: '#/components/schemas/TodoUpdate' +// responses: +// '204': +// description: successful operation +// '404': +// $ref: '#/components/responses/NotFound' +func (t *TodoRouter) ReplaceTodo(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + var todoToUpdate types.TodoUpdate + + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&todoToUpdate) + if err != nil { + return err + } + + currentRecord, err := t.service.GetTodo(id) + if err != nil { + return &errors.NotFound{Message: "Todo not found"} + } + + todo := types.Todo{Id: currentRecord.Id, Summary: todoToUpdate.Summary, Done: todoToUpdate.Done} + err = t.service.UpdateTodo(todo) + if err != nil { + return err + } + + render.NoContent(w, r) + return nil +} + +// @openapi +// paths: +// +// /todos/{id}: +// patch: +// tags: +// - todos +// summary: Update a Todo +// description: Update a Todo using [JSON Patch](https://jsonpatch.com/) +// operationId: updateTodo +// parameters: +// - name: id +// in: path +// description: The identifier of the Todo +// required: true +// schema: +// type: string +// requestBody: +// description: JSON Patch operations to perform in order to update the Todo item +// content: +// application/json-patch+json: +// schema: +// type: array +// items: +// $ref: "#/components/schemas/PatchBody" +// example: +// - {"op": "replace", "path": "/summary", "value": "An updated TODO item summary"} +// - {"op": "replace", "path": "/done", "value": true} +// responses: +// '204': +// description: successful operation +// '404': +// $ref: '#/components/responses/NotFound' +func (t *TodoRouter) UpdateTodo(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + + patch, err := jsonpatch.DecodePatch(body) + if err != nil { + return err + } + + currentRecord, err := t.service.GetTodo(id) + if err != nil { + return &errors.NotFound{Message: "Todo not found"} + } + + currentBytes, err := json.Marshal(currentRecord) + if err != nil { + return err + } + + modifiedBytes, err := patch.Apply(currentBytes) + if err != nil { + return err + } + + var modified types.Todo + err = json.Unmarshal(modifiedBytes, &modified) + if err != nil { + return err } + + if modified.Id != currentRecord.Id { + return &errors.BadRequest{Message: "Id field can't be changed"} + } + + err = t.service.UpdateTodo(modified) + if err != nil { + return err + } + + render.NoContent(w, r) + return nil } diff --git a/types/errors.go b/types/errors.go deleted file mode 100644 index b499e34..0000000 --- a/types/errors.go +++ /dev/null @@ -1,30 +0,0 @@ -package types - -// @openapi -// components: -// -// responses: -// NotFound: -// description: The specified resource was not found -// content: -// application/json: -// schema: -// $ref: '#/components/schemas/Error' -// Unauthorized: -// description: Unauthorized -// content: -// application/json: -// schema: -// $ref: '#/components/schemas/Error' -// schemas: -// Error: -// type: object -// properties: -// status: -// type: string -// error: -// type: string -type ErrorResponse struct { - Status string `json:"status"` - Error string `json:"error"` -} diff --git a/types/todo.go b/types/todo.go index 1b1b6e2..8008242 100644 --- a/types/todo.go +++ b/types/todo.go @@ -15,9 +15,14 @@ package types // type: string // description: The Todo's summary // example: Pick up the groceries +// done: +// type: boolean +// description: An indicator that tells if the Todo item is complete +// example: false type Todo struct { Id string `json:"id"` Summary string `json:"summary"` + Done bool `json:"done"` } // @openapi @@ -31,8 +36,13 @@ type Todo struct { // type: string // description: The Todo's summary // example: Pick up the groceries +// done: +// type: boolean +// description: An indicator that tells if the Todo item is complete +// example: false type TodoUpdate struct { Summary string `json:"summary"` + Done bool `json:"done"` } // @openapi