Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/list_api/list_framework.py: 48%
324 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Generic list handling for LiteLLM-defined collection routes.
3A resource declares a `ListSpec`; `build_query_plan` turns query parameters into a
4`QueryPlan` or an RFC 9457 problem without touching a database, and `handle_list`
5runs that plan through an injected `ListExecutor`. Keeping the planning pure is what
6lets a caller assert the plan as a value instead of asserting against a live Prisma
7client, and it keeps this module free of any database dependency.
9A plan's `where` is a tuple of frozen `Predicate`s rather than a backend-shaped
10mapping, so the framework never has to know which query builder executes it and a
11planned predicate cannot be rewritten afterwards. `where_sql` renders one for a
12raw-SQL executor with every caller-supplied value bound to a placeholder.
13"""
15from collections.abc import Callable, Mapping, Sequence
16from dataclasses import dataclass
17from datetime import datetime, timezone
18from functools import partial, reduce
19from math import ceil
20from typing import Final, Generic, Literal, Protocol, TypeVar
22from fastapi import Request
23from pydantic import TypeAdapter, ValidationError
24from typing_extensions import assert_never
26from litellm.proxy._types import UserAPIKeyAuth
27from litellm.proxy.list_api.common import (
28 PROBLEM_TYPE_BASE,
29 ManagementProblem,
30 build_list_links,
31 build_page_links,
32 escape_like,
33 unknown_query_param_problem,
34)
35from litellm.types.proxy.management_endpoints.management_v1 import (
36 FacetListResponse,
37 ListMeta,
38 ListResponse,
39 PageMeta,
40 ProblemDetail,
41)
43ComparisonOp = Literal["eq", "gte", "lte", "gt", "lt", "contains", "not"]
44# `is_null` is not in the design doc's operator set. It is here because there is no
45# other way to ask for "max_budget IS NULL", and a table that renders nulls as
46# "Unlimited" has to be able to filter on them.
47FilterOp = ComparisonOp | Literal["in", "is_null"]
49FilterType = type[str] | type[int] | type[float] | type[datetime]
50FilterValue = str | int | float | datetime
52PAGE_PARAM: Final = "page"
53PAGE_SIZE_PARAM: Final = "page_size"
54SORT_PARAM: Final = "sort"
55SEARCH_PARAM: Final = "q"
57TRow = TypeVar("TRow")
58TRow_co = TypeVar("TRow_co", covariant=True)
59TOut = TypeVar("TOut")
61_FILTER_OP_ADAPTER: Final[TypeAdapter[FilterOp]] = TypeAdapter(FilterOp)
64@dataclass(frozen=True, slots=True)
65class Compare:
66 """`field <op> value`."""
68 field: str
69 op: ComparisonOp
70 value: FilterValue
73@dataclass(frozen=True, slots=True)
74class Within:
75 """`field IN (values)`."""
77 field: str
78 values: tuple[FilterValue, ...]
81@dataclass(frozen=True, slots=True)
82class IsNull:
83 """`field IS NULL`, or `IS NOT NULL` when negated."""
85 field: str
86 negated: bool
89@dataclass(frozen=True, slots=True)
90class AnyOf:
91 """Disjunction of its clauses. `?q=` is the only producer.
93 Holding leaves rather than predicates keeps the disjunction one level deep by type, so
94 neither the SQL renderer nor an in-memory executor has to walk a tree to evaluate it.
95 """
97 clauses: tuple[Compare, ...]
100Predicate = Compare | Within | IsNull | AnyOf
103@dataclass(frozen=True, slots=True)
104class FilterSpec:
105 type: FilterType
106 ops: frozenset[FilterOp]
109@dataclass(frozen=True, slots=True)
110class SortKey:
111 field: str
112 descending: bool
115@dataclass(frozen=True, slots=True)
116class ScopeAll:
117 """The caller may read every row of the resource."""
120@dataclass(frozen=True, slots=True)
121class ScopeWhere:
122 """The caller may read the rows matching every predicate in `where`."""
124 where: tuple[Predicate, ...]
127@dataclass(frozen=True, slots=True)
128class ScopeDenied:
129 """The caller may read no rows at all, and should be told so rather than shown an empty page."""
131 reason: str
134Scope = ScopeAll | ScopeWhere | ScopeDenied
137@dataclass(frozen=True, slots=True)
138class ListSpec(Generic[TRow, TOut]):
139 resource: str
140 sortable: frozenset[str]
141 searchable: frozenset[str]
142 filters: Mapping[str, FilterSpec]
143 default_sort: tuple[SortKey, ...]
144 default_page_size: int
145 max_page_size: int
146 scope: Callable[[UserAPIKeyAuth], Scope]
147 serialize: Callable[[TRow], TOut]
148 tiebreaker: str
150 def __post_init__(self) -> None:
151 """A malformed spec is a programming error at import time, so this raises rather than
152 returning a problem: there is no request in flight and no caller to answer."""
153 if not 1 <= self.default_page_size <= self.max_page_size: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 raise ValueError(
155 f"{self.resource}: default_page_size must be between 1 and max_page_size "
156 f"({self.max_page_size}), got {self.default_page_size}. A default above the cap "
157 f"would serve more rows than the resource allows whenever page_size is omitted."
158 )
159 if not self.tiebreaker: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 raise ValueError(f"{self.resource}: tiebreaker is required; it is the final sort key on every query.")
161 undeclared: Final = tuple(sorted(frozenset(key.field for key in self.default_sort) - self.sortable))
162 if undeclared: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 raise ValueError(f"{self.resource}: default_sort orders by non-sortable field(s): {', '.join(undeclared)}.")
164 non_text: Final = tuple(
165 sorted(field for field, spec in self.filters.items() if "contains" in spec.ops and spec.type is not str)
166 )
167 if non_text: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 raise ValueError(
169 f"{self.resource}: contains renders as ILIKE and is only meaningful on text columns, "
170 f"but is declared on: {', '.join(non_text)}."
171 )
174@dataclass(frozen=True, slots=True)
175class QueryPlan:
176 """`where` is an implicit AND, ordered scope-first; `order` always ends with the spec's tiebreaker."""
178 where: tuple[Predicate, ...]
179 order: tuple[SortKey, ...]
180 skip: int
181 take: int
184class ListExecutor(Protocol[TRow_co]):
185 """The database half of a list, injected so this module never imports Prisma."""
187 async def count(self, where: tuple[Predicate, ...]) -> int: ... 187 ↛ exitline 187 didn't return from function 'count' because
189 async def find_many(self, plan: QueryPlan) -> Sequence[TRow_co]: ... 189 ↛ exitline 189 didn't return from function 'find_many' because
192class FacetExecutor(Protocol):
193 """The half of a facet that knows the rows. Separate from `ListExecutor` so a SQL
194 executor is not forced to implement `distinct` to keep serving entity lists."""
196 async def distinct(self, field: str, where: tuple[Predicate, ...]) -> Sequence[str]: ... 196 ↛ exitline 196 didn't return from function 'distinct' because
199def order_by_sql(order: tuple[SortKey, ...]) -> str:
200 """`ORDER BY` body for a plan, NULLS LAST in both directions.
202 Postgres sorts nulls last ascending but first descending, so an unqualified flip of
203 the sort direction drags every "Unlimited" row to the top of the table. Every field
204 reaching here is either a member of `ListSpec.sortable` (caller-supplied sort is
205 checked against it, `default_sort` at construction) or the spec's `tiebreaker`, so
206 these are developer-declared column names, never caller-controlled text.
207 """
208 return ", ".join(f'"{key.field}" {"DESC" if key.descending else "ASC"} NULLS LAST' for key in order)
211def _sql_operator(op: ComparisonOp) -> str:
212 match op:
213 case "eq":
214 return "="
215 case "not":
216 return "<>"
217 case "gte":
218 return ">="
219 case "lte":
220 return "<="
221 case "gt":
222 return ">"
223 case "lt":
224 return "<"
225 case "contains":
226 return "ILIKE"
227 case _:
228 assert_never(op)
231def _placeholder(index: int, value: FilterValue) -> str:
232 """`$n`, cast when the bind is a datetime.
234 Binds cross into the query engine as JSON, so a datetime arrives as text and
235 Postgres refuses `timestamp >= text` outright. Prisma stores DateTime as a naive
236 `TIMESTAMP(3)` holding UTC, so the bind is read as an instant and then dropped to
237 naive UTC to match the column, the same cast `/spend/logs/ui` applies.
238 """
239 return f"${index}::timestamptz AT TIME ZONE 'UTC'" if isinstance(value, datetime) else f"${index}"
242def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]:
243 match predicate:
244 case IsNull(field=field, negated=negated):
245 return f'"{field}" IS {"NOT NULL" if negated else "NULL"}', ()
246 case Within(field=field, values=values):
247 placeholders: Final = ", ".join(_placeholder(index + offset, value) for offset, value in enumerate(values))
248 return f'"{field}" IN ({placeholders})', values
249 case AnyOf(clauses=clauses):
250 rendered, params = _render_all(clauses, index)
251 return f"({' OR '.join(rendered)})", params
252 case Compare(field=field, op="contains", value=value):
253 return f"\"{field}\" ILIKE ${index} ESCAPE '\\'", (f"%{escape_like(str(value))}%",)
254 case Compare(field=field, op=op, value=value):
255 return f'"{field}" {_sql_operator(op)} {_placeholder(index, value)}', (value,)
256 case _:
257 assert_never(predicate)
260def _render_one(
261 rendered: tuple[tuple[str, ...], tuple[object, ...]],
262 predicate: Predicate,
263 first_index: int,
264) -> tuple[tuple[str, ...], tuple[object, ...]]:
265 """Append one predicate, numbering it after the binds already consumed."""
266 clauses, params = rendered
267 clause, clause_params = _render(predicate, first_index + len(params))
268 return (*clauses, clause), (*params, *clause_params)
271def _render_all(predicates: tuple[Predicate, ...], index: int) -> tuple[tuple[str, ...], tuple[object, ...]]:
272 """Render every predicate, numbering placeholders continuously across them.
274 Folded rather than self-recursive: walking a predicate list is a running index, and
275 recursing per predicate grew the stack with the filter count for nothing. `_render`
276 still re-enters here for `AnyOf`, whose clauses are plain `Compare`s from `?q=`, so
277 that nesting is one level deep and cannot be driven deeper by a caller.
278 """
279 return reduce(partial(_render_one, first_index=index), predicates, ((), ()))
282def where_sql(where: tuple[Predicate, ...], first_index: int = 1) -> tuple[str, tuple[object, ...]]:
283 """`WHERE` body and its bind parameters, numbered from `first_index`.
285 Returns `("", ())` when there is nothing to filter on. Every caller-supplied value
286 becomes a `$n` placeholder rather than being written into the SQL text; only column
287 names reach the text, and those come from the spec's own declarations.
288 """
289 clauses, params = _render_all(where, first_index)
290 return " AND ".join(clauses), params
293def _problem(slug: str, title: str, status: int, detail: str, allowed: tuple[str, ...] | None = None) -> ProblemDetail:
294 return ProblemDetail(
295 type=f"{PROBLEM_TYPE_BASE}{slug}",
296 title=title,
297 status=status,
298 detail=detail,
299 allowed=sorted(allowed) if allowed is not None else None,
300 )
303def _invalid(detail: str) -> ProblemDetail:
304 return _problem("invalid-query-parameter", "Invalid query parameter", 400, detail)
307def _parse_filter_key(name: str) -> tuple[str, FilterOp] | None:
308 """`filter[max_budget][gte]` -> `("max_budget", "gte")`; bare `filter[status]` -> `("status", "eq")`."""
309 if not name.startswith("filter[") or not name.endswith("]"):
310 return None
311 field, separator, raw_op = name[len("filter[") : -1].partition("][")
312 if not separator:
313 return field, "eq"
314 try:
315 return field, _FILTER_OP_ADAPTER.validate_python(raw_op)
316 except ValidationError:
317 return None
320def _is_known_param(spec: ListSpec[TRow, TOut], name: str) -> bool:
321 if name in (PAGE_PARAM, PAGE_SIZE_PARAM):
322 return True
323 if name == SORT_PARAM:
324 return bool(spec.sortable)
325 if name == SEARCH_PARAM:
326 return bool(spec.searchable)
327 parsed: Final = _parse_filter_key(name)
328 return parsed is not None and parsed[0] in spec.filters
331def _allowed_params(spec: ListSpec[TRow, TOut]) -> tuple[str, ...]:
332 return tuple(
333 sorted(
334 (PAGE_PARAM, PAGE_SIZE_PARAM)
335 + ((SORT_PARAM,) if spec.sortable else ())
336 + ((SEARCH_PARAM,) if spec.searchable else ())
337 + tuple(
338 f"filter[{field}]" if op == "eq" else f"filter[{field}][{op}]"
339 for field, filter_spec in spec.filters.items()
340 for op in filter_spec.ops
341 )
342 )
343 )
346def _parse_positive_int(name: str, raw: str) -> int | ProblemDetail:
347 try:
348 value: Final = int(raw)
349 except ValueError:
350 return _invalid(f"'{name}' must be an integer.")
351 if value < 1:
352 return _invalid(f"'{name}' must be 1 or greater.")
353 return value
356def _parse_page(params: Mapping[str, str]) -> int | ProblemDetail:
357 raw: Final = params.get(PAGE_PARAM)
358 return 1 if raw is None else _parse_positive_int(PAGE_PARAM, raw)
361def _parse_page_size(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> int | ProblemDetail:
362 raw: Final = params.get(PAGE_SIZE_PARAM)
363 if raw is None: 363 ↛ 365line 363 didn't jump to line 365 because the condition on line 363 was always true
364 return spec.default_page_size
365 value: Final = _parse_positive_int(PAGE_SIZE_PARAM, raw)
366 if isinstance(value, ProblemDetail):
367 return value
368 return min(value, spec.max_page_size)
371def _parse_sort(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> tuple[SortKey, ...] | ProblemDetail:
372 raw: Final = params.get(SORT_PARAM)
373 if raw is None: 373 ↛ 375line 373 didn't jump to line 375 because the condition on line 373 was always true
374 return spec.default_sort
375 segments: Final = tuple(segment.strip() for segment in raw.split(","))
376 keys = tuple(SortKey(field=segment.removeprefix("-"), descending=segment.startswith("-")) for segment in segments)
377 rejected: Final = tuple(sorted(frozenset(key.field for key in keys) - spec.sortable))
378 if rejected:
379 return _problem(
380 "invalid-sort-field",
381 "Invalid sort field",
382 400,
383 f"Cannot sort {spec.resource} by: {', '.join(repr(field) for field in rejected)}.",
384 tuple(spec.sortable),
385 )
386 # A repeated field cannot change the ordering, but an executor that sorts once per key
387 # does the work anyway. Rejecting repeats bounds that to the size of `sortable`, which
388 # matters because an unauthenticated caller can otherwise name one field a thousand times.
389 fields: Final = tuple(key.field for key in keys)
390 repeated: Final = tuple(sorted(frozenset(field for field in fields if fields.count(field) > 1)))
391 if repeated:
392 return _problem(
393 "duplicate-sort-field",
394 "Duplicate sort field",
395 400,
396 f"Sort field(s) named more than once: {', '.join(repeated)}. Each may appear once.",
397 tuple(spec.sortable),
398 )
399 return keys
402def _to_utc(value: datetime) -> datetime:
403 return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
406def _coerce(field: str, op: FilterOp, raw: str, target: FilterType) -> FilterValue | ProblemDetail:
407 try:
408 if target is str:
409 return raw
410 if target is int:
411 return int(raw)
412 if target is float:
413 return float(raw)
414 return _to_utc(datetime.fromisoformat(raw[:-1] + "+00:00" if raw.endswith("Z") else raw))
415 except ValueError:
416 return _invalid(f"'filter[{field}][{op}]' is not a valid {target.__name__}: {raw!r}.")
419def _null_predicate(field: str, raw: str) -> Predicate | ProblemDetail:
420 if raw.lower() == "true":
421 return IsNull(field=field, negated=False)
422 if raw.lower() == "false":
423 return IsNull(field=field, negated=True)
424 return _invalid(f"'filter[{field}][is_null]' must be 'true' or 'false'.")
427def _within_predicate(field: str, raw: str, target: FilterType) -> Predicate | ProblemDetail:
428 coerced: Final = tuple(_coerce(field, "in", item.strip(), target) for item in raw.split(","))
429 problems: Final = tuple(item for item in coerced if isinstance(item, ProblemDetail))
430 if problems:
431 return problems[0]
432 return Within(field=field, values=tuple(item for item in coerced if not isinstance(item, ProblemDetail)))
435def _parse_filter(field: str, op: FilterOp, raw: str, filter_spec: FilterSpec) -> Predicate | ProblemDetail:
436 if op not in filter_spec.ops:
437 return _problem(
438 "unsupported-filter-operator",
439 "Unsupported filter operator",
440 400,
441 f"Operator '{op}' is not supported on '{field}'.",
442 tuple(filter_spec.ops),
443 )
444 if op == "is_null":
445 return _null_predicate(field, raw)
446 if op == "in":
447 return _within_predicate(field, raw, filter_spec.type)
448 value: Final = _coerce(field, op, raw, filter_spec.type)
449 if isinstance(value, ProblemDetail):
450 return value
451 return Compare(field=field, op=op, value=value)
454def _parse_filters(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> tuple[Predicate, ...] | ProblemDetail:
455 keys: Final = tuple(
456 (name, parsed)
457 for name in sorted(params)
458 if (parsed := _parse_filter_key(name)) is not None and parsed[0] in spec.filters
459 )
460 parsed = tuple(_parse_filter(field, op, params[name], spec.filters[field]) for name, (field, op) in keys)
461 problems: Final = tuple(item for item in parsed if isinstance(item, ProblemDetail))
462 if problems: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 return problems[0]
464 return tuple(item for item in parsed if not isinstance(item, ProblemDetail))
467def _search_predicate(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> Predicate | None:
468 raw: Final = params.get(SEARCH_PARAM)
469 if not raw: 469 ↛ 471line 469 didn't jump to line 471 because the condition on line 469 was always true
470 return None
471 return AnyOf(clauses=tuple(Compare(field=field, op="contains", value=raw) for field in sorted(spec.searchable)))
474def _scope_predicates(scope: Scope) -> tuple[Predicate, ...] | ProblemDetail:
475 match scope:
476 case ScopeAll(): 476 ↛ 478line 476 didn't jump to line 478 because the pattern on line 476 always matched
477 return ()
478 case ScopeWhere(where=where):
479 return where
480 case ScopeDenied(reason=reason):
481 return _problem("forbidden", "Forbidden", 403, reason)
482 case _:
483 assert_never(scope)
486def build_query_plan(
487 spec: ListSpec[TRow, TOut],
488 params: Mapping[str, str],
489 caller: UserAPIKeyAuth,
490) -> QueryPlan | ProblemDetail:
491 """Turn query parameters into a plan, or into the problem that explains why they are not one."""
492 scope_predicates: Final = _scope_predicates(spec.scope(caller))
493 if isinstance(scope_predicates, ProblemDetail): 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 return scope_predicates
496 unknown: Final = tuple(sorted(name for name in params if not _is_known_param(spec, name)))
497 if unknown: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true
498 return unknown_query_param_problem(unknown=unknown, allowed=_allowed_params(spec))
500 page: Final = _parse_page(params)
501 if isinstance(page, ProblemDetail): 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true
502 return page
504 page_size: Final = _parse_page_size(spec, params)
505 if isinstance(page_size, ProblemDetail): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 return page_size
508 sort: Final = _parse_sort(spec, params)
509 if isinstance(sort, ProblemDetail): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 return sort
512 filters: Final = _parse_filters(spec, params)
513 if isinstance(filters, ProblemDetail): 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true
514 return filters
516 search: Final = _search_predicate(spec, params)
517 return QueryPlan(
518 # Scope first: conjuncts a caller filter sits behind and cannot replace.
519 where=scope_predicates + filters + ((search,) if search is not None else ()),
520 # Ordering by an all-null column without a unique final key lets Postgres return
521 # the same row on two different pages.
522 order=sort + (SortKey(field=spec.tiebreaker, descending=False),),
523 skip=(page - 1) * page_size,
524 take=page_size,
525 )
528def _facet_allowed_params(spec: ListSpec[TRow, TOut]) -> tuple[str, ...]:
529 """A facet's values are always ascending, so `sort` is not one of its parameters."""
530 return tuple(name for name in _allowed_params(spec) if name != SORT_PARAM)
533def _facet_where(
534 spec: ListSpec[TRow, TOut],
535 params: Mapping[str, str],
536 caller: UserAPIKeyAuth,
537) -> tuple[Predicate, ...] | ProblemDetail:
538 scope_predicates: Final = _scope_predicates(spec.scope(caller))
539 if isinstance(scope_predicates, ProblemDetail): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true
540 return scope_predicates
541 filters: Final = _parse_filters(spec, params)
542 if isinstance(filters, ProblemDetail): 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true
543 return filters
544 search: Final = _search_predicate(spec, params)
545 return scope_predicates + filters + ((search,) if search is not None else ())
548async def handle_facet(
549 spec: ListSpec[TRow, TOut],
550 executor: FacetExecutor,
551 request: Request,
552 caller: UserAPIKeyAuth,
553 field: str,
554) -> FacetListResponse:
555 """The distinct values one column takes over a filtered query on a resource.
557 Carries the parent's parameters so a filter dropdown offers exactly the values the
558 table can show, and `has_more` rather than a total, which would cost a COUNT(*) over
559 the whole match set on every keystroke.
560 """
561 params: Final = request.query_params
562 unknown: Final = tuple(sorted(name for name in params if name == SORT_PARAM or not _is_known_param(spec, name)))
563 if unknown: 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true
564 raise ManagementProblem(unknown_query_param_problem(unknown=unknown, allowed=_facet_allowed_params(spec)))
566 duplicates: Final = _duplicate_params(request)
567 if duplicates: 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true
568 raise ManagementProblem(
569 _problem(
570 "duplicate-query-parameter",
571 "Duplicate query parameter",
572 400,
573 f"Repeated query parameter(s): {', '.join(duplicates)}. Each may appear once; "
574 f"use a comma-separated list for multiple filter values.",
575 )
576 )
578 page: Final = _parse_page(params)
579 if isinstance(page, ProblemDetail): 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true
580 raise ManagementProblem(page)
581 page_size: Final = _parse_page_size(spec, params)
582 if isinstance(page_size, ProblemDetail): 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 raise ManagementProblem(page_size)
585 where: Final = _facet_where(spec, params, caller)
586 if isinstance(where, ProblemDetail): 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 raise ManagementProblem(where)
589 values: Final = await executor.distinct(field, where)
590 skip: Final = (page - 1) * page_size
591 window: Final = values[skip : skip + page_size + 1]
592 has_more: Final = len(window) > page_size
593 return FacetListResponse(
594 data=tuple(window[:page_size]),
595 meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
596 links=build_page_links(request=request, page=page, has_more=has_more),
597 )
600def _duplicate_params(request: Request) -> tuple[str, ...]:
601 names: Final = tuple(name for name, _ in request.query_params.multi_items())
602 return tuple(sorted(frozenset(name for name in names if names.count(name) > 1)))
605async def handle_list(
606 spec: ListSpec[TRow, TOut],
607 executor: ListExecutor[TRow],
608 request: Request,
609 caller: UserAPIKeyAuth,
610) -> ListResponse[TOut]:
611 """Plan, execute, count, serialize, envelope. Failures reach the client as RFC 9457 problems."""
612 plan: Final = build_query_plan(spec=spec, params=request.query_params, caller=caller)
613 if isinstance(plan, ProblemDetail): 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 raise ManagementProblem(plan)
616 # Checked here rather than in build_query_plan because a Mapping[str, str] cannot
617 # represent a repeat: query_params.get() silently keeps the last one, so ?page=1&page=999
618 # would page from 999 without the caller ever being told which value won.
619 duplicates: Final = _duplicate_params(request)
620 if duplicates: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true
621 raise ManagementProblem(
622 _problem(
623 "duplicate-query-parameter",
624 "Duplicate query parameter",
625 400,
626 f"Repeated query parameter(s): {', '.join(duplicates)}. Each may appear once; "
627 f"use a comma-separated list for multiple sort keys or filter values.",
628 )
629 )
631 total_count: Final = await executor.count(plan.where)
632 rows: Final = await executor.find_many(plan)
633 total_pages: Final = ceil(total_count / plan.take)
634 page: Final = plan.skip // plan.take + 1
635 return ListResponse[TOut](
636 data=tuple(spec.serialize(row) for row in rows),
637 meta=ListMeta(
638 total_count=total_count,
639 page=page,
640 page_size=plan.take,
641 total_pages=total_pages,
642 ),
643 links=build_list_links(request=request, page=page, total_pages=total_pages),
644 )