patera 0.111.13__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 (38) hide show
  1. patera-0.111.13/PKG-INFO +2160 -0
  2. patera-0.111.13/README.md +2094 -0
  3. patera-0.111.13/pyproject.toml +116 -0
  4. patera-0.111.13/src/patera/__init__.py +40 -0
  5. patera-0.111.13/src/patera/base_extension.py +53 -0
  6. patera-0.111.13/src/patera/cli/__init__.py +8 -0
  7. patera-0.111.13/src/patera/cli/cli.py +83 -0
  8. patera-0.111.13/src/patera/cli/cli_controller.py +130 -0
  9. patera-0.111.13/src/patera/cli/start_project.py +332 -0
  10. patera-0.111.13/src/patera/configuration_base.py +202 -0
  11. patera-0.111.13/src/patera/controller/__init__.py +39 -0
  12. patera-0.111.13/src/patera/controller/controller.py +160 -0
  13. patera-0.111.13/src/patera/controller/decorators.py +378 -0
  14. patera-0.111.13/src/patera/controller/utilities.py +111 -0
  15. patera-0.111.13/src/patera/cors/__init__.py +1 -0
  16. patera-0.111.13/src/patera/cors/cors_mw.py +199 -0
  17. patera-0.111.13/src/patera/exceptions/__init__.py +35 -0
  18. patera-0.111.13/src/patera/exceptions/exception_handler.py +71 -0
  19. patera-0.111.13/src/patera/exceptions/http_exceptions.py +102 -0
  20. patera-0.111.13/src/patera/exceptions/runtime_exceptions.py +25 -0
  21. patera-0.111.13/src/patera/graphics/patera_logo.png +0 -0
  22. patera-0.111.13/src/patera/http_methods.py +14 -0
  23. patera-0.111.13/src/patera/http_statuses.py +79 -0
  24. patera-0.111.13/src/patera/logger.py +9 -0
  25. patera-0.111.13/src/patera/logging/__init__.py +31 -0
  26. patera-0.111.13/src/patera/logging/inmemory_buffer.py +63 -0
  27. patera-0.111.13/src/patera/logging/logger_config_base.py +353 -0
  28. patera-0.111.13/src/patera/media_types.py +28 -0
  29. patera-0.111.13/src/patera/middleware.py +76 -0
  30. patera-0.111.13/src/patera/open_api.py +392 -0
  31. patera-0.111.13/src/patera/patera.py +944 -0
  32. patera-0.111.13/src/patera/request.py +500 -0
  33. patera-0.111.13/src/patera/response.py +322 -0
  34. patera-0.111.13/src/patera/router.py +46 -0
  35. patera-0.111.13/src/patera/static.py +48 -0
  36. patera-0.111.13/src/patera/testing/__init__.py +7 -0
  37. patera-0.111.13/src/patera/testing/pyjolt_test_client.py +70 -0
  38. patera-0.111.13/src/patera/utilities.py +188 -0
@@ -0,0 +1,2160 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera
3
+ Version: 0.111.13
4
+ Summary: A batteries included async-first python webframework
5
+ Author: MarkoSterk
6
+ Author-email: MarkoSterk <marko_sterk@hotmail.com>
7
+ Requires-Dist: aiofiles>=25.1.0
8
+ Requires-Dist: asgi-lifespan>=2.1.0
9
+ Requires-Dist: granian>=2.7.2
10
+ Requires-Dist: h11>=0.16.0
11
+ Requires-Dist: httpcore>=1.0.9
12
+ Requires-Dist: idna>=3.11
13
+ Requires-Dist: jinja2>=3.1.6
14
+ Requires-Dist: loguru>=0.7.3
15
+ Requires-Dist: mako>=1.3.10
16
+ Requires-Dist: markupsafe>=3.0.3
17
+ Requires-Dist: pydantic>=2.12.5
18
+ Requires-Dist: pydantic-settings>=2.13.1
19
+ Requires-Dist: python-dotenv>=1.2.2
20
+ Requires-Dist: python-multipart>=0.0.22
21
+ Requires-Dist: websockets>=16.0
22
+ Requires-Dist: werkzeug>=3.1.6
23
+ Requires-Dist: patera-admin ; extra == 'admin'
24
+ Requires-Dist: patera-aiinterface ; extra == 'aiinterface'
25
+ Requires-Dist: patera-database ; extra == 'all'
26
+ Requires-Dist: patera-email ; extra == 'all'
27
+ Requires-Dist: patera-admin ; extra == 'all'
28
+ Requires-Dist: patera-aiinterface ; extra == 'all'
29
+ Requires-Dist: patera-auth ; extra == 'all'
30
+ Requires-Dist: patera-caching ; extra == 'all'
31
+ Requires-Dist: patera-statemachine ; extra == 'all'
32
+ Requires-Dist: patera-taskmanager ; extra == 'all'
33
+ Requires-Dist: patera-auth ; extra == 'auth'
34
+ Requires-Dist: patera-caching ; extra == 'caching'
35
+ Requires-Dist: patera-caching[redis] ; extra == 'caching-redis'
36
+ Requires-Dist: patera-caching[sqlite] ; extra == 'caching-sqlite'
37
+ Requires-Dist: patera-database ; extra == 'database'
38
+ Requires-Dist: granian[reload] ; extra == 'dev'
39
+ Requires-Dist: patera-email ; extra == 'email'
40
+ Requires-Dist: patera-frontendext ; extra == 'frontend'
41
+ Requires-Dist: patera-frontend ; extra == 'frontend'
42
+ Requires-Dist: patera-statemachine ; extra == 'statemachine'
43
+ Requires-Dist: patera-taskmanager ; extra == 'taskmanager'
44
+ Requires-Dist: httpx ; extra == 'testing'
45
+ Requires-Dist: anyio ; extra == 'testing'
46
+ Requires-Dist: pytest ; extra == 'testing'
47
+ Requires-Dist: pytest-cov ; extra == 'testing'
48
+ Requires-Python: >=3.12
49
+ Project-URL: Homepage, https://github.com/MarkoSterk/Patera
50
+ Project-URL: Issues, https://github.com/MarkoSterk/Patera/issues
51
+ Provides-Extra: admin
52
+ Provides-Extra: aiinterface
53
+ Provides-Extra: all
54
+ Provides-Extra: auth
55
+ Provides-Extra: caching
56
+ Provides-Extra: caching-redis
57
+ Provides-Extra: caching-sqlite
58
+ Provides-Extra: database
59
+ Provides-Extra: dev
60
+ Provides-Extra: email
61
+ Provides-Extra: frontend
62
+ Provides-Extra: statemachine
63
+ Provides-Extra: taskmanager
64
+ Provides-Extra: testing
65
+ Description-Content-Type: text/markdown
66
+
67
+ <p align="center">
68
+ <img src="https://raw.githubusercontent.com/MarkoSterk/Patera/refs/heads/main/src/patera/graphics/pyjolt_logo.png" alt="Patera Logo" width="200">
69
+ </p>
70
+
71
+ # Patera - async first python web framework
72
+
73
+ This framework is in its alpha stage and will probably see some major changes/improvements until it reaches
74
+ the beta stage for testing. Any eager tinkerers are invited to test the framework in its alpha stage and provide feedback.
75
+
76
+ ## Getting started
77
+
78
+ ### From PyPi with uv or pip
79
+
80
+ In your project folder
81
+ ```
82
+ uv init
83
+ uv add patera
84
+ ```
85
+ or with pip
86
+ ```
87
+ pip install patera
88
+ ```
89
+ We strongly recommend using uv for dependency management.
90
+
91
+ The above command will install patera with basic dependencies. For some subpackages you will need additional dependencies. Options are:
92
+
93
+ **Caching**
94
+ ```
95
+ uv add "patera[cache]"
96
+ ```
97
+
98
+ **Scheduler**
99
+ ```
100
+ uv add "patera[scheduler]"
101
+ ```
102
+
103
+ **AI interface** (experimental)
104
+ ```
105
+ uv add "patera[ai_interface]"
106
+ ```
107
+
108
+ **Full install**
109
+ ```
110
+ uv add "patera[full]"
111
+ ```
112
+
113
+ ##Getting started with project template
114
+
115
+ ```
116
+ uv run patera new-project
117
+ ```
118
+
119
+ or with pip (don't forget to activate the virtual environment)
120
+ ```
121
+ pipx patera new-project
122
+ ```
123
+
124
+ This will create a template project structure which you can use to get started.
125
+
126
+ ## Blank start
127
+
128
+ If you wish to start without the template you can do that ofcourse. However, we recommend you have a look at the template structure to see how to organize your project. There is also an example project in the "examples/dev" folder of this GitHub repo where you can see the app structure and recommended patterns.
129
+
130
+ A minimum app example would be:
131
+
132
+ ```
133
+ #app/__init__.py <-- in the app folder
134
+
135
+ from app.configs import Config
136
+ from patera import Patera, app, on_shutdown, on_startup
137
+
138
+ @app(__name__, configs = Config)
139
+ class Application(Patera):
140
+ pass
141
+ ```
142
+
143
+ and the configuration object is:
144
+
145
+ ```
146
+ #app/configs.py <-- in the app folder
147
+
148
+ import os
149
+ from patera import BaseConfig
150
+
151
+ class Config(BaseConfig): #must inherit from BaseConfig
152
+ """Config class"""
153
+ APP_NAME: str = "Test app"
154
+ VERSION: str = "1.0"
155
+ SECRET_KEY: str = "some-super-secret-key" #change for a secure random string
156
+ BASE_PATH: str = os.path.dirname(__file__)
157
+ DEBUG: bool = True
158
+ ```
159
+
160
+ Available configuration options of the application are:
161
+
162
+ ```
163
+ APP_NAME: str = Field(description="Human-readable name of the app")
164
+ VERSION: str = Field(description="Application version")
165
+ BASE_PATH: str #base path of app. os.path.dirname(__file__) in the configs.py file is the usual value
166
+
167
+ REQUEST_CLASS: Type[Request] = Field(Request, description="Request class used for handling application requests. Must be a subclass of patera.request.Request")
168
+ RESPONSE_CLASS: Type[Response] = Field(Response, description="Response class used for returning application responses. Must be a subclass of patera.response.Response")
169
+
170
+ # required for Authentication extension
171
+ SECRET_KEY: Optional[str]
172
+
173
+ # optionals with sensible defaults
174
+ DEBUG: Optional[bool] = True
175
+ HOST: Optional[str] = "localhost"
176
+ TEMPLATES_DIR: Optional[str] = "/templates"
177
+ STATIC_DIR: Optional[str] = "/static"
178
+ STATIC_URL: Optional[str] = "/static"
179
+ TEMPLATES_STRICT: Optional[bool] = True
180
+ STRICT_SLASHES: Optional[bool] = False
181
+ OPEN_API: Optional[bool] = True
182
+ OPEN_API_URL: Optional[str] = "/openapi"
183
+ OPEN_API_DESCRIPTION: Optional[str] = "Simple API"
184
+
185
+ #global CORS policy - optional with defaults
186
+ CORS_ENABLED: Optional[bool] = True #use cors
187
+ CORS_ALLOW_ORIGINS: Optional[list[str]] = ["*"] #List of allowed origins
188
+ CORS_ALLOW_METHODS: Optional[list[str]] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] #allowed methods
189
+ CORS_ALLOW_HEADERS: Optional[list[str]] = ["Authorization", "Content-Type"] #List of allowed headers
190
+ CORS_EXPOSE_HEADERS: Optional[list[str]] = [] # List of headers to expose
191
+ CORS_ALLOW_CREDENTIALS: Optional[bool] = True #Allow credentials
192
+ CORS_MAX_AGE: Optional[int] = None #Max age in seconds. None to disable
193
+
194
+ # controllers, extensions, models
195
+ CONTROLLERS: Optional[List[str]] #import strings
196
+ CLI_CONTROLLERS: Optional[List[str]] #import strings
197
+ EXTENSIONS: Optional[List[str]] #import strings
198
+ MODELS: Optional[List[str]] #import strings
199
+ EXCEPTION_HANDLERS: Optional[List[str]] #import strings
200
+ MIDDLEWARE: Optional[List[str]] #import strings
201
+ LOGGERS: Optional[List[str]] #import strings
202
+
203
+ DEFAULT_LOGGER: dict[str, Any] = {
204
+ LEVEL: Optional[LogLevel] = LogLevel.TRACE
205
+ FORMAT: Optional[str] = "<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {extra[logger_name]} | <level>{message}<level>"
206
+ BACKTRACE: Optional[bool] = True
207
+ DIAGNOSE: Optional[bool] = True
208
+ COLORIZE: Optional[bool] = True
209
+ }
210
+ ```
211
+
212
+ You can then run the app with a run script:
213
+
214
+ ```
215
+ #run.py <-- in the root folder
216
+
217
+ if __name__ == "__main__":
218
+ import uvicorn
219
+ from app.configs import Config
220
+ configs = Config() #to load default values of user does not provide them
221
+ uvicorn.run("app:Application", host=configs.HOST, port=configs.PORT, lifespan=configs.LIFESPAN, reload=configs.DEBUG, factory=True)
222
+ ```
223
+
224
+ ```sh
225
+ uv run --env-file .env.dev run.py
226
+ ```
227
+
228
+ or directly from the terminal with:
229
+
230
+ ```sh
231
+ uv run --env-file .env.dev uvicorn app:Application --reload --port 8080 --factory --host localhost
232
+ ```
233
+
234
+ This will start the application on localhost on port 8080 with reload enabled (debug mode). The **lifespan** argument is important when you wish to use a database connection or other on_startup/on_shutdown methods. If lifespan="on", uvicorn will give startup/shutdown signals which the app can use to run certain methods. Other lifespan options are: "auto" and "off".
235
+
236
+ The ***--env-file .env.dev*** can be omitted if environmental variables are not used.
237
+
238
+ ### Startup and shutdown methods
239
+
240
+ Sometimes we wish to add startup and shutdown methods to our application. One of the most common reasons is connecting to a database at startup and disconnecting at shutdown. In fact, this is what the SqlDatabase extension does automatically (see Extensions section below).
241
+ To add such methods, we can add them to the application class implementation like this:
242
+
243
+ ```
244
+ from app.configs import Config
245
+ from patera import Patera, app, on_shutdown, on_startup
246
+
247
+
248
+ @app(__name__, configs = Config)
249
+ class Application(Patera):
250
+
251
+ @on_startup
252
+ async def first_startup_method(self):
253
+ print("Starting up...")
254
+
255
+ @on_shutdown
256
+ async def first_shutdown_method(self):
257
+ print("Shuting down...")
258
+ ```
259
+
260
+ All methods decorated with the @on_startup or @on_shutdown decorators will be executed when the application starts. In theory, any number of methods can be defined and decorated, however, they will be executed in alphabetical order which can cause issues if not careful. Therefore we suggest you use a single method per-decorator and use it to delegate work to other methods in the correct order.
261
+
262
+
263
+ ### Application methods and properties
264
+
265
+ ```
266
+ def get_conf(self, config_name: str, default: Any = None) -> Any:
267
+ """
268
+ Returns app configuration with provided config_name.
269
+ Raises error if configuration is not found.
270
+ """
271
+
272
+ def url_for(self, endpoint: str, **values) -> str:
273
+ """
274
+ Returns url for endpoint method/handler
275
+ :param endpoint: the name of the endpoint handler method namespaced with the controller name
276
+ :param values: dynamic route parameters
277
+ :return: url (string) for endpoint
278
+ """
279
+
280
+ def run_cli(self):
281
+ """
282
+ Runs the app and executes a CLI command (does not start the actual server).
283
+ """
284
+ @property
285
+ def configs(self) -> dict[str, Any]:
286
+ """
287
+ Returns the entire application configuration dictionary
288
+ """
289
+
290
+ @property
291
+ def root_path(self) -> str:
292
+ """
293
+ Returns root path of application
294
+ """
295
+
296
+ @property
297
+ def app(self):
298
+ """
299
+ Returns self
300
+ For compatibility with the Controller class
301
+ which contains the app object on the app property
302
+ """
303
+
304
+ @property
305
+ def static_files_path(self) -> str:
306
+ """Static files paths"""
307
+
308
+ @property
309
+ def version(self) -> str:
310
+ """Returns app version"""
311
+
312
+ @property
313
+ def app_name(self) -> str:
314
+ """Returns app name"""
315
+
316
+ @property
317
+ def logger(self):
318
+ """Returns the logger object (from Loguru)"""
319
+ ```
320
+
321
+ ## Logging
322
+
323
+ Patera uses Loguru for logging. It is available through the application object (***app.logger: Logger***) in every controller endpoint through the ***self*** keyword in endpoint methods. A default logger is configured for the application. You can modify its behaviour through application configurations. Configurations with defaults are:
324
+
325
+ ```
326
+ LEVEL: Optional[LogLevel] = LogLevel.TRACE
327
+ FORMAT: Optional[str] = "<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {extra[logger_name]} | <level>{message}<level>"
328
+ BACKTRACE: Optional[bool] = True
329
+ DIAGNOSE: Optional[bool] = True
330
+ COLORIZE: Optional[bool] = True
331
+ ```
332
+
333
+ To change the configurations you have to create a new dictionary with the name **DEFAULT_LOGGER** in the app configurations and provide the above configuration options. Example:
334
+
335
+ ```
336
+ #from patera import LogLevel
337
+
338
+ DEFAULT_LOGGER: dict[str, Any] = {
339
+ "LEVEL": LogLevel.DEBUG,
340
+ "FORMAT": "<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {extra[logger_name]} | <level>{message}<level>"
341
+ "BACKTRACE": True
342
+ "DIAGNOSE": True
343
+ "COLORIZE": True
344
+ "SERIALIZE": False
345
+ "ENCODING": "utf-8"
346
+ }
347
+ ```
348
+
349
+ ### Adding custom logger sinks
350
+
351
+ Patera uses the same global Logger instance everywhere. However, you can configure different sinks and configure filters, output formats etc.
352
+ To add a custom logger you have to create a class which inherits from the LoggerBase class
353
+
354
+ ```
355
+ #app/loggers/file_logger.py
356
+ from patera.logging import LoggerBase
357
+
358
+ class FileLogger(LoggerBase):
359
+ """File logger example"""
360
+ ```
361
+
362
+ and then simply add the logger to the application configs:
363
+
364
+ ```
365
+ #configs.py
366
+
367
+ LOGGERS: Optional[List[str]] = ['app.logging.file_logger:FileLogger']
368
+ ```
369
+
370
+ To configure the file logger you have to add an app config field (dictonary) with the name of the logger as
371
+ upper-snake-case (FileLogger -> FILE_LOGGER):
372
+
373
+ ```
374
+ #configs.py
375
+ import os
376
+ from patera import LogLevel
377
+
378
+ FILE_LOGGER: dict[str, Any] = {
379
+ SINK: Optional[str|Path] = os.path.join(BASE_PATH, "logging", "file.log"),
380
+ LEVEL: Optional[LogLevel] = LogLevel.TRACE,
381
+ FORMAT: Optional[str] = "<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {extra[logger_name]} | <level>{message}</level>",
382
+ ENQUEUE: Optional[bool] = False,
383
+ BACKTRACE: Optional[bool] = True,
384
+ DIAGNOSE: Optional[bool] = True,
385
+ COLORIZE: Optional[bool] = True,
386
+ DELAY: Optional[bool] = True,
387
+ ROTATION: Optional[RotationType] = "5 MB", #accepts: str, int, timedelta
388
+ RETENTION: Optional[RetentionType] = "5 files", #accepts: str, int or timedelta
389
+ COMPRESSION: CompressionType = "zip",
390
+ SERIALIZE: Optional[bool] = False
391
+ ENCODING: Optional[str] = "utf-8",
392
+ MODE: Optional[str] = "a",
393
+ }
394
+ ```
395
+ This will add a file sink which will write a "file.log" file until it reaches the 5 MB threshold size. When this size is reached, the file is renamed "file.log.<TIME_STAMP>" and a new "file.log" is started. The setup will rotate 5 files.
396
+
397
+ If you wish to implement log filtering or more complex formating you can simply override the default methods of the LoggerBase class:
398
+
399
+ **WARNING**
400
+ When using ENQUEUE=True, you MUST use server lifespan events to trigger appropriate removal of added sinks at application shutdown. Otherwise, a warning (resource tracker) for leaked semaphore objects will be triggered.
401
+
402
+ ```
403
+ class FileLogger(LoggerBase):
404
+ """Example file logger"""
405
+
406
+ def get_format(self) -> str:
407
+ """Should return a valid format string for the logger output"""
408
+ return self.get_conf_value(
409
+ "FORMAT",
410
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
411
+ "<level>{level: <8}</level> | {extra[logger_name]} | "
412
+ "{name}:{function}:{line} - <cyan>{message}</cyan>",
413
+ )
414
+
415
+ def get_filter(self) -> FilterType:
416
+ """Should return a filter method which returns a boolean"""
417
+ return None
418
+ ```
419
+
420
+ For example, the ***get_format*** method could return a valid JSON format string for the logger (to create a .jsonl file) and the filter method could filter log messages for specific phrases to destinguish between different log messages. Example filter method:
421
+
422
+ ```
423
+ def get_filter(self):
424
+ def _filter(record: dict[str, any]) -> bool:
425
+ # Only log messages where the message includes the string "PERFORMANCE"
426
+ # Message from a performance logger for bottleneck detection.
427
+ return "PERFORMANCE" in record["message"]
428
+
429
+ return _filter
430
+ ```
431
+
432
+ Every logger accepts all of the above configurations, however, some are only applied to file loggers (retention, rotation, queueu, etc) because they don't make sense for simple console loggers. **DEFAULT** sink is ***STDERR***, but ***STDOUT*** is also accepted.
433
+
434
+
435
+ ## Adding controllers for request handling
436
+
437
+ Controllers are created as classes with **async** methods that handle specific requests. An example controller is:
438
+
439
+ ```
440
+ #app/api/users/user_api.py
441
+
442
+ from patera import Request, Response, HttpStatus, MediaType
443
+ from patera.controller import Controller, path, get, produces, post, consumes
444
+ from pydantic import BaseModel
445
+
446
+ class UserData(BaseModel):
447
+ email: str
448
+ fullname: str
449
+
450
+ @path("/api/v1/users")
451
+ class UsersApi(Controller):
452
+
453
+ @get("/<int:user_id>")
454
+ @produces(MediaType.APPLICATION_JSON)
455
+ async def get_user(self, req: Request, user_id: int) -> Response:
456
+ """Returns a user by user_id"""
457
+ #some logic to load the user
458
+
459
+ return req.response.json({
460
+ "id": user_id,
461
+ "fullname": "John Doe",
462
+ "email": "johndoe@email.com"
463
+ }).status(HttpStatus.OK)
464
+
465
+ @post("/")
466
+ @consumes(MediaType.APPLICATION_JSON)
467
+ @produces(MediaType.APPLICATION_JSON)
468
+ async def create_user(self, req: Request, user_data: UserData) -> Response[UserData]:
469
+ """Creates new user"""
470
+ #logic for creating and storing user
471
+ return req.response.json(user_data).status(HttpStatus.CREATED)
472
+
473
+ ```
474
+ Each endpoint method has access to the application object and its configurations and methods via the self argument (self.app: Patera).
475
+ The controller must be registered with the application in the configurations:
476
+
477
+ ```
478
+ CONTROLLERS: List[str] = [
479
+ 'app.api.users.user_api:UserApi' #path:Controller
480
+ ]
481
+ ```
482
+
483
+ In the above example controller the **post** route accepts incomming json data (@consumes) and automatically
484
+ injects it into the **user_data** variable with a Pydantic BaseModel type object. The incomming data is also automatically validated
485
+ and raises a validation error (422 - Unprocessible entity) if data is incorrect/missing. For more details about data validation and options we suggest you take a look at the Pydantic library. The @produces decorator automatically sets the correct content-type on the
486
+ response object and the return type hint (-> Response[UserData]:) indicates as what type of object the response body should be serialized.
487
+
488
+ ### Available decorators for controllers
489
+
490
+ ```
491
+ @path(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
492
+ ```
493
+
494
+ This is the main decorator for a controller. It assignes the controller a url path and controlls if the controller should be included in the OpenApi specifications.
495
+ It also assignes tag(s) for grouping of controller endpoints in the OpenApi specs.
496
+
497
+ ```
498
+ @get(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
499
+ @post(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
500
+ @put(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
501
+ @patch(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
502
+ @delete(url_path: str, open_api_spec: bool = True, tags: list[str]|None = None)
503
+ @socket(url_path: str) #for webwocket connections
504
+ ```
505
+
506
+ Main decorator assigned to controller endpoint methods. Determines the type of http request an endpoint handles (GET, POST, PUT, PATCH or DELETE), the endpoint url path (conbines with the controller path), if it should be added to the OpenApi specifications and fine grain endpoint grouping in the OpenApi specs via the **tags** argument.
507
+
508
+ ```
509
+ @consumes(media_type: MediaType)
510
+ ```
511
+
512
+ Indicates the kind of http request body this endpoint consumes (example: MediaType.APPLICATION_JSON, indicates it needs a json request body.). Available options are:
513
+
514
+ ```
515
+ APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"
516
+ MULTIPART_FORM_DATA = "multipart/form-data"
517
+ APPLICATION_JSON = "application/json"
518
+ APPLICATION_PROBLEM_JSON = "application/problem+json"
519
+ APPLICATION_XML = "application/xml"
520
+ TEXT_XML = "text/xml"
521
+ TEXT_PLAIN = "text/plain"
522
+ TEXT_HTML = "text/html"
523
+ APPLICATION_OCTET_STREAM = "application/octet-stream"
524
+ IMAGE_PNG = "image/png"
525
+ IMAGE_JPEG = "image/jpeg"
526
+ IMAGE_GIF = "image/gif"
527
+ APPLICATION_PDF = "application/pdf"
528
+ APPLICATION_X_NDJSON = "application/x-ndjson"
529
+ APPLICATION_CSV = "application/csv"
530
+ TEXT_CSV = "text/csv"
531
+ APPLICATION_YAML = "application/yaml"
532
+ TEXT_YAML = "text/yaml"
533
+ APPLICATION_GRAPHQL = "application/graphql"
534
+ NO_CONTENT = "empty"
535
+ ```
536
+
537
+ If this decorator is used it must be used in conjuction with a Pydantic data class provided as a parameter in the endpoint method:
538
+
539
+ ```
540
+ @post("/")
541
+ @consumes(MediaType.APPLICATION_JSON)
542
+ @produces(MediaType.APPLICATION_JSON)
543
+ async def create_user(self, req: Request, data: TestModel) -> Response[ResponseModel]:
544
+ """Consumes and produces json"""
545
+ ```
546
+
547
+ TestModel is a Pydantic class.
548
+
549
+ ```
550
+ @produces(media_type: MediaType)
551
+ ```
552
+
553
+ The produces decorator indicates and sets the media type of the response body. Although the media type is set automatically it still shows a warning if the actual media type which was set in the endpoint by the developer does not match the intended value.
554
+
555
+ ```
556
+ @open_api_docs(*args: Descriptor)
557
+ ```
558
+
559
+ This decorator sets the possible return types of the decorated endpoint if the request was not successful (example: 404, 400, 401, 403 response codes). It accepts any number of Descriptor objects:
560
+
561
+ ```
562
+ Descriptor(status: HttpStatus = HttpStatus.BAD_REQUEST, description: str|None = None, media_type: MediaType = MediaType.APPLICATION_JSON, body: Type[BaseModel]|None = None)
563
+ ```
564
+
565
+ like this:
566
+
567
+ ```
568
+ @get("/<int:user_id>")
569
+ @produces(MediaType.APPLICATION_JSON)
570
+ @open_api_docs(Descriptor(status=HttpStatus.NOT_FOUND, description="User not found", body=ErrorResponse),
571
+ Descriptor(status=HttpStatus.BAD_REQUEST, description="Bad request", body=ErrorResponse))
572
+ async def get_user(self, req: Request, user_id: int) -> Response[ResponseModel]:
573
+ """Endpoint logic """
574
+ ```
575
+
576
+ The above example adds two possible endpoint responses (NOT_FOUND and BAD_REQUEST) with descriptions and what type of object is returned as json (default).
577
+
578
+ ```
579
+ @development
580
+ ```
581
+
582
+ This decorator can be applied to the controller class or individual endpoints. Controllers/endpoints with this decorator will be
583
+ disabled (unreachable) when the application is not in ***DEBUG*** mode (when ***DEBUG=False***). The decorator is for easy disabling
584
+ of features which are not yet ready for production.
585
+
586
+ ### Request and Response objects
587
+
588
+ Each request gets its own Request object which is passed to the controller endpoint method. The Request object contains all
589
+ request parameters:
590
+
591
+ ```
592
+ req: Request
593
+ req.route_parameters -> dict[str, int|str] #route parameters as a dictionary
594
+ req.method -> str #http method (uppercase string: GET, POST, PUT, PATCH, DELETE)
595
+ req.path -> str #request path (url: str)
596
+ req.query_string -> str #(the entire query string - what comes after "?" in the url)
597
+ req.headers -> dict[str, str] #all request headers
598
+ req.query_params -> dict[str, str] #query parameters as a dictionary
599
+ req.user -> Any #loaded user (if present). See the authentication implementation below.
600
+ req.res -> Response #the Response object
601
+ req.state -> Any #for setting any state which must be passed down in the request chain (i.e. middleware etc)
602
+ ```
603
+
604
+ The response object provided on the Request object has methods:
605
+
606
+ ```
607
+ req.res: Response
608
+ req.res.status(self, status_code: int|HttpStatus) -> Self #sets http status code
609
+ req.res.redirect(self, location: str, status_code: int|HttpStatus = HttpStatus.SEE_OTHER) -> instructs client to redirect to location
610
+ req.res.json(self, data: Any) -> Self #sets a json object as the response body
611
+ req.res.no_content(self) -> Self #no content response
612
+ req.res.text(self, text: str) -> Self #sets text as the response body
613
+ req.res.html_from_string(self, text: str, context: Optional[dict[str, Any]] = None) -> Self #creates a rendered template from the provided string
614
+ req.res.html(self, template_path: str, context: Optional[dict[str, Any]] = None) -> Self #creates a rendered template from the template file
615
+ req.res.send_file(self, body, headers) -> Self #sends a file as the response
616
+ req.res.set_header(self, key: str, value: str) -> Self #sets response header
617
+ req.res.set_cookie(self, cookie_name: str, value: str,
618
+ max_age: int|None = None, path: str = "/",
619
+ domain: str|None = None, secure: bool = False,
620
+ http_only: bool = True) -> Self #sets a cookie in the response
621
+ delete_cookie(self, cookie_name: str,
622
+ path: str = "/", domain: Optional[str] = None) -> Self #deletes a cookie
623
+ ```
624
+
625
+
626
+ ### Before and after request handling in Controllers
627
+
628
+ Sometimes we need to process a request before it ever hits the endpoint. For this, middleware or additional decorators is often used. If only a specific endpoint needs
629
+ this pre- or postprocessing, decorators are the way to go, however, if all controller endpoints need it we can add methods to the controller which will run for each request.
630
+ We can to this by adding and decorating controller methods:
631
+
632
+ ```
633
+ #at the top of the controller file:
634
+ from patera.controller import (Controller, path, get, produces, before_request, after_request)
635
+ ####
636
+ @path("/api/v1/users", tags=["Users"])
637
+ class UsersApi(Controller):
638
+
639
+ @before_request
640
+ async def before_request_method(self, req: Request):
641
+ """Some before request logic"""
642
+
643
+ @after_request
644
+ async def after_request_method(self, res: Response):
645
+ """Some after request logic"""
646
+
647
+ @get("/")
648
+ @produces(MediaType.APPLICATION_JSON)
649
+ async def get_users(self, req: Request) -> Response[ResponseModel]:
650
+ """Endpoint for returning all app users"""
651
+ #await asyncio.sleep(10)
652
+ session = db.create_session()
653
+ users = await User.query(session).all()
654
+ response: ResponseModel = ResponseModel(message="All users fetched.",
655
+ status="success", data=None)
656
+ await session.close() #must close the session
657
+ return req.response.json(response).status(HttpStatus.OK)
658
+ ```
659
+
660
+ The before and after request methods don't have to return anything. The request/response objects can be manipulated in-place. In theory, any number of methods
661
+ can be decorated with the before- and after_request decorators and all will run before the request is passed to the endpoint method, however, they are executed in
662
+ alphabetical order which can be combersome. This is why we suggest you use a single method which calls/delegates work to other methods.
663
+
664
+ ### Websockets
665
+
666
+ You can add a websocket handler to any controller by using the ***@socket(url_path: str)*** decorator on the handler method.
667
+
668
+ ```
669
+ @path("/api/v1/users", tags=["Users"])
670
+ class UsersApi(Controller):
671
+
672
+ @socket("/ws")
673
+ #@auth.login_required
674
+ #@role_required
675
+ async def socket_handler(self, req: Request) -> None:
676
+ """
677
+ Example socket handler
678
+ This method doesn't return anything. It is receiving/sending messages directly via the Request and Response objects.
679
+ """
680
+ #accept the connection
681
+ await req.accept()
682
+ while True:
683
+ data = await req.receive()
684
+ if data["type"] == "websocket.disconnect":
685
+ break #breaks the loop if the user disconnects
686
+ if data["type"] == "websocket.receive":
687
+ ##some logic to perform when user sends a message
688
+ await req.res.send({
689
+ "type": "websocket.send",
690
+ "text": "Hello from server. Echo: " + data.get("text", "")
691
+ })
692
+ ```
693
+
694
+ This is a minimal websocker handler implementation. It first accepts the connection and then listens to receiving/incomming messages and sends responses.
695
+ The handler method can be protected with ***@login_required*** and ***@role_required*** decorators from the authentication extension. See implementation details in the
696
+ extension section.
697
+
698
+ ## CORS
699
+
700
+ Patera has built-in CORS support. There are several configurations which you can set to in the Config class to configure CORS.
701
+ Configuration options with default values are:
702
+
703
+ ```
704
+ CORS_ENABLED: Optional[bool] = True #use cors
705
+ CORS_ALLOW_ORIGINS: Optional[list[str]] = ["*"] #List of allowed origins
706
+ CORS_ALLOW_METHODS: Optional[list[str]] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] #allowed methods
707
+ CORS_ALLOW_HEADERS: Optional[list[str]] = ["Authorization", "Content-Type"] #List of allowed headers
708
+ CORS_EXPOSE_HEADERS: Optional[list[str]] = [] # List of headers to expose
709
+ CORS_ALLOW_CREDENTIALS: Optional[bool] = True #Allow credentials
710
+ CORS_MAX_AGE: Optional[int] = None #Max age in seconds. None to disable
711
+ ```
712
+
713
+ The above configurations will set CORS policy on the application scope. If you wish to fine-tune the policy on specific
714
+ endpoints you can use two decoratos.
715
+
716
+ To disable cors on an endpoint:
717
+
718
+ ```
719
+ #imports
720
+ from patera.controller import no_cors
721
+
722
+ #inside a controller
723
+
724
+ @GET("/")
725
+ @no_cors
726
+ async def my_endpoint(self, req: Request) -> Response:
727
+ """some endpoint logic"""
728
+ ```
729
+
730
+ this will disable CORS for this specific endpoint no matter the global settings.
731
+
732
+ If you wish you can set a different set of CORS rules for an endpoint using the ***@cors*** decorator:
733
+
734
+ ```
735
+ #imports
736
+ from patera.controller import cors
737
+
738
+ #inside a controller
739
+
740
+ @GET("/")
741
+ @cors(*,
742
+ allow_origins: Optional[list[str]] = None,
743
+ allow_methods: Optional[list[str]] = None,
744
+ allow_headers: Optional[list[str]] = None,
745
+ expose_headers: Optional[list[str]] = None,
746
+ allow_credentials: Optional[bool] = None,
747
+ max_age: Optional[int] = None,)
748
+ async def my_endpoint(self, req: Request) -> Response:
749
+ """some endpoint logic"""
750
+ ```
751
+
752
+ This will override the global CORS settings with endpoint-specific settings.
753
+
754
+ ### CORS responses
755
+
756
+ If the request does not comply with CORS policy error responses are automatically returned:
757
+
758
+ **403 - Forbiden** - if the request origin is not allowed
759
+ **405 - Method not allowed** - if the request method is not allowed
760
+
761
+ ## Routing
762
+
763
+ Patera uses the same router as Flask under the hood (Werkzeug). This means that all the same patterns apply.
764
+
765
+ Examples:
766
+ ```
767
+ @get("/api/v1/users/<int:user_id>)
768
+ @get("/api/v1/users/<string:user_name>)
769
+ @get("/api/v1/users/<path:path>) #handles: "/api/v1/users/account/dashboard/main"
770
+ ```
771
+
772
+ Route parameters marked with "<int:>" will be injected into the handler as integers, "<string:>" as a string and "<path:>" injects the entire path as a string.
773
+
774
+ ### Route not found
775
+
776
+ If a route is not found (wrong url or http method) a NotFound (from patera.exception import NotFound) error is raised. You can handle the exception in the ExceptionHandler class. If not handled, a generic JSON response is returned.
777
+
778
+ ## Exception handling
779
+
780
+ Exception handling can be achived by creating an exception handler class (or more then one) and registering it with the application.
781
+
782
+ ```
783
+ # app/api/exceptions/exception_handler.py
784
+
785
+ from typing import Any
786
+ from pydantic import BaseModel, ValidationError
787
+ from patera.exceptions import ExceptionHandler, handles
788
+ from patera import Request, Response, HttpStatus
789
+
790
+ from .custom_exceptions import EntityNotFound
791
+
792
+ class ErrorResponse(BaseModel):
793
+ message: str
794
+ details: Any|None = None
795
+
796
+ class CustomExceptionHandler(ExceptionHandler):
797
+
798
+ @handles(ValidationError)
799
+ async def validation_error(self, req: "Request", exc: ValidationError) -> "Response[ErrorResponse]":
800
+ """Handles validation errors"""
801
+ details = {}
802
+ if hasattr(exc, "errors"):
803
+ for error in exc.errors():
804
+ details[error["loc"][0]] = error["msg"]
805
+
806
+ return req.response.json({
807
+ "message": "Validation failed.",
808
+ "details": details
809
+ }).status(HttpStatus.UNPROCESSABLE_ENTITY)
810
+ ```
811
+
812
+ The above CustomExceptionHandler class can also be registered with the application in configs.py file.
813
+
814
+ ```
815
+ EXCEPTION_HANDLERS: List[str] = [
816
+ 'app.api.exceptions.exception_handler:CustomExceptionHandler'
817
+ ]
818
+ ```
819
+
820
+ You can define any number of methods and decorate them with the @handles decorator to indicate which exception
821
+ should be handled by the method. The @handles decorator excepts any number of exceptions as arguments.
822
+
823
+ Any exceptions that are raised throughout the app can be handled in one or more ExceptionHandler classes. If an unhandled exception occurs
824
+ and the application is in DEBUG mode, the exception will raise an error, however, if the application is NOT in DEBUG mode, the exception is
825
+ suppressed and a JSON response with content
826
+
827
+ ```
828
+ {
829
+ "status": "error",
830
+ "message": "Internal server error",
831
+ }
832
+ ```
833
+
834
+ with status code 500 (Internal server error) is returned and the request is logged as critical.
835
+ To avoid this generic response you can implement a handler in your ExceptionHandler class which handles raw exceptions (pythons Exception class).
836
+
837
+ ```
838
+ @handles(ValidationError, SomeOtherException, AThirdException)
839
+ async def handler_method(self, req: "Request", exc: ValidationError|SomeOtherException|AThirdException) -> "Response[ErrorResponse]":
840
+ ###handler logic and response return
841
+ ```
842
+
843
+ Each handler method accepts exactly three arguments. The "self" keyword pointing at the exception handler instance (has acces to the application object -> self.app),
844
+ the current request object and the raised exception.
845
+
846
+ ### Custom exceptions
847
+
848
+ Custom exceptions can be made by defining a class which inherits from the patera.exceptions.BaseHttpException, from the patera.Exceptions.CustomException or simply by inheriting from pythons Exception class.
849
+
850
+ ```
851
+ from patera.exception import BaseHttpException, CustomException
852
+
853
+ class MyCustomException(Exception):
854
+ """implementation"""
855
+
856
+ class MyCustomHttpException(BaseHttpException):
857
+ """implementation"""
858
+
859
+ class CustomExceptionFromCustomException(CustomException):
860
+ """implementation"""
861
+ ```
862
+
863
+ The exceptions can then be registered with your exception handler to provide required responses to users.
864
+
865
+ ### Quick aborts
866
+
867
+ Sometimes, you just wish to quickly abort a request (when data is not found, something else goes wrong.). Since Patera advocates for the
868
+ fail-fast pattern, it provides two convinience methods for quickly aborting requests. These methods are:
869
+
870
+ ```
871
+ from patera import abort, html_abort
872
+ abort(msg: str, status_code: HttpStatus = HttpStatus.BAD_REQUEST, status: str = "error", data: Any = None)
873
+ html_abort(template: str, status_code: HttpStatus = HttpStatus.BAD_REQUEST, data: Any = None)
874
+ ```
875
+
876
+ These methods raise a AborterException and HtmlAborterException, respectively. An example of the abort method use;
877
+
878
+ ```
879
+ from patera import abort, html_abort
880
+
881
+ @get("/api/v1/users/<int:user_id>)
882
+ async def get_user(self, req: Request, user_id: int) -> Response:
883
+ """Handler logic"""
884
+ #Entity not found
885
+ abort(msg=f"User with id {user_id} not found",
886
+ status_code=HttpStatus.NOT_FOUND,
887
+ status="error", data=None)
888
+ ```
889
+
890
+ To handle AborterExceptions you have to implement a handler in your ExceptionHandler class, however, HtmlAborterExceptions are automatically
891
+ rendered and returned.
892
+
893
+ ### Redirecting
894
+ Sometimes we wish to redirect the user to a different resource. In this case we can use a redirect response of the Response object.
895
+
896
+ ```
897
+ @get("/api/v1/auth/login)
898
+ async def get_user(self, req: Request, data: UserLoginData) -> Response:
899
+ """Handler logic"""
900
+ #Redirect after login
901
+ return req.response.redirect("url-for-location")
902
+ ```
903
+
904
+ The above example instructs the client to redirect to "url-for-location" with default status code 303 (SEE OTHER).
905
+
906
+ ### Redirecting to other endpoint
907
+
908
+ We can provide a hard-coded string to the ***redirect*** method, however, this can be cumbersome. The url might change and the redirect would break.
909
+ To avoid this, we can use the url_for method provided by the application object:
910
+
911
+ ```
912
+ #Redirect after login
913
+ return req.response.redirect(self.app.url_for("<ControllerName>.<endpointMethodName>"), **kwargs)
914
+ ```
915
+
916
+ This will construct the correct url with any route parameters (provided as key-value pairs <-> kwargs) and return it as a string.
917
+ In this way, we do not have to hard-code and remember all urls in our app. We can also change the non-dynamic parts of the endpoint
918
+ without breaking redirects.
919
+
920
+
921
+ ## Static assets/files
922
+
923
+ The application serves files in the "/static" folder on the path "/static/<path:filename>".
924
+ If you have an image named "my_image.png" in the static folder you can access it on the url: http://localhost:8080/static/my_image.png
925
+ The path ("/static") and folder name ("/static") can be configured via the application configurations. The folder should be inside the "app" folder.
926
+
927
+ To construct the above example url for ***my_image.png*** we can use the ***url_for*** method like this:
928
+
929
+ ```
930
+ self.app.url_for("Static.get", filename="my_image.png")
931
+ ```
932
+
933
+ This will return the correct url for the image. If the image was located in subfolders we would simply have to change the ***filename** argument
934
+ in the method call.
935
+
936
+ In this example, the url_for method returns the url for the ***get*** method of the ***Static*** controller (automatically registered by the application)
937
+ with required ***filename*** argument.
938
+
939
+ ## Template (HTML) responses
940
+
941
+ Controller endpoints can also return rendered HTML or plain text content.
942
+
943
+ ```
944
+ #inside a controller class
945
+
946
+ @get("/<int:user_id>")
947
+ @produces(MediaType.TEXT_HTML)
948
+ async def get_user(self, req: Request, user_id: int) -> Response:
949
+ """Returns a user by user_id"""
950
+ #some logic to load the user
951
+ context: dict[str, Any] = {#any key-value pairs you wish to include in the template}
952
+
953
+ return await (req.response.html("my_template.html", context)).status(HttpStatus.OK)
954
+ ```
955
+
956
+ The template name/path must be relative to the templates folder of the application. Because the html response accesses/loads the template
957
+ from the templates folder, the .html method of the response object is async and must thus be awaited.
958
+
959
+ The name/location of the templates folder can be configured via application configurations.
960
+
961
+ Patera uses Jinja2 as the templating engine, the synatx is thus the same as in any framework which uses the same engine.
962
+
963
+ ## OpenAPI specifications
964
+
965
+ OpenAPI specifications are automatically generated and exposed on "/openapi/docs" (Swagger UI) and "/openapi/specs.json" endpoints (in Debug mode only).
966
+ To make sure the endpoint descriptions, return types and request specification are accurate, we suggest you use all required endpoint decorators available for
967
+ endpoints.
968
+
969
+ ## Extensions
970
+ Patera has a few built-in extensions that can be used ad configured for database connection/management, task scheduling, authentication and
971
+ interfacing with LLMs.
972
+
973
+ ### Database connectivity and management
974
+
975
+ #### SQL
976
+
977
+ To add SQL database connectivity to your Patera app you can use the database.sql module.
978
+
979
+ ```
980
+ #extensions.py
981
+ from patera.database.sql import SqlDatabase
982
+ from patera.database.sql.migrate import Migrate
983
+
984
+ db: SqlDatabase = SqlDatabase(db_name="db", configs_name="SQL_DATABASE") #"db" and "SQL_DATABASE" is the default so they can be omitted
985
+ migrate: Migrate = Migrate(db, command_prefix: str = "")
986
+ ```
987
+
988
+ you can then indicate the extensions in the app configurations:
989
+
990
+ ```
991
+ EXTENSIONS: List[str] = [
992
+ 'app.extensions:db',
993
+ 'app.extensions:migrate'
994
+ ]
995
+ ```
996
+
997
+ This will initilize and configure the extensions with the application at startup. To configure the extensions simply add
998
+ neccessary configurations to the config class or dictionary. Available configurations are:
999
+
1000
+ ```
1001
+ SQL_DATADATE = {
1002
+ "DATABASE_URI": "sqlite+aiosqlite:///./test.db",#for a simple SQLite database
1003
+ "SESSION_NAME": "session",
1004
+ "SHOW_SQL": False
1005
+ }
1006
+ ```
1007
+
1008
+ To use a Postgresql db the **DATABASE_URI** string should be like this:
1009
+ ```
1010
+ "DATABASE_URI": "postgresql+asyncpg://user:pass@localhost/dbname"
1011
+ ```
1012
+
1013
+ Session name variable (for use with @managed_session and @readonly_session):
1014
+ ```
1015
+ "SESSION_NAME": "session"
1016
+ ```
1017
+ This is the name of the AsyncSession variable that is injected when using the managed_session decorator of the extension. The default is "session". This is useful when you wish to use
1018
+ managed sessions for multiple databases in the same controller endpoint.
1019
+
1020
+ ```
1021
+ "SHOW_SQL": False
1022
+ ```
1023
+
1024
+ This configuration directs the extension to log every executed SQL statement to the console. This is a good way to
1025
+ debug and optimize code during development but should not be used in production.
1026
+
1027
+ **Migrate**
1028
+ ```
1029
+ ALEMBIC_MIGRATION_DIR: str = "migrations" #default folder name for migrations
1030
+ ALEMBIC_DATABASE_URI_SYNC: str = "sqlite:///./test.db" #a connection string with a sync driver
1031
+ ```
1032
+
1033
+ The SqlDatabase extension accepts a configs_name: str argument which is passed to its Migrate instance. This argument determines the configurations dictionary in the configs.py file which
1034
+ should be used for the extension. By default all extensions use upper-pascal-case format of the extension name (SqlDatabase -> "SQL_DATABASE"). The Migrate instance can be passed a
1035
+ command_prefix: str which can be used to differentiate different migration instances if uses multiple (for multiple databases).
1036
+ ```
1037
+ #extensions.py
1038
+ .
1039
+ .
1040
+ .
1041
+ db: SqlDatabase = SqlDatabase(configs_name="MY_DATABASE") #default configs_name="SQL_DATABASE"
1042
+ migrate: Migrate = Migrate(db: SqlDatabase, command_prefix: str = "")
1043
+ ```
1044
+
1045
+ In this case the configuration variables should be:
1046
+ ```
1047
+ MY_DATABASE = {
1048
+ "DATABASE_URI": "<connection_str>",
1049
+ "ALEMBIC_MIGRATION_DIR": "<migrations_directory>"
1050
+ "ALEMBIC_DATABASE_URI_SYNC": "<connection_str_with_sync_driver>"
1051
+ }
1052
+
1053
+ ```
1054
+ This is useful in cases where you need more then one database.
1055
+
1056
+ The migrate extension exposes some function which facilitate database management.
1057
+ They can be envoked via the cli.py script in the project root
1058
+
1059
+ ```
1060
+ #cli.py <- next to the run.py script
1061
+ """CLI utility script"""
1062
+
1063
+ if __name__ == "__main__":
1064
+ from app import Application
1065
+ app = Application()
1066
+ app.run_cli()
1067
+ ```
1068
+
1069
+ You can run the script with command like this:
1070
+ ```sh
1071
+ uv run cli.py db-init
1072
+ uv run cli.py db-migrate --message "Your migration message"
1073
+ uv run cli.py db-upgrade
1074
+ ```
1075
+ The above commands initialize the migrations tracking of the DB, prepares the migration script and finally upgrades the DB.
1076
+
1077
+ Other available cli commands for DB management are:
1078
+
1079
+ ```
1080
+ db-downgrade --revision "rev. number"
1081
+ db-history --verbose --indicate-current
1082
+ db-current --verbose
1083
+ db-heads --verbose
1084
+ db-show --revision "rev. number"
1085
+ db-stamp --revision "rev. number"
1086
+ ```
1087
+
1088
+ Arguments to the above commands are optional.
1089
+
1090
+ **If using command_prefix**
1091
+ If using a command prefix for the Migrate instance the commands can be executed like this:
1092
+
1093
+ ```
1094
+ uv run cli.py <command_prefix>db-init
1095
+ uv run cli.py <command_prefix>db-migrate --message "Your migration message"
1096
+ uv run cli.py <command_prefix>db-upgrade
1097
+ ```
1098
+
1099
+ The same applies to other commands of the Migrate extension.
1100
+
1101
+ **The use of the Migrate extension is completely optional when using a database.**
1102
+
1103
+ ##### Database Models
1104
+ To store/fetch data from the database you can use model classes. An example class is:
1105
+
1106
+ ```
1107
+ #app/api/models/user_model.py
1108
+
1109
+ from sqlalchemy import Integer, String, ForeignKey
1110
+ from sqlalchemy.orm import mapped_column, Mapped, relationship
1111
+
1112
+ from patera.database import create_declerative_base
1113
+
1114
+ Base = create_declerative_base("db") #passed argument must be the same as the database name you wish to
1115
+ #use the model with. Default is "db" so it can be omitted.
1116
+
1117
+ class User(Base):
1118
+ """
1119
+ User model
1120
+ """
1121
+ __tablename__: str = "users"
1122
+
1123
+ id: Mapped[int] = mapped_column(primary_key=True)
1124
+ fullname: Mapped[str] = mapped_column(String(30))
1125
+ email: Mapped[str] = mapped_column(String(50), unique=True)
1126
+ ```
1127
+
1128
+ The Base class created with create_declerative_base should be used with all db models for the same database.
1129
+
1130
+ ##### Querying
1131
+ To perform queries in the database you can use the associated models. A simple query for getting a user by its ID is:
1132
+
1133
+ ```
1134
+ user: User = await User.query(session).filter_by(id=user_id).first()
1135
+ ```
1136
+
1137
+ This returns the first user that matches the filter_by criteria. To get all users in the table you can do:
1138
+
1139
+ ```
1140
+ users: list[User] = await User.query(session).all()
1141
+ ```
1142
+
1143
+ The ***session*** object is an active AsyncSession instance which can be injected via the ***@managed_session*** or ***@readonly_session*** decorators on controller endpoint handlers.
1144
+
1145
+ **Manual session handling is highly discouraged and should be used only for very specific use cases and with utmost care. Unclosed sessions can cause memory leaks and other problem, especially in long running apps.**
1146
+
1147
+ The ***Model.query(session)*** method returns an AsyncQuery object which exposes many methods for querying and filtering:
1148
+
1149
+ ```
1150
+ def where(self, *conditions) -> "AsyncQuery": #Adds WHERE conditions (same as `filter()`).
1151
+ def filter(self, *conditions) -> "AsyncQuery": #Adds WHERE conditions to the query (supports multiple conditions).
1152
+ def filter_by(self, **kwargs) -> "AsyncQuery": #Adds WHERE conditions using keyword arguments (simpler syntax).
1153
+ def join(self, other_model: Model) -> "AsyncQuery": #Performs a SQL JOIN with another model.
1154
+ def limit(self, num: int) -> "AsyncQuery": #"Limits the number of results returned.
1155
+ def offset(self, num: int) -> "AsyncQuery": #Skips a certain number of results (used for pagination).
1156
+ def order_by(self, *columns) -> "AsyncQuery": Sorts results based on one or more columns.
1157
+ def like(self, column, pattern, escape=None) -> "AsyncQuery": #Filters results using a SQL LIKE condition.
1158
+ def ilike(self, column, pattern, escape=None) -> "AsyncQuery": #Filters results using a SQL ILIKE condition.
1159
+ ```
1160
+
1161
+ The above methods always return the AsyncQuery object and thus serve as query builders. This means that the methods can be chained to construct the desired query.
1162
+ Actual results are returned once we execute the query with one of the following methods (must be awaited):
1163
+
1164
+ ```
1165
+ async def count(self) -> int: #returns number of results
1166
+ async def paginate(self, page: int = 1, per_page: int = 10) -> Dict[str, Any]: #returnes a dictionary with paginated results (see below)
1167
+ async def all(self) -> list: #returns all results
1168
+ async def first(self) -> Any: #returns first result
1169
+ async def one(self) -> Any: #returns only one result
1170
+ ```
1171
+
1172
+ ##### Paginated results
1173
+
1174
+ The paginate method returns a pagination object (dictionary) with the following structure:
1175
+
1176
+ ```
1177
+ result = dict: {
1178
+ "items": list[Model], #List of results
1179
+ "total": int, #Total records
1180
+ "page": int, #Current page
1181
+ "pages": int, #Total pages
1182
+ "per_page": int, #Results per page
1183
+ "has_next": bool, #Whether there's a next page
1184
+ "has_prev": bool #Whether there's a previous page
1185
+ }
1186
+ ```
1187
+
1188
+ **For model detection (for correct Migration extension working) all models should be added in the app configurations**
1189
+
1190
+ ```
1191
+ MODELS: List[str] = [
1192
+ 'app.api.models.user_model:User'
1193
+ ]
1194
+ ```
1195
+
1196
+ **SqlDatabase and Migrate extension uses Sqlalchemy and Alembic under the hood.**
1197
+
1198
+ ##### Automatic session handling
1199
+
1200
+ Manual session handling is highly discouraged because it is easy to forget to close/commit an active session. Therefore two convenience decorators can be used:
1201
+
1202
+ ```
1203
+ @post("/")
1204
+ @consumes(MediaType.APPLICATION_JSON)
1205
+ @produces(MediaType.APPLICATION_JSON)
1206
+ @db.managed_session
1207
+ async def create_user(self, req: Request, user_data: UserData, session: AsyncSession) -> Response[UserData]:
1208
+ """Creates new user"""
1209
+ user: User = User(fullname=user_data.fullname, email=user_data.email)
1210
+ session.add(user)
1211
+ await session.flush() #to get the new users id.
1212
+ return req.response.json(UserData(id=user_id, fullname=user.fullname)).status(HttpStatus.OK)
1213
+
1214
+ @get("/<int:user_id>")
1215
+ @consumes(MediaType.APPLICATION_JSON)
1216
+ @produces(MediaType.APPLICATION_JSON)
1217
+ @db.readonly_session
1218
+ async def get_user(self, req: Request, user_id: int, session: AsyncSession) -> Response[UserData]:
1219
+ """Creates new user"""
1220
+ user: User = await User.query(session).filter_by(id=user_id).first()
1221
+ return req.response.json(UserData(id=user_id, fullname=user.fullname)).status(HttpStatus.OK)
1222
+ ```
1223
+
1224
+ The ***@managed_session*** decorator automatically injects the active session into the endpoint handler and runs the endpoint inside a session context, which handles
1225
+ session closure/commit and possible rollbacks in case of errors. The ***@readonly_session*** decorator injects the active session which can be used for read-only operations.
1226
+ No rollbacks or commits neccessary. The readonly session decorator prevents accidental writes (nothing is commited), has slightly lower overhead, fewer lock surprises and
1227
+ communicates a clear intent (reading data).
1228
+
1229
+ **session.flush()** will cause the session to perform the insert and fetch the objects id(s).
1230
+
1231
+ #### NoSQL
1232
+
1233
+ Besides SQL databases another popular solution are NoSQL databases like MongoDB. Patera supports them out of the box. To setup a NoSQL database you must provide the following configurations:
1234
+
1235
+ ```
1236
+ #configs.py
1237
+
1238
+ NOSQL_DATABASE = {
1239
+ "BACKEND": type #class of the selected NoSQL backend implementation. Example for MongoDB: from patera.database.nosql.backends import MongoBackend
1240
+ "DATABASE_URI": str #connection string. Example: mongodb+srv://<db_username>:<db_password>@cluster0.273gshd.mongodb.net
1241
+ "DATABASE": Optional[str]
1242
+ "DB_INJECT_NAME": str = "db" #name of the injected variable for managed sessions
1243
+ "SESSION_NAME": str = "session" #name of the injected session variable for managed sessions
1244
+ }
1245
+ ```
1246
+
1247
+ To use the NoSQL extension simply add it to the extension like this:
1248
+
1249
+ ```
1250
+ #extensions.py
1251
+ #other extensions
1252
+ from patera.database.nosql import NoSqlDatabase
1253
+
1254
+ nosqldb: NoSqlDatabase = NoSqlDatabase()
1255
+ ```
1256
+
1257
+ and then add the extension to the app configs
1258
+
1259
+ ```
1260
+ #configs.py
1261
+
1262
+ EXTENSIONS: list[str] = [
1263
+ #other extensions
1264
+ 'app.extensions:nosqldb',
1265
+ ]
1266
+ ```
1267
+
1268
+ This will initilize the extension and configure it. As usual, a config variable prefix can be supplied at instantiation: nosqldb: NoSqlDatabase = NoSqlDatabase(variable_prefix="MY_PREFIX_").
1269
+
1270
+ ##### Managed database transactions
1271
+
1272
+ To use a managed database transaction (scoped session) you can use the ***@managed_database*** decorator on controller endpoint handler methods.
1273
+
1274
+ ```
1275
+ #inside a controller
1276
+
1277
+ @post("/")
1278
+ @consumes(MediaType.APPLICATION_JSON)
1279
+ @produces(MediaType.APPLICATION_JSON)
1280
+ @nosqldb.managed_database
1281
+ async def create_user(self, req: Request, data: TestModel, db: Any, session: Any = None) -> Response[TestModelOut]:
1282
+ """Consumes and produces json"""
1283
+ #inserts a new document into collection
1284
+ await db.insert_one("<collection_name>", {"email": data.email, "fullname": data.fullname, "age": data.age}, session=session)
1285
+ return req.response.json({
1286
+ "message": "User added successfully",
1287
+ "status": "success"
1288
+ }).status(200)
1289
+ ```
1290
+
1291
+ The above usage of the ***managed_database*** decorator injects a db client handle and the corresponding session into the endpoint handler. You can pass the session object
1292
+ to all queries/inserts to scope them to the same session. This ensures that the entire transaction is rolled back in case of exceptions in one of the queries/inserts.
1293
+
1294
+ **session** objects are not available in all databases and therefore the injected session object is ***None*** in those cases. Please check if your database supports managed/scoped sessions. If managed/scoped sessions are not available everything still works, however, each query/insert is treated as an isolated call.
1295
+
1296
+ ##### Simple queries/inserts
1297
+
1298
+ If you wish to perform only one query, insert or delete (i.e. get/delete user by id or insert one user) you can simply use the instantiated NoSqlDatabase extension object (***nosqldb***) to call the desired query/insert/delete method.
1299
+
1300
+ ##### Methods and properties
1301
+ The extension exposes the following methods/properties:
1302
+
1303
+ ```
1304
+ @property
1305
+ def variable_prefix(self) -> str:
1306
+ return self._variable_prefix
1307
+
1308
+ @property
1309
+ def db_name(self) -> str:
1310
+ return self.__db_name__
1311
+
1312
+ @property
1313
+ def backend(self) -> AsyncNoSqlBackendBase:
1314
+ if not self._backend:
1315
+ raise RuntimeError("Backend not connected. Was init_app/connect called?")
1316
+ return self._backend
1317
+
1318
+ def database_handle(self) -> Any:
1319
+ return self.backend.database_handle()
1320
+
1321
+ def get_collection(self, name: str) -> Any:
1322
+ return self.backend.get_collection(name)
1323
+
1324
+ async def find_one(self, collection: str, filter: Mapping[str, Any], **kwargs) -> Any:
1325
+ return await self.backend.find_one(collection, filter, **kwargs)
1326
+
1327
+ async def find_many(self, collection: str, filter: Mapping[str, Any] | None = None, **kwargs) -> list[Any]:
1328
+ return await self.backend.find_many(collection, filter, **kwargs)
1329
+
1330
+ async def insert_one(self, collection: str, doc: Mapping[str, Any], **kwargs) -> Any:
1331
+ return await self.backend.insert_one(collection, doc, **kwargs)
1332
+
1333
+ async def insert_many(self, collection: str, docs: Iterable[Mapping[str, Any]], **kwargs) -> Any:
1334
+ return await self.backend.insert_many(collection, docs, **kwargs)
1335
+
1336
+ async def update_one(self, collection: str, filter: Mapping[str, Any], update: Mapping[str, Any], **kwargs) -> Any:
1337
+ return await self.backend.update_one(collection, filter, update, **kwargs)
1338
+
1339
+ async def delete_one(self, collection: str, filter: Mapping[str, Any], **kwargs) -> Any:
1340
+ return await self.backend.delete_one(collection, filter, **kwargs)
1341
+
1342
+ async def aggregate(self, collection: str, pipeline: Iterable[Mapping[str, Any]], **kwargs) -> list[Any]:
1343
+ return await self.backend.aggregate(collection, pipeline, **kwargs)
1344
+
1345
+ async def execute_raw(self, *args, **kwargs) -> Any:
1346
+ """
1347
+ Escape hatch for backend-specific commands. See MongoBackend.execute_raw docstring.
1348
+ """
1349
+ return await self.backend.execute_raw(*args, **kwargs)
1350
+ ```
1351
+
1352
+ Keep in mind that some aspects, like the ***execute_raw*** method are backend specific. They therefore depend on the selected backend (MongoDB etc).
1353
+
1354
+ ##### Custom backend implementations
1355
+
1356
+ To create a custom backend implementation create a class which extends and implements the ***AsyncNoSqlBackendBase*** abstract class. The abstract class can be imported as ***from patera.database.nosql.backends import AsyncNoSqlBackendBase***. After that, simply implement all required methods. The required methods are:
1357
+
1358
+ ```
1359
+ class AsyncNoSqlBackendBase(ABC):
1360
+ """
1361
+ Minimal async adapter interface a backend must implement.
1362
+ """
1363
+
1364
+ @classmethod
1365
+ @abstractmethod
1366
+ def configure_from_app(cls, app: "Patera", variable_prefix: str) -> "AsyncNoSqlBackendBase":
1367
+ """
1368
+ Classmethod to configure backend from app config.
1369
+ Called during NoSqlDatabase.init_app().
1370
+ """
1371
+ ...
1372
+
1373
+ @abstractmethod
1374
+ async def connect(self) -> None:
1375
+ ...
1376
+
1377
+ @abstractmethod
1378
+ async def disconnect(self) -> None:
1379
+ ...
1380
+
1381
+ @abstractmethod
1382
+ def database_handle(self) -> Any:
1383
+ """
1384
+ Returns an object representing the 'database' to use inside handlers.
1385
+ For backends without a database concept, return a client/root handle.
1386
+ """
1387
+ ...
1388
+
1389
+ @abstractmethod
1390
+ def supports_transactions(self) -> bool:
1391
+ ...
1392
+
1393
+ @abstractmethod
1394
+ async def start_session(self) -> Any:
1395
+ """
1396
+ Return a session/context object usable in transactions (or None if unsupported).
1397
+ """
1398
+ ...
1399
+
1400
+ @abstractmethod
1401
+ async def with_transaction(self, fn: Callable[..., Any], *args, session: Any = None, **kwargs) -> Any:
1402
+ """
1403
+ Execute fn inside a transaction if supported; otherwise call fn directly.
1404
+ """
1405
+ ...
1406
+
1407
+ @abstractmethod
1408
+ def get_collection(self, name: str) -> Any:
1409
+ ...
1410
+
1411
+ @abstractmethod
1412
+ async def find_one(self, collection: str, filter: Mapping[str, Any], *, session: Any = None, **kwargs) -> Any:
1413
+ ...
1414
+
1415
+ @abstractmethod
1416
+ async def find_many(self, collection: str, filter: Mapping[str, Any] | None = None, *, session: Any = None,
1417
+ limit: Optional[int] = None, skip: Optional[int] = None, sort: Optional[Iterable[tuple[str, int]]] = None,
1418
+ **kwargs) -> list[Any]:
1419
+ ...
1420
+
1421
+ @abstractmethod
1422
+ async def insert_one(self, collection: str, doc: Mapping[str, Any], *, session: Any = None, **kwargs) -> Any:
1423
+ ...
1424
+
1425
+ @abstractmethod
1426
+ async def insert_many(self, collection: str, docs: Iterable[Mapping[str, Any]], *, session: Any = None, **kwargs) -> Any:
1427
+ ...
1428
+
1429
+ @abstractmethod
1430
+ async def update_one(self, collection: str, filter: Mapping[str, Any], update: Mapping[str, Any], *,
1431
+ upsert: bool = False, session: Any = None, **kwargs) -> Any:
1432
+ ...
1433
+
1434
+ @abstractmethod
1435
+ async def delete_one(self, collection: str, filter: Mapping[str, Any], *, session: Any = None, **kwargs) -> Any:
1436
+ ...
1437
+
1438
+ @abstractmethod
1439
+ async def aggregate(self, collection: str, pipeline: Iterable[Mapping[str, Any]], *,
1440
+ session: Any = None, **kwargs) -> list[Any]:
1441
+ ...
1442
+
1443
+ @abstractmethod
1444
+ async def execute_raw(self, *args, **kwargs) -> Any:
1445
+ """
1446
+ Backend escape hatch for commands that don't fit the generic surface.
1447
+ For MongoDB, this could be db.command(...), collection.bulk_write(...), etc.
1448
+ """
1449
+ ...
1450
+ ```
1451
+
1452
+ The specific implementation for each database backend type will differ. Have a look at the ***patera.database.nosql.backend.mongo_backend*** for MongoDB.
1453
+
1454
+ ##### MongoDB
1455
+ To use MongoDB as the backend you will have to install the following dependencies:
1456
+
1457
+ ```
1458
+ uv add motor
1459
+ uv add "mongodb[srv]"
1460
+ ```
1461
+
1462
+ ## User Authentication
1463
+
1464
+ To setup user authentication and protection of controller endpoints use the authentication extension.
1465
+
1466
+ ```
1467
+ #authentication.py <- next to extensions.py
1468
+
1469
+ from enum import StrEnum
1470
+ from typing import Optional
1471
+ from patera import Request
1472
+ from patera.auth import Authentication
1473
+
1474
+ from app.extensions import db
1475
+ from app.api.models import User
1476
+
1477
+ class UserRoles(StrEnum):
1478
+ ADMIN = "admin"
1479
+ SUPERUSER = "superuser"
1480
+ USER = "user"
1481
+
1482
+ class Auth(Authentication):
1483
+
1484
+ async def user_loader(self, req: Request) -> Optional[User]:
1485
+ """Loads user from the provided cookie"""
1486
+ cookie_header = req.headers.get("cookie", "")
1487
+ if cookie_header:
1488
+ # Split the cookie string on semicolons and equals signs to extract individual cookies
1489
+ cookies = dict(cookie.strip().split('=', 1) for cookie in cookie_header.split(';'))
1490
+ auth_cookie = cookies.get("auth_cookie")
1491
+ if auth_cookie:
1492
+ user_id = self.decode_signed_cookie(auth_cookie)
1493
+ if user_id:
1494
+ session = db.create_session()
1495
+ user = await User.query(session).filter_by(id=user_id).first()
1496
+ await session.close()
1497
+ return user
1498
+ return None
1499
+
1500
+ async def role_check(self, user: User, roles: list[UserRoles]) -> bool:
1501
+ """Checks intersection of user roles and required roles"""
1502
+ user_roles = set([role.role for role in user.roles])
1503
+ return len(user_roles.intersection(set(roles))) > 0
1504
+
1505
+ auth: Auth = Auth()
1506
+ ```
1507
+
1508
+ The Auth class inherits from the Patera Authentication class. The user must implement the user_loader and role_check (optional) methods.
1509
+ These methods provide logic for loading a user when a protected endpoint is requested and checking if the user has permissions.
1510
+ Above is an example which loads the user from a cookie. If the user is not found an AuthenticationException is raised which can be handled
1511
+ in the CustomExceptionHandler. If the user doesn't have required roles (role_check -> False) an UnauthorizedException exception is raised
1512
+ which can be also handled in the CustomExceptionHandler.
1513
+
1514
+ The instantiated Auth class must be added to the application configs.
1515
+
1516
+ ```
1517
+ EXTENSIONS: List[str] = [
1518
+ 'app.extensions:db',
1519
+ 'app.extensions:migrate',
1520
+ 'app.authentication:auth'
1521
+ ]
1522
+ ```
1523
+
1524
+ Controller endpoints can be protected with two decorators like this:
1525
+
1526
+ ```
1527
+ @get("/<int:user_id>")
1528
+ @produces(MediaType.APPLICATION_JSON)
1529
+ @auth.login_required
1530
+ @auth.role_required(UserRoles.ADMIN, UserRoles.SUPERUSER)
1531
+ async def get_user(self, req: Request, user_id: int) -> Response[UserData]:
1532
+ """Returns a user by user_id"""
1533
+ session = db.create_session()
1534
+ user: User = await User.query(session).filter_by(id=user_id).first()
1535
+ await session.close()
1536
+
1537
+ return req.response.json(UserData(id=user_id, fullname=user.fullname, email=user.email)).status(HttpStatus.OK)
1538
+ ```
1539
+
1540
+ If using the @auth.role_required decorator you MUST also use the @auth.login_required decorator. The login_required
1541
+ decorator calls the user_loader method and attaches the loaded user object to the Request object: **req.user**.
1542
+ The above role_check implementation assumes that there is a one-to-many relationship on the User and Role (not shown) models.
1543
+
1544
+ The Authentication extension can be configured with the following options:
1545
+
1546
+ ```
1547
+ AUTHENTICATION = {
1548
+ "AUTHENTICATION_ERROR_MESSAGE": str = "Login required" #message of the raised exception
1549
+ "UNAUTHORIZED_ERROR_MESSAGE": str = "Missing user role(s)" #message of the raised exception
1550
+ }
1551
+ ```
1552
+
1553
+ The auth instance exposes other useful methods for easy user authentication:
1554
+
1555
+ ```
1556
+ auth.create_signed_cookie_value(self, value: str|int) -> str #creates a signed cookie
1557
+ auth.decode_signed_cookie(self, cookie_value: str) -> str #decodes signed cookie
1558
+ auth.create_password_hash(self, password: str) -> str #creates a password hash
1559
+ auth.check_password_hash(self, password: str, hashed_password: str) -> bool #check password hash against provided password
1560
+ auth.create_jwt_token(self, payload: Dict, expires_in: int = 3600) -> str #creates a JWT string
1561
+ auth.validate_jwt_token(self, token: str) -> Dict|None #validates JWT string (from request)
1562
+ ```
1563
+
1564
+ The decode_signed_cookie method is used in the above user_loader example.
1565
+
1566
+ ### Update - @login_required and @role_required with Controller classes
1567
+
1568
+ Both authentication related decorators can now be used on controller classes to protect all endpoints simultaneusly instead of each individual endpoint.
1569
+ This is useful for classes serving resources for which the user always has to be authenticated and for classes related to administrator tasks where the user always has to
1570
+ be authenticated and also have specific authorizations/roles (i.e. Admin). Usage:
1571
+
1572
+ ```
1573
+
1574
+ @path("/api/v1/users", tags=["Users"])
1575
+ @login_required
1576
+ @role_required(*roles)
1577
+ class UsersApi(Controller):
1578
+ """All endpoints are protected"""
1579
+ ...
1580
+ ```
1581
+
1582
+ The decorators are added to the classes methods list which is executed upon each request even before the @before_request methods. The execution order of the methods is
1583
+ top-bottom so make sure the @login_required decorator is above the @role_required decorator to load the user before checking roles.
1584
+
1585
+ ## Task scheduling
1586
+
1587
+ The task_manager extensions allows for easy management of tasks that should run periodically or running of one-time fire&forget methods.
1588
+ To use the extension you have to install the neccessary dependencies with:
1589
+
1590
+ ```
1591
+ uv add "patera[scheduler]"
1592
+ ```
1593
+
1594
+ The extension can be setup like this:
1595
+
1596
+ ```
1597
+ #scheduler.py <- next to __init__.py
1598
+
1599
+ from patera.task_manager import TaskManager, schedule_job
1600
+
1601
+ class Scheduler(TaskManager):
1602
+
1603
+ @schedule_job("interval", minutes=1, id="my_job")
1604
+ async def some_task(self):
1605
+ print("Performing task")
1606
+
1607
+ scheduler: Scheduler = Scheduler()
1608
+ ```
1609
+
1610
+ It can then be added to application configs like the Authentication extension.
1611
+
1612
+ ```
1613
+ EXTENSIONS: List[str] = [
1614
+ 'app.extensions:db',
1615
+ 'app.extensions:migrate',
1616
+ 'app.authentication:auth',
1617
+ 'app.scheduler:scheduler'
1618
+ ]
1619
+ ```
1620
+
1621
+ All methods defined in the Scheduler class and decorated with the @schedule_job decorator will be run with provided parameters. The extension uses the APScheduler
1622
+ module we therefore recommend you take a look at their documentation for more details about job scheduling. In the above example, the "some_task" method will run
1623
+ as an interval method every minute. To use the extension to run fire&forget methods (like sending emails) where we don't neccessary have to wait for the method to finish
1624
+ we can use the run_background_task method:
1625
+
1626
+ ```
1627
+ from app.scheduler import scheduler
1628
+
1629
+
1630
+ @post("/")
1631
+ @consumes(MediaType.APPLICATION_JSON)
1632
+ @produces(MediaType.APPLICATION_JSON)
1633
+ async def get_user(self, req: Request, user_data: UserData) -> Response[UserData]:
1634
+ """Creates new user"""
1635
+ user: User = User(fullname=user_data.fullname, email=user_data.email)
1636
+ session = db.create_session()
1637
+ session.add(user)
1638
+ await session.commit()
1639
+
1640
+ scheduler.run_background_task(send_email, *args, **kwargs) #args and kwargs are any number or arguments and keyword arguments that the send_mail method might need
1641
+ return req.response.json(UserData(id=user_id, fullname=user.fullname)).status(HttpStatus.OK)
1642
+ ```
1643
+
1644
+ This kicks off the send_email method without waiting for it to finish.
1645
+
1646
+ The extension accepts the following configuration options via the application (indicated are defaults):
1647
+
1648
+ ```
1649
+ TASK_MANAGER = {
1650
+ "JOB_STORES": {
1651
+ "default": MemoryJobStore()
1652
+ },
1653
+ "EXECUTORS": {
1654
+ "default": AsyncIOExecutor()
1655
+ },
1656
+ "JOB_DEFAULTS": {
1657
+ "coalesce": False,
1658
+ "max_instances": 3
1659
+ },
1660
+ "DAEMON": True,
1661
+ "SCHEDULER": AsyncIOScheduler
1662
+ }
1663
+ ```
1664
+
1665
+ The scheduler object exposes a number of methods which can be used to manipulate ongoing scheduled tasks:
1666
+
1667
+ ```
1668
+ scheduler.add_job(self, func: Callable, *args, **kwargs) -> Job #adds a Job to the scheduler
1669
+ scheduler.remove_job(self, job: str|Job, job_store: Optional[str] = None) #removes job from scheduler by its id:str or the Job object
1670
+ scheduler.pause_job(self, job: str|Job) #pauses a running job by job id:str or the Job object
1671
+ scheduler.resume_job(self, job: str|Job) #resumes a job by job id:str or the Job object
1672
+ scheduler.get_job(self, job_id: str) -> Job|None #returns the job if it exists
1673
+ ```
1674
+
1675
+ ## Caching
1676
+
1677
+ Caching is a simple method to increase the throughput of applications. It stores responses of frequently requested resources whos data
1678
+ doesn't change often. An example would be fetching all users of an app, where new users are not added often. Why do database queries for each request if the query result is always going to be the same. To prevent unneccessary database queries the controller endpoint response can be cached with the caching extensions.
1679
+
1680
+ After this, you can add the extension to your app with:
1681
+
1682
+ ```
1683
+ #extensions.py <-next to __init__.py
1684
+
1685
+ from patera.caching import Cache
1686
+
1687
+ #other extensions
1688
+ cache: Cache = Cache() #can also add a variable prefix to specify a configs namespace for using multiple caching instances.
1689
+ # cache: Cache = Cache(variable_prefix = "MY_CACHE_")
1690
+ ```
1691
+
1692
+ and then you can add the instantiated extension to application configs:
1693
+
1694
+ ```
1695
+ EXTENSIONS: List[str] = [
1696
+ 'app.extensions:db',
1697
+ 'app.extensions:migrate',
1698
+ 'app.authentication:auth',
1699
+ 'app.scheduler:scheduler',
1700
+ 'app.extensions:cache'
1701
+ ]
1702
+ ```
1703
+
1704
+ The cache can use in-memory caching (default), SQLite database or Redis. To use the in-memory cache no configurations are strictly neccessary.
1705
+ Available configurations:
1706
+
1707
+ ```
1708
+ CACHE = {
1709
+ BACKEND: Type[BaseCacheBackend] = MemoryCacheBackend
1710
+ REDIS_URL: str
1711
+ DURATION: int = 300 #cache duration in seconds - with default 300 s
1712
+ REDIS_PASSWORD: str
1713
+ KEY_PREFIX: Optional[str] #for using a namespace in a Redis/SQLite db (if multiple applications use the db)
1714
+ SQLITE_PATH: Optional[str] = "./pyjolt_cache.db" - SQLite cache only
1715
+ SQLITE_TABLE: Optional[str] = "cache_entries" #name of cache table in SQLite - SQLite cache only
1716
+ SQLITE_WAL_CHECKPOINT_MODE: Optional[str] = "PASSIVE" #Mode for WAL checkpointing: PASSIVE|FULL|RESTART|TRUNCATE - SQLite cache only
1717
+ SQLITE_WAL_CHECKPOINT_EVERY: Optional[int] = 100 #Insert WAL checkpoint every N write operations - SQLite cache only
1718
+ }
1719
+ ```
1720
+
1721
+ Only the default cache duration can be set if using in-memory/SQLite caching. The default value is 300 seconds.
1722
+ When using a variable prefix, the configs look like: "MY_PREFIX_CACHE_BACKEND", if "MY_PREFIX_" is passed as the prefix variable.
1723
+
1724
+ Once configured the caching extension can be used like this in controller endpoints:
1725
+
1726
+ ```
1727
+ @get("/<int:user_id>")
1728
+ @produces(MediaType.APPLICATION_JSON)
1729
+ @cache.cache(duration=300)#default is 300 so this is not needed
1730
+ async def get_user(self, req: Request, user_id: int) -> Response[UserData]:
1731
+ """Returns a user by user_id"""
1732
+ user: User = await User.query().filter_by(id=user_id).first()
1733
+
1734
+ return req.response.json(UserData(id=user_id, fullname=user.fullname, email=user.email)).status(HttpStatus.OK)
1735
+ ```
1736
+
1737
+ **The @cache.cache decorator MUST be applied as the bottom-most decorator** to make sure it caches the result of the actual
1738
+ endpoint function and NOT results of other decorators. This is especially crucial if using authentication.
1739
+
1740
+ The caching extension stores the result of the endpoint by creating a key-value pair, where the key is a combination
1741
+ of the endpoint function name and route parameters. This makes sure that the endpoint stores the response for user_id=1 and user_id=2
1742
+ seperately.
1743
+
1744
+ The extension exposes several methods on the cache object which allows for manual manipulation of the cache:
1745
+
1746
+ ```
1747
+ cache.set(key: str, value: Response, duration: Optional[int]) -> None #sets a cached key-value pair
1748
+ cache.get(key: str) -> Dict #gets the cache value for the provided key
1749
+ cache.delete(key: str) -> None #removes cache entry for the provided key
1750
+ cache.clear() -> None #clears entire cache
1751
+ ```
1752
+
1753
+ ### Custom caching backends
1754
+
1755
+ To create a custom caching backend you have to create a class which inherits and satisfies the ***BaseCacheBackend*** abstract class.
1756
+ Simply inherit from this class and implement the following methods:
1757
+
1758
+ ```
1759
+ #patera.caching
1760
+
1761
+ class BaseCacheBackend(ABC):
1762
+ """
1763
+ Abstract cache backend blueprint.
1764
+
1765
+ Subclasses should implement:
1766
+ - configure_from_app(cls, app) -> BaseCacheBackend
1767
+ - connect / disconnect
1768
+ - get / set / delete / clear
1769
+ """
1770
+
1771
+ @classmethod
1772
+ @abstractmethod
1773
+ def configure_from_app(cls, app: "Patera", variable_prefix: str) -> "BaseCacheBackend":
1774
+ """Create a configured backend instance using app config."""
1775
+
1776
+ @abstractmethod
1777
+ async def connect(self) -> None:
1778
+ """Establish any required connections (no-op for memory)."""
1779
+
1780
+ @abstractmethod
1781
+ async def disconnect(self) -> None:
1782
+ """Tear down connections (no-op for memory)."""
1783
+
1784
+ @abstractmethod
1785
+ async def get(self, key: str) -> Optional[dict]:
1786
+ """Return cached payload dict or None."""
1787
+
1788
+ @abstractmethod
1789
+ async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
1790
+ """Store payload dict under key with optional TTL in seconds."""
1791
+
1792
+ @abstractmethod
1793
+ async def delete(self, key: str) -> None:
1794
+ """Delete a cached entry if present."""
1795
+
1796
+ @abstractmethod
1797
+ async def clear(self) -> None:
1798
+ """Clear the entire cache namespace."""
1799
+ ```
1800
+
1801
+ Once you implement the class according to specifications (from patera.caching import BaseCacheBackend), simply pass it as the config parameter ("CACHE_BACKEND") and use it.
1802
+
1803
+ ## AI Interface (Experimental!)
1804
+
1805
+ The AI Interface extension helps the user integrate a chat interface to popular vendors with ChatGPT compatible api's seemlesly. You must first install the needed dependencies with:
1806
+
1807
+ ```
1808
+ uv add "patera[ai_interface]"
1809
+ ```
1810
+
1811
+ This will install OpenAi, Torch, Numpy, Sentence-transformers and pgvector libraries. These are neccessary for all required funcionality. With this, you will be able to connect to any ChatGPT compatible api like Groq, xAI, Perplexity (Sonar), Google Gemini and locally hosten Ollama, LM Studio or VLLM.
1812
+
1813
+ The extension accepts several configurations which are listed below (with defaults):
1814
+
1815
+ ```
1816
+ AI_INTERFACE = {
1817
+ API_KEY: str #required
1818
+ API_BASE_URL: Optional[str] = "https://api.openai.com/v1" #points to the OpenAi compatible api of the service
1819
+ ORGANIZATION_ID: Optional[str] = None
1820
+ PROJECT_ID: Optional[str] = None
1821
+ TIMEOUT: Optional[int] = 30
1822
+ MODEL: Optional[str] = "gpt-3.5-turbo" #model that is used
1823
+ TEMPERATURE: Optional[float] = 1.0 #temperature (randomness) of the used model. For higher "creativity"
1824
+ RESPONSE_FORMAT: Optional[dict[str, str]] = {"type": "json_object"} #format of the return object
1825
+ TOOL_CHOICE: Optional[bool] = False #if AI tools can be used
1826
+ MAX_RETRIES: Optional[int] = 0 #number of retries in case of failure
1827
+ CHAT_CONTEXT_NAME: Optional[str] = "chat_context" #name of the injected chat context varible
1828
+ }
1829
+ ```
1830
+
1831
+ To implement the interface:
1832
+
1833
+ ```
1834
+ #ai_interface.py #next to __init__.py
1835
+
1836
+ from typing import Optional
1837
+
1838
+ from app.api.models.chat_session import ChatSession
1839
+ from app.extensions import db
1840
+
1841
+ from patera.database import AsyncSession
1842
+ from patera.ai_interface import AiInterface
1843
+ from patera.request import Request
1844
+
1845
+
1846
+ class Interface(AiInterface):
1847
+
1848
+ @db.managed_session
1849
+ async def chat_context_loader(self, req: Request,
1850
+ session: AsyncSession) -> Optional[ChatSession]:
1851
+ chat_session_id: Optional[int] = req.route_parameters.get("chat_session_id",
1852
+ None)
1853
+ if chat_session_id is None:
1854
+ return None
1855
+ return await ChatSession.query(session).filter_by(id = chat_session_id).first()
1856
+
1857
+ ai_interface: Interface = Interface()
1858
+ ```
1859
+
1860
+ Then simply include the ai_interface in the application configs like before to load and register it with the app:
1861
+
1862
+ ```
1863
+ #configs.py
1864
+
1865
+ EXTENSIONS: list[str] = [
1866
+ 'app.extensions:db',
1867
+ 'app.extensions:migrate',
1868
+ 'app.authentication:auth',
1869
+ 'app.ai_interface:ai_interface'
1870
+ ]
1871
+ ```
1872
+
1873
+ When implementing the interface you have to provide the ***chat_context_loader*** method which at minimum accepts the ***self*** argument pointing at the extension (has access to the application via ***self.app***) and the current request. The above example
1874
+ also adds the ***@db.managed_session*** decorator for automatic injection and handling of database sessions. The implemented method must return None (when the chat context was not found) or the chat context object (database model). If the method returns None, the extension raises a ChatContextNotFound exception (from patera.ai_interface import ChatContextNotFound). This error can simply be handled in the ExceptionHandler implementation (see above).
1875
+
1876
+ If the method returns a valid object (not None), the object is injected into the endpoint handler method with the configured chat context name (default: "chat_context"). This helps with loading existing chat contexts and keeps the endpoint handlers lean.
1877
+
1878
+ ### AI Tools
1879
+
1880
+ You can also expose certain functions to the AI interface which can be called directly by the AI. This is useful to run methods like getting the current weather in a location. The exposed methods must be declared inside the interface class (next to the chat_context_loader) and decorated with the ***@tool*** decorator. Example:
1881
+
1882
+ ```
1883
+ from patera.ai_interface import tool
1884
+
1885
+ class Interface(AiInterface):
1886
+
1887
+ #chat_context_loader implementation
1888
+
1889
+ @tool(name = "method_name", description: "method_description")
1890
+ async def some_tool(self, arg1: str, arg2: str) -> Any:
1891
+ """some tool logic"
1892
+ ```
1893
+
1894
+ The above example exposes the method ***some_method*** to the AI interface. The decorator ***@tool*** accepts to optional arguments (name and description). If none are provided the actual method name is used and the doc string for the description. The description helps the AI interface (the called LLM) determine which method should be called. Therefore it is recommended to provide concise and accurate descriptions. The exposed method is not just exposed but also analyzed and a method metadata object is constructed which also provides details about the implemented method (arguments, arguments types etc.). With this added metadata the AI (called LLM) can determine which arguments it must pass to the method or if any arguments are missing.
1895
+
1896
+ If execution of the tool method failes for whatever reason, a "FailedToRunAiToolMethod" exception is raised which can be handled in the ExceptionHandler implementation.
1897
+
1898
+ The number of method tools is not limited, however, we recommend to seperate them into subclasses which the main interface class can inherit from (in addition to the AiInterface class). In this way, you can keep the tools logically grouped.
1899
+
1900
+ ## Email client
1901
+
1902
+ The email client extension can be used for sending emails using the ***aiosmtplib*** package. You can simply initilize the extension:
1903
+
1904
+ ```
1905
+ #app/extensions.py
1906
+ from patera.email import EmailClient
1907
+
1908
+ email_client: EmailClient = EmailClient(configs_name = "EMAIL_CLIENT") #configs_name="EMAIL_CLIENT" is the default and can be omitted
1909
+ ```
1910
+
1911
+ You then register the extension in the application configs:
1912
+ ```
1913
+ #app/configs.py
1914
+
1915
+ EXTENSIONS: list[str] = [
1916
+ .
1917
+ .
1918
+ .
1919
+ "app.extension:email_client"
1920
+ ]
1921
+ ```
1922
+
1923
+ You have to provide certain configurations for the client:
1924
+
1925
+ ```
1926
+ #app/configs.py
1927
+
1928
+ EMAIL_CLIENT: dict[str, str|int|bool] = {
1929
+ "SENDER_NAME_OR_ADDRESS": str #the name or email that is used for the sender
1930
+ "SMTP_SERVER": str #url of the smtp server
1931
+ "SMTP_PORT" int #port of the smtp server
1932
+ "USERNAME": str #username for the used email account
1933
+ "PASSWORD": str #password of the used email account
1934
+ "USE_TLS": bool = True #if tls encryption should be used. Default = True
1935
+ }
1936
+
1937
+ ```
1938
+
1939
+ Once registered and configured the client can be used in any endpoint/method like this:
1940
+
1941
+ ```
1942
+ #inside endpoint method:
1943
+
1944
+ await email_client.send_email(to_address: str|list[str], subject: str, body: str, attachments: Optional[dict[str, bytes]] = None) -> None
1945
+ await email_client.send_email_with_template(to_address: str|list[str], subject: str, template_path: str, attachments: Optional[dict[str, bytes]] = None, context: Optional[dict[str, Any]] = None) -> None
1946
+ ```
1947
+
1948
+ The first method sends the string body and the second method uses the template at the provided path (same as in template html responses)
1949
+
1950
+ ## Command line interface
1951
+
1952
+ If you wish you can create command line interface utility methods to help with application maintanence. To do so you have to use the CLIController class:
1953
+
1954
+ ```
1955
+ #app/cli/cli_controller.py
1956
+
1957
+ from patera.cli import CLIController, command, argument
1958
+
1959
+ class UtilityCLIController(CLIController):
1960
+ """A simple CLI utility controller."""
1961
+
1962
+ @command("greet", help="Greet a user with a message.")
1963
+ @argument("name", arg_type=str, description="The name of the user to greet.")
1964
+ async def greet(self, name: str):
1965
+ """Greet by name."""
1966
+ print(f"Hello, {name}! Welcome to the CLI utility.")
1967
+
1968
+ @command("add", help="Add two numbers.")
1969
+ @argument("a", arg_type=int, description="The first number.")
1970
+ @argument("b", arg_type=int, description="The second number.")
1971
+ async def add(self, a: int, b: int):
1972
+ """Add two numbers and print the result."""
1973
+ result = a + b
1974
+ print(f"The sum of {a} and {b} is {result}.")
1975
+ ```
1976
+
1977
+ In this controller you can add as many cli method as you wish with the use of the @command and @argument decorators. The ***self*** keyword points at the controller instance which has access to the application instance (***self.app: Patera***).
1978
+ Each command method requires the @command decorator, but the @argument decorator(s) are optional depending on if the method needs input from the user or not.
1979
+
1980
+ ### @command
1981
+ The @command decorator requires a coommand_name: str argument under which the command will be accessible. You can also provide a ***help*** argument detailing the purpose of the method and options.
1982
+
1983
+ ### @argument
1984
+ You can add as many @argument decorators as you wish to a method. This decorator tells the parser what arguments (name) to except and in what data type these arguments are going to be. Patera automatically casts arguments
1985
+ into the provided type. Allowed types are ***int***, ***float*** and ***str***.
1986
+
1987
+ After you have created the CLI controller you have to register it with the application. To do so you have to add it in the application configurations
1988
+
1989
+ ```
1990
+ CLI_CONTROLLERS: List[str] = [
1991
+ 'app.cli.cli_controller:UtilityCLIController' #path:CLIController
1992
+ ]
1993
+ ```
1994
+
1995
+ ## Middleware
1996
+
1997
+ Middleware can be useful for anything from logging to measuring performance or modifying requests/responses. To use middleware in your Patera app you have to create a middleware class
1998
+
1999
+ ```
2000
+ #app/middleware/timing_mw.py
2001
+
2002
+ import time
2003
+ from patera.middleware import MiddlewareBase
2004
+ from patera.request import Request
2005
+ from patera.response import Response
2006
+
2007
+ class TimingMW(MiddlewareBase):
2008
+ async def middleware(self, req: Request) -> Response:
2009
+ t0 = time.perf_counter()
2010
+ res = await self.next(req) # pass down
2011
+ res.headers["x-process-time-ms"] = str(int((time.perf_counter() - t0)*1000))
2012
+ return res
2013
+ ```
2014
+
2015
+ This class must inherit from MiddlewareBase and define an ***async def middleware(self, req: Request) -> Response*** method. The example measures how long it takes to process the request and adds an "x-process-time-ms" header to the response. Each middleware must return a Response (either by returning one directly - short-circuit, or by awaiting self.next(req) and returning that result).
2016
+
2017
+ To add the middleware to the application you simply register it by adding it to the configurations of the app:
2018
+
2019
+ ```
2020
+ #configs.py
2021
+
2022
+ MIDDLEWARE: list[str] = [
2023
+ 'app.middleware.timing_mw:TimingMW'
2024
+ ]
2025
+ ```
2026
+
2027
+ **Middleware order note**
2028
+ Middleware wraps the base application in reverse order of the provided list, so the **first element** is the **outermost** wrapper.
2029
+
2030
+ #### Exception handling in middleware
2031
+ Middleware runs in the same call chain as endpoint handlers. If your middleware raises, the framework catches it and dispatches to any registered exception handlers. If you handle the error inside the middleware and return a Response, exception handlers will not run. To attach data (e.g., timing) even on errors, store it on req.state: Any in a finally block and read it in your exception handler.
2032
+
2033
+ **Note**
2034
+ Middleware is useful when you wish to run some functionality for every request. For more fine-grained functionality we recommend using before/after request handlers in controllers or decorators on endpoint handlers.
2035
+
2036
+ ## Testing
2037
+
2038
+ Patera uses Pytest for running tests. For creating tests use the PyJoltTestClient object from ***patera.testing***.
2039
+ We recommend creating a ***tests*** folder inside your ***app*** directory (next to templates and static folders). You may organize tests differently as long as you follow Pytest’s discovery rules.
2040
+
2041
+ ### Configuring test client
2042
+
2043
+ Inside the ***tests*** folder create a **conftest.py** file with the following content:
2044
+
2045
+ ```
2046
+ # tests/conftest.py
2047
+
2048
+ import pytest
2049
+ from patera.testing import PyJoltTestClient
2050
+ from app import Application
2051
+
2052
+ @pytest.fixture
2053
+ async def application():
2054
+ yield Application()
2055
+
2056
+ @pytest.fixture
2057
+ async def client(application):
2058
+ async with PyJoltTestClient(application) as c:
2059
+ yield c
2060
+ ```
2061
+
2062
+ this creates and yields the test client for use in all test methods. The test client manages lifespan events using asgi_lifespan, so any startup/shutdown hooks will work just like when running the app with Uvicorn.
2063
+ Inside the ***tests*** folder you can create as many test files as you wish. To can also organize them into subfolders as long as you follow **Pytest naming conventions**. An example test file is:
2064
+
2065
+ ```
2066
+ #tests/test_user_api.py
2067
+
2068
+ async def test_get_users(client):
2069
+ res = await client.get("/api/v1/users")
2070
+ assert res.status_code == 200
2071
+
2072
+ ```
2073
+
2074
+ In this file there is a single method (test_get_users) which gets the PyJoltTestClient automatically injected. It makes a GET request to the "/api/v1/users" endpoint and asserts that the response
2075
+ status code is 200 (OK). If the assertion fails the test fails.
2076
+
2077
+ ### Running tests
2078
+
2079
+ If you use uv for dependency management you can run all specified tests with the following command:
2080
+
2081
+ ```
2082
+ uv run --env-file path/to/.env pytest
2083
+ ```
2084
+
2085
+ this will load environmental variables and run pytest. Pytest automatically detects the ***tests** folder and all specified tests (if proper naming conventions are followed). If you don't use .env files
2086
+ you can ommit "--env-file path/to/.env".
2087
+
2088
+ #### Pytest configs
2089
+
2090
+ If using ***uv*** for dependency management you can add configurations for Pytest to the pyproject.toml file. Otherwise, look up configuration handling. uv example:
2091
+
2092
+ ```
2093
+ [tool.pytest.ini_options]
2094
+ asyncio_mode = "auto"
2095
+ asyncio_default_fixture_loop_scope = "function"
2096
+ ```
2097
+
2098
+ ## Benchmarks
2099
+
2100
+ A simple test using Apache Bench, hitting the endpoint (example app) ***/api/v1/users*** shows the following results:
2101
+
2102
+ ```
2103
+ (Patera) marko@Markos-MacBook-Air Patera % ab -k -c 200 -n 2000 http://localhost:8080/api/v1/users
2104
+ This is ApacheBench, Version 2.3 <$Revision: 1923142 $>
2105
+ Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
2106
+ Licensed to The Apache Software Foundation, http://www.apache.org/
2107
+
2108
+ Benchmarking localhost (be patient)
2109
+ Completed 200 requests
2110
+ Completed 400 requests
2111
+ Completed 600 requests
2112
+ Completed 800 requests
2113
+ Completed 1000 requests
2114
+ Completed 1200 requests
2115
+ Completed 1400 requests
2116
+ Completed 1600 requests
2117
+ Completed 1800 requests
2118
+ Completed 2000 requests
2119
+ Finished 2000 requests
2120
+
2121
+
2122
+ Server Software: uvicorn
2123
+ Server Hostname: localhost
2124
+ Server Port: 8080
2125
+
2126
+ Document Path: /api/v1/users
2127
+ Document Length: 139 bytes
2128
+
2129
+ Concurrency Level: 200
2130
+ Time taken for tests: 1.845 seconds
2131
+ Complete requests: 2000
2132
+ Failed requests: 0
2133
+ Keep-Alive requests: 0
2134
+ Total transferred: 573561 bytes
2135
+ HTML transferred: 278000 bytes
2136
+ Requests per second: 1083.84 [#/sec] (mean)
2137
+ Time per request: 184.529 [ms] (mean)
2138
+ Time per request: 0.923 [ms] (mean, across all concurrent requests)
2139
+ Transfer rate: 303.54 [Kbytes/sec] received
2140
+
2141
+ Connection Times (ms)
2142
+ min mean[+/-sd] median max
2143
+ Connect: 0 0 0.7 0 3
2144
+ Processing: 6 178 114.2 177 1065
2145
+ Waiting: 2 178 114.2 177 1064
2146
+ Total: 6 179 114.3 177 1067
2147
+
2148
+ Percentage of the requests served within a certain time (ms)
2149
+ 50% 177
2150
+ 66% 194
2151
+ 75% 202
2152
+ 80% 214
2153
+ 90% 341
2154
+ 95% 377
2155
+ 98% 512
2156
+ 99% 534
2157
+ 100% 1067 (longest request)
2158
+ ```
2159
+
2160
+ The test was performed on ***localhost*** with 200 concurrent requests and 2000 total requests. The endpoint performs a simple query (SQLite database) to fetch all users.