From b3aa2cb1851290cc8ff21a9068726a4c958a76ee Mon Sep 17 00:00:00 2001 From: aaa2ppp Date: Tue, 8 Aug 2023 13:22:39 +0300 Subject: [PATCH] add DELETE /url/{id} --- api/openapi.yaml | 111 ++++++++++++++++++ cmd/url-shortener/main.go | 10 +- config/local.yaml | 8 ++ .../http-server/handlers/url/delete/delete.go | 59 ++++++++++ .../handlers/url/delete/delete_test.go | 99 ++++++++++++++++ .../handlers/url/delete/mocks/URLDeleter.go | 39 ++++++ .../http-server/handlers/url/save/save.go | 6 +- internal/storage/sqlite/sqlite.go | 26 ++++ tests/url_shortener_test.go | 16 ++- 9 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 api/openapi.yaml create mode 100644 config/local.yaml create mode 100644 internal/http-server/handlers/url/delete/delete.go create mode 100644 internal/http-server/handlers/url/delete/delete_test.go create mode 100644 internal/http-server/handlers/url/delete/mocks/URLDeleter.go diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..71504b0 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,111 @@ +openapi: 3.0.1 +info: + title: url-shortener + version: '123' +paths: + /{alias}: + get: + operationId: Redirect + parameters: + - name: alias + in: path + description: Short alias of URL + required: true + schema: + type: string + example: abcd + responses: + '302': + description: redirect + '200': + description: not found + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + /url: + post: + operationId: Save + security: + - basicAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SaveRequest' + required: true + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/SaveResponse' + '401': + description: unauthorized + /url/{id}: + delete: + operationId: Delete + security: + - basicAuth: [] + parameters: + - name: id + in: path + description: URL ID + required: true + schema: + type: integer + format: int64 + example: 10 + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: unauthorized +components: + schemas: + SaveRequest: + required: + - url + type: object + properties: + url: + type: string + format: url + alias: + type: string + SaveResponse: + required: + - status + type: object + properties: + status: + type: string + enum: ["OK", "Error"] + error: + type: string + id: + type: integer + format: int64 + alias: + type: string + url: + type: string + format: url + Response: + required: + - status + properties: + status: + type: string + enum: ["OK", "Error"] + error: + type: string + securitySchemes: + basicAuth: + type: http + scheme: basic diff --git a/cmd/url-shortener/main.go b/cmd/url-shortener/main.go index 82513bc..9c7a0c2 100644 --- a/cmd/url-shortener/main.go +++ b/cmd/url-shortener/main.go @@ -2,18 +2,18 @@ package main import ( "context" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "golang.org/x/exp/slog" "net/http" "os" "os/signal" "syscall" "time" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "golang.org/x/exp/slog" - "url-shortener/internal/config" "url-shortener/internal/http-server/handlers/redirect" + hDelete "url-shortener/internal/http-server/handlers/url/delete" "url-shortener/internal/http-server/handlers/url/save" mwLogger "url-shortener/internal/http-server/middleware/logger" "url-shortener/internal/lib/logger/handlers/slogpretty" @@ -59,7 +59,7 @@ func main() { })) r.Post("/", save.New(log, storage)) - // TODO: add DELETE /url/{id} + r.Delete("/{id}", hDelete.New(log, storage)) }) router.Get("/{alias}", redirect.New(log, storage)) diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..b780453 --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,8 @@ +env: "local" +storage_path: "./storage.db" +http_server: + address: "127.0.0.1:8082" + timeout: 4s + idle_timeout: 30s + user: "myuser" + password: "mypass" \ No newline at end of file diff --git a/internal/http-server/handlers/url/delete/delete.go b/internal/http-server/handlers/url/delete/delete.go new file mode 100644 index 0000000..3c79668 --- /dev/null +++ b/internal/http-server/handlers/url/delete/delete.go @@ -0,0 +1,59 @@ +package delete + +import ( + "errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" + "golang.org/x/exp/slog" + "net/http" + "strconv" + resp "url-shortener/internal/lib/api/response" + "url-shortener/internal/lib/logger/sl" + "url-shortener/internal/storage" +) + +//go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLDeleter +type URLDeleter interface { + DeleteURL(ID int64) error +} + +func New(log *slog.Logger, deleter URLDeleter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + const op = "handlers.url.delete.New" + + log := log.With( + slog.String("op", op), + slog.String("request_id", middleware.GetReqID(r.Context())), + ) + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + log.Error("can't parse url id", sl.Err(err)) + render.JSON(w, r, resp.Error("invalid id")) + return + } + + err = deleter.DeleteURL(id) + + if errors.Is(err, storage.ErrURLNotFound) { + log.Info("url id not found", slog.Int64("id", id)) + + render.JSON(w, r, resp.Error("url id not found")) + + return + } + + if err != nil { + log.Error("failed to delete url", sl.Err(err)) + + render.JSON(w, r, resp.Error("failed to delete url")) + + return + } + + log.Info("url deleted", slog.Int64("id", id)) + + render.JSON(w, r, resp.OK()) + } +} diff --git a/internal/http-server/handlers/url/delete/delete_test.go b/internal/http-server/handlers/url/delete/delete_test.go new file mode 100644 index 0000000..bf71045 --- /dev/null +++ b/internal/http-server/handlers/url/delete/delete_test.go @@ -0,0 +1,99 @@ +package delete_test + +import ( + "encoding/json" + "errors" + "github.com/go-chi/chi/v5" + "net/http" + "net/http/httptest" + "testing" + resp2 "url-shortener/internal/lib/api/response" + "url-shortener/internal/lib/logger/handlers/slogdiscard" + "url-shortener/internal/storage" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "url-shortener/internal/http-server/handlers/url/delete" + "url-shortener/internal/http-server/handlers/url/delete/mocks" +) + +func TestDeleteHandler(t *testing.T) { + cases := []struct { + name string + uri string + respError string + mockError error + code int + }{ + { + name: "Success", + uri: "/url/10", + code: http.StatusOK, + }, + { + name: "Invalid ID", + uri: "/url/XXX", + respError: "invalid id", + code: http.StatusOK, + }, + { + name: "Omitted ID", + uri: "/url/", + respError: "Don't delete it! We do not check this message because it is generated" + + " by the router. But it must not be empty for the test to work correctly.", + code: http.StatusNotFound, + }, + { + name: "ID Not Found", + uri: "/url/10", + respError: "url id not found", + mockError: storage.ErrURLNotFound, + code: http.StatusOK, + }, + { + name: "Delete Error", + uri: "/url/10", + respError: "failed to delete url", + mockError: errors.New("unexpected error"), + code: http.StatusOK, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + urlDeleterMock := mocks.NewURLDeleter(t) + + if tc.respError == "" || tc.mockError != nil { + urlDeleterMock.On("DeleteURL", mock.AnythingOfType("int64")). + Return(tc.mockError). + Once() + } + + handler := chi.NewRouter() + handler.Delete("/url/{id}", delete.New(slogdiscard.NewDiscardLogger(), urlDeleterMock)) + + req, err := http.NewRequest(http.MethodDelete, tc.uri, nil) + require.NoError(t, err) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, rr.Code, tc.code) + + if rr.Code == http.StatusOK { + body := rr.Body.String() + + var resp resp2.Response + + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + + require.Equal(t, tc.respError, resp.Error) + } + }) + } +} diff --git a/internal/http-server/handlers/url/delete/mocks/URLDeleter.go b/internal/http-server/handlers/url/delete/mocks/URLDeleter.go new file mode 100644 index 0000000..8520319 --- /dev/null +++ b/internal/http-server/handlers/url/delete/mocks/URLDeleter.go @@ -0,0 +1,39 @@ +// Code generated by mockery v2.28.2. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// URLDeleter is an autogenerated mock type for the URLDeleter type +type URLDeleter struct { + mock.Mock +} + +// DeleteURL provides a mock function with given fields: ID +func (_m *URLDeleter) DeleteURL(ID int64) error { + ret := _m.Called(ID) + + var r0 error + if rf, ok := ret.Get(0).(func(int64) error); ok { + r0 = rf(ID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewURLDeleter interface { + mock.TestingT + Cleanup(func()) +} + +// NewURLDeleter creates a new instance of URLDeleter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewURLDeleter(t mockConstructorTestingTNewURLDeleter) *URLDeleter { + mock := &URLDeleter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/http-server/handlers/url/save/save.go b/internal/http-server/handlers/url/save/save.go index 3928294..10015ff 100644 --- a/internal/http-server/handlers/url/save/save.go +++ b/internal/http-server/handlers/url/save/save.go @@ -23,6 +23,7 @@ type Request struct { type Response struct { resp.Response + ID int64 `json:"id,omitempty"` Alias string `json:"alias,omitempty"` } @@ -98,13 +99,14 @@ func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc { log.Info("url added", slog.Int64("id", id)) - responseOK(w, r, alias) + responseOK(w, r, id, alias) } } -func responseOK(w http.ResponseWriter, r *http.Request, alias string) { +func responseOK(w http.ResponseWriter, r *http.Request, id int64, alias string) { render.JSON(w, r, Response{ Response: resp.OK(), + ID: id, Alias: alias, }) } diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 4ef81ce..74f735a 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -90,3 +90,29 @@ func (s *Storage) GetURL(alias string) (string, error) { // TODO: implement method // func (s *Storage) DeleteURL(alias string) error +// Hmm?.. the main.go file said: `TODO: add DELETE /url/{id}' + +func (s *Storage) DeleteURL(urlID int64) error { + const op = "storage.sqlite.DeleteURL" + + stmt, err := s.db.Prepare("DELETE FROM url WHERE id = ?") + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + res, err := stmt.Exec(urlID) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + if n == 0 { + return fmt.Errorf("%s: %w", op, storage.ErrURLNotFound) + } + + return nil +} diff --git a/tests/url_shortener_test.go b/tests/url_shortener_test.go index f1693be..1551761 100644 --- a/tests/url_shortener_test.go +++ b/tests/url_shortener_test.go @@ -1,6 +1,7 @@ package tests import ( + "fmt" "net/http" "net/url" "testing" @@ -34,11 +35,13 @@ func TestURLShortener_HappyPath(t *testing.T) { Expect(). Status(200). JSON().Object(). + ContainsKey("status"). + ContainsKey("id"). ContainsKey("alias") } //nolint:funlen -func TestURLShortener_SaveRedirect(t *testing.T) { +func TestURLShortener_SaveRedirectDelete(t *testing.T) { testCases := []struct { name string url string @@ -102,9 +105,20 @@ func TestURLShortener_SaveRedirect(t *testing.T) { alias = resp.Value("alias").String().Raw() } + id := int64(resp.Value("id").Number().Raw()) + // Redirect testRedirect(t, alias, tc.url) + + // Delete + + resp = e.DELETE(fmt.Sprintf("/url/%d", id)). + WithBasicAuth("myuser", "mypass"). + Expect().Status(http.StatusOK). + JSON().Object() + + require.Equal(t, resp.Value("status").Raw(), "OK") }) } }