Skip to content

Commit

Permalink
Merge pull request #11 from TheJacksonLaboratory/G3-33-geneset-by-map…
Browse files Browse the repository at this point in the history
…pable-id

G3 33 geneset by mappable gene type id
  • Loading branch information
francastell authored Jan 17, 2024
2 parents c9a533e + 5d48afd commit 139efba
Show file tree
Hide file tree
Showing 12 changed files with 518 additions and 123 deletions.
159 changes: 80 additions & 79 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "geneweaver-api"
version = "0.0.1a6"
version = "0.0.1a7"
description = "description"
authors = ["Jax Computational Sciences <[email protected]>"]
packages = [
Expand Down
94 changes: 89 additions & 5 deletions src/geneweaver/api/controller/genesets.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
"""Endpoints related to genesets."""
import json
import os
import time
from tempfile import TemporaryDirectory
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Security
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.responses import FileResponse
from geneweaver.api import dependencies as deps
from geneweaver.api.schemas.auth import UserInternal
from geneweaver.api.services import geneset as genset_service
from geneweaver.core.enum import GeneIdentifier
from geneweaver.core.schema.geneset import GenesetUpload
from geneweaver.db import geneset as db_geneset
from geneweaver.db import geneset_value as db_geneset_value

from . import message as api_message

router = APIRouter(prefix="/genesets")
gene_id_type_options = [f"{choice.name} ({choice.value})" for choice in GeneIdentifier]


@router.get("")
Expand All @@ -29,19 +36,79 @@ def get_geneset(
geneset_id: int,
user: UserInternal = Security(deps.full_user),
cursor: Optional[deps.Cursor] = Depends(deps.cursor),
# using int type and adding doc options here as using GeneIdentifier
# won't produce the right docs for gene identifier options
gene_id_type: Optional[int] = Query(
None, description=f"Options: {gene_id_type_options}"
),
) -> dict:
"""Get a geneset by ID."""
response = genset_service.get_geneset(cursor, geneset_id, user)
"""Get a geneset by ID. Optional filter results by gene identifier type."""
if gene_id_type:
gene_identifier_type = get_gene_identifier_type(gene_id_type)
response = genset_service.get_geneset_w_gene_id_type(
cursor, geneset_id, user, gene_identifier_type
)
else:
response = genset_service.get_geneset(cursor, geneset_id, user)

if "error" in response:
if response.get("message") == api_message.ACCESS_FORBIDEN:
raise HTTPException(status_code=403, detail=api_message.ACCESS_FORBIDEN)
if response.get("message") == api_message.ACCESS_FORBIDDEN:
raise HTTPException(status_code=403, detail=api_message.ACCESS_FORBIDDEN)
else:
raise HTTPException(status_code=500, detail=api_message.UNEXPECTED_ERROR)

return response


@router.get("/{geneset_id}/file", response_class=FileResponse)
def get_export_geneset_by_id_type(
geneset_id: int,
user: UserInternal = Security(deps.full_user),
cursor: Optional[deps.Cursor] = Depends(deps.cursor),
temp_dir: TemporaryDirectory = Depends(deps.get_temp_dir),
# using int type and adding doc options here as using
# GeneIdentifier won't produce the right docs for gene identifier options
gene_id_type: Optional[int] = Query(
None, description=f"Options: {gene_id_type_options}"
),
) -> dict:
"""Export geneset into JSON file. Search by ID and optional gene identifier type."""
timestr = time.strftime("%Y%m%d-%H%M%S")

# Validate gene identifier type
if gene_id_type:
gene_identifier_type = get_gene_identifier_type(gene_id_type)
response = genset_service.get_geneset_w_gene_id_type(
cursor, geneset_id, user, gene_identifier_type
)
else:
response = genset_service.get_geneset(cursor, geneset_id, user)

if "error" in response:
if response.get("message") == api_message.ACCESS_FORBIDDEN:
raise HTTPException(status_code=403, detail=api_message.ACCESS_FORBIDDEN)
else:
raise HTTPException(status_code=500, detail=api_message.UNEXPECTED_ERROR)

id_type = response.get("gene_identifier_type")
if id_type:
geneset_filename = f"geneset_{geneset_id}_{id_type}_{timestr}.json"
else:
geneset_filename = f"geneset_{geneset_id}_{timestr}.json"

# Write the data to temp file
temp_file_path = os.path.join(temp_dir, geneset_filename)
with open(temp_file_path, "w") as f:
json.dump(response, f, default=str)

# Return as a download
return FileResponse(
path=temp_file_path,
media_type="application/octet-stream",
filename=geneset_filename,
)


@router.post("")
def upload_geneset(
geneset: GenesetUpload,
Expand All @@ -51,3 +118,20 @@ def upload_geneset(
"""Upload a geneset."""
db_geneset_value.format_geneset_values_for_file_insert(geneset.gene_list)
return {"geneset_id": 0}


def get_gene_identifier_type(gene_id_type: int) -> GeneIdentifier:
"""Get a valid GeneIdentifier object. Raise HTTP exception if invalid value.
@param gene_id_type: gene identifier type
@return: GeneIdentifier obj or HTTP exception if invalid id value.
"""
try:
gene_identifier = GeneIdentifier(gene_id_type)
except ValueError as err:
raise HTTPException(
status_code=400,
detail=f"{api_message.GENE_IDENTIFIER_TYPE_VALUE_ERROR}, "
f"valid options= {gene_id_type_options}",
) from err
return gene_identifier
3 changes: 2 additions & 1 deletion src/geneweaver/api/controller/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@


##Errors
ACCESS_FORBIDEN = "Forbidden"
ACCESS_FORBIDDEN = "Forbidden"
UNEXPECTED_ERROR = "Unexpected Error"
GENE_IDENTIFIER_TYPE_VALUE_ERROR = "Invalid gene identifier type"
23 changes: 21 additions & 2 deletions src/geneweaver/api/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Dependency injection capabilities for the GeneWeaver API."""
# ruff: noqa: B008
from tempfile import TemporaryDirectory
from typing import Generator

import psycopg
Expand Down Expand Up @@ -27,11 +28,20 @@ def cursor() -> Generator:
yield cur


def full_user(
async def full_user(
cursor: Cursor = Depends(cursor),
user: UserInternal = Depends(auth.get_user_strict),
) -> UserInternal:
"""Get the full user object.""" ""
"""Get the full user object.
Since there are external dependencies to wait for,
the recommendation is to use async
Also, Workaround FASTAPI issue, where logs hide exact place of errors
https://github.com/tiangolo/fastapi/discussions/8428
Geneweaver issue: G3-96.
@param cursor: DB cursor
@param user: GW user.
"""
try:
user.id = db_user.by_sso_id_and_email(cursor, user.sso_id, user.email)[0][
"usr_id"
Expand All @@ -49,3 +59,12 @@ def full_user(
)

yield user


async def get_temp_dir() -> TemporaryDirectory:
"""Get a temp directory."""
temp_dir = TemporaryDirectory()
try:
yield temp_dir.name
finally:
del temp_dir
49 changes: 46 additions & 3 deletions src/geneweaver/api/services/geneset.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@
from fastapi.logger import logger
from geneweaver.api.controller import message
from geneweaver.api.schemas.auth import User
from geneweaver.core.enum import GeneIdentifier
from geneweaver.db import geneset as db_geneset
from geneweaver.db import geneset_value as db_geneset_value
from geneweaver.db.geneset import is_readable as db_is_readable
from psycopg import Cursor


def get_geneset(cursor: Cursor, geneset_id: int, user: User) -> dict:
"""Get a geneset by ID."""
"""Get a geneset by ID.
@param cursor: DB cursor
@param geneset_id: geneset identifier
@param user: GW user
@return: dictionary response (geneset and genset values).
"""
try:
if not is_geneset_readable_by_user(cursor, geneset_id, user):
return {"error": True, "message": message.ACCESS_FORBIDEN}
return {"error": True, "message": message.ACCESS_FORBIDDEN}

geneset = db_geneset.by_id(cursor, geneset_id)
geneset_values = db_geneset_value.by_geneset_id(cursor, geneset_id)
Expand All @@ -24,8 +31,44 @@ def get_geneset(cursor: Cursor, geneset_id: int, user: User) -> dict:
raise err


def get_geneset_w_gene_id_type(
cursor: Cursor, geneset_id: int, user: User, gene_id_type: GeneIdentifier
) -> dict:
"""Get a geneset by ID and filter with gene identifier type.
@param cursor: DB cursor
@param geneset_id: geneset identifier
@param user: GW user
@param gene_id_type: gene identifier type object
@return: Dictionary response (geneset identifier, geneset, and genset values).
"""
try:
if not is_geneset_readable_by_user(cursor, geneset_id, user):
return {"error": True, "message": message.ACCESS_FORBIDDEN}

geneset = db_geneset.by_id(cursor, geneset_id)
geneset_values = db_geneset_value.by_geneset_id(
cursor, geneset_id, gene_id_type
)
return {
"gene_identifier_type": gene_id_type.name,
"geneset": geneset,
"geneset_values": geneset_values,
}

except Exception as err:
logger.error(err)
raise err


def is_geneset_readable_by_user(cursor: Cursor, geneset_id: int, user: User) -> bool:
"""Check if the user can read the geneset from DB."""
"""Check if the user can read the geneset from DB.
@param cursor: DB cursor object
@param geneset_id: geneset identifier
@param user: GW user
@return: True if geneset is readable by user.
"""
readable: bool = False
try:
readable = db_is_readable(cursor, user.id, geneset_id)
Expand Down
12 changes: 0 additions & 12 deletions tests/controllers/conftest.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
"""Fixtures for the controller tests."""
import importlib.resources
import json
from unittest.mock import Mock

import psycopg
import pytest
from fastapi.testclient import TestClient
from geneweaver.api.core.config_class import GeneweaverAPIConfig

# Load test data
# Opening JSON file
str_json = importlib.resources.read_text("tests.data", "response_geneset_1234.json")
# returns JSON string as a dictionary
test_data = json.loads(str_json)

response_mock = Mock()
response_mock.status_code = 200
response_mock.json.return_value = test_data


# Mock dependencies
def mock_full_user() -> Mock:
Expand Down
51 changes: 48 additions & 3 deletions tests/controllers/test_genesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@
from unittest.mock import patch

import pytest
from geneweaver.api.controller import message

from tests.controllers.conftest import test_data
from tests.data import test_geneset_data

geneset_by_id_resp = test_geneset_data.get("geneset_by_id_resp")
geneset_w_gene_id_type_resp = test_geneset_data.get("geneset_w_gene_id_type_resp")


@patch("geneweaver.api.services.geneset.get_geneset")
@patch("geneweaver.api.services.geneset.is_geneset_readable_by_user")
def test_get_geneset_response(mock_genset_is_readable, mock_get_genenset, client):
"""Test get geneset ID data response."""
mock_genset_is_readable.return_value = True
mock_get_genenset.return_value = test_data
mock_get_genenset.return_value = geneset_by_id_resp

response = client.get("/api/genesets/1234")
assert response.status_code == 200
assert response.json() == test_data
assert response.json() == geneset_by_id_resp


@patch("geneweaver.api.services.geneset.db_is_readable")
Expand All @@ -35,3 +39,44 @@ def test_get_geneset_unexpected_error(mock_genset_is_readable, client):

with pytest.raises(expected_exception=Exception):
client.get("/api/genesets/1234")


@patch("geneweaver.api.services.geneset.get_geneset_w_gene_id_type")
def test_get_geneset_w_gene_id_type(mock_service_get_geneset_w_gene_id_type, client):
"""Test get geneset with gene id type response."""
mock_service_get_geneset_w_gene_id_type.return_value = geneset_w_gene_id_type_resp
response = client.get("/api/genesets/1234?gene_id_type=2")

assert response.json() == geneset_w_gene_id_type_resp
assert response.status_code == 200


@patch("geneweaver.api.services.geneset.db_is_readable")
def test_get_geneset_export_forbidden(mock_genset_is_readable, client):
"""Test export forbidden response."""
mock_genset_is_readable.return_value = False
response = client.get("/api/genesets/1234/file?gene_id_type=2")

assert response.json() == {"detail": "Forbidden"}
assert response.status_code == 403


@patch("geneweaver.api.services.geneset.get_geneset_w_gene_id_type")
def test_export_geneset_w_gene_id_type(mock_service_get_geneset_w_gene_id_type, client):
"""Test geneset file export."""
mock_service_get_geneset_w_gene_id_type.return_value = geneset_w_gene_id_type_resp
response = client.get("/api/genesets/1234/file?gene_id_type=2")

assert response.headers.get("content-type") == "application/octet-stream"
assert int(response.headers.get("content-length")) > 0
assert response.status_code == 200


@patch("geneweaver.api.services.geneset.get_geneset_w_gene_id_type")
def test_invalid_gene_type_id(mock_service_get_geneset_w_gene_id_type, client):
"""Test geneset file export."""
mock_service_get_geneset_w_gene_id_type.return_value = geneset_w_gene_id_type_resp
response = client.get("/api/genesets/1234/file?gene_id_type=25")

assert message.GENE_IDENTIFIER_TYPE_VALUE_ERROR in response.json()["detail"]
assert response.status_code == 400
18 changes: 18 additions & 0 deletions tests/data/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
"""Data package for tests."""

import importlib.resources
import json

## Load test data
# Opening JSON files
geneset_response_json = importlib.resources.read_text(
"tests.data", "response_geneset_1234.json"
)
geneset_w_gene_id_type_json = importlib.resources.read_text(
"tests.data", "response_geneset_w_gene_id_type.json"
)

# returns JSON string as a dictionary
test_geneset_data = {
"geneset_by_id_resp": json.loads(geneset_response_json),
"geneset_w_gene_id_type_resp": json.loads(geneset_w_gene_id_type_json),
}
Loading

0 comments on commit 139efba

Please sign in to comment.