commit b86e0a1385d0b007f43c90d34103cd1b05c9e8c6
parent 03318522417a694906ea318071a8cb211a4f38ea
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 13:04:36 -0400
create/list endpoints
Diffstat:
| A | LICENSE | | | 21 | +++++++++++++++++++++ |
| A | README.md | | | 108 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | cmd/marky.go | | | 171 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | go.mod | | | 15 | +++++++++++++++ |
| A | go.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=