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.
Files changed (23) hide show
  1. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/PKG-INFO +1 -1
  2. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/model.py +62 -7
  3. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/route.py +51 -7
  4. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/security/oauth.py +0 -3
  5. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/PKG-INFO +1 -1
  6. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/pyproject.toml +1 -1
  7. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_controller_model.py +66 -0
  8. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_controller_route.py +83 -1
  9. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/LICENSE +0 -0
  10. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/README.md +0 -0
  11. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/__init__.py +0 -0
  12. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/controllers/__init__.py +0 -0
  13. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/middleware/auth.py +0 -0
  14. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/security/permissions.py +0 -0
  15. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/model.py +0 -0
  16. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/schema.py +0 -0
  17. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk/utils/test.py +0 -0
  18. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/SOURCES.txt +0 -0
  19. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/dependency_links.txt +0 -0
  20. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/requires.txt +0 -0
  21. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/fastapi_sdk.egg-info/top_level.txt +0 -0
  22. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/setup.cfg +0 -0
  23. {fastapi_sdk-0.4.2 → fastapi_sdk-0.4.4}/tests/test_middleware_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-sdk
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Utilities for FastAPI projects.
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -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 = self.model.model_config[
249
- "collection"
250
- ] or self.model.__name__.lower().replace("model", "")
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": [self.model.model_validate_doc(item) for item in items],
352
+ "items": models,
298
353
  "total": total,
299
354
  "page": page,
300
355
  "pages": pages,
301
- "size": len(items),
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
- instance = await self.controller(db).get(
119
- uuid=resource_id,
120
- claims=request.state.claims,
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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-sdk
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Utilities for FastAPI projects.
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-sdk"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Utilities for FastAPI projects."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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