From 1d290fb326a26095ec742494b8c30a24329c0d96 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 13:28:39 -0400 Subject: [PATCH 01/12] add saved prompt (get) action Signed-off-by: Jordan Dubrick --- src/models/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/models/config.py b/src/models/config.py index 28ff23264..3d6332899 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -1307,6 +1307,9 @@ class Action(str, Enum): MANAGE_PROMPTS = "manage_prompts" READ_PROMPTS = "read_prompts" + # User saved prompts (/v1/saved-prompts) + LIST_SAVED_PROMPTS = "list_saved_prompts" + class AccessRule(ConfigurationBase): """Rule defining what actions a role can perform.""" From 8736949ec19a5481dc4764bfa82cb5b9e4c68522 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 13:29:34 -0400 Subject: [PATCH 02/12] update docs for new saved prompt get action Signed-off-by: Jordan Dubrick --- docs/auth/authorization.md | 1 + docs/devel_doc/ARCHITECTURE.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/auth/authorization.md b/docs/auth/authorization.md index 34fa38250..9c4aa6522 100644 --- a/docs/auth/authorization.md +++ b/docs/auth/authorization.md @@ -111,6 +111,7 @@ authorization: | `model_override` | Override model in queries | N/A (permission flag) | | `read_prompts` | List and get prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | | `manage_prompts` | Manage prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | +| `list_saved_prompts` | List own saved prompts | `/v1/saved-prompts` | ### Conversation Actions diff --git a/docs/devel_doc/ARCHITECTURE.md b/docs/devel_doc/ARCHITECTURE.md index baa4ad12d..68b346430 100644 --- a/docs/devel_doc/ARCHITECTURE.md +++ b/docs/devel_doc/ARCHITECTURE.md @@ -200,6 +200,9 @@ The system defines 30+ actions that can be authorized. Examples (see `docs/auth. - `GET_CONVERSATION` - Get conversation details - `DELETE_CONVERSATION` - Delete conversations +**Saved Prompts:** +- `LIST_SAVED_PROMPTS` - List user's saved prompts + **Administrative Actions:** - `ADMIN` - Administrative operations - `FEEDBACK` - Submit user feedback From 11bc7a69a50a2107b47d28af3f925ca76ce90a5e Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 13:42:14 -0400 Subject: [PATCH 03/12] add savedpromptresponse class and tests Signed-off-by: Jordan Dubrick --- .../api/responses/successful/saved_prompts.py | 90 ++++++++++++++++++- .../test_saved_prompts_list_response.py | 74 +++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 tests/unit/models/test_saved_prompts_list_response.py diff --git a/src/models/api/responses/successful/saved_prompts.py b/src/models/api/responses/successful/saved_prompts.py index c12e30e47..c05fd7733 100644 --- a/src/models/api/responses/successful/saved_prompts.py +++ b/src/models/api/responses/successful/saved_prompts.py @@ -1,4 +1,6 @@ -"""Successful responses for saved prompts configuration.""" +"""Successful responses for saved prompts configuration and listing.""" + +from datetime import datetime from pydantic import Field @@ -42,3 +44,89 @@ class SavedPromptsConfigResponse(AbstractSuccessfulResponse): ] }, } + + +class SavedPromptResponse(AbstractSuccessfulResponse): + """Single saved prompt returned to an authenticated user. + + Attributes: + id: Unique identifier of the saved prompt. + name: Display name of the saved prompt. + content: Prompt body text. + created_at: When the prompt was created. + updated_at: When the prompt was last updated. + """ + + id: str = Field( + ..., + description="Unique identifier of the saved prompt", + examples=["abc123"], + ) + name: str = Field( + ..., + description="Display name of the saved prompt", + examples=["Deploy to staging"], + ) + content: str = Field( + ..., + description="Prompt body text", + examples=["Help me write a deployment checklist…"], + ) + created_at: datetime = Field( + ..., + description="When the prompt was created", + examples=["2026-07-22T16:00:00+00:00"], + ) + updated_at: datetime = Field( + ..., + description="When the prompt was last updated", + examples=["2026-07-22T16:00:00+00:00"], + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "id": "abc123", + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + "created_at": "2026-07-22T16:00:00+00:00", + "updated_at": "2026-07-22T16:00:00+00:00", + } + ] + }, + } + + +class SavedPromptsListResponse(AbstractSuccessfulResponse): + """List of saved prompts belonging to the authenticated user. + + Attributes: + prompts: Saved prompts ordered by created_at descending (newest first). + """ + + prompts: list[SavedPromptResponse] = Field( + ..., + description="Saved prompts for the authenticated user", + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "prompts": [ + { + "id": "abc123", + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + "created_at": "2026-07-22T16:00:00+00:00", + "updated_at": "2026-07-22T16:00:00+00:00", + } + ] + }, + {"prompts": []}, + ] + }, + } diff --git a/tests/unit/models/test_saved_prompts_list_response.py b/tests/unit/models/test_saved_prompts_list_response.py new file mode 100644 index 000000000..9c57d7c3c --- /dev/null +++ b/tests/unit/models/test_saved_prompts_list_response.py @@ -0,0 +1,74 @@ +"""Unit tests for saved prompts list response models.""" + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from models.api.responses.successful.saved_prompts import ( + SavedPromptResponse, + SavedPromptsListResponse, +) + + +def test_saved_prompt_response_serializes_expected_fields() -> None: + """SavedPromptResponse exposes id/name/content/timestamps and omits user_id.""" + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + updated = datetime(2026, 7, 22, 16, 5, 0, tzinfo=UTC) + item = SavedPromptResponse( + id="prompt-1", + name="Deploy to staging", + content="Help me write a deployment checklist", + created_at=created, + updated_at=updated, + ) + + payload = item.model_dump(mode="json") + + assert set(payload.keys()) == { + "id", + "name", + "content", + "created_at", + "updated_at", + } + assert payload["id"] == "prompt-1" + assert payload["name"] == "Deploy to staging" + assert payload["content"] == "Help me write a deployment checklist" + assert datetime.fromisoformat(payload["created_at"]) == created + assert datetime.fromisoformat(payload["updated_at"]) == updated + assert "user_id" not in payload + + +def test_saved_prompt_response_forbids_extra_fields() -> None: + """SavedPromptResponse rejects unknown fields such as user_id.""" + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + with pytest.raises(ValidationError): + SavedPromptResponse( + id="prompt-1", + name="Deploy", + content="body", + created_at=created, + updated_at=created, + user_id="user-1", # type: ignore[call-arg] + ) + + +def test_saved_prompts_list_response_empty_and_populated() -> None: + """SavedPromptsListResponse wraps prompts list including empty.""" + assert SavedPromptsListResponse(prompts=[]).model_dump() == {"prompts": []} + + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + response = SavedPromptsListResponse( + prompts=[ + SavedPromptResponse( + id="prompt-1", + name="one", + content="c1", + created_at=created, + updated_at=created, + ) + ] + ) + assert len(response.prompts) == 1 + assert response.prompts[0].name == "one" From ef1f7cc863743b9c81c575a42f5b3a3298402bdd Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 13:45:15 -0400 Subject: [PATCH 04/12] register savedpromptresponse Signed-off-by: Jordan Dubrick --- src/models/api/responses/successful/README.md | 2 +- src/models/api/responses/successful/__init__.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/models/api/responses/successful/README.md b/src/models/api/responses/successful/README.md index e35bc39d6..1982f57da 100644 --- a/src/models/api/responses/successful/README.md +++ b/src/models/api/responses/successful/README.md @@ -37,7 +37,7 @@ Successful response model for the OpenAI-compatible Responses API. Models for rlsapi v1 REST API responses. ## [saved_prompts.py](saved_prompts.py) -Successful responses for saved prompts configuration. +Successful responses for saved prompts configuration and listing. ## [vector_stores.py](vector_stores.py) Successful responses for vector stores and vector store files. diff --git a/src/models/api/responses/successful/__init__.py b/src/models/api/responses/successful/__init__.py index 0f4a4ef2b..ae95cf3f2 100644 --- a/src/models/api/responses/successful/__init__.py +++ b/src/models/api/responses/successful/__init__.py @@ -49,7 +49,11 @@ RlsapiV1InferData, RlsapiV1InferResponse, ) -from models.api.responses.successful.saved_prompts import SavedPromptsConfigResponse +from models.api.responses.successful.saved_prompts import ( + SavedPromptResponse, + SavedPromptsConfigResponse, + SavedPromptsListResponse, +) from models.api.responses.successful.vector_stores import ( FileResponse, VectorStoreDeleteResponse, @@ -90,7 +94,9 @@ "ResponsesResponse", "RlsapiV1InferData", "RlsapiV1InferResponse", + "SavedPromptResponse", "SavedPromptsConfigResponse", + "SavedPromptsListResponse", "ShieldsResponse", "StatusResponse", "StreamingInterruptResponse", From 7f20755f72219ac87432d31c01092101ad24c328 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 14:02:33 -0400 Subject: [PATCH 05/12] add saved prompts get endpoint Signed-off-by: Jordan Dubrick --- src/app/endpoints/saved_prompts.py | 73 +++++- .../unit/app/endpoints/test_saved_prompts.py | 248 +++++++++++++++++- tests/unit/utils/test_saved_prompts.py | 21 +- 3 files changed, 338 insertions(+), 4 deletions(-) diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 474b4126b..d47bb133a 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -3,6 +3,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.exc import SQLAlchemyError from authentication import get_auth_dependency from authentication.interface import AuthTuple @@ -16,9 +17,14 @@ ServiceUnavailableResponse, UnauthorizedResponse, ) -from models.api.responses.successful import SavedPromptsConfigResponse +from models.api.responses.successful import ( + SavedPromptResponse, + SavedPromptsConfigResponse, + SavedPromptsListResponse, +) from models.config import Action from utils.endpoints import check_configuration_loaded +from utils.saved_prompts import list_saved_prompts_by_user logger = get_logger(__name__) router = APIRouter(tags=["saved-prompts"]) @@ -32,6 +38,15 @@ 503: ServiceUnavailableResponse.openapi_response(examples=["kubernetes api"]), } +list_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 200: SavedPromptsListResponse.openapi_response(), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + @router.get("/saved-prompts/config", responses=get_saved_prompts_config_responses) @authorize(Action.GET_CONFIG) @@ -86,3 +101,59 @@ async def get_saved_prompts_config_handler( max_display_name_length=max_display_name_length, max_content_length=max_content_length, ) + + +@router.get("/saved-prompts", responses=list_saved_prompts_responses) +@authorize(Action.LIST_SAVED_PROMPTS) +async def list_saved_prompts_handler( + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], + request: Request, +) -> SavedPromptsListResponse: + """ + Handle requests to the GET /saved-prompts endpoint. + + Process GET requests that return all saved prompts belonging to the + authenticated user, ordered by creation timestamp descending. For example: + + curl http://localhost:8080/v1/saved-prompts + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 500 when configuration is not loaded or the + database query fails. + + ### Returns: + - SavedPromptsListResponse: Saved prompts for the authenticated user. + """ + _ = request + check_configuration_loaded(configuration) + + user_id = auth[0] + logger.info("Retrieving saved prompts for user %s", user_id) + + try: + rows = list_saved_prompts_by_user(user_id) + prompts = [ + SavedPromptResponse( + id=row.id, + name=row.name, + content=row.content, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ] + except SQLAlchemyError as exc: + logger.exception( + "Error retrieving saved prompts for user %s: %s", user_id, exc + ) + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Saved prompts for user %s: %s", user_id, len(prompts)) + return SavedPromptsListResponse(prompts=prompts) diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index e55f350c2..78b6dc848 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -1,4 +1,7 @@ -"""Unit tests for the /saved-prompts/config REST API endpoint.""" +"""Unit tests for the /saved-prompts REST API endpoints.""" + +from datetime import UTC, datetime +from types import SimpleNamespace import pytest from fastapi import FastAPI, HTTPException, Request, status @@ -6,13 +9,18 @@ from pytest_mock import MockerFixture import constants -from app.endpoints.saved_prompts import get_saved_prompts_config_handler, router +from app.endpoints.saved_prompts import ( + get_saved_prompts_config_handler, + list_saved_prompts_handler, + router, +) from authentication.interface import AuthTuple from configuration import AppConfig from models.config import Action from tests.unit.utils.auth_helpers import mock_authorization_resolvers MOCK_AUTH: AuthTuple = ("test_user_id", "test_user", True, "test_token") +MOCK_LIST_AUTH: AuthTuple = ("user-1", "test_user", True, "test_token") CUSTOM_MAX_PROMPTS_PER_USER = 100 CUSTOM_MAX_DISPLAY_NAME_LENGTH = 128 @@ -253,3 +261,239 @@ async def test_get_saved_prompts_config_uses_get_config_action( await_args = perform_check.await_args assert await_args is not None assert await_args.args[0] == Action.GET_CONFIG + + +def _prompt_row( + *, + prompt_id: str, + user_id: str, + name: str, + content: str, + created_at: datetime, + updated_at: datetime, +) -> SimpleNamespace: + """Build a SavedPrompt-like object for handler mapping tests.""" + return SimpleNamespace( + id=prompt_id, + user_id=user_id, + name=name, + content=content, + created_at=created_at, + updated_at=updated_at, + ) + + +@pytest.mark.asyncio +async def test_list_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts maps DAL rows and preserves order without user_id.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + newer_ts = datetime(2026, 7, 22, 16, 5, 0, tzinfo=UTC) + older_ts = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_list = mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[ + _prompt_row( + prompt_id="p-newer", + user_id="user-1", + name="second", + content="c2", + created_at=newer_ts, + updated_at=newer_ts, + ), + _prompt_row( + prompt_id="p-older", + user_id="user-1", + name="first", + content="c1", + created_at=older_ts, + updated_at=older_ts, + ), + ], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + mock_list.assert_called_once_with("user-1") + payload = response.model_dump(mode="json") + assert [item["id"] for item in payload["prompts"]] == ["p-newer", "p-older"] + assert payload["prompts"][0]["name"] == "second" + assert payload["prompts"][0]["content"] == "c2" + assert "user_id" not in payload["prompts"][0] + assert "user_id" not in payload + + +@pytest.mark.asyncio +async def test_list_saved_prompts_empty( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns an empty prompts list when DAL is empty.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert response.model_dump() == {"prompts": []} + + +@pytest.mark.asyncio +async def test_list_saved_prompts_isolates_near_collision_users( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """Handler only asks DAL for auth user_id; response stays that user's rows.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + ts = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_list = mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[ + _prompt_row( + prompt_id="owned", + user_id="user-1", + name="deploy", + content="owned-body", + created_at=ts, + updated_at=ts, + ) + ], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + mock_list.assert_called_once_with("user-1") + assert mock_list.call_args.args[0] not in {"user-11", "user-1a"} + payload = response.model_dump(mode="json") + assert len(payload["prompts"]) == 1 + assert payload["prompts"][0]["id"] == "owned" + assert payload["prompts"][0]["name"] == "deploy" + assert payload["prompts"][0]["content"] == "owned-body" + + +@pytest.mark.asyncio +async def test_list_saved_prompts_uses_list_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts authorizes with Action.LIST_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[], + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.LIST_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_list_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 403 when LIST_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_list_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """GET /v1/saved-prompts returns 401 when auth dependency rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.get("/v1/saved-prompts") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_list_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/tests/unit/utils/test_saved_prompts.py b/tests/unit/utils/test_saved_prompts.py index 400fd03e4..9c5b54d9c 100644 --- a/tests/unit/utils/test_saved_prompts.py +++ b/tests/unit/utils/test_saved_prompts.py @@ -317,7 +317,7 @@ def test_list_returns_only_that_users_prompts_ordered_by_created_at_desc( """Test list is user-scoped and ordered by created_at descending.""" older = create_saved_prompt("user-1", "first", "c1", max_prompts_per_user=50) newer = create_saved_prompt("user-1", "second", "c2", max_prompts_per_user=50) - create_saved_prompt("user-2", "other", "c3", max_prompts_per_user=50) + create_saved_prompt("user-11", "other", "c3", max_prompts_per_user=50) # Concurrent inserts can share the same created_at; force a clear order. session_factory = sessionmaker( @@ -336,6 +336,25 @@ def test_list_returns_only_that_users_prompts_ordered_by_created_at_desc( assert [p.id for p in results] == [newer.id, older.id] assert all(p.user_id == "user-1" for p in results) + def test_list_excludes_near_collision_user_ids(self) -> None: + """Test list does not match nearby user_id strings (prefix/substring).""" + owned = create_saved_prompt( + "user-1", "deploy", "owned-body", max_prompts_per_user=50 + ) + create_saved_prompt( + "user-11", "deploy-1", "other-11-body", max_prompts_per_user=50 + ) + create_saved_prompt( + "user-1a", "deploy", "other-1a-body", max_prompts_per_user=50 + ) + + results = list_saved_prompts_by_user("user-1") + + assert len(results) == 1 + assert results[0].id == owned.id + assert results[0].user_id == "user-1" + assert results[0].content == "owned-body" + def test_list_caps_at_configured_upper_bound( self, mocker: MockerFixture, sqlite_engine: Engine ) -> None: From 92681662b8beb4dbeceb5c23ddf0fa85b778583b Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 14:10:26 -0400 Subject: [PATCH 06/12] fix linter issues Signed-off-by: Jordan Dubrick --- src/app/endpoints/saved_prompts.py | 4 +--- tests/unit/app/endpoints/test_saved_prompts.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index d47bb133a..9afc8c74b 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -149,9 +149,7 @@ async def list_saved_prompts_handler( for row in rows ] except SQLAlchemyError as exc: - logger.exception( - "Error retrieving saved prompts for user %s: %s", user_id, exc - ) + logger.exception("Error retrieving saved prompts for user %s: %s", user_id, exc) error_response = InternalServerErrorResponse.database_error() raise HTTPException(**error_response.model_dump()) from exc diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index 78b6dc848..25d834e18 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -263,7 +263,8 @@ async def test_get_saved_prompts_config_uses_get_config_action( assert await_args.args[0] == Action.GET_CONFIG -def _prompt_row( +# Test helper; explicit fields keep call sites readable. +def _prompt_row( # pylint: disable=too-many-arguments *, prompt_id: str, user_id: str, From 5c409cee8b9d68bba84327edfcb6fc80fbba71f5 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 14:12:52 -0400 Subject: [PATCH 07/12] run uv make doc for saved prompts changes Signed-off-by: Jordan Dubrick --- docs/devel_doc/openapi.json | 261 ++++++++++++++++++++++++++++- tests/unit/app/endpoints/README.md | 2 +- tests/unit/models/README.md | 3 + 3 files changed, 264 insertions(+), 2 deletions(-) diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index 5175d2cf3..cf2d39755 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -7094,6 +7094,163 @@ } } }, + "/v1/saved-prompts": { + "get": { + "tags": [ + "saved-prompts" + ], + "summary": "List Saved Prompts Handler", + "description": "Handle requests to the GET /saved-prompts endpoint.\n\nProcess GET requests that return all saved prompts belonging to the\nauthenticated user, ordered by creation timestamp descending. For example:\n\n curl http://localhost:8080/v1/saved-prompts\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 when configuration is not loaded or the\n database query fails.\n\n### Returns:\n- SavedPromptsListResponse: Saved prompts for the authenticated user.", + "operationId": "list_saved_prompts_handler_v1_saved_prompts_get", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptsListResponse" + }, + "example": { + "prompts": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + }, + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + }, + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" + } + } + } + } + } + } + } + } + } + }, "/v1/feedback": { "post": { "tags": [ @@ -11026,7 +11183,8 @@ "read_vector_stores", "manage_files", "manage_prompts", - "read_prompts" + "read_prompts", + "list_saved_prompts" ], "title": "Action", "description": "Available actions in the system.\n\nNote: this is not a real model, just an enumeration of all action names." @@ -19442,6 +19600,72 @@ "title": "SQLiteDatabaseConfiguration", "description": "SQLite database configuration." }, + "SavedPromptResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Unique identifier of the saved prompt", + "examples": [ + "abc123" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Display name of the saved prompt", + "examples": [ + "Deploy to staging" + ] + }, + "content": { + "type": "string", + "title": "Content", + "description": "Prompt body text", + "examples": [ + "Help me write a deployment checklist\u2026" + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the prompt was created", + "examples": [ + "2026-07-22T16:00:00+00:00" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At", + "description": "When the prompt was last updated", + "examples": [ + "2026-07-22T16:00:00+00:00" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "content", + "created_at", + "updated_at" + ], + "title": "SavedPromptResponse", + "description": "Single saved prompt returned to an authenticated user.\n\nAttributes:\n id: Unique identifier of the saved prompt.\n name: Display name of the saved prompt.\n content: Prompt body text.\n created_at: When the prompt was created.\n updated_at: When the prompt was last updated.", + "examples": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + }, "SavedPromptsConfigResponse": { "properties": { "max_prompts_per_user": { @@ -19533,6 +19757,41 @@ "title": "SavedPromptsConfiguration", "description": "Configuration for saved prompts feature limits.\n\nControls the maximum number of prompts a user can save, the maximum\ndisplay name (title) length, and the maximum prompt content length.\nAll fields are optional and default to values defined in constants.\n\nAttributes:\n max_prompts_per_user: Maximum number of saved prompts allowed per user.\n max_display_name_length: Maximum character length for the prompt display name.\n max_content_length: Maximum character length for the prompt content body." }, + "SavedPromptsListResponse": { + "properties": { + "prompts": { + "items": { + "$ref": "#/components/schemas/SavedPromptResponse" + }, + "type": "array", + "title": "Prompts", + "description": "Saved prompts for the authenticated user" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "prompts" + ], + "title": "SavedPromptsListResponse", + "description": "List of saved prompts belonging to the authenticated user.\n\nAttributes:\n prompts: Saved prompts ordered by created_at descending (newest first).", + "examples": [ + { + "prompts": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + }, + { + "prompts": [] + } + ] + }, "SearchRankingOptions": { "properties": { "ranker": { diff --git a/tests/unit/app/endpoints/README.md b/tests/unit/app/endpoints/README.md index af613e2e3..fec5a5b37 100644 --- a/tests/unit/app/endpoints/README.md +++ b/tests/unit/app/endpoints/README.md @@ -67,7 +67,7 @@ Unit tests for the rlsapi v1 /infer REST API endpoint. Unit tests for the / endpoint handler. ## [test_saved_prompts.py](test_saved_prompts.py) -Unit tests for the /saved-prompts/config REST API endpoint. +Unit tests for the /saved-prompts REST API endpoints. ## [test_shields.py](test_shields.py) Unit tests for the /shields REST API endpoint. diff --git a/tests/unit/models/README.md b/tests/unit/models/README.md index 69f63da65..906c006a2 100644 --- a/tests/unit/models/README.md +++ b/tests/unit/models/README.md @@ -9,3 +9,6 @@ Unit tests for the ConversationSummary model. ## [test_saved_prompts_config.py](test_saved_prompts_config.py) Unit tests for SavedPromptsConfiguration. +## [test_saved_prompts_list_response.py](test_saved_prompts_list_response.py) +Unit tests for saved prompts list response models. + From bda634a052dd07db595dec810df06aaf7fb30318 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 14:46:47 -0400 Subject: [PATCH 08/12] address coderabbit comments Signed-off-by: Jordan Dubrick --- src/app/endpoints/saved_prompts.py | 6 +++--- tests/unit/app/endpoints/test_saved_prompts.py | 17 +++++++++++++++-- tests/unit/models/README.md | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 9afc8c74b..a577ad82f 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -134,7 +134,7 @@ async def list_saved_prompts_handler( check_configuration_loaded(configuration) user_id = auth[0] - logger.info("Retrieving saved prompts for user %s", user_id) + logger.info("Retrieving saved prompts") try: rows = list_saved_prompts_by_user(user_id) @@ -149,9 +149,9 @@ async def list_saved_prompts_handler( for row in rows ] except SQLAlchemyError as exc: - logger.exception("Error retrieving saved prompts for user %s: %s", user_id, exc) + logger.exception("Error retrieving saved prompts") error_response = InternalServerErrorResponse.database_error() raise HTTPException(**error_response.model_dump()) from exc - logger.info("Saved prompts for user %s: %s", user_id, len(prompts)) + logger.info("Retrieved %s saved prompts", len(prompts)) return SavedPromptsListResponse(prompts=prompts) diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index 25d834e18..30929d035 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime from types import SimpleNamespace +from typing import Final import pytest from fastapi import FastAPI, HTTPException, Request, status @@ -20,7 +21,7 @@ from tests.unit.utils.auth_helpers import mock_authorization_resolvers MOCK_AUTH: AuthTuple = ("test_user_id", "test_user", True, "test_token") -MOCK_LIST_AUTH: AuthTuple = ("user-1", "test_user", True, "test_token") +MOCK_LIST_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") CUSTOM_MAX_PROMPTS_PER_USER = 100 CUSTOM_MAX_DISPLAY_NAME_LENGTH = 128 @@ -273,7 +274,19 @@ def _prompt_row( # pylint: disable=too-many-arguments created_at: datetime, updated_at: datetime, ) -> SimpleNamespace: - """Build a SavedPrompt-like object for handler mapping tests.""" + """Build a SavedPrompt-like object for handler mapping tests. + + Parameters: + prompt_id: Saved prompt identifier mapped to ``id``. + user_id: Owning user identifier (not exposed in API responses). + name: Prompt display name. + content: Prompt body text. + created_at: Creation timestamp. + updated_at: Last-update timestamp. + + Returns: + A ``SimpleNamespace`` with attributes matching a ``SavedPrompt`` row. + """ return SimpleNamespace( id=prompt_id, user_id=user_id, diff --git a/tests/unit/models/README.md b/tests/unit/models/README.md index 906c006a2..ce6a4101f 100644 --- a/tests/unit/models/README.md +++ b/tests/unit/models/README.md @@ -10,5 +10,6 @@ Unit tests for the ConversationSummary model. Unit tests for SavedPromptsConfiguration. ## [test_saved_prompts_list_response.py](test_saved_prompts_list_response.py) + Unit tests for saved prompts list response models. From ccb9270de1fb48f6d9b6bce9f0f18f9d4c73eb98 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Wed, 22 Jul 2026 14:51:17 -0400 Subject: [PATCH 09/12] add threadpool for saved prompt db hit Signed-off-by: Jordan Dubrick --- src/app/endpoints/saved_prompts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index a577ad82f..2d6d5a00f 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -3,6 +3,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.concurrency import run_in_threadpool from sqlalchemy.exc import SQLAlchemyError from authentication import get_auth_dependency @@ -137,7 +138,7 @@ async def list_saved_prompts_handler( logger.info("Retrieving saved prompts") try: - rows = list_saved_prompts_by_user(user_id) + rows = await run_in_threadpool(list_saved_prompts_by_user, user_id) prompts = [ SavedPromptResponse( id=row.id, From a8480352eac41d0b7f635dd0ee221d2a8d870648 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Thu, 23 Jul 2026 10:09:03 -0400 Subject: [PATCH 10/12] add POST for creating saved prompts Signed-off-by: Jordan Dubrick --- docs/auth/authorization.md | 2 +- docs/devel_doc/ARCHITECTURE.md | 2 +- docs/devel_doc/openapi.json | 318 +++++++++++++-- src/app/endpoints/saved_prompts.py | 169 ++++++-- src/models/api/requests/README.md | 3 + src/models/api/requests/__init__.py | 2 + src/models/api/requests/saved_prompts.py | 38 ++ src/models/config.py | 83 +--- .../unit/app/endpoints/test_saved_prompts.py | 365 ++++++++++++++++-- 9 files changed, 814 insertions(+), 168 deletions(-) create mode 100644 src/models/api/requests/saved_prompts.py diff --git a/docs/auth/authorization.md b/docs/auth/authorization.md index 9c4aa6522..75ace318c 100644 --- a/docs/auth/authorization.md +++ b/docs/auth/authorization.md @@ -111,7 +111,7 @@ authorization: | `model_override` | Override model in queries | N/A (permission flag) | | `read_prompts` | List and get prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | | `manage_prompts` | Manage prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | -| `list_saved_prompts` | List own saved prompts | `/v1/saved-prompts` | +| `manage_saved_prompts` | Manage own saved prompts | `/v1/saved-prompts` | ### Conversation Actions diff --git a/docs/devel_doc/ARCHITECTURE.md b/docs/devel_doc/ARCHITECTURE.md index 68b346430..db62eed77 100644 --- a/docs/devel_doc/ARCHITECTURE.md +++ b/docs/devel_doc/ARCHITECTURE.md @@ -201,7 +201,7 @@ The system defines 30+ actions that can be authorized. Examples (see `docs/auth. - `DELETE_CONVERSATION` - Delete conversations **Saved Prompts:** -- `LIST_SAVED_PROMPTS` - List user's saved prompts +- `MANAGE_SAVED_PROMPTS` - Manage user's saved prompts (list, create; delete later) **Administrative Actions:** - `ADMIN` - Administrative operations diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index cf2d39755..3417cfeaf 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -6937,7 +6937,7 @@ "saved-prompts" ], "summary": "Get Saved Prompts Config Handler", - "description": "Handle requests to the GET /saved-prompts/config endpoint.\n\nProcess GET requests that return saved prompts configuration limits so\nconsuming services can provide limits consistent with what the server\nwill enforce. For example:\n\n curl http://localhost:8080/v1/saved-prompts/config\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency (used by middleware).\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 and a detail object containing `response`\n and `cause` when service configuration is wrong or incomplete.\n- HTTPException: with status 503 and a detail object containing `response`\n and `cause` when unable to connect to backend services.\n\n### Returns:\n- SavedPromptsConfigResponse: Saved prompts configuration limits.", + "description": "Handle requests to the GET /saved-prompts/config endpoint.\n\nProcess GET requests that return saved prompts configuration limits so\nconsuming services can provide limits consistent with what the server\nwill enforce. For example:\n\n curl http://localhost:8080/v1/saved-prompts/config\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency (used by middleware).\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 and a detail object containing `response`\n and `cause` when service configuration is not loaded.\n- HTTPException: with status 503 and a detail object containing `response`\n and `cause` when unable to connect to backend services.\n\n### Returns:\n- SavedPromptsConfigResponse: Saved prompts configuration limits.", "operationId": "get_saved_prompts_config_handler_v1_saved_prompts_config_get", "responses": { "200": { @@ -7249,6 +7249,239 @@ } } } + }, + "post": { + "tags": [ + "saved-prompts" + ], + "summary": "Create Saved Prompts Handler", + "description": "Handle requests to the POST /saved-prompts endpoint.\n\nProcess POST requests that create a saved prompt for the authenticated\nuser after validating name/content against configured limits. For example:\n\n curl -X POST http://localhost:8080/v1/saved-prompts \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\":\"Deploy to staging\",\"content\":\"Help me write a checklist\"}'\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- body: Saved prompt name and content.\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 409 when a prompt with the same name exists.\n- HTTPException: with status 422 when validation fails or the per-user\n limit would be exceeded.\n- HTTPException: with status 500 when configuration is not loaded or the\n database write fails.\n\n### Returns:\n- SavedPromptResponse: The created saved prompt.", + "operationId": "create_saved_prompts_handler_v1_saved_prompts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptResponse" + }, + "example": { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + }, + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictResponse" + }, + "examples": { + "mcp server": { + "value": { + "detail": { + "cause": "Mcp Server with name 'test-mcp-server' is already registered", + "response": "Mcp Server already exists" + } + } + }, + "mcp tool conflict": { + "value": { + "detail": { + "cause": "Client MCP tool with server_label 'my-server' conflicts with a server-configured MCP tool. Rename the client tool to avoid the conflict.", + "response": "Tool conflict" + } + } + }, + "file search conflict": { + "value": { + "detail": { + "cause": "Client file_search tool conflicts with a server-configured file_search tool. Remove the client file_search to use the server's configuration.", + "response": "Tool conflict" + } + } + } + } + } + } + }, + "422": { + "description": "Request validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnprocessableEntityResponse" + }, + "examples": { + "invalid format": { + "value": { + "detail": { + "cause": "Invalid request format. The request body could not be parsed.", + "response": "Invalid request format" + } + } + }, + "missing attributes": { + "value": { + "detail": { + "cause": "Missing required attributes: ['query', 'model', 'provider']", + "response": "Missing required attributes" + } + } + }, + "invalid value": { + "value": { + "detail": { + "cause": "Invalid attachment type: must be one of ['text/plain', 'application/json', 'application/yaml', 'application/xml']", + "response": "Invalid attribute value" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + }, + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" + } + } + } + } + } + } + } + } } }, "/v1/feedback": { @@ -11184,7 +11417,7 @@ "manage_files", "manage_prompts", "read_prompts", - "list_saved_prompts" + "manage_saved_prompts" ], "title": "Action", "description": "Available actions in the system.\n\nNote: this is not a real model, just an enumeration of all action names." @@ -19600,6 +19833,40 @@ "title": "SQLiteDatabaseConfiguration", "description": "SQLite database configuration." }, + "SavedPromptCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Display name of the saved prompt", + "examples": [ + "Deploy to staging" + ] + }, + "content": { + "type": "string", + "title": "Content", + "description": "Prompt body text", + "examples": [ + "Help me write a deployment checklist\u2026" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "content" + ], + "title": "SavedPromptCreateRequest", + "description": "Request body to create a user-scoped saved prompt.\n\nLength and emptiness limits are enforced by the endpoint using configured\nsaved-prompts limits, not by static field constraints here.\n\nAttributes:\n name: Display name of the saved prompt.\n content: Prompt body text.", + "examples": [ + { + "content": "Help me write a deployment checklist\u2026", + "name": "Deploy to staging" + } + ] + }, "SavedPromptResponse": { "properties": { "id": { @@ -19713,49 +19980,34 @@ "SavedPromptsConfiguration": { "properties": { "max_prompts_per_user": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 200.0, + "exclusiveMinimum": 0.0, "title": "Max prompts per user", - "description": "Maximum number of saved prompts a user can create. Defaults to 50. Cannot exceed 200." + "description": "Maximum number of saved prompts a user can create. Defaults to 50. Cannot exceed 200.", + "default": 50 }, "max_display_name_length": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 255.0, + "exclusiveMinimum": 0.0, "title": "Max display name length", - "description": "Maximum character length for prompt display name (title). Defaults to 255. Cannot exceed 255." + "description": "Maximum character length for prompt display name (title). Defaults to 255. Cannot exceed 255.", + "default": 255 }, "max_content_length": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 30000.0, + "exclusiveMinimum": 0.0, "title": "Max content length", - "description": "Maximum character length for the prompt content body. Defaults to 10000. Cannot exceed 30000." + "description": "Maximum character length for the prompt content body. Defaults to 10000. Cannot exceed 30000.", + "default": 10000 } }, "additionalProperties": false, "type": "object", "title": "SavedPromptsConfiguration", - "description": "Configuration for saved prompts feature limits.\n\nControls the maximum number of prompts a user can save, the maximum\ndisplay name (title) length, and the maximum prompt content length.\nAll fields are optional and default to values defined in constants.\n\nAttributes:\n max_prompts_per_user: Maximum number of saved prompts allowed per user.\n max_display_name_length: Maximum character length for the prompt display name.\n max_content_length: Maximum character length for the prompt content body." + "description": "Configuration for saved prompts feature limits.\n\nControls the maximum number of prompts a user can save, the maximum\ndisplay name (title) length, and the maximum prompt content length.\nOmitted fields use the defaults defined in constants.\n\nAttributes:\n max_prompts_per_user: Maximum number of saved prompts allowed per user.\n max_display_name_length: Maximum character length for the prompt display name.\n max_content_length: Maximum character length for the prompt content body." }, "SavedPromptsListResponse": { "properties": { diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 2d6d5a00f..62f17b1f6 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -2,7 +2,7 @@ from typing import Annotated, Any -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool from sqlalchemy.exc import SQLAlchemyError @@ -11,12 +11,15 @@ from authorization.middleware import authorize from configuration import configuration from log import get_logger +from models.api.requests import SavedPromptCreateRequest from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES from models.api.responses.error import ( + ConflictResponse, ForbiddenResponse, InternalServerErrorResponse, ServiceUnavailableResponse, UnauthorizedResponse, + UnprocessableEntityResponse, ) from models.api.responses.successful import ( SavedPromptResponse, @@ -24,13 +27,40 @@ SavedPromptsListResponse, ) from models.config import Action +from models.database.saved_prompts import SavedPrompt from utils.endpoints import check_configuration_loaded -from utils.saved_prompts import list_saved_prompts_by_user +from utils.saved_prompts import ( + SavedPromptConflictError, + SavedPromptLimitExceededError, + SavedPromptValidationError, + create_saved_prompt, + list_saved_prompts_by_user, + validate_saved_prompt_content, + validate_saved_prompt_name, +) logger = get_logger(__name__) router = APIRouter(tags=["saved-prompts"]) +def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse: + """Map a persisted saved-prompt row to the API response model. + + Parameters: + row: Saved prompt entity loaded from the database. + + Returns: + API response for a single saved prompt (excludes ``user_id``). + """ + return SavedPromptResponse( + id=row.id, + name=row.name, + content=row.content, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + get_saved_prompts_config_responses: dict[int | str, dict[str, Any]] = { 200: SavedPromptsConfigResponse.openapi_response(), 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), @@ -48,6 +78,17 @@ ), } +create_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 201: SavedPromptResponse.openapi_response(), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 409: ConflictResponse.openapi_response(), + 422: UnprocessableEntityResponse.openapi_response(), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + @router.get("/saved-prompts/config", responses=get_saved_prompts_config_responses) @authorize(Action.GET_CONFIG) @@ -72,7 +113,7 @@ async def get_saved_prompts_config_handler( - HTTPException: with status 401 for unauthorized access. - HTTPException: with status 403 if permission is denied. - HTTPException: with status 500 and a detail object containing `response` - and `cause` when service configuration is wrong or incomplete. + and `cause` when service configuration is not loaded. - HTTPException: with status 503 and a detail object containing `response` and `cause` when unable to connect to backend services. @@ -85,27 +126,15 @@ async def get_saved_prompts_config_handler( check_configuration_loaded(configuration) saved_prompts_config = configuration.configuration.saved_prompts - max_prompts_per_user = saved_prompts_config.max_prompts_per_user - max_display_name_length = saved_prompts_config.max_display_name_length - max_content_length = saved_prompts_config.max_content_length - if ( - max_prompts_per_user is None - or max_display_name_length is None - or max_content_length is None - ): - logger.error("Saved prompts configuration limits are not set") - error_response = InternalServerErrorResponse.generic() - raise HTTPException(**error_response.model_dump()) - return SavedPromptsConfigResponse( - max_prompts_per_user=max_prompts_per_user, - max_display_name_length=max_display_name_length, - max_content_length=max_content_length, + max_prompts_per_user=saved_prompts_config.max_prompts_per_user, + max_display_name_length=saved_prompts_config.max_display_name_length, + max_content_length=saved_prompts_config.max_content_length, ) @router.get("/saved-prompts", responses=list_saved_prompts_responses) -@authorize(Action.LIST_SAVED_PROMPTS) +@authorize(Action.MANAGE_SAVED_PROMPTS) async def list_saved_prompts_handler( auth: Annotated[AuthTuple, Depends(get_auth_dependency())], request: Request, @@ -139,16 +168,7 @@ async def list_saved_prompts_handler( try: rows = await run_in_threadpool(list_saved_prompts_by_user, user_id) - prompts = [ - SavedPromptResponse( - id=row.id, - name=row.name, - content=row.content, - created_at=row.created_at, - updated_at=row.updated_at, - ) - for row in rows - ] + prompts = [_to_saved_prompt_response(row) for row in rows] except SQLAlchemyError as exc: logger.exception("Error retrieving saved prompts") error_response = InternalServerErrorResponse.database_error() @@ -156,3 +176,94 @@ async def list_saved_prompts_handler( logger.info("Retrieved %s saved prompts", len(prompts)) return SavedPromptsListResponse(prompts=prompts) + + +@router.post( + "/saved-prompts", + responses=create_saved_prompts_responses, + status_code=status.HTTP_201_CREATED, +) +@authorize(Action.MANAGE_SAVED_PROMPTS) +async def create_saved_prompts_handler( + request: Request, + body: SavedPromptCreateRequest, + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], +) -> SavedPromptResponse: + r""" + Handle requests to the POST /saved-prompts endpoint. + + Process POST requests that create a saved prompt for the authenticated + user after validating name/content against configured limits. For example: + + curl -X POST http://localhost:8080/v1/saved-prompts \ + -H 'Content-Type: application/json' \ + -d '{"name":"Deploy to staging","content":"Help me write a checklist"}' + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - body: Saved prompt name and content. + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 409 when a prompt with the same name exists. + - HTTPException: with status 422 when validation fails or the per-user + limit would be exceeded. + - HTTPException: with status 500 when configuration is not loaded or the + database write fails. + + ### Returns: + - SavedPromptResponse: The created saved prompt. + """ + _ = request + check_configuration_loaded(configuration) + + saved_prompts_config = configuration.configuration.saved_prompts + + try: + name = validate_saved_prompt_name( + body.name, + max_display_name_length=saved_prompts_config.max_display_name_length, + ) + validate_saved_prompt_content( + body.content, + max_content_length=saved_prompts_config.max_content_length, + ) + except SavedPromptValidationError as exc: + error_response = UnprocessableEntityResponse( + response="Invalid attribute value", + cause=str(exc), + ) + raise HTTPException(**error_response.model_dump()) from exc + + user_id = auth[0] + logger.info("Creating saved prompt") + + try: + row = await run_in_threadpool( + create_saved_prompt, + user_id, + name, + body.content, + saved_prompts_config.max_prompts_per_user, + ) + except SavedPromptLimitExceededError as exc: + error_response = UnprocessableEntityResponse( + response="Saved prompt limit exceeded", + cause=str(exc), + ) + raise HTTPException(**error_response.model_dump()) from exc + except SavedPromptConflictError as exc: + error_response = ConflictResponse( + resource="Saved prompt", + resource_id=name, + ) + raise HTTPException(**error_response.model_dump()) from exc + except SQLAlchemyError as exc: + logger.exception("Error creating saved prompt") + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Created saved prompt id=%s", row.id) + return _to_saved_prompt_response(row) diff --git a/src/models/api/requests/README.md b/src/models/api/requests/README.md index 4ebbbf41e..1f7cef7f6 100644 --- a/src/models/api/requests/README.md +++ b/src/models/api/requests/README.md @@ -27,6 +27,9 @@ Request model for the OpenAI-compatible Responses API. ## [rlsapi.py](rlsapi.py) Models for rlsapi v1 REST API requests. +## [saved_prompts.py](saved_prompts.py) +Request models for saved prompts endpoints. + ## [vector_stores.py](vector_stores.py) Request models for vector store and file endpoints. diff --git a/src/models/api/requests/__init__.py b/src/models/api/requests/__init__.py index 93c31f16f..7820ed2a0 100644 --- a/src/models/api/requests/__init__.py +++ b/src/models/api/requests/__init__.py @@ -15,6 +15,7 @@ RlsapiV1SystemInfo, RlsapiV1Terminal, ) +from models.api.requests.saved_prompts import SavedPromptCreateRequest from models.api.requests.vector_stores import ( VectorStoreCreateRequest, VectorStoreFileCreateRequest, @@ -37,6 +38,7 @@ "RlsapiV1InferRequest", "RlsapiV1SystemInfo", "RlsapiV1Terminal", + "SavedPromptCreateRequest", "StreamingInterruptRequest", "VectorStoreCreateRequest", "VectorStoreFileCreateRequest", diff --git a/src/models/api/requests/saved_prompts.py b/src/models/api/requests/saved_prompts.py new file mode 100644 index 000000000..a77ace05e --- /dev/null +++ b/src/models/api/requests/saved_prompts.py @@ -0,0 +1,38 @@ +"""Request models for saved prompts endpoints.""" + +from pydantic import BaseModel, Field + + +class SavedPromptCreateRequest(BaseModel): + """Request body to create a user-scoped saved prompt. + + Length and emptiness limits are enforced by the endpoint using configured + saved-prompts limits, not by static field constraints here. + + Attributes: + name: Display name of the saved prompt. + content: Prompt body text. + """ + + name: str = Field( + ..., + description="Display name of the saved prompt", + examples=["Deploy to staging"], + ) + content: str = Field( + ..., + description="Prompt body text", + examples=["Help me write a deployment checklist…"], + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + } + ] + }, + } diff --git a/src/models/config.py b/src/models/config.py index 3d6332899..715710334 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -1308,7 +1308,7 @@ class Action(str, Enum): READ_PROMPTS = "read_prompts" # User saved prompts (/v1/saved-prompts) - LIST_SAVED_PROMPTS = "list_saved_prompts" + MANAGE_SAVED_PROMPTS = "manage_saved_prompts" class AccessRule(ConfigurationBase): @@ -2382,7 +2382,7 @@ class SavedPromptsConfiguration(ConfigurationBase): Controls the maximum number of prompts a user can save, the maximum display name (title) length, and the maximum prompt content length. - All fields are optional and default to values defined in constants. + Omitted fields use the defaults defined in constants. Attributes: max_prompts_per_user: Maximum number of saved prompts allowed per user. @@ -2390,94 +2390,33 @@ class SavedPromptsConfiguration(ConfigurationBase): max_content_length: Maximum character length for the prompt content body. """ - max_prompts_per_user: Optional[PositiveInt] = Field( - default=None, + max_prompts_per_user: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + le=constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND, title="Max prompts per user", description="Maximum number of saved prompts a user can create. " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND}.", ) - max_display_name_length: Optional[PositiveInt] = Field( - default=None, + max_display_name_length: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH, + le=constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND, title="Max display name length", description="Maximum character length for prompt display name (title). " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND}.", ) - max_content_length: Optional[PositiveInt] = Field( - default=None, + max_content_length: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH, + le=constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND, title="Max content length", description="Maximum character length for the prompt content body. " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND}.", ) - @model_validator(mode="after") - def apply_defaults_and_validate_bounds(self) -> Self: - """Apply default values for None fields and validate upper bounds. - - Logs an info message for each field that falls back to its default. - - Returns: - Self: The validated model instance with defaults applied. - - Raises: - ValueError: If any value exceeds its upper bound. - """ - if self.max_prompts_per_user is None: - self.max_prompts_per_user = constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER - logger.info( - "saved_prompts.max_prompts_per_user not configured, " - "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, - ) - elif ( - self.max_prompts_per_user > constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND - ): - raise ValueError( - f"max_prompts_per_user ({self.max_prompts_per_user}) exceeds " - f"upper bound ({constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND})." - ) - - if self.max_display_name_length is None: - self.max_display_name_length = ( - constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH - ) - logger.info( - "saved_prompts.max_display_name_length not configured, " - "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH, - ) - elif ( - self.max_display_name_length - > constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND - ): - raise ValueError( - f"max_display_name_length ({self.max_display_name_length}) exceeds " - f"database column limit " - f"({constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND})." - ) - - if self.max_content_length is None: - self.max_content_length = constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH - logger.info( - "saved_prompts.max_content_length not configured, " - + "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH, - ) - elif ( - self.max_content_length - > constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND - ): - raise ValueError( - f"max_content_length ({self.max_content_length}) exceeds " - f"upper bound ({constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND})." - ) - - return self - class QuestionValidityConfig(ConfigurationBase): """Configuration for the question validity guardrail.""" diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index 30929d035..ed8aeb7ed 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -8,20 +8,28 @@ from fastapi import FastAPI, HTTPException, Request, status from fastapi.testclient import TestClient from pytest_mock import MockerFixture +from sqlalchemy.exc import SQLAlchemyError import constants from app.endpoints.saved_prompts import ( + create_saved_prompts_handler, get_saved_prompts_config_handler, list_saved_prompts_handler, router, ) from authentication.interface import AuthTuple from configuration import AppConfig +from models.api.requests import SavedPromptCreateRequest from models.config import Action from tests.unit.utils.auth_helpers import mock_authorization_resolvers +from utils.saved_prompts import ( + SavedPromptConflictError, + SavedPromptLimitExceededError, +) MOCK_AUTH: AuthTuple = ("test_user_id", "test_user", True, "test_token") MOCK_LIST_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_CREATE_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") CUSTOM_MAX_PROMPTS_PER_USER = 100 CUSTOM_MAX_DISPLAY_NAME_LENGTH = 128 @@ -136,34 +144,6 @@ async def test_get_saved_prompts_config_configuration_not_loaded( ) -@pytest.mark.asyncio -async def test_get_saved_prompts_config_incomplete_limits( - mocker: MockerFixture, - minimal_config: AppConfig, - saved_prompts_http_request: Request, -) -> None: - """GET /saved-prompts/config returns 500 when a limit is unexpectedly None. - - The model validator on ``SavedPromptsConfiguration`` always fills in - defaults, so this simulates the defensive branch that guards against a - limit slipping through as ``None`` at runtime. - """ - mock_authorization_resolvers(mocker) - minimal_config.configuration.saved_prompts.max_prompts_per_user = None - mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) - - with pytest.raises(HTTPException) as exc_info: - await get_saved_prompts_config_handler( - auth=MOCK_AUTH, - request=saved_prompts_http_request, - ) - - assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - detail = exc_info.value.detail - assert isinstance(detail, dict) - assert detail["response"] == "Internal server error" # type: ignore[index] - - @pytest.mark.asyncio async def test_get_saved_prompts_config_forbidden_without_get_config_action( mocker: MockerFixture, @@ -407,12 +387,12 @@ async def test_list_saved_prompts_isolates_near_collision_users( @pytest.mark.asyncio -async def test_list_saved_prompts_uses_list_saved_prompts_action( +async def test_list_saved_prompts_uses_manage_saved_prompts_action( mocker: MockerFixture, minimal_config: AppConfig, saved_prompts_http_request: Request, ) -> None: - """GET /saved-prompts authorizes with Action.LIST_SAVED_PROMPTS.""" + """GET /saved-prompts authorizes with Action.MANAGE_SAVED_PROMPTS.""" mock_authorization_resolvers(mocker) mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) mocker.patch( @@ -432,7 +412,7 @@ async def test_list_saved_prompts_uses_list_saved_prompts_action( perform_check.assert_awaited_once() await_args = perform_check.await_args assert await_args is not None - assert await_args.args[0] == Action.LIST_SAVED_PROMPTS + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS @pytest.mark.asyncio @@ -441,7 +421,7 @@ async def test_list_saved_prompts_forbidden_without_action( minimal_config: AppConfig, saved_prompts_http_request: Request, ) -> None: - """GET /saved-prompts returns 403 when LIST_SAVED_PROMPTS is denied.""" + """GET /saved-prompts returns 403 when MANAGE_SAVED_PROMPTS is denied.""" mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) mock_role_resolver = mocker.AsyncMock() @@ -511,3 +491,324 @@ async def test_list_saved_prompts_configuration_not_loaded( ) assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_create_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns mapped prompt without user_id.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_create = mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy to staging", + content="Help me write a checklist", + created_at=created_at, + updated_at=created_at, + ), + ) + + response = await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest( + name="Deploy to staging", + content="Help me write a checklist", + ), + auth=MOCK_CREATE_AUTH, + ) + + mock_create.assert_called_once_with( + "user-1", + "Deploy to staging", + "Help me write a checklist", + constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + ) + payload = response.model_dump(mode="json") + assert set(payload.keys()) == { + "id", + "name", + "content", + "created_at", + "updated_at", + } + assert payload["id"] == "prompt-1" + assert payload["name"] == "Deploy to staging" + assert payload["content"] == "Help me write a checklist" + assert "user_id" not in payload + + +@pytest.mark.asyncio +async def test_create_saved_prompts_normalizes_name_before_dal( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts strips name whitespace before calling DAL.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_create = mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy", + content="body", + created_at=created_at, + updated_at=created_at, + ), + ) + + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name=" Deploy ", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + mock_create.assert_called_once_with( + "user-1", + "Deploy", + "body", + constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + ) + + +@pytest.mark.asyncio +async def test_create_saved_prompts_uses_manage_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts authorizes with Action.MANAGE_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy", + content="body", + created_at=created_at, + updated_at=created_at, + ), + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_create_saved_prompts_quota_exceeded( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 422 when the per-user quota is exceeded.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SavedPromptLimitExceededError( + "Saved prompt limit exceeded: 50 existing prompts, maximum is 50" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved prompt limit exceeded" # type: ignore[index] + assert "maximum is 50" in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_validation_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 422 for empty/invalid name or content.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_create = mocker.patch("app.endpoints.saved_prompts.create_saved_prompt") + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name=" ", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Invalid attribute value" # type: ignore[index] + assert "must not be empty" in detail["cause"] # type: ignore[index] + mock_create.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_saved_prompts_duplicate_name_conflict( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 409 when the prompt name already exists.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SavedPromptConflictError("Saved prompt name already exists"), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_409_CONFLICT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt already exists" # type: ignore[index] + assert "Deploy" in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 500 when the database write fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_create_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 403 when MANAGE_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_create_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """POST /v1/saved-prompts returns 401 when auth dependency rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.post( + "/v1/saved-prompts", + json={"name": "Deploy", "content": "body"}, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED From c4959a8ff9275bc9e89571689e54521455204e40 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Thu, 23 Jul 2026 13:37:47 -0400 Subject: [PATCH 11/12] add delete endpoint for saved prompts Signed-off-by: Jordan Dubrick --- docs/auth/authorization.md | 4 +- docs/devel_doc/ARCHITECTURE.md | 2 +- docs/devel_doc/openapi.json | 272 +++++++++++++++++ src/app/endpoints/saved_prompts.py | 90 +++++- tests/integration/test_openapi_json.py | 22 ++ .../unit/app/endpoints/test_saved_prompts.py | 277 ++++++++++++++++++ 6 files changed, 663 insertions(+), 4 deletions(-) diff --git a/docs/auth/authorization.md b/docs/auth/authorization.md index 75ace318c..9710bd2a1 100644 --- a/docs/auth/authorization.md +++ b/docs/auth/authorization.md @@ -100,7 +100,7 @@ authorization: | `query` | Submit queries | `/v1/query` | | `streaming_query` | Submit streaming queries | `/v1/streaming_query` | | `info` | Access service info | `/`, `/info`, `/readiness`, `/liveness` | -| `get_config` | View configuration | `/config` | +| `get_config` | View configuration | `/v1/config`, `/v1/saved-prompts/config` | | `get_models` | List available models | `/models` | | `get_tools` | List available tools | `/tools`, `/mcp-auth/client-options` | | `get_shields` | List safety shields | `/shields` | @@ -111,7 +111,7 @@ authorization: | `model_override` | Override model in queries | N/A (permission flag) | | `read_prompts` | List and get prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | | `manage_prompts` | Manage prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | -| `manage_saved_prompts` | Manage own saved prompts | `/v1/saved-prompts` | +| `manage_saved_prompts` | Manage own saved prompts | `/v1/saved-prompts`, `/v1/saved-prompts/{prompt_id}` | ### Conversation Actions diff --git a/docs/devel_doc/ARCHITECTURE.md b/docs/devel_doc/ARCHITECTURE.md index db62eed77..b3bf18635 100644 --- a/docs/devel_doc/ARCHITECTURE.md +++ b/docs/devel_doc/ARCHITECTURE.md @@ -201,7 +201,7 @@ The system defines 30+ actions that can be authorized. Examples (see `docs/auth. - `DELETE_CONVERSATION` - Delete conversations **Saved Prompts:** -- `MANAGE_SAVED_PROMPTS` - Manage user's saved prompts (list, create; delete later) +- `MANAGE_SAVED_PROMPTS` - Manage user's saved prompts (list, create, delete) **Administrative Actions:** - `ADMIN` - Administrative operations diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index 3417cfeaf..cc21a87b3 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -7484,6 +7484,278 @@ } } }, + "/v1/saved-prompts/{prompt_id}": { + "delete": { + "tags": [ + "saved-prompts" + ], + "summary": "Delete Saved Prompts Handler", + "description": "Handle requests to the DELETE /saved-prompts/{prompt_id} endpoint.\n\nProcess DELETE requests that remove a saved prompt belonging to the\nauthenticated user. Missing prompts and prompts owned by another user\nboth return 404 so foreign ids cannot be probed. For example:\n\n curl -X DELETE http://localhost:8080/v1/saved-prompts/{prompt_id}\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- prompt_id: Identifier of the saved prompt to delete.\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 400 when ``prompt_id`` has an invalid format.\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 404 when the prompt does not exist for this user.\n- HTTPException: with status 500 when configuration is not loaded or the\n database delete fails.\n\n### Returns:\n- Response: Empty 204 No Content on successful deletion.", + "operationId": "delete_saved_prompts_handler_v1_saved_prompts__prompt_id__delete", + "parameters": [ + { + "name": "prompt_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Prompt Id" + } + } + ], + "responses": { + "204": { + "description": "Saved prompt deleted" + }, + "400": { + "description": "Invalid request format", + "content": { + "application/json": { + "examples": { + "conversation_id": { + "value": { + "detail": { + "cause": "The conversation ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid conversation ID format" + } + } + }, + "prompt_id": { + "value": { + "detail": { + "cause": "The prompt ID pmpt_1234567890abcdef has invalid format.", + "response": "Invalid prompt ID format" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/BadRequestResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "examples": { + "conversation": { + "value": { + "detail": { + "cause": "Conversation with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Conversation not found" + } + } + }, + "provider": { + "value": { + "detail": { + "cause": "Provider with ID openai does not exist", + "response": "Provider not found" + } + } + }, + "model": { + "value": { + "detail": { + "cause": "Model with ID gpt-4o-mini does not exist", + "response": "Model not found" + } + } + }, + "rag": { + "value": { + "detail": { + "cause": "Rag with ID vs_7b52a8cf-0fa3-489c-beab-27e061d102f3 does not exist", + "response": "Rag not found" + } + } + }, + "streaming request": { + "value": { + "detail": { + "cause": "Streaming Request with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Streaming Request not found" + } + } + }, + "mcp server": { + "value": { + "detail": { + "cause": "Mcp Server with ID test-mcp-server does not exist", + "response": "Mcp Server not found" + } + } + }, + "vector store": { + "value": { + "detail": { + "cause": "Vector Store with ID vs_abc123 does not exist", + "response": "Vector Store not found" + } + } + }, + "file": { + "value": { + "detail": { + "cause": "File with ID file_abc123 does not exist", + "response": "File not found" + } + } + }, + "prompt": { + "value": { + "detail": { + "cause": "Prompt with ID pmpt_0123456789abcdef0123456789abcdef01234567 does not exist", + "response": "Prompt not found" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/NotFoundResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/feedback": { "post": { "tags": [ diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 62f17b1f6..0677cf8aa 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -2,7 +2,7 @@ from typing import Annotated, Any -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.concurrency import run_in_threadpool from sqlalchemy.exc import SQLAlchemyError @@ -14,9 +14,11 @@ from models.api.requests import SavedPromptCreateRequest from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES from models.api.responses.error import ( + BadRequestResponse, ConflictResponse, ForbiddenResponse, InternalServerErrorResponse, + NotFoundResponse, ServiceUnavailableResponse, UnauthorizedResponse, UnprocessableEntityResponse, @@ -30,14 +32,18 @@ from models.database.saved_prompts import SavedPrompt from utils.endpoints import check_configuration_loaded from utils.saved_prompts import ( + SavedPromptAccessDeniedError, SavedPromptConflictError, SavedPromptLimitExceededError, + SavedPromptNotFoundError, SavedPromptValidationError, create_saved_prompt, + delete_saved_prompt_by_id_and_user, list_saved_prompts_by_user, validate_saved_prompt_content, validate_saved_prompt_name, ) +from utils.suid import check_suid logger = get_logger(__name__) router = APIRouter(tags=["saved-prompts"]) @@ -89,6 +95,17 @@ def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse: ), } +delete_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 204: {"description": "Saved prompt deleted"}, + 400: BadRequestResponse.openapi_response(), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 404: NotFoundResponse.openapi_response(), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + @router.get("/saved-prompts/config", responses=get_saved_prompts_config_responses) @authorize(Action.GET_CONFIG) @@ -267,3 +284,74 @@ async def create_saved_prompts_handler( logger.info("Created saved prompt id=%s", row.id) return _to_saved_prompt_response(row) + + +@router.delete( + "/saved-prompts/{prompt_id}", + responses=delete_saved_prompts_responses, + status_code=status.HTTP_204_NO_CONTENT, +) +@authorize(Action.MANAGE_SAVED_PROMPTS) +async def delete_saved_prompts_handler( + request: Request, + prompt_id: str, + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], +) -> Response: + """ + Handle requests to the DELETE /saved-prompts/{prompt_id} endpoint. + + Process DELETE requests that remove a saved prompt belonging to the + authenticated user. Missing prompts and prompts owned by another user + both return 404 so foreign ids cannot be probed. For example: + + curl -X DELETE http://localhost:8080/v1/saved-prompts/{prompt_id} + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - prompt_id: Identifier of the saved prompt to delete. + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 400 when ``prompt_id`` has an invalid format. + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 404 when the prompt does not exist for this user. + - HTTPException: with status 500 when configuration is not loaded or the + database delete fails. + + ### Returns: + - Response: Empty 204 No Content on successful deletion. + """ + _ = request + check_configuration_loaded(configuration) + + if not check_suid(prompt_id): + logger.error("Invalid saved prompt ID format: %s", prompt_id) + error_response = BadRequestResponse( + resource="saved prompt", + resource_id=prompt_id, + ) + raise HTTPException(**error_response.model_dump()) + + user_id = auth[0] + logger.info("Deleting saved prompt id=%s", prompt_id) + + try: + await run_in_threadpool( + delete_saved_prompt_by_id_and_user, + prompt_id, + user_id, + ) + except (SavedPromptNotFoundError, SavedPromptAccessDeniedError) as exc: + error_response = NotFoundResponse( + resource="saved prompt", + resource_id=prompt_id, + ) + raise HTTPException(**error_response.model_dump()) from exc + except SQLAlchemyError as exc: + logger.exception("Error deleting saved prompt id=%s", prompt_id) + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Deleted saved prompt id=%s", prompt_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/tests/integration/test_openapi_json.py b/tests/integration/test_openapi_json.py index 7d85861e3..76fc7e695 100644 --- a/tests/integration/test_openapi_json.py +++ b/tests/integration/test_openapi_json.py @@ -257,6 +257,17 @@ def test_servers_section_present_from_url(spec_from_url: dict[str, Any]) -> None ), ("/v1/config", "get", {"200", "401", "403", "500"}), ("/v1/saved-prompts/config", "get", {"200", "401", "403", "500", "503"}), + ("/v1/saved-prompts", "get", {"200", "401", "403", "500"}), + ( + "/v1/saved-prompts", + "post", + {"201", "401", "403", "409", "422", "500"}, + ), + ( + "/v1/saved-prompts/{prompt_id}", + "delete", + {"204", "400", "401", "403", "404", "500"}, + ), ("/v1/feedback", "post", {"200", "401", "403", "404", "500"}), ("/v1/feedback/status", "get", {"200"}), ("/v1/feedback/status", "put", {"200", "401", "403", "500"}), @@ -362,6 +373,17 @@ def test_paths_and_responses_exist_from_file( ), ("/v1/config", "get", {"200", "401", "403", "500"}), ("/v1/saved-prompts/config", "get", {"200", "401", "403", "500", "503"}), + ("/v1/saved-prompts", "get", {"200", "401", "403", "500"}), + ( + "/v1/saved-prompts", + "post", + {"201", "401", "403", "409", "422", "500"}, + ), + ( + "/v1/saved-prompts/{prompt_id}", + "delete", + {"204", "400", "401", "403", "404", "500"}, + ), ("/v1/feedback", "post", {"200", "401", "403", "404", "500"}), ("/v1/feedback/status", "get", {"200"}), ("/v1/feedback/status", "put", {"200", "401", "403", "500"}), diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index ed8aeb7ed..9172e1914 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -1,5 +1,7 @@ """Unit tests for the /saved-prompts REST API endpoints.""" +# pylint: disable=too-many-lines + from datetime import UTC, datetime from types import SimpleNamespace from typing import Final @@ -13,6 +15,7 @@ import constants from app.endpoints.saved_prompts import ( create_saved_prompts_handler, + delete_saved_prompts_handler, get_saved_prompts_config_handler, list_saved_prompts_handler, router, @@ -23,13 +26,19 @@ from models.config import Action from tests.unit.utils.auth_helpers import mock_authorization_resolvers from utils.saved_prompts import ( + SavedPromptAccessDeniedError, SavedPromptConflictError, SavedPromptLimitExceededError, + SavedPromptNotFoundError, ) MOCK_AUTH: AuthTuple = ("test_user_id", "test_user", True, "test_token") MOCK_LIST_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") MOCK_CREATE_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_DELETE_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_DELETE_AUTH_OTHER: Final[AuthTuple] = ("user-2", "other_user", True, "test_token") +VALID_PROMPT_ID: Final[str] = "123e4567-e89b-12d3-a456-426614174000" +INVALID_PROMPT_ID: Final[str] = "not-a-valid-id" CUSTOM_MAX_PROMPTS_PER_USER = 100 CUSTOM_MAX_DISPLAY_NAME_LENGTH = 128 @@ -812,3 +821,271 @@ async def _reject(_self: object, _request: Request) -> None: ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE /saved-prompts/{prompt_id} returns 204 and scopes DAL to auth user.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + + response = await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + mock_delete.assert_called_once_with(VALID_PROMPT_ID, "user-1") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.body == b"" + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_uses_authenticated_user_id( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE passes the authenticated user_id into DAL, not another user's.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH_OTHER, + ) + + mock_delete.assert_called_once_with(VALID_PROMPT_ID, "user-2") + assert mock_delete.call_args.args[1] != "user-1" + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_invalid_id_format( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 400 for invalid prompt_id and does not call DAL.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user" + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=INVALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Invalid saved prompt ID format" # type: ignore[index] + mock_delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_not_found( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 404 when the prompt does not exist.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SavedPromptNotFoundError("Saved prompt not found"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt not found" # type: ignore[index] + assert VALID_PROMPT_ID in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_access_denied_as_not_found( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE maps non-owner access denied to 404 (same as missing).""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SavedPromptAccessDeniedError("Saved prompt access denied"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt not found" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_uses_manage_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE /saved-prompts/{prompt_id} authorizes with MANAGE_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 500 when the database delete fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 403 when MANAGE_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_delete_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """DELETE /v1/saved-prompts/{prompt_id} returns 401 when auth rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.delete(f"/v1/saved-prompts/{VALID_PROMPT_ID}") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED From 1ece1250d38c8b542cc7ea8ac68b053db8a44445 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Thu, 23 Jul 2026 14:34:04 -0400 Subject: [PATCH 12/12] add saved prompt examples for openapi schema Signed-off-by: Jordan Dubrick --- docs/devel_doc/openapi.json | 225 +++++++++--------- src/app/endpoints/saved_prompts.py | 10 +- src/models/api/requests/README.md | 1 + src/models/api/responses/error/bad_request.py | 10 + src/models/api/responses/error/conflict.py | 10 + src/models/api/responses/error/not_found.py | 10 + .../responses/error/unprocessable_entity.py | 17 ++ .../unit/app/endpoints/test_saved_prompts.py | 26 ++ .../models/responses/test_error_responses.py | 14 +- tests/unit/utils/test_models_dumper.py | 35 +++ 10 files changed, 240 insertions(+), 118 deletions(-) diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index cc21a87b3..dcef0b0ea 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -6036,6 +6036,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -6397,6 +6413,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -7389,27 +7421,11 @@ "$ref": "#/components/schemas/ConflictResponse" }, "examples": { - "mcp server": { - "value": { - "detail": { - "cause": "Mcp Server with name 'test-mcp-server' is already registered", - "response": "Mcp Server already exists" - } - } - }, - "mcp tool conflict": { - "value": { - "detail": { - "cause": "Client MCP tool with server_label 'my-server' conflicts with a server-configured MCP tool. Rename the client tool to avoid the conflict.", - "response": "Tool conflict" - } - } - }, - "file search conflict": { + "saved prompt": { "value": { "detail": { - "cause": "Client file_search tool conflicts with a server-configured file_search tool. Remove the client file_search to use the server's configuration.", - "response": "Tool conflict" + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" } } } @@ -7425,27 +7441,19 @@ "$ref": "#/components/schemas/UnprocessableEntityResponse" }, "examples": { - "invalid format": { - "value": { - "detail": { - "cause": "Invalid request format. The request body could not be parsed.", - "response": "Invalid request format" - } - } - }, - "missing attributes": { + "saved prompt invalid": { "value": { "detail": { - "cause": "Missing required attributes: ['query', 'model', 'provider']", - "response": "Missing required attributes" + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" } } }, - "invalid value": { + "saved prompt limit": { "value": { "detail": { - "cause": "Invalid attachment type: must be one of ['text/plain', 'application/json', 'application/yaml', 'application/xml']", - "response": "Invalid attribute value" + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" } } } @@ -7512,19 +7520,11 @@ "content": { "application/json": { "examples": { - "conversation_id": { + "saved_prompt_id": { "value": { "detail": { - "cause": "The conversation ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", - "response": "Invalid conversation ID format" - } - } - }, - "prompt_id": { - "value": { - "detail": { - "cause": "The prompt ID pmpt_1234567890abcdef has invalid format.", - "response": "Invalid prompt ID format" + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" } } } @@ -7636,75 +7636,11 @@ "content": { "application/json": { "examples": { - "conversation": { - "value": { - "detail": { - "cause": "Conversation with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", - "response": "Conversation not found" - } - } - }, - "provider": { - "value": { - "detail": { - "cause": "Provider with ID openai does not exist", - "response": "Provider not found" - } - } - }, - "model": { + "saved prompt": { "value": { "detail": { - "cause": "Model with ID gpt-4o-mini does not exist", - "response": "Model not found" - } - } - }, - "rag": { - "value": { - "detail": { - "cause": "Rag with ID vs_7b52a8cf-0fa3-489c-beab-27e061d102f3 does not exist", - "response": "Rag not found" - } - } - }, - "streaming request": { - "value": { - "detail": { - "cause": "Streaming Request with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", - "response": "Streaming Request not found" - } - } - }, - "mcp server": { - "value": { - "detail": { - "cause": "Mcp Server with ID test-mcp-server does not exist", - "response": "Mcp Server not found" - } - } - }, - "vector store": { - "value": { - "detail": { - "cause": "Vector Store with ID vs_abc123 does not exist", - "response": "Vector Store not found" - } - } - }, - "file": { - "value": { - "detail": { - "cause": "File with ID file_abc123 does not exist", - "response": "File not found" - } - } - }, - "prompt": { - "value": { - "detail": { - "cause": "Prompt with ID pmpt_0123456789abcdef0123456789abcdef01234567 does not exist", - "response": "Prompt not found" + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" } } } @@ -10333,6 +10269,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -10659,6 +10611,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -12634,6 +12602,13 @@ "response": "Invalid prompt ID format" }, "label": "prompt_id" + }, + { + "detail": { + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" + }, + "label": "saved_prompt_id" } ] }, @@ -13199,6 +13174,13 @@ "response": "Tool conflict" }, "label": "file search conflict" + }, + { + "detail": { + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" + }, + "label": "saved prompt" } ] }, @@ -15613,6 +15595,13 @@ "response": "Prompt not found" }, "label": "prompt" + }, + { + "detail": { + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" + }, + "label": "saved prompt" } ] }, @@ -21276,6 +21265,20 @@ "response": "Invalid attribute value" }, "label": "invalid value" + }, + { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + }, + "label": "saved prompt invalid" + }, + { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + }, + "label": "saved prompt limit" } ] }, diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 0677cf8aa..c75e77e9d 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -88,8 +88,10 @@ def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse: 201: SavedPromptResponse.openapi_response(), 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), - 409: ConflictResponse.openapi_response(), - 422: UnprocessableEntityResponse.openapi_response(), + 409: ConflictResponse.openapi_response(examples=["saved prompt"]), + 422: UnprocessableEntityResponse.openapi_response( + examples=["saved prompt invalid", "saved prompt limit"] + ), 500: InternalServerErrorResponse.openapi_response( examples=["configuration", "database"] ), @@ -97,10 +99,10 @@ def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse: delete_saved_prompts_responses: dict[int | str, dict[str, Any]] = { 204: {"description": "Saved prompt deleted"}, - 400: BadRequestResponse.openapi_response(), + 400: BadRequestResponse.openapi_response(examples=["saved_prompt_id"]), 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), - 404: NotFoundResponse.openapi_response(), + 404: NotFoundResponse.openapi_response(examples=["saved prompt"]), 500: InternalServerErrorResponse.openapi_response( examples=["configuration", "database"] ), diff --git a/src/models/api/requests/README.md b/src/models/api/requests/README.md index 1f7cef7f6..d5ace62f4 100644 --- a/src/models/api/requests/README.md +++ b/src/models/api/requests/README.md @@ -28,6 +28,7 @@ Request model for the OpenAI-compatible Responses API. Models for rlsapi v1 REST API requests. ## [saved_prompts.py](saved_prompts.py) + Request models for saved prompts endpoints. ## [vector_stores.py](vector_stores.py) diff --git a/src/models/api/responses/error/bad_request.py b/src/models/api/responses/error/bad_request.py index e6c286e9f..e1129d638 100644 --- a/src/models/api/responses/error/bad_request.py +++ b/src/models/api/responses/error/bad_request.py @@ -32,6 +32,16 @@ class BadRequestResponse(AbstractErrorResponse): "cause": "The prompt ID pmpt_1234567890abcdef has invalid format.", }, }, + { + "label": "saved_prompt_id", + "detail": { + "response": "Invalid saved prompt ID format", + "cause": ( + "The saved prompt ID " + "123e4567-e89b-12d3-a456-426614174000 has invalid format." + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/conflict.py b/src/models/api/responses/error/conflict.py index cafbf6b29..5a5fb9023 100644 --- a/src/models/api/responses/error/conflict.py +++ b/src/models/api/responses/error/conflict.py @@ -47,6 +47,16 @@ class ConflictResponse(AbstractErrorResponse): ), }, }, + { + "label": "saved prompt", + "detail": { + "response": "Saved Prompt already exists", + "cause": ( + "Saved Prompt with name 'Deploy to staging' " + "is already registered" + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/not_found.py b/src/models/api/responses/error/not_found.py index e55751629..4dbf448d4 100644 --- a/src/models/api/responses/error/not_found.py +++ b/src/models/api/responses/error/not_found.py @@ -89,6 +89,16 @@ class NotFoundResponse(AbstractErrorResponse): ), }, }, + { + "label": "saved prompt", + "detail": { + "response": "Saved Prompt not found", + "cause": ( + "Saved Prompt with ID " + "123e4567-e89b-12d3-a456-426614174000 does not exist" + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/unprocessable_entity.py b/src/models/api/responses/error/unprocessable_entity.py index 90c487bdb..9088a34d5 100644 --- a/src/models/api/responses/error/unprocessable_entity.py +++ b/src/models/api/responses/error/unprocessable_entity.py @@ -37,6 +37,23 @@ class UnprocessableEntityResponse(AbstractErrorResponse): "'application/json', 'application/yaml', 'application/xml']", }, }, + { + "label": "saved prompt invalid", + "detail": { + "response": "Invalid attribute value", + "cause": "Saved prompt name must not be empty", + }, + }, + { + "label": "saved prompt limit", + "detail": { + "response": "Saved prompt limit exceeded", + "cause": ( + "Saved prompt limit exceeded: 50 existing prompts, " + "maximum is 50" + ), + }, + }, ] } } diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index 9172e1914..0e0e59801 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -502,6 +502,32 @@ async def test_list_saved_prompts_configuration_not_loaded( assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR +@pytest.mark.asyncio +async def test_list_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 500 when the database query fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + @pytest.mark.asyncio async def test_create_saved_prompts_happy_path( mocker: MockerFixture, diff --git a/tests/unit/models/responses/test_error_responses.py b/tests/unit/models/responses/test_error_responses.py index 5564bc2f4..70b137d8f 100644 --- a/tests/unit/models/responses/test_error_responses.py +++ b/tests/unit/models/responses/test_error_responses.py @@ -83,11 +83,12 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 2 + assert expected_count == 3 # Verify example structure assert "conversation_id" in examples assert "prompt_id" in examples + assert "saved_prompt_id" in examples conversation_example = examples["conversation_id"] assert "value" in conversation_example assert "detail" in conversation_example["value"] @@ -98,6 +99,10 @@ def test_openapi_response(self) -> None: assert ( prompt_example["value"]["detail"]["response"] == "Invalid prompt ID format" ) + saved_prompt_example = examples["saved_prompt_id"] + assert saved_prompt_example["value"]["detail"]["response"] == ( + "Invalid saved prompt ID format" + ) def test_openapi_response_with_explicit_examples(self) -> None: """Test BadRequestResponse.openapi_response() with explicit examples. @@ -376,12 +381,14 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 3 + assert expected_count == 5 # Verify all labeled examples are present assert "invalid format" in examples assert "missing attributes" in examples assert "invalid value" in examples + assert "saved prompt invalid" in examples + assert "saved prompt limit" in examples # Verify example structure for one example invalid_format_example = examples["invalid format"] @@ -518,7 +525,7 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 9 + assert expected_count == 10 # Verify all labeled examples are present assert "conversation" in examples @@ -530,6 +537,7 @@ def test_openapi_response(self) -> None: assert "file" in examples assert "prompt" in examples assert "mcp server" in examples + assert "saved prompt" in examples # Verify example structure for one example conversation_example = examples["conversation"] diff --git a/tests/unit/utils/test_models_dumper.py b/tests/unit/utils/test_models_dumper.py index 2902fa25f..7230f8196 100644 --- a/tests/unit/utils/test_models_dumper.py +++ b/tests/unit/utils/test_models_dumper.py @@ -453,6 +453,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Invalid prompt ID format" }, "label": "prompt_id" + }, + { + "detail": { + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" + }, + "label": "saved_prompt_id" } ], "properties": { @@ -958,6 +965,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Tool conflict" }, "label": "file search conflict" + }, + { + "detail": { + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" + }, + "label": "saved prompt" } ], "properties": { @@ -3248,6 +3262,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Prompt not found" }, "label": "prompt" + }, + { + "detail": { + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" + }, + "label": "saved prompt" } ], "properties": { @@ -8457,6 +8478,20 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Invalid attribute value" }, "label": "invalid value" + }, + { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + }, + "label": "saved prompt invalid" + }, + { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + }, + "label": "saved prompt limit" } ], "properties": {