A community based topic aggregation platform built on atproto
1package aggregator
2
3import (
4 "encoding/json"
5 "log"
6 "net/http"
7 "strings"
8
9 "Coves/internal/core/aggregators"
10)
11
12// GetServicesHandler handles aggregator service details retrieval
13type GetServicesHandler struct {
14 service aggregators.Service
15}
16
17// NewGetServicesHandler creates a new get services handler
18func NewGetServicesHandler(service aggregators.Service) *GetServicesHandler {
19 return &GetServicesHandler{
20 service: service,
21 }
22}
23
24// HandleGetServices retrieves aggregator details by DID(s)
25// GET /xrpc/social.coves.aggregator.getServices?dids=did:plc:abc123,did:plc:def456&detailed=true
26// Following Bluesky's pattern: app.bsky.feed.getFeedGenerators
27func (h *GetServicesHandler) HandleGetServices(w http.ResponseWriter, r *http.Request) {
28 if r.Method != http.MethodGet {
29 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
30 return
31 }
32
33 // Parse DIDs from query parameter
34 didsParam := r.URL.Query().Get("dids")
35 if didsParam == "" {
36 writeError(w, http.StatusBadRequest, "InvalidRequest", "dids parameter is required")
37 return
38 }
39
40 // Parse detailed flag (default: false)
41 detailed := r.URL.Query().Get("detailed") == "true"
42
43 // Split comma-separated DIDs
44 rawDIDs := strings.Split(didsParam, ",")
45
46 // Trim whitespace and filter out empty DIDs (handles double commas, trailing commas, etc.)
47 dids := make([]string, 0, len(rawDIDs))
48 for _, did := range rawDIDs {
49 trimmed := strings.TrimSpace(did)
50 if trimmed != "" {
51 dids = append(dids, trimmed)
52 }
53 }
54
55 // Validate we have at least one valid DID
56 if len(dids) == 0 {
57 writeError(w, http.StatusBadRequest, "InvalidRequest", "at least one valid DID is required")
58 return
59 }
60
61 // Get aggregators from service
62 aggs, err := h.service.GetAggregators(r.Context(), dids)
63 if err != nil {
64 handleServiceError(w, err)
65 return
66 }
67
68 // Build response with appropriate view type based on detailed flag
69 response := GetServicesResponse{
70 Views: make([]interface{}, 0, len(aggs)),
71 }
72
73 for _, agg := range aggs {
74 if detailed {
75 response.Views = append(response.Views, toAggregatorViewDetailed(agg))
76 } else {
77 response.Views = append(response.Views, toAggregatorView(agg))
78 }
79 }
80
81 // Return response
82 w.Header().Set("Content-Type", "application/json")
83 w.WriteHeader(http.StatusOK)
84 if err := json.NewEncoder(w).Encode(response); err != nil {
85 log.Printf("ERROR: Failed to encode getServices response: %v", err)
86 }
87}
88
89// GetServicesResponse matches the lexicon output
90type GetServicesResponse struct {
91 Views []interface{} `json:"views"` // Union of aggregatorView | aggregatorViewDetailed
92}
93
94// AggregatorView matches social.coves.aggregator.defs#aggregatorView (without stats)
95type AggregatorView struct {
96 DID string `json:"did"`
97 DisplayName string `json:"displayName"`
98 Description *string `json:"description,omitempty"`
99 Avatar *string `json:"avatar,omitempty"`
100 ConfigSchema interface{} `json:"configSchema,omitempty"`
101 SourceURL *string `json:"sourceUrl,omitempty"`
102 MaintainerDID *string `json:"maintainer,omitempty"`
103 CreatedAt string `json:"createdAt"`
104 RecordUri string `json:"recordUri"`
105}
106
107// AggregatorViewDetailed matches social.coves.aggregator.defs#aggregatorViewDetailed (with stats)
108type AggregatorViewDetailed struct {
109 DID string `json:"did"`
110 DisplayName string `json:"displayName"`
111 Description *string `json:"description,omitempty"`
112 Avatar *string `json:"avatar,omitempty"`
113 ConfigSchema interface{} `json:"configSchema,omitempty"`
114 SourceURL *string `json:"sourceUrl,omitempty"`
115 MaintainerDID *string `json:"maintainer,omitempty"`
116 CreatedAt string `json:"createdAt"`
117 RecordUri string `json:"recordUri"`
118 Stats AggregatorStats `json:"stats"`
119}
120
121// AggregatorStats matches social.coves.aggregator.defs#aggregatorStats
122type AggregatorStats struct {
123 CommunitiesUsing int `json:"communitiesUsing"`
124 PostsCreated int `json:"postsCreated"`
125}
126
127// toAggregatorView converts domain model to basic aggregatorView (no stats)
128func toAggregatorView(agg *aggregators.Aggregator) AggregatorView {
129 view := AggregatorView{
130 DID: agg.DID,
131 DisplayName: agg.DisplayName,
132 CreatedAt: agg.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
133 RecordUri: agg.RecordURI,
134 }
135
136 // Add optional fields
137 if agg.Description != "" {
138 view.Description = &agg.Description
139 }
140 if agg.AvatarURL != "" {
141 view.Avatar = &agg.AvatarURL
142 }
143 if agg.MaintainerDID != "" {
144 view.MaintainerDID = &agg.MaintainerDID
145 }
146 if agg.SourceURL != "" {
147 view.SourceURL = &agg.SourceURL
148 }
149 if len(agg.ConfigSchema) > 0 {
150 // ConfigSchema is already JSON, unmarshal it for the view
151 var schema interface{}
152 if err := json.Unmarshal(agg.ConfigSchema, &schema); err == nil {
153 view.ConfigSchema = schema
154 }
155 }
156
157 return view
158}
159
160// toAggregatorViewDetailed converts domain model to detailed aggregatorViewDetailed (with stats)
161func toAggregatorViewDetailed(agg *aggregators.Aggregator) AggregatorViewDetailed {
162 view := AggregatorViewDetailed{
163 DID: agg.DID,
164 DisplayName: agg.DisplayName,
165 CreatedAt: agg.CreatedAt.Format("2006-01-02T15:04:05.000Z"),
166 RecordUri: agg.RecordURI,
167 Stats: AggregatorStats{
168 CommunitiesUsing: agg.CommunitiesUsing,
169 PostsCreated: agg.PostsCreated,
170 },
171 }
172
173 // Add optional fields
174 if agg.Description != "" {
175 view.Description = &agg.Description
176 }
177 if agg.AvatarURL != "" {
178 view.Avatar = &agg.AvatarURL
179 }
180 if agg.MaintainerDID != "" {
181 view.MaintainerDID = &agg.MaintainerDID
182 }
183 if agg.SourceURL != "" {
184 view.SourceURL = &agg.SourceURL
185 }
186 if len(agg.ConfigSchema) > 0 {
187 // ConfigSchema is already JSON, unmarshal it for the view
188 var schema interface{}
189 if err := json.Unmarshal(agg.ConfigSchema, &schema); err == nil {
190 view.ConfigSchema = schema
191 }
192 }
193
194 return view
195}