Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user