409 lines
13 KiB
Go
409 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"path/filepath"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
|
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
|
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Server holds all dependencies for the HTTP server.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
logger *zap.Logger
|
|
router chi.Router
|
|
mongo *repository.MongoDB
|
|
pg *repository.Postgres
|
|
redis *repository.Redis
|
|
|
|
// Worker lifecycle
|
|
workerCancel context.CancelFunc
|
|
|
|
// Handlers
|
|
bookH *handler.BookHandler
|
|
letterH *handler.LetterHandler
|
|
essayH *handler.EssayHandler
|
|
glossaryH *handler.GlossaryHandler
|
|
discipleH *handler.DiscipleHandler
|
|
journalH *handler.JournalHandler
|
|
magazineH *handler.MagazineHandler
|
|
otherDocH *handler.OtherDocumentHandler
|
|
transcriptionH *handler.TranscriptionHandler
|
|
searchH *handler.SearchHandler
|
|
shortURLH *handler.ShortURLHandler
|
|
}
|
|
|
|
// New creates a new Server with all routes registered.
|
|
func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|
mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger)
|
|
if err != nil {
|
|
logger.Fatal("failed to connect to MongoDB", zap.Error(err))
|
|
}
|
|
|
|
// Initialize Typesense client
|
|
tsClient := repository.NewTypesenseClient(cfg.TypesenseURL, cfg.TypesenseAPIKey)
|
|
|
|
// Initialize repositories
|
|
bookRepo := repository.NewBookRepo(mongoDB)
|
|
letterRepo := repository.NewLetterRepo(mongoDB)
|
|
essayRepo := repository.NewEssayRepo(mongoDB)
|
|
glossaryRepo := repository.NewGlossaryRepo(mongoDB)
|
|
discipleRepo := repository.NewDiscipleRepo(mongoDB)
|
|
journalRepo := repository.NewJournalRepo(mongoDB)
|
|
magazineRepo := repository.NewMagazineRepo(mongoDB)
|
|
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
|
|
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
|
|
|
|
s := &Server{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
mongo: mongoDB,
|
|
|
|
// Initialize handlers
|
|
bookH: handler.NewBookHandler(bookRepo),
|
|
letterH: handler.NewLetterHandler(letterRepo),
|
|
essayH: handler.NewEssayHandler(essayRepo),
|
|
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
|
discipleH: handler.NewDiscipleHandler(discipleRepo),
|
|
journalH: handler.NewJournalHandler(journalRepo),
|
|
magazineH: handler.NewMagazineHandler(magazineRepo),
|
|
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
|
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
|
searchH: handler.NewSearchHandler(tsClient),
|
|
}
|
|
|
|
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
|
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
|
|
s.initShortener(cfg, logger)
|
|
|
|
s.setupRouter()
|
|
return s
|
|
}
|
|
|
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
|
// On connection failure it logs and leaves the components nil — the handler
|
|
// short-circuits to 503 so the rest of the BFF keeps working.
|
|
func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.workerCancel = cancel
|
|
|
|
pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger)
|
|
if err != nil {
|
|
logger.Warn("URL shortener: Postgres unavailable — /share endpoints will return 503",
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
s.pg = pg
|
|
|
|
migrationsDir, err := filepath.Abs("migrations")
|
|
if err == nil {
|
|
if err := pg.RunMigrations(ctx, migrationsDir); err != nil {
|
|
logger.Warn("URL shortener: migrations failed", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
rd, err := repository.NewRedis(ctx, cfg.RedisURI, logger)
|
|
if err != nil {
|
|
logger.Warn("URL shortener: Redis unavailable — /share endpoints will return 503",
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
s.redis = rd
|
|
|
|
shortRepo := repository.NewShortURLRepo(pg)
|
|
queue := shorturl.NewQueue(rd.Client)
|
|
s.shortURLH = handler.NewShortURLHandler(shortRepo, queue, logger)
|
|
|
|
w := worker.NewShortURLWorker(shortRepo, queue, logger)
|
|
w.Run(ctx)
|
|
}
|
|
|
|
// Router returns the chi router.
|
|
func (s *Server) Router() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
// Close shuts down all connections gracefully.
|
|
func (s *Server) Close() {
|
|
if s.workerCancel != nil {
|
|
s.workerCancel()
|
|
}
|
|
if err := s.mongo.Close(); err != nil {
|
|
s.logger.Error("failed to close MongoDB connection", zap.Error(err))
|
|
}
|
|
if s.pg != nil {
|
|
s.pg.Close()
|
|
}
|
|
if s.redis != nil {
|
|
if err := s.redis.Close(); err != nil {
|
|
s.logger.Error("failed to close Redis connection", zap.Error(err))
|
|
}
|
|
}
|
|
s.logger.Info("all connections closed")
|
|
}
|
|
|
|
// injectBookCode is a middleware that sets "code" in chi's URL params
|
|
// for explicit sub-routes like /books/sb and /books/cc where {code}
|
|
// isn't a URL param but a fixed path prefix.
|
|
func injectBookCode(code string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rctx := chi.RouteContext(r.Context())
|
|
rctx.URLParams.Add("code", code)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRouter() {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(chiMiddleware.RequestID)
|
|
r.Use(chiMiddleware.RealIP)
|
|
r.Use(middleware.Logger(s.logger))
|
|
r.Use(chiMiddleware.Recoverer)
|
|
r.Use(chiMiddleware.Compress(5))
|
|
|
|
// CORS
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{s.cfg.FrontendURL},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
|
ExposedHeaders: []string{"X-Request-ID"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
// Health check
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// API v1
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Books
|
|
r.Route("/books", func(r chi.Router) {
|
|
r.Get("/", s.bookH.ListBooks)
|
|
|
|
// --- Verse books with divisions ---
|
|
|
|
// SB — canto divisions
|
|
r.Route("/sb", func(r chi.Router) {
|
|
r.Use(injectBookCode("sb"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
|
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
|
})
|
|
|
|
// CC — līlā divisions
|
|
r.Route("/cc", func(r chi.Router) {
|
|
r.Use(injectBookCode("cc"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
|
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
|
})
|
|
|
|
// --- Verse books without divisions ---
|
|
// BG, SB1962: pure verse books
|
|
for _, code := range []string{"bg", "sb1962"} {
|
|
code := code // capture for closure
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Verse + prose intro books (no division) ---
|
|
// BS, MM, NBS: have both verses and prose intro segments
|
|
for _, code := range []string{"bs", "mm", "nbs"} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Verse + prose intro books with special slugs ---
|
|
// ISO, NOI: mantras/texts accessed by slug, plus prose intros
|
|
for _, code := range []string{"iso", "noi"} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Volume-based prose book ---
|
|
// KB: 2 volumes, chapters within each volume
|
|
r.Route("/kb", func(r chi.Router) {
|
|
r.Use(injectBookCode("kb"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{volume}", s.bookH.ListVolumeChapters)
|
|
r.Get("/{volume}/{slug}", s.bookH.GetVolumeChapter)
|
|
})
|
|
|
|
// --- Simple prose books ---
|
|
// BBD, BK, CBHC, DS, EJ, EK, GG, HKH, LCL, LOB, MG, MOG, NOD,
|
|
// OCHKM, OWK, PK, POY, PQPA, RV, SSR, SVA, TLC, TLK, TQK, TTP, VA
|
|
for _, code := range []string{
|
|
"bbd", "bk", "cbhc", "ds", "ej", "ek", "gg", "hkh",
|
|
"lcl", "lob", "mg", "mog", "nod", "ochkm", "owk", "pk",
|
|
"poy", "pqpa", "rv", "ssr", "sva", "tlc", "tlk", "tqk",
|
|
"ttp", "va",
|
|
} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Letters
|
|
r.Route("/letters", func(r chi.Router) {
|
|
r.Get("/", s.letterH.Count)
|
|
r.Get("/years", s.letterH.GetYears)
|
|
r.Get("/recipients", s.letterH.GetRecipients)
|
|
r.Get("/locations", s.letterH.GetLocations)
|
|
r.Get("/year/{year}", s.letterH.GetByYear)
|
|
r.Get("/recipient/{name}", s.letterH.GetByRecipient)
|
|
r.Get("/location/{name}", s.letterH.GetByLocation)
|
|
r.Get("/{slug}", s.letterH.GetBySlug)
|
|
})
|
|
|
|
// Essays
|
|
r.Route("/essays", func(r chi.Router) {
|
|
r.Get("/", s.essayH.List)
|
|
r.Get("/{slug}", s.essayH.GetBySlug)
|
|
})
|
|
|
|
// Glossary
|
|
r.Route("/glossary", func(r chi.Router) {
|
|
r.Get("/", s.glossaryH.List)
|
|
r.Get("/letters", s.glossaryH.GetLetterCounts)
|
|
r.Get("/letter/{letter}", s.glossaryH.GetByLetter)
|
|
r.Get("/letter/{letter}/terms", s.glossaryH.GetTermsByLetter)
|
|
r.Get("/term/{term}", s.glossaryH.GetByTerm)
|
|
})
|
|
|
|
// Disciples
|
|
r.Route("/disciples", func(r chi.Router) {
|
|
r.Get("/", s.discipleH.List)
|
|
r.Get("/count", s.discipleH.Count)
|
|
r.Get("/letters", s.discipleH.LetterCounts)
|
|
r.Get("/suffixes", s.discipleH.SuffixCounts)
|
|
r.Get("/search", s.discipleH.Search)
|
|
})
|
|
|
|
// Journals
|
|
r.Route("/journals", func(r chi.Router) {
|
|
r.Get("/", s.journalH.ListJournals)
|
|
r.Get("/{book}", s.journalH.GetJournal)
|
|
r.Get("/{book}/chapters", s.journalH.ListChapters)
|
|
r.Get("/{book}/chapters/{slug}", s.journalH.GetChapter)
|
|
})
|
|
|
|
// Magazines
|
|
r.Route("/magazines", func(r chi.Router) {
|
|
r.Get("/", s.magazineH.ListMagazines)
|
|
r.Get("/{book}", s.magazineH.GetMagazine)
|
|
r.Get("/{book}/issues", s.magazineH.ListIssues)
|
|
r.Get("/{book}/issues/{slug}", s.magazineH.GetIssue)
|
|
})
|
|
|
|
// Other Documents
|
|
r.Route("/other-documents", func(r chi.Router) {
|
|
r.Get("/", s.otherDocH.ListDocuments)
|
|
r.Get("/{book}", s.otherDocH.GetDocument)
|
|
r.Get("/{book}/chapters", s.otherDocH.ListChapters)
|
|
r.Get("/{book}/chapters/{slug}", s.otherDocH.GetChapter)
|
|
})
|
|
|
|
// Transcriptions
|
|
r.Route("/transcriptions", func(r chi.Router) {
|
|
r.Get("/", s.transcriptionH.Count)
|
|
r.Get("/years", s.transcriptionH.GetYears)
|
|
r.Get("/places", s.transcriptionH.GetPlaces)
|
|
r.Get("/types", s.transcriptionH.GetTypeDistribution)
|
|
r.Get("/year/{year}", s.transcriptionH.GetByYear)
|
|
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
|
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
|
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
|
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
|
})
|
|
|
|
// Search (Typesense)
|
|
r.Get("/search", s.searchH.Search)
|
|
|
|
// User routes (PostgreSQL)
|
|
r.Route("/auth", func(r chi.Router) {
|
|
// TODO: register auth handlers (login, register, refresh)
|
|
})
|
|
r.Route("/users", func(r chi.Router) {
|
|
// TODO: register user handlers
|
|
})
|
|
r.Route("/collections", func(r chi.Router) {
|
|
// TODO: register collection/bookmark handlers
|
|
})
|
|
r.Route("/mentions", func(r chi.Router) {
|
|
// TODO: register mention handlers
|
|
})
|
|
|
|
// URL Shortener — Postgres-backed, Redis-queued.
|
|
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
|
r.Route("/share", func(r chi.Router) {
|
|
if s.shortURLH != nil {
|
|
r.Post("/", s.shortURLH.Create)
|
|
r.Get("/{code}", s.shortURLH.Resolve)
|
|
} else {
|
|
stub := func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte(`{"success":false,"error":{"code":503,"message":"url shortener unavailable"}}`))
|
|
}
|
|
r.Post("/", stub)
|
|
r.Get("/{code}", stub)
|
|
}
|
|
})
|
|
|
|
// Push Notifications
|
|
r.Route("/notifications", func(r chi.Router) {
|
|
// TODO: register push notification handlers
|
|
})
|
|
})
|
|
|
|
s.router = r
|
|
}
|