Files
nexarch/dms/internal/searchapi/handler.go
T
patrickandClaude Sonnet 5 2a49782510
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
SRC-08: facetten- & filter-api
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 01:01:20 +02:00

185 lines
5.2 KiB
Go

package searchapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"html"
"net/http"
"strconv"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
)
type PermissionChecker interface {
CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error)
}
type Server struct {
index *sql.DB
permissions PermissionChecker
}
func NewServer(index *sql.DB, permissions PermissionChecker) *Server {
return &Server{index: index, permissions: permissions}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /search", s.handleSearch)
}
type hitJSON struct {
DocumentID string `json:"document_id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
DocumentType string `json:"document_type"`
Score int `json:"score"`
UpdatedAt int64 `json:"updated_at"`
// SnippetHTML enthaelt nur <mark>; alle Fremdtexte sind HTML-escaped.
SnippetHTML string `json:"snippet_html"`
}
type responseJSON struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
TookMS int64 `json:"took_ms"`
Hits []hitJSON `json:"hits"`
// Facets nur bei gesetztem Parameter "facets"; Schluessel wie dort angegeben.
Facets map[string][]facetValueJSON `json:"facets,omitempty"`
}
type facetValueJSON struct {
Value string `json:"value"`
Count int `json:"count"`
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func intParam(r *http.Request, name string, def int) (int, error) {
v := r.URL.Query().Get(name)
if v == "" {
return def, nil
}
return strconv.Atoi(v)
}
func renderSnippet(sn searchindex.Snippet) string {
var b strings.Builder
for _, p := range sn {
if p.Match {
b.WriteString("<mark>" + html.EscapeString(p.Text) + "</mark>")
} else {
b.WriteString(html.EscapeString(p.Text))
}
}
return b.String()
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
start := time.Now()
q := r.URL.Query()
caller := strings.TrimSpace(q.Get("caller"))
if caller == "" {
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"caller\"")
return
}
term := strings.TrimSpace(q.Get("q"))
if term == "" {
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"q\"")
return
}
limit, err := intParam(r, "limit", searchindex.DefaultPageSize)
if err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger parameter \"limit\"")
return
}
offset, err := intParam(r, "offset", 0)
if err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger parameter \"offset\"")
return
}
sortVal, err := searchindex.ParseSort(q.Get("sort"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
filters := []searchindex.Filter{{Field: searchindex.FilterCreatedBy, Value: caller}}
for _, name := range []string{"folder_path", "created_by"} {
if v := q.Get(name); v != "" {
field, err := searchindex.ParseFilterField(name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
filters = append(filters, searchindex.Filter{Field: field, Value: v})
}
}
facetFilters, specs, err := parseFacetParams(q)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
searchReq := searchindex.SearchRequest{
Term: term, Filters: filters, Sort: sortVal, Limit: limit, Offset: offset, Facet: facetFilters,
}
page, err := searchindex.SearchPage(r.Context(), s.index, searchReq)
if err != nil {
if errors.Is(err, searchindex.ErrInvalidRequest) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := responseJSON{Total: page.Total, Limit: limit, Offset: offset, Hits: []hitJSON{}}
if resp.Limit <= 0 {
resp.Limit = searchindex.DefaultPageSize
}
for _, h := range page.Hits {
allowed, err := s.permissions.CanAccessDocument(r.Context(), caller, h.DocumentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !allowed {
continue
}
resp.Hits = append(resp.Hits, hitJSON{
DocumentID: h.DocumentID, Title: h.Title, FolderPath: h.FolderPath, DocumentType: h.DocumentType,
Score: h.Score, UpdatedAt: h.UpdatedAt, SnippetHTML: renderSnippet(h.Snippet),
})
}
if len(specs) > 0 {
results, err := searchindex.Facets(r.Context(), s.index, searchReq, specs)
if err != nil {
if errors.Is(err, searchindex.ErrInvalidRequest) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.Facets = map[string][]facetValueJSON{}
for _, fr := range results {
vals := make([]facetValueJSON, 0, len(fr.Values))
for _, v := range fr.Values {
vals = append(vals, facetValueJSON{Value: v.Value, Count: v.Count})
}
resp.Facets[facetName(fr.Spec)] = vals
}
}
resp.TookMS = time.Since(start).Milliseconds()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}