257 lines
6.9 KiB
Go
257 lines
6.9 KiB
Go
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
|
|
}
|