fastapi-rtk 0.2.27__py3-none-any.whl → 1.0.13__py3-none-any.whl

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 (98) hide show
  1. fastapi_rtk/__init__.py +39 -35
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +476 -221
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/generic/__init__.py +6 -0
  6. fastapi_rtk/backends/generic/column.py +21 -12
  7. fastapi_rtk/backends/generic/db.py +42 -7
  8. fastapi_rtk/backends/generic/filters.py +21 -16
  9. fastapi_rtk/backends/generic/interface.py +14 -8
  10. fastapi_rtk/backends/generic/model.py +19 -11
  11. fastapi_rtk/backends/sqla/__init__.py +1 -0
  12. fastapi_rtk/backends/sqla/db.py +77 -17
  13. fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
  14. fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
  15. fastapi_rtk/backends/sqla/filters.py +50 -21
  16. fastapi_rtk/backends/sqla/interface.py +96 -34
  17. fastapi_rtk/backends/sqla/model.py +56 -39
  18. fastapi_rtk/bases/__init__.py +20 -0
  19. fastapi_rtk/bases/db.py +94 -7
  20. fastapi_rtk/bases/file_manager.py +47 -3
  21. fastapi_rtk/bases/filter.py +22 -0
  22. fastapi_rtk/bases/interface.py +49 -5
  23. fastapi_rtk/bases/model.py +3 -0
  24. fastapi_rtk/bases/session.py +2 -0
  25. fastapi_rtk/cli/cli.py +62 -9
  26. fastapi_rtk/cli/commands/__init__.py +23 -0
  27. fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
  28. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
  29. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
  30. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
  31. fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
  32. fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
  33. fastapi_rtk/cli/commands/translate.py +299 -0
  34. fastapi_rtk/cli/decorators.py +9 -4
  35. fastapi_rtk/cli/utils.py +46 -0
  36. fastapi_rtk/config.py +41 -1
  37. fastapi_rtk/const.py +29 -1
  38. fastapi_rtk/db.py +76 -40
  39. fastapi_rtk/decorators.py +1 -1
  40. fastapi_rtk/dependencies.py +134 -62
  41. fastapi_rtk/exceptions.py +51 -1
  42. fastapi_rtk/fastapi_react_toolkit.py +186 -171
  43. fastapi_rtk/file_managers/file_manager.py +8 -6
  44. fastapi_rtk/file_managers/s3_file_manager.py +69 -33
  45. fastapi_rtk/globals.py +22 -12
  46. fastapi_rtk/lang/__init__.py +3 -0
  47. fastapi_rtk/lang/babel/__init__.py +4 -0
  48. fastapi_rtk/lang/babel/cli.py +40 -0
  49. fastapi_rtk/lang/babel/config.py +17 -0
  50. fastapi_rtk/lang/babel.cfg +1 -0
  51. fastapi_rtk/lang/lazy_text.py +120 -0
  52. fastapi_rtk/lang/messages.pot +238 -0
  53. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  54. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
  55. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  56. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
  57. fastapi_rtk/manager.py +355 -37
  58. fastapi_rtk/mixins.py +12 -0
  59. fastapi_rtk/routers.py +208 -72
  60. fastapi_rtk/schemas.py +142 -39
  61. fastapi_rtk/security/sqla/apis.py +39 -13
  62. fastapi_rtk/security/sqla/models.py +8 -23
  63. fastapi_rtk/security/sqla/security_manager.py +369 -11
  64. fastapi_rtk/setting.py +446 -88
  65. fastapi_rtk/types.py +94 -27
  66. fastapi_rtk/utils/__init__.py +8 -0
  67. fastapi_rtk/utils/async_task_runner.py +286 -61
  68. fastapi_rtk/utils/csv_json_converter.py +243 -40
  69. fastapi_rtk/utils/hooks.py +34 -0
  70. fastapi_rtk/utils/merge_schema.py +3 -3
  71. fastapi_rtk/utils/multiple_async_contexts.py +21 -0
  72. fastapi_rtk/utils/pydantic.py +46 -1
  73. fastapi_rtk/utils/run_utils.py +31 -1
  74. fastapi_rtk/utils/self_dependencies.py +1 -1
  75. fastapi_rtk/utils/use_default_when_none.py +1 -1
  76. fastapi_rtk/version.py +6 -1
  77. fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
  78. fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
  79. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
  80. fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
  81. fastapi_rtk/backends/gremlinpython/column.py +0 -208
  82. fastapi_rtk/backends/gremlinpython/db.py +0 -228
  83. fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
  84. fastapi_rtk/backends/gremlinpython/filters.py +0 -461
  85. fastapi_rtk/backends/gremlinpython/interface.py +0 -734
  86. fastapi_rtk/backends/gremlinpython/model.py +0 -364
  87. fastapi_rtk/backends/gremlinpython/session.py +0 -23
  88. fastapi_rtk/cli/commands.py +0 -295
  89. fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
  90. fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
  91. fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
  92. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
  93. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
  94. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
  95. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
  96. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
  97. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
  98. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/licenses/LICENSE +0 -0
fastapi_rtk/db.py CHANGED
@@ -1,14 +1,15 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import contextvars
4
+ import typing
4
5
  from typing import (
5
6
  Any,
6
7
  Callable,
7
8
  Dict,
8
- Literal,
9
9
  Optional,
10
10
  )
11
11
 
12
+ import sqlalchemy.orm
12
13
  from fastapi import Depends
13
14
  from fastapi_users.db import SQLAlchemyUserDatabase
14
15
  from fastapi_users.models import ID, OAP, UP
@@ -39,7 +40,8 @@ from sqlalchemy.orm import (
39
40
  )
40
41
 
41
42
  from .backends.sqla.model import Table, metadata, metadatas
42
- from .const import FASTAPI_RTK_TABLES
43
+ from .const import DEFAULT_METADATA_KEY, FASTAPI_RTK_TABLES, logger
44
+ from .exceptions import FastAPIReactToolkitException
43
45
  from .security.sqla.models import OAuthAccount, User
44
46
  from .utils import T, lazy_self, safe_call, smart_run, smartdefaultdict
45
47
 
@@ -90,6 +92,7 @@ class UserDatabase(SQLAlchemyUserDatabase[UP, ID]):
90
92
 
91
93
  statement = (
92
94
  select(self.user_table)
95
+ .options(sqlalchemy.orm.selectinload(self.user_table.oauth_accounts))
93
96
  .join(self.oauth_account_table)
94
97
  .where(self.oauth_account_table.oauth_name == oauth)
95
98
  .where(self.oauth_account_table.account_id == account_id)
@@ -120,6 +123,7 @@ class UserDatabase(SQLAlchemyUserDatabase[UP, ID]):
120
123
  raise NotImplementedError()
121
124
 
122
125
  await safe_call(self.session.refresh(user))
126
+ await user.load("oauth_accounts")
123
127
  oauth_account = self.oauth_account_table(**create_dict)
124
128
  self.session.add(oauth_account)
125
129
  user.oauth_accounts.append(oauth_account)
@@ -281,14 +285,14 @@ class DatabaseSessionManager:
281
285
  """
282
286
  Initializes the tables required for FastAPI RTK to function.
283
287
  """
284
- tables = [
285
- table for key, table in metadata.tables.items() if key in FASTAPI_RTK_TABLES
286
- ]
287
- fastapi_rtk_metadata = MetaData()
288
- for table in tables:
289
- table.to_metadata(fastapi_rtk_metadata)
290
- async with self.connect() as connection:
291
- await self._create_all(connection, fastapi_rtk_metadata)
288
+ await self.create_all(
289
+ None,
290
+ tables=[
291
+ table
292
+ for key, table in metadata.tables.items()
293
+ if key in FASTAPI_RTK_TABLES
294
+ ],
295
+ )
292
296
 
293
297
  async def close(self):
294
298
  """
@@ -332,6 +336,8 @@ class DatabaseSessionManager:
332
336
  Yields:
333
337
  AsyncConnection | Connection: The database connection.
334
338
  """
339
+ if bind == DEFAULT_METADATA_KEY:
340
+ bind = None
335
341
  engine = self._engine_binds.get(bind) if bind else self._engine
336
342
  if not engine:
337
343
  raise Exception("DatabaseSessionManager is not initialized")
@@ -341,14 +347,20 @@ class DatabaseSessionManager:
341
347
  try:
342
348
  yield connection
343
349
  except Exception:
344
- await connection.rollback()
350
+ try:
351
+ await connection.rollback()
352
+ except Exception as e:
353
+ logger.error(f"Failed to rollback connection: {e}")
345
354
  raise
346
355
  else:
347
356
  with engine.begin() as connection:
348
357
  try:
349
358
  yield connection
350
359
  except Exception:
351
- connection.rollback()
360
+ try:
361
+ connection.rollback()
362
+ except Exception as e:
363
+ logger.error(f"Failed to rollback connection: {e}")
352
364
  raise
353
365
 
354
366
  @contextlib.asynccontextmanager
@@ -367,6 +379,8 @@ class DatabaseSessionManager:
367
379
  Yields:
368
380
  AsyncSession | Session: The database session.
369
381
  """
382
+ if bind == DEFAULT_METADATA_KEY:
383
+ bind = None
370
384
  session_maker = (
371
385
  self._sessionmaker_binds.get(bind) if bind else self._sessionmaker
372
386
  )
@@ -379,10 +393,16 @@ class DatabaseSessionManager:
379
393
  try:
380
394
  yield session
381
395
  except Exception:
382
- await safe_call(session.rollback())
396
+ try:
397
+ await safe_call(session.rollback())
398
+ except Exception as e:
399
+ logger.error(f"Failed to rollback session: {e}")
383
400
  raise
384
401
  finally:
385
- await safe_call(session.close())
402
+ try:
403
+ await safe_call(session.close())
404
+ except Exception as e:
405
+ logger.error(f"Failed to close session: {e}")
386
406
  self._session_context.reset(token)
387
407
  self._sessions_context[bind].reset(bind_token)
388
408
 
@@ -423,41 +443,52 @@ class DatabaseSessionManager:
423
443
  await safe_call(scoped_session_maker.remove())
424
444
 
425
445
  # Used for testing
426
- async def create_all(self, binds: list[str] | Literal["all"] | None = "all"):
446
+ async def create_all(
447
+ self,
448
+ binds: typing.Literal["all", "default"] | str | list[str] | None = "all",
449
+ **kwargs,
450
+ ):
427
451
  """
428
452
  Creates all tables in the database.
429
453
 
430
454
  Args:
431
- binds (list[str] | Literal["all"] | None, optional): The database URLs to create tables in. Defaults to "all".
455
+ binds (typing.Literal["all", "default"] | str | list[str] | None, optional): The bind keys to create tables for. If `"default"` or `None`, only the tables for the primary database are created. If `"all"`, tables for all databases are created. If a string or list of strings, only the tables for the specified bind keys are created. Defaults to `"all"`.
456
+ **kwargs: Additional keyword arguments to pass to the `create_all` method of the metadata.
432
457
  """
433
- async with self.connect() as connection:
434
- await self._create_all(connection, metadata)
435
-
436
- if not self._engine_binds or not binds:
437
- return
458
+ metadata_to_create = list[tuple[str, MetaData]]()
459
+ if binds == "all" or binds == DEFAULT_METADATA_KEY or binds is None:
460
+ metadata_to_create.append((None, metadata))
461
+ if binds == "all":
462
+ metadata_to_create.extend(
463
+ (key, metadatas[key]) for key in self._engine_binds.keys()
464
+ )
465
+ elif binds is not None:
466
+ binds = [binds] if isinstance(binds, str) else binds
467
+ for bind in binds:
468
+ if bind == DEFAULT_METADATA_KEY:
469
+ metadata_to_create.append((None, metadata))
470
+ elif bind in self._engine_binds:
471
+ metadata_to_create.append((bind, metadatas[bind]))
472
+ else:
473
+ raise FastAPIReactToolkitException(f"Bind '{bind}' not found")
438
474
 
439
- bind_keys = self._engine_binds.keys() if binds == "all" else binds
440
- for key in bind_keys:
441
- async with self.connect(key) as connection:
442
- await self._create_all(connection, metadatas[key])
475
+ for bind, current_metadata in metadata_to_create:
476
+ async with self.connect(bind) as connection:
477
+ await self._create_all(connection, current_metadata, **kwargs)
443
478
 
444
- async def drop_all(self, binds: list[str] | Literal["all"] | None = "all"):
479
+ async def drop_all(
480
+ self,
481
+ binds: typing.Literal["all", "default"] | str | list[str] | None = "all",
482
+ **kwargs,
483
+ ):
445
484
  """
446
485
  Drops all tables in the database.
447
486
 
448
487
  Args:
449
- binds (list[str] | Literal["all"] | None, optional): The database URLs to drop tables in. Defaults to "all".
488
+ binds (typing.Literal["all", "default"] | str | list[str] | None, optional): The bind keys to drop tables from. If `"default"` or `None`, only the tables for the primary database are dropped. If `"all"`, tables for all databases are dropped. If a string or list of strings, only the tables for the specified bind keys are dropped. Defaults to `"all"`.
489
+ **kwargs: Additional keyword arguments to pass to the `drop_all` method of the metadata.
450
490
  """
451
- async with self.connect() as connection:
452
- await self._create_all(connection, metadata, drop=True)
453
-
454
- if not self._engine_binds or not binds:
455
- return
456
-
457
- bind_keys = self._engine_binds.keys() if binds == "all" else binds
458
- for key in bind_keys:
459
- async with self.connect(key) as connection:
460
- await self._create_all(connection, metadatas[key], drop=True)
491
+ return await self.create_all(binds, drop=True, **kwargs)
461
492
 
462
493
  async def autoload_table(self, func: Callable[[Connection], SA_Table]):
463
494
  """
@@ -542,7 +573,11 @@ class DatabaseSessionManager:
542
573
  return scoped_session(sessionmaker)
543
574
 
544
575
  async def _create_all(
545
- self, connection: Connection | AsyncConnection, metadata: MetaData, drop=False
576
+ self,
577
+ connection: Connection | AsyncConnection,
578
+ metadata: MetaData,
579
+ drop=False,
580
+ **kwargs,
546
581
  ):
547
582
  """
548
583
  Creates all tables in the database based on the metadata.
@@ -551,14 +586,15 @@ class DatabaseSessionManager:
551
586
  connection (Connection | AsyncConnection): The database connection.
552
587
  metadata (MetaData): The metadata object containing the tables to create.
553
588
  drop (bool, optional): Whether to drop the tables instead of creating them. Defaults to False.
589
+ **kwargs: Additional keyword arguments to pass to the `create_all` or `drop_all` method of the metadata.
554
590
 
555
591
  Returns:
556
592
  None
557
593
  """
558
594
  func = metadata.drop_all if drop else metadata.create_all
559
595
  if isinstance(connection, AsyncConnection):
560
- return await connection.run_sync(func)
561
- return func(connection)
596
+ return await connection.run_sync(func, **kwargs)
597
+ return func(connection, **kwargs)
562
598
 
563
599
 
564
600
  db = DatabaseSessionManager()
fastapi_rtk/decorators.py CHANGED
@@ -405,7 +405,7 @@ def docs(description="", data: dict[str, str] | None = None):
405
405
  column2 = Column(String(50))
406
406
  ```
407
407
  """
408
- from .cli.export import table_documentation
408
+ from .cli.commands.export import table_documentation
409
409
 
410
410
  data = data or {}
411
411
 
@@ -1,14 +1,16 @@
1
1
  import fastapi
2
+ import sqlalchemy
2
3
  from fastapi import Depends, HTTPException
3
4
 
4
5
  from .api.base_api import BaseApi
5
- from .const import PERMISSION_PREFIX
6
+ from .const import PERMISSION_PREFIX, ErrorCode, logger
7
+ from .db import db
6
8
  from .globals import g
7
- from .security.sqla.models import PermissionApi, User
9
+ from .security.sqla.models import Api, Permission, PermissionApi, Role, User
10
+ from .utils import smart_run
8
11
 
9
12
  __all__ = [
10
13
  "set_global_user",
11
- "permissions",
12
14
  "current_permissions",
13
15
  "has_access_dependency",
14
16
  ]
@@ -54,57 +56,19 @@ def check_g_user():
54
56
 
55
57
  async def check_g_user_dependency():
56
58
  if not g.user:
57
- raise HTTPException(status_code=403, detail="Forbidden")
59
+ raise HTTPException(
60
+ fastapi.status.HTTP_401_UNAUTHORIZED,
61
+ ErrorCode.GET_USER_MISSING_TOKEN_OR_INACTIVE_USER,
62
+ )
58
63
 
59
64
  return check_g_user_dependency
60
65
 
61
66
 
62
- def permissions(as_object=False):
63
- """
64
- A dependency for FastAPI that will return all permissions of the current user.
65
-
66
- This will implicitly call the `current_user` dependency from `fastapi_users`. Therefore, it can return `403 Forbidden` if the user is not authenticated.
67
-
68
- Args:
69
- as_object (bool): Whether to return the `PermissionApi` objects or return the api names (E.g "AuthApi" or "AuthApi|UserApi").
70
-
71
- Usage:
72
- ```python
73
- async def get_info(
74
- *,
75
- permissions: List[str] = Depends(permissions()),
76
- session: AsyncSession | Session = Depends(get_async_session),
77
- ):
78
- ...more code
79
- ```
80
- """
81
-
82
- async def permissions_depedency():
83
- if not g.user:
84
- raise HTTPException(status_code=401, detail="Unauthorized")
85
-
86
- if not g.user.roles:
87
- raise HTTPException(status_code=403, detail="Forbidden")
88
-
89
- permissions = []
90
- for role in g.user.roles:
91
- for permission_api in role.permissions:
92
- if as_object:
93
- permissions.append(permission_api)
94
- else:
95
- permissions.append(permission_api.api.name)
96
- permissions = list(set(permissions))
97
-
98
- return permissions
99
-
100
- return permissions_depedency
101
-
102
-
103
67
  def current_permissions(api: BaseApi):
104
68
  """
105
69
  A dependency for FastAPI that will return all permissions of the current user for the specified API.
106
70
 
107
- Because it will implicitly call the `permissions` dependency, it can return `403 Forbidden` if the user is not authenticated.
71
+ Because it will implicitly check whether the user is authenticated, it can return `401 Unauthorized` or `403 Forbidden`.
108
72
 
109
73
  Args:
110
74
  api (BaseApi): The API to be checked.
@@ -120,16 +84,45 @@ def current_permissions(api: BaseApi):
120
84
  ```
121
85
  """
122
86
 
123
- async def current_permissions_depedency(
124
- permissions_apis: list[PermissionApi] = Depends(permissions(as_object=True)),
125
- ):
126
- permissions = []
127
- for permission_api in permissions_apis:
128
- if api.__class__.__name__ in permission_api.api.name.split("|"):
129
- permissions = permissions + permission_api.permission.name.split("|")
87
+ async def current_permissions_depedency(_=Depends(_ensure_roles)):
88
+ sm = g.current_app.sm
89
+ api_name = api.__class__.__name__
90
+ permissions = set[str]()
91
+ db_role_ids = list[int]()
92
+
93
+ # Retrieve permissions from built-in roles
94
+ for role in g.user.roles:
95
+ if role.name not in sm.builtin_roles:
96
+ db_role_ids.append(role.id)
97
+ continue
98
+
99
+ api_permission_tuples = sm.get_api_permission_tuples_from_builtin_roles(
100
+ role.name
101
+ )
102
+ for multi_apis_str, multi_perms_str in api_permission_tuples:
103
+ api_names = multi_apis_str.split("|")
104
+ perm_names = multi_perms_str.split("|")
105
+ if api_name in api_names:
106
+ permissions.update(perm_names)
107
+
108
+ if db_role_ids:
109
+ query = (
110
+ sqlalchemy.select(Permission)
111
+ .join(PermissionApi)
112
+ .join(PermissionApi.roles)
113
+ .join(Api)
114
+ .where(Api.name == api_name, Role.id.in_(db_role_ids))
115
+ )
116
+ if api.base_permissions:
117
+ query = query.where(Permission.name.in_(api.base_permissions))
118
+
119
+ permissions_in_db = await smart_run(db.current_session.scalars, query)
120
+ permissions.update(perm.name for perm in permissions_in_db.all())
121
+
130
122
  if api.base_permissions:
131
- permissions = [x for x in permissions if x in api.base_permissions]
132
- return list(set(permissions))
123
+ permissions = permissions.intersection(set(api.base_permissions))
124
+
125
+ return list(permissions)
133
126
 
134
127
  return current_permissions_depedency
135
128
 
@@ -141,7 +134,7 @@ def has_access_dependency(
141
134
  """
142
135
  A dependency for FastAPI to check whether current user has access to the specified API and permission.
143
136
 
144
- Because it will implicitly call the `current_permissions` dependency, it can return `403 Forbidden` if the user is not authenticated.
137
+ Because it will implicitly check whether the user is authenticated, it can return `401 Unauthorized` or `403 Forbidden`.
145
138
 
146
139
  Usage:
147
140
  ```python
@@ -157,13 +150,55 @@ def has_access_dependency(
157
150
  api (BaseApi): The API to be checked.
158
151
  permission (str): The permission to check.
159
152
  """
153
+ permission = f"{PERMISSION_PREFIX}{permission}"
154
+
155
+ async def check_permission():
156
+ _ensure_roles(ErrorCode.PERMISSION_DENIED)
157
+ sm = g.current_app.sm
158
+
159
+ # First, check built-in roles (avoiding unnecessary DB queries)
160
+ # This also covers the case for API and permission name with pipes
161
+ if any(
162
+ sm.has_access_in_builtin_roles(
163
+ role.name, api.__class__.__name__, permission
164
+ )
165
+ for role in g.user.roles
166
+ ):
167
+ logger.debug(
168
+ f"User {g.user} has access to {api.__class__.__name__} with permission {permission} via built-in roles."
169
+ )
170
+ return
171
+
172
+ db_role_ids = [
173
+ role.id for role in g.user.roles if role.name not in sm.builtin_roles
174
+ ]
175
+ if not db_role_ids:
176
+ raise HTTPException(
177
+ fastapi.status.HTTP_403_FORBIDDEN, ErrorCode.PERMISSION_DENIED
178
+ )
179
+
180
+ api_name = api.__class__.__name__
181
+ stmt = (
182
+ sqlalchemy.select(sqlalchemy.literal(True))
183
+ .select_from(Permission)
184
+ .join(PermissionApi)
185
+ .join(PermissionApi.roles)
186
+ .join(Api)
187
+ .where(
188
+ Api.name == api_name,
189
+ Permission.name == permission,
190
+ Role.id.in_(db_role_ids),
191
+ )
192
+ .limit(1)
193
+ )
194
+ result = await smart_run(db.current_session.scalar, stmt)
195
+ result = bool(result)
196
+ if result:
197
+ return
160
198
 
161
- async def check_permission(
162
- permissions: list[str] = Depends(current_permissions(api)),
163
- ):
164
- if f"{PERMISSION_PREFIX}{permission}" not in permissions:
165
- raise HTTPException(status_code=403, detail="Forbidden")
166
- return
199
+ raise HTTPException(
200
+ fastapi.status.HTTP_403_FORBIDDEN, ErrorCode.PERMISSION_DENIED
201
+ )
167
202
 
168
203
  return check_permission
169
204
 
@@ -182,3 +217,40 @@ async def set_global_background_tasks(background_tasks: fastapi.BackgroundTasks)
182
217
  ...more code
183
218
  """
184
219
  g.background_tasks = background_tasks
220
+
221
+
222
+ async def set_global_request(request: fastapi.Request):
223
+ """
224
+ A dependency for FastAPI that will set the `request` to the global variable `g.request`.
225
+
226
+ Usage:
227
+ ```python
228
+ async def get_info(
229
+ *,
230
+ session: AsyncSession | Session = Depends(get_async_session),
231
+ none: None = Depends(set_global_request),
232
+ ):
233
+ ...more code
234
+ """
235
+ g.request = request
236
+
237
+
238
+ def _ensure_roles(err_forbidden_message=ErrorCode.GET_USER_NO_ROLES):
239
+ """
240
+ A dependency for FastAPI that will ensure the current user has roles assigned.
241
+
242
+ Args:
243
+ err_forbidden_message (str): The error message to be used when raising `403 Forbidden`. Defaults to `ErrorCode.GET_USER_NO_ROLES`.
244
+
245
+ Raises:
246
+ HTTPException: Raised when the user is not authenticated.
247
+ HTTPException: Raised when the user has no roles assigned.
248
+ """
249
+ if not g.user:
250
+ raise HTTPException(
251
+ fastapi.status.HTTP_401_UNAUTHORIZED,
252
+ ErrorCode.GET_USER_MISSING_TOKEN_OR_INACTIVE_USER,
253
+ )
254
+
255
+ if not g.user.roles:
256
+ raise HTTPException(fastapi.status.HTTP_403_FORBIDDEN, err_forbidden_message)
fastapi_rtk/exceptions.py CHANGED
@@ -1,8 +1,16 @@
1
1
  import typing
2
2
 
3
+ import fastapi
4
+ import pydantic
5
+
3
6
  from .utils import T
4
7
 
5
- __all__ = ["raise_exception", "FastAPIReactToolkitException", "RolesMismatchException"]
8
+ __all__ = [
9
+ "raise_exception",
10
+ "FastAPIReactToolkitException",
11
+ "RolesMismatchException",
12
+ "HTTPWithValidationException",
13
+ ]
6
14
 
7
15
 
8
16
  def raise_exception(message: str, return_type: typing.Type[T] | None = None) -> T:
@@ -28,3 +36,45 @@ class FastAPIReactToolkitException(Exception):
28
36
 
29
37
  class RolesMismatchException(FastAPIReactToolkitException):
30
38
  """Exception raised when the roles do not match the expected roles."""
39
+
40
+
41
+ class HTTPWithValidationException(fastapi.HTTPException):
42
+ """
43
+ Custom HTTP Exception for FastAPI with validation details.
44
+ """
45
+
46
+ URL = f"https://errors.pydantic.dev/{pydantic.version.version_short()}/v"
47
+ _no_input = object()
48
+
49
+ def __init__(
50
+ self,
51
+ status_code: int,
52
+ type: str,
53
+ location_type: typing.Literal["path", "query", "body"],
54
+ field: str,
55
+ msg: str,
56
+ input: typing.Any = _no_input,
57
+ headers: typing.Dict[str, str] | None = None,
58
+ ):
59
+ """
60
+ Initialize the HTTPWithValidationException.
61
+
62
+ Args:
63
+ status_code (int): HTTP status code for the exception.
64
+ type (str): Type of the error (e.g., "string_type", "validation_error").
65
+ location_type (typing.Literal["path", "query", "body"]): Location type of the error, either "path", "query", or "body".
66
+ field (str): Field name where the error occurred.
67
+ msg (str): Error message describing the validation issue.
68
+ input (typing.Any, optional): Optional input value that caused the error. Defaults to _no_input.
69
+ headers (typing.Dict[str, str] | None, optional): Optional headers to include in the response. Defaults to None.
70
+ """
71
+ obj = {"type": type, "loc": [location_type, field], "msg": msg}
72
+ if input is not self._no_input:
73
+ if isinstance(input, pydantic.BaseModel):
74
+ input = input.model_dump(mode="json")
75
+ else:
76
+ input = str(input)
77
+ obj["input"] = input
78
+ obj["url"] = f"{self.URL}/{type}"
79
+
80
+ super().__init__(status_code, [obj], headers)