├── 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 |
--------------------------------------------------------------------------------