sync refactor
This commit is contained in:
Erik Winter 2024-08-28 07:21:02 +02:00 committed by Erik Winter
parent 5f060e0470
commit faafe1a59b
7 changed files with 348 additions and 162 deletions

View File

@ -4,26 +4,48 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"path"
"strings"
"time" "time"
"code.ewintr.nl/planner/planner" "code.ewintr.nl/planner/planner"
"code.ewintr.nl/planner/storage" "code.ewintr.nl/planner/storage"
) )
func Index(w http.ResponseWriter, r *http.Request) { type Server struct {
fmt.Fprint(w, `{"status":"ok"}`) syncer storage.Syncer
logger *slog.Logger
} }
type ChangeSummary struct { func NewServer(syncer storage.Syncer, logger *slog.Logger) *Server {
Updated []planner.Syncable return &Server{
Deleted []string syncer: syncer,
logger: logger,
}
} }
func NewSyncHandler(mem storage.Syncer) func(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" {
switch r.Method { Index(w, r)
case http.MethodGet: 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{} timestamp := time.Time{}
tsStr := r.URL.Query().Get("ts") tsStr := r.URL.Query().Get("ts")
if tsStr != "" { if tsStr != "" {
@ -34,32 +56,22 @@ func NewSyncHandler(mem storage.Syncer) func(w http.ResponseWriter, r *http.Requ
} }
} }
items, err := mem.Updated(timestamp) items, err := s.syncer.Updated(timestamp)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
deleted, err := mem.Deleted(timestamp) body, err := json.Marshal(items)
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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, string(body)) fmt.Fprint(w, string(body))
}
case http.MethodPost: func (s *Server) SyncPost(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -67,24 +79,36 @@ func NewSyncHandler(mem storage.Syncer) func(w http.ResponseWriter, r *http.Requ
} }
defer r.Body.Close() defer r.Body.Close()
var changes ChangeSummary var items []planner.Syncable
if err := json.Unmarshal(body, changes); err != nil { if err := json.Unmarshal(body, &items); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) 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 return
} }
}
for _, deleted := range changes.Deleted { for _, item := range items {
if err := mem.Delete(deleted); err != nil { item.Updated = time.Now()
if err := s.syncer.Update(item); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
} }
w.WriteHeader(http.StatusNoContent) 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"}`)
} }

201
handler/handler_test.go Normal file
View File

@ -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)
}
})
}
}
*/

16
main.go
View File

@ -1,7 +1,11 @@
package main package main
import ( import (
"log/slog"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"code.ewintr.nl/planner/handler" "code.ewintr.nl/planner/handler"
"code.ewintr.nl/planner/storage" "code.ewintr.nl/planner/storage"
@ -9,9 +13,15 @@ import (
func main() { func main() {
mem := storage.NewMemory() mem := storage.NewMemory()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
http.HandleFunc("/", handler.Index) go http.ListenAndServe(":8092", handler.NewServer(mem, logger))
http.HandleFunc("/sync", handler.NewSyncHandler(mem))
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")
} }

View File

@ -6,9 +6,19 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type Syncable interface { type Syncable struct {
ID() string ID string
Updated() time.Time 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 { type Task struct {

View File

@ -6,25 +6,18 @@ import (
"code.ewintr.nl/planner/planner" "code.ewintr.nl/planner/planner"
) )
type deletedItem struct {
ID string
Timestamp time.Time
}
type Memory struct { type Memory struct {
items map[string]planner.Syncable items map[string]planner.Syncable
deleted []deletedItem
} }
func NewMemory() *Memory { func NewMemory() *Memory {
return &Memory{ return &Memory{
items: make(map[string]planner.Syncable), items: make(map[string]planner.Syncable),
deleted: make([]deletedItem, 0),
} }
} }
func (m *Memory) Update(item planner.Syncable) error { func (m *Memory) Update(item planner.Syncable) error {
m.items[item.ID()] = item m.items[item.ID] = item
return nil return nil
} }
@ -33,34 +26,10 @@ func (m *Memory) Updated(timestamp time.Time) ([]planner.Syncable, error) {
result := make([]planner.Syncable, 0) result := make([]planner.Syncable, 0)
for _, i := range m.items { 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) result = append(result, i)
} }
} }
return result, nil 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
}

View File

@ -1,7 +1,6 @@
package storage_test package storage_test
import ( import (
"errors"
"testing" "testing"
"time" "time"
@ -24,7 +23,7 @@ func TestMemoryItem(t *testing.T) {
} }
t.Log("add one") t.Log("add one")
t1 := planner.NewTask("test") t1 := planner.NewSyncable("test")
if actErr := mem.Update(t1); actErr != nil { if actErr := mem.Update(t1); actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -35,14 +34,14 @@ func TestMemoryItem(t *testing.T) {
if len(actItems) != 1 { if len(actItems) != 1 {
t.Errorf("exp 1, gor %d", len(actItems)) t.Errorf("exp 1, gor %d", len(actItems))
} }
if actItems[0].ID() != t1.ID() { if actItems[0].ID != t1.ID {
t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID)
} }
before := time.Now() before := time.Now()
t.Log("add second") t.Log("add second")
t2 := planner.NewTask("test 2") t2 := planner.NewSyncable("test 2")
if actErr := mem.Update(t2); actErr != nil { if actErr := mem.Update(t2); actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -53,18 +52,11 @@ func TestMemoryItem(t *testing.T) {
if len(actItems) != 2 { if len(actItems) != 2 {
t.Errorf("exp 2, gor %d", len(actItems)) t.Errorf("exp 2, gor %d", len(actItems))
} }
if actItems[0].ID() != t1.ID() { if actItems[0].ID != t1.ID {
t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID)
} }
if actItems[1].ID() != t2.ID() { if actItems[1].ID != t2.ID {
t.Errorf("exp %v, got %v", 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))
} }
actItems, actErr = mem.Updated(before) actItems, actErr = mem.Updated(before)
@ -74,44 +66,26 @@ func TestMemoryItem(t *testing.T) {
if len(actItems) != 1 { if len(actItems) != 1 {
t.Errorf("exp 1, gor %d", len(actItems)) t.Errorf("exp 1, gor %d", len(actItems))
} }
if actItems[0].ID() != t2.ID() { if actItems[0].ID != t2.ID {
t.Errorf("exp %v, got %v", actItems[0].ID(), t2.ID()) t.Errorf("exp %v, got %v", actItems[0].ID, t2.ID)
} }
t.Log("remove first") t.Log("update first")
if actErr := mem.Delete(t1.ID()); actErr != nil { t1.Updated = time.Now()
if actErr := mem.Update(t1); actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
actItems, actErr = mem.Updated(time.Time{}) actItems, actErr = mem.Updated(before)
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
if len(actItems) != 1 { if len(actItems) != 2 {
t.Errorf("exp 2, gor %d", len(actItems)) t.Errorf("exp 2, gor %d", len(actItems))
} }
if actItems[0].ID() != t2.ID() { if actItems[0].ID != t1.ID {
t.Errorf("exp %v, got %v", actItems[0].ID(), t1.ID()) t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID)
} }
actDeleted, actErr = mem.Deleted(time.Time{}) if actItems[1].ID != t2.ID {
if actErr != nil { t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID)
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)
} }
} }

View File

@ -14,6 +14,4 @@ var (
type Syncer interface { type Syncer interface {
Update(item planner.Syncable) error Update(item planner.Syncable) error
Updated(t time.Time) ([]planner.Syncable, error) Updated(t time.Time) ([]planner.Syncable, error)
Delete(id string) error
Deleted(t time.Time) ([]string, error)
} }