diff --git a/.env b/.env new file mode 100644 index 0000000..3c14277 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +PORT=8080 +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=mysecretpassword +POSTGRES_DATABASE=weather-app +JWT_SECRET_KEY="akusayangmantanku" \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..fa73f0e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/zhikariz/weather-app/internal/builder" + "github.com/zhikariz/weather-app/internal/config" + "github.com/zhikariz/weather-app/internal/http/binder" + "github.com/zhikariz/weather-app/internal/http/server" + "github.com/zhikariz/weather-app/internal/http/validator" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func main() { + cfg, err := config.NewConfig(".env") + checkError(err) + + splash() + + db, err := buildGormDB(cfg.Postgres) + checkError(err) + + publicRoutes := builder.BuildPublicRoutes(cfg, db) + privateRoutes := builder.BuildPrivateRoutes(cfg, db) + + echoBinder := &echo.DefaultBinder{} + formValidator := validator.NewFormValidator() + customBinder := binder.NewBinder(echoBinder, formValidator) + + srv := server.NewServer( + cfg, + customBinder, + publicRoutes, + privateRoutes, + ) + + runServer(srv, cfg.Port) + + waitForShutdown(srv) +} + +func runServer(srv *server.Server, port string) { + go func() { + err := srv.Start(fmt.Sprintf(":%s", port)) + log.Fatal(err) + }() +} + +func waitForShutdown(srv *server.Server) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + + <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go func() { + if err := srv.Shutdown(ctx); err != nil { + srv.Logger.Fatal(err) + } + }() +} + +func buildGormDB(cfg config.PostgresConfig) (*gorm.DB, error) { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Jakarta", cfg.Host, cfg.User, cfg.Password, cfg.Database, cfg.Port) + return gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) +} + +func splash() { + colorReset := "\033[0m" + + splashText := ` + + __ __ __ .__ _____ +/ \ / \ ____ _____ _/ |_| |__ ___________ / _ \ ______ ______ +\ \/\/ // __ \\__ \\ __\ | \_/ __ \_ __ \/ /_\ \\____ \\____ \ + \ /\ ___/ / __ \| | | Y \ ___/| | \/ | \ |_> > |_> > + \__/\ / \___ >____ /__| |___| /\___ >__| \____|__ / __/| __/ + \/ \/ \/ \/ \/ \/|__| |__| +` + fmt.Println(colorReset, strings.TrimSpace(splashText)) +} + +func checkError(err error) { + if err != nil { + panic(err) + } +} diff --git a/common/jwt.go b/common/jwt.go new file mode 100644 index 0000000..b5c1f60 --- /dev/null +++ b/common/jwt.go @@ -0,0 +1,12 @@ +package common + +import ( + "github.com/golang-jwt/jwt/v5" +) + +type JwtCustomClaims struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + jwt.RegisteredClaims +} diff --git a/db/migration-golang/20231120141232_create_users_table.down.sql b/db/migration-golang/20231120141232_create_users_table.down.sql new file mode 100644 index 0000000..07f389a --- /dev/null +++ b/db/migration-golang/20231120141232_create_users_table.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS "public"."users"; + +COMMIT; \ No newline at end of file diff --git a/db/migration-golang/20231120141232_create_users_table.up.sql b/db/migration-golang/20231120141232_create_users_table.up.sql new file mode 100644 index 0000000..f9d3962 --- /dev/null +++ b/db/migration-golang/20231120141232_create_users_table.up.sql @@ -0,0 +1,9 @@ +BEGIN; +CREATE TABLE IF NOT EXISTS "public"."users" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "created_at" timestamptz (6) NOT NULL, + "updated_at" timestamptz (6) NOT NULL, + "deleted_at" timestamptz (6) +); +COMMIT; \ No newline at end of file diff --git a/db/migration-golang/20231123141605_add_several_field_in_users_table.down.sql b/db/migration-golang/20231123141605_add_several_field_in_users_table.down.sql new file mode 100644 index 0000000..4c001ce --- /dev/null +++ b/db/migration-golang/20231123141605_add_several_field_in_users_table.down.sql @@ -0,0 +1,8 @@ +BEGIN; + + +ALTER TABLE "public"."users" +DROP COLUMN IF EXISTS email, +DROP COLUMN IF EXISTS password; + +COMMIT; \ No newline at end of file diff --git a/db/migration-golang/20231123141605_add_several_field_in_users_table.up.sql b/db/migration-golang/20231123141605_add_several_field_in_users_table.up.sql new file mode 100644 index 0000000..4c7a81a --- /dev/null +++ b/db/migration-golang/20231123141605_add_several_field_in_users_table.up.sql @@ -0,0 +1,7 @@ +BEGIN; + +ALTER TABLE "public"."users" +ADD COLUMN email VARCHAR(255), +ADD COLUMN password VARCHAR(255); + +COMMIT; \ No newline at end of file diff --git a/db/migrations/20231117204623_create_users_table.sql b/db/migrations/20231117204623_create_users_table.sql new file mode 100644 index 0000000..7f4b766 --- /dev/null +++ b/db/migrations/20231117204623_create_users_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS "public"."users" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "created_at" timestamptz (6) NOT NULL, + "updated_at" timestamptz (6) NOT NULL, + "deleted_at" timestamptz (6) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS "public"."users"; +-- +goose StatementEnd \ No newline at end of file diff --git a/db/users.sql b/db/users.sql new file mode 100644 index 0000000..fe81376 --- /dev/null +++ b/db/users.sql @@ -0,0 +1,10 @@ +CREATE TABLE + "public"."users" ( + "id" SERIAL NOT NULL, + "name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "created_at" timestamptz (6) NOT NULL, + "updated_at" timestamptz (6) NOT NULL, + "deleted_at" timestamptz (6) + ); + +ALTER TABLE "public"."users" ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); \ No newline at end of file diff --git a/entity/user.go b/entity/user.go new file mode 100644 index 0000000..86c5e20 --- /dev/null +++ b/entity/user.go @@ -0,0 +1,37 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"-"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `json:"-"` +} + +func NewUser(name, email, password string) *User { + return &User{ + Name: name, + Email: email, + Password: password, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func UpdateUser(id int64, name, email, password string) *User { + return &User{ + ID: id, + Name: name, + Email: email, + Password: password, + UpdatedAt: time.Now(), + } +} diff --git a/entity/weather.go b/entity/weather.go new file mode 100644 index 0000000..d634b6b --- /dev/null +++ b/entity/weather.go @@ -0,0 +1,4 @@ +package entity + +type Weather struct { +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..22e6882 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/zhikariz/weather-app + +go 1.20 + +require ( + github.com/caarlos0/env/v6 v6.10.1 + github.com/creasty/defaults v1.7.0 + github.com/go-playground/validator/v10 v10.16.0 + github.com/joho/godotenv v1.5.1 + github.com/labstack/echo-jwt/v4 v4.2.0 + github.com/labstack/echo/v4 v4.11.2 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/gommon v0.4.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..28f5603 --- /dev/null +++ b/go.sum @@ -0,0 +1,87 @@ +github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= +github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +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-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= +github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= +github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE= +github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/builder/builder.go b/internal/builder/builder.go new file mode 100644 index 0000000..be59d09 --- /dev/null +++ b/internal/builder/builder.go @@ -0,0 +1,25 @@ +package builder + +import ( + "github.com/zhikariz/weather-app/internal/config" + "github.com/zhikariz/weather-app/internal/http/handler" + "github.com/zhikariz/weather-app/internal/http/router" + "github.com/zhikariz/weather-app/internal/repository" + "github.com/zhikariz/weather-app/internal/service" + "gorm.io/gorm" +) + +func BuildPublicRoutes(cfg *config.Config, db *gorm.DB) []*router.Route { + userRepository := repository.NewUserRepository(db) + loginService := service.NewLoginService(userRepository) + tokenService := service.NewTokenService(cfg) + authHandler := handler.NewAuthHandler(loginService, tokenService) + return router.PublicRoutes(authHandler) +} + +func BuildPrivateRoutes(cfg *config.Config, db *gorm.DB) []*router.Route { + userRepository := repository.NewUserRepository(db) + userService := service.NewUserService(userRepository) + userHandler := handler.NewUserHandler(userService) + return router.PrivateRoutes(userHandler) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4f76170 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "errors" + + "github.com/caarlos0/env/v6" + "github.com/joho/godotenv" +) + +// Config is a config +type Config struct { + Port string `env:"PORT" envDefault:"8080"` + Postgres PostgresConfig `envPrefix:"POSTGRES_"` + JWT JwtConfig `envPrefix:"JWT_"` +} + +// JwtConfig is a config for jwt +type JwtConfig struct { + SecretKey string `env:"SECRET_KEY"` +} + +// PostgresConfig is a config for postgres +type PostgresConfig struct { + Host string `env:"HOST" envDefault:"localhost"` + Port string `env:"PORT" envDefault:"5432"` + User string `env:"USER" envDefault:"postgres"` + Password string `env:"PASSWORD" envDefault:"postgres"` + Database string `env:"DATABASE" envDefault:"postgres"` +} + +// NewConfig creates a new config +func NewConfig(envPath string) (*Config, error) { + cfg, err := parseConfig(envPath) + if err != nil { + return nil, err + } + return cfg, nil +} + +// parseConfig parses the configuration file located at envPath and returns a +// Config struct and an error if any. It uses the godotenv package to load the +// environment variables from the file and the env package to parse them into +// the Config struct. +// +// envPath: The path to the environment file. +// Returns: A pointer to the Config struct and an error. +func parseConfig(envPath string) (*Config, error) { + err := godotenv.Load(envPath) + if err != nil { + return nil, errors.New("failed to load env") + } + + cfg := &Config{} + err = env.Parse(cfg) + if err != nil { + return nil, errors.New("failed to parse config") + } + return cfg, nil +} diff --git a/internal/http/binder/binder.go b/internal/http/binder/binder.go new file mode 100644 index 0000000..318275d --- /dev/null +++ b/internal/http/binder/binder.go @@ -0,0 +1,36 @@ +package binder + +import ( + "github.com/creasty/defaults" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + internalValidator "github.com/zhikariz/weather-app/internal/http/validator" +) + +type Binder struct { + defaultBinder *echo.DefaultBinder + *internalValidator.FormValidator +} + +func NewBinder( + dbr *echo.DefaultBinder, + vdr *internalValidator.FormValidator) *Binder { + return &Binder{dbr, vdr} +} + +func (b *Binder) Bind(i interface{}, c echo.Context) error { + if err := b.defaultBinder.Bind(i, c); err != nil { + return err + } + + if err := defaults.Set(i); err != nil { + return err + } + + if err := b.Validate(i); err != nil { + errs := err.(validator.ValidationErrors) + return errs + } + + return nil +} diff --git a/internal/http/handler/auth.handler.go b/internal/http/handler/auth.handler.go new file mode 100644 index 0000000..6b344a1 --- /dev/null +++ b/internal/http/handler/auth.handler.go @@ -0,0 +1,53 @@ +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/zhikariz/weather-app/internal/http/validator" + "github.com/zhikariz/weather-app/internal/service" +) + +type AuthHandler struct { + loginService service.LoginUseCase + tokenService service.TokenUseCase +} + +func NewAuthHandler( + loginService service.LoginUseCase, + tokenService service.TokenUseCase, +) *AuthHandler { + return &AuthHandler{ + loginService: loginService, + tokenService: tokenService, + } +} + +func (h *AuthHandler) Login(ctx echo.Context) error { + var input struct { + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` + } + + if err := ctx.Bind(&input); err != nil { + return ctx.JSON(http.StatusBadRequest, validator.ValidatorErrors(err)) + } + + user, err := h.loginService.Login(ctx.Request().Context(), input.Email, input.Password) + + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + accessToken, err := h.tokenService.GenerateAccessToken(ctx.Request().Context(), user) + + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + data := map[string]string{ + "access_token": accessToken, + } + + return ctx.JSON(http.StatusOK, data) +} diff --git a/internal/http/handler/user.handler.go b/internal/http/handler/user.handler.go new file mode 100644 index 0000000..4b90f76 --- /dev/null +++ b/internal/http/handler/user.handler.go @@ -0,0 +1,116 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" + "github.com/zhikariz/weather-app/common" + "github.com/zhikariz/weather-app/entity" + "github.com/zhikariz/weather-app/internal/http/validator" + "github.com/zhikariz/weather-app/internal/service" +) + +type UserHandler struct { + userService service.UserUseCase +} + +func NewUserHandler(userService service.UserUseCase) *UserHandler { + return &UserHandler{userService} +} + +func (h *UserHandler) GetAllUsers(ctx echo.Context) error { + // Parse the token + userClaim := ctx.Get("user").(*jwt.Token) + claims := userClaim.Claims.(*common.JwtCustomClaims) + + if claims.Email == "helmi@ganteng.com" { + return ctx.JSON(http.StatusForbidden, errors.New("you don't have permission to access this resource")) + } + + fmt.Println(claims.Email) + + users, err := h.userService.FindAll(ctx.Request().Context()) + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + return ctx.JSON(http.StatusOK, users) +} + +func (h *UserHandler) CreateUser(ctx echo.Context) error { + var input struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` + } + + if err := ctx.Bind(&input); err != nil { + return ctx.JSON(http.StatusBadRequest, validator.ValidatorErrors(err)) + } + + user := entity.NewUser(input.Name, input.Email, input.Password) + err := h.userService.Create(ctx.Request().Context(), user) + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + return ctx.JSON(http.StatusCreated, user) +} + +func (h *UserHandler) UpdateUser(ctx echo.Context) error { + var input struct { + ID int64 `param:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` + } + + if err := ctx.Bind(&input); err != nil { + return ctx.JSON(http.StatusBadRequest, validator.ValidatorErrors(err)) + } + + user := entity.UpdateUser(input.ID, input.Name, input.Email, input.Password) + + err := h.userService.Update(ctx.Request().Context(), user) + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + return ctx.JSON(http.StatusOK, user) +} + +func (h *UserHandler) DeleteUser(ctx echo.Context) error { + var input struct { + ID int64 `param:"id" validate:"required"` + } + + if err := ctx.Bind(&input); err != nil { + return ctx.JSON(http.StatusBadRequest, validator.ValidatorErrors(err)) + } + + err := h.userService.Delete(ctx.Request().Context(), input.ID) + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + return ctx.NoContent(http.StatusNoContent) +} + +func (h *UserHandler) GetUserByID(ctx echo.Context) error { + var input struct { + ID int64 `param:"id" validate:"required"` + } + + if err := ctx.Bind(&input); err != nil { + return ctx.JSON(http.StatusBadRequest, validator.ValidatorErrors(err)) + } + + user, err := h.userService.FindByID(ctx.Request().Context(), input.ID) + if err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, err) + } + + return ctx.JSON(http.StatusOK, user) +} diff --git a/internal/http/router/routes.go b/internal/http/router/routes.go new file mode 100644 index 0000000..1281abf --- /dev/null +++ b/internal/http/router/routes.go @@ -0,0 +1,52 @@ +package router + +import ( + "github.com/labstack/echo/v4" + "github.com/zhikariz/weather-app/internal/http/handler" +) + +type Route struct { + Method string + Path string + Handler echo.HandlerFunc +} + +func PublicRoutes(authHandler *handler.AuthHandler) []*Route { + return []*Route{ + { + Method: echo.POST, + Path: "/login", + Handler: authHandler.Login, + }, + } +} + +func PrivateRoutes(userHandler *handler.UserHandler) []*Route { + return []*Route{ + { + Method: echo.GET, + Path: "/users", + Handler: userHandler.GetAllUsers, + }, + { + Method: echo.GET, + Path: "/users/:id", + Handler: userHandler.GetUserByID, + }, + { + Method: echo.POST, + Path: "/users", + Handler: userHandler.CreateUser, + }, + { + Method: echo.PUT, + Path: "/users/:id", + Handler: userHandler.UpdateUser, + }, + { + Method: echo.DELETE, + Path: "/users/:id", + Handler: userHandler.DeleteUser, + }, + } +} diff --git a/internal/http/server/echo.go b/internal/http/server/echo.go new file mode 100644 index 0000000..e328be6 --- /dev/null +++ b/internal/http/server/echo.go @@ -0,0 +1,56 @@ +package server + +import ( + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/zhikariz/weather-app/common" + "github.com/zhikariz/weather-app/internal/config" + "github.com/zhikariz/weather-app/internal/http/binder" + "github.com/zhikariz/weather-app/internal/http/router" +) + +type Server struct { + *echo.Echo +} + +func NewServer( + cfg *config.Config, + binder *binder.Binder, + publicRoutes, privateRoutes []*router.Route) *Server { + e := echo.New() + e.HideBanner = true + e.Binder = binder + + e.Use( + middleware.Logger(), + middleware.Recover(), + middleware.CORS(), + ) + + v1 := e.Group("/api/v1") + + for _, public := range publicRoutes { + v1.Add(public.Method, public.Path, public.Handler) + } + + for _, private := range privateRoutes { + v1.Add(private.Method, private.Path, private.Handler, JWTProtected(cfg.JWT.SecretKey)) + } + + e.GET("/ping", func(c echo.Context) error { + return c.String(200, "pong") + }) + + return &Server{e} +} + +func JWTProtected(secretKey string) echo.MiddlewareFunc { + return echojwt.WithConfig(echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(common.JwtCustomClaims) + }, + SigningKey: []byte(secretKey), + }) +} diff --git a/internal/http/validator/validator.go b/internal/http/validator/validator.go new file mode 100644 index 0000000..d82963b --- /dev/null +++ b/internal/http/validator/validator.go @@ -0,0 +1,48 @@ +package validator + +import ( + "fmt" + "reflect" + "strings" + + "github.com/go-playground/validator/v10" +) + +type FormValidator struct { + validator *validator.Validate +} + +func (fv *FormValidator) Validate(i interface{}) error { + return fv.validator.Struct(i) +} + +func NewFormValidator() *FormValidator { + validate := validator.New(validator.WithRequiredStructEnabled()) + + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) + + return &FormValidator{validate} +} + +func ValidatorErrors(err error) map[string]string { + fields := map[string]string{} + + if castedObject, ok := err.(validator.ValidationErrors); ok { + for _, err := range castedObject { + switch err.Tag() { + case "required": + fields[err.Field()] = fmt.Sprintf("field %s harus di isi", err.Field()) + default: + fields[err.Field()] = fmt.Sprintf("%s error with tag %s should be %s", err.Field(), err.Tag(), err.Param()) + } + } + } + + return fields +} diff --git a/internal/repository/user.repository.go b/internal/repository/user.repository.go new file mode 100644 index 0000000..381d218 --- /dev/null +++ b/internal/repository/user.repository.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + "errors" + + "github.com/zhikariz/weather-app/entity" + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{ + db: db, + } +} + +func (r *UserRepository) FindAll(ctx context.Context) ([]*entity.User, error) { + users := make([]*entity.User, 0) + err := r.db.WithContext(ctx).Find(&users).Error // SELECT * FROM users + if err != nil { + return nil, err + } + return users, nil +} + +func (r *UserRepository) Create(ctx context.Context, user *entity.User) error { + if err := r.db.WithContext(ctx).Create(&user).Error; err != nil { + return err + } + return nil +} + +func (r *UserRepository) Update(ctx context.Context, user *entity.User) error { + if err := r.db.WithContext(ctx). + Model(&entity.User{}). + Where("id = ?", user.ID). + Updates(&user).Error; err != nil { + return err + } + return nil +} + +func (r *UserRepository) Delete(ctx context.Context, id int64) error { + if err := r.db.WithContext(ctx).Delete(&entity.User{}, id).Error; err != nil { + return err + } + return nil +} + +func (r *UserRepository) FindByID(ctx context.Context, id int64) (*entity.User, error) { + user := new(entity.User) + if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil { + return nil, err + } + return user, nil +} + +func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) { + user := new(entity.User) + if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { + return nil, errors.New("user with that email not found") + } + return user, nil +} diff --git a/internal/service/login.service.go b/internal/service/login.service.go new file mode 100644 index 0000000..a05eb76 --- /dev/null +++ b/internal/service/login.service.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "errors" + + "github.com/zhikariz/weather-app/entity" +) + +type LoginUseCase interface { + Login(ctx context.Context, email, password string) (*entity.User, error) +} + +type LoginRepository interface { + FindByEmail(ctx context.Context, email string) (*entity.User, error) +} + +type LoginService struct { + repo LoginRepository +} + +func NewLoginService(repo LoginRepository) *LoginService { + return &LoginService{ + repo: repo, + } +} + +func (s *LoginService) Login(ctx context.Context, email, password string) (*entity.User, error) { + user, err := s.repo.FindByEmail(ctx, email) + + if err != nil { + return nil, err + } + + if user == nil { + return nil, errors.New("user with that email not found") + } + + if user.Password != password { + return nil, errors.New("incorrect login credentials") + } + + return user, nil +} diff --git a/internal/service/token.service.go b/internal/service/token.service.go new file mode 100644 index 0000000..1e450b5 --- /dev/null +++ b/internal/service/token.service.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/zhikariz/weather-app/common" + "github.com/zhikariz/weather-app/entity" + "github.com/zhikariz/weather-app/internal/config" +) + +type TokenUseCase interface { + GenerateAccessToken(ctx context.Context, user *entity.User) (string, error) +} + +type TokenService struct { + cfg *config.Config +} + +func NewTokenService(cfg *config.Config) *TokenService { + return &TokenService{ + cfg: cfg, + } +} + +func (s *TokenService) GenerateAccessToken(ctx context.Context, user *entity.User) (string, error) { + expiredTime := time.Now().Local().Add(10 * time.Minute) + claims := common.JwtCustomClaims{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiredTime), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + encodedToken, err := token.SignedString([]byte(s.cfg.JWT.SecretKey)) + + if err != nil { + return "", err + } + + return encodedToken, nil +} diff --git a/internal/service/user.service.go b/internal/service/user.service.go new file mode 100644 index 0000000..b5a056d --- /dev/null +++ b/internal/service/user.service.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + + "github.com/zhikariz/weather-app/entity" +) + +type UserUseCase interface { + FindAll(ctx context.Context) ([]*entity.User, error) + Create(ctx context.Context, user *entity.User) error + Update(ctx context.Context, user *entity.User) error + Delete(ctx context.Context, id int64) error + FindByID(ctx context.Context, id int64) (*entity.User, error) +} + +type UserRepository interface { + FindAll(ctx context.Context) ([]*entity.User, error) + Create(ctx context.Context, user *entity.User) error + Update(ctx context.Context, user *entity.User) error + Delete(ctx context.Context, id int64) error + FindByID(ctx context.Context, id int64) (*entity.User, error) +} + +type UserService struct { + repository UserRepository +} + +func NewUserService(repository UserRepository) *UserService { + return &UserService{repository} +} + +func (s *UserService) FindAll(ctx context.Context) ([]*entity.User, error) { + return s.repository.FindAll(ctx) +} + +func (s *UserService) Create(ctx context.Context, user *entity.User) error { + return s.repository.Create(ctx, user) +} + +func (s *UserService) Update(ctx context.Context, user *entity.User) error { + return s.repository.Update(ctx, user) +} + +func (s *UserService) Delete(ctx context.Context, id int64) error { + return s.repository.Delete(ctx, id) +} + +func (s *UserService) FindByID(ctx context.Context, id int64) (*entity.User, error) { + return s.repository.FindByID(ctx, id) +}