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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,19 @@ COPY app/ ./app/
# Copy built frontend from GitHub Actions build
COPY ui/dist ./ui/dist

# Expose port
EXPOSE 8000
# Create startup script that properly handles runtime PORT variable
RUN echo '#!/bin/sh\n\
PORT="${PORT:-8080}"\n\
echo "Starting server on port $PORT"\n\
exec /app/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"' > /app/start.sh && \
chmod +x /app/start.sh

# Expose port 8080 (Cloud Run default)
EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/ || exit 1
CMD curl -f http://localhost:${PORT:-8080}/ || exit 1

# Run the application
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["/app/start.sh"]
13 changes: 12 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,29 @@ class DevelopmentConfig(BaseConfig):
CORS_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173", # Vite dev server
"http://localhost:5174", # Vite dev server
"http://127.0.0.1:5173", # Vite dev server alternative
"http://127.0.0.1:5174", # Vite dev server alternative
"http://localhost:4173", # Vite preview server
"http://127.0.0.1:4173", # Vite preview server alternative
"http://localhost:8080", # Docker local
"http://localhost:8000", # Alternative local
]


class ProductionConfig(BaseConfig):
DEBUG = False
CORS_ORIGINS = [""]
# For Cloud Run, you need to add your actual domain
CORS_ORIGINS = (
os.getenv("CORS_ORIGINS", "").split(",") if os.getenv("CORS_ORIGINS") else ["*"]
)


def get_config():
# Check for DEBUG env var first, then APP_ENV
if os.getenv("DEBUG", "").lower() == "true":
return DevelopmentConfig

env = os.getenv("APP_ENV", "development")
if env == "production":
return ProductionConfig
Expand Down
Empty file removed app/controllers/hello.py
Empty file.
4 changes: 0 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse
from app.config import get_config
from app.routers.pathways import router as pathways_router
from app.routers.umap_router import router
from app.routers import gsea
from app.scripts.prepare_gene_lists import generate_all_library_gene_lists

Expand Down Expand Up @@ -45,8 +43,6 @@

# Include routers
app.include_router(gsea.router, prefix="/api", tags=["GSEA"])
app.include_router(pathways_router)
app.include_router(router, prefix="/umap")


# Mount static files for the React app
Expand Down
3 changes: 3 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.models.gsea import Gene, GseaJsonRequest

__all__ = ["Gene", "GseaJsonRequest"]
24 changes: 24 additions & 0 deletions app/models/gsea.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from pydantic import BaseModel, Field, field_validator
from typing import List


class Gene(BaseModel):
"""Individual gene with symbol and score."""

symbol: str = Field(..., description="Gene symbol (e.g., 'BRCA1', 'TP53')")
globalScore: float = Field(..., description="Gene score for ranking")


class GseaJsonRequest(BaseModel):
"""Request model for JSON-based GSEA endpoint."""

genes: List[Gene] = Field(
..., min_length=1, description="List of genes with symbols and scores"
)

@field_validator("genes")
@classmethod
def validate_genes_not_empty(cls, v):
if not v or len(v) == 0:
raise ValueError("Genes list cannot be empty")
return v
5 changes: 5 additions & 0 deletions app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""API routers for the Pathways API."""

from app.routers import gsea

__all__ = ["gsea"]
111 changes: 93 additions & 18 deletions app/routers/gsea.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,128 @@
from fastapi import APIRouter, UploadFile, File, Query, HTTPException
from typing import Literal
from app.services.gsea import run_gsea, available_gmt_files
from app.services.gsea import run_gsea_from_dataframe, available_gmt_files
from app.models.gsea import GseaJsonRequest
from app.utils.gsea_utils import validate_gsea_dataframe, handle_gsea_error
import tempfile
import pandas as pd
import os
import numpy as np

router = APIRouter()


@router.get("/gsea/libraries")
async def list_gmt_files():
"""List available GMT libraries."""
return list(available_gmt_files().keys())


@router.post("/gsea")
async def gsea_endpoint(
tsv_file: UploadFile = File(..., description="TSV file containing at least 2 columns: 'symbol' and 'globalScore'"),
@router.post("/gsea/analyze/file")
async def analyze_gsea_from_file(
tsv_file: UploadFile = File(
...,
description="TSV file containing at least 2 columns: 'symbol' and 'globalScore'",
),
gmt_name: str = Query(..., description="GMT library name (without .gmt extension)"),
analysis_direction: Literal["one_sided_positive", "one_sided_negative", "two_sided"] = Query(
default="one_sided_positive",
description="Analysis direction: 'one_sided_positive' filters NES > 0, 'one_sided_negative' filters NES < 0, 'two_sided' returns all results"
)
),
):
"""
Run GSEA analysis from uploaded TSV file.

Upload a TSV file with gene symbols and scores to perform Gene Set Enrichment Analysis.

Example:
POST /api/gsea/analyze/file?gmt_name=Reactome/ReactomePathways_2025
Content-Type: multipart/form-data
Body: file=your_data.tsv
"""
# Validate file extension
if not tsv_file.filename.endswith(".tsv"):
raise HTTPException(status_code=400, detail="File must be .tsv format")

# Save to temp file
# Read and validate file
with tempfile.NamedTemporaryFile(delete=False, suffix=".tsv") as tmp:
content = await tsv_file.read()
tmp.write(content)
tsv_path = tmp.name

# Validate TSV structure
try:
df = pd.read_csv(tsv_path, sep="\t", nrows=1) # read first row
if not {"symbol", "globalScore"}.issubset(df.columns) and not {0, 1}.issubset(df.columns):
raise ValueError("TSV must contain 'symbol' and 'globalScore' columns (or two unnamed columns).")
except Exception as e:
os.unlink(tsv_path)
raise HTTPException(status_code=400, detail=f"Invalid TSV format: {str(e)}")
# Load and validate DataFrame
df = pd.read_csv(tsv_path, sep="\t")
df = validate_gsea_dataframe(df)

# Run GSEA
try:
res_df, missing_stats = run_gsea(input_tsv=tsv_path, gmt_name=gmt_name)
# Run GSEA
res_df, input_overlap = run_gsea_from_dataframe(df, gmt_name)

except HTTPException:
raise
except Exception as e:
raise handle_gsea_error(e)
finally:
# Clean up temp file
os.unlink(tsv_path)
if os.path.exists(tsv_path):
os.unlink(tsv_path)

# Filter by NES based on analysis direction
if analysis_direction == "one_sided_positive":
res_df = res_df[res_df["NES"] > 0].copy()
elif analysis_direction == "one_sided_negative":
res_df = res_df[res_df["NES"] < 0].copy()

# Replace NaN/Inf with JSON-safe values
res_df = res_df.replace([np.inf, -np.inf], None)
res_df = res_df.where(pd.notna(res_df), None)

return {
"results": res_df.to_dict(orient="records"),
"input_overlap": input_overlap,
}


@router.post("/gsea/analyze/json")
async def analyze_gsea_from_json(
request: GseaJsonRequest,
gmt_name: str = Query(..., description="GMT library name (without .gmt extension)"),
analysis_direction: Literal["one_sided_positive", "one_sided_negative", "two_sided"] = Query(
default="one_sided_positive",
description="Analysis direction: 'one_sided_positive' filters NES > 0, 'one_sided_negative' filters NES < 0, 'two_sided' returns all results"
),
):
"""
Run GSEA analysis from JSON payload.

Send gene data as JSON to perform Gene Set Enrichment Analysis.

Example:
POST /api/gsea/analyze/json?gmt_name=Reactome/ReactomePathways_2025
Content-Type: application/json
Body: {
"genes": [
{"symbol": "BRCA1", "globalScore": 0.95},
{"symbol": "TP53", "globalScore": 0.87}
]
}
"""
try:
# Convert request to DataFrame
genes_data = [
{"symbol": g.symbol, "globalScore": g.globalScore} for g in request.genes
]
df = pd.DataFrame(genes_data)

# Validate DataFrame (should already be valid via Pydantic, but double-check)
df = validate_gsea_dataframe(df)

# Run GSEA directly (no file I/O needed!)
res_df, input_overlap = run_gsea_from_dataframe(df, gmt_name)

except HTTPException:
raise
except Exception as e:
raise handle_gsea_error(e)

# Filter by NES based on analysis direction
if analysis_direction == "one_sided_positive":
Expand All @@ -61,5 +136,5 @@ async def gsea_endpoint(

return {
"results": res_df.to_dict(orient="records"),
"input_overlap": missing_stats,
"input_overlap": input_overlap,
}
53 changes: 0 additions & 53 deletions app/routers/pathways.py

This file was deleted.

71 changes: 0 additions & 71 deletions app/routers/umap_router.py

This file was deleted.

Loading