parent
5f060e0470
commit
faafe1a59b
|
@ -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"}`)
|
||||
}
|
||||
|
|
|
@ -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
16
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")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue