├── logo.png ├── favicon.ico ├── architecture.png ├── internal ├── core │ ├── port │ │ ├── handler.go │ │ ├── service.go │ │ ├── repository.go │ │ ├── logger.go │ │ ├── repository_user.go │ │ ├── service_user.go │ │ ├── service_article.go │ │ └── repository_article.go │ ├── domain │ │ ├── main.go │ │ ├── article.go │ │ └── user.go │ ├── util │ │ ├── password.go │ │ ├── random.go │ │ ├── password_test.go │ │ ├── config.go │ │ ├── token │ │ │ ├── maker.go │ │ │ ├── jwt_maker.go │ │ │ └── jwt_maker_test.go │ │ ├── validator.go │ │ └── exception │ │ │ └── error.go │ └── service │ │ ├── service.go │ │ ├── main_test.go │ │ ├── user.go │ │ └── user_test.go └── adapter │ ├── handler │ ├── grpc │ │ ├── api │ │ │ ├── util.go │ │ │ ├── error.go │ │ │ ├── server.go │ │ │ ├── serializer.go │ │ │ ├── authorization.go │ │ │ ├── logger.go │ │ │ ├── user.go │ │ │ └── article.go │ │ ├── proto │ │ │ ├── user.proto │ │ │ ├── article.proto │ │ │ ├── rpc_user.proto │ │ │ ├── rpc_article.proto │ │ │ └── service.proto │ │ └── pb │ │ │ └── user.pb.go │ ├── restful │ │ ├── middleware.go │ │ ├── error.go │ │ ├── logger.go │ │ ├── util.go │ │ ├── serializer.go │ │ ├── server.go │ │ ├── user.go │ │ └── article.go │ └── main.go │ ├── repository │ ├── sql │ │ ├── db │ │ │ ├── migration │ │ │ │ ├── 000001_add_users.down.sql │ │ │ │ ├── main.go │ │ │ │ ├── 000002_add_articles.down.sql │ │ │ │ ├── 000001_add_users.up.sql │ │ │ │ └── 000002_add_articles.up.sql │ │ │ ├── logger.go │ │ │ └── db.go │ │ ├── error.go │ │ ├── repository.go │ │ ├── model │ │ │ ├── user.go │ │ │ └── article.go │ │ ├── user.go │ │ └── article.go │ ├── mongo │ │ ├── error.go │ │ ├── model │ │ │ ├── user.go │ │ │ └── article.go │ │ ├── repository.go │ │ ├── db.go │ │ ├── user.go │ │ └── article.go │ └── main.go │ └── logger │ ├── main.go │ ├── zerolog.go │ └── zap.go ├── main.go ├── tests ├── README.md └── run-api-tests.sh ├── .env.docker ├── .env.example ├── Dockerfile ├── .github ├── dependabot.yml └── workflows │ ├── test.yaml │ └── e2e.yaml ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── cmd ├── root.go └── server.go ├── .gitignore ├── .vscode ├── settings.json └── launch.json ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yaml ├── go.mod └── CODE_OF_CONDUCT.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labasubagia/realworld-backend/HEAD/logo.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labasubagia/realworld-backend/HEAD/favicon.ico -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labasubagia/realworld-backend/HEAD/architecture.png -------------------------------------------------------------------------------- /internal/core/port/handler.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | type Server interface { 4 | Start() error 5 | } 6 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | const ( 4 | DefaultPaginationSize = 20 5 | ) 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/migration/000001_add_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "user_follows"; 2 | 3 | --bun:split 4 | DROP TABLE IF EXISTS "users"; 5 | -------------------------------------------------------------------------------- /internal/core/port/service.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import "github.com/labasubagia/realworld-backend/internal/core/util/token" 4 | 5 | type Service interface { 6 | TokenMaker() token.Maker 7 | User() UserService 8 | Article() ArticleService 9 | } 10 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/migration/main.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import "github.com/uptrace/bun/migrate" 4 | 5 | var Migrations = migrate.NewMigrations() 6 | 7 | func init() { 8 | if err := Migrations.DiscoverCaller(); err != nil { 9 | panic(err) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # RealWorld API Spec 2 | 3 | ## Running API tests locally 4 | 5 | To locally run the provided Postman collection against your backend, execute: 6 | 7 | ``` 8 | APIURL=http://0.0.0.0:3000/api ./run-api-tests.sh 9 | ``` 10 | 11 | For more details, see [`run-api-tests.sh`](run-api-tests.sh). -------------------------------------------------------------------------------- /internal/core/port/repository.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type RepositoryAtomicCallback func(r Repository) error 8 | 9 | type Repository interface { 10 | Atomic(context.Context, RepositoryAtomicCallback) error 11 | User() UserRepository 12 | Article() ArticleRepository 13 | } 14 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=development 2 | POSTGRES_SOURCE=postgres://postgres:postgres@postgres:5432/realworld?sslmode=disable 3 | MONGO_SOURCE=mongodb://root:root@mongo:27017/realworld?authSource=admin 4 | LOG_TYPE=zerolog 5 | DB_TYPE=postgres 6 | SERVER_TYPE=restful 7 | SERVER_PORT=5000 8 | TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=development # production 2 | POSTGRES_SOURCE=postgres://postgres:postgres@0.0.0.0:5432/realworld?sslmode=disable 3 | MONGO_SOURCE=mongodb://root:root@0.0.0.0:27017/realworld?authSource=admin 4 | LOG_TYPE=zerolog 5 | DB_TYPE=postgres 6 | SERVER_TYPE=restful 7 | SERVER_PORT=5000 8 | TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012 -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/migration/000002_add_articles.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "article_favorites"; 2 | 3 | --bun:split 4 | DROP TABLE IF EXISTS "article_tags"; 5 | 6 | --bun:split 7 | DROP TABLE IF EXISTS "comments"; 8 | 9 | --bun:split 10 | DROP TABLE IF EXISTS "tags"; 11 | 12 | --bun:split 13 | DROP TABLE IF EXISTS "articles"; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.21-alpine AS builder 3 | WORKDIR /app 4 | COPY . . 5 | RUN go build -o main main.go 6 | 7 | # Run stage 8 | FROM alpine 9 | WORKDIR /app 10 | COPY --from=builder /app/main . 11 | COPY .env.example .env 12 | COPY internal/adapter/repository/sql/db/migration /app/internal/adapter/repository/sql/db/migration 13 | 14 | EXPOSE 5000 15 | CMD [ "/app/main" ] -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/proto/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | option go_package = "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb"; 6 | 7 | 8 | message User { 9 | string email = 1; 10 | string username = 2; 11 | string bio = 3; 12 | string image = 4; 13 | string token = 5; 14 | } 15 | 16 | message Profile { 17 | string username = 1; 18 | string image = 2; 19 | string bio = 3; 20 | bool following = 4; 21 | } 22 | -------------------------------------------------------------------------------- /internal/core/domain/main.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/oklog/ulid/v2" 5 | ) 6 | 7 | type ID string 8 | 9 | func (id ID) String() string { 10 | return string(id) 11 | } 12 | 13 | func NewID() ID { 14 | return ID(ulid.Make().String()) 15 | } 16 | 17 | func RandomID() ID { 18 | return NewID() 19 | } 20 | 21 | func ParseID(value string) (ID, error) { 22 | id, err := ulid.Parse(value) 23 | if err != nil { 24 | return ID(ulid.ULID{}.String()), err 25 | } 26 | return ID(id.String()), nil 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /tests/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://api.realworld.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" \ 17 | --insecure \ 18 | --verbose \ 19 | "$@" -------------------------------------------------------------------------------- /internal/core/util/password.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 5 | "golang.org/x/crypto/bcrypt" 6 | ) 7 | 8 | func HashPassword(password string) (string, error) { 9 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 10 | if err != nil { 11 | return "", exception.New(exception.TypePermissionDenied, "invalid password", err) 12 | } 13 | return string(hashedPassword), nil 14 | } 15 | 16 | func CheckPassword(password string, hashedPassword string) error { 17 | return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) 18 | } 19 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | 3 | ARG USER=vscode 4 | ARG UID=1000 5 | ARG GID=1000 6 | 7 | RUN apk add -q --update sudo openssh-client git zsh starship 8 | 9 | RUN adduser $USER -s /bin/zsh -D -u $UID $GID && \ 10 | mkdir -p /etc/sudoers.d && \ 11 | echo $USER ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USER && \ 12 | chmod 0440 /etc/sudoers.d/$USER 13 | 14 | USER $USER 15 | 16 | RUN go install golang.org/x/tools/gopls@latest 17 | RUN go install github.com/go-delve/delve/cmd/dlv@latest 18 | RUN go install honnef.co/go/tools/cmd/staticcheck@latest 19 | 20 | RUN echo "eval \"$(starship init zsh)\"" >> /home/$USER/.zshrc 21 | 22 | USER root -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/core/util" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var config util.Config 12 | 13 | var rootCmd = &cobra.Command{ 14 | Use: "realworld", 15 | Short: "Realworld backend app", 16 | Long: "Realworld is an app about article similar medium.com and dev.to", 17 | } 18 | 19 | func init() { 20 | var err error 21 | 22 | config, err = util.LoadConfig(".env") 23 | if err != nil { 24 | fmt.Fprintf(os.Stderr, "failed to load env config: %s", err) 25 | os.Exit(1) 26 | } 27 | } 28 | 29 | func Execute() error { 30 | return rootCmd.Execute() 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/middleware.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | const ( 8 | authorizationHeaderKey = "authorization" 9 | authorizationTypeToken = "token" 10 | authorizationArgKey = "authorization_arg" 11 | ) 12 | 13 | func (server *Server) AuthMiddleware(autoDenied bool) gin.HandlerFunc { 14 | return func(c *gin.Context) { 15 | authArg, err := server.parseToken(c) 16 | if err != nil { 17 | if hasToken(c) { 18 | errorHandler(c, err) 19 | return 20 | } 21 | if autoDenied { 22 | errorHandler(c, err) 23 | return 24 | } 25 | } 26 | c.Set(authorizationArgKey, authArg) 27 | c.Next() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /dist 6 | 7 | # IDEs and editors 8 | /.idea 9 | .project 10 | .classpath 11 | .c9/ 12 | *.launch 13 | .settings/ 14 | *.sublime-workspace 15 | 16 | # IDE - VSCode 17 | .vscode/* 18 | !.vscode/settings.json 19 | !.vscode/tasks.json 20 | !.vscode/launch.json 21 | !.vscode/extensions.json 22 | .history/* 23 | 24 | 25 | #System Files 26 | .DS_Store 27 | Thumbs.db 28 | 29 | 30 | #log files 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # environment 36 | .env 37 | .env.test 38 | 39 | # go 40 | __debug_bin* 41 | realworld-backend 42 | coverage.html 43 | coverage.profile -------------------------------------------------------------------------------- /internal/core/port/logger.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // set context value with this key 8 | const SubLoggerCtxKey = "sub_logger" 9 | 10 | type Logger interface { 11 | NewInstance() Logger 12 | 13 | Field(string, any) Logger 14 | Logger() Logger 15 | 16 | Info() LogEvent 17 | Error() LogEvent 18 | Fatal() LogEvent 19 | } 20 | 21 | type LogEvent interface { 22 | Field(string, any) LogEvent 23 | Err(error) LogEvent 24 | 25 | Msgf(string, ...any) 26 | Msg(...any) 27 | } 28 | 29 | func GetCtxSubLogger(ctx context.Context, defaultLogger Logger) Logger { 30 | if subLogger, ok := ctx.Value(SubLoggerCtxKey).(Logger); ok { 31 | return subLogger 32 | } 33 | return defaultLogger 34 | } 35 | -------------------------------------------------------------------------------- /internal/adapter/logger/main.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/port" 7 | "github.com/labasubagia/realworld-backend/internal/core/util" 8 | ) 9 | 10 | const defaultType = TypeZeroLog 11 | 12 | var fnNewMap = map[string]func(util.Config) port.Logger{ 13 | TypeZeroLog: NewZeroLogLogger, 14 | TypeZap: NewZapLogger, 15 | } 16 | 17 | func Keys() (keys []string) { 18 | for key := range fnNewMap { 19 | keys = append(keys, key) 20 | } 21 | sort.Strings(keys) 22 | return 23 | } 24 | 25 | func NewLogger(config util.Config) port.Logger { 26 | new, ok := fnNewMap[config.LogType] 27 | if ok { 28 | return new(config) 29 | } 30 | return fnNewMap[defaultType](config) 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/proto/article.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | import "user.proto"; 7 | 8 | option go_package = "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb"; 9 | 10 | message Article { 11 | string slug = 1; 12 | string title = 2; 13 | string description = 3; 14 | string body = 4; 15 | repeated string tag_list = 5; 16 | google.protobuf.Timestamp created_at = 6; 17 | google.protobuf.Timestamp updated_at = 7; 18 | bool favorited = 8; 19 | int64 favorite_count = 9; 20 | Profile author = 10; 21 | } 22 | 23 | message Comment { 24 | string id = 1; 25 | string body = 2; 26 | google.protobuf.Timestamp created_at = 3; 27 | google.protobuf.Timestamp updated_at = 4; 28 | Profile author = 5; 29 | } 30 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Realworld", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "realworld", 5 | "workspaceFolder": "/workspace", 6 | "shutdownAction": "stopCompose", 7 | "forwardPorts": [ 8 | 5000 9 | ], 10 | "remoteUser": "vscode", 11 | "postCreateCommand": "cp .env.docker .env", 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "golang.Go", 16 | "usernamehw.errorlens", 17 | "eamodio.gitlens", 18 | "streetsidesoftware.code-spell-checker", 19 | "EditorConfig.EditorConfig", 20 | "ms-azuretools.vscode-docker" 21 | ], 22 | "settings": { 23 | "[go]": { 24 | "editor.insertSpaces": false, 25 | "editor.formatOnSave": true, 26 | "editor.codeActionsOnSave": { 27 | "source.organizeImports": true 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /internal/core/util/random.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func init() { 11 | rand.New(rand.NewSource(time.Now().UnixNano())) 12 | } 13 | 14 | func RandomInt(min, max int64) int64 { 15 | return min + rand.Int63n(max-min+1) 16 | } 17 | 18 | const alphabet = "abcdefghijklmnopqrstuvwxyz" 19 | 20 | func RandomString(n int) string { 21 | var builder strings.Builder 22 | k := len(alphabet) 23 | for i := 0; i < n; i++ { 24 | c := alphabet[rand.Intn(k)] 25 | builder.WriteByte(c) 26 | } 27 | return builder.String() 28 | } 29 | 30 | func RandomUsername() string { 31 | return RandomString(6) 32 | } 33 | 34 | func RandomEmail() string { 35 | return fmt.Sprintf("%s@mail.com", RandomString(6)) 36 | } 37 | 38 | func RandomURL() string { 39 | return fmt.Sprintf("https://%s.com", RandomString(10)) 40 | } 41 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/migration/000001_add_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "users" ( 2 | "id" char(26) PRIMARY KEY, 3 | "email" varchar NOT NULL UNiQUE, 4 | "username" varchar NOT NULL UNiQUE, 5 | "password" varchar NOT NULL, 6 | "image" TEXT NOT NULL DEFAULT 'https://api.realworld.io/images/demo-avatar.png', 7 | "bio" TEXT NOT NULL DEFAULT '', 8 | "created_at" timestamptz NOT NULL DEFAULT (now()), 9 | "updated_at" timestamptz NOT NULL DEFAULT (now()) 10 | ); 11 | 12 | --bun:split 13 | CREATE TABLE "user_follows" ( 14 | "follower_id" char(26) NOT NULL, 15 | "followee_id" char(26) NOT NULL, 16 | PRIMARY KEY ("follower_id", "followee_id"), 17 | FOREIGN KEY ("follower_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 18 | FOREIGN KEY ("followee_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE 19 | ) -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/error.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 5 | "go.mongodb.org/mongo-driver/mongo" 6 | ) 7 | 8 | var mapException = map[int]string{ 9 | 11000: exception.TypeValidation, 10 | } 11 | 12 | func getWriteConcernCode(err error) string { 13 | if err == nil { 14 | return "" 15 | } 16 | fail, ok := err.(mongo.WriteException) 17 | if !ok { 18 | return "" 19 | } 20 | if len(fail.WriteErrors) == 0 { 21 | return "" 22 | } 23 | return mapException[fail.WriteErrors[0].Code] 24 | } 25 | 26 | func intoException(err error) *exception.Exception { 27 | if err == nil { 28 | return nil 29 | } 30 | typeWriteConcern := getWriteConcernCode(err) 31 | if typeWriteConcern != "" { 32 | return exception.New(typeWriteConcern, err.Error(), err) 33 | } 34 | return exception.Into(err) 35 | } 36 | -------------------------------------------------------------------------------- /internal/core/util/password_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/util" 7 | "github.com/stretchr/testify/require" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | func TestPassword(t *testing.T) { 12 | password := util.RandomString(8) 13 | hashedPassword, err := util.HashPassword(password) 14 | require.NoError(t, err) 15 | 16 | err = util.CheckPassword(password, hashedPassword) 17 | require.NoError(t, err) 18 | 19 | wrongPassword := util.RandomString(6) 20 | err = util.CheckPassword(wrongPassword, hashedPassword) 21 | require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error()) 22 | 23 | differentHashedPassword, err := util.HashPassword(password) 24 | require.NoError(t, err) 25 | require.NotEmpty(t, differentHashedPassword) 26 | require.NotEqual(t, hashedPassword, differentHashedPassword) 27 | } 28 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 5 | "google.golang.org/grpc/codes" 6 | "google.golang.org/grpc/status" 7 | ) 8 | 9 | func handleError(err error) error { 10 | if err == nil { 11 | return nil 12 | } 13 | fail, ok := err.(*exception.Exception) 14 | if !ok { 15 | return status.Error(codes.Internal, err.Error()) 16 | } 17 | if !fail.HasError() { 18 | fail.AddError("exception", fail.Message) 19 | } 20 | var code codes.Code 21 | switch fail.Type { 22 | case exception.TypeNotFound: 23 | code = codes.NotFound 24 | case exception.TypeTokenExpired, exception.TypeTokenInvalid, exception.TypePermissionDenied: 25 | code = codes.Unauthenticated 26 | case exception.TypeValidation: 27 | code = codes.InvalidArgument 28 | default: 29 | code = codes.Internal 30 | } 31 | return status.Error(code, err.Error()) 32 | } 33 | -------------------------------------------------------------------------------- /internal/core/port/repository_user.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | ) 8 | 9 | type FilterUserPayload struct { 10 | IDs []domain.ID 11 | Usernames []string 12 | Emails []string 13 | } 14 | 15 | type FilterUserFollowPayload struct { 16 | FollowerIDs []domain.ID 17 | FolloweeIDs []domain.ID 18 | } 19 | 20 | type UserRepository interface { 21 | CreateUser(context.Context, domain.User) (domain.User, error) 22 | UpdateUser(context.Context, domain.User) (domain.User, error) 23 | FilterUser(context.Context, FilterUserPayload) ([]domain.User, error) 24 | FindOne(context.Context, FilterUserPayload) (domain.User, error) 25 | 26 | FilterFollow(context.Context, FilterUserFollowPayload) ([]domain.UserFollow, error) 27 | Follow(context.Context, domain.UserFollow) (domain.UserFollow, error) 28 | UnFollow(context.Context, domain.UserFollow) (domain.UserFollow, error) 29 | } 30 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/error.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "github.com/jackc/pgx/v5" 5 | "github.com/jackc/pgx/v5/pgconn" 6 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 7 | ) 8 | 9 | var mapException = map[string]string{ 10 | "40001": exception.TypeValidation, 11 | "23503": exception.TypeValidation, 12 | "23505": exception.TypeValidation, 13 | } 14 | 15 | func postgresErrCode(err error) string { 16 | if err == nil { 17 | return "" 18 | } 19 | pgErr, ok := err.(*pgconn.PgError) 20 | if ok { 21 | return pgErr.Code 22 | } 23 | return "" 24 | } 25 | 26 | func intoException(err error) *exception.Exception { 27 | if err == nil { 28 | return nil 29 | } 30 | if err == pgx.ErrNoRows { 31 | return exception.New(exception.TypeNotFound, err.Error(), err) 32 | } 33 | kind, ok := mapException[postgresErrCode(err)] 34 | if ok { 35 | return exception.New(kind, err.Error(), err) 36 | } 37 | return exception.Into(err) 38 | } 39 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/proto/rpc_user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | import "user.proto"; 6 | 7 | option go_package = "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb"; 8 | 9 | message RegisterUserRequest { 10 | message User { 11 | string email = 1; 12 | string password = 2; 13 | string username = 3; 14 | } 15 | User user = 1; 16 | } 17 | 18 | 19 | message LoginUserRequest { 20 | message User { 21 | string email = 1; 22 | string password = 2; 23 | } 24 | User user = 1; 25 | } 26 | 27 | message UpdateUserRequest { 28 | message User { 29 | string email = 1; 30 | string username = 2; 31 | string password = 3; 32 | string image = 4; 33 | string bio = 5; 34 | } 35 | User user = 1; 36 | } 37 | 38 | message UserResponse { 39 | User user = 1; 40 | } 41 | 42 | message GetProfileRequest { 43 | string username = 1; 44 | } 45 | 46 | message ProfileResponse { 47 | Profile profile = 1; 48 | } -------------------------------------------------------------------------------- /internal/adapter/handler/restful/error.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 8 | ) 9 | 10 | func errorHandler(c *gin.Context, err error) { 11 | if err == nil { 12 | c.AbortWithStatusJSON(http.StatusOK, nil) 13 | return 14 | } 15 | fail, ok := err.(*exception.Exception) 16 | if !ok { 17 | c.AbortWithStatusJSON(http.StatusInternalServerError, fail) 18 | return 19 | } 20 | if !fail.HasError() { 21 | fail.AddError("exception", fail.Message) 22 | } 23 | var statusCode int 24 | switch fail.Type { 25 | case exception.TypeNotFound: 26 | statusCode = http.StatusNotFound 27 | case exception.TypeTokenExpired, exception.TypeTokenInvalid, exception.TypePermissionDenied: 28 | statusCode = http.StatusUnauthorized 29 | case exception.TypeValidation: 30 | statusCode = http.StatusUnprocessableEntity 31 | default: 32 | statusCode = http.StatusInternalServerError 33 | } 34 | c.AbortWithStatusJSON(statusCode, fail) 35 | } 36 | -------------------------------------------------------------------------------- /internal/adapter/handler/main.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "sort" 5 | 6 | grpc_api "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/api" 7 | "github.com/labasubagia/realworld-backend/internal/adapter/handler/restful" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | ) 11 | 12 | const defaultType = restful.TypeRestful 13 | 14 | var fnNewMap = map[string]func(util.Config, port.Service, port.Logger) port.Server{ 15 | restful.TypeRestful: restful.NewServer, 16 | grpc_api.TypeGrpc: grpc_api.NewServer, 17 | } 18 | 19 | func Keys() (keys []string) { 20 | for key := range fnNewMap { 21 | keys = append(keys, key) 22 | } 23 | sort.Strings(keys) 24 | return 25 | } 26 | 27 | func NewServer(config util.Config, service port.Service, logger port.Logger) port.Server { 28 | new, ok := fnNewMap[config.ServerType] 29 | if ok { 30 | return new(config, service, logger) 31 | } 32 | return fnNewMap[defaultType](config, service, logger) 33 | } 34 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/logger.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strings" 7 | "time" 8 | 9 | "github.com/labasubagia/realworld-backend/internal/core/port" 10 | "github.com/uptrace/bun" 11 | ) 12 | 13 | type LoggerHook struct { 14 | verbose bool 15 | logger port.Logger 16 | } 17 | 18 | func (h *LoggerHook) BeforeQuery(ctx context.Context, event *bun.QueryEvent) context.Context { 19 | return ctx 20 | } 21 | 22 | func (h *LoggerHook) AfterQuery(ctx context.Context, event *bun.QueryEvent) { 23 | if !h.verbose { 24 | switch event.Err { 25 | case nil, sql.ErrNoRows, sql.ErrTxDone: 26 | return 27 | } 28 | } 29 | now := time.Now() 30 | duration := now.Sub(event.StartTime) 31 | 32 | subLogger := port.GetCtxSubLogger(ctx, h.logger) 33 | logEvent := subLogger.Info() 34 | if event.Err != nil { 35 | logEvent = subLogger.Error().Err(event.Err) 36 | } 37 | logEvent. 38 | Field("duration", duration). 39 | Field("query", strings.ReplaceAll(event.Query, "\"", "")). 40 | Msgf("SQL %s", event.Operation()) 41 | } 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "autoincrement", 4 | "azuretools", 5 | "bson", 6 | "bundebug", 7 | "DBTX", 8 | "dgrijalva", 9 | "eamodio", 10 | "emptypb", 11 | "errorlens", 12 | "Favorited", 13 | "gonic", 14 | "interactor", 15 | "jackc", 16 | "labasubagia", 17 | "mapstructure", 18 | "Msgf", 19 | "notnull", 20 | "nullzero", 21 | "oklog", 22 | "pgconn", 23 | "pgdialect", 24 | "pgxpool", 25 | "realworld", 26 | "requestid", 27 | "sslmode", 28 | "stdlib", 29 | "stretchr", 30 | "timestamppb", 31 | "Upsert", 32 | "uptrace", 33 | "usernamehw", 34 | "writeconcern", 35 | "zerolog" 36 | ], 37 | "go.testFlags": [ 38 | "-count=1" 39 | ], 40 | "dotenv.enableAutocloaking": false, 41 | "protoc": { 42 | "options": [ 43 | "--proto_path=internal/adapter/handler/grpc/proto" 44 | ] 45 | } 46 | } -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | postgres: 18 | image: postgres:alpine 19 | env: 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_DB: realworld 23 | ports: 24 | - 5432:5432 25 | mongo: 26 | image: mongo 27 | env: 28 | MONGO_INITDB_ROOT_USERNAME: root 29 | MONGO_INITDB_ROOT_PASSWORD: root 30 | ports: 31 | - 27017:27017 32 | 33 | steps: 34 | - uses: actions/setup-go@v4 35 | with: 36 | go-version: '^1.21.0' 37 | check-latest: true 38 | 39 | - name: Check out code 40 | uses: actions/checkout@v3 41 | 42 | - name: Mod tidy 43 | run: go mod tidy 44 | 45 | - name: Test 46 | run: | 47 | cp .env.example .env 48 | make test_all -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 RealWorld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/core/port/service_user.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | "github.com/labasubagia/realworld-backend/internal/core/util/token" 8 | ) 9 | 10 | type AuthParams struct { 11 | Token string 12 | Payload *token.Payload 13 | } 14 | 15 | type RegisterParams struct { 16 | User domain.User 17 | } 18 | 19 | type LoginParams struct { 20 | User domain.User 21 | } 22 | type UpdateUserParams struct { 23 | AuthArg AuthParams 24 | User domain.User 25 | } 26 | 27 | type ProfileParams struct { 28 | AuthArg AuthParams 29 | Username string 30 | } 31 | 32 | type UserService interface { 33 | Register(context.Context, RegisterParams) (domain.User, error) 34 | Login(context.Context, LoginParams) (domain.User, error) 35 | Update(context.Context, UpdateUserParams) (domain.User, error) 36 | Current(context.Context, AuthParams) (domain.User, error) 37 | 38 | Profile(context.Context, ProfileParams) (domain.User, error) 39 | Follow(context.Context, ProfileParams) (domain.User, error) 40 | UnFollow(context.Context, ProfileParams) (domain.User, error) 41 | } 42 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # database 2 | 3 | POSTGRES_URL=postgresql://postgres:postgres@0.0.0.0:5432/realworld?sslmode=disable 4 | POSTGRES_MIGRATION_PATH=internal/adapter/repository/sql/db/migration 5 | 6 | postgres_migrate_up: 7 | migrate -path "$(POSTGRES_MIGRATION_PATH)" -database "$(POSTGRES_URL)" -verbose up 8 | 9 | postgres_migrate_down: 10 | migrate -path "$(POSTGRES_MIGRATION_PATH)" -database "$(POSTGRES_URL)" -verbose down 11 | 12 | postgres_migrate_drop: 13 | migrate -path "$(POSTGRES_MIGRATION_PATH)" -database "$(POSTGRES_URL)" -verbose drop 14 | 15 | # testing 16 | 17 | test: 18 | go test -cover ./... 19 | 20 | test_all: 21 | export TEST_REPO=all 22 | make test 23 | 24 | test_cover: 25 | go test -coverprofile=coverage.profile -cover ./... 26 | go tool cover -html coverage.profile -o coverage.html 27 | 28 | e2e: 29 | APIURL=http://0.0.0.0:5000 ./tests/run-api-tests.sh 30 | 31 | 32 | # gen 33 | gen_grpc_protoc: 34 | protoc \ 35 | --proto_path=internal/adapter/handler/grpc/proto --go_out=internal/adapter/handler/grpc/pb --go_opt=paths=source_relative \ 36 | --go-grpc_out=internal/adapter/handler/grpc/pb --go-grpc_opt=paths=source_relative \ 37 | internal/adapter/handler/grpc/proto/*.proto -------------------------------------------------------------------------------- /internal/core/util/config.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | const ( 8 | EnvProduction = "production" 9 | EnvDevelopment = "development" 10 | ) 11 | 12 | type Config struct { 13 | Environment string `mapstructure:"ENVIRONMENT"` 14 | 15 | PostgresSource string `mapstructure:"POSTGRES_SOURCE"` 16 | MongoSource string `mapstructure:"MONGO_SOURCE"` 17 | 18 | ServerType string `mapstructure:"SERVER_TYPE"` 19 | ServerPort int `mapstructure:"SERVER_PORT"` 20 | 21 | LogType string `mapstructure:"LOG_TYPE"` 22 | DBType string `mapstructure:"DB_TYPE"` 23 | 24 | TokenSymmetricKey string `mapstructure:"TOKEN_SYMMETRIC_KEY"` 25 | 26 | TestRepo string `mapstructure:"TEST_REPO"` 27 | } 28 | 29 | func (c Config) IsProduction() bool { 30 | return c.Environment == EnvProduction 31 | } 32 | 33 | func (c Config) IsTestAllRepo() bool { 34 | return c.TestRepo == "all" 35 | } 36 | 37 | func LoadConfig(path string) (config Config, err error) { 38 | viper.SetConfigFile(path) 39 | viper.SetConfigType("env") 40 | viper.AutomaticEnv() 41 | err = viper.ReadInConfig() 42 | if err != nil { 43 | return 44 | } 45 | err = viper.Unmarshal(&config) 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | e2e: 13 | name: E2E Test 14 | runs-on: ubuntu-latest 15 | services: 16 | postgres: 17 | image: postgres:alpine 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_DB: realworld 22 | ports: 23 | - 5432:5432 24 | mongo: 25 | image: mongo 26 | env: 27 | MONGO_INITDB_ROOT_USERNAME: root 28 | MONGO_INITDB_ROOT_PASSWORD: root 29 | ports: 30 | - 27017:27017 31 | 32 | steps: 33 | - uses: actions/setup-go@v4 34 | with: 35 | go-version: '^1.21.0' 36 | check-latest: true 37 | 38 | - name: Check out code 39 | uses: actions/checkout@v3 40 | 41 | - name: Build 42 | run: | 43 | cp .env.example .env 44 | go build -o app 45 | 46 | - name: Run 47 | run: nohup ./app server & 48 | 49 | - name: E2E 50 | run: | 51 | npm i -g newman 52 | make e2e 53 | 54 | -------------------------------------------------------------------------------- /internal/core/util/token/maker.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 9 | ) 10 | 11 | type Maker interface { 12 | CreateToken(userID domain.ID, duration time.Duration) (string, *Payload, error) 13 | VerifyToken(token string) (*Payload, error) 14 | } 15 | 16 | type Payload struct { 17 | ID uuid.UUID `json:"id"` 18 | UserID domain.ID `json:"user_id"` 19 | IssuedAt time.Time `json:"issued_at"` 20 | ExpiredAt time.Time `json:"expired_at"` 21 | } 22 | 23 | func NewPayload(userID domain.ID, duration time.Duration) (*Payload, error) { 24 | tokenID, err := uuid.NewRandom() 25 | if err != nil { 26 | return nil, exception.New(exception.TypeInternal, "failed generate token id", err) 27 | } 28 | payload := &Payload{ 29 | ID: tokenID, 30 | UserID: userID, 31 | IssuedAt: time.Now(), 32 | ExpiredAt: time.Now().Add(duration), 33 | } 34 | return payload, nil 35 | } 36 | 37 | func (payload Payload) Valid() error { 38 | if time.Now().After(payload.ExpiredAt) { 39 | return exception.New(exception.TypeTokenExpired, "token expired", nil) 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/reflection" 12 | ) 13 | 14 | const TypeGrpc = "grpc" 15 | 16 | type Server struct { 17 | pb.UnimplementedRealWorldServer 18 | config util.Config 19 | service port.Service 20 | logger port.Logger 21 | } 22 | 23 | func NewServer(config util.Config, repository port.Service, logger port.Logger) port.Server { 24 | server := &Server{ 25 | config: config, 26 | service: repository, 27 | logger: logger, 28 | } 29 | return server 30 | } 31 | 32 | func (server *Server) Start() error { 33 | logger := grpc.UnaryInterceptor(server.Logger) 34 | 35 | grpcServer := grpc.NewServer(logger) 36 | pb.RegisterRealWorldServer(grpcServer, server) 37 | reflection.Register(grpcServer) 38 | 39 | listen, err := net.Listen("tcp", fmt.Sprintf(":%d", server.config.ServerPort)) 40 | if err != nil { 41 | return err 42 | } 43 | if err := grpcServer.Serve(listen); err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/core/util/validator.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "net/mail" 6 | "net/url" 7 | "regexp" 8 | ) 9 | 10 | var ( 11 | isValidUsername = regexp.MustCompile(`^[a-z0-9_]+$`).MatchString 12 | ) 13 | 14 | func ValidateString(value string, min, max int) error { 15 | n := len(value) 16 | if n < min || n > max { 17 | return fmt.Errorf("must contain from %d-%d characters", min, max) 18 | } 19 | return nil 20 | } 21 | 22 | func ValidateUsername(value string) error { 23 | if err := ValidateString(value, 3, 100); err != nil { 24 | return err 25 | } 26 | if !isValidUsername(value) { 27 | return fmt.Errorf("must contain only lowercase letters, digits or underscores") 28 | } 29 | return nil 30 | } 31 | 32 | func ValidatePassword(value string) error { 33 | return ValidateString(value, 8, 200) 34 | } 35 | 36 | func ValidateEmail(value string) error { 37 | if err := ValidateString(value, 3, 100); err != nil { 38 | return err 39 | } 40 | if _, err := mail.ParseAddress(value); err != nil { 41 | return fmt.Errorf("is not an valid email address") 42 | } 43 | return nil 44 | } 45 | 46 | func ValidateID(value int64) error { 47 | if value <= 0 { 48 | return fmt.Errorf("id must be positive integer") 49 | } 50 | return nil 51 | } 52 | 53 | func ValidateURL(value string) error { 54 | _, err := url.ParseRequestURI(value) 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /internal/adapter/repository/main.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/mongo" 7 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/sql" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | ) 11 | 12 | const defaultType = sql.TypePostgres 13 | 14 | var fnNewMap = map[string]func(util.Config, port.Logger) (port.Repository, error){ 15 | sql.TypePostgres: sql.NewSQLRepository, 16 | mongo.TypeMongo: mongo.NewMongoRepository, 17 | } 18 | 19 | func Keys() (keys []string) { 20 | for key := range fnNewMap { 21 | keys = append(keys, key) 22 | } 23 | sort.Strings(keys) 24 | return 25 | } 26 | 27 | func ListRepository(config util.Config, logger port.Logger) ([]port.Repository, error) { 28 | repos := []port.Repository{} 29 | for _, fn := range fnNewMap { 30 | repo, err := fn(config, logger) 31 | if err != nil { 32 | return []port.Repository{}, err 33 | } 34 | repos = append(repos, repo) 35 | } 36 | return repos, nil 37 | } 38 | 39 | func NewRepository(config util.Config, logger port.Logger) (port.Repository, error) { 40 | new, ok := fnNewMap[config.DBType] 41 | if ok { 42 | return new(config, logger) 43 | } 44 | return fnNewMap[defaultType](config, logger) 45 | } 46 | -------------------------------------------------------------------------------- /internal/core/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/internal/core/port" 5 | "github.com/labasubagia/realworld-backend/internal/core/util" 6 | "github.com/labasubagia/realworld-backend/internal/core/util/token" 7 | ) 8 | 9 | type serviceProperty struct { 10 | config util.Config 11 | tokenMaker token.Maker 12 | repo port.Repository 13 | logger port.Logger 14 | } 15 | 16 | type services struct { 17 | property serviceProperty 18 | articleService port.ArticleService 19 | userService port.UserService 20 | } 21 | 22 | func NewService(config util.Config, repo port.Repository, logger port.Logger) (port.Service, error) { 23 | tokenMaker, err := token.NewJWTMaker(config.TokenSymmetricKey) 24 | if err != nil { 25 | return nil, err 26 | } 27 | property := serviceProperty{ 28 | config: config, 29 | repo: repo, 30 | tokenMaker: tokenMaker, 31 | logger: logger, 32 | } 33 | svc := services{ 34 | property: property, 35 | articleService: NewArticleService(property), 36 | userService: NewUserService(property), 37 | } 38 | return &svc, nil 39 | } 40 | 41 | func (s *services) TokenMaker() token.Maker { 42 | return s.property.tokenMaker 43 | } 44 | 45 | func (s *services) Article() port.ArticleService { 46 | return s.articleService 47 | } 48 | 49 | func (s *services) User() port.UserService { 50 | return s.userService 51 | } 52 | -------------------------------------------------------------------------------- /internal/core/util/exception/error.go: -------------------------------------------------------------------------------- 1 | package exception 2 | 3 | const ( 4 | TypeInternal = "ErrInternal" 5 | TypeValidation = "ErrValidation" 6 | TypeNotFound = "ErrNotFound" 7 | TypePermissionDenied = "ErrPermissionDenied" 8 | TypeTokenExpired = "TokenExpired" 9 | TypeTokenInvalid = "TokenInvalid" 10 | ) 11 | 12 | type Err = map[string][]string 13 | 14 | type Exception struct { 15 | Type string `json:"-"` 16 | Message string `json:"-"` 17 | Cause error `json:"-"` 18 | Errors Err `json:"errors"` 19 | } 20 | 21 | func New(kind, message string, err error) *Exception { 22 | return &Exception{ 23 | Type: kind, 24 | Cause: err, 25 | Message: message, 26 | Errors: make(map[string][]string), 27 | } 28 | } 29 | 30 | func Validation() *Exception { 31 | return New(TypeValidation, "validation error", nil) 32 | } 33 | 34 | func (e *Exception) HasError() bool { 35 | return len(e.Errors) > 0 36 | } 37 | 38 | func (e *Exception) AddError(key, msg string) *Exception { 39 | e.Errors[key] = append(e.Errors[key], msg) 40 | return e 41 | } 42 | 43 | func Into(err error) *Exception { 44 | if err == nil { 45 | return nil 46 | } 47 | fail, ok := err.(*Exception) 48 | if ok { 49 | return fail 50 | } 51 | return New(TypeInternal, err.Error(), err) 52 | } 53 | 54 | func (fail *Exception) Error() string { 55 | if fail.Cause == nil { 56 | return "" 57 | } 58 | return fail.Cause.Error() 59 | } 60 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/repository.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/sql/db" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "github.com/uptrace/bun" 11 | ) 12 | 13 | const TypePostgres = "postgres" 14 | 15 | type sqlRepo struct { 16 | db bun.IDB 17 | logger port.Logger 18 | userRepo port.UserRepository 19 | articleRepo port.ArticleRepository 20 | } 21 | 22 | func NewSQLRepository(config util.Config, logger port.Logger) (port.Repository, error) { 23 | db, err := db.New(config, logger) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return create(db.DB(), logger), nil 28 | } 29 | 30 | func (r *sqlRepo) Atomic(ctx context.Context, fn port.RepositoryAtomicCallback) error { 31 | err := r.db.RunInTx( 32 | ctx, 33 | &sql.TxOptions{Isolation: sql.LevelSerializable}, 34 | func(ctx context.Context, tx bun.Tx) error { 35 | return fn(create(tx, r.logger)) 36 | }, 37 | ) 38 | if err != nil { 39 | return intoException(err) 40 | } 41 | return nil 42 | } 43 | 44 | func create(db bun.IDB, logger port.Logger) port.Repository { 45 | return &sqlRepo{ 46 | db: db, 47 | logger: logger, 48 | userRepo: NewUserRepository(db), 49 | articleRepo: NewArticleRepository(db), 50 | } 51 | } 52 | 53 | func (r *sqlRepo) User() port.UserRepository { 54 | return r.userRepo 55 | } 56 | 57 | func (r *sqlRepo) Article() port.ArticleRepository { 58 | return r.articleRepo 59 | } 60 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/serializer.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb" 5 | "github.com/labasubagia/realworld-backend/internal/core/domain" 6 | "google.golang.org/protobuf/types/known/timestamppb" 7 | ) 8 | 9 | func serializeUser(arg domain.User) *pb.User { 10 | return &pb.User{ 11 | Email: arg.Email, 12 | Username: arg.Username, 13 | Bio: arg.Bio, 14 | Image: arg.Image, 15 | Token: arg.Token, 16 | } 17 | } 18 | 19 | func serializeProfile(arg domain.User) *pb.Profile { 20 | return &pb.Profile{ 21 | Username: arg.Username, 22 | Image: arg.Image, 23 | Bio: arg.Bio, 24 | Following: arg.IsFollowed, 25 | } 26 | } 27 | 28 | func serializeArticle(arg domain.Article) *pb.Article { 29 | tags := []string{} 30 | if len(arg.TagNames) > 0 { 31 | tags = arg.TagNames 32 | } 33 | return &pb.Article{ 34 | Slug: arg.Slug, 35 | Title: arg.Title, 36 | Description: arg.Description, 37 | Body: arg.Body, 38 | TagList: tags, 39 | Favorited: arg.IsFavorite, 40 | FavoriteCount: int64(arg.FavoriteCount), 41 | Author: serializeProfile(arg.Author), 42 | CreatedAt: timestamppb.New(arg.CreatedAt), 43 | UpdatedAt: timestamppb.New(arg.UpdatedAt), 44 | } 45 | } 46 | 47 | func serializeComment(arg domain.Comment) *pb.Comment { 48 | return &pb.Comment{ 49 | Id: arg.ID.String(), 50 | Body: arg.Body, 51 | Author: serializeProfile(arg.Author), 52 | CreatedAt: timestamppb.New(arg.CreatedAt), 53 | UpdatedAt: timestamppb.New(arg.UpdatedAt), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | ) 8 | 9 | type User struct { 10 | ID domain.ID `bson:"id"` 11 | Email string `bson:"email"` 12 | Username string `bson:"username"` 13 | Password string `bson:"password"` 14 | Image string `bson:"image"` 15 | Bio string `bson:"bio"` 16 | CreatedAt time.Time `bson:"created_at"` 17 | UpdatedAt time.Time `bson:"updated_at"` 18 | } 19 | 20 | func (data User) ToDomain() domain.User { 21 | return domain.User{ 22 | ID: data.ID, 23 | Email: data.Email, 24 | Username: data.Username, 25 | Password: data.Password, 26 | Image: data.Image, 27 | Bio: data.Bio, 28 | CreatedAt: data.CreatedAt, 29 | UpdatedAt: data.UpdatedAt, 30 | } 31 | } 32 | 33 | func AsUser(arg domain.User) User { 34 | return User{ 35 | ID: arg.ID, 36 | Email: arg.Email, 37 | Username: arg.Username, 38 | Password: arg.Password, 39 | Image: arg.Image, 40 | Bio: arg.Bio, 41 | CreatedAt: arg.CreatedAt, 42 | UpdatedAt: arg.UpdatedAt, 43 | } 44 | } 45 | 46 | type UserFollow struct { 47 | FollowerID domain.ID `bson:"follower_id"` 48 | FolloweeID domain.ID `bson:"followee_id"` 49 | } 50 | 51 | func (data UserFollow) ToDomain() domain.UserFollow { 52 | return domain.UserFollow{ 53 | FollowerID: data.FollowerID, 54 | FolloweeID: data.FolloweeID, 55 | } 56 | } 57 | 58 | func AsUserFollow(arg domain.UserFollow) UserFollow { 59 | return UserFollow{ 60 | FollowerID: arg.FollowerID, 61 | FolloweeID: arg.FolloweeID, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/logger.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/labasubagia/realworld-backend/internal/core/domain" 10 | "github.com/labasubagia/realworld-backend/internal/core/port" 11 | ) 12 | 13 | func (s *Server) Logger() gin.HandlerFunc { 14 | return func(c *gin.Context) { 15 | 16 | // request id 17 | reqID := c.GetHeader("x-request-id") 18 | if reqID == "" { 19 | reqID = domain.NewID().String() 20 | } 21 | 22 | // make logger and sub-logger 23 | // ? make unique instance for each handler/interactor request 24 | logger := s.logger.NewInstance().Field("request_id", reqID).Logger() 25 | // logger := s.logger.Field("request_id", reqID).Logger() // this is use single instance 26 | c.Set(port.SubLoggerCtxKey, logger) 27 | 28 | // process request 29 | startTime := time.Now() 30 | c.Next() 31 | duration := time.Since(startTime) 32 | 33 | // log 34 | logEvent := logger.Info() 35 | if c.Writer.Status() >= 500 { 36 | logEvent = logger.Error() 37 | if c.Request != nil && c.Request.Body != nil { 38 | if body, err := io.ReadAll(c.Request.Body); err == nil { 39 | logEvent.Field("body", body) 40 | } 41 | } 42 | } 43 | logEvent. 44 | Field("protocol", "http"). 45 | Field("client_ip", c.ClientIP()). 46 | Field("user_agent", c.Request.UserAgent()). 47 | Field("method", c.Request.Method). 48 | Field("path", c.Request.URL.Path). 49 | Field("status_code", c.Writer.Status()). 50 | Field("status", http.StatusText(c.Writer.Status())). 51 | Field("duration", duration). 52 | Msg("received http request") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/authorization.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 10 | "google.golang.org/grpc/metadata" 11 | ) 12 | 13 | const ( 14 | authorizationHeader = "authorization" 15 | authorizationTypeToken = "token" 16 | authorizationArgKey = "authorization_arg" 17 | ) 18 | 19 | func (s *Server) authorizeUser(ctx context.Context) (port.AuthParams, error) { 20 | metaData, ok := metadata.FromIncomingContext(ctx) 21 | if !ok { 22 | return port.AuthParams{}, exception.New(exception.TypePermissionDenied, "missing metadata", nil) 23 | } 24 | values := metaData.Get(authorizationHeader) 25 | if len(values) == 0 { 26 | return port.AuthParams{}, exception.New(exception.TypePermissionDenied, "missing authorization header", nil) 27 | } 28 | 29 | fields := strings.Fields(values[0]) 30 | if len(fields) < 2 { 31 | msg := "invalid authorization format" 32 | err := exception.New(exception.TypePermissionDenied, msg, nil) 33 | return port.AuthParams{}, err 34 | } 35 | 36 | authorizationType := strings.ToLower(fields[0]) 37 | if authorizationType != authorizationTypeToken { 38 | msg := fmt.Sprintf("authorization type %s not supported", authorizationType) 39 | err := exception.New(exception.TypePermissionDenied, msg, nil) 40 | return port.AuthParams{}, err 41 | } 42 | 43 | token := fields[1] 44 | payload, err := s.service.TokenMaker().VerifyToken(token) 45 | if err != nil { 46 | return port.AuthParams{}, err 47 | } 48 | return port.AuthParams{Token: token, Payload: payload}, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/proto/rpc_article.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | import "article.proto"; 6 | 7 | option go_package = "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb"; 8 | 9 | message ArticleResponse { 10 | Article article = 1; 11 | } 12 | 13 | 14 | message ArticlesResponse { 15 | repeated Article articles = 1; 16 | int64 count = 2; 17 | } 18 | 19 | message FilterArticleRequest { 20 | optional string tag = 1; 21 | optional string author = 2; 22 | optional string favorited = 3; 23 | optional int64 offset = 4; 24 | optional int64 limit = 5; 25 | } 26 | 27 | message GetArticleRequest { 28 | string slug = 1; 29 | } 30 | 31 | message CreateArticleRequest { 32 | message Article { 33 | string title = 1; 34 | string description = 2; 35 | string body = 3; 36 | repeated string tag_list = 4; 37 | } 38 | Article article = 1; 39 | } 40 | 41 | message UpdateArticleRequest { 42 | message Article { 43 | string title = 1; 44 | string description = 2; 45 | string body = 3; 46 | } 47 | string slug = 1; 48 | Article article = 2; 49 | } 50 | 51 | message CommentResponse { 52 | Comment comment = 1; 53 | } 54 | 55 | message CommentsResponse { 56 | repeated Comment comments = 1; 57 | } 58 | 59 | message CreateCommentRequest { 60 | message Comment { 61 | string body = 1; 62 | } 63 | string slug = 1; 64 | Comment comment = 2; 65 | } 66 | 67 | message ListCommentRequest { 68 | string slug = 1; 69 | } 70 | 71 | message GetCommentRequest { 72 | string slug = 1; 73 | string comment_id = 2; 74 | } 75 | 76 | message ListTagResponse { 77 | repeated string tags = 1; 78 | } -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/proto/service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | import "google/protobuf/empty.proto"; 6 | import "rpc_user.proto"; 7 | import "rpc_article.proto"; 8 | 9 | option go_package = "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb"; 10 | 11 | message Response { 12 | string status = 1; 13 | } 14 | 15 | service RealWorld { 16 | rpc RegisterUser(RegisterUserRequest) returns (UserResponse) {}; 17 | rpc LoginUser(LoginUserRequest) returns (UserResponse) {}; 18 | rpc UpdateUser(UpdateUserRequest) returns (UserResponse) {}; 19 | rpc CurrentUser(google.protobuf.Empty) returns (UserResponse) {}; 20 | 21 | rpc GetProfile(GetProfileRequest) returns (ProfileResponse) {}; 22 | rpc FollowUser(GetProfileRequest) returns (ProfileResponse) {}; 23 | rpc UnFollowUser(GetProfileRequest) returns (ProfileResponse) {}; 24 | 25 | rpc ListArticle(FilterArticleRequest) returns (ArticlesResponse) {}; 26 | rpc FeedArticle(FilterArticleRequest) returns (ArticlesResponse) {}; 27 | rpc GetArticle(GetArticleRequest) returns (ArticleResponse) {}; 28 | rpc CreateArticle(CreateArticleRequest) returns (ArticleResponse) {}; 29 | rpc UpdateArticle(UpdateArticleRequest) returns (ArticleResponse) {}; 30 | rpc DeleteArticle(GetArticleRequest) returns (Response) {}; 31 | rpc FavoriteArticle(GetArticleRequest) returns (ArticleResponse) {}; 32 | rpc UnFavoriteArticle(GetArticleRequest) returns (ArticleResponse) {}; 33 | rpc ListTag(google.protobuf.Empty) returns (ListTagResponse) {}; 34 | 35 | rpc CreateComment(CreateCommentRequest) returns (CommentResponse) {}; 36 | rpc ListComment(ListCommentRequest) returns (CommentsResponse) {}; 37 | rpc DeleteComment(GetCommentRequest) returns (Response) {}; 38 | } -------------------------------------------------------------------------------- /internal/core/service/main_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/labasubagia/realworld-backend/internal/adapter/logger" 9 | "github.com/labasubagia/realworld-backend/internal/adapter/repository" 10 | "github.com/labasubagia/realworld-backend/internal/core/port" 11 | "github.com/labasubagia/realworld-backend/internal/core/service" 12 | "github.com/labasubagia/realworld-backend/internal/core/util" 13 | ) 14 | 15 | var testRepo port.Repository 16 | var testService port.Service 17 | 18 | func TestMain(m *testing.M) { 19 | config, err := util.LoadConfig("../../../.env") 20 | if err != nil { 21 | fmt.Fprintln(os.Stderr, "failed to load config", err) 22 | os.Exit(1) 23 | } 24 | logger := logger.NewLogger(config) 25 | 26 | var code int 27 | 28 | if config.IsTestAllRepo() { 29 | // test every store repo against service 30 | // slower test 31 | // NOTE: add env TEST_REPO=all 32 | 33 | repos, err := repository.ListRepository(config, logger) 34 | if err != nil { 35 | logger.Fatal().Err(err).Msg("failed to load repository") 36 | } 37 | for _, repo := range repos { 38 | testRepo = repo 39 | testService, err = service.NewService(config, testRepo, logger) 40 | if err != nil { 41 | logger.Fatal().Err(err).Msg("failed to load service") 42 | } 43 | code = m.Run() 44 | } 45 | 46 | } else { 47 | // test main repo (currently used) 48 | // faster test 49 | 50 | testRepo, err = repository.NewRepository(config, logger) 51 | if err != nil { 52 | logger.Fatal().Err(err).Msg("failed to load repository") 53 | } 54 | 55 | testService, err = service.NewService(config, testRepo, logger) 56 | if err != nil { 57 | logger.Fatal().Err(err).Msg("failed to load service") 58 | } 59 | code = m.Run() 60 | } 61 | 62 | os.Exit(code) 63 | } 64 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/migration/000002_add_articles.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "articles" ( 2 | "id" char(26) PRIMARY KEY, 3 | "slug" text NOT NULL, 4 | "title" text NOT NULL, 5 | "description" text NOT NULL, 6 | "body" text NOT NULL, 7 | "created_at" timestamptz NOT NULL DEFAULT (now()), 8 | "updated_at" timestamptz NOT NULL DEFAULT (now()), 9 | "author_id" char(26) NOT NULL, 10 | FOREIGN KEY ("author_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE 11 | ); 12 | 13 | --bun:split 14 | CREATE TABLE "comments" ( 15 | "id" char(26) PRIMARY KEY, 16 | "body" TEXT NOT NULL, 17 | "article_id" char(26) NOT NULL, 18 | "author_id" char(26) NOT NULL, 19 | "created_at" timestamptz NOT NULL DEFAULT (now()), 20 | "updated_at" timestamptz NOT NULL DEFAULT (now()), 21 | FOREIGN KEY ("article_id") REFERENCES "articles" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 22 | FOREIGN KEY ("author_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE 23 | ); 24 | 25 | --bun:split 26 | CREATE TABLE "tags" ( 27 | "id" char(26) PRIMARY KEY, 28 | "name" varchar NOT NULL 29 | ); 30 | 31 | --bun:split 32 | CREATE TABLE "article_tags" ( 33 | "article_id" char(26) NOT NULL, 34 | "tag_id" char(26) NOT NULL, 35 | PRIMARY KEY ("article_id", "tag_id"), 36 | FOREIGN KEY ("article_id") REFERENCES "articles" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 37 | FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE CASCADE ON UPDATE CASCADE 38 | ); 39 | 40 | --bun:split 41 | CREATE TABLE "article_favorites" ( 42 | "user_id" char(26) NOT NULL, 43 | "article_id" char(26) NOT NULL, 44 | PRIMARY KEY ("user_id", "article_id"), 45 | FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 46 | FOREIGN KEY ("article_id") REFERENCES "articles" ("id") ON DELETE CASCADE ON UPDATE CASCADE 47 | ) 48 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/repository.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/port" 7 | "github.com/labasubagia/realworld-backend/internal/core/util" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | "go.mongodb.org/mongo-driver/mongo/writeconcern" 11 | ) 12 | 13 | const TypeMongo = "mongo" 14 | 15 | type mongoRepo struct { 16 | db DB 17 | logger port.Logger 18 | userRepo port.UserRepository 19 | articleRepo port.ArticleRepository 20 | } 21 | 22 | func NewMongoRepository(config util.Config, logger port.Logger) (port.Repository, error) { 23 | db, err := NewDB(config, logger) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return create(db, logger), nil 28 | } 29 | 30 | func create(db DB, logger port.Logger) port.Repository { 31 | return &mongoRepo{ 32 | db: db, 33 | userRepo: NewUserRepository(db), 34 | articleRepo: NewArticleRepository(db), 35 | } 36 | } 37 | 38 | func (r *mongoRepo) Atomic(ctx context.Context, fn port.RepositoryAtomicCallback) error { 39 | wc := writeconcern.New(writeconcern.WMajority()) 40 | txnOptions := options.Transaction().SetWriteConcern(wc) 41 | 42 | session, err := r.db.Client().StartSession() 43 | if err != nil { 44 | return intoException(err) 45 | } 46 | defer session.EndSession(ctx) 47 | 48 | _, err = session.WithTransaction(ctx, func(sessionCtx mongo.SessionContext) (any, error) { 49 | if err := fn(create(r.db, r.logger)); err != nil { 50 | return nil, intoException(err) 51 | } 52 | return nil, nil 53 | }, txnOptions) 54 | 55 | if err != nil { 56 | return intoException(err) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (r *mongoRepo) User() port.UserRepository { 63 | return r.userRepo 64 | } 65 | 66 | func (r *mongoRepo) Article() port.ArticleRepository { 67 | return r.articleRepo 68 | } 69 | -------------------------------------------------------------------------------- /internal/core/util/token/jwt_maker.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/dgrijalva/jwt-go" 8 | "github.com/labasubagia/realworld-backend/internal/core/domain" 9 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 10 | ) 11 | 12 | const minSecretKeySize = 32 13 | 14 | type JWTMaker struct { 15 | secretKey string 16 | } 17 | 18 | func NewJWTMaker(secretKey string) (Maker, error) { 19 | if len(secretKey) < minSecretKeySize { 20 | return nil, fmt.Errorf("invalid key size: must be at least %d characters", minSecretKeySize) 21 | } 22 | return &JWTMaker{secretKey}, nil 23 | } 24 | 25 | func (maker JWTMaker) CreateToken(userID domain.ID, duration time.Duration) (string, *Payload, error) { 26 | payload, err := NewPayload(userID, duration) 27 | if err != nil { 28 | return "", payload, err 29 | } 30 | 31 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) 32 | token, err := jwtToken.SignedString([]byte(maker.secretKey)) 33 | return token, payload, err 34 | } 35 | 36 | func (maker *JWTMaker) VerifyToken(token string) (*Payload, error) { 37 | keyFunc := func(t *jwt.Token) (interface{}, error) { 38 | _, ok := t.Method.(*jwt.SigningMethodHMAC) 39 | if !ok { 40 | return nil, exception.New(exception.TypeTokenInvalid, "invalid token", nil) 41 | } 42 | return []byte(maker.secretKey), nil 43 | } 44 | 45 | jwtToken, err := jwt.ParseWithClaims(token, &Payload{}, keyFunc) 46 | if err != nil { 47 | vErr, ok := err.(*jwt.ValidationError) 48 | if ok { 49 | fail, ok := vErr.Inner.(*exception.Exception) 50 | if ok { 51 | return nil, fail 52 | } 53 | } 54 | return nil, exception.New(exception.TypeTokenInvalid, "invalid token", err) 55 | } 56 | 57 | payload, ok := jwtToken.Claims.(*Payload) 58 | if !ok { 59 | return nil, exception.New(exception.TypeTokenInvalid, "invalid token", nil) 60 | } 61 | 62 | return payload, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | "github.com/uptrace/bun" 8 | ) 9 | 10 | type User struct { 11 | bun.BaseModel `bun:"table:users,alias:u"` 12 | ID domain.ID `bun:"id,pk"` 13 | Email string `bun:"email,notnull"` 14 | Username string `bun:"username,notnull"` 15 | Password string `bun:"password,notnull"` 16 | Image string `bun:"image"` 17 | Bio string `bun:"bio"` 18 | CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 19 | UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 20 | } 21 | 22 | func (data User) ToDomain() domain.User { 23 | return domain.User{ 24 | ID: data.ID, 25 | Email: data.Email, 26 | Username: data.Username, 27 | Password: data.Password, 28 | Image: data.Image, 29 | Bio: data.Bio, 30 | CreatedAt: data.CreatedAt, 31 | UpdatedAt: data.UpdatedAt, 32 | } 33 | } 34 | 35 | func AsUser(arg domain.User) User { 36 | return User{ 37 | ID: arg.ID, 38 | Email: arg.Email, 39 | Username: arg.Username, 40 | Password: arg.Password, 41 | Image: arg.Image, 42 | Bio: arg.Bio, 43 | CreatedAt: arg.CreatedAt, 44 | UpdatedAt: arg.UpdatedAt, 45 | } 46 | } 47 | 48 | type UserFollow struct { 49 | bun.BaseModel `bun:"table:user_follows,alias:uf"` 50 | FollowerID domain.ID `bun:"follower_id"` 51 | FolloweeID domain.ID `bun:"followee_id"` 52 | } 53 | 54 | func (data UserFollow) ToDomain() domain.UserFollow { 55 | return domain.UserFollow{ 56 | FollowerID: data.FollowerID, 57 | FolloweeID: data.FolloweeID, 58 | } 59 | } 60 | 61 | func AsUserFollow(arg domain.UserFollow) UserFollow { 62 | return UserFollow{ 63 | FollowerID: arg.FollowerID, 64 | FolloweeID: arg.FolloweeID, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/logger.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/labasubagia/realworld-backend/internal/core/domain" 9 | "github.com/labasubagia/realworld-backend/internal/core/port" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/metadata" 13 | "google.golang.org/grpc/peer" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | const ( 18 | reqIDHeader = "request-id" 19 | userAgentHeader = "user-agent" 20 | ) 21 | 22 | func (server *Server) Logger(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { 23 | 24 | reqID := domain.NewID().String() 25 | var userAgent string 26 | metaData, ok := metadata.FromIncomingContext(ctx) 27 | if ok { 28 | if reqIDs := metaData.Get(reqIDHeader); len(reqIDs) > 0 { 29 | userAgent = reqIDs[0] 30 | } 31 | if userAgents := metaData.Get(userAgentHeader); len(userAgents) > 0 { 32 | userAgent = userAgents[0] 33 | } 34 | } 35 | 36 | peerInfo, _ := peer.FromContext(ctx) 37 | clientIP := peerInfo.Addr.String() 38 | 39 | logger := server.logger.NewInstance().Field("request_id", reqID).Logger() 40 | ctx = context.WithValue(ctx, port.SubLoggerCtxKey, logger) 41 | 42 | startTime := time.Now() 43 | result, err := handler(ctx, req) 44 | duration := time.Since(startTime) 45 | 46 | code := codes.Unknown 47 | if st, ok := status.FromError(err); ok { 48 | code = st.Code() 49 | } 50 | 51 | logEvent := logger.Info() 52 | if code == codes.Internal { 53 | logEvent = logger.Error() 54 | bytes, _ := json.Marshal(req) 55 | logEvent.Field("request", string(bytes)) 56 | } 57 | 58 | logEvent. 59 | Field("protocol", "grpc"). 60 | Field("method", info.FullMethod). 61 | Field("client_ip", clientIP). 62 | Field("user_agent", userAgent). 63 | Field("status_code", int(code)). 64 | Field("status_text", code.String()). 65 | Field("duration", duration). 66 | Msg("receive grpc request") 67 | 68 | return result, err 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### Golang Hexagonal Architecture codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | 6 | ### [Demo](https://demo.realworld.io/)    [RealWorld](https://github.com/gothinkster/realworld) 7 | 8 | 9 | This codebase was created to demonstrate a fully fledged fullstack application built with Golang including CRUD operations, authentication, routing, pagination, and more. 10 | 11 | We've gone to great lengths to adhere to the Golang community styleguides & best practices. 12 | 13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 14 | 15 | 16 | # How it works 17 | ![Architecture](architecture.png) 18 | The [hexagonal architecture](https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)) is simply push any external dependency to the edge of the app and keep business logic (service) in the core part. With this architecture we can easily swap external dependencies such as swap restful API to gRPC, mongo to postgres etc. 19 | 20 | # Getting started 21 | ## Prerequisite 22 | - [docker](https://www.docker.com/) 23 | - [docker compose](https://docs.docker.com/compose/) 24 | - [go](https://go.dev/) 25 | 26 | 27 | ## Simple Mode 28 | Run default app (rest-api, postgres) (see docker-compose.yaml) 29 | ```sh 30 | $ docker compose --profile restful_postgres up -d 31 | 32 | ``` 33 | ## Development Mode 34 | 1. Add environment 35 | ``` 36 | cp env.example .env 37 | ``` 38 | 1. Run all required external dependency for development (databases) 39 | ```sh 40 | $ docker compose up -d 41 | ``` 42 | 1. Run the app 43 | ```sh 44 | $ go run main.go server 45 | ``` 46 | 1. (optional) See help of application 47 | ```sh 48 | $ go run main.go server --help 49 | ``` 50 | 1. (optional) run app with different dependency 51 | ```sh 52 | $ go run main.go server --server grpc --database mongo --log zap 53 | ``` -------------------------------------------------------------------------------- /internal/adapter/logger/zerolog.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | const TypeZeroLog = "zerolog" 14 | 15 | type zeroLogLogger struct { 16 | config util.Config 17 | logger zerolog.Logger 18 | fields map[string]any 19 | } 20 | 21 | func NewZeroLogLogger(config util.Config) port.Logger { 22 | logger := zerolog.New(os.Stderr) 23 | if !config.IsProduction() { 24 | output := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339} 25 | logger = zerolog.New(output).With().Timestamp().Logger() 26 | } 27 | return &zeroLogLogger{ 28 | config: config, 29 | logger: logger, 30 | fields: map[string]any{}, 31 | } 32 | } 33 | 34 | func (l *zeroLogLogger) NewInstance() port.Logger { 35 | return NewZeroLogLogger(l.config) 36 | } 37 | 38 | func (l *zeroLogLogger) Field(key string, value any) port.Logger { 39 | l.fields[key] = value 40 | return l 41 | } 42 | 43 | func (l *zeroLogLogger) Logger() port.Logger { 44 | return l 45 | } 46 | 47 | func (l *zeroLogLogger) Info() port.LogEvent { 48 | return newZeroLogEvent(l.fields, l.logger.Info()) 49 | } 50 | 51 | func (l *zeroLogLogger) Error() port.LogEvent { 52 | return newZeroLogEvent(l.fields, l.logger.Error()) 53 | } 54 | 55 | func (l *zeroLogLogger) Fatal() port.LogEvent { 56 | return newZeroLogEvent(l.fields, l.logger.Fatal()) 57 | } 58 | 59 | type zeroLogEvent struct { 60 | event *zerolog.Event 61 | } 62 | 63 | func newZeroLogEvent(initialFields map[string]any, event *zerolog.Event) port.LogEvent { 64 | event.Fields(initialFields) 65 | return &zeroLogEvent{event: event} 66 | } 67 | 68 | func (e *zeroLogEvent) Err(err error) port.LogEvent { 69 | e.event.Err(err) 70 | return e 71 | } 72 | 73 | func (e *zeroLogEvent) Field(key string, value any) port.LogEvent { 74 | e.event.Any(key, value) 75 | return e 76 | } 77 | 78 | func (e *zeroLogEvent) Msg(v ...any) { 79 | msg := fmt.Sprint(v...) 80 | e.event.Msg(msg) 81 | } 82 | 83 | func (e *zeroLogEvent) Msgf(format string, v ...any) { 84 | msg := fmt.Sprintf(format, v...) 85 | e.event.Msg(msg) 86 | } 87 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | postgres: 4 | image: postgres:alpine 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=realworld 10 | - PGDATA=/var/lib/postgresql/data/pgdata 11 | ports: 12 | - 5432:5432 13 | volumes: 14 | - postgres:/var/lib/postgresql/data 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready"] 17 | interval: 1s 18 | timeout: 1s 19 | retries: 10 20 | 21 | mongo: 22 | image: mongo 23 | restart: always 24 | environment: 25 | MONGO_INITDB_ROOT_USERNAME: root 26 | MONGO_INITDB_ROOT_PASSWORD: root 27 | ports: 28 | - 27017:27017 29 | volumes: 30 | - mongo:/data/db 31 | healthcheck: 32 | test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] 33 | interval: 1s 34 | timeout: 1s 35 | retries: 10 36 | 37 | dbgate: 38 | image: dbgate/dbgate:alpine 39 | ports: 40 | - 8080:3000 41 | volumes: 42 | - dbgate:/root/.dbgate 43 | depends_on: 44 | - postgres 45 | - mongo 46 | environment: 47 | - CONNECTIONS=con1,con2 48 | 49 | - LABEL_con1=Postgres 50 | - SERVER_con1=postgres 51 | - USER_con1=postgres 52 | - PASSWORD_con1=postgres 53 | - PORT_con1=5432 54 | - ENGINE_con1=postgres@dbgate-plugin-postgres 55 | 56 | - LABEL_con2=MongoDB 57 | - SERVER_con2=mongo 58 | - USER_con2=root 59 | - PASSWORD_con2=root 60 | - PORT_con2=27017 61 | - ENGINE_con2=mongo@dbgate-plugin-mongo 62 | 63 | realworld: 64 | build: 65 | dockerfile: Dockerfile 66 | context: . 67 | depends_on: 68 | - postgres 69 | - mongo 70 | ports: 71 | - 5000:5000 72 | volumes: 73 | - ..:/workspace:cached 74 | - ~/.ssh:/home/vscode/.ssh:ro 75 | command: /bin/sh -c "while sleep 1000; do :; done" 76 | 77 | # don't use env var for in development here, it will conflict (e.g with test env) 78 | # create separate .env 79 | # env_file: 80 | # - ../.env.docker 81 | 82 | volumes: 83 | mongo: 84 | postgres: 85 | dbgate: -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run Server", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}", 13 | "envFile": "${workspaceFolder}/.env", 14 | "args": [ 15 | "server", 16 | ] 17 | }, 18 | { 19 | "name": "Run Server Prod", 20 | "type": "go", 21 | "request": "launch", 22 | "mode": "auto", 23 | "program": "${workspaceFolder}", 24 | "envFile": "${workspaceFolder}/.env", 25 | "args": [ 26 | "server", 27 | "--prod" 28 | ] 29 | }, 30 | { 31 | "name": "Run Server (Postgres)", 32 | "type": "go", 33 | "request": "launch", 34 | "mode": "auto", 35 | "program": "${workspaceFolder}", 36 | "envFile": "${workspaceFolder}/.env", 37 | "args": [ 38 | "server", 39 | "-d", 40 | "postgres" 41 | ] 42 | }, 43 | { 44 | "name": "Run Server (Mongo)", 45 | "type": "go", 46 | "request": "launch", 47 | "mode": "auto", 48 | "program": "${workspaceFolder}", 49 | "envFile": "${workspaceFolder}/.env", 50 | "args": [ 51 | "server", 52 | "-d", 53 | "mongo" 54 | ] 55 | }, 56 | { 57 | "name": "Run Server gRPC", 58 | "type": "go", 59 | "request": "launch", 60 | "mode": "auto", 61 | "program": "${workspaceFolder}", 62 | "envFile": "${workspaceFolder}/.env", 63 | "args": [ 64 | "server", 65 | "-s", 66 | "grpc" 67 | ] 68 | } 69 | ] 70 | } -------------------------------------------------------------------------------- /internal/core/port/service_article.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | ) 8 | 9 | type CreateArticleTxParams struct { 10 | AuthArg AuthParams 11 | Article domain.Article 12 | Tags []string 13 | } 14 | 15 | type UpdateArticleParams struct { 16 | AuthArg AuthParams 17 | Slug string 18 | Article domain.Article 19 | } 20 | 21 | type DeleteArticleParams struct { 22 | AuthArg AuthParams 23 | Slug string 24 | } 25 | 26 | type ListArticleParams struct { 27 | AuthArg AuthParams 28 | IDs []domain.ID 29 | Tags []string 30 | AuthorNames []string 31 | FavoritedNames []string 32 | Limit int 33 | Offset int 34 | } 35 | 36 | type AddFavoriteParams struct { 37 | AuthArg AuthParams 38 | Slug string 39 | UserID domain.ID 40 | } 41 | 42 | type RemoveFavoriteParams AddFavoriteParams 43 | 44 | type GetArticleParams struct { 45 | AuthArg AuthParams 46 | Slug string 47 | } 48 | 49 | type AddCommentParams struct { 50 | AuthArg AuthParams 51 | Slug string 52 | Comment domain.Comment 53 | } 54 | 55 | type ListCommentParams struct { 56 | AuthArg AuthParams 57 | Slug string 58 | } 59 | 60 | type DeleteCommentParams struct { 61 | AuthArg AuthParams 62 | Slug string 63 | CommentID domain.ID 64 | } 65 | 66 | type ArticleService interface { 67 | Create(context.Context, CreateArticleTxParams) (domain.Article, error) 68 | Update(context.Context, UpdateArticleParams) (domain.Article, error) 69 | Delete(context.Context, DeleteArticleParams) error 70 | List(context.Context, ListArticleParams) ([]domain.Article, error) 71 | Feed(context.Context, ListArticleParams) ([]domain.Article, error) 72 | Get(context.Context, GetArticleParams) (domain.Article, error) 73 | 74 | AddComment(context.Context, AddCommentParams) (domain.Comment, error) 75 | ListComments(context.Context, ListCommentParams) ([]domain.Comment, error) 76 | DeleteComment(context.Context, DeleteCommentParams) error 77 | 78 | AddFavorite(context.Context, AddFavoriteParams) (domain.Article, error) 79 | RemoveFavorite(context.Context, RemoveFavoriteParams) (domain.Article, error) 80 | 81 | ListTags(context.Context) ([]string, error) 82 | } 83 | -------------------------------------------------------------------------------- /internal/core/port/repository_article.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | ) 8 | 9 | type FilterArticlePayload struct { 10 | Slugs []string 11 | IDs []domain.ID 12 | AuthorIDs []domain.ID 13 | Limit int 14 | Offset int 15 | } 16 | 17 | type AddTagsPayload struct { 18 | Tags []string 19 | } 20 | 21 | type AssignTagPayload struct { 22 | ArticleID domain.ID 23 | TagIDs []domain.ID 24 | } 25 | 26 | type FilterTagPayload struct { 27 | IDs []domain.ID 28 | Names []string 29 | } 30 | 31 | type FilterArticleTagPayload struct { 32 | ArticleIDs []domain.ID 33 | TagIDs []domain.ID 34 | } 35 | 36 | type FilterFavoritePayload struct { 37 | UserIDs []domain.ID 38 | ArticleIDs []domain.ID 39 | } 40 | 41 | type FilterCommentPayload struct { 42 | ArticleIDs []domain.ID 43 | AuthorIDs []domain.ID 44 | } 45 | 46 | type ArticleRepository interface { 47 | CreateArticle(context.Context, domain.Article) (domain.Article, error) 48 | UpdateArticle(context.Context, domain.Article) (domain.Article, error) 49 | DeleteArticle(context.Context, domain.Article) error 50 | FilterArticle(context.Context, FilterArticlePayload) ([]domain.Article, error) 51 | FindOneArticle(context.Context, FilterArticlePayload) (domain.Article, error) 52 | 53 | FilterTags(context.Context, FilterTagPayload) ([]domain.Tag, error) 54 | AddTags(context.Context, AddTagsPayload) ([]domain.Tag, error) 55 | 56 | FilterArticleTags(context.Context, FilterArticleTagPayload) ([]domain.ArticleTag, error) 57 | AssignArticleTags(context.Context, AssignTagPayload) ([]domain.ArticleTag, error) 58 | 59 | AddFavorite(context.Context, domain.ArticleFavorite) (domain.ArticleFavorite, error) 60 | RemoveFavorite(context.Context, domain.ArticleFavorite) (domain.ArticleFavorite, error) 61 | FilterFavorite(context.Context, FilterFavoritePayload) ([]domain.ArticleFavorite, error) 62 | FilterFavoriteCount(context.Context, FilterFavoritePayload) ([]domain.ArticleFavoriteCount, error) 63 | 64 | AddComment(context.Context, domain.Comment) (domain.Comment, error) 65 | DeleteComment(context.Context, domain.Comment) error 66 | FilterComment(context.Context, FilterCommentPayload) ([]domain.Comment, error) 67 | } 68 | -------------------------------------------------------------------------------- /internal/core/domain/article.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/core/util" 8 | ) 9 | 10 | type Article struct { 11 | ID ID 12 | AuthorID ID 13 | Title string 14 | Slug string 15 | Description string 16 | Body string 17 | CreatedAt time.Time 18 | UpdatedAt time.Time 19 | TagNames []string 20 | Author User 21 | IsFavorite bool 22 | FavoriteCount int 23 | } 24 | 25 | func (article *Article) SetTitle(value string) { 26 | article.Title = value 27 | article.Slug = strings.ToLower(strings.ReplaceAll(value, " ", "-")) 28 | } 29 | 30 | func NewArticle(arg Article) Article { 31 | now := time.Now() 32 | article := Article{ 33 | ID: NewID(), 34 | AuthorID: arg.AuthorID, 35 | Title: arg.Title, 36 | Description: arg.Description, 37 | Body: arg.Body, 38 | CreatedAt: now, 39 | UpdatedAt: now, 40 | } 41 | article.SetTitle(arg.Title) 42 | return article 43 | } 44 | 45 | func RandomArticle(author User) Article { 46 | article := Article{ 47 | AuthorID: author.ID, 48 | Description: util.RandomString(15), 49 | Body: util.RandomString(20), 50 | } 51 | article.SetTitle(util.RandomString(10)) 52 | return article 53 | } 54 | 55 | type Tag struct { 56 | ID ID 57 | Name string 58 | } 59 | 60 | func NewTag(arg Tag) Tag { 61 | return Tag{ 62 | ID: NewID(), 63 | Name: arg.Name, 64 | } 65 | } 66 | 67 | type ArticleTag struct { 68 | ArticleID ID 69 | TagID ID 70 | } 71 | 72 | type Comment struct { 73 | ID ID 74 | ArticleID ID 75 | AuthorID ID 76 | Body string 77 | CreatedAt time.Time 78 | UpdatedAt time.Time 79 | Author User 80 | } 81 | 82 | func NewComment(arg Comment) Comment { 83 | now := time.Now() 84 | return Comment{ 85 | ID: NewID(), 86 | ArticleID: arg.ArticleID, 87 | AuthorID: arg.AuthorID, 88 | Body: arg.Body, 89 | CreatedAt: now, 90 | UpdatedAt: now, 91 | } 92 | } 93 | 94 | type ArticleFavorite struct { 95 | ArticleID ID 96 | UserID ID 97 | } 98 | 99 | type ArticleFavoriteCount struct { 100 | ArticleID ID 101 | Count int 102 | } 103 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/adapter/handler" 8 | "github.com/labasubagia/realworld-backend/internal/adapter/logger" 9 | "github.com/labasubagia/realworld-backend/internal/adapter/repository" 10 | "github.com/labasubagia/realworld-backend/internal/core/service" 11 | "github.com/labasubagia/realworld-backend/internal/core/util" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func init() { 16 | dbTypeStr := strings.Join(repository.Keys(), ", ") 17 | logTypeStr := strings.Join(logger.Keys(), ", ") 18 | serverTypeStr := strings.Join(handler.Keys(), ", ") 19 | 20 | rootCmd.AddCommand(serverCmd) 21 | 22 | serverCmd.Flags().Bool("prod", config.IsProduction(), "use for production") 23 | serverCmd.Flags().StringVarP(&config.ServerType, "server", "s", config.ServerType, fmt.Sprintf("server type in (%s)", serverTypeStr)) 24 | serverCmd.Flags().IntVarP(&config.ServerPort, "port", "p", config.ServerPort, "server port number") 25 | serverCmd.Flags().StringVarP(&config.DBType, "database", "d", config.DBType, fmt.Sprintf("database type in (%s)", dbTypeStr)) 26 | serverCmd.Flags().StringVarP(&config.LogType, "log", "l", config.LogType, fmt.Sprintf("log type in (%s)", logTypeStr)) 27 | } 28 | 29 | var serverCmd = &cobra.Command{ 30 | Use: "server", 31 | Short: "run server", 32 | Long: "Run server", 33 | Run: func(cmd *cobra.Command, args []string) { 34 | 35 | // is_prod 36 | isProduction, err := cmd.Flags().GetBool("prod") 37 | if err == nil && isProduction { 38 | config.Environment = util.EnvProduction 39 | } 40 | logger := logger.NewLogger(config) 41 | logger.Info().Msgf("use logger %s", config.LogType) 42 | 43 | // repository 44 | repo, err := repository.NewRepository(config, logger) 45 | if err != nil { 46 | logger.Fatal().Err(err).Msg("failed to load repository") 47 | } 48 | logger.Info().Msgf("use repository %s", config.DBType) 49 | 50 | service, err := service.NewService(config, repo, logger) 51 | if err != nil { 52 | logger.Fatal().Err(err).Msg("failed to load service") 53 | } 54 | 55 | logger.Info().Msgf("%s server listen to port %d", config.ServerType, config.ServerPort) 56 | server := handler.NewServer(config, service, logger) 57 | if server.Start(); err != nil { 58 | logger.Fatal().Err(err).Msg("failed to load service") 59 | } 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /internal/core/util/token/jwt_maker_test.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/dgrijalva/jwt-go" 8 | "github.com/labasubagia/realworld-backend/internal/core/domain" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestJWTMaker(t *testing.T) { 15 | maker, err := NewJWTMaker(util.RandomString(32)) 16 | require.NoError(t, err) 17 | 18 | userID := domain.RandomID() 19 | duration := time.Minute 20 | 21 | issuedAt := time.Now() 22 | expiredAt := issuedAt.Add(duration) 23 | 24 | token, payload, err := maker.CreateToken(userID, duration) 25 | require.NoError(t, err) 26 | require.NotEmpty(t, token) 27 | require.NotEmpty(t, payload) 28 | 29 | payload, err = maker.VerifyToken(token) 30 | require.NoError(t, err) 31 | require.NotEmpty(t, token) 32 | 33 | require.NotZero(t, payload.ID) 34 | require.Equal(t, userID, payload.UserID) 35 | require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second) 36 | require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second) 37 | } 38 | 39 | func TestExpiredJWTToken(t *testing.T) { 40 | maker, err := NewJWTMaker(util.RandomString(32)) 41 | require.NoError(t, err) 42 | 43 | token, payload, err := maker.CreateToken(domain.RandomID(), -time.Minute) 44 | require.NoError(t, err) 45 | require.NotEmpty(t, token) 46 | require.NotEmpty(t, payload) 47 | 48 | payload, err = maker.VerifyToken(token) 49 | require.Error(t, err) 50 | 51 | fail, ok := err.(*exception.Exception) 52 | require.True(t, ok) 53 | require.Equal(t, exception.TypeTokenExpired, fail.Type) 54 | require.Nil(t, payload) 55 | } 56 | 57 | func TestInvalidJWTToken(t *testing.T) { 58 | payload, err := NewPayload(domain.RandomID(), time.Minute) 59 | require.NoError(t, err) 60 | 61 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodNone, payload) 62 | token, err := jwtToken.SignedString(jwt.UnsafeAllowNoneSignatureType) 63 | require.NoError(t, err) 64 | 65 | maker, err := NewJWTMaker(util.RandomString(32)) 66 | require.NoError(t, err) 67 | 68 | payload, err = maker.VerifyToken(token) 69 | require.Error(t, err) 70 | 71 | fail, ok := err.(*exception.Exception) 72 | require.True(t, ok) 73 | require.Equal(t, exception.TypeTokenInvalid, fail.Type) 74 | require.Nil(t, payload) 75 | } 76 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/util.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/labasubagia/realworld-backend/internal/core/port" 11 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 12 | ) 13 | 14 | const formatTime string = "2006-01-02T15:04:05.999Z" 15 | 16 | func (s *Server) parseToken(c *gin.Context) (port.AuthParams, error) { 17 | authorizationHeader := c.GetHeader(authorizationHeaderKey) 18 | if len(authorizationHeader) == 0 { 19 | msg := "authorization header not provided" 20 | err := exception.New(exception.TypePermissionDenied, msg, nil) 21 | return port.AuthParams{}, err 22 | } 23 | 24 | fields := strings.Fields(authorizationHeader) 25 | if len(fields) < 2 { 26 | msg := "invalid authorization format" 27 | err := exception.New(exception.TypePermissionDenied, msg, nil) 28 | return port.AuthParams{}, err 29 | } 30 | 31 | authorizationType := strings.ToLower(fields[0]) 32 | if authorizationType != authorizationTypeToken { 33 | msg := fmt.Sprintf("authorization type %s not supported", authorizationType) 34 | err := exception.New(exception.TypePermissionDenied, msg, nil) 35 | return port.AuthParams{}, err 36 | } 37 | 38 | token := fields[1] 39 | payload, err := s.service.TokenMaker().VerifyToken(token) 40 | if err != nil { 41 | return port.AuthParams{}, err 42 | } 43 | return port.AuthParams{Token: token, Payload: payload}, nil 44 | } 45 | 46 | func hasToken(c *gin.Context) bool { 47 | authorizationHeader := c.GetHeader(authorizationHeaderKey) 48 | return len(authorizationHeader) > 0 49 | } 50 | 51 | func getAuthArg(c *gin.Context) (port.AuthParams, error) { 52 | arg, ok := c.Get(authorizationArgKey) 53 | if !ok { 54 | return port.AuthParams{}, exception.New(exception.TypePermissionDenied, "no authorization arguments provided", nil) 55 | } 56 | authArg, ok := arg.(port.AuthParams) 57 | if !ok { 58 | return port.AuthParams{}, exception.New(exception.TypePermissionDenied, "invalid authorization arguments", nil) 59 | } 60 | return authArg, nil 61 | } 62 | 63 | func getPagination(c *gin.Context) (offset, limit int) { 64 | offset, err := strconv.Atoi(c.Query("offset")) 65 | if err != nil { 66 | offset = 0 67 | } 68 | limit, err = strconv.Atoi(c.Query("limit")) 69 | if err != nil { 70 | limit = 20 71 | } 72 | return offset, limit 73 | } 74 | 75 | func timeString(t time.Time) string { 76 | return t.UTC().Format(formatTime) 77 | } 78 | -------------------------------------------------------------------------------- /internal/adapter/logger/zap.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/port" 7 | "github.com/labasubagia/realworld-backend/internal/core/util" 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | const TypeZap = "zap" 13 | 14 | type zapLogger struct { 15 | config util.Config 16 | level zapcore.Level 17 | fields map[string]any 18 | } 19 | 20 | func NewZapLogger(config util.Config) port.Logger { 21 | return &zapLogger{ 22 | config: config, 23 | fields: map[string]any{}, 24 | level: zap.InfoLevel, 25 | } 26 | } 27 | 28 | func (l *zapLogger) NewInstance() port.Logger { 29 | return NewZapLogger(l.config) 30 | } 31 | 32 | func (l *zapLogger) Field(key string, value any) port.Logger { 33 | l.fields[key] = value 34 | return l 35 | } 36 | 37 | func (l *zapLogger) Logger() port.Logger { 38 | return l 39 | } 40 | 41 | func (l *zapLogger) Info() port.LogEvent { 42 | l.level = zap.InfoLevel 43 | return newZapEvent(l) 44 | } 45 | 46 | func (l *zapLogger) Error() port.LogEvent { 47 | l.level = zapcore.ErrorLevel 48 | return newZapEvent(l) 49 | } 50 | 51 | func (l *zapLogger) Fatal() port.LogEvent { 52 | l.level = zap.PanicLevel 53 | return newZapEvent(l) 54 | } 55 | 56 | type zapEvent struct { 57 | opt *zapLogger 58 | fields map[string]any 59 | } 60 | 61 | func newZapEvent(opt *zapLogger) port.LogEvent { 62 | return &zapEvent{ 63 | opt: opt, 64 | fields: map[string]any{}, 65 | } 66 | } 67 | 68 | func (e *zapEvent) Err(err error) port.LogEvent { 69 | if err != nil { 70 | return e 71 | } 72 | e.fields["error"] = err.Error() 73 | return e 74 | } 75 | 76 | func (e *zapEvent) Field(key string, value any) port.LogEvent { 77 | e.fields[key] = value 78 | return e 79 | } 80 | 81 | func (e *zapEvent) Msg(v ...any) { 82 | msg := fmt.Sprint(v...) 83 | e.send(msg) 84 | } 85 | 86 | func (e *zapEvent) Msgf(format string, v ...any) { 87 | msg := fmt.Sprintf(format, v...) 88 | e.send(msg) 89 | } 90 | 91 | func (e *zapEvent) send(msg string) { 92 | config := zap.NewProductionConfig() 93 | if !e.opt.config.IsProduction() { 94 | config = zap.NewDevelopmentConfig() 95 | } 96 | 97 | for k, v := range e.opt.fields { 98 | e.fields[k] = v 99 | } 100 | config.InitialFields = e.fields 101 | 102 | logger, _ := config.Build() 103 | defer func() { 104 | logger.Sync() 105 | e.fields = map[string]any{} 106 | }() 107 | stdLogger, _ := zap.NewStdLogAt(logger, e.opt.level) 108 | stdLogger.Println(msg) 109 | } 110 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | postgres: 5 | image: postgres:alpine 6 | restart: always 7 | environment: 8 | - POSTGRES_USER=postgres 9 | - POSTGRES_PASSWORD=postgres 10 | - POSTGRES_DB=realworld 11 | - PGDATA=/var/lib/postgresql/data/pgdata 12 | ports: 13 | - 5432:5432 14 | volumes: 15 | - postgres:/var/lib/postgresql/data 16 | healthcheck: 17 | test: ["CMD-SHELL", "pg_isready"] 18 | interval: 1s 19 | timeout: 1s 20 | retries: 10 21 | 22 | adminer: 23 | image: adminer 24 | restart: always 25 | ports: 26 | - 8081:8080 27 | depends_on: 28 | postgres: 29 | condition: service_healthy 30 | 31 | mongo: 32 | image: mongo 33 | restart: always 34 | environment: 35 | MONGO_INITDB_ROOT_USERNAME: root 36 | MONGO_INITDB_ROOT_PASSWORD: root 37 | ports: 38 | - 27017:27017 39 | volumes: 40 | - mongo:/data/db 41 | healthcheck: 42 | test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] 43 | interval: 1s 44 | timeout: 1s 45 | retries: 10 46 | 47 | # apps 48 | 49 | restful: 50 | build: 51 | context: . 52 | dockerfile: Dockerfile 53 | ports: 54 | - 5000:5000 55 | env_file: 56 | - .env.docker 57 | depends_on: 58 | postgres: 59 | condition: service_healthy 60 | command: ["/app/main", "server"] 61 | profiles: 62 | - restful_postgres 63 | 64 | restful_mongo: 65 | build: 66 | context: . 67 | dockerfile: Dockerfile 68 | ports: 69 | - 5002:5000 70 | env_file: 71 | - .env.docker 72 | depends_on: 73 | mongo: 74 | condition: service_healthy 75 | command: ["/app/main", "server", "-d", "mongo"] 76 | profiles: 77 | - restful_mongo 78 | 79 | grpc: 80 | build: 81 | context: . 82 | dockerfile: Dockerfile 83 | ports: 84 | - 5001:5000 85 | env_file: 86 | - .env.docker 87 | depends_on: 88 | postgres: 89 | condition: service_healthy 90 | command: ["/app/main", "server", "-s", "grpc"] 91 | profiles: 92 | - grpc 93 | - grpc_postgres 94 | 95 | grpc_mongo: 96 | build: 97 | context: . 98 | dockerfile: Dockerfile 99 | ports: 100 | - 5001:5000 101 | env_file: 102 | - .env.docker 103 | depends_on: 104 | postgres: 105 | condition: service_healthy 106 | command: ["/app/main", "server", "-s", "grpc", "-d", "mongo"] 107 | profiles: 108 | - grpc_mongo 109 | 110 | volumes: 111 | postgres: 112 | mongo: -------------------------------------------------------------------------------- /internal/adapter/repository/sql/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/golang-migrate/migrate/v4" 12 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 13 | _ "github.com/golang-migrate/migrate/v4/source/file" 14 | "github.com/jackc/pgx/v5" 15 | "github.com/jackc/pgx/v5/stdlib" 16 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/sql/db/migration" 17 | "github.com/labasubagia/realworld-backend/internal/core/port" 18 | "github.com/labasubagia/realworld-backend/internal/core/util" 19 | "github.com/uptrace/bun" 20 | "github.com/uptrace/bun/dialect/pgdialect" 21 | bunMigrate "github.com/uptrace/bun/migrate" 22 | ) 23 | 24 | type DB struct { 25 | config util.Config 26 | logger port.Logger 27 | db *bun.DB 28 | } 29 | 30 | func New(config util.Config, logger port.Logger) (*DB, error) { 31 | var err error 32 | database := &DB{ 33 | config: config, 34 | logger: logger, 35 | } 36 | if database.db, err = database.connect(); err != nil { 37 | return nil, err 38 | } 39 | if err := database.migrate(); err != nil { 40 | return nil, err 41 | } 42 | return database, nil 43 | } 44 | 45 | func (db *DB) DB() *bun.DB { 46 | return db.db 47 | } 48 | 49 | func (db *DB) connect() (*bun.DB, error) { 50 | config, err := pgx.ParseConfig(db.config.PostgresSource) 51 | if err != nil { 52 | return nil, err 53 | } 54 | sqlDB := stdlib.OpenDB(*config) 55 | database := bun.NewDB(sqlDB, pgdialect.New()) 56 | 57 | // log 58 | if !db.config.IsProduction() { 59 | // database.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) 60 | database.AddQueryHook(&LoggerHook{verbose: true, logger: db.logger}) 61 | } 62 | return database, nil 63 | } 64 | 65 | func (db *DB) migrate() error { 66 | _, currentFile, _, ok := runtime.Caller(0) 67 | if !ok { 68 | return errors.New("unable to get the current current file") 69 | } 70 | currentDir := filepath.Dir(currentFile) 71 | migrationURL := fmt.Sprintf("file://%s", path.Join(currentDir, "migration")) 72 | 73 | migration, err := migrate.New(migrationURL, db.config.PostgresSource) 74 | if err != nil { 75 | return fmt.Errorf("cannot create new migration instance: %s", err) 76 | } 77 | if err := migration.Up(); err != nil && err != migrate.ErrNoChange { 78 | return fmt.Errorf("failed to run migrate: %s", err) 79 | } 80 | return nil 81 | } 82 | 83 | func (db *DB) migrateBun() error { 84 | ctx := context.Background() 85 | migrator := bunMigrate.NewMigrator(db.db, migration.Migrations) 86 | 87 | if err := migrator.Init(ctx); err != nil { 88 | return err 89 | } 90 | if err := migrator.Lock(ctx); err != nil { 91 | return err 92 | } 93 | defer migrator.Unlock(ctx) 94 | 95 | group, err := migrator.Migrate(ctx) 96 | if err != nil { 97 | return err 98 | } 99 | if group.IsZero() { 100 | return nil 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/core/domain/user.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/util" 7 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 8 | ) 9 | 10 | const UserDefaultImage string = "https://api.realworld.io/images/demo-avatar.png" 11 | 12 | type User struct { 13 | ID ID 14 | Email string 15 | Username string 16 | Password string 17 | Image string 18 | Bio string 19 | CreatedAt time.Time 20 | UpdatedAt time.Time 21 | IsFollowed bool 22 | Token string 23 | } 24 | 25 | func (user *User) SetEmail(email string) error { 26 | if err := util.ValidateEmail(email); err != nil { 27 | return err 28 | } 29 | user.Email = email 30 | return nil 31 | } 32 | 33 | func (user *User) SetPassword(password string) error { 34 | hashedPassword, err := util.HashPassword(password) 35 | if err != nil { 36 | return err 37 | } 38 | user.Password = hashedPassword 39 | return nil 40 | } 41 | 42 | func (user *User) SetUsername(username string) error { 43 | if err := util.ValidateUsername(username); err != nil { 44 | return err 45 | } 46 | user.Username = username 47 | return nil 48 | } 49 | 50 | func (user *User) SetImageURL(url string) error { 51 | if err := util.ValidateURL(url); err != nil { 52 | return err 53 | } 54 | user.Image = url 55 | return nil 56 | } 57 | 58 | func NewUser(arg User) (User, error) { 59 | validator := exception.Validation() 60 | now := time.Now() 61 | 62 | user := User{ 63 | ID: NewID(), 64 | Bio: arg.Bio, 65 | CreatedAt: now, 66 | UpdatedAt: now, 67 | } 68 | 69 | if err := user.SetEmail(arg.Email); err != nil { 70 | validator.AddError("email", err.Error()) 71 | } 72 | if err := user.SetUsername(arg.Username); err != nil { 73 | validator.AddError("username", err.Error()) 74 | } 75 | image := UserDefaultImage 76 | if arg.Image != "" { 77 | image = arg.Image 78 | } 79 | if err := user.SetImageURL(image); err != nil { 80 | validator.AddError("image", err.Error()) 81 | } 82 | if err := user.SetPassword(arg.Password); err != nil { 83 | validator.AddError("password", err.Error()) 84 | } 85 | 86 | if validator.HasError() { 87 | return user, validator 88 | } 89 | 90 | return user, nil 91 | } 92 | 93 | func RandomUser() User { 94 | now := time.Now() 95 | return User{ 96 | Email: util.RandomEmail(), 97 | Username: util.RandomUsername(), 98 | Password: util.RandomString(8), 99 | CreatedAt: now, 100 | UpdatedAt: now, 101 | } 102 | } 103 | 104 | type UserFollow struct { 105 | FollowerID ID 106 | FolloweeID ID 107 | } 108 | 109 | func NewUserFollow(arg UserFollow) (UserFollow, error) { 110 | if arg.FollowerID == arg.FolloweeID { 111 | return UserFollow{}, exception.Validation().AddError("exception", "cannot follow yourself") 112 | } 113 | follow := UserFollow{ 114 | FollowerID: arg.FollowerID, 115 | FolloweeID: arg.FolloweeID, 116 | } 117 | return follow, nil 118 | } 119 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/serializer.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import "github.com/labasubagia/realworld-backend/internal/core/domain" 4 | 5 | type Profile struct { 6 | Username string `json:"username"` 7 | Bio string `json:"bio"` 8 | Image string `json:"image"` 9 | Following bool `json:"following"` 10 | } 11 | 12 | type ProfileResponse struct { 13 | Profile Profile `json:"profile"` 14 | } 15 | 16 | func serializeProfile(arg domain.User) Profile { 17 | return Profile{ 18 | Username: arg.Username, 19 | Image: arg.Image, 20 | Bio: arg.Bio, 21 | Following: arg.IsFollowed, 22 | } 23 | } 24 | 25 | type User struct { 26 | Email string `json:"email"` 27 | Username string `json:"username"` 28 | Bio string `json:"bio"` 29 | Image string `json:"image"` 30 | Token string `json:"token"` 31 | } 32 | 33 | type UserResponse struct { 34 | User User `json:"user"` 35 | } 36 | 37 | type UsersResponse struct { 38 | Users []User `json:"users"` 39 | } 40 | 41 | func serializeUser(arg domain.User) User { 42 | return User{ 43 | Email: arg.Email, 44 | Username: arg.Username, 45 | Bio: arg.Bio, 46 | Image: arg.Image, 47 | Token: arg.Token, 48 | } 49 | } 50 | 51 | type Article struct { 52 | Slug string `json:"slug"` 53 | Title string `json:"title"` 54 | Description string `json:"description"` 55 | Body string `json:"body"` 56 | TagList []string `json:"tagList"` 57 | CreatedAt string `json:"createdAt"` 58 | UpdatedAt string `json:"updatedAt"` 59 | Favorited bool `json:"favorited"` 60 | FavoritesCount int `json:"favoritesCount"` 61 | Author Profile `json:"author"` 62 | } 63 | 64 | type ArticleResponse struct { 65 | Article Article `json:"article"` 66 | } 67 | 68 | type ArticlesResponse struct { 69 | Articles []Article `json:"articles"` 70 | Count int `json:"articlesCount"` 71 | } 72 | 73 | func serializeArticle(arg domain.Article) Article { 74 | tags := []string{} 75 | if len(arg.TagNames) > 0 { 76 | tags = arg.TagNames 77 | } 78 | return Article{ 79 | Slug: arg.Slug, 80 | Title: arg.Title, 81 | Description: arg.Description, 82 | Body: arg.Body, 83 | TagList: tags, 84 | Favorited: arg.IsFavorite, 85 | FavoritesCount: arg.FavoriteCount, 86 | Author: serializeProfile(arg.Author), 87 | CreatedAt: timeString(arg.CreatedAt), 88 | UpdatedAt: timeString(arg.UpdatedAt), 89 | } 90 | } 91 | 92 | type Comment struct { 93 | ID domain.ID `json:"id"` 94 | CreatedAt string `json:"createdAt"` 95 | UpdatedAt string `json:"updatedAt"` 96 | Body string `json:"body"` 97 | Author Profile `json:"author"` 98 | } 99 | 100 | type CommentResponse struct { 101 | Comment Comment `json:"comment"` 102 | } 103 | 104 | type CommentsResponse struct { 105 | Comments []Comment `json:"comments"` 106 | } 107 | 108 | func serializeComment(arg domain.Comment) Comment { 109 | return Comment{ 110 | ID: arg.ID, 111 | Body: arg.Body, 112 | Author: serializeProfile(arg.Author), 113 | CreatedAt: timeString(arg.CreatedAt), 114 | UpdatedAt: timeString(arg.UpdatedAt), 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/db.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/core/port" 8 | "github.com/labasubagia/realworld-backend/internal/core/util" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/event" 11 | "go.mongodb.org/mongo-driver/mongo" 12 | "go.mongodb.org/mongo-driver/mongo/options" 13 | ) 14 | 15 | const ( 16 | DBName = "realworld" 17 | CollectionUser = "users" 18 | CollectionUserFollow = "user_follows" 19 | CollectionTag = "tags" 20 | CollectionArticle = "articles" 21 | CollectionComment = "comments" 22 | CollectionArticleTag = "article_tags" 23 | CollectionArticleFavorite = "article_favorites" 24 | ) 25 | 26 | type DB struct { 27 | client *mongo.Client 28 | logger port.Logger 29 | } 30 | 31 | func NewDB(config util.Config, logger port.Logger) (DB, error) { 32 | 33 | ctx := context.Background() 34 | clientOpts := options.Client().ApplyURI(config.MongoSource) 35 | if !config.IsProduction() { 36 | cmdMonitor := &event.CommandMonitor{ 37 | Started: func(ctx context.Context, event *event.CommandStartedEvent) { 38 | var query any 39 | json.Unmarshal([]byte(event.Command.String()), &query) 40 | subLogger := port.GetCtxSubLogger(ctx, logger) 41 | subLogger.Info().Field("query", query).Msgf("mongo %s", event.CommandName) 42 | }, 43 | } 44 | clientOpts.SetMonitor(cmdMonitor) 45 | } 46 | client, err := mongo.Connect(ctx, clientOpts) 47 | if err != nil { 48 | return DB{}, err 49 | } 50 | db := DB{ 51 | client: client, 52 | logger: logger, 53 | } 54 | if err := db.migrate(); err != nil { 55 | return DB{}, err 56 | } 57 | return db, nil 58 | } 59 | 60 | func (db *DB) Client() *mongo.Client { 61 | return db.client 62 | } 63 | 64 | func (db *DB) Collection(name string) *mongo.Collection { 65 | return db.Client().Database(DBName).Collection(name) 66 | } 67 | 68 | func (db *DB) migrate() error { 69 | ctx := context.Background() 70 | 71 | // user index 72 | _, err := db.Collection(CollectionUser).Indexes().CreateMany(ctx, []mongo.IndexModel{ 73 | {Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true)}, 74 | {Keys: bson.D{{Key: "username", Value: 1}}, Options: options.Index().SetUnique(true)}, 75 | }) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // user follow index 81 | _, err = db.Collection(CollectionUserFollow).Indexes().CreateOne(ctx, mongo.IndexModel{ 82 | Keys: bson.D{{Key: "follower_id", Value: 1}, {Key: "followee_id", Value: 1}}, 83 | Options: options.Index().SetUnique(true), 84 | }) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // tag index 90 | _, err = db.Collection(CollectionTag).Indexes().CreateOne(ctx, mongo.IndexModel{ 91 | Keys: bson.D{{Key: "name", Value: 1}}, 92 | Options: options.Index().SetUnique(true), 93 | }) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | // article tag index 99 | _, err = db.Collection(CollectionArticleTag).Indexes().CreateOne(ctx, mongo.IndexModel{ 100 | Keys: bson.D{{Key: "article_id", Value: 1}, {Key: "tag_id", Value: 1}}, 101 | Options: options.Index().SetUnique(true), 102 | }) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // article favorite index 108 | _, err = db.Collection(CollectionArticleFavorite).Indexes().CreateOne(ctx, mongo.IndexModel{ 109 | Keys: bson.D{{Key: "article_id", Value: 1}, {Key: "user_id", Value: 1}}, 110 | Options: options.Index().SetUnique(true), 111 | }) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/server.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/gin-contrib/cors" 13 | "github.com/gin-gonic/gin" 14 | "github.com/labasubagia/realworld-backend/internal/core/port" 15 | "github.com/labasubagia/realworld-backend/internal/core/util" 16 | ) 17 | 18 | const TypeRestful = "restful" 19 | 20 | type Server struct { 21 | config util.Config 22 | router *gin.Engine 23 | service port.Service 24 | logger port.Logger 25 | } 26 | 27 | func NewServer(config util.Config, service port.Service, logger port.Logger) port.Server { 28 | server := &Server{ 29 | config: config, 30 | service: service, 31 | logger: logger, 32 | } 33 | server.setupRouter() 34 | return server 35 | } 36 | 37 | func (server *Server) setupRouter() { 38 | 39 | gin.SetMode(gin.ReleaseMode) 40 | router := gin.New() 41 | 42 | router.Use(server.Logger(), gin.Recovery(), cors.Default()) 43 | 44 | router.NoRoute(func(ctx *gin.Context) { 45 | ctx.JSON(http.StatusNotFound, gin.H{"message": "page not found"}) 46 | }) 47 | router.NoMethod(func(ctx *gin.Context) { 48 | ctx.JSON(http.StatusMethodNotAllowed, gin.H{"message": "no method provided"}) 49 | }) 50 | 51 | router.GET("/", func(ctx *gin.Context) { 52 | ctx.JSON(http.StatusOK, gin.H{"message": "Hello World!"}) 53 | }) 54 | router.POST("/users", server.Register) 55 | router.POST("/users/login", server.Login) 56 | 57 | userRouter := router.Group("/user") 58 | userRouter.Use(server.AuthMiddleware(true)) 59 | userRouter.GET("/", server.CurrentUser) 60 | userRouter.PUT("/", server.UpdateUser) 61 | 62 | profileRouter := router.Group("/profiles/:username") 63 | profileRouter.Use(server.AuthMiddleware(false)) 64 | profileRouter.GET("/", server.Profile) 65 | profileRouter.POST("/follow", server.FollowUser) 66 | profileRouter.DELETE("/follow", server.UnFollowUser) 67 | 68 | articleRouter := router.Group("/articles") 69 | articleRouter.Use(server.AuthMiddleware(false)) 70 | articleRouter.GET("/", server.ListArticle) 71 | articleRouter.GET("/feed", server.FeedArticle) 72 | articleRouter.GET("/:slug", server.GetArticle) 73 | articleRouter.POST("/", server.CreateArticle) 74 | articleRouter.PUT("/:slug", server.UpdateArticle) 75 | articleRouter.DELETE("/:slug", server.DeleteArticle) 76 | 77 | commentRouter := articleRouter.Group("/:slug/comments") 78 | commentRouter.POST("/", server.AddComment) 79 | commentRouter.GET("/", server.ListComments) 80 | commentRouter.DELETE("/:comment_id", server.DeleteComment) 81 | 82 | favoriteArticleRouter := articleRouter.Group("/:slug/favorite") 83 | favoriteArticleRouter.POST("/", server.AddFavoriteArticle) 84 | favoriteArticleRouter.DELETE("/", server.RemoveFavoriteArticle) 85 | 86 | tagRouter := router.Group("/tags") 87 | tagRouter.GET("/", server.ListTags) 88 | 89 | server.router = router 90 | } 91 | 92 | func (server *Server) Start() error { 93 | srv := &http.Server{ 94 | Addr: fmt.Sprintf(":%d", server.config.ServerPort), 95 | Handler: server.router, 96 | } 97 | 98 | go func() { 99 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 100 | server.logger.Fatal().Err(err).Msg("failed listen") 101 | } 102 | }() 103 | 104 | quit := make(chan os.Signal) 105 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 106 | <-quit 107 | server.logger.Info().Msg("shutdown server...") 108 | 109 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 110 | defer cancel() 111 | 112 | if err := srv.Shutdown(ctx); err != nil { 113 | server.logger.Fatal().Err(err).Msg("failed shutdown server") 114 | } 115 | 116 | select { 117 | case <-ctx.Done(): 118 | server.logger.Info().Msg("timeout in 5 seconds") 119 | } 120 | server.logger.Info().Msg("server exiting") 121 | 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/labasubagia/realworld-backend 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.9.1 7 | github.com/jackc/pgx/v5 v5.4.3 8 | github.com/oklog/ulid/v2 v2.1.0 9 | github.com/stretchr/testify v1.8.4 10 | github.com/uptrace/bun v1.1.14 11 | github.com/uptrace/bun/dialect/pgdialect v1.1.14 12 | go.mongodb.org/mongo-driver v1.7.5 13 | go.uber.org/zap v1.25.0 14 | golang.org/x/crypto v0.13.0 15 | google.golang.org/grpc v1.55.0 16 | google.golang.org/protobuf v1.31.0 17 | ) 18 | 19 | require ( 20 | github.com/bytedance/sonic v1.10.0 // indirect 21 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 22 | github.com/chenzhuoyu/iasm v0.9.0 // indirect 23 | github.com/fsnotify/fsnotify v1.6.0 // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 25 | github.com/gin-contrib/sse v0.1.0 // indirect 26 | github.com/go-playground/locales v0.14.1 // indirect 27 | github.com/go-playground/universal-translator v0.18.1 // indirect 28 | github.com/go-playground/validator/v10 v10.15.3 // indirect 29 | github.com/go-stack/stack v1.8.0 // indirect 30 | github.com/goccy/go-json v0.10.2 // indirect 31 | github.com/golang/protobuf v1.5.3 // indirect 32 | github.com/golang/snappy v0.0.4 // indirect 33 | github.com/hashicorp/errwrap v1.1.0 // indirect 34 | github.com/hashicorp/go-multierror v1.1.1 // indirect 35 | github.com/hashicorp/hcl v1.0.0 // indirect 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 | github.com/json-iterator/go v1.1.12 // indirect 38 | github.com/klauspost/compress v1.15.11 // indirect 39 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 40 | github.com/leodido/go-urn v1.2.4 // indirect 41 | github.com/lib/pq v1.10.2 // indirect 42 | github.com/magiconair/properties v1.8.7 // indirect 43 | github.com/mattn/go-colorable v0.1.13 // indirect 44 | github.com/mattn/go-isatty v0.0.19 // indirect 45 | github.com/mitchellh/mapstructure v1.5.0 // indirect 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 47 | github.com/modern-go/reflect2 v1.0.2 // indirect 48 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 49 | github.com/pkg/errors v0.9.1 // indirect 50 | github.com/spf13/afero v1.9.5 // indirect 51 | github.com/spf13/cast v1.5.1 // indirect 52 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 53 | github.com/spf13/pflag v1.0.5 // indirect 54 | github.com/subosito/gotenv v1.4.2 // indirect 55 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 56 | github.com/ugorji/go/codec v1.2.11 // indirect 57 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 58 | github.com/xdg-go/scram v1.1.1 // indirect 59 | github.com/xdg-go/stringprep v1.0.3 // indirect 60 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 61 | go.uber.org/atomic v1.9.0 // indirect 62 | go.uber.org/multierr v1.11.0 // indirect 63 | golang.org/x/arch v0.5.0 // indirect 64 | golang.org/x/net v0.15.0 // indirect 65 | golang.org/x/sync v0.2.0 // indirect 66 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 67 | gopkg.in/ini.v1 v1.67.0 // indirect 68 | ) 69 | 70 | require ( 71 | github.com/davecgh/go-spew v1.1.1 // indirect 72 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 73 | github.com/gin-contrib/cors v1.4.0 74 | github.com/golang-migrate/migrate/v4 v4.16.2 75 | github.com/google/uuid v1.3.1 76 | github.com/jackc/pgpassfile v1.0.0 // indirect 77 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 78 | github.com/jinzhu/inflection v1.0.0 // indirect 79 | github.com/pmezard/go-difflib v1.0.0 // indirect 80 | github.com/rogpeppe/go-internal v1.11.0 // indirect 81 | github.com/rs/zerolog v1.30.0 82 | github.com/spf13/cobra v1.7.0 83 | github.com/spf13/viper v1.16.0 84 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect 85 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 86 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 87 | golang.org/x/sys v0.12.0 // indirect 88 | golang.org/x/text v0.13.0 // indirect 89 | gopkg.in/yaml.v3 v3.0.1 // indirect 90 | ) 91 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/model/article.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | ) 8 | 9 | type Article struct { 10 | ID domain.ID `bson:"id"` 11 | AuthorID domain.ID `bson:"author_id"` 12 | Title string `bson:"title"` 13 | Slug string `bson:"slug"` 14 | Description string `bson:"description"` 15 | Body string `bson:"body"` 16 | CreatedAt time.Time `bson:"created_at"` 17 | UpdatedAt time.Time `bson:"updated_at"` 18 | } 19 | 20 | func (data Article) ToDomain() domain.Article { 21 | return domain.Article{ 22 | ID: data.ID, 23 | AuthorID: data.AuthorID, 24 | Title: data.Title, 25 | Slug: data.Slug, 26 | Description: data.Description, 27 | Body: data.Body, 28 | CreatedAt: data.CreatedAt.UTC(), 29 | UpdatedAt: data.UpdatedAt.UTC(), 30 | } 31 | } 32 | 33 | func AsArticle(arg domain.Article) Article { 34 | return Article{ 35 | ID: arg.ID, 36 | AuthorID: arg.AuthorID, 37 | Title: arg.Title, 38 | Slug: arg.Slug, 39 | Description: arg.Description, 40 | Body: arg.Body, 41 | CreatedAt: arg.CreatedAt.UTC(), 42 | UpdatedAt: arg.UpdatedAt.UTC(), 43 | } 44 | } 45 | 46 | type Tag struct { 47 | ID domain.ID `bson:"id"` 48 | Name string `bson:"name"` 49 | } 50 | 51 | func (data Tag) ToDomain() domain.Tag { 52 | return domain.Tag{ 53 | ID: data.ID, 54 | Name: data.Name, 55 | } 56 | } 57 | 58 | func AsTag(arg domain.Tag) Tag { 59 | return Tag{ 60 | ID: arg.ID, 61 | Name: arg.Name, 62 | } 63 | } 64 | 65 | type ArticleTag struct { 66 | ArticleID domain.ID `bson:"article_id"` 67 | TagID domain.ID `bson:"tag_id"` 68 | } 69 | 70 | func (data ArticleTag) ToDomain() domain.ArticleTag { 71 | return domain.ArticleTag{ 72 | ArticleID: data.ArticleID, 73 | TagID: data.TagID, 74 | } 75 | } 76 | 77 | func AsArticleTag(arg domain.ArticleTag) ArticleTag { 78 | return ArticleTag{ 79 | ArticleID: arg.ArticleID, 80 | TagID: arg.TagID, 81 | } 82 | } 83 | 84 | type Comment struct { 85 | ID domain.ID `bson:"id"` 86 | ArticleID domain.ID `bson:"article_id"` 87 | AuthorID domain.ID `bson:"author_id"` 88 | Body string `bson:"body"` 89 | CreatedAt time.Time `bson:"created_at"` 90 | UpdatedAt time.Time `bson:"updated_at"` 91 | } 92 | 93 | func (data Comment) ToDomain() domain.Comment { 94 | return domain.Comment{ 95 | ID: data.ID, 96 | ArticleID: data.ArticleID, 97 | AuthorID: data.AuthorID, 98 | Body: data.Body, 99 | CreatedAt: data.CreatedAt.UTC(), 100 | UpdatedAt: data.UpdatedAt.UTC(), 101 | } 102 | } 103 | 104 | func AsComment(arg domain.Comment) Comment { 105 | return Comment{ 106 | ID: arg.ID, 107 | ArticleID: arg.ArticleID, 108 | AuthorID: arg.AuthorID, 109 | Body: arg.Body, 110 | CreatedAt: arg.CreatedAt.UTC(), 111 | UpdatedAt: arg.UpdatedAt.UTC(), 112 | } 113 | } 114 | 115 | type ArticleFavorite struct { 116 | ArticleID domain.ID `bson:"article_id"` 117 | UserID domain.ID `bson:"user_id"` 118 | } 119 | 120 | func (data ArticleFavorite) ToDomain() domain.ArticleFavorite { 121 | return domain.ArticleFavorite{ 122 | ArticleID: data.ArticleID, 123 | UserID: data.UserID, 124 | } 125 | } 126 | 127 | func AsArticleFavorite(arg domain.ArticleFavorite) ArticleFavorite { 128 | return ArticleFavorite{ 129 | ArticleID: arg.ArticleID, 130 | UserID: arg.UserID, 131 | } 132 | } 133 | 134 | type ArticleFavoriteCount struct { 135 | ArticleID domain.ID `bson:"article_id"` 136 | Count int `bson:"favorite_count"` 137 | } 138 | 139 | func (data ArticleFavoriteCount) ToDomain() domain.ArticleFavoriteCount { 140 | return domain.ArticleFavoriteCount{ 141 | ArticleID: data.ArticleID, 142 | Count: data.Count, 143 | } 144 | } 145 | 146 | func AsArticleFavoriteCount(arg domain.ArticleFavoriteCount) ArticleFavoriteCount { 147 | return ArticleFavoriteCount{ 148 | ArticleID: arg.ArticleID, 149 | Count: arg.Count, 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "google.golang.org/protobuf/types/known/emptypb" 10 | ) 11 | 12 | func (server *Server) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.UserResponse, error) { 13 | user, err := server.service.User().Register(ctx, port.RegisterParams{ 14 | User: domain.User{ 15 | Email: req.GetUser().GetEmail(), 16 | Username: req.GetUser().GetUsername(), 17 | Password: req.GetUser().GetPassword(), 18 | }, 19 | }) 20 | if err != nil { 21 | return nil, handleError(err) 22 | } 23 | res := &pb.UserResponse{ 24 | User: serializeUser(user), 25 | } 26 | return res, nil 27 | } 28 | 29 | func (server *Server) LoginUser(ctx context.Context, req *pb.LoginUserRequest) (*pb.UserResponse, error) { 30 | user, err := server.service.User().Login(ctx, port.LoginParams{ 31 | User: domain.User{ 32 | Email: req.GetUser().GetEmail(), 33 | Password: req.GetUser().GetPassword(), 34 | }, 35 | }) 36 | if err != nil { 37 | return nil, handleError(err) 38 | } 39 | res := &pb.UserResponse{ 40 | User: serializeUser(user), 41 | } 42 | return res, nil 43 | } 44 | 45 | func (server *Server) CurrentUser(ctx context.Context, _ *emptypb.Empty) (*pb.UserResponse, error) { 46 | auth, err := server.authorizeUser(ctx) 47 | if err != nil { 48 | return nil, handleError(err) 49 | } 50 | user, err := server.service.User().Current(ctx, auth) 51 | if err != nil { 52 | return nil, handleError(err) 53 | } 54 | res := &pb.UserResponse{ 55 | User: serializeUser(user), 56 | } 57 | return res, nil 58 | } 59 | 60 | func (server *Server) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UserResponse, error) { 61 | auth, err := server.authorizeUser(ctx) 62 | if err != nil { 63 | return nil, handleError(err) 64 | } 65 | user, err := server.service.User().Update(ctx, port.UpdateUserParams{ 66 | AuthArg: auth, 67 | User: domain.User{ 68 | ID: auth.Payload.UserID, 69 | Email: req.GetUser().GetEmail(), 70 | Username: req.GetUser().GetUsername(), 71 | Password: req.GetUser().GetPassword(), 72 | Image: req.GetUser().GetImage(), 73 | Bio: req.GetUser().GetBio(), 74 | }, 75 | }) 76 | if err != nil { 77 | return nil, handleError(err) 78 | } 79 | res := &pb.UserResponse{ 80 | User: serializeUser(user), 81 | } 82 | return res, nil 83 | } 84 | 85 | func (server *Server) GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.ProfileResponse, error) { 86 | auth, _ := server.authorizeUser(ctx) 87 | user, err := server.service.User().Profile(ctx, port.ProfileParams{ 88 | Username: req.GetUsername(), 89 | AuthArg: auth, 90 | }) 91 | if err != nil { 92 | return nil, handleError(err) 93 | } 94 | res := &pb.ProfileResponse{ 95 | Profile: serializeProfile(user), 96 | } 97 | return res, nil 98 | } 99 | 100 | func (server *Server) FollowUser(ctx context.Context, req *pb.GetProfileRequest) (*pb.ProfileResponse, error) { 101 | auth, err := server.authorizeUser(ctx) 102 | if err != nil { 103 | return nil, handleError(err) 104 | } 105 | user, err := server.service.User().Follow(ctx, port.ProfileParams{ 106 | Username: req.GetUsername(), 107 | AuthArg: auth, 108 | }) 109 | if err != nil { 110 | return nil, handleError(err) 111 | } 112 | res := &pb.ProfileResponse{ 113 | Profile: serializeProfile(user), 114 | } 115 | return res, nil 116 | } 117 | 118 | func (server *Server) UnFollowUser(ctx context.Context, req *pb.GetProfileRequest) (*pb.ProfileResponse, error) { 119 | auth, err := server.authorizeUser(ctx) 120 | if err != nil { 121 | return nil, handleError(err) 122 | } 123 | user, err := server.service.User().UnFollow(ctx, port.ProfileParams{ 124 | Username: req.GetUsername(), 125 | AuthArg: auth, 126 | }) 127 | if err != nil { 128 | return nil, handleError(err) 129 | } 130 | res := &pb.ProfileResponse{ 131 | Profile: serializeProfile(user), 132 | } 133 | return res, nil 134 | } 135 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/user.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/sql/model" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 10 | "github.com/uptrace/bun" 11 | ) 12 | 13 | type userRepo struct { 14 | db bun.IDB 15 | } 16 | 17 | func NewUserRepository(db bun.IDB) port.UserRepository { 18 | return &userRepo{ 19 | db: db, 20 | } 21 | } 22 | 23 | func (r *userRepo) CreateUser(ctx context.Context, arg domain.User) (domain.User, error) { 24 | user := model.AsUser(arg) 25 | _, err := r.db.NewInsert().Model(&user).Exec(ctx) 26 | if err != nil { 27 | return domain.User{}, intoException(err) 28 | } 29 | return user.ToDomain(), nil 30 | } 31 | 32 | func (r *userRepo) UpdateUser(ctx context.Context, arg domain.User) (domain.User, error) { 33 | 34 | // find current 35 | current, err := r.FindOne(ctx, port.FilterUserPayload{IDs: []domain.ID{arg.ID}}) 36 | if err != nil { 37 | return domain.User{}, intoException(err) 38 | } 39 | 40 | // omit same 41 | if current.Email == arg.Email { 42 | arg.Email = "" 43 | } 44 | if current.Username == arg.Username { 45 | arg.Username = "" 46 | } 47 | 48 | // update 49 | req := model.AsUser(arg) 50 | _, err = r.db.NewUpdate().Model(&req).OmitZero().Where("id = ?", req.ID).Exec(ctx) 51 | if err != nil { 52 | return domain.User{}, intoException(err) 53 | } 54 | 55 | // find updated 56 | updated, err := r.FindOne(ctx, port.FilterUserPayload{IDs: []domain.ID{req.ID}}) 57 | if err != nil { 58 | return domain.User{}, intoException(err) 59 | } 60 | 61 | return updated, nil 62 | } 63 | 64 | func (r *userRepo) FilterUser(ctx context.Context, filter port.FilterUserPayload) ([]domain.User, error) { 65 | users := []model.User{} 66 | query := r.db.NewSelect().Model(&users) 67 | if len(filter.IDs) > 0 { 68 | query = query.Where("id IN (?)", bun.In(filter.IDs)) 69 | } 70 | if len(filter.Emails) > 0 { 71 | query = query.Where("email IN (?)", bun.In(filter.Emails)) 72 | } 73 | if len(filter.Usernames) > 0 { 74 | query = query.Where("username IN (?)", bun.In(filter.Usernames)) 75 | } 76 | err := query.Scan(ctx) 77 | if err != nil { 78 | return []domain.User{}, intoException(err) 79 | } 80 | result := []domain.User{} 81 | for _, user := range users { 82 | result = append(result, user.ToDomain()) 83 | } 84 | return result, nil 85 | } 86 | 87 | func (r *userRepo) FindOne(ctx context.Context, filter port.FilterUserPayload) (domain.User, error) { 88 | users, err := r.FilterUser(ctx, filter) 89 | if err != nil { 90 | return domain.User{}, intoException(err) 91 | } 92 | if len(users) == 0 { 93 | return domain.User{}, exception.New(exception.TypeNotFound, "user not found", nil) 94 | } 95 | return users[0], nil 96 | } 97 | 98 | func (r *userRepo) FilterFollow(ctx context.Context, filter port.FilterUserFollowPayload) ([]domain.UserFollow, error) { 99 | follows := []model.UserFollow{} 100 | query := r.db.NewSelect().Model(&follows) 101 | if len(filter.FollowerIDs) > 0 { 102 | query = query.Where("follower_id IN (?)", bun.In(filter.FollowerIDs)) 103 | } 104 | if len(filter.FolloweeIDs) > 0 { 105 | query = query.Where("followee_id IN (?)", bun.In(filter.FolloweeIDs)) 106 | } 107 | err := query.Scan(ctx) 108 | if err != nil { 109 | return []domain.UserFollow{}, intoException(err) 110 | } 111 | result := []domain.UserFollow{} 112 | for _, follow := range follows { 113 | result = append(result, follow.ToDomain()) 114 | } 115 | return result, nil 116 | } 117 | 118 | func (r *userRepo) Follow(ctx context.Context, arg domain.UserFollow) (domain.UserFollow, error) { 119 | req := model.AsUserFollow(arg) 120 | _, err := r.db.NewInsert().Model(&req).Exec(ctx) 121 | if err != nil { 122 | return domain.UserFollow{}, exception.Into(err) 123 | } 124 | return req.ToDomain(), nil 125 | } 126 | 127 | func (r *userRepo) UnFollow(ctx context.Context, arg domain.UserFollow) (domain.UserFollow, error) { 128 | req := model.AsUserFollow(arg) 129 | _, err := r.db. 130 | NewDelete(). 131 | Model(&req). 132 | Where("follower_id = ?", req.FollowerID). 133 | Where("followee_id = ?", req.FolloweeID). 134 | Exec(ctx) 135 | if err != nil { 136 | return domain.UserFollow{}, exception.Into(err) 137 | } 138 | return req.ToDomain(), nil 139 | } 140 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/model/article.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/core/domain" 7 | "github.com/uptrace/bun" 8 | ) 9 | 10 | type Article struct { 11 | bun.BaseModel `bun:"table:articles,alias:a"` 12 | ID domain.ID `bun:"id,pk"` 13 | AuthorID domain.ID `bun:"author_id,notnull"` 14 | Title string `bun:"title,notnull"` 15 | Slug string `bun:"slug,notnull"` 16 | Description string `bun:"description,notnull"` 17 | Body string `bun:"body,notnull"` 18 | CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 19 | UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 20 | } 21 | 22 | func (data Article) ToDomain() domain.Article { 23 | return domain.Article{ 24 | ID: data.ID, 25 | AuthorID: data.AuthorID, 26 | Title: data.Title, 27 | Slug: data.Slug, 28 | Description: data.Description, 29 | Body: data.Body, 30 | CreatedAt: data.CreatedAt, 31 | UpdatedAt: data.UpdatedAt, 32 | } 33 | } 34 | 35 | func AsArticle(arg domain.Article) Article { 36 | return Article{ 37 | ID: arg.ID, 38 | AuthorID: arg.AuthorID, 39 | Title: arg.Title, 40 | Slug: arg.Slug, 41 | Description: arg.Description, 42 | Body: arg.Body, 43 | CreatedAt: arg.CreatedAt, 44 | UpdatedAt: arg.UpdatedAt, 45 | } 46 | } 47 | 48 | type Tag struct { 49 | bun.BaseModel `bun:"table:tags,alias:t"` 50 | ID domain.ID `bun:"id,pk"` 51 | Name string `bun:"name,notnull"` 52 | } 53 | 54 | func (data Tag) ToDomain() domain.Tag { 55 | return domain.Tag{ 56 | ID: data.ID, 57 | Name: data.Name, 58 | } 59 | } 60 | 61 | func AsTag(arg domain.Tag) Tag { 62 | return Tag{ 63 | ID: arg.ID, 64 | Name: arg.Name, 65 | } 66 | } 67 | 68 | type ArticleTag struct { 69 | bun.BaseModel `bun:"table:article_tags,alias:at"` 70 | ArticleID domain.ID `bun:"article_id,notnull"` 71 | TagID domain.ID `bun:"tag_id,notnull"` 72 | } 73 | 74 | func (data ArticleTag) ToDomain() domain.ArticleTag { 75 | return domain.ArticleTag{ 76 | ArticleID: data.ArticleID, 77 | TagID: data.TagID, 78 | } 79 | } 80 | 81 | func AsArticleTag(arg domain.ArticleTag) ArticleTag { 82 | return ArticleTag{ 83 | ArticleID: arg.ArticleID, 84 | TagID: arg.TagID, 85 | } 86 | } 87 | 88 | type Comment struct { 89 | bun.BaseModel `bun:"table:comments,alias:c"` 90 | ID domain.ID `bun:"id,pk"` 91 | ArticleID domain.ID `bun:"article_id,notnull"` 92 | AuthorID domain.ID `bun:"author_id,notnull"` 93 | Body string `bun:"body,notnull"` 94 | CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 95 | UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 96 | } 97 | 98 | func (data Comment) ToDomain() domain.Comment { 99 | return domain.Comment{ 100 | ID: data.ID, 101 | ArticleID: data.ArticleID, 102 | AuthorID: data.AuthorID, 103 | Body: data.Body, 104 | CreatedAt: data.CreatedAt, 105 | UpdatedAt: data.UpdatedAt, 106 | } 107 | } 108 | 109 | func AsComment(arg domain.Comment) Comment { 110 | return Comment{ 111 | ID: arg.ID, 112 | ArticleID: arg.ArticleID, 113 | AuthorID: arg.AuthorID, 114 | Body: arg.Body, 115 | CreatedAt: arg.CreatedAt, 116 | UpdatedAt: arg.UpdatedAt, 117 | } 118 | } 119 | 120 | type ArticleFavorite struct { 121 | bun.BaseModel `bun:"table:article_favorites,alias:af"` 122 | ArticleID domain.ID `bun:"article_id,notnull"` 123 | UserID domain.ID `bun:"user_id,notnull"` 124 | } 125 | 126 | func (data ArticleFavorite) ToDomain() domain.ArticleFavorite { 127 | return domain.ArticleFavorite{ 128 | ArticleID: data.ArticleID, 129 | UserID: data.UserID, 130 | } 131 | } 132 | 133 | func AsArticleFavorite(arg domain.ArticleFavorite) ArticleFavorite { 134 | return ArticleFavorite{ 135 | ArticleID: arg.ArticleID, 136 | UserID: arg.UserID, 137 | } 138 | } 139 | 140 | type ArticleFavoriteCount struct { 141 | bun.BaseModel `bun:"table:article_favorites,alias:af"` 142 | ArticleID domain.ID `bun:"article_id"` 143 | Count int `bun:"favorite_count"` 144 | } 145 | 146 | func (data ArticleFavoriteCount) ToDomain() domain.ArticleFavoriteCount { 147 | return domain.ArticleFavoriteCount{ 148 | ArticleID: data.ArticleID, 149 | Count: data.Count, 150 | } 151 | } 152 | 153 | func AsArticleFavoriteCount(arg domain.ArticleFavoriteCount) ArticleFavoriteCount { 154 | return ArticleFavoriteCount{ 155 | ArticleID: arg.ArticleID, 156 | Count: arg.Count, 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/user.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | ) 10 | 11 | type RegisterRequestUser struct { 12 | Email string `json:"email"` 13 | Username string `json:"username"` 14 | Password string `json:"password"` 15 | } 16 | 17 | type RegisterRequest struct { 18 | User RegisterRequestUser `json:"user"` 19 | } 20 | 21 | func (server *Server) Register(c *gin.Context) { 22 | req := RegisterRequest{} 23 | if err := c.BindJSON(&req); err != nil { 24 | errorHandler(c, err) 25 | return 26 | } 27 | 28 | user, err := server.service.User().Register(c, port.RegisterParams{ 29 | User: domain.User{ 30 | Email: req.User.Email, 31 | Username: req.User.Username, 32 | Password: req.User.Password, 33 | }, 34 | }) 35 | if err != nil { 36 | errorHandler(c, err) 37 | return 38 | } 39 | 40 | res := UserResponse{serializeUser(user)} 41 | c.JSON(http.StatusCreated, res) 42 | } 43 | 44 | type LoginParamUser struct { 45 | Email string `json:"email"` 46 | Password string `json:"password"` 47 | } 48 | 49 | type LoginRequest struct { 50 | User LoginParamUser `json:"user"` 51 | } 52 | 53 | func (server *Server) Login(c *gin.Context) { 54 | req := LoginRequest{} 55 | if err := c.BindJSON(&req); err != nil { 56 | errorHandler(c, err) 57 | return 58 | } 59 | 60 | user, err := server.service.User().Login(c, port.LoginParams{ 61 | User: domain.User{ 62 | Email: req.User.Email, 63 | Password: req.User.Password, 64 | }, 65 | }) 66 | if err != nil { 67 | errorHandler(c, err) 68 | return 69 | } 70 | 71 | res := UserResponse{serializeUser(user)} 72 | c.JSON(http.StatusOK, res) 73 | } 74 | 75 | func (server *Server) CurrentUser(c *gin.Context) { 76 | authArg, err := getAuthArg(c) 77 | if err != nil { 78 | errorHandler(c, err) 79 | return 80 | } 81 | user, err := server.service.User().Current(c, authArg) 82 | if err != nil { 83 | errorHandler(c, err) 84 | return 85 | } 86 | res := UserResponse{serializeUser(user)} 87 | c.JSON(http.StatusOK, res) 88 | } 89 | 90 | type UpdateUser struct { 91 | Email string `json:"email,omitempty"` 92 | Username string `json:"username,omitempty"` 93 | Password string `json:"password,omitempty"` 94 | Bio string `json:"bio,omitempty"` 95 | Image string `json:"image,omitempty"` 96 | } 97 | 98 | type UpdateUserRequest struct { 99 | User UpdateUser `json:"user"` 100 | } 101 | 102 | func (server *Server) UpdateUser(c *gin.Context) { 103 | authArg, err := getAuthArg(c) 104 | if err != nil { 105 | errorHandler(c, err) 106 | return 107 | } 108 | req := UpdateUserRequest{} 109 | if err := c.BindJSON(&req); err != nil { 110 | errorHandler(c, err) 111 | return 112 | } 113 | user, err := server.service.User().Update(c, port.UpdateUserParams{ 114 | AuthArg: authArg, 115 | User: domain.User{ 116 | ID: authArg.Payload.UserID, 117 | Email: req.User.Email, 118 | Username: req.User.Username, 119 | Password: req.User.Password, 120 | Image: req.User.Image, 121 | Bio: req.User.Bio, 122 | }, 123 | }) 124 | if err != nil { 125 | errorHandler(c, err) 126 | return 127 | } 128 | res := UserResponse{serializeUser(user)} 129 | c.JSON(http.StatusOK, res) 130 | } 131 | 132 | func (server *Server) Profile(c *gin.Context) { 133 | username := c.Param("username") 134 | authArg, _ := getAuthArg(c) 135 | user, err := server.service.User().Profile(c, port.ProfileParams{ 136 | Username: username, 137 | AuthArg: authArg, 138 | }) 139 | if err != nil { 140 | errorHandler(c, err) 141 | return 142 | } 143 | res := ProfileResponse{serializeProfile(user)} 144 | c.JSON(http.StatusOK, res) 145 | } 146 | 147 | func (server *Server) FollowUser(c *gin.Context) { 148 | username := c.Param("username") 149 | authArg, err := getAuthArg(c) 150 | if err != nil { 151 | errorHandler(c, err) 152 | return 153 | } 154 | user, err := server.service.User().Follow(c, port.ProfileParams{ 155 | Username: username, 156 | AuthArg: authArg, 157 | }) 158 | if err != nil { 159 | errorHandler(c, err) 160 | return 161 | } 162 | res := ProfileResponse{serializeProfile(user)} 163 | c.JSON(http.StatusOK, res) 164 | } 165 | 166 | func (server *Server) UnFollowUser(c *gin.Context) { 167 | username := c.Param("username") 168 | authArg, err := getAuthArg(c) 169 | if err != nil { 170 | errorHandler(c, err) 171 | return 172 | } 173 | user, err := server.service.User().UnFollow(c, port.ProfileParams{ 174 | Username: username, 175 | AuthArg: authArg, 176 | }) 177 | if err != nil { 178 | errorHandler(c, err) 179 | return 180 | } 181 | res := ProfileResponse{serializeProfile(user)} 182 | c.JSON(http.StatusOK, res) 183 | } 184 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/user.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/mongo/model" 8 | "github.com/labasubagia/realworld-backend/internal/core/domain" 9 | "github.com/labasubagia/realworld-backend/internal/core/port" 10 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 11 | "go.mongodb.org/mongo-driver/bson" 12 | ) 13 | 14 | type userRepo struct { 15 | db DB 16 | } 17 | 18 | func NewUserRepository(db DB) port.UserRepository { 19 | return &userRepo{ 20 | db: db, 21 | } 22 | } 23 | func (r *userRepo) CreateUser(ctx context.Context, arg domain.User) (domain.User, error) { 24 | user := model.AsUser(arg) 25 | _, err := r.db.Collection(CollectionUser).InsertOne(ctx, user) 26 | if err != nil { 27 | return domain.User{}, intoException(err) 28 | } 29 | return user.ToDomain(), nil 30 | } 31 | 32 | func (r *userRepo) FilterFollow(ctx context.Context, arg port.FilterUserFollowPayload) ([]domain.UserFollow, error) { 33 | query := []bson.M{} 34 | if len(arg.FollowerIDs) > 0 { 35 | query = append(query, bson.M{"follower_id": bson.M{"$in": arg.FollowerIDs}}) 36 | } 37 | if len(arg.FolloweeIDs) > 0 { 38 | query = append(query, bson.M{"followee_id": bson.M{"$in": arg.FolloweeIDs}}) 39 | } 40 | filter := bson.M{} 41 | if len(query) > 0 { 42 | filter = bson.M{"$and": query} 43 | } 44 | 45 | cursor, err := r.db.Collection(CollectionUserFollow).Find(ctx, filter) 46 | if err != nil { 47 | return []domain.UserFollow{}, intoException(err) 48 | } 49 | 50 | result := []domain.UserFollow{} 51 | for cursor.Next(ctx) { 52 | data := model.UserFollow{} 53 | if err := cursor.Decode(&data); err != nil { 54 | return []domain.UserFollow{}, intoException(err) 55 | } 56 | result = append(result, data.ToDomain()) 57 | } 58 | 59 | return result, nil 60 | } 61 | 62 | func (r *userRepo) FilterUser(ctx context.Context, arg port.FilterUserPayload) ([]domain.User, error) { 63 | 64 | query := []bson.M{} 65 | if len(arg.IDs) > 0 { 66 | query = append(query, bson.M{"id": bson.M{"$in": arg.IDs}}) 67 | } 68 | if len(arg.Emails) > 0 { 69 | query = append(query, bson.M{"email": bson.M{"$in": arg.Emails}}) 70 | } 71 | if len(arg.Usernames) > 0 { 72 | query = append(query, bson.M{"username": bson.M{"$in": arg.Usernames}}) 73 | } 74 | 75 | filter := bson.M{} 76 | if len(query) > 0 { 77 | filter = bson.M{"$and": query} 78 | } 79 | 80 | cursor, err := r.db.Collection(CollectionUser).Find(ctx, filter) 81 | if err != nil { 82 | return []domain.User{}, intoException(err) 83 | } 84 | 85 | result := []domain.User{} 86 | for cursor.Next(ctx) { 87 | data := model.User{} 88 | if err := cursor.Decode(&data); err != nil { 89 | return []domain.User{}, intoException(err) 90 | } 91 | result = append(result, data.ToDomain()) 92 | } 93 | 94 | return result, nil 95 | } 96 | 97 | func (r *userRepo) FindOne(ctx context.Context, arg port.FilterUserPayload) (domain.User, error) { 98 | users, err := r.FilterUser(ctx, arg) 99 | if err != nil { 100 | return domain.User{}, intoException(err) 101 | } 102 | if len(users) == 0 { 103 | return domain.User{}, exception.New(exception.TypeNotFound, "user not found", nil) 104 | } 105 | return users[0], nil 106 | } 107 | 108 | func (r *userRepo) Follow(ctx context.Context, arg domain.UserFollow) (domain.UserFollow, error) { 109 | follow := model.AsUserFollow(arg) 110 | _, err := r.db.Collection(CollectionUserFollow).InsertOne(ctx, follow) 111 | if err != nil { 112 | return domain.UserFollow{}, intoException(err) 113 | } 114 | return follow.ToDomain(), nil 115 | } 116 | 117 | func (r *userRepo) UnFollow(ctx context.Context, arg domain.UserFollow) (domain.UserFollow, error) { 118 | _, err := r.db.Collection(CollectionUserFollow).DeleteOne(ctx, bson.M{ 119 | "follower_id": arg.FollowerID, 120 | "followee_id": arg.FolloweeID, 121 | }) 122 | if err != nil { 123 | return domain.UserFollow{}, intoException(err) 124 | } 125 | return arg, nil 126 | } 127 | 128 | func (r *userRepo) UpdateUser(ctx context.Context, arg domain.User) (domain.User, error) { 129 | 130 | filter := bson.M{"id": arg.ID} 131 | fields := bson.M{} 132 | if arg.Email != "" { 133 | fields["email"] = arg.Email 134 | } 135 | if arg.Username != "" { 136 | fields["username"] = arg.Username 137 | } 138 | if arg.Bio != "" { 139 | fields["bio"] = arg.Bio 140 | } 141 | if arg.Image != "" { 142 | fields["image"] = arg.Image 143 | } 144 | if arg.Password != "" { 145 | fields["password"] = arg.Password 146 | } 147 | if len(fields) > 0 { 148 | fields["updated_at"] = time.Now() 149 | } 150 | 151 | _, err := r.db.Collection(CollectionUser).UpdateOne(ctx, filter, bson.M{"$set": fields}) 152 | if err != nil { 153 | return domain.User{}, intoException(err) 154 | } 155 | 156 | updated, err := r.FindOne(ctx, port.FilterUserPayload{IDs: []domain.ID{arg.ID}}) 157 | if err != nil { 158 | return domain.User{}, intoException(err) 159 | } 160 | 161 | return updated, nil 162 | } 163 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement by contacting the maintainer team 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /internal/core/service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 11 | ) 12 | 13 | type userService struct { 14 | property serviceProperty 15 | } 16 | 17 | func NewUserService(property serviceProperty) port.UserService { 18 | return &userService{ 19 | property: property, 20 | } 21 | } 22 | 23 | func (s *userService) Register(ctx context.Context, req port.RegisterParams) (user domain.User, err error) { 24 | reqUser, err := domain.NewUser(req.User) 25 | if err != nil { 26 | return domain.User{}, exception.Into(err) 27 | } 28 | user, err = s.property.repo.User().CreateUser(ctx, reqUser) 29 | if err != nil { 30 | return domain.User{}, exception.Into(err) 31 | } 32 | 33 | user.Token, _, err = s.property.tokenMaker.CreateToken(user.ID, 2*time.Hour) 34 | if err != nil { 35 | return domain.User{}, exception.Into(err) 36 | } 37 | 38 | return user, nil 39 | } 40 | 41 | func (s *userService) Login(ctx context.Context, req port.LoginParams) (user domain.User, err error) { 42 | 43 | existing, err := s.property.repo.User().FilterUser(ctx, port.FilterUserPayload{Emails: []string{req.User.Email}}) 44 | if err != nil { 45 | return domain.User{}, exception.Into(err) 46 | } 47 | if len(existing) < 1 { 48 | return domain.User{}, exception.Validation().AddError("exception", "email or password invalid") 49 | } 50 | 51 | user = existing[0] 52 | if err := util.CheckPassword(req.User.Password, user.Password); err != nil { 53 | return domain.User{}, exception.Into(err) 54 | } 55 | 56 | user.Token, _, err = s.property.tokenMaker.CreateToken(user.ID, 2*time.Hour) 57 | if err != nil { 58 | return domain.User{}, exception.Into(err) 59 | } 60 | 61 | return user, nil 62 | } 63 | 64 | func (s *userService) Current(ctx context.Context, arg port.AuthParams) (user domain.User, err error) { 65 | if arg.Payload == nil { 66 | return domain.User{}, exception.New(exception.TypePermissionDenied, "token payload not provided", nil) 67 | } 68 | 69 | existing, err := s.property.repo.User().FilterUser(ctx, port.FilterUserPayload{IDs: []domain.ID{arg.Payload.UserID}}) 70 | if err != nil { 71 | return domain.User{}, exception.Into(err) 72 | } 73 | if len(existing) == 0 { 74 | return domain.User{}, exception.New(exception.TypePermissionDenied, "no user found", nil) 75 | } 76 | 77 | user = existing[0] 78 | user.Token = arg.Token 79 | 80 | return user, nil 81 | } 82 | 83 | func (s *userService) Update(ctx context.Context, arg port.UpdateUserParams) (user domain.User, err error) { 84 | if arg.AuthArg.Payload == nil { 85 | return domain.User{}, exception.New(exception.TypePermissionDenied, "token payload not provided", nil) 86 | } 87 | 88 | payload := domain.User{ 89 | ID: arg.User.ID, 90 | Username: arg.User.Username, 91 | Email: arg.User.Email, 92 | Password: arg.User.Password, 93 | Image: arg.User.Image, 94 | Bio: arg.User.Bio, 95 | UpdatedAt: time.Now(), 96 | } 97 | if arg.User.Password != "" { 98 | if err := payload.SetPassword(arg.User.Password); err != nil { 99 | return domain.User{}, exception.Into(err) 100 | } 101 | } 102 | 103 | user, err = s.property.repo.User().UpdateUser(ctx, payload) 104 | if err != nil { 105 | return domain.User{}, exception.Into(err) 106 | } 107 | 108 | user.Token = arg.AuthArg.Token 109 | return user, nil 110 | } 111 | 112 | func (s *userService) Profile(ctx context.Context, arg port.ProfileParams) (user domain.User, err error) { 113 | user, err = s.property.repo.User().FindOne(ctx, port.FilterUserPayload{Usernames: []string{arg.Username}}) 114 | if err != nil { 115 | return domain.User{}, exception.Into(err) 116 | } 117 | 118 | if arg.AuthArg.Payload == nil { 119 | return user, nil 120 | } 121 | 122 | follows, err := s.property.repo.User().FilterFollow(ctx, port.FilterUserFollowPayload{ 123 | FollowerIDs: []domain.ID{arg.AuthArg.Payload.UserID}, 124 | FolloweeIDs: []domain.ID{user.ID}, 125 | }) 126 | if err != nil { 127 | return domain.User{}, exception.Into(err) 128 | } 129 | if len(follows) > 0 { 130 | user.IsFollowed = true 131 | } 132 | 133 | return user, nil 134 | } 135 | 136 | func (s *userService) Follow(ctx context.Context, arg port.ProfileParams) (user domain.User, err error) { 137 | if arg.AuthArg.Payload == nil { 138 | return user, exception.New(exception.TypePermissionDenied, "authentication required", nil) 139 | } 140 | 141 | user, err = s.property.repo.User().FindOne(ctx, port.FilterUserPayload{Usernames: []string{arg.Username}}) 142 | if err != nil { 143 | return domain.User{}, exception.Into(err) 144 | } 145 | 146 | follows, err := s.property.repo.User().FilterFollow(ctx, port.FilterUserFollowPayload{ 147 | FollowerIDs: []domain.ID{arg.AuthArg.Payload.UserID}, 148 | FolloweeIDs: []domain.ID{user.ID}, 149 | }) 150 | if err != nil { 151 | return domain.User{}, exception.Into(err) 152 | } 153 | if len(follows) > 0 { 154 | user.IsFollowed = true 155 | return user, nil 156 | } 157 | 158 | newFollow, err := domain.NewUserFollow(domain.UserFollow{ 159 | FollowerID: arg.AuthArg.Payload.UserID, 160 | FolloweeID: user.ID, 161 | }) 162 | if err != nil { 163 | return domain.User{}, exception.Into(err) 164 | } 165 | _, err = s.property.repo.User().Follow(ctx, newFollow) 166 | if err != nil { 167 | return domain.User{}, exception.Into(err) 168 | } 169 | 170 | user.IsFollowed = true 171 | return user, nil 172 | } 173 | 174 | func (s *userService) UnFollow(ctx context.Context, arg port.ProfileParams) (user domain.User, err error) { 175 | if arg.AuthArg.Payload == nil { 176 | return user, exception.New(exception.TypePermissionDenied, "authentication required", nil) 177 | } 178 | 179 | user, err = s.property.repo.User().FindOne(ctx, port.FilterUserPayload{Usernames: []string{arg.Username}}) 180 | if err != nil { 181 | return domain.User{}, exception.Into(err) 182 | } 183 | 184 | follows, err := s.property.repo.User().FilterFollow(ctx, port.FilterUserFollowPayload{ 185 | FollowerIDs: []domain.ID{arg.AuthArg.Payload.UserID}, 186 | FolloweeIDs: []domain.ID{user.ID}, 187 | }) 188 | if err != nil { 189 | return domain.User{}, exception.Into(err) 190 | } 191 | if len(follows) == 0 { 192 | user.IsFollowed = false 193 | return user, nil 194 | } 195 | 196 | _, err = s.property.repo.User().UnFollow(ctx, domain.UserFollow{ 197 | FollowerID: arg.AuthArg.Payload.UserID, 198 | FolloweeID: user.ID, 199 | }) 200 | if err != nil { 201 | return domain.User{}, exception.Into(err) 202 | } 203 | 204 | user.IsFollowed = false 205 | return user, nil 206 | } 207 | -------------------------------------------------------------------------------- /internal/core/service/user_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util" 10 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRegisterOK(t *testing.T) { 15 | createRandomUser(t) 16 | } 17 | 18 | func TestRegisterWithImageOK(t *testing.T) { 19 | arg := createUserArg() 20 | arg.User.SetImageURL(util.RandomURL()) 21 | createUser(t, arg) 22 | } 23 | 24 | func TestLoginOK(t *testing.T) { 25 | createRandomLogin(t) 26 | } 27 | 28 | func TestLoginInvalid(t *testing.T) { 29 | result, err := testService.User().Login(context.Background(), port.LoginParams{ 30 | User: domain.RandomUser(), 31 | }) 32 | require.NotNil(t, err) 33 | require.Empty(t, result) 34 | } 35 | 36 | func TestCurrentUserOK(t *testing.T) { 37 | user, authArg, _ := createRandomUser(t) 38 | 39 | result, err := testService.User().Current(context.Background(), authArg) 40 | require.Nil(t, err) 41 | require.NotEmpty(t, result) 42 | 43 | require.Equal(t, user.Email, result.Email) 44 | require.Equal(t, user.Username, result.Username) 45 | require.Equal(t, user.Image, result.Image) 46 | require.Equal(t, user.Bio, result.Bio) 47 | require.Equal(t, authArg.Token, result.Token) 48 | } 49 | 50 | func TestUpdateUserOK(t *testing.T) { 51 | user, authArg, _ := createRandomUser(t) 52 | 53 | newEmail := util.RandomEmail() 54 | newUsername := util.RandomUsername() 55 | newPassword := util.RandomString(8) 56 | newImage := util.RandomURL() 57 | newBio := util.RandomString(5) 58 | 59 | result, err := testService.User().Update(context.Background(), port.UpdateUserParams{ 60 | AuthArg: authArg, 61 | User: domain.User{ 62 | ID: user.ID, 63 | Email: newEmail, 64 | Username: newUsername, 65 | Password: newPassword, 66 | Image: newImage, 67 | Bio: newBio, 68 | }, 69 | }) 70 | require.Nil(t, err) 71 | require.NotEmpty(t, result) 72 | require.Equal(t, newEmail, result.Email) 73 | require.Equal(t, newUsername, result.Username) 74 | require.Equal(t, newImage, result.Image) 75 | require.Equal(t, newBio, result.Bio) 76 | require.Nil(t, util.CheckPassword(newPassword, result.Password)) 77 | } 78 | 79 | func TestUpdateUserSameDataOK(t *testing.T) { 80 | user, authArg, password := createRandomUser(t) 81 | 82 | result, err := testService.User().Update(context.Background(), port.UpdateUserParams{ 83 | AuthArg: authArg, 84 | User: domain.User{ 85 | ID: user.ID, 86 | Email: user.Email, 87 | Username: user.Username, 88 | Password: password, 89 | Image: user.Image, 90 | Bio: user.Bio, 91 | }, 92 | }) 93 | require.Nil(t, err) 94 | require.NotEmpty(t, result) 95 | require.Equal(t, user.Email, result.Email) 96 | require.Equal(t, user.Username, result.Username) 97 | require.Equal(t, user.Image, result.Image) 98 | require.Equal(t, user.Bio, result.Bio) 99 | require.Nil(t, util.CheckPassword(password, result.Password)) 100 | } 101 | 102 | func TestUpdateUserEmptyOK(t *testing.T) { 103 | user, authArg, password := createRandomUser(t) 104 | 105 | result, err := testService.User().Update(context.Background(), port.UpdateUserParams{ 106 | AuthArg: authArg, 107 | User: domain.User{ 108 | ID: user.ID, 109 | Email: "", 110 | Username: "", 111 | Password: "", 112 | Image: "", 113 | Bio: "", 114 | }, 115 | }) 116 | require.Nil(t, err) 117 | require.NotEmpty(t, result) 118 | require.Equal(t, user.Email, result.Email) 119 | require.Equal(t, user.Username, result.Username) 120 | require.Equal(t, user.Image, result.Image) 121 | require.Equal(t, user.Bio, result.Bio) 122 | require.Nil(t, util.CheckPassword(password, result.Password)) 123 | } 124 | 125 | func TestProfile(t *testing.T) { 126 | user, authArg, _ := createRandomUser(t) 127 | result, err := testService.User().Profile(context.Background(), port.ProfileParams{ 128 | Username: user.Username, 129 | AuthArg: authArg, 130 | }) 131 | require.Nil(t, err) 132 | require.NotEmpty(t, result) 133 | require.Equal(t, user.Email, result.Email) 134 | require.Equal(t, user.Username, result.Username) 135 | require.Equal(t, user.Image, result.Image) 136 | require.Equal(t, user.Bio, result.Bio) 137 | require.False(t, result.IsFollowed) 138 | } 139 | 140 | func TestFollowUnFollow(t *testing.T) { 141 | followee, _, _ := createRandomUser(t) 142 | _, followerAuthArg, _ := createRandomUser(t) 143 | 144 | ctx := context.Background() 145 | arg := port.ProfileParams{ 146 | Username: followee.Username, 147 | AuthArg: followerAuthArg, 148 | } 149 | 150 | // follow 151 | followResult, err := testService.User().Follow(ctx, arg) 152 | require.Nil(t, err) 153 | require.True(t, followResult.IsFollowed) 154 | 155 | // already follow 156 | followResult, err = testService.User().Follow(ctx, arg) 157 | require.Nil(t, err) 158 | require.True(t, followResult.IsFollowed) 159 | 160 | profileResult, err := testService.User().Profile(ctx, arg) 161 | require.Nil(t, err) 162 | require.True(t, profileResult.IsFollowed) 163 | 164 | // un follow 165 | unFollowResult, err := testService.User().UnFollow(ctx, arg) 166 | require.Nil(t, err) 167 | require.False(t, unFollowResult.IsFollowed) 168 | 169 | // already un follow 170 | unFollowResult, err = testService.User().UnFollow(ctx, arg) 171 | require.Nil(t, err) 172 | require.False(t, unFollowResult.IsFollowed) 173 | 174 | profileResult, err = testService.User().Profile(ctx, arg) 175 | require.Nil(t, err) 176 | require.False(t, profileResult.IsFollowed) 177 | 178 | } 179 | 180 | func TestSelfFollowFail(t *testing.T) { 181 | user, authArg, _ := createRandomUser(t) 182 | arg := port.ProfileParams{ 183 | Username: user.Username, 184 | AuthArg: authArg, 185 | } 186 | followResult, err := testService.User().Follow(context.Background(), arg) 187 | require.NotNil(t, err) 188 | require.Empty(t, followResult) 189 | fail, ok := err.(*exception.Exception) 190 | require.True(t, ok) 191 | require.NotNil(t, fail) 192 | require.Equal(t, exception.TypeValidation, fail.Type) 193 | } 194 | 195 | func createRandomLogin(t *testing.T) { 196 | user, _, password := createRandomUser(t) 197 | createLogin(t, port.LoginParams{User: domain.User{ 198 | Email: user.Email, 199 | Password: password, 200 | }}) 201 | } 202 | 203 | func createLogin(t *testing.T, arg port.LoginParams) (user domain.User, token, password string) { 204 | result, err := testService.User().Login(context.Background(), port.LoginParams{ 205 | User: domain.User{ 206 | Email: arg.User.Email, 207 | Password: arg.User.Password, 208 | }, 209 | }) 210 | require.Nil(t, err) 211 | require.NotEmpty(t, result) 212 | 213 | payload, err := testService.TokenMaker().VerifyToken(result.Token) 214 | require.NoError(t, err) 215 | require.NotNil(t, payload) 216 | require.Equal(t, result.ID, payload.UserID) 217 | 218 | return user, result.Token, password 219 | } 220 | 221 | func createRandomUser(t *testing.T) (user domain.User, authArg port.AuthParams, password string) { 222 | return createUser(t, createUserArg()) 223 | } 224 | 225 | func createUser(t *testing.T, arg port.RegisterParams) (user domain.User, authArg port.AuthParams, password string) { 226 | image := arg.User.Image 227 | if image == "" { 228 | image = domain.UserDefaultImage 229 | } 230 | 231 | result, err := testService.User().Register(context.Background(), arg) 232 | 233 | require.Nil(t, err) 234 | require.NotEmpty(t, result) 235 | 236 | user = result 237 | require.Equal(t, arg.User.Email, user.Email) 238 | require.Equal(t, arg.User.Username, user.Username) 239 | require.Equal(t, image, user.Image) 240 | require.NotEqual(t, arg.User.Password, user.Password) 241 | require.Nil(t, util.CheckPassword(arg.User.Password, user.Password)) 242 | 243 | payload, err := testService.TokenMaker().VerifyToken(result.Token) 244 | require.NoError(t, err) 245 | require.NotNil(t, payload) 246 | require.Equal(t, user.ID, payload.UserID) 247 | 248 | authArg = port.AuthParams{ 249 | Token: result.Token, 250 | Payload: payload, 251 | } 252 | return user, authArg, arg.User.Password 253 | } 254 | 255 | func createUserArg() port.RegisterParams { 256 | return port.RegisterParams{ 257 | User: domain.RandomUser(), 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/api/article.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/adapter/handler/grpc/pb" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "google.golang.org/protobuf/types/known/emptypb" 10 | ) 11 | 12 | func (server *Server) ListArticle(ctx context.Context, req *pb.FilterArticleRequest) (*pb.ArticlesResponse, error) { 13 | 14 | auth, _ := server.authorizeUser(ctx) 15 | 16 | offset := 0 17 | if req.Offset != nil { 18 | offset = int(req.GetOffset()) 19 | } 20 | 21 | limit := DefaultPaginationSize 22 | if req.Limit != nil { 23 | limit = int(req.GetLimit()) 24 | } 25 | 26 | arg := port.ListArticleParams{ 27 | Tags: []string{}, 28 | AuthorNames: []string{}, 29 | FavoritedNames: []string{}, 30 | AuthArg: auth, 31 | Offset: offset, 32 | Limit: limit, 33 | } 34 | if req.GetTag() != "" { 35 | arg.Tags = append(arg.Tags, req.GetTag()) 36 | } 37 | if req.GetAuthor() != "" { 38 | arg.AuthorNames = append(arg.AuthorNames, req.GetAuthor()) 39 | } 40 | if req.GetFavorited() != "" { 41 | arg.FavoritedNames = append(arg.FavoritedNames, req.GetFavorited()) 42 | } 43 | 44 | articles, err := server.service.Article().List(ctx, arg) 45 | if err != nil { 46 | return nil, handleError(err) 47 | } 48 | 49 | res := &pb.ArticlesResponse{ 50 | Articles: []*pb.Article{}, 51 | Count: int64(len(articles)), 52 | } 53 | for _, article := range articles { 54 | res.Articles = append(res.Articles, serializeArticle(article)) 55 | } 56 | 57 | return res, nil 58 | } 59 | 60 | func (server *Server) FeedArticle(ctx context.Context, req *pb.FilterArticleRequest) (*pb.ArticlesResponse, error) { 61 | auth, err := server.authorizeUser(ctx) 62 | if err != nil { 63 | return nil, handleError(err) 64 | } 65 | 66 | offset := 0 67 | if req.Offset != nil { 68 | offset = int(req.GetOffset()) 69 | } 70 | 71 | limit := DefaultPaginationSize 72 | if req.Limit != nil { 73 | limit = int(req.GetLimit()) 74 | } 75 | 76 | arg := port.ListArticleParams{ 77 | AuthArg: auth, 78 | Offset: offset, 79 | Limit: limit, 80 | } 81 | articles, err := server.service.Article().Feed(ctx, arg) 82 | if err != nil { 83 | return nil, handleError(err) 84 | } 85 | 86 | res := &pb.ArticlesResponse{ 87 | Articles: []*pb.Article{}, 88 | Count: int64(len(articles)), 89 | } 90 | for _, article := range articles { 91 | res.Articles = append(res.Articles, serializeArticle(article)) 92 | } 93 | 94 | return res, nil 95 | } 96 | 97 | func (server *Server) GetArticle(ctx context.Context, req *pb.GetArticleRequest) (*pb.ArticleResponse, error) { 98 | 99 | authArg, _ := server.authorizeUser(ctx) 100 | 101 | article, err := server.service.Article().Get(ctx, port.GetArticleParams{ 102 | AuthArg: authArg, 103 | Slug: req.GetSlug(), 104 | }) 105 | if err != nil { 106 | return nil, handleError(err) 107 | } 108 | res := &pb.ArticleResponse{ 109 | Article: serializeArticle(article), 110 | } 111 | return res, nil 112 | } 113 | 114 | func (server *Server) CreateArticle(ctx context.Context, req *pb.CreateArticleRequest) (*pb.ArticleResponse, error) { 115 | authArg, err := server.authorizeUser(ctx) 116 | if err != nil { 117 | return nil, handleError(err) 118 | } 119 | article, err := server.service.Article().Create(ctx, port.CreateArticleTxParams{ 120 | AuthArg: authArg, 121 | Tags: req.GetArticle().GetTagList(), 122 | Article: domain.Article{ 123 | Title: req.GetArticle().GetTitle(), 124 | Description: req.GetArticle().GetDescription(), 125 | Body: req.GetArticle().GetBody(), 126 | }, 127 | }) 128 | if err != nil { 129 | return nil, handleError(err) 130 | } 131 | res := &pb.ArticleResponse{ 132 | Article: serializeArticle(article), 133 | } 134 | return res, nil 135 | } 136 | 137 | func (server *Server) UpdateArticle(ctx context.Context, req *pb.UpdateArticleRequest) (*pb.ArticleResponse, error) { 138 | auth, err := server.authorizeUser(ctx) 139 | if err != nil { 140 | return nil, handleError(err) 141 | } 142 | article, err := server.service.Article().Update(ctx, port.UpdateArticleParams{ 143 | AuthArg: auth, 144 | Slug: req.GetSlug(), 145 | Article: domain.Article{ 146 | Title: req.GetArticle().GetTitle(), 147 | Description: req.GetArticle().GetDescription(), 148 | Body: req.GetArticle().GetBody(), 149 | }, 150 | }) 151 | if err != nil { 152 | return nil, handleError(err) 153 | } 154 | 155 | res := &pb.ArticleResponse{ 156 | Article: serializeArticle(article), 157 | } 158 | return res, nil 159 | } 160 | 161 | func (server *Server) DeleteArticle(ctx context.Context, req *pb.GetArticleRequest) (*pb.Response, error) { 162 | auth, err := server.authorizeUser(ctx) 163 | if err != nil { 164 | return nil, handleError(err) 165 | } 166 | 167 | err = server.service.Article().Delete(ctx, port.DeleteArticleParams{ 168 | AuthArg: auth, 169 | Slug: req.GetSlug(), 170 | }) 171 | if err != nil { 172 | return nil, handleError(err) 173 | } 174 | res := &pb.Response{Status: "OK"} 175 | return res, nil 176 | } 177 | 178 | func (server *Server) CreateComment(ctx context.Context, req *pb.CreateCommentRequest) (*pb.CommentResponse, error) { 179 | auth, err := server.authorizeUser(ctx) 180 | if err != nil { 181 | return nil, handleError(err) 182 | } 183 | 184 | result, err := server.service.Article().AddComment(ctx, port.AddCommentParams{ 185 | AuthArg: auth, 186 | Slug: req.GetSlug(), 187 | Comment: domain.Comment{ 188 | Body: req.GetComment().GetBody(), 189 | }, 190 | }) 191 | if err != nil { 192 | return nil, handleError(err) 193 | } 194 | 195 | res := &pb.CommentResponse{ 196 | Comment: serializeComment(result), 197 | } 198 | return res, nil 199 | } 200 | 201 | func (server *Server) ListComment(ctx context.Context, req *pb.ListCommentRequest) (*pb.CommentsResponse, error) { 202 | auth, _ := server.authorizeUser(ctx) 203 | comments, err := server.service.Article().ListComments(ctx, port.ListCommentParams{ 204 | AuthArg: auth, 205 | Slug: req.GetSlug(), 206 | }) 207 | if err != nil { 208 | return nil, handleError(err) 209 | } 210 | 211 | res := &pb.CommentsResponse{ 212 | Comments: []*pb.Comment{}, 213 | } 214 | for _, comment := range comments { 215 | res.Comments = append(res.Comments, serializeComment(comment)) 216 | } 217 | return res, nil 218 | } 219 | 220 | func (server *Server) DeleteComment(ctx context.Context, req *pb.GetCommentRequest) (*pb.Response, error) { 221 | commentID, err := domain.ParseID(req.GetCommentId()) 222 | if err != nil { 223 | return nil, handleError(err) 224 | } 225 | auth, err := server.authorizeUser(ctx) 226 | if err != nil { 227 | return nil, handleError(err) 228 | } 229 | err = server.service.Article().DeleteComment(ctx, port.DeleteCommentParams{ 230 | AuthArg: auth, 231 | Slug: req.Slug, 232 | CommentID: domain.ID(commentID), 233 | }) 234 | if err != nil { 235 | return nil, handleError(err) 236 | } 237 | res := &pb.Response{Status: "OK"} 238 | return res, nil 239 | } 240 | 241 | func (server *Server) FavoriteArticle(ctx context.Context, req *pb.GetArticleRequest) (*pb.ArticleResponse, error) { 242 | auth, err := server.authorizeUser(ctx) 243 | if err != nil { 244 | return nil, handleError(err) 245 | } 246 | article, err := server.service.Article().AddFavorite(ctx, port.AddFavoriteParams{ 247 | AuthArg: auth, 248 | Slug: req.GetSlug(), 249 | UserID: auth.Payload.UserID, 250 | }) 251 | if err != nil { 252 | return nil, handleError(err) 253 | } 254 | res := &pb.ArticleResponse{ 255 | Article: serializeArticle(article), 256 | } 257 | return res, nil 258 | } 259 | 260 | func (server *Server) UnFavoriteArticle(ctx context.Context, req *pb.GetArticleRequest) (*pb.ArticleResponse, error) { 261 | authArg, err := server.authorizeUser(ctx) 262 | if err != nil { 263 | return nil, handleError(err) 264 | } 265 | article, err := server.service.Article().RemoveFavorite(ctx, port.RemoveFavoriteParams{ 266 | AuthArg: authArg, 267 | Slug: req.GetSlug(), 268 | UserID: authArg.Payload.UserID, 269 | }) 270 | if err != nil { 271 | return nil, handleError(err) 272 | } 273 | res := &pb.ArticleResponse{ 274 | Article: serializeArticle(article), 275 | } 276 | return res, nil 277 | } 278 | 279 | func (server *Server) ListTag(ctx context.Context, _ *emptypb.Empty) (*pb.ListTagResponse, error) { 280 | tags, err := server.service.Article().ListTags(ctx) 281 | if err != nil { 282 | return nil, handleError(err) 283 | } 284 | res := &pb.ListTagResponse{ 285 | Tags: tags, 286 | } 287 | return res, nil 288 | } 289 | -------------------------------------------------------------------------------- /internal/adapter/handler/restful/article.go: -------------------------------------------------------------------------------- 1 | package restful 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 10 | ) 11 | 12 | func (server *Server) ListArticle(c *gin.Context) { 13 | Tag := c.Query("tag") 14 | Author := c.Query("author") 15 | FavoritedBy := c.Query("favorited") 16 | 17 | offset, limit := getPagination(c) 18 | authArg, _ := getAuthArg(c) 19 | 20 | arg := port.ListArticleParams{ 21 | Tags: []string{}, 22 | AuthorNames: []string{}, 23 | FavoritedNames: []string{}, 24 | AuthArg: authArg, 25 | Offset: offset, 26 | Limit: limit, 27 | } 28 | if Tag != "" { 29 | arg.Tags = append(arg.Tags, Tag) 30 | } 31 | if Author != "" { 32 | arg.AuthorNames = append(arg.AuthorNames, Author) 33 | } 34 | if FavoritedBy != "" { 35 | arg.FavoritedNames = append(arg.FavoritedNames, FavoritedBy) 36 | } 37 | 38 | articles, err := server.service.Article().List(c, arg) 39 | if err != nil { 40 | errorHandler(c, err) 41 | return 42 | } 43 | 44 | res := ArticlesResponse{ 45 | Articles: []Article{}, 46 | Count: len(articles), 47 | } 48 | for _, article := range articles { 49 | res.Articles = append(res.Articles, serializeArticle(article)) 50 | } 51 | 52 | c.JSON(http.StatusOK, res) 53 | } 54 | 55 | func (server *Server) FeedArticle(c *gin.Context) { 56 | offset, limit := getPagination(c) 57 | authArg, err := getAuthArg(c) 58 | if err != nil { 59 | errorHandler(c, err) 60 | return 61 | } 62 | 63 | arg := port.ListArticleParams{ 64 | AuthArg: authArg, 65 | Offset: offset, 66 | Limit: limit, 67 | } 68 | articles, err := server.service.Article().Feed(c, arg) 69 | if err != nil { 70 | errorHandler(c, err) 71 | return 72 | } 73 | 74 | res := ArticlesResponse{ 75 | Articles: []Article{}, 76 | Count: len(articles), 77 | } 78 | for _, article := range articles { 79 | res.Articles = append(res.Articles, serializeArticle(article)) 80 | } 81 | 82 | c.JSON(http.StatusOK, res) 83 | } 84 | 85 | func (server *Server) GetArticle(c *gin.Context) { 86 | slug := c.Param("slug") 87 | authArg, _ := getAuthArg(c) 88 | 89 | article, err := server.service.Article().Get(c, port.GetArticleParams{ 90 | AuthArg: authArg, 91 | Slug: slug, 92 | }) 93 | if err != nil { 94 | errorHandler(c, err) 95 | return 96 | } 97 | 98 | res := ArticleResponse{serializeArticle(article)} 99 | c.JSON(http.StatusOK, res) 100 | } 101 | 102 | type CreateArticle struct { 103 | Title string `json:"title"` 104 | Description string `json:"description"` 105 | Body string `json:"body"` 106 | TagList []string `json:"tagList"` 107 | } 108 | 109 | type CreateArticleRequest struct { 110 | Article CreateArticle `json:"article"` 111 | } 112 | 113 | func (server *Server) CreateArticle(c *gin.Context) { 114 | authArg, err := getAuthArg(c) 115 | if err != nil { 116 | errorHandler(c, err) 117 | return 118 | } 119 | 120 | var req CreateArticleRequest 121 | if err := c.BindJSON(&req); err != nil { 122 | errorHandler(c, err) 123 | return 124 | } 125 | 126 | article, err := server.service.Article().Create(c, port.CreateArticleTxParams{ 127 | AuthArg: authArg, 128 | Tags: req.Article.TagList, 129 | Article: domain.Article{ 130 | Title: req.Article.Title, 131 | Description: req.Article.Description, 132 | Body: req.Article.Body, 133 | }, 134 | }) 135 | if err != nil { 136 | errorHandler(c, err) 137 | return 138 | } 139 | 140 | res := ArticleResponse{serializeArticle(article)} 141 | c.JSON(http.StatusCreated, res) 142 | } 143 | 144 | type UpdateArticle struct { 145 | Title string `json:"title"` 146 | Description string `json:"description"` 147 | Body string `json:"body"` 148 | } 149 | 150 | type UpdateArticleRequest struct { 151 | Article UpdateArticle `json:"article"` 152 | } 153 | 154 | func (server *Server) UpdateArticle(c *gin.Context) { 155 | slug := c.Param("slug") 156 | authArg, err := getAuthArg(c) 157 | if err != nil { 158 | errorHandler(c, err) 159 | return 160 | } 161 | 162 | var req UpdateArticleRequest 163 | if err := c.BindJSON(&req); err != nil { 164 | errorHandler(c, err) 165 | return 166 | } 167 | 168 | article, err := server.service.Article().Update(c, port.UpdateArticleParams{ 169 | AuthArg: authArg, 170 | Slug: slug, 171 | Article: domain.Article{ 172 | Title: req.Article.Title, 173 | Description: req.Article.Description, 174 | Body: req.Article.Body, 175 | }, 176 | }) 177 | if err != nil { 178 | errorHandler(c, err) 179 | return 180 | } 181 | 182 | res := ArticleResponse{serializeArticle(article)} 183 | c.JSON(http.StatusOK, res) 184 | } 185 | 186 | func (server *Server) DeleteArticle(c *gin.Context) { 187 | slug := c.Param("slug") 188 | authArg, err := getAuthArg(c) 189 | if err != nil { 190 | errorHandler(c, err) 191 | return 192 | } 193 | 194 | err = server.service.Article().Delete(c, port.DeleteArticleParams{ 195 | AuthArg: authArg, 196 | Slug: slug, 197 | }) 198 | if err != nil { 199 | errorHandler(c, err) 200 | return 201 | } 202 | 203 | c.JSON(http.StatusOK, gin.H{"status": "OK"}) 204 | } 205 | 206 | type AddCommentRequest struct { 207 | Comment Comment `json:"comment"` 208 | } 209 | 210 | func (server *Server) AddComment(c *gin.Context) { 211 | slug := c.Param("slug") 212 | authArg, err := getAuthArg(c) 213 | if err != nil { 214 | errorHandler(c, err) 215 | return 216 | } 217 | 218 | var req AddCommentRequest 219 | if err := c.BindJSON(&req); err != nil { 220 | errorHandler(c, err) 221 | return 222 | } 223 | 224 | result, err := server.service.Article().AddComment(c, port.AddCommentParams{ 225 | AuthArg: authArg, 226 | Slug: slug, 227 | Comment: domain.Comment{ 228 | Body: req.Comment.Body, 229 | }, 230 | }) 231 | if err != nil { 232 | errorHandler(c, err) 233 | return 234 | } 235 | 236 | res := CommentResponse{serializeComment(result)} 237 | c.JSON(http.StatusOK, res) 238 | } 239 | 240 | func (server *Server) ListComments(c *gin.Context) { 241 | slug := c.Param("slug") 242 | authArg, _ := getAuthArg(c) 243 | 244 | comments, err := server.service.Article().ListComments(c, port.ListCommentParams{ 245 | AuthArg: authArg, 246 | Slug: slug, 247 | }) 248 | if err != nil { 249 | errorHandler(c, err) 250 | return 251 | } 252 | 253 | res := CommentsResponse{ 254 | Comments: []Comment{}, 255 | } 256 | for _, comment := range comments { 257 | res.Comments = append(res.Comments, serializeComment(comment)) 258 | } 259 | c.JSON(http.StatusOK, res) 260 | } 261 | 262 | func (server *Server) DeleteComment(c *gin.Context) { 263 | slug := c.Param("slug") 264 | commentID, err := domain.ParseID(c.Param("comment_id")) 265 | if err != nil { 266 | err = exception.Validation().AddError("comment_id", "should valid id") 267 | errorHandler(c, err) 268 | return 269 | } 270 | 271 | authArg, err := getAuthArg(c) 272 | if err != nil { 273 | errorHandler(c, err) 274 | return 275 | } 276 | 277 | err = server.service.Article().DeleteComment(c, port.DeleteCommentParams{ 278 | AuthArg: authArg, 279 | Slug: slug, 280 | CommentID: domain.ID(commentID), 281 | }) 282 | if err != nil { 283 | errorHandler(c, err) 284 | return 285 | } 286 | 287 | c.JSON(http.StatusOK, gin.H{"status": "OK"}) 288 | } 289 | 290 | func (server *Server) AddFavoriteArticle(c *gin.Context) { 291 | slug := c.Param("slug") 292 | authArg, err := getAuthArg(c) 293 | if err != nil { 294 | errorHandler(c, err) 295 | return 296 | } 297 | 298 | article, err := server.service.Article().AddFavorite(c, port.AddFavoriteParams{ 299 | AuthArg: authArg, 300 | Slug: slug, 301 | UserID: authArg.Payload.UserID, 302 | }) 303 | if err != nil { 304 | errorHandler(c, err) 305 | return 306 | } 307 | 308 | res := ArticleResponse{ 309 | Article: serializeArticle(article), 310 | } 311 | 312 | c.JSON(http.StatusOK, res) 313 | } 314 | 315 | func (server *Server) RemoveFavoriteArticle(c *gin.Context) { 316 | slug := c.Param("slug") 317 | authArg, err := getAuthArg(c) 318 | if err != nil { 319 | errorHandler(c, err) 320 | return 321 | } 322 | 323 | article, err := server.service.Article().RemoveFavorite(c, port.RemoveFavoriteParams{ 324 | AuthArg: authArg, 325 | Slug: slug, 326 | UserID: authArg.Payload.UserID, 327 | }) 328 | if err != nil { 329 | errorHandler(c, err) 330 | return 331 | } 332 | 333 | res := ArticleResponse{serializeArticle(article)} 334 | c.JSON(http.StatusOK, res) 335 | } 336 | 337 | func (server *Server) ListTags(c *gin.Context) { 338 | tags, err := server.service.Article().ListTags(c) 339 | if err != nil { 340 | errorHandler(c, err) 341 | return 342 | } 343 | c.JSON(http.StatusOK, gin.H{"tags": tags}) 344 | } 345 | -------------------------------------------------------------------------------- /internal/adapter/handler/grpc/pb/user.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.28.1 4 | // protoc v4.24.1 5 | // source: user.proto 6 | 7 | package pb 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | type User struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | 28 | Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` 29 | Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` 30 | Bio string `protobuf:"bytes,3,opt,name=bio,proto3" json:"bio,omitempty"` 31 | Image string `protobuf:"bytes,4,opt,name=image,proto3" json:"image,omitempty"` 32 | Token string `protobuf:"bytes,5,opt,name=token,proto3" json:"token,omitempty"` 33 | } 34 | 35 | func (x *User) Reset() { 36 | *x = User{} 37 | if protoimpl.UnsafeEnabled { 38 | mi := &file_user_proto_msgTypes[0] 39 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 40 | ms.StoreMessageInfo(mi) 41 | } 42 | } 43 | 44 | func (x *User) String() string { 45 | return protoimpl.X.MessageStringOf(x) 46 | } 47 | 48 | func (*User) ProtoMessage() {} 49 | 50 | func (x *User) ProtoReflect() protoreflect.Message { 51 | mi := &file_user_proto_msgTypes[0] 52 | if protoimpl.UnsafeEnabled && x != nil { 53 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 54 | if ms.LoadMessageInfo() == nil { 55 | ms.StoreMessageInfo(mi) 56 | } 57 | return ms 58 | } 59 | return mi.MessageOf(x) 60 | } 61 | 62 | // Deprecated: Use User.ProtoReflect.Descriptor instead. 63 | func (*User) Descriptor() ([]byte, []int) { 64 | return file_user_proto_rawDescGZIP(), []int{0} 65 | } 66 | 67 | func (x *User) GetEmail() string { 68 | if x != nil { 69 | return x.Email 70 | } 71 | return "" 72 | } 73 | 74 | func (x *User) GetUsername() string { 75 | if x != nil { 76 | return x.Username 77 | } 78 | return "" 79 | } 80 | 81 | func (x *User) GetBio() string { 82 | if x != nil { 83 | return x.Bio 84 | } 85 | return "" 86 | } 87 | 88 | func (x *User) GetImage() string { 89 | if x != nil { 90 | return x.Image 91 | } 92 | return "" 93 | } 94 | 95 | func (x *User) GetToken() string { 96 | if x != nil { 97 | return x.Token 98 | } 99 | return "" 100 | } 101 | 102 | type Profile struct { 103 | state protoimpl.MessageState 104 | sizeCache protoimpl.SizeCache 105 | unknownFields protoimpl.UnknownFields 106 | 107 | Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` 108 | Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` 109 | Bio string `protobuf:"bytes,3,opt,name=bio,proto3" json:"bio,omitempty"` 110 | Following bool `protobuf:"varint,4,opt,name=following,proto3" json:"following,omitempty"` 111 | } 112 | 113 | func (x *Profile) Reset() { 114 | *x = Profile{} 115 | if protoimpl.UnsafeEnabled { 116 | mi := &file_user_proto_msgTypes[1] 117 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 118 | ms.StoreMessageInfo(mi) 119 | } 120 | } 121 | 122 | func (x *Profile) String() string { 123 | return protoimpl.X.MessageStringOf(x) 124 | } 125 | 126 | func (*Profile) ProtoMessage() {} 127 | 128 | func (x *Profile) ProtoReflect() protoreflect.Message { 129 | mi := &file_user_proto_msgTypes[1] 130 | if protoimpl.UnsafeEnabled && x != nil { 131 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 132 | if ms.LoadMessageInfo() == nil { 133 | ms.StoreMessageInfo(mi) 134 | } 135 | return ms 136 | } 137 | return mi.MessageOf(x) 138 | } 139 | 140 | // Deprecated: Use Profile.ProtoReflect.Descriptor instead. 141 | func (*Profile) Descriptor() ([]byte, []int) { 142 | return file_user_proto_rawDescGZIP(), []int{1} 143 | } 144 | 145 | func (x *Profile) GetUsername() string { 146 | if x != nil { 147 | return x.Username 148 | } 149 | return "" 150 | } 151 | 152 | func (x *Profile) GetImage() string { 153 | if x != nil { 154 | return x.Image 155 | } 156 | return "" 157 | } 158 | 159 | func (x *Profile) GetBio() string { 160 | if x != nil { 161 | return x.Bio 162 | } 163 | return "" 164 | } 165 | 166 | func (x *Profile) GetFollowing() bool { 167 | if x != nil { 168 | return x.Following 169 | } 170 | return false 171 | } 172 | 173 | var File_user_proto protoreflect.FileDescriptor 174 | 175 | var file_user_proto_rawDesc = []byte{ 176 | 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70, 0x62, 177 | 0x22, 0x76, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 178 | 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 179 | 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 180 | 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x62, 0x69, 181 | 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x62, 0x69, 0x6f, 0x12, 0x14, 0x0a, 0x05, 182 | 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 183 | 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 184 | 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x6b, 0x0a, 0x07, 0x50, 0x72, 0x6f, 0x66, 185 | 0x69, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 186 | 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 187 | 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 188 | 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x62, 0x69, 0x6f, 0x18, 0x03, 0x20, 0x01, 189 | 0x28, 0x09, 0x52, 0x03, 0x62, 0x69, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x6f, 0x6c, 0x6c, 0x6f, 190 | 0x77, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x66, 0x6f, 0x6c, 0x6c, 191 | 0x6f, 0x77, 0x69, 0x6e, 0x67, 0x42, 0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 192 | 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x61, 0x62, 0x61, 0x73, 0x75, 0x62, 0x61, 0x67, 0x69, 0x61, 0x2f, 193 | 0x72, 0x65, 0x61, 0x6c, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 194 | 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x64, 0x61, 0x70, 0x74, 195 | 0x65, 0x72, 0x2f, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x72, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 196 | 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 197 | } 198 | 199 | var ( 200 | file_user_proto_rawDescOnce sync.Once 201 | file_user_proto_rawDescData = file_user_proto_rawDesc 202 | ) 203 | 204 | func file_user_proto_rawDescGZIP() []byte { 205 | file_user_proto_rawDescOnce.Do(func() { 206 | file_user_proto_rawDescData = protoimpl.X.CompressGZIP(file_user_proto_rawDescData) 207 | }) 208 | return file_user_proto_rawDescData 209 | } 210 | 211 | var file_user_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 212 | var file_user_proto_goTypes = []interface{}{ 213 | (*User)(nil), // 0: pb.User 214 | (*Profile)(nil), // 1: pb.Profile 215 | } 216 | var file_user_proto_depIdxs = []int32{ 217 | 0, // [0:0] is the sub-list for method output_type 218 | 0, // [0:0] is the sub-list for method input_type 219 | 0, // [0:0] is the sub-list for extension type_name 220 | 0, // [0:0] is the sub-list for extension extendee 221 | 0, // [0:0] is the sub-list for field type_name 222 | } 223 | 224 | func init() { file_user_proto_init() } 225 | func file_user_proto_init() { 226 | if File_user_proto != nil { 227 | return 228 | } 229 | if !protoimpl.UnsafeEnabled { 230 | file_user_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 231 | switch v := v.(*User); i { 232 | case 0: 233 | return &v.state 234 | case 1: 235 | return &v.sizeCache 236 | case 2: 237 | return &v.unknownFields 238 | default: 239 | return nil 240 | } 241 | } 242 | file_user_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 243 | switch v := v.(*Profile); i { 244 | case 0: 245 | return &v.state 246 | case 1: 247 | return &v.sizeCache 248 | case 2: 249 | return &v.unknownFields 250 | default: 251 | return nil 252 | } 253 | } 254 | } 255 | type x struct{} 256 | out := protoimpl.TypeBuilder{ 257 | File: protoimpl.DescBuilder{ 258 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 259 | RawDescriptor: file_user_proto_rawDesc, 260 | NumEnums: 0, 261 | NumMessages: 2, 262 | NumExtensions: 0, 263 | NumServices: 0, 264 | }, 265 | GoTypes: file_user_proto_goTypes, 266 | DependencyIndexes: file_user_proto_depIdxs, 267 | MessageInfos: file_user_proto_msgTypes, 268 | }.Build() 269 | File_user_proto = out.File 270 | file_user_proto_rawDesc = nil 271 | file_user_proto_goTypes = nil 272 | file_user_proto_depIdxs = nil 273 | } 274 | -------------------------------------------------------------------------------- /internal/adapter/repository/sql/article.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/sql/model" 7 | "github.com/labasubagia/realworld-backend/internal/core/domain" 8 | "github.com/labasubagia/realworld-backend/internal/core/port" 9 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 10 | "github.com/uptrace/bun" 11 | ) 12 | 13 | type articleRepo struct { 14 | db bun.IDB 15 | } 16 | 17 | func NewArticleRepository(db bun.IDB) port.ArticleRepository { 18 | return &articleRepo{ 19 | db: db, 20 | } 21 | } 22 | 23 | func (r *articleRepo) CreateArticle(ctx context.Context, arg domain.Article) (domain.Article, error) { 24 | article := model.AsArticle(arg) 25 | _, err := r.db.NewInsert().Model(&article).Exec(ctx) 26 | if err != nil { 27 | return domain.Article{}, intoException(err) 28 | } 29 | return article.ToDomain(), nil 30 | } 31 | 32 | func (r *articleRepo) UpdateArticle(ctx context.Context, arg domain.Article) (domain.Article, error) { 33 | if arg.Title != "" { 34 | arg.SetTitle(arg.Title) 35 | } 36 | article := model.AsArticle(arg) 37 | 38 | // update 39 | _, err := r.db.NewUpdate().Model(&article).OmitZero().Where("id = ?", article.ID).Exec(ctx) 40 | if err != nil { 41 | return domain.Article{}, intoException(err) 42 | } 43 | 44 | // find updated 45 | updated, err := r.FindOneArticle(ctx, port.FilterArticlePayload{IDs: []domain.ID{article.ID}}) 46 | if err != nil { 47 | return domain.Article{}, intoException(err) 48 | } 49 | 50 | return updated, err 51 | } 52 | 53 | func (r *articleRepo) DeleteArticle(ctx context.Context, arg domain.Article) error { 54 | article := model.AsArticle(arg) 55 | _, err := r.db.NewDelete(). 56 | Model(&article). 57 | Where("id = ?", article.ID). 58 | Where("slug = ?", article.Slug). 59 | Exec(ctx) 60 | if err != nil { 61 | return intoException(err) 62 | } 63 | return nil 64 | } 65 | 66 | func (r *articleRepo) FilterArticle(ctx context.Context, filter port.FilterArticlePayload) ([]domain.Article, error) { 67 | articles := []model.Article{} 68 | query := r.db.NewSelect().Model(&articles) 69 | if len(filter.IDs) > 0 { 70 | query = query.Where("id IN (?)", bun.In(filter.IDs)) 71 | } 72 | if len(filter.Slugs) > 0 { 73 | query = query.Where("slug IN (?)", bun.In(filter.Slugs)) 74 | } 75 | if len(filter.AuthorIDs) > 0 { 76 | query = query.Where("author_id IN (?)", bun.In(filter.AuthorIDs)) 77 | } 78 | if filter.Limit > 0 { 79 | query = query.Limit(filter.Limit) 80 | } 81 | query = query.Offset(filter.Offset) 82 | query = query.Order("created_at DESC") 83 | err := query.Scan(ctx) 84 | if err != nil { 85 | return []domain.Article{}, nil 86 | } 87 | result := []domain.Article{} 88 | for _, article := range articles { 89 | result = append(result, article.ToDomain()) 90 | } 91 | return result, nil 92 | } 93 | 94 | func (r *articleRepo) FindOneArticle(ctx context.Context, filter port.FilterArticlePayload) (domain.Article, error) { 95 | articles, err := r.FilterArticle(ctx, filter) 96 | if err != nil { 97 | return domain.Article{}, intoException(err) 98 | } 99 | if len(articles) == 0 { 100 | return domain.Article{}, exception.New(exception.TypeNotFound, "article not found", nil) 101 | } 102 | return articles[0], nil 103 | } 104 | 105 | func (r *articleRepo) FilterTags(ctx context.Context, filter port.FilterTagPayload) ([]domain.Tag, error) { 106 | tags := []model.Tag{} 107 | query := r.db.NewSelect().Model(&tags) 108 | if len(filter.IDs) > 0 { 109 | query = query.Where("id IN (?)", bun.In(filter.IDs)) 110 | } 111 | if len(filter.Names) > 0 { 112 | query = query.Where("name IN (?)", bun.In(filter.Names)) 113 | } 114 | query = query.Order("name ASC") 115 | err := query.Scan(ctx) 116 | if err != nil { 117 | return []domain.Tag{}, intoException(err) 118 | } 119 | 120 | result := []domain.Tag{} 121 | for _, tag := range tags { 122 | result = append(result, tag.ToDomain()) 123 | } 124 | return result, nil 125 | } 126 | 127 | func (r *articleRepo) AddTags(ctx context.Context, arg port.AddTagsPayload) ([]domain.Tag, error) { 128 | if len(arg.Tags) == 0 { 129 | return []domain.Tag{}, exception.Validation().AddError("tags", "empty") 130 | } 131 | 132 | existing, err := r.FilterTags(ctx, port.FilterTagPayload{Names: arg.Tags}) 133 | if err != nil { 134 | return []domain.Tag{}, intoException(err) 135 | } 136 | existMap := map[string]domain.Tag{} 137 | for _, tag := range existing { 138 | existMap[tag.Name] = tag 139 | } 140 | 141 | newTags := []model.Tag{} 142 | for _, tag := range arg.Tags { 143 | if _, exist := existMap[tag]; exist { 144 | continue 145 | } 146 | newTag := domain.NewTag(domain.Tag{Name: tag}) 147 | newTags = append(newTags, model.AsTag(newTag)) 148 | } 149 | if len(newTags) > 0 { 150 | _, err = r.db.NewInsert().Model(&newTags).Returning("*").Exec(ctx) 151 | if err != nil { 152 | return []domain.Tag{}, intoException(err) 153 | } 154 | } 155 | 156 | resultNewTags := []domain.Tag{} 157 | for _, tag := range newTags { 158 | resultNewTags = append(resultNewTags, tag.ToDomain()) 159 | } 160 | 161 | // return existing and new tags 162 | return append(existing, resultNewTags...), nil 163 | } 164 | 165 | func (r *articleRepo) AssignArticleTags(ctx context.Context, arg port.AssignTagPayload) ([]domain.ArticleTag, error) { 166 | if len(arg.TagIDs) == 0 { 167 | return []domain.ArticleTag{}, exception.Validation().AddError("tags", "empty") 168 | } 169 | 170 | articleTags := make([]model.ArticleTag, len(arg.TagIDs)) 171 | for i, tagID := range arg.TagIDs { 172 | articleTags[i] = model.ArticleTag{ 173 | ArticleID: arg.ArticleID, 174 | TagID: tagID, 175 | } 176 | } 177 | _, err := r.db.NewInsert().Model(&articleTags).Exec(ctx) 178 | if err != nil { 179 | return []domain.ArticleTag{}, intoException(err) 180 | } 181 | 182 | result := []domain.ArticleTag{} 183 | for _, tag := range articleTags { 184 | result = append(result, tag.ToDomain()) 185 | } 186 | 187 | return result, nil 188 | } 189 | 190 | func (r *articleRepo) FilterArticleTags(ctx context.Context, arg port.FilterArticleTagPayload) ([]domain.ArticleTag, error) { 191 | articleTags := []model.ArticleTag{} 192 | query := r.db.NewSelect().Model(&articleTags) 193 | if len(arg.TagIDs) > 0 { 194 | query = query.Where("tag_id IN (?)", bun.In(arg.TagIDs)) 195 | } 196 | if len(arg.ArticleIDs) > 0 { 197 | query = query.Where("article_id IN (?)", bun.In(arg.ArticleIDs)) 198 | } 199 | err := query.Scan(ctx) 200 | if err != nil { 201 | return []domain.ArticleTag{}, intoException(err) 202 | } 203 | 204 | result := []domain.ArticleTag{} 205 | for _, articleTag := range articleTags { 206 | result = append(result, articleTag.ToDomain()) 207 | } 208 | return result, nil 209 | } 210 | 211 | func (r *articleRepo) AddFavorite(ctx context.Context, arg domain.ArticleFavorite) (domain.ArticleFavorite, error) { 212 | favorite := model.AsArticleFavorite(arg) 213 | _, err := r.db.NewInsert().Model(&favorite).Exec(ctx) 214 | if err != nil { 215 | return domain.ArticleFavorite{}, intoException(err) 216 | } 217 | return favorite.ToDomain(), nil 218 | } 219 | 220 | func (r *articleRepo) RemoveFavorite(ctx context.Context, arg domain.ArticleFavorite) (domain.ArticleFavorite, error) { 221 | favorite := model.AsArticleFavorite(arg) 222 | _, err := r.db.NewDelete(). 223 | Model(&favorite). 224 | Where("article_id = ?", favorite.ArticleID). 225 | Where("user_id = ?", favorite.UserID). 226 | Exec(ctx) 227 | if err != nil { 228 | return domain.ArticleFavorite{}, intoException(err) 229 | } 230 | return favorite.ToDomain(), nil 231 | } 232 | 233 | func (r *articleRepo) FilterFavorite(ctx context.Context, arg port.FilterFavoritePayload) ([]domain.ArticleFavorite, error) { 234 | articleFavorites := []model.ArticleFavorite{} 235 | query := r.db.NewSelect().Model(&articleFavorites) 236 | if len(arg.UserIDs) > 0 { 237 | query = query.Where("user_id IN (?)", bun.In(arg.UserIDs)) 238 | } 239 | if len(arg.ArticleIDs) > 0 { 240 | query = query.Where("article_id IN (?)", bun.In(arg.ArticleIDs)) 241 | } 242 | err := query.Scan(ctx) 243 | if err != nil { 244 | return []domain.ArticleFavorite{}, intoException(err) 245 | } 246 | 247 | result := []domain.ArticleFavorite{} 248 | for _, articleFavorite := range articleFavorites { 249 | result = append(result, articleFavorite.ToDomain()) 250 | } 251 | return result, nil 252 | } 253 | 254 | func (r *articleRepo) FilterFavoriteCount(ctx context.Context, filter port.FilterFavoritePayload) ([]domain.ArticleFavoriteCount, error) { 255 | counts := []model.ArticleFavoriteCount{} 256 | err := r.db.NewSelect(). 257 | Model(&counts). 258 | Column("article_id"). 259 | ColumnExpr("count(article_id) as favorite_count"). 260 | Group("article_id"). 261 | Scan(ctx) 262 | if err != nil { 263 | return []domain.ArticleFavoriteCount{}, intoException(err) 264 | } 265 | result := []domain.ArticleFavoriteCount{} 266 | for _, count := range counts { 267 | result = append(result, count.ToDomain()) 268 | } 269 | return result, nil 270 | } 271 | 272 | func (r *articleRepo) AddComment(ctx context.Context, arg domain.Comment) (domain.Comment, error) { 273 | comment := model.AsComment(arg) 274 | _, err := r.db.NewInsert().Model(&comment).Exec(ctx) 275 | if err != nil { 276 | return domain.Comment{}, intoException(err) 277 | } 278 | return comment.ToDomain(), nil 279 | } 280 | 281 | func (r *articleRepo) FilterComment(ctx context.Context, arg port.FilterCommentPayload) ([]domain.Comment, error) { 282 | comments := []model.Comment{} 283 | query := r.db.NewSelect().Model(&comments) 284 | if len(arg.ArticleIDs) > 0 { 285 | query = query.Where("article_id IN (?)", bun.In(arg.ArticleIDs)) 286 | } 287 | if len(arg.AuthorIDs) > 0 { 288 | query = query.Where("author_id IN (?)", bun.In(arg.AuthorIDs)) 289 | } 290 | err := query.Scan(ctx) 291 | if err != nil { 292 | return []domain.Comment{}, intoException(err) 293 | } 294 | result := []domain.Comment{} 295 | for _, comment := range comments { 296 | result = append(result, comment.ToDomain()) 297 | } 298 | return result, nil 299 | } 300 | 301 | func (r *articleRepo) DeleteComment(ctx context.Context, arg domain.Comment) error { 302 | comment := model.AsComment(arg) 303 | _, err := r.db.NewDelete(). 304 | Model(&comment). 305 | Where("id = ?", comment.ID). 306 | Where("author_id = ?", comment.AuthorID). 307 | Where("article_id = ?", comment.ArticleID). 308 | Exec(ctx) 309 | if err != nil { 310 | return intoException(err) 311 | } 312 | return nil 313 | } 314 | -------------------------------------------------------------------------------- /internal/adapter/repository/mongo/article.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/labasubagia/realworld-backend/internal/adapter/repository/mongo/model" 8 | "github.com/labasubagia/realworld-backend/internal/core/domain" 9 | "github.com/labasubagia/realworld-backend/internal/core/port" 10 | "github.com/labasubagia/realworld-backend/internal/core/util/exception" 11 | "go.mongodb.org/mongo-driver/bson" 12 | "go.mongodb.org/mongo-driver/mongo/options" 13 | ) 14 | 15 | type articleRepo struct { 16 | db DB 17 | } 18 | 19 | func NewArticleRepository(db DB) port.ArticleRepository { 20 | return &articleRepo{ 21 | db: db, 22 | } 23 | } 24 | 25 | func (r *articleRepo) AddComment(ctx context.Context, arg domain.Comment) (domain.Comment, error) { 26 | comment := model.AsComment(arg) 27 | _, err := r.db.Collection(CollectionComment).InsertOne(ctx, comment) 28 | if err != nil { 29 | return domain.Comment{}, intoException(err) 30 | } 31 | return comment.ToDomain(), nil 32 | } 33 | 34 | func (r *articleRepo) AddFavorite(ctx context.Context, arg domain.ArticleFavorite) (domain.ArticleFavorite, error) { 35 | favorite := model.AsArticleFavorite(arg) 36 | _, err := r.db.Collection(CollectionArticleFavorite).InsertOne(ctx, favorite) 37 | if err != nil { 38 | return domain.ArticleFavorite{}, intoException(err) 39 | } 40 | return favorite.ToDomain(), nil 41 | } 42 | 43 | func (r *articleRepo) AddTags(ctx context.Context, arg port.AddTagsPayload) ([]domain.Tag, error) { 44 | 45 | if len(arg.Tags) == 0 { 46 | return []domain.Tag{}, exception.Validation().AddError("tags", "empty") 47 | } 48 | 49 | existing, err := r.FilterTags(ctx, port.FilterTagPayload{Names: arg.Tags}) 50 | if err != nil { 51 | return []domain.Tag{}, intoException(err) 52 | } 53 | existMap := map[string]domain.Tag{} 54 | for _, tag := range existing { 55 | existMap[tag.Name] = tag 56 | } 57 | 58 | newTags := []any{} 59 | for _, tag := range arg.Tags { 60 | if _, exist := existMap[tag]; exist { 61 | continue 62 | } 63 | newTag := domain.NewTag(domain.Tag{Name: tag}) 64 | newTags = append(newTags, model.AsTag(newTag)) 65 | } 66 | if len(newTags) > 0 { 67 | _, err = r.db.Collection(CollectionTag).InsertMany(ctx, newTags) 68 | if err != nil { 69 | return []domain.Tag{}, intoException(err) 70 | } 71 | } 72 | 73 | resultNewTags := []domain.Tag{} 74 | for _, tag := range newTags { 75 | tag, ok := tag.(model.Tag) 76 | if !ok { 77 | continue 78 | } 79 | resultNewTags = append(resultNewTags, tag.ToDomain()) 80 | } 81 | 82 | // return existing and new tags 83 | return append(existing, resultNewTags...), nil 84 | 85 | } 86 | 87 | func (r *articleRepo) AssignArticleTags(ctx context.Context, arg port.AssignTagPayload) ([]domain.ArticleTag, error) { 88 | if len(arg.TagIDs) == 0 { 89 | return []domain.ArticleTag{}, exception.Validation().AddError("tags", "empty") 90 | } 91 | 92 | articleTags := make([]any, len(arg.TagIDs)) 93 | for i, tagID := range arg.TagIDs { 94 | articleTags[i] = model.ArticleTag{ 95 | ArticleID: arg.ArticleID, 96 | TagID: tagID, 97 | } 98 | } 99 | _, err := r.db.Collection(CollectionArticleTag).InsertMany(ctx, articleTags) 100 | if err != nil { 101 | return []domain.ArticleTag{}, intoException(err) 102 | } 103 | 104 | result := []domain.ArticleTag{} 105 | for _, tag := range articleTags { 106 | tag, ok := tag.(model.ArticleTag) 107 | if !ok { 108 | continue 109 | } 110 | result = append(result, tag.ToDomain()) 111 | } 112 | 113 | return result, nil 114 | } 115 | 116 | func (r *articleRepo) CreateArticle(ctx context.Context, arg domain.Article) (domain.Article, error) { 117 | article := model.AsArticle(arg) 118 | _, err := r.db.Collection(CollectionArticle).InsertOne(ctx, article) 119 | if err != nil { 120 | return domain.Article{}, intoException(err) 121 | } 122 | return article.ToDomain(), nil 123 | } 124 | 125 | func (r *articleRepo) DeleteArticle(ctx context.Context, arg domain.Article) error { 126 | _, err := r.db.Collection(CollectionArticle).DeleteOne(ctx, bson.M{ 127 | "id": arg.ID, 128 | "slug": arg.Slug, 129 | }) 130 | if err != nil { 131 | return intoException(err) 132 | } 133 | return nil 134 | } 135 | 136 | func (r *articleRepo) DeleteComment(ctx context.Context, arg domain.Comment) error { 137 | _, err := r.db.Collection(CollectionComment).DeleteOne(ctx, bson.M{ 138 | "id": arg.ID, 139 | "author_id": arg.AuthorID, 140 | "article_id": arg.ArticleID, 141 | }) 142 | if err != nil { 143 | return intoException(err) 144 | } 145 | return nil 146 | } 147 | 148 | func (r *articleRepo) FilterArticle(ctx context.Context, arg port.FilterArticlePayload) ([]domain.Article, error) { 149 | 150 | query := []bson.M{} 151 | if len(arg.IDs) > 0 { 152 | query = append(query, bson.M{"id": bson.M{"$in": arg.IDs}}) 153 | } 154 | if len(arg.AuthorIDs) > 0 { 155 | query = append(query, bson.M{"author_id": bson.M{"$in": arg.AuthorIDs}}) 156 | } 157 | if len(arg.Slugs) > 0 { 158 | query = append(query, bson.M{"slug": bson.M{"$in": arg.Slugs}}) 159 | } 160 | if len(arg.Slugs) > 0 { 161 | query = append(query, bson.M{"slug": bson.M{"$in": arg.Slugs}}) 162 | } 163 | filter := bson.M{} 164 | if len(query) > 0 { 165 | filter = bson.M{"$and": query} 166 | } 167 | 168 | limit := int64(arg.Limit) 169 | offset := int64(arg.Offset) 170 | option := options.FindOptions{Limit: &limit, Skip: &offset, Sort: bson.M{"id": -1}} 171 | 172 | cursor, err := r.db.Collection(CollectionArticle).Find(ctx, filter, &option) 173 | if err != nil { 174 | return []domain.Article{}, intoException(err) 175 | } 176 | 177 | result := []domain.Article{} 178 | for cursor.Next(ctx) { 179 | data := model.Article{} 180 | if err := cursor.Decode(&data); err != nil { 181 | return []domain.Article{}, intoException(err) 182 | } 183 | result = append(result, data.ToDomain()) 184 | } 185 | 186 | return result, nil 187 | } 188 | 189 | func (r *articleRepo) FilterArticleTags(ctx context.Context, arg port.FilterArticleTagPayload) ([]domain.ArticleTag, error) { 190 | 191 | query := []bson.M{} 192 | if len(arg.ArticleIDs) > 0 { 193 | query = append(query, bson.M{"article_id": bson.M{"$in": arg.ArticleIDs}}) 194 | } 195 | if len(arg.TagIDs) > 0 { 196 | query = append(query, bson.M{"tag_id": bson.M{"$in": arg.TagIDs}}) 197 | } 198 | filter := bson.M{} 199 | if len(query) > 0 { 200 | filter = bson.M{"$and": query} 201 | } 202 | 203 | cursor, err := r.db.Collection(CollectionArticleTag).Find(ctx, filter) 204 | if err != nil { 205 | return []domain.ArticleTag{}, intoException(err) 206 | } 207 | 208 | result := []domain.ArticleTag{} 209 | for cursor.Next(ctx) { 210 | data := model.ArticleTag{} 211 | if err := cursor.Decode(&data); err != nil { 212 | return []domain.ArticleTag{}, intoException(err) 213 | } 214 | result = append(result, data.ToDomain()) 215 | } 216 | 217 | return result, nil 218 | } 219 | 220 | func (r *articleRepo) FilterComment(ctx context.Context, arg port.FilterCommentPayload) ([]domain.Comment, error) { 221 | query := []bson.M{} 222 | if len(arg.ArticleIDs) > 0 { 223 | query = append(query, bson.M{"article_id": bson.M{"$in": arg.ArticleIDs}}) 224 | } 225 | if len(arg.AuthorIDs) > 0 { 226 | query = append(query, bson.M{"author_id": bson.M{"$in": arg.AuthorIDs}}) 227 | } 228 | filter := bson.M{} 229 | if len(query) > 0 { 230 | filter = bson.M{"$and": query} 231 | } 232 | 233 | cursor, err := r.db.Collection(CollectionComment).Find(ctx, filter) 234 | if err != nil { 235 | return []domain.Comment{}, intoException(err) 236 | } 237 | 238 | result := []domain.Comment{} 239 | for cursor.Next(ctx) { 240 | data := model.Comment{} 241 | if err := cursor.Decode(&data); err != nil { 242 | return []domain.Comment{}, intoException(err) 243 | } 244 | result = append(result, data.ToDomain()) 245 | } 246 | 247 | return result, nil 248 | } 249 | 250 | func (r *articleRepo) FilterFavorite(ctx context.Context, arg port.FilterFavoritePayload) ([]domain.ArticleFavorite, error) { 251 | query := []bson.M{} 252 | if len(arg.ArticleIDs) > 0 { 253 | query = append(query, bson.M{"article_id": bson.M{"$in": arg.ArticleIDs}}) 254 | } 255 | if len(arg.UserIDs) > 0 { 256 | query = append(query, bson.M{"user_id": bson.M{"$in": arg.UserIDs}}) 257 | } 258 | filter := bson.M{} 259 | if len(query) > 0 { 260 | filter = bson.M{"$and": query} 261 | } 262 | 263 | cursor, err := r.db.Collection(CollectionArticleFavorite).Find(ctx, filter) 264 | if err != nil { 265 | return []domain.ArticleFavorite{}, intoException(err) 266 | } 267 | 268 | result := []domain.ArticleFavorite{} 269 | for cursor.Next(ctx) { 270 | data := model.ArticleFavorite{} 271 | if err := cursor.Decode(&data); err != nil { 272 | return []domain.ArticleFavorite{}, intoException(err) 273 | } 274 | result = append(result, data.ToDomain()) 275 | } 276 | 277 | return result, nil 278 | } 279 | 280 | func (r *articleRepo) FilterFavoriteCount(ctx context.Context, arg port.FilterFavoritePayload) ([]domain.ArticleFavoriteCount, error) { 281 | 282 | groupStage := bson.A{} 283 | 284 | // match query stage 285 | matchQueryItems := []bson.M{} 286 | if len(arg.ArticleIDs) > 0 { 287 | matchQueryItems = append(matchQueryItems, bson.M{"article_id": bson.M{"$in": arg.ArticleIDs}}) 288 | } 289 | if len(arg.UserIDs) > 0 { 290 | matchQueryItems = append(matchQueryItems, bson.M{"user_id": bson.M{"$in": arg.UserIDs}}) 291 | } 292 | if len(matchQueryItems) > 0 { 293 | groupStage = append(groupStage, bson.M{ 294 | "$match": bson.M{"$and": matchQueryItems}, 295 | }) 296 | } 297 | 298 | groupStage = append( 299 | groupStage, 300 | // grouping stage 301 | bson.M{ 302 | "$group": bson.M{ 303 | "_id": "$article_id", 304 | "favorite_count": bson.M{ 305 | "$count": bson.M{}, 306 | }, 307 | }, 308 | }, 309 | // custom field stage 310 | bson.M{ 311 | "$addFields": bson.M{ 312 | "article_id": "$_id", 313 | "_id": "$$REMOVE", 314 | }, 315 | }, 316 | ) 317 | 318 | cursor, err := r.db.Collection(CollectionArticleFavorite).Aggregate(ctx, groupStage) 319 | if err != nil { 320 | return []domain.ArticleFavoriteCount{}, intoException(err) 321 | } 322 | 323 | result := []domain.ArticleFavoriteCount{} 324 | for cursor.Next(ctx) { 325 | data := model.ArticleFavoriteCount{} 326 | if err := cursor.Decode(&data); err != nil { 327 | return []domain.ArticleFavoriteCount{}, intoException(err) 328 | } 329 | result = append(result, data.ToDomain()) 330 | } 331 | 332 | return result, nil 333 | } 334 | 335 | func (r *articleRepo) FilterTags(ctx context.Context, arg port.FilterTagPayload) ([]domain.Tag, error) { 336 | 337 | query := []bson.M{} 338 | if len(arg.IDs) > 0 { 339 | query = append(query, bson.M{"id": bson.M{"$in": arg.IDs}}) 340 | } 341 | if len(arg.Names) > 0 { 342 | query = append(query, bson.M{"name": bson.M{"$in": arg.Names}}) 343 | } 344 | filter := bson.M{} 345 | if len(query) > 0 { 346 | filter = bson.M{"$and": query} 347 | } 348 | option := options.FindOptions{Sort: bson.M{"name": 1}} 349 | 350 | cursor, err := r.db.Collection(CollectionTag).Find(ctx, filter, &option) 351 | if err != nil { 352 | return []domain.Tag{}, intoException(err) 353 | } 354 | 355 | result := []domain.Tag{} 356 | for cursor.Next(ctx) { 357 | tag := model.Tag{} 358 | if err := cursor.Decode(&tag); err != nil { 359 | return []domain.Tag{}, intoException(err) 360 | } 361 | result = append(result, tag.ToDomain()) 362 | } 363 | 364 | return result, nil 365 | } 366 | 367 | func (r *articleRepo) FindOneArticle(ctx context.Context, arg port.FilterArticlePayload) (domain.Article, error) { 368 | articles, err := r.FilterArticle(ctx, arg) 369 | if err != nil { 370 | return domain.Article{}, intoException(err) 371 | } 372 | if len(articles) == 0 { 373 | return domain.Article{}, exception.New(exception.TypeNotFound, "article not found", nil) 374 | } 375 | return articles[0], nil 376 | } 377 | 378 | func (r *articleRepo) RemoveFavorite(ctx context.Context, arg domain.ArticleFavorite) (domain.ArticleFavorite, error) { 379 | favorite := model.AsArticleFavorite(arg) 380 | _, err := r.db.Collection(CollectionArticleFavorite).DeleteOne(ctx, bson.M{ 381 | "user_id": arg.UserID, 382 | "article_id": arg.ArticleID, 383 | }) 384 | if err != nil { 385 | return domain.ArticleFavorite{}, intoException(err) 386 | } 387 | return favorite.ToDomain(), nil 388 | } 389 | 390 | func (r *articleRepo) UpdateArticle(ctx context.Context, arg domain.Article) (domain.Article, error) { 391 | if arg.Title != "" { 392 | arg.SetTitle(arg.Title) 393 | } 394 | article := model.AsArticle(arg) 395 | 396 | filter := bson.M{"_id": arg.ID} 397 | 398 | fields := bson.M{} 399 | if arg.Title != "" { 400 | fields["title"] = arg.Title 401 | } 402 | if arg.Slug != "" { 403 | fields["slug"] = arg.Slug 404 | } 405 | if arg.Body != "" { 406 | fields["body"] = arg.Body 407 | } 408 | if arg.Description != "" { 409 | fields["description"] = arg.Description 410 | } 411 | if len(fields) > 0 { 412 | fields["updated_at"] = time.Now() 413 | } 414 | 415 | _, err := r.db.Collection(CollectionArticle).UpdateOne(ctx, filter, bson.M{"$set": fields}) 416 | if err != nil { 417 | return domain.Article{}, intoException(err) 418 | } 419 | 420 | // find updated 421 | updated, err := r.FindOneArticle(ctx, port.FilterArticlePayload{IDs: []domain.ID{article.ID}}) 422 | if err != nil { 423 | return domain.Article{}, intoException(err) 424 | } 425 | 426 | return updated, err 427 | } 428 | --------------------------------------------------------------------------------