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"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"strings"
"time"
"code.ewintr.nl/planner/planner"
"code.ewintr.nl/planner/storage"
)
func Index(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"ok"}`)
type Server struct {
syncer storage.Syncer
logger *slog.Logger
}
type ChangeSummary struct {
Updated []planner.Syncable
Deleted []string
func NewServer(syncer storage.Syncer, logger *slog.Logger) *Server {
return &Server{
syncer: syncer,
logger: logger,
}
}
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:
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 != "" {
@ -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 {
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)
body, err := json.Marshal(items)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprint(w, string(body))
}
case http.MethodPost:
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)
@ -67,24 +79,36 @@ func NewSyncHandler(mem storage.Syncer) func(w http.ResponseWriter, r *http.Requ
}
defer r.Body.Close()
var changes ChangeSummary
if err := json.Unmarshal(body, changes); err != nil {
var items []planner.Syncable
if err := json.Unmarshal(body, &items); 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 {
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"}`)
}

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
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")
}

View File

@ -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 {

View File

@ -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
}
func NewMemory() *Memory {
return &Memory{
items: make(map[string]planner.Syncable),
deleted: make([]deletedItem, 0),
}
}
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
}

View File

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

View File

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