Files

258 lines
7.0 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.quad4.io/quad4-software/osv-server/internal/config"
"git.quad4.io/quad4-software/osv-server/internal/osv"
"git.quad4.io/quad4-software/osv-server/internal/query"
)
// Server represents the HTTP API server.
type Server struct {
router *chi.Mux
osv *osv.Manager
enableDownloadEndpoint bool
silent bool
}
// NewServer creates a new API server instance.
func NewServer(osvManager *osv.Manager, cfg *config.Config) *Server {
s := &Server{
router: chi.NewRouter(),
osv: osvManager,
enableDownloadEndpoint: cfg.EnableDownloadEndpoint,
silent: os.Getenv("OSV_SILENT") == "true",
}
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
if !s.silent {
s.router.Use(middleware.Logger)
}
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Get(RouteHealth, s.handleHealth)
s.router.Get(RouteStats, s.handleStats)
s.router.Get(RouteQuery, s.handleQuery)
s.router.Post(RouteQuery, s.handleQueryPost)
s.router.Get(RouteQueryBatch, s.handleQueryBatch)
s.router.Post(RouteQueryBatch, s.handleQueryBatchPost)
if s.enableDownloadEndpoint {
s.router.Get(RouteDownload, s.handleDownload)
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
status := StatusHealthy
if !s.osv.DataExists() {
status = StatusInitializing
}
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusOK)
// #nosec G104 - http.ResponseWriter.Write errors are rare and non-critical for health endpoint
_, _ = w.Write([]byte(`{"status":"` + status + `"}`))
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
stats := s.osv.GetStats()
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusOK)
json := `{
"last_update": "` + stats.LastUpdate.Format(time.RFC3339) + `",
"last_update_error": "` + stats.LastUpdateError + `",
"total_downloads": ` + fmt.Sprintf("%d", stats.TotalDownloads) + `,
"failed_downloads": ` + fmt.Sprintf("%d", stats.FailedDownloads) + `,
"update_in_progress": ` + fmt.Sprintf("%t", stats.UpdateInProgress)
if stats.DownloadProgress != nil {
json += `,
"download_progress": {
"percent": ` + fmt.Sprintf("%.2f", stats.DownloadProgress.Percent) + `,
"speed": "` + stats.DownloadProgress.Speed + `",
"downloaded": ` + fmt.Sprintf("%d", stats.DownloadProgress.Downloaded) + `,
"total": ` + fmt.Sprintf("%d", stats.DownloadProgress.Total) + `
}`
}
json += `
}`
// #nosec G104 - http.ResponseWriter.Write errors are rare and non-critical for stats endpoint
_, _ = w.Write([]byte(json))
}
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
if !s.osv.DataExists() {
s.writeDownloadingResponse(w)
return
}
dataPath := s.osv.GetDataPath()
http.ServeFile(w, r, dataPath)
}
func (s *Server) handleQueryPost(w http.ResponseWriter, r *http.Request) {
if !s.osv.DataExists() {
s.writeDownloadingResponse(w)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"Failed to read request body"}`, http.StatusBadRequest)
return
}
var req query.QueryRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"Invalid JSON request"}`, http.StatusBadRequest)
return
}
idx := s.osv.GetIndexer()
resp, err := query.QueryDatabase(idx, &req)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusOK)
respJSON, err := json.Marshal(resp)
if err != nil {
http.Error(w, `{"error":"Failed to marshal response"}`, http.StatusInternalServerError)
return
}
// #nosec G104 - http.ResponseWriter.Write errors are rare
_, _ = w.Write(respJSON)
}
func (s *Server) handleQueryBatch(w http.ResponseWriter, r *http.Request) {
if !s.osv.DataExists() {
s.writeDownloadingResponse(w)
return
}
dataPath := s.osv.GetDataPath()
http.ServeFile(w, r, dataPath)
}
func (s *Server) handleQueryBatchPost(w http.ResponseWriter, r *http.Request) {
if !s.osv.DataExists() {
s.writeDownloadingResponse(w)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"Failed to read request body"}`, http.StatusBadRequest)
return
}
var batchReq struct {
Queries []query.QueryRequest `json:"queries"`
}
if err := json.Unmarshal(body, &batchReq); err != nil {
http.Error(w, `{"error":"Invalid JSON request"}`, http.StatusBadRequest)
return
}
idx := s.osv.GetIndexer()
var results []query.QueryResponse
for _, req := range batchReq.Queries {
resp, err := query.QueryDatabase(idx, &req)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"Query failed: %s"}`, err.Error()), http.StatusInternalServerError)
return
}
results = append(results, *resp)
}
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusOK)
response := struct {
Results []query.QueryResponse `json:"results"`
}{Results: results}
respJSON, err := json.Marshal(response)
if err != nil {
http.Error(w, `{"error":"Failed to marshal response"}`, http.StatusInternalServerError)
return
}
// #nosec G104 - http.ResponseWriter.Write errors are rare
_, _ = w.Write(respJSON)
}
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
if !s.osv.DataExists() {
s.writeDownloadingResponse(w)
return
}
stats := s.osv.GetStats()
if stats.UpdateInProgress {
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusServiceUnavailable)
// #nosec G104 - http.ResponseWriter.Write errors are rare
_, _ = w.Write([]byte(`{"error":"Database update in progress, please try again later"}`))
return
}
dataPath := s.osv.GetDataPath()
meta, err := s.osv.GetMetadata()
if err == nil && meta != nil {
w.Header().Set("X-OSV-Hash", meta.Hash)
w.Header().Set("X-OSV-Last-Modified", meta.LastModified.Format(time.RFC3339))
w.Header().Set("X-OSV-Size", fmt.Sprintf("%d", meta.Size))
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=osv-database.zip")
http.ServeFile(w, r, dataPath)
}
func (s *Server) writeDownloadingResponse(w http.ResponseWriter) {
stats := s.osv.GetStats()
w.Header().Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusServiceUnavailable)
var message string
if stats.DownloadProgress != nil {
message = fmt.Sprintf(`"message":"Initial database download in progress: %.2f%% (%s/s)"`,
stats.DownloadProgress.Percent, stats.DownloadProgress.Speed)
} else {
message = `"message":"Initial database download in progress"`
}
response := fmt.Sprintf(`{"error":"Data not available yet",%s,"status":"downloading"}`, message)
// #nosec G104 - http.ResponseWriter.Write errors are rare
_, _ = w.Write([]byte(response))
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}