A community based topic aggregation platform built on atproto
1package aggregator
2
3import (
4 "Coves/internal/core/aggregators"
5 "encoding/json"
6 "log"
7 "net/http"
8 "strings"
9)
10
11// GetServicesHandler handles aggregator service details retrieval
12type GetServicesHandler struct {
13 service aggregators.Service
14}
15
16// NewGetServicesHandler creates a new get services handler
17func NewGetServicesHandler(service aggregators.Service) *GetServicesHandler {
18 return &GetServicesHandler{
19 service: service,
20 }
21}
22
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
26func (h *GetServicesHandler) HandleGetServices(w http.ResponseWriter, r *http.Request) {
27 if r.Method != http.MethodGet {
28 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
29 return
30 }
31
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")
36 return
37 }
38
39 // Parse detailed flag (default: false)
40 detailed := r.URL.Query().Get("detailed") == "true"
41
42 // Split comma-separated DIDs
43 rawDIDs := strings.Split(didsParam, ",")
44
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)
49 if trimmed != "" {
50 dids = append(dids, trimmed)
51 }
52 }
53
54 // Validate we have at least one valid DID
55 if len(dids) == 0 {
56 writeError(w, http.StatusBadRequest, "InvalidRequest", "at least one valid DID is required")
57 return
58 }
59
60 // Get aggregators from service
61 aggs, err := h.service.GetAggregators(r.Context(), dids)
62 if err != nil {
63 handleServiceError(w, err)
64 return
65 }
66
67 // Build response with appropriate view type based on detailed flag
68 response := GetServicesResponse{
69 Views: make([]interface{}, 0, len(aggs)),
70 }
71
72 for _, agg := range aggs {
73 if detailed {
74 response.Views = append(response.Views, toAggregatorViewDetailed(agg))
75 } else {
76 response.Views = append(response.Views, toAggregatorView(agg))
77 }
78 }
79
80 // Return response
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)
85 }
86}
87
88// GetServicesResponse matches the lexicon output
89type GetServicesResponse struct {
90 Views []interface{} `json:"views"` // Union of aggregatorView | aggregatorViewDetailed
91}
92
93// AggregatorView matches social.coves.aggregator.defs#aggregatorView (without stats)
94type 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"`
104}
105
106// AggregatorViewDetailed matches social.coves.aggregator.defs#aggregatorViewDetailed (with stats)
107type 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"`
118}
119
120// AggregatorStats matches social.coves.aggregator.defs#aggregatorStats
121type AggregatorStats struct {
122 CommunitiesUsing int `json:"communitiesUsing"`
123 PostsCreated int `json:"postsCreated"`
124}
125
126// toAggregatorView converts domain model to basic aggregatorView (no stats)
127func toAggregatorView(agg *aggregators.Aggregator) AggregatorView {
128 view := AggregatorView{
129 DID: agg.DID,
130 DisplayName: agg.DisplayName,
131 CreatedAt: agg.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
132 RecordUri: agg.RecordURI,
133 }
134
135 // Add optional fields
136 if agg.Description != "" {
137 view.Description = &agg.Description
138 }
139 if agg.AvatarURL != "" {
140 view.Avatar = &agg.AvatarURL
141 }
142 if agg.MaintainerDID != "" {
143 view.MaintainerDID = &agg.MaintainerDID
144 }
145 if agg.SourceURL != "" {
146 view.SourceURL = &agg.SourceURL
147 }
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
153 }
154 }
155
156 return view
157}
158
159// toAggregatorViewDetailed converts domain model to detailed aggregatorViewDetailed (with stats)
160func toAggregatorViewDetailed(agg *aggregators.Aggregator) AggregatorViewDetailed {
161 view := AggregatorViewDetailed{
162 DID: agg.DID,
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,
169 },
170 }
171
172 // Add optional fields
173 if agg.Description != "" {
174 view.Description = &agg.Description
175 }
176 if agg.AvatarURL != "" {
177 view.Avatar = &agg.AvatarURL
178 }
179 if agg.MaintainerDID != "" {
180 view.MaintainerDID = &agg.MaintainerDID
181 }
182 if agg.SourceURL != "" {
183 view.SourceURL = &agg.SourceURL
184 }
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
190 }
191 }
192
193 return view
194}