feat: add plural value filter params

closes #76
This commit is contained in:
Enot (ded) Skelly committed 2026-07-23 14:26:16 -07:00
1 parent 8e2798612b
commit b33ebc007b
14 files changed
+278 -77

No files matched your search

+15 -15
View File
@@ -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
+5 -5
View File
@@ -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)
}
}
+7 -7
View File
@@ -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
+22 -22
View File
@@ -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,
)
+18
View File
@@ -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",
+18
View File
@@ -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",
+13
View File
@@ -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
+15 -20
View File
@@ -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
+103 -1
View File
@@ -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{
+53
View File
@@ -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
+3 -3
View File
@@ -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
}
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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].