Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ The `limit` and `offset` parameters can now be used independently, you no longer
to provide both if you wish to set only one.

#### `POST /datasets/tag`

???+ warning "This endpoint is deprecated"

The new tag endpoint is `POST` `/datasets/{identifier}/tags`.
The dataset identifier is provided as part of the path, the tag is still provided in the body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (typo): Fix the comma splice between the two independent clauses.

You can fix this by replacing the comma with "and", using a semicolon, or splitting it into two sentences.

Suggested change
The dataset identifier is provided as part of the path, the tag is still provided in the body.
The dataset identifier is provided as part of the path and the tag is still provided in the body.

This endpoint is provided for easier migration in case the response matters to you.

When successful, the "tag" property in the returned response is now always a list, even if only one tag exists for the entity.
For example, after tagging dataset 21 with the tag `"foo"`:
```diff
Expand Down
9 changes: 7 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@ The sunset date of the PHP-based REST API will depend on the progress of this re
## Phase 1: Achieve Feature Parity

The first goal is _rough_ feature parity.
This means that we want to expose the same endpoints with the same general responses.
We may deviate in case the original implementation had an oversight or a bug, and fix those along the way.
This means that we want to expose the same endpoints with the same general responses, but:

- we may deviate in case the original implementation had an oversight or a bug, and fix those along the way.
- we may drop support for some admin-only or previously undocumented endpoints.
- we may make other alterations at our discretion, e.g., improve consistency in responses or [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) error handling. But we do not significantly restructure "happy path" responses or endpoint structure.

We can be pragmatic about this, for example, copying over a complex query from the PHP API.
This phase is considered complete when the output of the new REST API can be mapped to output of the old REST API for all endpoints the PHP-based API has and the [migration guide](migration.md) is done.


## Phase 2: Modernizing the API

We are also working on incorporating modern standards like [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) for error reporting, better use of JSON types, including authentication in the request's HTTP header instead of the URL, or changing HTTP methods or paths to better reflect the behavior of the endpoint.
We may also consider schema changes to responses to improve e.g., usability or performance.

## Phase 3: Revisiting the Data(base)

Expand Down
38 changes: 26 additions & 12 deletions src/routers/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from http import HTTPStatus
from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple

from fastapi import APIRouter, Body, Depends, Query
from fastapi import APIRouter, Body, Depends, Path, Query
from loguru import logger
from sqlalchemy import bindparam, text

Expand Down Expand Up @@ -89,30 +89,44 @@ def _format_dataset_url(dataset: UntypedRow) -> str:

@router.post(
path="/tag",
deprecated=True,
)
async def tag_dataset(
data_id: Annotated[Identifier, Body()],
tag: Annotated[TagString, Body()],
user: Annotated[User, Depends(fetch_user_or_raise)],
expdb_db: Annotated[AsyncSession, Depends(expdb_session)],
expdb: Annotated[AsyncSession, Depends(expdb_session)],
) -> dict[str, dict[str, Any]]:
"""Add a tag to the dataset, this tag is publicly visible to all users."""
await tag_dataset_new(data_id, tag, user, expdb)

tags = await database.datasets.get_tags_for(data_id, expdb)

return {
"data_tag": {"id": str(data_id), "tag": tags},
}


@router.post(
path="/{identifier}/tags",
)
async def tag_dataset_new(
identifier: Annotated[Identifier, Path()],
tag: Annotated[TagString, Body(embed=True)],
user: Annotated[User, Depends(fetch_user_or_raise)],
expdb: Annotated[AsyncSession, Depends(expdb_session)],
) -> None:
"""Add a tag to the dataset, this tag is publicly visible to all users."""
try:
await database.datasets.tag(data_id, tag, user_id=user.user_id, session=expdb_db)
await database.datasets.tag(identifier, tag, user_id=user.user_id, session=expdb)
except ForeignKeyConstraintError:
msg = f"Dataset {data_id} not found."
msg = f"Dataset {identifier} not found."
raise DatasetNotFoundError(msg, code=472) from None
except DuplicatePrimaryKeyError:
msg = f"Dataset {data_id} already tagged with {tag!r}."
msg = f"Dataset {identifier} already tagged with {tag!r}."
raise TagAlreadyExistsError(msg) from None

logger.info("Dataset {data_id} tagged '{tag}'.", data_id=data_id, tag=tag)

tags = await database.datasets.get_tags_for(data_id, expdb_db)

return {
"data_tag": {"id": str(data_id), "tag": tags},
}
logger.info("Dataset {identifier} tagged '{tag}'.", identifier=identifier, tag=tag)


@router.post(path="/untag", deprecated=True)
Expand Down
49 changes: 34 additions & 15 deletions tests/routers/dataset_tag_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,41 @@
from sqlalchemy.ext.asyncio import AsyncSession


@pytest.mark.parametrize(
"key",
[None, ApiKey.INVALID],
ids=["no authentication", "invalid key"],
)
async def test_dataset_tag_rejects_unauthorized(key: ApiKey, py_api: httpx.AsyncClient) -> None:
apikey = "" if key is None else f"?api_key={key}"
async def test_dataset_tag_requires_authorization(py_api: httpx.AsyncClient) -> None:
any_dataset_identifier = 1
response = await py_api.post(
f"/datasets/tag{apikey}",
"/datasets/tag",
json={"data_id": any_dataset_identifier, "tag": "test"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED


# ── Direct call tests: tag_dataset ──
async def test_dataset_tag_json(py_api: httpx.AsyncClient, dataset_factory: DatasetFactory) -> None:
dataset_id = await dataset_factory()
response = await py_api.post(
f"/datasets/tag?api_key={ApiKey.SOME_USER}",
json={"data_id": dataset_id, "tag": "test"},
)
assert response.status_code == HTTPStatus.OK
expected_json = {
"data_tag": {
"id": str(dataset_id),
"tag": ["test"],
}
}
assert response.json() == expected_json


async def test_dataset_tag_new_json(
py_api: httpx.AsyncClient, dataset_factory: DatasetFactory
) -> None:
dataset_id = await dataset_factory()
response = await py_api.post(
f"/datasets/{dataset_id}/tags?api_key={ApiKey.SOME_USER}",
json={"tag": "test"},
)
assert response.status_code == HTTPStatus.OK, response.json()
assert response.json() is None


@pytest.mark.mut
Expand All @@ -47,7 +66,7 @@ async def test_dataset_tag(
) -> None:
dataset_id = await dataset_factory()
tag = "test_tag"
result = await tag_dataset(data_id=dataset_id, tag=tag, user=user, expdb_db=expdb_session)
result = await tag_dataset(data_id=dataset_id, tag=tag, user=user, expdb=expdb_session)
assert result == {"data_tag": {"id": str(dataset_id), "tag": [tag]}}

tags = await get_tags_for(dataset_id=dataset_id, session=expdb_session)
Expand All @@ -59,9 +78,9 @@ async def test_dataset_tag_returns_existing_tags(
expdb_session: AsyncSession, dataset_factory: DatasetFactory
) -> None:
dataset_id = await dataset_factory()
await tag_dataset(data_id=dataset_id, tag="first", user=OWNER_USER, expdb_db=expdb_session)
await tag_dataset(data_id=dataset_id, tag="first", user=OWNER_USER, expdb=expdb_session)
result = await tag_dataset(
data_id=dataset_id, tag="second", user=ADMIN_USER, expdb_db=expdb_session
data_id=dataset_id, tag="second", user=ADMIN_USER, expdb=expdb_session
)
assert result == {"data_tag": {"id": str(dataset_id), "tag": ["first", "second"]}}

Expand All @@ -72,10 +91,10 @@ async def test_dataset_tag_fails_if_tag_exists(
) -> None:
tag = "repeated_tag"
dataset_id = await dataset_factory()
await tag_dataset(data_id=dataset_id, tag=tag, user=OWNER_USER, expdb_db=expdb_session)
await tag_dataset(data_id=dataset_id, tag=tag, user=OWNER_USER, expdb=expdb_session)

with pytest.raises(TagAlreadyExistsError) as e:
await tag_dataset(data_id=dataset_id, tag=tag, user=ADMIN_USER, expdb_db=expdb_session)
await tag_dataset(data_id=dataset_id, tag=tag, user=ADMIN_USER, expdb=expdb_session)
assert str(dataset_id) in e.value.detail
assert tag in e.value.detail

Expand All @@ -87,7 +106,7 @@ async def test_dataset_tag_fails_if_dataset_does_not_exist(expdb_session: AsyncS
data_id=dataset_id,
tag="foo",
user=ADMIN_USER,
expdb_db=expdb_session,
expdb=expdb_session,
)
assert str(dataset_id) in e.value.detail
dataset_not_found_in_tag_endpoint = 472
Expand Down
Loading