package httpapi import ( "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // Stable ETag for the embedded public OpenAPI document (compile-time bytes). var v1OpenAPIETag = func() string { sum := sha256.Sum256(v1OpenAPIYAML) return `"` + hex.EncodeToString(sum[:16]) + `"` }() // Precompressed OpenAPI body (~12KB vs ~74KB raw) for Accept-Encoding: gzip. var v1OpenAPIGzip = func() []byte { var buf bytes.Buffer zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) if err != nil { return nil } if _, err := zw.Write(v1OpenAPIYAML); err != nil { _ = zw.Close() return nil } if err := zw.Close(); err != nil { return nil } return buf.Bytes() }() func acceptEncodingIncludesGzip(header string) bool { for _, part := range strings.Split(header, ",") { encoding := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) if strings.EqualFold(encoding, "gzip") { return true } } return false } // mountV1 registers the public API-key surface under /api/v1. // Handlers reuse dashboard services with company isolation from RequireAPIKey. func (s *Server) mountV1(r chi.Router) { r.Route("/api/v1", func(r chi.Router) { r.Get("/openapi.yaml", s.handleV1OpenAPI) r.Get("/health", s.handleHealthz) r.Group(func(r chi.Router) { r.Use(s.RateLimitAPIKeyAttempts) r.Use(s.RequireAPIKey) r.Use(s.RateLimitAPIKey) r.Use(s.RateLimitV1Process) r.Get("/products", s.handleV1ListProducts) r.Get("/products/quality", s.handleV1ListProductQuality) r.Post("/products/reset", s.handleResetProducts) // Legacy public contract (items[].ean → 200 { data: { process_id } }). // Not an alias of POST/GET /process (flat ProcessingJob). r.Post("/products/process", s.handleV1StartProcess) r.Get("/products/process/{id}", s.handleV1GetProcess) r.Get("/products/{id}", s.handleGetProduct) r.Patch("/products/{id}", s.handleUpdateProduct) // Content calendar — separate from email /api/campaigns (session UI). r.Get("/marketing/calendar", s.handleGetMarketingCalendar) r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar) // Legacy public aliases (Next.js /api/v1/campaigns). r.Get("/campaigns", s.handleV1ListCampaigns) r.Post("/campaigns/prepare", s.handleV1PrepareCampaign) r.Get("/categories", s.handleV1ListCategories) r.Post("/categories", s.handleV1CreateCategory) r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias r.Get("/categories/{id}", s.handleGetCategory) r.Patch("/categories/{id}", s.handleUpdateCategory) r.Delete("/categories/{id}", s.handleV1DeleteCategory) r.Get("/attributes", s.handleV1ListAttributes) r.Post("/attributes", s.handleV1CreateAttribute) r.Post("/attributes/create", s.handleV1CreateAttribute) // legacy alias r.Patch("/attributes/{id}", s.handleUpdateAttribute) r.Delete("/attributes/{id}", s.handleV1DeleteAttribute) r.Get("/feeds", s.handleV1ListFeeds) r.Post("/feeds", s.handleV1CreateFeed) r.Get("/feeds/{id}", s.handleV1GetFeed) r.Patch("/feeds/{id}", s.handleUpdateFeed) r.Delete("/feeds/{id}", s.handleDeleteFeed) r.Post("/feeds/{id}/sync", s.handleV1SyncFeed) r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings) r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings) r.Post("/feeds/{id}/extract-schema", s.handleExtractFeedSchema) r.Post("/feeds/{id}/sync-process-sample", s.handleSyncAndProcessSample) r.Get("/export-feeds", s.handleV1ListExportFeeds) r.Post("/export-feeds", s.handleV1CreateExportFeed) r.Get("/export-feeds/{id}", s.handleGetExportFeed) r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed) r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate) r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed) r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken) r.Post("/export-feeds/{id}/generate", s.handleV1GenerateExportFeed) r.Post("/export-feeds/{id}/export-products", s.handleExportSelectedProducts) // Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations. r.Post("/process", s.handleStartProcessingJob) r.Get("/process", s.handleV1ListProcessJobs) r.Get("/process/{id}", s.handleGetProcessingJob) r.Post("/process/{id}/cancel", s.handleCancelProcessingJob) r.Post("/process/{id}/terminate", s.handleCancelProcessingJob) r.Post("/process/{id}/retry", s.handleRetryProcessingJob) }) }) } func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request) { cid, ok := CompanyIDFromContext(r.Context()) if !ok || cid == uuid.Nil { Error(w, http.StatusUnauthorized, "unauthorized") return } limit, _ := ParseLimitOffset(r) items, err := s.Processing.ListJobs(r.Context(), cid, limit) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit}) } func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/yaml; charset=utf-8") // Public, immutable-for-process document: browsers / API clients can reuse across visits. w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") w.Header().Set("ETag", v1OpenAPIETag) w.Header().Set("Vary", "Accept-Encoding") if match := r.Header.Get("If-None-Match"); match != "" && match == v1OpenAPIETag { w.WriteHeader(http.StatusNotModified) return } if len(v1OpenAPIGzip) > 0 && acceptEncodingIncludesGzip(r.Header.Get("Accept-Encoding")) { w.Header().Set("Content-Encoding", "gzip") w.WriteHeader(http.StatusOK) _, _ = w.Write(v1OpenAPIGzip) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(v1OpenAPIYAML) }