Files
relata/internal/api/handlers.go
T

1131 lines
31 KiB
Go

package api
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/ingest"
"github.com/andydixon/relata/internal/relationships"
"github.com/andydixon/relata/internal/search"
)
// Deps bundles what the handlers need.
type Deps struct {
Cfg *config.Config
Items domain.ItemRepository
Chunks domain.ChunkRepository
Entities domain.EntityRepository
Aliases domain.AliasRepository
Claims domain.ClaimRepository
Rels domain.RelationshipRepository
Jobs domain.JobRepository
Runs domain.RunRepository
Models domain.ModelVersionRepository
Vectors domain.VectorStore
Ingest *ingest.Service
Pipeline *ingest.Pipeline
Search *search.Service
Answer *search.Answerer
Reviewer *relationships.Reviewer
Analysis *analysis.Service
Audit *audit.Recorder
Tokens *auth.TokenService
Embedder domain.Embedder
}
// Handlers implements the REST API.
type Handlers struct {
d Deps
}
// NewHandlers builds the API handlers.
func NewHandlers(d Deps) *Handlers { return &Handlers{d: d} }
// principal returns the authenticated caller, or an error.
func principal(r *http.Request) (*domain.Principal, error) {
p := PrincipalFrom(r.Context())
if p == nil || p.WorkspaceID == "" {
return nil, &APIError{Status: http.StatusUnauthorized, Code: CodeUnauthorised,
Message: "Authentication is required."}
}
return p, nil
}
// pathID extracts and validates a UUID path parameter.
//
// Validating the shape before any query means a crafted identifier never
// reaches the database layer at all.
func pathID(r *http.Request, name string) (string, error) {
v := r.PathValue(name)
if !ids.Valid(v) {
return "", &APIError{Status: http.StatusBadRequest, Code: CodeInvalidRequest,
Message: fmt.Sprintf("The %s must be a UUID.", name)}
}
return v, nil
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
// createItemRequest is the JSON body for POST /api/v1/items.
type createItemRequest struct {
Title string `json:"title"`
Content string `json:"content"`
SourceType string `json:"source_type"`
MIMEType string `json:"mime_type"`
Filename string `json:"filename"`
Metadata map[string]string `json:"metadata"`
CreatedAt string `json:"created_at"`
AllowDuplicate bool `json:"allow_duplicate"`
}
// CreateItem ingests pasted text or a JSON document.
func (h *Handlers) CreateItem(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
var req createItemRequest
if err := DecodeJSON(w, r, h.d.Cfg.Uploads.MaxUploadBytes, &req); err != nil {
WriteError(w, r, err)
return
}
if strings.TrimSpace(req.Content) == "" {
WriteError(w, r, BadRequest("The item has no content.",
FieldError{Field: "content", Message: "must not be empty"}))
return
}
ing := ingest.Request{
Title: req.Title,
Content: []byte(req.Content),
Filename: req.Filename,
DeclaredMIME: req.MIMEType,
Metadata: req.Metadata,
AllowDuplicate: req.AllowDuplicate,
}
if req.SourceType != "" {
st := domain.SourceType(req.SourceType)
if !st.Valid() {
WriteError(w, r, BadRequest("Unrecognised source type.",
FieldError{Field: "source_type", Message: "must be one of " + sourceTypeList()}))
return
}
ing.SourceType = st
}
if req.CreatedAt != "" {
t, perr := parseTime(req.CreatedAt)
if perr != nil {
WriteError(w, r, BadRequest("Unrecognised creation date.",
FieldError{Field: "created_at", Message: "must be RFC 3339, for example 2025-03-14T09:00:00Z"}))
return
}
ing.CreatedAt = &t
}
res, err := h.d.Ingest.Ingest(r.Context(), p, ing)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusAccepted, map[string]any{
"item": res.Item,
"job_id": res.JobID,
"status": "queued for processing",
})
}
// UploadItem ingests a multipart file upload.
func (h *Handlers) UploadItem(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
limit := h.d.Cfg.Uploads.MaxUploadBytes
// Allow a little headroom for multipart framing above the file limit.
r.Body = http.MaxBytesReader(w, r.Body, limit+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
WriteError(w, r, &APIError{
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: fmt.Sprintf("The upload exceeds the %d byte limit.", limit),
})
return
}
WriteError(w, r, BadRequest("The upload could not be read as a multipart form."))
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
WriteError(w, r, BadRequest("No file was supplied.",
FieldError{Field: "file", Message: "a file is required"}))
return
}
defer file.Close()
// Read at most limit+1 bytes so an oversized file is detected rather than
// silently truncated.
content, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
WriteError(w, r, BadRequest("The uploaded file could not be read."))
return
}
if int64(len(content)) > limit {
WriteError(w, r, &APIError{
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: fmt.Sprintf("The file exceeds the %d byte limit.", limit),
})
return
}
ing := ingest.Request{
Title: r.FormValue("title"),
Content: content,
Filename: header.Filename,
DeclaredMIME: header.Header.Get("Content-Type"),
Metadata: metadataFromForm(r),
AllowDuplicate: r.FormValue("allow_duplicate") == "true",
}
if st := r.FormValue("source_type"); st != "" {
if s := domain.SourceType(st); s.Valid() {
ing.SourceType = s
}
}
if ca := r.FormValue("created_at"); ca != "" {
if t, perr := parseTime(ca); perr == nil {
ing.CreatedAt = &t
}
}
res, err := h.d.Ingest.Ingest(r.Context(), p, ing)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusAccepted, map[string]any{
"item": res.Item,
"job_id": res.JobID,
"status": "queued for processing",
})
}
// ListItems returns a page of items.
func (h *Handlers) ListItems(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
f, ferr := itemFilterFromQuery(r)
if ferr != nil {
WriteError(w, r, ferr)
return
}
page := pageFromQuery(r)
items, total, err := h.d.Items.List(r.Context(), p.WorkspaceID, f, page)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"items": items,
"pagination": map[string]any{
"total": total, "limit": page.Limit, "offset": page.Offset,
"has_more": int64(page.Offset+len(items)) < total,
},
})
}
// GetItem returns one item with its derived records.
func (h *Handlers) GetItem(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
item, err := h.d.Items.Get(r.Context(), p.WorkspaceID, id)
if err != nil {
WriteError(w, r, err)
return
}
ctx := r.Context()
chunks, _ := h.d.Chunks.ListByItem(ctx, p.WorkspaceID, id)
ents, _ := h.d.Entities.ListByItem(ctx, p.WorkspaceID, id)
claims, _ := h.d.Claims.ListByItem(ctx, p.WorkspaceID, id)
outRels, inRels, _ := h.d.Rels.ListForNode(ctx, p.WorkspaceID, domain.NodeItem, id, domain.RelationshipFilter{})
runs, _ := h.d.Runs.ListByItem(ctx, p.WorkspaceID, id, 10)
WriteJSON(w, http.StatusOK, map[string]any{
"item": item,
"chunks": chunks,
"entities": ents,
"claims": claims,
"outgoing_relationships": withExplanations(outRels),
"incoming_relationships": withExplanations(inRels),
"processing_runs": runs,
})
}
// DeleteItem removes an item, or reports what would be removed.
func (h *Handlers) DeleteItem(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
// A dry run reports the blast radius and changes nothing.
if r.URL.Query().Get("dry_run") == "true" {
plan, err := h.d.Pipeline.PlanDeletion(r.Context(), p.WorkspaceID, id)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"dry_run": true,
"plan": plan,
"note": "Nothing has been deleted. Repeat this request without dry_run=true to proceed.",
})
return
}
jobID, err := h.d.Pipeline.RequestDeletion(r.Context(), p, id)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusAccepted, map[string]any{
"deleted": id,
"job_id": jobID,
"status": "The item is hidden immediately; its chunks, vectors, claims, orphaned entities and relationships are being removed.",
})
}
// ReprocessItem queues an item to be processed again.
func (h *Handlers) ReprocessItem(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
var req struct {
Reason string `json:"reason"`
}
if r.ContentLength > 0 {
if err := DecodeJSON(w, r, 8<<10, &req); err != nil {
WriteError(w, r, err)
return
}
}
jobID, err := h.d.Pipeline.RequestReprocess(r.Context(), p, id, req.Reason)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusAccepted, map[string]any{
"item_id": id, "job_id": jobID, "status": "queued for reprocessing",
})
}
// GetItemRelationships returns an item's relationships.
func (h *Handlers) GetItemRelationships(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
f, ferr := relFilterFromQuery(r)
if ferr != nil {
WriteError(w, r, ferr)
return
}
out, in, err := h.d.Rels.ListForNode(r.Context(), p.WorkspaceID, domain.NodeItem, id, f)
if err != nil {
WriteError(w, r, err)
return
}
// Resolve the titles at the far end of each relationship.
var otherIDs []string
for _, rel := range append(append([]*domain.Relationship{}, out...), in...) {
otherIDs = append(otherIDs, rel.SourceID, rel.TargetID)
}
titles := map[string]string{}
if items, err := h.d.Items.GetMany(r.Context(), p.WorkspaceID, otherIDs); err == nil {
for _, it := range items {
titles[it.ID] = it.Title
}
}
WriteJSON(w, http.StatusOK, map[string]any{
"item_id": id,
"outgoing": withTitles(withExplanations(out), titles),
"incoming": withTitles(withExplanations(in), titles),
"note": "Relationship strength is a heuristic score derived from the listed signals. " +
"It is not a calibrated probability.",
})
}
// ---------------------------------------------------------------------------
// Relationships
// ---------------------------------------------------------------------------
// CreateRelationship records a relationship a person asserted.
func (h *Handlers) CreateRelationship(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
var req struct {
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
Type string `json:"type"`
Note string `json:"note"`
}
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
WriteError(w, r, err)
return
}
rel, err := h.d.Reviewer.CreateManual(r.Context(), p, req.SourceID, req.TargetID,
domain.RelationshipType(req.Type), req.Note)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusCreated, relResponse(rel))
}
// PatchRelationship changes a relationship's type.
func (h *Handlers) PatchRelationship(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
var req struct {
Type string `json:"type"`
Note string `json:"note"`
}
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
WriteError(w, r, err)
return
}
if req.Type == "" {
WriteError(w, r, BadRequest("No change was requested.",
FieldError{Field: "type", Message: "a relationship type is required"}))
return
}
rel, err := h.d.Reviewer.ChangeType(r.Context(), p, id, domain.RelationshipType(req.Type), req.Note)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, relResponse(rel))
}
// ConfirmRelationship records a human confirmation.
func (h *Handlers) ConfirmRelationship(w http.ResponseWriter, r *http.Request) {
h.review(w, r, "confirm")
}
// RejectRelationship records a human rejection.
func (h *Handlers) RejectRelationship(w http.ResponseWriter, r *http.Request) {
h.review(w, r, "reject")
}
func (h *Handlers) review(w http.ResponseWriter, r *http.Request, action string) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
var req struct {
Note string `json:"note"`
}
if r.ContentLength > 0 {
if err := DecodeJSON(w, r, 32<<10, &req); err != nil {
WriteError(w, r, err)
return
}
}
var rel *domain.Relationship
if action == "confirm" {
rel, err = h.d.Reviewer.Confirm(r.Context(), p, id, req.Note)
} else {
rel, err = h.d.Reviewer.Reject(r.Context(), p, id, req.Note)
}
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, relResponse(rel))
}
// ListRelationships returns a page of relationships.
func (h *Handlers) ListRelationships(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
f, ferr := relFilterFromQuery(r)
if ferr != nil {
WriteError(w, r, ferr)
return
}
page := pageFromQuery(r)
rels, total, err := h.d.Rels.List(r.Context(), p.WorkspaceID, f, page)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"relationships": withExplanations(rels),
"pagination": map[string]any{
"total": total, "limit": page.Limit, "offset": page.Offset,
"has_more": int64(page.Offset+len(rels)) < total,
},
})
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
// Search runs a hybrid query.
func (h *Handlers) Search(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
q, qerr := searchQueryFromRequest(r)
if qerr != nil {
WriteError(w, r, qerr)
return
}
resp, err := h.d.Search.Search(r.Context(), p, q)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"search": resp,
"score_guide": map[string]string{
"query_relevance": "How well this item answers this particular query, relative to the best result. Not a probability.",
"semantic_similarity": "How close in meaning a matched section is to the query text. Similarity is not evidence of a factual relationship.",
"relationship_strength": "A heuristic strength derived from the listed signals, under a versioned scoring model. Not a calibrated probability.",
},
})
}
// Answer generates an answer from stored material only.
func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
if !h.d.Answer.Enabled() {
WriteError(w, r, &APIError{
Status: http.StatusServiceUnavailable, Code: CodeFeatureDisabled,
Message: "Answer generation requires a language model, which is not configured.",
})
return
}
var req struct {
Question string `json:"question"`
SourceTypes []string `json:"source_types"`
EntityIDs []string `json:"entity_ids"`
ConfirmedOnly bool `json:"confirmed_only"`
Limit int `json:"limit"`
}
if err := DecodeJSON(w, r, 64<<10, &req); err != nil {
WriteError(w, r, err)
return
}
if strings.TrimSpace(req.Question) == "" {
WriteError(w, r, BadRequest("No question was supplied.",
FieldError{Field: "question", Message: "must not be empty"}))
return
}
if len(req.Question) > 2000 {
WriteError(w, r, BadRequest("The question is too long.",
FieldError{Field: "question", Message: "must be at most 2000 characters"}))
return
}
ar := search.AnswerRequest{Question: req.Question}
ar.ConfirmedOnly = req.ConfirmedOnly
ar.Limit = req.Limit
ar.EntityIDs = filterUUIDs(req.EntityIDs)
for _, st := range req.SourceTypes {
if s := domain.SourceType(st); s.Valid() {
ar.SourceTypes = append(ar.SourceTypes, s)
}
}
ans, err := h.d.Answer.Answer(r.Context(), p, ar)
if err != nil {
WriteError(w, r, err)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditAnswerGenerated, "answer", "", "success", map[string]string{
"retrieved": strconv.Itoa(ans.Retrieved), "sufficient": strconv.FormatBool(ans.Sufficient),
})
WriteJSON(w, http.StatusOK, ans)
}
// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------
// ListEntities returns a page of entities.
func (h *Handlers) ListEntities(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
f := domain.EntityFilter{
NameContains: strings.TrimSpace(r.URL.Query().Get("q")),
}
for _, t := range splitCSV(r.URL.Query().Get("type")) {
if et := domain.EntityType(t); et.Valid() {
f.Types = append(f.Types, et)
}
}
if v := r.URL.Query().Get("item_id"); v != "" {
if !ids.Valid(v) {
WriteError(w, r, BadRequest("item_id must be a UUID."))
return
}
f.ItemID = v
}
if v := r.URL.Query().Get("min_mentions"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.MinMentions = n
}
}
page := pageFromQuery(r)
ents, total, err := h.d.Entities.List(r.Context(), p.WorkspaceID, f, page)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"entities": ents,
"pagination": map[string]any{
"total": total, "limit": page.Limit, "offset": page.Offset,
"has_more": int64(page.Offset+len(ents)) < total,
},
})
}
// GetEntity returns one entity with its aliases and citing items.
func (h *Handlers) GetEntity(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
ent, err := h.d.Entities.Get(r.Context(), p.WorkspaceID, id)
if err != nil {
WriteError(w, r, err)
return
}
aliases, _ := h.d.Aliases.ListByEntity(r.Context(), p.WorkspaceID, id)
items, _ := h.d.Items.GetMany(r.Context(), p.WorkspaceID, ent.ItemIDs)
WriteJSON(w, http.StatusOK, map[string]any{
"entity": ent,
"aliases": aliases,
"items": items,
"note": "Candidate aliases are suggestions only. Relata never merges entities on name similarity alone.",
})
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
// ListJobs returns a page of background jobs.
func (h *Handlers) ListJobs(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
var statuses []domain.JobStatus
for _, s := range splitCSV(r.URL.Query().Get("status")) {
switch domain.JobStatus(s) {
case domain.JobPending, domain.JobRunning, domain.JobSucceeded, domain.JobFailed, domain.JobCancelled:
statuses = append(statuses, domain.JobStatus(s))
}
}
var types []domain.JobType
for _, t := range splitCSV(r.URL.Query().Get("type")) {
if jt := domain.JobType(t); jt.Valid() {
types = append(types, jt)
}
}
page := pageFromQuery(r)
jobs, total, err := h.d.Jobs.List(r.Context(), p.WorkspaceID, statuses, types, page)
if err != nil {
WriteError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"jobs": jobs,
"pagination": map[string]any{
"total": total, "limit": page.Limit, "offset": page.Offset,
"has_more": int64(page.Offset+len(jobs)) < total,
},
})
}
// RetryJob returns a failed job to the queue.
func (h *Handlers) RetryJob(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
job, err := h.d.Jobs.Retry(r.Context(), p.WorkspaceID, id)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
WriteError(w, r, &APIError{
Status: http.StatusConflict, Code: CodeConflict,
Message: "That job does not exist, or is not in a state that can be retried.",
})
return
}
WriteError(w, r, err)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditJobRetried, "job", id, "success",
map[string]string{"type": string(job.Type)})
WriteJSON(w, http.StatusOK, map[string]any{"job": job})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// relView adds computed fields to a relationship for API responses.
type relView struct {
*domain.Relationship
Explanation []string `json:"explanation"`
SourceTitle string `json:"source_title,omitempty"`
TargetTitle string `json:"target_title,omitempty"`
// ScoreMeaning states plainly what the number is, in every response that
// carries one.
ScoreMeaning string `json:"score_meaning"`
}
func relResponse(rel *domain.Relationship) map[string]any {
return map[string]any{"relationship": relViewOf(rel)}
}
func relViewOf(rel *domain.Relationship) *relView {
return &relView{
Relationship: rel,
Explanation: relationships.Explain(rel),
ScoreMeaning: "A heuristic relationship strength derived from the listed signals under " +
rel.ScoreVersion + ". It is not a calibrated probability.",
}
}
func withExplanations(rels []*domain.Relationship) []*relView {
out := make([]*relView, 0, len(rels))
for _, rel := range rels {
out = append(out, relViewOf(rel))
}
return out
}
func withTitles(views []*relView, titles map[string]string) []*relView {
for _, v := range views {
v.SourceTitle = titles[v.SourceID]
v.TargetTitle = titles[v.TargetID]
}
return views
}
func pageFromQuery(r *http.Request) domain.Page {
p := domain.Page{}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
p.Limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
p.Offset = n
}
}
return p.Normalise(20, 200)
}
// itemFilterFromQuery builds a typed filter from query parameters.
//
// Unrecognised values are rejected rather than ignored, so a client that
// mistypes a source type learns about it instead of silently getting
// unfiltered results.
func itemFilterFromQuery(r *http.Request) (domain.ItemFilter, error) {
q := r.URL.Query()
f := domain.ItemFilter{TitleContains: strings.TrimSpace(q.Get("title"))}
for _, s := range splitCSV(q.Get("source_type")) {
st := domain.SourceType(s)
if !st.Valid() {
return f, BadRequest("Unrecognised source type.",
FieldError{Field: "source_type", Message: s + " is not one of " + sourceTypeList()})
}
f.SourceTypes = append(f.SourceTypes, st)
}
f.MIMETypes = append(f.MIMETypes, splitCSV(q.Get("mime_type"))...)
for _, s := range splitCSV(q.Get("status")) {
f.Statuses = append(f.Statuses, domain.ProcessingStatus(s))
}
if v := strings.TrimSpace(q.Get("content_hash")); v != "" {
if !isHex(v) {
return f, BadRequest("content_hash must be a hexadecimal digest.")
}
f.ContentHash = v
}
if v := q.Get("created_from"); v != "" {
t, err := parseTime(v)
if err != nil {
return f, BadRequest("created_from must be an RFC 3339 date.")
}
f.CreatedFrom = &t
}
if v := q.Get("created_to"); v != "" {
t, err := parseTime(v)
if err != nil {
return f, BadRequest("created_to must be an RFC 3339 date.")
}
f.CreatedTo = &t
}
return f, nil
}
func relFilterFromQuery(r *http.Request) (domain.RelationshipFilter, error) {
q := r.URL.Query()
f := domain.RelationshipFilter{Direction: q.Get("direction")}
for _, t := range splitCSV(q.Get("type")) {
rt := domain.RelationshipType(t)
if !rt.Valid() {
return f, BadRequest("Unrecognised relationship type.",
FieldError{Field: "type", Message: t + " is not a recognised relationship type"})
}
f.Types = append(f.Types, rt)
}
for _, pv := range splitCSV(q.Get("provenance")) {
p := domain.Provenance(pv)
if !p.Valid() {
return f, BadRequest("Unrecognised provenance.",
FieldError{Field: "provenance", Message: pv + " is not a recognised provenance"})
}
f.Provenances = append(f.Provenances, p)
}
for _, rs := range splitCSV(q.Get("review_status")) {
f.ReviewStatus = append(f.ReviewStatus, domain.ReviewStatus(rs))
}
if q.Get("confirmed_only") == "true" {
f.ConfirmedOnly = true
}
if v := q.Get("min_score"); v != "" {
n, err := strconv.ParseFloat(v, 64)
if err != nil || n < 0 || n > 1 {
return f, BadRequest("min_score must be a number between 0 and 1.")
}
f.MinScore = n
}
return f, nil
}
func searchQueryFromRequest(r *http.Request) (search.Query, error) {
q := r.URL.Query()
out := search.Query{
Text: q.Get("q"),
ConfirmedOnly: q.Get("confirmed_only") == "true",
Expand: q.Get("expand") != "false",
}
if len(out.Text) > 2000 {
return out, BadRequest("The query is too long.",
FieldError{Field: "q", Message: "must be at most 2000 characters"})
}
for _, s := range splitCSV(q.Get("source_type")) {
st := domain.SourceType(s)
if !st.Valid() {
return out, BadRequest("Unrecognised source type.",
FieldError{Field: "source_type", Message: s + " is not one of " + sourceTypeList()})
}
out.SourceTypes = append(out.SourceTypes, st)
}
out.MIMETypes = append(out.MIMETypes, splitCSV(q.Get("mime_type"))...)
for _, t := range splitCSV(q.Get("relationship_type")) {
rt := domain.RelationshipType(t)
if !rt.Valid() {
return out, BadRequest("Unrecognised relationship type.",
FieldError{Field: "relationship_type", Message: t + " is not recognised"})
}
out.RelTypes = append(out.RelTypes, rt)
}
out.EntityIDs = filterUUIDs(splitCSV(q.Get("entity_id")))
if v := q.Get("created_from"); v != "" {
t, err := parseTime(v)
if err != nil {
return out, BadRequest("created_from must be an RFC 3339 date.")
}
out.CreatedFrom = &t
}
if v := q.Get("created_to"); v != "" {
t, err := parseTime(v)
if err != nil {
return out, BadRequest("created_to must be an RFC 3339 date.")
}
out.CreatedTo = &t
}
if v := q.Get("min_relationship_strength"); v != "" {
n, err := strconv.ParseFloat(v, 64)
if err != nil || n < 0 || n > 1 {
return out, BadRequest("min_relationship_strength must be between 0 and 1.")
}
out.MinRelStrength = n
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
out.Limit = n
}
}
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
if n > 0 {
out.Offset = n
}
}
}
return out, nil
}
// parseTime accepts RFC 3339 or a plain date.
func parseTime(v string) (time.Time, error) {
v = strings.TrimSpace(v)
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t.UTC(), nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return t.UTC(), nil
}
return time.Time{}, fmt.Errorf("unrecognised time %q", v)
}
func splitCSV(v string) []string {
if strings.TrimSpace(v) == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
func filterUUIDs(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if ids.Valid(v) {
out = append(out, v)
}
}
return out
}
func isHex(s string) bool {
if len(s) == 0 || len(s) > 128 {
return false
}
for _, r := range s {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
func sourceTypeList() string {
names := make([]string, 0, len(domain.ValidSourceTypes))
for _, s := range domain.ValidSourceTypes {
names = append(names, string(s))
}
return strings.Join(names, ", ")
}
// metadataFromForm reads metadata[key]=value pairs from a multipart form.
func metadataFromForm(r *http.Request) map[string]string {
out := map[string]string{}
for k, vs := range r.Form {
if !strings.HasPrefix(k, "metadata[") || !strings.HasSuffix(k, "]") || len(vs) == 0 {
continue
}
key := strings.TrimSuffix(strings.TrimPrefix(k, "metadata["), "]")
if key != "" {
out[key] = vs[0]
}
}
// Also accept parallel metadata_key / metadata_value arrays from the form.
keys := r.Form["metadata_key"]
vals := r.Form["metadata_value"]
for i := range keys {
if i < len(vals) && strings.TrimSpace(keys[i]) != "" {
out[strings.TrimSpace(keys[i])] = vals[i]
}
}
if len(out) == 0 {
return nil
}
return out
}
// ---------------------------------------------------------------------------
// Entity aliases
// ---------------------------------------------------------------------------
// ConfirmAlias records that a person agrees two names refer to the same thing.
//
// Only a confirmed alias contributes evidence to relationship discovery. Until
// someone decides, a candidate is a suggestion and is weighted at nothing.
func (h *Handlers) ConfirmAlias(w http.ResponseWriter, r *http.Request) {
h.reviewAlias(w, r, domain.AliasConfirmed)
}
// RejectAlias records that a person judged two similar names to be different
// things. The proposal is retained, so it is not offered again as new.
func (h *Handlers) RejectAlias(w http.ResponseWriter, r *http.Request) {
h.reviewAlias(w, r, domain.AliasRejected)
}
func (h *Handlers) reviewAlias(w http.ResponseWriter, r *http.Request, status domain.AliasStatus) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
if err := h.d.Aliases.SetStatus(r.Context(), p.WorkspaceID, id, status); err != nil {
WriteError(w, r, err)
return
}
h.d.Audit.Record(r.Context(), p, "entity_alias."+string(status), "entity_alias", id, "success", nil)
WriteJSON(w, http.StatusOK, map[string]any{
"alias_id": id,
"status": status,
"note": "Entities are still stored separately. A confirmed alias contributes evidence to " +
"relationship discovery; it does not merge the two records.",
})
}