diff --git a/src/mavedb/lib/authorization.py b/src/mavedb/lib/authorization.py index 9b30cb86..c9b2ab81 100644 --- a/src/mavedb/lib/authorization.py +++ b/src/mavedb/lib/authorization.py @@ -2,7 +2,6 @@ from typing import Optional from fastapi import Depends, HTTPException -from starlette import status from mavedb.lib.authentication import UserData, get_current_user from mavedb.lib.logging.context import logging_context, save_to_logging_context @@ -21,10 +20,7 @@ async def require_current_user( ) -> UserData: if user_data is None: logger.info(msg="Non-authenticated user attempted to access protected route.", extra=logging_context()) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - ) + raise HTTPException(status_code=401, detail="Could not validate credentials") return user_data @@ -38,8 +34,7 @@ async def require_current_user_with_email( msg="User attempted to access email protected route without a valid email.", extra=logging_context() ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="There must be an email address associated with your account to use this feature.", + status_code=403, detail="There must be an email address associated with your account to use this feature." ) return user_data @@ -54,10 +49,7 @@ async def __call__(self, user_data: UserData = Depends(require_current_user)) -> logger.info( msg="User attempted to access role protected route without a required role.", extra=logging_context() ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="You are not authorized to use this feature", - ) + raise HTTPException(status_code=403, detail="You are not authorized to use this feature") return user_data @@ -68,9 +60,6 @@ async def require_role(roles: list[UserRole], user_data: UserData = Depends(requ logger.info( msg="User attempted to access role protected route without a required role.", extra=logging_context() ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="You are not authorized to use this feature", - ) + raise HTTPException(status_code=403, detail="You are not authorized to use this feature") return user_data diff --git a/src/mavedb/lib/taxonomies.py b/src/mavedb/lib/taxonomies.py index 34107477..9dfe39cd 100644 --- a/src/mavedb/lib/taxonomies.py +++ b/src/mavedb/lib/taxonomies.py @@ -66,6 +66,6 @@ async def search_NCBI_taxonomy(db: Session, search: str) -> Any: else: raise HTTPException(status_code=404, detail=f"Taxonomy with search {search_text} not found in NCBI") else: - raise HTTPException(status_code=404, detail="Please enter valid searching words") + raise HTTPException(status_code=400, detail="Search text is required") return taxonomy_record diff --git a/src/mavedb/routers/access_keys.py b/src/mavedb/routers/access_keys.py index ce40529e..c584dcb2 100644 --- a/src/mavedb/routers/access_keys.py +++ b/src/mavedb/routers/access_keys.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends from fastapi.encoders import jsonable_encoder from fastapi.exceptions import HTTPException +from sqlalchemy import and_ from sqlalchemy.orm import Session from mavedb import deps @@ -17,15 +18,27 @@ from mavedb.lib.logging.context import logging_context, save_to_logging_context from mavedb.models.access_key import AccessKey from mavedb.models.enums.user_role import UserRole +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import access_key +TAG_NAME = "Access Keys" + router = APIRouter( - prefix="/api/v1", - tags=["access keys"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Manage API access keys for programmatic access to the MaveDB API.", + "externalDocs": { + "description": "Access Keys Documentation", + "url": "https://mavedb.org/docs/mavedb/accounts.html#api-access-tokens", + }, +} + logger = logging.getLogger(__name__) @@ -49,7 +62,8 @@ def generate_key_pair(): "/users/me/access-keys", status_code=200, response_model=list[access_key.AccessKey], - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="List my access keys", ) def list_my_access_keys(*, user_data: UserData = Depends(require_current_user)) -> Any: """ @@ -62,7 +76,8 @@ def list_my_access_keys(*, user_data: UserData = Depends(require_current_user)) "/users/me/access-keys", status_code=200, response_model=access_key.NewAccessKey, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Create a new access key for myself", ) def create_my_access_key( *, @@ -88,7 +103,8 @@ def create_my_access_key( "/users/me/access-keys/{role}", status_code=200, response_model=access_key.NewAccessKey, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Create a new access key for myself with a specified role", ) async def create_my_access_key_with_role( *, @@ -125,7 +141,12 @@ async def create_my_access_key_with_role( return response_item -@router.delete("/users/me/access-keys/{key_id}", status_code=200, responses={404: {}, 500: {}}) +@router.delete( + "/users/me/access-keys/{key_id}", + status_code=200, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Delete one of my access keys", +) def delete_my_access_key( *, key_id: str, @@ -135,8 +156,20 @@ def delete_my_access_key( """ Delete one of the current user's access keys. """ - item = db.query(AccessKey).filter(AccessKey.key_id == key_id).one_or_none() - if item and item.user.id == user_data.user.id: - db.delete(item) - db.commit() - logger.debug(msg="Successfully deleted provided API key.", extra=logging_context()) + item = ( + db.query(AccessKey) + .filter(and_(AccessKey.key_id == key_id, AccessKey.user_id == user_data.user.id)) + .one_or_none() + ) + + if not item: + logger.warning( + msg="Could not delete API key; Provided key ID does not exist and/or does not belong to the current user.", + extra=logging_context(), + ) + # Never acknowledge the existence of an access key that doesn't belong to the user. + raise HTTPException(status_code=404, detail=f"Access key with ID {key_id} not found.") + + db.delete(item) + db.commit() + logger.debug(msg="Successfully deleted provided API key.", extra=logging_context()) diff --git a/src/mavedb/routers/api_information.py b/src/mavedb/routers/api_information.py index 8ca8c3f3..41f3f7ed 100644 --- a/src/mavedb/routers/api_information.py +++ b/src/mavedb/routers/api_information.py @@ -3,15 +3,27 @@ from fastapi import APIRouter from mavedb import __project__, __version__ +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import api_version -router = APIRouter(prefix="/api/v1/api", tags=["api information"], responses={404: {"description": "Not found"}}) +TAG_NAME = "API Information" +router = APIRouter( + prefix=f"{ROUTER_BASE_PREFIX}/api", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, +) -@router.get("/version", status_code=200, response_model=api_version.ApiVersion, responses={404: {}}) +metadata = { + "name": TAG_NAME, + "description": "Retrieve information about the MaveDB API.", +} + + +@router.get("/version", status_code=200, response_model=api_version.ApiVersion, summary="Show API version") def show_version() -> Any: """ - Describe the API version. + Describe the API version and project. """ return api_version.ApiVersion(name=__project__, version=__version__) diff --git a/src/mavedb/routers/collections.py b/src/mavedb/routers/collections.py index f813ce5b..cf215a69 100644 --- a/src/mavedb/routers/collections.py +++ b/src/mavedb/routers/collections.py @@ -24,24 +24,39 @@ from mavedb.models.experiment import Experiment from mavedb.models.score_set import ScoreSet from mavedb.models.user import User -from mavedb.view_models import collection -from mavedb.view_models import collection_bundle +from mavedb.routers.shared import ( + ACCESS_CONTROL_ERROR_RESPONSES, + BASE_400_RESPONSE, + BASE_409_RESPONSE, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) +from mavedb.view_models import collection, collection_bundle + +TAG_NAME = "Collections" logger = logging.getLogger(__name__) router = APIRouter( - prefix="/api/v1", - tags=["collections"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Manage the members and permissions of data set collections.", +} + @router.get( "/users/me/collections", status_code=200, response_model=collection_bundle.CollectionBundle, response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="List my collections", ) def list_my_collections( *, @@ -49,7 +64,8 @@ def list_my_collections( user_data: UserData = Depends(require_current_user), ) -> Dict[str, Sequence[Collection]]: """ - List my collections. + List the current user's collections. These are all the collections the user either owns or + is listed as a contributor (in any role). """ collection_bundle: Dict[str, Sequence[Collection]] = {} for role in ContributionRole: @@ -92,8 +108,9 @@ def list_my_collections( "/collections/{urn}", status_code=200, response_model=collection.Collection, - responses={404: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, response_model_exclude_none=True, + summary="Fetch a collection by URN", ) def fetch_collection( *, @@ -137,8 +154,9 @@ def fetch_collection( @router.post( "/collections/", response_model=collection.Collection, - responses={422: {}}, + responses={**BASE_400_RESPONSE, **ACCESS_CONTROL_ERROR_RESPONSES}, response_model_exclude_none=True, + summary="Create a collection", ) async def create_collection( *, @@ -147,7 +165,7 @@ async def create_collection( user_data: UserData = Depends(require_current_user_with_email), ) -> Any: """ - Create a collection. + Create a new collection owned by the current user. """ logger.debug(msg="Began creation of new collection.", extra=logging_context()) @@ -197,7 +215,7 @@ async def create_collection( save_to_logging_context(format_raised_exception_info_as_dict(e)) logger.error(msg="Multiple users found with the given ORCID iD", extra=logging_context()) raise HTTPException( - status_code=400, + status_code=500, detail="Multiple MaveDB users found with the given ORCID iD", ) @@ -220,7 +238,7 @@ async def create_collection( except MultipleResultsFound as e: save_to_logging_context(format_raised_exception_info_as_dict(e)) logger.error(msg="Multiple resources found with the given URN", extra=logging_context()) - raise HTTPException(status_code=400, detail="Multiple resources found with the given URN") + raise HTTPException(status_code=500, detail="Multiple resources found with the given URN") item = Collection( **jsonable_encoder( @@ -253,8 +271,9 @@ async def create_collection( @router.patch( "/collections/{urn}", response_model=collection.Collection, - responses={422: {}}, + responses={**BASE_400_RESPONSE, **ACCESS_CONTROL_ERROR_RESPONSES}, response_model_exclude_none=True, + summary="Update a collection", ) async def update_collection( *, @@ -328,7 +347,11 @@ async def update_collection( @router.post( "/collections/{collection_urn}/score-sets", response_model=collection.Collection, - responses={422: {}}, + responses={ + 401: {"description": "Not authenticated"}, + 403: {"description": "User lacks necessary permissions"}, + }, + summary="Add a score set to a collection", ) async def add_score_set_to_collection( *, @@ -399,7 +422,8 @@ async def add_score_set_to_collection( @router.delete( "/collections/{collection_urn}/score-sets/{score_set_urn}", response_model=collection.Collection, - responses={422: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE}, + summary="Remove a score set from a collection", ) async def delete_score_set_from_collection( *, @@ -409,7 +433,8 @@ async def delete_score_set_from_collection( user_data: UserData = Depends(require_current_user_with_email), ) -> Any: """ - Remove a score set from an existing collection. Preserves the score set in the database, only removes the association between the score set and the collection. + Remove a score set from an existing collection. The score set will be preserved in the database. This endpoint will only remove + the association between the score set and the collection. """ save_to_logging_context({"requested_resource": collection_urn}) @@ -435,7 +460,7 @@ async def delete_score_set_from_collection( extra=logging_context(), ) raise HTTPException( - status_code=404, + status_code=409, detail=f"association between score set '{score_set_urn}' and collection '{collection_urn}' not found", ) @@ -478,7 +503,8 @@ async def delete_score_set_from_collection( @router.post( "/collections/{collection_urn}/experiments", response_model=collection.Collection, - responses={422: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Add an experiment to a collection", ) async def add_experiment_to_collection( *, @@ -549,7 +575,8 @@ async def add_experiment_to_collection( @router.delete( "/collections/{collection_urn}/experiments/{experiment_urn}", response_model=collection.Collection, - responses={422: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE}, + summary="Remove an experiment from a collection", ) async def delete_experiment_from_collection( *, @@ -559,7 +586,8 @@ async def delete_experiment_from_collection( user_data: UserData = Depends(require_current_user_with_email), ) -> Any: """ - Remove an experiment from an existing collection. Preserves the experiment in the database, only removes the association between the experiment and the collection. + Remove an experiment from an existing collection. The experiment will be preserved in the database. This endpoint will only remove + the association between the experiment and the collection. """ save_to_logging_context({"requested_resource": collection_urn}) @@ -585,7 +613,7 @@ async def delete_experiment_from_collection( extra=logging_context(), ) raise HTTPException( - status_code=404, + status_code=409, detail=f"association between experiment '{experiment_urn}' and collection '{collection_urn}' not found", ) @@ -628,7 +656,8 @@ async def delete_experiment_from_collection( @router.post( "/collections/{urn}/{role}s", response_model=collection.Collection, - responses={422: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE}, + summary="Add a user to a collection role", ) async def add_user_to_collection_role( *, @@ -640,7 +669,7 @@ async def add_user_to_collection_role( ) -> Any: """ Add an existing user to a collection under the specified role. - Removes the user from any other roles in this collection. + If a user is already in a role for this collection, this will remove the user from any other roles in this collection. """ save_to_logging_context({"requested_resource": urn}) @@ -680,7 +709,7 @@ async def add_user_to_collection_role( extra=logging_context(), ) raise HTTPException( - status_code=400, + status_code=409, detail=f"user with ORCID iD '{body.orcid_id}' is already a {role} for collection '{urn}'", ) # A user can only be in one role per collection, so remove from any other roles @@ -714,7 +743,8 @@ async def add_user_to_collection_role( @router.delete( "/collections/{urn}/{role}s/{orcid_id}", response_model=collection.Collection, - responses={422: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE}, + summary="Remove a user from a collection role", ) async def remove_user_from_collection_role( *, @@ -725,7 +755,8 @@ async def remove_user_from_collection_role( user_data: UserData = Depends(require_current_user_with_email), ) -> Any: """ - Remove a user from a collection role. + Remove a user from a collection role. Both the user and the role should be provided explicitly and match + the current assignment. """ save_to_logging_context({"requested_resource": urn}) @@ -768,7 +799,7 @@ async def remove_user_from_collection_role( extra=logging_context(), ) raise HTTPException( - status_code=404, + status_code=409, detail=f"user with ORCID iD '{orcid_id}' does not currently hold the role {role} for collection '{urn}'", ) @@ -794,7 +825,11 @@ async def remove_user_from_collection_role( return item -@router.delete("/collections/{urn}", responses={422: {}}) +@router.delete( + "/collections/{urn}", + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Delete a collection", +) async def delete_collection( *, urn: str, diff --git a/src/mavedb/routers/controlled_keywords.py b/src/mavedb/routers/controlled_keywords.py index 25891b7a..a5c08152 100644 --- a/src/mavedb/routers/controlled_keywords.py +++ b/src/mavedb/routers/controlled_keywords.py @@ -6,19 +6,33 @@ from mavedb import deps from mavedb.lib.keywords import search_keyword as _search_keyword from mavedb.models.controlled_keyword import ControlledKeyword +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import keyword +TAG_NAME = "Controlled Keywords" + router = APIRouter( - prefix="/api/v1/controlled-keywords", tags=["controlled-keywords"], responses={404: {"description": "Not found"}} + prefix=f"{ROUTER_BASE_PREFIX}/controlled-keywords", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Retrieve controlled keywords used for annotating MaveDB records.", + "externalDocs": { + "description": "Controlled Keywords Schema", + "url": "https://github.com/ave-dcd/mave_vocabulary?tab=readme-ov-file", + }, +} + @router.get( "/{key}", status_code=200, response_model=list[keyword.Keyword], - responses={404: {}}, response_model_exclude_none=True, + summary="Fetch keywords by category", ) def fetch_keywords_by_key( *, @@ -26,7 +40,7 @@ def fetch_keywords_by_key( db: Session = Depends(deps.get_db), ) -> list[ControlledKeyword]: """ - Fetch keywords by category. + Fetch the controlled keywords for a given key. """ lower_key = key.lower() items = ( @@ -40,9 +54,11 @@ def fetch_keywords_by_key( return items -@router.post("/search/{key}/{value}", status_code=200, response_model=keyword.Keyword) +@router.post( + "/search/{key}/{value}", status_code=200, response_model=keyword.Keyword, summary="Search keyword by key and value" +) def search_keyword_by_key_and_value(key: str, label: str, db: Session = Depends(deps.get_db)) -> ControlledKeyword: """ - Search keywords. + Search controlled keywords by key and label. """ return _search_keyword(db, key, label) diff --git a/src/mavedb/routers/doi_identifiers.py b/src/mavedb/routers/doi_identifiers.py index fa992bb5..a13bb804 100644 --- a/src/mavedb/routers/doi_identifiers.py +++ b/src/mavedb/routers/doi_identifiers.py @@ -6,18 +6,34 @@ from mavedb import deps from mavedb.models.doi_identifier import DoiIdentifier +from mavedb.routers.shared import BASE_400_RESPONSE, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import doi_identifier from mavedb.view_models.search import TextSearch +TAG_NAME = "DOI Identifiers" + router = APIRouter( - prefix="/api/v1/doi-identifiers", tags=["DOI identifiers"], responses={404: {"description": "Not found"}} + prefix=f"{ROUTER_BASE_PREFIX}/doi-identifiers", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve DOI identifiers associated with MaveDB records.", +} + -@router.post("/search", status_code=200, response_model=List[doi_identifier.DoiIdentifier]) +@router.post( + "/search", + status_code=200, + response_model=List[doi_identifier.DoiIdentifier], + responses={**BASE_400_RESPONSE}, + summary="Search DOI identifiers", +) def search_doi_identifiers(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ - Search DOI identifiers. + Search DOI identifiers based on the provided text. """ query = db.query(DoiIdentifier) @@ -26,7 +42,7 @@ def search_doi_identifiers(search: TextSearch, db: Session = Depends(deps.get_db lower_search_text = search.text.strip().lower() query = query.filter(func.lower(DoiIdentifier.identifier).contains(lower_search_text)) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(DoiIdentifier.identifier).limit(50).all() if not items: diff --git a/src/mavedb/routers/experiment_sets.py b/src/mavedb/routers/experiment_sets.py index 5f8df70d..386da37b 100644 --- a/src/mavedb/routers/experiment_sets.py +++ b/src/mavedb/routers/experiment_sets.py @@ -10,17 +10,29 @@ from mavedb.lib.experiments import enrich_experiment_with_num_score_sets from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.lib.permissions import Action, has_permission +from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.models.experiment_set import ExperimentSet +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import experiment_set +TAG_NAME = "Experiment Sets" + router = APIRouter( - prefix="/api/v1/experiment-sets", - tags=["experiment-sets"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/experiment-sets", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Retrieve experiment sets and their associated experiments.", + "externalDocs": { + "description": "Experiment Sets Documentation", + "url": "https://mavedb.org/docs/mavedb/record_types.html#experiment-sets", + }, +} + logger = logging.getLogger(__name__) @@ -28,7 +40,8 @@ "/{urn}", status_code=200, response_model=experiment_set.ExperimentSet, - responses={404: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Fetch experiment set by URN", ) def fetch_experiment_set( *, urn: str, db: Session = Depends(deps.get_db), user_data: UserData = Depends(get_current_user) @@ -48,7 +61,7 @@ def fetch_experiment_set( else: item.experiments.sort(key=attrgetter("urn")) - has_permission(user_data, item, Action.READ) + assert_permission(user_data, item, Action.READ) # Filter experiment sub-resources to only those experiments readable by the requesting user. item.experiments[:] = [exp for exp in item.experiments if has_permission(user_data, exp, Action.READ).permitted] diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index a2682edd..02d68744 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -23,7 +23,7 @@ from mavedb.lib.keywords import search_keyword from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.lib.permissions import Action, assert_permission +from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.lib.score_sets import find_superseded_score_set_tail from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.keywords import validate_keyword_list @@ -32,18 +32,35 @@ from mavedb.models.experiment_controlled_keyword import ExperimentControlledKeywordAssociation from mavedb.models.experiment_set import ExperimentSet from mavedb.models.score_set import ScoreSet +from mavedb.routers.shared import ( + ACCESS_CONTROL_ERROR_RESPONSES, + GATEWAY_ERROR_RESPONSES, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models import experiment, score_set from mavedb.view_models.search import ExperimentsSearch +TAG_NAME = "Experiments" + logger = logging.getLogger(__name__) router = APIRouter( - prefix="/api/v1", - tags=["experiments"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Manage and retrieve experiments and their associated data.", + "externalDocs": { + "description": "Experiments Documentation", + "url": "https://mavedb.org/docs/mavedb/record_types.html#experiments", + }, +} + # None of any part calls this function. Feel free to modify it if we need it in the future. @router.get( @@ -51,6 +68,8 @@ status_code=200, response_model=list[experiment.Experiment], response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="List experiments", ) def list_experiments( *, @@ -59,15 +78,15 @@ def list_experiments( user_data: Optional[UserData] = Depends(get_current_user), ) -> list[Experiment]: """ - List experiments. + List all experiments viewable by the current user. """ - query = db.query(Experiment) + if editable and user_data is None: + logger.debug(msg="User is anonymous; Cannot list their experiments.", extra=logging_context()) + return [] - if editable: - if user_data is None or user_data.user is None: - logger.debug(msg="User is anonymous; Cannot list their experiments.", extra=logging_context()) - return [] + query = db.query(Experiment) + if editable and user_data is not None: logger.debug(msg="Listing experiments for the current user.", extra=logging_context()) query = query.filter( or_( @@ -77,13 +96,14 @@ def list_experiments( ) items = query.order_by(Experiment.urn).all() - return items + return [item for item in items if has_permission(user_data, item, Action.READ).permitted] @router.post( "/experiments/search", status_code=200, response_model=list[experiment.ShortExperiment], + summary="Search experiments", ) def search_experiments(search: ExperimentsSearch, db: Session = Depends(deps.get_db)) -> Any: """ @@ -97,6 +117,8 @@ def search_experiments(search: ExperimentsSearch, db: Session = Depends(deps.get "/me/experiments/search", status_code=200, response_model=list[experiment.ShortExperiment], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Search my experiments", ) def search_my_experiments( search: ExperimentsSearch, @@ -114,7 +136,8 @@ def search_my_experiments( "/experiments/{urn}", status_code=200, response_model=experiment.Experiment, - responses={404: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Fetch experiment by URN", response_model_exclude_none=True, ) def fetch_experiment( @@ -142,7 +165,8 @@ def fetch_experiment( "/experiments/{urn}/score-sets", status_code=200, response_model=list[score_set.ScoreSet], - responses={404: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get score sets for an experiment", response_model_exclude_none=True, ) def get_experiment_score_sets( @@ -179,24 +203,26 @@ def get_experiment_score_sets( logger.info(msg="No score sets are associated with the requested experiment.", extra=logging_context()) raise HTTPException(status_code=404, detail="no associated score sets") - else: - filtered_score_sets.sort(key=attrgetter("urn")) - save_to_logging_context({"associated_resources": [item.urn for item in score_set_result]}) - enriched_score_sets = [] - for fs in filtered_score_sets: - enriched_experiment = enrich_experiment_with_num_score_sets(fs.experiment, user_data) - response_item = score_set.ScoreSet.model_validate(fs).copy(update={"experiment": enriched_experiment}) - enriched_score_sets.append(response_item) - return enriched_score_sets + filtered_score_sets.sort(key=attrgetter("urn")) + save_to_logging_context({"associated_resources": [item.urn for item in score_set_result]}) + enriched_score_sets = [] + for fs in filtered_score_sets: + enriched_experiment = enrich_experiment_with_num_score_sets(fs.experiment, user_data) + response_item = score_set.ScoreSet.model_validate(fs).copy(update={"experiment": enriched_experiment}) + enriched_score_sets.append(response_item) - return filtered_score_sets + return enriched_score_sets @router.post( "/experiments/", + status_code=200, response_model=experiment.Experiment, - responses={422: {}}, + responses={ + **ACCESS_CONTROL_ERROR_RESPONSES, + **GATEWAY_ERROR_RESPONSES, + }, response_model_exclude_none=True, ) async def create_experiment( @@ -236,7 +262,7 @@ async def create_experiment( ] except NonexistentOrcidUserError as e: logger.error(msg="Could not find ORCID user with the provided user ID.", extra=logging_context()) - raise HTTPException(status_code=422, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) try: doi_identifiers = [ @@ -258,11 +284,17 @@ async def create_experiment( except requests.exceptions.ConnectTimeout: logger.error(msg="Gateway timed out while creating experiment identifiers.", extra=logging_context()) - raise HTTPException(status_code=504, detail="Gateway Timeout") + raise HTTPException( + status_code=504, + detail="Gateway Timeout while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) except requests.exceptions.HTTPError: logger.error(msg="Encountered bad gateway while creating experiment identifiers.", extra=logging_context()) - raise HTTPException(status_code=502, detail="Bad Gateway") + raise HTTPException( + status_code=502, + detail="Bad Gateway while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) # create a temporary `primary` attribute on each of our publications that indicates # to our association proxy whether it is a primary publication or not @@ -328,8 +360,12 @@ async def create_experiment( @router.put( "/experiments/{urn}", + status_code=200, response_model=experiment.Experiment, - responses={422: {}}, + responses={ + **ACCESS_CONTROL_ERROR_RESPONSES, + **GATEWAY_ERROR_RESPONSES, + }, response_model_exclude_none=True, ) async def update_experiment( @@ -377,25 +413,40 @@ async def update_experiment( ] except NonexistentOrcidUserError as e: logger.error(msg="Could not find ORCID user with the provided user ID.", extra=logging_context()) - raise HTTPException(status_code=422, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) - doi_identifiers = [ - await find_or_create_doi_identifier(db, identifier.identifier) - for identifier in item_update.doi_identifiers or [] - ] - raw_read_identifiers = [ - await find_or_create_raw_read_identifier(db, identifier.identifier) - for identifier in item_update.raw_read_identifiers or [] - ] + try: + doi_identifiers = [ + await find_or_create_doi_identifier(db, identifier.identifier) + for identifier in item_update.doi_identifiers or [] + ] + raw_read_identifiers = [ + await find_or_create_raw_read_identifier(db, identifier.identifier) + for identifier in item_update.raw_read_identifiers or [] + ] - primary_publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in item_update.primary_publication_identifiers or [] - ] - publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in item_update.secondary_publication_identifiers or [] - ] + primary_publication_identifiers + primary_publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in item_update.primary_publication_identifiers or [] + ] + publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in item_update.secondary_publication_identifiers or [] + ] + primary_publication_identifiers + + except requests.exceptions.ConnectTimeout: + logger.error(msg="Gateway timed out while creating experiment identifiers.", extra=logging_context()) + raise HTTPException( + status_code=504, + detail="Gateway Timeout while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) + + except requests.exceptions.HTTPError: + logger.error(msg="Encountered bad gateway while creating experiment identifiers.", extra=logging_context()) + raise HTTPException( + status_code=502, + detail="Bad Gateway while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) # create a temporary `primary` attribute on each of our publications that indicates # to our association proxy whether it is a primary publication or not @@ -431,7 +482,13 @@ async def update_experiment( return enrich_experiment_with_num_score_sets(item, user_data) -@router.delete("/experiments/{urn}", response_model=None, responses={422: {}}) +@router.delete( + "/experiments/{urn}", + status_code=200, + response_model=None, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Delete an experiment", +) async def delete_experiment( *, urn: str, @@ -439,17 +496,7 @@ async def delete_experiment( user_data: UserData = Depends(require_current_user), ) -> None: """ - Delete a experiment . - - Raises - - Returns - _______ - Does not return anything - string : HTTP code 200 successful but returning content - or - communitcate to client whether the operation succeeded - 204 if successful but not returning content - likely going with this + Delete an experiment. """ save_to_logging_context({"requested_resource": urn}) diff --git a/src/mavedb/routers/hgvs.py b/src/mavedb/routers/hgvs.py index 87ee26e5..ca43aa4b 100644 --- a/src/mavedb/routers/hgvs.py +++ b/src/mavedb/routers/hgvs.py @@ -8,18 +8,31 @@ from hgvs.exceptions import HGVSDataNotAvailableError, HGVSInvalidVariantError from mavedb.deps import hgvs_data_provider +from mavedb.routers.shared import BASE_400_RESPONSE, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX + +TAG_NAME = "Transcripts" router = APIRouter( - prefix="/api/v1/hgvs", - tags=["transcripts"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/hgvs", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Retrieve transcript information and validate HGVS variants.", +} + -@router.get("/fetch/{accession}", status_code=200, response_model=str) +@router.get( + "/fetch/{accession}", + status_code=200, + response_model=str, + summary="Fetch stored sequence by accession", +) def hgvs_fetch(accession: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> str: """ - List stored sequences + Fetches a stored genomic sequence by its accession identifier. """ try: return hdp.seqfetcher.fetch_seq(accession) @@ -27,10 +40,16 @@ def hgvs_fetch(accession: str, hdp: RESTDataProvider = Depends(hgvs_data_provide raise HTTPException(404, str(e)) -@router.post("/validate", status_code=200, response_model=bool) +@router.post( + "/validate", + status_code=200, + response_model=bool, + responses={**BASE_400_RESPONSE}, + summary="Validate a provided variant", +) def hgvs_validate(variant: dict[str, str], hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> bool: """ - Validate a provided variant + Validate the provided HGVS variant string. """ hp = parser.Parser() variant_hgvs = hp.parse(variant["variant"]) @@ -43,18 +62,23 @@ def hgvs_validate(variant: dict[str, str], hdp: RESTDataProvider = Depends(hgvs_ return valid -@router.get("/assemblies", status_code=200, response_model=list[str]) +@router.get("/assemblies", status_code=200, response_model=list[str], summary="List stored assemblies") def list_assemblies(hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> list[str]: """ - List stored assemblies + List stored genomic assemblies """ return list(hdp.assembly_maps.keys()) -@router.get("/{assembly}/accessions", status_code=200, response_model=list[str]) +@router.get( + "/{assembly}/accessions", + status_code=200, + response_model=list[str], + summary="List stored accessions for an assembly", +) def list_accessions(assembly: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> list[str]: """ - List stored accessions + List stored accessions for a specified assembly """ if assembly not in hdp.assembly_maps: raise HTTPException(404, f"Assembly '{assembly}' Not Found") @@ -62,20 +86,20 @@ def list_accessions(assembly: str, hdp: RESTDataProvider = Depends(hgvs_data_pro return list(hdp.get_assembly_map(assembly_name=assembly).keys()) -@router.get("/genes", status_code=200, response_model=list) +@router.get("/genes", status_code=200, response_model=list, summary="List stored genes") def list_genes(): """ - List stored genes + Lists the HGNC names for stored genes """ # Even though it doesn't provide the most complete transcript pool, UTA does provide more direct # access to a complete list of genes which have transcript information available. return list(chain.from_iterable(hgvs.dataproviders.uta.connect()._fetchall("select hgnc from gene"))) -@router.get("/genes/{gene}", status_code=200, response_model=dict[str, Any]) +@router.get("/genes/{gene}", status_code=200, response_model=dict[str, Any], summary="Show stored gene information") def gene_info(gene: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> dict[str, Any]: """ - List stored gene information for a specified gene + Shows all gene metadata for a particular gene """ gene_info = hdp.get_gene_info(gene) @@ -85,10 +109,10 @@ def gene_info(gene: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> return gene_info -@router.get("/gene/{gene}", status_code=200, response_model=list[str]) +@router.get("/gene/{gene}", status_code=200, response_model=list[str], summary="List transcripts for gene") def list_transcripts_for_gene(gene: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> list[str]: """ - List transcripts associated with a particular gene + Lists the transcripts associated with a particular gene """ transcripts = set([tx_info["tx_ac"] for tx_info in hdp.get_tx_for_gene(gene)]) @@ -98,10 +122,10 @@ def list_transcripts_for_gene(gene: str, hdp: RESTDataProvider = Depends(hgvs_da return list(transcripts) -@router.get("/{transcript}", status_code=200, response_model=dict[str, Any]) +@router.get("/{transcript}", status_code=200, response_model=dict[str, Any], summary="Show transcript information") def transcript_info(transcript: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> dict[str, Any]: """ - List transcript information for a particular transcript + Shows all transcript metadata for a particular transcript """ transcript_info = hdp.get_tx_identity_info(transcript) @@ -111,10 +135,12 @@ def transcript_info(transcript: str, hdp: RESTDataProvider = Depends(hgvs_data_p return transcript_info -@router.get("/protein/{transcript}", status_code=200, response_model=str) +@router.get( + "/protein/{transcript}", status_code=200, response_model=str, summary="Convert transcript to protein accession" +) def convert_to_protein(transcript: str, hdp: RESTDataProvider = Depends(hgvs_data_provider)) -> str: """ - Convert a provided transcript from it's nucleotide accession identifier to its protein accession identifier + Convert a provided transcript from it's nucleotide accession identifier to its protein accession """ protein_transcript = hdp.get_pro_ac_for_tx_ac(transcript) diff --git a/src/mavedb/routers/licenses.py b/src/mavedb/routers/licenses.py index 78b29aa1..c12d1d71 100644 --- a/src/mavedb/routers/licenses.py +++ b/src/mavedb/routers/licenses.py @@ -5,38 +5,54 @@ from mavedb import deps from mavedb.models.license import License +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import license -router = APIRouter(prefix="/api/v1/licenses", tags=["licenses"], responses={404: {"description": "Not found"}}) +TAG_NAME = "Licenses" +router = APIRouter( + prefix=f"{ROUTER_BASE_PREFIX}/licenses", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, +) -@router.get("/", status_code=200, response_model=List[license.ShortLicense], responses={404: {}}) +metadata = { + "name": TAG_NAME, + "description": "Retrieve information about licenses supported by MaveDB.", + "externalDocs": { + "description": "Licenses Documentation", + "url": "https://mavedb.org/docs/mavedb/data_licensing.html", + }, +} + + +@router.get("/", status_code=200, response_model=List[license.ShortLicense], summary="List all licenses") def list_licenses( *, db: Session = Depends(deps.get_db), ) -> Any: """ - List licenses. + List all supported licenses. """ items = db.query(License).order_by(License.short_name).all() return items -@router.get("/active", status_code=200, response_model=List[license.ShortLicense], responses={404: {}}) +@router.get("/active", status_code=200, response_model=List[license.ShortLicense], summary="List active licenses") def list_active_licenses( *, db: Session = Depends(deps.get_db), ) -> Any: """ - List active licenses. + List all active licenses. """ items = db.query(License).where(License.active.is_(True)).order_by(License.short_name).all() return items -@router.get("/{item_id}", status_code=200, response_model=license.License, responses={404: {}}) +@router.get("/{item_id}", status_code=200, response_model=license.License, summary="Fetch license by ID") def fetch_license( *, item_id: int, diff --git a/src/mavedb/routers/log.py b/src/mavedb/routers/log.py index 74e5578c..d50dfd26 100644 --- a/src/mavedb/routers/log.py +++ b/src/mavedb/routers/log.py @@ -4,18 +4,26 @@ from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX + +TAG_NAME = "Log" router = APIRouter( - prefix="/api/v1/log", - tags=["log"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/log", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Log interactions with the MaveDB API for auditing and debugging purposes.", +} + # NOTE: Despite not containing any calls to a logger, this route will log posted context # by nature of its inheritance from LoggedRoute. -@router.post("/", status_code=200, response_model=str, responses={404: {}}) +@router.post("/", status_code=200, response_model=str, summary="Log an interaction") def log_it(log_context: dict) -> Any: """ Log an interaction. diff --git a/src/mavedb/routers/mapped_variant.py b/src/mavedb/routers/mapped_variant.py index 1c64a2a5..5657fd3a 100644 --- a/src/mavedb/routers/mapped_variant.py +++ b/src/mavedb/routers/mapped_variant.py @@ -4,32 +4,34 @@ from fastapi import APIRouter, Depends, Path from fastapi.exceptions import HTTPException from ga4gh.core.identifiers import GA4GH_IR_REGEXP -from ga4gh.va_spec.base.core import ExperimentalVariantFunctionalImpactStudyResult, Statement from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine +from ga4gh.va_spec.base.core import ExperimentalVariantFunctionalImpactStudyResult, Statement from sqlalchemy import or_, select from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import Session from mavedb import deps from mavedb.lib.annotation.annotate import ( - variant_study_result, variant_functional_impact_statement, variant_pathogenicity_evidence, + variant_study_result, ) from mavedb.lib.annotation.exceptions import MappingDataDoesntExistException from mavedb.lib.authentication import UserData from mavedb.lib.authorization import get_current_user -from mavedb.lib.permissions import has_permission from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import ( logging_context, save_to_logging_context, ) -from mavedb.lib.permissions import Action, assert_permission +from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.models.mapped_variant import MappedVariant from mavedb.models.variant import Variant +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import mapped_variant +TAG_NAME = "Mapped Variants" + logger = logging.getLogger(__name__) @@ -69,19 +71,30 @@ async def fetch_mapped_variant_by_variant_urn(db: Session, user: Optional[UserDa router = APIRouter( - prefix="/api/v1/mapped-variants", - tags=["mapped variants"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/mapped-variants", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Retrieve mapped variants and their associated variant annotations.", +} + -@router.get("/{urn}", status_code=200, response_model=mapped_variant.MappedVariant, responses={404: {}, 500: {}}) +@router.get( + "/{urn}", + status_code=200, + response_model=mapped_variant.MappedVariant, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Fetch mapped variant by URN", +) async def show_mapped_variant( *, urn: str, db: Session = Depends(deps.get_db), user: Optional[UserData] = Depends(get_current_user) ) -> Any: """ - Fetch a mapped variant by URN. + Fetch a single mapped variant by URN. """ save_to_logging_context({"requested_resource": urn}) @@ -92,13 +105,14 @@ async def show_mapped_variant( "/{urn}/va/study-result", status_code=200, response_model=ExperimentalVariantFunctionalImpactStudyResult, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Construct a VA-Spec StudyResult from a mapped variant", ) async def show_mapped_variant_study_result( *, urn: str, db: Session = Depends(deps.get_db), user: Optional[UserData] = Depends(get_current_user) ) -> ExperimentalVariantFunctionalImpactStudyResult: """ - Construct a VA-Spec StudyResult from a mapped variant. + Construct a single VA-Spec StudyResult from a mapped variant by URN. """ save_to_logging_context({"requested_resource": urn}) @@ -111,16 +125,22 @@ async def show_mapped_variant_study_result( msg=f"Could not construct a study result for mapped variant {urn}: {e}", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"Could not construct a study result for mapped variant {urn}: {e}") + raise HTTPException(status_code=404, detail=f"No study result exists for mapped variant {urn}: {e}") # TODO#416: For now, this route supports only one statement per mapped variant. Eventually, we should support the possibility of multiple statements. -@router.get("/{urn}/va/functional-impact", status_code=200, response_model=Statement, responses={404: {}, 500: {}}) +@router.get( + "/{urn}/va/functional-impact", + status_code=200, + response_model=Statement, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Construct a VA-Spec Statement from a mapped variant", +) async def show_mapped_variant_functional_impact_statement( *, urn: str, db: Session = Depends(deps.get_db), user: Optional[UserData] = Depends(get_current_user) ) -> Statement: """ - Construct a VA-Spec Statement from a mapped variant. + Construct a single VA-Spec Statement from a mapped variant by URN. """ save_to_logging_context({"requested_resource": urn}) @@ -134,7 +154,7 @@ async def show_mapped_variant_functional_impact_statement( extra=logging_context(), ) raise HTTPException( - status_code=404, detail=f"Could not construct a functional impact statement for mapped variant {urn}: {e}" + status_code=404, detail=f"No functional impact statement exists for mapped variant {urn}: {e}" ) if not functional_impact: @@ -144,7 +164,7 @@ async def show_mapped_variant_functional_impact_statement( ) raise HTTPException( status_code=404, - detail=f"Could not construct a functional impact statement for mapped variant {urn}. Variant does not have sufficient evidence to evaluate its functional impact.", + detail=f"No functional impact statement exists for mapped variant {urn}. Variant does not have sufficient evidence to evaluate its functional impact.", ) return functional_impact @@ -155,13 +175,14 @@ async def show_mapped_variant_functional_impact_statement( "/{urn}/va/clinical-evidence", status_code=200, response_model=VariantPathogenicityEvidenceLine, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Construct a VA-Spec EvidenceLine from a mapped variant", ) async def show_mapped_variant_acmg_evidence_line( *, urn: str, db: Session = Depends(deps.get_db), user: Optional[UserData] = Depends(get_current_user) ) -> VariantPathogenicityEvidenceLine: """ - Construct a list of VA-Spec EvidenceLine(s) from a mapped variant. + Construct a list of VA-Spec EvidenceLine(s) from a mapped variant by URN. """ save_to_logging_context({"requested_resource": urn}) @@ -175,7 +196,7 @@ async def show_mapped_variant_acmg_evidence_line( extra=logging_context(), ) raise HTTPException( - status_code=404, detail=f"Could not construct a pathogenicity evidence line for mapped variant {urn}: {e}" + status_code=404, detail=f"No pathogenicity evidence line exists for mapped variant {urn}: {e}" ) if not pathogenicity_evidence: @@ -185,7 +206,7 @@ async def show_mapped_variant_acmg_evidence_line( ) raise HTTPException( status_code=404, - detail=f"Could not construct a pathogenicity evidence line for mapped variant {urn}; Variant does not have sufficient evidence to evaluate its pathogenicity.", + detail=f"No pathogenicity evidence line exists for mapped variant {urn}; Variant does not have sufficient evidence to evaluate its pathogenicity.", ) return pathogenicity_evidence @@ -195,7 +216,8 @@ async def show_mapped_variant_acmg_evidence_line( "/vrs/{identifier}", status_code=200, response_model=list[mapped_variant.MappedVariant], - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Fetch mapped variants by VRS identifier", ) async def show_mapped_variants_by_identifier( *, @@ -212,7 +234,7 @@ async def show_mapped_variants_by_identifier( user: Optional[UserData] = Depends(get_current_user), ) -> list[MappedVariant]: """ - Fetch a mapped variant by GA4GH identifier. + Fetch a single mapped variant by GA4GH identifier. """ query = select(MappedVariant).where( or_(MappedVariant.pre_mapped["id"].astext == identifier, MappedVariant.post_mapped["id"].astext == identifier) diff --git a/src/mavedb/routers/orcid.py b/src/mavedb/routers/orcid.py index 53f4a090..8df3898b 100644 --- a/src/mavedb/routers/orcid.py +++ b/src/mavedb/routers/orcid.py @@ -11,22 +11,42 @@ from mavedb.lib.logging.context import logging_context, save_to_logging_context from mavedb.lib.orcid import fetch_orcid_user from mavedb.models.user import User +from mavedb.routers.shared import ( + ACCESS_CONTROL_ERROR_RESPONSES, + BASE_401_RESPONSE, + GATEWAY_ERROR_RESPONSES, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models import orcid +TAG_NAME = "Orcid" + logger = logging.getLogger(__name__) router = APIRouter( - prefix="/api/v1/orcid", - tags=["orcid"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/orcid", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Look up ORCID users and handle ORCID authentication.", +} + ORCID_CLIENT_ID = os.getenv("ORCID_CLIENT_ID") ORCID_CLIENT_SECRET = os.getenv("ORCID_CLIENT_SECRET") -@router.get("/users/{orcid_id}", status_code=200, response_model=orcid.OrcidUser) +@router.get( + "/users/{orcid_id}", + status_code=200, + response_model=orcid.OrcidUser, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **GATEWAY_ERROR_RESPONSES}, + summary="Look up an ORCID user by ORCID ID", +) def lookup_orcid_user( orcid_id: str, user: User = Depends(require_current_user), @@ -54,7 +74,8 @@ def lookup_orcid_user( "/token", status_code=200, response_model=orcid.OrcidAuthTokenResponse, - responses={404: {}, 500: {}}, + responses={**BASE_401_RESPONSE}, + summary="Exchange an ORCID authorization code for an access token", include_in_schema=False, ) async def get_token_from_code(*, request: orcid.OrcidAuthTokenRequest) -> Any: diff --git a/src/mavedb/routers/permissions.py b/src/mavedb/routers/permissions.py index 7a16f063..c100cfa2 100644 --- a/src/mavedb/routers/permissions.py +++ b/src/mavedb/routers/permissions.py @@ -15,14 +15,22 @@ from mavedb.models.experiment_set import ExperimentSet from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX + +TAG_NAME = "Permissions" router = APIRouter( - prefix="/api/v1/permissions", - tags=["permissions"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/permissions", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Check user permissions on various MaveDB resources.", +} + logger = logging.getLogger(__name__) @@ -38,6 +46,8 @@ class ModelName(str, Enum): "/user-is-permitted/{model_name}/{urn}/{action}", status_code=200, response_model=bool, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Check user permissions on a resource", ) async def check_permission( *, @@ -48,7 +58,7 @@ async def check_permission( user_data: UserData = Depends(get_current_user), ) -> bool: """ - Check whether users have authorizations in adding/editing/deleting/publishing experiment or score set. + Check whether users have permission to perform a given action on a resource. """ save_to_logging_context({"requested_resource": urn}) diff --git a/src/mavedb/routers/publication_identifiers.py b/src/mavedb/routers/publication_identifiers.py index c8cd37b2..936f625e 100644 --- a/src/mavedb/routers/publication_identifiers.py +++ b/src/mavedb/routers/publication_identifiers.py @@ -11,24 +11,39 @@ from mavedb.lib.identifiers import find_generic_article from mavedb.lib.validation.constants.publication import valid_dbnames from mavedb.models.publication_identifier import PublicationIdentifier +from mavedb.routers.shared import ( + BASE_400_RESPONSE, + GATEWAY_ERROR_RESPONSES, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models import publication_identifier from mavedb.view_models.search import TextSearch +TAG_NAME = "Publication Identifiers" + # I don't think we can escape the type: ignore hint here on a dynamically created enumerated type. PublicationDatabases = Enum("PublicationDataBases", ((x, x) for x in valid_dbnames)) # type: ignore router = APIRouter( - prefix="/api/v1/publication-identifiers", - tags=["publication identifiers"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/publication-identifiers", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve publication identifiers associated with MaveDB records and their metadata.", +} + -@router.get("/", status_code=200, response_model=list[publication_identifier.PublicationIdentifier]) +@router.get( + "/", status_code=200, response_model=list[publication_identifier.PublicationIdentifier], summary="List publications" +) def list_publications(*, db: Session = Depends(deps.get_db)) -> Any: """ - List stored all stored publications. + List all stored publications. """ items = db.query(PublicationIdentifier).all() return items @@ -61,11 +76,11 @@ def to_string(self, value: str) -> str: "/{identifier:publication}", status_code=200, response_model=publication_identifier.PublicationIdentifier, - responses={404: {}}, + summary="Fetch publication by identifier", ) def fetch_publication_by_identifier(*, identifier: str, db: Session = Depends(deps.get_db)) -> PublicationIdentifier: """ - Fetch a single publication by identifier. + Fetch a single saved publication by identifier. """ try: item = db.query(PublicationIdentifier).filter(PublicationIdentifier.identifier == identifier).one_or_none() @@ -84,7 +99,7 @@ def fetch_publication_by_identifier(*, identifier: str, db: Session = Depends(de "/{db_name:str}/{identifier:publication}", status_code=200, response_model=publication_identifier.PublicationIdentifier, - responses={404: {}}, + summary="Fetch publication by db name and identifier", ) def fetch_publication_by_dbname_and_identifier( *, @@ -93,7 +108,7 @@ def fetch_publication_by_dbname_and_identifier( db: Session = Depends(deps.get_db), ) -> PublicationIdentifier: """ - Fetch a single publication by db name and identifier. + Fetch a single saved publication by db name and identifier. """ try: item = ( @@ -115,34 +130,42 @@ def fetch_publication_by_dbname_and_identifier( return item -@router.get("/journals", status_code=200, response_model=list[str], responses={404: {}}) -def list_publication_journal_names(*, db: Session = Depends(deps.get_db)) -> Any: +@router.get("/journals", status_code=200, response_model=list[str], summary="List publication journal names") +def list_publication_journal_names(*, db: Session = Depends(deps.get_db)) -> list[str]: """ - List distinct journal names, in alphabetical order. + List distinct saved journal names, in alphabetical order. """ items = db.scalars( select(PublicationIdentifier).where(PublicationIdentifier.publication_journal.is_not(None)) ).all() journals = map(lambda item: item.publication_journal, items) - return sorted(list(set(journals))) + return sorted([journal for journal in set(journals) if journal is not None]) -@router.get("/databases", status_code=200, response_model=list[str], responses={404: {}}) -def list_publication_database_names(*, db: Session = Depends(deps.get_db)) -> Any: +@router.get("/databases", status_code=200, response_model=list[str], summary="List publication database names") +def list_publication_database_names(*, db: Session = Depends(deps.get_db)) -> list[str]: """ - List distinct database names, in alphabetical order. + List distinct saved database names, in alphabetical order. """ items = db.query(PublicationIdentifier).all() databases = map(lambda item: item.db_name, items) - return sorted(list(set(databases))) + return sorted([database for database in set(databases) if database is not None]) -@router.post("/search/identifier", status_code=200, response_model=list[publication_identifier.PublicationIdentifier]) +@router.post( + "/search/identifier", + status_code=200, + response_model=list[publication_identifier.PublicationIdentifier], + responses={ + 400: {"description": "Bad request"}, + }, + summary="Search publication identifiers", +) def search_publication_identifier_identifiers(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ - Search publication identifiers via a TextSearch query. + Search saved publication identifiers via a TextSearch query. """ query = db.query(PublicationIdentifier) @@ -151,7 +174,7 @@ def search_publication_identifier_identifiers(search: TextSearch, db: Session = lower_search_text = search.text.strip().lower() query = query.filter(func.lower(PublicationIdentifier.identifier).contains(lower_search_text)) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(PublicationIdentifier.identifier).limit(50).all() if not items: @@ -159,10 +182,16 @@ def search_publication_identifier_identifiers(search: TextSearch, db: Session = return items -@router.post("/search/doi", status_code=200, response_model=list[publication_identifier.PublicationIdentifier]) +@router.post( + "/search/doi", + status_code=200, + response_model=list[publication_identifier.PublicationIdentifier], + responses={**BASE_400_RESPONSE}, + summary="Search publication DOIs", +) def search_publication_identifier_dois(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ - Search publication DOIs via a TextSearch query. + Search saved publication DOIs via a TextSearch query. """ query = db.query(PublicationIdentifier) @@ -171,7 +200,7 @@ def search_publication_identifier_dois(search: TextSearch, db: Session = Depends lower_search_text = search.text.strip().lower() query = query.filter(func.lower(PublicationIdentifier.doi).contains(lower_search_text)) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(PublicationIdentifier.doi).limit(50).all() if not items: @@ -179,10 +208,18 @@ def search_publication_identifier_dois(search: TextSearch, db: Session = Depends return items -@router.post("/search", status_code=200, response_model=list[publication_identifier.PublicationIdentifier]) +@router.post( + "/search", + status_code=200, + response_model=list[publication_identifier.PublicationIdentifier], + responses={ + 400: {"description": "Bad request"}, + }, + summary="Search publication identifiers and DOIs", +) def search_publication_identifiers(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ - Search publication identifiers via a TextSearch query, returning substring matches on DOI and Identifier. + Search saved publication identifiers via a TextSearch query, returning substring matches on DOI and Identifier. """ query = db.query(PublicationIdentifier) @@ -196,7 +233,7 @@ def search_publication_identifiers(search: TextSearch, db: Session = Depends(dep ) ) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(PublicationIdentifier.identifier).limit(50).all() if not items: @@ -208,11 +245,11 @@ def search_publication_identifiers(search: TextSearch, db: Session = Depends(dep "/search/{identifier}", status_code=200, response_model=publication_identifier.PublicationIdentifier, - responses={404: {}, 500: {}}, + summary="Search publication identifiers by their identifier", ) async def search_publications_by_identifier(*, identifier: str, db: Session = Depends(deps.get_db)) -> Any: """ - Search publication identifiers via their identifier. + Search saved publication identifiers via their identifier. """ query = db.query(PublicationIdentifier).filter(PublicationIdentifier.identifier == identifier).all() @@ -225,7 +262,7 @@ async def search_publications_by_identifier(*, identifier: str, db: Session = De "/search/{db_name}/{identifier}", status_code=200, response_model=list[publication_identifier.PublicationIdentifier], - responses={404: {}, 500: {}}, + summary="Search publication identifiers by their identifier and database", ) async def search_publications_by_identifier_and_db( *, @@ -234,7 +271,7 @@ async def search_publications_by_identifier_and_db( db: Session = Depends(deps.get_db), ) -> Any: """ - Search all of the publication identifiers via their identifier and database. + Search all saved publication identifiers via their identifier and database. """ query = ( db.query(PublicationIdentifier) @@ -251,19 +288,26 @@ async def search_publications_by_identifier_and_db( @router.post( - "/search-external", status_code=200, response_model=List[publication_identifier.ExternalPublicationIdentifier] + "/search-external", + status_code=200, + response_model=List[publication_identifier.ExternalPublicationIdentifier], + responses={ + **BASE_400_RESPONSE, + **GATEWAY_ERROR_RESPONSES, + }, + summary="Search external publication identifiers", ) async def search_external_publication_identifiers(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ - Search external publication identifiers via a TextSearch query. - Technically, this should be some sort of accepted publication identifier. + Search external publication identifiers via a TextSearch query. The provided text is searched against multiple external publication databases, + and should be a valid identifier in at least one of those databases. """ if search.text and len(search.text.strip()) > 0: lower_search_text = search.text.strip().lower() items = await find_generic_article(db, lower_search_text) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") if not any(items.values()): raise HTTPException(status_code=404, detail="No publications matched the provided search text") diff --git a/src/mavedb/routers/raw_read_identifiers.py b/src/mavedb/routers/raw_read_identifiers.py index f2ffac42..90ff207b 100644 --- a/src/mavedb/routers/raw_read_identifiers.py +++ b/src/mavedb/routers/raw_read_identifiers.py @@ -6,15 +6,31 @@ from mavedb import deps from mavedb.models.raw_read_identifier import RawReadIdentifier +from mavedb.routers.shared import BASE_400_RESPONSE, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import raw_read_identifier from mavedb.view_models.search import TextSearch +TAG_NAME = "Raw Read Identifiers" + router = APIRouter( - prefix="/api/v1/raw-read-identifiers", tags=["Raw read identifiers"], responses={404: {"description": "Not found"}} + prefix=f"{ROUTER_BASE_PREFIX}/raw-read-identifiers", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve Raw Read identifiers associated with MaveDB records.", +} + -@router.post("/search", status_code=200, response_model=List[raw_read_identifier.RawReadIdentifier]) +@router.post( + "/search", + status_code=200, + response_model=List[raw_read_identifier.RawReadIdentifier], + responses={**BASE_400_RESPONSE}, + summary="Search Raw Read identifiers", +) def search_raw_read_identifiers(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ Search Raw Read identifiers. @@ -26,7 +42,7 @@ def search_raw_read_identifiers(search: TextSearch, db: Session = Depends(deps.g lower_search_text = search.text.strip().lower() query = query.filter(func.lower(RawReadIdentifier.identifier).contains(lower_search_text)) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(RawReadIdentifier.identifier).limit(50).all() if not items: diff --git a/src/mavedb/routers/refget.py b/src/mavedb/routers/refget.py index 7e77f858..979c63d1 100644 --- a/src/mavedb/routers/refget.py +++ b/src/mavedb/routers/refget.py @@ -8,34 +8,46 @@ import logging import os import re +from typing import Optional, Union -from biocommons.seqrepo import SeqRepo, __version__ as seqrepo_dep_version -from fastapi import APIRouter, Depends, Query, HTTPException, Header +from biocommons.seqrepo import SeqRepo +from biocommons.seqrepo import __version__ as seqrepo_dep_version +from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import StreamingResponse -from typing import Optional, Union -from mavedb import deps +from mavedb import __version__, deps from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.lib.seqrepo import get_sequence_ids, base64url_to_hex, sequence_generator +from mavedb.lib.seqrepo import base64url_to_hex, get_sequence_ids, sequence_generator +from mavedb.routers.shared import ( + BASE_400_RESPONSE, + BASE_416_RESPONSE, + BASE_501_RESPONSE, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models.refget import RefgetMetadataResponse, RefgetServiceInfo -from mavedb import __version__ - - RANGE_HEADER_REGEX = r"^bytes=(\d+)-(\d+)$" +TAG_NAME = "Refget" logger = logging.getLogger(__name__) router = APIRouter( - prefix="/api/v1/refget", - tags=["refget"], - responses={404: {"description": "not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/refget", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Implementation of the Refget sequences API for MaveDB.", + "externalDocs": {"description": "Refget API Documentation", "url": "https://ga4gh.github.io/refget/sequences"}, +} + -@router.get("/sequence/service-info", response_model=RefgetServiceInfo) +@router.get("/sequence/service-info", response_model=RefgetServiceInfo, summary="Get Refget service information") def service_info() -> dict[str, Union[str, dict[str, Union[str, list[str], bool, None]]]]: """ Returns information about the refget service. @@ -61,8 +73,11 @@ def service_info() -> dict[str, Union[str, dict[str, Union[str, list[str], bool, } -@router.get("/sequence/{alias}/metadata", response_model=RefgetMetadataResponse) +@router.get("/sequence/{alias}/metadata", response_model=RefgetMetadataResponse, summary="Get Refget sequence metadata") def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[str, dict]: + """ + Show metadata for a particular Refget sequence with the provided alias. + """ save_to_logging_context({"requested_refget_alias": alias, "requested_resource": "metadata"}) seq_ids = get_sequence_ids(sr, alias) @@ -72,7 +87,9 @@ def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[st raise HTTPException(status_code=404, detail="Sequence not found") if len(seq_ids) > 1: logger.error(msg="Multiple sequences found for alias", extra=logging_context()) - raise HTTPException(status_code=422, detail=f"Multiple sequences exist for alias '{alias}'") + raise HTTPException( + status_code=400, detail=f"Multiple sequences exist for alias '{alias}'. Use an explicit namespace" + ) seq_id = seq_ids[0] seqinfo = sr.sequences.fetch_seqinfo(seq_id) @@ -95,21 +112,32 @@ def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[st } -@router.get("/sequence/{alias}") +@router.get( + "/sequence/{alias}", + summary="Get Refget sequence", + responses={ + 200: {"description": "OK: Full sequence returned", "content": {"text/plain": {}}}, + 206: {"description": "Partial Content: Partial sequence returned", "content": {"text/plain": {}}}, + **BASE_400_RESPONSE, + **BASE_416_RESPONSE, + **BASE_501_RESPONSE, + }, +) def get_sequence( alias: str, range_header: Optional[str] = Header( None, alias="Range", - description=""" - Specify a substring as a single HTTP Range. One byte range is permitted, and is 0-based inclusive. - For example, 'Range: bytes=0-9' corresponds to '?start=0&end=10'. - """, + description="Specify a substring as a single HTTP Range. One byte range is permitted, " + "and is 0-based inclusive. For example, 'Range: bytes=0-9' corresponds to '?start=0&end=10'.", ), start: Optional[int] = Query(None, description="Request a subsequence of the data (0-based)."), end: Optional[int] = Query(None, description="Request a subsequence of the data by specifying the end."), sr: SeqRepo = Depends(deps.get_seqrepo), ) -> StreamingResponse: + """ + Get a Refget sequence by alias. + """ save_to_logging_context( { "requested_refget_alias": alias, @@ -150,7 +178,9 @@ def get_sequence( raise HTTPException(status_code=404, detail="Sequence not found") if len(seq_ids) > 1: logger.error(msg="Multiple sequences found for alias", extra=logging_context()) - raise HTTPException(status_code=422, detail=f"Multiple sequences exist for alias '{alias}'") + raise HTTPException( + status_code=400, detail=f"Multiple sequences exist for alias '{alias}'. Use an explicit namespace." + ) seq_id = seq_ids[0] seqinfo = sr.sequences.fetch_seqinfo(seq_id) diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 981d4496..ea174ade 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -5,8 +5,9 @@ from typing import Any, List, Literal, Optional, Sequence, TypedDict, Union import pandas as pd +import requests from arq import ArqRedis -from fastapi import APIRouter, Depends, File, Query, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi.encoders import jsonable_encoder from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.responses import StreamingResponse @@ -77,6 +78,14 @@ from mavedb.models.target_gene import TargetGene from mavedb.models.target_sequence import TargetSequence from mavedb.models.variant import Variant +from mavedb.routers.shared import ( + ACCESS_CONTROL_ERROR_RESPONSES, + BASE_400_RESPONSE, + BASE_409_RESPONSE, + GATEWAY_ERROR_RESPONSES, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models import clinical_control, gnomad_variant, mapped_variant, score_set from mavedb.view_models.contributor import ContributorCreate from mavedb.view_models.doi_identifier import DoiIdentifierCreate @@ -85,6 +94,7 @@ from mavedb.view_models.search import ScoreSetsSearch, ScoreSetsSearchFilterOptionsResponse, ScoreSetsSearchResponse from mavedb.view_models.target_gene import TargetGeneCreate +TAG_NAME = "Score Sets" logger = logging.getLogger(__name__) SCORE_SET_SEARCH_MAX_LIMIT = 100 @@ -204,7 +214,7 @@ async def score_set_update( logger.info( msg="Failed to update score set; The requested license does not exist.", extra=logging_context() ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown license") + raise HTTPException(status_code=404, detail="Unknown license") # Allow in-active licenses to be retained on update if they already exist on the item. elif not license_.active and item.license.id != item_update_license_id: @@ -212,7 +222,7 @@ async def score_set_update( msg="Failed to update score set license; The requested license is no longer active.", extra=logging_context(), ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid license") + raise HTTPException(status_code=409, detail="Invalid license") item.license = license_ @@ -230,10 +240,26 @@ async def score_set_update( PublicationIdentifierCreate(**identifier) for identifier in item_update_dict.get("primary_publication_identifiers") or [] ] - primary_publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in primary_publication_identifiers_list - ] + try: + primary_publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in primary_publication_identifiers_list + ] + except requests.exceptions.ConnectTimeout: + logger.error(msg="Gateway timed out while creating publication identifiers.", extra=logging_context()) + raise HTTPException( + status_code=504, + detail="Gateway Timeout while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) + + except requests.exceptions.HTTPError: + logger.error( + msg="Encountered bad gateway while creating publication identifiers.", extra=logging_context() + ) + raise HTTPException( + status_code=502, + detail="Bad Gateway while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) else: # set to existing primary publication identifiers if not provided in update primary_publication_identifiers = [p for p in item.publication_identifiers if getattr(p, "primary", False)] @@ -243,10 +269,27 @@ async def score_set_update( PublicationIdentifierCreate(**identifier) for identifier in item_update_dict.get("secondary_publication_identifiers") or [] ] - secondary_publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in secondary_publication_identifiers_list - ] + try: + secondary_publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in secondary_publication_identifiers_list + ] + except requests.exceptions.ConnectTimeout: + logger.error(msg="Gateway timed out while creating publication identifiers.", extra=logging_context()) + raise HTTPException( + status_code=504, + detail="Gateway Timeout while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) + + except requests.exceptions.HTTPError: + logger.error( + msg="Encountered bad gateway while creating publication identifiers.", extra=logging_context() + ) + raise HTTPException( + status_code=502, + detail="Bad Gateway while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) + else: # set to existing secondary publication identifiers if not provided in update secondary_publication_identifiers = [ @@ -273,7 +316,7 @@ async def score_set_update( ] except NonexistentOrcidUserError as e: logger.error(msg="Could not find ORCID user with the provided user ID.", extra=logging_context()) - raise HTTPException(status_code=422, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) # Score set has not been published and attributes affecting scores may still be edited. if item.private: @@ -308,7 +351,7 @@ async def score_set_update( extra=logging_context(), ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=404, detail=f"Unknown taxonomy {gene.target_sequence.taxonomy.code}", ) @@ -486,14 +529,29 @@ async def fetch_score_set_by_urn( router = APIRouter( - prefix="/api/v1", - tags=["score sets"], - responses={404: {"description": "not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Manage and retrieve Score Sets and their associated data.", + "externalDocs": { + "description": "Score Sets Documentation", + "url": "https://mavedb.org/docs/mavedb/record_types.html#score-sets", + }, +} + -@router.post("/score-sets/search", status_code=200, response_model=ScoreSetsSearchResponse) +@router.post( + "/score-sets/search", + status_code=200, + response_model=ScoreSetsSearchResponse, + summary="Search score sets", + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, +) def search_score_sets( search: ScoreSetsSearch, db: Session = Depends(deps.get_db), @@ -506,7 +564,7 @@ def search_score_sets( # Disallow searches for unpublished score sets via this endpoint. if search.published is False: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=422, detail="Cannot search for private score sets except in the context of the current user's data.", ) search.published = True @@ -518,7 +576,7 @@ def search_score_sets( search.limit = SCORE_SET_SEARCH_MAX_LIMIT elif search.publication_identifiers is None and (search.limit is None or search.limit > SCORE_SET_SEARCH_MAX_LIMIT): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=422, detail=f"Cannot search for more than {SCORE_SET_SEARCH_MAX_LIMIT} score sets at a time. Please use the offset and limit parameters to run a paginated search.", ) @@ -529,7 +587,7 @@ def search_score_sets( and len(search.publication_identifiers) > SCORE_SET_SEARCH_MAX_PUBLICATION_IDENTIFIERS ): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=422, detail=f"Cannot search for score sets belonging to more than {SCORE_SET_SEARCH_MAX_PUBLICATION_IDENTIFIERS} publication identifiers at once.", ) @@ -553,7 +611,13 @@ def get_filter_options_for_search( return fetch_score_set_search_filter_options(db, None, search) -@router.get("/score-sets/mapped-genes", status_code=200, response_model=dict[str, list[str]]) +@router.get( + "/score-sets/mapped-genes", + status_code=200, + response_model=dict[str, list[str]], + summary="Get score set to mapped gene symbol mapping", + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, +) def score_set_mapped_gene_mapping( db: Session = Depends(deps.get_db), user_data: UserData = Depends(get_current_user) ) -> Any: @@ -587,6 +651,8 @@ def score_set_mapped_gene_mapping( @router.post( "/me/score-sets/search", status_code=200, + summary="Search my score sets", + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, response_model=ScoreSetsSearchResponse, ) def search_my_score_sets( @@ -611,8 +677,9 @@ def search_my_score_sets( "/score-sets/{urn}", status_code=200, response_model=score_set.ScoreSet, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, response_model_exclude_none=True, + summary="Fetch score set by URN", ) async def show_score_set( *, @@ -637,8 +704,11 @@ async def show_score_set( "content": {"text/csv": {}}, "description": """Variant data in CSV format, with four fixed columns (accession, hgvs_nt, hgvs_pro,""" """ and hgvs_splice), plus score columns defined by the score set.""", - } + }, + **BASE_400_RESPONSE, + **ACCESS_CONTROL_ERROR_RESPONSES, }, + summary="Get score set variant data in CSV format", ) def get_score_set_variants_csv( *, @@ -698,10 +768,10 @@ def get_score_set_variants_csv( if start and start < 0: logger.info(msg="Could not fetch scores with negative start index.", extra=logging_context()) - raise HTTPException(status_code=400, detail="Start index must be non-negative") + raise HTTPException(status_code=422, detail="Start index must be non-negative") if limit is not None and limit <= 0: logger.info(msg="Could not fetch scores with non-positive limit.", extra=logging_context()) - raise HTTPException(status_code=400, detail="Limit must be positive") + raise HTTPException(status_code=422, detail="Limit must be positive") score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: @@ -732,8 +802,11 @@ def get_score_set_variants_csv( "content": {"text/csv": {}}, "description": """Variant scores in CSV format, with four fixed columns (accession, hgvs_nt, hgvs_pro,""" """ and hgvs_splice), plus score columns defined by the score set.""", - } + }, + **BASE_400_RESPONSE, + **ACCESS_CONTROL_ERROR_RESPONSES, }, + summary="Get score set scores in CSV format", ) def get_score_set_scores_csv( *, @@ -787,8 +860,11 @@ def get_score_set_scores_csv( "content": {"text/csv": {}}, "description": """Variant counts in CSV format, with four fixed columns (accession, hgvs_nt, hgvs_pro,""" """ and hgvs_splice), plus score columns defined by the score set.""", - } + }, + **BASE_400_RESPONSE, + **ACCESS_CONTROL_ERROR_RESPONSES, }, + summary="Get score set counts in CSV format", ) async def get_score_set_counts_csv( *, @@ -838,13 +914,15 @@ async def get_score_set_counts_csv( "/score-sets/{urn}/mapped-variants", status_code=200, response_model=list[mapped_variant.MappedVariant], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get mapped variants from score set by URN", ) def get_score_set_mapped_variants( *, urn: str, db: Session = Depends(deps.get_db), user_data: Optional[UserData] = Depends(get_current_user), -) -> Any: +) -> list[MappedVariant]: """ Return mapped variants from a score set, identified by URN. """ @@ -952,11 +1030,16 @@ class VariantPathogenicityEvidenceLineResponseType(TypedDict): @router.get( "/score-sets/{urn}/annotated-variants/pathogenicity-evidence-line", + status_code=200, + response_model=dict[str, Optional[VariantPathogenicityEvidenceLine]], + response_model_exclude_none=True, + summary="Get pathogenicity evidence line annotations for mapped variants within a score set", responses={ 200: { "content": {"application/x-ndjson": {}}, "description": "Stream pathogenicity evidence line annotations for mapped variants.", }, + **ACCESS_CONTROL_ERROR_RESPONSES, }, ) def get_score_set_annotated_variants( @@ -1065,11 +1148,16 @@ class FunctionalImpactStatementResponseType(TypedDict): @router.get( "/score-sets/{urn}/annotated-variants/functional-impact-statement", + status_code=200, + response_model=dict[str, Optional[Statement]], + response_model_exclude_none=True, + summary="Get functional impact statement annotations for mapped variants within a score set", responses={ 200: { "content": {"application/x-ndjson": {}}, "description": "Stream functional impact statement annotations for mapped variants.", }, + **ACCESS_CONTROL_ERROR_RESPONSES, }, ) def get_score_set_annotated_variants_functional_statement( @@ -1176,11 +1264,16 @@ class FunctionalStudyResultResponseType(TypedDict): @router.get( "/score-sets/{urn}/annotated-variants/functional-study-result", + status_code=200, + response_model=dict[str, Optional[ExperimentalVariantFunctionalImpactStudyResult]], + response_model_exclude_none=True, + summary="Get functional study result annotations for mapped variants within a score set", responses={ 200: { "content": {"application/x-ndjson": {}}, "description": "Stream functional study result annotations for mapped variants.", }, + **ACCESS_CONTROL_ERROR_RESPONSES, }, ) def get_score_set_annotated_variants_functional_study_result( @@ -1287,8 +1380,9 @@ def get_score_set_annotated_variants_functional_study_result( @router.post( "/score-sets/", response_model=score_set.ScoreSet, - responses={422: {}}, response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE, **GATEWAY_ERROR_RESPONSES}, + summary="Create a score set", ) async def create_score_set( *, @@ -1308,11 +1402,11 @@ async def create_score_set( logger.info( msg="Failed to create score set; The requested experiment does not exist.", extra=logging_context() ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown experiment") + raise HTTPException(status_code=404, detail="The requested experiment does not exist") # Not allow add score set in meta-analysis experiments. if any(s.meta_analyzes_score_sets for s in experiment.score_sets): raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, + status_code=409, detail="Score sets may not be added to a meta-analysis experiment.", ) @@ -1324,12 +1418,15 @@ async def create_score_set( if not license_: logger.info(msg="Failed to create score set; The requested license does not exist.", extra=logging_context()) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown license") + raise HTTPException(status_code=404, detail="The requested license does not exist") elif not license_.active: logger.info( msg="Failed to create score set; The requested license is no longer active.", extra=logging_context() ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid license") + raise HTTPException( + status_code=409, + detail="Invalid license. The requested license is not active and may no longer be attached to score sets.", + ) save_to_logging_context({"requested_superseded_score_set": item_create.superseded_score_set_urn}) if item_create.superseded_score_set_urn is not None: @@ -1343,8 +1440,8 @@ async def create_score_set( extra=logging_context(), ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Unknown superseded score set", + status_code=404, + detail="The requested superseded score set does not exist", ) else: superseded_score_set = None @@ -1367,7 +1464,7 @@ async def create_score_set( extra=logging_context(), ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=404, detail=f"Unknown meta-analyzed score set {distinct_meta_analyzes_score_set_urns[i]}", ) @@ -1424,20 +1521,35 @@ async def create_score_set( ] except NonexistentOrcidUserError as e: logger.error(msg="Could not find ORCID user with the provided user ID.", extra=logging_context()) - raise HTTPException(status_code=422, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) - doi_identifiers = [ - await find_or_create_doi_identifier(db, identifier.identifier) - for identifier in item_create.doi_identifiers or [] - ] - primary_publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in item_create.primary_publication_identifiers or [] - ] - publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in item_create.secondary_publication_identifiers or [] - ] + primary_publication_identifiers + try: + doi_identifiers = [ + await find_or_create_doi_identifier(db, identifier.identifier) + for identifier in item_create.doi_identifiers or [] + ] + primary_publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in item_create.primary_publication_identifiers or [] + ] + publication_identifiers = [ + await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + for identifier in item_create.secondary_publication_identifiers or [] + ] + primary_publication_identifiers + + except requests.exceptions.ConnectTimeout: + logger.error(msg="Gateway timed out while creating experiment identifiers.", extra=logging_context()) + raise HTTPException( + status_code=504, + detail="Gateway Timeout while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) + + except requests.exceptions.HTTPError: + logger.error(msg="Encountered bad gateway while creating experiment identifiers.", extra=logging_context()) + raise HTTPException( + status_code=502, + detail="Bad Gateway while attempting to contact PubMed/bioRxiv/medRxiv/Crossref APIs. Please try again later.", + ) # create a temporary `primary` attribute on each of our publications that indicates # to our association proxy whether it is a primary publication or not @@ -1472,7 +1584,7 @@ async def create_score_set( logger.info( msg="Failed to create score set; The requested taxonomy does not exist.", extra=logging_context() ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown taxonomy") + raise HTTPException(status_code=404, detail="The requested taxonomy does not exist") # If the target sequence has a label, use it. Otherwise, use the name from the target gene as the label. # View model validation rules enforce that sequences must have a label defined if there are more than one @@ -1585,8 +1697,9 @@ async def create_score_set( @router.post( "/score-sets/{urn}/variants/data", response_model=score_set.ScoreSet, - responses={422: {}}, response_model_exclude_none=True, + responses={**BASE_400_RESPONSE, **ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Upload score and variant count files for a score set", ) async def upload_score_set_variant_data( *, @@ -1656,8 +1769,9 @@ async def upload_score_set_variant_data( @router.patch( "/score-sets-with-variants/{urn}", response_model=score_set.ScoreSet, - responses={422: {}}, response_model_exclude_none=True, + responses={**BASE_400_RESPONSE, **ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Update score ranges / calibrations for a score set", ) async def update_score_set_with_variants( *, @@ -1777,7 +1891,11 @@ async def update_score_set_with_variants( @router.put( - "/score-sets/{urn}", response_model=score_set.ScoreSet, responses={422: {}}, response_model_exclude_none=True + "/score-sets/{urn}", + response_model=score_set.ScoreSet, + response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE, **GATEWAY_ERROR_RESPONSES}, + summary="Update a score set", ) async def update_score_set( *, @@ -1819,7 +1937,12 @@ async def update_score_set( return score_set.ScoreSet.model_validate(updatedItem).copy(update={"experiment": enriched_experiment}) -@router.delete("/score-sets/{urn}", responses={422: {}}) +@router.delete( + "/score-sets/{urn}", + status_code=200, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Delete a score set", +) async def delete_score_set( *, urn: str, @@ -1857,6 +1980,7 @@ async def delete_score_set( status_code=200, response_model=score_set.ScoreSet, response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES, **BASE_409_RESPONSE}, ) async def publish_score_set( *, @@ -1883,7 +2007,7 @@ async def publish_score_set( extra=logging_context(), ) raise HTTPException( - status_code=500, + status_code=409, detail="This score set does not belong to an experiment and cannot be published.", ) if not item.experiment.experiment_set: @@ -1892,7 +2016,7 @@ async def publish_score_set( extra=logging_context(), ) raise HTTPException( - status_code=500, + status_code=409, detail="This score set's experiment does not belong to an experiment set and cannot be published.", ) # TODO This can probably be done more efficiently; at least, it's worth checking the SQL query that SQLAlchemy @@ -1903,7 +2027,7 @@ async def publish_score_set( extra=logging_context(), ) raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=409, detail="cannot publish score set without variant scores", ) @@ -1959,6 +2083,8 @@ async def publish_score_set( status_code=200, response_model=list[clinical_control.ClinicalControlWithMappedVariants], response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get clinical controls for a score set", ) async def get_clinical_controls_for_score_set( *, @@ -2027,6 +2153,8 @@ async def get_clinical_controls_for_score_set( status_code=200, response_model=list[clinical_control.ClinicalControlOptions], response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get clinical control options for a score set", ) async def get_clinical_controls_options_for_score_set( *, @@ -2086,6 +2214,8 @@ async def get_clinical_controls_options_for_score_set( status_code=200, response_model=list[gnomad_variant.GnomADVariantWithMappedVariants], response_model_exclude_none=True, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get gnomad variants for a score set", ) async def get_gnomad_variants_for_score_set( *, diff --git a/src/mavedb/routers/seqrepo.py b/src/mavedb/routers/seqrepo.py index 3f9ea93c..42ec1464 100644 --- a/src/mavedb/routers/seqrepo.py +++ b/src/mavedb/routers/seqrepo.py @@ -1,9 +1,9 @@ import logging +from typing import Optional, Union from biocommons.seqrepo import SeqRepo -from fastapi import APIRouter, Query, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse -from typing import Optional, Union from mavedb import deps from mavedb.lib.logging import LoggedRoute @@ -12,21 +12,33 @@ save_to_logging_context, ) from mavedb.lib.seqrepo import get_sequence_ids, seqrepo_versions, sequence_generator - +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models.seqrepo import SeqRepoMetadata, SeqRepoVersions - +TAG_NAME = "Seqrepo" logger = logging.getLogger(__name__) router = APIRouter( - prefix="/api/v1/seqrepo", - tags=["seqrepo"], - responses={404: {"description": "not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/seqrepo", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Provides REST interfaces for biological sequences and their metadata stored in MaveDBs Seqrepo repository.", +} + -@router.get("/sequence/{alias}") +@router.get( + "/sequence/{alias}", + status_code=200, + responses={ + 200: {"description": "Successful response", "content": {"text/plain": {}}}, + }, + summary="Get sequence by alias", +) def get_sequence( alias: str, start: Optional[int] = Query(None), @@ -55,12 +67,14 @@ def get_sequence( raise HTTPException(status_code=404, detail="Sequence not found") if len(seq_ids) > 1: logger.error(msg="Multiple sequences found for alias", extra=logging_context()) - raise HTTPException(status_code=422, detail=f"Multiple sequences exist for alias '{alias}'") + raise HTTPException( + status_code=400, detail=f"Multiple sequences exist for alias '{alias}'. Use an explicit namespace." + ) return StreamingResponse(sequence_generator(sr, seq_ids[0], start, end), media_type="text/plain") -@router.get("/metadata/{alias}", response_model=SeqRepoMetadata) +@router.get("/metadata/{alias}", response_model=SeqRepoMetadata, summary="Get sequence metadata by alias") def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[str, Union[str, list[str]]]: save_to_logging_context({"requested_seqrepo_alias": alias, "requested_resource": "metadata"}) @@ -71,7 +85,9 @@ def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[st raise HTTPException(status_code=404, detail="Sequence not found") if len(seq_ids) > 1: logger.error(msg="Multiple sequences found for alias", extra=logging_context()) - raise HTTPException(status_code=422, detail=f"Multiple sequences exist for alias '{alias}'") + raise HTTPException( + status_code=400, detail=f"Multiple sequences exist for alias '{alias}'. Use an explicit namespace." + ) seq_id = seq_ids[0] seq_info = sr.sequences.fetch_seqinfo(seq_id) @@ -85,6 +101,6 @@ def get_metadata(alias: str, sr: SeqRepo = Depends(deps.get_seqrepo)) -> dict[st } -@router.get("/version", response_model=SeqRepoVersions) +@router.get("/version", response_model=SeqRepoVersions, summary="Get SeqRepo version information") def get_versions() -> dict[str, str]: return seqrepo_versions() diff --git a/src/mavedb/routers/shared.py b/src/mavedb/routers/shared.py new file mode 100644 index 00000000..f98edb39 --- /dev/null +++ b/src/mavedb/routers/shared.py @@ -0,0 +1,38 @@ +from typing import Any, Mapping, Union + +ROUTER_BASE_PREFIX = "/api/v1" + +BASE_RESPONSES: Mapping[int, dict[str, Any]] = { + 400: {"description": "Bad request. Check parameters and payload."}, + 401: {"description": "Authentication required."}, + 403: {"description": "Forbidden. Insufficient permissions."}, + 404: {"description": "Resource not found."}, + 409: {"description": "Conflict with current resource state."}, + 416: {"description": "Requested range not satisfiable."}, + 422: {"description": "Unprocessable entity. Validation failed."}, + 429: {"description": "Too many requests. Rate limit exceeded."}, + 500: {"description": "Internal server error."}, + 501: {"description": "Not implemented. The server does not support the functionality required."}, + 502: {"description": "Bad gateway. Upstream responded invalidly."}, + 503: {"description": "Service unavailable. Temporary overload or maintenance."}, + 504: {"description": "Gateway timeout. Upstream did not respond in time."}, +} + +BASE_400_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {400: BASE_RESPONSES[400]} +BASE_401_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {401: BASE_RESPONSES[401]} +BASE_403_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {403: BASE_RESPONSES[403]} +BASE_404_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {404: BASE_RESPONSES[404]} +BASE_409_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {409: BASE_RESPONSES[409]} +BASE_416_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {416: BASE_RESPONSES[416]} +BASE_422_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {422: BASE_RESPONSES[422]} +BASE_429_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {429: BASE_RESPONSES[429]} +BASE_500_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {500: BASE_RESPONSES[500]} +BASE_501_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {501: BASE_RESPONSES[501]} +BASE_502_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {502: BASE_RESPONSES[502]} +BASE_503_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {503: BASE_RESPONSES[503]} +BASE_504_RESPONSE: Mapping[Union[int, str], dict[str, Any]] = {504: BASE_RESPONSES[504]} + +PUBLIC_ERROR_RESPONSES = {**BASE_404_RESPONSE, **BASE_500_RESPONSE} +ACCESS_CONTROL_ERROR_RESPONSES = {**BASE_401_RESPONSE, **BASE_403_RESPONSE} +VALIDATION_ERROR_RESPONSES = {**BASE_400_RESPONSE, **BASE_422_RESPONSE} +GATEWAY_ERROR_RESPONSES = {**BASE_502_RESPONSE, **BASE_503_RESPONSE, **BASE_504_RESPONSE} diff --git a/src/mavedb/routers/statistics.py b/src/mavedb/routers/statistics.py index 81002a29..e52d2582 100644 --- a/src/mavedb/routers/statistics.py +++ b/src/mavedb/routers/statistics.py @@ -1,10 +1,10 @@ import itertools -from collections import OrderedDict, Counter +from collections import Counter, OrderedDict from enum import Enum -from typing import Any, Union, Optional +from typing import Any, Optional, Union from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import Table, func, select, Select +from sqlalchemy import Select, Table, func, select from sqlalchemy.orm import Session from mavedb.deps import get_db @@ -37,14 +37,21 @@ from mavedb.models.uniprot_identifier import UniprotIdentifier from mavedb.models.uniprot_offset import UniprotOffset from mavedb.models.user import User +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX + +TAG_NAME = "Statistics" +TARGET_ACCESSION_TAXONOMY = "Homo sapiens" router = APIRouter( - prefix="/api/v1/statistics", - tags=["statistics"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/statistics", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) -TARGET_ACCESSION_TAXONOMY = "Homo sapiens" +metadata = { + "name": TAG_NAME, + "description": "Provides statistics and analytics for MaveDB records.", +} ## Union types @@ -129,7 +136,10 @@ def _count_for_identifier_in_query(db: Session, query: Select[tuple[Any, int]]) @router.get( - "/record/{record}/keywords", status_code=200, response_model=Union[dict[str, int], dict[str, dict[str, int]]] + "/record/{record}/keywords", + status_code=200, + response_model=Union[dict[str, int], dict[str, dict[str, int]]], + summary="Get keyword statistics for a record", ) def experiment_keyword_statistics( record: RecordNames, db: Session = Depends(get_db) @@ -156,7 +166,12 @@ def experiment_keyword_statistics( return _count_for_identifier_in_query(db, query) -@router.get("/record/{record}/publication-identifiers", status_code=200, response_model=dict[str, dict[str, int]]) +@router.get( + "/record/{record}/publication-identifiers", + status_code=200, + response_model=dict[str, dict[str, int]], + summary="Get publication identifier statistics for a record", +) def experiment_publication_identifier_statistics( record: RecordNames, db: Session = Depends(get_db) ) -> dict[str, dict[str, int]]: @@ -193,7 +208,12 @@ def experiment_publication_identifier_statistics( return publication_identifiers -@router.get("/record/{record}/raw-read-identifiers", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/{record}/raw-read-identifiers", + status_code=200, + response_model=dict[str, int], + summary="Get raw read identifier statistics for a record", +) def experiment_raw_read_identifier_statistics(record: RecordNames, db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `identifier` field (member of the `raw_read_identifiers` table). @@ -213,7 +233,12 @@ def experiment_raw_read_identifier_statistics(record: RecordNames, db: Session = return _count_for_identifier_in_query(db, query) -@router.get("/record/{record}/doi-identifiers", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/{record}/doi-identifiers", + status_code=200, + response_model=dict[str, int], + summary="Get DOI identifier statistics for a record", +) def experiment_doi_identifiers_statistics(record: RecordNames, db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `identifier` field (member of the `doi_identifiers` table). @@ -233,7 +258,12 @@ def experiment_doi_identifiers_statistics(record: RecordNames, db: Session = Dep return _count_for_identifier_in_query(db, query) -@router.get("/record/{record}/created-by", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/{record}/created-by", + status_code=200, + response_model=dict[str, int], + summary="Get created by statistics for a record", +) def experiment_created_by_statistics(record: RecordNames, db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `username` field (member of the `users` table). @@ -251,7 +281,12 @@ def experiment_created_by_statistics(record: RecordNames, db: Session = Depends( return _count_for_identifier_in_query(db, query) -@router.get("/record/{model}/published/count", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/{model}/published/count", + status_code=200, + response_model=dict[str, int], + summary="Get published record counts", +) def record_counts(model: RecordNames, group: Optional[GroupBy] = None, db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the number of published records of the `model` parameter. @@ -277,7 +312,12 @@ def record_counts(model: RecordNames, group: Optional[GroupBy] = None, db: Sessi return OrderedDict(sorted(grouped.items())) -@router.get("/record/score-set/variant/count", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/score-set/variant/count", + status_code=200, + response_model=dict[str, int], + summary="Get variant statistics for score sets", +) def record_variant_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the number of published and distinct variants in the database contained @@ -293,7 +333,12 @@ def record_variant_counts(db: Session = Depends(get_db)) -> dict[str, int]: return OrderedDict(sorted(filter(lambda item: item[1] > 0, grouped.items()))) -@router.get("/record/score-set/mapped-variant/count", status_code=200, response_model=dict[str, int]) +@router.get( + "/record/score-set/mapped-variant/count", + status_code=200, + response_model=dict[str, int], + summary="Get mapped variant statistics for score sets", +) def record_mapped_variant_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the number of published and distinct mapped variants in the database contained @@ -317,7 +362,12 @@ def record_mapped_variant_counts(db: Session = Depends(get_db)) -> dict[str, int ##### Accession based targets ##### -@router.get("/target/accession/accession", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/accession/accession", + status_code=200, + response_model=dict[str, int], + summary="Get target accession statistics for accessions", +) def target_accessions_accession_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `accession` field (member of the `target_accessions` table). @@ -330,7 +380,12 @@ def target_accessions_accession_counts(db: Session = Depends(get_db)) -> dict[st return _count_for_identifier_in_query(db, query) -@router.get("/target/accession/assembly", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/accession/assembly", + status_code=200, + response_model=dict[str, int], + summary="Get target accession statistics for assemblies", +) def target_accessions_assembly_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `assembly` field (member of the `target_accessions` table). @@ -343,7 +398,12 @@ def target_accessions_assembly_counts(db: Session = Depends(get_db)) -> dict[str return _count_for_identifier_in_query(db, query) -@router.get("/target/accession/gene", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/accession/gene", + status_code=200, + response_model=dict[str, int], + summary="Get target accession statistics for genes", +) def target_accessions_gene_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `gene` field (member of the `target_accessions` table). @@ -359,7 +419,12 @@ def target_accessions_gene_counts(db: Session = Depends(get_db)) -> dict[str, in ##### Sequence based targets ##### -@router.get("/target/sequence/sequence", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/sequence/sequence", + status_code=200, + response_model=dict[str, int], + summary="Get target sequence statistics for sequences", +) def target_sequences_sequence_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `sequence` field (member of the `target_sequences` table). @@ -372,7 +437,12 @@ def target_sequences_sequence_counts(db: Session = Depends(get_db)) -> dict[str, return _count_for_identifier_in_query(db, query) -@router.get("/target/sequence/sequence-type", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/sequence/sequence-type", + status_code=200, + response_model=dict[str, int], + summary="Get target sequence statistics for sequence types", +) def target_sequences_sequence_type_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `sequence_type` field (member of the `target_sequences` table). @@ -388,7 +458,12 @@ def target_sequences_sequence_type_counts(db: Session = Depends(get_db)) -> dict ##### Target genes ##### -@router.get("/target/gene/category", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/gene/category", + status_code=200, + response_model=dict[str, int], + summary="Get target gene statistics for categories", +) def target_genes_category_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `category` field (member of the `target_sequences` table). @@ -401,7 +476,12 @@ def target_genes_category_counts(db: Session = Depends(get_db)) -> dict[str, int return _count_for_identifier_in_query(db, query) -@router.get("/target/gene/organism", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/gene/organism", + status_code=200, + response_model=dict[str, int], + summary="Get target gene statistics for organisms", +) def target_genes_organism_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `organism` field (member of the `taxonomies` table). @@ -431,7 +511,12 @@ def target_genes_organism_counts(db: Session = Depends(get_db)) -> dict[str, int return organisms -@router.get("/target/gene/ensembl-identifier", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/gene/ensembl-identifier", + status_code=200, + response_model=dict[str, int], + summary="Get target gene statistics for Ensembl identifiers", +) def target_genes_ensembl_identifier_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `identifier` field (member of the `ensembl_identifiers` table). @@ -447,7 +532,12 @@ def target_genes_ensembl_identifier_counts(db: Session = Depends(get_db)) -> dic return _count_for_identifier_in_query(db, query) -@router.get("/target/gene/refseq-identifier", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/gene/refseq-identifier", + status_code=200, + response_model=dict[str, int], + summary="Get target gene statistics for RefSeq identifiers", +) def target_genes_refseq_identifier_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `identifier` field (member of the `refseq_identifiers` table). @@ -463,7 +553,12 @@ def target_genes_refseq_identifier_counts(db: Session = Depends(get_db)) -> dict return _count_for_identifier_in_query(db, query) -@router.get("/target/gene/uniprot-identifier", status_code=200, response_model=dict[str, int]) +@router.get( + "/target/gene/uniprot-identifier", + status_code=200, + response_model=dict[str, int], + summary="Get target gene statistics for UniProt identifiers", +) def target_genes_uniprot_identifier_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `identifier` field (member of the `uniprot_identifiers` table). @@ -479,7 +574,12 @@ def target_genes_uniprot_identifier_counts(db: Session = Depends(get_db)) -> dic return _count_for_identifier_in_query(db, query) -@router.get("/target/mapped/gene") +@router.get( + "/target/mapped/gene", + status_code=200, + response_model=dict[str, int], + summary="Get mapped target gene statistics for genes", +) def mapped_target_gene_counts(db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the distinct values of the `gene` property within the `post_mapped_metadata` @@ -509,7 +609,7 @@ def mapped_target_gene_counts(db: Session = Depends(get_db)) -> dict[str, int]: ######################################################################################## -@router.get("/variant/count", status_code=200, response_model=dict[str, int]) +@router.get("/variant/count", status_code=200, response_model=dict[str, int], summary="Get variant statistics") def variant_counts(group: Optional[GroupBy] = None, db: Session = Depends(get_db)) -> dict[str, int]: """ Returns a dictionary of counts for the number of published and distinct variants in the database. @@ -537,7 +637,9 @@ def variant_counts(group: Optional[GroupBy] = None, db: Session = Depends(get_db return OrderedDict(sorted(grouped.items())) -@router.get("/mapped-variant/count", status_code=200, response_model=dict[str, int]) +@router.get( + "/mapped-variant/count", status_code=200, response_model=dict[str, int], summary="Get mapped variant statistics" +) def mapped_variant_counts( group: Optional[GroupBy] = None, onlyCurrent: bool = True, db: Session = Depends(get_db) ) -> dict[str, int]: diff --git a/src/mavedb/routers/target_gene_identifiers.py b/src/mavedb/routers/target_gene_identifiers.py index 4869f6a9..06180209 100644 --- a/src/mavedb/routers/target_gene_identifiers.py +++ b/src/mavedb/routers/target_gene_identifiers.py @@ -6,24 +6,38 @@ from mavedb import deps from mavedb.lib.identifiers import EXTERNAL_GENE_IDENTIFIER_CLASSES +from mavedb.routers.shared import BASE_400_RESPONSE, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import external_gene_identifier from mavedb.view_models.search import TextSearch +TAG_NAME = "Target Gene Identifiers" + router = APIRouter( - prefix="/api/v1/target-gene-identifiers", - tags=["target gene identifiers"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}/target-gene-identifiers", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, ) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve target gene identifiers associated with MaveDB records.", +} + -@router.post("/search", status_code=200, response_model=List[external_gene_identifier.ExternalGeneIdentifier]) +@router.post( + "/search", + status_code=200, + response_model=List[external_gene_identifier.ExternalGeneIdentifier], + summary="Search target gene identifiers", + responses={**BASE_400_RESPONSE}, +) def search_target_gene_identifiers(db_name: str, search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ Search target gene identifiers. """ if db_name not in EXTERNAL_GENE_IDENTIFIER_CLASSES: raise HTTPException( - status_code=404, + status_code=422, detail=f"Unexpected db_name: {db_name}. Expected one of: {list(EXTERNAL_GENE_IDENTIFIER_CLASSES.keys())}", ) @@ -36,7 +50,7 @@ def search_target_gene_identifiers(db_name: str, search: TextSearch, db: Session lower_search_text = search.text.strip().lower() query = query.filter(func.lower(identifier_class.identifier).contains(lower_search_text)) else: - raise HTTPException(status_code=500, detail="Search text is required") + raise HTTPException(status_code=400, detail="Search text is required") items = query.order_by(identifier_class.identifier).limit(50).all() if not items: diff --git a/src/mavedb/routers/target_genes.py b/src/mavedb/routers/target_genes.py index 25fce780..29f91c5e 100644 --- a/src/mavedb/routers/target_genes.py +++ b/src/mavedb/routers/target_genes.py @@ -13,13 +13,35 @@ ) from mavedb.models.score_set import ScoreSet from mavedb.models.target_gene import TargetGene +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import target_gene from mavedb.view_models.search import TextSearch -router = APIRouter(prefix="/api/v1", tags=["target-genes"], responses={404: {"description": "Not found"}}) +TAG_NAME = "Target Genes" + +router = APIRouter( + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, +) + +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve target genes associated with MaveDB records.", + "externalDocs": { + "description": "Target Genes Documentation", + "url": "https://mavedb.org/docs/mavedb/target_sequences.html", + }, +} -@router.post("/me/target-genes/search", status_code=200, response_model=List[target_gene.TargetGeneWithScoreSetUrn]) +@router.post( + "/me/target-genes/search", + status_code=200, + response_model=List[target_gene.TargetGeneWithScoreSetUrn], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Search my target genes", +) def search_my_target_genes( search: TextSearch, db: Session = Depends(deps.get_db), user_data: UserData = Depends(require_current_user) ) -> Any: @@ -32,7 +54,11 @@ def search_my_target_genes( @router.get( - "/target-genes", status_code=200, response_model=List[target_gene.TargetGeneWithScoreSetUrn], responses={404: {}} + "/target-genes", + status_code=200, + response_model=List[target_gene.TargetGeneWithScoreSetUrn], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="List target genes", ) def list_target_genes( *, @@ -56,7 +82,7 @@ def list_target_genes( return sorted(validated_items, key=lambda i: i.name) -@router.get("/target-genes/names", status_code=200, response_model=List[str], responses={404: {}}) +@router.get("/target-genes/names", status_code=200, response_model=List[str], summary="List target gene names") def list_target_gene_names( *, db: Session = Depends(deps.get_db), @@ -70,7 +96,9 @@ def list_target_gene_names( return sorted(list(set(names))) -@router.get("/target-genes/categories", status_code=200, response_model=List[str], responses={404: {}}) +@router.get( + "/target-genes/categories", status_code=200, response_model=List[str], summary="List target gene categories" +) def list_target_gene_categories( *, db: Session = Depends(deps.get_db), @@ -88,7 +116,7 @@ def list_target_gene_categories( "/target-genes/{item_id}", status_code=200, response_model=target_gene.TargetGeneWithScoreSetUrn, - responses={404: {}}, + summary="Fetch target gene by ID", ) def fetch_target_gene( *, @@ -105,7 +133,13 @@ def fetch_target_gene( return item -@router.post("/target-genes/search", status_code=200, response_model=List[target_gene.TargetGeneWithScoreSetUrn]) +@router.post( + "/target-genes/search", + status_code=200, + response_model=List[target_gene.TargetGeneWithScoreSetUrn], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Search target genes", +) def search_target_genes( search: TextSearch, db: Session = Depends(deps.get_db), user_data: Optional[UserData] = Depends(get_current_user) ) -> Any: diff --git a/src/mavedb/routers/taxonomies.py b/src/mavedb/routers/taxonomies.py index b859627a..0d680166 100644 --- a/src/mavedb/routers/taxonomies.py +++ b/src/mavedb/routers/taxonomies.py @@ -7,13 +7,25 @@ from mavedb import deps from mavedb.lib.taxonomies import search_NCBI_taxonomy from mavedb.models.taxonomy import Taxonomy +from mavedb.routers.shared import PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import taxonomy from mavedb.view_models.search import TextSearch -router = APIRouter(prefix="/api/v1/taxonomies", tags=["taxonomies"], responses={404: {"description": "Not found"}}) +TAG_NAME = "Taxonomies" +router = APIRouter( + prefix=f"{ROUTER_BASE_PREFIX}/taxonomies", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, +) -@router.get("/", status_code=200, response_model=List[taxonomy.Taxonomy], responses={404: {}}) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve taxonomies associated with MaveDB records.", +} + + +@router.get("/", status_code=200, response_model=List[taxonomy.Taxonomy], summary="List taxonomies") def list_taxonomies( *, db: Session = Depends(deps.get_db), @@ -25,7 +37,7 @@ def list_taxonomies( return items -@router.get("/speciesNames", status_code=200, response_model=List[str], responses={404: {}}) +@router.get("/speciesNames", status_code=200, response_model=List[str], summary="List species names") def list_taxonomy_organism_names( *, db: Session = Depends(deps.get_db), @@ -39,7 +51,7 @@ def list_taxonomy_organism_names( return sorted(list(set(organism_names))) -@router.get("/commonNames", status_code=200, response_model=List[str], responses={404: {}}) +@router.get("/commonNames", status_code=200, response_model=List[str], summary="List common names") def list_taxonomy_common_names( *, db: Session = Depends(deps.get_db), @@ -53,7 +65,7 @@ def list_taxonomy_common_names( return sorted(list(set(common_names))) -@router.get("/{item_id}", status_code=200, response_model=taxonomy.Taxonomy, responses={404: {}}) +@router.get("/{item_id}", status_code=200, response_model=taxonomy.Taxonomy, summary="Fetch taxonomy by ID") def fetch_taxonomy( *, item_id: int, @@ -68,7 +80,7 @@ def fetch_taxonomy( return item -@router.get("/code/{item_id}", status_code=200, response_model=taxonomy.Taxonomy, responses={404: {}}) +@router.get("/code/{item_id}", status_code=200, response_model=taxonomy.Taxonomy, summary="Fetch taxonomy by code") def fetch_taxonomy_by_code( *, item_id: int, @@ -83,7 +95,7 @@ def fetch_taxonomy_by_code( return item -@router.post("/search", status_code=200, response_model=List[taxonomy.Taxonomy]) +@router.post("/search", status_code=200, response_model=List[taxonomy.Taxonomy], summary="Search taxonomies") async def search_taxonomies(search: TextSearch, db: Session = Depends(deps.get_db)) -> Any: """ Search Taxonomy. diff --git a/src/mavedb/routers/users.py b/src/mavedb/routers/users.py index 09990bb9..fd3a4d95 100644 --- a/src/mavedb/routers/users.py +++ b/src/mavedb/routers/users.py @@ -1,5 +1,4 @@ import logging -from typing import Any from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -13,15 +12,23 @@ from mavedb.lib.permissions import Action, assert_permission from mavedb.models.enums.user_role import UserRole from mavedb.models.user import User +from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX from mavedb.view_models import user +TAG_NAME = "Users" + router = APIRouter( - prefix="/api/v1", - tags=["access keys"], - responses={404: {"description": "Not found"}}, + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, route_class=LoggedRoute, ) +metadata = { + "name": TAG_NAME, + "description": "Manage and retrieve MaveDB users.", +} + logger = logging.getLogger(__name__) @@ -41,12 +48,18 @@ def to_string(self, value: str) -> str: # Trailing slash is deliberate -@router.get("/users/", status_code=200, response_model=list[user.AdminUser], responses={404: {}}) +@router.get( + "/users/", + status_code=200, + response_model=list[user.AdminUser], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="List users", +) async def list_users( *, db: Session = Depends(deps.get_db), - user_data: UserData = Depends(RoleRequirer([UserRole.admin])), -) -> Any: + _: UserData = Depends(RoleRequirer([UserRole.admin])), +) -> list[User]: """ List users. """ @@ -58,9 +71,10 @@ async def list_users( "/users/me", status_code=200, response_model=user.CurrentUser, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Show my user", ) -async def show_me(*, user_data: UserData = Depends(require_current_user)) -> Any: +async def show_me(*, user_data: UserData = Depends(require_current_user)) -> User: """ Return the current user. """ @@ -71,14 +85,15 @@ async def show_me(*, user_data: UserData = Depends(require_current_user)) -> Any "/users/{id:int}", status_code=200, response_model=user.AdminUser, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Show user by ID", ) async def show_user_admin( *, id: int, user_data: UserData = Depends(RoleRequirer([UserRole.admin])), db: Session = Depends(deps.get_db), -) -> Any: +) -> User: """ Fetch a single user by ID. Returns admin view of requested user. """ @@ -100,14 +115,15 @@ async def show_user_admin( "/users/{orcid_id:orcid_id}", status_code=200, response_model=user.User, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Show user by Orcid ID", ) async def show_user( *, orcid_id: str, user_data: UserData = Depends(require_current_user), db: Session = Depends(deps.get_db), -) -> Any: +) -> User: """ Fetch a single user by Orcid ID. Returns limited view of user. """ @@ -130,14 +146,15 @@ async def show_user( "/users/me", status_code=200, response_model=user.CurrentUser, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Update my user", ) async def update_me( *, user_update: user.CurrentUserUpdate, db: Session = Depends(deps.get_db), user_data: UserData = Depends(require_current_user), -) -> Any: +) -> User: """ Update the current user. """ @@ -155,13 +172,14 @@ async def update_me( "/users/me/has-logged-in", status_code=200, response_model=user.CurrentUser, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Mark that the current user has logged in", ) async def user_has_logged_in( *, db: Session = Depends(deps.get_db), user_data: UserData = Depends(require_current_user), -) -> Any: +) -> User: """ Update the current users log in state. """ @@ -179,7 +197,8 @@ async def user_has_logged_in( "/users//{id}", status_code=200, response_model=user.AdminUser, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Update user by ID", ) async def update_user( *, @@ -187,7 +206,7 @@ async def update_user( item_update: user.AdminUserUpdate, db: Session = Depends(deps.get_db), user_data: UserData = Depends(require_current_user), -) -> Any: +) -> User: """ Update a user. """ diff --git a/src/mavedb/routers/variants.py b/src/mavedb/routers/variants.py index f21174a8..4de1de1d 100644 --- a/src/mavedb/routers/variants.py +++ b/src/mavedb/routers/variants.py @@ -4,40 +4,68 @@ from fastapi import APIRouter, Depends from fastapi.exceptions import HTTPException -from mavedb.lib.authentication import UserData, get_current_user -from mavedb.lib.permissions import Action, assert_permission, has_permission from sqlalchemy import select from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import Session, joinedload from sqlalchemy.sql import or_ from mavedb import deps +from mavedb.lib.authentication import UserData, get_current_user from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.models.score_set import ScoreSet +from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.models.mapped_variant import MappedVariant +from mavedb.models.score_set import ScoreSet from mavedb.models.variant import Variant from mavedb.models.variant_translation import VariantTranslation +from mavedb.routers.shared import ( + ACCESS_CONTROL_ERROR_RESPONSES, + BASE_400_RESPONSE, + PUBLIC_ERROR_RESPONSES, + ROUTER_BASE_PREFIX, +) from mavedb.view_models.variant import ( - ClingenAlleleIdVariantLookupsRequest, ClingenAlleleIdVariantLookupResponse, + ClingenAlleleIdVariantLookupsRequest, VariantEffectMeasurementWithScoreSet, ) +TAG_NAME = "Variants" + +logger = logging.getLogger(__name__) + router = APIRouter( - prefix="/api/v1", tags=["access keys"], responses={404: {"description": "Not found"}}, route_class=LoggedRoute + prefix=f"{ROUTER_BASE_PREFIX}", + tags=[TAG_NAME], + responses={**PUBLIC_ERROR_RESPONSES}, + route_class=LoggedRoute, ) -logger = logging.getLogger(__name__) +metadata = { + "name": TAG_NAME, + "description": "Search and retrieve variants associated with MaveDB records.", +} -@router.post("/variants/clingen-allele-id-lookups", response_model=list[ClingenAlleleIdVariantLookupResponse]) +@router.post( + "/variants/clingen-allele-id-lookups", + status_code=200, + response_model=list[ClingenAlleleIdVariantLookupResponse], + responses={ + **BASE_400_RESPONSE, + **ACCESS_CONTROL_ERROR_RESPONSES, + }, + summary="Lookup variants by ClinGen Allele IDs", +) def lookup_variants( *, request: ClingenAlleleIdVariantLookupsRequest, db: Session = Depends(deps.get_db), user_data: UserData = Depends(get_current_user), ): + """ + Lookup variants by ClinGen Allele IDs. + """ save_to_logging_context({"requested_resource": "clingen-allele-id-lookups"}) save_to_logging_context({"clingen_allele_ids_to_lookup": request.clingen_allele_ids}) logger.debug(msg="Looking up variants by Clingen Allele IDs", extra=logging_context()) @@ -409,8 +437,9 @@ def lookup_variants( "/variants/{urn}", status_code=200, response_model=VariantEffectMeasurementWithScoreSet, - responses={404: {}, 500: {}}, + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, response_model_exclude_none=True, + summary="Fetch variant by URN", ) def get_variant(*, urn: str, db: Session = Depends(deps.get_db), user_data: UserData = Depends(get_current_user)): """ @@ -421,9 +450,7 @@ def get_variant(*, urn: str, db: Session = Depends(deps.get_db), user_data: User query = db.query(Variant).filter(Variant.urn == urn) variant = query.one_or_none() except MultipleResultsFound: - logger.info( - msg="Could not fetch the requested score set; Multiple such variants exist.", extra=logging_context() - ) + logger.info(msg="Could not fetch the requested variant; Multiple such variants exist.", extra=logging_context()) raise HTTPException(status_code=500, detail=f"multiple variants with URN '{urn}' were found") if not variant: diff --git a/src/mavedb/server_main.py b/src/mavedb/server_main.py index 1037b282..80db5403 100644 --- a/src/mavedb/server_main.py +++ b/src/mavedb/server_main.py @@ -10,7 +10,6 @@ from fastapi.middleware.gzip import GZipMiddleware from fastapi.openapi.utils import get_openapi from sqlalchemy.orm import configure_mappers -from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse from starlette_context.plugins import ( @@ -125,7 +124,7 @@ async def permission_exception_handler(request: Request, exc: PermissionExceptio @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): response = JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=422, content=jsonable_encoder({"detail": list(map(lambda error: customize_validation_error(error), exc.errors()))}), ) save_to_logging_context(format_raised_exception_info_as_dict(exc)) @@ -213,6 +212,34 @@ def customize_openapi_schema(): "url": "https://www.gnu.org/licenses/agpl-3.0.en.html", }, } + + openapi_schema["tags"] = [ + access_keys.metadata, + api_information.metadata, + collections.metadata, + controlled_keywords.metadata, + doi_identifiers.metadata, + experiment_sets.metadata, + experiments.metadata, + hgvs.metadata, + licenses.metadata, + # log.metadata, + mapped_variant.metadata, + orcid.metadata, + permissions.metadata, + publication_identifiers.metadata, + raw_read_identifiers.metadata, + refget.metadata, + score_sets.metadata, + seqrepo.metadata, + statistics.metadata, + target_gene_identifiers.metadata, + target_genes.metadata, + taxonomies.metadata, + users.metadata, + variants.metadata, + ] + app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/src/mavedb/view_models/access_key.py b/src/mavedb/view_models/access_key.py index 28995161..8962e82f 100644 --- a/src/mavedb/view_models/access_key.py +++ b/src/mavedb/view_models/access_key.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from typing import Optional from mavedb.models.enums.user_role import UserRole @@ -10,7 +10,7 @@ class AccessKeyBase(BaseModel): key_id: str name: Optional[str] = None expiration_date: Optional[date] = None - created_at: Optional[str] = None + creation_time: Optional[datetime] = None # Properties shared by models stored in DB diff --git a/tests/routers/test_access_keys.py b/tests/routers/test_access_keys.py index 836dad6d..467aba08 100644 --- a/tests/routers/test_access_keys.py +++ b/tests/routers/test_access_keys.py @@ -9,7 +9,6 @@ from mavedb.models.access_key import AccessKey from mavedb.models.enums.user_role import UserRole from mavedb.models.user import User - from tests.helpers.constants import EXTRA_USER from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.access_key import create_admin_key_for_current_user, create_api_key_for_current_user @@ -101,7 +100,7 @@ def test_user_cannot_delete_other_users_access_key(client, setup_router_db, sess session.commit() del_response = client.delete(f"api/v1/users/me/access-keys/{key_id}") - assert del_response.status_code == 200 + assert del_response.status_code == 404 saved_access_key = session.query(AccessKey).filter(AccessKey.key_id == key_id).one_or_none() assert saved_access_key is not None diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index 26541036..9767c125 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -21,7 +21,6 @@ from mavedb.models.score_set import ScoreSet as ScoreSetDbModel from mavedb.view_models.experiment import Experiment, ExperimentCreate from mavedb.view_models.orcid import OrcidUser - from tests.helpers.constants import ( EXTRA_USER, TEST_BIORXIV_IDENTIFIER, @@ -41,9 +40,9 @@ ) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.contributor import add_contributor -from tests.helpers.util.user import change_ownership from tests.helpers.util.experiment import create_experiment from tests.helpers.util.score_set import create_seq_score_set, create_seq_score_set_with_variants, publish_score_set +from tests.helpers.util.user import change_ownership from tests.helpers.util.variant import mock_worker_variant_insertion @@ -104,7 +103,7 @@ def test_cannot_create_experiment_with_nonexistent_contributor(client, setup_rou ): response = client.post("/api/v1/experiments/", json=experiment) - assert response.status_code == 422 + assert response.status_code == 404 response_data = response.json() assert "No ORCID user was found for ORCID ID 1111-1111-1111-1111." in response_data["detail"] @@ -127,7 +126,7 @@ def test_create_experiment_with_keywords(session, client, setup_router_db): def test_cannot_create_experiment_without_email(client, setup_router_db): client.put("api/v1/users/me", json={"email": None}) response = client.post("/api/v1/experiments/", json=TEST_MINIMAL_EXPERIMENT) - assert response.status_code == 400 + assert response.status_code == 403 response_data = response.json() assert response_data["detail"] == "There must be an email address associated with your account to use this feature." @@ -734,7 +733,7 @@ def test_cannot_add_nonexistent_contributor_to_experiment(client, setup_router_d ): response = client.put(f"/api/v1/experiments/{experiment['urn']}", json=experiment_post_payload) - assert response.status_code == 422 + assert response.status_code == 404 response_data = response.json() assert "No ORCID user was found for ORCID ID 1111-1111-1111-1111." in response_data["detail"] diff --git a/tests/routers/test_mapped_variants.py b/tests/routers/test_mapped_variants.py index fb18baa3..81bd62e1 100644 --- a/tests/routers/test_mapped_variants.py +++ b/tests/routers/test_mapped_variants.py @@ -1,26 +1,27 @@ # ruff: noqa: E402 -import pytest import json +import pytest + from tests.helpers.util.user import change_ownership arq = pytest.importorskip("arq") cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") -from sqlalchemy import select -from sqlalchemy.orm.session import make_transient from urllib.parse import quote_plus -from ga4gh.va_spec.base.core import ExperimentalVariantFunctionalImpactStudyResult, Statement from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine +from ga4gh.va_spec.base.core import ExperimentalVariantFunctionalImpactStudyResult, Statement +from sqlalchemy import select +from sqlalchemy.orm.session import make_transient + from mavedb.models.mapped_variant import MappedVariant from mavedb.models.score_set import ScoreSet as ScoreSetDbModel from mavedb.models.variant import Variant from mavedb.view_models.mapped_variant import SavedMappedVariant - -from tests.helpers.constants import TEST_BIORXIV_IDENTIFIER, TEST_PUBMED_IDENTIFIER, TEST_BRNICH_SCORE_CALIBRATION +from tests.helpers.constants import TEST_BIORXIV_IDENTIFIER, TEST_BRNICH_SCORE_CALIBRATION, TEST_PUBMED_IDENTIFIER from tests.helpers.util.common import deepcamelize from tests.helpers.util.experiment import create_experiment from tests.helpers.util.score_calibration import create_publish_and_promote_score_calibration @@ -182,7 +183,7 @@ def test_cannot_show_mapped_variant_study_result_when_no_mapping_data_exists( assert response.status_code == 404 assert ( - f"Could not construct a study result for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." + f"No study result exists for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." in response_data["detail"] ) @@ -301,7 +302,7 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_no_mapping_ assert response.status_code == 404 assert ( - f"Could not construct a functional impact statement for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." + f"No functional impact statement exists for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." in response_data["detail"] ) @@ -325,7 +326,7 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_insufficien assert response.status_code == 404 assert ( - f"Could not construct a functional impact statement for mapped variant {score_set['urn']}#1. Variant does not have sufficient evidence to evaluate its functional impact" + f"No functional impact statement exists for mapped variant {score_set['urn']}#1. Variant does not have sufficient evidence to evaluate its functional impact" in response_data["detail"] ) @@ -444,7 +445,7 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_mapping_data_ assert response.status_code == 404 assert ( - f"Could not construct a pathogenicity evidence line for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." + f"No pathogenicity evidence line exists for mapped variant {score_set['urn']}#1: Variant {score_set['urn']}#1 does not have a post mapped variant." in response_data["detail"] ) @@ -466,7 +467,7 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_insufficient_pat assert response.status_code == 404 assert ( - f"Could not construct a pathogenicity evidence line for mapped variant {score_set['urn']}#1; Variant does not have sufficient evidence to evaluate its pathogenicity" + f"No pathogenicity evidence line exists for mapped variant {score_set['urn']}#1; Variant does not have sufficient evidence to evaluate its pathogenicity" in response_data["detail"] ) diff --git a/tests/routers/test_refget.py b/tests/routers/test_refget.py index 04ae7398..760b9f02 100644 --- a/tests/routers/test_refget.py +++ b/tests/routers/test_refget.py @@ -1,7 +1,8 @@ # ruff: noqa: E402 -import pytest from unittest.mock import patch +import pytest + arq = pytest.importorskip("arq") cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") @@ -34,7 +35,7 @@ def test_get_metadata_multiple_ids(client): # This simulates a scenario where the alias resolves to multiple sequences with patch("mavedb.routers.refget.get_sequence_ids", return_value=["seq1", "seq2"]): resp = client.get(f"/api/v1/refget/sequence/{VALID_ENSEMBL_IDENTIFIER}/metadata") - assert resp.status_code == 422 + assert resp.status_code == 400 assert "Multiple sequences exist" in resp.text @@ -86,7 +87,7 @@ def test_get_sequence_multiple_ids(client): # This simulates a scenario where the alias resolves to multiple sequences with patch("mavedb.routers.refget.get_sequence_ids", return_value=["seq1", "seq2"]): resp = client.get(f"/api/v1/refget/sequence/{VALID_ENSEMBL_IDENTIFIER}") - assert resp.status_code == 422 + assert resp.status_code == 400 assert "Multiple sequences exist" in resp.text diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index 6d5d97ff..c0d7748b 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -258,7 +258,7 @@ def test_cannot_create_score_set_with_nonexistent_contributor(client, mock_publi ): response = client.post("/api/v1/score-sets/", json=score_set) - assert response.status_code == 422 + assert response.status_code == 404 response_data = response.json() assert "No ORCID user was found for ORCID ID 1111-1111-1111-1111." in response_data["detail"] @@ -281,7 +281,7 @@ def test_cannot_create_score_set_without_email(client, mock_publication_fetch, s score_set_post_payload["experimentUrn"] = experiment["urn"] client.put("api/v1/users/me", json={"email": None}) response = client.post("/api/v1/score-sets/", json=score_set_post_payload) - assert response.status_code == 400 + assert response.status_code == 403 response_data = response.json() assert response_data["detail"] in "There must be an email address associated with your account to use this feature." @@ -645,7 +645,7 @@ def test_cannot_update_score_set_with_nonexistent_contributor( ): response = client.put(f"/api/v1/score-sets/{score_set['urn']}", json=score_set_update_payload) - assert response.status_code == 422 + assert response.status_code == 404 response_data = response.json() assert "No ORCID user was found for ORCID ID 1111-1111-1111-1111." in response_data["detail"] @@ -1028,7 +1028,7 @@ def test_cannot_add_scores_to_score_set_without_email(session, client, setup_rou f"/api/v1/score-sets/{score_set['urn']}/variants/data", files={"scores_file": (scores_csv_path.name, scores_file, "text/csv")}, ) - assert response.status_code == 400 + assert response.status_code == 403 response_data = response.json() assert response_data["detail"] in "There must be an email address associated with your account to use this feature." @@ -1366,7 +1366,7 @@ def test_cannot_publish_score_set_without_variants(client, setup_router_db): with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue: response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") - assert response.status_code == 422 + assert response.status_code == 409 worker_queue.assert_not_called() response_data = response.json() @@ -1794,7 +1794,7 @@ def test_cannot_add_score_set_to_meta_analysis_experiment(session, data_provider response = client.post("/api/v1/score-sets/", json=score_set_2) response_data = response.json() - assert response.status_code == 403 + assert response.status_code == 409 assert "Score sets may not be added to a meta-analysis experiment." in response_data["detail"] @@ -2494,7 +2494,7 @@ def test_cannot_create_score_set_with_inactive_license(session, client, setup_ro score_set_post_payload["experimentUrn"] = experiment["urn"] score_set_post_payload["licenseId"] = TEST_INACTIVE_LICENSE["id"] response = client.post("/api/v1/score-sets/", json=score_set_post_payload) - assert response.status_code == 400 + assert response.status_code == 409 def test_cannot_modify_score_set_to_inactive_license(session, client, setup_router_db): @@ -2503,7 +2503,7 @@ def test_cannot_modify_score_set_to_inactive_license(session, client, setup_rout score_set_post_payload = score_set.copy() score_set_post_payload.update({"licenseId": TEST_INACTIVE_LICENSE["id"], "urn": score_set["urn"]}) response = client.put(f"/api/v1/score-sets/{score_set['urn']}", json=score_set_post_payload) - assert response.status_code == 400 + assert response.status_code == 409 def test_can_modify_metadata_for_score_set_with_inactive_license(session, client, setup_router_db): diff --git a/tests/routers/test_seqrepo.py b/tests/routers/test_seqrepo.py index aa8aa335..231f06a5 100644 --- a/tests/routers/test_seqrepo.py +++ b/tests/routers/test_seqrepo.py @@ -1,7 +1,8 @@ # ruff: noqa: E402 -import pytest from unittest.mock import patch +import pytest + arq = pytest.importorskip("arq") cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") @@ -41,7 +42,7 @@ def test_get_sequence_multiple_ids(client): # This simulates a scenario where the alias resolves to multiple sequences with patch("mavedb.routers.seqrepo.get_sequence_ids", return_value=["seq1", "seq2"]): resp = client.get(f"/api/v1/seqrepo/sequence/{VALID_ENSEMBL_IDENTIFIER}") - assert resp.status_code == 422 + assert resp.status_code == 400 assert "Multiple sequences exist" in resp.text @@ -76,7 +77,7 @@ def test_get_metadata_multiple_ids(client): # This simulates a scenario where the alias resolves to multiple sequences with patch("mavedb.routers.seqrepo.get_sequence_ids", return_value=["seq1", "seq2"]): resp = client.get(f"/api/v1/seqrepo/metadata/{VALID_ENSEMBL_IDENTIFIER}") - assert resp.status_code == 422 + assert resp.status_code == 400 assert "Multiple sequences exist" in resp.text diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index bae66fbc..03b57c0b 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -1,8 +1,9 @@ # ruff: noqa: E402 -import pytest from unittest import mock +import pytest + arq = pytest.importorskip("arq") cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") @@ -10,7 +11,6 @@ from mavedb.lib.authentication import get_current_user from mavedb.lib.authorization import require_current_user from mavedb.models.enums.user_role import UserRole - from tests.helpers.constants import ADMIN_USER, EXTRA_USER, TEST_USER, camelize from tests.helpers.dependency_overrider import DependencyOverrider @@ -26,7 +26,7 @@ def test_cannot_list_users_as_anonymous_user(client, setup_router_db, anonymous_ def test_cannot_list_users_as_normal_user(client, setup_router_db): response = client.get("/api/v1/users/") - assert response.status_code == 401 + assert response.status_code == 403 response_value = response.json() assert response_value["detail"] in "You are not authorized to use this feature" @@ -117,7 +117,7 @@ def test_cannot_fetch_single_user_as_anonymous_user(client, setup_router_db, ses def test_cannot_fetch_single_user_as_normal_user(client, setup_router_db, session): response = client.get("/api/v1/users/2") - assert response.status_code == 401 + assert response.status_code == 403 assert response.json()["detail"] in "You are not authorized to use this feature" # Some lingering db transaction holds this test open unless it is explicitly closed.