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:
dbastrikin
2026-03-15 21:10:37 +02:00
parent b427576629
commit 19a985ad49
44 changed files with 87 additions and 87 deletions

View 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
}

View 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
}

View 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"))
}

View 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
}

View 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,
})
}

View 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))
})
}

View 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()),
)
})
}

View 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)
})
}

View 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
}

View 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,
})
}
}