refactor: restructure internal/ into adapters/, infra/, and app layers
- internal/gemini/ → internal/adapters/openai/ (renamed package to openai) - internal/pexels/ → internal/adapters/pexels/ - internal/config/ → internal/infra/config/ - internal/database/ → internal/infra/database/ - internal/locale/ → internal/infra/locale/ - internal/middleware/ → internal/infra/middleware/ - internal/server/ → internal/infra/server/ All import paths and call sites updated accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
35
backend/internal/infra/config/config.go
Normal file
35
backend/internal/infra/config/config.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int `envconfig:"PORT" default:"8080"`
|
||||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
||||
|
||||
// Firebase
|
||||
FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"`
|
||||
|
||||
// JWT
|
||||
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||
JWTAccessDuration time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"`
|
||||
JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"`
|
||||
|
||||
// CORS
|
||||
AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
|
||||
|
||||
// External APIs
|
||||
OpenAIAPIKey string `envconfig:"OPENAI_API_KEY" required:"true"`
|
||||
PexelsAPIKey string `envconfig:"PEXELS_API_KEY" required:"true"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
32
backend/internal/infra/database/postgres.go
Normal file
32
backend/internal/infra/database/postgres.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = 20
|
||||
config.MinConns = 5
|
||||
config.MaxConnLifetime = 30 * time.Minute
|
||||
config.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
102
backend/internal/infra/locale/locale.go
Normal file
102
backend/internal/infra/locale/locale.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Default is the fallback language when no supported language is detected.
|
||||
const Default = "en"
|
||||
|
||||
// Supported is the set of language codes the application currently handles.
|
||||
// Keys are ISO 639-1 two-letter codes (lower-case).
|
||||
// Populated by LoadFromDB at server startup.
|
||||
var Supported = map[string]bool{
|
||||
"en": true,
|
||||
"ru": true,
|
||||
}
|
||||
|
||||
// Language is a supported language record loaded from the DB.
|
||||
type Language struct {
|
||||
Code string
|
||||
NativeName string
|
||||
EnglishName string
|
||||
}
|
||||
|
||||
// Languages is the ordered list of active languages.
|
||||
// Populated by LoadFromDB at server startup.
|
||||
var Languages []Language
|
||||
|
||||
// LoadFromDB queries the languages table and updates both Supported and Languages.
|
||||
// Must be called once at startup before the server begins accepting requests.
|
||||
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT code, native_name, english_name FROM languages WHERE is_active ORDER BY sort_order`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load languages from db: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
newSupported := map[string]bool{}
|
||||
var newLanguages []Language
|
||||
for rows.Next() {
|
||||
var l Language
|
||||
if err := rows.Scan(&l.Code, &l.NativeName, &l.EnglishName); err != nil {
|
||||
return err
|
||||
}
|
||||
newSupported[l.Code] = true
|
||||
newLanguages = append(newLanguages, l)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
Supported = newSupported
|
||||
Languages = newLanguages
|
||||
return nil
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// Parse returns the best-matching supported language from an Accept-Language
|
||||
// header value. It iterates through the comma-separated list in preference
|
||||
// order and returns the first entry whose primary subtag is in Supported.
|
||||
// Returns Default when the header is empty or no match is found.
|
||||
func Parse(acceptLang string) string {
|
||||
if acceptLang == "" {
|
||||
return Default
|
||||
}
|
||||
for part := range strings.SplitSeq(acceptLang, ",") {
|
||||
// Strip quality value (e.g. ";q=0.9").
|
||||
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
// Use only the primary subtag (e.g. "ru" from "ru-RU").
|
||||
lang := strings.ToLower(strings.SplitN(tag, "-", 2)[0])
|
||||
if Supported[lang] {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// WithLang returns a copy of ctx carrying the given language code.
|
||||
func WithLang(ctx context.Context, lang string) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, lang)
|
||||
}
|
||||
|
||||
// FromContext returns the language stored in ctx.
|
||||
// Returns Default when no language has been set.
|
||||
func FromContext(ctx context.Context) string {
|
||||
if lang, ok := ctx.Value(contextKey{}).(string); ok && lang != "" {
|
||||
return lang
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// FromRequest extracts the preferred language from the request's
|
||||
// Accept-Language header.
|
||||
func FromRequest(r *http.Request) string {
|
||||
return Parse(r.Header.Get("Accept-Language"))
|
||||
}
|
||||
56
backend/internal/infra/middleware/auth.go
Normal file
56
backend/internal/infra/middleware/auth.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
userIDKey contextKey = "user_id"
|
||||
userPlanKey contextKey = "user_plan"
|
||||
)
|
||||
|
||||
// TokenClaims represents the result of validating an access token.
|
||||
type TokenClaims struct {
|
||||
UserID string
|
||||
Plan string
|
||||
}
|
||||
|
||||
// AccessTokenValidator validates JWT access tokens.
|
||||
type AccessTokenValidator interface {
|
||||
ValidateAccessToken(tokenStr string) (*TokenClaims, error)
|
||||
}
|
||||
|
||||
func Auth(validator AccessTokenValidator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
claims, err := validator.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, userPlanKey, claims.Plan)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UserIDFromCtx(ctx context.Context) string {
|
||||
id, _ := ctx.Value(userIDKey).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func UserPlanFromCtx(ctx context.Context) string {
|
||||
plan, _ := ctx.Value(userPlanKey).(string)
|
||||
return plan
|
||||
}
|
||||
18
backend/internal/infra/middleware/cors.go
Normal file
18
backend/internal/infra/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
}
|
||||
18
backend/internal/infra/middleware/language.go
Normal file
18
backend/internal/infra/middleware/language.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
)
|
||||
|
||||
// Language reads the Accept-Language request header, resolves the best
|
||||
// supported language via locale.Parse, and stores it in the request context.
|
||||
// Downstream handlers retrieve it with locale.FromContext.
|
||||
func Language(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := locale.FromRequest(r)
|
||||
ctx := locale.WithLang(r.Context(), lang)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
34
backend/internal/infra/middleware/logging.go
Normal file
34
backend/internal/infra/middleware/logging.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.statusCode,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromCtx(r.Context()),
|
||||
)
|
||||
})
|
||||
}
|
||||
23
backend/internal/infra/middleware/recovery.go
Normal file
23
backend/internal/infra/middleware/recovery.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
slog.Error("panic recovered",
|
||||
"error", err,
|
||||
"stack", string(debug.Stack()),
|
||||
"request_id", RequestIDFromCtx(r.Context()),
|
||||
)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
29
backend/internal/infra/middleware/request_id.go
Normal file
29
backend/internal/infra/middleware/request_id.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestIDKey contextKey = "request_id"
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
id = uuid.Must(uuid.NewV7()).String()
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), requestIDKey, id)
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func RequestIDFromCtx(ctx context.Context) string {
|
||||
id, _ := ctx.Value(requestIDKey).(string)
|
||||
return id
|
||||
}
|
||||
144
backend/internal/infra/server/server.go
Normal file
144
backend/internal/infra/server/server.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/language"
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
pool *pgxpool.Pool,
|
||||
authHandler *auth.Handler,
|
||||
userHandler *user.Handler,
|
||||
recommendationHandler *recommendation.Handler,
|
||||
savedRecipeHandler *savedrecipe.Handler,
|
||||
ingredientHandler *ingredient.Handler,
|
||||
productHandler *product.Handler,
|
||||
recognitionHandler *recognition.Handler,
|
||||
menuHandler *menu.Handler,
|
||||
diaryHandler *diary.Handler,
|
||||
homeHandler *home.Handler,
|
||||
dishHandler *dish.Handler,
|
||||
recipeHandler *recipe.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
allowedOrigins []string,
|
||||
unitsListHandler http.HandlerFunc,
|
||||
cuisineListHandler http.HandlerFunc,
|
||||
tagListHandler http.HandlerFunc,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logging)
|
||||
r.Use(middleware.Recovery)
|
||||
r.Use(middleware.CORS(allowedOrigins))
|
||||
r.Use(middleware.Language)
|
||||
|
||||
// Public
|
||||
r.Get("/health", healthCheck(pool))
|
||||
r.Get("/languages", language.List)
|
||||
r.Get("/units", unitsListHandler)
|
||||
r.Get("/cuisines", cuisineListHandler)
|
||||
r.Get("/tags", tagListHandler)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", authHandler.Login)
|
||||
r.Post("/refresh", authHandler.Refresh)
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
})
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/ingredients/search", ingredientHandler.Search)
|
||||
|
||||
r.Get("/profile", userHandler.Get)
|
||||
r.Put("/profile", userHandler.Update)
|
||||
|
||||
r.Get("/recommendations", recommendationHandler.GetRecommendations)
|
||||
|
||||
r.Route("/saved-recipes", func(r chi.Router) {
|
||||
r.Post("/", savedRecipeHandler.Save)
|
||||
r.Get("/", savedRecipeHandler.List)
|
||||
r.Get("/{id}", savedRecipeHandler.GetByID)
|
||||
r.Delete("/{id}", savedRecipeHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/products", func(r chi.Router) {
|
||||
r.Get("/", productHandler.List)
|
||||
r.Post("/", productHandler.Create)
|
||||
r.Post("/batch", productHandler.BatchCreate)
|
||||
r.Put("/{id}", productHandler.Update)
|
||||
r.Delete("/{id}", productHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/dishes", func(r chi.Router) {
|
||||
r.Get("/", dishHandler.List)
|
||||
r.Get("/{id}", dishHandler.GetByID)
|
||||
})
|
||||
|
||||
r.Get("/recipes/{id}", recipeHandler.GetByID)
|
||||
|
||||
r.Route("/menu", func(r chi.Router) {
|
||||
r.Get("/", menuHandler.GetMenu)
|
||||
r.Put("/items/{id}", menuHandler.UpdateMenuItem)
|
||||
r.Delete("/items/{id}", menuHandler.DeleteMenuItem)
|
||||
})
|
||||
|
||||
r.Route("/shopping-list", func(r chi.Router) {
|
||||
r.Get("/", menuHandler.GetShoppingList)
|
||||
r.Post("/generate", menuHandler.GenerateShoppingList)
|
||||
r.Patch("/items/{index}/check", menuHandler.ToggleShoppingItem)
|
||||
})
|
||||
|
||||
r.Route("/diary", func(r chi.Router) {
|
||||
r.Get("/", diaryHandler.GetByDate)
|
||||
r.Post("/", diaryHandler.Create)
|
||||
r.Delete("/{id}", diaryHandler.Delete)
|
||||
})
|
||||
|
||||
r.Get("/home/summary", homeHandler.GetSummary)
|
||||
|
||||
r.Route("/ai", func(r chi.Router) {
|
||||
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
||||
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
||||
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
|
||||
r.Post("/generate-menu", menuHandler.GenerateMenu)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func healthCheck(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dbStatus := "connected"
|
||||
if err := pool.Ping(r.Context()); err != nil {
|
||||
dbStatus = "disconnected"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"db": dbStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user