diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c92c2d1..ebf08f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,33 +14,28 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [1.16, 1.17] + go: ["1.22"] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: Run tests - env: - DB_HOST: 127.0.0.1 run: | echo "GOPATH=$(go env GOPATH)" >> $GITHUB_ENV echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - sudo /etc/init.d/mysql start - while ! mysqladmin ping --silent; do - sleep 1 - done - sudo mysql -proot -e 'CREATE DATABASE goyave CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' - sudo mysql -proot -e 'CREATE USER "goyave"@"%" IDENTIFIED BY "secret"' - sudo mysql -proot -e 'GRANT ALL PRIVILEGES ON goyave.* TO "goyave"@"%"' go test -v -race ./... lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: false - name: Run lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v6 with: - version: v1.45 + version: v1.58 diff --git a/.gitignore b/.gitignore index 1009244..02defcb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ config.json .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +.storage/avatars/* +!.storage/avatars/.gitkeep \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 322e01e..6d38c7f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,10 +1,6 @@ -run: - skip-dirs: - - .github - linters-settings: gocyclo: - min-complexity: 25 + min-complexity: 15 gofmt: simplify: true misspell: @@ -12,8 +8,6 @@ linters-settings: govet: disable: - shadow - - nilness - - fieldalignment enable-all: true linters: @@ -22,11 +16,15 @@ linters: - revive - gocyclo - misspell - - bodyclose - govet - - deadcode - disable: + - unused - errcheck + - exportloopref + - gosimple + - ineffassign + - staticcheck + - testifylint + - bodyclose disable-all: false fast: false @@ -34,3 +32,13 @@ issues: exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 + exclude-dirs: + - .github + exclude: + - should have a package comment + - for error assertions use require + - should have comment or be unexported + exclude-rules: + - path: _test\.go + linters: + - gocyclo diff --git a/.storage/avatars/.gitkeep b/.storage/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8c2ea42..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM golang:1.17-alpine - -LABEL maintainer="Jérémy LAMBERT (SystemGlitch) " - -RUN apk update && apk upgrade && apk add --no-cache git openssh gcc libc-dev -RUN go get github.com/cespare/reflex - -ENV DOCKERIZE_VERSION v0.6.1 -RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ - && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ - && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz - -WORKDIR /app - -EXPOSE 8080 - -CMD dockerize -wait tcp://mariadb:3306 reflex -s -- sh -c 'go run main.go' \ No newline at end of file diff --git a/LICENSE b/LICENSE index 03a4b17..56d26be 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Jérémy LAMBERT (SystemGlitch) +Copyright (c) 2024 Jérémy LAMBERT (SystemGlitch) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f8d4666..57bc14b 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,24 @@

- Goyave Logo + Goyave Logo + Goyave Logo

## Goyave Blog Example ![https://github.com/go-goyave/goyave-blog-example/actions](https://github.com/go-goyave/goyave-blog-example/workflows/Test/badge.svg) -This codebase was created to demonstrate a fully fledged fullstack application built with **[Goyave](https://github.com/System-Glitch/goyave)** including CRUD operations, authentication, routing, pagination, and more. +This example project was created to demonstrate a simple application built with **[Goyave](https://github.com/go-goyave/goyave)** including CRUD operations, authentication, routing, pagination, and more. With this application, users can register, login and write blog posts (articles) or read the other user's ones. -## Getting Started +## Running the project -### Requirements +First, make your own configuration for your local environment. -- Go 1.16+ -- Go modules +- Copy `config.example.json` to `config.json`. +- Start the database container with `docker-compose up`. +- Run migrations with [dbmate](https://github.com/amacneil/dbmate): `dbmate -u postgres://dbuser:secret@127.0.0.1:5432/blog?sslmode=disable -d ./database/migrations --no-dump-schema migrate` +- Run `go run main.go` in your project's directory to start the server. If you want to seed your database with random records use the `-seed` flag: `go run main.go -seed`. Users will all be created with the following password: `p4ssW0rd_` -### Directory structure +## Resources -``` -. -├── database -│   ├── model // ORM models -│   | └── ... -│   └── seeder // Generators for database testing -│   └── ... -├── http -│   ├── controller // Business logic of the application -│   │ └── ... -│   ├── middleware // Logic executed before or after controllers -│   │ └── ... -│   ├── validation -│   │   └── validation.go // Custom validation rules -│   └── route -│   └── route.go // Routes definition -│ -├── resources -│   └── lang -│      └── en-US // Overrides to the default language lines -│      ├── fields.json -│      ├── locale.json -│      └── rules.json -│ -├── test // Functional tests -| └── ... -| -├── .gitignore -├── .golangci.yml // Settings for the Golangci-lint linter -├── config.example.json // Example config for local development -├── config.test.json // Config file used for tests -├── go.mod -└── main.go // Application entrypoint -``` - -### Running the project - -First, make your own configuration for your local environment. You can copy `config.example.json` to `config.json`. - -Run `go run main.go` in your project's directory to start the server. - -**Using docker:** - -``` -docker-compose up -``` - -**Run tests with docker:** - -``` -docker-compose -f docker-compose.test.yml up --abort-on-container-exit -``` - -**Database seeding:** - -If `app.environment` is set to `localhost` in the config and if the database is empty (no record in the users table), the seeders will be executed and a random dataset will be generated and inserted into the database. - -## Learning Goyave - -The Goyave framework has an extensive documentation covering in-depth subjects and teaching you how to run a project using Goyave from setup to deployment. - -

Read the documentation

- -

pkg.go.dev

- -## License - -This example project is MIT Licensed. Copyright © 2020 Jérémy LAMBERT (SystemGlitch) - -The Goyave framework is MIT Licensed. Copyright © 2019 Jérémy LAMBERT (SystemGlitch) +- [Documentation](https://goyave.dev) +- [go.pkg.dev](https://pkg.go.dev/goyave.dev/goyave/v5) \ No newline at end of file diff --git a/config.example.json b/config.example.json index d202ee0..eb93813 100644 --- a/config.example.json +++ b/config.example.json @@ -3,36 +3,52 @@ "name": "goyave-blog-example", "environment": "localhost", "debug": true, - "defaultLanguage": "en-US", - "bcryptCost": 10 + "defaultLanguage": "en-US" }, "server": { - "host": "0.0.0.0", - "maintenance": false, - "protocol": "http", + "host": "127.0.0.1", "domain": "", "port": 8080, - "httpsPort": 443, - "timeout": 10, - "maxUploadSize": 10 + "writeTimeout": 10, + "readTimeout": 10, + "readHeaderTimeout": 10, + "idleTimeout": 20, + "websocketCloseTimeout": 10, + "maxUploadSize": 10.0, + "proxy": { + "protocol": "http", + "host": "", + "port": 80, + "base": "" + } }, "database": { - "connection": "mysql", - "host": "mariadb", - "port": 3306, - "name": "goyave", - "username": "goyave", + "connection": "postgres", + "host": "127.0.0.1", + "port": 5432, + "name": "blog", + "username": "dbuser", "password": "secret", - "options": "charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=true&loc=Local", + "options": "sslmode=disable application_name=goyave-blog-example", "maxOpenConnections": 20, "maxIdleConnections": 20, "maxLifetime": 300, - "autoMigrate": true + "defaultReadQueryTimeout": 20000, + "defaultWriteQueryTimeout": 40000, + "config": { + "skipDefaultTransaction": false, + "dryRun": false, + "prepareStmt": true, + "disableNestedTransaction": false, + "allowGlobalUpdate": false, + "disableAutomaticPing": false, + "disableForeignKeyConstraintWhenMigrating": false + } }, "auth": { "jwt": { - "expiry": 3600, - "secret": "1C4C304DACBEC13CC975A3708CFAF6AED0A478573C1C0D7EBFF0E7E8530C06B7" + "secret": "jwt-secret", + "expiry": 3000 } } } \ No newline at end of file diff --git a/config.test.json b/config.test.json index 7b477ab..4ade090 100644 --- a/config.test.json +++ b/config.test.json @@ -1,38 +1,54 @@ { "app": { - "name": "goyave-blog-example", - "environment": "test", - "debug": false, - "defaultLanguage": "en-US", - "bcryptCost": 10 + "name": "goyave.dev/template", + "environment": "localhost", + "debug": true, + "defaultLanguage": "en-US" }, "server": { "host": "127.0.0.1", - "maintenance": false, - "protocol": "http", "domain": "", - "port": 8080, - "httpsPort": 443, - "timeout": 10, - "maxUploadSize": 10 + "port": 0, + "writeTimeout": 10, + "readTimeout": 10, + "readHeaderTimeout": 10, + "idleTimeout": 20, + "websocketCloseTimeout": 10, + "maxUploadSize": 10.0, + "proxy": { + "protocol": "http", + "host": "", + "port": 80, + "base": "" + } }, "database": { - "connection": "mysql", - "host": "${DB_HOST}", - "port": 3306, - "name": "goyave", - "username": "goyave", - "password": "secret", - "options": "charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=true&loc=Local", + "connection": "none", + "host": "127.0.0.1", + "port": 0, + "name": "", + "username": "", + "password": "", + "options": "", "maxOpenConnections": 20, "maxIdleConnections": 20, "maxLifetime": 300, - "autoMigrate": true + "defaultReadQueryTimeout": 20000, + "defaultWriteQueryTimeout": 40000, + "config": { + "skipDefaultTransaction": false, + "dryRun": false, + "prepareStmt": true, + "disableNestedTransaction": false, + "allowGlobalUpdate": false, + "disableAutomaticPing": true, + "disableForeignKeyConstraintWhenMigrating": false + } }, "auth": { "jwt": { - "expiry": 3600, - "secret": "1C4C304DACBEC13CC975A3708CFAF6AED0A478573C1C0D7EBFF0E7E8530C06B7" + "secret": "jwt-secret", + "expiry": 3000 } } } \ No newline at end of file diff --git a/database/migrations/20240510133046_create_users_table.sql b/database/migrations/20240510133046_create_users_table.sql new file mode 100644 index 0000000..b72c24f --- /dev/null +++ b/database/migrations/20240510133046_create_users_table.sql @@ -0,0 +1,13 @@ +-- migrate:up +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(320) NOT NULL UNIQUE, + username VARCHAR(100) NOT NULL, + password CHAR(60) NOT NULL, + avatar VARCHAR(300) DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL +); + +-- migrate:down +DROP TABLE IF EXISTS users; diff --git a/database/migrations/20240510133055_create_articles_table.sql b/database/migrations/20240510133055_create_articles_table.sql new file mode 100644 index 0000000..2b7a5f9 --- /dev/null +++ b/database/migrations/20240510133055_create_articles_table.sql @@ -0,0 +1,14 @@ +-- migrate:up +CREATE TABLE articles ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + contents TEXT NOT NULL, + slug VARCHAR(126) NOT NULL UNIQUE, + author_id BIGINT REFERENCES users (id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL +); + +-- migrate:down +DROP TABLE IF EXISTS articles; \ No newline at end of file diff --git a/database/model/article.go b/database/model/article.go index fb92333..c799012 100644 --- a/database/model/article.go +++ b/database/model/article.go @@ -1,98 +1,25 @@ package model import ( - "fmt" "time" - "github.com/bxcodec/faker/v3" - "github.com/gosimple/slug" + "github.com/guregu/null/v5" "gorm.io/gorm" - "goyave.dev/goyave/v4/database" ) -func init() { - database.RegisterModel(&Article{}) - slug.MaxLength = 80 -} - -// Article represents an article posted by a user. type Article struct { - ID uint `gorm:"primarykey"` - CreatedAt time.Time - UpdatedAt time.Time - Title string `gorm:"type:char(200);not null"` - Contents string `gorm:"type:text;not null"` - Slug string `gorm:"type:char(80);not null;unique;uniqueIndex"` - AuthorID uint `json:"-"` - Author *User `gorm:"constraint:OnDelete:CASCADE;" json:",omitempty"` -} - -// BeforeCreate hook executed before a Article record is inserted in the database. -// Ensures the slug is up to date. -func (a *Article) BeforeCreate(tx *gorm.DB) error { - return a.slugify(tx) -} - -// BeforeUpdate hook executed before an Article record is updated in the database. -// Ensures the slug is up to date. -func (a *Article) BeforeUpdate(tx *gorm.DB) error { - if tx.Statement.Changed("Title") { - return a.slugify(tx) - } - - return nil -} + Author *User -func (a *Article) slugify(tx *gorm.DB) error { - var newSlug string - switch a := tx.Statement.Dest.(type) { - case map[string]interface{}: - newSlug = generateSlug(a["title"].(string)) - case *Article: - newSlug = generateSlug(a.Title) - case []*Article: - newSlug = generateSlug(a[tx.Statement.CurDestIndex].Title) - } - - tx.Statement.SetColumn("slug", newSlug) - - return nil -} - -// generateSlug creates a slug from the given title. This functions ensures -// the slug is unique by prepending an incremented counter if the slug already -// exists. -func generateSlug(title string) string { - actualTitle := title - increment := 0 - s := "" - count := int64(1) - for count > 0 { - s = slug.Make(actualTitle) - if err := database.Conn().Model(&Article{}).Where("slug = ?", s).Count(&count).Error; err != nil { - panic(err) - } - increment++ - actualTitle = fmt.Sprintf("%d %s", increment, title) - } - return s + CreatedAt time.Time + UpdatedAt null.Time + DeletedAt gorm.DeletedAt + Title string + Contents string + Slug string + AuthorID uint + ID uint `gorm:"primarykey"` } -// ArticleGenerator generator function for the Article model. -// -// Be careful, this generator doesn't set the AuthorID! -// -// Generate articles using the following: -// database.NewFactory(model.ArticleGenerator).Generate(5) -func ArticleGenerator() interface{} { - article := &Article{} - - faker.SetGenerateUniqueValues(true) - article.Title = faker.Sentence() - faker.SetGenerateUniqueValues(false) - - article.Contents = faker.Paragraph() - article.Slug = slug.Make(article.Title) - - return article +func (Article) TableName() string { + return "articles" } diff --git a/database/model/user.go b/database/model/user.go index 04bea05..f23cb27 100644 --- a/database/model/user.go +++ b/database/model/user.go @@ -1,110 +1,22 @@ package model import ( - "reflect" "time" - "github.com/bxcodec/faker/v3" - "golang.org/x/crypto/bcrypt" - "gopkg.in/guregu/null.v4" - "gorm.io/gorm" - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/config" - "goyave.dev/goyave/v4/database" - "goyave.dev/goyave/v4/middleware/ratelimiter" + "github.com/guregu/null/v5" ) -func init() { - database.RegisterModel(&User{}) - - config.Register("app.bcryptCost", config.Entry{ - Value: 10, - Type: reflect.Int, - IsSlice: false, - AuthorizedValues: []interface{}{}, - }) -} - -// User represents a user. type User struct { - ID uint `gorm:"primarykey"` - CreatedAt time.Time - UpdatedAt time.Time - Username string `gorm:"type:char(100);unique;uniqueIndex;not null"` - Email string `gorm:"type:char(100);unique;uniqueIndex;not null" auth:"username"` - Image null.String `gorm:"type:char(100);default:null" json:"-"` - Password string `gorm:"type:char(60);not null" auth:"password" json:"-"` -} - -// BeforeCreate hook executed before a User record is inserted in the database. -// Ensures the password is encrypted using bcrypt, with the cost defined by the -// config entry "app.bcryptCost". -func (u *User) BeforeCreate(tx *gorm.DB) error { - return u.bcryptPassword(tx) + Email string + Username string + Avatar string + Password string + CreatedAt time.Time `json:"createdAt"` + UpdatedAt null.Time `json:"updatedAt"` + Articles []*Article `gorm:"foreignKey:AuthorID"` + ID uint `gorm:"primaryKey"` } -// BeforeUpdate hook executed before a User record is updated in the database. -// Ensures the password is encrypted using bcrypt, with the cost defined by the -// config entry "app.bcryptCost". -func (u *User) BeforeUpdate(tx *gorm.DB) error { - if tx.Statement.Changed("Password") { - return u.bcryptPassword(tx) - } - - return nil -} - -func (u *User) bcryptPassword(tx *gorm.DB) error { - var newPass string - switch u := tx.Statement.Dest.(type) { - case map[string]interface{}: - newPass = u["password"].(string) - case *User: - newPass = u.Password - case []*User: - newPass = u[tx.Statement.CurDestIndex].Password - } - - b, err := bcrypt.GenerateFromPassword([]byte(newPass), config.GetInt("app.bcryptCost")) - if err != nil { - return err - } - tx.Statement.SetColumn("password", b) - - return nil -} - -// RateLimiterFunc returns rate limiting configuration -// Anonymous users have a quota of 50 requests per minute while -// authenticated users are limited to 500 requests per minute. -func RateLimiterFunc(request *goyave.Request) ratelimiter.Config { - var id interface{} - quota := 50 - if request.User != nil { - id = request.User.(*User).ID - quota = 500 - } - return ratelimiter.Config{ - ClientID: id, - RequestQuota: quota, - QuotaDuration: time.Minute, - } -} - -// UserGenerator generator function for the User model. -// Generate users using the following: -// database.NewFactory(model.UserGenerator).Generate(5) -func UserGenerator() interface{} { - user := &User{} - user.Username = faker.Name() - - b, _ := bcrypt.GenerateFromPassword([]byte(faker.Password()), config.GetInt("app.bcryptCost")) - user.Password = string(b) - - faker.SetGenerateUniqueValues(true) - user.Email = faker.Email() - faker.SetGenerateUniqueValues(false) - - user.Username = faker.Name() - return user +func (User) TableName() string { + return "users" } diff --git a/database/repository/article.go b/database/repository/article.go new file mode 100644 index 0000000..14bea3c --- /dev/null +++ b/database/repository/article.go @@ -0,0 +1,82 @@ +package repository + +import ( + "context" + + "github.com/go-goyave/goyave-blog-example/database/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "goyave.dev/filter" + "goyave.dev/goyave/v5/database" + "goyave.dev/goyave/v5/util/errors" + "goyave.dev/goyave/v5/util/session" +) + +type Article struct { + DB *gorm.DB +} + +func NewArticle(db *gorm.DB) *Article { + return &Article{ + DB: db, + } +} + +func (r *Article) Index(ctx context.Context, request *filter.Request) (*database.Paginator[*model.Article], error) { + settings := &filter.Settings[*model.Article]{ + DefaultSort: []*filter.Sort{ + {Field: "created_at", Order: filter.SortDescending}, + }, + FieldsSearch: []string{"title"}, + Blacklist: filter.Blacklist{ + FieldsBlacklist: []string{"deleted_at"}, + Relations: map[string]*filter.Blacklist{ + "Author": {IsFinal: true}, + }, + }, + } + paginator, err := settings.Scope(session.DB(ctx, r.DB), request, &[]*model.Article{}) + return paginator, errors.New(err) +} + +func (r *Article) GetByID(ctx context.Context, id uint) (*model.Article, error) { + var article *model.Article + db := session.DB(ctx, r.DB).Where("id", id).First(&article) + return article, errors.New(db.Error) +} + +func (r *Article) GetBySlug(ctx context.Context, slug string) (*model.Article, error) { + var article *model.Article + db := session.DB(ctx, r.DB).Where("slug", slug).First(&article) + return article, errors.New(db.Error) +} + +func (r *Article) Create(ctx context.Context, article *model.Article) (*model.Article, error) { + db := session.DB(ctx, r.DB).Omit(clause.Associations).Create(&article) + return article, errors.New(db.Error) +} + +func (r *Article) Update(ctx context.Context, article *model.Article) (*model.Article, error) { + db := session.DB(ctx, r.DB).Omit(clause.Associations).Save(&article) + return article, errors.New(db.Error) +} + +func (r *Article) Delete(ctx context.Context, id uint) error { + db := session.DB(ctx, r.DB).Delete(&model.Article{ID: id}) + if db.RowsAffected == 0 { + return errors.New(gorm.ErrRecordNotFound) + } + return errors.New(db.Error) +} + +func (r *Article) IsOwner(ctx context.Context, resourceID, ownerID uint) (bool, error) { + var one int64 + db := session.DB(ctx, r.DB). + Table(model.Article{}.TableName()). + Select("1"). + Where("id", resourceID). + Where("author_id", ownerID). + Where("deleted_at IS NULL"). + Find(&one) + return one == 1, errors.New(db.Error) +} diff --git a/database/repository/user.go b/database/repository/user.go new file mode 100644 index 0000000..08b4f98 --- /dev/null +++ b/database/repository/user.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + + "github.com/go-goyave/goyave-blog-example/database/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "goyave.dev/goyave/v5/util/errors" + "goyave.dev/goyave/v5/util/session" +) + +type User struct { + DB *gorm.DB +} + +func NewUser(db *gorm.DB) *User { + return &User{ + DB: db, + } +} + +func (r *User) GetByID(ctx context.Context, id uint) (*model.User, error) { + var user *model.User + db := session.DB(ctx, r.DB).Where("id", id).First(&user) + return user, errors.New(db.Error) +} + +func (r *User) GetByEmail(ctx context.Context, email string) (*model.User, error) { + var user *model.User + db := session.DB(ctx, r.DB).Where("email", email).First(&user) + return user, errors.New(db.Error) +} + +func (r *User) Create(ctx context.Context, user *model.User) (*model.User, error) { + db := session.DB(ctx, r.DB).Omit(clause.Associations).Create(&user) + return user, errors.New(db.Error) +} + +func (r *User) Update(ctx context.Context, user *model.User) (*model.User, error) { + db := session.DB(ctx, r.DB).Omit(clause.Associations).Save(&user) + return user, errors.New(db.Error) +} + +func (r *User) UniqueScope() func(db *gorm.DB, val any) *gorm.DB { + return func(db *gorm.DB, val any) *gorm.DB { + return db.Table(model.User{}.TableName()).Where("email", val) + } +} diff --git a/database/seed/article.go b/database/seed/article.go new file mode 100644 index 0000000..2aea339 --- /dev/null +++ b/database/seed/article.go @@ -0,0 +1,16 @@ +package seed + +import ( + "github.com/go-faker/faker/v4" + "github.com/go-goyave/goyave-blog-example/database/model" + "github.com/go-goyave/goyave-blog-example/service/article" + "github.com/samber/lo" +) + +func ArticleGenerator() *model.Article { + a := &model.Article{} + a.Title = faker.Sentence() + a.Contents = faker.Paragraph() + a.Slug = lo.Must(article.NewService(nil, nil).GenerateSlug(a.Title)) + return a +} diff --git a/database/seed/seed.go b/database/seed/seed.go new file mode 100644 index 0000000..b8e5f3a --- /dev/null +++ b/database/seed/seed.go @@ -0,0 +1,11 @@ +package seed + +import ( + "gorm.io/gorm" + "goyave.dev/goyave/v5/database" +) + +func Seed(db *gorm.DB) { + userFactory := database.NewFactory(UserGenerator) + userFactory.Save(db, 10) +} diff --git a/database/seed/user.go b/database/seed/user.go new file mode 100644 index 0000000..acb914e --- /dev/null +++ b/database/seed/user.go @@ -0,0 +1,21 @@ +package seed + +import ( + "math/rand" + + "github.com/go-faker/faker/v4" + "github.com/go-faker/faker/v4/pkg/options" + "github.com/go-goyave/goyave-blog-example/database/model" + "github.com/samber/lo" +) + +func UserGenerator() *model.User { + user := &model.User{} + user.Username = faker.Name() + user.Email = faker.Email(options.WithGenerateUniqueValues(true)) + user.Password = "$2a$10$TllZ98eJjoknEcE25qR3J.kaLGlOTztt/2SMgbZiTZq5L1O35v76a" // p4ssW0rd_ + user.Articles = lo.Times(rand.Intn(5), func(_ int) *model.Article { + return ArticleGenerator() + }) + return user +} diff --git a/database/seeder/article.go b/database/seeder/article.go deleted file mode 100644 index 7b48145..0000000 --- a/database/seeder/article.go +++ /dev/null @@ -1,40 +0,0 @@ -package seeder - -import ( - "math/rand" - "time" - - "github.com/go-goyave/goyave-blog-example/database/model" - "goyave.dev/goyave/v4/database" -) - -const ( - // ArticleCount the number of articles generated by the User seeder - ArticleCount = 40 -) - -// Article seeder for articles. Generate and save articles with a random -// author in the database. -func Article() { - rand.Seed(time.Now().UTC().UnixNano()) - - users := make([]uint, 0, 10) - db := database.Conn() - if err := db.Model(&model.User{}).Select("id").Find(&users).Error; err != nil { - panic(err) - } - - factory := database.NewFactory(model.ArticleGenerator) - articles := make([]*model.Article, 0, ArticleCount) - for i := 0; i < ArticleCount; i++ { - o := &model.Article{ - AuthorID: uint(users[rand.Intn(len(users))]), - } - generated := factory.Override(o).Generate(1).([]*model.Article)[0] - articles = append(articles, generated) - } - - if err := db.Create(articles).Error; err != nil { - panic(err) - } -} diff --git a/database/seeder/seeder.go b/database/seeder/seeder.go deleted file mode 100644 index 0531d46..0000000 --- a/database/seeder/seeder.go +++ /dev/null @@ -1,25 +0,0 @@ -package seeder - -import ( - "github.com/go-goyave/goyave-blog-example/database/model" - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/config" - "goyave.dev/goyave/v4/database" -) - -// Run run seeders if the user table is empty. -// Only triggers if the environment is "localhost". -func Run() { - if config.GetString("app.environment") == "localhost" { - count := int64(0) - if err := database.Conn().Model(&model.User{}).Count(&count).Error; err != nil { - panic(err) - } - - if count <= 0 { - goyave.Logger.Println("Running seeders...") - User() - Article() - } - } -} diff --git a/database/seeder/user.go b/database/seeder/user.go deleted file mode 100644 index 2070742..0000000 --- a/database/seeder/user.go +++ /dev/null @@ -1,23 +0,0 @@ -package seeder - -import ( - "github.com/go-goyave/goyave-blog-example/database/model" - - "github.com/bxcodec/faker/v3" - "goyave.dev/goyave/v4/database" -) - -const ( - // UserCount the number of users generated by the User seeder - UserCount = 10 -) - -// User seeder for users. Generate and save users in the database. -func User() { - database.NewFactory(model.UserGenerator).Save(UserCount) - - // As user generator makes unique emails, - // forget generated unique emails. - // See https://github.com/bxcodec/faker/blob/master/SingleFakeData.md#unique-values - faker.ResetUnique() -} diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index 5eeb78e..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This is the test docker-compose -# Run with: docker-compose -f docker-compose.test.yml up --abort-on-container-exit - -version: "3" -services: - testapi: - build: . - networks: - - goyave-test-backend - ports: - - "8080:8080" - depends_on: - - testmariadb - volumes: - - .:/app - environment: - DB_HOST: testmariadb - command: dockerize -wait tcp://testmariadb:3306 -timeout 100s sh -c 'go test -v -race -coverprofile=c.out -coverpkg=./... ./... ; go tool cover -func=c.out | grep total ; rm c.out' - testmariadb: - image: mariadb - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: goyave - MYSQL_USER: goyave - MYSQL_PASSWORD: secret - networks: - - goyave-test-backend - restart: on-failure - ports: - - "3306:3306" - volumes: - - testDatabaseVolume:/var/lib/mysql - healthcheck: - test: ["CMD", "mysqladmin ping"] - interval: 10s - timeout: 10s - retries: 100 -volumes: - testDatabaseVolume: {} -networks: - goyave-test-backend: - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index fc59062..9afc1e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,37 +1,14 @@ version: "3" services: - api: - build: . - restart: always - networks: - - goyave-backend - ports: - - "8080:8080" - depends_on: - - mariadb - volumes: - - .:/app - - storageVolume:/root/storage - mariadb: - image: mariadb + postgres: + image: postgres:latest environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: goyave - MYSQL_USER: goyave - MYSQL_PASSWORD: secret - networks: - - goyave-backend + - POSTGRES_DB=blog + - POSTGRES_USER=dbuser + - POSTGRES_PASSWORD=secret + ports: + - '127.0.0.1:5432:5432' restart: on-failure - volumes: - - databaseVolume:/var/lib/mysql - healthcheck: - test: ["CMD", "mysqladmin ping"] - interval: 10s - timeout: 10s - retries: 100 -volumes: - databaseVolume: {} - storageVolume: {} networks: - goyave-backend: - driver: bridge + default: + name: goyave-backend diff --git a/dto/article.go b/dto/article.go new file mode 100644 index 0000000..e96a03e --- /dev/null +++ b/dto/article.go @@ -0,0 +1,31 @@ +package dto + +import ( + "time" + + "github.com/guregu/null/v5" + "goyave.dev/goyave/v5/util/typeutil" +) + +type Article struct { + Author *User `json:"author,omitempty"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt null.Time `json:"updatedAt"` + Title string `json:"title"` + Contents string `json:"contents"` + Slug string `json:"slug"` + AuthorID uint `json:"authorID"` + ID uint `json:"id"` +} + +type CreateArticle struct { + Title string `json:"title"` + Contents string `json:"contents"` + AuthorID uint `json:"authorID"` +} + +type UpdateArticle struct { + Title typeutil.Undefined[string] `json:"title"` + Contents typeutil.Undefined[string] `json:"contents"` +} diff --git a/dto/user.go b/dto/user.go new file mode 100644 index 0000000..3c21ec5 --- /dev/null +++ b/dto/user.go @@ -0,0 +1,39 @@ +package dto + +import ( + "time" + + "github.com/guregu/null/v5" + "goyave.dev/goyave/v5/util/fsutil" + "goyave.dev/goyave/v5/util/typeutil" +) + +// User the public user DTO. Used to show profiles for example. +type User struct { + CreatedAt time.Time `json:"createdAt"` + UpdatedAt null.Time `json:"updatedAt"` + Username string `json:"username"` + Email string `json:"email"` + ID uint `json:"id"` +} + +// InternalUser contains private user info that should not be exposed to clients. +type InternalUser struct { + Avatar string `json:"avatar"` + Password string `json:"password"` + User +} + +type RegisterUser struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password" copier:"-"` + Avatar typeutil.Undefined[[]fsutil.File] `json:"avatar" copier:"-"` +} + +type UpdateUser struct { + Email typeutil.Undefined[string] `json:"email"` + Username typeutil.Undefined[string] `json:"username"` + Password typeutil.Undefined[string] `json:"password" copier:"-"` + Avatar typeutil.Undefined[[]fsutil.File] `json:"avatar" copier:"-"` +} diff --git a/go.mod b/go.mod index d404ff5..52ff77c 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,32 @@ module github.com/go-goyave/goyave-blog-example -go 1.16 +go 1.22 require ( - github.com/bxcodec/faker/v3 v3.6.0 - github.com/gosimple/slug v1.9.0 - github.com/mitchellh/go-homedir v1.1.0 - github.com/stretchr/testify v1.7.0 - golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce - gopkg.in/guregu/null.v4 v4.0.0 - gorm.io/gorm v1.22.5 - goyave.dev/goyave/v4 v4.0.0 + github.com/go-faker/faker/v4 v4.4.1 + github.com/google/uuid v1.6.0 + github.com/gosimple/slug v1.14.0 + github.com/guregu/null/v5 v5.0.0 + github.com/samber/lo v1.39.0 + golang.org/x/crypto v0.23.0 + gorm.io/gorm v1.25.10 + goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78 + goyave.dev/goyave/v5 v5.0.0-rc11 +) + +require ( + github.com/Code-Hex/uniseg v0.2.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.15.0 // indirect + gorm.io/driver/postgres v1.5.7 // indirect + goyave.dev/copier v0.4.3 // indirect ) diff --git a/go.sum b/go.sum index 75b9422..9694e35 100644 --- a/go.sum +++ b/go.sum @@ -1,217 +1,64 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Code-Hex/uniseg v0.2.0 h1:QB/2UJFvEuRLSZqe+Sb1XQBTWjqGVbZoC6oSWzQRKws= github.com/Code-Hex/uniseg v0.2.0/go.mod h1:/ndS2tP+X1lk2HUOcXWGtVTxVq0lWilwgMa4CbzdRsg= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/bxcodec/faker/v3 v3.6.0 h1:Meuh+M6pQJsQJwxVALq6H5wpDzkZ4pStV9pmH7gbKKs= -github.com/bxcodec/faker/v3 v3.6.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/denisenkom/go-mssqldb v0.11.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/go-faker/faker/v4 v4.4.1 h1:LY1jDgjVkBZWIhATCt+gkl0x9i/7wC61gZx73GTFb+Q= +github.com/go-faker/faker/v4 v4.4.1/go.mod h1:HRLrjis+tYsbFtIHufEPTAIzcZiRu0rS9EYl2Ccwme4= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= -github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +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/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= +github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/guregu/null/v5 v5.0.0 h1:PRxjqyOekS11W+w/7Vfz6jgJE/BCwELWtgvOJzddimw= +github.com/guregu/null/v5 v5.0.0/go.mod h1:SjupzNy+sCPtwQTKWhUCqjhVCO69hpsl2QsZrWHjlwU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= -github.com/jackc/pgtype v1.9.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgtype v1.9.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= -github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8= -github.com/jackc/pgx/v4 v4.14.1/go.mod h1:RgDuE4Z34o7XE92RpLsvFiOEfrAUT0Xt2KxvX73W06M= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas= -github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= -github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI= -golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg= -gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.2.3 h1:cZqzlOfg5Kf1VIdLC1D9hT6Cy9BgxhExLj/2tIgUe7Y= -gorm.io/driver/mysql v1.2.3/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo= -gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs= -gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY= -gorm.io/driver/sqlserver v1.2.1/go.mod h1:nixq0OB3iLXZDiPv6JSOjWuPgpyaRpOIIevYtA4Ulb4= -gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= -gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= -gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk= -gorm.io/gorm v1.22.5 h1:lYREBgc02Be/5lSCTuysZZDb6ffL2qrat6fg9CFbvXU= -gorm.io/gorm v1.22.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= -goyave.dev/goyave/v4 v4.0.0 h1:noNGoHL/FY6Y23x6PSbWN9LuJk30Hbn8COO+xx1eoRQ= -goyave.dev/goyave/v4 v4.0.0/go.mod h1:VVpXSj2WMTL+Cb8wDs4IPrGWVzlQXu0t6uHNuU8WRFU= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= +gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +goyave.dev/copier v0.4.3 h1:MxX2wBnhQUbv0mHPXEgw/zS4TZMtTVpzj/aYS3h4amk= +goyave.dev/copier v0.4.3/go.mod h1:WJu0Ex81v29f5U0eMWzSNsMTGmuGY6lQ/q5yGlyLDsU= +goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78 h1:MFv0XiihEpvSLzK3C70amCTVtp+j1Vz8fkC2q2KmQ0c= +goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78/go.mod h1:zeSUREjru306U6K1Op7xdtQOXWPXDlCCA1vAoGgVzsg= +goyave.dev/goyave/v5 v5.0.0-rc11 h1:D3EhhsBm3fRSlIXgJgnUOyBGtc/H04/8N8a0hBOyvRk= +goyave.dev/goyave/v5 v5.0.0-rc11/go.mod h1:9FZ+9lQa5gzQDWLV9jGT2ZO35vDgYU8pK3Jov5E2zQc= diff --git a/http/controller/article/article.go b/http/controller/article/article.go index 69356e6..655ec92 100644 --- a/http/controller/article/article.go +++ b/http/controller/article/article.go @@ -1,114 +1,111 @@ package article import ( + "context" "net/http" + "strconv" - "github.com/go-goyave/goyave-blog-example/database/model" - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/database" - "goyave.dev/goyave/v4/util/sqlutil" + "github.com/go-goyave/goyave-blog-example/dto" + "github.com/go-goyave/goyave-blog-example/http/middleware" + "github.com/go-goyave/goyave-blog-example/service" + "goyave.dev/filter" + "goyave.dev/goyave/v5" + "goyave.dev/goyave/v5/auth" + "goyave.dev/goyave/v5/database" + "goyave.dev/goyave/v5/util/typeutil" ) -const ( - // DefaultPageSize the number of records per page when paginating - DefaultPageSize = 10 -) +type Service interface { + Index(ctx context.Context, request *filter.Request) (*database.PaginatorDTO[*dto.Article], error) + GetBySlug(ctx context.Context, slug string) (*dto.Article, error) + Create(ctx context.Context, createDTO *dto.CreateArticle) error + Update(ctx context.Context, id uint, updateDTO *dto.UpdateArticle) error + Delete(ctx context.Context, id uint) error + IsOwner(ctx context.Context, resourceID, ownerID uint) (bool, error) +} -// Index paginates all articles. -// Accepts the "page" and "pageSize" query parameters. -// If "search" query parameter is set, performs naive search by title. -func Index(response *goyave.Response, request *goyave.Request) { - articles := []model.Article{} - page := 1 - if request.Has("page") { - page = request.Integer("page") - } - pageSize := DefaultPageSize - if request.Has("pageSize") { - pageSize = request.Integer("pageSize") - } +type Controller struct { + goyave.Component + ArticleService Service +} - tx := database.Conn() +func NewController() *Controller { + return &Controller{} +} - if request.Has("search") { - search := sqlutil.EscapeLike(request.String("search")) - tx = tx.Where("title LIKE ?", "%"+search+"%") - } +func (ctrl *Controller) Init(server *goyave.Server) { + ctrl.Component.Init(server) + ctrl.ArticleService = server.Service(service.Article).(Service) +} - paginator := database.NewPaginator(tx, page, pageSize, &articles) - result := paginator.Find() - if response.HandleDatabaseError(result) { - response.JSON(http.StatusOK, paginator) - } +func (ctrl *Controller) RegisterRoutes(router *goyave.Router) { + subrouter := router.Subrouter("/articles") + subrouter.Get("/", ctrl.Index).ValidateQuery(filter.Validation) + subrouter.Get("/{slug}", ctrl.Show) + + authRouter := subrouter.Group().SetMeta(auth.MetaAuth, true) + authRouter.Post("/", ctrl.Create).ValidateBody(ctrl.CreateRequest) + + ownedRouter := authRouter.Group() + ownerMiddleware := middleware.NewOwner("articleID", ctrl.ArticleService) + ownedRouter.Middleware(ownerMiddleware) + ownedRouter.Patch("/{articleID:[0-9]+}", ctrl.Update).ValidateBody(ctrl.UpdateRequest) + ownedRouter.Delete("/{articleID:[0-9]+}", ctrl.Delete) } -// Show a single article. -func Show(response *goyave.Response, request *goyave.Request) { - article := model.Article{} - result := database.Conn().Where("slug = ?", request.Params["slug"]).First(&article) - if response.HandleDatabaseError(result) { - response.JSON(http.StatusOK, article) +func (ctrl *Controller) Index(response *goyave.Response, request *goyave.Request) { + paginator, err := ctrl.ArticleService.Index(request.Context(), filter.NewRequest(request.Query)) + if response.WriteDBError(err) { + return } + response.JSON(http.StatusOK, paginator) } -// Store a new article. -func Store(response *goyave.Response, request *goyave.Request) { - article := model.Article{ - Title: request.String("title"), - Contents: request.String("contents"), - AuthorID: request.User.(*model.User).ID, +func (ctrl *Controller) Show(response *goyave.Response, request *goyave.Request) { + user, err := ctrl.ArticleService.GetBySlug(request.Context(), request.RouteParams["slug"]) + if err != nil { + response.Error(err) + return } - if err := database.Conn().Create(&article).Error; err != nil { + response.JSON(http.StatusOK, user) +} + +func (ctrl *Controller) Create(response *goyave.Response, request *goyave.Request) { + createDTO := typeutil.MustConvert[*dto.CreateArticle](request.Data) + createDTO.AuthorID = request.User.(*dto.InternalUser).ID + + err := ctrl.ArticleService.Create(request.Context(), createDTO) + if err != nil { response.Error(err) - } else { - response.JSON(http.StatusCreated, map[string]interface{}{ - "id": article.ID, - "slug": article.Slug, - }) + return } + response.Status(http.StatusCreated) } -// Update an existing article. Only the author of the article can do that. -func Update(response *goyave.Response, request *goyave.Request) { - article := model.Article{} - db := database.Conn() - result := db.Select("id") - if slug, ok := request.Params["slug"]; ok { - result = result.Where("slug = ?", slug).First(&article) - } else { - result = result.First(&article, request.Params["id"]) +func (ctrl *Controller) Update(response *goyave.Response, request *goyave.Request) { + id, err := strconv.ParseUint(request.RouteParams["articleID"], 10, 64) + if err != nil { + response.Status(http.StatusNotFound) + return } - if response.HandleDatabaseError(result) { - updates := map[string]interface{}{} - for c := range UpdateRequest { - if request.Has(c) { - updates[c] = request.Data[c] - } - } - - if len(updates) <= 0 { - return - } - - if err := db.Model(&article).Updates(updates).Error; err != nil { - response.Error(err) - } + + updateDTO := typeutil.MustConvert[*dto.UpdateArticle](request.Data) + + err = ctrl.ArticleService.Update(request.Context(), uint(id), updateDTO) + if response.WriteDBError(err) { + return } } -// Destroy an existing article. Only the author of the article can do that. -func Destroy(response *goyave.Response, request *goyave.Request) { - article := model.Article{} - db := database.Conn() - result := db.Select("id") - if slug, ok := request.Params["slug"]; ok { - result = result.Where("slug = ?", slug).First(&article) - } else { - result = result.First(&article, request.Params["id"]) +func (ctrl *Controller) Delete(response *goyave.Response, request *goyave.Request) { + id, err := strconv.ParseUint(request.RouteParams["articleID"], 10, 64) + if err != nil { + response.Status(http.StatusNotFound) + return } - if response.HandleDatabaseError(result) { - if err := db.Delete(&article).Error; err != nil { - response.Error(err) - } + + err = ctrl.ArticleService.Delete(request.Context(), uint(id)) + if response.WriteDBError(err) { + return } } diff --git a/http/controller/article/request.go b/http/controller/article/request.go deleted file mode 100644 index ff9cd60..0000000 --- a/http/controller/article/request.go +++ /dev/null @@ -1,24 +0,0 @@ -package article - -import "goyave.dev/goyave/v4/validation" - -var ( - // InsertRequest validates Post requests for articles - InsertRequest validation.RuleSet = validation.RuleSet{ - "title": validation.List{"required", "string", "max:200"}, - "contents": validation.List{"required", "string"}, - } - - // UpdateRequest validates Patch requests for articles - UpdateRequest validation.RuleSet = validation.RuleSet{ - "title": validation.List{"string", "max:200"}, - "contents": validation.List{"string"}, - } - - // IndexRequest validates query parameters for paginating articles - IndexRequest validation.RuleSet = validation.RuleSet{ - "page": validation.List{"integer", "min:1"}, - "pageSize": validation.List{"integer", "between:10,100"}, - "search": validation.List{"string", "max:200"}, - } -) diff --git a/http/controller/article/validation.go b/http/controller/article/validation.go new file mode 100644 index 0000000..700f72e --- /dev/null +++ b/http/controller/article/validation.go @@ -0,0 +1,22 @@ +package article + +import ( + "goyave.dev/goyave/v5" + v "goyave.dev/goyave/v5/validation" +) + +func (ctrl *Controller) CreateRequest(_ *goyave.Request) v.RuleSet { + return v.RuleSet{ + {Path: v.CurrentElement, Rules: v.List{v.Required(), v.Object()}}, + {Path: "title", Rules: v.List{v.Required(), v.String(), v.Trim(), v.Between(1, 200)}}, + {Path: "contents", Rules: v.List{v.Required(), v.String()}}, + } +} + +func (ctrl *Controller) UpdateRequest(_ *goyave.Request) v.RuleSet { + return v.RuleSet{ + {Path: v.CurrentElement, Rules: v.List{v.Required(), v.Object()}}, + {Path: "title", Rules: v.List{v.String(), v.Trim(), v.Between(1, 200)}}, + {Path: "contents", Rules: v.List{v.String()}}, + } +} diff --git a/http/controller/user/request.go b/http/controller/user/request.go deleted file mode 100644 index 7815e54..0000000 --- a/http/controller/user/request.go +++ /dev/null @@ -1,27 +0,0 @@ -package user - -import "goyave.dev/goyave/v4/validation" - -var ( - // InsertRequest validates Post requests for users - InsertRequest validation.RuleSet = validation.RuleSet{ - "email": validation.List{"required", "string", "email", "between:3,100", "unique:users"}, - "username": validation.List{"required", "string", "between:3,100", "unique:users"}, - "image": validation.List{"nullable", "file", "image", "max:2048", "count:1"}, - "password": validation.List{"required", "string", "between:6,100", "password"}, - } - - // UpdateRequest validates Patch requests for users - UpdateRequest validation.RuleSet = validation.RuleSet{ - "email": validation.List{"string", "email", "between:3,100", "unique:users"}, - "username": validation.List{"string", "between:3,100", "unique:users"}, - "image": validation.List{"nullable", "file", "image", "max:2048", "count:1"}, - "password": validation.List{"string", "between:6,100", "password"}, - } - - // LoginRequest validates user login requests - LoginRequest validation.RuleSet = validation.RuleSet{ - "email": validation.List{"required", "string", "email"}, - "password": validation.List{"required", "string"}, - } -) diff --git a/http/controller/user/user.go b/http/controller/user/user.go index c77630d..2f12701 100644 --- a/http/controller/user/user.go +++ b/http/controller/user/user.go @@ -1,104 +1,94 @@ package user import ( + "context" + "io/fs" "net/http" + "strconv" - "github.com/go-goyave/goyave-blog-example/database/model" - "github.com/mitchellh/go-homedir" - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/database" - "goyave.dev/goyave/v4/util/fsutil" + "github.com/go-goyave/goyave-blog-example/dto" + "github.com/go-goyave/goyave-blog-example/service" + "gorm.io/gorm" + "goyave.dev/goyave/v5" + "goyave.dev/goyave/v5/auth" + "goyave.dev/goyave/v5/util/typeutil" ) -var ( - // StoragePath the path used to store user profile pictures - StoragePath string -) +type Service interface { + UniqueScope() func(db *gorm.DB, val any) *gorm.DB + GetByID(ctx context.Context, id uint) (*dto.InternalUser, error) + Register(ctx context.Context, registerDTO *dto.RegisterUser) error + Update(ctx context.Context, id uint, updateDTO *dto.UpdateUser) error +} -func init() { - home, err := homedir.Dir() - if err != nil { - goyave.ErrLogger.Fatal(err) - } - StoragePath = home + "/storage/" +type StorageService interface { + GetFS() fs.StatFS } -// Register insert a new user in the database -func Register(response *goyave.Response, request *goyave.Request) { - user := &model.User{ - Email: request.String("email"), - Username: request.String("username"), - Password: request.String("password"), - } - if request.Has("image") { // image is nullable - image := request.File("image")[0] - user.Image.String = image.Save(StoragePath, user.Username+"-"+image.Header.Filename) - user.Image.Valid = true - } +type Controller struct { + goyave.Component + UserService Service + StorageService StorageService +} - if err := database.Conn().Create(user).Error; err != nil { - if user.Image.Valid { - fsutil.Delete(StoragePath + user.Image.String) - } - response.Error(err) - } else { - response.JSON(http.StatusCreated, map[string]uint{"id": user.ID}) - } +func NewController() *Controller { + return &Controller{} } -// Show returns the authenticated user -func Show(response *goyave.Response, request *goyave.Request) { - response.JSON(http.StatusOK, request.User) +func (ctrl *Controller) Init(server *goyave.Server) { + ctrl.Component.Init(server) + ctrl.UserService = server.Service(service.User).(Service) + ctrl.StorageService = server.Service(service.Storage).(StorageService) } -// Image returns the profile picture of the authenticated user. -// A default profile picture is sent if the user doesn't have a profile picture. -func Image(response *goyave.Response, request *goyave.Request) { - user := model.User{} - result := database.Conn().First(&user, request.Params["id"]) - if response.HandleDatabaseError(result) { - path := "" - if user.Image.Valid { - path = StoragePath + user.Image.String - } else { - path = "resources/img/default_profile_picture.png" - } - if err := response.File(path); err != nil { - response.Error(err) - } - } +func (ctrl *Controller) RegisterRoutes(router *goyave.Router) { + subrouter := router.Subrouter("/users") + subrouter.Post("/", ctrl.Register).ValidateBody(ctrl.RegisterRequest) + subrouter.Get("/{userID:[0-9]+}/avatar", ctrl.ShowAvatar) + + authRouter := subrouter.Group().SetMeta(auth.MetaAuth, true) + authRouter.Get("/profile", ctrl.ShowProfile) + authRouter.Patch("/", ctrl.Update).ValidateBody(ctrl.UpdateRequest) +} + +func (ctrl *Controller) ShowProfile(response *goyave.Response, request *goyave.Request) { + userDTO := typeutil.MustConvert[*dto.User](request.User) + response.JSON(http.StatusOK, userDTO) } -// Update replaces the record of the authenticated user -// If the profile picture is modified, the previous one is deleted. -func Update(response *goyave.Response, request *goyave.Request) { - db := database.Conn() - user := request.User.(*model.User) - if request.Has("image") { - path := StoragePath + user.Image.String - if user.Image.Valid && fsutil.FileExists(path) { - fsutil.Delete(path) - } - - if request.Data["image"] != nil { - image := request.File("image")[0] - actualName := image.Save(StoragePath, user.Username+"-"+image.Header.Filename) - request.Data["image"] = actualName - } +func (ctrl *Controller) ShowAvatar(response *goyave.Response, request *goyave.Request) { + id, err := strconv.ParseUint(request.RouteParams["userID"], 10, 64) + if err != nil { + response.Status(http.StatusNotFound) + return } - updates := map[string]interface{}{} - for c := range UpdateRequest { - if request.Has(c) { - updates[c] = request.Data[c] - } + user, err := ctrl.UserService.GetByID(request.Context(), uint(id)) + if response.WriteDBError(err) { + return } - if len(updates) <= 0 { + response.File(ctrl.StorageService.GetFS(), user.Avatar) +} + +func (ctrl *Controller) Register(response *goyave.Response, request *goyave.Request) { + registerDTO := typeutil.MustConvert[*dto.RegisterUser](request.Data) + + err := ctrl.UserService.Register(request.Context(), registerDTO) + if err != nil { + response.Error(err) return } + response.Status(http.StatusCreated) +} - if err := db.Model(request.User).Updates(updates).Error; err != nil { +func (ctrl *Controller) Update(response *goyave.Response, request *goyave.Request) { + updateDTO := typeutil.MustConvert[*dto.UpdateUser](request.Data) + id := request.User.(*dto.InternalUser).ID + + err := ctrl.UserService.Update(request.Context(), id, updateDTO) + if err != nil { response.Error(err) + return } } diff --git a/http/controller/user/validation.go b/http/controller/user/validation.go new file mode 100644 index 0000000..4e40dcb --- /dev/null +++ b/http/controller/user/validation.go @@ -0,0 +1,31 @@ +package user + +import ( + vv "github.com/go-goyave/goyave-blog-example/http/validation" + "goyave.dev/goyave/v5" + v "goyave.dev/goyave/v5/validation" +) + +func (ctrl *Controller) RegisterRequest(_ *goyave.Request) v.RuleSet { + return v.RuleSet{ + {Path: v.CurrentElement, Rules: v.List{v.Required(), v.Object()}}, + {Path: "email", Rules: v.List{ + v.Required(), v.String(), v.Trim(), v.Email(), v.Max(320), v.Unique(ctrl.UserService.UniqueScope()), + }}, + {Path: "username", Rules: v.List{v.Required(), v.String(), v.Trim(), v.Between(3, 100)}}, + {Path: "avatar", Rules: v.List{v.Nullable(), v.File(), v.Image(), v.Max(2048), v.FileCount(1)}}, + {Path: "password", Rules: v.List{v.Required(), v.String(), v.Between(6, 72), vv.Password()}}, + } +} + +func (ctrl *Controller) UpdateRequest(_ *goyave.Request) v.RuleSet { + return v.RuleSet{ + {Path: v.CurrentElement, Rules: v.List{v.Required(), v.Object()}}, + {Path: "email", Rules: v.List{ + v.String(), v.Trim(), v.Email(), v.Max(320), v.Unique(ctrl.UserService.UniqueScope()), + }}, + {Path: "username", Rules: v.List{v.String(), v.Trim(), v.Between(3, 100)}}, + {Path: "avatar", Rules: v.List{v.Nullable(), v.File(), v.Image(), v.Max(2048), v.FileCount(1)}}, + {Path: "password", Rules: v.List{v.String(), v.Between(6, 72), vv.Password()}}, + } +} diff --git a/http/middleware/.gitkeep b/http/middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/http/middleware/owner.go b/http/middleware/owner.go index d3d8ad9..38c51f2 100644 --- a/http/middleware/owner.go +++ b/http/middleware/owner.go @@ -1,35 +1,54 @@ package middleware import ( - "errors" + "context" "net/http" + "strconv" - dbModel "github.com/go-goyave/goyave-blog-example/database/model" - "gorm.io/gorm" - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/database" + "github.com/go-goyave/goyave-blog-example/dto" + "goyave.dev/goyave/v5" ) -// Owner checks if the authenticated user is the owner of the requested resource. -// - param: the name of the URL parameter to use -// - column: the name of the foreign key column -// - model: the model of the requested resource -func Owner(param, column string, model interface{}) goyave.Middleware { - return func(next goyave.Handler) goyave.Handler { - return func(response *goyave.Response, request *goyave.Request) { - - if p, ok := request.Params[param]; ok { - id := request.User.(*dbModel.User).ID - if err := database.Conn().Model(model).Select("1").Where(column+" = ?", id).First(map[string]interface{}{}, p).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Status(http.StatusForbidden) - } else { - response.Error(err) - } - return - } - } - next(response, request) +type OwnerService interface { + IsOwner(ctx context.Context, resourceID, ownerID uint) (bool, error) +} + +type Owner struct { + goyave.Component + + OwnerService OwnerService + + // RouteParam the name of the route param identifying the requested resource (e.g: "articleID") + RouteParam string +} + +func NewOwner(routeParam string, ownerService OwnerService) *Owner { + return &Owner{ + RouteParam: routeParam, + OwnerService: ownerService, + } +} + +func (m *Owner) Handle(next goyave.Handler) goyave.Handler { + return func(response *goyave.Response, request *goyave.Request) { + resourceID, err := strconv.ParseUint(request.RouteParams[m.RouteParam], 10, 64) + if err != nil { + response.Status(http.StatusNotFound) + return + } + + user := request.User.(*dto.InternalUser) + + isOwner, err := m.OwnerService.IsOwner(request.Context(), uint(resourceID), user.ID) + if response.WriteDBError(err) { + return } + + if !isOwner { + response.Status(http.StatusForbidden) + return + } + + next(response, request) } } diff --git a/http/route/route.go b/http/route/route.go index f6d10b3..22f0282 100644 --- a/http/route/route.go +++ b/http/route/route.go @@ -1,63 +1,29 @@ package route import ( - "github.com/go-goyave/goyave-blog-example/database/model" "github.com/go-goyave/goyave-blog-example/http/controller/article" "github.com/go-goyave/goyave-blog-example/http/controller/user" - "github.com/go-goyave/goyave-blog-example/http/middleware" - - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/auth" - "goyave.dev/goyave/v4/cors" - "goyave.dev/goyave/v4/log" - "goyave.dev/goyave/v4/middleware/ratelimiter" + "github.com/go-goyave/goyave-blog-example/service" + userservice "github.com/go-goyave/goyave-blog-example/service/user" + "goyave.dev/goyave/v5" + "goyave.dev/goyave/v5/auth" + "goyave.dev/goyave/v5/cors" + "goyave.dev/goyave/v5/log" + "goyave.dev/goyave/v5/middleware/parse" ) -// Register all the application routes. This is the main route registrer. -func Register(router *goyave.Router) { - +func Register(server *goyave.Server, router *goyave.Router) { router.CORS(cors.Default()) - router.GlobalMiddleware(log.CombinedLogMiddleware()) - router.GlobalMiddleware(ratelimiter.New(model.RateLimiterFunc)) - - authenticator := auth.Middleware(&model.User{}, &auth.JWTAuthenticator{}) - - registerUserRoutes(router, authenticator) - registerArticleRoutes(router, authenticator) -} - -func registerUserRoutes(parent *goyave.Router, authenticator goyave.Middleware) { - - jwtController := auth.NewJWTController(&model.User{}) - jwtController.UsernameField = "email" - userRouter := parent.Subrouter("/user") - userRouter.Post("/login", jwtController.Login).Validate(user.LoginRequest) - userRouter.Post("/", user.Register).Validate(user.InsertRequest) - userRouter.Get("/{id:[0-9]+}/image", user.Image) - - authRouter := userRouter.Group() - authRouter.Middleware(authenticator) - authRouter.Get("/", user.Show) - authRouter.Patch("/", user.Update).Validate(user.UpdateRequest) - -} - -func registerArticleRoutes(parent *goyave.Router, authenticator goyave.Middleware) { - - articleRouter := parent.Subrouter("/article") - articleRouter.Get("/", article.Index).Validate(article.IndexRequest) - articleRouter.Get("/{slug}", article.Show) - - authRouter := articleRouter.Group() - authRouter.Middleware(authenticator) - authRouter.Post("/", article.Store).Validate(article.InsertRequest) - - ownedRouter := authRouter.Group() - ownerMiddleware := middleware.Owner("id", "author_id", &model.Article{}) - ownedRouter.Middleware(ownerMiddleware) - ownedRouter.Patch("/{id:[0-9]+}", article.Update).Validate(article.UpdateRequest) - ownedRouter.Patch("/{slug}", article.Update).Validate(article.UpdateRequest) - ownedRouter.Delete("/{id:[0-9]+}", article.Destroy) - ownedRouter.Delete("/{slug}", article.Destroy) - + router.GlobalMiddleware(log.CombinedLogMiddleware(), &parse.Middleware{}) + + userService := server.Service(service.User).(*userservice.Service) + authenticator := auth.NewJWTAuthenticator(userService) + authMiddleware := auth.Middleware(authenticator) + router.GlobalMiddleware(authMiddleware) + + loginController := auth.NewJWTController(userService, "Password") + loginController.UsernameRequestField = "email" + router.Controller(loginController) + router.Controller(user.NewController()) + router.Controller(article.NewController()) } diff --git a/http/validation/.gitkeep b/http/validation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/http/validation/validation.go b/http/validation/password.go similarity index 59% rename from http/validation/validation.go rename to http/validation/password.go index fc8291c..ae87e14 100644 --- a/http/validation/validation.go +++ b/http/validation/password.go @@ -1,35 +1,12 @@ package validation -import "goyave.dev/goyave/v4/validation" +import "goyave.dev/goyave/v5/validation" -func init() { - validation.AddRule("password", &validation.RuleDefinition{ - Function: validatePassword, - RequiredParameters: 0, - }) +type PasswordValidator struct { + validation.BaseValidator } -func isLowerCaseLetter(r rune) bool { - return r >= 'a' && r <= 'z' -} - -func isUpperCaseLetter(r rune) bool { - return r >= 'A' && r <= 'Z' -} - -func isDigit(r rune) bool { - return r >= '0' && r <= '9' -} - -func isSpecialChar(r rune) bool { - return !isLowerCaseLetter(r) && !isUpperCaseLetter(r) && !isDigit(r) -} - -// validatePassword takes an input and checks if it fulfills password strength criteria: -// - at least one uppercase and one lowercase letter -// - at least one digit -// - at least one special character (! @ # ? ] etc., any utf-8 character that is not a letter or a digit) -func validatePassword(ctx *validation.Context) bool { +func (v *PasswordValidator) Validate(ctx *validation.Context) bool { str, ok := ctx.Value.(string) if ok { @@ -55,3 +32,27 @@ func validatePassword(ctx *validation.Context) bool { return false // Cannot validate this field } + +func isLowerCaseLetter(r rune) bool { + return r >= 'a' && r <= 'z' +} + +func isUpperCaseLetter(r rune) bool { + return r >= 'A' && r <= 'Z' +} + +func isDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +func isSpecialChar(r rune) bool { + return !isLowerCaseLetter(r) && !isUpperCaseLetter(r) && !isDigit(r) +} + +func (v *PasswordValidator) Name() string { return "password" } + +// Password the field under validation must contain at least one lower case, +// one upper case letter, one digit and one special character. +func Password() *PasswordValidator { + return &PasswordValidator{} +} diff --git a/http/validation/validation_test.go b/http/validation/validation_test.go deleted file mode 100644 index 83d4f80..0000000 --- a/http/validation/validation_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "goyave.dev/goyave/v4/validation" -) - -func TestIsLowerCaseLetter(t *testing.T) { - assert.True(t, isLowerCaseLetter('a')) - assert.True(t, isLowerCaseLetter('z')) - assert.False(t, isLowerCaseLetter('A')) - assert.False(t, isLowerCaseLetter('Z')) - assert.False(t, isLowerCaseLetter('0')) - assert.False(t, isLowerCaseLetter('9')) - assert.False(t, isLowerCaseLetter(' ')) - assert.False(t, isLowerCaseLetter('*')) - assert.False(t, isLowerCaseLetter('·')) - assert.False(t, isLowerCaseLetter('👍')) -} - -func TestIsUpperCaseLetter(t *testing.T) { - assert.False(t, isUpperCaseLetter('a')) - assert.False(t, isUpperCaseLetter('z')) - assert.True(t, isUpperCaseLetter('A')) - assert.True(t, isUpperCaseLetter('Z')) - assert.False(t, isUpperCaseLetter('0')) - assert.False(t, isUpperCaseLetter('9')) - assert.False(t, isUpperCaseLetter(' ')) - assert.False(t, isUpperCaseLetter('*')) - assert.False(t, isUpperCaseLetter('·')) - assert.False(t, isUpperCaseLetter('👍')) -} - -func TestIsDigit(t *testing.T) { - assert.False(t, isDigit('a')) - assert.False(t, isDigit('z')) - assert.False(t, isDigit('A')) - assert.False(t, isDigit('Z')) - assert.True(t, isDigit('0')) - assert.True(t, isDigit('9')) - assert.False(t, isDigit(' ')) - assert.False(t, isDigit('*')) - assert.False(t, isDigit('·')) - assert.False(t, isDigit('👍')) -} - -func TestIsSpecialChar(t *testing.T) { - assert.False(t, isSpecialChar('a')) - assert.False(t, isSpecialChar('z')) - assert.False(t, isSpecialChar('A')) - assert.False(t, isSpecialChar('Z')) - assert.False(t, isSpecialChar('0')) - assert.False(t, isSpecialChar('9')) - assert.True(t, isSpecialChar(' ')) - assert.True(t, isSpecialChar('*')) - assert.True(t, isSpecialChar('·')) - assert.True(t, isSpecialChar('👍')) -} - -func TestValidatePassword(t *testing.T) { - assert.True(t, validatePassword(&validation.Context{Value: "pAssword.1"})) - assert.False(t, validatePassword(&validation.Context{Value: "pAssword."})) - assert.False(t, validatePassword(&validation.Context{Value: "pAssword1"})) - assert.False(t, validatePassword(&validation.Context{Value: "password.1"})) - assert.False(t, validatePassword(&validation.Context{Value: "PASSWORD.1"})) - assert.False(t, validatePassword(&validation.Context{Value: 42})) -} diff --git a/main.go b/main.go index 3190042..c28d498 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,94 @@ package main import ( + "embed" + "flag" + "fmt" "os" - "github.com/go-goyave/goyave-blog-example/database/seeder" + "github.com/go-goyave/goyave-blog-example/database/repository" + seeders "github.com/go-goyave/goyave-blog-example/database/seed" "github.com/go-goyave/goyave-blog-example/http/route" + "github.com/go-goyave/goyave-blog-example/service/article" + "github.com/go-goyave/goyave-blog-example/service/storage" + "github.com/go-goyave/goyave-blog-example/service/user" - _ "github.com/go-goyave/goyave-blog-example/http/validation" + "goyave.dev/goyave/v5" + "goyave.dev/goyave/v5/util/errors" + "goyave.dev/goyave/v5/util/fsutil" + "goyave.dev/goyave/v5/util/fsutil/osfs" + "goyave.dev/goyave/v5/util/session" - "goyave.dev/goyave/v4" - _ "goyave.dev/goyave/v4/database/dialect/mysql" + _ "goyave.dev/goyave/v5/database/dialect/postgres" ) +//go:embed resources +var resources embed.FS + func main() { - goyave.RegisterStartupHook(seeder.Run) + var seed bool + flag.BoolVar(&seed, "seed", false, "If true, the database will be seeded with random data.") + flag.Parse() + + resources := fsutil.NewEmbed(resources) + langFS, err := resources.Sub("resources/lang") + if err != nil { + fmt.Fprintln(os.Stderr, err.(*errors.Error).String()) + os.Exit(1) + } - if err := goyave.Start(route.Register); err != nil { - os.Exit(err.(*goyave.Error).ExitCode) + opts := goyave.Options{ + LangFS: langFS, } + + server, err := goyave.New(opts) + if err != nil { + fmt.Fprintln(os.Stderr, err.(*errors.Error).String()) + os.Exit(1) + } + + server.Logger.Info("Registering hooks") + server.RegisterSignalHook() + + server.RegisterStartupHook(func(s *goyave.Server) { + server.Logger.Info("Server is listening", "host", s.Host()) + }) + + server.RegisterShutdownHook(func(s *goyave.Server) { + s.Logger.Info("Server is shutting down") + }) + + registerServices(server) + + server.Logger.Info("Registering routes") + server.RegisterRoutes(route.Register) + + if seed { + server.Logger.Info("Seeding database...") + seeders.Seed(server.DB()) + } + + if err := server.Start(); err != nil { + server.Logger.Error(err) + os.Exit(2) + } +} + +func registerServices(server *goyave.Server) { + server.Logger.Info("Registering services") + + session := session.GORM(server.DB(), nil) + + userRepo := repository.NewUser(server.DB()) + articleRepo := repository.NewArticle(server.DB()) + + storageFS, err := (&osfs.FS{}).Sub(".storage/avatars") + if err != nil { + panic(errors.New(err)) + } + storageService := storage.NewService(storageFS) + + server.RegisterService(storageService) + server.RegisterService(user.NewService(session, server.Logger, userRepo, storageService)) + server.RegisterService(article.NewService(session, articleRepo)) } diff --git a/resources/img/default_profile_picture.png b/resources/img/default_profile_picture.png deleted file mode 100644 index 31dc931..0000000 Binary files a/resources/img/default_profile_picture.png and /dev/null differ diff --git a/resources/lang/README.md b/resources/lang/README.md deleted file mode 100644 index ac220a8..0000000 --- a/resources/lang/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Localization - -The Goyave framework provides a convenient way to support multiple languages within your application. Out of the box, Goyave only provides the `en-US` language. - -Language files are stored in the `resources/lang` directory. - -``` -. -└── resources -    └── lang -       └── en-US (language name) -       ├── fields.json (optional) -       ├── locale.json (optional) -       └── rules.json (optional) -``` - -Each language has its own directory and should be named with an [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code. You can also append a variant to your languages: `en-US`, `en-UK`, `fr-FR`, `fr-CA`, ... **Case is important.** - -Each language directory contains three files. Each file is **optional**. -- `fields.json`: field names translations and field-specific rule messages. -- `locale.json`: all other language lines. -- `rules.json`: validation rules messages. - -All directories in the `resources/lang` directory are automatically loaded when the server starts. - -Learn more about localization [here](https://goyave.dev/guide/advanced/localization.html). \ No newline at end of file diff --git a/resources/lang/en-US/fields.json b/resources/lang/en-US/fields.json index 4102129..9e26dfe 100644 --- a/resources/lang/en-US/fields.json +++ b/resources/lang/en-US/fields.json @@ -1,5 +1 @@ -{ - "pageSize": { - "name": "page size" - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/resources/lang/en-US/rules.json b/resources/lang/en-US/rules.json index 802f93d..0c9a09e 100644 --- a/resources/lang/en-US/rules.json +++ b/resources/lang/en-US/rules.json @@ -1,3 +1,3 @@ { - "password": "The :field must contain at least one lower case, one upper case letter, one digit and one special character." + "password": "The :field must contain at least one lower case, one upper case letter, one digit and one special character." } \ No newline at end of file diff --git a/resources/test/img/goyave_64.png b/resources/test/img/goyave_64.png deleted file mode 100644 index a65c819..0000000 Binary files a/resources/test/img/goyave_64.png and /dev/null differ diff --git a/service/article/article.go b/service/article/article.go new file mode 100644 index 0000000..6285c0c --- /dev/null +++ b/service/article/article.go @@ -0,0 +1,111 @@ +package article + +import ( + "context" + "encoding/base32" + "fmt" + "strings" + + "github.com/go-goyave/goyave-blog-example/database/model" + "github.com/go-goyave/goyave-blog-example/dto" + "github.com/go-goyave/goyave-blog-example/service" + "github.com/google/uuid" + "github.com/gosimple/slug" + "goyave.dev/filter" + "goyave.dev/goyave/v5/database" + "goyave.dev/goyave/v5/util/errors" + "goyave.dev/goyave/v5/util/session" + "goyave.dev/goyave/v5/util/typeutil" +) + +func init() { + slug.MaxLength = 126 +} + +type Repository interface { + Index(ctx context.Context, request *filter.Request) (*database.Paginator[*model.Article], error) + Create(ctx context.Context, article *model.Article) (*model.Article, error) + Update(ctx context.Context, article *model.Article) (*model.Article, error) + GetByID(ctx context.Context, id uint) (*model.Article, error) + GetBySlug(ctx context.Context, slug string) (*model.Article, error) + Delete(ctx context.Context, id uint) error + IsOwner(ctx context.Context, resourceID, ownerID uint) (bool, error) +} + +type Service struct { + Session session.Session + Repository Repository +} + +func NewService(session session.Session, repository Repository) *Service { + return &Service{ + Session: session, + Repository: repository, + } +} + +func (s *Service) Index(ctx context.Context, request *filter.Request) (*database.PaginatorDTO[*dto.Article], error) { + paginator, err := s.Repository.Index(ctx, request) + if err != nil { + return nil, errors.New(err) + } + return typeutil.MustConvert[*database.PaginatorDTO[*dto.Article]](paginator), nil +} + +func (s *Service) GetBySlug(ctx context.Context, slug string) (*dto.Article, error) { + user, err := s.Repository.GetBySlug(ctx, slug) + if err != nil { + return nil, errors.New(err) + } + return typeutil.MustConvert[*dto.Article](user), nil +} + +func (s *Service) Create(ctx context.Context, createDTO *dto.CreateArticle) error { + article := typeutil.Copy(&model.Article{}, createDTO) + var err error + article.Slug, err = s.GenerateSlug(article.Title) + if err != nil { + return errors.New(err) + } + _, err = s.Repository.Create(ctx, article) + return errors.New(err) +} + +func (s *Service) GenerateSlug(title string) (string, error) { + uuid, err := uuid.NewRandom() + if err != nil { + return "", errors.New(err) + } + + shortUID := strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(uuid[:]), "=")) + return slug.Make(fmt.Sprintf("%s-%s", shortUID, title)), nil +} + +func (s *Service) Update(ctx context.Context, id uint, updateDTO *dto.UpdateArticle) error { + err := s.Session.Transaction(ctx, func(ctx context.Context) error { + var err error + user, err := s.Repository.GetByID(ctx, id) + if err != nil { + return errors.New(err) + } + + user = typeutil.Copy(user, updateDTO) + + _, err = s.Repository.Update(ctx, user) + return errors.New(err) + }) + + return errors.New(err) +} + +func (s *Service) Delete(ctx context.Context, id uint) error { + return s.Repository.Delete(ctx, id) +} + +func (s *Service) IsOwner(ctx context.Context, resourceID, ownerID uint) (bool, error) { + return s.Repository.IsOwner(ctx, resourceID, ownerID) +} + +func (s *Service) Name() string { + return service.Article +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..852d48f --- /dev/null +++ b/service/service.go @@ -0,0 +1,8 @@ +package service + +// Name of the implemented services. +const ( + User = "user" + Storage = "storage" + Article = "article" +) diff --git a/service/storage/storage.go b/service/storage/storage.go new file mode 100644 index 0000000..b5cae7b --- /dev/null +++ b/service/storage/storage.go @@ -0,0 +1,41 @@ +package storage + +import ( + "fmt" + "io/fs" + + "github.com/go-goyave/goyave-blog-example/service" + "goyave.dev/goyave/v5/util/fsutil" +) + +type FS interface { + fsutil.FS + fsutil.WritableFS + fsutil.RemoveFS +} + +type Service struct { + FS FS +} + +func NewService(fs FS) *Service { + return &Service{ + FS: fs, + } +} + +func (s *Service) GetFS() fs.StatFS { + return s.FS +} + +func (s *Service) SaveAvatar(file fsutil.File) (string, error) { + return file.Save(s.FS, "", fmt.Sprintf("user_avatar_%s", file.Header.Filename)) +} + +func (s *Service) Delete(name string) error { + return s.FS.Remove(name) +} + +func (*Service) Name() string { + return service.Storage +} diff --git a/service/user/user.go b/service/user/user.go new file mode 100644 index 0000000..1868f6a --- /dev/null +++ b/service/user/user.go @@ -0,0 +1,118 @@ +package user + +import ( + "context" + "fmt" + "io/fs" + + "github.com/go-goyave/goyave-blog-example/database/model" + "github.com/go-goyave/goyave-blog-example/dto" + "github.com/go-goyave/goyave-blog-example/service" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "goyave.dev/goyave/v5/slog" + "goyave.dev/goyave/v5/util/errors" + "goyave.dev/goyave/v5/util/fsutil" + "goyave.dev/goyave/v5/util/session" + "goyave.dev/goyave/v5/util/typeutil" +) + +type Repository interface { + Create(ctx context.Context, user *model.User) (*model.User, error) + Update(ctx context.Context, user *model.User) (*model.User, error) + GetByID(ctx context.Context, id uint) (*model.User, error) + GetByEmail(ctx context.Context, email string) (*model.User, error) + UniqueScope() func(db *gorm.DB, val any) *gorm.DB +} + +type StorageService interface { + GetFS() fs.StatFS + SaveAvatar(fsutil.File) (string, error) + Delete(string) error +} + +type Service struct { + Session session.Session + Repository Repository + StorageService StorageService + Logger *slog.Logger +} + +func NewService(session session.Session, logger *slog.Logger, repository Repository, storageService StorageService) *Service { + return &Service{ + Session: session, + Logger: logger, + Repository: repository, + StorageService: storageService, + } +} + +func (s *Service) UniqueScope() func(db *gorm.DB, val any) *gorm.DB { + return s.Repository.UniqueScope() +} + +func (s *Service) GetByID(ctx context.Context, id uint) (*dto.InternalUser, error) { + user, err := s.Repository.GetByID(ctx, id) + if err != nil { + return nil, errors.New(err) + } + return typeutil.MustConvert[*dto.InternalUser](user), nil +} + +func (s *Service) FindByUsername(ctx context.Context, username any) (*dto.InternalUser, error) { + user, err := s.Repository.GetByEmail(ctx, fmt.Sprintf("%v", username)) + if err != nil { + return nil, errors.New(err) + } + return typeutil.MustConvert[*dto.InternalUser](user), nil +} + +func (s *Service) Register(ctx context.Context, registerDTO *dto.RegisterUser) error { + err := s.Session.Transaction(ctx, func(ctx context.Context) error { + user := typeutil.Copy(&model.User{}, registerDTO) + + b, err := bcrypt.GenerateFromPassword([]byte(registerDTO.Password), bcrypt.DefaultCost) + if err != nil { + return errors.New(err) + } + user.Password = string(b) + + if registerDTO.Avatar.IsPresent() { + filename, err := s.StorageService.SaveAvatar(registerDTO.Avatar.Val[0]) + if err != nil { + return errors.New(err) + } + user.Avatar = filename + } + + _, err = s.Repository.Create(ctx, user) + if err != nil { + if err := s.StorageService.Delete(user.Avatar); err != nil { + s.Logger.Error(errors.New(err)) + } + } + return errors.New(err) + }) + return errors.New(err) +} + +func (s *Service) Update(ctx context.Context, id uint, updateDTO *dto.UpdateUser) error { + err := s.Session.Transaction(ctx, func(ctx context.Context) error { + var err error + user, err := s.Repository.GetByID(ctx, id) + if err != nil { + return errors.New(err) + } + + user = typeutil.Copy(user, updateDTO) + + _, err = s.Repository.Update(ctx, user) + return errors.New(err) + }) + + return errors.New(err) +} + +func (s *Service) Name() string { + return service.User +} diff --git a/test/article_test.go b/test/article_test.go deleted file mode 100644 index 2a9f6e8..0000000 --- a/test/article_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package test - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "testing" - - "goyave.dev/goyave/v4" - - "github.com/go-goyave/goyave-blog-example/database/model" - "github.com/go-goyave/goyave-blog-example/http/controller/article" - "github.com/go-goyave/goyave-blog-example/http/route" - _ "github.com/go-goyave/goyave-blog-example/http/validation" - "goyave.dev/goyave/v4/auth" - "goyave.dev/goyave/v4/database" - _ "goyave.dev/goyave/v4/database/dialect/mysql" -) - -type ArticleTestSuite struct { - goyave.TestSuite - userID uint -} - -type paginationExpectation struct { - MaxPage float64 - Total float64 - PageSize float64 - CurrentPage float64 - RecordsLength float64 -} - -func (suite *ArticleTestSuite) SetupTest() { - suite.ClearDatabase() - factory := database.NewFactory(model.UserGenerator) - override := &model.User{ - Username: "jack", - Email: "jack@example.org", - } - suite.userID = factory.Override(override).Save(1).([]*model.User)[0].ID -} - -func (suite *ArticleTestSuite) expect(resp *http.Response, expectation paginationExpectation) { - json := map[string]interface{}{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Equal(expectation.MaxPage, json["maxPage"]) - suite.Equal(expectation.Total, json["total"]) - suite.Equal(expectation.PageSize, json["pageSize"]) - suite.Equal(expectation.CurrentPage, json["currentPage"]) - - records := json["records"].([]interface{}) - suite.Len(records, int(expectation.RecordsLength)) - } -} - -func (suite *ArticleTestSuite) TestIndex() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - } - factory.Override(override).Save(article.DefaultPageSize + 1) - - resp, err := suite.Get("/article", nil) - suite.Nil(err) - if err == nil { - defer resp.Body.Close() - suite.expect(resp, paginationExpectation{ - MaxPage: 2, - Total: article.DefaultPageSize + 1, - PageSize: article.DefaultPageSize, - CurrentPage: 1, - RecordsLength: article.DefaultPageSize, - }) - } - - resp, err = suite.Get("/article?page=2", nil) - suite.Nil(err) - if err == nil { - defer resp.Body.Close() - suite.expect(resp, paginationExpectation{ - MaxPage: 2, - Total: article.DefaultPageSize + 1, - PageSize: article.DefaultPageSize, - CurrentPage: 2, - RecordsLength: 1, - }) - } - - resp, err = suite.Get("/article?pageSize=15&page=1", nil) - suite.Nil(err) - if err == nil { - defer resp.Body.Close() - suite.expect(resp, paginationExpectation{ - MaxPage: 1, - Total: article.DefaultPageSize + 1, - PageSize: 15, - CurrentPage: 1, - RecordsLength: 11, - }) - } - }) -} - -func (suite *ArticleTestSuite) TestIndexSearch() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - } - factory.Override(override).Save(article.DefaultPageSize + 1) - - override = &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - factory.Override(override).Save(1) - - resp, err := suite.Get("/article?search=interesting", nil) - suite.Nil(err) - if err == nil { - defer resp.Body.Close() - suite.expect(resp, paginationExpectation{ - MaxPage: 1, - Total: 1, - PageSize: article.DefaultPageSize, - CurrentPage: 1, - RecordsLength: 1, - }) - } - }) -} - -func (suite *ArticleTestSuite) TestShow() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - factory.Override(override).Save(1) - - resp, err := suite.Get("/article/a-very-interesting-article", nil) - suite.Nil(err) - if err == nil { - defer resp.Body.Close() - json := map[string]interface{}{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Equal(override.Title, json["Title"]) - } - } - }) -} - -func (suite *ArticleTestSuite) TestStore() { - suite.RunServer(route.Register, func() { - token, err := auth.GenerateToken("jack@example.org") - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Content-Type": "application/json", - "Authorization": "Bearer " + token, - } - request := map[string]interface{}{ - "title": "A very interesting article", - "contents": "lorem ipsum sit dolor amet", - } - body, _ := json.Marshal(request) - resp, err := suite.Post("/article", headers, bytes.NewReader(body)) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusCreated, resp.StatusCode) - json := map[string]interface{}{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Contains(json, "id") - suite.Contains(json, "slug") - suite.Equal("a-very-interesting-article", json["slug"]) - } - - count := int64(0) - res := database.Conn(). - Model(&model.Article{}). - Where("slug = ?", "a-very-interesting-article"). - Where("author_id = ?", suite.userID). - Count(&count) - if err := res.Error; err != nil { - suite.Error(err) - } - suite.Equal(int64(1), count) - } - }) -} - -func (suite *ArticleTestSuite) TestUpdateByID() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - article := factory.Override(override).Save(1).([]*model.Article)[0] - - suite.testUpdate(fmt.Sprintf("/article/%d", article.ID)) - }) -} - -func (suite *ArticleTestSuite) TestUpdateBySlug() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - article := factory.Override(override).Save(1).([]*model.Article)[0] - - suite.testUpdate(fmt.Sprintf("/article/%s", article.Slug)) - }) -} - -func (suite *ArticleTestSuite) testUpdate(url string) { - token, err := auth.GenerateToken("jack@example.org") - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Content-Type": "application/json", - "Authorization": "Bearer " + token, - } - request := map[string]interface{}{ - "title": "A boring article", - } - body, _ := json.Marshal(request) - resp, err := suite.Patch(url, headers, bytes.NewReader(body)) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusNoContent, resp.StatusCode) - - count := int64(0) - res := database.Conn(). - Model(&model.Article{}). - Where("slug = ?", "a-boring-article"). - Count(&count) - if err := res.Error; err != nil { - suite.Error(err) - } - suite.Equal(int64(1), count) - } -} - -func (suite *ArticleTestSuite) TestDestroyByID() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - article := factory.Override(override).Save(1).([]*model.Article)[0] - - suite.testDestroy(fmt.Sprintf("/article/%d", article.ID), article.ID) - }) -} - -func (suite *ArticleTestSuite) TestDestroyBySlug() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.ArticleGenerator) - override := &model.Article{ - AuthorID: suite.userID, - Title: "A very interesting article", - } - article := factory.Override(override).Save(1).([]*model.Article)[0] - - suite.testDestroy(fmt.Sprintf("/article/%s", article.Slug), article.ID) - }) -} - -func (suite *ArticleTestSuite) testDestroy(url string, articleID uint) { - token, err := auth.GenerateToken("jack@example.org") - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Authorization": "Bearer " + token, - } - resp, err := suite.Delete(url, headers, nil) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusNoContent, resp.StatusCode) - - count := int64(0) - res := database.Conn(). - Model(&model.Article{}). - Where("id = ?", articleID). - Count(&count) - if err := res.Error; err != nil { - suite.Error(err) - } - suite.Equal(int64(0), count) - } -} - -func TestArticleSuite(t *testing.T) { - goyave.RunTest(t, new(ArticleTestSuite)) -} diff --git a/test/test.go b/test/test.go deleted file mode 100644 index b03273c..0000000 --- a/test/test.go +++ /dev/null @@ -1,4 +0,0 @@ -package test - -// This file is there to prevent "no non-test Go files in..." error -// with some versions of the go tools diff --git a/test/user_test.go b/test/user_test.go deleted file mode 100644 index e0728bc..0000000 --- a/test/user_test.go +++ /dev/null @@ -1,440 +0,0 @@ -package test - -import ( - "bytes" - "encoding/json" - "fmt" - "io/ioutil" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/go-goyave/goyave-blog-example/database/model" - userController "github.com/go-goyave/goyave-blog-example/http/controller/user" - "github.com/go-goyave/goyave-blog-example/http/route" - "golang.org/x/crypto/bcrypt" - "gopkg.in/guregu/null.v4" - - "goyave.dev/goyave/v4" - "goyave.dev/goyave/v4/auth" - "goyave.dev/goyave/v4/database" - "goyave.dev/goyave/v4/util/fsutil" - "goyave.dev/goyave/v4/validation" - - _ "github.com/go-goyave/goyave-blog-example/http/validation" - _ "goyave.dev/goyave/v4/database/dialect/mysql" -) - -type UserTestSuite struct { - goyave.TestSuite -} - -func (suite *UserTestSuite) readFile(path string) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - contents, err := ioutil.ReadAll(file) - if err != nil { - return nil, err - } - - return contents, nil -} - -func (suite *UserTestSuite) SetupTest() { - suite.ClearDatabase() -} - -func (suite *UserTestSuite) TestRegister() { - suite.RunServer(route.Register, func() { - headers := map[string]string{"Content-Type": "application/json"} - request := map[string]interface{}{ - "username": "jack", - "email": "jack@example.org", - "password": "super_Secret_password_2", - } - body, _ := json.Marshal(request) - resp, err := suite.Post("/user", headers, bytes.NewReader(body)) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusCreated, resp.StatusCode) - json := map[string]interface{}{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Contains(json, "id") - } - - count := int64(0) - if err := database.Conn().Model(&model.User{}).Where("email = ?", "jack@example.org").Count(&count).Error; err != nil { - suite.Error(err) - } - suite.Equal(int64(1), count) - } - }) -} - -func (suite *UserTestSuite) TestRegisterValidationError() { - suite.RunServer(route.Register, func() { - resp, err := suite.Post("/user", nil, nil) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { // Expect validation errors (field "username", "email" and "password" are required) - defer resp.Body.Close() - suite.Equal(http.StatusUnprocessableEntity, resp.StatusCode) - json := map[string]validation.Errors{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Contains(json["validationError"], "username") - suite.Contains(json["validationError"], "email") - suite.Contains(json["validationError"], "password") - } - } - }) -} - -func (suite *UserTestSuite) TestRegisterNotUnique() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.UserGenerator) - override := &model.User{ - Username: "jack", - Email: "jack@example.org", - } - factory.Override(override).Save(1) - - headers := map[string]string{"Content-Type": "application/json"} - request := map[string]interface{}{ - "username": override.Username, - "email": override.Email, - "password": "super_Secret_password_2", - } - body, _ := json.Marshal(request) - resp, err := suite.Post("/user", headers, bytes.NewReader(body)) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusUnprocessableEntity, resp.StatusCode) - json := map[string]validation.Errors{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Contains(json["validationError"], "username") - suite.Contains(json["validationError"], "email") - } - } - }) -} - -func (suite *UserTestSuite) TestRegisterWithImage() { - suite.RunServer(route.Register, func() { - const path = "resources/test/img/goyave_64.png" - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - suite.WriteField(writer, "email", "jack@example.org") - suite.WriteField(writer, "username", "jack") - suite.WriteField(writer, "password", "super_Secret_password_2") - suite.WriteFile(writer, path, "image", filepath.Base(path)) - if err := writer.Close(); err != nil { - suite.Error(err) - return - } - headers := map[string]string{"Content-Type": writer.FormDataContentType()} - - resp, err := suite.Post("/user", headers, body) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusCreated, resp.StatusCode) - json := map[string]interface{}{} - err := suite.GetJSONBody(resp, &json) - suite.Nil(err) - if err == nil { - suite.Contains(json, "id") - } - - u := &model.User{} - if err := database.Conn().Where("email = ?", "jack@example.org").First(u).Error; err != nil { - suite.Error(err) - } - - actualPath := userController.StoragePath + u.Image.String - if suite.FileExists(actualPath) { - ref, err := suite.readFile(path) - if err != nil { - suite.Error(err) - return - } - - actual, err := suite.readFile(actualPath) - if err != nil { - suite.Error(err) - return - } - - suite.Equal(ref, actual) - fsutil.Delete(actualPath) - } - } - }) -} - -func (suite *UserTestSuite) TestBcryptPassword() { - factory := database.NewFactory(model.UserGenerator) - generatedUsers := factory.Generate(5).([]*model.User) - passwords := make([]string, 0, len(generatedUsers)) - for _, u := range generatedUsers { - passwords = append(passwords, u.Password) - } - - db := database.Conn() - if err := db.Create(generatedUsers).Error; err != nil { - suite.Error(err) - return - } - - users := make([]*model.User, 0, len(generatedUsers)) - if err := db.Order("id asc").Find(&users).Error; err != nil { - suite.Error(err) - } - - for k, u := range users { - if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(passwords[k])); err != nil { - suite.Failf("Hash and password comparison failed", "%q %q: %w", u.Password, passwords[k], err) - } - } -} - -func (suite *UserTestSuite) TestShow() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.UserGenerator) - override := &model.User{ - Email: "jack@example.org", - } - generatedUser := factory.Override(override).Save(1).([]*model.User)[0] - token, err := auth.GenerateToken("jack@example.org") - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Content-Type": "application/json", - "Authorization": "Bearer " + token, - } - resp, err := suite.Get("/user", headers) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusOK, resp.StatusCode) - user := &model.User{} - err := suite.GetJSONBody(resp, user) - suite.Nil(err) - if err == nil { - suite.Equal(generatedUser.ID, user.ID) - suite.Equal(generatedUser.Email, user.Email) - suite.Equal(generatedUser.Username, user.Username) - suite.Equal("", user.Password) // Password is hidden - } - } - }) -} - -func (suite *UserTestSuite) TestImage() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.UserGenerator) - str := null.String{} - str.String = "test_profile_picture.png" - str.Valid = true - override := &model.User{ - Image: str, - } - u := factory.Override(override).Save(1).([]*model.User)[0] - - // Create temp profile picture - destPath := userController.StoragePath + "test_profile_picture.png" - refPath := "resources/test/img/goyave_64.png" - input, err := ioutil.ReadFile(refPath) - if err != nil { - suite.Error(err) - return - } - - err = ioutil.WriteFile(destPath, input, 0660) - if err != nil { - suite.Error(err) - return - } - defer fsutil.Delete(destPath) - - resp, err := suite.Get(fmt.Sprintf("/user/%d/image", u.ID), nil) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusOK, resp.StatusCode) - suite.Equal("image/png", resp.Header.Get("Content-Type")) - body := suite.GetBody(resp) - - ref, err := suite.readFile(refPath) - if err != nil { - suite.Error(err) - return - } - - suite.Equal(ref, body) - } - }) -} - -func (suite *UserTestSuite) TestImageDefault() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.UserGenerator) // UserGenerator doesn't set the Image field - user := factory.Save(1).([]*model.User)[0] - - resp, err := suite.Get(fmt.Sprintf("/user/%d/image", user.ID), nil) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusOK, resp.StatusCode) - suite.Equal("image/png", resp.Header.Get("Content-Type")) - body := suite.GetBody(resp) - - ref, err := suite.readFile("resources/img/default_profile_picture.png") - if err != nil { - suite.Error(err) - return - } - - suite.Equal(ref, body) - } - }) -} - -func (suite *UserTestSuite) TestUpdate() { - suite.RunServer(route.Register, func() { - factory := database.NewFactory(model.UserGenerator) - user := factory.Save(1).([]*model.User)[0] - token, err := auth.GenerateToken(user.Email) - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Content-Type": "application/json", - "Authorization": "Bearer " + token, - } - request := map[string]interface{}{ - "username": user.Username + "_edited", - } - body, _ := json.Marshal(request) - resp, err := suite.Patch("/user", headers, bytes.NewReader(body)) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusNoContent, resp.StatusCode) - - updatedUser := &model.User{} - if err := database.Conn().Model(&model.User{}).Where("email = ?", user.Email).First(updatedUser).Error; err != nil { - suite.Error(err) - } - suite.Equal(request["username"], updatedUser.Username) - } - }) -} - -func (suite *UserTestSuite) TestUpdateImage() { - suite.RunServer(route.Register, func() { - const path = "resources/test/img/goyave_64.png" - - factory := database.NewFactory(model.UserGenerator) - user := factory.Save(1).([]*model.User)[0] - - // Set image for user (to check deletion on update) - destPath := userController.StoragePath + "test_profile_picture.png" - input, err := ioutil.ReadFile(path) - if err != nil { - suite.Error(err) - return - } - - err = ioutil.WriteFile(destPath, input, 0660) - if err != nil { - suite.Error(err) - return - } - user.Image.String = "test_profile_picture.png" - user.Image.Valid = true - database.Conn().Save(user) - - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - suite.WriteFile(writer, path, "image", filepath.Base(path)) - if err := writer.Close(); err != nil { - suite.Error(err) - return - } - - token, err := auth.GenerateToken(user.Email) - if err != nil { - suite.Error(err) - return - } - - headers := map[string]string{ - "Content-Type": writer.FormDataContentType(), - "Authorization": "Bearer " + token, - } - - resp, err := suite.Patch("/user", headers, body) - suite.Nil(err) - suite.NotNil(resp) - if resp != nil { - defer resp.Body.Close() - suite.Equal(http.StatusNoContent, resp.StatusCode) - - u := &model.User{} - if err := database.Conn().Where("email = ?", user.Email).First(u).Error; err != nil { - suite.Error(err) - } - - // Previous image has been deleted - suite.NoFileExists(userController.StoragePath + user.Image.String) - - actualPath := userController.StoragePath + u.Image.String - if suite.FileExists(actualPath) { - - ref, err := suite.readFile(path) - if err != nil { - suite.Error(err) - return - } - - actual, err := suite.readFile(actualPath) - if err != nil { - suite.Error(err) - return - } - suite.Equal(ref, actual) - - fsutil.Delete(actualPath) - } - } - }) -} - -func TestUserSuite(t *testing.T) { - goyave.RunTest(t, new(UserTestSuite)) -}