442 lines
12 KiB
Go
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
|
|
}
|
|
}
|
|
}
|