persistence-kit 3.11.0__tar.gz → 3.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/PKG-INFO +147 -8
  2. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/README.md +141 -6
  3. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/__init__.py +47 -0
  4. persistence_kit-3.13.0/persistence_kit/api/__init__.py +55 -0
  5. persistence_kit-3.13.0/persistence_kit/api/encrypted_middleware.py +65 -0
  6. persistence_kit-3.13.0/persistence_kit/api/encrypted_routes.py +335 -0
  7. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/contracts.py +2 -0
  8. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/memory.py +6 -0
  9. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/mongo.py +14 -0
  10. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/namespaced.py +3 -0
  11. persistence_kit-3.13.0/persistence_kit/mail/__init__.py +57 -0
  12. persistence_kit-3.13.0/persistence_kit/mail/contracts.py +9 -0
  13. persistence_kit-3.13.0/persistence_kit/mail/errors.py +26 -0
  14. persistence_kit-3.13.0/persistence_kit/mail/factory.py +91 -0
  15. persistence_kit-3.13.0/persistence_kit/mail/mailer.py +74 -0
  16. persistence_kit-3.13.0/persistence_kit/mail/memory.py +28 -0
  17. persistence_kit-3.13.0/persistence_kit/mail/message.py +121 -0
  18. persistence_kit-3.13.0/persistence_kit/mail/mime.py +64 -0
  19. persistence_kit-3.13.0/persistence_kit/mail/preview.py +84 -0
  20. persistence_kit-3.13.0/persistence_kit/mail/smtp.py +57 -0
  21. persistence_kit-3.13.0/persistence_kit/mail/templates.py +133 -0
  22. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/__init__.py +25 -1
  23. persistence_kit-3.13.0/persistence_kit/security/encrypted/__init__.py +19 -0
  24. persistence_kit-3.13.0/persistence_kit/security/encrypted/envelope.py +79 -0
  25. persistence_kit-3.13.0/persistence_kit/security/encrypted/errors.py +2 -0
  26. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/factory.py +35 -0
  27. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/ports.py +24 -1
  28. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/__init__.py +19 -1
  29. persistence_kit-3.13.0/persistence_kit/security/providers/encryption_provider.py +103 -0
  30. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/__init__.py +2 -0
  31. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/app_settings.py +9 -0
  32. persistence_kit-3.13.0/persistence_kit/settings/mail_settings.py +24 -0
  33. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/pyproject.toml +7 -3
  34. persistence_kit-3.11.0/persistence_kit/api/__init__.py +0 -26
  35. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/LICENSE +0 -0
  36. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/common.py +0 -0
  37. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/error_handlers.py +0 -0
  38. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/exceptions.py +0 -0
  39. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/rate_limit.py +0 -0
  40. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/api/route_loader.py +0 -0
  41. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/authenticated_user.py +0 -0
  42. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/__init__.py +0 -0
  43. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/configuration.py +0 -0
  44. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/seeders.py +0 -0
  45. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/startup.py +0 -0
  46. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/__init__.py +0 -0
  47. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/dynamodb.py +0 -0
  48. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/cache/factory.py +0 -0
  49. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/__init__.py +0 -0
  50. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/repository.py +0 -0
  51. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/contracts/view_repository.py +0 -0
  52. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/py.typed +0 -0
  53. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/__init__.py +0 -0
  54. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  55. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  56. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/filter_ops.py +0 -0
  57. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  58. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  59. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  60. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  61. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  62. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  63. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  64. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  65. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  66. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  67. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  68. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/__init__.py +0 -0
  69. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  70. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  71. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  72. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  73. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  74. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  75. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/resilience/__init__.py +0 -0
  76. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/resilience/circuit.py +0 -0
  77. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/__init__.py +0 -0
  78. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/aggregate.py +0 -0
  79. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  80. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  81. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/base.py +0 -0
  82. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/basic.py +0 -0
  83. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  84. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/login.py +0 -0
  85. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  86. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/caching.py +0 -0
  87. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/client.py +0 -0
  88. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/config.py +0 -0
  89. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/contracts.py +0 -0
  90. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/errors.py +0 -0
  91. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/factory.py +0 -0
  92. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/mapping.py +0 -0
  93. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/memory.py +0 -0
  94. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/payload.py +0 -0
  95. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/populate.py +0 -0
  96. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/provider.py +0 -0
  97. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/registry.py +0 -0
  98. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/resolver.py +0 -0
  99. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/restclient/retry.py +0 -0
  100. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  101. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  102. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/registration.py +0 -0
  103. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  104. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  105. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  106. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/cache_settings.py +0 -0
  107. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/constants.py +0 -0
  108. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/parsers.py +0 -0
  109. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/settings/repo_settings.py +0 -0
  110. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/__init__.py +0 -0
  111. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/contracts.py +0 -0
  112. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/errors.py +0 -0
  113. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/factory.py +0 -0
  114. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/local.py +0 -0
  115. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/media.py +0 -0
  116. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/routes.py +0 -0
  117. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/storage/s3.py +0 -0
  118. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/utils/__init__.py +0 -0
  119. {persistence_kit-3.11.0 → persistence_kit-3.13.0}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.11.0
3
+ Version: 3.13.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -21,6 +21,8 @@ Classifier: Typing :: Typed
21
21
  Provides-Extra: all
22
22
  Provides-Extra: api
23
23
  Provides-Extra: dynamodb
24
+ Provides-Extra: encrypted
25
+ Provides-Extra: mail
24
26
  Provides-Extra: restclient
25
27
  Provides-Extra: security
26
28
  Provides-Extra: security-cognito
@@ -29,8 +31,10 @@ Provides-Extra: storage-s3
29
31
  Provides-Extra: testing
30
32
  Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
31
33
  Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
32
- Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
34
+ Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
35
+ Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "encrypted" or extra == "testing" or extra == "all"
33
36
  Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
37
+ Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
34
38
  Requires-Dist: motor (>=3.7.1,<4.0.0)
35
39
  Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
36
40
  Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
@@ -149,7 +153,9 @@ pip install "persistence-kit[api,security,restclient]"
149
153
  | `storage-routes` | FastAPI route to serve local exports | fastapi, python-multipart |
150
154
  | `dynamodb` | DynamoDB repository backend | boto3 |
151
155
  | `restclient` | The REST client and its cache | httpx |
152
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart |
156
+ | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
157
+ | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
158
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
153
159
  | `all` | Every optional capability | all of the above |
154
160
 
155
161
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -165,11 +171,12 @@ add, not an obscure `ImportError`.
165
171
  | `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
166
172
  | `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
167
173
  | `storage/` | Object storage: local directory or S3, with presigned URLs |
168
- | `security/` | Identity providers and JWT verifiers, memory or Cognito |
174
+ | `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
169
175
  | `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
170
176
  | `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
177
+ | `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
171
178
  | `resilience/` | Circuit breaker, shared by anything that calls out |
172
- | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
179
+ | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
173
180
  | `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
174
181
  | `utils/` | Small transversal helpers such as upserts |
175
182
 
@@ -231,6 +238,65 @@ application. The kit only answers "who is this".
231
238
 
232
239
  Needs `[security]`, or `[security-cognito]` for Cognito.
233
240
 
241
+ ## Encrypted payloads
242
+
243
+ For data that should not be readable between the browser and your handler, not
244
+ even to a proxy that terminates TLS.
245
+
246
+ ```python
247
+ from fastapi import APIRouter, FastAPI
248
+
249
+ from persistence_kit import (
250
+ EncryptedResponseMiddleware,
251
+ build_encrypted_route,
252
+ encrypted,
253
+ )
254
+
255
+ router = APIRouter(route_class=build_encrypted_route(get_settings))
256
+
257
+
258
+ @router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
259
+ async def login(payload: LoginRequest) -> LoginResponse:
260
+ ...
261
+
262
+
263
+ app = FastAPI()
264
+ app.add_middleware(EncryptedResponseMiddleware)
265
+ app.include_router(router)
266
+ ```
267
+
268
+ The client generates one AES-256-GCM key per request, wraps it with the server's
269
+ public key, and sends it inside the envelope. The route unwraps it and opens the
270
+ payload before your handler runs; the middleware encrypts whatever comes back with
271
+ the same key, including the errors your own `exception_handler` builds. Your
272
+ handler never sees an envelope, and both pieces are required.
273
+
274
+ Two modes. `encrypted()` encrypts the whole body and the whole response.
275
+ `encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
276
+ stays readable; paths may be nested and may cross lists, as in `items[].price`.
277
+ Those paths are validated when the route is mounted — against your Pydantic body
278
+ when there is one — so a typo fails at startup rather than quietly leaving the
279
+ field unencrypted.
280
+
281
+ By default the private key is generated in memory at startup, so encrypted routes
282
+ work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
283
+ from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
284
+ KMS. Clients fetch the matching public key from your own endpoint, built on
285
+ `public_key_der_b64(provider)`.
286
+
287
+ Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
288
+ `CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
289
+ share. It warns and starts anyway.
290
+
291
+ Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
292
+ accepted once, so a captured request cannot be replayed. Anything malformed,
293
+ expired, replayed or tampered with comes back as a 400.
294
+
295
+ In `local` stage a plain body is allowed through, so you can call your API from
296
+ Swagger while developing.
297
+
298
+ Needs `[encrypted]`.
299
+
234
300
  ## REST client and cache
235
301
 
236
302
  For calling somebody else's HTTP API without writing the same client again.
@@ -271,6 +337,69 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
271
337
 
272
338
  Needs `[restclient]`.
273
339
 
340
+ ## Mail
341
+
342
+ Sending mail from an application without it knowing which relay carries it.
343
+ The kit ships the machinery and no templates: the words belong to the app that
344
+ sends them.
345
+
346
+ ```python
347
+ from persistence_kit.mail import MailMessage, build_mailer
348
+
349
+ mailer = build_mailer("app/templates/mail")
350
+
351
+ message = mailer.compose(
352
+ "user_roles_changed",
353
+ to="ana@udea.edu.co",
354
+ cc=["coordinacion@udea.edu.co"],
355
+ bcc="auditoria@udea.edu.co",
356
+ context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
357
+ )
358
+ mailer.dispatch(message)
359
+ ```
360
+
361
+ A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
362
+ (required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
363
+ They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
364
+ and a variable missing from the context fails instead of rendering blank.
365
+
366
+ `to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
367
+ Blind copies travel in the SMTP envelope only and never appear in the headers.
368
+ Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
369
+
370
+ `compose` raises on a broken template, so a test that composes every template
371
+ catches it. `dispatch` delivers in the background: if the relay fails, the error
372
+ is logged and the request that triggered the mail carries on. Use `await
373
+ mailer.send(message)` when the caller has to know, and `await
374
+ mailer.wait_for_pending()` in tests or at shutdown.
375
+
376
+ Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
377
+ `images/logo.png` from the same directory as an inline part, so it shows even where
378
+ remote images are blocked. `compose` adds it on its own; a missing file or one that
379
+ is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
380
+ does the same for images the caller builds.
381
+
382
+ A template can carry a fourth file, `user_roles_changed.sample.json`, with an
383
+ example context. `MailTemplates.names()` lists the templates and
384
+ `sample_context(name)` reads that file, so one parametrized test covers them all.
385
+ The same sample drives the preview, which renders without sending:
386
+
387
+ ```bash
388
+ python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
389
+ ```
390
+
391
+ It writes the `.html` and the `.txt` to a temporary folder, prints the subject
392
+ and opens the HTML in the browser. Without names it renders every template;
393
+ `--output` changes the folder and `--no-open` skips the browser.
394
+
395
+ The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
396
+ `MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
397
+ `smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
398
+ only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
399
+
400
+ Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
401
+ library.
402
+
274
403
  ## Resilience
275
404
 
276
405
  `CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
@@ -295,7 +424,7 @@ class Settings(PersistenceKitSettings):
295
424
  ```
296
425
 
297
426
  The factories read that object: `get_identity_provider`, `get_token_verifier`,
298
- `get_export_storage`, `get_cache`.
427
+ `get_export_storage`, `get_cache`, `get_mail_sender`.
299
428
 
300
429
  ### Environment variables
301
430
 
@@ -310,6 +439,15 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
310
439
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
311
440
  | `POSTGRES_SSL` | Set when the server requires TLS |
312
441
  | `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
442
+ | `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
443
+ | `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
444
+ | `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
445
+ | `MAIL_BACKEND` | `memory` (default) or `smtp` |
446
+ | `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
447
+ | `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
448
+ | `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
449
+ | `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
450
+ | `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
313
451
 
314
452
  ## Wiring it into an application
315
453
 
@@ -433,11 +571,11 @@ poetry install --with dev --all-extras
433
571
  poetry run pytest -q
434
572
  ```
435
573
 
436
- Current baseline: **434 tests passing** (version 3.9.1). Async tests use
574
+ Current baseline: **621 tests passing** (version 3.13.0). Async tests use
437
575
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
438
576
 
439
577
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
440
- importing `persistence_kit` drags in fastapi, pyjwt or boto3.
578
+ importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
441
579
 
442
580
  ## Releasing
443
581
 
@@ -471,6 +609,7 @@ every iteration.
471
609
 
472
610
  - [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
473
611
  - [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
612
+ - [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
474
613
 
475
614
  Author: Andres Felipe Serrano Barrios · [github.com/AndresFSerrano/persistence-kit](https://github.com/AndresFSerrano/persistence-kit)
476
615
 
@@ -105,7 +105,9 @@ pip install "persistence-kit[api,security,restclient]"
105
105
  | `storage-routes` | FastAPI route to serve local exports | fastapi, python-multipart |
106
106
  | `dynamodb` | DynamoDB repository backend | boto3 |
107
107
  | `restclient` | The REST client and its cache | httpx |
108
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart |
108
+ | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
109
+ | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
110
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
109
111
  | `all` | Every optional capability | all of the above |
110
112
 
111
113
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -121,11 +123,12 @@ add, not an obscure `ImportError`.
121
123
  | `repository_factory/` | The entity registry, the factory, and the view repository that populates relations |
122
124
  | `settings/` | `RepoSettings` and `PersistenceKitSettings`, plus shared enums and parsers |
123
125
  | `storage/` | Object storage: local directory or S3, with presigned URLs |
124
- | `security/` | Identity providers and JWT verifiers, memory or Cognito |
126
+ | `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
125
127
  | `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
126
128
  | `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
129
+ | `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
127
130
  | `resilience/` | Circuit breaker, shared by anything that calls out |
128
- | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting |
131
+ | `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
129
132
  | `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
130
133
  | `utils/` | Small transversal helpers such as upserts |
131
134
 
@@ -187,6 +190,65 @@ application. The kit only answers "who is this".
187
190
 
188
191
  Needs `[security]`, or `[security-cognito]` for Cognito.
189
192
 
193
+ ## Encrypted payloads
194
+
195
+ For data that should not be readable between the browser and your handler, not
196
+ even to a proxy that terminates TLS.
197
+
198
+ ```python
199
+ from fastapi import APIRouter, FastAPI
200
+
201
+ from persistence_kit import (
202
+ EncryptedResponseMiddleware,
203
+ build_encrypted_route,
204
+ encrypted,
205
+ )
206
+
207
+ router = APIRouter(route_class=build_encrypted_route(get_settings))
208
+
209
+
210
+ @router.post("/login", openapi_extra=encrypted(fields=["password"], response_fields=["token"]))
211
+ async def login(payload: LoginRequest) -> LoginResponse:
212
+ ...
213
+
214
+
215
+ app = FastAPI()
216
+ app.add_middleware(EncryptedResponseMiddleware)
217
+ app.include_router(router)
218
+ ```
219
+
220
+ The client generates one AES-256-GCM key per request, wraps it with the server's
221
+ public key, and sends it inside the envelope. The route unwraps it and opens the
222
+ payload before your handler runs; the middleware encrypts whatever comes back with
223
+ the same key, including the errors your own `exception_handler` builds. Your
224
+ handler never sees an envelope, and both pieces are required.
225
+
226
+ Two modes. `encrypted()` encrypts the whole body and the whole response.
227
+ `encrypted(fields=[...])` encrypts named fields only, so the rest of the payload
228
+ stays readable; paths may be nested and may cross lists, as in `items[].price`.
229
+ Those paths are validated when the route is mounted — against your Pydantic body
230
+ when there is one — so a typo fails at startup rather than quietly leaving the
231
+ field unencrypted.
232
+
233
+ By default the private key is generated in memory at startup, so encrypted routes
234
+ work with no configuration. Set `ENCRYPTED_TYPE=local` to load a fixed base64 PEM
235
+ from `ENCRYPTED_PRIVATE_KEY`, or `ENCRYPTED_TYPE=kms` to keep it inside AWS
236
+ KMS. Clients fetch the matching public key from your own endpoint, built on
237
+ `public_key_der_b64(provider)`.
238
+
239
+ Outside the local stage the kit logs a warning when `ENCRYPTED_TYPE=memory` or
240
+ `CACHE_BACKEND=memory`: both keep per-process state that several replicas do not
241
+ share. It warns and starts anyway.
242
+
243
+ Envelopes carry a timestamp, expire after sixty seconds, and each nonce is
244
+ accepted once, so a captured request cannot be replayed. Anything malformed,
245
+ expired, replayed or tampered with comes back as a 400.
246
+
247
+ In `local` stage a plain body is allowed through, so you can call your API from
248
+ Swagger while developing.
249
+
250
+ Needs `[encrypted]`.
251
+
190
252
  ## REST client and cache
191
253
 
192
254
  For calling somebody else's HTTP API without writing the same client again.
@@ -227,6 +289,69 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
227
289
 
228
290
  Needs `[restclient]`.
229
291
 
292
+ ## Mail
293
+
294
+ Sending mail from an application without it knowing which relay carries it.
295
+ The kit ships the machinery and no templates: the words belong to the app that
296
+ sends them.
297
+
298
+ ```python
299
+ from persistence_kit.mail import MailMessage, build_mailer
300
+
301
+ mailer = build_mailer("app/templates/mail")
302
+
303
+ message = mailer.compose(
304
+ "user_roles_changed",
305
+ to="ana@udea.edu.co",
306
+ cc=["coordinacion@udea.edu.co"],
307
+ bcc="auditoria@udea.edu.co",
308
+ context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
309
+ )
310
+ mailer.dispatch(message)
311
+ ```
312
+
313
+ A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
314
+ (required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
315
+ They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
316
+ and a variable missing from the context fails instead of rendering blank.
317
+
318
+ `to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
319
+ Blind copies travel in the SMTP envelope only and never appear in the headers.
320
+ Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
321
+
322
+ `compose` raises on a broken template, so a test that composes every template
323
+ catches it. `dispatch` delivers in the background: if the relay fails, the error
324
+ is logged and the request that triggered the mail carries on. Use `await
325
+ mailer.send(message)` when the caller has to know, and `await
326
+ mailer.wait_for_pending()` in tests or at shutdown.
327
+
328
+ Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
329
+ `images/logo.png` from the same directory as an inline part, so it shows even where
330
+ remote images are blocked. `compose` adds it on its own; a missing file or one that
331
+ is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
332
+ does the same for images the caller builds.
333
+
334
+ A template can carry a fourth file, `user_roles_changed.sample.json`, with an
335
+ example context. `MailTemplates.names()` lists the templates and
336
+ `sample_context(name)` reads that file, so one parametrized test covers them all.
337
+ The same sample drives the preview, which renders without sending:
338
+
339
+ ```bash
340
+ python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
341
+ ```
342
+
343
+ It writes the `.html` and the `.txt` to a temporary folder, prints the subject
344
+ and opens the HTML in the browser. Without names it renders every template;
345
+ `--output` changes the folder and `--no-open` skips the browser.
346
+
347
+ The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
348
+ `MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
349
+ `smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
350
+ only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
351
+
352
+ Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
353
+ library.
354
+
230
355
  ## Resilience
231
356
 
232
357
  `CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
@@ -251,7 +376,7 @@ class Settings(PersistenceKitSettings):
251
376
  ```
252
377
 
253
378
  The factories read that object: `get_identity_provider`, `get_token_verifier`,
254
- `get_export_storage`, `get_cache`.
379
+ `get_export_storage`, `get_cache`, `get_mail_sender`.
255
380
 
256
381
  ### Environment variables
257
382
 
@@ -266,6 +391,15 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
266
391
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
267
392
  | `POSTGRES_SSL` | Set when the server requires TLS |
268
393
  | `DYNAMODB_REGION`, `DYNAMODB_TABLE_PREFIX` | DynamoDB connection |
394
+ | `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
395
+ | `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
396
+ | `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
397
+ | `MAIL_BACKEND` | `memory` (default) or `smtp` |
398
+ | `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
399
+ | `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
400
+ | `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
401
+ | `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
402
+ | `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
269
403
 
270
404
  ## Wiring it into an application
271
405
 
@@ -389,11 +523,11 @@ poetry install --with dev --all-extras
389
523
  poetry run pytest -q
390
524
  ```
391
525
 
392
- Current baseline: **434 tests passing** (version 3.9.1). Async tests use
526
+ Current baseline: **621 tests passing** (version 3.13.0). Async tests use
393
527
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
394
528
 
395
529
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
396
- importing `persistence_kit` drags in fastapi, pyjwt or boto3.
530
+ importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
397
531
 
398
532
  ## Releasing
399
533
 
@@ -427,5 +561,6 @@ every iteration.
427
561
 
428
562
  - [`docs/repositories_and_relations.md`](docs/repositories_and_relations.md): relations, pivots, and how the view repository populates them.
429
563
  - [`docs/restclient_and_cache.md`](docs/restclient_and_cache.md): the REST client, its auth strategies, and the cache.
564
+ - [`docs/encrypted_payloads.md`](docs/encrypted_payloads.md): the envelope format, the key providers, and what the client has to do.
430
565
 
431
566
  Author: Andres Felipe Serrano Barrios · [github.com/AndresFSerrano/persistence-kit](https://github.com/AndresFSerrano/persistence-kit)
@@ -14,6 +14,7 @@ from .settings import (
14
14
  DeploymentStage,
15
15
  ExportStorageProvider,
16
16
  MediaStorageProvider,
17
+ EncryptedType,
17
18
  PersistenceKitSettings,
18
19
  )
19
20
  from .settings.constants import Database
@@ -96,6 +97,51 @@ _OPTIONAL_EXPORTS = {
96
97
  "get_token_verifier",
97
98
  "security",
98
99
  ),
100
+ "KeyProvider": ("persistence_kit.security.ports", "KeyProvider", "security"),
101
+ "get_key_provider": ("persistence_kit.security.factory", "get_key_provider", "security"),
102
+ "public_key_der_b64": (
103
+ "persistence_kit.security.ports",
104
+ "public_key_der_b64",
105
+ "encrypted",
106
+ ),
107
+ "LocalKeyProvider": (
108
+ "persistence_kit.security.providers.encryption_provider",
109
+ "LocalKeyProvider",
110
+ "encrypted",
111
+ ),
112
+ "MemoryKeyProvider": (
113
+ "persistence_kit.security.providers.encryption_provider",
114
+ "MemoryKeyProvider",
115
+ "encrypted",
116
+ ),
117
+ "KmsKeyProvider": (
118
+ "persistence_kit.security.providers.encryption_provider",
119
+ "KmsKeyProvider",
120
+ "encrypted",
121
+ ),
122
+ "EncryptedPayloadError": (
123
+ "persistence_kit.security.encrypted.errors",
124
+ "EncryptedPayloadError",
125
+ "encrypted",
126
+ ),
127
+ "encrypted": ("persistence_kit.api.encrypted_routes", "encrypted", "encrypted"),
128
+ "build_encrypted_route": (
129
+ "persistence_kit.api.encrypted_routes",
130
+ "build_encrypted_route",
131
+ "encrypted",
132
+ ),
133
+ "EncryptedResponseMiddleware": (
134
+ "persistence_kit.api.encrypted_middleware",
135
+ "EncryptedResponseMiddleware",
136
+ "encrypted",
137
+ ),
138
+ "declare_key_header": (
139
+ "persistence_kit.api.encrypted_routes",
140
+ "declare_key_header",
141
+ "encrypted",
142
+ ),
143
+ "ENCRYPTED_FLAG": ("persistence_kit.api.encrypted_routes", "ENCRYPTED_FLAG", "encrypted"),
144
+ "KEY_HEADER": ("persistence_kit.api.encrypted_routes", "KEY_HEADER", "encrypted"),
99
145
  "RegistrationResult": (
100
146
  "persistence_kit.security.registration",
101
147
  "RegistrationResult",
@@ -286,6 +332,7 @@ __all__ = [
286
332
  "AuthProvider",
287
333
  "ExportStorageProvider",
288
334
  "MediaStorageProvider",
335
+ "EncryptedType",
289
336
  "DeploymentStage",
290
337
  "LOCAL_DEFAULT_JOB_SERVICE_API_KEY",
291
338
  "DEFAULT_MEMORY_JWT_SECRET",
@@ -0,0 +1,55 @@
1
+ from .common import ApiError, pagination_params
2
+ from .error_handlers import handle_repository_errors, handle_service_errors
3
+ from .exceptions import (
4
+ BaseAPIException,
5
+ BusinessRuleException,
6
+ DatabaseException,
7
+ NotFoundException,
8
+ ValidationException,
9
+ )
10
+ from .rate_limit import InMemoryRateLimiter, build_auth_rate_limit_dependency
11
+ from .route_loader import build_api_router
12
+
13
+ _LAZY = {
14
+ "KEY_HEADER": ("persistence_kit.api.encrypted_routes", "KEY_HEADER"),
15
+ "ENCRYPTED_FLAG": ("persistence_kit.api.encrypted_routes", "ENCRYPTED_FLAG"),
16
+ "build_encrypted_route": ("persistence_kit.api.encrypted_routes", "build_encrypted_route"),
17
+ "declare_key_header": ("persistence_kit.api.encrypted_routes", "declare_key_header"),
18
+ "encrypted": ("persistence_kit.api.encrypted_routes", "encrypted"),
19
+ "EncryptedResponseMiddleware": (
20
+ "persistence_kit.api.encrypted_middleware",
21
+ "EncryptedResponseMiddleware",
22
+ ),
23
+ }
24
+
25
+ __all__ = [
26
+ "ApiError",
27
+ "pagination_params",
28
+ "BaseAPIException",
29
+ "NotFoundException",
30
+ "ValidationException",
31
+ "BusinessRuleException",
32
+ "DatabaseException",
33
+ "handle_service_errors",
34
+ "handle_repository_errors",
35
+ "build_api_router",
36
+ "InMemoryRateLimiter",
37
+ "build_auth_rate_limit_dependency",
38
+ "build_encrypted_route",
39
+ "EncryptedResponseMiddleware",
40
+ "encrypted",
41
+ "declare_key_header",
42
+ "ENCRYPTED_FLAG",
43
+ "KEY_HEADER",
44
+ ]
45
+
46
+
47
+ def __getattr__(name: str):
48
+ if name in _LAZY:
49
+ from importlib import import_module
50
+
51
+ module_name, attr = _LAZY[name]
52
+ value = getattr(import_module(module_name), attr)
53
+ globals()[name] = value
54
+ return value
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,65 @@
1
+ import json
2
+ from collections.abc import Callable
3
+
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+ from starlette.requests import Request
6
+ from starlette.responses import Response
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from persistence_kit.api.encrypted_routes import (
10
+ KEY_STATE,
11
+ RESPONSE_FIELDS_STATE,
12
+ locate_all,
13
+ )
14
+ from persistence_kit.security.encrypted.envelope import encrypt
15
+ from persistence_kit.security.encrypted.errors import EncryptedPayloadError
16
+
17
+
18
+ def _encrypt_fields(payload: dict | list, fields: list[str], data_key: bytes) -> dict | list:
19
+ if isinstance(payload, list):
20
+ return [_encrypt_fields(item, fields, data_key) for item in payload]
21
+ if not isinstance(payload, dict):
22
+ raise EncryptedPayloadError("La respuesta debe ser un objeto o una lista de objetos")
23
+ for field in fields:
24
+ for container, key in locate_all(payload, field):
25
+ if key in container:
26
+ container[key] = encrypt(json.dumps(container[key]).encode(), data_key)
27
+ return payload
28
+
29
+
30
+ def _encrypted_response(payload, source: Response) -> JSONResponse:
31
+ response = JSONResponse(payload, status_code=source.status_code)
32
+ response.raw_headers += [
33
+ (name, value)
34
+ for name, value in source.raw_headers
35
+ if name not in (b"content-length", b"content-type")
36
+ ]
37
+ return response
38
+
39
+
40
+ class EncryptedResponseMiddleware(BaseHTTPMiddleware):
41
+ """Encrypts the response of routes marked with ``encrypted()``.
42
+
43
+ Runs outside the ``ExceptionMiddleware``, so it also covers the responses
44
+ built by the app's own exception handlers. The route only leaves the key on
45
+ ``request.state``.
46
+ """
47
+
48
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
49
+ response = await call_next(request)
50
+ data_key = getattr(request.state, KEY_STATE, None)
51
+ if data_key is None:
52
+ return response
53
+
54
+ body = b"".join([chunk async for chunk in response.body_iterator])
55
+ if not body:
56
+ empty = Response(status_code=response.status_code)
57
+ empty.raw_headers = list(response.raw_headers)
58
+ return empty
59
+
60
+ response_fields = getattr(request.state, RESPONSE_FIELDS_STATE, None)
61
+ if response_fields:
62
+ payload = _encrypt_fields(json.loads(body), response_fields, data_key)
63
+ else:
64
+ payload = encrypt(body, data_key)
65
+ return _encrypted_response(payload, response)