diff --git a/docs/migration.md b/docs/migration.md index 2c349c82..f2e57f12 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -109,6 +109,9 @@ For example, after tagging dataset 21 with the tag `"foo"`: ## Setups +### `GET /{id}` +The endpoint behaves identically to the PHP implementation. Note that `setup_id` and `flow_id` are consistently returned as strings instead of integers to maintain strict backward compatibility. Also, if a setup has no parameters, the `parameter` field is omitted entirely from the response. + ### `POST /setup/tag` and `POST /setup/untag` When successful, the "tag" property in the returned response is now always a list, even if only one tag exists for the entity. When removing the last tag, the "tag" property will be an empty list `[]` instead of being omitted from the response. diff --git a/src/database/setups.py b/src/database/setups.py index e399f194..a91af5eb 100644 --- a/src/database/setups.py +++ b/src/database/setups.py @@ -1,7 +1,7 @@ """All database operations that directly operate on setups.""" from sqlalchemy import text -from sqlalchemy.engine import Row +from sqlalchemy.engine import Row, RowMapping from sqlalchemy.ext.asyncio import AsyncConnection @@ -20,6 +20,33 @@ async def get(setup_id: int, connection: AsyncConnection) -> Row | None: return row.first() +async def get_parameters(setup_id: int, connection: AsyncConnection) -> list[RowMapping]: + """Get all parameters for setup with `setup_id` from the database.""" + rows = await connection.execute( + text( + """ + SELECT + CAST(t_input.id AS CHAR) as id, + CAST(t_input.implementation_id AS CHAR) as flow_id, + t_impl.name AS flow_name, + CONCAT(t_impl.fullName, '_', t_input.name) AS full_name, + t_input.name AS parameter_name, + t_input.name AS name, + t_input.dataType AS data_type, + t_input.defaultValue AS default_value, + t_setting.value AS value + FROM input_setting t_setting + JOIN input t_input ON t_setting.input_id = t_input.id + JOIN implementation t_impl ON t_input.implementation_id = t_impl.id + WHERE t_setting.setup = :setup_id + ORDER BY t_impl.id, t_input.id + """, + ), + parameters={"setup_id": setup_id}, + ) + return list(rows.mappings().all()) + + async def get_tags(setup_id: int, connection: AsyncConnection) -> list[Row]: """Get all tags for setup with `setup_id` from the database.""" rows = await connection.execute( diff --git a/src/routers/openml/setups.py b/src/routers/openml/setups.py index 65d2d533..267866d5 100644 --- a/src/routers/openml/setups.py +++ b/src/routers/openml/setups.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, Path from sqlalchemy.ext.asyncio import AsyncConnection import database.setups @@ -15,10 +15,33 @@ from database.users import User, UserGroup from routers.dependencies import expdb_connection, fetch_user_or_raise from routers.types import SystemString64 +from schemas.setups import SetupParameters, SetupResponse router = APIRouter(prefix="/setup", tags=["setup"]) +@router.get(path="/{setup_id}", response_model_exclude_none=True) +async def get_setup( + setup_id: Annotated[int, Path()], + expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)], +) -> SetupResponse: + """Get setup by id.""" + setup = await database.setups.get(setup_id, expdb_db) + if not setup: + msg = f"Setup {setup_id} not found." + raise SetupNotFoundError(msg, code=281) + + setup_parameters = await database.setups.get_parameters(setup_id, expdb_db) + + params_model = SetupParameters( + setup_id=str(setup_id), + flow_id=str(setup.implementation_id), + parameter=setup_parameters or None, + ) + + return SetupResponse(setup_parameters=params_model) + + @router.post(path="/tag") async def tag_setup( setup_id: Annotated[int, Body()], diff --git a/src/schemas/setups.py b/src/schemas/setups.py new file mode 100644 index 00000000..7dbb7011 --- /dev/null +++ b/src/schemas/setups.py @@ -0,0 +1,37 @@ +"""Pydantic schemas for the setup API endpoints.""" + +from pydantic import BaseModel, ConfigDict + + +class SetupParameter(BaseModel): + """Schema representing an individual parameter within a setup.""" + + id: str + flow_id: str + flow_name: str + full_name: str + parameter_name: str + name: str + data_type: str | None = None + default_value: str | None = None + value: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class SetupParameters(BaseModel): + """Schema representing the grouped properties of a setup and its parameters.""" + + setup_id: str + flow_id: str + parameter: list[SetupParameter] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class SetupResponse(BaseModel): + """Schema for the complete response of the GET /setup/{id} endpoint.""" + + setup_parameters: SetupParameters + + model_config = ConfigDict(from_attributes=True) diff --git a/tests/routers/openml/migration/setups_migration_test.py b/tests/routers/openml/migration/setups_migration_test.py index b33742e0..3aefcab4 100644 --- a/tests/routers/openml/migration/setups_migration_test.py +++ b/tests/routers/openml/migration/setups_migration_test.py @@ -266,6 +266,35 @@ async def test_setup_tag_response_is_identical_tag_already_exists( assert original.status_code == HTTPStatus.INTERNAL_SERVER_ERROR assert new.status_code == HTTPStatus.CONFLICT - assert original.json()["error"]["code"] == new.json()["code"] assert original.json()["error"]["message"] == "Entity already tagged by this tag." assert new.json()["detail"] == f"Setup {setup_id} already has tag {tag!r}." + + +async def test_get_setup_response_is_identical_setup_doesnt_exist( + py_api: httpx.AsyncClient, + php_api: httpx.AsyncClient, +) -> None: + setup_id = 999999 + + original = await php_api.get(f"/setup/{setup_id}") + new = await py_api.get(f"/setup/{setup_id}") + + assert original.status_code == HTTPStatus.PRECONDITION_FAILED + assert new.status_code == HTTPStatus.NOT_FOUND + assert original.json()["error"]["message"] == "Unknown setup" + assert original.json()["error"]["code"] == new.json()["code"] + + +@pytest.mark.parametrize("setup_id", [1, 48]) +async def test_get_setup_response_is_identical( + setup_id: int, + py_api: httpx.AsyncClient, + php_api: httpx.AsyncClient, +) -> None: + original = await php_api.get(f"/setup/{setup_id}") + new = await py_api.get(f"/setup/{setup_id}") + + assert original.status_code == HTTPStatus.OK + assert new.status_code == HTTPStatus.OK + + assert original.json() == new.json() diff --git a/tests/routers/openml/setups_test.py b/tests/routers/openml/setups_test.py index 305bf423..db35b41b 100644 --- a/tests/routers/openml/setups_test.py +++ b/tests/routers/openml/setups_test.py @@ -130,3 +130,17 @@ async def test_setup_tag_success(py_api: httpx.AsyncClient, expdb_test: AsyncCon text("SELECT * FROM setup_tag WHERE id = 1 AND tag = 'my_new_success_tag'") ) assert len(rows.all()) == 1 + + +async def test_get_setup_unknown(py_api: httpx.AsyncClient) -> None: + response = await py_api.get("/setup/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert re.match(r"Setup \d+ not found.", response.json()["detail"]) + + +async def test_get_setup_success(py_api: httpx.AsyncClient) -> None: + response = await py_api.get("/setup/1") + assert response.status_code == HTTPStatus.OK + data = response.json()["setup_parameters"] + assert data["setup_id"] == "1" + assert "parameter" in data