├── scripts └── migrations │ ├── 000001_create_users.down.sql │ ├── 000002_posts_create.down.sql │ ├── 000005_add_comments.down.sql │ ├── 000012_add_roles_table.down.sql │ ├── 000007_add_followers_table.down.sql │ ├── 000009_add_invitation.down.sql │ ├── 000006_add_version_posts.down.sql │ ├── 000003_alter_post_table.down.sql │ ├── 000010_add_activated_to_user.down.sql │ ├── 000013_alter_users_with_roles.down.sql │ ├── 000006_add_version_posts.up.sql │ ├── 000011_add_expiry_to_invitations.down.sql │ ├── 000010_add_activated_to_user.up.sql │ ├── 000003_alter_post_table.up.sql │ ├── 000011_add_expiry_to_invitations.up.sql │ ├── 000004_alter_posts_with_tags_updated.down.sql │ ├── 000009_add_invitation.up.sql │ ├── 000004_alter_posts_with_tags_updated.up.sql │ ├── 000002_posts_create.up.sql │ ├── 000005_add_comments.up.sql │ ├── 000008_add_indexes.down.sql │ ├── 000001_create_users.up.sql │ ├── 000007_add_followers_table.up.sql │ ├── 000013_alter_users_with_roles.up.sql │ ├── 000008_add_indexes.up.sql │ └── 000012_add_roles_table.up.sql ├── logger └── logger.go ├── internal ├── service │ ├── service_models │ │ ├── role.go │ │ ├── follower.go │ │ ├── comment.go │ │ ├── post.go │ │ ├── user.go │ │ └── pagination.go │ ├── role.go │ ├── follower.go │ ├── cache.go │ ├── template │ │ └── user_invitation.tmpl │ ├── comment.go │ ├── authenticator.go │ ├── post.go │ ├── ratelimit.go │ ├── mailer.go │ ├── user.go │ └── seed.go ├── gateway │ ├── helper │ │ ├── validate.go │ │ ├── helper.go │ │ └── errors.go │ ├── routes │ │ ├── health.go │ │ ├── authentication.go │ │ ├── user.go │ │ ├── post.go │ │ └── RegisterRoutes.go │ ├── json │ │ └── json.go │ ├── handlers │ │ ├── health.go │ │ ├── feed.go │ │ ├── post.go │ │ ├── auth.go │ │ └── user.go │ ├── server.go │ └── middlewares │ │ └── middleware.go └── repository │ ├── error.go │ ├── ratelimit.go │ ├── role.go │ ├── cache.go │ ├── follower.go │ ├── comment.go │ ├── post.go │ └── user.go ├── utils ├── redis.go ├── transaction.go └── DBConnection.go ├── docker-compose.yml ├── cmd ├── http.go ├── root.go └── seed.go ├── main.go ├── .gitignore ├── Makefile ├── .github └── workflows │ └── ci.yml ├── go.mod ├── config └── config.go ├── go.sum └── docs ├── swagger.yaml ├── swagger.json └── docs.go /scripts/migrations/000001_create_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; -------------------------------------------------------------------------------- /scripts/migrations/000002_posts_create.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS posts; -------------------------------------------------------------------------------- /scripts/migrations/000005_add_comments.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS comments; -------------------------------------------------------------------------------- /scripts/migrations/000012_add_roles_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS roles; -------------------------------------------------------------------------------- /scripts/migrations/000007_add_followers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS followers; -------------------------------------------------------------------------------- /scripts/migrations/000009_add_invitation.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS user_invitations; -------------------------------------------------------------------------------- /scripts/migrations/000006_add_version_posts.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | POSTS DROP COLUMN version; -------------------------------------------------------------------------------- /scripts/migrations/000003_alter_post_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | posts DROP CONSTRAINT fk_user; -------------------------------------------------------------------------------- /scripts/migrations/000010_add_activated_to_user.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | users DROP COLUMN is_active; -------------------------------------------------------------------------------- /scripts/migrations/000013_alter_users_with_roles.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | IF EXISTS users DROP COLUMN role_id; -------------------------------------------------------------------------------- /scripts/migrations/000006_add_version_posts.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | posts 3 | ADD 4 | COLUMN version INT DEFAULT 0; -------------------------------------------------------------------------------- /scripts/migrations/000011_add_expiry_to_invitations.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | user_invitations DROP COLUMN expiry; -------------------------------------------------------------------------------- /scripts/migrations/000010_add_activated_to_user.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | users 3 | ADD 4 | COLUMN is_active BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | ) 7 | 8 | var Logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) 9 | -------------------------------------------------------------------------------- /scripts/migrations/000003_alter_post_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | posts 3 | ADD 4 | CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id); -------------------------------------------------------------------------------- /scripts/migrations/000011_add_expiry_to_invitations.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | user_invitations 3 | ADD 4 | COLUMN expiry TIMESTAMP(0) WITH TIME ZONE NOT NULL; -------------------------------------------------------------------------------- /scripts/migrations/000004_alter_posts_with_tags_updated.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | posts DROP COLUMN tags; 3 | 4 | ALTER TABLE 5 | posts DROP COLUMN updated_at; -------------------------------------------------------------------------------- /scripts/migrations/000009_add_invitation.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user_invitations ( 2 | token bytea PRIMARY KEY, 3 | user_id bigint NOT NULL 4 | ) 5 | 6 | -------------------------------------------------------------------------------- /scripts/migrations/000004_alter_posts_with_tags_updated.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | posts 3 | ADD 4 | COLUMN tags VARCHAR(100) []; 5 | 6 | ALTER TABLE 7 | posts 8 | ADD 9 | COLUMN updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW(); -------------------------------------------------------------------------------- /internal/service/service_models/role.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | type Role struct { 4 | ID int64 `json:"id"` 5 | Name string `json:"name"` 6 | Description string `json:"description"` 7 | Level int64 `json:"level"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/gateway/helper/validate.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import "github.com/go-playground/validator/v10" 4 | 5 | var Validate *validator.Validate 6 | 7 | func init() { 8 | Validate = validator.New(validator.WithRequiredStructEnabled()) 9 | } 10 | -------------------------------------------------------------------------------- /scripts/migrations/000002_posts_create.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS posts ( 2 | id bigserial PRIMARY KEY, 3 | title text NOT NULL, 4 | user_id bigint NOT NULL, 5 | content text NOT NULL, 6 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW() 7 | ); -------------------------------------------------------------------------------- /scripts/migrations/000005_add_comments.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS comments ( 2 | id bigserial PRIMARY KEY, 3 | post_id bigserial NOT NULL, 4 | user_id bigserial NOT NULL, 5 | content TEXT NOT NULL, 6 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW() 7 | ); -------------------------------------------------------------------------------- /scripts/migrations/000008_add_indexes.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS idx_posts_title; 2 | 3 | DROP INDEX IF EXISTS idx_posts_tags; 4 | 5 | DROP INDEX IF EXISTS idx_comments_content; 6 | 7 | DROP INDEX IF EXISTS idx_users_username; 8 | 9 | DROP INDEX IF EXISTS idx_posts_user_id; 10 | 11 | DROP INDEX IF EXISTS idx_comments_post_id; -------------------------------------------------------------------------------- /scripts/migrations/000001_create_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS citext; 2 | 3 | CREATE TABLE IF NOT EXISTS users( 4 | id bigserial PRIMARY KEY, 5 | email citext UNIQUE NOT NULL, 6 | username varchar(255) UNIQUE NOT NULL, 7 | password bytea NOT NULL, 8 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW() 9 | ); -------------------------------------------------------------------------------- /internal/service/service_models/follower.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | import "time" 4 | 5 | type Follower struct { 6 | UserId int64 `json:"user_id"` 7 | FollowerId int64 `json:"follower_id"` 8 | CreatedAt time.Time `json:"created_at"` 9 | } 10 | 11 | type FollowUser struct { 12 | UserID int64 `json:"user_id"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/service_models/comment.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | import "time" 4 | 5 | type Comment struct { 6 | ID int64 `json:"id"` 7 | PostID int64 `json:"post_id"` 8 | UserID int64 `json:"user_id"` 9 | Content string `json:"content"` 10 | CreatedAt time.Time `json:"created_at"` 11 | User User `json:"user"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/repository/error.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrsNotFound = errors.New("resource not found") 7 | ErrsConflict = errors.New("resource already exists") 8 | ErrDuplicateUsername = errors.New("duplicate username") 9 | ErrDuplicateEmail = errors.New("duplicate email") 10 | ErrRateLimitExceeded = errors.New("rate limit exceeded") 11 | ) 12 | -------------------------------------------------------------------------------- /utils/redis.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/go-redis/redis/v8" 5 | "github.com/saleh-ghazimoradi/Gophergram/logger" 6 | ) 7 | 8 | func RedisConnection(addr, pw string, db int) (*redis.Client, error) { 9 | logger.Logger.Info("Connecting to Redis...") 10 | return redis.NewClient(&redis.Options{ 11 | Addr: addr, 12 | Password: pw, 13 | DB: db, 14 | }), nil 15 | 16 | } 17 | -------------------------------------------------------------------------------- /scripts/migrations/000007_add_followers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS followers ( 2 | user_id bigint NOT NULL, 3 | follower_id bigint NOT NULL, 4 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), 5 | 6 | PRIMARY KEY (user_id, follower_id), 7 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 8 | FOREIGN KEY (follower_id) REFERENCES users (id) ON DELETE CASCADE 9 | ); 10 | -------------------------------------------------------------------------------- /scripts/migrations/000013_alter_users_with_roles.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | IF EXISTS users 3 | ADD 4 | COLUMN role_id INT REFERENCES roles(id) DEFAULT 1; 5 | 6 | UPDATE 7 | users 8 | SET 9 | role_id = ( 10 | SELECT 11 | id 12 | FROM 13 | roles 14 | WHERE 15 | name = 'user' 16 | ); 17 | 18 | ALTER TABLE 19 | users 20 | ALTER COLUMN 21 | role_id DROP DEFAULT; 22 | 23 | ALTER TABLE 24 | users 25 | ALTER COLUMN 26 | role_id 27 | SET 28 | NOT NULL; -------------------------------------------------------------------------------- /utils/transaction.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | type TxFunc func(tx *sql.Tx) error 9 | 10 | func WithTransaction(ctx context.Context, db *sql.DB, fn TxFunc) error { 11 | tx, err := db.BeginTx(ctx, nil) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | defer func() { 17 | if p := recover(); p != nil { 18 | _ = tx.Rollback() 19 | panic(p) 20 | } else if err != nil { 21 | _ = tx.Rollback() 22 | } else { 23 | err = tx.Commit() 24 | } 25 | }() 26 | 27 | err = fn(tx) 28 | 29 | return err 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16.3 4 | container_name: GopherGram 5 | env_file: 6 | - app.env 7 | environment: 8 | POSTGRES_DB: ${DB_NAME} 9 | POSTGRES_USER: ${DB_USER} 10 | POSTGRES_PASSWORD: ${DB_PASSWORD} 11 | volumes: 12 | - db-data:/var/lib/postgresql/data 13 | ports: 14 | - "5415:5432" 15 | 16 | redis: 17 | image: redis:6.2-alpine 18 | restart: unless-stopped 19 | container_name: redis 20 | ports: 21 | - "6379:6379" 22 | command: redis-server --save 60 1 --loglevel warning 23 | 24 | volumes: 25 | db-data: -------------------------------------------------------------------------------- /cmd/http.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway" 8 | "github.com/saleh-ghazimoradi/Gophergram/logger" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // httpCmd represents the http command 13 | var httpCmd = &cobra.Command{ 14 | Use: "http", 15 | Short: "Launching the app via http", 16 | 17 | Run: func(cmd *cobra.Command, args []string) { 18 | if err := gateway.Server(); err != nil { 19 | logger.Logger.Error(err.Error()) 20 | } 21 | }, 22 | } 23 | 24 | func init() { 25 | rootCmd.AddCommand(httpCmd) 26 | } 27 | -------------------------------------------------------------------------------- /scripts/migrations/000008_add_indexes.up.sql: -------------------------------------------------------------------------------- 1 | -- Create the extension and indexes for full-text search 2 | -- Check article: https://niallburkley.com/blog/index-columns-for-like-in-postgres/ 3 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 4 | 5 | CREATE INDEX idx_comments_content ON comments USING gin (content gin_trgm_ops); 6 | 7 | CREATE INDEX IF NOT EXISTS idx_posts_title ON posts USING gin (title gin_trgm_ops); 8 | 9 | CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING gin (tags); 10 | 11 | CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); 12 | 13 | CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); 14 | 15 | CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id); -------------------------------------------------------------------------------- /internal/gateway/helper/helper.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "errors" 5 | "github.com/julienschmidt/httprouter" 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | func ReadIdParam(r *http.Request) (int64, error) { 11 | params := httprouter.ParamsFromContext(r.Context()) 12 | id, err := strconv.ParseInt(params.ByName("id"), 10, 64) 13 | if err != nil || id < 1 { 14 | return 0, errors.New("invalid id") 15 | } 16 | return id, nil 17 | } 18 | 19 | func ReadTokenParam(r *http.Request) (string, error) { 20 | params := httprouter.ParamsFromContext(r.Context()) 21 | token := params.ByName("token") 22 | if token == "" { 23 | return "", errors.New("invalid or missing token") 24 | } 25 | return token, nil 26 | } 27 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // @title Gophergram API 2 | // @description API for Gophergram, a social network for gophers 3 | // @termsOfService http://swagger.io/terms/ 4 | // @contact.name API support 5 | // @contact.url http://www.swagger.io/support 6 | // @contact.email support@swagger.io 7 | // @license.name Apache 2.0 8 | // @license.url http://www.apache.org/licenses/LICENSE-2.0.html 9 | // @BasePath / 10 | // @securityDefinitions.apikey ApiKeyAuth 11 | // @in header 12 | // @name Authorization 13 | // @description 14 | /* 15 | Copyright © 2024 NAME HERE 16 | */ 17 | package main 18 | 19 | import "github.com/saleh-ghazimoradi/Gophergram/cmd" 20 | 21 | func main() { 22 | cmd.Execute() 23 | } 24 | -------------------------------------------------------------------------------- /scripts/migrations/000012_add_roles_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS roles ( 2 | id BIGSERIAL PRIMARY KEY, 3 | name VARCHAR(255) NOT NULL UNIQUE, 4 | level int NOT NULL DEFAULT 0, 5 | description TEXT 6 | ); 7 | 8 | INSERT INTO 9 | roles (name, description, level) 10 | VALUES 11 | ( 12 | 'user', 13 | 'A user can create posts and comments', 14 | 1 15 | ); 16 | 17 | INSERT INTO 18 | roles (name, description, level) 19 | VALUES 20 | ( 21 | 'moderator', 22 | 'A moderator can update other users posts', 23 | 2 24 | ); 25 | 26 | INSERT INTO 27 | roles (name, description, level) 28 | VALUES 29 | ( 30 | 'admin', 31 | 'An admin can update and delete other users posts', 32 | 3 33 | ); -------------------------------------------------------------------------------- /internal/service/role.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 7 | ) 8 | 9 | type RoleService interface { 10 | GetByName(ctx context.Context, name string) (*service_models.Role, error) 11 | } 12 | 13 | type roleService struct { 14 | roleRepository repository.RoleRepository 15 | } 16 | 17 | func (s *roleService) GetByName(ctx context.Context, name string) (*service_models.Role, error) { 18 | return s.roleRepository.GetByName(ctx, name) 19 | } 20 | 21 | func NewRoleService(roleRepository repository.RoleRepository) RoleService { 22 | return &roleService{ 23 | roleRepository: roleRepository, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/gateway/routes/health.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/julienschmidt/httprouter" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/middlewares" 7 | "net/http" 8 | ) 9 | 10 | func registerHealthRoutes(router *httprouter.Router, health *handlers.HealthHandler, middleware *middlewares.CustomMiddleware) { 11 | authMiddleware := middleware.BasicAuthentication 12 | rateLimitMiddleware := middleware.RateLimitMiddleware 13 | recoverPanic := middleware.RecoverPanic 14 | commonHeader := middleware.CommonHeaders 15 | router.Handler(http.MethodGet, "/v1/health", commonHeader(recoverPanic(rateLimitMiddleware(authMiddleware(http.HandlerFunc(health.Health)))))) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | *.idea/ 15 | *.bin/ 16 | # Test binary, built with `go test -c` 17 | *.test 18 | 19 | # Output of the go coverage tool, specifically when used with LiteIDE 20 | *.out 21 | 22 | *.env 23 | app.env 24 | 25 | # Dependency directories (remove the comment below to include it) 26 | # vendor/ 27 | config.json 28 | # Go workspace file 29 | go.work 30 | 31 | # End of https://www.toptal.com/developers/gitignore/api/go -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "github.com/saleh-ghazimoradi/Gophergram/config" 8 | "log" 9 | "os" 10 | "time" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // rootCmd represents the base command when called without any subcommands 16 | var rootCmd = &cobra.Command{ 17 | Use: "Gophergram", 18 | Short: "A social platform as Instagram", 19 | } 20 | 21 | func Execute() { 22 | err := os.Setenv("TZ", time.UTC.String()) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | err = rootCmd.Execute() 28 | if err != nil { 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func init() { 34 | cobra.OnInitialize(initConfig) 35 | } 36 | 37 | func initConfig() { 38 | err := config.LoadingConfig() 39 | if err != nil { 40 | log.Fatal("there went something wrong while loading config file") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/gateway/routes/authentication.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/julienschmidt/httprouter" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/middlewares" 7 | "net/http" 8 | ) 9 | 10 | func registerAuthenticationRoutes(router *httprouter.Router, authHandler *handlers.AuthHandler, middleware *middlewares.CustomMiddleware) { 11 | rateLimitMiddleware := middleware.RateLimitMiddleware 12 | recoverPanic := middleware.RecoverPanic 13 | commonHeader := middleware.CommonHeaders 14 | router.Handler(http.MethodPost, "/v1/authentication/user", commonHeader(recoverPanic(rateLimitMiddleware(http.HandlerFunc(authHandler.RegisterUserHandler))))) 15 | router.Handler(http.MethodPost, "/v1/authentication/token", commonHeader(recoverPanic(rateLimitMiddleware(http.HandlerFunc(authHandler.CreateTokenHandler))))) 16 | } 17 | -------------------------------------------------------------------------------- /internal/service/follower.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 6 | ) 7 | 8 | type FollowerService interface { 9 | Follow(ctx context.Context, followerId, userId int64) error 10 | Unfollow(ctx context.Context, followerId, userId int64) error 11 | } 12 | 13 | type followerService struct { 14 | followerRepo repository.FollowerRepository 15 | } 16 | 17 | func (s *followerService) Follow(ctx context.Context, followerId, userId int64) error { 18 | return s.followerRepo.Follow(ctx, followerId, userId) 19 | } 20 | 21 | func (s *followerService) Unfollow(ctx context.Context, followerId, userId int64) error { 22 | return s.followerRepo.Unfollow(ctx, followerId, userId) 23 | } 24 | 25 | func NewFollowerService(followerRepo repository.FollowerRepository) FollowerService { 26 | return &followerService{ 27 | followerRepo: followerRepo, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/service/cache.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 7 | ) 8 | 9 | type CacheService interface { 10 | Get(ctx context.Context, id int64) (*service_models.User, error) 11 | Set(ctx context.Context, user *service_models.User) error 12 | } 13 | 14 | type cacheService struct { 15 | cacheRepository repository.CacheRepository 16 | } 17 | 18 | func (s *cacheService) Get(ctx context.Context, id int64) (*service_models.User, error) { 19 | return s.cacheRepository.Get(ctx, id) 20 | } 21 | 22 | func (s *cacheService) Set(ctx context.Context, user *service_models.User) error { 23 | return s.cacheRepository.Set(ctx, user) 24 | } 25 | 26 | func NewCacheService(cacheRepository repository.CacheRepository) CacheService { 27 | return &cacheService{ 28 | cacheRepository: cacheRepository, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/template/user_invitation.tmpl: -------------------------------------------------------------------------------- 1 | {{define "subject"}} Finish Registration with GopherSocial {{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Hi {{.Username}},

11 |

Thanks for signing up for Gophergram. We're excited to have you on board!

12 |

Before you can start using Gophergram, you need to confirm your email address. Click the link below to confirm your email address:

13 |

{{.ActivationURL}}

14 |

If you want to activate your account manually copy and paste the code from the link above

15 |

If you didn't sign up for Gophergram, you can safely ignore this email.

16 | 17 |

Thanks,

18 |

The Gophergram Team

19 | 20 | 21 | 22 | {{end}} -------------------------------------------------------------------------------- /internal/service/comment.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 7 | ) 8 | 9 | type CommentService interface { 10 | GetByPostId(ctx context.Context, id int64) ([]service_models.Comment, error) 11 | Create(ctx context.Context, comment *service_models.Comment) error 12 | } 13 | 14 | type commentService struct { 15 | commentRepo repository.CommentRepository 16 | } 17 | 18 | func (c *commentService) GetByPostId(ctx context.Context, id int64) ([]service_models.Comment, error) { 19 | return c.commentRepo.GetByPostId(ctx, id) 20 | } 21 | 22 | func (c *commentService) Create(ctx context.Context, comment *service_models.Comment) error { 23 | return c.commentRepo.Create(ctx, comment) 24 | } 25 | 26 | func NewCommentService(commentRepo repository.CommentRepository) CommentService { 27 | return &commentService{ 28 | commentRepo: commentRepo, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/service_models/post.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | import "time" 4 | 5 | type Post struct { 6 | ID int64 `json:"id"` 7 | Content string `json:"content"` 8 | Title string `json:"title"` 9 | UserID int64 `json:"user_id"` 10 | Tags []string `json:"tags"` 11 | CreatedAt time.Time `json:"created_at"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | Version int `json:"version"` 14 | Comments []Comment `json:"comments"` 15 | User User `json:"user"` 16 | } 17 | 18 | type CreatePostPayload struct { 19 | Title string `json:"title" validate:"required,max=200"` 20 | Content string `json:"content" validate:"required,max=1000"` 21 | Tags []string `json:"tags"` 22 | } 23 | 24 | type UpdatePostPayload struct { 25 | Title *string `json:"title" validate:"omitempty,max=200"` 26 | Content *string `json:"content" validate:"omitempty,max=1000"` 27 | } 28 | 29 | type PostFeed struct { 30 | Post 31 | CommentCount int `json:"comment_count"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/gateway/json/json.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | func WriteJSON(w http.ResponseWriter, status int, data any) error { 9 | w.Header().Set("Content-Type", "application/json") 10 | w.WriteHeader(status) 11 | return json.NewEncoder(w).Encode(data) 12 | } 13 | 14 | func ReadJSON(w http.ResponseWriter, r *http.Request, data any) error { 15 | maxBytes := 1_048_576 16 | r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) 17 | decoder := json.NewDecoder(r.Body) 18 | decoder.DisallowUnknownFields() 19 | return decoder.Decode(data) 20 | } 21 | 22 | func WriteJSONError(w http.ResponseWriter, status int, message string) error { 23 | type envelope struct { 24 | Error string `json:"error"` 25 | } 26 | return WriteJSON(w, status, envelope{Error: message}) 27 | } 28 | 29 | func JSONResponse(w http.ResponseWriter, status int, data any) error { 30 | type envelope struct { 31 | Data any `json:"data"` 32 | } 33 | return WriteJSON(w, status, envelope{Data: data}) 34 | } 35 | -------------------------------------------------------------------------------- /internal/gateway/handlers/health.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/saleh-ghazimoradi/Gophergram/config" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 7 | "net/http" 8 | ) 9 | 10 | type HealthHandler struct{} 11 | 12 | // Health provides the health status of the application. 13 | // 14 | // @Summary Healthcheck 15 | // @Description Healthcheck endpoint 16 | // @Tags ops 17 | // @Produce json 18 | // @Success 200 {object} string "ok" 19 | // @Router /v1/health [get] 20 | func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { 21 | data := map[string]string{ 22 | "status": "ok", 23 | "env": config.AppConfig.ServerConfig.Env, 24 | "version": config.AppConfig.ServerConfig.Version, 25 | } 26 | if err := json.JSONResponse(w, http.StatusOK, data); err != nil { 27 | helper.InternalServerError(w, r, err) 28 | } 29 | } 30 | 31 | func NewHealthHandler() *HealthHandler { 32 | return &HealthHandler{} 33 | } 34 | -------------------------------------------------------------------------------- /internal/repository/ratelimit.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v8" 6 | "time" 7 | ) 8 | 9 | type RateLimitRepository interface { 10 | IncrementRequestCount(ctx context.Context, clientID string, window time.Duration) (int64, error) 11 | SetExpiration(ctx context.Context, clientID string, window time.Duration) error 12 | GetTTL(ctx context.Context, clientID string) (time.Duration, error) 13 | } 14 | 15 | type rateLimitRepository struct { 16 | client *redis.Client 17 | } 18 | 19 | func (r *rateLimitRepository) IncrementRequestCount(ctx context.Context, clientID string, window time.Duration) (int64, error) { 20 | return r.client.Incr(ctx, clientID).Result() 21 | } 22 | 23 | func (r *rateLimitRepository) SetExpiration(ctx context.Context, clientID string, window time.Duration) error { 24 | return r.client.Expire(ctx, clientID, window).Err() 25 | } 26 | 27 | func (r *rateLimitRepository) GetTTL(ctx context.Context, clientID string) (time.Duration, error) { 28 | return r.client.TTL(ctx, clientID).Result() 29 | } 30 | 31 | func NewRateLimitRepository(client *redis.Client) RateLimitRepository { 32 | return &rateLimitRepository{ 33 | client: client, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /utils/DBConnection.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "github.com/saleh-ghazimoradi/Gophergram/config" 8 | "github.com/saleh-ghazimoradi/Gophergram/logger" 9 | ) 10 | 11 | func PostURI() string { 12 | return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", config.AppConfig.DBConfig.DbHost, config.AppConfig.DBConfig.DbPort, config.AppConfig.DBConfig.DbUser, config.AppConfig.DBConfig.DbPassword, config.AppConfig.DBConfig.DbName, config.AppConfig.DBConfig.DbSslMode) 13 | } 14 | 15 | func PostConnection() (*sql.DB, error) { 16 | postURI := PostURI() 17 | logger.Logger.Info("Connecting to Postgres with options: " + postURI) 18 | 19 | db, err := sql.Open("postgres", postURI) 20 | if err != nil { 21 | return nil, fmt.Errorf("error connecting to Postgres: %v", err) 22 | } 23 | db.SetMaxOpenConns(config.AppConfig.DBConfig.MaxOpenConns) 24 | db.SetMaxIdleConns(config.AppConfig.DBConfig.MaxIdleConns) 25 | db.SetConnMaxLifetime(config.AppConfig.DBConfig.MaxIdleTime) 26 | ctx, cancel := context.WithTimeout(context.Background(), config.AppConfig.DBConfig.Timeout) 27 | defer cancel() 28 | 29 | if err = db.PingContext(ctx); err != nil { 30 | return nil, fmt.Errorf("error pinging Postgres database: %w", err) 31 | } 32 | 33 | return db, nil 34 | } 35 | -------------------------------------------------------------------------------- /cmd/seed.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 11 | "github.com/saleh-ghazimoradi/Gophergram/utils" 12 | "log" 13 | 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // seedCmd represents the seed command 18 | var seedCmd = &cobra.Command{ 19 | Use: "seed", 20 | Short: "Seeding DB", 21 | Run: func(cmd *cobra.Command, args []string) { 22 | fmt.Println("seed called") 23 | db, err := utils.PostConnection() 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | postRepository := repository.NewPostRepository(db, db) 28 | userRepository := repository.NewUserRepository(db, db) 29 | commentRepository := repository.NewCommentRepository(db, db) 30 | postService := service.NewPostService(postRepository, db) 31 | userService := service.NewUserService(userRepository, db) 32 | commentService := service.NewCommentService(commentRepository) 33 | seed := service.NewSeederService(userService, postService, commentService) 34 | 35 | if err := seed.Seed(context.Background(), db); err != nil { 36 | log.Fatal(err) 37 | } 38 | }, 39 | } 40 | 41 | func init() { 42 | rootCmd.AddCommand(seedCmd) 43 | 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/service_models/user.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | ID int64 `json:"id"` 10 | Username string `json:"username"` 11 | Email string `json:"email"` 12 | Password Password `json:"-"` 13 | CreatedAt time.Time `json:"created_at"` 14 | IsActive bool `json:"is_active"` 15 | RoleID int64 `json:"role_id"` 16 | Role Role `json:"role"` 17 | } 18 | 19 | type RegisterUserPayload struct { 20 | Username string `json:"username" validate:"required,max=50"` 21 | Email string `json:"email" validate:"required,email,max=255"` 22 | Password string `json:"password" validate:"required,min=3,max=32"` 23 | } 24 | 25 | type UserWithToken struct { 26 | *User 27 | Token string `json:"token"` 28 | } 29 | 30 | type CreateUserTokenPayload struct { 31 | Email string `json:"email" validate:"required,email,max=255"` 32 | Password string `json:"password" validate:"required,min=3,max=32"` 33 | } 34 | 35 | type Password struct { 36 | Text *string 37 | Hash []byte 38 | } 39 | 40 | func (p *Password) Set(text string) error { 41 | hash, err := bcrypt.GenerateFromPassword([]byte(text), bcrypt.DefaultCost) 42 | if err != nil { 43 | return err 44 | } 45 | p.Hash = hash 46 | p.Text = &text 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/service/authenticator.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "github.com/golang-jwt/jwt/v5" 6 | ) 7 | 8 | type Authenticator interface { 9 | GenerateToken(claims jwt.Claims) (string, error) 10 | ValidateToken(token string) (*jwt.Token, error) 11 | } 12 | 13 | type JWTAuthenticator struct { 14 | secret string 15 | aud string 16 | iss string 17 | } 18 | 19 | func (a *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) { 20 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 21 | tokenString, err := token.SignedString([]byte(a.secret)) 22 | if err != nil { 23 | return "", err 24 | } 25 | return tokenString, nil 26 | } 27 | 28 | func (a *JWTAuthenticator) ValidateToken(token string) (*jwt.Token, error) { 29 | return jwt.Parse(token, func(token *jwt.Token) (any, error) { 30 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 31 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 32 | } 33 | return []byte(a.secret), nil 34 | }, 35 | jwt.WithExpirationRequired(), 36 | jwt.WithAudience(a.aud), 37 | jwt.WithIssuer(a.iss), 38 | jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}), 39 | ) 40 | } 41 | 42 | func NewJWTAuthenticator(secret, aud, iss string) Authenticator { 43 | return &JWTAuthenticator{ 44 | secret: secret, 45 | aud: aud, 46 | iss: iss, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifneq (,$(wildcard ./app.env)) 2 | include app.env 3 | export $(shell sed 's/=.*//' app.env) 4 | endif 5 | 6 | MIGRATE_PATH = ./scripts/migrations 7 | DATABASE_URL = ${DB_SOURCE} 8 | 9 | format: 10 | @echo "Applying go fmt to the project" 11 | go fmt ./... 12 | 13 | 14 | vet: 15 | @echo "Checking for errors with vet" 16 | go vet ./... 17 | 18 | dockerup: 19 | docker compose --env-file app.env up -d 20 | 21 | dockerdown: 22 | docker compose --env-file app.env down 23 | 24 | migrate-create: 25 | @echo "Creating migration files for ${name}..." 26 | migrate create -seq -ext=.sql -dir=./scripts/migrations ${name} 27 | 28 | migrate-up: 29 | @echo "Running up migrations..." 30 | migrate -path ${MIGRATE_PATH} -database "${DATABASE_URL}" up 31 | 32 | migrate-down: 33 | @echo "Rolling back migrations..." 34 | @if [ -z "$(n)" ]; then \ 35 | migrate -path ${MIGRATE_PATH} -database "${DATABASE_URL}" down 1; \ 36 | else \ 37 | migrate -path ${MIGRATE_PATH} -database "${DATABASE_URL}" down $(n); \ 38 | fi 39 | 40 | 41 | migrate-drop: 42 | @echo "Dropping all migrations..." 43 | migrate -path ${MIGRATE_PATH} -database "${DATABASE_URL}" drop -f 44 | 45 | # Run the HTTP server 46 | http: 47 | go run . http 48 | 49 | seed: 50 | go run . seed 51 | 52 | # Declare targets that are not files 53 | .PHONY: format vet dockerup dockerdown migrate-create migrate-up migrate-down migrate-drop http seed -------------------------------------------------------------------------------- /internal/repository/role.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "github.com/saleh-ghazimoradi/Gophergram/config" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 8 | ) 9 | 10 | type RoleRepository interface { 11 | GetByName(ctx context.Context, name string) (*service_models.Role, error) 12 | WithTx(tx *sql.Tx) RoleRepository 13 | } 14 | 15 | type roleRepository struct { 16 | dbRead *sql.DB 17 | dbWrite *sql.DB 18 | tx *sql.Tx 19 | } 20 | 21 | func (r *roleRepository) GetByName(ctx context.Context, name string) (*service_models.Role, error) { 22 | query := `SELECT id, name, description, level FROM roles WHERE name = $1` 23 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 24 | defer cancel() 25 | 26 | role := &service_models.Role{} 27 | err := r.dbRead.QueryRowContext(ctx, query, name).Scan( 28 | &role.ID, 29 | &role.Name, 30 | &role.Description, 31 | &role.Level, 32 | ) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return role, nil 37 | } 38 | 39 | func (r *roleRepository) WithTx(tx *sql.Tx) RoleRepository { 40 | return &roleRepository{ 41 | dbRead: r.dbRead, 42 | dbWrite: r.dbWrite, 43 | tx: tx, 44 | } 45 | } 46 | 47 | func NewRoleRepository(dbRead, dbWrite *sql.DB) RoleRepository { 48 | return &roleRepository{ 49 | dbRead: dbRead, 50 | dbWrite: dbWrite, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/gateway/routes/user.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/julienschmidt/httprouter" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/middlewares" 7 | "net/http" 8 | ) 9 | 10 | func registerUserRoutes(router *httprouter.Router, user *handlers.UserHandler, middleware *middlewares.CustomMiddleware, feed *handlers.FeedHandler) { 11 | authTokenMiddleware := middleware.AuthTokenMiddleware 12 | rateLimitMiddleware := middleware.RateLimitMiddleware 13 | recoverPanic := middleware.RecoverPanic 14 | commonHeader := middleware.CommonHeaders 15 | router.Handler(http.MethodPut, "/v1/user/activate/:token", commonHeader(recoverPanic(rateLimitMiddleware(http.HandlerFunc(user.ActivateUserHandler))))) 16 | router.Handler(http.MethodGet, "/v1/users/:id", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(http.HandlerFunc(user.GetUserHandler)))))) 17 | router.Handler(http.MethodPut, "/v1/users/:id/follow", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(http.HandlerFunc(user.FollowUserHandler)))))) 18 | router.Handler(http.MethodPut, "/v1/users/:id/unfollow", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(http.HandlerFunc(user.UnFollowUserHandler)))))) 19 | router.Handler(http.MethodGet, "/v1/user/feed", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(http.HandlerFunc(feed.GetUserFeedHandler)))))) 20 | } 21 | -------------------------------------------------------------------------------- /internal/gateway/routes/post.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/julienschmidt/httprouter" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/middlewares" 7 | "net/http" 8 | ) 9 | 10 | func registerPostRoutes(router *httprouter.Router, handler *handlers.PostHandler, middleware *middlewares.CustomMiddleware) { 11 | authTokenMiddleware := middleware.AuthTokenMiddleware 12 | postMiddleware := middleware.PostsContextMiddleware 13 | checkOwnership := middleware.CheckPostOwnership 14 | rateLimitMiddleware := middleware.RateLimitMiddleware 15 | recoverPanic := middleware.RecoverPanic 16 | commonHeader := middleware.CommonHeaders 17 | 18 | router.Handler(http.MethodPost, "/v1/posts", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(http.HandlerFunc(handler.CreatePostHandler)))))) 19 | router.Handler(http.MethodGet, "/v1/posts/:id", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(postMiddleware(http.HandlerFunc(handler.GetPostByIdHandler))))))) 20 | router.Handler(http.MethodPatch, "/v1/posts/:id", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(postMiddleware(checkOwnership("moderator", http.HandlerFunc(handler.UpdatePostHandler)))))))) 21 | router.Handler(http.MethodDelete, "/v1/posts/:id", commonHeader(recoverPanic(rateLimitMiddleware(authTokenMiddleware(postMiddleware(checkOwnership("admin", http.HandlerFunc(handler.DeletePostHandler)))))))) 22 | } 23 | -------------------------------------------------------------------------------- /internal/repository/cache.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/go-redis/redis/v8" 8 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 9 | "time" 10 | ) 11 | 12 | const UserExpiration = time.Minute 13 | 14 | type CacheRepository interface { 15 | Get(ctx context.Context, id int64) (*service_models.User, error) 16 | Set(ctx context.Context, user *service_models.User) error 17 | } 18 | 19 | type cacheRepository struct { 20 | client *redis.Client 21 | } 22 | 23 | func (c *cacheRepository) Get(ctx context.Context, id int64) (*service_models.User, error) { 24 | cacheKey := fmt.Sprintf("user-%v", id) 25 | data, err := c.client.Get(ctx, cacheKey).Result() 26 | if err == redis.Nil { 27 | return nil, nil 28 | } else if err != nil { 29 | return nil, err 30 | } 31 | 32 | user := &service_models.User{} 33 | if data != "" { 34 | err := json.Unmarshal([]byte(data), &user) 35 | if err != nil { 36 | return nil, err 37 | } 38 | } 39 | 40 | return user, nil 41 | } 42 | 43 | func (c *cacheRepository) Set(ctx context.Context, user *service_models.User) error { 44 | cacheKey := fmt.Sprintf("user-%v", user.ID) 45 | data, err := json.Marshal(user) 46 | if err != nil { 47 | return err 48 | } 49 | return c.client.SetEX(ctx, cacheKey, data, UserExpiration).Err() 50 | } 51 | 52 | func NewCacheRepository(client *redis.Client) CacheRepository { 53 | return &cacheRepository{ 54 | client: client, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci-test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | test: 11 | name: test 12 | runs-on: ubuntu-latest 13 | 14 | services: 15 | postgres: 16 | image: postgres:16.3 17 | env: 18 | POSTGRES_USER: ${{ secrets.DB_USER }} 19 | POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} 20 | POSTGRES_DB: ${{ secrets.DB_NAME }} 21 | ports: 22 | - 5415:5432 23 | options: >- 24 | --health-cmd pg_isready 25 | --health-interval 10s 26 | --health-timeout 5s 27 | --health-retries 5 28 | 29 | steps: 30 | - name: Check out code into the Go module directory 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v4 35 | with: 36 | go-version: '1.23.2' 37 | id: go 38 | 39 | - name: Install golang-migrate 40 | run: | 41 | curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.1/migrate.linux-amd64.tar.gz | tar xvz 42 | sudo mv migrate /usr/bin 43 | which migrate 44 | 45 | - name: Run migrations 46 | env: 47 | DB_DRIVER: ${{ secrets.DB_DRIVER }} 48 | DB_SOURCE: ${{ secrets.DB_SOURCE }} 49 | DATABASE_URL: ${{ secrets.DB_SOURCE }} 50 | run: make migrate-up 51 | 52 | - name: Build 53 | run: go mod download && go build -v main.go -------------------------------------------------------------------------------- /internal/repository/follower.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "github.com/lib/pq" 7 | "github.com/saleh-ghazimoradi/Gophergram/config" 8 | ) 9 | 10 | type FollowerRepository interface { 11 | Follow(ctx context.Context, followerId, userId int64) error 12 | Unfollow(ctx context.Context, followerId, userId int64) error 13 | WithTx(tx *sql.Tx) FollowerRepository 14 | } 15 | 16 | type followerRepository struct { 17 | dbWrite *sql.DB 18 | dbRead *sql.DB 19 | tx *sql.Tx 20 | } 21 | 22 | func (f *followerRepository) Follow(ctx context.Context, followerId, userId int64) error { 23 | query := `INSERT INTO followers(user_id, follower_id) VALUES ($1, $2)` 24 | 25 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 26 | defer cancel() 27 | _, err := f.dbWrite.ExecContext(ctx, query, followerId, userId) 28 | if err != nil { 29 | if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { 30 | return ErrsConflict 31 | } 32 | } 33 | return err 34 | } 35 | 36 | func (f *followerRepository) Unfollow(ctx context.Context, followerId, userId int64) error { 37 | query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2` 38 | 39 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 40 | defer cancel() 41 | _, err := f.dbWrite.ExecContext(ctx, query, followerId, userId) 42 | 43 | return err 44 | } 45 | 46 | func (f *followerRepository) WithTx(tx *sql.Tx) FollowerRepository { 47 | return &followerRepository{ 48 | dbWrite: f.dbWrite, 49 | dbRead: f.dbRead, 50 | tx: tx, 51 | } 52 | } 53 | 54 | func NewFollowerRepository(dbWrite *sql.DB, dbRead *sql.DB) FollowerRepository { 55 | return &followerRepository{ 56 | dbWrite: dbWrite, 57 | dbRead: dbRead, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/service/post.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 8 | "github.com/saleh-ghazimoradi/Gophergram/utils" 9 | ) 10 | 11 | type PostService interface { 12 | Create(ctx context.Context, post *service_models.Post) error 13 | GetById(ctx context.Context, id int64) (*service_models.Post, error) 14 | GetUserFeed(ctx context.Context, id int64, fq service_models.PaginatedFeedQuery) ([]service_models.PostFeed, error) 15 | Update(ctx context.Context, post *service_models.Post) error 16 | Delete(ctx context.Context, id int64) error 17 | } 18 | 19 | type postService struct { 20 | postRepo repository.PostRepository 21 | db *sql.DB 22 | } 23 | 24 | func (p *postService) Create(ctx context.Context, post *service_models.Post) error { 25 | return utils.WithTransaction(ctx, p.db, func(tx *sql.Tx) error { 26 | userRepoWithTx := p.postRepo.WithTx(tx) 27 | return userRepoWithTx.Create(ctx, post) 28 | }) 29 | } 30 | 31 | func (p *postService) GetById(ctx context.Context, id int64) (*service_models.Post, error) { 32 | return p.postRepo.GetById(ctx, id) 33 | } 34 | 35 | func (p *postService) Update(ctx context.Context, post *service_models.Post) error { 36 | return p.postRepo.Update(ctx, post) 37 | } 38 | 39 | func (p *postService) Delete(ctx context.Context, id int64) error { 40 | return p.postRepo.Delete(ctx, id) 41 | } 42 | 43 | func (p *postService) GetUserFeed(ctx context.Context, id int64, fq service_models.PaginatedFeedQuery) ([]service_models.PostFeed, error) { 44 | return p.postRepo.GetUserFeed(ctx, id, fq) 45 | } 46 | 47 | func NewPostService(postRepo repository.PostRepository, db *sql.DB) PostService { 48 | return &postService{ 49 | postRepo: postRepo, 50 | db: db, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/service/service_models/pagination.go: -------------------------------------------------------------------------------- 1 | package service_models 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type PaginatedFeedQuery struct { 12 | Limit int `json:"limit" validate:"gte=1,lte=20"` 13 | Offset int `json:"offset" validate:"gte=0"` 14 | Sort string `json:"sort" validate:"oneof=asc desc"` 15 | Tags []string `json:"tags" validate:"max=5"` 16 | Search string `json:"search" validate:"max=100"` 17 | Since time.Time `json:"since"` 18 | Until time.Time `json:"until"` 19 | } 20 | 21 | func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) { 22 | qs := r.URL.Query() 23 | 24 | limit := qs.Get("limit") 25 | if limit != "" { 26 | l, err := strconv.Atoi(limit) 27 | if err != nil { 28 | return fq, nil 29 | } 30 | 31 | fq.Limit = l 32 | } 33 | 34 | offset := qs.Get("offset") 35 | if offset != "" { 36 | l, err := strconv.Atoi(offset) 37 | if err != nil { 38 | return fq, nil 39 | } 40 | 41 | fq.Offset = l 42 | } 43 | 44 | sort := qs.Get("sort") 45 | if sort != "" { 46 | fq.Sort = sort 47 | } 48 | 49 | tags := qs.Get("tags") 50 | if tags != "" { 51 | fq.Tags = strings.Split(tags, ",") 52 | } 53 | 54 | search := qs.Get("search") 55 | if search != "" { 56 | fq.Search = search 57 | } 58 | 59 | since := qs.Get("since") 60 | t, err := parseTime(since) 61 | if err != nil { 62 | return fq, err 63 | } 64 | fq.Since = t 65 | 66 | until := qs.Get("until") 67 | t, err = parseTime(until) 68 | if err != nil { 69 | return fq, err 70 | } 71 | fq.Until = t 72 | 73 | return fq, nil 74 | } 75 | 76 | func parseTime(value string) (time.Time, error) { 77 | if value == "" { 78 | return time.Time{}, nil 79 | } 80 | t, err := time.Parse(time.RFC3339, value) 81 | if err != nil { 82 | return time.Time{}, fmt.Errorf("invalid time format: %v", err) 83 | } 84 | return t, nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/service/ratelimit.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 6 | "time" 7 | ) 8 | 9 | type RateLimitService interface { 10 | IncrementRequestCount(ctx context.Context, clientID string, window time.Duration) (int64, error) 11 | SetExpiration(ctx context.Context, clientID string, window time.Duration) error 12 | GetTTL(ctx context.Context, clientID string) (time.Duration, error) 13 | IsAllowed(ctx context.Context, clientID string, limit int, window time.Duration) (bool, time.Duration, error) 14 | } 15 | 16 | type rateLimitService struct { 17 | rateLimitRepository repository.RateLimitRepository 18 | } 19 | 20 | func (r *rateLimitService) IncrementRequestCount(ctx context.Context, clientID string, window time.Duration) (int64, error) { 21 | return r.rateLimitRepository.IncrementRequestCount(ctx, clientID, window) 22 | } 23 | 24 | func (r *rateLimitService) SetExpiration(ctx context.Context, clientID string, window time.Duration) error { 25 | return r.rateLimitRepository.SetExpiration(ctx, clientID, window) 26 | } 27 | 28 | func (r *rateLimitService) GetTTL(ctx context.Context, clientID string) (time.Duration, error) { 29 | return r.rateLimitRepository.GetTTL(ctx, clientID) 30 | } 31 | 32 | func (r *rateLimitService) IsAllowed(ctx context.Context, clientID string, limit int, window time.Duration) (bool, time.Duration, error) { 33 | count, err := r.rateLimitRepository.IncrementRequestCount(ctx, clientID, window) 34 | if err != nil { 35 | return false, 0, err 36 | } 37 | 38 | if count == 1 { 39 | if err := r.rateLimitRepository.SetExpiration(ctx, clientID, window); err != nil { 40 | return false, 0, err 41 | } 42 | } 43 | 44 | if count > int64(limit) { 45 | ttl, _ := r.rateLimitRepository.GetTTL(ctx, clientID) 46 | return false, ttl, repository.ErrRateLimitExceeded 47 | } 48 | 49 | return true, 0, nil 50 | } 51 | 52 | func NewRateLimitService(rateLimitRepository repository.RateLimitRepository) RateLimitService { 53 | return &rateLimitService{ 54 | rateLimitRepository: rateLimitRepository, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/saleh-ghazimoradi/Gophergram 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/caarlos0/env v3.5.0+incompatible 7 | github.com/go-playground/validator/v10 v10.23.0 8 | github.com/go-redis/redis/v8 v8.11.5 9 | github.com/google/uuid v1.6.0 10 | github.com/joho/godotenv v1.5.1 11 | github.com/julienschmidt/httprouter v1.3.0 12 | github.com/lib/pq v1.10.9 13 | github.com/sendgrid/sendgrid-go v3.16.0+incompatible 14 | github.com/spf13/cobra v1.8.1 15 | github.com/swaggo/http-swagger/v2 v2.0.2 16 | github.com/swaggo/swag v1.16.4 17 | golang.org/x/crypto v0.31.0 18 | ) 19 | 20 | require ( 21 | github.com/KyleBanks/depth v1.2.1 // indirect 22 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 25 | github.com/fsnotify/fsnotify v1.7.0 // indirect 26 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 27 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 28 | github.com/go-openapi/jsonreference v0.21.0 // indirect 29 | github.com/go-openapi/spec v0.21.0 // indirect 30 | github.com/go-openapi/swag v0.23.0 // indirect 31 | github.com/go-playground/locales v0.14.1 // indirect 32 | github.com/go-playground/universal-translator v0.18.1 // indirect 33 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 35 | github.com/josharian/intern v1.0.0 // indirect 36 | github.com/justinas/alice v1.2.0 // indirect 37 | github.com/leodido/go-urn v1.4.0 // indirect 38 | github.com/mailru/easyjson v0.9.0 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/sendgrid/rest v2.6.9+incompatible // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/swaggo/files/v2 v2.0.0 // indirect 43 | golang.org/x/net v0.33.0 // indirect 44 | golang.org/x/sys v0.28.0 // indirect 45 | golang.org/x/text v0.21.0 // indirect 46 | golang.org/x/tools v0.28.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /internal/service/mailer.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "github.com/saleh-ghazimoradi/Gophergram/config" 8 | "github.com/sendgrid/sendgrid-go" 9 | "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | "html/template" 11 | "time" 12 | ) 13 | 14 | //go:embed "template" 15 | var FS embed.FS 16 | 17 | type Mailer interface { 18 | Send(templateFile, username, email string, data any, isSandbox bool) (int, error) 19 | } 20 | 21 | type mailService struct { 22 | fromEmail string 23 | apiKey string 24 | client *sendgrid.Client 25 | } 26 | 27 | func (m *mailService) Send(templateFile, username, email string, data any, isSandbox bool) (int, error) { 28 | from := mail.NewEmail(config.AppConfig.Mail.FromName, m.fromEmail) 29 | to := mail.NewEmail(username, email) 30 | 31 | // template parsing and building 32 | tmpl, err := template.ParseFS(FS, "template/"+templateFile) 33 | if err != nil { 34 | return -1, err 35 | } 36 | 37 | subject := new(bytes.Buffer) 38 | err = tmpl.ExecuteTemplate(subject, "subject", data) 39 | if err != nil { 40 | return -1, err 41 | } 42 | 43 | body := new(bytes.Buffer) 44 | err = tmpl.ExecuteTemplate(body, "body", data) 45 | if err != nil { 46 | return -1, err 47 | } 48 | 49 | message := mail.NewSingleEmail(from, subject.String(), to, "", body.String()) 50 | 51 | message.SetMailSettings(&mail.MailSettings{ 52 | SandboxMode: &mail.Setting{ 53 | Enable: &isSandbox, 54 | }, 55 | }) 56 | 57 | var retryErr error 58 | for i := 0; i < int(config.AppConfig.Mail.MaxRetries); i++ { 59 | response, retryErr := m.client.Send(message) 60 | if retryErr != nil { 61 | time.Sleep(time.Second * time.Duration(i+1)) 62 | continue 63 | } 64 | return response.StatusCode, nil 65 | } 66 | return -1, fmt.Errorf("failed to send email after %d attempt, error: %v", int(config.AppConfig.Mail.MaxRetries), retryErr) 67 | } 68 | 69 | func NewMailer(fromEmail, apiKey string) Mailer { 70 | client := sendgrid.NewSendClient(apiKey) 71 | return &mailService{ 72 | fromEmail: fromEmail, 73 | apiKey: apiKey, 74 | client: client, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/gateway/handlers/feed.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/saleh-ghazimoradi/Gophergram/config" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 8 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 11 | "net/http" 12 | ) 13 | 14 | type FeedHandler struct { 15 | postService service.PostService 16 | } 17 | 18 | // GetUserFeedHandler godoc 19 | // 20 | // @Summary Fetches the user feed 21 | // @Description Fetches the user feed 22 | // @Tags feed 23 | // @Accept json 24 | // @Produce json 25 | // @Param limit query int false "Limit" 26 | // @Param offset query int false "Offset" 27 | // @Param sort query string false "Sort" 28 | // @Success 200 {object} []service_models.PostFeed 29 | // @Failure 400 {object} error 30 | // @Failure 500 {object} error 31 | // @Security ApiKeyAuth 32 | // @Router /v1/users/feed [get] 33 | func (f *FeedHandler) GetUserFeedHandler(w http.ResponseWriter, r *http.Request) { 34 | p := service_models.PaginatedFeedQuery{ 35 | Limit: config.AppConfig.Pagination.Limit, 36 | Offset: config.AppConfig.Pagination.Offset, 37 | Sort: config.AppConfig.Pagination.Sort, 38 | } 39 | 40 | fmt.Printf("limit: %d, type: %T, offset: %d, type: %T\n", p.Limit, p.Limit, p.Offset, p.Offset) 41 | 42 | fq, err := p.Parse(r) 43 | if err != nil { 44 | helper.BadRequestResponse(w, r, err) 45 | return 46 | } 47 | 48 | if err = helper.Validate.Struct(fq); err != nil { 49 | helper.BadRequestResponse(w, r, err) 50 | return 51 | } 52 | 53 | //user := getUserFromContext(r) 54 | //fmt.Println(user.ID) 55 | 56 | feed, err := f.postService.GetUserFeed(context.Background(), int64(10), fq) 57 | if err != nil { 58 | helper.InternalServerError(w, r, err) 59 | return 60 | } 61 | 62 | if err = json.JSONResponse(w, http.StatusOK, feed); err != nil { 63 | helper.InternalServerError(w, r, err) 64 | } 65 | } 66 | 67 | func NewFeedHandler(postService service.PostService) *FeedHandler { 68 | return &FeedHandler{ 69 | postService: postService, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/gateway/server.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/julienschmidt/httprouter" 7 | "github.com/saleh-ghazimoradi/Gophergram/config" 8 | "github.com/saleh-ghazimoradi/Gophergram/docs" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/routes" 10 | "github.com/saleh-ghazimoradi/Gophergram/logger" 11 | "github.com/saleh-ghazimoradi/Gophergram/utils" 12 | "net/http" 13 | "os" 14 | "os/signal" 15 | "sync" 16 | "syscall" 17 | "time" 18 | ) 19 | 20 | var wg sync.WaitGroup 21 | 22 | func Server() error { 23 | docs.SwaggerInfo.Version = config.AppConfig.ServerConfig.Version 24 | docs.SwaggerInfo.Host = config.AppConfig.ServerConfig.APIURL 25 | 26 | db, err := utils.PostConnection() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | redis, err := utils.RedisConnection(config.AppConfig.Redis.Addr, config.AppConfig.Redis.PW, config.AppConfig.Redis.DB) 32 | if err != nil { 33 | logger.Logger.Error(err.Error()) 34 | } 35 | 36 | router := httprouter.New() 37 | routes.RegisterRoutes(router, db, redis) 38 | 39 | srv := &http.Server{ 40 | Addr: config.AppConfig.ServerConfig.Port, 41 | Handler: router, 42 | ReadTimeout: config.AppConfig.ServerConfig.ReadTimeout, 43 | WriteTimeout: config.AppConfig.ServerConfig.WriteTimeout, 44 | IdleTimeout: config.AppConfig.ServerConfig.IdleTimeout, 45 | } 46 | 47 | shutdownError := make(chan error) 48 | go func() { 49 | quit := make(chan os.Signal, 1) 50 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 51 | s := <-quit 52 | 53 | logger.Logger.Info("shutting down server", "signal", s.String()) 54 | 55 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 56 | defer cancel() 57 | 58 | err := srv.Shutdown(ctx) 59 | if err != nil { 60 | shutdownError <- err 61 | } 62 | 63 | logger.Logger.Info("completing background tasks", "addr", srv.Addr) 64 | 65 | wg.Wait() 66 | shutdownError <- nil 67 | }() 68 | 69 | logger.Logger.Info("starting server", "addr", config.AppConfig.ServerConfig.Port, "env", config.AppConfig.ServerConfig.Env) 70 | 71 | err = srv.ListenAndServe() 72 | if !errors.Is(err, http.ErrServerClosed) { 73 | return err 74 | } 75 | 76 | err = <-shutdownError 77 | if err != nil { 78 | return err 79 | } 80 | 81 | logger.Logger.Info("stopped server", "addr", srv.Addr) 82 | 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/gateway/helper/errors.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 5 | "github.com/saleh-ghazimoradi/Gophergram/logger" 6 | "net/http" 7 | ) 8 | 9 | func InternalServerError(w http.ResponseWriter, r *http.Request, err error) { 10 | logger.Logger.Error("internal error", "method", r.Method, "path", r.URL.Path, "err", err.Error()) 11 | json.WriteJSONError(w, http.StatusInternalServerError, "the server encountered a problem") 12 | } 13 | 14 | func BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) { 15 | logger.Logger.Warn("bad request", "method", r.Method, "path", r.URL.Path, "err", err.Error()) 16 | json.WriteJSONError(w, http.StatusBadRequest, err.Error()) 17 | } 18 | 19 | func NotFoundResponse(w http.ResponseWriter, r *http.Request, err error) { 20 | logger.Logger.Warn("not found error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) 21 | json.WriteJSONError(w, http.StatusNotFound, err.Error()) 22 | } 23 | 24 | func ConflictResponse(w http.ResponseWriter, r *http.Request, err error) { 25 | logger.Logger.Error("conflict response", "method", r.Method, "path", r.URL.Path, "error", err.Error()) 26 | json.WriteJSONError(w, http.StatusConflict, err.Error()) 27 | } 28 | 29 | func UnauthorizedErrorResponse(w http.ResponseWriter, r *http.Request, err error) { 30 | logger.Logger.Warn("unauthorized error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) 31 | json.WriteJSONError(w, http.StatusUnauthorized, "unauthorized") 32 | } 33 | 34 | func UnauthorizedBasicErrorResponse(w http.ResponseWriter, r *http.Request, err error) { 35 | logger.Logger.Warn("unauthorized basic error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) 36 | w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) 37 | json.WriteJSONError(w, http.StatusUnauthorized, "unauthorized") 38 | } 39 | 40 | func ForbiddenResponse(w http.ResponseWriter, r *http.Request) { 41 | logger.Logger.Warn("forbidden", "method", r.Method, "path", r.URL.Path) 42 | json.WriteJSONError(w, http.StatusForbidden, "forbidden") 43 | } 44 | 45 | func RateLimitExceededResponse(w http.ResponseWriter, r *http.Request, retryAfter string) { 46 | logger.Logger.Warn("rate limit exceeded", "method", r.Method, "path", r.URL.Path) 47 | w.Header().Set("Retry_After", retryAfter) 48 | json.WriteJSONError(w, http.StatusTooManyRequests, "rate limit exceeded, retry after: "+retryAfter) 49 | } 50 | -------------------------------------------------------------------------------- /internal/repository/comment.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "github.com/saleh-ghazimoradi/Gophergram/config" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 8 | ) 9 | 10 | type CommentRepository interface { 11 | GetByPostId(ctx context.Context, id int64) ([]service_models.Comment, error) 12 | Create(ctx context.Context, comment *service_models.Comment) error 13 | WithTx(tx *sql.Tx) CommentRepository 14 | } 15 | 16 | type commentRepository struct { 17 | dbRead *sql.DB 18 | dbWrite *sql.DB 19 | tx *sql.Tx 20 | } 21 | 22 | func (c *commentRepository) GetByPostId(ctx context.Context, id int64) ([]service_models.Comment, error) { 23 | query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c JOIN users on users.id = c.user_id WHERE c.post_id = $1 ORDER BY c.created_at DESC;` 24 | 25 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 26 | defer cancel() 27 | 28 | rows, err := c.dbRead.QueryContext(ctx, query, id) 29 | if err != nil { 30 | return nil, err 31 | } 32 | defer rows.Close() 33 | 34 | comments := make([]service_models.Comment, 0) 35 | for rows.Next() { 36 | var comment service_models.Comment 37 | comment.User = service_models.User{} 38 | err = rows.Scan( 39 | &comment.ID, 40 | &comment.PostID, 41 | &comment.UserID, 42 | &comment.Content, 43 | &comment.CreatedAt, 44 | &comment.User.Username, 45 | &comment.UserID, 46 | ) 47 | if err != nil { 48 | return nil, err 49 | } 50 | comments = append(comments, comment) 51 | } 52 | if err = rows.Err(); err != nil { 53 | return nil, err 54 | } 55 | return comments, nil 56 | } 57 | 58 | func (c *commentRepository) Create(ctx context.Context, comment *service_models.Comment) error { 59 | query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at` 60 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 61 | defer cancel() 62 | err := c.dbWrite.QueryRowContext( 63 | ctx, 64 | query, 65 | comment.PostID, 66 | comment.UserID, 67 | comment.Content, 68 | ).Scan( 69 | &comment.ID, 70 | &comment.CreatedAt, 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | 78 | func (c *commentRepository) WithTx(tx *sql.Tx) CommentRepository { 79 | return &commentRepository{ 80 | dbRead: c.dbRead, 81 | dbWrite: c.dbWrite, 82 | tx: tx, 83 | } 84 | } 85 | 86 | func NewCommentRepository(dbRead, dbWrite *sql.DB) CommentRepository { 87 | return &commentRepository{ 88 | dbRead: dbRead, 89 | dbWrite: dbWrite, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/gateway/routes/RegisterRoutes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/go-redis/redis/v8" 7 | "github.com/julienschmidt/httprouter" 8 | "github.com/saleh-ghazimoradi/Gophergram/config" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/middlewares" 11 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 12 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 13 | httpSwagger "github.com/swaggo/http-swagger/v2" 14 | "net/http" 15 | ) 16 | 17 | func RegisterRoutes(router *httprouter.Router, db *sql.DB, client *redis.Client) { 18 | health := handlers.NewHealthHandler() 19 | 20 | userRepo := repository.NewUserRepository(db, db) 21 | followRepo := repository.NewFollowerRepository(db, db) 22 | postRepo := repository.NewPostRepository(db, db) 23 | commentRepo := repository.NewCommentRepository(db, db) 24 | roleRepo := repository.NewRoleRepository(db, db) 25 | cacheRepository := repository.NewCacheRepository(client) 26 | rateLimitRepository := repository.NewRateLimitRepository(client) 27 | 28 | userService := service.NewUserService(userRepo, db) 29 | followService := service.NewFollowerService(followRepo) 30 | postService := service.NewPostService(postRepo, db) 31 | commentService := service.NewCommentService(commentRepo) 32 | mailService := service.NewMailer(config.AppConfig.Mail.ApiKey, config.AppConfig.Mail.FromEmail) 33 | JWTAuthenticator := service.NewJWTAuthenticator(config.AppConfig.Authentication.Secret, config.AppConfig.Authentication.Aud, config.AppConfig.Authentication.Iss) 34 | roleService := service.NewRoleService(roleRepo) 35 | cacheService := service.NewCacheService(cacheRepository) 36 | rateLimitService := service.NewRateLimitService(rateLimitRepository) 37 | 38 | middleware := middlewares.NewMiddleware(postService, userService, JWTAuthenticator, roleService, cacheService, rateLimitService) 39 | 40 | feedHandler := handlers.NewFeedHandler(postService) 41 | userHandler := handlers.NewUserHandler(userService, followService, cacheService) 42 | postHandler := handlers.NewPostHandler(postService, commentService) 43 | authHandler := handlers.NewAuthHandler(userService, mailService, JWTAuthenticator) 44 | 45 | registerHealthRoutes(router, health, middleware) 46 | registerUserRoutes(router, userHandler, middleware, feedHandler) 47 | registerPostRoutes(router, postHandler, middleware) 48 | registerAuthenticationRoutes(router, authHandler, middleware) 49 | 50 | docsURL := fmt.Sprintf("%s/swagger/doc.json", config.AppConfig.ServerConfig.Port) 51 | router.Handler(http.MethodGet, "/swagger/*any", httpSwagger.Handler(httpSwagger.URL(docsURL))) 52 | } 53 | -------------------------------------------------------------------------------- /internal/service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 8 | "github.com/saleh-ghazimoradi/Gophergram/utils" 9 | "time" 10 | ) 11 | 12 | type UserService interface { 13 | Create(ctx context.Context, user *service_models.User) error 14 | GetById(ctx context.Context, id int64) (*service_models.User, error) 15 | GetByEmail(ctx context.Context, email string) (*service_models.User, error) 16 | CreateAndInvite(ctx context.Context, user *service_models.User, token string, invitationExp time.Duration) error 17 | Delete(ctx context.Context, id int64) error 18 | Activate(ctx context.Context, token string) error 19 | } 20 | 21 | type userService struct { 22 | userRepo repository.UserRepository 23 | db *sql.DB 24 | } 25 | 26 | func (u *userService) Create(ctx context.Context, user *service_models.User) error { 27 | return utils.WithTransaction(ctx, u.db, func(tx *sql.Tx) error { 28 | userRepoWithTx := u.userRepo.WithTx(tx) 29 | return userRepoWithTx.Create(ctx, user) 30 | }) 31 | } 32 | 33 | func (u *userService) GetById(ctx context.Context, id int64) (*service_models.User, error) { 34 | return u.userRepo.GetById(ctx, id) 35 | } 36 | 37 | func (u *userService) CreateAndInvite(ctx context.Context, user *service_models.User, token string, invitationExp time.Duration) error { 38 | return utils.WithTransaction(ctx, u.db, func(tx *sql.Tx) error { 39 | userRepoWithTx := u.userRepo.WithTx(tx) 40 | if err := userRepoWithTx.Create(ctx, user); err != nil { 41 | return err 42 | } 43 | if err := userRepoWithTx.CreateUserInvitation(ctx, token, invitationExp, user.ID); err != nil { 44 | return err 45 | } 46 | return nil 47 | }) 48 | } 49 | 50 | func (u *userService) Activate(ctx context.Context, token string) error { 51 | return utils.WithTransaction(ctx, u.db, func(tx *sql.Tx) error { 52 | user, err := u.userRepo.GetUserFromInvitation(ctx, token) 53 | if err != nil { 54 | return err 55 | } 56 | user.IsActive = true 57 | if err = u.userRepo.UpdateUserInvitation(ctx, user); err != nil { 58 | return err 59 | } 60 | if err = u.userRepo.DeleteUserInvitation(ctx, user.ID); err != nil { 61 | return err 62 | } 63 | return nil 64 | }) 65 | } 66 | 67 | func (u *userService) Delete(ctx context.Context, id int64) error { 68 | return utils.WithTransaction(ctx, u.db, func(tx *sql.Tx) error { 69 | if err := u.userRepo.Delete(ctx, id); err != nil { 70 | return err 71 | } 72 | if err := u.userRepo.DeleteUserInvitation(ctx, id); err != nil { 73 | return err 74 | } 75 | return nil 76 | }) 77 | } 78 | 79 | func (u *userService) GetByEmail(ctx context.Context, email string) (*service_models.User, error) { 80 | return u.userRepo.GetByEmail(ctx, email) 81 | } 82 | 83 | func NewUserService(userRepo repository.UserRepository, db *sql.DB) UserService { 84 | return &userService{ 85 | userRepo: userRepo, 86 | db: db, 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/caarlos0/env" 5 | "github.com/joho/godotenv" 6 | "log" 7 | "time" 8 | ) 9 | 10 | var AppConfig *Config 11 | 12 | type Config struct { 13 | ServerConfig ServerConfig 14 | DBConfig DBConfig 15 | Context Context 16 | Pagination Pagination 17 | Mail Mail 18 | Authentication Authentication 19 | Redis Redis 20 | Rate Rate 21 | } 22 | 23 | type ServerConfig struct { 24 | Port string `env:"SERVER_PORT,required"` 25 | Version string `env:"SERVER_VERSION,required"` 26 | IdleTimeout time.Duration `env:"SERVER_IDLE_TIMEOUT,required"` 27 | ReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT,required"` 28 | WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT,required"` 29 | Env string `env:"SERVER_ENV,required"` 30 | APIURL string `env:"SERVER_API_URL,required"` 31 | } 32 | 33 | type Context struct { 34 | ContextTimeout time.Duration `env:"CONTEXT_TIME_OUT,required"` 35 | } 36 | 37 | type Authentication struct { 38 | Username string `env:"USERNAME,required"` 39 | Password string `env:"PASSWORD,required"` 40 | Secret string `env:"SECRET,required"` 41 | Exp time.Duration `env:"EXP,required"` 42 | Aud string `env:"AUD,required"` 43 | Iss string `env:"ISS,required"` 44 | } 45 | 46 | type Rate struct { 47 | Limit int `env:"RATE_LIMIT,required"` 48 | Window time.Duration `env:"RATE_WINDOW,required"` 49 | } 50 | 51 | type Redis struct { 52 | Addr string `env:"REDIS_ADDR,required"` 53 | PW string `env:"REDIS_PASSWORD,required"` 54 | DB int `env:"REDIS_DB,required"` 55 | Enabled bool `env:"REDIS_ENABLED,required"` 56 | } 57 | 58 | type Mail struct { 59 | Exp time.Duration `env:"TOKEN_EXPIRATION,required"` 60 | FromName string `env:"MAIL_FROM_NAME,required"` 61 | MaxRetries uint `env:"MAIL_MAX_RETRIES,required"` 62 | UserWelcomeTemplate string `env:"TOKEN_USER_WELCOME_TEMPLATE,required"` 63 | FromEmail string `env:"FROM_EMAIL,required"` 64 | ApiKey string `env:"API_KEY,required"` 65 | FrontendURL string `env:"FRONTEND_URL,required"` 66 | } 67 | 68 | type DBConfig struct { 69 | DBDriver string `env:"DB_DRIVER,required"` 70 | DBSource string `env:"DB_SOURCE,required"` 71 | DbHost string `env:"DB_HOST,required"` 72 | DbPort string `env:"DB_PORT,required"` 73 | DbUser string `env:"DB_USER,required"` 74 | DbPassword string `env:"DB_PASSWORD,required"` 75 | DbName string `env:"DB_NAME,required"` 76 | DbSslMode string `env:"DB_SSLMODE,required"` 77 | MaxOpenConns int `env:"DB_MAX_OPEN_CONNECTIONS,required"` 78 | MaxIdleConns int `env:"DB_MAX_IDLE_CONNECTIONS,required"` 79 | MaxIdleTime time.Duration `env:"DB_MAX_IDLE_TIME,required"` 80 | Timeout time.Duration `env:"DB_TIMEOUT,required"` 81 | } 82 | 83 | type Pagination struct { 84 | Limit int `env:"LIMIT,required"` 85 | Offset int `env:"OFFSET,required"` 86 | Sort string `env:"SORT,required"` 87 | } 88 | 89 | func LoadingConfig() error { 90 | if err := godotenv.Load("app.env"); err != nil { 91 | log.Fatal("error loading app.env file") 92 | } 93 | 94 | config := &Config{} 95 | 96 | if err := env.Parse(config); err != nil { 97 | log.Fatal("error parsing config") 98 | } 99 | 100 | serverConfig := &ServerConfig{} 101 | 102 | if err := env.Parse(serverConfig); err != nil { 103 | log.Fatal("error parsing config") 104 | } 105 | 106 | config.ServerConfig = *serverConfig 107 | 108 | dbConfig := &DBConfig{} 109 | if err := env.Parse(dbConfig); err != nil { 110 | log.Fatal("error parsing config") 111 | } 112 | 113 | contextConfig := &Context{} 114 | 115 | if err := env.Parse(contextConfig); err != nil { 116 | log.Fatal("error parsing config") 117 | } 118 | 119 | config.Context = *contextConfig 120 | 121 | config.DBConfig = *dbConfig 122 | 123 | paginationConfig := &Pagination{} 124 | 125 | if err := env.Parse(paginationConfig); err != nil { 126 | log.Fatal("error parsing pagination config") 127 | } 128 | 129 | config.Pagination = *paginationConfig 130 | 131 | mailConfig := &Mail{} 132 | if err := env.Parse(mailConfig); err != nil { 133 | log.Fatal("error parsing token config") 134 | } 135 | config.Mail = *mailConfig 136 | 137 | authConfig := &Authentication{} 138 | if err := env.Parse(authConfig); err != nil { 139 | log.Fatal("error parsing token config") 140 | } 141 | config.Authentication = *authConfig 142 | 143 | redisConfig := &Redis{} 144 | if err := env.Parse(redisConfig); err != nil { 145 | log.Fatal("error parsing token config") 146 | } 147 | config.Redis = *redisConfig 148 | 149 | rateConfig := &Rate{} 150 | if err := env.Parse(rateConfig); err != nil { 151 | log.Fatal("error parsing token config") 152 | } 153 | 154 | config.Rate = *rateConfig 155 | 156 | AppConfig = config 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /internal/repository/post.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "github.com/lib/pq" 8 | _ "github.com/lib/pq" 9 | "github.com/saleh-ghazimoradi/Gophergram/config" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 11 | ) 12 | 13 | type PostRepository interface { 14 | Create(ctx context.Context, post *service_models.Post) error 15 | GetById(ctx context.Context, id int64) (*service_models.Post, error) 16 | GetUserFeed(ctx context.Context, id int64, fq service_models.PaginatedFeedQuery) ([]service_models.PostFeed, error) 17 | Delete(ctx context.Context, id int64) error 18 | Update(ctx context.Context, post *service_models.Post) error 19 | WithTx(tx *sql.Tx) PostRepository 20 | } 21 | 22 | type postRepository struct { 23 | dbRead *sql.DB 24 | dbWrite *sql.DB 25 | tx *sql.Tx 26 | } 27 | 28 | func (p *postRepository) Create(ctx context.Context, post *service_models.Post) error { 29 | query := `INSERT INTO posts (content, title, user_id, tags) VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at` 30 | 31 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 32 | defer cancel() 33 | 34 | args := []any{post.Content, post.Title, post.UserID, pq.Array(post.Tags)} 35 | 36 | if err := p.dbWrite.QueryRowContext(ctx, query, args...).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt); err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func (p *postRepository) GetById(ctx context.Context, id int64) (*service_models.Post, error) { 43 | query := `SELECT id, content, title, user_id, tags, created_at, updated_at , version FROM posts WHERE id = $1` 44 | 45 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 46 | defer cancel() 47 | 48 | var post service_models.Post 49 | 50 | err := p.dbRead.QueryRowContext(ctx, query, id).Scan(&post.ID, &post.Content, &post.Title, &post.UserID, pq.Array(&post.Tags), &post.CreatedAt, &post.UpdatedAt, &post.Version) 51 | 52 | if err != nil { 53 | switch { 54 | case errors.Is(err, sql.ErrNoRows): 55 | return nil, ErrsNotFound 56 | default: 57 | return nil, err 58 | } 59 | } 60 | return &post, nil 61 | } 62 | 63 | func (p *postRepository) Delete(ctx context.Context, id int64) error { 64 | query := `DELETE FROM posts WHERE id = $1` 65 | 66 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 67 | defer cancel() 68 | 69 | result, err := p.dbWrite.ExecContext(ctx, query, id) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | rows, err := result.RowsAffected() 75 | if err != nil { 76 | return err 77 | } 78 | if rows == 0 { 79 | return ErrsNotFound 80 | } 81 | return nil 82 | } 83 | 84 | func (p *postRepository) Update(ctx context.Context, post *service_models.Post) error { 85 | query := `UPDATE posts SET title = $1, content = $2, version = version + 1 WHERE id = $3 AND version = $4 RETURNING version` 86 | 87 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 88 | defer cancel() 89 | 90 | err := p.dbWrite.QueryRowContext(ctx, query, post.Title, post.Content, post.ID, post.Version).Scan(&post.Version) 91 | if err != nil { 92 | switch { 93 | case errors.Is(err, sql.ErrNoRows): 94 | return ErrsNotFound 95 | default: 96 | return err 97 | } 98 | } 99 | return nil 100 | } 101 | 102 | func (p *postRepository) GetUserFeed(ctx context.Context, id int64, fq service_models.PaginatedFeedQuery) ([]service_models.PostFeed, error) { 103 | query := ` 104 | SELECT 105 | p.id, p.user_id, p.title, p.content, p.created_at, p.version, p.tags, 106 | u.username, 107 | COUNT(c.id) AS comments_count 108 | FROM posts p 109 | LEFT JOIN comments c ON c.post_id = p.id 110 | LEFT JOIN users u ON p.user_id = u.id 111 | JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1 112 | WHERE f.user_id = $1 OR p.user_id = $1 113 | GROUP BY p.id, u.username 114 | ORDER BY p.created_at ` + fq.Sort + ` 115 | LIMIT $2 OFFSET $3 116 | ` 117 | 118 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 119 | defer cancel() 120 | 121 | rows, err := p.dbRead.QueryContext(ctx, query, id, fq.Limit, fq.Offset) 122 | if err != nil { 123 | return nil, err 124 | } 125 | defer rows.Close() 126 | 127 | var feed []service_models.PostFeed 128 | for rows.Next() { 129 | var ps service_models.PostFeed 130 | err := rows.Scan( 131 | &ps.ID, 132 | &ps.UserID, 133 | &ps.Title, 134 | &ps.Content, 135 | &ps.CreatedAt, 136 | &ps.Version, 137 | pq.Array(&ps.Tags), 138 | &ps.User.Username, 139 | &ps.CommentCount, 140 | ) 141 | if err != nil { 142 | return nil, err 143 | } 144 | feed = append(feed, ps) 145 | } 146 | return feed, nil 147 | } 148 | 149 | func (p *postRepository) WithTx(tx *sql.Tx) PostRepository { 150 | return &postRepository{ 151 | dbRead: p.dbRead, 152 | dbWrite: p.dbWrite, 153 | tx: tx, 154 | } 155 | } 156 | 157 | func NewPostRepository(dbRead, dbWrite *sql.DB) PostRepository { 158 | return &postRepository{ 159 | dbRead: dbRead, 160 | dbWrite: dbWrite, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /internal/gateway/handlers/post.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 8 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 11 | "net/http" 12 | ) 13 | 14 | type PostKey string 15 | 16 | const PostCtx PostKey = "post" 17 | 18 | type PostHandler struct { 19 | postService service.PostService 20 | commentService service.CommentService 21 | } 22 | 23 | // CreatePostHandler handles creating a new post. 24 | // 25 | // @Summary Creates a post 26 | // @Description Creates a post 27 | // @Tags posts 28 | // @Accept json 29 | // @Produce json 30 | // @Param payload body service_models.CreatePostPayload true "Post payload" 31 | // @Success 201 {object} service_models.Post 32 | // @Failure 400 {object} error 33 | // @Failure 401 {object} error 34 | // @Failure 500 {object} error 35 | // @Security ApiKeyAuth 36 | // @Router /v1/posts [post] 37 | func (p *PostHandler) CreatePostHandler(w http.ResponseWriter, r *http.Request) { 38 | var payload service_models.CreatePostPayload 39 | if err := json.ReadJSON(w, r, &payload); err != nil { 40 | helper.InternalServerError(w, r, err) 41 | return 42 | } 43 | 44 | if err := helper.Validate.Struct(payload); err != nil { 45 | helper.BadRequestResponse(w, r, err) 46 | return 47 | } 48 | 49 | user := GetUserFromContext(r) 50 | 51 | post := &service_models.Post{ 52 | Title: payload.Title, 53 | Content: payload.Content, 54 | Tags: payload.Tags, 55 | UserID: user.ID, 56 | } 57 | 58 | if err := p.postService.Create(context.Background(), post); err != nil { 59 | helper.InternalServerError(w, r, err) 60 | return 61 | } 62 | 63 | if err := json.JSONResponse(w, http.StatusCreated, post); err != nil { 64 | helper.InternalServerError(w, r, err) 65 | } 66 | } 67 | 68 | // GetPostByIdHandler retrieves a specific post by ID. 69 | // 70 | // @Summary Fetches a post 71 | // @Description Fetches a post by ID 72 | // @Tags posts 73 | // @Accept json 74 | // @Produce json 75 | // @Param id path int true "Post ID" 76 | // @Success 200 {object} service_models.Post 77 | // @Failure 404 {object} error 78 | // @Failure 500 {object} error 79 | // @Security ApiKeyAuth 80 | // @Router /v1/posts/{id} [get] 81 | func (p *PostHandler) GetPostByIdHandler(w http.ResponseWriter, r *http.Request) { 82 | post := GetPostFromCTX(r) 83 | 84 | comments, err := p.commentService.GetByPostId(context.Background(), post.ID) 85 | if err != nil { 86 | helper.InternalServerError(w, r, err) 87 | return 88 | } 89 | 90 | post.Comments = comments 91 | 92 | if err = json.JSONResponse(w, http.StatusOK, post); err != nil { 93 | helper.InternalServerError(w, r, err) 94 | } 95 | } 96 | 97 | // UpdatePostHandler updates an existing post. 98 | // 99 | // @Summary Updates a post 100 | // @Description Updates a post by ID 101 | // @Tags posts 102 | // @Accept json 103 | // @Produce json 104 | // @Param id path int true "Post ID" 105 | // @Param payload body service_models.UpdatePostPayload true "Post payload" 106 | // @Success 200 {object} service_models.Post 107 | // @Failure 400 {object} error 108 | // @Failure 401 {object} error 109 | // @Failure 404 {object} error 110 | // @Failure 500 {object} error 111 | // @Security ApiKeyAuth 112 | // @Router /v1/posts/{id} [patch] 113 | func (p *PostHandler) UpdatePostHandler(w http.ResponseWriter, r *http.Request) { 114 | post := GetPostFromCTX(r) 115 | 116 | var payload service_models.UpdatePostPayload 117 | if err := json.ReadJSON(w, r, &payload); err != nil { 118 | helper.BadRequestResponse(w, r, err) 119 | return 120 | } 121 | 122 | if err := helper.Validate.Struct(payload); err != nil { 123 | helper.BadRequestResponse(w, r, err) 124 | return 125 | } 126 | 127 | if payload.Title != nil { 128 | post.Title = *payload.Title 129 | } 130 | 131 | if payload.Content != nil { 132 | post.Content = *payload.Content 133 | } 134 | 135 | if err := p.postService.Update(context.Background(), post); err != nil { 136 | helper.InternalServerError(w, r, err) 137 | return 138 | } 139 | 140 | if err := json.JSONResponse(w, http.StatusOK, post); err != nil { 141 | helper.InternalServerError(w, r, err) 142 | } 143 | } 144 | 145 | // DeletePostHandler deletes a post by ID. 146 | // 147 | // @Summary Deletes a post 148 | // @Description Delete a post by ID 149 | // @Tags posts 150 | // @Accept json 151 | // @Produce json 152 | // @Param id path int true "Post ID" 153 | // @Success 204 {object} string 154 | // @Failure 404 {object} error 155 | // @Failure 500 {object} error 156 | // @Security ApiKeyAuth 157 | // @Router /v1/posts/{id} [delete] 158 | func (p *PostHandler) DeletePostHandler(w http.ResponseWriter, r *http.Request) { 159 | post := GetPostFromCTX(r) 160 | 161 | if err := p.postService.Delete(context.Background(), post.ID); err != nil { 162 | switch { 163 | case errors.Is(err, repository.ErrsNotFound): 164 | helper.NotFoundResponse(w, r, err) 165 | default: 166 | helper.InternalServerError(w, r, err) 167 | } 168 | } 169 | w.WriteHeader(http.StatusNoContent) 170 | } 171 | 172 | func GetPostFromCTX(r *http.Request) *service_models.Post { 173 | post, _ := r.Context().Value(PostCtx).(*service_models.Post) 174 | return post 175 | } 176 | 177 | func NewPostHandler(postServer service.PostService, commentService service.CommentService) *PostHandler { 178 | return &PostHandler{ 179 | postService: postServer, 180 | commentService: commentService, 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /internal/gateway/handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/google/uuid" 10 | "github.com/saleh-ghazimoradi/Gophergram/config" 11 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 12 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 13 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 14 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 15 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 16 | "github.com/saleh-ghazimoradi/Gophergram/logger" 17 | "net/http" 18 | "time" 19 | ) 20 | 21 | type AuthHandler struct { 22 | userService service.UserService 23 | mailService service.Mailer 24 | authService service.Authenticator 25 | } 26 | 27 | // RegisterUserHandler Register a user 28 | // 29 | // @Summary Register a user 30 | // @Description Register a user 31 | // @Tags authentication 32 | // @Accept json 33 | // @Produce json 34 | // @Param payload body service_models.RegisterUserPayload true "User credentials" 35 | // @Success 201 {string} service_models.UserWithToken "User registered" 36 | // @Failure 400 {object} error 37 | // @Failure 404 {object} error 38 | // @Security ApiKeyAuth 39 | // @Router /v1/authentication/user [post] 40 | func (a *AuthHandler) RegisterUserHandler(w http.ResponseWriter, r *http.Request) { 41 | var payload service_models.RegisterUserPayload 42 | if err := json.ReadJSON(w, r, &payload); err != nil { 43 | helper.BadRequestResponse(w, r, err) 44 | return 45 | } 46 | 47 | if err := helper.Validate.Struct(payload); err != nil { 48 | helper.BadRequestResponse(w, r, err) 49 | return 50 | } 51 | 52 | user := &service_models.User{ 53 | Username: payload.Username, 54 | Email: payload.Email, 55 | } 56 | 57 | if err := user.Password.Set(payload.Password); err != nil { 58 | helper.InternalServerError(w, r, err) 59 | return 60 | } 61 | 62 | plainToken := uuid.New().String() 63 | hash := sha256.Sum256([]byte(plainToken)) 64 | hashToken := hex.EncodeToString(hash[:]) 65 | 66 | if err := a.userService.CreateAndInvite(context.Background(), user, hashToken, config.AppConfig.Mail.Exp); err != nil { 67 | switch err { 68 | case repository.ErrDuplicateEmail: 69 | helper.BadRequestResponse(w, r, err) 70 | case repository.ErrDuplicateUsername: 71 | helper.BadRequestResponse(w, r, err) 72 | default: 73 | helper.InternalServerError(w, r, err) 74 | } 75 | return 76 | } 77 | 78 | userWithToken := &service_models.UserWithToken{ 79 | User: user, 80 | Token: plainToken, 81 | } 82 | 83 | activationURL := fmt.Sprintf("%s/confirm/%s", config.AppConfig.Mail.FrontendURL, plainToken) 84 | isProdEnv := config.AppConfig.ServerConfig.Env == "production" 85 | vars := struct { 86 | Username string 87 | ActivationURL string 88 | }{ 89 | Username: user.Username, 90 | ActivationURL: activationURL, 91 | } 92 | 93 | status, err := a.mailService.Send(config.AppConfig.Mail.UserWelcomeTemplate, user.Username, user.Email, vars, !isProdEnv) 94 | if err != nil { 95 | logger.Logger.Error("error sending welcome email", "error", err) 96 | 97 | // rollback user creation if email fails (SAGA pattern) 98 | if err := a.userService.Delete(context.Background(), user.ID); err != nil { 99 | logger.Logger.Error("error deleting user", "error", err) 100 | } 101 | helper.InternalServerError(w, r, err) 102 | return 103 | } 104 | 105 | logger.Logger.Info("Email sent", "status code", status) 106 | if err := json.JSONResponse(w, http.StatusCreated, userWithToken); err != nil { 107 | helper.InternalServerError(w, r, err) 108 | } 109 | 110 | } 111 | 112 | // CreateTokenHandler Register a user 113 | // 114 | // @Summary Creates a token 115 | // @Description Creates a token for a user 116 | // @Tags authentication 117 | // @Accept json 118 | // @Produce json 119 | // @Param payload body service_models.CreateUserTokenPayload true "User credentials" 120 | // @Success 200 {string} string "Token" 121 | // @Failure 400 {object} error 122 | // @Failure 401 {object} error 123 | // @Failure 500 {object} error 124 | // @Router /v1/authentication/token [post] 125 | func (a *AuthHandler) CreateTokenHandler(w http.ResponseWriter, r *http.Request) { 126 | var payload service_models.CreateUserTokenPayload 127 | if err := json.ReadJSON(w, r, &payload); err != nil { 128 | helper.BadRequestResponse(w, r, err) 129 | return 130 | } 131 | 132 | if err := helper.Validate.Struct(payload); err != nil { 133 | helper.BadRequestResponse(w, r, err) 134 | return 135 | } 136 | 137 | user, err := a.userService.GetByEmail(context.Background(), payload.Email) 138 | if err != nil { 139 | switch err { 140 | case repository.ErrsNotFound: 141 | helper.UnauthorizedErrorResponse(w, r, err) 142 | default: 143 | helper.InternalServerError(w, r, err) 144 | } 145 | return 146 | } 147 | 148 | claims := jwt.MapClaims{ 149 | "sub": user.ID, 150 | "exp": time.Now().Add(config.AppConfig.Mail.Exp).Unix(), 151 | "iat": time.Now().Unix(), 152 | "nbf": time.Now().Unix(), 153 | "iss": config.AppConfig.Authentication.Iss, 154 | "aud": config.AppConfig.Authentication.Aud, 155 | } 156 | 157 | token, err := a.authService.GenerateToken(claims) 158 | if err != nil { 159 | helper.InternalServerError(w, r, err) 160 | return 161 | } 162 | 163 | if err := json.JSONResponse(w, http.StatusCreated, token); err != nil { 164 | helper.InternalServerError(w, r, err) 165 | } 166 | } 167 | 168 | func NewAuthHandler(userService service.UserService, mailService service.Mailer, authService service.Authenticator) *AuthHandler { 169 | return &AuthHandler{ 170 | userService: userService, 171 | mailService: mailService, 172 | authService: authService, 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /internal/gateway/handlers/user.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/json" 8 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 9 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 11 | "github.com/saleh-ghazimoradi/Gophergram/logger" 12 | "net/http" 13 | ) 14 | 15 | type UserKey string 16 | 17 | const UserCTX UserKey = "user" 18 | 19 | type UserHandler struct { 20 | userService service.UserService 21 | followerService service.FollowerService 22 | cacheService service.CacheService 23 | } 24 | 25 | // GetUserHandler retrieves the current user from the context. 26 | // 27 | // @Summary Fetches a user profile 28 | // @Description Fetches a user profile by ID 29 | // @Tags users 30 | // @Accept json 31 | // @Produce json 32 | // @Param id path int true "id" 33 | // @Success 200 {object} service_models.User 34 | // @Failure 400 {object} error 35 | // @Failure 404 {object} error 36 | // @Failure 500 {object} error 37 | // @Security ApiKeyAuth 38 | // @Router /v1/users/{id} [get] 39 | func (u *UserHandler) GetUserHandler(w http.ResponseWriter, r *http.Request) { 40 | id, err := helper.ReadIdParam(r) 41 | if err != nil || id < 1 { 42 | helper.BadRequestResponse(w, r, err) 43 | return 44 | } 45 | 46 | user, err := u.getUser(context.Background(), id) 47 | if err != nil { 48 | switch err { 49 | case repository.ErrsNotFound: 50 | helper.NotFoundResponse(w, r, err) 51 | default: 52 | helper.InternalServerError(w, r, err) 53 | } 54 | return 55 | } 56 | 57 | if err := json.JSONResponse(w, http.StatusOK, user); err != nil { 58 | helper.InternalServerError(w, r, err) 59 | } 60 | } 61 | 62 | func (u *UserHandler) getUser(ctx context.Context, id int64) (*service_models.User, error) { 63 | logger.Logger.Info("cache hit", "key", "user", "id", id) 64 | user, err := u.cacheService.Get(ctx, id) 65 | if err != nil { 66 | return nil, err 67 | } 68 | if user == nil { 69 | logger.Logger.Info("fetching from DB", "id", id) 70 | user, err = u.userService.GetById(ctx, id) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if err = u.cacheService.Set(ctx, user); err != nil { 75 | return nil, err 76 | } 77 | } 78 | return user, nil 79 | } 80 | 81 | // FollowUserHandler allows a user to follow another user. 82 | // 83 | // @Summary Follows a user 84 | // @Description Follows a user by ID 85 | // @Tags users 86 | // @Accept json 87 | // @Produce json 88 | // @Param id path int true "id" 89 | // @Success 204 {string} string "User followed" 90 | // @Failure 400 {object} error "User payload missing" 91 | // @Failure 404 {object} error "User not found" 92 | // @Security ApiKeyAuth 93 | // @Router /v1/users/{id}/follow [put] 94 | func (u *UserHandler) FollowUserHandler(w http.ResponseWriter, r *http.Request) { 95 | followedUser := GetUserFromContext(r) 96 | followedID, err := helper.ReadIdParam(r) 97 | if err != nil { 98 | helper.BadRequestResponse(w, r, err) 99 | return 100 | } 101 | 102 | if err := u.followerService.Follow(context.Background(), followedUser.ID, followedID); err != nil { 103 | switch { 104 | case errors.Is(err, repository.ErrsConflict): 105 | helper.ConflictResponse(w, r, err) 106 | return 107 | default: 108 | helper.InternalServerError(w, r, err) 109 | return 110 | } 111 | } 112 | 113 | if err := json.JSONResponse(w, http.StatusNoContent, nil); err != nil { 114 | helper.InternalServerError(w, r, err) 115 | } 116 | } 117 | 118 | // UnFollowUserHandler allows a user to unfollow another user. 119 | // 120 | // @Summary Unfollow a user 121 | // @Description Unfollow a user by ID 122 | // @Tags users 123 | // @Accept json 124 | // @Produce json 125 | // @Param id path int true "id" 126 | // @Success 204 {string} string "User unfollowed" 127 | // @Failure 400 {object} error "User payload missing" 128 | // @Failure 404 {object} error "User not found" 129 | // @Security ApiKeyAuth 130 | // @Router /v1/users/{id}/unfollow [put] 131 | func (u *UserHandler) UnFollowUserHandler(w http.ResponseWriter, r *http.Request) { 132 | followedUser := GetUserFromContext(r) 133 | 134 | unfollowedID, err := helper.ReadIdParam(r) 135 | if err != nil { 136 | helper.BadRequestResponse(w, r, err) 137 | return 138 | } 139 | 140 | if err := u.followerService.Unfollow(context.Background(), followedUser.ID, unfollowedID); err != nil { 141 | helper.InternalServerError(w, r, err) 142 | return 143 | } 144 | 145 | if err := json.JSONResponse(w, http.StatusNoContent, nil); err != nil { 146 | helper.InternalServerError(w, r, err) 147 | } 148 | } 149 | 150 | // ActivateUserHandler Activates the registered users 151 | // 152 | // @Summary Activates/Register a user 153 | // @Description Activates/Register a user by invitation token 154 | // @Tags users 155 | // @Produce json 156 | // @Param token path string true "token" 157 | // @Success 204 {string} string "User activated" 158 | // @Failure 404 {object} error 159 | // @Failure 500 {object} error 160 | // @Security ApiKeyAuth 161 | // @Router /v1/user/activate/{token} [put] 162 | func (u *UserHandler) ActivateUserHandler(w http.ResponseWriter, r *http.Request) { 163 | token, err := helper.ReadTokenParam(r) 164 | if err != nil { 165 | helper.BadRequestResponse(w, r, err) 166 | return 167 | } 168 | 169 | err = u.userService.Activate(context.Background(), token) 170 | if err != nil { 171 | switch err { 172 | case repository.ErrsNotFound: 173 | helper.NotFoundResponse(w, r, err) 174 | default: 175 | helper.InternalServerError(w, r, err) 176 | } 177 | return 178 | } 179 | 180 | if err = json.JSONResponse(w, http.StatusOK, nil); err != nil { 181 | helper.InternalServerError(w, r, err) 182 | } 183 | } 184 | 185 | func GetUserFromContext(r *http.Request) *service_models.User { 186 | user, _ := r.Context().Value(UserCTX).(*service_models.User) 187 | return user 188 | } 189 | 190 | func NewUserHandler(userService service.UserService, followService service.FollowerService, cacheService service.CacheService) *UserHandler { 191 | return &UserHandler{ 192 | userService: userService, 193 | followerService: followService, 194 | cacheService: cacheService, 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /internal/repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "database/sql" 7 | "encoding/hex" 8 | "errors" 9 | "github.com/saleh-ghazimoradi/Gophergram/config" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 11 | "time" 12 | ) 13 | 14 | type UserRepository interface { 15 | Create(ctx context.Context, user *service_models.User) error 16 | GetById(ctx context.Context, id int64) (*service_models.User, error) 17 | CreateUserInvitation(ctx context.Context, token string, exp time.Duration, id int64) error 18 | GetUserFromInvitation(ctx context.Context, token string) (*service_models.User, error) 19 | GetByEmail(ctx context.Context, email string) (*service_models.User, error) 20 | UpdateUserInvitation(ctx context.Context, user *service_models.User) error 21 | DeleteUserInvitation(ctx context.Context, id int64) error 22 | Delete(ctx context.Context, id int64) error 23 | WithTx(tx *sql.Tx) UserRepository 24 | } 25 | 26 | type userRepository struct { 27 | dbRead *sql.DB 28 | dbWrite *sql.DB 29 | tx *sql.Tx 30 | } 31 | 32 | func (u *userRepository) Create(ctx context.Context, user *service_models.User) error { 33 | 34 | query := ` 35 | INSERT INTO users (username, password, email, role_id) VALUES 36 | ($1, $2, $3, (SELECT id FROM roles WHERE name = $4)) 37 | RETURNING id, created_at 38 | ` 39 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 40 | defer cancel() 41 | 42 | role := user.Role.Name 43 | if role == "" { 44 | role = "user" 45 | } 46 | 47 | args := []any{user.Username, user.Password.Hash, user.Email, role} 48 | if err := u.dbWrite.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt); err != nil { 49 | switch { 50 | case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`: 51 | return ErrDuplicateUsername 52 | case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: 53 | return ErrDuplicateEmail 54 | default: 55 | return err 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (u *userRepository) GetById(ctx context.Context, id int64) (*service_models.User, error) { 63 | query := `SELECT users.id, username, email, password, created_at, roles.* FROM users JOIN roles ON (users.role_id = roles.id) WHERE users.id = $1 AND is_active = true` 64 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 65 | defer cancel() 66 | var user service_models.User 67 | err := u.dbRead.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Username, &user.Email, &user.Password.Hash, &user.CreatedAt, &user.Role.ID, &user.Role.Name, &user.Role.Level, &user.Role.Description) 68 | if err != nil { 69 | switch { 70 | case errors.Is(err, sql.ErrNoRows): 71 | return nil, ErrsNotFound 72 | default: 73 | return nil, err 74 | } 75 | } 76 | 77 | return &user, nil 78 | } 79 | 80 | func (u *userRepository) CreateUserInvitation(ctx context.Context, token string, exp time.Duration, id int64) error { 81 | query := `INSERT INTO user_invitations (token, user_id, expiry) VALUES ($1, $2, $3)` 82 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 83 | defer cancel() 84 | 85 | args := []any{token, id, time.Now().Add(exp)} 86 | if _, err := u.dbWrite.ExecContext(ctx, query, args...); err != nil { 87 | return err 88 | } 89 | return nil 90 | } 91 | 92 | func (u *userRepository) GetUserFromInvitation(ctx context.Context, token string) (*service_models.User, error) { 93 | query := `SELECT u.id, u.username, u.email, u.created_at, u.is_active FROM users u JOIN user_invitations ui ON u.id = ui.user_id WHERE ui.token = $1 AND ui.expiry > $2` 94 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 95 | defer cancel() 96 | 97 | hash := sha256.Sum256([]byte(token)) 98 | hashToken := hex.EncodeToString(hash[:]) 99 | user := &service_models.User{} 100 | 101 | if err := u.dbRead.QueryRowContext(ctx, query, hashToken, time.Now()).Scan( 102 | &user.ID, 103 | &user.Username, 104 | &user.Email, 105 | &user.CreatedAt, 106 | &user.IsActive, 107 | ); err != nil { 108 | switch err { 109 | case sql.ErrNoRows: 110 | return nil, ErrsNotFound 111 | default: 112 | return nil, err 113 | } 114 | } 115 | return user, nil 116 | } 117 | 118 | func (u *userRepository) DeleteUserInvitation(ctx context.Context, id int64) error { 119 | query := `DELETE FROM user_invitations WHERE user_id = $1` 120 | 121 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 122 | defer cancel() 123 | 124 | _, err := u.dbWrite.ExecContext(ctx, query, id) 125 | if err != nil { 126 | return err 127 | } 128 | return nil 129 | } 130 | 131 | func (u *userRepository) UpdateUserInvitation(ctx context.Context, user *service_models.User) error { 132 | query := `UPDATE users SET username = $1, email = $2, is_active = $3 WHERE id = $4` 133 | 134 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 135 | defer cancel() 136 | 137 | _, err := u.dbWrite.ExecContext(ctx, query, user.Username, user.Email, user.IsActive, user.ID) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (u *userRepository) Delete(ctx context.Context, id int64) error { 146 | query := `DELETE FROM user_invitations WHERE user_id = $1` 147 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 148 | defer cancel() 149 | _, err := u.dbWrite.ExecContext(ctx, query, id) 150 | if err != nil { 151 | return err 152 | } 153 | return nil 154 | } 155 | 156 | func (u *userRepository) GetByEmail(ctx context.Context, email string) (*service_models.User, error) { 157 | query := ` 158 | SELECT id, username, email, password, created_at FROM users 159 | WHERE email = $1 AND is_active = true 160 | ` 161 | 162 | ctx, cancel := context.WithTimeout(ctx, config.AppConfig.Context.ContextTimeout) 163 | defer cancel() 164 | 165 | user := &service_models.User{} 166 | err := u.dbRead.QueryRowContext(ctx, query, email).Scan( 167 | &user.ID, 168 | &user.Username, 169 | &user.Email, 170 | &user.Password.Hash, 171 | &user.CreatedAt, 172 | ) 173 | if err != nil { 174 | switch err { 175 | case sql.ErrNoRows: 176 | return nil, ErrsNotFound 177 | default: 178 | return nil, err 179 | } 180 | } 181 | 182 | return user, nil 183 | } 184 | 185 | func (u *userRepository) WithTx(tx *sql.Tx) UserRepository { 186 | return &userRepository{ 187 | dbRead: u.dbRead, 188 | dbWrite: u.dbWrite, 189 | tx: tx, 190 | } 191 | } 192 | 193 | func NewUserRepository(dbRead, dbWrite *sql.DB) UserRepository { 194 | return &userRepository{ 195 | dbRead: dbRead, 196 | dbWrite: dbWrite, 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /internal/service/seed.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 8 | "log" 9 | "math/rand" 10 | ) 11 | 12 | var usernames = []string{ 13 | "alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi", 14 | "ivan", "judy", "karl", "laura", "mallory", "nina", "oscar", "peggy", 15 | "quinn", "rachel", "steve", "trent", "ursula", "victor", "wendy", "xander", 16 | "yvonne", "zack", "amber", "brian", "carol", "doug", "eric", "fiona", 17 | "george", "hannah", "ian", "jessica", "kevin", "lisa", "mike", "natalie", 18 | "oliver", "peter", "queen", "ron", "susan", "tim", "uma", "vicky", 19 | "walter", "xenia", "yasmin", "zoe", 20 | } 21 | 22 | var titles = []string{ 23 | "The Power of Habit", "Embracing Minimalism", "Healthy Eating Tips", 24 | "Travel on a Budget", "Mindfulness Meditation", "Boost Your Productivity", 25 | "Home Office Setup", "Digital Detox", "Gardening Basics", 26 | "DIY Home Projects", "Yoga for Beginners", "Sustainable Living", 27 | "Mastering Time Management", "Exploring Nature", "Simple Cooking Recipes", 28 | "Fitness at Home", "Personal Finance Tips", "Creative Writing", 29 | "Mental Health Awareness", "Learning New Skills", 30 | } 31 | 32 | var contents = []string{ 33 | "In this post, we'll explore how to develop good habits that stick and transform your life.", 34 | "Discover the benefits of a minimalist lifestyle and how to declutter your home and mind.", 35 | "Learn practical tips for eating healthy on a budget without sacrificing flavor.", 36 | "Traveling doesn't have to be expensive. Here are some tips for seeing the world on a budget.", 37 | "Mindfulness meditation can reduce stress and improve your mental well-being. Here's how to get started.", 38 | "Increase your productivity with these simple and effective strategies.", 39 | "Set up the perfect home office to boost your work-from-home efficiency and comfort.", 40 | "A digital detox can help you reconnect with the real world and improve your mental health.", 41 | "Start your gardening journey with these basic tips for beginners.", 42 | "Transform your home with these fun and easy DIY projects.", 43 | "Yoga is a great way to stay fit and flexible. Here are some beginner-friendly poses to try.", 44 | "Sustainable living is good for you and the planet. Learn how to make eco-friendly choices.", 45 | "Master time management with these tips and get more done in less time.", 46 | "Nature has so much to offer. Discover the benefits of spending time outdoors.", 47 | "Whip up delicious meals with these simple and quick cooking recipes.", 48 | "Stay fit without leaving home with these effective at-home workout routines.", 49 | "Take control of your finances with these practical personal finance tips.", 50 | "Unleash your creativity with these inspiring writing prompts and exercises.", 51 | "Mental health is just as important as physical health. Learn how to take care of your mind.", 52 | "Learning new skills can be fun and rewarding. Here are some ideas to get you started.", 53 | } 54 | 55 | var tags = []string{ 56 | "Self Improvement", "Minimalism", "Health", "Travel", "Mindfulness", 57 | "Productivity", "Home Office", "Digital Detox", "Gardening", "DIY", 58 | "Yoga", "Sustainability", "Time Management", "Nature", "Cooking", 59 | "Fitness", "Personal Finance", "Writing", "Mental Health", "Learning", 60 | } 61 | 62 | var commentsText = []string{ 63 | "Great post! Thanks for sharing.", 64 | "I completely agree with your thoughts.", 65 | "Thanks for the tips, very helpful.", 66 | "Interesting perspective, I hadn't considered that.", 67 | "Thanks for sharing your experience.", 68 | "Well written, I enjoyed reading this.", 69 | "This is very insightful, thanks for posting.", 70 | "Great advice, I'll definitely try that.", 71 | "I love this, very inspirational.", 72 | "Thanks for the information, very useful.", 73 | } 74 | 75 | type Seeder interface { 76 | Seed(ctx context.Context, db *sql.DB) error 77 | } 78 | 79 | type seederService struct { 80 | userService UserService 81 | postService PostService 82 | commentService CommentService 83 | } 84 | 85 | func (s *seederService) Seed(ctx context.Context, db *sql.DB) error { 86 | tx, err := db.BeginTx(ctx, nil) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | users := generateUsers(100) 92 | for _, user := range users { 93 | if err := s.userService.Create(ctx, user); err != nil { 94 | tx.Rollback() 95 | log.Println("Error creating user:", err) 96 | return err 97 | } 98 | } 99 | 100 | posts := generatePosts(200, users) 101 | for _, post := range posts { 102 | if err := s.postService.Create(ctx, post); err != nil { 103 | tx.Rollback() 104 | log.Println("Error creating post:", err) 105 | return err 106 | } 107 | } 108 | 109 | comments := generateComments(500, users, posts) 110 | for _, comment := range comments { 111 | if err := s.commentService.Create(ctx, comment); err != nil { 112 | tx.Rollback() 113 | log.Println("Error creating comment:", err) 114 | return err 115 | } 116 | } 117 | 118 | err = tx.Commit() 119 | if err != nil { 120 | log.Println("Error committing transaction:", err) 121 | return err 122 | } 123 | 124 | log.Println("Seeding complete") 125 | return nil 126 | } 127 | 128 | func generateUsers(num int) []*service_models.User { 129 | users := make([]*service_models.User, num) 130 | 131 | for i := 0; i < num; i++ { 132 | users[i] = &service_models.User{ 133 | Username: usernames[i%len(usernames)] + fmt.Sprintf("%d", i), 134 | Email: usernames[i%len(usernames)] + fmt.Sprintf("%d", i) + "@example.com", 135 | Role: service_models.Role{ 136 | Name: "user", 137 | }, 138 | } 139 | } 140 | return users 141 | } 142 | 143 | func generatePosts(num int, users []*service_models.User) []*service_models.Post { 144 | posts := make([]*service_models.Post, num) 145 | for i := 0; i < num; i++ { 146 | user := users[rand.Intn(len(users))] 147 | posts[i] = &service_models.Post{ 148 | UserID: user.ID, 149 | Title: titles[rand.Intn(len(titles))], 150 | Content: contents[rand.Intn(len(contents))], 151 | Tags: []string{ 152 | tags[rand.Intn(len(tags))], 153 | tags[rand.Intn(len(tags))], 154 | }, 155 | } 156 | } 157 | return posts 158 | } 159 | 160 | func generateComments(num int, users []*service_models.User, posts []*service_models.Post) []*service_models.Comment { 161 | cms := make([]*service_models.Comment, num) 162 | for i := 0; i < num; i++ { 163 | cms[i] = &service_models.Comment{ 164 | PostID: posts[rand.Intn(len(posts))].ID, 165 | UserID: users[rand.Intn(len(users))].ID, 166 | Content: commentsText[rand.Intn(len(commentsText))], 167 | } 168 | } 169 | return cms 170 | } 171 | 172 | func NewSeederService(userService UserService, postService PostService, commentService CommentService) Seeder { 173 | return &seederService{ 174 | userService: userService, 175 | postService: postService, 176 | commentService: commentService, 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /internal/gateway/middlewares/middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/saleh-ghazimoradi/Gophergram/config" 10 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/handlers" 11 | "github.com/saleh-ghazimoradi/Gophergram/internal/gateway/helper" 12 | "github.com/saleh-ghazimoradi/Gophergram/internal/repository" 13 | "github.com/saleh-ghazimoradi/Gophergram/internal/service" 14 | "github.com/saleh-ghazimoradi/Gophergram/internal/service/service_models" 15 | "github.com/saleh-ghazimoradi/Gophergram/logger" 16 | "net/http" 17 | "strconv" 18 | "strings" 19 | ) 20 | 21 | type CustomMiddleware struct { 22 | postService service.PostService 23 | userService service.UserService 24 | authService service.Authenticator 25 | roleService service.RoleService 26 | cacheService service.CacheService 27 | rateLimitService service.RateLimitService 28 | } 29 | 30 | func (m *CustomMiddleware) PostsContextMiddleware(next http.Handler) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | id, err := helper.ReadIdParam(r) 33 | if err != nil { 34 | helper.BadRequestResponse(w, r, err) 35 | return 36 | } 37 | 38 | post, err := m.postService.GetById(context.Background(), id) 39 | if err != nil { 40 | switch { 41 | case errors.Is(err, repository.ErrsNotFound): 42 | helper.NotFoundResponse(w, r, err) 43 | default: 44 | helper.InternalServerError(w, r, err) 45 | } 46 | return 47 | } 48 | 49 | ctx := context.WithValue(context.Background(), handlers.PostCtx, post) 50 | next.ServeHTTP(w, r.WithContext(ctx)) 51 | }) 52 | } 53 | 54 | func (m *CustomMiddleware) BasicAuthentication(next http.Handler) http.Handler { 55 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 | authHeader := r.Header.Get("Authorization") 57 | if authHeader == "" { 58 | helper.UnauthorizedBasicErrorResponse(w, r, fmt.Errorf("authorization header required")) 59 | return 60 | } 61 | 62 | parts := strings.Split(authHeader, " ") 63 | if len(parts) != 2 || parts[0] != "Basic" { 64 | helper.UnauthorizedBasicErrorResponse(w, r, fmt.Errorf("authorization header format must be Basic")) 65 | return 66 | } 67 | 68 | decoded, err := base64.StdEncoding.DecodeString(parts[1]) 69 | if err != nil { 70 | helper.UnauthorizedBasicErrorResponse(w, r, err) 71 | return 72 | } 73 | 74 | username := config.AppConfig.Authentication.Username 75 | pass := config.AppConfig.Authentication.Password 76 | 77 | creds := strings.SplitN(string(decoded), ":", 2) 78 | if len(creds) != 2 || creds[0] != username || creds[1] != pass { 79 | helper.UnauthorizedBasicErrorResponse(w, r, fmt.Errorf("invalid credentials")) 80 | } 81 | 82 | next.ServeHTTP(w, r) 83 | }) 84 | } 85 | 86 | func (m *CustomMiddleware) CommonHeaders(next http.Handler) http.Handler { 87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | w.Header().Set("Content-Security-Policy", 89 | "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com") 90 | 91 | w.Header().Set("Referrer-Policy", "origin-when-cross-origin") 92 | w.Header().Set("X-Content-Type-Options", "nosniff") 93 | w.Header().Set("X-Frame-Options", "deny") 94 | w.Header().Set("X-XSS-Protection", "0") 95 | 96 | w.Header().Set("Server", "Go") 97 | 98 | next.ServeHTTP(w, r) 99 | }) 100 | } 101 | 102 | func (m *CustomMiddleware) AuthTokenMiddleware(next http.Handler) http.Handler { 103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 104 | authHeader := r.Header.Get("Authorization") 105 | if authHeader == "" { 106 | helper.UnauthorizedErrorResponse(w, r, fmt.Errorf("authorization header required")) 107 | return 108 | } 109 | 110 | parts := strings.Split(authHeader, " ") 111 | if len(parts) != 2 || parts[0] != "Bearer" { 112 | helper.UnauthorizedErrorResponse(w, r, fmt.Errorf("authorization header format must be Bearer")) 113 | return 114 | } 115 | 116 | token := parts[1] 117 | 118 | jwtToken, err := m.authService.ValidateToken(token) 119 | if err != nil { 120 | helper.UnauthorizedErrorResponse(w, r, err) 121 | return 122 | } 123 | 124 | claims, _ := jwtToken.Claims.(jwt.MapClaims) 125 | userId, err := strconv.ParseInt(fmt.Sprintf("%.f", claims["sub"]), 10, 64) 126 | if err != nil { 127 | helper.UnauthorizedErrorResponse(w, r, err) 128 | return 129 | } 130 | 131 | user, err := m.getUser(context.Background(), userId) 132 | if err != nil { 133 | helper.UnauthorizedErrorResponse(w, r, err) 134 | return 135 | } 136 | 137 | ctx := context.WithValue(r.Context(), handlers.UserCTX, user) 138 | next.ServeHTTP(w, r.WithContext(ctx)) 139 | }) 140 | } 141 | 142 | func (m *CustomMiddleware) RecoverPanic(next http.Handler) http.Handler { 143 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 | defer func() { 145 | if err := recover(); err != nil { 146 | w.Header().Set("Connection", "close") 147 | w.WriteHeader(http.StatusInternalServerError) 148 | } 149 | }() 150 | next.ServeHTTP(w, r) 151 | }) 152 | } 153 | 154 | func (m *CustomMiddleware) getUser(ctx context.Context, id int64) (*service_models.User, error) { 155 | logger.Logger.Info("cache hit", "key", "user", "id", id) 156 | user, err := m.cacheService.Get(ctx, id) 157 | if err != nil { 158 | return nil, err 159 | } 160 | if user == nil { 161 | logger.Logger.Info("fetching from DB", "id", id) 162 | user, err = m.userService.GetById(ctx, id) 163 | if err != nil { 164 | return nil, err 165 | } 166 | if err = m.cacheService.Set(ctx, user); err != nil { 167 | return nil, err 168 | } 169 | } 170 | return user, nil 171 | } 172 | func (m *CustomMiddleware) CheckPostOwnership(requiredRole string, next http.Handler) http.Handler { 173 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 174 | user := handlers.GetUserFromContext(r) 175 | post := handlers.GetPostFromCTX(r) 176 | 177 | if post.UserID == user.ID { 178 | next.ServeHTTP(w, r) 179 | return 180 | } 181 | 182 | allowed, err := m.checkRolePrecedence(context.Background(), user, requiredRole) 183 | if err != nil { 184 | helper.InternalServerError(w, r, err) 185 | return 186 | } 187 | 188 | if !allowed { 189 | helper.ForbiddenResponse(w, r) 190 | return 191 | } 192 | 193 | next.ServeHTTP(w, r) 194 | }) 195 | } 196 | 197 | func (m *CustomMiddleware) checkRolePrecedence(ctx context.Context, user *service_models.User, roleName string) (bool, error) { 198 | role, err := m.roleService.GetByName(ctx, roleName) 199 | if err != nil { 200 | return false, err 201 | } 202 | return user.Role.Level >= role.Level, nil 203 | } 204 | 205 | func (m *CustomMiddleware) RateLimitMiddleware(next http.Handler) http.Handler { 206 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 207 | clientIP := r.RemoteAddr 208 | allowed, retryAfter, err := m.rateLimitService.IsAllowed(r.Context(), clientIP, config.AppConfig.Rate.Limit, config.AppConfig.Rate.Window) 209 | 210 | if err != nil && errors.Is(err, repository.ErrRateLimitExceeded) { 211 | w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) 212 | helper.RateLimitExceededResponse(w, r, fmt.Sprintf("%v", config.AppConfig.Rate.Window)) 213 | return 214 | } else if err != nil { 215 | helper.InternalServerError(w, r, err) 216 | return 217 | } 218 | 219 | if !allowed { 220 | w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) 221 | helper.RateLimitExceededResponse(w, r, fmt.Sprintf("%v", config.AppConfig.Rate.Window)) 222 | return 223 | } 224 | 225 | next.ServeHTTP(w, r) 226 | }) 227 | } 228 | 229 | func NewMiddleware(postService service.PostService, userService service.UserService, authService service.Authenticator, roleService service.RoleService, cacheService service.CacheService, rateLimitService service.RateLimitService) *CustomMiddleware { 230 | return &CustomMiddleware{ 231 | postService: postService, 232 | userService: userService, 233 | authService: authService, 234 | roleService: roleService, 235 | cacheService: cacheService, 236 | rateLimitService: rateLimitService, 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 2 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 3 | github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= 4 | github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 11 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 12 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 13 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 14 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 15 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 16 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 17 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 18 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 19 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 20 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 21 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 22 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 23 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 24 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 25 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 26 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 27 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 28 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 29 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 30 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= 31 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 32 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 33 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 34 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 35 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 36 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 41 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 42 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 43 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 44 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 45 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 46 | github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= 47 | github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= 48 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 49 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 53 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 54 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 55 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 56 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 57 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 58 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 59 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 60 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 61 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 62 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= 63 | github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= 64 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 65 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 67 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 68 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 69 | github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= 70 | github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= 71 | github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= 72 | github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= 73 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 74 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 75 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 76 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 77 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 78 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 79 | github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= 80 | github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= 81 | github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= 82 | github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= 83 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 84 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 85 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 86 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 87 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 88 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 89 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 90 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 91 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 92 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 93 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 94 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 95 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 96 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 97 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 98 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 99 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 100 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 101 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 102 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 103 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 104 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 105 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 106 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 107 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 108 | -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: / 2 | definitions: 3 | service_models.Comment: 4 | properties: 5 | content: 6 | type: string 7 | created_at: 8 | type: string 9 | id: 10 | type: integer 11 | post_id: 12 | type: integer 13 | user: 14 | $ref: '#/definitions/service_models.User' 15 | user_id: 16 | type: integer 17 | type: object 18 | service_models.CreatePostPayload: 19 | properties: 20 | content: 21 | maxLength: 1000 22 | type: string 23 | tags: 24 | items: 25 | type: string 26 | type: array 27 | title: 28 | maxLength: 200 29 | type: string 30 | required: 31 | - content 32 | - title 33 | type: object 34 | service_models.CreateUserTokenPayload: 35 | properties: 36 | email: 37 | maxLength: 255 38 | type: string 39 | password: 40 | maxLength: 32 41 | minLength: 3 42 | type: string 43 | required: 44 | - email 45 | - password 46 | type: object 47 | service_models.Post: 48 | properties: 49 | comments: 50 | items: 51 | $ref: '#/definitions/service_models.Comment' 52 | type: array 53 | content: 54 | type: string 55 | created_at: 56 | type: string 57 | id: 58 | type: integer 59 | tags: 60 | items: 61 | type: string 62 | type: array 63 | title: 64 | type: string 65 | updated_at: 66 | type: string 67 | user: 68 | $ref: '#/definitions/service_models.User' 69 | user_id: 70 | type: integer 71 | version: 72 | type: integer 73 | type: object 74 | service_models.PostFeed: 75 | properties: 76 | comment_count: 77 | type: integer 78 | comments: 79 | items: 80 | $ref: '#/definitions/service_models.Comment' 81 | type: array 82 | content: 83 | type: string 84 | created_at: 85 | type: string 86 | id: 87 | type: integer 88 | tags: 89 | items: 90 | type: string 91 | type: array 92 | title: 93 | type: string 94 | updated_at: 95 | type: string 96 | user: 97 | $ref: '#/definitions/service_models.User' 98 | user_id: 99 | type: integer 100 | version: 101 | type: integer 102 | type: object 103 | service_models.RegisterUserPayload: 104 | properties: 105 | email: 106 | maxLength: 255 107 | type: string 108 | password: 109 | maxLength: 32 110 | minLength: 3 111 | type: string 112 | username: 113 | maxLength: 50 114 | type: string 115 | required: 116 | - email 117 | - password 118 | - username 119 | type: object 120 | service_models.Role: 121 | properties: 122 | description: 123 | type: string 124 | id: 125 | type: integer 126 | level: 127 | type: integer 128 | name: 129 | type: string 130 | type: object 131 | service_models.UpdatePostPayload: 132 | properties: 133 | content: 134 | maxLength: 1000 135 | type: string 136 | title: 137 | maxLength: 200 138 | type: string 139 | type: object 140 | service_models.User: 141 | properties: 142 | created_at: 143 | type: string 144 | email: 145 | type: string 146 | id: 147 | type: integer 148 | is_active: 149 | type: boolean 150 | role: 151 | $ref: '#/definitions/service_models.Role' 152 | role_id: 153 | type: integer 154 | username: 155 | type: string 156 | type: object 157 | info: 158 | contact: 159 | email: support@swagger.io 160 | name: API support 161 | url: http://www.swagger.io/support 162 | description: API for Gophergram, a social network for gophers 163 | license: 164 | name: Apache 2.0 165 | url: http://www.apache.org/licenses/LICENSE-2.0.html 166 | termsOfService: http://swagger.io/terms/ 167 | title: Gophergram API 168 | paths: 169 | /v1/authentication/token: 170 | post: 171 | consumes: 172 | - application/json 173 | description: Creates a token for a user 174 | parameters: 175 | - description: User credentials 176 | in: body 177 | name: payload 178 | required: true 179 | schema: 180 | $ref: '#/definitions/service_models.CreateUserTokenPayload' 181 | produces: 182 | - application/json 183 | responses: 184 | "200": 185 | description: Token 186 | schema: 187 | type: string 188 | "400": 189 | description: Bad Request 190 | schema: {} 191 | "401": 192 | description: Unauthorized 193 | schema: {} 194 | "500": 195 | description: Internal Server Error 196 | schema: {} 197 | summary: Creates a token 198 | tags: 199 | - authentication 200 | /v1/authentication/user: 201 | post: 202 | consumes: 203 | - application/json 204 | description: Register a user 205 | parameters: 206 | - description: User credentials 207 | in: body 208 | name: payload 209 | required: true 210 | schema: 211 | $ref: '#/definitions/service_models.RegisterUserPayload' 212 | produces: 213 | - application/json 214 | responses: 215 | "201": 216 | description: User registered 217 | schema: 218 | type: string 219 | "400": 220 | description: Bad Request 221 | schema: {} 222 | "404": 223 | description: Not Found 224 | schema: {} 225 | security: 226 | - ApiKeyAuth: [] 227 | summary: Register a user 228 | tags: 229 | - authentication 230 | /v1/health: 231 | get: 232 | description: Healthcheck endpoint 233 | produces: 234 | - application/json 235 | responses: 236 | "200": 237 | description: ok 238 | schema: 239 | type: string 240 | summary: Healthcheck 241 | tags: 242 | - ops 243 | /v1/posts: 244 | post: 245 | consumes: 246 | - application/json 247 | description: Creates a post 248 | parameters: 249 | - description: Post payload 250 | in: body 251 | name: payload 252 | required: true 253 | schema: 254 | $ref: '#/definitions/service_models.CreatePostPayload' 255 | produces: 256 | - application/json 257 | responses: 258 | "201": 259 | description: Created 260 | schema: 261 | $ref: '#/definitions/service_models.Post' 262 | "400": 263 | description: Bad Request 264 | schema: {} 265 | "401": 266 | description: Unauthorized 267 | schema: {} 268 | "500": 269 | description: Internal Server Error 270 | schema: {} 271 | security: 272 | - ApiKeyAuth: [] 273 | summary: Creates a post 274 | tags: 275 | - posts 276 | /v1/posts/{id}: 277 | delete: 278 | consumes: 279 | - application/json 280 | description: Delete a post by ID 281 | parameters: 282 | - description: Post ID 283 | in: path 284 | name: id 285 | required: true 286 | type: integer 287 | produces: 288 | - application/json 289 | responses: 290 | "204": 291 | description: No Content 292 | schema: 293 | type: string 294 | "404": 295 | description: Not Found 296 | schema: {} 297 | "500": 298 | description: Internal Server Error 299 | schema: {} 300 | security: 301 | - ApiKeyAuth: [] 302 | summary: Deletes a post 303 | tags: 304 | - posts 305 | get: 306 | consumes: 307 | - application/json 308 | description: Fetches a post by ID 309 | parameters: 310 | - description: Post ID 311 | in: path 312 | name: id 313 | required: true 314 | type: integer 315 | produces: 316 | - application/json 317 | responses: 318 | "200": 319 | description: OK 320 | schema: 321 | $ref: '#/definitions/service_models.Post' 322 | "404": 323 | description: Not Found 324 | schema: {} 325 | "500": 326 | description: Internal Server Error 327 | schema: {} 328 | security: 329 | - ApiKeyAuth: [] 330 | summary: Fetches a post 331 | tags: 332 | - posts 333 | patch: 334 | consumes: 335 | - application/json 336 | description: Updates a post by ID 337 | parameters: 338 | - description: Post ID 339 | in: path 340 | name: id 341 | required: true 342 | type: integer 343 | - description: Post payload 344 | in: body 345 | name: payload 346 | required: true 347 | schema: 348 | $ref: '#/definitions/service_models.UpdatePostPayload' 349 | produces: 350 | - application/json 351 | responses: 352 | "200": 353 | description: OK 354 | schema: 355 | $ref: '#/definitions/service_models.Post' 356 | "400": 357 | description: Bad Request 358 | schema: {} 359 | "401": 360 | description: Unauthorized 361 | schema: {} 362 | "404": 363 | description: Not Found 364 | schema: {} 365 | "500": 366 | description: Internal Server Error 367 | schema: {} 368 | security: 369 | - ApiKeyAuth: [] 370 | summary: Updates a post 371 | tags: 372 | - posts 373 | /v1/user/activate/{token}: 374 | put: 375 | description: Activates/Register a user by invitation token 376 | parameters: 377 | - description: token 378 | in: path 379 | name: token 380 | required: true 381 | type: string 382 | produces: 383 | - application/json 384 | responses: 385 | "204": 386 | description: User activated 387 | schema: 388 | type: string 389 | "404": 390 | description: Not Found 391 | schema: {} 392 | "500": 393 | description: Internal Server Error 394 | schema: {} 395 | security: 396 | - ApiKeyAuth: [] 397 | summary: Activates/Register a user 398 | tags: 399 | - users 400 | /v1/users/{id}: 401 | get: 402 | consumes: 403 | - application/json 404 | description: Fetches a user profile by ID 405 | parameters: 406 | - description: id 407 | in: path 408 | name: id 409 | required: true 410 | type: integer 411 | produces: 412 | - application/json 413 | responses: 414 | "200": 415 | description: OK 416 | schema: 417 | $ref: '#/definitions/service_models.User' 418 | "400": 419 | description: Bad Request 420 | schema: {} 421 | "404": 422 | description: Not Found 423 | schema: {} 424 | "500": 425 | description: Internal Server Error 426 | schema: {} 427 | security: 428 | - ApiKeyAuth: [] 429 | summary: Fetches a user profile 430 | tags: 431 | - users 432 | /v1/users/{id}/follow: 433 | put: 434 | consumes: 435 | - application/json 436 | description: Follows a user by ID 437 | parameters: 438 | - description: id 439 | in: path 440 | name: id 441 | required: true 442 | type: integer 443 | produces: 444 | - application/json 445 | responses: 446 | "204": 447 | description: User followed 448 | schema: 449 | type: string 450 | "400": 451 | description: User payload missing 452 | schema: {} 453 | "404": 454 | description: User not found 455 | schema: {} 456 | security: 457 | - ApiKeyAuth: [] 458 | summary: Follows a user 459 | tags: 460 | - users 461 | /v1/users/{id}/unfollow: 462 | put: 463 | consumes: 464 | - application/json 465 | description: Unfollow a user by ID 466 | parameters: 467 | - description: id 468 | in: path 469 | name: id 470 | required: true 471 | type: integer 472 | produces: 473 | - application/json 474 | responses: 475 | "204": 476 | description: User unfollowed 477 | schema: 478 | type: string 479 | "400": 480 | description: User payload missing 481 | schema: {} 482 | "404": 483 | description: User not found 484 | schema: {} 485 | security: 486 | - ApiKeyAuth: [] 487 | summary: Unfollow a user 488 | tags: 489 | - users 490 | /v1/users/feed: 491 | get: 492 | consumes: 493 | - application/json 494 | description: Fetches the user feed 495 | parameters: 496 | - description: Limit 497 | in: query 498 | name: limit 499 | type: integer 500 | - description: Offset 501 | in: query 502 | name: offset 503 | type: integer 504 | - description: Sort 505 | in: query 506 | name: sort 507 | type: string 508 | produces: 509 | - application/json 510 | responses: 511 | "200": 512 | description: OK 513 | schema: 514 | items: 515 | $ref: '#/definitions/service_models.PostFeed' 516 | type: array 517 | "400": 518 | description: Bad Request 519 | schema: {} 520 | "500": 521 | description: Internal Server Error 522 | schema: {} 523 | security: 524 | - ApiKeyAuth: [] 525 | summary: Fetches the user feed 526 | tags: 527 | - feed 528 | securityDefinitions: 529 | ApiKeyAuth: 530 | in: header 531 | name: Authorization 532 | type: apiKey 533 | swagger: "2.0" 534 | -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "API for Gophergram, a social network for gophers", 5 | "title": "Gophergram API", 6 | "termsOfService": "http://swagger.io/terms/", 7 | "contact": { 8 | "name": "API support", 9 | "url": "http://www.swagger.io/support", 10 | "email": "support@swagger.io" 11 | }, 12 | "license": { 13 | "name": "Apache 2.0", 14 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 15 | } 16 | }, 17 | "basePath": "/", 18 | "paths": { 19 | "/v1/authentication/token": { 20 | "post": { 21 | "description": "Creates a token for a user", 22 | "consumes": [ 23 | "application/json" 24 | ], 25 | "produces": [ 26 | "application/json" 27 | ], 28 | "tags": [ 29 | "authentication" 30 | ], 31 | "summary": "Creates a token", 32 | "parameters": [ 33 | { 34 | "description": "User credentials", 35 | "name": "payload", 36 | "in": "body", 37 | "required": true, 38 | "schema": { 39 | "$ref": "#/definitions/service_models.CreateUserTokenPayload" 40 | } 41 | } 42 | ], 43 | "responses": { 44 | "200": { 45 | "description": "Token", 46 | "schema": { 47 | "type": "string" 48 | } 49 | }, 50 | "400": { 51 | "description": "Bad Request", 52 | "schema": {} 53 | }, 54 | "401": { 55 | "description": "Unauthorized", 56 | "schema": {} 57 | }, 58 | "500": { 59 | "description": "Internal Server Error", 60 | "schema": {} 61 | } 62 | } 63 | } 64 | }, 65 | "/v1/authentication/user": { 66 | "post": { 67 | "security": [ 68 | { 69 | "ApiKeyAuth": [] 70 | } 71 | ], 72 | "description": "Register a user", 73 | "consumes": [ 74 | "application/json" 75 | ], 76 | "produces": [ 77 | "application/json" 78 | ], 79 | "tags": [ 80 | "authentication" 81 | ], 82 | "summary": "Register a user", 83 | "parameters": [ 84 | { 85 | "description": "User credentials", 86 | "name": "payload", 87 | "in": "body", 88 | "required": true, 89 | "schema": { 90 | "$ref": "#/definitions/service_models.RegisterUserPayload" 91 | } 92 | } 93 | ], 94 | "responses": { 95 | "201": { 96 | "description": "User registered", 97 | "schema": { 98 | "type": "string" 99 | } 100 | }, 101 | "400": { 102 | "description": "Bad Request", 103 | "schema": {} 104 | }, 105 | "404": { 106 | "description": "Not Found", 107 | "schema": {} 108 | } 109 | } 110 | } 111 | }, 112 | "/v1/health": { 113 | "get": { 114 | "description": "Healthcheck endpoint", 115 | "produces": [ 116 | "application/json" 117 | ], 118 | "tags": [ 119 | "ops" 120 | ], 121 | "summary": "Healthcheck", 122 | "responses": { 123 | "200": { 124 | "description": "ok", 125 | "schema": { 126 | "type": "string" 127 | } 128 | } 129 | } 130 | } 131 | }, 132 | "/v1/posts": { 133 | "post": { 134 | "security": [ 135 | { 136 | "ApiKeyAuth": [] 137 | } 138 | ], 139 | "description": "Creates a post", 140 | "consumes": [ 141 | "application/json" 142 | ], 143 | "produces": [ 144 | "application/json" 145 | ], 146 | "tags": [ 147 | "posts" 148 | ], 149 | "summary": "Creates a post", 150 | "parameters": [ 151 | { 152 | "description": "Post payload", 153 | "name": "payload", 154 | "in": "body", 155 | "required": true, 156 | "schema": { 157 | "$ref": "#/definitions/service_models.CreatePostPayload" 158 | } 159 | } 160 | ], 161 | "responses": { 162 | "201": { 163 | "description": "Created", 164 | "schema": { 165 | "$ref": "#/definitions/service_models.Post" 166 | } 167 | }, 168 | "400": { 169 | "description": "Bad Request", 170 | "schema": {} 171 | }, 172 | "401": { 173 | "description": "Unauthorized", 174 | "schema": {} 175 | }, 176 | "500": { 177 | "description": "Internal Server Error", 178 | "schema": {} 179 | } 180 | } 181 | } 182 | }, 183 | "/v1/posts/{id}": { 184 | "get": { 185 | "security": [ 186 | { 187 | "ApiKeyAuth": [] 188 | } 189 | ], 190 | "description": "Fetches a post by ID", 191 | "consumes": [ 192 | "application/json" 193 | ], 194 | "produces": [ 195 | "application/json" 196 | ], 197 | "tags": [ 198 | "posts" 199 | ], 200 | "summary": "Fetches a post", 201 | "parameters": [ 202 | { 203 | "type": "integer", 204 | "description": "Post ID", 205 | "name": "id", 206 | "in": "path", 207 | "required": true 208 | } 209 | ], 210 | "responses": { 211 | "200": { 212 | "description": "OK", 213 | "schema": { 214 | "$ref": "#/definitions/service_models.Post" 215 | } 216 | }, 217 | "404": { 218 | "description": "Not Found", 219 | "schema": {} 220 | }, 221 | "500": { 222 | "description": "Internal Server Error", 223 | "schema": {} 224 | } 225 | } 226 | }, 227 | "delete": { 228 | "security": [ 229 | { 230 | "ApiKeyAuth": [] 231 | } 232 | ], 233 | "description": "Delete a post by ID", 234 | "consumes": [ 235 | "application/json" 236 | ], 237 | "produces": [ 238 | "application/json" 239 | ], 240 | "tags": [ 241 | "posts" 242 | ], 243 | "summary": "Deletes a post", 244 | "parameters": [ 245 | { 246 | "type": "integer", 247 | "description": "Post ID", 248 | "name": "id", 249 | "in": "path", 250 | "required": true 251 | } 252 | ], 253 | "responses": { 254 | "204": { 255 | "description": "No Content", 256 | "schema": { 257 | "type": "string" 258 | } 259 | }, 260 | "404": { 261 | "description": "Not Found", 262 | "schema": {} 263 | }, 264 | "500": { 265 | "description": "Internal Server Error", 266 | "schema": {} 267 | } 268 | } 269 | }, 270 | "patch": { 271 | "security": [ 272 | { 273 | "ApiKeyAuth": [] 274 | } 275 | ], 276 | "description": "Updates a post by ID", 277 | "consumes": [ 278 | "application/json" 279 | ], 280 | "produces": [ 281 | "application/json" 282 | ], 283 | "tags": [ 284 | "posts" 285 | ], 286 | "summary": "Updates a post", 287 | "parameters": [ 288 | { 289 | "type": "integer", 290 | "description": "Post ID", 291 | "name": "id", 292 | "in": "path", 293 | "required": true 294 | }, 295 | { 296 | "description": "Post payload", 297 | "name": "payload", 298 | "in": "body", 299 | "required": true, 300 | "schema": { 301 | "$ref": "#/definitions/service_models.UpdatePostPayload" 302 | } 303 | } 304 | ], 305 | "responses": { 306 | "200": { 307 | "description": "OK", 308 | "schema": { 309 | "$ref": "#/definitions/service_models.Post" 310 | } 311 | }, 312 | "400": { 313 | "description": "Bad Request", 314 | "schema": {} 315 | }, 316 | "401": { 317 | "description": "Unauthorized", 318 | "schema": {} 319 | }, 320 | "404": { 321 | "description": "Not Found", 322 | "schema": {} 323 | }, 324 | "500": { 325 | "description": "Internal Server Error", 326 | "schema": {} 327 | } 328 | } 329 | } 330 | }, 331 | "/v1/user/activate/{token}": { 332 | "put": { 333 | "security": [ 334 | { 335 | "ApiKeyAuth": [] 336 | } 337 | ], 338 | "description": "Activates/Register a user by invitation token", 339 | "produces": [ 340 | "application/json" 341 | ], 342 | "tags": [ 343 | "users" 344 | ], 345 | "summary": "Activates/Register a user", 346 | "parameters": [ 347 | { 348 | "type": "string", 349 | "description": "token", 350 | "name": "token", 351 | "in": "path", 352 | "required": true 353 | } 354 | ], 355 | "responses": { 356 | "204": { 357 | "description": "User activated", 358 | "schema": { 359 | "type": "string" 360 | } 361 | }, 362 | "404": { 363 | "description": "Not Found", 364 | "schema": {} 365 | }, 366 | "500": { 367 | "description": "Internal Server Error", 368 | "schema": {} 369 | } 370 | } 371 | } 372 | }, 373 | "/v1/users/feed": { 374 | "get": { 375 | "security": [ 376 | { 377 | "ApiKeyAuth": [] 378 | } 379 | ], 380 | "description": "Fetches the user feed", 381 | "consumes": [ 382 | "application/json" 383 | ], 384 | "produces": [ 385 | "application/json" 386 | ], 387 | "tags": [ 388 | "feed" 389 | ], 390 | "summary": "Fetches the user feed", 391 | "parameters": [ 392 | { 393 | "type": "integer", 394 | "description": "Limit", 395 | "name": "limit", 396 | "in": "query" 397 | }, 398 | { 399 | "type": "integer", 400 | "description": "Offset", 401 | "name": "offset", 402 | "in": "query" 403 | }, 404 | { 405 | "type": "string", 406 | "description": "Sort", 407 | "name": "sort", 408 | "in": "query" 409 | } 410 | ], 411 | "responses": { 412 | "200": { 413 | "description": "OK", 414 | "schema": { 415 | "type": "array", 416 | "items": { 417 | "$ref": "#/definitions/service_models.PostFeed" 418 | } 419 | } 420 | }, 421 | "400": { 422 | "description": "Bad Request", 423 | "schema": {} 424 | }, 425 | "500": { 426 | "description": "Internal Server Error", 427 | "schema": {} 428 | } 429 | } 430 | } 431 | }, 432 | "/v1/users/{id}": { 433 | "get": { 434 | "security": [ 435 | { 436 | "ApiKeyAuth": [] 437 | } 438 | ], 439 | "description": "Fetches a user profile by ID", 440 | "consumes": [ 441 | "application/json" 442 | ], 443 | "produces": [ 444 | "application/json" 445 | ], 446 | "tags": [ 447 | "users" 448 | ], 449 | "summary": "Fetches a user profile", 450 | "parameters": [ 451 | { 452 | "type": "integer", 453 | "description": "id", 454 | "name": "id", 455 | "in": "path", 456 | "required": true 457 | } 458 | ], 459 | "responses": { 460 | "200": { 461 | "description": "OK", 462 | "schema": { 463 | "$ref": "#/definitions/service_models.User" 464 | } 465 | }, 466 | "400": { 467 | "description": "Bad Request", 468 | "schema": {} 469 | }, 470 | "404": { 471 | "description": "Not Found", 472 | "schema": {} 473 | }, 474 | "500": { 475 | "description": "Internal Server Error", 476 | "schema": {} 477 | } 478 | } 479 | } 480 | }, 481 | "/v1/users/{id}/follow": { 482 | "put": { 483 | "security": [ 484 | { 485 | "ApiKeyAuth": [] 486 | } 487 | ], 488 | "description": "Follows a user by ID", 489 | "consumes": [ 490 | "application/json" 491 | ], 492 | "produces": [ 493 | "application/json" 494 | ], 495 | "tags": [ 496 | "users" 497 | ], 498 | "summary": "Follows a user", 499 | "parameters": [ 500 | { 501 | "type": "integer", 502 | "description": "id", 503 | "name": "id", 504 | "in": "path", 505 | "required": true 506 | } 507 | ], 508 | "responses": { 509 | "204": { 510 | "description": "User followed", 511 | "schema": { 512 | "type": "string" 513 | } 514 | }, 515 | "400": { 516 | "description": "User payload missing", 517 | "schema": {} 518 | }, 519 | "404": { 520 | "description": "User not found", 521 | "schema": {} 522 | } 523 | } 524 | } 525 | }, 526 | "/v1/users/{id}/unfollow": { 527 | "put": { 528 | "security": [ 529 | { 530 | "ApiKeyAuth": [] 531 | } 532 | ], 533 | "description": "Unfollow a user by ID", 534 | "consumes": [ 535 | "application/json" 536 | ], 537 | "produces": [ 538 | "application/json" 539 | ], 540 | "tags": [ 541 | "users" 542 | ], 543 | "summary": "Unfollow a user", 544 | "parameters": [ 545 | { 546 | "type": "integer", 547 | "description": "id", 548 | "name": "id", 549 | "in": "path", 550 | "required": true 551 | } 552 | ], 553 | "responses": { 554 | "204": { 555 | "description": "User unfollowed", 556 | "schema": { 557 | "type": "string" 558 | } 559 | }, 560 | "400": { 561 | "description": "User payload missing", 562 | "schema": {} 563 | }, 564 | "404": { 565 | "description": "User not found", 566 | "schema": {} 567 | } 568 | } 569 | } 570 | } 571 | }, 572 | "definitions": { 573 | "service_models.Comment": { 574 | "type": "object", 575 | "properties": { 576 | "content": { 577 | "type": "string" 578 | }, 579 | "created_at": { 580 | "type": "string" 581 | }, 582 | "id": { 583 | "type": "integer" 584 | }, 585 | "post_id": { 586 | "type": "integer" 587 | }, 588 | "user": { 589 | "$ref": "#/definitions/service_models.User" 590 | }, 591 | "user_id": { 592 | "type": "integer" 593 | } 594 | } 595 | }, 596 | "service_models.CreatePostPayload": { 597 | "type": "object", 598 | "required": [ 599 | "content", 600 | "title" 601 | ], 602 | "properties": { 603 | "content": { 604 | "type": "string", 605 | "maxLength": 1000 606 | }, 607 | "tags": { 608 | "type": "array", 609 | "items": { 610 | "type": "string" 611 | } 612 | }, 613 | "title": { 614 | "type": "string", 615 | "maxLength": 200 616 | } 617 | } 618 | }, 619 | "service_models.CreateUserTokenPayload": { 620 | "type": "object", 621 | "required": [ 622 | "email", 623 | "password" 624 | ], 625 | "properties": { 626 | "email": { 627 | "type": "string", 628 | "maxLength": 255 629 | }, 630 | "password": { 631 | "type": "string", 632 | "maxLength": 32, 633 | "minLength": 3 634 | } 635 | } 636 | }, 637 | "service_models.Post": { 638 | "type": "object", 639 | "properties": { 640 | "comments": { 641 | "type": "array", 642 | "items": { 643 | "$ref": "#/definitions/service_models.Comment" 644 | } 645 | }, 646 | "content": { 647 | "type": "string" 648 | }, 649 | "created_at": { 650 | "type": "string" 651 | }, 652 | "id": { 653 | "type": "integer" 654 | }, 655 | "tags": { 656 | "type": "array", 657 | "items": { 658 | "type": "string" 659 | } 660 | }, 661 | "title": { 662 | "type": "string" 663 | }, 664 | "updated_at": { 665 | "type": "string" 666 | }, 667 | "user": { 668 | "$ref": "#/definitions/service_models.User" 669 | }, 670 | "user_id": { 671 | "type": "integer" 672 | }, 673 | "version": { 674 | "type": "integer" 675 | } 676 | } 677 | }, 678 | "service_models.PostFeed": { 679 | "type": "object", 680 | "properties": { 681 | "comment_count": { 682 | "type": "integer" 683 | }, 684 | "comments": { 685 | "type": "array", 686 | "items": { 687 | "$ref": "#/definitions/service_models.Comment" 688 | } 689 | }, 690 | "content": { 691 | "type": "string" 692 | }, 693 | "created_at": { 694 | "type": "string" 695 | }, 696 | "id": { 697 | "type": "integer" 698 | }, 699 | "tags": { 700 | "type": "array", 701 | "items": { 702 | "type": "string" 703 | } 704 | }, 705 | "title": { 706 | "type": "string" 707 | }, 708 | "updated_at": { 709 | "type": "string" 710 | }, 711 | "user": { 712 | "$ref": "#/definitions/service_models.User" 713 | }, 714 | "user_id": { 715 | "type": "integer" 716 | }, 717 | "version": { 718 | "type": "integer" 719 | } 720 | } 721 | }, 722 | "service_models.RegisterUserPayload": { 723 | "type": "object", 724 | "required": [ 725 | "email", 726 | "password", 727 | "username" 728 | ], 729 | "properties": { 730 | "email": { 731 | "type": "string", 732 | "maxLength": 255 733 | }, 734 | "password": { 735 | "type": "string", 736 | "maxLength": 32, 737 | "minLength": 3 738 | }, 739 | "username": { 740 | "type": "string", 741 | "maxLength": 50 742 | } 743 | } 744 | }, 745 | "service_models.Role": { 746 | "type": "object", 747 | "properties": { 748 | "description": { 749 | "type": "string" 750 | }, 751 | "id": { 752 | "type": "integer" 753 | }, 754 | "level": { 755 | "type": "integer" 756 | }, 757 | "name": { 758 | "type": "string" 759 | } 760 | } 761 | }, 762 | "service_models.UpdatePostPayload": { 763 | "type": "object", 764 | "properties": { 765 | "content": { 766 | "type": "string", 767 | "maxLength": 1000 768 | }, 769 | "title": { 770 | "type": "string", 771 | "maxLength": 200 772 | } 773 | } 774 | }, 775 | "service_models.User": { 776 | "type": "object", 777 | "properties": { 778 | "created_at": { 779 | "type": "string" 780 | }, 781 | "email": { 782 | "type": "string" 783 | }, 784 | "id": { 785 | "type": "integer" 786 | }, 787 | "is_active": { 788 | "type": "boolean" 789 | }, 790 | "role": { 791 | "$ref": "#/definitions/service_models.Role" 792 | }, 793 | "role_id": { 794 | "type": "integer" 795 | }, 796 | "username": { 797 | "type": "string" 798 | } 799 | } 800 | } 801 | }, 802 | "securityDefinitions": { 803 | "ApiKeyAuth": { 804 | "type": "apiKey", 805 | "name": "Authorization", 806 | "in": "header" 807 | } 808 | } 809 | } -------------------------------------------------------------------------------- /docs/docs.go: -------------------------------------------------------------------------------- 1 | // Package docs Code generated by swaggo/swag. DO NOT EDIT 2 | package docs 3 | 4 | import "github.com/swaggo/swag" 5 | 6 | const docTemplate = `{ 7 | "schemes": {{ marshal .Schemes }}, 8 | "swagger": "2.0", 9 | "info": { 10 | "description": "{{escape .Description}}", 11 | "title": "{{.Title}}", 12 | "termsOfService": "http://swagger.io/terms/", 13 | "contact": { 14 | "name": "API support", 15 | "url": "http://www.swagger.io/support", 16 | "email": "support@swagger.io" 17 | }, 18 | "license": { 19 | "name": "Apache 2.0", 20 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 21 | }, 22 | "version": "{{.Version}}" 23 | }, 24 | "host": "{{.Host}}", 25 | "basePath": "{{.BasePath}}", 26 | "paths": { 27 | "/v1/authentication/token": { 28 | "post": { 29 | "description": "Creates a token for a user", 30 | "consumes": [ 31 | "application/json" 32 | ], 33 | "produces": [ 34 | "application/json" 35 | ], 36 | "tags": [ 37 | "authentication" 38 | ], 39 | "summary": "Creates a token", 40 | "parameters": [ 41 | { 42 | "description": "User credentials", 43 | "name": "payload", 44 | "in": "body", 45 | "required": true, 46 | "schema": { 47 | "$ref": "#/definitions/service_models.CreateUserTokenPayload" 48 | } 49 | } 50 | ], 51 | "responses": { 52 | "200": { 53 | "description": "Token", 54 | "schema": { 55 | "type": "string" 56 | } 57 | }, 58 | "400": { 59 | "description": "Bad Request", 60 | "schema": {} 61 | }, 62 | "401": { 63 | "description": "Unauthorized", 64 | "schema": {} 65 | }, 66 | "500": { 67 | "description": "Internal Server Error", 68 | "schema": {} 69 | } 70 | } 71 | } 72 | }, 73 | "/v1/authentication/user": { 74 | "post": { 75 | "security": [ 76 | { 77 | "ApiKeyAuth": [] 78 | } 79 | ], 80 | "description": "Register a user", 81 | "consumes": [ 82 | "application/json" 83 | ], 84 | "produces": [ 85 | "application/json" 86 | ], 87 | "tags": [ 88 | "authentication" 89 | ], 90 | "summary": "Register a user", 91 | "parameters": [ 92 | { 93 | "description": "User credentials", 94 | "name": "payload", 95 | "in": "body", 96 | "required": true, 97 | "schema": { 98 | "$ref": "#/definitions/service_models.RegisterUserPayload" 99 | } 100 | } 101 | ], 102 | "responses": { 103 | "201": { 104 | "description": "User registered", 105 | "schema": { 106 | "type": "string" 107 | } 108 | }, 109 | "400": { 110 | "description": "Bad Request", 111 | "schema": {} 112 | }, 113 | "404": { 114 | "description": "Not Found", 115 | "schema": {} 116 | } 117 | } 118 | } 119 | }, 120 | "/v1/health": { 121 | "get": { 122 | "description": "Healthcheck endpoint", 123 | "produces": [ 124 | "application/json" 125 | ], 126 | "tags": [ 127 | "ops" 128 | ], 129 | "summary": "Healthcheck", 130 | "responses": { 131 | "200": { 132 | "description": "ok", 133 | "schema": { 134 | "type": "string" 135 | } 136 | } 137 | } 138 | } 139 | }, 140 | "/v1/posts": { 141 | "post": { 142 | "security": [ 143 | { 144 | "ApiKeyAuth": [] 145 | } 146 | ], 147 | "description": "Creates a post", 148 | "consumes": [ 149 | "application/json" 150 | ], 151 | "produces": [ 152 | "application/json" 153 | ], 154 | "tags": [ 155 | "posts" 156 | ], 157 | "summary": "Creates a post", 158 | "parameters": [ 159 | { 160 | "description": "Post payload", 161 | "name": "payload", 162 | "in": "body", 163 | "required": true, 164 | "schema": { 165 | "$ref": "#/definitions/service_models.CreatePostPayload" 166 | } 167 | } 168 | ], 169 | "responses": { 170 | "201": { 171 | "description": "Created", 172 | "schema": { 173 | "$ref": "#/definitions/service_models.Post" 174 | } 175 | }, 176 | "400": { 177 | "description": "Bad Request", 178 | "schema": {} 179 | }, 180 | "401": { 181 | "description": "Unauthorized", 182 | "schema": {} 183 | }, 184 | "500": { 185 | "description": "Internal Server Error", 186 | "schema": {} 187 | } 188 | } 189 | } 190 | }, 191 | "/v1/posts/{id}": { 192 | "get": { 193 | "security": [ 194 | { 195 | "ApiKeyAuth": [] 196 | } 197 | ], 198 | "description": "Fetches a post by ID", 199 | "consumes": [ 200 | "application/json" 201 | ], 202 | "produces": [ 203 | "application/json" 204 | ], 205 | "tags": [ 206 | "posts" 207 | ], 208 | "summary": "Fetches a post", 209 | "parameters": [ 210 | { 211 | "type": "integer", 212 | "description": "Post ID", 213 | "name": "id", 214 | "in": "path", 215 | "required": true 216 | } 217 | ], 218 | "responses": { 219 | "200": { 220 | "description": "OK", 221 | "schema": { 222 | "$ref": "#/definitions/service_models.Post" 223 | } 224 | }, 225 | "404": { 226 | "description": "Not Found", 227 | "schema": {} 228 | }, 229 | "500": { 230 | "description": "Internal Server Error", 231 | "schema": {} 232 | } 233 | } 234 | }, 235 | "delete": { 236 | "security": [ 237 | { 238 | "ApiKeyAuth": [] 239 | } 240 | ], 241 | "description": "Delete a post by ID", 242 | "consumes": [ 243 | "application/json" 244 | ], 245 | "produces": [ 246 | "application/json" 247 | ], 248 | "tags": [ 249 | "posts" 250 | ], 251 | "summary": "Deletes a post", 252 | "parameters": [ 253 | { 254 | "type": "integer", 255 | "description": "Post ID", 256 | "name": "id", 257 | "in": "path", 258 | "required": true 259 | } 260 | ], 261 | "responses": { 262 | "204": { 263 | "description": "No Content", 264 | "schema": { 265 | "type": "string" 266 | } 267 | }, 268 | "404": { 269 | "description": "Not Found", 270 | "schema": {} 271 | }, 272 | "500": { 273 | "description": "Internal Server Error", 274 | "schema": {} 275 | } 276 | } 277 | }, 278 | "patch": { 279 | "security": [ 280 | { 281 | "ApiKeyAuth": [] 282 | } 283 | ], 284 | "description": "Updates a post by ID", 285 | "consumes": [ 286 | "application/json" 287 | ], 288 | "produces": [ 289 | "application/json" 290 | ], 291 | "tags": [ 292 | "posts" 293 | ], 294 | "summary": "Updates a post", 295 | "parameters": [ 296 | { 297 | "type": "integer", 298 | "description": "Post ID", 299 | "name": "id", 300 | "in": "path", 301 | "required": true 302 | }, 303 | { 304 | "description": "Post payload", 305 | "name": "payload", 306 | "in": "body", 307 | "required": true, 308 | "schema": { 309 | "$ref": "#/definitions/service_models.UpdatePostPayload" 310 | } 311 | } 312 | ], 313 | "responses": { 314 | "200": { 315 | "description": "OK", 316 | "schema": { 317 | "$ref": "#/definitions/service_models.Post" 318 | } 319 | }, 320 | "400": { 321 | "description": "Bad Request", 322 | "schema": {} 323 | }, 324 | "401": { 325 | "description": "Unauthorized", 326 | "schema": {} 327 | }, 328 | "404": { 329 | "description": "Not Found", 330 | "schema": {} 331 | }, 332 | "500": { 333 | "description": "Internal Server Error", 334 | "schema": {} 335 | } 336 | } 337 | } 338 | }, 339 | "/v1/user/activate/{token}": { 340 | "put": { 341 | "security": [ 342 | { 343 | "ApiKeyAuth": [] 344 | } 345 | ], 346 | "description": "Activates/Register a user by invitation token", 347 | "produces": [ 348 | "application/json" 349 | ], 350 | "tags": [ 351 | "users" 352 | ], 353 | "summary": "Activates/Register a user", 354 | "parameters": [ 355 | { 356 | "type": "string", 357 | "description": "token", 358 | "name": "token", 359 | "in": "path", 360 | "required": true 361 | } 362 | ], 363 | "responses": { 364 | "204": { 365 | "description": "User activated", 366 | "schema": { 367 | "type": "string" 368 | } 369 | }, 370 | "404": { 371 | "description": "Not Found", 372 | "schema": {} 373 | }, 374 | "500": { 375 | "description": "Internal Server Error", 376 | "schema": {} 377 | } 378 | } 379 | } 380 | }, 381 | "/v1/users/feed": { 382 | "get": { 383 | "security": [ 384 | { 385 | "ApiKeyAuth": [] 386 | } 387 | ], 388 | "description": "Fetches the user feed", 389 | "consumes": [ 390 | "application/json" 391 | ], 392 | "produces": [ 393 | "application/json" 394 | ], 395 | "tags": [ 396 | "feed" 397 | ], 398 | "summary": "Fetches the user feed", 399 | "parameters": [ 400 | { 401 | "type": "integer", 402 | "description": "Limit", 403 | "name": "limit", 404 | "in": "query" 405 | }, 406 | { 407 | "type": "integer", 408 | "description": "Offset", 409 | "name": "offset", 410 | "in": "query" 411 | }, 412 | { 413 | "type": "string", 414 | "description": "Sort", 415 | "name": "sort", 416 | "in": "query" 417 | } 418 | ], 419 | "responses": { 420 | "200": { 421 | "description": "OK", 422 | "schema": { 423 | "type": "array", 424 | "items": { 425 | "$ref": "#/definitions/service_models.PostFeed" 426 | } 427 | } 428 | }, 429 | "400": { 430 | "description": "Bad Request", 431 | "schema": {} 432 | }, 433 | "500": { 434 | "description": "Internal Server Error", 435 | "schema": {} 436 | } 437 | } 438 | } 439 | }, 440 | "/v1/users/{id}": { 441 | "get": { 442 | "security": [ 443 | { 444 | "ApiKeyAuth": [] 445 | } 446 | ], 447 | "description": "Fetches a user profile by ID", 448 | "consumes": [ 449 | "application/json" 450 | ], 451 | "produces": [ 452 | "application/json" 453 | ], 454 | "tags": [ 455 | "users" 456 | ], 457 | "summary": "Fetches a user profile", 458 | "parameters": [ 459 | { 460 | "type": "integer", 461 | "description": "id", 462 | "name": "id", 463 | "in": "path", 464 | "required": true 465 | } 466 | ], 467 | "responses": { 468 | "200": { 469 | "description": "OK", 470 | "schema": { 471 | "$ref": "#/definitions/service_models.User" 472 | } 473 | }, 474 | "400": { 475 | "description": "Bad Request", 476 | "schema": {} 477 | }, 478 | "404": { 479 | "description": "Not Found", 480 | "schema": {} 481 | }, 482 | "500": { 483 | "description": "Internal Server Error", 484 | "schema": {} 485 | } 486 | } 487 | } 488 | }, 489 | "/v1/users/{id}/follow": { 490 | "put": { 491 | "security": [ 492 | { 493 | "ApiKeyAuth": [] 494 | } 495 | ], 496 | "description": "Follows a user by ID", 497 | "consumes": [ 498 | "application/json" 499 | ], 500 | "produces": [ 501 | "application/json" 502 | ], 503 | "tags": [ 504 | "users" 505 | ], 506 | "summary": "Follows a user", 507 | "parameters": [ 508 | { 509 | "type": "integer", 510 | "description": "id", 511 | "name": "id", 512 | "in": "path", 513 | "required": true 514 | } 515 | ], 516 | "responses": { 517 | "204": { 518 | "description": "User followed", 519 | "schema": { 520 | "type": "string" 521 | } 522 | }, 523 | "400": { 524 | "description": "User payload missing", 525 | "schema": {} 526 | }, 527 | "404": { 528 | "description": "User not found", 529 | "schema": {} 530 | } 531 | } 532 | } 533 | }, 534 | "/v1/users/{id}/unfollow": { 535 | "put": { 536 | "security": [ 537 | { 538 | "ApiKeyAuth": [] 539 | } 540 | ], 541 | "description": "Unfollow a user by ID", 542 | "consumes": [ 543 | "application/json" 544 | ], 545 | "produces": [ 546 | "application/json" 547 | ], 548 | "tags": [ 549 | "users" 550 | ], 551 | "summary": "Unfollow a user", 552 | "parameters": [ 553 | { 554 | "type": "integer", 555 | "description": "id", 556 | "name": "id", 557 | "in": "path", 558 | "required": true 559 | } 560 | ], 561 | "responses": { 562 | "204": { 563 | "description": "User unfollowed", 564 | "schema": { 565 | "type": "string" 566 | } 567 | }, 568 | "400": { 569 | "description": "User payload missing", 570 | "schema": {} 571 | }, 572 | "404": { 573 | "description": "User not found", 574 | "schema": {} 575 | } 576 | } 577 | } 578 | } 579 | }, 580 | "definitions": { 581 | "service_models.Comment": { 582 | "type": "object", 583 | "properties": { 584 | "content": { 585 | "type": "string" 586 | }, 587 | "created_at": { 588 | "type": "string" 589 | }, 590 | "id": { 591 | "type": "integer" 592 | }, 593 | "post_id": { 594 | "type": "integer" 595 | }, 596 | "user": { 597 | "$ref": "#/definitions/service_models.User" 598 | }, 599 | "user_id": { 600 | "type": "integer" 601 | } 602 | } 603 | }, 604 | "service_models.CreatePostPayload": { 605 | "type": "object", 606 | "required": [ 607 | "content", 608 | "title" 609 | ], 610 | "properties": { 611 | "content": { 612 | "type": "string", 613 | "maxLength": 1000 614 | }, 615 | "tags": { 616 | "type": "array", 617 | "items": { 618 | "type": "string" 619 | } 620 | }, 621 | "title": { 622 | "type": "string", 623 | "maxLength": 200 624 | } 625 | } 626 | }, 627 | "service_models.CreateUserTokenPayload": { 628 | "type": "object", 629 | "required": [ 630 | "email", 631 | "password" 632 | ], 633 | "properties": { 634 | "email": { 635 | "type": "string", 636 | "maxLength": 255 637 | }, 638 | "password": { 639 | "type": "string", 640 | "maxLength": 32, 641 | "minLength": 3 642 | } 643 | } 644 | }, 645 | "service_models.Post": { 646 | "type": "object", 647 | "properties": { 648 | "comments": { 649 | "type": "array", 650 | "items": { 651 | "$ref": "#/definitions/service_models.Comment" 652 | } 653 | }, 654 | "content": { 655 | "type": "string" 656 | }, 657 | "created_at": { 658 | "type": "string" 659 | }, 660 | "id": { 661 | "type": "integer" 662 | }, 663 | "tags": { 664 | "type": "array", 665 | "items": { 666 | "type": "string" 667 | } 668 | }, 669 | "title": { 670 | "type": "string" 671 | }, 672 | "updated_at": { 673 | "type": "string" 674 | }, 675 | "user": { 676 | "$ref": "#/definitions/service_models.User" 677 | }, 678 | "user_id": { 679 | "type": "integer" 680 | }, 681 | "version": { 682 | "type": "integer" 683 | } 684 | } 685 | }, 686 | "service_models.PostFeed": { 687 | "type": "object", 688 | "properties": { 689 | "comment_count": { 690 | "type": "integer" 691 | }, 692 | "comments": { 693 | "type": "array", 694 | "items": { 695 | "$ref": "#/definitions/service_models.Comment" 696 | } 697 | }, 698 | "content": { 699 | "type": "string" 700 | }, 701 | "created_at": { 702 | "type": "string" 703 | }, 704 | "id": { 705 | "type": "integer" 706 | }, 707 | "tags": { 708 | "type": "array", 709 | "items": { 710 | "type": "string" 711 | } 712 | }, 713 | "title": { 714 | "type": "string" 715 | }, 716 | "updated_at": { 717 | "type": "string" 718 | }, 719 | "user": { 720 | "$ref": "#/definitions/service_models.User" 721 | }, 722 | "user_id": { 723 | "type": "integer" 724 | }, 725 | "version": { 726 | "type": "integer" 727 | } 728 | } 729 | }, 730 | "service_models.RegisterUserPayload": { 731 | "type": "object", 732 | "required": [ 733 | "email", 734 | "password", 735 | "username" 736 | ], 737 | "properties": { 738 | "email": { 739 | "type": "string", 740 | "maxLength": 255 741 | }, 742 | "password": { 743 | "type": "string", 744 | "maxLength": 32, 745 | "minLength": 3 746 | }, 747 | "username": { 748 | "type": "string", 749 | "maxLength": 50 750 | } 751 | } 752 | }, 753 | "service_models.Role": { 754 | "type": "object", 755 | "properties": { 756 | "description": { 757 | "type": "string" 758 | }, 759 | "id": { 760 | "type": "integer" 761 | }, 762 | "level": { 763 | "type": "integer" 764 | }, 765 | "name": { 766 | "type": "string" 767 | } 768 | } 769 | }, 770 | "service_models.UpdatePostPayload": { 771 | "type": "object", 772 | "properties": { 773 | "content": { 774 | "type": "string", 775 | "maxLength": 1000 776 | }, 777 | "title": { 778 | "type": "string", 779 | "maxLength": 200 780 | } 781 | } 782 | }, 783 | "service_models.User": { 784 | "type": "object", 785 | "properties": { 786 | "created_at": { 787 | "type": "string" 788 | }, 789 | "email": { 790 | "type": "string" 791 | }, 792 | "id": { 793 | "type": "integer" 794 | }, 795 | "is_active": { 796 | "type": "boolean" 797 | }, 798 | "role": { 799 | "$ref": "#/definitions/service_models.Role" 800 | }, 801 | "role_id": { 802 | "type": "integer" 803 | }, 804 | "username": { 805 | "type": "string" 806 | } 807 | } 808 | } 809 | }, 810 | "securityDefinitions": { 811 | "ApiKeyAuth": { 812 | "type": "apiKey", 813 | "name": "Authorization", 814 | "in": "header" 815 | } 816 | } 817 | }` 818 | 819 | // SwaggerInfo holds exported Swagger Info so clients can modify it 820 | var SwaggerInfo = &swag.Spec{ 821 | Version: "", 822 | Host: "", 823 | BasePath: "/", 824 | Schemes: []string{}, 825 | Title: "Gophergram API", 826 | Description: "API for Gophergram, a social network for gophers", 827 | InfoInstanceName: "swagger", 828 | SwaggerTemplate: docTemplate, 829 | LeftDelim: "{{", 830 | RightDelim: "}}", 831 | } 832 | 833 | func init() { 834 | swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) 835 | } 836 | --------------------------------------------------------------------------------