···
4
+
"Coves/internal/core/aggregators"
11
+
// GetServicesHandler handles aggregator service details retrieval
12
+
type GetServicesHandler struct {
13
+
service aggregators.Service
16
+
// NewGetServicesHandler creates a new get services handler
17
+
func NewGetServicesHandler(service aggregators.Service) *GetServicesHandler {
18
+
return &GetServicesHandler{
23
+
// HandleGetServices retrieves aggregator details by DID(s)
24
+
// GET /xrpc/social.coves.aggregator.getServices?dids=did:plc:abc123,did:plc:def456&detailed=true
25
+
// Following Bluesky's pattern: app.bsky.feed.getFeedGenerators
26
+
func (h *GetServicesHandler) HandleGetServices(w http.ResponseWriter, r *http.Request) {
27
+
if r.Method != http.MethodGet {
28
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
32
+
// Parse DIDs from query parameter
33
+
didsParam := r.URL.Query().Get("dids")
34
+
if didsParam == "" {
35
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "dids parameter is required")
39
+
// Parse detailed flag (default: false)
40
+
detailed := r.URL.Query().Get("detailed") == "true"
42
+
// Split comma-separated DIDs
43
+
rawDIDs := strings.Split(didsParam, ",")
45
+
// Trim whitespace and filter out empty DIDs (handles double commas, trailing commas, etc.)
46
+
dids := make([]string, 0, len(rawDIDs))
47
+
for _, did := range rawDIDs {
48
+
trimmed := strings.TrimSpace(did)
50
+
dids = append(dids, trimmed)
54
+
// Validate we have at least one valid DID
56
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "at least one valid DID is required")
60
+
// Get aggregators from service
61
+
aggs, err := h.service.GetAggregators(r.Context(), dids)
63
+
handleServiceError(w, err)
67
+
// Build response with appropriate view type based on detailed flag
68
+
response := GetServicesResponse{
69
+
Views: make([]interface{}, 0, len(aggs)),
72
+
for _, agg := range aggs {
74
+
response.Views = append(response.Views, toAggregatorViewDetailed(agg))
76
+
response.Views = append(response.Views, toAggregatorView(agg))
81
+
w.Header().Set("Content-Type", "application/json")
82
+
w.WriteHeader(http.StatusOK)
83
+
if err := json.NewEncoder(w).Encode(response); err != nil {
84
+
log.Printf("ERROR: Failed to encode getServices response: %v", err)
88
+
// GetServicesResponse matches the lexicon output
89
+
type GetServicesResponse struct {
90
+
Views []interface{} `json:"views"` // Union of aggregatorView | aggregatorViewDetailed
93
+
// AggregatorView matches social.coves.aggregator.defs#aggregatorView (without stats)
94
+
type AggregatorView struct {
95
+
DID string `json:"did"`
96
+
DisplayName string `json:"displayName"`
97
+
Description *string `json:"description,omitempty"`
98
+
Avatar *string `json:"avatar,omitempty"`
99
+
ConfigSchema interface{} `json:"configSchema,omitempty"`
100
+
SourceURL *string `json:"sourceUrl,omitempty"`
101
+
MaintainerDID *string `json:"maintainer,omitempty"`
102
+
CreatedAt string `json:"createdAt"`
103
+
RecordUri string `json:"recordUri"`
106
+
// AggregatorViewDetailed matches social.coves.aggregator.defs#aggregatorViewDetailed (with stats)
107
+
type AggregatorViewDetailed struct {
108
+
DID string `json:"did"`
109
+
DisplayName string `json:"displayName"`
110
+
Description *string `json:"description,omitempty"`
111
+
Avatar *string `json:"avatar,omitempty"`
112
+
ConfigSchema interface{} `json:"configSchema,omitempty"`
113
+
SourceURL *string `json:"sourceUrl,omitempty"`
114
+
MaintainerDID *string `json:"maintainer,omitempty"`
115
+
CreatedAt string `json:"createdAt"`
116
+
RecordUri string `json:"recordUri"`
117
+
Stats AggregatorStats `json:"stats"`
120
+
// AggregatorStats matches social.coves.aggregator.defs#aggregatorStats
121
+
type AggregatorStats struct {
122
+
CommunitiesUsing int `json:"communitiesUsing"`
123
+
PostsCreated int `json:"postsCreated"`
126
+
// toAggregatorView converts domain model to basic aggregatorView (no stats)
127
+
func toAggregatorView(agg *aggregators.Aggregator) AggregatorView {
128
+
view := AggregatorView{
130
+
DisplayName: agg.DisplayName,
131
+
CreatedAt: agg.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
132
+
RecordUri: agg.RecordURI,
135
+
// Add optional fields
136
+
if agg.Description != "" {
137
+
view.Description = &agg.Description
139
+
if agg.AvatarURL != "" {
140
+
view.Avatar = &agg.AvatarURL
142
+
if agg.MaintainerDID != "" {
143
+
view.MaintainerDID = &agg.MaintainerDID
145
+
if agg.SourceURL != "" {
146
+
view.SourceURL = &agg.SourceURL
148
+
if len(agg.ConfigSchema) > 0 {
149
+
// ConfigSchema is already JSON, unmarshal it for the view
150
+
var schema interface{}
151
+
if err := json.Unmarshal(agg.ConfigSchema, &schema); err == nil {
152
+
view.ConfigSchema = schema
159
+
// toAggregatorViewDetailed converts domain model to detailed aggregatorViewDetailed (with stats)
160
+
func toAggregatorViewDetailed(agg *aggregators.Aggregator) AggregatorViewDetailed {
161
+
view := AggregatorViewDetailed{
163
+
DisplayName: agg.DisplayName,
164
+
CreatedAt: agg.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
165
+
RecordUri: agg.RecordURI,
166
+
Stats: AggregatorStats{
167
+
CommunitiesUsing: agg.CommunitiesUsing,
168
+
PostsCreated: agg.PostsCreated,
172
+
// Add optional fields
173
+
if agg.Description != "" {
174
+
view.Description = &agg.Description
176
+
if agg.AvatarURL != "" {
177
+
view.Avatar = &agg.AvatarURL
179
+
if agg.MaintainerDID != "" {
180
+
view.MaintainerDID = &agg.MaintainerDID
182
+
if agg.SourceURL != "" {
183
+
view.SourceURL = &agg.SourceURL
185
+
if len(agg.ConfigSchema) > 0 {
186
+
// ConfigSchema is already JSON, unmarshal it for the view
187
+
var schema interface{}
188
+
if err := json.Unmarshal(agg.ConfigSchema, &schema); err == nil {
189
+
view.ConfigSchema = schema