diff --git a/db/packets.go b/db/packets.go index 2b38740..66a0be6 100644 --- a/db/packets.go +++ b/db/packets.go @@ -58,9 +58,9 @@ func (s *Store) SetPacketDecrypted(ctx context.Context, hash []byte) error { return s.q.SetPacketDecrypted(ctx, hash) } -func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { +func (s *Store) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { if len(iatas) > 0 { - return s.listPacketsByIATAs(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit) + return s.listPacketsByIATAs(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit) } var cursorTS pgtype.Timestamptz if cursor > 0 { @@ -75,13 +75,13 @@ func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, i untilTS = pgtype.Timestamptz{Time: until, Valid: true} } rows, err := s.q.ListPackets(ctx, sqlc.ListPacketsParams{ - Column1: payloadType, - Column2: routeType, + Column1: payloadTypes, + Column2: routeTypes, Column3: sinceTS, Column4: untilTS, Column5: cursorTS, Limit: limit + 1, - Column7: scope, + Column7: scopes, }) if err != nil { return api.Page[api.PacketSummary]{}, err @@ -124,7 +124,7 @@ func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, i }, nil } -func (s *Store) listPacketsByIATAs(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { +func (s *Store) listPacketsByIATAs(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} @@ -141,15 +141,15 @@ func (s *Store) listPacketsByIATAs(ctx context.Context, payloadType, routeType i // ((packet_hash, observer_id) is unique), so scan deep enough per site // to still fill the page after collapsing those duplicates. rows, err := s.q.ListPacketsByIATAs(ctx, sqlc.ListPacketsByIATAsParams{ - Iatas: iatas, - ScanDepth: (limit + 1) * 8, - CursorTs: cursorTS, - PayloadType: payloadType, - RouteType: routeType, - SinceTs: sinceTS, - UntilTs: untilTS, - ScopeName: scope, - PageLimit: limit + 1, + Iatas: iatas, + ScanDepth: (limit + 1) * 8, + CursorTs: cursorTS, + PayloadTypes: payloadTypes, + RouteTypes: routeTypes, + SinceTs: sinceTS, + UntilTs: untilTS, + ScopeNames: scopes, + PageLimit: limit + 1, }) if err != nil { return api.Page[api.PacketSummary]{}, err diff --git a/db/packets_test.go b/db/packets_test.go index 9f270e5..ba228c5 100644 --- a/db/packets_test.go +++ b/db/packets_test.go @@ -80,7 +80,7 @@ func TestListPackets_Pagination(t *testing.T) { Return(rows, nil) store := &Store{q: mock} - page, err := store.ListPackets(context.Background(), 0, 0, nil, "", time.Time{}, time.Time{}, 0, 2) + page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -113,7 +113,7 @@ func TestListPackets_LatestObserverNil(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListPackets(context.Background(), 0, 0, nil, "", time.Time{}, time.Time{}, 0, 10) + page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -145,7 +145,7 @@ func TestListPackets_LatestObserverSet(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListPackets(context.Background(), 0, 0, nil, "", time.Time{}, time.Time{}, 0, 10) + page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -422,7 +422,7 @@ func TestListPackets_IATAFilterRoutesToObservationIndex(t *testing.T) { }) store := &Store{q: mock} - page, err := store.ListPackets(context.Background(), -1, -1, []string{"ALF"}, "", time.Time{}, time.Time{}, 0, 1) + page, err := store.ListPackets(context.Background(), nil, nil, []string{"ALF"}, nil, time.Time{}, time.Time{}, 0, 1) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -445,7 +445,7 @@ func TestListPackets_UnfilteredKeepsGlobalQuery(t *testing.T) { Return([]sqlc.ListPacketsRow{}, nil) store := &Store{q: mock} - if _, err := store.ListPackets(context.Background(), -1, -1, nil, "", time.Time{}, time.Time{}, 0, 50); err != nil { + if _, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 50); err != nil { t.Fatalf("unexpected error: %v", err) } } diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 296e8be..610525e 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -390,12 +390,12 @@ LEFT JOIN LATERAL ( LEFT JOIN observers o ON o.id = po.observer_id LEFT JOIN transport_scopes ts ON ts.id = p.scope_id WHERE - ($1::smallint = -1 OR p.payload_type = $1::smallint) - AND ($2::smallint = -1 OR p.route_type = $2::smallint) + (COALESCE(cardinality($1::smallint[]), 0) = 0 OR p.payload_type = ANY($1::smallint[])) + AND (COALESCE(cardinality($2::smallint[]), 0) = 0 OR p.route_type = ANY($2::smallint[])) AND ($3::timestamptz IS NULL OR p.first_heard_at >= $3) AND ($4::timestamptz IS NULL OR p.first_heard_at <= $4) AND ($5::timestamptz IS NULL OR p.last_heard_at < $5) - AND ($7::text = '' OR ts.name = $7::text) + AND (COALESCE(cardinality($7::text[]), 0) = 0 OR ts.name = ANY($7::text[])) ORDER BY p.last_heard_at DESC LIMIT $6; @@ -431,13 +431,13 @@ FROM ( JOIN packets p2 ON p2.packet_hash = po3.packet_hash WHERE po3.iata = req.iata AND po3.heard_at < COALESCE(@cursor_ts::timestamptz, 'infinity'::timestamptz) - AND (@payload_type::smallint = -1 OR p2.payload_type = @payload_type::smallint) - AND (@route_type::smallint = -1 OR p2.route_type = @route_type::smallint) + AND (COALESCE(cardinality(@payload_types::smallint[]), 0) = 0 OR p2.payload_type = ANY(@payload_types::smallint[])) + AND (COALESCE(cardinality(@route_types::smallint[]), 0) = 0 OR p2.route_type = ANY(@route_types::smallint[])) AND (@since_ts::timestamptz IS NULL OR p2.first_heard_at >= @since_ts) AND (@until_ts::timestamptz IS NULL OR p2.first_heard_at <= @until_ts) - AND (@scope_name::text = '' OR EXISTS ( + AND (COALESCE(cardinality(@scope_names::text[]), 0) = 0 OR EXISTS ( SELECT 1 FROM transport_scopes ts2 - WHERE ts2.id = p2.scope_id AND ts2.name = @scope_name::text)) + WHERE ts2.id = p2.scope_id AND ts2.name = ANY(@scope_names::text[]))) ORDER BY po3.heard_at DESC LIMIT @scan_depth ) hits diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 7182701..d2332a0 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -2558,24 +2558,24 @@ LEFT JOIN LATERAL ( LEFT JOIN observers o ON o.id = po.observer_id LEFT JOIN transport_scopes ts ON ts.id = p.scope_id WHERE - ($1::smallint = -1 OR p.payload_type = $1::smallint) - AND ($2::smallint = -1 OR p.route_type = $2::smallint) + (COALESCE(cardinality($1::smallint[]), 0) = 0 OR p.payload_type = ANY($1::smallint[])) + AND (COALESCE(cardinality($2::smallint[]), 0) = 0 OR p.route_type = ANY($2::smallint[])) AND ($3::timestamptz IS NULL OR p.first_heard_at >= $3) AND ($4::timestamptz IS NULL OR p.first_heard_at <= $4) AND ($5::timestamptz IS NULL OR p.last_heard_at < $5) - AND ($7::text = '' OR ts.name = $7::text) + AND (COALESCE(cardinality($7::text[]), 0) = 0 OR ts.name = ANY($7::text[])) ORDER BY p.last_heard_at DESC LIMIT $6 ` type ListPacketsParams struct { - Column1 int16 `json:"column_1"` - Column2 int16 `json:"column_2"` + Column1 []int16 `json:"column_1"` + Column2 []int16 `json:"column_2"` Column3 pgtype.Timestamptz `json:"column_3"` Column4 pgtype.Timestamptz `json:"column_4"` Column5 pgtype.Timestamptz `json:"column_5"` Limit int32 `json:"limit"` - Column7 string `json:"column_7"` + Column7 []string `json:"column_7"` } type ListPacketsRow struct { @@ -2745,13 +2745,13 @@ FROM ( JOIN packets p2 ON p2.packet_hash = po3.packet_hash WHERE po3.iata = req.iata AND po3.heard_at < COALESCE($2::timestamptz, 'infinity'::timestamptz) - AND ($3::smallint = -1 OR p2.payload_type = $3::smallint) - AND ($4::smallint = -1 OR p2.route_type = $4::smallint) + AND (COALESCE(cardinality($3::smallint[]), 0) = 0 OR p2.payload_type = ANY($3::smallint[])) + AND (COALESCE(cardinality($4::smallint[]), 0) = 0 OR p2.route_type = ANY($4::smallint[])) AND ($5::timestamptz IS NULL OR p2.first_heard_at >= $5) AND ($6::timestamptz IS NULL OR p2.first_heard_at <= $6) - AND ($7::text = '' OR EXISTS ( + AND (COALESCE(cardinality($7::text[]), 0) = 0 OR EXISTS ( SELECT 1 FROM transport_scopes ts2 - WHERE ts2.id = p2.scope_id AND ts2.name = $7::text)) + WHERE ts2.id = p2.scope_id AND ts2.name = ANY($7::text[]))) ORDER BY po3.heard_at DESC LIMIT $8 ) hits @@ -2778,15 +2778,15 @@ ORDER BY sh.site_heard_at DESC ` type ListPacketsByIATAsParams struct { - Iatas []string `json:"iatas"` - CursorTs pgtype.Timestamptz `json:"cursor_ts"` - PayloadType int16 `json:"payload_type"` - RouteType int16 `json:"route_type"` - SinceTs pgtype.Timestamptz `json:"since_ts"` - UntilTs pgtype.Timestamptz `json:"until_ts"` - ScopeName string `json:"scope_name"` - ScanDepth int32 `json:"scan_depth"` - PageLimit int32 `json:"page_limit"` + Iatas []string `json:"iatas"` + CursorTs pgtype.Timestamptz `json:"cursor_ts"` + PayloadTypes []int16 `json:"payload_types"` + RouteTypes []int16 `json:"route_types"` + SinceTs pgtype.Timestamptz `json:"since_ts"` + UntilTs pgtype.Timestamptz `json:"until_ts"` + ScopeNames []string `json:"scope_names"` + ScanDepth int32 `json:"scan_depth"` + PageLimit int32 `json:"page_limit"` } type ListPacketsByIATAsRow struct { @@ -2817,11 +2817,11 @@ func (q *Queries) ListPacketsByIATAs(ctx context.Context, arg ListPacketsByIATAs rows, err := q.db.Query(ctx, listPacketsByIATAs, arg.Iatas, arg.CursorTs, - arg.PayloadType, - arg.RouteType, + arg.PayloadTypes, + arg.RouteTypes, arg.SinceTs, arg.UntilTs, - arg.ScopeName, + arg.ScopeNames, arg.ScanDepth, arg.PageLimit, ) diff --git a/docs/docs.go b/docs/docs.go index 334052d..288eb58 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -968,6 +968,12 @@ const docTemplate = `{ "name": "payloadType", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple payload types, comma-separated e.g. 2,4", + "name": "payloadTypes", + "in": "query" + }, { "type": "string", "description": "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)", @@ -980,6 +986,12 @@ const docTemplate = `{ "name": "routeType", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple route types, comma-separated e.g. 0,1", + "name": "routeTypes", + "in": "query" + }, { "type": "string", "description": "Filter by single IATA code (case-insensitive)", @@ -998,6 +1010,12 @@ const docTemplate = `{ "name": "scope", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple transport scope names, comma-separated e.g. %23bc,%23west", + "name": "scopes", + "in": "query" + }, { "type": "integer", "description": "Filter by region ID, expands to member IATAs", diff --git a/docs/swagger.json b/docs/swagger.json index af9cf37..479b58b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -966,6 +966,12 @@ "name": "payloadType", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple payload types, comma-separated e.g. 2,4", + "name": "payloadTypes", + "in": "query" + }, { "type": "string", "description": "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)", @@ -978,6 +984,12 @@ "name": "routeType", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple route types, comma-separated e.g. 0,1", + "name": "routeTypes", + "in": "query" + }, { "type": "string", "description": "Filter by single IATA code (case-insensitive)", @@ -996,6 +1008,12 @@ "name": "scope", "in": "query" }, + { + "type": "string", + "description": "Filter by multiple transport scope names, comma-separated e.g. %23bc,%23west", + "name": "scopes", + "in": "query" + }, { "type": "integer", "description": "Filter by region ID, expands to member IATAs", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e90fc54..d63e219 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1695,6 +1695,10 @@ paths: in: query name: payloadType type: integer + - description: Filter by multiple payload types, comma-separated e.g. 2,4 + in: query + name: payloadTypes + type: string - description: Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req) in: query @@ -1704,6 +1708,10 @@ paths: in: query name: routeType type: integer + - description: Filter by multiple route types, comma-separated e.g. 0,1 + in: query + name: routeTypes + type: string - description: Filter by single IATA code (case-insensitive) in: query name: iata @@ -1716,6 +1724,11 @@ paths: in: query name: scope type: string + - description: Filter by multiple transport scope names, comma-separated e.g. + %23bc,%23west + in: query + name: scopes + type: string - description: Filter by region ID, expands to member IATAs in: query name: regionId diff --git a/internal/api/handlers/packets.go b/internal/api/handlers/packets.go index e7749ba..43e6bb7 100644 --- a/internal/api/handlers/packets.go +++ b/internal/api/handlers/packets.go @@ -32,11 +32,14 @@ func PacketsRouter(reader api.Reader) http.Handler { // @Tags Packets // @Produce json // @Param payloadType query int false "Filter by payload type integer" +// @Param payloadTypes query string false "Filter by multiple payload types, comma-separated e.g. 2,4" // @Param payloadTypeName query string false "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)" // @Param routeType query int false "Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)" +// @Param routeTypes query string false "Filter by multiple route types, comma-separated e.g. 0,1" // @Param iata query string false "Filter by single IATA code (case-insensitive)" // @Param iatas query string false "Filter by multiple IATA codes, comma-separated e.g. YVR,YYJ" // @Param scope query string false "Filter by transport scope name e.g. %23bc (URL-encoded #bc)" +// @Param scopes query string false "Filter by multiple transport scope names, comma-separated e.g. %23bc,%23west" // @Param regionId query int false "Filter by region ID, expands to member IATAs" // @Param region query string false "Filter by region slug, expands to member IATAs" // @Param since query int false "Filter by first_heard_at >= since (epoch ms)" @@ -49,28 +52,20 @@ func PacketsRouter(reader api.Reader) http.Handler { // @Router /packets [get] func listPackets(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var payloadType int16 = -1 - if p := r.URL.Query().Get("payloadType"); p != "" { - t, err := strconv.ParseInt(p, 10, 16) - if err != nil { - respondError(w, http.StatusBadRequest, "payloadType must be an integer") - return - } - payloadType = int16(t) + payloadTypes, err := parseInt16CSVOrSingle(r, "payloadTypes", "payloadType") + if err != nil { + respondError(w, http.StatusBadRequest, "payloadType/payloadTypes must be integers") + return } - if payloadType == -1 { + if len(payloadTypes) == 0 { if p := r.URL.Query().Get("payloadTypeName"); p != "" { - payloadType = api.PayloadTypeFromString(p) + payloadTypes = []int16{api.PayloadTypeFromString(p)} } } - var routeType int16 = -1 - if p := r.URL.Query().Get("routeType"); p != "" { - t, err := strconv.ParseInt(p, 10, 16) - if err != nil { - respondError(w, http.StatusBadRequest, "routeType must be an integer") - return - } - routeType = int16(t) + routeTypes, err := parseInt16CSVOrSingle(r, "routeTypes", "routeType") + if err != nil { + respondError(w, http.StatusBadRequest, "routeType/routeTypes must be integers") + return } var since, until time.Time if p := r.URL.Query().Get("since"); p != "" { @@ -116,8 +111,8 @@ func listPackets(reader api.Reader) http.HandlerFunc { } iatas = append(iatas, regionIATAs...) } - scope := r.URL.Query().Get("scope") - packets, err := reader.ListPackets(r.Context(), payloadType, routeType, iatas, scope, since, until, cursor, limit) + scopes := parseCSVOrSingle(r, "scopes", "scope") + packets, err := reader.ListPackets(r.Context(), payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return diff --git a/internal/api/handlers/packets_test.go b/internal/api/handlers/packets_test.go index 2c9e51e..2a34328 100644 --- a/internal/api/handlers/packets_test.go +++ b/internal/api/handlers/packets_test.go @@ -127,7 +127,7 @@ func TestListPacketsBackfill_InvalidLimit(t *testing.T) { func TestListPackets_OK(t *testing.T) { r := chi.NewRouter() r.Get("/packets", listPackets(stubReader{ - listPackets: func(_ context.Context, _, _ int16, _ []string, _ string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { + listPackets: func(_ context.Context, _, _ []int16, _ []string, _ []string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { return api.Page[api.PacketSummary]{Items: []api.PacketSummary{{PacketHash: "deadbeef"}}}, nil }, })) @@ -139,6 +139,108 @@ func TestListPackets_OK(t *testing.T) { } } +func TestListPackets_PluralParams_PassedThrough(t *testing.T) { + var gotPayloadTypes, gotRouteTypes []int16 + var gotScopes []string + r := chi.NewRouter() + r.Get("/packets", listPackets(stubReader{ + listPackets: func(_ context.Context, payloadTypes, routeTypes []int16, _ []string, scopes []string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { + gotPayloadTypes = payloadTypes + gotRouteTypes = routeTypes + gotScopes = scopes + return api.Page[api.PacketSummary]{}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/packets?payloadTypes=2,4&routeTypes=0,1&scopes=%23bc,%23west", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if len(gotPayloadTypes) != 2 || gotPayloadTypes[0] != 2 || gotPayloadTypes[1] != 4 { + t.Errorf("expected payloadTypes [2 4], got %v", gotPayloadTypes) + } + if len(gotRouteTypes) != 2 || gotRouteTypes[0] != 0 || gotRouteTypes[1] != 1 { + t.Errorf("expected routeTypes [0 1], got %v", gotRouteTypes) + } + if len(gotScopes) != 2 || gotScopes[0] != "#bc" || gotScopes[1] != "#west" { + t.Errorf("expected scopes [#bc #west], got %v", gotScopes) + } +} + +func TestListPackets_SingularParams_StillWork(t *testing.T) { + var gotPayloadTypes, gotRouteTypes []int16 + var gotScopes []string + r := chi.NewRouter() + r.Get("/packets", listPackets(stubReader{ + listPackets: func(_ context.Context, payloadTypes, routeTypes []int16, _ []string, scopes []string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { + gotPayloadTypes = payloadTypes + gotRouteTypes = routeTypes + gotScopes = scopes + return api.Page[api.PacketSummary]{}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/packets?payloadType=4&routeType=1&scope=%23bc", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if len(gotPayloadTypes) != 1 || gotPayloadTypes[0] != 4 { + t.Errorf("expected payloadTypes [4], got %v", gotPayloadTypes) + } + if len(gotRouteTypes) != 1 || gotRouteTypes[0] != 1 { + t.Errorf("expected routeTypes [1], got %v", gotRouteTypes) + } + if len(gotScopes) != 1 || gotScopes[0] != "#bc" { + t.Errorf("expected scopes [#bc], got %v", gotScopes) + } +} + +func TestListPackets_PluralParams_TakePrecedenceOverSingular(t *testing.T) { + // Mirrors parseIATAs' precedence: when both are present, the plural param wins outright + // rather than merging with the singular one. + var gotPayloadTypes []int16 + r := chi.NewRouter() + r.Get("/packets", listPackets(stubReader{ + listPackets: func(_ context.Context, payloadTypes, _ []int16, _ []string, _ []string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { + gotPayloadTypes = payloadTypes + return api.Page[api.PacketSummary]{}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/packets?payloadType=9&payloadTypes=2,4", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if len(gotPayloadTypes) != 2 || gotPayloadTypes[0] != 2 || gotPayloadTypes[1] != 4 { + t.Errorf("expected payloadTypes [2 4] (plural wins), got %v", gotPayloadTypes) + } +} + +func TestListPackets_InvalidPayloadTypes(t *testing.T) { + r := chi.NewRouter() + r.Get("/packets", listPackets(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/packets?payloadTypes=2,notanint", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestListPackets_InvalidRouteTypes(t *testing.T) { + r := chi.NewRouter() + r.Get("/packets", listPackets(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/packets?routeTypes=0,notanint", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + func TestGetPacket_OK(t *testing.T) { r := chi.NewRouter() r.Get("/packets/{packetHash}", getPacket(stubReader{ diff --git a/internal/api/handlers/regions.go b/internal/api/handlers/regions.go index 356cb9d..b9bfe5c 100644 --- a/internal/api/handlers/regions.go +++ b/internal/api/handlers/regions.go @@ -95,6 +95,59 @@ func parseIATAs(r *http.Request) []string { return iatas } +// parseInt16CSVOrSingle reads a comma-separated list from pluralParam, falling back to a +// single value from singularParam if pluralParam is absent -- same precedence as parseIATAs. +func parseInt16CSVOrSingle(r *http.Request, pluralParam, singularParam string) ([]int16, error) { + raw := r.URL.Query().Get(pluralParam) + if raw == "" { + single := r.URL.Query().Get(singularParam) + if single == "" { + return nil, nil + } + v, err := strconv.ParseInt(single, 10, 16) + if err != nil { + return nil, err + } + return []int16{int16(v)}, nil + } + parts := strings.Split(raw, ",") + out := make([]int16, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + v, err := strconv.ParseInt(p, 10, 16) + if err != nil { + return nil, err + } + out = append(out, int16(v)) + } + return out, nil +} + +// parseCSVOrSingle reads a comma-separated list from pluralParam, falling back to a single +// value from singularParam if pluralParam is absent -- same precedence as parseIATAs, but +// without the uppercasing (used for scope names, which are case-sensitive, e.g. "#bc"). +func parseCSVOrSingle(r *http.Request, pluralParam, singularParam string) []string { + raw := r.URL.Query().Get(pluralParam) + if raw == "" { + if single := r.URL.Query().Get(singularParam); single != "" { + return []string{single} + } + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + // resolveRegionIATAs expands a regionId or region slug to a slice of IATA codes. func resolveRegionIATAs(ctx context.Context, regionID, regionSlug string, reader api.Reader) ([]string, error) { var region *api.Region diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index 9d80fdc..948d57a 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -35,7 +35,7 @@ type stubReader struct { getNode func(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) getNodeNeighbors func(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) listNodeObservations func(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) - listPackets func(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) + listPackets func(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) listPacketsAfterID func(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) getPacket func(ctx context.Context, packetHash []byte) (*api.Packet, error) getRadioPresets func(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) @@ -201,9 +201,9 @@ func (s stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, return api.Page[api.PacketObservationSummary]{}, nil } -func (s stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { +func (s stubReader) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { if s.listPackets != nil { - return s.listPackets(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit) + return s.listPackets(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit) } return api.Page[api.PacketSummary]{}, nil } diff --git a/internal/api/reader.go b/internal/api/reader.go index 225bdeb..ad95e58 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -119,7 +119,9 @@ type Reader interface { // Unfiltered lists order by global last_heard_at; when iatas are set, ordering // and the cursor are site-local (heard_at at the requested sites) instead. // cursor is epoch ms in either case; pass 0 to start from the beginning. - ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (Page[PacketSummary], error) + // payloadTypes/routeTypes/scopes are OR'd within each filter, ANDed across filters; pass + // nil/empty to skip a filter. + ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (Page[PacketSummary], error) // ListPacketsAfterID returns packets with observations after the given observation ID, // ordered oldest first. Used for WS reconnect backfill. diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 8043c6d..8ab4337 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -160,7 +160,7 @@ func (s *stubReader) ListObserverAdverts(_ context.Context, _ uuid.UUID, _ int64 return api.Page[api.AdvertObservation]{}, nil } -func (s *stubReader) ListPackets(_ context.Context, _, _ int16, _ []string, _ string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { +func (s *stubReader) ListPackets(_ context.Context, _, _ []int16, _ []string, _ []string, _, _ time.Time, _ int64, _ int32) (api.Page[api.PacketSummary], error) { return api.Page[api.PacketSummary]{}, nil } diff --git a/internal/cache/reader.go b/internal/cache/reader.go index bdef20f..46d001e 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -394,8 +394,8 @@ func (cr *CachedReader) ListObserverAdverts(ctx context.Context, observerID uuid } // ListPackets implements [api.Reader]. -func (cr *CachedReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { - return cr.inner.ListPackets(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit) +func (cr *CachedReader) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { + return cr.inner.ListPackets(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit) } // ListPacketsAfterID implements [api.Reader].