Skip to content

Commit 4c235e1

Browse files
committed
feat: add api version
1 parent 4e356c8 commit 4c235e1

11 files changed

Lines changed: 112 additions & 85 deletions

File tree

README.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
│   └── workflows
1313
│   └── ci.yml
1414
├── telemetry_api
15-
│   ├── api # Основные роутеры
15+
│   ├── api/v1 # Основные роутеры
1616
│   │   ├── __init__.py # Инициализация FastAPI и подключение роутеров
1717
│   │   ├── analytics.py
1818
│   │   ├── devices.py
@@ -50,24 +50,27 @@
5050
```
5151

5252
## Endpoints:
53-
* `POST`: `/users/` — Создание нового пользователя
54-
* `GET`: `/users/{user_id}` — Получение пользователя по id
55-
* `PATCH`: `/users/{user_id}` — Частичное обновление данных пользователя
56-
* `DELETE`: `/users/{user_id}` — Удаление пользователя
57-
* `GET`: `/users/` — Получение списка пользователей с пагинацией
53+
Базовый префикс API: `/api`
54+
Текущая версия: `/v1`
55+
56+
* `POST`: `/api/v1/users/` — Создание нового пользователя
57+
* `GET`: `/api/v1/users/{user_id}` — Получение пользователя по id
58+
* `PATCH`: `/api/v1/users/{user_id}` — Частичное обновление данных пользователя
59+
* `DELETE`: `/api/v1/users/{user_id}` — Удаление пользователя
60+
* `GET`: `/api/v1/users/` — Получение списка пользователей с пагинацией
5861
<br>
5962

60-
* `POST`: `/devices/` — Создание нового устройства (с возможностью привязки к пользователю)
61-
* `GET`: `/devices/{device_id}` — Получение устройства по id
62-
* `PATCH`: `/devices/{device_id}` — Частичное обновление данных устройства (включая смену владельца)
63-
* `DELETE`: `/devices/{device_id}` — Удаление устройства
64-
* `GET`: `/devices/` — Получение списка устройств с пагинацией
63+
* `POST`: `/api/v1/devices/` — Создание нового устройства (с возможностью привязки к пользователю)
64+
* `GET`: `/api/v1/devices/{device_id}` — Получение устройства по id
65+
* `PATCH`: `/api/v1/devices/{device_id}` — Частичное обновление данных устройства (включая смену владельца)
66+
* `DELETE`: `/api/v1/devices/{device_id}` — Удаление устройства
67+
* `GET`: `/api/v1/devices/` — Получение списка устройств с пагинацией
6568
<br>
6669

67-
* `POST`: `/analytics/{device_id}/data` — Добавление одного измерения для конкретного устройства
68-
* `GET`: `/analytics/` — Получение агрегированной аналитики по устройству или пользователю (с возможностью фильтрации по времени и пагинацией)
69-
* `POST`: `/analytics/generate` — Запуск фоновой задачи через Celery для генерации аналитики асинхронно
70-
* `GET`: `/analytics/tasks/{task_id}` — Получение статуса или готового (пагинированного) результата фоновой задачи генерации аналитики
70+
* `POST`: `/api/v1/analytics/{device_id}/data` — Добавление одного измерения для конкретного устройства
71+
* `GET`: `/api/v1/analytics/` — Получение агрегированной аналитики по устройству или пользователю (с возможностью фильтрации по времени и пагинацией)
72+
* `POST`: `/api/v1/analytics/generate` — Запуск фоновой задачи через Celery для генерации аналитики асинхронно
73+
* `GET`: `/api/v1/analytics/tasks/{task_id}` — Получение статуса или готового (пагинированного) результата фоновой задачи генерации аналитики
7174
<br>
7275

7376
* `GET`: `/docs/` — Подробная документация с описанием нетривиальных функций и примерами данных

locustfile.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from locust import HttpUser, between, task
44

5+
API_PREFIX = "/api"
6+
API_VERSION = "v1"
7+
API_ROOT = f"{API_PREFIX}/{API_VERSION}"
8+
59

610
class TelemetryApiUser(HttpUser):
711
wait_time = between(0.5, 3.0)
@@ -19,9 +23,9 @@ def _create_user(self) -> int:
1923
}
2024

2125
with self.client.post(
22-
"/users/",
26+
f"{API_ROOT}/users/",
2327
json=payload,
24-
name="POST /users/",
28+
name=f"POST {API_ROOT}/users/",
2529
catch_response=True
2630
) as response:
2731
if response.status_code != 201:
@@ -38,9 +42,9 @@ def _create_device(self, user_id: int) -> int:
3842
}
3943

4044
with self.client.post(
41-
"/devices/",
45+
f"{API_ROOT}/devices/",
4246
json=payload,
43-
name="POST /devices/",
47+
name=f"POST {API_ROOT}/devices/",
4448
catch_response=True
4549
) as response:
4650
if response.status_code != 201:
@@ -58,9 +62,9 @@ def _add_measurement(self, device_id: int):
5862
}
5963

6064
with self.client.post(
61-
f"/analytics/{device_id}/data",
65+
f"{API_ROOT}/analytics/{device_id}/data",
6266
json=payload,
63-
name="POST /analytics/{device_id}/data",
67+
name=f"POST {API_ROOT}/analytics/{device_id}/data",
6468
catch_response=True,
6569
) as response:
6670
if response.status_code != 201:
@@ -78,7 +82,12 @@ def get_device_analytics(self):
7882
"offset": 0,
7983
}
8084

81-
with self.client.get("/analytics/", params=params, name="GET /analytics/?device_id", catch_response=True) as response:
85+
with self.client.get(
86+
f"{API_ROOT}/analytics/",
87+
params=params,
88+
name=f"GET {API_ROOT}/analytics/?device_id",
89+
catch_response=True
90+
) as response:
8291
if response.status_code != 200:
8392
response.failure(f"Unexpected status: {response.status_code}, body={response.text}")
8493
return
@@ -90,9 +99,9 @@ def get_device_analytics(self):
9099
@task(2)
91100
def get_users(self):
92101
with self.client.get(
93-
"/users/",
102+
f"{API_ROOT}/users/",
94103
params={"limit": 10, "offset": 0},
95-
name="GET /users/",
104+
name=f"GET {API_ROOT}/users/",
96105
catch_response=True,
97106
) as response:
98107
if response.status_code != 200:
@@ -101,9 +110,9 @@ def get_users(self):
101110
@task(2)
102111
def get_devices(self):
103112
with self.client.get(
104-
"/devices/",
113+
f"{API_ROOT}/devices/",
105114
params={"limit": 10, "offset": 0},
106-
name="GET /devices/",
115+
name=f"GET {API_ROOT}/devices/",
107116
catch_response=True,
108117
) as response:
109118
if response.status_code != 200:

telemetry_api/api/__init__.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from fastapi import FastAPI
1+
from fastapi import APIRouter, FastAPI
22

3-
from telemetry_api.api import analytics, devices, users
3+
from telemetry_api.api.v1 import analytics, devices, users
4+
from telemetry_api.config import config
45

56

67
def create_api() -> FastAPI:
@@ -11,12 +12,18 @@ def create_api() -> FastAPI:
1112
api = FastAPI(
1213
title="Telemetry API",
1314
description="",
14-
version="1.0.0"
15+
version="1.0.0",
1516
)
1617

17-
api.include_router(analytics.router)
18-
api.include_router(devices.router)
19-
api.include_router(users.router)
18+
api_router = APIRouter(prefix=config.api_prefix.rstrip("/"))
19+
v1_router = APIRouter(prefix=f"/{config.default_api_version.strip('/')}")
20+
21+
v1_router.include_router(analytics.router)
22+
v1_router.include_router(devices.router)
23+
v1_router.include_router(users.router)
24+
25+
api_router.include_router(v1_router)
26+
api.include_router(api_router)
2027
return api
2128

2229

telemetry_api/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class Settings(BaseSettings):
88
host: str = "0.0.0.0"
99
port: int = 8000
1010
celery_result_expires: int = 12 * 60 * 60
11+
api_prefix: str = "/api"
12+
default_api_version: str = "v1"
1113

1214
postgres_user: SecretStr
1315
postgres_password: SecretStr
@@ -27,7 +29,7 @@ class Settings(BaseSettings):
2729

2830

2931
@lru_cache
30-
def get_config() -> BaseSettings:
32+
def get_config() -> Settings:
3133
return Settings()
3234

3335

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,13 @@ async def async_client():
4747

4848
async with AsyncClient(transport=transport, base_url="https://example.com") as client:
4949
yield client
50+
51+
52+
API_PREFIX = "/api"
53+
API_VERSION = "v1"
54+
API_ROOT = f"{API_PREFIX}/{API_VERSION}"
55+
56+
57+
def api_path(path: str) -> str:
58+
normalized_path = path if path.startswith("/") else f"/{path}"
59+
return f"{API_ROOT}{normalized_path}"

tests/test_analytics.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
from httpx import AsyncClient
22

3+
from conftest import api_path
4+
35

46
async def _create_user(async_client: AsyncClient) -> int:
57
response = await async_client.post(
6-
"/users/",
8+
api_path("/users/"),
79
json={"username": "kirill", "email": "kirill@example.com"},
810
)
911
return response.json()["id"]
1012

1113

1214
async def _create_device(async_client: AsyncClient, name: str, user_id: int) -> int:
1315
response = await async_client.post(
14-
"/devices/",
16+
api_path("/devices/"),
1517
json={"name": name, "user_id": user_id},
1618
)
1719
return response.json()["id"]
@@ -27,10 +29,8 @@ async def test_add_measurement(async_client: AsyncClient):
2729
user_id = await _create_user(async_client)
2830
device_id = await _create_device(async_client, "Analytics Device", user_id)
2931

30-
response = await async_client.post(
31-
f"/analytics/{device_id}/data",
32-
json={"x": 1.5, "y": 2.5, "z": 3.5},
33-
)
32+
analytics_url = api_path("/analytics/").rstrip("/")
33+
response = await async_client.post(f"{analytics_url}/{device_id}/data", json={"x": 1.5, "y": 2.5, "z": 3.5})
3434

3535
assert response.status_code == 201
3636
data = response.json()
@@ -49,10 +49,8 @@ async def test_add_measurement_device_not_found(async_client: AsyncClient):
4949
Ожидает код 404 при обращении к отсутствующему объекту.
5050
"""
5151

52-
response = await async_client.post(
53-
"/analytics/999999/data",
54-
json={"x": 1.0, "y": 2.0, "z": 3.0},
55-
)
52+
analytics_url = api_path("/analytics/").rstrip("/")
53+
response = await async_client.post(f"{analytics_url}/999999/data", json={"x": 1.0, "y": 2.0, "z": 3.0})
5654

5755
assert response.status_code == 404
5856

@@ -67,17 +65,12 @@ async def test_get_analytics_by_device(async_client: AsyncClient):
6765
user_id = await _create_user(async_client)
6866
device_id = await _create_device(async_client, "Analytics Reader Device", user_id)
6967

70-
await async_client.post(
71-
f"/analytics/{device_id}/data",
72-
json={"x": 1.0, "y": 2.0, "z": 3.0}
73-
)
74-
await async_client.post(
75-
f"/analytics/{device_id}/data",
76-
json={"x": 3.0, "y": 4.0, "z": 5.0}
77-
)
68+
analytics_url = api_path("/analytics/").rstrip("/")
69+
await async_client.post(f"{analytics_url}/{device_id}/data", json={"x": 1.0, "y": 2.0, "z": 3.0})
70+
await async_client.post(f"{analytics_url}/{device_id}/data", json={"x": 3.0, "y": 4.0, "z": 5.0})
7871

7972
response = await async_client.get(
80-
"/analytics/",
73+
api_path("/analytics/"),
8174
params={"device_id": device_id, "limit": 25, "offset": 0},
8275
)
8376

@@ -107,15 +100,16 @@ async def test_get_analytics_by_user_uses_stable_pagination(async_client: AsyncC
107100
first_device_id = await _create_device(async_client, "Device A", user_id)
108101
second_device_id = await _create_device(async_client, "Device B", user_id)
109102

110-
await async_client.post(f"/analytics/{first_device_id}/data", json={"x": 1.0, "y": 1.0, "z": 1.0})
111-
await async_client.post(f"/analytics/{second_device_id}/data", json={"x": 2.0, "y": 2.0, "z": 2.0})
103+
analytics_url = api_path("/analytics/").rstrip("/")
104+
await async_client.post(f"{analytics_url}/{first_device_id}/data", json={"x": 1.0, "y": 1.0, "z": 1.0})
105+
await async_client.post(f"{analytics_url}/{second_device_id}/data", json={"x": 2.0, "y": 2.0, "z": 2.0})
112106

113107
first_page_response = await async_client.get(
114-
"/analytics/",
108+
api_path("/analytics/"),
115109
params={"user_id": user_id, "limit": 1, "offset": 0},
116110
)
117111
second_page_response = await async_client.get(
118-
"/analytics/",
112+
api_path("/analytics/"),
119113
params={"user_id": user_id, "limit": 1, "offset": 1},
120114
)
121115

@@ -138,7 +132,7 @@ async def test_get_analytics_requires_filter(async_client: AsyncClient):
138132
Ожидает ошибку 400 при отсутствии обязательного фильтра запроса.
139133
"""
140134

141-
response = await async_client.get("/analytics/")
135+
response = await async_client.get(api_path("/analytics/"))
142136
assert response.status_code == 400
143137

144138

@@ -150,7 +144,7 @@ async def test_get_analytics_validates_limit_and_offset(async_client: AsyncClien
150144
"""
151145

152146
response = await async_client.get(
153-
"/analytics/",
147+
api_path("/analytics/"),
154148
params={"device_id": 1, "limit": 0, "offset": -1},
155149
)
156150

@@ -164,6 +158,6 @@ async def test_generate_analytics_route_not_conflicting(async_client: AsyncClien
164158
Ожидает бизнес-ошибку фильтров, а не парсинг `generate` как `device_id`.
165159
"""
166160

167-
response = await async_client.post("/analytics/generate")
161+
response = await async_client.post(api_path("/analytics/generate"))
168162

169163
assert response.status_code == 400

tests/test_devices.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
from httpx import AsyncClient
22

3+
from conftest import api_path
4+
35

46
async def _create_user(async_client: AsyncClient) -> int:
57
response = await async_client.post(
6-
"/users/",
8+
api_path("/users/"),
79
json={"username": "kirill", "email": "kirill@example.com"},
810
)
911
return response.json()["id"]
1012

1113

1214
async def _create_device(async_client: AsyncClient, name: str, user_id: int | None) -> dict:
1315
response = await async_client.post(
14-
"/devices/",
16+
api_path("/devices/"),
1517
json={"name": name, "user_id": user_id},
1618
)
1719

@@ -58,7 +60,8 @@ async def test_get_device(async_client: AsyncClient):
5860
owner_id = await _create_user(async_client)
5961
device = await _create_device(async_client, "Humidity Sensor", owner_id)
6062

61-
response = await async_client.get(f"/devices/{device['id']}")
63+
devices_url = api_path("/devices/").rstrip("/")
64+
response = await async_client.get(f"{devices_url}/{device['id']}")
6265

6366
assert response.status_code == 200
6467
assert response.json()["name"] == "Humidity Sensor"
@@ -75,10 +78,8 @@ async def test_update_device(async_client: AsyncClient):
7578
owner_id = await _create_user(async_client)
7679
device = await _create_device(async_client, "Old Sensor Name", owner_id)
7780

78-
update_response = await async_client.patch(
79-
f"/devices/{device['id']}",
80-
json={"name": "New Sensor Name"},
81-
)
81+
devices_url = api_path("/devices/").rstrip("/")
82+
update_response = await async_client.patch(f"{devices_url}/{device['id']}", json={"name": "New Sensor Name"})
8283
updated_device = update_response.json()
8384

8485
assert update_response.status_code == 200
@@ -96,10 +97,11 @@ async def test_delete_device(async_client: AsyncClient):
9697
owner_id = await _create_user(async_client)
9798
device = await _create_device(async_client, "Sensor to Delete", owner_id)
9899

99-
delete_response = await async_client.delete(f"/devices/{device['id']}")
100+
devices_url = api_path("/devices/").rstrip("/")
101+
delete_response = await async_client.delete(f"{devices_url}/{device['id']}")
100102
assert delete_response.status_code == 204
101103

102-
response = await async_client.get(f"/devices/{device['id']}")
104+
response = await async_client.get(f"{devices_url}/{device['id']}")
103105
assert response.status_code == 404
104106

105107

@@ -116,11 +118,11 @@ async def test_paginate_device(async_client: AsyncClient):
116118
second_device = await _create_device(async_client, "Paginated Sensor 2", owner_id)
117119

118120
first_response = await async_client.get(
119-
"/devices/",
121+
api_path("/devices/"),
120122
params={"limit": 1, "offset": 0},
121123
)
122124
second_response = await async_client.get(
123-
"/devices/",
125+
api_path("/devices/"),
124126
params={"limit": 1, "offset": 1},
125127
)
126128

0 commit comments

Comments
 (0)