Standardize API response envelope shapes across all endpoints

This commit standardizes how API responses are wrapped, solving issue #24.

Problem:
- Inconsistent response envelopes (jails vs items vs bans vs no wrapper)
- Frontend required multiple field name variants
- Integration bugs from branching logic
- No clear pattern for different response types

Solution:
- Created response.py with base classes: PaginatedListResponse,
  CollectionResponse, CommandResponse
- Standardized all list/collection responses to use 'items' field
- Domain-specific field names for detail and aggregation responses
- Updated all backends routers and mappers
- Updated frontend types and hooks to match

Changes:
Backend:
- backend/app/models/response.py (new): Base response models
- backend/app/models/ban.py: Updated responses to inherit from bases
- backend/app/models/jail.py: Updated JailListResponse, JailCommandResponse
- backend/app/models/config.py: Updated collection responses
- backend/app/services/jail_service.py: Updated return statements
- backend/app/mappers/ban_mappers.py: Updated 'bans' to 'items'
- backend/tests/test_mappers/test_ban_mappers.py: Updated tests

Frontend:
- frontend/src/types/jail.ts: Updated response interfaces
- frontend/src/types/config.ts: Updated response interfaces
- frontend/src/hooks/useActiveBans.ts: Updated selector
- frontend/src/hooks/useJailList.ts: Updated selector
- frontend/src/hooks/useJailConfigs.ts: Updated selector
- frontend/src/hooks/useConfigActiveStatus.ts: Updated field access
- frontend/src/hooks/useJailAdmin.ts: Updated field access

Documentation:
- Docs/Backend-Development.md: Added § 4.1 API Response Envelope Policy

The policy defines:
1. Paginated lists use PaginatedListResponse (items, total, page, page_size)
2. Non-paginated collections use CollectionResponse (items, total)
3. Detail responses use entity-specific field names (jail, status, settings)
4. Command responses use CommandResponse (message, success, optional target)
5. Aggregations use domain-specific fields (jails, countries, buckets, bans)

All responses now follow one of these patterns, reducing frontend complexity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 10:12:55 +02:00
parent 7ba1cf7ca2
commit 1c673d600c
16 changed files with 415 additions and 86 deletions

View File

@@ -30,7 +30,7 @@ export interface UseActiveBansResult {
export function useActiveBans(): UseActiveBansResult {
const fetcher = useCallback((signal: AbortSignal) => fetchActiveBans(signal), []);
const selector = useCallback((response: ActiveBanListResponse) => response.bans, []);
const selector = useCallback((response: ActiveBanListResponse) => response.items, []);
const { items: bans, loading, error, refresh } = useListData<ActiveBanListResponse, ActiveBan>({
fetcher,

View File

@@ -72,8 +72,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
.then(([jailsResp, configsResp]) => {
if (ctrl.signal.aborted) return;
const summaries: JailSummary[] = jailsResp.jails;
const configs: JailConfig[] = configsResp.jails;
const summaries: JailSummary[] = jailsResp.items;
const configs: JailConfig[] = configsResp.items;
// Active jails: enabled in the runtime summary list.
const jailSet = new Set<string>(

View File

@@ -52,7 +52,7 @@ export function useJailAdmin(): UseJailAdminResult {
fetchInactiveJails(ctrl.signal)
.then((resp) => {
if (!ctrl.signal.aborted) {
setInactiveJails(resp.jails);
setInactiveJails(resp.items);
}
})
.catch((err: unknown) => {

View File

@@ -29,7 +29,7 @@ export function useJailConfigs(): UseJailConfigsResult {
[],
);
const selector = useCallback((response: JailConfigListResponse) => response.jails, []);
const selector = useCallback((response: JailConfigListResponse) => response.items, []);
const onSuccess = useCallback((response: JailConfigListResponse) => {
setTotal(response.total);

View File

@@ -39,7 +39,7 @@ export function useJails(): UseJailsResult {
[],
);
const selector = useCallback((response: JailListResponse) => response.jails, []);
const selector = useCallback((response: JailListResponse) => response.items, []);
const onSuccess = useCallback((response: JailListResponse) => {
setTotal(response.total);

View File

@@ -67,7 +67,7 @@ export interface JailConfigResponse {
}
export interface JailConfigListResponse {
jails: JailConfig[];
items: JailConfig[];
total: number;
}
@@ -536,7 +536,7 @@ export interface InactiveJail {
}
export interface InactiveJailListResponse {
jails: InactiveJail[];
items: InactiveJail[];
total: number;
}

View File

@@ -66,7 +66,7 @@ export interface JailSummary {
*/
export interface JailListResponse {
/** All known jails. */
jails: JailSummary[];
items: JailSummary[];
/** Total number of jails. */
total: number;
}
@@ -174,7 +174,7 @@ export interface ActiveBan {
*/
export interface ActiveBanListResponse {
/** List of all currently active bans. */
bans: ActiveBan[];
items: ActiveBan[];
/** Total number of active bans. */
total: number;
}