Commit d8fd76b

mo khan <mo@mokhan.ca>
2025-04-10 23:35:46
feat: add a single API endpoint to return a list of sparkles
1 parent a722bd0
cmd/sparkled/main.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+	"log"
+	"net/http"
+
+	"github.com/xlgmokha/x/pkg/env"
+	"gitlab.com/mokhax/sparkled/pkg/web"
+)
+
+func main() {
+	log.Fatal(http.ListenAndServe(
+		env.Fetch("BIND_ADDR", ":http"),
+		web.NewServer(nil),
+	))
+}
pkg/db/repository.go
@@ -0,0 +1,45 @@
+package db
+
+import (
+	"gitlab.com/mokhax/sparkled/pkg/domain"
+	"gitlab.com/mokhax/sparkled/pkg/pls"
+)
+
+type Repository interface {
+	All() []*domain.Sparkle
+	Each(func(*domain.Sparkle))
+	Save(*domain.Sparkle) error
+}
+
+type inMemoryRepository struct {
+	sparkles []*domain.Sparkle
+}
+
+func NewRepository() Repository {
+	return &inMemoryRepository{
+		sparkles: []*domain.Sparkle{},
+	}
+}
+
+func (r *inMemoryRepository) All() []*domain.Sparkle {
+	return r.sparkles
+}
+
+func (r *inMemoryRepository) Each(visitor func(item *domain.Sparkle)) {
+	for _, item := range r.All() {
+		visitor(item)
+	}
+}
+
+func (r *inMemoryRepository) Save(item *domain.Sparkle) error {
+	if err := item.Validate(); err != nil {
+		return err
+	}
+
+	if item.ID == "" {
+		item.ID = pls.GenerateULID()
+	}
+
+	r.sparkles = append(r.sparkles, item)
+	return nil
+}
pkg/db/repository_test.go
@@ -0,0 +1,41 @@
+package db
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gitlab.com/mokhax/sparkled/pkg/domain"
+)
+
+func TestRepository(t *testing.T) {
+	storage := NewRepository()
+
+	t.Run("Save", func(t *testing.T) {
+		t.Run("an invalid Sparkle", func(t *testing.T) {
+			err := storage.Save(&domain.Sparkle{Reason: "because"})
+
+			counter := 0
+			storage.Each(func(item *domain.Sparkle) {
+				counter++
+			})
+
+			assert.NotNil(t, err)
+			assert.Equal(t, 0, counter)
+		})
+
+		t.Run("a valid Sparkle", func(t *testing.T) {
+			sparkle := &domain.Sparkle{Sparklee: "@tanuki", Reason: "because"}
+			require.NoError(t, storage.Save(sparkle))
+
+			sparkles := []*domain.Sparkle{}
+			storage.Each(func(item *domain.Sparkle) {
+				sparkles = append(sparkles, item)
+			})
+			assert.Equal(t, 1, len(sparkles))
+			assert.NotEmpty(t, sparkles[0].ID)
+			assert.Equal(t, "@tanuki", sparkles[0].Sparklee)
+			assert.Equal(t, "because", sparkles[0].Reason)
+		})
+	})
+}
pkg/domain/sparkle.go
@@ -0,0 +1,50 @@
+package domain
+
+import (
+	"errors"
+	"regexp"
+
+	"gitlab.com/mokhax/sparkled/pkg/pls"
+)
+
+type Sparkle struct {
+	ID       string `json:"id" jsonapi:"primary,sparkles"`
+	Sparklee string `json:"sparklee" jsonapi:"attr,sparklee"`
+	Reason   string `json:"reason" jsonapi:"attr,reason"`
+}
+
+var SparkleRegex = regexp.MustCompile(`\A\s*(?P<sparklee>@\w+)\s+(?P<reason>.+)\z`)
+var SparkleeIndex = SparkleRegex.SubexpIndex("sparklee")
+var ReasonIndex = SparkleRegex.SubexpIndex("reason")
+
+var ReasonIsRequired = errors.New("Reason is required")
+var SparkleIsEmpty = errors.New("Sparkle is empty")
+var SparkleIsInvalid = errors.New("Sparkle is invalid")
+var SparkleeIsRequired = errors.New("Sparklee is required")
+
+func NewSparkle(text string) (*Sparkle, error) {
+	if len(text) == 0 {
+		return nil, SparkleIsEmpty
+	}
+
+	matches := SparkleRegex.FindStringSubmatch(text)
+	if len(matches) == 0 {
+		return nil, SparkleIsInvalid
+	}
+
+	return &Sparkle{
+		ID:       pls.GenerateULID(),
+		Sparklee: matches[SparkleeIndex],
+		Reason:   matches[ReasonIndex],
+	}, nil
+}
+
+func (s *Sparkle) Validate() error {
+	if s.Sparklee == "" {
+		return SparkleeIsRequired
+	}
+	if s.Reason == "" {
+		return ReasonIsRequired
+	}
+	return nil
+}
pkg/domain/sparkle_test.go
@@ -0,0 +1,51 @@
+package domain
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSparkle(t *testing.T) {
+	t.Run("NewSparkle", func(t *testing.T) {
+		t.Run("with a valid body", func(t *testing.T) {
+			sparkle, err := NewSparkle("@tanuki for helping me with my homework!")
+
+			assert.Nil(t, err)
+			if err != nil {
+				assert.Equal(t, "@tanuki", sparkle.Sparklee)
+				assert.Equal(t, "for helping me with my homework!", sparkle.Reason)
+			}
+		})
+
+		t.Run("with an empty body", func(t *testing.T) {
+			sparkle, err := NewSparkle("")
+
+			assert.Nil(t, sparkle)
+			assert.NotNil(t, err)
+			if err != nil {
+				assert.Equal(t, "Sparkle is empty", err.Error())
+			}
+		})
+
+		t.Run("without a reason", func(t *testing.T) {
+			sparkle, err := NewSparkle("@tanuki")
+
+			assert.Nil(t, sparkle)
+			assert.NotNil(t, err)
+			if err != nil {
+				assert.Equal(t, "Sparkle is invalid", err.Error())
+			}
+		})
+
+		t.Run("without a username", func(t *testing.T) {
+			sparkle, err := NewSparkle("for helping me with my homework")
+
+			assert.Nil(t, sparkle)
+			assert.NotNil(t, err)
+			if err != nil {
+				assert.Equal(t, "Sparkle is invalid", err.Error())
+			}
+		})
+	})
+}
pkg/pls/ulid.go
@@ -0,0 +1,16 @@
+package pls
+
+import (
+	"math/rand"
+	"time"
+
+	"github.com/oklog/ulid"
+)
+
+func GenerateULID() string {
+	seed := time.Now().UnixNano()
+	source := rand.NewSource(seed)
+	entropy := rand.New(source)
+	id, _ := ulid.New(ulid.Timestamp(time.Now()), entropy)
+	return id.String()
+}
pkg/web/server.go
@@ -0,0 +1,33 @@
+package web
+
+import (
+	"net/http"
+
+	"github.com/google/jsonapi"
+	"github.com/xlgmokha/x/pkg/serde"
+	"gitlab.com/mokhax/sparkled/pkg/db"
+)
+
+type Server struct {
+	db         db.Repository
+	fileserver http.Handler
+}
+
+func NewServer(storage db.Repository) *Server {
+	return &Server{
+		db:         storage,
+		fileserver: http.FileServer(http.Dir("public")),
+	}
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	switch r.URL.String() {
+	case "/sparkles.json":
+		switch r.Method {
+		case "GET":
+			w.Header().Set("Content-Type", jsonapi.MediaType)
+			serde.ToJSONAPI(w, s.db.All())
+		}
+		break
+	}
+}
pkg/web/server_test.go
@@ -0,0 +1,37 @@
+package web
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/xlgmokha/x/pkg/serde"
+	"gitlab.com/mokhax/sparkled/pkg/db"
+	"gitlab.com/mokhax/sparkled/pkg/domain"
+)
+
+func TestServer(t *testing.T) {
+	t.Run("GET /sparkles.json", func(t *testing.T) {
+		t.Run("returns the list of sparkles", func(t *testing.T) {
+			sparkle, _ := domain.NewSparkle("@tanuki for helping me")
+			store := db.NewRepository()
+			store.Save(sparkle)
+
+			response := httptest.NewRecorder()
+			request, err := http.NewRequest("GET", "/sparkles.json", nil)
+			require.NoError(t, err)
+			NewServer(store).ServeHTTP(response, request)
+
+			assert.Equal(t, http.StatusOK, response.Code)
+
+			items, err := serde.FromJSONAPI[[]*domain.Sparkle](response.Body)
+			require.NoError(t, err)
+
+			assert.Equal(t, 1, len(items))
+			assert.Equal(t, "@tanuki", items[0].Sparklee)
+			assert.Equal(t, "for helping me", items[0].Reason)
+		})
+	})
+}
public/index.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Sparkled</title>
+  </head>
+  <body>
+  </body>
+</html>
go.mod
@@ -1,3 +1,17 @@
 module gitlab.com/mokhax/sparkled
 
 go 1.24.0
+
+require (
+	github.com/oklog/ulid v1.3.1
+	github.com/stretchr/testify v1.10.0
+	github.com/xlgmokha/x v0.0.0-20250404223908-0b29f54f06e7
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/google/jsonapi v1.0.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
go.sum
@@ -0,0 +1,18 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/jsonapi v1.0.0 h1:qIGgO5Smu3yJmSs+QlvhQnrscdZfFhiV6S8ryJAglqU=
+github.com/google/jsonapi v1.0.0/go.mod h1:YYHiRPJT8ARXGER8In9VuLv4qvLfDmA9ULQqptbLE4s=
+github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xlgmokha/x v0.0.0-20250404223908-0b29f54f06e7 h1:Jjik5MGVznsXlo+otZXsWuKvbg3lCixMEIIkoxx0Ojc=
+github.com/xlgmokha/x v0.0.0-20250404223908-0b29f54f06e7/go.mod h1:kLXa5uHaL3VF9ly6XlioU/Q1gittXvAYh6s1WpOFaU8=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
README.md
@@ -0,0 +1,14 @@
+# sparkled
+
+This is a web application that allows you to say nice things about other people
+called "Sparkles".
+
+A single HTML file can be found in `./public/index.html` that is the home page
+for the web application.
+
+## Getting Started
+
+```bash
+  mise install
+  go run ./cmd/sparkled/main.go
+```