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
+53
View File
@@ -0,0 +1,53 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "bin"
[build]
args_bin = []
bin = "./bin/main"
cmd = "go build -o ./bin/main ./cmd/api"
delay = 1000
exclude_dir = ["assets", "bin", "vendor", "testdata", "docs", "scripts"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = ['make gen-docs']
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true
+50
View File
@@ -0,0 +1,50 @@
## General Guidelines for Code Generation
- **Overall Style:**
- Write clear, concise, and well-commented code.
- Follow idiomatic coding practices that prioritize readability, maintainability, and simplicity.
- Apply the DRY (Don't Repeat Yourself) principle to avoid duplication.
## Backend (Go) Specifics
- **Routing & Handlers:**
- Use go-chi for routing and maintain a modular structure.
- Write handler functions that perform one clear task: parsing requests, interacting with PostgreSQL, and returning JSON responses.
- Include inline comments to explain complex logic and error handling.
- **Error Handling & Patterns:**
- Check for errors at each step and use context-aware patterns.
- Use proper naming conventions (camelCase for variables and PascalCase for types).
- **Database & Caching:**
- When interacting with PostgreSQL, use environment variables (e.g., `DATABASE_URL`) to read connection details and use parameterized queries to prevent SQL injection.
- For Redis, initialize the client with context and perform error checking to ensure the connection is stable.
## Swagger Documentation
- **API Documentation:**
- Generate Swagger (OpenAPI) YAML snippets to document each endpoint, including parameters, expected responses, and error messages.
- Ensure the Swagger docs are clear and adhere to standardized formats.
## Frontend (NuxtJS) Specifics
- **Setup & Configuration:**
- Assume a client-side only NuxtJS application.
- Generate lightweight, reusable Vue components using the Composition API.
- Use async/await syntax for API calls and refer to environment variables (e.g., API base URL) for configuration.
## Commit & PR Messaging
- **Descriptive Messaging:**
- When generating commit messages or pull request descriptions, include:
- A short summary of the changes.
- The reason for the changes.
- Optionally, emojis to indicate the type of update (e.g., :sparkles: for features, :bug: for fixes).
## Additional Guidance & Context
- **Suggestions & Improvements:**
- When proposing code changes, suggest alternative approaches or improvements, particularly around error handling, security, and performance optimization.
- Ask clarifying questions if the context is unclear to ensure alignment with project requirements.
+39
View File
@@ -0,0 +1,39 @@
name: Audit
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
audit:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.4"
- name: Verify dependencies
run: go mod tidy
- name: Build
run: go build -v ./...
- name: Run go vet
run: go vet ./...
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: Run staticcheck
run: staticcheck ./...
- name: Run Staticcheck
run: staticcheck ./...
- name: Run tests
run: go test -race ./...
+24
View File
@@ -0,0 +1,24 @@
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
name: Release Please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
# this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named
# `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs"
# for more options
release-type: simple
+32
View File
@@ -0,0 +1,32 @@
name: Update Version and Release
on:
push:
branches:
- master
permissions:
contents: write
jobs:
update-version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract latest version from CHANGELOG.md
id: get_version
run: |
VERSION=$(grep -oP '\[\K[0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md | head -1)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Update version in cmd/api/main.go
run: |
sed -i "s/const version = \".*\"/const version = \"${{ steps.get_version.outputs.VERSION }}\"/" cmd/api/main.go
- name: Commit and push if changed
run: |
git config --global user.email "[email protected]"
git config --global user.name "GitHub Action"
git add cmd/api/main.go
git commit -m "Update version to ${{ steps.get_version.outputs.VERSION }}" || echo "No changes to commit"
git push
+1
View File
@@ -0,0 +1 @@
.envrc
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 1.0.0 (2025-03-31)
### Features
* initial commit ([051aedd](https://github.com/FernandoJVideira/LK_API_Temp/commit/051aeddf7d1bf6d537630104bb51e3cef418bcae))
+34
View File
@@ -0,0 +1,34 @@
include .envrc
MIGRATION_PATH = ./cmd/migrate/migrations
.PHONY: test
test:
@go test -v ./...
.PHONY: start
start:
@docker compose up -d
.PHONY: stop
stop:
@docker compose down
.PHONY: migrate-create
migration:
@migrate create -seq -ext sql -dir $(MIGRATION_PATH) $(filter-out $@,$(MAKECMDGOALS))
.PHONY: migrate-up
migrate-up:
@migrate -path=$(MIGRATION_PATH) -database=$(DB_ADDR) up
.PHONY: migrate-down
migrate-down:
@migrate -path=$(MIGRATION_PATH) -database=$(DB_ADDR) down $(filter-out $@,$(MAKECMDGOALS))
.PHONY: seed
seed:
@go run cmd/migrate/seed/main.go
.PHONY: gen-docs
gen-docs:
@swag init -g ./api/main.go -d cmd,internal && swag fmt
+88
View File
@@ -0,0 +1,88 @@
# LK API Template
A modern, production-ready API template built with Go and React, featuring comprehensive authentication, mailing capabilities, and automatic API documentation.
## 🚀 Features
- **Backend (Go)**
- Chi router for efficient HTTP routing
- Swagger/OpenAPI documentation
- SendGrid integration for email notifications
- Rate limiting support
- Redis caching
- PostgreSQL database integration
- JWT-based authentication
- Environment-based configuration
- Structured logging with Zap
- Hot reload support with Air
- **Frontend (React + TypeScript)**
- Vite for fast development and building
- TypeScript support
- ESLint configuration for code quality
- Hot Module Replacement (HMR)
## 📋 Prerequisites
- Go 1.21 or higher
- Node.js 18 or higher
- PostgreSQL
- Redis
- SendGrid API key (for email functionality)
## 🛠️ Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/lk-api-template.git
cd lk-api-template
```
2. Install backend dependencies:
```bash
go mod download
```
3. Install frontend dependencies:
```bash
cd web
npm install
```
4. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
## 🚀 Development
### Backend
Run the backend server with hot reload:
```bash
air
```
The API will be available at `http://localhost:8080` with the base path `/v1`.
### Frontend
Run the frontend development server:
```bash
cd web
npm run dev
```
The frontend will be available at `http://localhost:5173`.
## 📚 API Documentation
The API documentation is automatically generated using Swagger. Once the server is running, you can access the Swagger UI at:
File diff suppressed because one or more lines are too long
Executable
BIN
View File
Binary file not shown.
+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
})
}
@@ -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;
+21
View File
@@ -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)
}
+34
View File
@@ -0,0 +1,34 @@
services:
db:
image: postgres:16.3
container_name: postgres-db
environment:
POSTGRES_DB: lk_tmpl
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:6.2-alpine
restart: unless-stopped
container_name: redis
ports:
- "6379:6379"
command: redis-server --save 60 1 --loglevel warning
redis-commander:
container_name: redis-commander
hostname: redis-commander
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOST=redis
ports:
- "127.0.0.1:8081:8081"
depends_on:
- redis
restart: unless-stopped
volumes:
db-data:
+1105
View File
File diff suppressed because it is too large Load Diff
+1079
View File
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
basePath: /v1
definitions:
main.CreatePostPayload:
properties:
content:
maxLength: 1000
type: string
tags:
items:
type: string
type: array
title:
maxLength: 100
type: string
required:
- content
- title
type: object
main.CredentialsPayload:
properties:
email:
maxLength: 255
type: string
password:
maxLength: 72
minLength: 3
type: string
required:
- email
- password
type: object
main.RegisterUserPayload:
properties:
email:
maxLength: 255
type: string
first_name:
maxLength: 100
type: string
last_name:
maxLength: 100
type: string
password:
type: string
username:
maxLength: 100
type: string
required:
- email
- first_name
- last_name
- password
- username
type: object
main.UpdatePostPayload:
properties:
content:
maxLength: 1000
type: string
title:
maxLength: 100
type: string
type: object
main.UserWithToken:
properties:
created_at:
type: string
email:
type: string
first_name:
type: string
id:
type: integer
is_active:
type: boolean
last_name:
type: string
role:
$ref: '#/definitions/store.Role'
role_id:
type: integer
token:
type: string
updated_at:
type: string
username:
type: string
type: object
main.updateUserPasswordPayload:
properties:
current_password:
type: string
new_password:
type: string
required:
- current_password
- new_password
type: object
main.updateUserPayload:
properties:
email:
maxLength: 255
type: string
first_name:
maxLength: 100
type: string
last_name:
maxLength: 100
type: string
username:
maxLength: 100
type: string
type: object
store.Comment:
properties:
content:
type: string
created_at:
type: string
id:
type: integer
post_id:
type: integer
updated_at:
type: string
user:
$ref: '#/definitions/store.User'
user_id:
type: integer
type: object
store.Post:
properties:
comments:
items:
$ref: '#/definitions/store.Comment'
type: array
content:
type: string
created_at:
type: string
id:
type: integer
tags:
items:
type: string
type: array
title:
type: string
updated_at:
type: string
user:
$ref: '#/definitions/store.User'
user_id:
type: integer
version:
type: integer
type: object
store.PostWithMetadata:
properties:
comment_count:
type: integer
comments:
items:
$ref: '#/definitions/store.Comment'
type: array
content:
type: string
created_at:
type: string
id:
type: integer
tags:
items:
type: string
type: array
title:
type: string
updated_at:
type: string
user:
$ref: '#/definitions/store.User'
user_id:
type: integer
version:
type: integer
type: object
store.Role:
properties:
description:
type: string
id:
type: integer
level:
type: integer
name:
type: string
type: object
store.User:
properties:
created_at:
type: string
email:
type: string
first_name:
type: string
id:
type: integer
is_active:
type: boolean
last_name:
type: string
role:
$ref: '#/definitions/store.Role'
role_id:
type: integer
updated_at:
type: string
username:
type: string
type: object
info:
contact:
email: [email protected]
name: API Support
url: http://www.swagger.io/support
description: This is a template for building APIs with Go, Chi, and Swagger.
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
termsOfService: http://swagger.io/terms/
title: LK API Template
paths:
/authentication/token:
post:
consumes:
- application/json
description: Create a new token for the user
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.CredentialsPayload'
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Create a new token
tags:
- authentication
/authentication/user:
post:
consumes:
- application/json
description: Register a new User
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.RegisterUserPayload'
produces:
- application/json
responses:
"201":
description: User Registered
schema:
$ref: '#/definitions/main.UserWithToken'
"400":
description: User payload error
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Register a new User
tags:
- authentication
/health:
get:
description: Health Check
responses:
"204":
description: ok
schema:
type: string
"500":
description: Internal Server Error
schema: {}
summary: Health Check
tags:
- ops
/posts:
post:
consumes:
- application/json
description: Creates a new Post
parameters:
- description: Post Payload
in: body
name: in
required: true
schema:
$ref: '#/definitions/main.CreatePostPayload'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.Post'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Creates a new Post
tags:
- posts
/posts/{id}:
delete:
consumes:
- application/json
description: Deletse a Post by ID
parameters:
- description: Post ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: No Content
schema:
type: string
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Deletes a Post
tags:
- posts
get:
consumes:
- application/json
description: Fetches a Post by ID
parameters:
- description: Post ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.Post'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Fetches a Post
tags:
- posts
patch:
consumes:
- application/json
description: Update a Post by ID
parameters:
- description: Post ID
in: path
name: id
required: true
type: integer
- description: Post Payload
in: body
name: in
required: true
schema:
$ref: '#/definitions/main.UpdatePostPayload'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.Post'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Update a Post
tags:
- posts
/posts/{id}/comments:
post:
consumes:
- application/json
description: Creates a new Comment
parameters:
- description: Post ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.Comment'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Creates a new Comment
tags:
- comments
/users/{id}:
get:
consumes:
- application/json
description: Fetches a user Profile by ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.User'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Fetches a user Profile
tags:
- users
patch:
consumes:
- application/json
description: Update a user Profile by ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
- description: User Profile
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.updateUserPayload'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.User'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Update a user Profile
tags:
- users
/users/{id}/follow:
put:
consumes:
- application/json
description: Follows a user by ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: User followed
schema:
type: string
"400":
description: User not found
schema: {}
summary: Follows a user
tags:
- users
/users/{id}/password:
patch:
consumes:
- application/json
description: |-
Update user password with verification
Password must:
- Be at least 8 characters long
- Contain at least one uppercase letter
- Contain at least one lowercase letter
- Contain at least one number
- Contain at least one special character
- Be different from the current password
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
- description: Password Update
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.updateUserPasswordPayload'
produces:
- application/json
responses:
"204":
description: Password updated successfully
schema:
type: string
"400":
description: Invalid request
schema: {}
"401":
description: Unauthorized
schema: {}
"404":
description: User not found
schema: {}
"500":
description: Internal server error
schema: {}
security:
- ApiKeyAuth: []
summary: Update user password
tags:
- users
/users/{id}/unfollow:
put:
consumes:
- application/json
description: Unfollows a user by ID
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: User unfollowed
schema:
type: string
"400":
description: User payload error
schema: {}
"404":
description: User not found
schema: {}
summary: Unfollows a user
tags:
- users
/users/activate/{token}:
put:
consumes:
- application/json
description: Activate/Register a user by invitation token
parameters:
- description: Invitation Token
in: path
name: token
required: true
type: string
produces:
- application/json
responses:
"204":
description: User activated
schema:
type: string
"404":
description: User not found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Activate/Register a user
tags:
- users
/users/feed:
get:
consumes:
- application/json
description: Fetches the user feed
parameters:
- description: Limit
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
- description: Sort
in: query
name: sort
type: string
- description: Tags
in: query
name: tags
type: string
- description: Search
in: query
name: search
type: string
- description: Since
in: query
name: since
type: string
- description: Until
in: query
name: until
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/store.PostWithMetadata'
type: array
"400":
description: Bad Request
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Fetches the user feed
tags:
- feed
securityDefinitions:
ApiKeyAuth:
in: header
name: Authorization
type: apiKey
swagger: "2.0"
+46
View File
@@ -0,0 +1,46 @@
module github.com/FernandoVideira/LK_API_Temp
go 1.23.4
require (
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.24.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/sendgrid/sendgrid-go v3.16.0+incompatible
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag v1.16.4
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.32.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+103
View File
@@ -0,0 +1,103 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw=
github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8
View File
@@ -0,0 +1,8 @@
package auth
import "github.com/golang-jwt/jwt/v5"
type Authenticator interface {
GenerateToken(jwt.Claims) (string, error)
ValidateToken(string) (*jwt.Token, error)
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
)
type JWTAuthenticator struct {
secret string
aud string
iss string
}
func NewJWTAuthenticator(secret, aud, iss string) *JWTAuthenticator {
return &JWTAuthenticator{
secret: secret,
aud: aud,
iss: iss,
}
}
func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(j.secret))
if err != nil {
return "", err
}
return tokenString, nil
}
func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(j.secret), nil
},
jwt.WithExpirationRequired(),
jwt.WithAudience(j.aud),
jwt.WithIssuer(j.iss),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
)
}
+31
View File
@@ -0,0 +1,31 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type TestAuthenticator struct {
}
const testToken = "test-token"
var testClaims = jwt.MapClaims{
"aud": "test-audience",
"iss": "test-issuer",
"sub": 1,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims)
return token.SignedString([]byte(testToken))
}
func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil
})
}
+27
View File
@@ -0,0 +1,27 @@
package db
import (
"context"
"database/sql"
"time"
)
func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", addr)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxIdleTime(maxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
+186
View File
@@ -0,0 +1,186 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"math/rand"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
)
var first_names = []string{
"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy",
"Mallory", "Nathan", "Olivia", "Peggy", "Quinn", "Rachel", "Sam", "Trent", "Ursula", "Victor",
"Wendy", "Xander", "Yvonne", "Zach", "Amy", "Brian", "Carl", "Diana", "Edward", "Fiona",
"George", "Hannah", "Ian", "Jessica", "Kevin", "Lisa", "Michael", "Nina", "Oscar", "Paula",
"Quentin", "Rebecca", "Steve", "Tina", "Uma", "Vincent", "Willow", "Xenia", "Yuri", "Zoe",
}
var last_names = []string{
"Smith", "Johnson", "Williams", "Jones", "Brown", "Davis", "Miller", "Wilson", "Moore", "Taylor",
"Anderson", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Garcia", "Martinez", "Robinson",
"Clark", "Rodriguez", "Lewis", "Lee", "Walker", "Hall", "Allen", "Young", "Hernandez", "King",
"Wright", "Lopez", "Hill", "Scott", "Green", "Adams", "Baker", "Gonzalez", "Nelson", "Carter",
"Mitchell", "Perez", "Roberts", "Turner", "Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins",
}
var usernames = []string{
"alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy",
"mallory", "nathan", "olivia", "peggy", "quinn", "rachel", "sam", "trent", "ursula", "victor",
"wendy", "xander", "yvonne", "zach", "amy", "brian", "carl", "diana", "edward", "fiona",
"george", "hannah", "ian", "jessica", "kevin", "lisa", "michael", "nina", "oscar", "paula",
"quentin", "rebecca", "steve", "tina", "uma", "vincent", "willow", "xenia", "yuri", "zoe",
}
var titles = []string{
"How to Learn Go in 10 Days",
"Understanding Concurrency in Go",
"Building REST APIs with Go",
"Mastering Go: Tips and Tricks",
"Go vs. Python: A Comparison",
"Error Handling in Go",
"Go Routines and Channels Explained",
"Building a Web Server in Go",
"Go for Beginners: A Comprehensive Guide",
"Advanced Go Programming Techniques",
"Testing in Go: Best Practices",
"Go Modules: Managing Dependencies",
"Introduction to Go Interfaces",
"Go Data Structures and Algorithms",
"Building CLI Applications with Go",
"Go Memory Management",
"Go Design Patterns",
"Deploying Go Applications",
"Go and Microservices",
"Profiling and Optimizing Go Code",
}
var contents = []string{
"Learn the basics of Go programming in just 10 days with this comprehensive guide.",
"Understand the intricacies of concurrency in Go and how to effectively manage goroutines.",
"Step-by-step guide to building RESTful APIs using the Go programming language.",
"Discover tips and tricks to master Go programming and write efficient code.",
"Compare the features and performance of Go and Python in various scenarios.",
"Learn how to handle errors gracefully in Go and write robust applications.",
"An in-depth explanation of goroutines and channels in Go for concurrent programming.",
"Build a simple yet powerful web server using Go and the net/http package.",
"A comprehensive guide for beginners to learn Go programming from scratch.",
"Explore advanced programming techniques in Go to write high-performance applications.",
"Best practices for writing and running tests in Go to ensure code quality.",
"Manage dependencies in your Go projects using Go Modules effectively.",
"An introduction to interfaces in Go and how to use them for polymorphism.",
"Learn about various data structures and algorithms implemented in Go.",
"Build command-line applications in Go with ease using the flag package.",
"Understand Go's memory management and how to optimize memory usage.",
"Explore common design patterns in Go to write maintainable and scalable code.",
"Learn how to deploy Go applications to various environments.",
"Discover how to build microservices architecture using Go.",
"Techniques for profiling and optimizing the performance of Go code.",
}
var tags = []string{
"golang", "programming", "tutorial", "webdev", "backend", "frontend", "api", "concurrency", "microservices", "testing",
"designpatterns", "cli", "memory", "optimization", "deployment", "databases", "security", "networking", "algorithms", "datastructures",
}
var comments = []string{
"Great post! Thanks for sharing.",
"Very informative. I learned a lot.",
"I disagree with some points, but overall a good read.",
"Can you provide more details on this topic?",
"Excellent writing! Keep it up.",
"This helped me understand Go better. Thanks!",
"Interesting perspective. I never thought about it that way.",
"Could you share the source code?",
"Looking forward to your next post.",
"Thanks for the insights. Very helpful.",
"Well explained. I appreciate the examples.",
"I have a question about concurrency in Go.",
"Can you recommend more resources on this topic?",
"Great tips! I'll definitely try them out.",
"This post is a bit confusing. Can you clarify?",
"Thanks for the tutorial. It was easy to follow.",
"I found a typo in the code snippet.",
"Can you write about error handling in Go?",
"Your posts are always so detailed and helpful.",
"I tried this and it worked perfectly. Thanks!",
}
func Seed(store store.Storage, db *sql.DB) {
ctx := context.Background()
users := generateUsers(100)
tx, _ := db.BeginTx(ctx, nil)
for _, u := range users {
if err := store.Users.Create(ctx, tx, u); err != nil {
tx.Rollback()
log.Println("failed to create user:", err)
}
}
tx.Commit()
posts := generatePosts(200, users)
for _, p := range posts {
if err := store.Posts.Create(ctx, p); err != nil {
log.Println("failed to create post:", err)
}
}
comments := generateComments(500, users, posts)
for _, c := range comments {
if err := store.Comments.Create(ctx, c); err != nil {
log.Println("failed to create comment:", err)
}
}
log.Println("seeded database with sample data")
}
func generateUsers(n int) []*store.User {
users := make([]*store.User, n)
for i := 0; i < n; i++ {
users[i] = &store.User{
FirstName: first_names[i%len(first_names)],
LastName: last_names[i%len(last_names)],
Username: usernames[i%len(usernames)] + fmt.Sprintf("%d", i),
Email: usernames[i%len(usernames)] + fmt.Sprintf("%d", i) + "@example.com",
Role: store.Role{
Name: "user",
},
}
}
return users
}
func generatePosts(n int, users []*store.User) []*store.Post {
posts := make([]*store.Post, n)
for i := 0; i < n; i++ {
user := users[rand.Intn(len(users))]
posts[i] = &store.Post{
UserID: user.ID,
Title: titles[rand.Intn(len(titles))],
Content: contents[rand.Intn(len(contents))],
Tags: []string{tags[rand.Intn(len(tags))], tags[rand.Intn(len(tags))]},
}
}
return posts
}
func generateComments(n int, users []*store.User, posts []*store.Post) []*store.Comment {
post_comments := make([]*store.Comment, n)
for i := 0; i < n; i++ {
user := users[rand.Intn(len(users))]
post := posts[rand.Intn(len(posts))]
post_comments[i] = &store.Comment{
PostID: post.ID,
UserID: user.ID,
Content: comments[rand.Intn(len(comments))],
}
}
return post_comments
}
+64
View File
@@ -0,0 +1,64 @@
package env
import (
"os"
"strconv"
"strings"
"time"
)
func GetString(key, fallback string) string {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
return val
}
func GetInt(key string, fallback int) int {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
valAsInt, err := strconv.Atoi(val)
if err != nil {
return fallback
}
return valAsInt
}
func GetDuration(key string, fallback string) time.Duration {
parsedFallback, _ := time.ParseDuration(fallback)
val, ok := os.LookupEnv(key)
if !ok {
return parsedFallback
}
parsedVal, err := time.ParseDuration(val)
if err != nil {
return parsedFallback
}
return parsedVal
}
func GetBool(key string, fallback bool) bool {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
valAsBool, err := strconv.ParseBool(val)
if err != nil {
return fallback
}
return valAsBool
}
func GetList(key string, fallback []string) []string {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
return strings.Split(val, ",")
}
+16
View File
@@ -0,0 +1,16 @@
package mailer
import "embed"
const (
FromName = "LK API"
maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl"
)
//go:embed "templates"
var FS embed.FS
type Client interface {
Send(templateFile, username, email string, data any, isDevEnv bool) (int, error)
}
+67
View File
@@ -0,0 +1,67 @@
package mailer
import (
"bytes"
"fmt"
"text/template"
"time"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
type SendGridMailer struct {
fromEmail string
apiKey string
client *sendgrid.Client
}
func NewSendGrid(apiKey, fromEmail string) *SendGridMailer {
client := sendgrid.NewSendClient(apiKey)
return &SendGridMailer{
fromEmail: fromEmail,
apiKey: apiKey,
client: client,
}
}
func (s *SendGridMailer) Send(templateFile, username, email string, data any, isDevEnv bool) (int, error) {
from := mail.NewEmail(FromName, s.fromEmail)
to := mail.NewEmail(username, email)
tmpl, err := template.ParseFS(FS, "templates/"+templateFile)
if err != nil {
return -1, err
}
// Template Parsing and sending
subject := new(bytes.Buffer)
if err := tmpl.ExecuteTemplate(subject, "subject", data); err != nil {
return -1, err
}
body := new(bytes.Buffer)
if err := tmpl.ExecuteTemplate(body, "body", data); err != nil {
return -1, err
}
message := mail.NewSingleEmail(from, subject.String(), to, "", body.String())
message.SetMailSettings(&mail.MailSettings{
SandboxMode: &mail.Setting{
Enable: &isDevEnv,
},
})
var retryErr error
for i := 0; i < maxRetries; i++ {
resp, retryErr := s.client.Send(message)
if retryErr != nil {
//* Exponential backoff
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
return resp.StatusCode, nil
}
return -1, fmt.Errorf("failed to send the email after %d attempts, error: %v", maxRetries, retryErr)
}
@@ -0,0 +1,22 @@
{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body> <p>Hi {{.Username}},</p>
<p>Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!</p></p>
<p>Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:</p>
<p><a href="{{.ActivationURL}}">{{.ActivationURL}}</a></p>
<p>If you want to activate your account manually copy and paste the code from the link above</p>
<p>If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
+51
View File
@@ -0,0 +1,51 @@
package ratelimiter
import (
"log"
"sync"
"time"
)
type FixedWindowLimiter struct {
sync.RWMutex
clients map[string]int
limit int
window time.Duration
}
func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter {
return &FixedWindowLimiter{
clients: make(map[string]int),
limit: limit,
window: window,
}
}
func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
rl.RLock()
count, exists := rl.clients[ip]
rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit {
rl.Lock()
if !exists {
go rl.resetCount(ip)
}
rl.clients[ip]++
rl.Unlock()
return true, 0
}
return false, rl.window
}
func (rl *FixedWindowLimiter) resetCount(ip string) {
time.Sleep(rl.window)
rl.Lock()
delete(rl.clients, ip)
rl.Unlock()
}
+15
View File
@@ -0,0 +1,15 @@
package ratelimiter
import "time"
//! Use Redis for rate limiting when in multiple instances
type Limiter interface {
Allow(ip string) (bool, time.Duration)
}
type Config struct {
RequestsPerTimeFrame int
TimeFrame time.Duration
Enabled bool
}
+33
View File
@@ -0,0 +1,33 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/stretchr/testify/mock"
)
func NewMockCache() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Get(ctx context.Context, id int64) (*store.User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) Set(ctx context.Context, user *store.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+11
View File
@@ -0,0 +1,11 @@
package cache
import "github.com/go-redis/redis/v8"
func NewRedisClient(addr, pw string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: pw,
DB: db,
})
}
+22
View File
@@ -0,0 +1,22 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type Storage struct {
Users interface {
Get(context.Context, int64) (*store.User, error)
Set(context.Context, *store.User) error
Delete(context.Context, int64) error
}
}
func NewRedisStorage(rdb *redis.Client) Storage {
return Storage{
Users: &UserStore{rdb: rdb},
}
}
+60
View File
@@ -0,0 +1,60 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type UserStore struct {
rdb *redis.Client
}
const UserExpTime = time.Hour * 24
func (s *UserStore) Get(ctx context.Context, id int64) (*store.User, error) {
cacheKey := fmt.Sprintf("user-%d", id)
data, err := s.rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return nil, nil
} else if err != nil {
return nil, err
}
var user store.User
if data != "" {
err := json.Unmarshal([]byte(data), &user)
if err != nil {
return nil, err
}
}
return &user, nil
}
func (s *UserStore) Set(ctx context.Context, user *store.User) error {
if user.ID == 0 {
return fmt.Errorf("user ID is required")
}
cacheKey := fmt.Sprintf("user-%d", user.ID)
json, err := json.Marshal(user)
if err != nil {
return err
}
return s.rdb.SetEX(ctx, cacheKey, json, UserExpTime).Err()
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
cacheKey := fmt.Sprintf("user-%d", id)
return s.rdb.Del(ctx, cacheKey).Err()
}
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"context"
"database/sql"
)
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
UserID int64 `json:"user_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
User User `json:"user"`
}
type CommentStore struct {
db *sql.DB
}
func (s *CommentStore) Create(ctx context.Context, c *Comment) error {
query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at;`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(ctx, query, c.PostID, c.UserID, c.Content).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *CommentStore) GetByPostID(ctx context.Context, postID int64) ([]Comment, error) {
query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c
JOIN users on c.user_id = users.id
WHERE c.post_id = $1
ORDER BY c.created_at DESC;
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
comments := []Comment{}
for rows.Next() {
var comment Comment
comment.User = User{}
err := rows.Scan(
&comment.ID,
&comment.PostID,
&comment.UserID,
&comment.Content,
&comment.CreatedAt,
&comment.User.Username,
&comment.User.ID,
)
if err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, nil
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"context"
"database/sql"
"github.com/lib/pq"
)
type Follower struct {
ID int64 `json:"id"`
FolloweeID int64 `json:"followee_id"`
CreatedAt string `json:"created_at"`
}
type FollowerStore struct {
db *sql.DB
}
func (s *FollowerStore) Follow(ctx context.Context, followeeID, followerID int64) error {
query := `INSERT INTO followers (user_id, follower_id) VALUES ($1, $2)`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrConflict
}
}
return nil
}
func (s *FollowerStore) Unfollow(ctx context.Context, followeeID, followerID int64) error {
query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrNotFound
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"context"
"database/sql"
"time"
"github.com/stretchr/testify/mock"
)
func NewMockStorage() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
args := m.Called(tx, user)
return args.Error(0)
}
func (m *MockUserStore) Activate(ctx context.Context, token string) error {
args := m.Called(token)
return args.Error(0)
}
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
args := m.Called(email)
return nil, args.Error(1)
}
func (m *MockUserStore) Update(ctx context.Context, user *User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token string, expiresAt time.Duration) error {
args := m.Called(user, token, expiresAt)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockUserStore) DeleteUser(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"net/http"
"strconv"
"strings"
"time"
)
type PaginatedFeedQuery struct {
Limit int `json:"limit" validate:"gte=1,lte=20"`
Offset int `json:"offset" validate:"gte=0"`
Sort string `json:"sort" validate:"oneof=asc desc"`
Tags []string `json:"tags" validate:"max=5"`
Search string `json:"search" validate:"max=100"`
Since string `json:"since"`
Until string `json:"until"`
}
func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) {
qs := r.URL.Query()
limit := qs.Get("limit")
if limit != "" {
l, err := strconv.Atoi(limit)
if err != nil {
return fq, err
}
fq.Limit = l
}
offset := qs.Get("offset")
if offset != "" {
o, err := strconv.Atoi(offset)
if err != nil {
return PaginatedFeedQuery{}, err
}
fq.Offset = o
}
sort := qs.Get("sort")
if sort != "" {
fq.Sort = sort
}
tags := qs.Get("tags")
if tags != "" {
fq.Tags = strings.Split(tags, ",")
}
search := qs.Get("search")
if search != "" {
fq.Search = search
}
since := qs.Get("since")
if since != "" {
fq.Since = parseTime(since)
}
until := qs.Get("until")
if until != "" {
fq.Until = parseTime(until)
}
return fq, nil
}
func parseTime(s string) string {
t, err := time.Parse(time.DateTime, s)
if err != nil {
return ""
}
return t.Format(time.DateTime)
}
+205
View File
@@ -0,0 +1,205 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/lib/pq"
)
type Post struct {
ID int64 `json:"id"`
Content string `json:"content"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Version int `json:"version"`
Comments []Comment `json:"comments"`
User User `json:"user"`
}
type PostWithMetadata struct {
Post
CommentCount int `json:"comment_count"`
}
type PostStore struct {
db *sql.DB
}
func (s *PostStore) Create(ctx context.Context, post *Post) error {
query := `
INSERT INTO posts (content, title, user_id, tags)
VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.UserID,
pq.Array(post.Tags),
).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *PostStore) GetByID(ctx context.Context, id int64) (*Post, error) {
query := `
SELECT id, content, title, user_id, tags, created_at, updated_at, version
FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
var post Post
err := s.db.QueryRowContext(ctx, query, id).Scan(
&post.ID,
&post.Content,
&post.Title,
&post.UserID,
pq.Array(&post.Tags),
&post.CreatedAt,
&post.UpdatedAt,
&post.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
default:
return nil, err
}
}
return &post, nil
}
func (s *PostStore) GetUserFeed(ctx context.Context, userID int64, fq PaginatedFeedQuery) ([]PostWithMetadata, error) {
query := `
SELECT
p.id, p.user_id, p.title, p.content, p.created_at, p.updated_at, p.version, p.tags,
u.username,
COUNT(c.id) AS comments_count
FROM posts p
LEFT JOIN comments c on c.post_id = p.id
LEFT JOIN users u ON p.user_id = u.id
JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1
WHERE
f.user_id = $1 AND
(p.title ILIKE '%' || $4 || '%' OR p.content ILIKE '%' || $4 || '%') AND
(p.tags @> $5 OR $5 = '{}')
GROUP BY p.id, u.username
ORDER BY p.created_at ` + fq.Sort + `
LIMIT $2 OFFSET $3
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, fq.Limit, fq.Offset, fq.Search, pq.Array(fq.Tags))
if err != nil {
return nil, err
}
defer rows.Close()
var feed []PostWithMetadata
for rows.Next() {
var p PostWithMetadata
err := rows.Scan(
&p.ID,
&p.UserID,
&p.Title,
&p.Content,
&p.CreatedAt,
&p.UpdatedAt,
&p.Version,
pq.Array(&p.Tags),
&p.User.Username,
&p.CommentCount,
)
if err != nil {
return nil, err
}
fmt.Println(p)
feed = append(feed, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return feed, nil
}
func (s *PostStore) Update(ctx context.Context, post *Post) error {
query := `
UPDATE posts
SET content = $1, title = $2, updated_at = NOW(), version = version + 1
WHERE id = $3 AND version = $4
RETURNING version
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.ID,
post.Version,
).Scan(&post.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
default:
return err
}
}
return nil
}
func (s *PostStore) Delete(ctx context.Context, id int64) error {
query := `
DELETE FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
res, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return ErrNotFound
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package store
import (
"context"
"database/sql"
)
type Role struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int64 `json:"level"`
Description string `json:"description"`
}
type RoleStore struct {
db *sql.DB
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
query := `
SELECT id, name, level, description
FROM roles
WHERE name = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan(
&role.ID,
&role.Name,
&role.Level,
&role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return role, nil
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
var (
QueryTimeout = 5 * time.Second
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrDuplicateEmail = errors.New("email already exists")
ErrDuplicateUsername = errors.New("username already exists")
ErrPasswordNotSet = errors.New("password is empty")
)
type Storage struct {
Posts interface {
Create(context.Context, *Post) error
GetByID(context.Context, int64) (*Post, error)
GetUserFeed(context.Context, int64, PaginatedFeedQuery) ([]PostWithMetadata, error)
Update(context.Context, *Post) error
Delete(context.Context, int64) error
}
Users interface {
Create(context.Context, *sql.Tx, *User) error
Activate(context.Context, string) error
GetByID(context.Context, int64) (*User, error)
GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error
Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error
}
Comments interface {
Create(context.Context, *Comment) error
GetByPostID(context.Context, int64) ([]Comment, error)
}
Followers interface {
Follow(context.Context, int64, int64) error
Unfollow(context.Context, int64, int64) error
}
Roles interface {
GetByName(context.Context, string) (*Role, error)
}
}
func NewStorage(db *sql.DB) Storage {
return Storage{
Posts: &PostStore{db},
Users: &UserStore{db},
Comments: &CommentStore{db},
Followers: &FollowerStore{db},
Roles: &RoleStore{db},
}
}
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := f(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
+363
View File
@@ -0,0 +1,363 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
Email string `json:"email"`
Password password `json:"-"`
IsActive bool `json:"is_active"`
RoleID int64 `json:"role_id"`
Role Role `json:"role"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type password struct {
text *string `validate:"required,password"`
hash []byte
}
func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
p.text = &password
p.hash = hash
return nil
}
type UserStore struct {
db *sql.DB
}
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name
if role == "" {
role = "user"
}
err := tx.QueryRowContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
role,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`:
return ErrDuplicateUsername
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return ErrDuplicateEmail
default:
return err
}
}
return nil
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token string, invitationExp time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.Create(ctx, tx, user); err != nil {
return err
}
if err := s.createUserInvitation(ctx, tx, user.ID, invitationExp, token); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token)
if err != nil {
return err
}
// Update the user's status to active
user.IsActive = true
if err := s.update(ctx, tx, user); err != nil {
return err
}
// Delete the token from the invitations table
if err := s.deleteUserInvitation(ctx, tx, user.ID); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.delete(ctx, tx, id); err != nil {
return err
}
if err := s.deleteUserInvitation(ctx, tx, id); err != nil {
return err
}
return nil
})
}
// * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}
func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
}
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.IsActive,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
return nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.IsActive,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID)
if err != nil {
return err
}
return nil
}
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}
+1
View File
@@ -0,0 +1 @@
CREATE EXTENTION IF NOT EXISTS citext;
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+6
View File
@@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
</template>
Executable
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
+15
View File
@@ -0,0 +1,15 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: [
'@nuxt/content',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/image',
'@nuxt/scripts',
'@nuxt/test-utils'
]
})
+26
View File
@@ -0,0 +1,26 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/content": "3.4.0",
"@nuxt/eslint": "1.3.0",
"@nuxt/fonts": "0.11.0",
"@nuxt/icon": "1.11.0",
"@nuxt/image": "1.10.0",
"@nuxt/scripts": "0.11.5",
"@nuxt/test-utils": "3.17.2",
"@unhead/vue": "^2.0.0-rc.8",
"eslint": "^9.0.0",
"nuxt": "^3.16.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
+4
View File
@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}