resourcey 0.0.1__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.
- resourcey-0.0.1/.gitignore +32 -0
- resourcey-0.0.1/LICENSE +21 -0
- resourcey-0.0.1/PKG-INFO +504 -0
- resourcey-0.0.1/README.md +451 -0
- resourcey-0.0.1/pyproject.toml +176 -0
- resourcey-0.0.1/specs/api_key.qnt +261 -0
- resourcey-0.0.1/specs/auth.qnt +271 -0
- resourcey-0.0.1/specs/background_tasks.qnt +200 -0
- resourcey-0.0.1/specs/cache_defaults.qnt +148 -0
- resourcey-0.0.1/specs/cache_freshness.qnt +136 -0
- resourcey-0.0.1/specs/conditional_write.qnt +344 -0
- resourcey-0.0.1/specs/dto_defaults.qnt +284 -0
- resourcey-0.0.1/specs/exposure.qnt +342 -0
- resourcey-0.0.1/specs/filestore.qnt +153 -0
- resourcey-0.0.1/specs/filtering.qnt +281 -0
- resourcey-0.0.1/specs/jobs.qnt +193 -0
- resourcey-0.0.1/specs/me.qnt +207 -0
- resourcey-0.0.1/specs/oauth.qnt +317 -0
- resourcey-0.0.1/specs/permissions.qnt +272 -0
- resourcey-0.0.1/specs/principal_store.qnt +187 -0
- resourcey-0.0.1/specs/rbac.qnt +439 -0
- resourcey-0.0.1/specs/realtime.qnt +312 -0
- resourcey-0.0.1/specs/resource_actions.qnt +687 -0
- resourcey-0.0.1/specs/roles.qnt +296 -0
- resourcey-0.0.1/specs/secret_serialization.qnt +147 -0
- resourcey-0.0.1/specs/sorting.qnt +138 -0
- resourcey-0.0.1/specs/storage_sharing.qnt +187 -0
- resourcey-0.0.1/specs/triggers.qnt +221 -0
- resourcey-0.0.1/src/resourcey/__init__.py +4 -0
- resourcey-0.0.1/src/resourcey/__main__.py +48 -0
- resourcey-0.0.1/src/resourcey/auth/auth_api_key.py +237 -0
- resourcey-0.0.1/src/resourcey/auth/auth_api_key_resource.py +251 -0
- resourcey-0.0.1/src/resourcey/auth/auth_api_key_service.py +94 -0
- resourcey-0.0.1/src/resourcey/auth/auth_authorized_dependency.py +149 -0
- resourcey-0.0.1/src/resourcey/auth/auth_authorized_service.py +274 -0
- resourcey-0.0.1/src/resourcey/auth/auth_config.py +75 -0
- resourcey-0.0.1/src/resourcey/auth/auth_cookie.py +139 -0
- resourcey-0.0.1/src/resourcey/auth/auth_me_routes.py +147 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth.py +362 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_client.py +270 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_config.py +79 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_provider.py +257 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_routes.py +474 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_service.py +85 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_setup.py +206 -0
- resourcey-0.0.1/src/resourcey/auth/auth_oauth_token.py +408 -0
- resourcey-0.0.1/src/resourcey/auth/auth_policy.py +275 -0
- resourcey-0.0.1/src/resourcey/auth/auth_principal.py +340 -0
- resourcey-0.0.1/src/resourcey/auth/auth_rbac.py +213 -0
- resourcey-0.0.1/src/resourcey/auth/auth_rbac_resolver.py +128 -0
- resourcey-0.0.1/src/resourcey/auth/auth_rbac_store.py +180 -0
- resourcey-0.0.1/src/resourcey/auth/auth_role.py +124 -0
- resourcey-0.0.1/src/resourcey/cache/cache_defaults.py +96 -0
- resourcey-0.0.1/src/resourcey/cache/cache_header.py +83 -0
- resourcey-0.0.1/src/resourcey/cache/cache_store.py +105 -0
- resourcey-0.0.1/src/resourcey/cache/cache_strategy.py +257 -0
- resourcey-0.0.1/src/resourcey/cache/cached_resource.py +177 -0
- resourcey-0.0.1/src/resourcey/cache/cached_service.py +247 -0
- resourcey-0.0.1/src/resourcey/config/config_base.py +185 -0
- resourcey-0.0.1/src/resourcey/config/lazy_field.py +188 -0
- resourcey-0.0.1/src/resourcey/core/dto.py +748 -0
- resourcey-0.0.1/src/resourcey/core/errors.py +46 -0
- resourcey-0.0.1/src/resourcey/core/manifest.py +129 -0
- resourcey-0.0.1/src/resourcey/core/resource.py +224 -0
- resourcey-0.0.1/src/resourcey/core/service.py +477 -0
- resourcey-0.0.1/src/resourcey/encryption/encryption_config.py +70 -0
- resourcey-0.0.1/src/resourcey/encryption/encryption_service.py +200 -0
- resourcey-0.0.1/src/resourcey/filestore/file_config.py +46 -0
- resourcey-0.0.1/src/resourcey/filestore/file_resource.py +476 -0
- resourcey-0.0.1/src/resourcey/filestore/file_routes.py +325 -0
- resourcey-0.0.1/src/resourcey/filestore/file_store.py +124 -0
- resourcey-0.0.1/src/resourcey/filestore/local_file_store.py +200 -0
- resourcey-0.0.1/src/resourcey/filestore/s3_file_store.py +263 -0
- resourcey-0.0.1/src/resourcey/filestore/signed_url.py +142 -0
- resourcey-0.0.1/src/resourcey/filestore/sql_file_store.py +254 -0
- resourcey-0.0.1/src/resourcey/http/app.py +135 -0
- resourcey-0.0.1/src/resourcey/http/batch_routes.py +466 -0
- resourcey-0.0.1/src/resourcey/http/dependency_builder.py +123 -0
- resourcey-0.0.1/src/resourcey/http/http_config.py +68 -0
- resourcey-0.0.1/src/resourcey/http/routes.py +957 -0
- resourcey-0.0.1/src/resourcey/jobs/jobs_config.py +62 -0
- resourcey-0.0.1/src/resourcey/jobs/jobs_details.py +71 -0
- resourcey-0.0.1/src/resourcey/jobs/jobs_model.py +227 -0
- resourcey-0.0.1/src/resourcey/jobs/jobs_runner.py +470 -0
- resourcey-0.0.1/src/resourcey/list/list_resource.py +291 -0
- resourcey-0.0.1/src/resourcey/list/list_service.py +270 -0
- resourcey-0.0.1/src/resourcey/list/pydantic_2_dto.py +84 -0
- resourcey-0.0.1/src/resourcey/mongo/embedded.py +127 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_client.py +170 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_config.py +118 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_filter_converter.py +363 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_resource.py +373 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_service.py +481 -0
- resourcey-0.0.1/src/resourcey/mongo/mongo_sort_converter.py +93 -0
- resourcey-0.0.1/src/resourcey/py.typed +0 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_asyncapi.py +349 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_channel.py +120 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_config.py +29 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_event.py +52 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_redis_channel.py +116 -0
- resourcey-0.0.1/src/resourcey/realtime/realtime_routes.py +386 -0
- resourcey-0.0.1/src/resourcey/sql/cursor.py +71 -0
- resourcey-0.0.1/src/resourcey/sql/db_config.py +60 -0
- resourcey-0.0.1/src/resourcey/sql/filter_converter.py +438 -0
- resourcey-0.0.1/src/resourcey/sql/model_type.py +64 -0
- resourcey-0.0.1/src/resourcey/sql/session_manager.py +131 -0
- resourcey-0.0.1/src/resourcey/sql/sort_converter.py +101 -0
- resourcey-0.0.1/src/resourcey/sql/sql_config.py +49 -0
- resourcey-0.0.1/src/resourcey/sql/sql_resource.py +345 -0
- resourcey-0.0.1/src/resourcey/sql/sql_service.py +453 -0
- resourcey-0.0.1/src/resourcey/sql/sqlalchemy_2_dto.py +315 -0
- resourcey-0.0.1/src/resourcey/tasks/cli.py +98 -0
- resourcey-0.0.1/src/resourcey/tasks/cron.py +231 -0
- resourcey-0.0.1/src/resourcey/tasks/scheduler.py +200 -0
- resourcey-0.0.1/src/resourcey/tasks/task.py +148 -0
- resourcey-0.0.1/src/resourcey/triggers/redis_trigger.py +128 -0
- resourcey-0.0.1/src/resourcey/triggers/trigger.py +78 -0
- resourcey-0.0.1/src/resourcey/triggers/trigger_config.py +71 -0
- resourcey-0.0.1/src/resourcey/triggers/trigger_runner.py +95 -0
- resourcey-0.0.1/src/resourcey/triggers/triggered_dependency_builder.py +178 -0
- resourcey-0.0.1/src/resourcey/triggers/triggered_resource.py +185 -0
- resourcey-0.0.1/src/resourcey/triggers/triggered_service.py +175 -0
- resourcey-0.0.1/src/resourcey/triggers/webhook_trigger.py +203 -0
- resourcey-0.0.1/src/resourcey/util/cursor.py +132 -0
- resourcey-0.0.1/src/resourcey/util/env_parser.py +525 -0
- resourcey-0.0.1/src/resourcey/util/import_paths.py +50 -0
- resourcey-0.0.1/src/resourcey/util/missing.py +60 -0
- resourcey-0.0.1/src/resourcey/util/models.py +367 -0
- resourcey-0.0.1/src/resourcey/util/naming.py +57 -0
- resourcey-0.0.1/src/resourcey/util/search_filter.py +510 -0
- resourcey-0.0.1/src/resourcey/util/secret_serialization.py +76 -0
- resourcey-0.0.1/src/resourcey/util/singleton.py +113 -0
- resourcey-0.0.1/src/resourcey/util/sort_order.py +96 -0
- resourcey-0.0.1/src/resourcey/view/resource_view.py +361 -0
- resourcey-0.0.1/src/resourcey/view/view_service.py +136 -0
- resourcey-0.0.1/tests/unit/config_helpers.py +66 -0
- resourcey-0.0.1/tests/unit/config_lazy_helpers.py +34 -0
- resourcey-0.0.1/tests/unit/dup_base.py +15 -0
- resourcey-0.0.1/tests/unit/dup_helper_a.py +12 -0
- resourcey-0.0.1/tests/unit/dup_helper_b.py +11 -0
- resourcey-0.0.1/tests/unit/import_path_helpers.py +27 -0
- resourcey-0.0.1/tests/unit/test_app.py +1061 -0
- resourcey-0.0.1/tests/unit/test_auth.py +650 -0
- resourcey-0.0.1/tests/unit/test_authorization.py +566 -0
- resourcey-0.0.1/tests/unit/test_batch_routes.py +975 -0
- resourcey-0.0.1/tests/unit/test_cache.py +727 -0
- resourcey-0.0.1/tests/unit/test_cached_resource.py +559 -0
- resourcey-0.0.1/tests/unit/test_config.py +479 -0
- resourcey-0.0.1/tests/unit/test_dto.py +754 -0
- resourcey-0.0.1/tests/unit/test_encryption.py +314 -0
- resourcey-0.0.1/tests/unit/test_env_parser.py +762 -0
- resourcey-0.0.1/tests/unit/test_filestore.py +945 -0
- resourcey-0.0.1/tests/unit/test_filtering.py +600 -0
- resourcey-0.0.1/tests/unit/test_import_paths.py +93 -0
- resourcey-0.0.1/tests/unit/test_isolation.py +506 -0
- resourcey-0.0.1/tests/unit/test_jobs.py +598 -0
- resourcey-0.0.1/tests/unit/test_list.py +722 -0
- resourcey-0.0.1/tests/unit/test_me.py +282 -0
- resourcey-0.0.1/tests/unit/test_models.py +305 -0
- resourcey-0.0.1/tests/unit/test_mongo.py +1537 -0
- resourcey-0.0.1/tests/unit/test_naming.py +62 -0
- resourcey-0.0.1/tests/unit/test_oauth.py +1115 -0
- resourcey-0.0.1/tests/unit/test_principal.py +378 -0
- resourcey-0.0.1/tests/unit/test_rbac.py +621 -0
- resourcey-0.0.1/tests/unit/test_realtime.py +1026 -0
- resourcey-0.0.1/tests/unit/test_redis_trigger.py +318 -0
- resourcey-0.0.1/tests/unit/test_request_limits.py +283 -0
- resourcey-0.0.1/tests/unit/test_resource_service.py +1004 -0
- resourcey-0.0.1/tests/unit/test_roles.py +598 -0
- resourcey-0.0.1/tests/unit/test_singleton.py +294 -0
- resourcey-0.0.1/tests/unit/test_sorting.py +545 -0
- resourcey-0.0.1/tests/unit/test_sql_config.py +215 -0
- resourcey-0.0.1/tests/unit/test_sql_conversion.py +828 -0
- resourcey-0.0.1/tests/unit/test_sql_pagination.py +276 -0
- resourcey-0.0.1/tests/unit/test_tasks.py +501 -0
- resourcey-0.0.1/tests/unit/test_triggers.py +719 -0
- resourcey-0.0.1/tests/unit/test_view.py +644 -0
- resourcey-0.0.1/tests/unit/test_webhook_trigger.py +304 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.coverage
|
|
9
|
+
htmlcov/
|
|
10
|
+
coverage.xml
|
|
11
|
+
*.egg-info/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
|
|
15
|
+
# uv
|
|
16
|
+
# uv.lock IS committed — it pins the resolved dependency graph.
|
|
17
|
+
|
|
18
|
+
# Env / secrets
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
!.env.example
|
|
22
|
+
|
|
23
|
+
# Editor
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/*
|
|
26
|
+
!.vscode/settings.json
|
|
27
|
+
!.vscode/launch.json
|
|
28
|
+
!.vscode/tasks.json
|
|
29
|
+
|
|
30
|
+
# OS
|
|
31
|
+
.DS_Store
|
|
32
|
+
examples/02_mongodb/uv.lock
|
resourcey-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tofarr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
resourcey-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: resourcey
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A resource-oriented Python web framework built on FastAPI, SQLAlchemy, and Alembic — progressive enhancement with escape hatches to the underlying stack.
|
|
5
|
+
Project-URL: Repository, https://github.com/tofarr/resourcey
|
|
6
|
+
Project-URL: Issues, https://github.com/tofarr/resourcey/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/tofarr/resourcey/releases
|
|
8
|
+
Author: tofarr
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: alembic,async,fastapi,framework,resource,rest,sqlalchemy
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: alembic>=1.13
|
|
25
|
+
Requires-Dist: asyncpg>=0.29
|
|
26
|
+
Requires-Dist: fastapi>=0.115
|
|
27
|
+
Requires-Dist: joserfc>=1.7.5
|
|
28
|
+
Requires-Dist: pydantic>=2.7
|
|
29
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
30
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
31
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: aiosqlite>=0.22; extra == 'dev'
|
|
34
|
+
Requires-Dist: fakeredis>=2.38.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
36
|
+
Requires-Dist: mongomock>=4.3; extra == 'dev'
|
|
37
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
38
|
+
Requires-Dist: pymongo>=4.18.1; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest-xdist>=3.6; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
44
|
+
Provides-Extra: mongodb
|
|
45
|
+
Requires-Dist: motor>=3.6; extra == 'mongodb'
|
|
46
|
+
Provides-Extra: redis
|
|
47
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
48
|
+
Provides-Extra: s3
|
|
49
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
50
|
+
Provides-Extra: webhooks
|
|
51
|
+
Requires-Dist: httpx>=0.27; extra == 'webhooks'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# resourcey
|
|
55
|
+
|
|
56
|
+
A resource-oriented Python web framework built on FastAPI, SQLAlchemy, and
|
|
57
|
+
Alembic — **progressive enhancement with escape hatches to the underlying
|
|
58
|
+
stack.**
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install resourcey
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv add resourcey
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Optional extras pull in a driver for the parts you use:
|
|
71
|
+
|
|
72
|
+
| Extra | Adds | For |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `resourcey[mongodb]` | `motor` | the Mongo backend |
|
|
75
|
+
| `resourcey[s3]` | `boto3` | the S3 file-store medium |
|
|
76
|
+
| `resourcey[redis]` | `redis` | `RedisTrigger` / `RedisChannel` |
|
|
77
|
+
| `resourcey[webhooks]` | `httpx` | `WebhookTrigger` |
|
|
78
|
+
| `resourcey[dev]` | pytest, ruff, mypy, test drivers | working on `resourcey` itself |
|
|
79
|
+
|
|
80
|
+
## Why?
|
|
81
|
+
|
|
82
|
+
Most web frameworks either hide the database entirely or make you wire every
|
|
83
|
+
layer by hand. `resourcey` takes a middle path: you declare a **resource**
|
|
84
|
+
with enough metadata to describe how it should be stored, validated, and
|
|
85
|
+
exposed, and the framework generates a service for you. When the abstraction
|
|
86
|
+
gets in the way, you drop back a rung — to FastAPI, SQLAlchemy, or Alembic —
|
|
87
|
+
and nothing breaks.
|
|
88
|
+
|
|
89
|
+
The guiding principles:
|
|
90
|
+
|
|
91
|
+
* **Resource-oriented.** A resource is the central unit. Define it once — for
|
|
92
|
+
SQL, by declaring your SQLAlchemy model — and derive the DTO, the REST
|
|
93
|
+
models, the service, and the permission checks from that single declaration.
|
|
94
|
+
* **Progressive enhancement.** Each layer is opt-in. Use the generated
|
|
95
|
+
service, or write your own route that calls the repository directly, or
|
|
96
|
+
query SQLAlchemy yourself. The layers compose; they don't trap you.
|
|
97
|
+
* **Escape hatches.** FastAPI, SQLAlchemy, and Alembic are first-class
|
|
98
|
+
citizens, not implementation details. You can always reach them.
|
|
99
|
+
|
|
100
|
+
## What a resource gives you
|
|
101
|
+
|
|
102
|
+
Declaring a resource produces:
|
|
103
|
+
|
|
104
|
+
* **A REST service** exposing the standard actions:
|
|
105
|
+
`create`, `read`, `update`, `delete`, `search`, `count`, `batch_read`, plus one
|
|
106
|
+
app-level `POST /batch-edit` spanning every resource.
|
|
107
|
+
* **Pydantic models** for request/response validation, projected from the
|
|
108
|
+
resource's DTO declaration — create / update / read / search shapes, never
|
|
109
|
+
hand-written.
|
|
110
|
+
* **Persistence**, from whichever backend you declared it against. For SQL the
|
|
111
|
+
workflow is **model-first**: your SQLAlchemy model is the schema of record and
|
|
112
|
+
the DTO is inferred from it, so the mapping you already write stays
|
|
113
|
+
authoritative (and Alembic autogenerates from that same metadata). The Mongo
|
|
114
|
+
and list backends are DTO-first, since they have no schema of record.
|
|
115
|
+
* **Search filtering** — `search` and `count` accept `<field>__<op>` query
|
|
116
|
+
params (`?title__contains=ali&id__gt=5`). A field is filterable exactly when
|
|
117
|
+
the read model exposes it, so a hidden field is rejected `400`; a resource can
|
|
118
|
+
declare a richer filter via `get_search_filter_type`. Filters lower to a
|
|
119
|
+
storage-agnostic tree and are pushed into the SQL `WHERE` clause; an
|
|
120
|
+
unconvertible filter fails loudly unless the resource opts into an in-memory
|
|
121
|
+
fallback.
|
|
122
|
+
* **Sorting and cursor paging** — `?sort=<field>&desc=true` over any sortable
|
|
123
|
+
(i.e. readable) field, with opaque keyset cursors instead of `offset`.
|
|
124
|
+
* **Caching** — an `ETag` / `Last-Modified` strategy chosen from the resource's
|
|
125
|
+
shape, marked `private` whenever a response is caller-scoped.
|
|
126
|
+
* **Authorization** — pluggable `Policy` rules reduce a principal to a
|
|
127
|
+
per-action filter: API-key / cookie authentication, per-app roles, and a
|
|
128
|
+
stored users / groups / roles RBAC store.
|
|
129
|
+
|
|
130
|
+
## Storage backends
|
|
131
|
+
|
|
132
|
+
A resource is declared against one of three backends; the action contract,
|
|
133
|
+
paging, sort, filters, and cache headers are identical across all three.
|
|
134
|
+
|
|
135
|
+
| Backend | Base class | Storage |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| SQL | `resourcey.sql.sql_resource.SqlResource` | SQLAlchemy 2 (async) table |
|
|
138
|
+
| Mongo | `resourcey.mongo.mongo_resource.MongoResource` | an async `motor` collection |
|
|
139
|
+
| List | `resourcey.list.list_resource.ListResource` | an in-process list of Pydantic objects |
|
|
140
|
+
|
|
141
|
+
(There are no `__init__.py` re-exports, so import from the module that defines
|
|
142
|
+
the class — `from resourcey.sql.sql_resource import SqlResource`.)
|
|
143
|
+
|
|
144
|
+
A **list-backed** resource is read-only: it narrows its actions to
|
|
145
|
+
`read` / `search` / `count` / `batch_read` and serves data already modelled as
|
|
146
|
+
Pydantic objects (country codes, feature flags, catalog entries) without
|
|
147
|
+
copying it into a table. Because the objects already exist as Pydantic models,
|
|
148
|
+
there is no schema generation — the model is projected onto the DTO the REST
|
|
149
|
+
shapes derive from, and (being read-only) there is no create/update model and
|
|
150
|
+
no columns. The list *is* the storage, so there is no table and no migration.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from pydantic import BaseModel
|
|
154
|
+
|
|
155
|
+
from resourcey.core.manifest import Manifest
|
|
156
|
+
from resourcey.list.list_resource import ListResource
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Country(BaseModel):
|
|
160
|
+
id: str
|
|
161
|
+
name: str
|
|
162
|
+
iso3: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
countries = [
|
|
166
|
+
Country(id="us", name="United States", iso3="USA"),
|
|
167
|
+
Country(id="ca", name="Canada", iso3="CAN"),
|
|
168
|
+
]
|
|
169
|
+
resource = ListResource(countries, path="countries")
|
|
170
|
+
|
|
171
|
+
manifest = Manifest(resources=(resource,))
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
That manifest serves `GET /countries` (search), `GET /countries/count`,
|
|
175
|
+
`GET /countries/batch-read`, and `GET /countries/{id}` — and nothing else: a
|
|
176
|
+
write route is never mounted, so a `POST` is a `405`.
|
|
177
|
+
|
|
178
|
+
`defensive=True` is the default: every object the resource outputs is a deep
|
|
179
|
+
copy of the stored object, so a caller cannot mutate the served collection
|
|
180
|
+
through a result. Pass `defensive=False` to serve the stored objects directly.
|
|
181
|
+
|
|
182
|
+
## The `core` package — DTO, Resource, Service, Manifest
|
|
183
|
+
|
|
184
|
+
`src/resourcey/core/` is a deliberately minimal package that states the
|
|
185
|
+
architecture in terms of a **DTO**, a **Resource**, a **Service**, and a
|
|
186
|
+
**Manifest**. It is the foundation every backend and transport layer is built
|
|
187
|
+
on.
|
|
188
|
+
|
|
189
|
+
The split separates **the DTO** (the data object you work with internally) from
|
|
190
|
+
**the six REST models** (the wire shapes), which the older code entangled, and
|
|
191
|
+
does away with six hand-written models per resource — those are now *derived*.
|
|
192
|
+
|
|
193
|
+
Here is a whole manifest against the SQL backend — the ORM models are the only
|
|
194
|
+
declarations:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from sqlalchemy import ForeignKey
|
|
198
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
199
|
+
|
|
200
|
+
from resourcey.core.manifest import Manifest
|
|
201
|
+
from resourcey.sql.sql_resource import SqlResource
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Base(DeclarativeBase):
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Thread(Base):
|
|
209
|
+
__tablename__ = "threads"
|
|
210
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
211
|
+
title: Mapped[str]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Message(Base):
|
|
215
|
+
__tablename__ = "messages"
|
|
216
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
217
|
+
thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"))
|
|
218
|
+
body: Mapped[str]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
manifest = Manifest(resources=(SqlResource(Thread), SqlResource(Message)))
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
From those two classes `create_app(manifest)` serves `POST /threads` and
|
|
225
|
+
`GET /threads` (search), `GET|PATCH|DELETE /threads/{id}`, `GET /threads/count`,
|
|
226
|
+
and `GET /threads/batch-read` — and the same set for `messages` — plus
|
|
227
|
+
`POST /batch-edit`, which spans both resources, and the OpenAPI docs.
|
|
228
|
+
|
|
229
|
+
`POST /batch-edit` takes one ordered list of `create` / `update` / `delete`
|
|
230
|
+
nodes, each naming its target resource, and answers with a positionally aligned
|
|
231
|
+
list. Nodes apply in the order given, so a later node sees an earlier node's
|
|
232
|
+
write. The first failure aborts the batch; rollback is best effort, holding
|
|
233
|
+
wherever the backend provides a shared transaction (SQL resources on one
|
|
234
|
+
session factory roll back together). It is mounted by default — pass
|
|
235
|
+
`create_app(manifest, batch_edit=False)` to turn it off, or
|
|
236
|
+
`batch_edit_path="/ops/edits"` to move it. The node count is capped by
|
|
237
|
+
`batch_edit_max_edits`, which defaults to `RequestLimitsConfig.max_batch_edits`
|
|
238
|
+
(`APP_MAX_BATCH_EDITS`, 100). A resource opts out by declaring no write action,
|
|
239
|
+
and a hidden resource is never reachable through it. It is the **only** batch
|
|
240
|
+
write surface: there is no per-resource `/batch-edit`, and each node is
|
|
241
|
+
authorized against its own singular action.
|
|
242
|
+
|
|
243
|
+
SQLAlchemy is the schema of record: the DTO and the six REST models are
|
|
244
|
+
inferred from the ORM model, so a developer keeps the mapping they already
|
|
245
|
+
write. The Mongo backend is DTO-first instead (there is no schema of record);
|
|
246
|
+
see its section below.
|
|
247
|
+
|
|
248
|
+
The five files:
|
|
249
|
+
|
|
250
|
+
* **`dto.py`** — a `DTO` is a plain declaration class (not a Pydantic model).
|
|
251
|
+
Fields carry ordinary Pydantic annotations plus a `DtoField` describing how
|
|
252
|
+
each projects into the six REST shapes via six `in_*` flags
|
|
253
|
+
(`in_create_request`, `in_create_response`, `in_update_request`,
|
|
254
|
+
`in_update_response`, `in_read_response`, `in_search_response` — all default
|
|
255
|
+
`True`, superseding the older `creatable` / `updatable` / `readable` triple).
|
|
256
|
+
Tag a field with `Annotated[T, DtoField(...)]` — the canonical Pydantic
|
|
257
|
+
mechanism; it keeps the real field type (the assignment form
|
|
258
|
+
`key: str = DtoField(...)` is a type error under `mypy --strict`), and no
|
|
259
|
+
explicit `| Missing` is needed because the generator widens every field
|
|
260
|
+
itself:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
class MyStoredKey(DTO):
|
|
264
|
+
id: Annotated[
|
|
265
|
+
UUID,
|
|
266
|
+
DtoField(
|
|
267
|
+
in_create_request=False, in_update_request=False, default_factory_for_create=uuid4
|
|
268
|
+
),
|
|
269
|
+
]
|
|
270
|
+
key: Annotated[str, DtoField(in_read_response=False)]
|
|
271
|
+
description: Annotated[str | None, DtoField(default_for_create=None)]
|
|
272
|
+
created_at: Annotated[
|
|
273
|
+
datetime,
|
|
274
|
+
DtoField(
|
|
275
|
+
in_create_request=False, in_update_request=False, default_factory_for_create=utc_now
|
|
276
|
+
),
|
|
277
|
+
]
|
|
278
|
+
updated_at: Annotated[
|
|
279
|
+
datetime,
|
|
280
|
+
DtoField(
|
|
281
|
+
in_create_request=False,
|
|
282
|
+
in_update_request=False,
|
|
283
|
+
default_factory_for_create=utc_now,
|
|
284
|
+
default_factory_for_update=utc_now,
|
|
285
|
+
),
|
|
286
|
+
]
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`DtoField` carries **operation-scoped** defaults (`default_for_create` /
|
|
290
|
+
`default_factory_for_create` and `default_for_update` /
|
|
291
|
+
`default_factory_for_update`), whose precedence is *client value → default
|
|
292
|
+
for that operation → `MISSING`*. Create and update do not want the same
|
|
293
|
+
default: an omitted update field with no update default is *left unchanged*
|
|
294
|
+
(PATCH semantics), while an `in_update_request=False` field is always omitted
|
|
295
|
+
and so always takes its update default (the "always overwrite" case — how
|
|
296
|
+
`updated_at` is re-set on every update while `created_at` is written once).
|
|
297
|
+
Optionality is never inferred from the annotation: a nullable field with no
|
|
298
|
+
create default is required on create. `DTO.__init_subclass__` applies the
|
|
299
|
+
`id` / timestamp conventions and wraps every field as `ann | Missing`
|
|
300
|
+
defaulting to `MISSING`, so an omitted field is distinguishable from an
|
|
301
|
+
explicit `None`. The create request carries concrete defaults; the update
|
|
302
|
+
request keeps the `MISSING` sentinel on the wire boundary so an omitted field
|
|
303
|
+
is distinguishable from an explicit `null`, and the route converts a request
|
|
304
|
+
into a DTO through the single sanctioned hop (`request_to_dto`, i.e.
|
|
305
|
+
`model_dump(exclude_unset=True)` + `model_validate`).
|
|
306
|
+
`Missing` is a usable annotation type (it carries a Pydantic core schema and
|
|
307
|
+
serializes to `null`), so `UUID | Missing` validates. The six REST models are
|
|
308
|
+
field-selection projections of the DTO — never hand-written — and the
|
|
309
|
+
one-time-reveal case (`key` in the create response only) is expressible.
|
|
310
|
+
Both `DTO` and `DtoField` carry a free-form `metadata: dict[str, Any]`, a
|
|
311
|
+
general-purpose store for extra data that `core` never reads. A DTO's
|
|
312
|
+
metadata is inherited and merged down the MRO and is not a field; a
|
|
313
|
+
`DtoField`'s metadata is per-field and excluded from equality/hash, so
|
|
314
|
+
differing metadata never churns the derived models.
|
|
315
|
+
`DTO.id_field_name` selects the identifier — `id` by default, overridable
|
|
316
|
+
with the `id_field_name=` class keyword to make another declared field the
|
|
317
|
+
identifier (a natural key). It is validated at declaration time: a name with
|
|
318
|
+
no matching field raises `TypeError`. The identifier is always immutable
|
|
319
|
+
(never in an update request); the conventional `id` is also server-generated
|
|
320
|
+
in the SQL backend and so excluded from create requests, while a custom
|
|
321
|
+
identifier stays client-supplied on create, e.g.
|
|
322
|
+
`class Country(DTO, id_field_name="code")` with a `code: str` field.
|
|
323
|
+
|
|
324
|
+
* **`resource.py`** — `Resource` is a genuine ABC: every method is abstract, so
|
|
325
|
+
`core` holds no backend behaviour and a backend supplies the whole contract.
|
|
326
|
+
It is generic over the DTO and the identifier type (`Resource[T, K]`).
|
|
327
|
+
`get_service(ctx)` is **async** and takes an optional call-scoped
|
|
328
|
+
`MutableMapping`; awaiting it builds the service (a backend may resolve a
|
|
329
|
+
connection first) and the returned `Service` is the async context manager that
|
|
330
|
+
owns the storage, so the call site is
|
|
331
|
+
`async with await resource.get_service(ctx)`. `get_supported_actions()` is the
|
|
332
|
+
single action declaration (there is no `actions` property), and
|
|
333
|
+
`get_exposed_resource()` composes on top of it — the exposed resource's
|
|
334
|
+
declaration wins outright.
|
|
335
|
+
* **`service.py`** — `Service` is generic over the DTO, declares the eight
|
|
336
|
+
actions, and *is* the async context manager; a call before `__aenter__`
|
|
337
|
+
raises clearly. `Action` is the action enum, `Edit` the
|
|
338
|
+
create/update/delete union, and `ForbiddenError` / `NotFoundError` /
|
|
339
|
+
`ServiceError` live here.
|
|
340
|
+
* **`errors.py`** — the framework error base `ResourceyError` and the
|
|
341
|
+
storage-neutral errors a backend raises and the transport maps:
|
|
342
|
+
`ResourceyConfigError`, `InvalidInputError`, `UnsupportedFilterError`,
|
|
343
|
+
`ConflictError`.
|
|
344
|
+
* **`manifest.py`** — the `Manifest` owns the resource set and its lifecycle
|
|
345
|
+
(it may also own app-lifecycle managers, e.g. a `SqlSessionManager`, entered
|
|
346
|
+
before the resources and exited after them),
|
|
347
|
+
and asserts at construction that every resource's supported actions name only
|
|
348
|
+
real `Action` members (a typo would otherwise silently drop a route).
|
|
349
|
+
|
|
350
|
+
Storage ownership follows one rule, letting session-per-service and
|
|
351
|
+
session-per-operation both live:
|
|
352
|
+
|
|
353
|
+
> Whoever opens the storage owns its commit and close. A resource that finds
|
|
354
|
+
> storage already in `ctx` reuses it and neither commits nor closes it.
|
|
355
|
+
|
|
356
|
+
`ctx` is a plain `MutableMapping` keyed by module-level sentinels, so a caller
|
|
357
|
+
can pre-seed storage (the escape hatch) and every resource in the call adopts
|
|
358
|
+
it. `AppContext` (app-scoped) stays a separate concept. HTTP construction
|
|
359
|
+
(`create_app`) is deliberately **not** part of `core` — it belongs to the
|
|
360
|
+
transport layer, where the per-request service dependency is built through a
|
|
361
|
+
configurable `DependencyBuilder` (its default, `OpenDependencyBuilder`, opens
|
|
362
|
+
the resource's own service over the request-scoped `ctx` and applies no
|
|
363
|
+
authentication). `util` is the bottom layer: an isolation test pins the layer
|
|
364
|
+
ranks `util < core < {sql, mongo, list, view, filestore, http, config, cache,
|
|
365
|
+
encryption, auth, jobs, tasks, triggers, realtime}`, so no module imports a
|
|
366
|
+
higher layer at runtime.
|
|
367
|
+
|
|
368
|
+
### Configuration — env-driven edges
|
|
369
|
+
|
|
370
|
+
`core` stays config-free (a service is built from the objects it is handed),
|
|
371
|
+
while each env-driven *edge* is a `BaseConfig` block living with what it
|
|
372
|
+
configures: `SqlConfig` in `sql/`, `EncryptionKeysConfig` in `encryption/`,
|
|
373
|
+
`FileStoreConfig` in `filestore/`, `RealtimeConfig` in `realtime/`,
|
|
374
|
+
`ApiKeysConfig` / `IdpConfig` / `SessionCookieConfig` in `auth/`,
|
|
375
|
+
`BackgroundTasksConfig` in `tasks/`, `JobsConfig` in `jobs/`. All read the one
|
|
376
|
+
process-wide prefix, `APP` by default, so connection `n` is
|
|
377
|
+
`APP_SQL_CONNECTIONS_<n>_NAME` / `_URL` / `_PASSWORD` and the key is
|
|
378
|
+
`APP_ENCRYPTION_KEY_ID` / `_VALUE` (plus `APP_DECRYPTION_KEYS_<n>_*` for
|
|
379
|
+
rotation). An absent encryption key degrades to a loud dev default
|
|
380
|
+
(`changeme` plus a warning); a partially specified one is a hard
|
|
381
|
+
`ResourceyConfigError`.
|
|
382
|
+
|
|
383
|
+
An app composes the blocks by inheritance and calls the composed config once at
|
|
384
|
+
its entry point:
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
from resourcey.encryption.encryption_config import EncryptionKeysConfig
|
|
388
|
+
from resourcey.sql.sql_config import SqlConfig
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class AppConfig(SqlConfig, EncryptionKeysConfig):
|
|
392
|
+
"""The app's single config object, composed from the framework blocks."""
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
config = AppConfig.get_instance() # reads APP_* from os.environ
|
|
396
|
+
print(config.generate_env_template()) # a commented .env skeleton, secrets redacted
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`get_instance()` caches **per class**, so `AppConfig.get_instance()` and
|
|
400
|
+
`SqlConfig.get_instance()` are distinct instances whose values agree (both read
|
|
401
|
+
the same env namespace). Framework internals resolve their own block;
|
|
402
|
+
`SqlResource` uses `get_encryption_service()` / `get_sql_session_manager()` by
|
|
403
|
+
default and accepts an explicit `encryption_service=` / `session_factory=` to
|
|
404
|
+
override. The framework does no `.env` loading — use `uvicorn --env-file` or a
|
|
405
|
+
wrapper script to populate the environment. A pluggable medium or channel is
|
|
406
|
+
chosen by name with a `LazyField` (`APP_MEDIUM_CLASS`, `APP_CHANNEL_CLASS`), so
|
|
407
|
+
swapping the implementation needs no code change.
|
|
408
|
+
|
|
409
|
+
## Stack
|
|
410
|
+
|
|
411
|
+
| Concern | Tool |
|
|
412
|
+
|---|---|
|
|
413
|
+
| HTTP | FastAPI |
|
|
414
|
+
| Validation | Pydantic v2 |
|
|
415
|
+
| ORM | SQLAlchemy 2 (async) |
|
|
416
|
+
| Migrations | Alembic |
|
|
417
|
+
| Package management | uv |
|
|
418
|
+
| Formal specs | Quint |
|
|
419
|
+
| Tests | pytest (≥90% coverage enforced) |
|
|
420
|
+
|
|
421
|
+
## Authorization (users, groups, roles)
|
|
422
|
+
|
|
423
|
+
`resourcey.auth` secures a resource through two composable seams: an
|
|
424
|
+
`Authenticator` (API-key, cookie, and OAuth / OIDC authenticators) that produces
|
|
425
|
+
a `Principal`, and a `PolicyResolver` that maps that principal to `Policy` rules.
|
|
426
|
+
The built-ins are `AllowAll` / `DenyAll` / `ReadOnly` / `Owner`, with a
|
|
427
|
+
per-app role vocabulary (`RolePolicyResolver`) and a store-backed RBAC resolver
|
|
428
|
+
(`RbacPolicyResolver`) over `users` / `groups` / `roles` / `role_permissions` /
|
|
429
|
+
`resource_acls` tables. An `AuthorizedService` enforces the reduced filter per
|
|
430
|
+
action: a denied create is `403`, an out-of-scope read/update/delete is `404`,
|
|
431
|
+
and a denied search/count yields an empty page / `0`.
|
|
432
|
+
|
|
433
|
+
### Who am I — the `me` endpoint
|
|
434
|
+
|
|
435
|
+
`register_me_routes(app, authenticator=..., user_resource=...)` (issue #150)
|
|
436
|
+
mounts `GET` / `POST /me` **after** `create_app`, reading the same `Authenticator`
|
|
437
|
+
the app's builder uses, so it works uniformly across the API-key, cookie, and
|
|
438
|
+
OAuth methods. The response is an [OIDC UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)
|
|
439
|
+
object: `sub` (the caller's internal principal id — always present), plus the
|
|
440
|
+
framework's `kind` / `roles` / `scopes` and, for an external-IdP caller,
|
|
441
|
+
`external_id`. When a `user_resource` is supplied, the caller's **own** row adds
|
|
442
|
+
profile claims (`email`, `preferred_username`, …); an unavailable claim is
|
|
443
|
+
omitted, never `null`. The route reads that row directly (not through the
|
|
444
|
+
`users` policy) and can only ever return the id the credential already proved, so
|
|
445
|
+
an admin-only `users` surface does not block a caller from seeing its own `me`.
|
|
446
|
+
It is guarded by the **strict** principal dependency: an absent or invalid
|
|
447
|
+
credential is a `401` regardless of the builder's posture. See
|
|
448
|
+
[`examples/04_simple_roles`](https://github.com/tofarr/resourcey/tree/main/examples/04_simple_roles) / [`07_oauth`](https://github.com/tofarr/resourcey/tree/main/examples/07_oauth).
|
|
449
|
+
|
|
450
|
+
### OAuth / OIDC (external identity providers)
|
|
451
|
+
|
|
452
|
+
The OAuth method (issue #151) federates to an external IdP. `OAuthAuthenticator`
|
|
453
|
+
verifies an `Authorization: Bearer` token — issuer-keyed client lookup, JWKS
|
|
454
|
+
signature check, and `alg` pinned to the client row's allowlist (no algorithm
|
|
455
|
+
confusion) — maps `(iss, sub)` through an `ExternalIdentity` table to a local
|
|
456
|
+
user, and validates that user against the local store (a missing / disabled user
|
|
457
|
+
is rejected: the local store stays authoritative over the IdP). Provider
|
|
458
|
+
configuration and tokens are **resources**, so they live in the environment
|
|
459
|
+
(`APP_OAUTH_CLIENTS_<n>_*`) or the database; the refresh token lives only,
|
|
460
|
+
encrypted at rest, in an `oauth_tokens` table, and refresh is serialized across
|
|
461
|
+
processes with a lease / compare-and-swap column. `configure_oauth(...)` wires
|
|
462
|
+
the whole subsystem in one call and `register_oauth_routes` mounts the
|
|
463
|
+
interactive login / callback / refresh flow (BFF session, PKCE). See
|
|
464
|
+
[`examples/07_oauth`](https://github.com/tofarr/resourcey/tree/main/examples/07_oauth).
|
|
465
|
+
|
|
466
|
+
## Configuration
|
|
467
|
+
|
|
468
|
+
A typed environment-variable parser is bundled in `resourcey.util.env_parser`
|
|
469
|
+
(vendored from the
|
|
470
|
+
[OpenHands Software Agent SDK](https://github.com/OpenHands/software-agent-sdk/blob/28e8ed273617992e9556410804f54937cc059878/openhands-agent-server/openhands/agent_server/env_parser.py),
|
|
471
|
+
written by the same author). It supports complex nested types and polymorphism
|
|
472
|
+
that `pydantic-settings` cannot express. There is **no runtime dependency** on
|
|
473
|
+
the SDK.
|
|
474
|
+
|
|
475
|
+
A `DiscriminatedUnionMixin` is also bundled in `resourcey.util.models` for
|
|
476
|
+
polymorphic models keyed by a `kind` discriminator — likewise vendored from
|
|
477
|
+
the SDK with no dependency.
|
|
478
|
+
|
|
479
|
+
## Migrations
|
|
480
|
+
|
|
481
|
+
Database revisions are generated with [Alembic](https://alembic.sqlalchemy.org/)
|
|
482
|
+
from the current resource models. There is no `resourcey migrate` wrapper:
|
|
483
|
+
SQLAlchemy is the schema of record, so Alembic is driven directly against
|
|
484
|
+
`Base.metadata` (see the examples' `migrations/` directories):
|
|
485
|
+
|
|
486
|
+
```bash
|
|
487
|
+
alembic revision --autogenerate -m "add widget table"
|
|
488
|
+
alembic upgrade head # apply
|
|
489
|
+
alembic downgrade -1 # one step back
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Generated revisions are drafts.** Alembic's autogeneration cannot detect
|
|
493
|
+
table or column *renames* — a rename looks like a drop followed by a create,
|
|
494
|
+
which loses data. Review every generated revision before applying it.
|
|
495
|
+
|
|
496
|
+
## Status
|
|
497
|
+
|
|
498
|
+
Early-stage. The roadmap is tracked in
|
|
499
|
+
[GitHub issues](https://github.com/tofarr/resourcey/issues). See `AGENTS.md`
|
|
500
|
+
for contributor rules and `specs/` for the formal Quint specifications.
|
|
501
|
+
|
|
502
|
+
## License
|
|
503
|
+
|
|
504
|
+
MIT
|