-
Notifications
You must be signed in to change notification settings - Fork 97
RHIDP-15891: add /v1/skills endpoint #2294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
056bcf6
d5cabc5
450a1d3
aae3d69
3f5a80d
2ca195b
95bccef
e78fcea
f160d44
fcff6a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| """Handler for REST API call to list loaded agent skills.""" | ||
|
|
||
| from typing import Annotated, Any | ||
|
|
||
| from fastapi import APIRouter, Request | ||
| from fastapi.params import Depends | ||
|
|
||
| from authentication import get_auth_dependency | ||
| from authentication.interface import AuthTuple | ||
| from authorization.middleware import authorize | ||
| from configuration import configuration | ||
| from log import get_logger | ||
| from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES | ||
| from models.api.responses.error import ( | ||
| ForbiddenResponse, | ||
| InternalServerErrorResponse, | ||
| UnauthorizedResponse, | ||
| ) | ||
| from models.api.responses.successful import SkillsResponse | ||
| from models.config import Action | ||
| from utils.endpoints import check_configuration_loaded | ||
| from utils.pydantic_ai_helpers import get_skills_metadata | ||
|
|
||
| logger = get_logger(__name__) | ||
| router = APIRouter(tags=["skills"]) | ||
|
|
||
|
|
||
| skills_responses: dict[int | str, dict[str, Any]] = { | ||
| 200: SkillsResponse.openapi_response(), | ||
| 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), | ||
| 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), | ||
| 500: InternalServerErrorResponse.openapi_response(examples=["configuration"]), | ||
| } | ||
|
|
||
|
|
||
| @router.get("/skills", responses=skills_responses) | ||
| @authorize(Action.GET_SKILLS) | ||
| async def skills_endpoint_handler( | ||
| request: Request, | ||
| auth: Annotated[AuthTuple, Depends(get_auth_dependency())], | ||
| ) -> SkillsResponse: | ||
| """Handle requests to the /skills endpoint. | ||
|
|
||
| Process GET requests to the /skills endpoint, returning a list of loaded | ||
| agent skills with their metadata (name, description). | ||
|
|
||
| ### Parameters: | ||
| - request: The incoming HTTP request (used by middleware). | ||
| - auth: Authentication tuple from the auth dependency (used by middleware). | ||
|
|
||
| ### Raises: | ||
| - 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. | ||
|
|
||
| ### Returns: | ||
| - SkillsResponse: An object containing the list of loaded skills. | ||
| """ | ||
| _ = auth | ||
| _ = request | ||
|
|
||
| check_configuration_loaded(configuration) | ||
|
|
||
| skills_metadata = get_skills_metadata(configuration.configuration.skills) | ||
| return SkillsResponse(skills=skills_metadata) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """Unit tests for skills endpoint.""" | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
| from fastapi import Request | ||
| from pytest_mock import MockerFixture | ||
|
|
||
| from app.endpoints.skills import skills_endpoint_handler | ||
| from authentication.interface import AuthTuple | ||
| from models.api.responses.successful import SkillsResponse | ||
| from models.config import SkillsConfiguration | ||
| from tests.unit.utils.auth_helpers import mock_authorization_resolvers | ||
|
|
||
| MOCK_AUTH: AuthTuple = ("mock_user_id", "mock_username", True, "mock_token") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_skills_loaded( | ||
| mocker: MockerFixture, | ||
| tmp_path: Path, | ||
| ) -> None: | ||
| """Test that loaded skills are returned with name and description.""" | ||
| mock_authorization_resolvers(mocker) | ||
|
|
||
| skills_root = tmp_path / "skills" | ||
| for name, desc in [ | ||
| ("code-review", "Review code for quality and security"), | ||
| ("openshift-troubleshooting", "Troubleshoot OpenShift cluster issues"), | ||
| ]: | ||
| skill_dir = skills_root / name | ||
| skill_dir.mkdir(parents=True) | ||
| (skill_dir / "SKILL.md").write_text( | ||
| f"---\nname: {name}\ndescription: {desc}\n---\n\nInstructions.\n", | ||
| encoding="utf-8", | ||
| ) | ||
|
|
||
| skills_config = SkillsConfiguration(paths=[skills_root]) | ||
| mock_config = mocker.patch("app.endpoints.skills.configuration") | ||
| mock_config.configuration.skills = skills_config | ||
|
|
||
| request = Request(scope={"type": "http"}) | ||
| response = await skills_endpoint_handler(auth=MOCK_AUTH, request=request) | ||
|
Comment on lines
+38
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win Add route-level authorization coverage. These tests call the handler directly with a hand-built request and auth tuple, so they do not verify Based on the PR objective, this endpoint must be both versioned and authorized. 🤖 Prompt for AI Agents |
||
|
|
||
| assert isinstance(response, SkillsResponse) | ||
| assert len(response.skills) == 2 | ||
| names = {s["name"] for s in response.skills} | ||
| assert names == {"code-review", "openshift-troubleshooting"} | ||
| for skill in response.skills: | ||
| assert "name" in skill | ||
| assert "description" in skill | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_no_skills_configured( | ||
| mocker: MockerFixture, | ||
| ) -> None: | ||
| """Test that an empty list is returned when no skills are configured.""" | ||
| mock_authorization_resolvers(mocker) | ||
|
|
||
| mock_config = mocker.patch("app.endpoints.skills.configuration") | ||
| mock_config.configuration.skills = None | ||
|
|
||
| request = Request(scope={"type": "http"}) | ||
| response = await skills_endpoint_handler(auth=MOCK_AUTH, request=request) | ||
|
|
||
| assert isinstance(response, SkillsResponse) | ||
| assert response.skills == [] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_empty_skills_paths( | ||
| mocker: MockerFixture, | ||
| ) -> None: | ||
| """Test that an empty list is returned when skills paths are empty.""" | ||
| mock_authorization_resolvers(mocker) | ||
|
|
||
| mock_config = mocker.patch("app.endpoints.skills.configuration") | ||
| mock_config.configuration.skills = SkillsConfiguration(paths=[]) | ||
|
|
||
| request = Request(scope={"type": "http"}) | ||
| response = await skills_endpoint_handler(auth=MOCK_AUTH, request=request) | ||
|
|
||
| assert isinstance(response, SkillsResponse) | ||
| assert response.skills == [] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_skills_with_references( | ||
| mocker: MockerFixture, | ||
| tmp_path: Path, | ||
| ) -> None: | ||
| """Test that skills with references/ subdirectory are listed correctly.""" | ||
| mock_authorization_resolvers(mocker) | ||
|
|
||
| skills_root = tmp_path / "skills" | ||
| skill_dir = skills_root / "rhdh-dynamic-plugins" | ||
| skill_dir.mkdir(parents=True) | ||
| (skill_dir / "SKILL.md").write_text( | ||
| "---\nname: rhdh-dynamic-plugins\ndescription: RHDH dynamic plugins guide\n---\n\nInstructions.\n", | ||
| encoding="utf-8", | ||
| ) | ||
| refs_dir = skill_dir / "references" | ||
| refs_dir.mkdir() | ||
| (refs_dir / "plugin-list.md").write_text("# Plugins\n- plugin-a\n", encoding="utf-8") | ||
|
|
||
| skills_config = SkillsConfiguration(paths=[skills_root]) | ||
| mock_config = mocker.patch("app.endpoints.skills.configuration") | ||
| mock_config.configuration.skills = skills_config | ||
|
|
||
| request = Request(scope={"type": "http"}) | ||
| response = await skills_endpoint_handler(auth=MOCK_AUTH, request=request) | ||
|
|
||
| assert isinstance(response, SkillsResponse) | ||
| assert len(response.skills) == 1 | ||
| assert response.skills[0]["name"] == "rhdh-dynamic-plugins" | ||
| assert response.skills[0]["description"] == "RHDH dynamic plugins guide" | ||
Uh oh!
There was an error while loading. Please reload this page.