marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit b86e0a1385d0b007f43c90d34103cd1b05c9e8c6
parent 03318522417a694906ea318071a8cb211a4f38ea
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 13:04:36 -0400

create/list endpoints

Diffstat:
ALICENSE | 21+++++++++++++++++++++
AREADME.md | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/marky.go | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mgo.mod | 15+++++++++++++++
Ago.sum | 37+++++++++++++++++++++++++++++++++++++
5 files changed, 350 insertions(+), 2 deletions(-)

diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 David Eisinger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md @@ -0,0 +1,108 @@ +# marky + +A simple web API for bookmarking websites, written in Go. It stores bookmarks in a local SQLite database at `~/.marky/bookmarks.sql` and exposes HTTP endpoints to create and list bookmarks. + +- Language: Go +- Storage: SQLite (via `modernc.org/sqlite`, pure Go) +- Default address: `:8080` + +## Requirements + +- Go 1.24+ (tested with 1.24.5) +- Make (optional, for the provided Makefile) + +## Build + +- `make build` — builds `bin/marky` +- Or: `go build -o bin/marky cmd/marky.go` + +## Run + +- `./bin/marky` +- The database file is created automatically at `~/.marky/bookmarks.sql` and migrations are applied on startup. + +## API + +### POST /bookmarks + +Create a new bookmark. + +- Request headers: + - `Content-Type: application/json` +- Request body: +``` +{ + "url": "https://example.com", + "title": "Example", + "description": "Optional description" // optional +} +``` +- Responses: + - `201 Created` with JSON body of the created bookmark + - `400 Bad Request` for invalid payload + - `415 Unsupported Media Type` if not JSON + - `500 Internal Server Error` on unexpected errors +- Response body: +``` +{ + "id": 1, + "url": "https://example.com", + "title": "Example", + "description": "Optional description", + "created_at": "2025-01-01T12:34:56Z", + "updated_at": "2025-01-01T12:34:56Z" +} +``` +- curl example: +``` +curl -i -X POST http://localhost:8080/bookmarks \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://example.com","title":"Example","description":"Sample"}' +``` + +### GET /bookmarks + +List bookmarks in reverse chronological order. Supports basic pagination. + +- Query params: + - `limit` (optional, default 50, max 200) + - `offset` (optional, default 0) +- Responses: + - `200 OK` with JSON array of bookmarks + - `400 Bad Request` for invalid `limit` or `offset` + - `500 Internal Server Error` on unexpected errors +- curl examples: +``` +curl -s http://localhost:8080/bookmarks | jq +curl -s 'http://localhost:8080/bookmarks?limit=20&offset=20' | jq +``` + +## Data Model + +Table: `bookmarks` +- `id` INTEGER PRIMARY KEY AUTOINCREMENT (starts at 1) +- `url` TEXT NOT NULL +- `title` TEXT NOT NULL +- `description` TEXT NULL +- `created_at` TEXT NOT NULL (RFC3339) +- `updated_at` TEXT NOT NULL (RFC3339) + +Migrations are embedded and applied automatically on startup. + +## Development + +- Code entrypoint: `cmd/marky.go` +- Database and helpers: `internal/` +- Migrations: `internal/migrations/*.sql` + +--- + +## Acknowledgments + +This project was developed with the assistance of [ChatGPT][1] for code generation. + +[1]: https://openai.com/chatgpt + +## License + +MIT License -- see LICENSE for details. diff --git a/cmd/marky.go b/cmd/marky.go @@ -1,8 +1,175 @@ package main -import "fmt" +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + dbpkg "marky/internal" +) + +type createBookmarkRequest struct { + URL string `json:"url"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` +} + +type bookmarkResponse struct { + ID int64 `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toResp(b *dbpkg.Bookmark) bookmarkResponse { + var desc *string + if b.Description.Valid { + desc = &b.Description.String + } + return bookmarkResponse{ + ID: b.ID, + URL: b.URL, + Title: b.Title, + Description: desc, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} func main() { - fmt.Println("Hello, world!") + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Open database (creates and migrates if needed) + db, err := dbpkg.Open(ctx) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/bookmarks", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if r.Header.Get("Content-Type") != "" { + // Accept application/json and variations with charset + ct := r.Header.Get("Content-Type") + if ct != "application/json" && ct != "application/json; charset=utf-8" && ct != "application/json;charset=utf-8" { + // Be lenient but enforce JSON type + // If not JSON, return 415 + if ct != "" && len(ct) >= 16 && ct[:16] != "application/json" { + http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType) + return + } + } + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var req createBookmarkRequest + if err := decoder.Decode(&req); err != nil { + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + if req.URL == "" || req.Title == "" { + http.Error(w, "url and title are required", http.StatusBadRequest) + return + } + + created, err := db.CreateBookmark(r.Context(), req.URL, req.Title, req.Description) + if err != nil { + // Map validation errors to 400, others 500 + if errors.Is(err, os.ErrInvalid) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("create bookmark error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(toResp(created)) + + case http.MethodGet: + // Optional pagination: limit and offset + q := r.URL.Query() + limit := 50 + offset := 0 + if s := q.Get("limit"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + if v > 200 { + v = 200 + } + limit = v + } else if s != "" { + http.Error(w, "invalid limit", http.StatusBadRequest) + return + } + } + if s := q.Get("offset"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v >= 0 { + offset = v + } else { + http.Error(w, "invalid offset", http.StatusBadRequest) + return + } + } + + items, err := db.ListBookmarks(r.Context(), limit, offset) + if err != nil { + log.Printf("list bookmarks error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + resp := make([]bookmarkResponse, 0, len(items)) + for i := range items { + b := items[i] + resp = append(resp, toResp(&b)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + + default: + w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + srv := &http.Server{ + Addr: ":8080", + Handler: logging(mux), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("marky API listening on %s", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("server shutdown error: %v", err) + } } +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) +} diff --git a/go.mod b/go.mod @@ -3,3 +3,18 @@ module marky go 1.24.5 require modernc.org/sqlite v1.33.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // 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/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum @@ -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=