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.
- patera-0.111.13/PKG-INFO +2160 -0
- patera-0.111.13/README.md +2094 -0
- patera-0.111.13/pyproject.toml +116 -0
- patera-0.111.13/src/patera/__init__.py +40 -0
- patera-0.111.13/src/patera/base_extension.py +53 -0
- patera-0.111.13/src/patera/cli/__init__.py +8 -0
- patera-0.111.13/src/patera/cli/cli.py +83 -0
- patera-0.111.13/src/patera/cli/cli_controller.py +130 -0
- patera-0.111.13/src/patera/cli/start_project.py +332 -0
- patera-0.111.13/src/patera/configuration_base.py +202 -0
- patera-0.111.13/src/patera/controller/__init__.py +39 -0
- patera-0.111.13/src/patera/controller/controller.py +160 -0
- patera-0.111.13/src/patera/controller/decorators.py +378 -0
- patera-0.111.13/src/patera/controller/utilities.py +111 -0
- patera-0.111.13/src/patera/cors/__init__.py +1 -0
- patera-0.111.13/src/patera/cors/cors_mw.py +199 -0
- patera-0.111.13/src/patera/exceptions/__init__.py +35 -0
- patera-0.111.13/src/patera/exceptions/exception_handler.py +71 -0
- patera-0.111.13/src/patera/exceptions/http_exceptions.py +102 -0
- patera-0.111.13/src/patera/exceptions/runtime_exceptions.py +25 -0
- patera-0.111.13/src/patera/graphics/patera_logo.png +0 -0
- patera-0.111.13/src/patera/http_methods.py +14 -0
- patera-0.111.13/src/patera/http_statuses.py +79 -0
- patera-0.111.13/src/patera/logger.py +9 -0
- patera-0.111.13/src/patera/logging/__init__.py +31 -0
- patera-0.111.13/src/patera/logging/inmemory_buffer.py +63 -0
- patera-0.111.13/src/patera/logging/logger_config_base.py +353 -0
- patera-0.111.13/src/patera/media_types.py +28 -0
- patera-0.111.13/src/patera/middleware.py +76 -0
- patera-0.111.13/src/patera/open_api.py +392 -0
- patera-0.111.13/src/patera/patera.py +944 -0
- patera-0.111.13/src/patera/request.py +500 -0
- patera-0.111.13/src/patera/response.py +322 -0
- patera-0.111.13/src/patera/router.py +46 -0
- patera-0.111.13/src/patera/static.py +48 -0
- patera-0.111.13/src/patera/testing/__init__.py +7 -0
- patera-0.111.13/src/patera/testing/pyjolt_test_client.py +70 -0
- patera-0.111.13/src/patera/utilities.py +188 -0
patera-0.111.13/PKG-INFO
ADDED
|
@@ -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.
|