feat: initial commit

This commit is contained in:
2025-03-31 16:17:35 +01:00
commit 6719f8a61a
91 changed files with 7119 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"context"
"errors"
"expvar"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/FernandoVideira/LK_API_Temp/docs" // This is required to autogenerate swagger docs
"github.com/FernandoVideira/LK_API_Temp/internal/auth"
"github.com/FernandoVideira/LK_API_Temp/internal/mailer"
"github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/FernandoVideira/LK_API_Temp/internal/store/cache"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware
"go.uber.org/zap"
)
type api struct {
config config
store store.Storage
cacheStore cache.Storage
logger *zap.SugaredLogger
mailer mailer.Client
authenticator auth.Authenticator
rateLimiter ratelimiter.Limiter
}
type config struct {
addr string
db dbConfig
env string
apiURL string
mail mailConfig
frontEndURL string
auth authConfig
redisCfg redisConfig
rateLimiter ratelimiter.Config
}
type mailConfig struct {
exp time.Duration
fromEmail string
sendGrid sendGridConfig
}
type authConfig struct {
basic basicConfig
token tokenConfig
}
type basicConfig struct {
username string
password string
}
type tokenConfig struct {
secret string
exp time.Duration
iss string
aud string
}
type redisConfig struct {
addr string
pw string
db int
enabled bool
}
// TODO: Switch to previously made SMTP mailer
type sendGridConfig struct {
apiKey string
}
type dbConfig struct {
addr string
maxOpenConns int
maxIdleConns int
maxIdleTime time.Duration
}
func (api *api) mount() http.Handler {
r := chi.NewRouter()
// *CORS
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"}, //* Use this for development
//AllowedOrigins: []string{env.GetList("ALLOWED_ORIGINS", []string{"https://lk-api-temp.onrender.com"})}, //* Use this for production
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
r.Use(api.RateLimiterMiddleware)
r.Use(middleware.Timeout(60 * time.Second))
r.Route("/v1", func(r chi.Router) {
//r.With(api.BasicAuthMiddleware()).Get("/health", api.healthCheckHandler)
r.Get("/health", api.healthCheckHandler)
r.With(api.BasicAuthMiddleware()).Get("/debug/vars", expvar.Handler().ServeHTTP)
docsURL := fmt.Sprintf("%s/swagger/doc.json", api.config.addr)
r.Get("/swagger/*", httpSwagger.Handler(httpSwagger.URL(docsURL)))
r.Route("/posts", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Post("/", api.createPostHandler)
r.Route("/{id}", func(r chi.Router) {
r.Use(api.postsContextMiddleware)
r.Get("/", api.getPostHandler)
r.Patch("/", api.checkPostOwnership("moderator", api.updatePostHandler))
r.Delete("/", api.checkPostOwnership("admin", api.deletePostHandler))
r.Post("/comments", api.createCommentHandler)
})
})
r.Route("/users", func(r chi.Router) {
r.Put("/activate/{token}", api.activateUserHandler)
r.Route("/{id}", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/", api.getUserHandler)
r.Patch("/", api.checkProfileOwnership(api.updateUserHandler))
r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler))
r.Put(("/follow"), api.followUserHandler)
r.Put(("/unfollow"), api.unfollowUserHandler)
})
r.Group(func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/feed", api.getUserFeedHandler)
})
})
r.Route("/authentication", func(r chi.Router) {
r.Post("/user", api.registerUserHandler)
r.Post("/token", api.createTokenHandler)
})
})
return r
}
func (api *api) run(mux http.Handler) error {
//Docs
docs.SwaggerInfo.Version = "0.0.1"
docs.SwaggerInfo.Host = api.config.apiURL
docs.SwaggerInfo.BasePath = "/v1"
srv := http.Server{
Addr: api.config.addr,
Handler: mux,
WriteTimeout: 30 * time.Second,
ReadTimeout: 10 * time.Second,
IdleTimeout: time.Minute,
}
shutdown := make(chan error)
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
api.logger.Infow("shutting down server", "signal", s.String())
shutdown <- srv.Shutdown(ctx)
}()
api.logger.Infow("server has started", "addr", api.config.addr, "env", api.config.env)
err := srv.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
return err
}
err = <-shutdown
if err != nil {
return err
}
api.logger.Infow("server has been shutdown")
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter"
)
func TestRateLimiterMiddleware(t *testing.T) {
cfg := config{
rateLimiter: ratelimiter.Config{
RequestsPerTimeFrame: 10,
TimeFrame: time.Second * 5,
Enabled: true,
},
addr: ":8080",
}
api := newTestApi(t, cfg)
ts := httptest.NewServer(api.mount())
defer ts.Close()
client := ts.Client()
mockIP := "192.168.1.1"
marginOfError := 2
for i := 0; i < cfg.rateLimiter.RequestsPerTimeFrame+marginOfError; i++ {
req, err := http.NewRequest("GET", ts.URL+"/v1/health", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("X-Forwarded-For", mockIP)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("failed to send request: %v", err)
}
defer resp.Body.Close()
if i < cfg.rateLimiter.RequestsPerTimeFrame {
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.StatusCode)
}
} else {
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected status code %d, got %d", http.StatusTooManyRequests, resp.StatusCode)
}
}
}
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/mailer"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type RegisterUserPayload struct {
FirstName string `json:"first_name" validate:"required,max=100"`
LastName string `json:"last_name" validate:"required,max=100"`
Username string `json:"username" validate:"required,max=100"`
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,password"`
}
type UserWithToken struct {
*store.User
Token string `json:"token"`
}
// RegisterUser godoc
//
// @Summary Register a new User
// @Description Register a new User
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body RegisterUserPayload true "User Credentials"
// @Success 201 {object} UserWithToken "User Registered"
// @Failure 400 {object} error "User payload error"
// @Failure 500 {object} error "Internal Server Error"
// @Router /authentication/user [post]
func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
var payload RegisterUserPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user := &store.User{
FirstName: payload.FirstName,
LastName: payload.LastName,
Username: payload.Username,
Email: payload.Email,
Role: store.Role{
Name: "user",
},
}
// Hash the user password
if err := user.Password.Set(payload.Password); err != nil {
api.internalServerError(w, r, err)
return
}
ctx := r.Context()
plainToken := uuid.New().String()
hash := sha256.Sum256([]byte(plainToken))
hashedToken := hex.EncodeToString(hash[:])
// Store the user
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp)
if err != nil {
switch {
case errors.Is(err, store.ErrDuplicateEmail):
api.badRequestError(w, r, err)
case errors.Is(err, store.ErrDuplicateUsername):
api.badRequestError(w, r, err)
default:
api.internalServerError(w, r, err)
}
return
}
userWithToken := UserWithToken{
User: user,
Token: plainToken,
}
activationURL := fmt.Sprintf("%s/confirm/%s", api.config.frontEndURL, plainToken)
isProdEnv := api.config.env == "production"
vars := struct {
ServiceName string
Username string
ActivationURL string
}{
ServiceName: "LK_API_Temp",
Username: user.Username,
ActivationURL: activationURL,
}
// Send Email
status, err := api.mailer.Send(mailer.UserWelcomeTemplate, user.Username, user.Email, vars, !isProdEnv)
if err != nil {
// Log the error
api.logger.Errorw("failed to send welcome email", "error", err)
//Rollback the user creation if the email fails
if err := api.store.Users.Delete(ctx, user.ID); err != nil {
api.logger.Errorw("failed to rollback user creation", "error", err)
}
api.internalServerError(w, r, err)
return
}
api.logger.Infow("email sent", "status code", status)
if err := api.jsonResponse(w, http.StatusCreated, userWithToken); err != nil {
api.internalServerError(w, r, err)
return
}
}
type CredentialsPayload struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=3,max=72"`
}
// CreateTokenHandler godoc
//
// @Summary Create a new token
// @Description Create a new token for the user
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body CredentialsPayload true "User Credentials"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/token [post]
func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
//* parse payload credentials
var payload CredentialsPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
//* fetch the user (check if the user exists) from the payload
user, err := api.store.Users.GetByEmail(r.Context(), payload.Email)
if err != nil {
switch err {
case store.ErrNotFound:
api.unauthorizedError(w, r, err)
default:
api.internalServerError(w, r, err)
}
return
}
//* compare the password from the payload with the user password
if err := user.Password.Compare(payload.Password); err != nil {
api.unauthorizedError(w, r, err)
return
}
claims := &jwt.MapClaims{
"sub": user.ID,
"exp": time.Now().Add(api.config.auth.token.exp).Unix(),
"iat": time.Now().Unix(),
"nbf": time.Now().Unix(),
"iss": api.config.auth.token.iss,
"aud": api.config.auth.token.aud,
}
//* generate a new token -> add the claims
token, err := api.authenticator.GenerateToken(claims)
if err != nil {
api.internalServerError(w, r, err)
return
}
//* send the token back to the user
if err := api.jsonResponse(w, http.StatusCreated, token); err != nil {
api.internalServerError(w, r, err)
return
}
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"errors"
"net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
)
type CreateCommentPayload struct {
Content string `json:"content" validate:"required, max=1000"`
}
// CreateComment godo
//
// @Summary Creates a new Comment
// @Description Creates a new Comment
// @Tags comments
// @Accept json
// @Produce json
// @Param id path int true "Post ID"
// @Success 200 {object} store.Comment
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /posts/{id}/comments [post]
func (api *api) createCommentHandler(w http.ResponseWriter, r *http.Request) {
var payload CreateCommentPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
post := getPostFromContext(r)
if post == nil {
api.notFoundError(w, r, errors.New("post not found"))
return
}
user := getUserFromContext(r)
comment := &store.Comment{
PostID: post.ID,
UserID: user.ID,
Content: payload.Content,
}
if err := api.store.Comments.Create(r.Context(), comment); err != nil {
api.internalServerError(w, r, err)
return
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"net/http"
)
func (api *api) internalServerError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Errorw("internal server error", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusInternalServerError, "internal server error")
}
func (api *api) badRequestError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Warnw("bad request", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusInternalServerError, err.Error())
}
func (api *api) notFoundError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Warnw("not found", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusNotFound, "resource not found")
}
func (api *api) conflictError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Errorw("conflict", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusConflict, "resource conflict")
}
func (api *api) unauthorizedError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Warnw("unauthorized", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusUnauthorized, "unauthorized")
}
func (api *api) unauthorizedBasicError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Warnw("unauthorized", "method", r.Method, "path", r.URL.Path, "error", err.Error())
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
errorJSON(w, http.StatusUnauthorized, "basic unauthorized")
}
func (api *api) forbiddenError(w http.ResponseWriter, r *http.Request, err error) {
api.logger.Warnw("forbidden", "method", r.Method, "path", r.URL.Path, "error", err.Error())
errorJSON(w, http.StatusForbidden, "forbidden")
}
func (api *api) rateLimitExceededError(w http.ResponseWriter, r *http.Request, retryAfter string) {
api.logger.Warnw("rate limit exceeded", "method", r.Method, "path", r.URL.Path)
w.Header().Set("Retry-After", retryAfter)
errorJSON(w, http.StatusTooManyRequests, "too many requests, retry after "+retryAfter)
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"log"
"net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
)
// GetFeed godoc
//
// @Summary Fetches the user feed
// @Description Fetches the user feed
// @Tags feed
// @Accept json
// @Produce json
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Param sort query string false "Sort"
// @Param tags query string false "Tags"
// @Param search query string false "Search"
// @Param since query string false "Since"
// @Param until query string false "Until"
// @Success 200 {object} []store.PostWithMetadata
// @Failure 400 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /users/feed [get]
func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) {
fq := store.PaginatedFeedQuery{
Limit: 20,
Offset: 0,
Sort: "desc",
Tags: []string{},
Search: "",
}
fq, err := fq.Parse(r)
if err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(fq); err != nil {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
user := getUserFromContext(r)
log.Println(user)
feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq)
if err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusOK, feed); err != nil {
api.internalServerError(w, r, err)
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"net/http"
)
// HealthCheck godoc
//
// @Summary Health Check
// @Description Health Check
// @Tags ops
// @Success 204 {string} string "ok"
// @Failure 500 {object} error "Internal Server Error"
// @Router /health [get]
func (api *api) healthCheckHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"status": "ok",
"env": api.config.env,
"version": version,
}
if err := api.jsonResponse(w, http.StatusOK, data); err != nil {
api.internalServerError(w, r, err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"encoding/json"
"net/http"
"unicode"
"github.com/go-playground/validator/v10"
)
var Validate *validator.Validate
func init() {
Validate = validator.New(validator.WithRequiredStructEnabled())
Validate.RegisterValidation("password", validatePassword)
}
func writeJSON(w http.ResponseWriter, status int, data any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}
func readJSON(w http.ResponseWriter, r *http.Request, data any) error {
maxBytes := 1_048_578 // 1MB
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(data)
}
func errorJSON(w http.ResponseWriter, status int, err string) error {
type envelope struct {
Error string `json:"error"`
}
return writeJSON(w, status, &envelope{Error: err})
}
func (api *api) jsonResponse(w http.ResponseWriter, status int, data any) error {
type envelope struct {
Data any `json:"data"`
}
return writeJSON(w, status, &envelope{Data: data})
}
func validatePassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
var (
hasMinLen = len(password) >= 8
hasMaxLen = len(password) <= 72
hasUpper = false
hasLower = false
hasNumber = false
hasSymbol = false
)
for _, char := range password {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsNumber(char):
hasNumber = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSymbol = true
}
}
return hasMinLen && hasMaxLen && hasUpper && hasLower && hasNumber && hasSymbol
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"expvar"
"log"
"runtime"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/auth"
"github.com/FernandoVideira/LK_API_Temp/internal/db"
"github.com/FernandoVideira/LK_API_Temp/internal/env"
"github.com/FernandoVideira/LK_API_Temp/internal/mailer"
"github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/FernandoVideira/LK_API_Temp/internal/store/cache"
"github.com/go-redis/redis/v8"
"go.uber.org/zap"
)
const version = ""
// @title LK API Template
// @description This is a template for building APIs with Go, Chi, and Swagger.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email [email protected]
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /v1
//
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @description
func main() {
cfg := config{
addr: env.GetString("ADDR", ":8080"),
apiURL: env.GetString("EXTERNAL_URL", "localhost:8080"),
frontEndURL: env.GetString("FRONTEND_URL", "http://localhost:5173"),
db: dbConfig{
addr: env.GetString("DB_ADDR", "postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"),
maxOpenConns: env.GetInt("DB_MAX_OPEN_CONNS", 30),
maxIdleConns: env.GetInt("DB_MAX_IDLE_CONNS", 30),
maxIdleTime: env.GetDuration("DB_MAX_IDLE_TIME", "15m"),
},
redisCfg: redisConfig{
addr: env.GetString("REDIS_ADDR", "localhost:6379"),
pw: env.GetString("REDIS_PASSWORD", ""),
db: env.GetInt("REDIS_DB", 0),
enabled: env.GetBool("REDIS_ENABLED", false),
},
env: env.GetString("ENV", "development"),
mail: mailConfig{
exp: time.Hour * 24 * 3,
fromEmail: env.GetString("FROM_EMAIL", ""),
sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""),
},
},
auth: authConfig{
basic: basicConfig{
username: env.GetString("BASIC_AUTH_USERNAME", "admin"),
password: env.GetString("BASIC_AUTH_PASSWORD", "admin"),
},
token: tokenConfig{
secret: env.GetString("AUTH_TOKEN_SECRET", "secret"),
exp: env.GetDuration("AUTH_TOKEN_EXP", "24h"),
iss: env.GetString("AUTH_TOKEN_ISS", "lkapi"),
aud: env.GetString("AUTH_TOKEN_AUD", "lkapi"),
},
},
rateLimiter: ratelimiter.Config{
RequestsPerTimeFrame: env.GetInt("RATE_LIMITER_REQS_COUNT", 20),
TimeFrame: env.GetDuration("RATE_LIMITER_TIME_FRAME", "1m"),
Enabled: env.GetBool("RATE_LIMITER_ENABLED", true),
},
}
// Logger
logger := zap.Must(zap.NewProduction()).Sugar()
defer logger.Sync()
// Database
db, err := db.New(
cfg.db.addr,
cfg.db.maxOpenConns,
cfg.db.maxIdleConns,
cfg.db.maxIdleTime,
)
if err != nil {
logger.Fatal(err)
}
defer db.Close()
logger.Info("database connection established")
// Cache
var redisDb *redis.Client
if cfg.redisCfg.enabled {
redisDb = cache.NewRedisClient(cfg.redisCfg.addr, cfg.redisCfg.pw, cfg.redisCfg.db)
logger.Info("redis connection established")
}
// Rate Limiter
rateLimiter := ratelimiter.NewFixedWindowLimiter(
cfg.rateLimiter.RequestsPerTimeFrame,
cfg.rateLimiter.TimeFrame,
)
store := store.NewStorage(db)
cacheStorage := cache.NewRedisStorage(redisDb)
mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
jwtAuthenticator := auth.NewJWTAuthenticator(
cfg.auth.token.secret,
cfg.auth.token.aud,
cfg.auth.token.iss,
)
api := api{
config: cfg,
store: store,
cacheStore: cacheStorage,
logger: logger,
mailer: mailer,
authenticator: jwtAuthenticator,
rateLimiter: rateLimiter,
}
// Metrics Collected
expvar.NewString("version").Set(version)
expvar.Publish("database", expvar.Func(func() any {
return db.Stats()
}))
expvar.Publish("redis", expvar.Func(func() any {
return redisDb.Info(context.Background(), "keyspace")
}))
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
mux := api.mount()
log.Fatal(api.run(mux))
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
)
func (api *api) BasicAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//* read the auth header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
api.unauthorizedBasicError(w, r, fmt.Errorf("missing Authorization header"))
return
}
//* parse the header -> get the base64
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Basic" {
api.unauthorizedBasicError(w, r, fmt.Errorf("invalid Authorization header"))
return
}
//* decode the base64
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
api.unauthorizedBasicError(w, r, err)
return
}
username := api.config.auth.basic.username
pwd := api.config.auth.basic.password
//* check the credentials
creds := strings.SplitN(string(decoded), ":", 2)
if len(creds) != 2 || creds[0] != username || creds[1] != pwd {
api.unauthorizedBasicError(w, r, fmt.Errorf("invalid credentials"))
return
}
next.ServeHTTP(w, r)
})
}
}
func (api *api) AuthTokenMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
api.unauthorizedError(w, r, fmt.Errorf("missing Authorization header"))
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
api.unauthorizedError(w, r, fmt.Errorf("invalid Authorization header"))
return
}
token := parts[1]
jwtToken, err := api.authenticator.ValidateToken(token)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
claims := jwtToken.Claims.(jwt.MapClaims)
userID, err := strconv.ParseInt(fmt.Sprintf("%.f", claims["sub"]), 10, 64)
if err != nil {
api.unauthorizedError(w, r, fmt.Errorf("invalid token"))
return
}
ctx := r.Context()
user, err := api.getUser(ctx, userID)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
ctx = context.WithValue(ctx, userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idParam, 10, 64)
if err != nil {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
post, err := api.store.Posts.GetByID(ctx, id)
if err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err)
default:
api.internalServerError(w, r, err)
}
return
}
ctx = context.WithValue(ctx, postCtx, post)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (api *api) checkPostOwnership(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
post := getPostFromContext(r)
if user.ID == post.UserID {
next.ServeHTTP(w, r)
return
}
allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole)
if err != nil {
api.internalServerError(w, r, err)
return
}
if !allowed {
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
return
}
next.ServeHTTP(w, r)
})
}
func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
profile := getUserFromContext(r)
if user.ID == profile.ID {
next.ServeHTTP(w, r)
return
}
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
})
}
func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) {
role, err := api.store.Roles.GetByName(ctx, roleName)
if err != nil {
return false, err
}
return user.Role.Level >= role.Level, nil
}
func (api *api) getUser(ctx context.Context, id int64) (*store.User, error) {
if !api.config.redisCfg.enabled {
return api.store.Users.GetByID(ctx, id)
}
user, err := api.cacheStore.Users.Get(ctx, id)
if err != nil {
return nil, err
}
if user == nil {
user, err = api.store.Users.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := api.cacheStore.Users.Set(ctx, user); err != nil {
return nil, err
}
}
return user, nil
}
func (api *api) RateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.config.rateLimiter.Enabled {
if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow {
api.rateLimitExceededError(w, r, retryAfter.String())
return
}
}
next.ServeHTTP(w, r)
})
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"errors"
"net/http"
"strconv"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-chi/chi/v5"
)
type postKey string
const postCtx postKey = "post"
type CreatePostPayload struct {
Title string `json:"title" validate:"required,max=100"`
Content string `json:"content" validate:"required,max=1000"`
Tags []string `json:"tags"`
}
// CreatePost godoc
//
// @Summary Creates a new Post
// @Description Creates a new Post
// @Tags posts
// @Accept json
// @Produce json
// @Param in body CreatePostPayload true "Post Payload"
// @Success 200 {object} store.Post
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /posts [post]
func (api *api) createPostHandler(w http.ResponseWriter, r *http.Request) {
var payload CreatePostPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user := getUserFromContext(r)
post := &store.Post{
UserID: user.ID,
Title: payload.Title,
Content: payload.Content,
Tags: payload.Tags,
}
ctx := r.Context()
if err := api.store.Posts.Create(ctx, post); err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusCreated, post); err != nil {
api.internalServerError(w, r, err)
return
}
}
// GetPost godoc
//
// @Summary Fetches a Post
// @Description Fetches a Post by ID
// @Tags posts
// @Accept json
// @Produce json
// @Param id path int true "Post ID"
// @Success 200 {object} store.Post
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /posts/{id} [get]
func (api *api) getPostHandler(w http.ResponseWriter, r *http.Request) {
post := getPostFromContext(r)
comments, err := api.store.Comments.GetByPostID(r.Context(), post.ID)
if err != nil {
api.internalServerError(w, r, err)
return
}
post.Comments = comments
if err := api.jsonResponse(w, http.StatusOK, post); err != nil {
api.internalServerError(w, r, err)
return
}
}
type UpdatePostPayload struct {
Title *string `json:"title" validate:"omitempty,max=100"`
Content *string `json:"content" validate:"omitempty,max=1000"`
}
// UpdatePost godoc
//
// @Summary Update a Post
// @Description Update a Post by ID
// @Tags posts
// @Accept json
// @Produce json
// @Param id path int true "Post ID"
// @Param in body UpdatePostPayload true "Post Payload"
// @Success 200 {object} store.Post
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /posts/{id} [patch]
func (api *api) updatePostHandler(w http.ResponseWriter, r *http.Request) {
post := getPostFromContext(r)
var payload UpdatePostPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
if payload.Content != nil {
post.Content = *payload.Content
}
if payload.Title != nil {
post.Title = *payload.Title
}
if err := api.store.Posts.Update(r.Context(), post); err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
api.conflictError(w, r, err)
default:
api.internalServerError(w, r, err)
}
return
}
if err := api.jsonResponse(w, http.StatusOK, post); err != nil {
api.internalServerError(w, r, err)
return
}
}
// DeletePost godoc
//
// @Summary Deletes a Post
// @Description Deletse a Post by ID
// @Tags posts
// @Accept json
// @Produce json
// @Param id path int true "Post ID"
// @Success 204 {object} string
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /posts/{id} [delete]
func (api *api) deletePostHandler(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idParam, 10, 64)
if err != nil {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
if err := api.store.Posts.Delete(ctx, id); err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err)
default:
api.internalServerError(w, r, err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func getPostFromContext(r *http.Request) *store.Post {
post, _ := r.Context().Value(postCtx).(*store.Post)
return post
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/FernandoVideira/LK_API_Temp/internal/auth"
"github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/FernandoVideira/LK_API_Temp/internal/store/cache"
"go.uber.org/zap"
)
func newTestApi(t *testing.T, cfg config) *api {
t.Helper()
logger := zap.NewNop().Sugar()
mockStore := store.NewMockStorage()
mockCache := cache.NewMockCache()
testAuth := &auth.TestAuthenticator{}
rateLimiter := ratelimiter.NewFixedWindowLimiter(
cfg.rateLimiter.RequestsPerTimeFrame,
cfg.rateLimiter.TimeFrame,
)
return &api{
logger: logger,
store: mockStore,
cacheStore: mockCache,
authenticator: testAuth,
config: cfg,
rateLimiter: rateLimiter,
}
}
func executeRequest(req *http.Request, mux http.Handler) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
return rr
}
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"strconv"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-chi/chi/v5"
)
type userKey string
const userContextKey userKey = "user"
// GetUser godoc
//
// @Summary Fetches a user Profile
// @Description Fetches a user Profile by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /users/{id} [get]
func (api *api) getUserHandler(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil || userID < 1 {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
user, err := api.getUser(ctx, userID)
if err != nil {
switch err {
case store.ErrNotFound:
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
if err := api.jsonResponse(w, http.StatusOK, user); err != nil {
api.internalServerError(w, r, err)
}
}
type updateUserPayload struct {
FirstName *string `json:"first_name" validate:"omitempty,max=100"`
LastName *string `json:"last_name" validate:"omitempty,max=100"`
Username *string `json:"username" validate:"omitempty,max=100"`
Email *string `json:"email" validate:"omitempty,email,max=255"`
}
// Update godoc
//
// @Summary Update a user Profile
// @Description Update a user Profile by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Param payload body updateUserPayload true "User Profile"
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /users/{id} [patch]
func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
log.Println(user)
var payload updateUserPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
user = validateUser(payload, user)
if err := api.updateUser(r.Context(), user); err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusOK, user); err != nil {
api.internalServerError(w, r, err)
}
}
func (api *api) updateUser(ctx context.Context, user *store.User) error {
if err := api.store.Users.Update(ctx, user); err != nil {
return err
}
api.cacheStore.Users.Delete(ctx, user.ID)
return nil
}
// FollowUser godoc
//
// @Summary Follows a user
// @Description Follows a user by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 204 {string} string "User followed"
// @Failure 400 {object} error "User not found"
// @Router /users/{id}/follow [put]
func (api *api) followUserHandler(w http.ResponseWriter, r *http.Request) {
followee := getUserFromContext(r)
followedID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
api.badRequestError(w, r, err)
return
}
if err := api.store.Followers.Follow(r.Context(), followee.ID, followedID); err != nil {
switch {
case errors.Is(err, store.ErrConflict):
api.conflictError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
if err := api.jsonResponse(w, http.StatusNoContent, nil); err != nil {
api.internalServerError(w, r, err)
}
}
// UnollowUser godoc
//
// @Summary Unfollows a user
// @Description Unfollows a user by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 204 {string} string "User unfollowed"
// @Failure 400 {object} error "User payload error"
// @Failure 404 {object} error "User not found"
// @Router /users/{id}/unfollow [put]
func (api *api) unfollowUserHandler(w http.ResponseWriter, r *http.Request) {
followee := getUserFromContext(r)
followedUserID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
api.badRequestError(w, r, err)
return
}
if err := api.store.Followers.Unfollow(r.Context(), followee.ID, followedUserID); err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
api.conflictError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
if err := api.jsonResponse(w, http.StatusNoContent, nil); err != nil {
api.internalServerError(w, r, err)
}
}
// ActivateUser godoc
//
// @Summary Activate/Register a user
// @Description Activate/Register a user by invitation token
// @Tags users
// @Accept json
// @Produce json
// @Param token path string true "Invitation Token"
// @Success 204 {string} string "User activated"
// @Failure 404 {object} error "User not found"
// @Failure 500 {object} error "Internal Server Error"
// @Security ApiKeyAuth
// @Router /users/activate/{token} [put]
func (api *api) activateUserHandler(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
err := api.store.Users.Activate(r.Context(), token)
if err != nil {
switch err {
case store.ErrNotFound:
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil {
api.internalServerError(w, r, err)
}
}
type updateUserPasswordPayload struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,password,nefield=CurrentPassword"`
}
// UpdatePassword godoc
//
// @Summary Update user password
// @Description Update user password with verification
// @Description Password must:
// @Description - Be at least 8 characters long
// @Description - Contain at least one uppercase letter
// @Description - Contain at least one lowercase letter
// @Description - Contain at least one number
// @Description - Contain at least one special character
// @Description - Be different from the current password
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Param payload body updateUserPasswordPayload true "Password Update"
// @Success 204 {string} string "Password updated successfully"
// @Failure 400 {object} error "Invalid request"
// @Failure 401 {object} error "Unauthorized"
// @Failure 404 {object} error "User not found"
// @Failure 500 {object} error "Internal server error"
// @Security ApiKeyAuth
// @Router /users/{id}/password [patch]
func (api *api) updatePasswordHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
var payload updateUserPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := user.Password.Compare(payload.CurrentPassword); err != nil {
api.unauthorizedError(w, r, err)
return
}
if err := user.Password.Set(payload.NewPassword); err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.updateUser(r.Context(), user); err != nil {
api.internalServerError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil {
api.internalServerError(w, r, err)
}
}
func validateUser(payload updateUserPayload, usrToUpdate *store.User) *store.User {
if payload.Email != nil {
usrToUpdate.Email = *payload.Email
}
if payload.FirstName != nil {
usrToUpdate.FirstName = *payload.FirstName
}
if payload.LastName != nil {
usrToUpdate.LastName = *payload.LastName
}
if payload.Username != nil {
usrToUpdate.Username = *payload.Username
}
return usrToUpdate
}
func getUserFromContext(r *http.Request) *store.User {
user, _ := r.Context().Value(userContextKey).(*store.User)
return user
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"net/http"
"testing"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/FernandoVideira/LK_API_Temp/internal/store/cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestGetUser(t *testing.T) {
withReddis := config{
redisCfg: redisConfig{
enabled: true,
},
}
api := newTestApi(t, withReddis)
mux := api.mount()
testToken, err := api.authenticator.GenerateToken(nil)
if err != nil {
t.Fatal(err)
}
t.Run("should not allow unauthenticated requests", func(t *testing.T) {
mockStore := api.store.Users.(*store.MockUserStore)
mockStore.On("GetByID", int64(1)).Return(nil, nil)
req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil)
if err != nil {
t.Fatal(err)
}
rr := executeRequest(req, mux)
assert.Equal(t, http.StatusUnauthorized, rr.Code, "should not allow unauthenticated requests")
mockStore.AssertNotCalled(t, "GetByID")
mockStore.Calls = nil // Reset the mock calls
})
t.Run("should allow authenticated requests", func(t *testing.T) {
mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore)
mockStore := api.store.Users.(*store.MockUserStore)
mockCacheStore.On("Get", int64(1)).Return(nil, nil)
mockCacheStore.On("Set", mock.Anything, mock.Anything).Return(nil)
mockStore.On("GetByID", int64(1)).Return(&store.User{ID: 1}, nil)
mockStore.On("GetByID", int64(42)).Return(nil, store.ErrNotFound)
req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+testToken)
rr := executeRequest(req, mux)
assert.Equal(t, http.StatusOK, rr.Code, "should allow authenticated requests")
mockStore.AssertNumberOfCalls(t, "GetByID", 2)
mockCacheStore.Calls = nil // Reset the mock calls
})
t.Run("should hit the cache first and if not found add to cache", func(t *testing.T) {
mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore)
mockCacheStore.On("Get", int64(42)).Return(nil, nil)
mockCacheStore.On("Get", int64(1)).Return(nil, nil)
mockCacheStore.On("Set", mock.Anything, mock.Anything).Return(nil)
req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+testToken)
rr := executeRequest(req, mux)
assert.Equal(t, http.StatusOK, rr.Code, "should hit the cache first and if not found add to cache")
mockCacheStore.AssertNumberOfCalls(t, "Get", 2)
mockCacheStore.Calls = nil // Reset the mock calls
})
t.Run("should not hit the cache if it is turned off", func(t *testing.T) {
withReddis := config{
redisCfg: redisConfig{
enabled: false,
},
}
api := newTestApi(t, withReddis)
mux := api.mount()
testToken, err := api.authenticator.GenerateToken(nil)
if err != nil {
t.Fatal(err)
}
mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore)
mockStore := api.store.Users.(*store.MockUserStore)
mockStore.On("GetByID", int64(1)).Return(nil, nil)
req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+testToken)
rr := executeRequest(req, mux)
assert.Equal(t, http.StatusOK, rr.Code, "should not hit the cache if it is turned off")
mockCacheStore.AssertNotCalled(t, "Get")
mockCacheStore.Calls = nil // Reset the mock calls
})
}