Skip to content

Commit

Permalink
Merge pull request #273 from Clinical-Genomics/accept_http_forms
Browse files Browse the repository at this point in the history
Modify pydantic model so report endpoints accept either JSON or form data
  • Loading branch information
northwestwitch authored Apr 16, 2024
2 parents 07c42bf + 20b4505 commit df61952
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## [unreleased]
### Added
- Coverage report and genes coverage overview endpoints now accept also requests with application/x-www-form-urlencoded data
### Fixed
- Faster genes overview report loading
- Broken GitHub action due to d4tools failing to install using cargo
Expand Down
23 changes: 20 additions & 3 deletions src/chanjo2/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from importlib_resources import files

from chanjo2.constants import BUILD_37
from chanjo2.constants import BUILD_37, DEFAULT_COMPLETENESS_LEVELS

BASE_PATH: str = "chanjo2.demo"

Expand All @@ -25,16 +25,17 @@

HTTP_SERVER_D4_file = "https://d4-format-testing.s3.us-west-1.amazonaws.com/hg002.d4"
DEMO_HGNC_GENE_SYMBOLS = ["MTHFR", "DHFR", "FOLR1", "SLC46A1", "LAMA1", "PIPPI6"]
ANALYSIS_DATE = "2023-04-23T10:20:30.400+02:30"

# Data for generating a demo coverage report
# JSON Data for generating a demo coverage report
DEMO_COVERAGE_QUERY_DATA = {
"build": BUILD_37,
"samples": [
{
"name": DEMO_SAMPLE["name"],
"case_name": DEMO_CASE["name"],
"coverage_file_path": d4_demo_path,
"analysis_date": "2023-04-23T10:20:30.400+02:30",
"analysis_date": ANALYSIS_DATE,
}
],
"case_display_name": "643594",
Expand All @@ -45,3 +46,19 @@
"hgnc_gene_symbols": DEMO_HGNC_GENE_SYMBOLS,
"default_level": 20,
}

# HTTP FORM-like data for generating a demo coverage report
DEMO_COVERAGE_QUERY_FORM = {
"build": BUILD_37,
"samples": f"[{{'name': '{DEMO_SAMPLE['name']}', 'coverage_file_path': '{d4_demo_path}', 'case_name': '{DEMO_CASE['name']}', 'analysis_date': '{ANALYSIS_DATE}'}}]",
"case_display_name": DEMO_CASE["name"],
"gene_panel": "A test Panel 1.0",
"interval_type": "transcripts",
"ensembl_gene_ids": [],
"hgnc_gene_ids": [],
"hgnc_gene_symbols": ",".join(DEMO_HGNC_GENE_SYMBOLS),
"default_level": "20",
"completeness_thresholds": ",".join(
[str(threshold) for threshold in DEFAULT_COMPLETENESS_LEVELS]
),
}
20 changes: 17 additions & 3 deletions src/chanjo2/endpoints/overview.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import logging
from os import path

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic_core._pydantic_core import ValidationError
from sqlalchemy.orm import Session
from starlette.datastructures import FormData

Expand Down Expand Up @@ -50,12 +51,25 @@ async def demo_overview(request: Request, db: Session = Depends(get_session)):

@router.post("/overview", response_class=HTMLResponse)
async def overview(
request: Request, report_query: ReportQuery, db: Session = Depends(get_session)
request: Request,
db: Session = Depends(get_session),
):
"""Return the genes overview page over a list of genes for a list of samples."""
request_headers: str = request.headers.get("Content-Type")
try:
if request_headers == "application/json":
overview_query = ReportQuery(**await request.json())
else:
overview_query = ReportQuery.as_form(await request.form())

except ValidationError as ve:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=ve.json(),
)

overview_content: dict = get_report_data(
query=report_query, session=db, is_overview=True
query=overview_query, session=db, is_overview=True
)
return templates.TemplateResponse(
"overview.html",
Expand Down
19 changes: 17 additions & 2 deletions src/chanjo2/endpoints/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from os import path
from typing import Dict

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic_core._pydantic_core import ValidationError
from sqlalchemy.orm import Session

from chanjo2.dbutil import get_session
Expand Down Expand Up @@ -50,11 +51,25 @@ async def demo_report(request: Request, db: Session = Depends(get_session)):

@router.post("/report", response_class=HTMLResponse)
async def report(
request: Request, report_query: ReportQuery, db: Session = Depends(get_session)
request: Request,
db: Session = Depends(get_session),
):
"""Return a coverage report over a list of genes for a list of samples."""

start_time = time.time()
request_headers: str = request.headers.get("Content-Type")

try:
if request_headers == "application/json":
report_query = ReportQuery(**await request.json())
else:
report_query = ReportQuery.as_form(await request.form())
except ValidationError as ve:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=ve.json(),
)

report_content: dict = get_report_data(query=report_query, session=db)
LOG.debug(f"Time to compute stats: {time.time() - start_time} seconds.")
return templates.TemplateResponse(
Expand Down
41 changes: 40 additions & 1 deletion src/chanjo2/models/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

import validators
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic_settings import SettingsConfigDict
from starlette.datastructures import FormData

from chanjo2.constants import (
AMBIGUOUS_SAMPLES_INPUT,
Expand Down Expand Up @@ -232,6 +233,44 @@ class ReportQuery(BaseModel):
case_display_name: Optional[str] = None
samples: List[ReportQuerySample]

@staticmethod
def comma_sep_values_to_list(
comma_sep_values: Optional[str], items_format: Union[str, int]
) -> Optional[List[Union[str, int]]]:
"""Helper function that formats list of strings or integers passed by a form as comma separated values."""
if comma_sep_values is None:
return
if items_format == str:
return [item.strip() for item in comma_sep_values.split(",")]
else:
return [int(item.strip()) for item in comma_sep_values.split(",")]

@classmethod
def as_form(cls, form_data: FormData) -> "ReportQuery":
report_query = cls(
build=form_data.get("build"),
completeness_thresholds=cls.comma_sep_values_to_list(
comma_sep_values=form_data.get("completeness_thresholds"),
items_format=int,
)
or DEFAULT_COMPLETENESS_LEVELS,
ensembl_gene_ids=cls.comma_sep_values_to_list(
comma_sep_values=form_data.get("ensembl_gene_ids"), items_format=str
),
hgnc_gene_ids=cls.comma_sep_values_to_list(
comma_sep_values=form_data.get("hgnc_gene_ids"), items_format=int
),
hgnc_gene_symbols=cls.comma_sep_values_to_list(
comma_sep_values=form_data.get("hgnc_gene_symbols"), items_format=str
),
interval_type=form_data.get("interval_type"),
default_level=form_data.get("default_level"),
panel_name=form_data.get("panel_name"),
case_display_name=form_data.get("case_display_name"),
samples=form_data.get("samples"),
)
return report_query

@field_validator("samples", mode="before")
def samples_validator(cls, sample_list):
if isinstance(sample_list, str):
Expand Down
21 changes: 19 additions & 2 deletions tests/src/chanjo2/endpoints/test_overview.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import json
from typing import Dict, List, Type

from fastapi import status
from fastapi.testclient import TestClient
from requests.models import Response

from chanjo2.constants import BUILD_37, DEFAULT_COMPLETENESS_LEVELS
from chanjo2.demo import DEMO_COVERAGE_QUERY_DATA
from chanjo2.demo import DEMO_COVERAGE_QUERY_DATA, DEMO_COVERAGE_QUERY_FORM


def test_demo_overview(client: TestClient, endpoints: Type):
Expand All @@ -22,7 +23,7 @@ def test_demo_overview(client: TestClient, endpoints: Type):


def test_overview_json_data(client: TestClient, endpoints: Type):
"""Test the endpoint that creates the genes coverage overview page by providing json data in POST request."""
"""Test the endpoint that creates the genes coverage overview page by providing json data in the POST request."""

# GIVEN a query with json data to the genes coverage overview endpoint
response: Response = client.post(
Expand All @@ -37,6 +38,22 @@ def test_overview_json_data(client: TestClient, endpoints: Type):
assert response.template.name == "overview.html"


def test_overview_form_data(client: TestClient, endpoints: Type):
"""Test the endpoint that creates the genes coverage overview page by providing application/x-www-form-urlencoded data in the POST request."""

# GIVEN a query with application/x-www-form-urlencoded data to the genes coverage overview endpoint
response: Response = client.post(
endpoints.OVERVIEW,
data=DEMO_COVERAGE_QUERY_FORM,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# Then the request should be successful
assert response.status_code == status.HTTP_200_OK

# And return an HTML page
assert response.template.name == "overview.html"


def test_gene_overview(
client: TestClient, endpoints: Type, genomic_ids_per_build: Dict[str, List]
):
Expand Down
22 changes: 19 additions & 3 deletions tests/src/chanjo2/endpoints/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from requests.models import Response

from chanjo2.constants import GENE_LISTS_NOT_SUPPORTED_MSG
from chanjo2.demo import DEMO_COVERAGE_QUERY_DATA
from chanjo2.demo import DEMO_COVERAGE_QUERY_DATA, DEMO_COVERAGE_QUERY_FORM


def test_demo_report(client: TestClient, endpoints: Type):
Expand Down Expand Up @@ -38,11 +38,11 @@ def test_report_request_no_genes(client: TestClient, endpoints: Type):
# THEN the response should return the expected error code and message
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
result = response.json()
assert GENE_LISTS_NOT_SUPPORTED_MSG in result["detail"][0]["msg"]
assert GENE_LISTS_NOT_SUPPORTED_MSG in result["detail"]


def test_report_json_data(client: TestClient, endpoints: Type):
"""Test the coverage report endpoint by providing json data in POST request."""
"""Test the coverage report endpoint by providing json data in a POST request."""

# GIVEN a query with json data to the report endpoint
response: Response = client.post(
Expand All @@ -55,3 +55,19 @@ def test_report_json_data(client: TestClient, endpoints: Type):

# And return an HTML page
assert response.template.name == "report.html"


def test_report_form_data(client: TestClient, endpoints: Type):
"""Test the coverage report endpoint by providing application/x-www-form-urlencoded data in a POST request."""

# GIVEN a query with application/x-www-form-urlencoded data to the report endpoint
response: Response = client.post(
endpoints.REPORT,
data=DEMO_COVERAGE_QUERY_FORM,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# Then the request should be successful
assert response.status_code == status.HTTP_200_OK

# And return an HTML page
assert response.template.name == "report.html"

0 comments on commit df61952

Please sign in to comment.