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}