fastapi-sdk 0.4.2__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/PKG-INFO +1 -1
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/model.py +62 -7
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/route.py +51 -7
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/security/oauth.py +0 -3
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/PKG-INFO +1 -1
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/pyproject.toml +1 -1
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_controller_model.py +66 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_controller_route.py +83 -1
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/LICENSE +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/README.md +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/__init__.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/__init__.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/middleware/auth.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/security/permissions.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/model.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/schema.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/test.py +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/SOURCES.txt +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/dependency_links.txt +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/requires.txt +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/top_level.txt +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/setup.cfg +0 -0
- {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_middleware_auth.py +0 -0
|
@@ -242,12 +242,21 @@ class ModelController:
|
|
|
242
242
|
query: Optional[List[dict]] = None,
|
|
243
243
|
order_by: Optional[dict] = None,
|
|
244
244
|
claims: Optional[Dict[str, Any]] = None,
|
|
245
|
+
include: Optional[List[str]] = None,
|
|
245
246
|
) -> List[BaseModel]:
|
|
246
|
-
"""List models.
|
|
247
|
+
"""List models.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
page: The page number (0-based)
|
|
251
|
+
query: Optional query filters
|
|
252
|
+
order_by: Optional sorting criteria
|
|
253
|
+
claims: Optional claims for ownership verification
|
|
254
|
+
include: Optional list of related objects to include
|
|
255
|
+
"""
|
|
247
256
|
# Get the collection
|
|
248
|
-
collection_name =
|
|
249
|
-
"collection"
|
|
250
|
-
|
|
257
|
+
collection_name = (
|
|
258
|
+
self.model.model_config.get("collection") or self.model.__collection__
|
|
259
|
+
)
|
|
251
260
|
_collection = self.db_engine.database[collection_name]
|
|
252
261
|
|
|
253
262
|
# Create a pipeline for aggregation
|
|
@@ -268,10 +277,53 @@ class ModelController:
|
|
|
268
277
|
)
|
|
269
278
|
if claims:
|
|
270
279
|
ownership_filter = self._get_ownership_filter(claims)
|
|
271
|
-
print("ownership_filter", ownership_filter)
|
|
272
280
|
if ownership_filter:
|
|
273
281
|
_query.update(ownership_filter)
|
|
274
282
|
|
|
283
|
+
# Append pipeline stages for related objects
|
|
284
|
+
if include:
|
|
285
|
+
for relation in include:
|
|
286
|
+
if relation not in self.relationships:
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
rel_info = self.relationships[relation]
|
|
290
|
+
rel_controller_name = rel_info["controller"]
|
|
291
|
+
rel_type = rel_info["type"]
|
|
292
|
+
foreign_key = rel_info.get("foreign_key")
|
|
293
|
+
|
|
294
|
+
# Get the controller class from the registry
|
|
295
|
+
rel_controller_class = self.get_controller(rel_controller_name)
|
|
296
|
+
|
|
297
|
+
if rel_type == "one_to_many":
|
|
298
|
+
_pipeline.append(
|
|
299
|
+
{
|
|
300
|
+
"$lookup": {
|
|
301
|
+
"from": rel_controller_class.model.model_config.get(
|
|
302
|
+
"collection"
|
|
303
|
+
)
|
|
304
|
+
or rel_controller_class.model.__collection__,
|
|
305
|
+
"localField": "uuid",
|
|
306
|
+
"foreignField": foreign_key,
|
|
307
|
+
"as": relation,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
elif rel_type == "many_to_one":
|
|
312
|
+
_pipeline.append(
|
|
313
|
+
{
|
|
314
|
+
"$lookup": {
|
|
315
|
+
"from": rel_controller_class.model.model_config.get(
|
|
316
|
+
"collection"
|
|
317
|
+
)
|
|
318
|
+
or rel_controller_class.model.__collection__,
|
|
319
|
+
"localField": foreign_key,
|
|
320
|
+
"foreignField": "uuid",
|
|
321
|
+
"as": relation,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
_pipeline.append({"$unwind": f"${relation}"})
|
|
326
|
+
|
|
275
327
|
# Sorting, default by created_at
|
|
276
328
|
_sort = order_by if order_by else {"created_at": -1}
|
|
277
329
|
|
|
@@ -293,12 +345,15 @@ class ModelController:
|
|
|
293
345
|
if total % self.n_per_page > 0:
|
|
294
346
|
pages += 1
|
|
295
347
|
|
|
348
|
+
# Convert items to models
|
|
349
|
+
models = [self.model.model_validate_doc(item) for item in items]
|
|
350
|
+
|
|
296
351
|
return {
|
|
297
|
-
"items":
|
|
352
|
+
"items": models,
|
|
298
353
|
"total": total,
|
|
299
354
|
"page": page,
|
|
300
355
|
"pages": pages,
|
|
301
|
-
"size": len(
|
|
356
|
+
"size": len(models),
|
|
302
357
|
}
|
|
303
358
|
|
|
304
359
|
async def list_related(
|
|
@@ -6,7 +6,7 @@ with database and user dependencies.
|
|
|
6
6
|
|
|
7
7
|
from typing import Any, Callable, List, Optional, Type
|
|
8
8
|
|
|
9
|
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
|
|
12
12
|
from fastapi_sdk.controllers import ModelController
|
|
@@ -112,13 +112,38 @@ class RouteController:
|
|
|
112
112
|
async def get_route(
|
|
113
113
|
request: Request,
|
|
114
114
|
resource_id: str,
|
|
115
|
+
include: List[str] = Query(default=None),
|
|
115
116
|
db: Any = Depends(self.get_db),
|
|
116
117
|
):
|
|
117
|
-
"""Get a resource by ID (requires authentication).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
"""Get a resource by ID (requires authentication).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
request: The FastAPI request object
|
|
122
|
+
resource_id: The ID of the resource to get
|
|
123
|
+
include: Optional list of related objects to include in the response
|
|
124
|
+
db: The database connection
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
# Get an account with its projects included
|
|
128
|
+
GET /accounts/{account_id}?include=projects
|
|
129
|
+
|
|
130
|
+
# Get a project with its tasks included
|
|
131
|
+
GET /projects/{project_id}?include=tasks
|
|
132
|
+
|
|
133
|
+
# Get an account with multiple relations included
|
|
134
|
+
GET /accounts/{account_id}?include=projects&include=tasks
|
|
135
|
+
"""
|
|
136
|
+
if include:
|
|
137
|
+
instance = await self.controller(db).get_with_relations(
|
|
138
|
+
uuid=resource_id,
|
|
139
|
+
include=include,
|
|
140
|
+
claims=request.state.claims,
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
instance = await self.controller(db).get(
|
|
144
|
+
uuid=resource_id,
|
|
145
|
+
claims=request.state.claims,
|
|
146
|
+
)
|
|
122
147
|
if not instance:
|
|
123
148
|
raise HTTPException(status_code=404, detail="Resource not found")
|
|
124
149
|
return self.schema_response(**instance.model_dump())
|
|
@@ -130,11 +155,30 @@ class RouteController:
|
|
|
130
155
|
@require_permission(f"{self.model_name}:read")
|
|
131
156
|
async def list_route(
|
|
132
157
|
request: Request,
|
|
158
|
+
include: List[str] = Query(default=None),
|
|
133
159
|
db: Any = Depends(self.get_db),
|
|
134
160
|
):
|
|
135
|
-
"""List all resources (requires authentication).
|
|
161
|
+
"""List all resources (requires authentication).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
request: The FastAPI request object
|
|
165
|
+
db: The database connection
|
|
166
|
+
include: Optional list of related objects to include in the response
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
# List accounts with their projects included
|
|
170
|
+
GET /accounts/?include=projects
|
|
171
|
+
|
|
172
|
+
# List projects with their tasks included
|
|
173
|
+
GET /projects/?include=tasks
|
|
174
|
+
|
|
175
|
+
# List accounts with multiple relations included
|
|
176
|
+
GET /accounts/?include=projects&include=tasks
|
|
177
|
+
"""
|
|
178
|
+
|
|
136
179
|
instances = await self.controller(db).list(
|
|
137
180
|
claims=request.state.claims,
|
|
181
|
+
include=include,
|
|
138
182
|
)
|
|
139
183
|
return instances
|
|
140
184
|
|
|
@@ -77,11 +77,8 @@ def decode_access_token(
|
|
|
77
77
|
|
|
78
78
|
# Validate expiration and other standard claims
|
|
79
79
|
claims.validate()
|
|
80
|
-
print("claims", claims)
|
|
81
80
|
|
|
82
81
|
# Check if issuer matches auth_issuer
|
|
83
|
-
print("claims.get('iss')", claims.get("iss"))
|
|
84
|
-
print("auth_issuer", auth_issuer)
|
|
85
82
|
if claims.get("iss") != auth_issuer:
|
|
86
83
|
logger.error("Token issuer does not match auth_issuer")
|
|
87
84
|
raise ValueError("Token issuer does not match auth_issuer")
|
|
@@ -542,3 +542,69 @@ class TestOwnership:
|
|
|
542
542
|
# Delete the record without claims
|
|
543
543
|
deleted = await controller.delete(instance.uuid, claims=None)
|
|
544
544
|
assert deleted.deleted is True
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@pytest.mark.asyncio
|
|
548
|
+
async def test_list_with_relations(db_engine: AgnosticDatabase):
|
|
549
|
+
"""Test listing models with included relations."""
|
|
550
|
+
|
|
551
|
+
# Create test data
|
|
552
|
+
account = await Account(db_engine).create({"name": "Test Account"})
|
|
553
|
+
account_claims = {"account_id": account.uuid}
|
|
554
|
+
project = await Project(db_engine).create(
|
|
555
|
+
{"name": "Test Project", "account_id": account.uuid},
|
|
556
|
+
claims=account_claims,
|
|
557
|
+
)
|
|
558
|
+
task = await Task(db_engine).create(
|
|
559
|
+
{
|
|
560
|
+
"name": "Test Task",
|
|
561
|
+
"description": "Test Task Description",
|
|
562
|
+
"account_id": account.uuid,
|
|
563
|
+
"project_id": project.uuid,
|
|
564
|
+
},
|
|
565
|
+
claims=account_claims,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Test listing accounts with projects included
|
|
569
|
+
result = await Account(db_engine).list(include=["projects"], claims=account_claims)
|
|
570
|
+
assert result["total"] >= 1
|
|
571
|
+
account_with_projects = next(
|
|
572
|
+
(a for a in result["items"] if a.uuid == account.uuid), None
|
|
573
|
+
)
|
|
574
|
+
assert account_with_projects is not None
|
|
575
|
+
assert hasattr(account_with_projects, "projects")
|
|
576
|
+
assert len(account_with_projects.projects) >= 1
|
|
577
|
+
assert any(p.uuid == project.uuid for p in account_with_projects.projects)
|
|
578
|
+
|
|
579
|
+
# Test listing projects with tasks included
|
|
580
|
+
result = await Project(db_engine).list(include=["tasks"], claims=account_claims)
|
|
581
|
+
assert result["total"] >= 1
|
|
582
|
+
project_with_tasks = next(
|
|
583
|
+
(p for p in result["items"] if p.uuid == project.uuid), None
|
|
584
|
+
)
|
|
585
|
+
assert project_with_tasks is not None
|
|
586
|
+
assert hasattr(project_with_tasks, "tasks")
|
|
587
|
+
assert len(project_with_tasks.tasks) >= 1
|
|
588
|
+
assert any(t.uuid == task.uuid for t in project_with_tasks.tasks)
|
|
589
|
+
|
|
590
|
+
# Test listing with multiple relations
|
|
591
|
+
result = await Project(db_engine).list(
|
|
592
|
+
include=["tasks", "account"], claims=account_claims
|
|
593
|
+
)
|
|
594
|
+
assert result["total"] >= 1
|
|
595
|
+
project_with_relations = next(
|
|
596
|
+
(p for p in result["items"] if p.uuid == project.uuid), None
|
|
597
|
+
)
|
|
598
|
+
assert project_with_relations is not None
|
|
599
|
+
assert hasattr(project_with_relations, "account")
|
|
600
|
+
assert hasattr(project_with_relations, "tasks")
|
|
601
|
+
assert len(project_with_relations.tasks) >= 1
|
|
602
|
+
assert any(t.uuid == task.uuid for t in project_with_relations.tasks)
|
|
603
|
+
|
|
604
|
+
# Test listing with non-existent relation
|
|
605
|
+
result = await Account(db_engine).list(
|
|
606
|
+
include=["non_existent"], claims=account_claims
|
|
607
|
+
)
|
|
608
|
+
assert result["total"] >= 1
|
|
609
|
+
account = next((a for a in result["items"] if a.uuid == account.uuid), None)
|
|
610
|
+
assert account is not None
|
|
@@ -162,6 +162,44 @@ class TestTaskRoutes:
|
|
|
162
162
|
assert data["uuid"] == task.uuid
|
|
163
163
|
assert data["description"] == task.description
|
|
164
164
|
|
|
165
|
+
async def test_get_with_relations(
|
|
166
|
+
self, client, auth_headers, account, project, task
|
|
167
|
+
):
|
|
168
|
+
"""Test getting a resource with included relations."""
|
|
169
|
+
# Test getting an account with projects included
|
|
170
|
+
response = client.get(
|
|
171
|
+
f"/accounts/{account.uuid}?include=projects", headers=auth_headers
|
|
172
|
+
)
|
|
173
|
+
assert response.status_code == 200
|
|
174
|
+
data = response.json()
|
|
175
|
+
assert data["uuid"] == account.uuid
|
|
176
|
+
assert "projects" in data
|
|
177
|
+
assert len(data["projects"]) >= 1
|
|
178
|
+
assert any(p["uuid"] == project.uuid for p in data["projects"])
|
|
179
|
+
|
|
180
|
+
# Test getting a project with tasks and account included
|
|
181
|
+
response = client.get(
|
|
182
|
+
f"/projects/{project.uuid}?include=tasks&include=account",
|
|
183
|
+
headers=auth_headers,
|
|
184
|
+
)
|
|
185
|
+
assert response.status_code == 200
|
|
186
|
+
data = response.json()
|
|
187
|
+
assert data["uuid"] == project.uuid
|
|
188
|
+
assert "tasks" in data
|
|
189
|
+
assert len(data["tasks"]) >= 1
|
|
190
|
+
assert any(t["uuid"] == task.uuid for t in data["tasks"])
|
|
191
|
+
assert "account" in data
|
|
192
|
+
assert data["account"]["uuid"] == account.uuid
|
|
193
|
+
|
|
194
|
+
# Test with non-existent relation
|
|
195
|
+
response = client.get(
|
|
196
|
+
f"/accounts/{account.uuid}?include=non_existent", headers=auth_headers
|
|
197
|
+
)
|
|
198
|
+
assert response.status_code == 200
|
|
199
|
+
data = response.json()
|
|
200
|
+
assert data["uuid"] == account.uuid
|
|
201
|
+
assert "non_existent" not in data
|
|
202
|
+
|
|
165
203
|
async def test_list_tasks(self, client, auth_headers, task):
|
|
166
204
|
"""Test listing tasks."""
|
|
167
205
|
response = client.get("/tasks/", headers=auth_headers)
|
|
@@ -170,6 +208,51 @@ class TestTaskRoutes:
|
|
|
170
208
|
assert len(data["items"]) >= 1
|
|
171
209
|
assert any(t["uuid"] == task.uuid for t in data["items"])
|
|
172
210
|
|
|
211
|
+
async def test_list_with_relations(
|
|
212
|
+
self, client, auth_headers, account, project, task
|
|
213
|
+
):
|
|
214
|
+
"""Test listing resources with included relations."""
|
|
215
|
+
# Test listing accounts with projects included
|
|
216
|
+
response = client.get("/accounts/?include=projects", headers=auth_headers)
|
|
217
|
+
assert response.status_code == 200
|
|
218
|
+
data = response.json()
|
|
219
|
+
assert len(data["items"]) >= 1
|
|
220
|
+
account_data = next(a for a in data["items"] if a["uuid"] == account.uuid)
|
|
221
|
+
assert "projects" in account_data
|
|
222
|
+
assert len(account_data["projects"]) >= 1
|
|
223
|
+
assert any(p["uuid"] == project.uuid for p in account_data["projects"])
|
|
224
|
+
|
|
225
|
+
# Test listing projects with tasks included
|
|
226
|
+
response = client.get(
|
|
227
|
+
"/projects/?include=tasks&include=account", headers=auth_headers
|
|
228
|
+
)
|
|
229
|
+
assert response.status_code == 200
|
|
230
|
+
data = response.json()
|
|
231
|
+
assert len(data["items"]) >= 1
|
|
232
|
+
project_data = next(p for p in data["items"] if p["uuid"] == project.uuid)
|
|
233
|
+
assert "tasks" in project_data
|
|
234
|
+
assert len(project_data["tasks"]) >= 1
|
|
235
|
+
assert any(t["uuid"] == task.uuid for t in project_data["tasks"])
|
|
236
|
+
assert "account" in project_data
|
|
237
|
+
assert project_data["account"]["uuid"] == account.uuid
|
|
238
|
+
|
|
239
|
+
# Test listing tasks with multiple relations included
|
|
240
|
+
response = client.get("/tasks/?include=project", headers=auth_headers)
|
|
241
|
+
assert response.status_code == 200
|
|
242
|
+
data = response.json()
|
|
243
|
+
assert len(data["items"]) >= 1
|
|
244
|
+
task_data = next(t for t in data["items"] if t["uuid"] == task.uuid)
|
|
245
|
+
assert "project" in task_data
|
|
246
|
+
assert task_data["project"]["uuid"] == project.uuid
|
|
247
|
+
|
|
248
|
+
# Test with non-existent relation
|
|
249
|
+
response = client.get("/accounts/?include=non_existent", headers=auth_headers)
|
|
250
|
+
assert response.status_code == 200
|
|
251
|
+
data = response.json()
|
|
252
|
+
assert len(data["items"]) >= 1
|
|
253
|
+
account_data = next(a for a in data["items"] if a["uuid"] == account.uuid)
|
|
254
|
+
assert "non_existent" not in account_data
|
|
255
|
+
|
|
173
256
|
async def test_update_task(self, client, auth_headers, task):
|
|
174
257
|
"""Test updating a task."""
|
|
175
258
|
response = client.put(
|
|
@@ -177,7 +260,6 @@ class TestTaskRoutes:
|
|
|
177
260
|
headers=auth_headers,
|
|
178
261
|
json={"description": "Updated description"},
|
|
179
262
|
)
|
|
180
|
-
print(response.json())
|
|
181
263
|
assert response.status_code == 200
|
|
182
264
|
data = response.json()
|
|
183
265
|
assert data["description"] == "Updated description"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|