diff --git a/backend/app/routers/action_config.py b/backend/app/routers/action_config.py index a3da655..1ba964a 100644 --- a/backend/app/routers/action_config.py +++ b/backend/app/routers/action_config.py @@ -128,6 +128,11 @@ _NamePath = Annotated[ "", response_model=ActionListResponse, summary="List all available actions with active/inactive status", + responses={ + 200: {"description": "Action list returned", "model": ActionListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def list_actions( request: Request, @@ -165,6 +170,12 @@ async def list_actions( "/{name}", response_model=ActionConfig, summary="Return full parsed detail for a single action", + responses={ + 200: {"description": "Action config returned", "model": ActionConfig}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Action not found in action.d/"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_action( request: Request, @@ -205,6 +216,14 @@ async def get_action( response_model=ActionConfig, summary="Update an action's .local override with new lifecycle command values", dependencies=[Depends(_check_action_update_rate_limit)], + responses={ + 200: {"description": "Action updated", "model": ActionConfig}, + 400: {"description": "Invalid action name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Action not found"}, + 429: {"description": "Rate limit exceeded for action update operations"}, + 500: {"description": "Failed to write .local file"}, + }, ) async def update_action( request: Request, @@ -246,6 +265,14 @@ async def update_action( status_code=status.HTTP_201_CREATED, summary="Create a new user-defined action", dependencies=[Depends(_check_action_create_rate_limit)], + responses={ + 201: {"description": "Action created", "model": ActionConfig}, + 400: {"description": "Invalid action name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "Action already exists"}, + 429: {"description": "Rate limit exceeded for action create operations"}, + 500: {"description": "Failed to write .local file"}, + }, ) async def create_action( request: Request, @@ -284,6 +311,15 @@ async def create_action( status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user-created action's .local file", dependencies=[Depends(_check_action_delete_rate_limit)], + responses={ + 204: {"description": "Action deleted successfully"}, + 400: {"description": "Invalid action name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Action not found"}, + 409: {"description": "Action is a shipped default (conf-only)"}, + 429: {"description": "Rate limit exceeded for action delete operations"}, + 500: {"description": "Failed to delete .local file"}, + }, ) async def delete_action( request: Request, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9c2f14d..f87852c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -45,6 +45,13 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) "/login", response_model=LoginResponse, summary="Authenticate with the master password", + responses={ + 200: {"description": "Login successful", "model": LoginResponse}, + 401: {"description": "Invalid password"}, + 422: {"description": "Validation error — invalid request body"}, + 429: {"description": "Too many login attempts, retry after delay"}, + 503: {"description": "Setup not complete"}, + }, ) async def login( body: LoginRequest, @@ -116,6 +123,10 @@ async def login( "/session", response_model=SessionValidResponse, summary="Validate the current session", + responses={ + 200: {"description": "Session valid", "model": SessionValidResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def validate_session( _: AuthDep, @@ -142,6 +153,10 @@ async def validate_session( "/logout", response_model=LogoutResponse, summary="Revoke the current session", + responses={ + 200: {"description": "Logout successful", "model": LogoutResponse}, + 401: {"description": "Session missing or invalid (silently successful)"}, + }, ) async def logout( request: Request, diff --git a/backend/app/routers/bans.py b/backend/app/routers/bans.py index e2a1a7f..60892b0 100644 --- a/backend/app/routers/bans.py +++ b/backend/app/routers/bans.py @@ -101,6 +101,11 @@ def _check_unban_rate_limit( "/active", response_model=ActiveBanListResponse, summary="List all currently banned IPs across all jails", + responses={ + 200: {"description": "Active ban list returned", "model": ActiveBanListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_active_bans( request: Request, @@ -144,6 +149,15 @@ async def get_active_bans( response_model=JailCommandResponse, summary="Ban an IP address in a specific jail", dependencies=[Depends(_check_ban_rate_limit)], + responses={ + 201: {"description": "IP banned successfully", "model": JailCommandResponse}, + 400: {"description": "Invalid IP address"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "Ban command failed in fail2ban"}, + 429: {"description": "Rate limit exceeded for ban operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def ban_ip( request: Request, @@ -182,6 +196,15 @@ async def ban_ip( response_model=JailCommandResponse, summary="Unban an IP address from one or all jails", dependencies=[Depends(_check_unban_rate_limit)], + responses={ + 200: {"description": "IP unbanned successfully", "model": JailCommandResponse}, + 400: {"description": "Invalid IP address"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "Unban command failed in fail2ban"}, + 429: {"description": "Rate limit exceeded for unban operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def unban_ip( request: Request, @@ -225,6 +248,11 @@ async def unban_ip( "/all", response_model=UnbanAllResponse, summary="Unban every currently banned IP across all jails", + responses={ + 200: {"description": "All bans cleared", "model": UnbanAllResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def unban_all( request: Request, diff --git a/backend/app/routers/blocklist.py b/backend/app/routers/blocklist.py index 28a7e49..aaa5328 100644 --- a/backend/app/routers/blocklist.py +++ b/backend/app/routers/blocklist.py @@ -97,6 +97,10 @@ def _check_blocklist_import_rate_limit( "", response_model=BlocklistListResponse, summary="List all blocklist sources", + responses={ + 200: {"description": "Blocklist sources returned", "model": BlocklistListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def list_blocklists( blocklist_ctx: BlocklistServiceContextDep, @@ -120,6 +124,11 @@ async def list_blocklists( response_model=BlocklistSource, status_code=status.HTTP_201_CREATED, summary="Add a new blocklist source", + responses={ + 201: {"description": "Blocklist source created", "model": BlocklistSource}, + 400: {"description": "URL validation failed"}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def create_blocklist( payload: BlocklistSourceCreate, @@ -157,6 +166,11 @@ async def create_blocklist( response_model=ImportRunResult, summary="Trigger a manual blocklist import", dependencies=[Depends(_check_blocklist_import_rate_limit)], + responses={ + 200: {"description": "Import completed", "model": ImportRunResult}, + 401: {"description": "Session missing, expired, or invalid"}, + 429: {"description": "Rate limit exceeded for blocklist import"}, + }, ) async def run_import_now( http_session: HttpSessionDep, @@ -193,6 +207,10 @@ async def run_import_now( "/schedule", response_model=ScheduleInfo, summary="Get the current import schedule", + responses={ + 200: {"description": "Schedule info returned", "model": ScheduleInfo}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def get_schedule( blocklist_ctx: BlocklistServiceContextDep, @@ -219,6 +237,10 @@ async def get_schedule( "/schedule", response_model=ScheduleInfo, summary="Update the import schedule", + responses={ + 200: {"description": "Schedule updated", "model": ScheduleInfo}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def update_schedule( payload: ScheduleConfig, @@ -255,6 +277,10 @@ async def update_schedule( "/log", response_model=ImportLogListResponse, summary="Get the paginated import log", + responses={ + 200: {"description": "Import log returned", "model": ImportLogListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def get_import_log( blocklist_ctx: BlocklistServiceContextDep, @@ -291,6 +317,11 @@ async def get_import_log( "/{source_id}", response_model=BlocklistSource, summary="Get a single blocklist source", + responses={ + 200: {"description": "Blocklist source returned", "model": BlocklistSource}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Blocklist source not found"}, + }, ) async def get_blocklist( source_id: int, @@ -317,6 +348,12 @@ async def get_blocklist( "/{source_id}", response_model=BlocklistSource, summary="Update a blocklist source", + responses={ + 200: {"description": "Blocklist source updated", "model": BlocklistSource}, + 400: {"description": "URL validation failed"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Blocklist source not found"}, + }, ) async def update_blocklist( source_id: int, @@ -355,6 +392,11 @@ async def update_blocklist( "/{source_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a blocklist source", + responses={ + 204: {"description": "Blocklist source deleted successfully"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Blocklist source not found"}, + }, ) async def delete_blocklist( source_id: int, @@ -380,6 +422,12 @@ async def delete_blocklist( "/{source_id}/preview", response_model=PreviewResponse, summary="Preview the contents of a blocklist source", + responses={ + 200: {"description": "Blocklist preview returned", "model": PreviewResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Blocklist source not found"}, + 502: {"description": "URL could not be reached"}, + }, ) async def preview_blocklist( source_id: int, diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py index ce2618f..a52420b 100644 --- a/backend/app/routers/config_misc.py +++ b/backend/app/routers/config_misc.py @@ -111,6 +111,11 @@ def _validate_log_target(value: str) -> None: "/global", response_model=GlobalConfigResponse, summary="Return global fail2ban settings", + responses={ + 200: {"description": "Global config returned", "model": GlobalConfigResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_global_config( _request: Request, @@ -140,6 +145,13 @@ async def get_global_config( status_code=status.HTTP_204_NO_CONTENT, summary="Update global fail2ban settings", dependencies=[Depends(_check_config_update_rate_limit)], + responses={ + 204: {"description": "Global config updated successfully"}, + 400: {"description": "Set command rejected or log_target invalid"}, + 401: {"description": "Session missing, expired, or invalid"}, + 429: {"description": "Rate limit exceeded for config update operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def update_global_config( _request: Request, @@ -171,6 +183,12 @@ async def update_global_config( "/reload", status_code=status.HTTP_204_NO_CONTENT, summary="Reload fail2ban to apply configuration changes", + responses={ + 204: {"description": "Fail2ban reloaded successfully"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "Reload command failed in fail2ban"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def reload_fail2ban( _request: Request, @@ -199,6 +217,13 @@ async def reload_fail2ban( "/restart", status_code=status.HTTP_204_NO_CONTENT, summary="Restart the fail2ban service", + responses={ + 204: {"description": "Fail2ban restarted successfully"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "Stop command failed in fail2ban"}, + 502: {"description": "fail2ban unreachable for stop command"}, + 503: {"description": "fail2ban did not come back online within 10s"}, + }, ) async def restart_fail2ban( _request: Request, @@ -252,6 +277,11 @@ async def restart_fail2ban( "/regex-test", response_model=RegexTestResponse, summary="Test a fail regex pattern against a sample log line", + responses={ + 200: {"description": "Regex test result", "model": RegexTestResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 422: {"description": "Invalid regex pattern"}, + }, ) async def regex_test( _auth: AuthDep, @@ -281,6 +311,11 @@ async def regex_test( "/preview-log", response_model=LogPreviewResponse, summary="Preview log file lines against a regex pattern", + responses={ + 200: {"description": "Log preview result", "model": LogPreviewResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 422: {"description": "Invalid regex pattern"}, + }, ) async def preview_log( _auth: AuthDep, @@ -310,6 +345,10 @@ async def preview_log( "/map-color-thresholds", response_model=MapColorThresholdsResponse, summary="Get map color threshold configuration", + responses={ + 200: {"description": "Color thresholds returned", "model": MapColorThresholdsResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def get_map_color_thresholds( _request: Request, @@ -334,6 +373,12 @@ async def get_map_color_thresholds( response_model=MapColorThresholdsResponse, summary="Update map color threshold configuration", dependencies=[Depends(_check_config_update_rate_limit)], + responses={ + 200: {"description": "Color thresholds updated", "model": MapColorThresholdsResponse}, + 400: {"description": "Validation error (thresholds not properly ordered)"}, + 401: {"description": "Session missing, expired, or invalid"}, + 429: {"description": "Rate limit exceeded for config update operations"}, + }, ) async def update_map_color_thresholds( _request: Request, @@ -365,6 +410,12 @@ async def update_map_color_thresholds( "/fail2ban-log", response_model=Fail2BanLogResponse, summary="Read the tail of the fail2ban daemon log file", + responses={ + 200: {"description": "Log file lines returned", "model": Fail2BanLogResponse}, + 400: {"description": "Log target not a file or path outside allowed directory"}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_fail2ban_log( _request: Request, @@ -416,6 +467,11 @@ async def get_fail2ban_log( "/service-status", response_model=ServiceStatusResponse, summary="Return fail2ban service health status with log configuration", + responses={ + 200: {"description": "Service status returned", "model": ServiceStatusResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_service_status( _request: Request, diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index ea3456b..94279f9 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -56,6 +56,11 @@ _DEFAULT_RANGE: TimeRange = "24h" "/status", response_model=ServerStatusResponse, summary="Return the cached fail2ban server status", + responses={ + 200: {"description": "Server status returned", "model": ServerStatusResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_server_status( server_status: ServerStatusDep, @@ -84,6 +89,11 @@ async def get_server_status( "/bans", response_model=DashboardBanListResponse, summary="Return a paginated list of recent bans", + responses={ + 200: {"description": "Ban list returned", "model": DashboardBanListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_dashboard_bans( _auth: AuthDep, @@ -145,6 +155,11 @@ async def get_dashboard_bans( "/bans/by-country", response_model=BansByCountryResponse, summary="Return ban counts aggregated by country", + responses={ + 200: {"description": "Ban counts by country returned", "model": BansByCountryResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_bans_by_country( _auth: AuthDep, @@ -205,6 +220,11 @@ async def get_bans_by_country( "/bans/trend", response_model=BanTrendResponse, summary="Return ban counts aggregated into time buckets", + responses={ + 200: {"description": "Ban trend data returned", "model": BanTrendResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_ban_trend( _auth: AuthDep, @@ -259,6 +279,11 @@ async def get_ban_trend( "/bans/by-jail", response_model=BansByJailResponse, summary="Return ban counts aggregated by jail", + responses={ + 200: {"description": "Ban counts by jail returned", "model": BansByJailResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_bans_by_jail( _auth: AuthDep, diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index e2c276e..706d8af 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -75,6 +75,11 @@ _NamePath = Annotated[ "/jail-files", response_model=JailConfigFilesResponse, summary="List all jail config files", + responses={ + 200: {"description": "Jail config files returned", "model": JailConfigFilesResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def list_jail_config_files( config_dir: Fail2BanConfigDirDep, @@ -97,6 +102,13 @@ async def list_jail_config_files( "/jail-files/{filename}", response_model=JailConfigFileContent, summary="Return a single jail config file with its content", + responses={ + 200: {"description": "Jail config file returned", "model": JailConfigFileContent}, + 400: {"description": "Filename unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_jail_config_file( config_dir: Fail2BanConfigDirDep, @@ -123,6 +135,13 @@ async def get_jail_config_file( "/jail-files/{filename}", status_code=status.HTTP_204_NO_CONTENT, summary="Overwrite a jail.d config file with new raw content", + responses={ + 204: {"description": "File overwritten successfully"}, + 400: {"description": "Filename unsafe or content invalid"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def write_jail_config_file( config_dir: Fail2BanConfigDirDep, @@ -151,6 +170,13 @@ async def write_jail_config_file( "/jail-files/{filename}/enabled", status_code=status.HTTP_204_NO_CONTENT, summary="Enable or disable a jail configuration file", + responses={ + 204: {"description": "Enabled state updated successfully"}, + 400: {"description": "Filename unsafe or operation failed"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def set_jail_config_file_enabled( config_dir: Fail2BanConfigDirDep, @@ -182,6 +208,13 @@ async def set_jail_config_file_enabled( response_model=ConfFileContent, status_code=status.HTTP_201_CREATED, summary="Create a new jail.d config file", + responses={ + 201: {"description": "File created", "model": ConfFileContent}, + 400: {"description": "Name unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "File with that name already exists"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def create_jail_config_file( config_dir: Fail2BanConfigDirDep, @@ -220,6 +253,13 @@ async def create_jail_config_file( "/filters/{name}/raw", response_model=ConfFileContent, summary="Return a filter definition file's raw content", + responses={ + 200: {"description": "Filter file returned", "model": ConfFileContent}, + 400: {"description": "Name unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_filter_file_raw( config_dir: Fail2BanConfigDirDep, @@ -250,6 +290,13 @@ async def get_filter_file_raw( "/filters/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, summary="Update a filter definition file (raw content)", + responses={ + 204: {"description": "Filter file updated successfully"}, + 400: {"description": "Name unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def write_filter_file( config_dir: Fail2BanConfigDirDep, @@ -276,6 +323,13 @@ async def write_filter_file( status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new filter definition file (raw content)", + responses={ + 201: {"description": "Filter file created", "model": ConfFileContent}, + 400: {"description": "Name invalid or content exceeds limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "File with that name already exists"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def create_filter_file( config_dir: Fail2BanConfigDirDep, @@ -314,6 +368,11 @@ async def create_filter_file( "/actions", response_model=ConfFilesResponse, summary="List all action definition files", + responses={ + 200: {"description": "Action files returned", "model": ConfFilesResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def list_action_files( config_dir: Fail2BanConfigDirDep, @@ -333,6 +392,13 @@ async def list_action_files( "/actions/{name}/raw", response_model=ConfFileContent, summary="Return an action definition file with its content", + responses={ + 200: {"description": "Action file returned", "model": ConfFileContent}, + 400: {"description": "Name unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_action_file( config_dir: Fail2BanConfigDirDep, @@ -359,6 +425,13 @@ async def get_action_file( "/actions/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action definition file", + responses={ + 204: {"description": "Action file updated successfully"}, + 400: {"description": "Name unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "File not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def write_action_file( config_dir: Fail2BanConfigDirDep, @@ -385,6 +458,13 @@ async def write_action_file( status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new action definition file", + responses={ + 201: {"description": "Action file created", "model": ConfFileContent}, + 400: {"description": "Name invalid or content exceeds limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "File with that name already exists"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def create_action_file( config_dir: Fail2BanConfigDirDep, @@ -423,6 +503,13 @@ async def create_action_file( "/filters/{name}/parsed", response_model=FilterConfig, summary="Return a filter file parsed into a structured model", + responses={ + 200: {"description": "Filter config returned", "model": FilterConfig}, + 400: {"description": "Name unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Filter file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_parsed_filter( config_dir: Fail2BanConfigDirDep, @@ -453,6 +540,13 @@ async def get_parsed_filter( "/filters/{name}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update a filter file from a structured model", + responses={ + 204: {"description": "Filter file updated successfully"}, + 400: {"description": "Name unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Filter file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def update_parsed_filter( config_dir: Fail2BanConfigDirDep, @@ -486,6 +580,13 @@ async def update_parsed_filter( "/actions/{name}/parsed", response_model=ActionConfig, summary="Return an action file parsed into a structured model", + responses={ + 200: {"description": "Action config returned", "model": ActionConfig}, + 400: {"description": "Name unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Action file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_parsed_action( config_dir: Fail2BanConfigDirDep, @@ -516,6 +617,13 @@ async def get_parsed_action( "/actions/{name}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action file from a structured model", + responses={ + 204: {"description": "Action file updated successfully"}, + 400: {"description": "Name unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Action file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def update_parsed_action( config_dir: Fail2BanConfigDirDep, @@ -549,6 +657,13 @@ async def update_parsed_action( "/jail-files/{filename}/parsed", response_model=JailFileConfig, summary="Return a jail.d file parsed into a structured model", + responses={ + 200: {"description": "Jail file config returned", "model": JailFileConfig}, + 400: {"description": "Filename unsafe"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def get_parsed_jail_file( config_dir: Fail2BanConfigDirDep, @@ -579,6 +694,13 @@ async def get_parsed_jail_file( "/jail-files/{filename}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update a jail.d file from a structured model", + responses={ + 204: {"description": "Jail file updated successfully"}, + 400: {"description": "Filename unsafe or content exceeds size limit"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail file not found"}, + 503: {"description": "Config directory unavailable"}, + }, ) async def update_parsed_jail_file( config_dir: Fail2BanConfigDirDep, diff --git a/backend/app/routers/filter_config.py b/backend/app/routers/filter_config.py index b84ce89..89134ce 100644 --- a/backend/app/routers/filter_config.py +++ b/backend/app/routers/filter_config.py @@ -125,6 +125,11 @@ _FilterNamePath = Annotated[ "", response_model=FilterListResponse, summary="List all available filters with active/inactive status", + responses={ + 200: {"description": "Filter list returned", "model": FilterListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def list_filters( request: Request, @@ -163,6 +168,12 @@ async def list_filters( "/{name}", response_model=FilterConfig, summary="Return full parsed detail for a single filter", + responses={ + 200: {"description": "Filter config returned", "model": FilterConfig}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Filter not found in filter.d/"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_filter( request: Request, @@ -208,6 +219,15 @@ _FilterNamePath = Annotated[ response_model=FilterConfig, summary="Update a filter's .local override with new regex/pattern values", dependencies=[Depends(_check_filter_update_rate_limit)], + responses={ + 200: {"description": "Filter updated", "model": FilterConfig}, + 400: {"description": "Invalid filter name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Filter not found"}, + 422: {"description": "Regex pattern failed to compile"}, + 429: {"description": "Rate limit exceeded for filter update operations"}, + 500: {"description": "Failed to write .local file"}, + }, ) async def update_filter( request: Request, @@ -260,6 +280,15 @@ async def update_filter( status_code=status.HTTP_201_CREATED, summary="Create a new user-defined filter", dependencies=[Depends(_check_filter_create_rate_limit)], + responses={ + 201: {"description": "Filter created", "model": FilterConfig}, + 400: {"description": "Invalid filter name or regex too long"}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "Filter already exists"}, + 422: {"description": "Regex pattern failed to compile"}, + 429: {"description": "Rate limit exceeded for filter create operations"}, + 500: {"description": "Failed to write .local file"}, + }, ) async def create_filter( request: Request, @@ -309,6 +338,15 @@ async def create_filter( status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user-created filter's .local file", dependencies=[Depends(_check_filter_delete_rate_limit)], + responses={ + 204: {"description": "Filter deleted successfully"}, + 400: {"description": "Invalid filter name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Filter not found"}, + 409: {"description": "Filter is a shipped default (conf-only)"}, + 429: {"description": "Rate limit exceeded for filter delete operations"}, + 500: {"description": "Failed to delete .local file"}, + }, ) async def delete_filter( request: Request, diff --git a/backend/app/routers/geo.py b/backend/app/routers/geo.py index 9195e4c..b1ce458 100644 --- a/backend/app/routers/geo.py +++ b/backend/app/routers/geo.py @@ -30,6 +30,12 @@ _IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")] "/lookup/{ip}", response_model=IpLookupResponse, summary="Look up ban status and geo information for an IP", + responses={ + 200: {"description": "IP lookup result returned", "model": IpLookupResponse}, + 400: {"description": "Invalid IP address"}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def lookup_ip( _auth: AuthDep, @@ -75,6 +81,10 @@ async def lookup_ip( "/stats", response_model=GeoCacheStatsResponse, summary="Geo cache diagnostic counters", + responses={ + 200: {"description": "Geo cache stats returned", "model": GeoCacheStatsResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def geo_stats( _auth: AuthDep, @@ -99,6 +109,10 @@ async def geo_stats( "/re-resolve", summary="Re-resolve all IPs whose country could not be determined", response_model=GeoReResolveResponse, + responses={ + 200: {"description": "Re-resolve result", "model": GeoReResolveResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def re_resolve_geo( _auth: AuthDep, diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 2fdbe03..52bc591 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -28,7 +28,15 @@ router: APIRouter = APIRouter(prefix="/api/v1/health", tags=["Health"]) log: structlog.stdlib.BoundLogger = structlog.get_logger() -@router.get("", summary="Application health check", response_model=HealthResponse) +@router.get( + "", + summary="Application health check", + response_model=HealthResponse, + responses={ + 200: {"description": "All components healthy"}, + 503: {"description": "fail2ban offline or component degraded"}, + }, +) async def health_check( app_state: AppStateDep, server_status: ServerStatusDep, diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 52ee30c..b93d6a8 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -41,6 +41,11 @@ router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"]) "", response_model=HistoryListResponse, summary="Return a paginated list of historical bans", + responses={ + 200: {"description": "History list returned", "model": HistoryListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_history( request: Request, @@ -120,6 +125,11 @@ async def get_history( "/archive", response_model=HistoryListResponse, summary="Return a paginated list of archived historical bans", + responses={ + 200: {"description": "Archived history list returned", "model": HistoryListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_history_archive( request: Request, @@ -157,6 +167,12 @@ async def get_history_archive( "/{ip}", response_model=IpDetailResponse, summary="Return the full ban history for a single IP address", + responses={ + 200: {"description": "IP history detail returned", "model": IpDetailResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "No history found for this IP"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_ip_history( request: Request, diff --git a/backend/app/routers/jail_config.py b/backend/app/routers/jail_config.py index d87e965..2b39b4d 100644 --- a/backend/app/routers/jail_config.py +++ b/backend/app/routers/jail_config.py @@ -213,6 +213,11 @@ _NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban "", response_model=JailConfigListResponse, summary="List configuration for all active jails", + responses={ + 200: {"description": "Jail configs returned", "model": JailConfigListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_jail_configs( request: Request, @@ -241,6 +246,11 @@ async def get_jail_configs( "/inactive", response_model=InactiveJailListResponse, summary="List all inactive jails discovered in config files", + responses={ + 200: {"description": "Inactive jail list returned", "model": InactiveJailListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_inactive_jails( request: Request, @@ -268,6 +278,10 @@ async def get_inactive_jails( "/pending-recovery", response_model=PendingRecovery | None, summary="Return active crash-recovery record if one exists", + responses={ + 200: {"description": "Recovery record or null", "model": PendingRecovery}, + 401: {"description": "Session missing, expired, or invalid"}, + }, ) async def get_pending_recovery( _auth: AuthDep, @@ -293,6 +307,12 @@ async def get_pending_recovery( "/{name}", response_model=JailConfigResponse, summary="Return configuration for a single jail", + responses={ + 200: {"description": "Jail config returned", "model": JailConfigResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_jail_config( request: Request, @@ -325,6 +345,15 @@ async def get_jail_config( status_code=status.HTTP_204_NO_CONTENT, summary="Update jail configuration", dependencies=[Depends(_check_jail_update_rate_limit)], + responses={ + 204: {"description": "Jail config updated successfully"}, + 400: {"description": "Set command rejected or invalid regex"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 422: {"description": "Regex pattern failed to compile"}, + 429: {"description": "Rate limit exceeded for jail update operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def update_jail_config( request: Request, @@ -365,6 +394,14 @@ async def update_jail_config( status_code=status.HTTP_204_NO_CONTENT, summary="Add a log file path to an existing jail", dependencies=[Depends(_check_jail_create_rate_limit)], + responses={ + 204: {"description": "Log path added successfully"}, + 400: {"description": "Command rejected or path invalid"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 429: {"description": "Rate limit exceeded for jail create operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def add_log_path( request: Request, @@ -400,6 +437,15 @@ async def add_log_path( status_code=status.HTTP_204_NO_CONTENT, summary="Remove a monitored log path from a jail", dependencies=[Depends(_check_jail_delete_rate_limit)], + responses={ + 204: {"description": "Log path removed successfully"}, + 400: {"description": "Command rejected"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 422: {"description": "Log path outside allowed directories"}, + 429: {"description": "Rate limit exceeded for jail delete operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def delete_log_path( request: Request, @@ -440,6 +486,15 @@ async def delete_log_path( response_model=JailActivationResponse, summary="Activate an inactive jail", dependencies=[Depends(_check_jail_activate_rate_limit)], + responses={ + 200: {"description": "Jail activated", "model": JailActivationResponse}, + 400: {"description": "Invalid jail name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found in config files"}, + 409: {"description": "Jail already active"}, + 429: {"description": "Rate limit exceeded for jail activate operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def activate_jail( app: AppDep, @@ -494,6 +549,15 @@ async def activate_jail( response_model=JailActivationResponse, summary="Deactivate an active jail", dependencies=[Depends(_check_jail_deactivate_rate_limit)], + responses={ + 200: {"description": "Jail deactivated", "model": JailActivationResponse}, + 400: {"description": "Invalid jail name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found in config files"}, + 409: {"description": "Jail already inactive"}, + 429: {"description": "Rate limit exceeded for jail deactivate operations"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def deactivate_jail( _auth: AuthDep, @@ -536,6 +600,15 @@ async def deactivate_jail( "/{name}/local", status_code=status.HTTP_204_NO_CONTENT, summary="Delete the jail.d override file for an inactive jail", + responses={ + 204: {"description": "Override file deleted successfully"}, + 400: {"description": "Invalid jail name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found in config files"}, + 409: {"description": "Jail currently active"}, + 500: {"description": "File cannot be deleted"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def delete_jail_local_override( request: Request, @@ -578,6 +651,12 @@ async def delete_jail_local_override( "/{name}/validate", response_model=JailValidationResult, summary="Validate jail configuration before activation", + responses={ + 200: {"description": "Validation result", "model": JailValidationResult}, + 400: {"description": "Invalid jail name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found in config files"}, + }, ) async def validate_jail( request: Request, @@ -609,6 +688,12 @@ async def validate_jail( "/{name}/rollback", response_model=RollbackResponse, summary="Disable a bad jail config and restart fail2ban", + responses={ + 200: {"description": "Rollback completed", "model": RollbackResponse}, + 400: {"description": "Invalid jail name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 500: {"description": "Failed to write .local override file"}, + }, ) async def rollback_jail( _auth: AuthDep, @@ -654,6 +739,14 @@ async def rollback_jail( status_code=status.HTTP_204_NO_CONTENT, summary="Assign a filter to a jail", dependencies=[Depends(_check_jail_create_rate_limit)], + responses={ + 204: {"description": "Filter assigned successfully"}, + 400: {"description": "Invalid jail name or filter name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail or filter not found"}, + 429: {"description": "Rate limit exceeded for jail create operations"}, + 500: {"description": "Failed to write .local override file"}, + }, ) async def assign_filter_to_jail( request: Request, @@ -689,6 +782,14 @@ async def assign_filter_to_jail( status_code=status.HTTP_204_NO_CONTENT, summary="Add an action to a jail", dependencies=[Depends(_check_jail_create_rate_limit)], + responses={ + 204: {"description": "Action added successfully"}, + 400: {"description": "Invalid jail name or action name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail or action not found"}, + 429: {"description": "Rate limit exceeded for jail create operations"}, + 500: {"description": "Failed to write .local override file"}, + }, ) async def assign_action_to_jail( request: Request, @@ -725,6 +826,14 @@ async def assign_action_to_jail( status_code=status.HTTP_204_NO_CONTENT, summary="Remove an action from a jail", dependencies=[Depends(_check_jail_delete_rate_limit)], + responses={ + 204: {"description": "Action removed successfully"}, + 400: {"description": "Invalid jail name or action name"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found in config files"}, + 429: {"description": "Rate limit exceeded for jail delete operations"}, + 500: {"description": "Failed to write .local override file"}, + }, ) async def remove_action_from_jail( request: Request, diff --git a/backend/app/routers/jails.py b/backend/app/routers/jails.py index ee87faa..a0f6bb9 100644 --- a/backend/app/routers/jails.py +++ b/backend/app/routers/jails.py @@ -57,6 +57,11 @@ _NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban "", response_model=JailListResponse, summary="List all active fail2ban jails", + responses={ + 200: {"description": "Jails list returned", "model": JailListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_jails( _auth: AuthDep, @@ -85,6 +90,12 @@ async def get_jails( "/{name}", response_model=JailDetailResponse, summary="Return full detail for a single jail", + responses={ + 200: {"description": "Jail detail returned", "model": JailDetailResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_jail( _auth: AuthDep, @@ -129,6 +140,12 @@ async def get_jail( "/reload-all", response_model=JailCommandResponse, summary="Reload all fail2ban jails", + responses={ + 200: {"description": "All jails reloaded", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def reload_all_jails( _auth: AuthDep, @@ -157,6 +174,13 @@ async def reload_all_jails( "/{name}/start", response_model=JailCommandResponse, summary="Start a stopped jail", + responses={ + 200: {"description": "Jail started", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def start_jail( _auth: AuthDep, @@ -185,6 +209,12 @@ async def start_jail( "/{name}/stop", response_model=JailCommandResponse, summary="Stop a running jail", + responses={ + 200: {"description": "Jail stopped", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def stop_jail( _auth: AuthDep, @@ -216,6 +246,13 @@ async def stop_jail( "/{name}/idle", response_model=JailCommandResponse, summary="Toggle idle mode for a jail", + responses={ + 200: {"description": "Idle mode toggled", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def toggle_idle( _auth: AuthDep, @@ -253,6 +290,13 @@ async def toggle_idle( "/{name}/reload", response_model=JailCommandResponse, summary="Reload a single jail", + responses={ + 200: {"description": "Jail reloaded", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def reload_jail( _auth: AuthDep, @@ -294,6 +338,12 @@ class _IgnoreSelfRequest(IgnoreIpRequest): "/{name}/ignoreip", response_model=IgnoreListResponse, summary="List the ignore IPs for a jail", + responses={ + 200: {"description": "Ignore list returned", "model": IgnoreListResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_ignore_list( _auth: AuthDep, @@ -322,6 +372,14 @@ async def get_ignore_list( status_code=status.HTTP_201_CREATED, response_model=JailCommandResponse, summary="Add an IP or network to the ignore list", + responses={ + 201: {"description": "IP added to ignore list", "model": JailCommandResponse}, + 400: {"description": "IP or network invalid"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def add_ignore_ip( _auth: AuthDep, @@ -359,6 +417,13 @@ async def add_ignore_ip( "/{name}/ignoreip", response_model=JailCommandResponse, summary="Remove an IP or network from the ignore list", + responses={ + 200: {"description": "IP removed from ignore list", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def del_ignore_ip( _auth: AuthDep, @@ -392,6 +457,13 @@ async def del_ignore_ip( "/{name}/ignoreself", response_model=JailCommandResponse, summary="Toggle the ignoreself option for a jail", + responses={ + 200: {"description": "ignoreself toggled", "model": JailCommandResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 409: {"description": "fail2ban reports operation failed"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def toggle_ignore_self( _auth: AuthDep, @@ -434,6 +506,13 @@ async def toggle_ignore_self( "/{name}/banned", response_model=JailBannedIpsResponse, summary="Return paginated currently-banned IPs for a single jail", + responses={ + 200: {"description": "Banned IPs returned", "model": JailBannedIpsResponse}, + 400: {"description": "page or page_size out of range"}, + 401: {"description": "Session missing, expired, or invalid"}, + 404: {"description": "Jail not found"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_jail_banned_ips( _auth: AuthDep, diff --git a/backend/app/routers/server.py b/backend/app/routers/server.py index 25de9d4..2db5b37 100644 --- a/backend/app/routers/server.py +++ b/backend/app/routers/server.py @@ -30,6 +30,11 @@ router: APIRouter = APIRouter(prefix="/api/v1/server", tags=["Server"]) "/settings", response_model=ServerSettingsResponse, summary="Return fail2ban server-level settings", + responses={ + 200: {"description": "Server settings returned", "model": ServerSettingsResponse}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def get_server_settings( request: Request, @@ -59,6 +64,12 @@ async def get_server_settings( "/settings", status_code=status.HTTP_204_NO_CONTENT, summary="Update fail2ban server-level settings", + responses={ + 204: {"description": "Settings updated successfully"}, + 400: {"description": "Set command rejected by fail2ban"}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def update_server_settings( request: Request, @@ -88,6 +99,12 @@ async def update_server_settings( status_code=status.HTTP_200_OK, summary="Flush and re-open fail2ban log files", response_model=FlushLogsResponse, + responses={ + 200: {"description": "Logs flushed successfully", "model": FlushLogsResponse}, + 400: {"description": "Command rejected by fail2ban"}, + 401: {"description": "Session missing, expired, or invalid"}, + 502: {"description": "fail2ban unreachable"}, + }, ) async def flush_logs( request: Request, diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py index 11adc0c..87daf36 100644 --- a/backend/app/routers/setup.py +++ b/backend/app/routers/setup.py @@ -23,9 +23,12 @@ router = APIRouter(prefix="/api/v1/setup", tags=["setup"]) @router.get( - "", + "/", response_model=SetupStatusResponse, summary="Check whether setup has been completed", + responses={ + 200: {"description": "Setup status returned", "model": SetupStatusResponse}, + }, ) async def get_setup_status(app: AppDep) -> SetupStatusResponse: """Return whether the initial setup wizard has been completed. @@ -43,6 +46,11 @@ async def get_setup_status(app: AppDep) -> SetupStatusResponse: response_model=SetupResponse, status_code=status.HTTP_201_CREATED, summary="Run the initial setup wizard", + responses={ + 201: {"description": "Setup completed successfully", "model": SetupResponse}, + 400: {"description": "Validation error in request body"}, + 409: {"description": "Setup already completed"}, + }, ) async def post_setup( app: AppDep, @@ -88,6 +96,9 @@ async def post_setup( "/timezone", response_model=SetupTimezoneResponse, summary="Return the configured IANA timezone", + responses={ + 200: {"description": "Timezone returned", "model": SetupTimezoneResponse}, + }, ) async def get_timezone(settings: SettingsDep) -> SetupTimezoneResponse: """Return the IANA timezone configured during the initial setup wizard.