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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from starlette.requests import Request as StarletteRequest

INTERNAL_AUTH_TOKEN = os.getenv("CMS_INTERNAL_AUTH_TOKEN")


def custom_rate_limit_key_func(request: StarletteRequest):
"""
Custom key function for rate limiting.
- Authenticated requests get unique keys to effectively bypass rate limiting
- Other requests are rate limited by IP address
"""
token = request.headers.get("x-internal-auth")

# Check for valid authentication token
if token and INTERNAL_AUTH_TOKEN and token == INTERNAL_AUTH_TOKEN:
# Return a unique key for each authenticated request
# This effectively bypasses rate limiting since each request gets its own bucket
return f"auth_bypass_{id(request)}"

Check warning on line 23 in app/limiter.py

View check run for this annotation

Codecov / codecov/patch

app/limiter.py#L23

Added line #L23 was not covered by tests
# For unauthenticated requests, use IP-based rate limiting
return get_remote_address(request)


limiter = Limiter(key_func=custom_rate_limit_key_func)
rate_limit_exceeded_handler = _rate_limit_exceeded_handler
RateLimitExceededExc = RateLimitExceeded
10 changes: 10 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from __future__ import annotations


# Import limiter and related objects from the shared limiter module
from app.limiter import limiter, rate_limit_exceeded_handler, RateLimitExceededExc

import os
from fastapi import FastAPI
from fastapi import status
Expand All @@ -8,6 +12,7 @@
from fastapi_versioning import VersionedFastAPI
from prometheus_fastapi_instrumentator import Instrumentator
from typing import Dict
from slowapi.middleware import SlowAPIMiddleware

from .routers import chem
from .routers import converters
Expand Down Expand Up @@ -84,6 +89,11 @@ def create_app_metadata() -> Dict:
version=os.getenv("RELEASE_VERSION", "1.0"),
)

# Add middleware AFTER VersionedFastAPI creation
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceededExc, rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)

Instrumentator().instrument(app).expose(app)

origins = ["*"]
Expand Down
55 changes: 30 additions & 25 deletions app/routers/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
from typing import Literal

import selfies as sf
from fastapi import FastAPI
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Query
from fastapi import status
from fastapi import Request
from fastapi import Body
from fastapi.responses import Response
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# Use the shared limiter instance
from app.limiter import limiter
from rdkit import Chem

# Schema imports
Expand Down Expand Up @@ -46,15 +44,6 @@
from app.modules.toolkits.rdkit_wrapper import get_3d_conformers
from app.modules.toolkits.rdkit_wrapper import get_rdkit_CXSMILES

# Create the Limiter instance
limiter = Limiter(key_func=get_remote_address)

# Initialize FastAPI app
app = FastAPI()

# Add the middleware to handle rate limit exceeded errors
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

router = APIRouter(
prefix="/convert",
Expand Down Expand Up @@ -622,9 +611,15 @@
"description": "Successful response",
"model": GenerateFormatsResponse,
},
400: {"description": "Bad Request", "model": BadRequestModel},
404: {"description": "Not Found", "model": NotFoundModel},
422: {"description": "Unprocessable Entity", "model": ErrorResponse},
400: {
"description": "Bad Request",
"model": BadRequestModel},
404: {
"description": "Not Found",
"model": NotFoundModel},
422: {
"description": "Unprocessable Entity",
"model": ErrorResponse},
},
)
async def smiles_convert_to_formats(
Expand Down Expand Up @@ -820,24 +815,29 @@
input_format = input_item.get("input_format", "")

if not value or not input_format:
raise ValueError("Missing required fields: value or input_format")
raise ValueError(

Check warning on line 818 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L818

Added line #L818 was not covered by tests
"Missing required fields: value or input_format")

# First convert input to SMILES if it's not already in SMILES format
# First convert input to SMILES if it's not already in SMILES
# format
smiles = value

if input_format.lower() == "iupac":
smiles = get_smiles_opsin(value)
if not smiles:
raise ValueError(f"Failed to convert IUPAC name '{value}' to SMILES")
raise ValueError(

Check warning on line 828 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L828

Added line #L828 was not covered by tests
f"Failed to convert IUPAC name '{value}' to SMILES")
elif input_format.lower() == "selfies":
smiles = sf.decoder(value)
if not smiles:
raise ValueError(f"Failed to decode SELFIES '{value}' to SMILES")
raise ValueError(

Check warning on line 833 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L833

Added line #L833 was not covered by tests
f"Failed to decode SELFIES '{value}' to SMILES")
elif input_format.lower() == "inchi":
# Use RDKit to convert InChI to SMILES
mol = Chem.inchi.MolFromInchi(value)
if not mol:
raise ValueError(f"Failed to convert InChI '{value}' to molecule")
raise ValueError(

Check warning on line 839 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L839

Added line #L839 was not covered by tests
f"Failed to convert InChI '{value}' to molecule")
smiles = Chem.MolToSmiles(mol)
elif input_format.lower() != "smiles":
raise ValueError(f"Unsupported input format: {input_format}")
Expand All @@ -854,7 +854,9 @@
output_value = str(get_canonical_SMILES(mol))
elif toolkit == "rdkit":
mol = parse_input(smiles, "rdkit", False)
output_value = str(Chem.MolToSmiles(mol, kekuleSmiles=True))
output_value = str(

Check warning on line 857 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L857

Added line #L857 was not covered by tests
Chem.MolToSmiles(
mol, kekuleSmiles=True))
elif toolkit == "openbabel":
output_value = get_ob_canonical_SMILES(smiles)

Expand Down Expand Up @@ -889,14 +891,16 @@
mol = parse_input(smiles, "rdkit", False)
output_value = str(get_rdkit_CXSMILES(mol))
else:
raise ValueError(f"CXSMILES conversion not supported by toolkit: {toolkit}")
raise ValueError(

Check warning on line 894 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L894

Added line #L894 was not covered by tests
f"CXSMILES conversion not supported by toolkit: {toolkit}")

elif output_format.lower() == "smarts":
if toolkit == "rdkit":
mol = parse_input(smiles, "rdkit", False)
output_value = str(Chem.MolToSmarts(mol))
else:
raise ValueError(f"SMARTS conversion not supported by toolkit: {toolkit}")
raise ValueError(

Check warning on line 902 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L902

Added line #L902 was not covered by tests
f"SMARTS conversion not supported by toolkit: {toolkit}")

elif output_format.lower() == "mol2d":
if toolkit == "cdk":
Expand All @@ -915,7 +919,8 @@
elif toolkit == "openbabel":
output_value = get_ob_mol(smiles, threeD=True)
else:
raise ValueError(f"3D coordinates generation not supported by toolkit: {toolkit}")
raise ValueError(

Check warning on line 922 in app/routers/converters.py

View check run for this annotation

Codecov / codecov/patch

app/routers/converters.py#L922

Added line #L922 was not covered by tests
f"3D coordinates generation not supported by toolkit: {toolkit}")

else:
raise ValueError(f"Unsupported output format: {output_format}")
Expand Down
34 changes: 9 additions & 25 deletions app/routers/depict.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,26 @@
from __future__ import annotations

from typing import Literal
from typing import Optional
from typing import Literal, Optional

from fastapi import FastAPI
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from fastapi import status
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

from app.modules.depiction import get_cdk_depiction
from app.modules.depiction import get_rdkit_depiction
# Use the shared limiter instance
from app.limiter import limiter
from app.modules.depiction import get_cdk_depiction, get_rdkit_depiction
from app.modules.toolkits.helpers import parse_input
from app.modules.toolkits.openbabel_wrapper import get_ob_mol
from app.modules.toolkits.rdkit_wrapper import get_3d_conformers
from app.schemas import HealthCheck
from app.schemas.depict_schema import Depict2DResponse
from app.schemas.depict_schema import Depict3DResponse
from app.schemas.error import BadRequestModel
from app.schemas.error import ErrorResponse
from app.schemas.error import NotFoundModel
from app.schemas.depict_schema import Depict2DResponse, Depict3DResponse
from app.schemas.error import BadRequestModel, ErrorResponse, NotFoundModel

templates = Jinja2Templates(directory="app/templates")
# Create the Limiter instance
limiter = Limiter(key_func=get_remote_address)

# Initialize FastAPI app
app = FastAPI()

# Add the middleware to handle rate limit exceeded errors
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Removed local FastAPI app instance and limiter/exception handler setup
# in favour of shared one.

router = APIRouter(
prefix="/depict",
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
context: ./
dockerfile: Dockerfile.lite
container_name: cheminformatics-microservice-lite
env_file:
- .env
environment:
- HOMEPAGE_URL=https://docs.api.naturalproducts.net
- RELEASE_VERSION=v2.6.0
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
context: ./
dockerfile: Dockerfile
container_name: cheminformatics-microservice-api
env_file:
- .env
environment:
- HOMEPAGE_URL=https://docs.api.naturalproducts.net
- RELEASE_VERSION=v2.6.0
Expand Down