package api import ( "context" "encoding/json" "io" "log" "net/http" "os" "path/filepath" "strings" "sync" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/pys/pdf-count/internal/config" "github.com/pys/pdf-count/internal/domain" "github.com/pys/pdf-count/internal/model" "github.com/pys/pdf-count/internal/ocr" "github.com/pys/pdf-count/internal/pipeline" "gopkg.in/yaml.v3" ) // Server holds the shared, stateless dependencies of the count-api. type Server struct { cfg config.Config predictor *model.Predictor // nil → heuristic fallback ocrEngine *ocr.Engine openAPI []byte } // NewServer constructs a Server. predictor may be nil when the ONNX model is // unavailable; the pipeline will fall back to a counting heuristic. func NewServer( cfg config.Config, predictor *model.Predictor, ocrEngine *ocr.Engine, openAPI []byte, ) *Server { return &Server{ cfg: cfg, predictor: predictor, ocrEngine: ocrEngine, openAPI: openAPI, } } // Router registers all HTTP routes. func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Get("/health", s.health) r.Get("/ready", s.ready) r.Get("/openapi.json", s.openAPIHandler) r.Route("/v1", func(r chi.Router) { r.Post("/count", s.countSync) }) return r } // health returns a simple liveness response. func (s *Server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // ready returns 200 once the server is accepting traffic (always ready after init). func (s *Server) ready(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } // openAPIHandler serves the OpenAPI specification as JSON. func (s *Server) openAPIHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write(s.openAPI) } // countSync processes a multipart batch of PDFs synchronously and returns the // counting results in a single JSON response. No state is persisted. // // POST /v1/count // // Query: include_rejected=true|false // Body: multipart/form-data, field "files" (one or more PDFs) // Limits: SYNC_MAX_FILES (default 50), MAX_PDF_SIZE_MB (default 20) func (s *Server) countSync(w http.ResponseWriter, r *http.Request) { includeRejected := r.URL.Query().Get("include_rejected") == "true" maxBytes := int64(s.cfg.MaxPDFSizeMB) * 1024 * 1024 if err := r.ParseMultipartForm(maxBytes * int64(s.cfg.SyncMaxFiles)); err != nil { httpError(w, http.StatusBadRequest, "invalid multipart form") return } files := r.MultipartForm.File["files"] if len(files) == 0 { httpError(w, http.StatusBadRequest, "field 'files' required") return } if len(files) > s.cfg.SyncMaxFiles { httpError(w, http.StatusBadRequest, "too many files") return } log.Printf("POST /v1/count: %d archivo(s), include_rejected=%v", len(files), includeRejected) type fileInput struct { filename string data []byte errRes *domain.PDFResult } inputs := make([]fileInput, 0, len(files)) for _, fh := range files { name := filepath.Base(fh.Filename) f, err := fh.Open() if err != nil { errRes := domain.PDFResult{Filename: name, Status: domain.StatusError, Error: err.Error()} inputs = append(inputs, fileInput{errRes: &errRes}) continue } data, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) _ = f.Close() if err != nil { errRes := domain.PDFResult{Filename: name, Status: domain.StatusError, Error: err.Error()} inputs = append(inputs, fileInput{errRes: &errRes}) continue } if int64(len(data)) > maxBytes { errRes := domain.PDFResult{Filename: name, Status: domain.StatusError, Error: "file too large"} inputs = append(inputs, fileInput{errRes: &errRes}) continue } inputs = append(inputs, fileInput{filename: name, data: data}) } proc := s.newProcessor(includeRejected) results := make([]domain.PDFResult, len(inputs)) failed := 0 concurrency := s.syncConcurrency() sem := make(chan struct{}, concurrency) var wg sync.WaitGroup var mu sync.Mutex for i, in := range inputs { if in.errRes != nil { results[i] = *in.errRes mu.Lock() failed++ mu.Unlock() continue } wg.Add(1) go func(idx int, in fileInput) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() res := proc.ProcessPDF(in.filename, in.data) mu.Lock() results[idx] = res if res.Status == domain.StatusError { failed++ } mu.Unlock() }(i, in) } wg.Wait() log.Printf("POST /v1/count completado: %d procesados, %d fallidos", len(results)-failed, failed) writeJSON(w, http.StatusOK, domain.CountResponse{ Results: results, Processed: len(results) - failed, Failed: failed, }) } // syncConcurrency returns the effective parallelism for /v1/count requests. func (s *Server) syncConcurrency() int { c := s.cfg.SyncProcessConcurrency if c <= 0 { c = s.cfg.OCRPoolSize } if c <= 0 { c = 4 } pool := s.cfg.OCRPoolSize if pool <= 0 { pool = 4 } if c > pool { c = pool } return c } func (s *Server) newProcessor(includeRejected bool) *pipeline.Processor { return pipeline.NewProcessor(s.predictor, s.ocrEngine, pipeline.Options{ DPI: s.cfg.OCRDPI, Lang: s.cfg.OCRLang, DBSCANEps: s.cfg.DBSCANEps, DBSCANMinSamples: s.cfg.DBSCANMinSamples, IncludeRejected: includeRejected, LogPipelineTiming: s.cfg.LogPipelineTiming, }) } // writeJSON encodes v as JSON and writes it with the given HTTP status. func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // httpError writes a JSON error response. func httpError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // LoadOpenAPI reads an OpenAPI spec from path. // YAML files are converted to JSON on the fly. Returns a minimal stub on error. func LoadOpenAPI(path string) []byte { data, err := os.ReadFile(path) if err != nil { return []byte(`{"openapi":"3.1.0","info":{"title":"count-api","version":"1.0.0"}}`) } if strings.HasSuffix(strings.ToLower(path), ".yaml") || strings.HasSuffix(strings.ToLower(path), ".yml") { var doc interface{} if err := yaml.Unmarshal(data, &doc); err == nil { if j, err := json.Marshal(doc); err == nil { return j } } } return data } // ListenAndServe starts the HTTP server and blocks until ctx is cancelled. func ListenAndServe(ctx context.Context, addr string, handler http.Handler) error { srv := &http.Server{Addr: addr, Handler: handler} go func() { <-ctx.Done() _ = srv.Shutdown(context.Background()) }() if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { return err } return nil }