Files
WPSidekick/internal/api/server.go
T
2026-01-25 08:57:55 +01:00

442 lines
12 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/wordpress"
)
// Server provides HTTP API for manual optimization triggers and status checks
type Server struct {
optimizer *seo.Optimizer
wpClient *wordpress.Client
storage *storage.OptimizationStorage
port int
server *http.Server
logger *logger.Logger
// In-memory job tracking (simple implementation for MVP)
jobs map[string]*Job
jobsMu sync.RWMutex
}
// Job represents an optimization job (running or completed)
type Job struct {
ID string `json:"id"`
PostID int `json:"post_id"`
Language string `json:"language"`
Status string `json:"status"` // "running", "completed", "failed"
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
}
// NewServer creates a new API server
func NewServer(optimizer *seo.Optimizer, wpClient *wordpress.Client, store *storage.OptimizationStorage, port int, log *logger.Logger) *Server {
if log == nil {
log = logger.NewDefaultLogger()
}
return &Server{
optimizer: optimizer,
wpClient: wpClient,
storage: store,
port: port,
jobs: make(map[string]*Job),
logger: log.WithComponent("api"),
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
router := mux.NewRouter()
// API endpoints (no authentication - local access only)
router.HandleFunc("/api/v1/optimize", s.handleOptimize).Methods("POST")
router.HandleFunc("/api/v1/apply-draft", s.handleApplyDraft).Methods("POST")
router.HandleFunc("/api/v1/reject-draft", s.handleRejectDraft).Methods("POST")
router.HandleFunc("/api/v1/pending", s.handleListPending).Methods("GET")
router.HandleFunc("/api/v1/optimization/{post_id}", s.handleGetOptimization).Methods("GET")
router.HandleFunc("/api/v1/status/{job_id}", s.handleStatus).Methods("GET")
router.HandleFunc("/api/v1/health", s.handleHealth).Methods("GET")
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.logger.Info("API server starting", "port", s.port)
return s.server.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("API server shutting down")
return s.server.Shutdown(ctx)
}
// handleOptimize handles POST /api/v1/optimize
// Request: {"post_id": 123, "language": "es"}
// Response: 202 Accepted + {"job_id": "uuid", "status": "accepted"}
func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
var req struct {
PostID int `json:"post_id"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
if req.Language == "" {
req.Language = "en" // Default to English
}
// Create job
jobID := uuid.New().String()
job := &Job{
ID: jobID,
PostID: req.PostID,
Language: req.Language,
Status: "running",
StartedAt: time.Now(),
}
s.jobsMu.Lock()
s.jobs[jobID] = job
s.jobsMu.Unlock()
s.logger.Info("Optimization job started",
"job_id", jobID,
"post_id", req.PostID,
"language", req.Language,
)
// Run optimization asynchronously
go s.runOptimizationJob(job)
// Return accepted response
w.WriteHeader(http.StatusAccepted)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "accepted",
})
}
// handleStatus handles GET /api/v1/status/:job_id
// Response: {"status": "running|completed|failed", "details": "..."}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
jobID := vars["job_id"]
s.jobsMu.RLock()
job, exists := s.jobs[jobID]
s.jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
}
// handleApplyDraft handles POST /api/v1/apply-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "applied", "message": "Draft applied to original post"}
func (s *Server) handleApplyDraft(w http.ResponseWriter, r *http.Request) {
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
s.logger.Info("Applying draft to original post",
"draft_post_id", req.DraftPostID,
)
// Apply draft to original post
ctx := context.Background()
if err := s.wpClient.ApplyDraftToOriginal(ctx, req.DraftPostID); err != nil {
if err := s.applyDraftWithStorageFallback(ctx, req.DraftPostID, err); err != nil {
s.logger.Error("Failed to apply draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to apply draft: %v", err), http.StatusInternalServerError)
return
}
}
// Update storage status to "applied"
// Extract original post ID from draft to update storage
// We need to find the record by searching for the draft post ID
pendingRecords, err := s.storage.ListPending()
if err != nil {
s.logger.Warn("Failed to list pending records", "error", err)
} else {
for _, record := range pendingRecords {
if record.DraftPostID == req.DraftPostID {
if err := s.storage.UpdateStatus(record.OriginalPostID, "applied"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
s.logger.Info("Draft applied successfully",
"draft_post_id", req.DraftPostID,
)
// Return success response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "applied",
"message": "Draft applied to original post and draft deleted",
})
}
func (s *Server) applyDraftWithStorageFallback(ctx context.Context, draftPostID int, applyErr error) error {
if !strings.Contains(applyErr.Error(), "not linked to an original post") {
return applyErr
}
pendingRecords, err := s.storage.ListPending()
if err != nil {
return applyErr
}
for _, record := range pendingRecords {
if record.DraftPostID == draftPostID && record.OriginalPostID > 0 {
return s.wpClient.ApplyDraftToOriginalWithID(ctx, draftPostID, record.OriginalPostID)
}
}
return applyErr
}
// handleRejectDraft handles POST /api/v1/reject-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "rejected", "message": "Draft deleted"}
func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
s.logger.Info("Rejecting draft post",
"draft_post_id", req.DraftPostID,
)
ctx := context.Background()
if err := s.wpClient.DeleteDraft(ctx, req.DraftPostID); err != nil {
s.logger.Error("Failed to delete draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to delete draft: %v", err), http.StatusInternalServerError)
return
}
pendingRecords, err := s.storage.ListPending()
if err != nil {
s.logger.Warn("Failed to list pending records", "error", err)
} else {
for _, record := range pendingRecords {
if record.DraftPostID == req.DraftPostID {
if err := s.storage.UpdateStatus(record.OriginalPostID, "rejected"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "rejected",
"message": "Draft deleted",
})
}
// handleListPending handles GET /api/v1/pending
// Response: 200 OK + [{"post_id": 123, "draft_post_id": 456, ...}, ...]
func (s *Server) handleListPending(w http.ResponseWriter, r *http.Request) {
s.logger.Debug("Listing pending optimizations")
records, err := s.storage.ListPending()
if err != nil {
s.logger.Error("Failed to list pending optimizations", "error", err)
http.Error(w, fmt.Sprintf("Failed to list pending optimizations: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(records),
"records": records,
})
}
// handleGetOptimization handles GET /api/v1/optimization/:post_id
// Response: 200 OK + optimization details
func (s *Server) handleGetOptimization(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
postIDStr := vars["post_id"]
postID, err := strconv.Atoi(postIDStr)
if err != nil {
http.Error(w, "Invalid post_id", http.StatusBadRequest)
return
}
s.logger.Debug("Getting optimization details", "post_id", postID)
record, err := s.storage.Get(postID)
if err != nil {
s.logger.Error("Failed to get optimization record",
"post_id", postID,
"error", err,
)
http.Error(w, fmt.Sprintf("Optimization not found for post %d", postID), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(record)
}
// handleHealth handles GET /api/v1/health
// Response: {"status": "ok", "version": "1.0.0"}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": "1.0.0",
})
}
// runOptimizationJob runs an optimization job in the background
func (s *Server) runOptimizationJob(job *Job) {
ctx := context.Background()
s.logger.Info("Running optimization job",
"job_id", job.ID,
"post_id", job.PostID,
"language", job.Language,
)
// Run optimization
err := s.optimizer.OptimizeSpecificPost(ctx, job.PostID, job.Language)
// Update job status
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
now := time.Now()
job.EndedAt = &now
if err != nil {
var skipErr *seo.SkipError
if errors.As(err, &skipErr) {
job.Status = "completed"
job.Summary = fmt.Sprintf("Skipped: %s", skipErr.Reason)
s.logger.Info("Optimization job skipped",
"job_id", job.ID,
"reason", skipErr.Reason,
)
return
}
job.Status = "failed"
job.Error = err.Error()
s.logger.Error("Optimization job failed",
"job_id", job.ID,
"error", err,
)
} else {
job.Status = "completed"
job.Summary = "Optimization completed successfully"
s.logger.Info("Optimization job completed",
"job_id", job.ID,
"duration", now.Sub(job.StartedAt),
)
}
}
// CleanupOldJobs removes completed jobs older than the specified duration
// This prevents memory growth in long-running deployments
func (s *Server) CleanupOldJobs(maxAge time.Duration) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
cutoff := time.Now().Add(-maxAge)
for id, job := range s.jobs {
if job.EndedAt != nil && job.EndedAt.Before(cutoff) {
delete(s.jobs, id)
}
}
}
// StartPeriodicCleanup starts a background goroutine that cleans up old jobs
func (s *Server) StartPeriodicCleanup(ctx context.Context, cleanupInterval, maxJobAge time.Duration) {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.CleanupOldJobs(maxJobAge)
s.logger.Debug("Cleaned up old jobs")
case <-ctx.Done():
return
}
}
}