Skip to content

Commit 6b11cdb

Browse files
xdefragclaude
andcommitted
fix: add 404 checks, search validation, and extract shared helpers
- Add AccountExists checks to GetRelationships and GetReputation endpoints to honor the 404 contract documented in Swagger - Add search query length validation (max 100 chars) and tag length validation matching the web handler - Discard too-short queries (<2 chars) while preserving tag-only search - Extract validateAccountID, inferAccountType, convertCategories, and convertRelationship helpers to eliminate duplication - Unify account type classification between listAll and Search via shared inferAccountType helper - Simplify tag parsing with lo.FilterMap (trim-before-filter) - Regenerate Swagger docs with 400 response for search endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a99b4aa commit 6b11cdb

8 files changed

Lines changed: 127 additions & 104 deletions

File tree

internal/api/accounts.go

Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,6 @@ func (h *Handler) listAll(ctx context.Context, limit, offset int) ([]AccountList
163163
}
164164

165165
items := lo.Map(rows, func(a repository.AllAccountRow, _ int) AccountListItem {
166-
accountType := "person"
167-
if a.MTLACBalance > 0 && a.MTLACBalance <= 4 {
168-
accountType = "corporate"
169-
} else if a.MTLAXBalance > 0 && a.MTLAPBalance == 0 {
170-
accountType = "synthetic"
171-
}
172-
173166
grade := ""
174167
if a.ReputationScore > 0 {
175168
grade = reputation.ScoreToGrade(a.ReputationScore)
@@ -178,7 +171,7 @@ func (h *Handler) listAll(ctx context.Context, limit, offset int) ([]AccountList
178171
return AccountListItem{
179172
ID: a.AccountID,
180173
Name: a.Name,
181-
Type: accountType,
174+
Type: inferAccountType(a.MTLAPBalance, a.MTLACBalance, a.MTLAXBalance),
182175
MTLAPBalance: a.MTLAPBalance,
183176
MTLACBalance: a.MTLACBalance,
184177
MTLAXBalance: a.MTLAXBalance,
@@ -207,15 +200,8 @@ func (h *Handler) listAll(ctx context.Context, limit, offset int) ([]AccountList
207200
// @Router /api/v1/accounts/{id} [get]
208201
func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
209202
ctx := r.Context()
210-
accountID := r.PathValue("id")
211-
212-
if accountID == "" {
213-
writeError(w, http.StatusBadRequest, "account ID is required")
214-
return
215-
}
216-
217-
if !isValidStellarID(accountID) {
218-
writeError(w, http.StatusBadRequest, "invalid Stellar account ID format")
203+
accountID, ok := validateAccountID(w, r)
204+
if !ok {
219205
return
220206
}
221207

@@ -356,34 +342,35 @@ func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
356342

357343
if len(relationships) > 0 {
358344
categories := bsn.GroupRelationships(accountID, relationships, confirmed)
359-
resp.Categories = lo.Map(categories, func(cat model.RelationshipCategory, _ int) RelationshipCategoryResponse {
360-
if cat.IsEmpty {
361-
return RelationshipCategoryResponse{
362-
Name: cat.Name,
363-
Color: cat.Color,
364-
Relationships: []RelationshipResponse{},
365-
}
366-
}
367-
return RelationshipCategoryResponse{
368-
Name: cat.Name,
369-
Color: cat.Color,
370-
Relationships: lo.Map(cat.Relationships, func(rel model.Relationship, _ int) RelationshipResponse {
371-
return RelationshipResponse{
372-
Type: rel.Type,
373-
TargetID: rel.TargetID,
374-
TargetName: rel.TargetName,
375-
Direction: rel.Direction,
376-
IsMutual: rel.IsMutual,
377-
IsConfirmed: rel.IsConfirmed,
378-
}
379-
}),
380-
}
381-
})
345+
resp.Categories = convertCategories(categories)
382346
}
383347

384348
writeJSON(w, http.StatusOK, resp)
385349
}
386350

351+
func convertCategories(categories []model.RelationshipCategory) []RelationshipCategoryResponse {
352+
return lo.Map(categories, func(cat model.RelationshipCategory, _ int) RelationshipCategoryResponse {
353+
return RelationshipCategoryResponse{
354+
Name: cat.Name,
355+
Color: cat.Color,
356+
Relationships: lo.Map(cat.Relationships, func(rel model.Relationship, _ int) RelationshipResponse {
357+
return convertRelationship(rel)
358+
}),
359+
}
360+
})
361+
}
362+
363+
func convertRelationship(rel model.Relationship) RelationshipResponse {
364+
return RelationshipResponse{
365+
Type: rel.Type,
366+
TargetID: rel.TargetID,
367+
TargetName: rel.TargetName,
368+
Direction: rel.Direction,
369+
IsMutual: rel.IsMutual,
370+
IsConfirmed: rel.IsConfirmed,
371+
}
372+
}
373+
387374
func convertReputationScore(score *model.ReputationScore) *ReputationResponse {
388375
if score == nil {
389376
return nil

internal/api/docs/docs.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,12 @@ const docTemplate = `{
300300
"$ref": "#/definitions/api.PaginatedResponse"
301301
}
302302
},
303+
"400": {
304+
"description": "Bad Request",
305+
"schema": {
306+
"$ref": "#/definitions/api.ErrorResponse"
307+
}
308+
},
303309
"500": {
304310
"description": "Internal Server Error",
305311
"schema": {

internal/api/docs/swagger.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,12 @@
293293
"$ref": "#/definitions/api.PaginatedResponse"
294294
}
295295
},
296+
"400": {
297+
"description": "Bad Request",
298+
"schema": {
299+
"$ref": "#/definitions/api.ErrorResponse"
300+
}
301+
},
296302
"500": {
297303
"description": "Internal Server Error",
298304
"schema": {

internal/api/docs/swagger.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ paths:
408408
description: OK
409409
schema:
410410
$ref: '#/definitions/api.PaginatedResponse'
411+
"400":
412+
description: Bad Request
413+
schema:
414+
$ref: '#/definitions/api.ErrorResponse'
411415
"500":
412416
description: Internal Server Error
413417
schema:

internal/api/handler.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,33 @@ func writeError(w http.ResponseWriter, status int, msg string) {
5959
}
6060

6161
func isValidStellarID(id string) bool {
62-
if len(id) != 56 {
63-
return false
62+
return len(id) == 56 && id[0] == 'G'
63+
}
64+
65+
// validateAccountID validates the account ID path parameter and writes an error response if invalid.
66+
// Returns the account ID and true if valid, or empty string and false if invalid (error already written).
67+
func validateAccountID(w http.ResponseWriter, r *http.Request) (string, bool) {
68+
accountID := r.PathValue("id")
69+
if accountID == "" {
70+
writeError(w, http.StatusBadRequest, "account ID is required")
71+
return "", false
72+
}
73+
if !isValidStellarID(accountID) {
74+
writeError(w, http.StatusBadRequest, "invalid Stellar account ID format")
75+
return "", false
76+
}
77+
return accountID, true
78+
}
79+
80+
// inferAccountType determines account type from token balances.
81+
func inferAccountType(mtlapBalance, mtlacBalance, mtlaxBalance float64) string {
82+
if mtlacBalance > 0 && mtlacBalance <= 4 {
83+
return "corporate"
84+
}
85+
if mtlaxBalance > 0 && mtlapBalance == 0 {
86+
return "synthetic"
6487
}
65-
return id[0] == 'G'
88+
return "person"
6689
}
6790

6891
func parseIntParam(r *http.Request, name string, defaultVal, maxVal int) int {

internal/api/relationships.go

Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"net/http"
66

77
"github.com/mtlprog/lore/internal/bsn"
8-
"github.com/mtlprog/lore/internal/model"
98
"github.com/samber/lo"
109
)
1110

@@ -26,15 +25,19 @@ import (
2625
// @Router /api/v1/accounts/{id}/relationships [get]
2726
func (h *Handler) GetRelationships(w http.ResponseWriter, r *http.Request) {
2827
ctx := r.Context()
29-
accountID := r.PathValue("id")
30-
31-
if accountID == "" {
32-
writeError(w, http.StatusBadRequest, "account ID is required")
28+
accountID, ok := validateAccountID(w, r)
29+
if !ok {
3330
return
3431
}
3532

36-
if !isValidStellarID(accountID) {
37-
writeError(w, http.StatusBadRequest, "invalid Stellar account ID format")
33+
exists, err := h.accounts.AccountExists(ctx, accountID)
34+
if err != nil {
35+
slog.Error("api: failed to check account existence", "account_id", accountID, "error", err)
36+
writeError(w, http.StatusInternalServerError, "failed to check account")
37+
return
38+
}
39+
if !exists {
40+
writeError(w, http.StatusNotFound, "account not found")
3841
return
3942
}
4043

@@ -56,42 +59,26 @@ func (h *Handler) GetRelationships(w http.ResponseWriter, r *http.Request) {
5659
}
5760

5861
categories := bsn.GroupRelationships(accountID, relationships, confirmed)
62+
resp := convertCategories(categories)
5963

60-
resp := lo.Map(categories, func(cat model.RelationshipCategory, _ int) RelationshipCategoryResponse {
61-
rels := lo.Map(cat.Relationships, func(rel model.Relationship, _ int) RelationshipResponse {
62-
return RelationshipResponse{
63-
Type: rel.Type,
64-
TargetID: rel.TargetID,
65-
TargetName: rel.TargetName,
66-
Direction: rel.Direction,
67-
IsMutual: rel.IsMutual,
68-
IsConfirmed: rel.IsConfirmed,
69-
}
70-
})
71-
72-
// Apply filters
73-
if filterType != "" {
74-
rels = lo.Filter(rels, func(r RelationshipResponse, _ int) bool {
75-
return r.Type == filterType
76-
})
77-
}
78-
if filterConfirmed {
79-
rels = lo.Filter(rels, func(r RelationshipResponse, _ int) bool {
80-
return r.IsConfirmed
81-
})
82-
}
83-
if filterMutual {
84-
rels = lo.Filter(rels, func(r RelationshipResponse, _ int) bool {
85-
return r.IsMutual
64+
// Apply optional filters to each category's relationships
65+
hasFilters := filterType != "" || filterConfirmed || filterMutual
66+
if hasFilters {
67+
for i := range resp {
68+
resp[i].Relationships = lo.Filter(resp[i].Relationships, func(rel RelationshipResponse, _ int) bool {
69+
if filterType != "" && rel.Type != filterType {
70+
return false
71+
}
72+
if filterConfirmed && !rel.IsConfirmed {
73+
return false
74+
}
75+
if filterMutual && !rel.IsMutual {
76+
return false
77+
}
78+
return true
8679
})
8780
}
88-
89-
return RelationshipCategoryResponse{
90-
Name: cat.Name,
91-
Color: cat.Color,
92-
Relationships: rels,
93-
}
94-
})
81+
}
9582

9683
writeJSON(w, http.StatusOK, resp)
9784
}

internal/api/reputation.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,19 @@ import (
2323
// @Router /api/v1/accounts/{id}/reputation [get]
2424
func (h *Handler) GetReputation(w http.ResponseWriter, r *http.Request) {
2525
ctx := r.Context()
26-
accountID := r.PathValue("id")
27-
28-
if accountID == "" {
29-
writeError(w, http.StatusBadRequest, "account ID is required")
26+
accountID, ok := validateAccountID(w, r)
27+
if !ok {
3028
return
3129
}
3230

33-
if !isValidStellarID(accountID) {
34-
writeError(w, http.StatusBadRequest, "invalid Stellar account ID format")
31+
exists, err := h.accounts.AccountExists(ctx, accountID)
32+
if err != nil {
33+
slog.Error("api: failed to check account existence", "account_id", accountID, "error", err)
34+
writeError(w, http.StatusInternalServerError, "failed to check account")
35+
return
36+
}
37+
if !exists {
38+
writeError(w, http.StatusNotFound, "account not found")
3539
return
3640
}
3741

internal/api/search.go

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
// @Param limit query int false "Number of results" default(20) maximum(100)
2323
// @Param offset query int false "Offset for pagination" default(0)
2424
// @Success 200 {object} PaginatedResponse
25+
// @Failure 400 {object} ErrorResponse
2526
// @Failure 500 {object} ErrorResponse
2627
// @Router /api/v1/search [get]
2728
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
@@ -32,14 +33,12 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
3233
offset := parseIntParam(r, "offset", 0, 0)
3334

3435
// Parse tags from comma-separated string
35-
tagsParam := r.URL.Query().Get("tags")
3636
var tags []string
37-
if tagsParam != "" {
38-
tags = lo.Filter(
39-
strings.Split(tagsParam, ","),
40-
func(t string, _ int) bool { return strings.TrimSpace(t) != "" },
41-
)
42-
tags = lo.Map(tags, func(t string, _ int) string { return strings.TrimSpace(t) })
37+
if tagsParam := r.URL.Query().Get("tags"); tagsParam != "" {
38+
tags = lo.FilterMap(strings.Split(tagsParam, ","), func(t string, _ int) (string, bool) {
39+
trimmed := strings.TrimSpace(t)
40+
return trimmed, trimmed != ""
41+
})
4342
}
4443

4544
// Parse sort
@@ -49,6 +48,20 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
4948
repoSort = repository.SearchSortByReputation
5049
}
5150

51+
// Validate query length
52+
if len(query) > 100 {
53+
writeError(w, http.StatusBadRequest, "search query too long (max 100 characters)")
54+
return
55+
}
56+
57+
// Validate tag lengths
58+
tags = lo.Filter(tags, func(t string, _ int) bool { return len(t) <= 100 })
59+
60+
// Discard too-short queries (still allow tag-only search)
61+
if len(query) < 2 {
62+
query = ""
63+
}
64+
5265
// If no query and no tags, return empty result
5366
if query == "" && len(tags) == 0 {
5467
writeJSON(w, http.StatusOK, PaginatedResponse{
@@ -82,17 +95,10 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
8295
grade = reputation.ScoreToGrade(row.ReputationScore)
8396
}
8497

85-
accountType := "person"
86-
if row.MTLACBalance > 0 && row.MTLACBalance <= 4 {
87-
accountType = "corporate"
88-
} else if row.MTLAPBalance == 0 && row.MTLACBalance == 0 && row.MTLAXBalance > 0 {
89-
accountType = "synthetic"
90-
}
91-
9298
return AccountListItem{
9399
ID: row.AccountID,
94100
Name: row.Name,
95-
Type: accountType,
101+
Type: inferAccountType(row.MTLAPBalance, row.MTLACBalance, row.MTLAXBalance),
96102
MTLAPBalance: row.MTLAPBalance,
97103
MTLACBalance: row.MTLACBalance,
98104
MTLAXBalance: row.MTLAXBalance,

0 commit comments

Comments
 (0)