184 lines
4.8 KiB
Go
184 lines
4.8 KiB
Go
// Package graph implements NIP-XX Graph Query protocol support.
|
|
// It provides types and functions for parsing and validating graph traversal queries.
|
|
package graph
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
|
|
"git.mleku.dev/mleku/nostr/encoders/filter"
|
|
)
|
|
|
|
// Query represents a graph traversal query from a _graph filter extension.
|
|
type Query struct {
|
|
// Method is the traversal method: "follows", "followers", "mentions", "thread"
|
|
Method string `json:"method"`
|
|
|
|
// Seed is the starting point for traversal (pubkey hex or event ID hex)
|
|
Seed string `json:"seed"`
|
|
|
|
// Depth is the maximum traversal depth (1-16, default: 1)
|
|
Depth int `json:"depth,omitempty"`
|
|
|
|
// InboundRefs specifies which inbound references to collect
|
|
// (events that reference discovered events via e-tags)
|
|
InboundRefs []RefSpec `json:"inbound_refs,omitempty"`
|
|
|
|
// OutboundRefs specifies which outbound references to collect
|
|
// (events referenced by discovered events via e-tags)
|
|
OutboundRefs []RefSpec `json:"outbound_refs,omitempty"`
|
|
}
|
|
|
|
// RefSpec specifies which event references to include in results.
|
|
type RefSpec struct {
|
|
// Kinds is the list of event kinds to match (OR semantics within this spec)
|
|
Kinds []int `json:"kinds"`
|
|
|
|
// FromDepth specifies the minimum depth at which to collect refs (default: 0)
|
|
// 0 = include refs from seed itself
|
|
// 1 = start from first-hop connections
|
|
FromDepth int `json:"from_depth,omitempty"`
|
|
}
|
|
|
|
// Validation errors
|
|
var (
|
|
ErrMissingMethod = errors.New("_graph.method is required")
|
|
ErrInvalidMethod = errors.New("_graph.method must be one of: follows, followers, mentions, thread")
|
|
ErrMissingSeed = errors.New("_graph.seed is required")
|
|
ErrInvalidSeed = errors.New("_graph.seed must be a 64-character hex string")
|
|
ErrDepthTooHigh = errors.New("_graph.depth cannot exceed 16")
|
|
ErrEmptyRefSpecKinds = errors.New("ref spec kinds array cannot be empty")
|
|
)
|
|
|
|
// Valid method names
|
|
var validMethods = map[string]bool{
|
|
"follows": true,
|
|
"followers": true,
|
|
"mentions": true,
|
|
"thread": true,
|
|
}
|
|
|
|
// Validate checks the query for correctness and applies defaults.
|
|
func (q *Query) Validate() error {
|
|
// Method is required
|
|
if q.Method == "" {
|
|
return ErrMissingMethod
|
|
}
|
|
if !validMethods[q.Method] {
|
|
return ErrInvalidMethod
|
|
}
|
|
|
|
// Seed is required
|
|
if q.Seed == "" {
|
|
return ErrMissingSeed
|
|
}
|
|
if len(q.Seed) != 64 {
|
|
return ErrInvalidSeed
|
|
}
|
|
// Validate hex characters
|
|
for _, c := range q.Seed {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
return ErrInvalidSeed
|
|
}
|
|
}
|
|
|
|
// Apply depth defaults and limits
|
|
if q.Depth < 1 {
|
|
q.Depth = 1
|
|
}
|
|
if q.Depth > 16 {
|
|
return ErrDepthTooHigh
|
|
}
|
|
|
|
// Validate ref specs
|
|
for _, rs := range q.InboundRefs {
|
|
if len(rs.Kinds) == 0 {
|
|
return ErrEmptyRefSpecKinds
|
|
}
|
|
}
|
|
for _, rs := range q.OutboundRefs {
|
|
if len(rs.Kinds) == 0 {
|
|
return ErrEmptyRefSpecKinds
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HasInboundRefs returns true if the query includes inbound reference collection.
|
|
func (q *Query) HasInboundRefs() bool {
|
|
return len(q.InboundRefs) > 0
|
|
}
|
|
|
|
// HasOutboundRefs returns true if the query includes outbound reference collection.
|
|
func (q *Query) HasOutboundRefs() bool {
|
|
return len(q.OutboundRefs) > 0
|
|
}
|
|
|
|
// HasRefs returns true if the query includes any reference collection.
|
|
func (q *Query) HasRefs() bool {
|
|
return q.HasInboundRefs() || q.HasOutboundRefs()
|
|
}
|
|
|
|
// InboundKindsAtDepth returns a set of kinds that should be collected at the given depth.
|
|
// It aggregates all RefSpecs where from_depth <= depth.
|
|
func (q *Query) InboundKindsAtDepth(depth int) map[int]bool {
|
|
kinds := make(map[int]bool)
|
|
for _, rs := range q.InboundRefs {
|
|
if rs.FromDepth <= depth {
|
|
for _, k := range rs.Kinds {
|
|
kinds[k] = true
|
|
}
|
|
}
|
|
}
|
|
return kinds
|
|
}
|
|
|
|
// OutboundKindsAtDepth returns a set of kinds that should be collected at the given depth.
|
|
func (q *Query) OutboundKindsAtDepth(depth int) map[int]bool {
|
|
kinds := make(map[int]bool)
|
|
for _, rs := range q.OutboundRefs {
|
|
if rs.FromDepth <= depth {
|
|
for _, k := range rs.Kinds {
|
|
kinds[k] = true
|
|
}
|
|
}
|
|
}
|
|
return kinds
|
|
}
|
|
|
|
// ExtractFromFilter checks if a filter has a _graph extension and parses it.
|
|
// Returns nil if no _graph field is present.
|
|
// Returns an error if _graph is present but invalid.
|
|
func ExtractFromFilter(f *filter.F) (*Query, error) {
|
|
if f == nil || f.Extra == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
raw, ok := f.Extra["_graph"]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
var q Query
|
|
if err := json.Unmarshal(raw, &q); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := q.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &q, nil
|
|
}
|
|
|
|
// IsGraphQuery returns true if the filter contains a _graph extension.
|
|
// This is a quick check that doesn't parse the full query.
|
|
func IsGraphQuery(f *filter.F) bool {
|
|
if f == nil || f.Extra == nil {
|
|
return false
|
|
}
|
|
_, ok := f.Extra["_graph"]
|
|
return ok
|
|
}
|