637 lines
19 KiB
Go
637 lines
19 KiB
Go
package mentions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/entityrefs"
|
|
apiv1 "github.com/kandev/kandev/pkg/api/v1"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type registeredProvider struct {
|
|
descriptor ProviderDescriptor
|
|
provider MentionProvider
|
|
authorizer ReferenceAuthorizer
|
|
owner string
|
|
}
|
|
|
|
// Registry stores provider descriptors independently of their implementation type.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
providers []registeredProvider
|
|
sources map[string]struct{}
|
|
providerKinds map[string]struct{}
|
|
referenceAuthorizers map[string]ReferenceAuthorizer
|
|
refreshers []SourceRefresher
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
sources: make(map[string]struct{}),
|
|
providerKinds: make(map[string]struct{}),
|
|
referenceAuthorizers: make(map[string]ReferenceAuthorizer),
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Register(provider MentionProvider) error {
|
|
registered, err := newRegisteredProvider("", provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if err := r.ensureAvailable(registered); err != nil {
|
|
return err
|
|
}
|
|
r.addRegistered(registered)
|
|
return nil
|
|
}
|
|
|
|
// ReplaceOwner atomically replaces every dynamic provider owned by owner.
|
|
// The registry snapshot sees either the complete previous set or the complete
|
|
// replacement, never a partially refreshed plugin manifest.
|
|
func (r *Registry) ReplaceOwner(owner string, providers ...MentionProvider) error {
|
|
owner = strings.TrimSpace(owner)
|
|
if !validIdentity(owner) {
|
|
return fmt.Errorf("%w: owner is required", ErrInvalidDescriptor)
|
|
}
|
|
replacement, err := newRegisteredProviders(owner, providers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
retained := providersWithoutOwner(r.providers, owner)
|
|
sources, providerKinds, authorizers := providerIndexes(retained)
|
|
if err := ensureRegisteredProvidersAvailable(replacement, sources, providerKinds); err != nil {
|
|
return err
|
|
}
|
|
for _, registered := range replacement {
|
|
providerKey := referenceProviderKey(registered.descriptor.Provider, registered.descriptor.Kind)
|
|
sources[registered.descriptor.Source] = struct{}{}
|
|
providerKinds[providerKey] = struct{}{}
|
|
authorizers[providerKey] = registered.authorizer
|
|
}
|
|
retained = append(retained, replacement...)
|
|
r.providers = retained
|
|
r.sources = sources
|
|
r.providerKinds = providerKinds
|
|
r.referenceAuthorizers = authorizers
|
|
return nil
|
|
}
|
|
|
|
// UnregisterOwner removes every dynamic provider owned by owner.
|
|
func (r *Registry) UnregisterOwner(owner string) error {
|
|
return r.ReplaceOwner(owner)
|
|
}
|
|
|
|
// RegisterSourceRefresher adds a dynamic source refresher. Registry reads run
|
|
// refreshers before taking their lock-protected snapshots or authorizer lookups.
|
|
func (r *Registry) RegisterSourceRefresher(refresher SourceRefresher) {
|
|
if refresher == nil {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.refreshers = append(r.refreshers, refresher)
|
|
}
|
|
|
|
func (r *Registry) refreshDynamicSources() {
|
|
r.mu.RLock()
|
|
refreshers := append([]SourceRefresher(nil), r.refreshers...)
|
|
r.mu.RUnlock()
|
|
for _, refresher := range refreshers {
|
|
refresher.RefreshMentionSources()
|
|
}
|
|
}
|
|
|
|
func newRegisteredProviders(owner string, providers []MentionProvider) ([]registeredProvider, error) {
|
|
registered := make([]registeredProvider, 0, len(providers))
|
|
for _, provider := range providers {
|
|
entry, err := newRegisteredProvider(owner, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registered = append(registered, entry)
|
|
}
|
|
return registered, nil
|
|
}
|
|
|
|
func newRegisteredProvider(owner string, provider MentionProvider) (registeredProvider, error) {
|
|
if provider == nil {
|
|
return registeredProvider{}, fmt.Errorf("%w: provider is nil", ErrInvalidDescriptor)
|
|
}
|
|
descriptor, err := normalizeDescriptor(provider.Descriptor())
|
|
if err != nil {
|
|
return registeredProvider{}, err
|
|
}
|
|
authorizer, ok := provider.(ReferenceAuthorizer)
|
|
if !ok || authorizer == nil {
|
|
return registeredProvider{}, ErrMissingAuthorizer
|
|
}
|
|
return registeredProvider{descriptor: descriptor, provider: provider, authorizer: authorizer, owner: owner}, nil
|
|
}
|
|
|
|
func (r *Registry) ensureAvailable(registered registeredProvider) error {
|
|
if r.sources == nil {
|
|
r.sources = make(map[string]struct{})
|
|
}
|
|
if r.providerKinds == nil {
|
|
r.providerKinds = make(map[string]struct{})
|
|
}
|
|
if _, exists := r.sources[registered.descriptor.Source]; exists {
|
|
return fmt.Errorf("%w: %s", ErrDuplicateSource, registered.descriptor.Source)
|
|
}
|
|
providerKey := referenceProviderKey(registered.descriptor.Provider, registered.descriptor.Kind)
|
|
if _, exists := r.providerKinds[providerKey]; exists {
|
|
return fmt.Errorf("%w: %s/%s", ErrDuplicateProvider, registered.descriptor.Provider, registered.descriptor.Kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) addRegistered(registered registeredProvider) {
|
|
if r.referenceAuthorizers == nil {
|
|
r.referenceAuthorizers = make(map[string]ReferenceAuthorizer)
|
|
}
|
|
providerKey := referenceProviderKey(registered.descriptor.Provider, registered.descriptor.Kind)
|
|
r.providers = append(r.providers, registered)
|
|
r.sources[registered.descriptor.Source] = struct{}{}
|
|
r.providerKinds[providerKey] = struct{}{}
|
|
r.referenceAuthorizers[providerKey] = registered.authorizer
|
|
}
|
|
|
|
func providersWithoutOwner(providers []registeredProvider, owner string) []registeredProvider {
|
|
retained := make([]registeredProvider, 0, len(providers))
|
|
for _, provider := range providers {
|
|
if provider.owner != owner {
|
|
retained = append(retained, provider)
|
|
}
|
|
}
|
|
return retained
|
|
}
|
|
|
|
func providerIndexes(providers []registeredProvider) (
|
|
map[string]struct{}, map[string]struct{}, map[string]ReferenceAuthorizer,
|
|
) {
|
|
sources := make(map[string]struct{}, len(providers))
|
|
providerKinds := make(map[string]struct{}, len(providers))
|
|
authorizers := make(map[string]ReferenceAuthorizer, len(providers))
|
|
for _, provider := range providers {
|
|
providerKey := referenceProviderKey(provider.descriptor.Provider, provider.descriptor.Kind)
|
|
sources[provider.descriptor.Source] = struct{}{}
|
|
providerKinds[providerKey] = struct{}{}
|
|
authorizers[providerKey] = provider.authorizer
|
|
}
|
|
return sources, providerKinds, authorizers
|
|
}
|
|
|
|
func ensureRegisteredProvidersAvailable(
|
|
providers []registeredProvider,
|
|
sources map[string]struct{},
|
|
providerKinds map[string]struct{},
|
|
) error {
|
|
for _, provider := range providers {
|
|
if _, exists := sources[provider.descriptor.Source]; exists {
|
|
return fmt.Errorf("%w: %s", ErrDuplicateSource, provider.descriptor.Source)
|
|
}
|
|
providerKey := referenceProviderKey(provider.descriptor.Provider, provider.descriptor.Kind)
|
|
if _, exists := providerKinds[providerKey]; exists {
|
|
return fmt.Errorf("%w: %s/%s", ErrDuplicateProvider, provider.descriptor.Provider, provider.descriptor.Kind)
|
|
}
|
|
// Record accepted replacements as we go. This retains exact duplicate
|
|
// detection while making large plugin manifests linear rather than
|
|
// rescanning every preceding descriptor.
|
|
sources[provider.descriptor.Source] = struct{}{}
|
|
providerKinds[providerKey] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func referenceProviderKey(provider, kind string) string {
|
|
return provider + "\x00" + kind
|
|
}
|
|
|
|
// AuthorizeReference dispatches to the provider registered for the normalized identity.
|
|
func (r *Registry) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error {
|
|
r.refreshDynamicSources()
|
|
key := referenceProviderKey(request.Reference.Provider, request.Reference.Kind)
|
|
r.mu.RLock()
|
|
authorizer, ok := r.referenceAuthorizers[key]
|
|
r.mu.RUnlock()
|
|
if !ok {
|
|
return ErrReferenceProviderUnavailable
|
|
}
|
|
return authorizer.AuthorizeReference(ctx, request)
|
|
}
|
|
|
|
// AuthorizeForWorkspace satisfies entityrefs.WorkspaceAuthorizer for message submission.
|
|
func (r *Registry) AuthorizeForWorkspace(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
reference apiv1.EntityReference,
|
|
) error {
|
|
return r.AuthorizeReference(ctx, ReferenceAuthorizationRequest{
|
|
WorkspaceID: workspaceID,
|
|
Purpose: ReferencePurposeSubmission,
|
|
Reference: reference,
|
|
})
|
|
}
|
|
|
|
var descriptorIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`)
|
|
|
|
func normalizeDescriptor(descriptor ProviderDescriptor) (ProviderDescriptor, error) {
|
|
descriptor.Source = strings.TrimSpace(descriptor.Source)
|
|
descriptor.Provider = strings.TrimSpace(descriptor.Provider)
|
|
descriptor.Kind = strings.TrimSpace(descriptor.Kind)
|
|
if !descriptorIDPattern.MatchString(descriptor.Source) ||
|
|
!descriptorIDPattern.MatchString(descriptor.Provider) ||
|
|
!descriptorIDPattern.MatchString(descriptor.Kind) {
|
|
return ProviderDescriptor{}, fmt.Errorf("%w: source, provider, and kind must be namespaced lowercase identifiers", ErrInvalidDescriptor)
|
|
}
|
|
descriptor.DisplayName = strings.TrimSpace(descriptor.DisplayName)
|
|
descriptor.KindLabel = strings.TrimSpace(descriptor.KindLabel)
|
|
if !validDescriptorLabel(descriptor.DisplayName) || !validDescriptorLabel(descriptor.KindLabel) {
|
|
return ProviderDescriptor{}, fmt.Errorf("%w: labels must be valid UTF-8 and at most 100 characters", ErrInvalidDescriptor)
|
|
}
|
|
if descriptor.DisplayName == "" {
|
|
descriptor.DisplayName = descriptor.Provider
|
|
}
|
|
if descriptor.KindLabel == "" {
|
|
descriptor.KindLabel = mentionLabelWorkItem
|
|
}
|
|
return descriptor, nil
|
|
}
|
|
|
|
func validDescriptorLabel(label string) bool {
|
|
return utf8.ValidString(label) && utf8.RuneCountInString(label) <= 100
|
|
}
|
|
|
|
func (r *Registry) snapshot() []registeredProvider {
|
|
r.refreshDynamicSources()
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
providers := append([]registeredProvider(nil), r.providers...)
|
|
sort.SliceStable(providers, func(i, j int) bool {
|
|
if providers[i].descriptor.Order == providers[j].descriptor.Order {
|
|
return providers[i].descriptor.Source < providers[j].descriptor.Source
|
|
}
|
|
return providers[i].descriptor.Order < providers[j].descriptor.Order
|
|
})
|
|
return providers
|
|
}
|
|
|
|
// Service validates and aggregates every registered provider.
|
|
type Service struct {
|
|
registry *Registry
|
|
log *logger.Logger
|
|
providerTimeout time.Duration
|
|
maxConcurrency int
|
|
providerSlots chan struct{}
|
|
providerSlotMu sync.Mutex
|
|
providerInFlight map[string]chan struct{}
|
|
}
|
|
|
|
const (
|
|
defaultProviderTimeout = 1500 * time.Millisecond
|
|
defaultMaxConcurrency = 4
|
|
)
|
|
|
|
// Option configures aggregate search execution.
|
|
type Option func(*Service)
|
|
|
|
func WithProviderTimeout(timeout time.Duration) Option {
|
|
return func(service *Service) {
|
|
if timeout > 0 {
|
|
service.providerTimeout = timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithMaxConcurrency(limit int) Option {
|
|
return func(service *Service) {
|
|
if limit > 0 {
|
|
service.maxConcurrency = limit
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithLogger(log *logger.Logger) Option {
|
|
return func(service *Service) {
|
|
service.log = log
|
|
}
|
|
}
|
|
|
|
func NewService(registry *Registry, options ...Option) *Service {
|
|
service := &Service{
|
|
registry: registry,
|
|
providerTimeout: defaultProviderTimeout,
|
|
maxConcurrency: defaultMaxConcurrency,
|
|
}
|
|
for _, option := range options {
|
|
option(service)
|
|
}
|
|
if service.maxConcurrency <= 0 {
|
|
service.maxConcurrency = defaultMaxConcurrency
|
|
}
|
|
service.providerSlots = make(chan struct{}, service.maxConcurrency)
|
|
service.providerInFlight = make(map[string]chan struct{})
|
|
return service
|
|
}
|
|
|
|
func (s *Service) Search(ctx context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) {
|
|
request, err := normalizeRequest(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
response := &apiv1.MentionSearchResponse{
|
|
Query: request.Query,
|
|
Groups: []apiv1.MentionGroup{},
|
|
}
|
|
providers := s.providers()
|
|
if len(providers) == 0 {
|
|
return response, nil
|
|
}
|
|
response.Groups = s.searchProviders(ctx, providers, request)
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *Service) providers() []registeredProvider {
|
|
if s.registry == nil {
|
|
return nil
|
|
}
|
|
return s.registry.snapshot()
|
|
}
|
|
|
|
func (s *Service) searchProviders(
|
|
ctx context.Context,
|
|
providers []registeredProvider,
|
|
request SearchRequest,
|
|
) []apiv1.MentionGroup {
|
|
groups := make([]apiv1.MentionGroup, len(providers))
|
|
var wg sync.WaitGroup
|
|
for index := range providers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
groups[index] = s.searchProvider(ctx, providers[index], request)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return groups
|
|
}
|
|
|
|
func failedGroup(descriptor ProviderDescriptor, status Status) apiv1.MentionGroup {
|
|
group := groupFromDescriptor(descriptor)
|
|
group.Status = status
|
|
return group
|
|
}
|
|
|
|
func (s *Service) searchProvider(ctx context.Context, registered registeredProvider, request SearchRequest) apiv1.MentionGroup {
|
|
waitCtx, cancelWait := context.WithTimeout(ctx, s.providerTimeout)
|
|
providerSlot := s.slotForProvider(registered.descriptor.Source)
|
|
select {
|
|
case providerSlot <- struct{}{}:
|
|
cancelWait()
|
|
case <-waitCtx.Done():
|
|
cancelWait()
|
|
return failedGroup(registered.descriptor, StatusTimeout)
|
|
}
|
|
select {
|
|
case s.providerSlots <- struct{}{}:
|
|
case <-ctx.Done():
|
|
<-providerSlot
|
|
return failedGroup(registered.descriptor, StatusTimeout)
|
|
}
|
|
providerCtx, cancelProvider := context.WithTimeout(ctx, s.providerTimeout)
|
|
defer cancelProvider()
|
|
result := make(chan apiv1.MentionGroup, 1)
|
|
go func() {
|
|
group := searchProviderWithinContext(providerCtx, registered, request, s.log)
|
|
// The provider slot belongs to the underlying call. A provider that ignores
|
|
// cancellation stays quarantined without consuming shared search capacity.
|
|
<-providerSlot
|
|
result <- group
|
|
}()
|
|
select {
|
|
case group := <-result:
|
|
<-s.providerSlots
|
|
return group
|
|
case <-providerCtx.Done():
|
|
<-s.providerSlots
|
|
return failedGroup(registered.descriptor, StatusTimeout)
|
|
}
|
|
}
|
|
|
|
func (s *Service) slotForProvider(source string) chan struct{} {
|
|
s.providerSlotMu.Lock()
|
|
defer s.providerSlotMu.Unlock()
|
|
if s.providerInFlight == nil {
|
|
s.providerInFlight = make(map[string]chan struct{})
|
|
}
|
|
if slot, ok := s.providerInFlight[source]; ok {
|
|
return slot
|
|
}
|
|
slot := make(chan struct{}, 1)
|
|
s.providerInFlight[source] = slot
|
|
return slot
|
|
}
|
|
|
|
func searchProviderWithinContext(
|
|
ctx context.Context,
|
|
registered registeredProvider,
|
|
request SearchRequest,
|
|
log *logger.Logger,
|
|
) apiv1.MentionGroup {
|
|
group := groupFromDescriptor(registered.descriptor)
|
|
candidates, err := registered.provider.Search(ctx, request)
|
|
if err != nil {
|
|
status := classifyProviderError(err)
|
|
if log != nil && !errors.Is(err, context.Canceled) {
|
|
log.Warn(
|
|
"mention provider search failed",
|
|
zap.String("source", registered.descriptor.Source),
|
|
zap.String("provider", registered.descriptor.Provider),
|
|
zap.String("kind", registered.descriptor.Kind),
|
|
zap.String("status", string(status)),
|
|
)
|
|
}
|
|
group.Status = status
|
|
return group
|
|
}
|
|
if ctx.Err() != nil {
|
|
return failedGroup(registered.descriptor, StatusTimeout)
|
|
}
|
|
seen := make(map[string]struct{})
|
|
for _, candidate := range candidates {
|
|
if len(group.Results) == request.Limit {
|
|
break
|
|
}
|
|
candidate, ok := normalizeCandidate(candidate)
|
|
if !ok {
|
|
continue
|
|
}
|
|
reference := referenceFromCandidate(registered.descriptor, candidate)
|
|
if registered.authorizer != nil {
|
|
if err := registered.authorizer.AuthorizeReference(ctx, ReferenceAuthorizationRequest{
|
|
WorkspaceID: request.WorkspaceID,
|
|
Purpose: ReferencePurposeSearch,
|
|
Reference: reference,
|
|
}); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
if _, duplicate := seen[reference.Ref]; duplicate {
|
|
continue
|
|
}
|
|
seen[reference.Ref] = struct{}{}
|
|
group.Results = append(group.Results, reference)
|
|
}
|
|
return group
|
|
}
|
|
|
|
func classifyProviderError(err error) Status {
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return StatusTimeout
|
|
}
|
|
var providerErr *ProviderError
|
|
if errors.As(err, &providerErr) && safeFailureStatus(providerErr.Status) {
|
|
return providerErr.Status
|
|
}
|
|
return StatusUpstreamError
|
|
}
|
|
|
|
func safeFailureStatus(status Status) bool {
|
|
switch status {
|
|
case StatusNotConfigured, StatusUnauthorized, StatusRateLimited, StatusTimeout,
|
|
StatusUpstreamError, StatusUnsupportedScope:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeCandidate(candidate Candidate) (Candidate, bool) {
|
|
candidate.ID = strings.TrimSpace(candidate.ID)
|
|
candidate.Scope = strings.TrimSpace(candidate.Scope)
|
|
if !validIdentity(candidate.ID) || !validIdentity(candidate.Scope) {
|
|
return Candidate{}, false
|
|
}
|
|
candidate.Title = normalizeDisplayText(candidate.Title)
|
|
candidate.Key = normalizeDisplayText(candidate.Key)
|
|
if candidate.Title == "" || utf8.RuneCountInString(candidate.Title) > 500 ||
|
|
utf8.RuneCountInString(candidate.Key) > 200 {
|
|
return Candidate{}, false
|
|
}
|
|
candidate.URL = strings.TrimSpace(candidate.URL)
|
|
if !safeMentionURL(candidate.URL) {
|
|
return Candidate{}, false
|
|
}
|
|
return candidate, true
|
|
}
|
|
|
|
func validIdentity(value string) bool {
|
|
if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > 512 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizeDisplayText(value string) string {
|
|
if !utf8.ValidString(value) {
|
|
return ""
|
|
}
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func safeMentionURL(raw string) bool {
|
|
if raw == "" || !utf8.ValidString(raw) || utf8.RuneCountInString(raw) > 2048 {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.User != nil {
|
|
return false
|
|
}
|
|
if parsed.Scheme == "" && parsed.Host == "" {
|
|
return false
|
|
}
|
|
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
|
}
|
|
|
|
func normalizeRequest(request SearchRequest) (SearchRequest, error) {
|
|
request.WorkspaceID = strings.TrimSpace(request.WorkspaceID)
|
|
request.Query = strings.TrimSpace(request.Query)
|
|
if !validIdentity(request.WorkspaceID) {
|
|
return SearchRequest{}, fmt.Errorf("%w: workspace ID is required", ErrInvalidRequest)
|
|
}
|
|
if !utf8.ValidString(request.Query) {
|
|
return SearchRequest{}, fmt.Errorf("%w: query must be valid UTF-8", ErrInvalidRequest)
|
|
}
|
|
queryLength := utf8.RuneCountInString(request.Query)
|
|
if queryLength < 1 || queryLength > 200 {
|
|
return SearchRequest{}, fmt.Errorf("%w: query must contain 1 to 200 characters", ErrInvalidRequest)
|
|
}
|
|
for _, character := range request.Query {
|
|
if unicode.IsControl(character) {
|
|
return SearchRequest{}, fmt.Errorf("%w: query must not contain control characters", ErrInvalidRequest)
|
|
}
|
|
}
|
|
switch {
|
|
case request.Limit == 0:
|
|
request.Limit = DefaultLimit
|
|
case request.Limit < 1:
|
|
request.Limit = 1
|
|
case request.Limit > MaxLimit:
|
|
request.Limit = MaxLimit
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func groupFromDescriptor(descriptor ProviderDescriptor) apiv1.MentionGroup {
|
|
return apiv1.MentionGroup{
|
|
Source: descriptor.Source,
|
|
Provider: descriptor.Provider,
|
|
Kind: descriptor.Kind,
|
|
DisplayName: descriptor.DisplayName,
|
|
KindLabel: descriptor.KindLabel,
|
|
Status: apiv1.MentionStatusOK,
|
|
Results: []apiv1.EntityReference{},
|
|
}
|
|
}
|
|
|
|
func referenceFromCandidate(descriptor ProviderDescriptor, candidate Candidate) apiv1.EntityReference {
|
|
return apiv1.EntityReference{
|
|
Version: apiv1.EntityReferenceVersion,
|
|
Ref: canonicalRef(descriptor.Provider, descriptor.Kind, candidate.Scope, candidate.ID),
|
|
Provider: descriptor.Provider,
|
|
Kind: descriptor.Kind,
|
|
ID: candidate.ID,
|
|
Key: candidate.Key,
|
|
Title: candidate.Title,
|
|
URL: candidate.URL,
|
|
Scope: candidate.Scope,
|
|
}
|
|
}
|
|
|
|
func canonicalRef(provider, kind, scope, id string) string {
|
|
return entityrefs.CanonicalRef(provider, kind, scope, id)
|
|
}
|