Commit d8fd76b
Changed files (12)
cmd
sparkled
pkg
domain
pls
public
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
+```