feat: initial commit
This commit is contained in:
+210
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id bigserial PRIMARY KEY,
|
||||
first_name VARCHAR(255) NOT NULL,
|
||||
last_name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email citext UNIQUE NOT NULL,
|
||||
password bytea NOT NULL,
|
||||
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS posts;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id bigserial PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE posts
|
||||
DROP COLUMN tags;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN tags VARCHAR(100)[] NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS Comments;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS Comments (
|
||||
id bigserial PRIMARY KEY,
|
||||
post_id bigserial NOT NULL,
|
||||
user_id bigserial NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (post_id) REFERENCES Posts(id),
|
||||
FOREIGN KEY (user_id) REFERENCES Users(id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts DROP COLUMN version;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts ADD COLUMN version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS followers;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS followers (
|
||||
user_id bigint NOT NULL,
|
||||
follower_id bigint NOT NULL,
|
||||
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (user_id, follower_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (follower_id) REFERENCES users (id)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP INDEX IF EXISTS idx_comments_content;
|
||||
DROP INDEX IF EXISTS idx_comments_post_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_posts_title;
|
||||
DROP INDEX IF EXISTS idx_posts_tags;
|
||||
DROP INDEX IF EXISTS idx_posts_user_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_users_username;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Indexes are used to speed up the query process. They are used to quickly locate the rows in a table that match the search condition.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX idx_comments_content ON comments USING gin (content gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_title ON posts USING gin (title gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING gin (tags);
|
||||
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS invitations;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
token bytea PRIMARY KEY,
|
||||
user_id bigint NOT NULL
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
users
|
||||
DROP COLUMN
|
||||
is_active;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
users
|
||||
ADD COLUMN
|
||||
is_active boolean NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE
|
||||
invitations DROP COLUMN expiry;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
invitations
|
||||
ADD COLUMN
|
||||
expiry TIMESTAMP(0) WITH TIME ZONE NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS roles;
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
roles (name, level, description)
|
||||
VALUES (
|
||||
'user',
|
||||
1,
|
||||
'User'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
roles (name, level, description)
|
||||
VALUES (
|
||||
'moderator',
|
||||
2,
|
||||
'Moderator'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
roles (name, level, description)
|
||||
VALUES (
|
||||
'admin',
|
||||
3,
|
||||
'Administrator'
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE
|
||||
IF EXISTS users DROP COLUMN role_id;
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE
|
||||
IF EXISTS users
|
||||
ADD
|
||||
COLUMN role_id INT REFERENCES roles(id) DEFAULT 1;
|
||||
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
role_id = (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
roles
|
||||
WHERE
|
||||
name = 'user'
|
||||
)
|
||||
WHERE
|
||||
role_id IS NULL;
|
||||
|
||||
ALTER TABLE
|
||||
users
|
||||
ALTER COLUMN
|
||||
role_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role_id
|
||||
SET NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/db"
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/env"
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := env.GetString("DB_ADDR", "postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable")
|
||||
conn, err := db.New(addr, 3, 3, env.GetDuration("DB_MAX_IDLE_TIME", "5m"))
|
||||
if err != nil {
|
||||
log.Fatalf("could not connect to db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := store.NewStorage(conn)
|
||||
db.Seed(store, conn)
|
||||
}
|
||||
Reference in New Issue
Block a user