Skip to content

Commit 0d49032

Browse files
authored
API stability fixes and dependency modernisation (#36)
* security fixes & packages upgrade * add comments
1 parent f91d4df commit 0d49032

36 files changed

Lines changed: 464 additions & 373 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,5 @@ cython_debug/
193193
.cursorignore
194194
.cursorindexingignore
195195

196-
alembic/versions/
196+
alembic/versions/
197+
.DS_Store

config/database.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from sqlalchemy import create_engine
2-
from sqlalchemy.orm import sessionmaker, declarative_base
2+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
33
import os
44
from dotenv import load_dotenv
55
import warnings
@@ -13,8 +13,19 @@
1313
# Read the database URL from the environment variable
1414
DATABASE_URL = os.getenv("DATABASE_URL")
1515
if DATABASE_URL is not None:
16-
# Create the SQLAlchemy engine with the database URL
17-
engine = create_engine(DATABASE_URL)
16+
# Create the SQLAlchemy engine with the database URL.
17+
# pool_size / max_overflow cap concurrent DB connections so one slow
18+
# query can't starve the whole server. statement_timeout kills any
19+
# single query that runs longer than 60 s so a runaway request doesn't
20+
# hold a connection indefinitely.
21+
engine = create_engine(
22+
DATABASE_URL,
23+
pool_size=10,
24+
max_overflow=5,
25+
pool_timeout=30,
26+
pool_pre_ping=True,
27+
connect_args={"options": "-c statement_timeout=60000"},
28+
)
1829
# Create a session factory
1930
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
2031
else:
@@ -23,7 +34,8 @@
2334
# Base is the base class for all the SQLAlchemy ORM models.
2435
# It tells SQLAlchemy that a model maps to a real table.
2536
# Without inheriting from Base, the class won’t be recognized by SQLAlchemy’s ORM.
26-
Base = declarative_base()
37+
class Base(DeclarativeBase):
38+
pass
2739

2840
# Dependency to get a DB session for FastAPI routes (used in controllers)
2941
def get_db():

controllers/disco_controller.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, Depends, Query, Request
1+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
22
from sqlalchemy.orm import Session
33
from services.disco_service import DiscoService
44
from dtos.generic_response_dto import GenericResponseDTO, build_url
@@ -56,16 +56,24 @@ async def get_events(
5656
None, description="Total number of Atlas probes active in the reported stream (ASN, Country, or geographical area)."),
5757
ongoing: Optional[str] = Query(
5858
None, description="Deprecated, this value is unused"),
59+
include_probe_details: bool = Query(
60+
False, description="Include per-probe details in the response."),
5961
page: Optional[int] = Query(
6062
1, ge=1, description="A page number within the paginated result set."),
6163
ordering: Optional[str] = Query(
6264
None, description="Which field to use when ordering the results")
6365
) -> GenericResponseDTO[DiscoEventsDTO]:
6466
"""
65-
List network disconnections detected with RIPE Atlas.
67+
List network disconnections detected with RIPE Atlas.
6668
These events have different levels of granularity - it can be at a network level (AS), city, or country level.
6769
"""
6870

71+
if not any([starttime, starttime__gte, starttime__lte, endtime, endtime__gte, endtime__lte]):
72+
raise HTTPException(
73+
status_code=400,
74+
detail="At least one time parameter is required: starttime, starttime__gte, starttime__lte, endtime, endtime__gte, or endtime__lte."
75+
)
76+
6977
events_data, total_count = DiscoController.service.get_disco_events(
7078
db,
7179
streamname=streamname,
@@ -86,6 +94,7 @@ async def get_events(
8694
totalprobes_gte=totalprobes__gte,
8795
totalprobes_lte=totalprobes__lte,
8896
ongoing=ongoing,
97+
include_probe_details=include_probe_details,
8998
page=page,
9099
order_by=ordering
91100
)

docs/add_new_endpoint.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,55 @@ Create a service file in the `services/` directory or modify an existing one. Th
1919
### 3. **Create the Repository**
2020
Add a repository file in the `repositories/` directory or modify an existing one. Ensure it handles pagination and ordering using `offset` and `limit`.
2121

22+
Use the SQLAlchemy 2.0 `select()` style — **not** the legacy `db.query()` API.
23+
24+
Example:
25+
```python
26+
# filepath: repositories/new_entity_repository.py
27+
from sqlalchemy.orm import Session
28+
from sqlalchemy import select, func
29+
from models.new_entity_model import NewEntity
30+
from typing import Optional, List, Tuple
31+
from utils import page_size
32+
33+
34+
class NewEntityRepository:
35+
def get_all(
36+
self,
37+
db: Session,
38+
field1: Optional[str] = None,
39+
page: int = 1,
40+
order_by: Optional[str] = None,
41+
) -> Tuple[List[NewEntity], int]:
42+
stmt = select(NewEntity)
43+
44+
if field1:
45+
stmt = stmt.where(NewEntity.field1 == field1)
46+
47+
total_count = db.scalar(select(func.count()).select_from(stmt.subquery()))
48+
49+
if order_by and hasattr(NewEntity, order_by):
50+
stmt = stmt.order_by(getattr(NewEntity, order_by))
51+
52+
offset = (page - 1) * page_size
53+
results = db.scalars(stmt.offset(offset).limit(page_size)).all()
54+
55+
return results, total_count
56+
```
57+
58+
If the model has a relationship that needs to be loaded eagerly alongside the main query, use `contains_eager` with `of_type()`:
59+
```python
60+
from sqlalchemy.orm import contains_eager, aliased
61+
62+
RelatedModel = aliased(NewEntity.related_relation.property.mapper.class_)
63+
stmt = (
64+
select(NewEntity)
65+
.join(NewEntity.related_relation.of_type(RelatedModel))
66+
.options(contains_eager(NewEntity.related_relation.of_type(RelatedModel)))
67+
)
68+
# then add .where() clauses and call db.scalars(...).unique().all()
69+
```
70+
2271
---
2372

2473
### 4. **Define the Model**
@@ -88,14 +137,13 @@ Add a DTO in the `dtos/` directory to define the structure of the response.
88137
Example:
89138
```python
90139
# filepath: dtos/new_entity_dto.py
91-
from pydantic import BaseModel
140+
from pydantic import BaseModel, ConfigDict
92141

93142
class NewEntityDTO(BaseModel):
94143
field1: str
95144
field2: str
96145

97-
class Config:
98-
from_attributes = True
146+
model_config = ConfigDict(from_attributes=True)
99147
```
100148
---
101149

dtos/country_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, ConfigDict
22

33
class CountryDTO(BaseModel):
44
code: str
55
name: str
66

7-
class Config:
8-
from_attributes = True
7+
model_config = ConfigDict(from_attributes=True)

dtos/disco_events_dto.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dtos.disco_probes_dto import DiscoProbesDTO
2-
from pydantic import BaseModel
2+
from pydantic import BaseModel, ConfigDict
33
from datetime import datetime
44
from typing import List, Optional
55

@@ -13,13 +13,12 @@ class DiscoEventsDTO(BaseModel):
1313
nbdiscoprobes: int
1414
totalprobes: int
1515
ongoing: bool
16-
discoprobes: List[DiscoProbesDTO]
16+
discoprobes: Optional[List[DiscoProbesDTO]] = None
1717

18-
class Config:
19-
from_attributes = True
18+
model_config = ConfigDict(from_attributes=True)
2019

2120
@staticmethod
22-
def from_model(disco_event):
21+
def from_model(disco_event, include_probe_details: bool = False):
2322
return DiscoEventsDTO(
2423
id=disco_event.id,
2524
streamtype=disco_event.streamtype,
@@ -30,6 +29,6 @@ def from_model(disco_event):
3029
nbdiscoprobes=disco_event.nbdiscoprobes,
3130
totalprobes=disco_event.totalprobes,
3231
ongoing=disco_event.ongoing,
33-
discoprobes=[DiscoProbesDTO.from_orm(
34-
probe) for probe in disco_event.probes]
32+
discoprobes=[DiscoProbesDTO.model_validate(probe) for probe in disco_event.probes]
33+
if include_probe_details else None,
3534
)

dtos/disco_probes_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, ConfigDict
22
from datetime import datetime
33

44

@@ -13,5 +13,4 @@ class DiscoProbesDTO(BaseModel):
1313
lat: float
1414
lon: float
1515

16-
class Config:
17-
from_attributes = True
16+
model_config = ConfigDict(from_attributes=True)

dtos/hegemony_alarms_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, ConfigDict
22
from datetime import datetime
33

44

@@ -11,5 +11,4 @@ class HegemonyAlarmsDTO(BaseModel):
1111
asn_name: str
1212
originasn_name: str
1313

14-
class Config:
15-
from_attributes = True
14+
model_config = ConfigDict(from_attributes=True)

dtos/hegemony_cone_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, ConfigDict
22
from datetime import datetime
33

44

@@ -8,5 +8,4 @@ class HegemonyConeDTO(BaseModel):
88
conesize: int
99
af: int
1010

11-
class Config:
12-
from_attributes = True
11+
model_config = ConfigDict(from_attributes=True)

dtos/hegemony_country_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, ConfigDict
22
from datetime import datetime
33

44

@@ -13,5 +13,4 @@ class HegemonyCountryDTO(BaseModel):
1313
weightscheme: str
1414
transitonly: bool
1515

16-
class Config:
17-
from_attributes = True
16+
model_config = ConfigDict(from_attributes=True)

0 commit comments

Comments
 (0)