diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index 1b8e7a0..54711ad 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -529,6 +529,8 @@ components: type: array items: $ref: "#/components/schemas/TrainingPrice" + slug: + type: string required: - name - days @@ -544,6 +546,7 @@ components: $ref: "#/components/schemas/TrainingID" required: - id + - slug TrainingID: type: integer diff --git a/go.mod b/go.mod index 6ce8c7e..61ce84f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gofiber/contrib/fiberzap/v2 v2.1.3 github.com/gofiber/contrib/swagger v1.1.2 github.com/gofiber/fiber/v2 v2.52.4 + github.com/gosimple/unidecode v1.0.1 github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e github.com/jackc/pgx/v5 v5.6.0 github.com/oapi-codegen/fiber-middleware v1.0.2 diff --git a/go.sum b/go.sum index 0d78b89..05f4450 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ 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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/server/api.gen.go b/internal/server/api.gen.go index 0690e9c..40a5116 100644 --- a/internal/server/api.gen.go +++ b/internal/server/api.gen.go @@ -83,6 +83,7 @@ type NewTraining struct { Description string `json:"description"` Name string `json:"name"` Pricing []TrainingPrice `json:"pricing"` + Slug *string `json:"slug,omitempty"` } // NewTrainingDate defines model for NewTrainingDate. @@ -138,6 +139,7 @@ type Training struct { Id TrainingID `json:"id"` Name string `json:"name"` Pricing []TrainingPrice `json:"pricing"` + Slug string `json:"slug"` } // TrainingDate defines model for TrainingDate. @@ -2336,44 +2338,45 @@ func (sh *strictHandler) ListTrainingUpcomingDates(ctx *fiber.Ctx, trainingID Tr // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "VQAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/server/api.go b/internal/server/api.go index 332e13c..cef7d3f 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -79,8 +79,14 @@ func (h *APIHandlers) CreateTraining(ctx context.Context, req CreateTrainingRequ } } + slug := "" + if req.Body.Slug != nil { + slug = *req.Body.Slug + } + t := training.Training{ Name: req.Body.Name, + Slug: slug, Days: req.Body.Days, Description: req.Body.Description, Pricing: pricing, @@ -108,6 +114,7 @@ func (h *APIHandlers) CreateTraining(ctx context.Context, req CreateTrainingRequ return CreateTraining201JSONResponse{ Id: t.ID, Name: t.Name, + Slug: t.Slug, Days: t.Days, Description: t.Description, Pricing: responsePricing, @@ -162,6 +169,7 @@ func (h *APIHandlers) GetTraining(ctx context.Context, req GetTrainingRequestObj return GetTraining200JSONResponse{ Id: t.ID, Name: t.Name, + Slug: t.Slug, Days: t.Days, Description: t.Description, Pricing: pricing, @@ -187,9 +195,16 @@ func (h *APIHandlers) UpdateTraining(ctx context.Context, req UpdateTrainingRequ Type: training.PriceType(p.Type), } } + + slug := "" + if req.Body.Slug != nil { + slug = *req.Body.Slug + } + t := training.Training{ ID: req.TrainingID, Name: req.Body.Name, + Slug: slug, Days: req.Body.Days, Description: req.Body.Description, Pricing: pricing, @@ -208,6 +223,7 @@ func (h *APIHandlers) UpdateTraining(ctx context.Context, req UpdateTrainingRequ return UpdateTraining200JSONResponse{ Id: t.ID, Name: t.Name, + Slug: t.Slug, Days: t.Days, Description: t.Description, }, nil diff --git a/internal/server/server_test.go b/internal/server/server_test.go index bf239a7..194a5ba 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -9,6 +9,7 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "gitlab.mareshq.com/hq/yggdrasil/internal/money" + "gitlab.mareshq.com/hq/yggdrasil/pkg/slug" "gitlab.mareshq.com/hq/yggdrasil/pkg/training" "net/http" "net/http/httptest" @@ -115,6 +116,8 @@ func TestServer(t *testing.T) { }, } + slugString := slug.NewString(newTraining.Name) + rr, _ := doPost(t, app, "/v1/trainings", newTraining) assert.Equal(t, http.StatusCreated, rr.StatusCode) @@ -122,6 +125,7 @@ func TestServer(t *testing.T) { err := json.NewDecoder(rr.Body).Decode(&resultTraining) assert.NoError(t, err, "error unmarshalling response") assert.Equal(t, newTraining.Name, resultTraining.Name) + assert.Equal(t, slugString, resultTraining.Slug) assert.Equal(t, newTraining.Description, resultTraining.Description) assert.Equal(t, newTraining.Days, resultTraining.Days) assert.Equal(t, newTraining.Pricing, resultTraining.Pricing) @@ -177,8 +181,10 @@ func TestServer(t *testing.T) { _ = handlers.trainingRepository.Create(tr) + updatedSlug := "updated-training" updTr := NewTraining{ Name: "Updated Training", + Slug: &updatedSlug, Description: tr.Description, Days: tr.Days, Pricing: []TrainingPrice{ @@ -197,6 +203,7 @@ func TestServer(t *testing.T) { err := json.NewDecoder(rr.Body).Decode(&trr) assert.NoError(t, err, "error unmarshalling response") assert.Equal(t, updTr.Name, trr.Name) + assert.Equal(t, updatedSlug, trr.Slug) }) t.Run("Delete training", func(t *testing.T) { diff --git a/migrations/001_trainings.up.sql b/migrations/001_trainings.up.sql index c0a4fe6..4756fe9 100644 --- a/migrations/001_trainings.up.sql +++ b/migrations/001_trainings.up.sql @@ -5,6 +5,7 @@ CREATE SCHEMA IF NOT EXISTS training; CREATE TABLE IF NOT EXISTS training.trainings ( id SERIAL PRIMARY KEY, name varchar(255) NOT NULL, + slug varchar(255) NOT NULL, description text NOT NULL, days smallint NOT NULL ); diff --git a/pkg/slug/slug.go b/pkg/slug/slug.go new file mode 100644 index 0000000..40fce17 --- /dev/null +++ b/pkg/slug/slug.go @@ -0,0 +1,47 @@ +package slug + +import ( + "errors" + "github.com/gosimple/unidecode" + "regexp" + "strings" +) + +type Slug string + +var ( + ErrInvalidSlug = errors.New("invalid slug") + + regexpNonAuthorizedChars = regexp.MustCompile("[^a-zA-Z0-9-_]") + regexpMultipleDashes = regexp.MustCompile("-+") +) + +func New(s string) Slug { + s = unidecode.Unidecode(s) + s = strings.ToLower(s) + s = regexpNonAuthorizedChars.ReplaceAllString(s, "-") + s = regexpMultipleDashes.ReplaceAllString(s, "-") + s = strings.Trim(s, "-_") + + return Slug(s) +} + +func NewString(s string) string { + return New(s).String() +} + +func Validate(s string) error { + if s == "" { + return ErrInvalidSlug + } + + if s == NewString(s) { + return nil + } + + return ErrInvalidSlug +} + +func (s Slug) String() string { + return string(s) +} diff --git a/pkg/slug/slug_test.go b/pkg/slug/slug_test.go new file mode 100644 index 0000000..b77ffb7 --- /dev/null +++ b/pkg/slug/slug_test.go @@ -0,0 +1,59 @@ +package slug + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNew(t *testing.T) { + t.Run("Test New", func(t *testing.T) { + s := New("Hello World") + assert.Equal(t, "hello-world", s.String()) + }) + + t.Run("Test New with !", func(t *testing.T) { + s := New("Hello World!") + assert.Equal(t, "hello-world", s.String()) + }) + + t.Run("Test New with unicode", func(t *testing.T) { + s := New("Ahoj Světe!") // hello world in Czech + assert.Equal(t, "ahoj-svete", s.String()) + }) +} + +func TestValidate(t *testing.T) { + t.Run("Test Validate", func(t *testing.T) { + assert.NoError(t, Validate("hello-world")) + }) + + t.Run("Test Validate with !", func(t *testing.T) { + assert.ErrorIs(t, Validate("hello-world!"), ErrInvalidSlug) + }) + + t.Run("Test Validate with unicode", func(t *testing.T) { + assert.ErrorIs(t, Validate("ahoj-světe"), ErrInvalidSlug) + }) + + t.Run("Test Validate with empty string", func(t *testing.T) { + assert.ErrorIs(t, Validate(""), ErrInvalidSlug) + }) +} + +func TestSlug_String(t *testing.T) { + t.Run("Test String", func(t *testing.T) { + s := Slug("hello-world") + assert.Equal(t, "hello-world", s.String()) + }) +} + +func TestNewString(t *testing.T) { + t.Run("Test NewString", func(t *testing.T) { + input := "Hello World" + expected := "hello-world" + stringSlug := NewString(input) + + assert.Equal(t, expected, stringSlug) + assert.IsType(t, string(""), stringSlug) + }) +} diff --git a/pkg/training/inmemory_repository.go b/pkg/training/inmemory_repository.go index 5d7c978..9aa6f9b 100644 --- a/pkg/training/inmemory_repository.go +++ b/pkg/training/inmemory_repository.go @@ -1,6 +1,7 @@ package training import ( + "gitlab.mareshq.com/hq/yggdrasil/pkg/slug" "sync" "time" ) @@ -24,6 +25,16 @@ func (r *InMemoryTrainingRepository) Create(training *Training) error { training.ID = r.ai r.ai++ + + if training.Slug == "" { + training.Slug = slug.NewString(training.Name) + } else { + slugValidateErr := slug.Validate(training.Slug) + if slugValidateErr != nil { + return slugValidateErr + } + } + r.trainings[training.ID] = *training return nil } @@ -54,6 +65,15 @@ func (r *InMemoryTrainingRepository) Update(training *Training) error { r.lock.Lock() defer r.lock.Unlock() + if training.Slug == "" { + training.Slug = slug.NewString(training.Name) + } else { + slugValidateErr := slug.Validate(training.Slug) + if slugValidateErr != nil { + return slugValidateErr + } + } + r.trainings[training.ID] = *training return nil } diff --git a/pkg/training/model.go b/pkg/training/model.go index e9f9a39..adaa5ea 100644 --- a/pkg/training/model.go +++ b/pkg/training/model.go @@ -11,6 +11,7 @@ type ID = int type Training struct { ID ID Name string + Slug string Days int8 Description string Pricing []Price `db:"-"` diff --git a/pkg/training/postgres_repository.go b/pkg/training/postgres_repository.go index 0b00291..615da33 100644 --- a/pkg/training/postgres_repository.go +++ b/pkg/training/postgres_repository.go @@ -4,6 +4,7 @@ import ( "context" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "gitlab.mareshq.com/hq/yggdrasil/pkg/slug" "time" ) @@ -19,16 +20,20 @@ func (r *PostgresTrainingRepository) Create(training *Training) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + if training.Slug == "" { + training.Slug = slug.NewString(training.Name) + } + tx, txErr := r.pg.Begin(ctx) if txErr != nil { return txErr } queryErr := tx.QueryRow(ctx, ` - INSERT INTO training.trainings (name, description, days) - VALUES ($1, $2, $3) + INSERT INTO training.trainings (name, slug, description, days) + VALUES ($1, $2, $3, $4) RETURNING id - `, training.Name, training.Description, training.Days).Scan(&training.ID) + `, training.Name, training.Slug, training.Description, training.Days).Scan(&training.ID) if queryErr != nil { return queryErr } @@ -75,7 +80,7 @@ func (r *PostgresTrainingRepository) FindByID(id ID) (*Training, error) { SELECT id, name, description, days FROM training.trainings WHERE id = $1 - `, id).Scan(&training.ID, &training.Name, &training.Description, &training.Days) + `, id).Scan(&training.ID, &training.Name, &training.Slug, &training.Description, &training.Days) if err != nil { return nil, err @@ -122,7 +127,7 @@ func (r *PostgresTrainingRepository) FindAll() ([]Training, error) { trainings, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Training, error) { var training Training - scanErr := row.Scan(&training.ID, &training.Name, &training.Description, &training.Days) + scanErr := row.Scan(&training.ID, &training.Name, &training.Slug, &training.Description, &training.Days) if scanErr != nil { return Training{}, scanErr } @@ -151,6 +156,15 @@ func (r *PostgresTrainingRepository) Update(training *Training) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + if training.Slug == "" { + training.Slug = slug.NewString(training.Name) + } else { + slugValidateErr := slug.Validate(training.Slug) + if slugValidateErr != nil { + return slugValidateErr + } + } + tx, err := r.pg.Begin(ctx) if err != nil { return err @@ -158,9 +172,9 @@ func (r *PostgresTrainingRepository) Update(training *Training) error { _, err = tx.Exec(ctx, ` UPDATE training.trainings - SET name = $1, description = $2, days = $3 + SET name = $1, slug = $2, description = $3, days = $4 WHERE id = $4 - `, training.Name, training.Description, training.Days, training.ID) + `, training.Name, training.Slug, training.Description, training.Days, training.ID) if err != nil { return err