Compare commits

...

10 Commits

Author SHA1 Message Date
Erik Winter 5ddeb5bb5a client 2024-09-13 07:52:28 +02:00
Erik Winter 423effa1b4 remove serlfsigned cert 2024-09-12 07:19:30 +02:00
Erik Winter 1e2b0dcdb3 mv sync 2024-09-11 07:37:53 +02:00
Erik Winter 5c95750a0a add kind 2024-09-11 07:33:22 +02:00
Erik Winter 4f21fd8aa8 docker push command, config logging 2024-09-10 14:30:05 +02:00
Erik Winter cb1af81da7 add https 2024-09-10 14:01:13 +02:00
Erik Winter 6fb35315fc docker 2024-09-09 16:36:03 +02:00
Erik Winter cb500b7558 valiation and logging 2024-09-09 07:56:01 +02:00
Erik Winter fdbefeffe2 some input validation 2024-09-09 07:41:34 +02:00
Erik Winter 32bed5acc8 rename syncable 2024-09-09 07:36:05 +02:00
19 changed files with 502 additions and 147 deletions

View File

@ -1,3 +1,6 @@
run: sync-run:
PLANNER_PORT=8092 PLANNER_API_KEY=testKey go run ./sync-service/ cd sync && PLANNER_DB_PATH=test.db PLANNER_PORT=8092 PLANNER_API_KEY=testKey go run .
sync-build-and-push:
cd sync && docker build . -t codeberg.org/ewintr/syncservice && docker push codeberg.org/ewintr/syncservice

87
cal/client.go Normal file
View File

@ -0,0 +1,87 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
c *http.Client
}
func NewClient(url, apiKey string) *Client {
return &Client{
baseURL: url,
apiKey: apiKey,
c: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Update(items []Item) error {
body, err := json.Marshal(items)
if err != nil {
return fmt.Errorf("could not marhal body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/sync", c.baseURL), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("could not create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
res, err := c.c.Do(req)
if err != nil {
return fmt.Errorf("could not make request: %v", err)
}
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("server returned status %d", res.StatusCode)
}
return nil
}
func (c *Client) Updated(ks []Kind, ts time.Time) ([]Item, error) {
ksStr := make([]string, 0, len(ks))
for _, k := range ks {
ksStr = append(ksStr, string(k))
}
u := fmt.Sprintf("%s/sync?ks=%s", c.baseURL, strings.Join(ksStr, ","))
if !ts.IsZero() {
u = fmt.Sprintf("%s&ts=", url.QueryEscape(ts.Format(time.RFC3339)))
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("could not create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
res, err := c.c.Do(req)
if err != nil {
return nil, fmt.Errorf("could not get response: %v", err)
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status %d", res.StatusCode)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("could not read response body: %v", err)
}
var items []Item
if err := json.Unmarshal(body, &items); err != nil {
return nil, fmt.Errorf("could not unmarshal response body: %v", err)
}
return items, nil
}

25
cal/go.mod Normal file
View File

@ -0,0 +1,25 @@
module code.ewintr.nl/planner/cal
go 1.21.5
require (
code.ewintr.nl/planner/sync v0.0.0-20240912051930-423effa1b447
github.com/google/uuid v1.6.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
replace code.ewintr.nl/planner/sync => ../sync

View File

@ -1,7 +1,7 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@ -10,11 +10,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=

39
cal/main.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"fmt"
"os"
"time"
)
func main() {
fmt.Println("cal")
c := NewClient("http://localhost:8092", "testKey")
items, err := c.Updated([]Kind{KindEvent}, time.Time{})
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Printf("%+v\n", items)
i := Item{
ID: "id-1",
Kind: KindEvent,
Updated: time.Now(),
Body: "body",
}
if err := c.Update([]Item{i}); err != nil {
fmt.Println(err)
os.Exit(1)
}
items, err = c.Updated([]Kind{KindEvent}, time.Time{})
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Printf("%+v\n", items)
}

35
cal/planner.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"time"
"github.com/google/uuid"
)
type Kind string
const (
KindTask Kind = "task"
KindEvent Kind = "event"
)
var (
KnownKinds = []Kind{KindTask, KindEvent}
)
type Item struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Updated time.Time `json:"updated"`
Deleted bool `json:"deleted"`
Body string `json:"body"`
}
func NewItem(k Kind, body string) Item {
return Item{
ID: uuid.New().String(),
Kind: k,
Updated: time.Now(),
Body: body,
}
}

View File

@ -1,33 +0,0 @@
package main
import (
"time"
)
type Memory struct {
items map[string]Syncable
}
func NewMemory() *Memory {
return &Memory{
items: make(map[string]Syncable),
}
}
func (m *Memory) Update(item Syncable) error {
m.items[item.ID] = item
return nil
}
func (m *Memory) Updated(timestamp time.Time) ([]Syncable, error) {
result := make([]Syncable, 0)
for _, i := range m.items {
if timestamp.IsZero() || i.Updated.Equal(timestamp) || i.Updated.After(timestamp) {
result = append(result, i)
}
}
return result, nil
}

View File

@ -1,51 +0,0 @@
package main
import (
"time"
"github.com/google/uuid"
)
type Kind string
const (
KindTask Kind = "task"
)
type Syncable struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Updated time.Time `json:"updated"`
Deleted bool `json:"deleted"`
Item string `json:"item"`
}
func NewSyncable(item string) Syncable {
return Syncable{
ID: uuid.New().String(),
Updated: time.Now(),
Item: item,
}
}
type Task struct {
id string
description string
updated time.Time
}
func NewTask(description string) Task {
return Task{
id: uuid.New().String(),
description: description,
updated: time.Now(),
}
}
func (t Task) ID() string {
return t.id
}
func (t Task) Updated() time.Time {
return t.updated
}

11
sync/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM golang:1.23-alpine
RUN apk add --no-cache git
WORKDIR /src
COPY . ./
RUN go mod download
RUN go build -o syncservice
CMD ["./syncservice"]

View File

@ -1,11 +1,14 @@
module code.ewintr.nl/planner module code.ewintr.nl/planner/sync
go 1.21.5 go 1.21.5
require (
github.com/google/uuid v1.6.0
modernc.org/sqlite v1.33.0
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
@ -14,7 +17,6 @@ require (
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.0 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

37
sync/go.sum Normal file
View File

@ -0,0 +1,37 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk=
modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"path" "path"
"slices"
"strings" "strings"
"time" "time"
) )
@ -33,7 +34,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", s.apiKey) { if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", s.apiKey) {
http.Error(w, `{"error":"not authorized"}`, http.StatusUnauthorized) msg := "not authorized"
http.Error(w, fmtError(msg), http.StatusUnauthorized)
s.logger.Info(msg)
return return
} }
@ -46,7 +49,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case head == "sync" && r.Method == http.MethodPost: case head == "sync" && r.Method == http.MethodPost:
s.SyncPost(w, r) s.SyncPost(w, r)
default: default:
http.Error(w, `{"error":"not found"}`, http.StatusNotFound) msg := "not found"
http.Error(w, fmtError(msg), http.StatusNotFound)
s.logger.Info(msg)
} }
} }
@ -56,20 +61,39 @@ func (s *Server) SyncGet(w http.ResponseWriter, r *http.Request) {
if tsStr != "" { if tsStr != "" {
var err error var err error
if timestamp, err = time.Parse(time.RFC3339, tsStr); err != nil { if timestamp, err = time.Parse(time.RFC3339, tsStr); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return return
} }
} }
ks := make([]Kind, 0)
ksStr := r.URL.Query().Get("ks")
if ksStr != "" {
for _, k := range strings.Split(ksStr, ",") {
if !slices.Contains(KnownKinds, Kind(k)) {
msg := fmt.Sprintf("unknown kind: %s", k)
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return
}
ks = append(ks, Kind(k))
}
}
items, err := s.syncer.Updated(timestamp) items, err := s.syncer.Updated(ks, timestamp)
if err != nil { if err != nil {
http.Error(w, fmtError(err), http.StatusInternalServerError) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusInternalServerError)
s.logger.Error(msg)
return return
} }
body, err := json.Marshal(items) body, err := json.Marshal(items)
if err != nil { if err != nil {
http.Error(w, fmtError(err), http.StatusInternalServerError) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusInternalServerError)
s.logger.Error(msg)
return return
} }
@ -80,21 +104,51 @@ func (s *Server) SyncGet(w http.ResponseWriter, r *http.Request) {
func (s *Server) SyncPost(w http.ResponseWriter, r *http.Request) { 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, fmtError(err), http.StatusBadRequest) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return return
} }
defer r.Body.Close() defer r.Body.Close()
var items []Syncable var items []Item
if err := json.Unmarshal(body, &items); err != nil { if err := json.Unmarshal(body, &items); err != nil {
http.Error(w, fmtError(err), http.StatusBadRequest) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return return
} }
for _, item := range items { for _, item := range items {
if item.ID == "" {
msg := "item without an id"
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return
}
if item.Kind == "" {
msg := fmt.Sprintf("item %s does not have a kind", item.ID)
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return
}
if !slices.Contains(KnownKinds, item.Kind) {
msg := fmt.Sprintf("items %s does not have a know kind", item.ID)
http.Error(w, fmtError(msg), http.StatusBadRequest)
s.logger.Info(msg)
return
}
if item.Body == "" {
msg := fmt.Sprintf(`{"error":"item %s does not have a body"}`, item.ID)
http.Error(w, msg, http.StatusBadRequest)
s.logger.Info(msg)
return
}
item.Updated = time.Now() item.Updated = time.Now()
if err := s.syncer.Update(item); err != nil { if err := s.syncer.Update(item); err != nil {
http.Error(w, fmtError(err), http.StatusInternalServerError) msg := err.Error()
http.Error(w, fmtError(msg), http.StatusInternalServerError)
s.logger.Error(msg)
return return
} }
} }
@ -120,6 +174,6 @@ func Index(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"ok"}`) fmt.Fprint(w, `{"status":"ok"}`)
} }
func fmtError(err error) string { func fmtError(msg string) string {
return fmt.Sprintf(`{"error":%q}`, err.Error()) return fmt.Sprintf(`{"error":%q}`, msg)
} }

View File

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"sort" "sort"
"strings"
"testing" "testing"
"time" "time"
) )
@ -55,10 +56,10 @@ func TestSyncGet(t *testing.T) {
now := time.Now() now := time.Now()
mem := NewMemory() mem := NewMemory()
items := []Syncable{ items := []Item{
{ID: "id-0", Updated: now.Add(-10 * time.Minute)}, {ID: "id-0", Kind: KindEvent, Updated: now.Add(-10 * time.Minute)},
{ID: "id-1", Updated: now.Add(-5 * time.Minute)}, {ID: "id-1", Kind: KindEvent, Updated: now.Add(-5 * time.Minute)},
{ID: "id-2", Updated: now.Add(time.Minute)}, {ID: "id-2", Kind: KindTask, Updated: now.Add(time.Minute)},
} }
for _, item := range items { for _, item := range items {
@ -73,8 +74,9 @@ func TestSyncGet(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
ts time.Time ts time.Time
ks []string
expStatus int expStatus int
expItems []Syncable expItems []Item
}{ }{
{ {
name: "full", name: "full",
@ -82,14 +84,28 @@ func TestSyncGet(t *testing.T) {
expItems: items, expItems: items,
}, },
{ {
name: "normal", name: "new",
ts: now.Add(-6 * time.Minute), ts: now.Add(-6 * time.Minute),
expStatus: http.StatusOK, expStatus: http.StatusOK,
expItems: []Syncable{items[1], items[2]}, expItems: []Item{items[1], items[2]},
},
{
name: "kind",
ks: []string{string(KindTask)},
expStatus: http.StatusOK,
expItems: []Item{items[2]},
},
{
name: "unknown kind",
ks: []string{"test"},
expStatus: http.StatusBadRequest,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
url := fmt.Sprintf("/sync?ts=%s", url.QueryEscape(tc.ts.Format(time.RFC3339))) url := fmt.Sprintf("/sync?ts=%s", url.QueryEscape(tc.ts.Format(time.RFC3339)))
if len(tc.ks) > 0 {
url = fmt.Sprintf("%s&ks=%s", url, strings.Join(tc.ks, ","))
}
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
@ -101,7 +117,11 @@ func TestSyncGet(t *testing.T) {
if res.Result().StatusCode != tc.expStatus { if res.Result().StatusCode != tc.expStatus {
t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode) t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode)
} }
var actItems []Syncable if tc.expStatus != http.StatusOK {
return
}
var actItems []Item
actBody, err := io.ReadAll(res.Result().Body) actBody, err := io.ReadAll(res.Result().Body)
if err != nil { if err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
@ -135,27 +155,34 @@ func TestSyncPost(t *testing.T) {
name string name string
reqBody []byte reqBody []byte
expStatus int expStatus int
expItems []Syncable expItems []Item
}{ }{
{ {
name: "empty", name: "empty",
expStatus: http.StatusBadRequest, expStatus: http.StatusBadRequest,
}, },
{ {
name: "invalid", name: "invalid json",
reqBody: []byte(`{"fail}`), reqBody: []byte(`{"fail}`),
expStatus: http.StatusBadRequest, expStatus: http.StatusBadRequest,
}, },
{
name: "invalid item",
reqBody: []byte(`[
{"id":"id-1","kind":"event","updated":"2024-09-06T08:00:00Z"},
]`),
expStatus: http.StatusBadRequest,
},
{ {
name: "normal", name: "normal",
reqBody: []byte(`[ reqBody: []byte(`[
{"ID":"id-1","Updated":"2024-09-06T08:00:00Z","Deleted":false,"Item":""}, {"id":"id-1","kind":"event","updated":"2024-09-06T08:00:00Z","deleted":false,"body":"item"},
{"ID":"id-2","Updated":"2024-09-06T08:12:00Z","Deleted":false,"Item":""} {"id":"id-2","kind":"event","updated":"2024-09-06T08:12:00Z","deleted":false,"body":"item2"}
]`), ]`),
expStatus: http.StatusNoContent, expStatus: http.StatusNoContent,
expItems: []Syncable{ expItems: []Item{
{ID: "id-1", Updated: time.Date(2024, 9, 6, 8, 0, 0, 0, time.UTC)}, {ID: "id-1", Kind: KindEvent, 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)}, {ID: "id-2", Kind: KindEvent, Updated: time.Date(2024, 9, 6, 12, 0, 0, 0, time.UTC)},
}, },
}, },
} { } {
@ -174,7 +201,7 @@ func TestSyncPost(t *testing.T) {
t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode) t.Errorf("exp %v, got %v", tc.expStatus, res.Result().StatusCode)
} }
actItems, err := mem.Updated(time.Time{}) actItems, err := mem.Updated([]Kind{}, time.Time{})
if err != nil { if err != nil {
t.Errorf("exp nil, git %v", err) t.Errorf("exp nil, git %v", err)
} }

View File

@ -11,6 +11,11 @@ import (
) )
func main() { func main() {
dbPath := os.Getenv("PLANNER_DB_PATH")
if dbPath == "" {
fmt.Println("PLANNER_DB_PATH is empty")
os.Exit(1)
}
port, err := strconv.Atoi(os.Getenv("PLANNER_PORT")) port, err := strconv.Atoi(os.Getenv("PLANNER_PORT"))
if err != nil { if err != nil {
fmt.Println("PLANNER_PORT env is not an integer") fmt.Println("PLANNER_PORT env is not an integer")
@ -22,16 +27,22 @@ func main() {
os.Exit(1) os.Exit(1)
} }
//mem := NewMemory() repo, err := NewSqlite(dbPath)
repo, err := NewSqlite("test.db")
if err != nil { if err != nil {
fmt.Printf("could not open sqlite db: %s", err.Error()) fmt.Printf("could not open sqlite db: %s", err.Error())
os.Exit(1) os.Exit(1)
} }
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("configuration", "configuration", map[string]string{
"dbPath": dbPath,
"port": fmt.Sprintf("%d", port),
"apiKey": "***",
})
go http.ListenAndServe(fmt.Sprintf(":%d", port), NewServer(repo, apiKey, logger)) address := fmt.Sprintf(":%d", port)
srv := NewServer(repo, apiKey, logger)
go http.ListenAndServe(address, srv)
logger.Info("service started") logger.Info("service started")

36
sync/memory.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"slices"
"time"
)
type Memory struct {
items map[string]Item
}
func NewMemory() *Memory {
return &Memory{
items: make(map[string]Item),
}
}
func (m *Memory) Update(item Item) error {
m.items[item.ID] = item
return nil
}
func (m *Memory) Updated(kinds []Kind, timestamp time.Time) ([]Item, error) {
result := make([]Item, 0)
for _, i := range m.items {
timeOK := timestamp.IsZero() || i.Updated.Equal(timestamp) || i.Updated.After(timestamp)
kindOK := len(kinds) == 0 || slices.Contains(kinds, i.Kind)
if timeOK && kindOK {
result = append(result, i)
}
}
return result, nil
}

View File

@ -11,7 +11,7 @@ func TestMemoryItem(t *testing.T) {
mem := NewMemory() mem := NewMemory()
t.Log("start empty") t.Log("start empty")
actItems, actErr := mem.Updated(time.Time{}) actItems, actErr := mem.Updated([]Kind{}, time.Time{})
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -20,11 +20,11 @@ func TestMemoryItem(t *testing.T) {
} }
t.Log("add one") t.Log("add one")
t1 := NewSyncable("test") t1 := NewItem(Kind("kinda"), "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)
} }
actItems, actErr = mem.Updated(time.Time{}) actItems, actErr = mem.Updated([]Kind{}, time.Time{})
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -38,11 +38,11 @@ func TestMemoryItem(t *testing.T) {
before := time.Now() before := time.Now()
t.Log("add second") t.Log("add second")
t2 := NewSyncable("test 2") t2 := NewItem(Kind("kindb"), "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)
} }
actItems, actErr = mem.Updated(time.Time{}) actItems, actErr = mem.Updated([]Kind{}, time.Time{})
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -56,7 +56,7 @@ func TestMemoryItem(t *testing.T) {
t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID) t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID)
} }
actItems, actErr = mem.Updated(before) actItems, actErr = mem.Updated([]Kind{}, before)
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -72,7 +72,7 @@ func TestMemoryItem(t *testing.T) {
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)
} }
actItems, actErr = mem.Updated(before) actItems, actErr = mem.Updated([]Kind{}, before)
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
@ -85,4 +85,16 @@ func TestMemoryItem(t *testing.T) {
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)
} }
t.Log("select kind")
actItems, actErr = mem.Updated([]Kind{"kinda"}, time.Time{})
if actErr != nil {
t.Errorf("exp nil, got %v", actErr)
}
if len(actItems) != 1 {
t.Errorf("exp 1, got %d", len(actItems))
}
if actItems[0].ID != t1.ID {
t.Errorf("exp %v, got %v", t1.ID, actItems[0].ID)
}
} }

35
sync/planner.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"time"
"github.com/google/uuid"
)
type Kind string
const (
KindTask Kind = "task"
KindEvent Kind = "event"
)
var (
KnownKinds = []Kind{KindTask, KindEvent}
)
type Item struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Updated time.Time `json:"updated"`
Deleted bool `json:"deleted"`
Body string `json:"body"`
}
func NewItem(k Kind, body string) Item {
return Item{
ID: uuid.New().String(),
Kind: k,
Updated: time.Now(),
Body: body,
}
}

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@ -48,7 +49,7 @@ func NewSqlite(dbPath string) (*Sqlite, error) {
return s, nil return s, nil
} }
func (s *Sqlite) Update(item Syncable) error { func (s *Sqlite) Update(item Item) error {
if _, err := s.db.Exec(` if _, err := s.db.Exec(`
INSERT INTO items INSERT INTO items
(id, kind, updated, deleted, body) (id, kind, updated, deleted, body)
@ -56,31 +57,47 @@ VALUES
(?, ?, ?, ?, ?) (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE ON CONFLICT(id) DO UPDATE
SET SET
kind=?, kind=excluded.kind,
updated=?, updated=excluded.updated,
deleted=?, deleted=excluded.deleted,
body=?`, body=excluded.body`,
item.ID, item.Kind, item.Updated.Format(timestampFormat), item.Deleted, item.Item, item.ID, item.Kind, item.Updated.Format(timestampFormat), item.Deleted, item.Body); err != nil {
item.Kind, item.Updated.Format(timestampFormat), item.Deleted, item.Item); err != nil {
return fmt.Errorf("%w: %v", ErrSqliteFailure, err) return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
return nil return nil
} }
func (s *Sqlite) Updated(t time.Time) ([]Syncable, error) { func (s *Sqlite) Updated(ks []Kind, t time.Time) ([]Item, error) {
rows, err := s.db.Query(` query := `
SELECT id, kind, updated, deleted, body SELECT id, kind, updated, deleted, body
FROM items FROM items
WHERE updated > ?`, t.Format(timestampFormat)) WHERE updated > ?`
if err != nil { var rows *sql.Rows
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) var err error
if len(ks) == 0 {
rows, err = s.db.Query(query, t.Format(timestampFormat))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
}
} else {
args := []any{t.Format(timestampFormat)}
ph := make([]string, 0, len(ks))
for _, k := range ks {
args = append(args, string(k))
ph = append(ph, "?")
}
query = fmt.Sprintf("%s AND kind in (%s)", query, strings.Join(ph, ","))
rows, err = s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
}
} }
result := make([]Syncable, 0) result := make([]Item, 0)
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var item Syncable var item Item
if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Item); err != nil { if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
result = append(result, item) result = append(result, item)

View File

@ -10,6 +10,6 @@ var (
) )
type Syncer interface { type Syncer interface {
Update(item Syncable) error Update(item Item) error
Updated(t time.Time) ([]Syncable, error) Updated(kind []Kind, t time.Time) ([]Item, error)
} }