From 29fd154571278b29abb08be99e4ea74d8c1da6a8 Mon Sep 17 00:00:00 2001 From: SystemGlitch Date: Fri, 10 May 2024 19:08:08 +0200 Subject: [PATCH] Rewrite for Goyave v5 --- .github/workflows/test.yml | 25 +- .gitignore | 5 +- .golangci.yml | 28 +- .storage/avatars/.gitkeep | 0 Dockerfile | 17 - LICENSE | 2 +- README.md | 89 +--- config.example.json | 50 +- config.test.json | 58 ++- .../20240510133046_create_users_table.sql | 13 + .../20240510133055_create_articles_table.sql | 14 + database/model/article.go | 97 +--- database/model/user.go | 110 +---- database/repository/article.go | 82 ++++ database/repository/user.go | 49 ++ database/seed/article.go | 16 + database/seed/seed.go | 11 + database/seed/user.go | 21 + database/seeder/article.go | 40 -- database/seeder/seeder.go | 25 - database/seeder/user.go | 23 - docker-compose.test.yml | 42 -- docker-compose.yml | 41 +- dto/article.go | 31 ++ dto/user.go | 39 ++ go.mod | 36 +- go.sum | 247 ++-------- http/controller/article/article.go | 169 ++++--- http/controller/article/request.go | 24 - http/controller/article/validation.go | 22 + http/controller/user/request.go | 27 -- http/controller/user/user.go | 144 +++--- http/controller/user/validation.go | 31 ++ http/middleware/.gitkeep | 0 http/middleware/owner.go | 69 ++- http/route/route.go | 74 +-- http/validation/.gitkeep | 0 .../validation/{validation.go => password.go} | 55 +-- http/validation/validation_test.go | 69 --- main.go | 87 +++- resources/img/default_profile_picture.png | Bin 17253 -> 0 bytes resources/lang/README.md | 26 -- resources/lang/en-US/fields.json | 6 +- resources/lang/en-US/rules.json | 2 +- resources/test/img/goyave_64.png | Bin 3893 -> 0 bytes service/article/article.go | 111 +++++ service/service.go | 8 + service/storage/storage.go | 41 ++ service/user/user.go | 118 +++++ test/article_test.go | 323 ------------- test/test.go | 4 - test/user_test.go | 440 ------------------ 52 files changed, 1152 insertions(+), 1909 deletions(-) create mode 100644 .storage/avatars/.gitkeep delete mode 100644 Dockerfile create mode 100644 database/migrations/20240510133046_create_users_table.sql create mode 100644 database/migrations/20240510133055_create_articles_table.sql create mode 100644 database/repository/article.go create mode 100644 database/repository/user.go create mode 100644 database/seed/article.go create mode 100644 database/seed/seed.go create mode 100644 database/seed/user.go delete mode 100644 database/seeder/article.go delete mode 100644 database/seeder/seeder.go delete mode 100644 database/seeder/user.go delete mode 100644 docker-compose.test.yml create mode 100644 dto/article.go create mode 100644 dto/user.go delete mode 100644 http/controller/article/request.go create mode 100644 http/controller/article/validation.go delete mode 100644 http/controller/user/request.go create mode 100644 http/controller/user/validation.go create mode 100644 http/middleware/.gitkeep create mode 100644 http/validation/.gitkeep rename http/validation/{validation.go => password.go} (59%) delete mode 100644 http/validation/validation_test.go delete mode 100644 resources/img/default_profile_picture.png delete mode 100644 resources/lang/README.md delete mode 100644 resources/test/img/goyave_64.png create mode 100644 service/article/article.go create mode 100644 service/service.go create mode 100644 service/storage/storage.go create mode 100644 service/user/user.go delete mode 100644 test/article_test.go delete mode 100644 test/test.go delete mode 100644 test/user_test.go 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 31dc9315eac71ee69934ad1f8221c56182acd701..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17253 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 D4maS) 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 a65c819bab3d415ad65a84c08e809ff97fbd61ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3893 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 DXPHLM 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)) -}