fastapi-rtk 0.0.1__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.
@@ -0,0 +1,533 @@
1
+ import importlib.util
2
+ import io
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.templating import Jinja2Templates
7
+ from jinja2 import Environment, TemplateNotFound, select_autoescape
8
+ from sqlalchemy import and_, create_engine, insert
9
+ from sqlalchemy.orm import Session, sessionmaker
10
+
11
+ # Import all submodules
12
+ from .api import *
13
+ from .apis import *
14
+ from .auth import *
15
+ from .const import *
16
+ from .db import *
17
+ from .decorators import *
18
+ from .dependencies import *
19
+ from .filters import *
20
+ from .generic import *
21
+ from .globals import *
22
+ from .hasher import *
23
+ from .manager import *
24
+ from .model import *
25
+ from .models import *
26
+ from .routers import *
27
+ from .schemas import *
28
+ from .types import *
29
+ from .utils import *
30
+
31
+
32
+ class FastapiReactToolkit:
33
+ """
34
+ The main class for the FastapiReactToolkit library.
35
+
36
+ This class provides a set of methods to initialize a FastAPI application, add APIs, manage permissions and roles,
37
+ and initialize the database with permissions, APIs, roles, and their relationships.
38
+
39
+ In case you need to create a synchronous session, set the `with_session` parameter to True. this will allow you to use `db` attribute to interact with the database.
40
+
41
+ Usage:
42
+ ```python
43
+ toolkit = FastapiReactToolkit(
44
+ config_file="./app/config.py",
45
+ )
46
+
47
+ @asynccontextmanager
48
+ async def lifespan(app: FastAPI):
49
+ # Run when the app is starting up
50
+ toolkit.connect_to_database()
51
+
52
+ # Not needed if you setup a migration system like Alembic
53
+ async with sessionmanager.connect() as conn:
54
+ await sessionmanager.create_all(conn)
55
+
56
+ # Creating permission, apis, roles, and connecting them
57
+ await toolkit.init_database()
58
+
59
+ async with sessionmanager.session() as session:
60
+ # Add base data
61
+ await add_base_data(session)
62
+
63
+ yield
64
+
65
+ # Run when the app is shutting down
66
+ if sessionmanager._engine:
67
+ await sessionmanager.close()
68
+
69
+
70
+ app = FastAPI(lifespan=lifespan)
71
+ app.add_middleware(
72
+ CORSMiddleware,
73
+ allow_origins=["*"],
74
+ allow_credentials=True,
75
+ allow_methods=["*"],
76
+ allow_headers=["*"],
77
+ )
78
+ toolkit.initialize(app)
79
+ ```
80
+ """
81
+
82
+ app: FastAPI = None
83
+ apis: list[ModelRestApi] = None
84
+
85
+ db: sessionmaker[Session] = None
86
+ initialized: bool = False
87
+
88
+ _mounted = False
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ app: FastAPI | None = None,
94
+ config_file: str | None = None,
95
+ user_manager: Type[UserManager] | None = None,
96
+ cookie_transport: CookieTransport | None = None,
97
+ bearer_transport: BearerTransport | None = None,
98
+ cookie_backend: AuthenticationBackend | None = None,
99
+ jwt_backend: AuthenticationBackend | None = None,
100
+ authenticator: Authenticator | None = None,
101
+ fast_api_users: FastAPIUsers[User, int] | None = None,
102
+ password_helper: PasswordHelperProtocol | None = None,
103
+ with_session: bool = False,
104
+ ) -> None:
105
+ """
106
+ Initialize the FastAPI extension.
107
+
108
+ Args:
109
+ app (FastAPI | None, optional): The FastAPI application instance. Defaults to None.
110
+ config_file (str | None, optional): The path to the configuration file. Defaults to None.
111
+ user_manager (Type[UserManager] | None, optional): Set this to override default user manager class. Defaults to None.
112
+ cookie_transport (CookieTransport | None, optional): Set this to override default cookie transport instance. Defaults to None.
113
+ bearer_transport (BearerTransport | None, optional): Set this to override default bearer transport instance. Defaults to None.
114
+ cookie_backend (AuthenticationBackend | None, optional): Set this to override default cookie backend instance. Defaults to None.
115
+ jwt_backend (AuthenticationBackend | None, optional): Set this to override default jwt backend instance. Defaults to None.
116
+ authenticator (Authenticator | None, optional): Set this to override default authenticator instance. Defaults to None.
117
+ fast_api_users (FastAPIUsers[User, int] | None, optional): Set this to override default FastAPIUsers instance. Defaults to None.
118
+ password_helper (PasswordHelperProtocol | None, optional): Set this to override default password helper instance. Defaults to None.
119
+ with_session (bool, optional): Flag indicating whether to enable session management. Defaults to False.
120
+
121
+ Raises:
122
+ ValueError: If SQLALCHEMY_DATABASE_URI is not set in the configuration.
123
+ """
124
+ if config_file:
125
+ self.read_config_file(config_file)
126
+
127
+ # Override default classes
128
+ g.auth.user_manager = user_manager or g.auth.user_manager
129
+ g.auth.cookie_transport = cookie_transport or g.auth.cookie_transport
130
+ g.auth.bearer_transport = bearer_transport or g.auth.bearer_transport
131
+ g.auth.cookie_backend = cookie_backend or g.auth.cookie_backend
132
+ g.auth.jwt_backend = jwt_backend or g.auth.jwt_backend
133
+ g.auth.authenticator = authenticator or g.auth.authenticator
134
+ g.auth.fastapi_users = fast_api_users or g.auth.fastapi_users
135
+ g.auth.password_helper = password_helper or g.auth.password_helper
136
+
137
+ if app:
138
+ self.initialize(app)
139
+
140
+ if with_session:
141
+ if not g.config.get("SQLALCHEMY_DATABASE_URI"):
142
+ raise ValueError(
143
+ "SQLALCHEMY_DATABASE_URI is not set in the configuration"
144
+ )
145
+ engine = create_engine(g.config.get("SQLALCHEMY_DATABASE_URI"))
146
+ self.db = sessionmaker(bind=engine)
147
+
148
+ def initialize(self, app: FastAPI) -> None:
149
+ """
150
+ Initializes the FastAPI application.
151
+
152
+ Args:
153
+ app (FastAPI): The FastAPI application instance.
154
+
155
+ Returns:
156
+ None
157
+ """
158
+ if self.initialized:
159
+ return
160
+
161
+ self.initialized = True
162
+ self.app = app
163
+ self.apis = []
164
+
165
+ # Add the APIs
166
+ self._init_info_api()
167
+ self._init_auth_api()
168
+ self._init_users_api()
169
+ self._init_roles_api()
170
+ self._init_permissions_api()
171
+ self._init_apis_api()
172
+ self._init_permission_apis_api()
173
+
174
+ # Add the JS manifest route
175
+ self._init_js_manifest()
176
+
177
+ def add_api(self, api: ModelRestApi) -> None:
178
+ """
179
+ Adds the specified API to the FastAPI application.
180
+
181
+ Parameters:
182
+ - api (ModelRestApi): The API to be added.
183
+
184
+ Returns:
185
+ - None
186
+
187
+ Raises:
188
+ - ValueError: If the API is added after the `mount()` method is called.
189
+ """
190
+ if self._mounted:
191
+ raise ValueError(
192
+ "API Mounted after mount() was called, please add APIs before calling mount()"
193
+ )
194
+
195
+ api = api if isinstance(api, ModelRestApi) else api()
196
+ self.apis.append(api)
197
+ api.integrate_router(self.app)
198
+ api.toolkit = self
199
+
200
+ def total_permissions(self) -> list[str]:
201
+ """
202
+ Returns the total list of permissions required by all APIs.
203
+
204
+ Returns:
205
+ - list[str]: The total list of permissions.
206
+ """
207
+ permissions = []
208
+ for api in self.apis:
209
+ permissions.extend(getattr(api, "permissions", []))
210
+ return list(set(permissions))
211
+
212
+ def read_config_file(self, config_file: str):
213
+ """
214
+ Reads a configuration file and sets the `config` attribute with the variables defined in the file.
215
+
216
+ It will also set the `SECRET_KEY` in the global `g` object if it is defined in the configuration file.
217
+
218
+ Args:
219
+ config_file (str): The path to the configuration file.
220
+
221
+ Returns:
222
+ None
223
+ """
224
+ spec = importlib.util.spec_from_file_location("config", config_file)
225
+ config_module = importlib.util.module_from_spec(spec)
226
+ spec.loader.exec_module(config_module)
227
+
228
+ # Get the dictionary of variables in the module
229
+ g.config = {
230
+ key: value
231
+ for key, value in config_module.__dict__.items()
232
+ if not key.startswith("__")
233
+ }
234
+
235
+ self._post_read_config()
236
+
237
+ def mount(self):
238
+ """
239
+ Mounts the static and template folders specified in the configuration.
240
+
241
+ PLEASE ONLY RUN THIS AFTER ALL APIS HAVE BEEN ADDED.
242
+ """
243
+ if self._mounted:
244
+ return
245
+
246
+ self._mounted = True
247
+ self._mount_static_folder()
248
+ self._mount_template_folder()
249
+
250
+ def connect_to_database(self):
251
+ """
252
+ Connects to the database using the configured SQLAlchemy database URI.
253
+
254
+ This method initializes the database session maker with the SQLAlchemy
255
+ database URI specified in the configuration. If no URI is found in the
256
+ configuration, the default URI is used.
257
+
258
+ Returns:
259
+ None
260
+ """
261
+ uri = g.config.get("SQLALCHEMY_DATABASE_URI_ASYNC")
262
+ if not uri:
263
+ raise ValueError(
264
+ "SQLALCHEMY_DATABASE_URI_ASYNC is not set in the configuration"
265
+ )
266
+
267
+ binds = g.config.get("SQLALCHEMY_BINDS")
268
+ session_manager.init_db(uri, binds)
269
+
270
+ async def init_database(self):
271
+ """
272
+ Initializes the database by inserting permissions, APIs, roles, and their relationships.
273
+
274
+ The initialization process is as follows:
275
+ 1. Inserts permissions into the database.
276
+ 2. Inserts APIs into the database.
277
+ 3. Inserts roles into the database.
278
+ 4. Inserts the relationship between permissions and APIs into the database.
279
+ 5. Inserts the relationship between permissions, APIs, and roles into the database.
280
+
281
+ Returns:
282
+ None
283
+ """
284
+ async with session_manager.session() as db:
285
+ logger.info("INITIALIZING DATABASE")
286
+ await self._insert_permissions(db)
287
+ await self._insert_apis(db)
288
+ await self._insert_roles(db)
289
+ await self._associate_permission_with_api(db)
290
+ await self._associate_permission_api_with_role(db)
291
+
292
+ async def _insert_permissions(self, db: AsyncSession):
293
+ new_permissions = self.total_permissions()
294
+ stmt = select(Permission).where(Permission.name.in_(new_permissions))
295
+ result = await db.execute(stmt)
296
+ existing_permissions = [
297
+ permission.name for permission in result.scalars().all()
298
+ ]
299
+ if len(new_permissions) == len(existing_permissions):
300
+ return
301
+
302
+ permission_objs = [
303
+ Permission(name=permission)
304
+ for permission in new_permissions
305
+ if permission not in existing_permissions
306
+ ]
307
+ for permission in permission_objs:
308
+ logger.info(f"ADDING PERMISSION {permission}")
309
+ db.add(permission)
310
+ await db.commit()
311
+
312
+ async def _insert_apis(self, db: AsyncSession):
313
+ new_apis = [api.__class__.__name__ for api in self.apis]
314
+ stmt = select(Api).where(Api.name.in_(new_apis))
315
+ result = await db.execute(stmt)
316
+ existing_apis = [api.name for api in result.scalars().all()]
317
+ if len(new_apis) == len(existing_apis):
318
+ return
319
+
320
+ api_objs = [Api(name=api) for api in new_apis if api not in existing_apis]
321
+ for api in api_objs:
322
+ logger.info(f"ADDING API {api}")
323
+ db.add(api)
324
+ await db.commit()
325
+
326
+ async def _insert_roles(self, db: AsyncSession):
327
+ new_roles = DEFAULT_ROLES
328
+ stmt = select(Role).where(Role.name.in_(new_roles))
329
+ result = await db.execute(stmt)
330
+ existing_roles = [role.name for role in result.scalars().all()]
331
+ if len(new_roles) == len(existing_roles):
332
+ return
333
+
334
+ role_objs = [
335
+ Role(name=role) for role in new_roles if role not in existing_roles
336
+ ]
337
+ for role in role_objs:
338
+ logger.info(f"ADDING ROLE {role}")
339
+ db.add(role)
340
+ await db.commit()
341
+
342
+ async def _associate_permission_with_api(self, db: AsyncSession):
343
+ for api in self.apis:
344
+ new_permissions = getattr(api, "permissions", [])
345
+ if not new_permissions:
346
+ continue
347
+
348
+ # Get the api object
349
+ stmt = select(Api).where(Api.name == api.__class__.__name__)
350
+ result = await db.execute(stmt)
351
+ api_obj = result.scalars().first()
352
+
353
+ if not api_obj:
354
+ raise ValueError(f"API {api.__class__.__name__} not found")
355
+
356
+ stmt = select(Permission).where(
357
+ and_(
358
+ Permission.name.in_(new_permissions),
359
+ ~Permission.id.in_([p.permission_id for p in api_obj.permissions]),
360
+ )
361
+ )
362
+ result = await db.execute(stmt)
363
+ new_permissions = result.scalars().all()
364
+
365
+ if not new_permissions:
366
+ continue
367
+
368
+ for permission in new_permissions:
369
+ stmt = insert(PermissionApi).values(
370
+ permission_id=permission.id, api_id=api_obj.id
371
+ )
372
+ await db.execute(stmt)
373
+ logger.info(f"ASSOCIATING PERMISSION {permission} WITH API {api_obj}")
374
+ await db.commit()
375
+
376
+ async def _associate_permission_api_with_role(self, db: AsyncSession):
377
+ # Get admin role
378
+ stmt = select(Role).where(Role.name == ADMIN_ROLE)
379
+ result = await db.execute(stmt)
380
+ admin_role = result.scalars().first()
381
+
382
+ if admin_role:
383
+ # Get list of permission-api.assoc_permission_api_id of the admin role
384
+ stmt = select(PermissionApi).where(
385
+ ~PermissionApi.roles.contains(admin_role)
386
+ )
387
+ result = await db.execute(stmt)
388
+ existing_assoc_permission_api_roles = result.scalars().all()
389
+
390
+ # Add admin role to all permission-api objects
391
+ for permission_api in existing_assoc_permission_api_roles:
392
+ permission_api.roles.append(admin_role)
393
+ logger.info(
394
+ f"ASSOCIATING {admin_role} WITH PERMISSION-API {permission_api}"
395
+ )
396
+ await db.commit()
397
+
398
+ # Read config based roles
399
+ roles_dict = g.config.get("ROLES") or g.config.get("FAB_ROLES", {})
400
+
401
+ for role_name, role_permissions in roles_dict.items():
402
+ stmt = select(Role).where(Role.name == role_name)
403
+ result = await db.execute(stmt)
404
+ role = result.scalars().first()
405
+
406
+ if not role:
407
+ role = Role(name=role_name)
408
+ db.add(role)
409
+ logger.info(f"ADDING ROLE {role}")
410
+
411
+ for apis, permissions in role_permissions:
412
+ api_names = apis.split("|")
413
+ permission_names = permissions.split("|")
414
+
415
+ stmt = (
416
+ select(PermissionApi)
417
+ .where(
418
+ and_(
419
+ Api.name.in_(api_names),
420
+ Permission.name.in_(permission_names),
421
+ )
422
+ )
423
+ .join(Permission)
424
+ .join(Api)
425
+ .options(selectinload(PermissionApi.roles))
426
+ )
427
+ result = await db.execute(stmt)
428
+ permission_apis = result.scalars().all()
429
+
430
+ for permission_api in permission_apis:
431
+ if role not in permission_api.roles:
432
+ permission_api.roles.append(role)
433
+ logger.info(
434
+ f"ASSOCIATING {role} WITH PERMISSION-API {permission_api}"
435
+ )
436
+
437
+ await db.commit()
438
+
439
+ def _post_read_config(self):
440
+ """
441
+ Function to be called after setting the configuration.
442
+
443
+ - Sets the secret key in the global `g` object if it exists in the configuration.
444
+
445
+ Returns:
446
+ None
447
+ """
448
+ secret_key = g.config.get("SECRET_KEY")
449
+ if secret_key:
450
+ g.auth.secret_key = secret_key
451
+
452
+ def _mount_static_folder(self):
453
+ """
454
+ Mounts the static folder specified in the configuration.
455
+
456
+ Returns:
457
+ None
458
+ """
459
+ # If the folder does not exist, create it
460
+ os.makedirs(g.config.get("STATIC_FOLDER", DEFAULT_STATIC_FOLDER), exist_ok=True)
461
+
462
+ static_folder = g.config.get("STATIC_FOLDER", DEFAULT_STATIC_FOLDER)
463
+ self.app.mount("/static", StaticFiles(directory=static_folder), name="static")
464
+
465
+ def _mount_template_folder(self):
466
+ """
467
+ Mounts the template folder specified in the configuration.
468
+
469
+ Returns:
470
+ None
471
+ """
472
+ # If the folder does not exist, create it
473
+ os.makedirs(
474
+ g.config.get("TEMPLATE_FOLDER", DEFAULT_TEMPLATE_FOLDER), exist_ok=True
475
+ )
476
+
477
+ templates = Jinja2Templates(
478
+ directory=g.config.get("TEMPLATE_FOLDER", DEFAULT_TEMPLATE_FOLDER)
479
+ )
480
+
481
+ @self.app.get("/{full_path:path}", response_class=HTMLResponse)
482
+ def index(request: Request):
483
+ try:
484
+ return templates.TemplateResponse(
485
+ request=request,
486
+ name="index.html",
487
+ context={"base_path": g.config.get("BASE_PATH", "/")},
488
+ )
489
+ except TemplateNotFound:
490
+ raise HTTPException(status_code=404, detail="Not Found")
491
+
492
+ """
493
+ -----------------------------------------
494
+ INIT FUNCTIONS
495
+ -----------------------------------------
496
+ """
497
+
498
+ def _init_info_api(self):
499
+ self.add_api(InfoApi)
500
+
501
+ def _init_auth_api(self):
502
+ self.add_api(AuthApi)
503
+
504
+ def _init_users_api(self):
505
+ self.add_api(UsersApi)
506
+
507
+ def _init_roles_api(self):
508
+ self.add_api(RolesApi)
509
+
510
+ def _init_permissions_api(self):
511
+ self.add_api(PermissionsApi)
512
+
513
+ def _init_apis_api(self):
514
+ self.add_api(ViewsMenusApi)
515
+
516
+ def _init_permission_apis_api(self):
517
+ self.add_api(PermissionViewApi)
518
+
519
+ def _init_js_manifest(self):
520
+ @self.app.get("/server-config.js", response_class=StreamingResponse)
521
+ def js_manifest():
522
+ env = Environment(autoescape=select_autoescape(["html", "xml"]))
523
+ template_string = "window.fab_react_config = {{ react_vars |tojson }}"
524
+ template = env.from_string(template_string)
525
+ rendered_string = template.render(
526
+ react_vars=json.dumps(g.config.get("FAB_REACT_CONFIG", {}))
527
+ )
528
+ content = rendered_string.encode("utf-8")
529
+ scriptfile = io.BytesIO(content)
530
+ return StreamingResponse(
531
+ scriptfile,
532
+ media_type="application/javascript",
533
+ )