From faafe1a59bfcbcba665a7963436f914079439fc7 Mon Sep 17 00:00:00 2001 From: Erik Winter Date: Wed, 28 Aug 2024 07:21:02 +0200 Subject: [PATCH] server sync refactor --- handler/handler.go | 172 +++++++++++++++++++--------------- handler/handler_test.go | 201 ++++++++++++++++++++++++++++++++++++++++ main.go | 16 +++- planner/planner.go | 16 +++- storage/memory.go | 39 +------- storage/memory_test.go | 64 ++++--------- storage/storage.go | 2 - 7 files changed, 348 insertions(+), 162 deletions(-) create mode 100644 handler/handler_test.go diff --git a/handler/handler.go b/handler/handler.go index 698e5a7..dd77c35 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -4,87 +4,111 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" + "path" + "strings" "time" "code.ewintr.nl/planner/planner" "code.ewintr.nl/planner/storage" ) +type Server struct { + syncer storage.Syncer + logger *slog.Logger +} + +func NewServer(syncer storage.Syncer, logger *slog.Logger) *Server { + return &Server{ + syncer: syncer, + logger: logger, + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + Index(w, r) + return + } + + head, tail := ShiftPath(r.URL.Path) + switch { + case head == "sync" && tail != "/": + http.Error(w, "not found", http.StatusNotFound) + case head == "sync" && r.Method == http.MethodGet: + s.SyncGet(w, r) + case head == "sync" && r.Method == http.MethodPost: + s.SyncPost(w, r) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (s *Server) SyncGet(w http.ResponseWriter, r *http.Request) { + timestamp := time.Time{} + tsStr := r.URL.Query().Get("ts") + if tsStr != "" { + var err error + if timestamp, err = time.Parse(time.RFC3339, tsStr); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + + items, err := s.syncer.Updated(timestamp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + body, err := json.Marshal(items) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Fprint(w, string(body)) +} + +func (s *Server) SyncPost(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer r.Body.Close() + + var items []planner.Syncable + if err := json.Unmarshal(body, &items); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + for _, item := range items { + item.Updated = time.Now() + if err := s.syncer.Update(item); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusNoContent) + +} + +// ShiftPath splits off the first component of p, which will be cleaned of +// relative components before processing. head will never contain a slash and +// tail will always be a rooted path without trailing slash. +// See https://blog.merovius.de/posts/2017-06-18-how-not-to-use-an-http-router/ +func ShiftPath(p string) (head, tail string) { + p = path.Clean("/" + p) + i := strings.Index(p[1:], "/") + 1 + if i <= 0 { + return p[1:], "/" + } + return p[1:i], p[i:] +} + func Index(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"status":"ok"}`) } - -type ChangeSummary struct { - Updated []planner.Syncable - Deleted []string -} - -func NewSyncHandler(mem storage.Syncer) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - timestamp := time.Time{} - tsStr := r.URL.Query().Get("ts") - if tsStr != "" { - var err error - if timestamp, err = time.Parse(time.RFC3339, tsStr); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - } - - items, err := mem.Updated(timestamp) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - deleted, err := mem.Deleted(timestamp) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - result := ChangeSummary{ - Updated: items, - Deleted: deleted, - } - - body, err := json.Marshal(result) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - fmt.Fprint(w, string(body)) - - case http.MethodPost: - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - defer r.Body.Close() - - var changes ChangeSummary - if err := json.Unmarshal(body, changes); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } - - for _, updated := range changes.Updated { - if err := mem.Update(updated); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - for _, deleted := range changes.Deleted { - if err := mem.Delete(deleted); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - w.WriteHeader(http.StatusNoContent) - } - } -} diff --git a/handler/handler_test.go b/handler/handler_test.go new file mode 100644 index 0000000..278ff0f --- /dev/null +++ b/handler/handler_test.go @@ -0,0 +1,201 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sort" + "testing" + "time" + + "code.ewintr.nl/planner/handler" + "code.ewintr.nl/planner/planner" + "code.ewintr.nl/planner/storage" +) + +func TestSyncGet(t *testing.T) { + t.Parallel() + + now := time.Now() + mem := storage.NewMemory() + + items := []planner.Syncable{ + {ID: "id-0", Updated: now.Add(-10 * time.Minute)}, + {ID: "id-1", Updated: now.Add(-5 * time.Minute)}, + {ID: "id-2", Updated: now.Add(time.Minute)}, + } + + for _, item := range items { + if err := mem.Update(item); err != nil { + t.Errorf("exp nil, got %v", err) + } + } + + srv := handler.NewServer(mem, slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + for _, tc := range []struct { + name string + ts time.Time + expStatus int + expItems []planner.Syncable + }{ + { + name: "full", + expStatus: http.StatusOK, + expItems: items, + }, + { + name: "normal", + ts: now.Add(-6 * time.Minute), + expStatus: http.StatusOK, + expItems: []planner.Syncable{items[1], items[2]}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + url := fmt.Sprintf("/sync?ts=%s", url.QueryEscape(tc.ts.Format(time.RFC3339))) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + + if res.Result().StatusCode != tc.expStatus { + t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode) + } + var actItems []planner.Syncable + actBody, err := io.ReadAll(res.Result().Body) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + defer res.Result().Body.Close() + + if err := json.Unmarshal(actBody, &actItems); err != nil { + t.Errorf("exp nil, got %v", err) + } + + if len(actItems) != len(tc.expItems) { + t.Errorf("exp %d, got %d", len(tc.expItems), len(actItems)) + } + sort.Slice(actItems, func(i, j int) bool { + return actItems[i].ID < actItems[j].ID + }) + for i := range actItems { + if actItems[i].ID != tc.expItems[i].ID { + t.Errorf("exp %v, got %v", tc.expItems[i].ID, actItems[i].ID) + } + } + }) + } +} + +func TestSyncPost(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + reqBody []byte + expStatus int + expItems []planner.Syncable + }{ + { + name: "empty", + expStatus: http.StatusBadRequest, + }, + { + name: "invalid", + reqBody: []byte(`{"fail}`), + expStatus: http.StatusBadRequest, + }, + { + name: "normal", + reqBody: []byte(`[ + {"ID":"id-1","Updated":"2024-09-06T08:00:00Z","Deleted":false,"Item":""}, + {"ID":"id-2","Updated":"2024-09-06T08:12:00Z","Deleted":false,"Item":""} +]`), + expStatus: http.StatusNoContent, + expItems: []planner.Syncable{ + {ID: "id-1", Updated: time.Date(2024, 9, 6, 8, 0, 0, 0, time.UTC)}, + {ID: "id-2", Updated: time.Date(2024, 9, 6, 12, 0, 0, 0, time.UTC)}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mem := storage.NewMemory() + srv := handler.NewServer(mem, slog.New(slog.NewJSONHandler(os.Stdout, nil))) + req, err := http.NewRequest(http.MethodPost, "/sync", bytes.NewBuffer(tc.reqBody)) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + + if res.Result().StatusCode != tc.expStatus { + t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode) + } + + actItems, err := mem.Updated(time.Time{}) + if err != nil { + t.Errorf("exp nil, git %v", err) + } + if len(actItems) != len(tc.expItems) { + t.Errorf("exp %d, got %d", len(tc.expItems), len(actItems)) + } + sort.Slice(actItems, func(i, j int) bool { + return actItems[i].ID < actItems[j].ID + }) + for i := range actItems { + if actItems[i].ID != tc.expItems[i].ID { + t.Errorf("exp %v, got %v", tc.expItems[i].ID, actItems[i].ID) + } + } + }) + } +} + +/* +func TestSyncHandler(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + items []item + method string + body handler.ChangeSummary + expStatus int + expBody handler.ChangeSummary + }{ + { + name: "empty", + expStatus: http.StatusOK, + }, + { + name: "full sync", + }, + } { + t.Run(tc.name, func(t *testing.T) { + mem := storage.NewMemory() + for _, i := range tc.items { + mem.Update(i) + } + sh := handler.NewSyncHandler(mem) + req, err := http.NewRequest(tc.method, "/sync", nil) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + rec := httptest.NewRecorder() + sh(rec, req) + res := rec.Result() + if res.StatusCode != tc.expStatus { + t.Errorf("exp %d, got %d", tc.expStatus, res.StatusCode) + } + }) + } +} +*/ diff --git a/main.go b/main.go index f012966..731699d 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,11 @@ package main import ( + "log/slog" "net/http" + "os" + "os/signal" + "syscall" "code.ewintr.nl/planner/handler" "code.ewintr.nl/planner/storage" @@ -9,9 +13,15 @@ import ( func main() { mem := storage.NewMemory() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - http.HandleFunc("/", handler.Index) - http.HandleFunc("/sync", handler.NewSyncHandler(mem)) + go http.ListenAndServe(":8092", handler.NewServer(mem, logger)) - http.ListenAndServe(":8092", nil) + logger.Info("service started") + + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + <-c + + logger.Info("service stopped") } diff --git a/planner/planner.go b/planner/planner.go index 7ccca43..6737e96 100644 --- a/planner/planner.go +++ b/planner/planner.go @@ -6,9 +6,19 @@ import ( "github.com/google/uuid" ) -type Syncable interface { - ID() string - Updated() time.Time +type Syncable struct { + ID string + Updated time.Time + Deleted bool + Item string +} + +func NewSyncable(item string) Syncable { + return Syncable{ + ID: uuid.New().String(), + Updated: time.Now(), + Item: item, + } } type Task struct { diff --git a/storage/memory.go b/storage/memory.go index a28a3b0..c3db03a 100644 --- a/storage/memory.go +++ b/storage/memory.go @@ -6,25 +6,18 @@ import ( "code.ewintr.nl/planner/planner" ) -type deletedItem struct { - ID string - Timestamp time.Time -} - type Memory struct { - items map[string]planner.Syncable - deleted []deletedItem + items map[string]planner.Syncable } func NewMemory() *Memory { return &Memory{ - items: make(map[string]planner.Syncable), - deleted: make([]deletedItem, 0), + items: make(map[string]planner.Syncable), } } func (m *Memory) Update(item planner.Syncable) error { - m.items[item.ID()] = item + m.items[item.ID] = item return nil } @@ -33,34 +26,10 @@ func (m *Memory) Updated(timestamp time.Time) ([]planner.Syncable, error) { result := make([]planner.Syncable, 0) for _, i := range m.items { - if timestamp.IsZero() || i.Updated().Equal(timestamp) || i.Updated().After(timestamp) { + if timestamp.IsZero() || i.Updated.Equal(timestamp) || i.Updated.After(timestamp) { result = append(result, i) } } return result, nil } - -func (m *Memory) Delete(id string) error { - if _, exists := m.items[id]; !exists { - return ErrNotFound - } - - delete(m.items, id) - m.deleted = append(m.deleted, deletedItem{ - ID: id, - Timestamp: time.Now(), - }) - - return nil -} - -func (m *Memory) Deleted(t time.Time) ([]string, error) { - result := make([]string, 0) - for _, di := range m.deleted { - if di.Timestamp.Equal(t) || di.Timestamp.After(t) { - result = append(result, di.ID) - } - } - return result, nil -} diff --git a/storage/memory_test.go b/storage/memory_test.go index e1d99b4..e50f9e6 100644 --- a/storage/memory_test.go +++ b/storage/memory_test.go @@ -1,7 +1,6 @@ package storage_test import ( - "errors" "testing" "time" @@ -24,7 +23,7 @@ func TestMemoryItem(t *testing.T) { } t.Log("add one") - t1 := planner.NewTask("test") + t1 := planner.NewSyncable("test") if actErr := mem.Update(t1); actErr != nil { t.Errorf("exp nil, got %v", actErr) } @@ -35,14 +34,14 @@ func TestMemoryItem(t *testing.T) { if len(actItems) != 1 { t.Errorf("exp 1, gor %d", len(actItems)) } - if actItems[0].ID() != t1.ID() { - t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) + if actItems[0].ID != t1.ID { + t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID) } before := time.Now() t.Log("add second") - t2 := planner.NewTask("test 2") + t2 := planner.NewSyncable("test 2") if actErr := mem.Update(t2); actErr != nil { t.Errorf("exp nil, got %v", actErr) } @@ -53,18 +52,11 @@ func TestMemoryItem(t *testing.T) { if len(actItems) != 2 { t.Errorf("exp 2, gor %d", len(actItems)) } - if actItems[0].ID() != t1.ID() { - t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) + if actItems[0].ID != t1.ID { + t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID) } - if actItems[1].ID() != t2.ID() { - t.Errorf("exp %v, got %v", actItems[1].ID(), t2.ID()) - } - actDeleted, actErr := mem.Deleted(time.Time{}) - if actErr != nil { - t.Errorf("exp nil, got %v", actErr) - } - if len(actDeleted) != 0 { - t.Errorf("exp 0, got %d", len(actDeleted)) + if actItems[1].ID != t2.ID { + t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID) } actItems, actErr = mem.Updated(before) @@ -74,44 +66,26 @@ func TestMemoryItem(t *testing.T) { if len(actItems) != 1 { t.Errorf("exp 1, gor %d", len(actItems)) } - if actItems[0].ID() != t2.ID() { - t.Errorf("exp %v, got %v", actItems[0].ID(), t2.ID()) + if actItems[0].ID != t2.ID { + t.Errorf("exp %v, got %v", actItems[0].ID, t2.ID) } - t.Log("remove first") - if actErr := mem.Delete(t1.ID()); actErr != nil { + t.Log("update first") + t1.Updated = time.Now() + if actErr := mem.Update(t1); actErr != nil { t.Errorf("exp nil, got %v", actErr) } - actItems, actErr = mem.Updated(time.Time{}) + actItems, actErr = mem.Updated(before) if actErr != nil { t.Errorf("exp nil, got %v", actErr) } - if len(actItems) != 1 { + if len(actItems) != 2 { t.Errorf("exp 2, gor %d", len(actItems)) } - if actItems[0].ID() != t2.ID() { - t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) + if actItems[0].ID != t1.ID { + t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID) } - actDeleted, actErr = mem.Deleted(time.Time{}) - if actErr != nil { - t.Errorf("exp nil, got %v", actErr) - } - if len(actDeleted) != 1 { - t.Errorf("exp 1, got %d", len(actDeleted)) - } - if actDeleted[0] != t1.ID() { - t.Errorf("exp %v, got %v", actDeleted[0], t1.ID()) - } - actDeleted, actErr = mem.Deleted(time.Now()) - if actErr != nil { - t.Errorf("exp nil, got %v", actErr) - } - if len(actDeleted) != 0 { - t.Errorf("exp 0, got %d", len(actDeleted)) - } - - t.Log("remove non-existing") - if actErr := mem.Delete("test"); !errors.Is(actErr, storage.ErrNotFound) { - t.Errorf("exp %v, got %v", storage.ErrNotFound, actErr) + if actItems[1].ID != t2.ID { + t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID) } } diff --git a/storage/storage.go b/storage/storage.go index 375110e..ee743c1 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -14,6 +14,4 @@ var ( type Syncer interface { Update(item planner.Syncable) error Updated(t time.Time) ([]planner.Syncable, error) - Delete(id string) error - Deleted(t time.Time) ([]string, error) }