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.
Files changed (178) hide show
  1. resourcey-0.0.1/.gitignore +32 -0
  2. resourcey-0.0.1/LICENSE +21 -0
  3. resourcey-0.0.1/PKG-INFO +504 -0
  4. resourcey-0.0.1/README.md +451 -0
  5. resourcey-0.0.1/pyproject.toml +176 -0
  6. resourcey-0.0.1/specs/api_key.qnt +261 -0
  7. resourcey-0.0.1/specs/auth.qnt +271 -0
  8. resourcey-0.0.1/specs/background_tasks.qnt +200 -0
  9. resourcey-0.0.1/specs/cache_defaults.qnt +148 -0
  10. resourcey-0.0.1/specs/cache_freshness.qnt +136 -0
  11. resourcey-0.0.1/specs/conditional_write.qnt +344 -0
  12. resourcey-0.0.1/specs/dto_defaults.qnt +284 -0
  13. resourcey-0.0.1/specs/exposure.qnt +342 -0
  14. resourcey-0.0.1/specs/filestore.qnt +153 -0
  15. resourcey-0.0.1/specs/filtering.qnt +281 -0
  16. resourcey-0.0.1/specs/jobs.qnt +193 -0
  17. resourcey-0.0.1/specs/me.qnt +207 -0
  18. resourcey-0.0.1/specs/oauth.qnt +317 -0
  19. resourcey-0.0.1/specs/permissions.qnt +272 -0
  20. resourcey-0.0.1/specs/principal_store.qnt +187 -0
  21. resourcey-0.0.1/specs/rbac.qnt +439 -0
  22. resourcey-0.0.1/specs/realtime.qnt +312 -0
  23. resourcey-0.0.1/specs/resource_actions.qnt +687 -0
  24. resourcey-0.0.1/specs/roles.qnt +296 -0
  25. resourcey-0.0.1/specs/secret_serialization.qnt +147 -0
  26. resourcey-0.0.1/specs/sorting.qnt +138 -0
  27. resourcey-0.0.1/specs/storage_sharing.qnt +187 -0
  28. resourcey-0.0.1/specs/triggers.qnt +221 -0
  29. resourcey-0.0.1/src/resourcey/__init__.py +4 -0
  30. resourcey-0.0.1/src/resourcey/__main__.py +48 -0
  31. resourcey-0.0.1/src/resourcey/auth/auth_api_key.py +237 -0
  32. resourcey-0.0.1/src/resourcey/auth/auth_api_key_resource.py +251 -0
  33. resourcey-0.0.1/src/resourcey/auth/auth_api_key_service.py +94 -0
  34. resourcey-0.0.1/src/resourcey/auth/auth_authorized_dependency.py +149 -0
  35. resourcey-0.0.1/src/resourcey/auth/auth_authorized_service.py +274 -0
  36. resourcey-0.0.1/src/resourcey/auth/auth_config.py +75 -0
  37. resourcey-0.0.1/src/resourcey/auth/auth_cookie.py +139 -0
  38. resourcey-0.0.1/src/resourcey/auth/auth_me_routes.py +147 -0
  39. resourcey-0.0.1/src/resourcey/auth/auth_oauth.py +362 -0
  40. resourcey-0.0.1/src/resourcey/auth/auth_oauth_client.py +270 -0
  41. resourcey-0.0.1/src/resourcey/auth/auth_oauth_config.py +79 -0
  42. resourcey-0.0.1/src/resourcey/auth/auth_oauth_provider.py +257 -0
  43. resourcey-0.0.1/src/resourcey/auth/auth_oauth_routes.py +474 -0
  44. resourcey-0.0.1/src/resourcey/auth/auth_oauth_service.py +85 -0
  45. resourcey-0.0.1/src/resourcey/auth/auth_oauth_setup.py +206 -0
  46. resourcey-0.0.1/src/resourcey/auth/auth_oauth_token.py +408 -0
  47. resourcey-0.0.1/src/resourcey/auth/auth_policy.py +275 -0
  48. resourcey-0.0.1/src/resourcey/auth/auth_principal.py +340 -0
  49. resourcey-0.0.1/src/resourcey/auth/auth_rbac.py +213 -0
  50. resourcey-0.0.1/src/resourcey/auth/auth_rbac_resolver.py +128 -0
  51. resourcey-0.0.1/src/resourcey/auth/auth_rbac_store.py +180 -0
  52. resourcey-0.0.1/src/resourcey/auth/auth_role.py +124 -0
  53. resourcey-0.0.1/src/resourcey/cache/cache_defaults.py +96 -0
  54. resourcey-0.0.1/src/resourcey/cache/cache_header.py +83 -0
  55. resourcey-0.0.1/src/resourcey/cache/cache_store.py +105 -0
  56. resourcey-0.0.1/src/resourcey/cache/cache_strategy.py +257 -0
  57. resourcey-0.0.1/src/resourcey/cache/cached_resource.py +177 -0
  58. resourcey-0.0.1/src/resourcey/cache/cached_service.py +247 -0
  59. resourcey-0.0.1/src/resourcey/config/config_base.py +185 -0
  60. resourcey-0.0.1/src/resourcey/config/lazy_field.py +188 -0
  61. resourcey-0.0.1/src/resourcey/core/dto.py +748 -0
  62. resourcey-0.0.1/src/resourcey/core/errors.py +46 -0
  63. resourcey-0.0.1/src/resourcey/core/manifest.py +129 -0
  64. resourcey-0.0.1/src/resourcey/core/resource.py +224 -0
  65. resourcey-0.0.1/src/resourcey/core/service.py +477 -0
  66. resourcey-0.0.1/src/resourcey/encryption/encryption_config.py +70 -0
  67. resourcey-0.0.1/src/resourcey/encryption/encryption_service.py +200 -0
  68. resourcey-0.0.1/src/resourcey/filestore/file_config.py +46 -0
  69. resourcey-0.0.1/src/resourcey/filestore/file_resource.py +476 -0
  70. resourcey-0.0.1/src/resourcey/filestore/file_routes.py +325 -0
  71. resourcey-0.0.1/src/resourcey/filestore/file_store.py +124 -0
  72. resourcey-0.0.1/src/resourcey/filestore/local_file_store.py +200 -0
  73. resourcey-0.0.1/src/resourcey/filestore/s3_file_store.py +263 -0
  74. resourcey-0.0.1/src/resourcey/filestore/signed_url.py +142 -0
  75. resourcey-0.0.1/src/resourcey/filestore/sql_file_store.py +254 -0
  76. resourcey-0.0.1/src/resourcey/http/app.py +135 -0
  77. resourcey-0.0.1/src/resourcey/http/batch_routes.py +466 -0
  78. resourcey-0.0.1/src/resourcey/http/dependency_builder.py +123 -0
  79. resourcey-0.0.1/src/resourcey/http/http_config.py +68 -0
  80. resourcey-0.0.1/src/resourcey/http/routes.py +957 -0
  81. resourcey-0.0.1/src/resourcey/jobs/jobs_config.py +62 -0
  82. resourcey-0.0.1/src/resourcey/jobs/jobs_details.py +71 -0
  83. resourcey-0.0.1/src/resourcey/jobs/jobs_model.py +227 -0
  84. resourcey-0.0.1/src/resourcey/jobs/jobs_runner.py +470 -0
  85. resourcey-0.0.1/src/resourcey/list/list_resource.py +291 -0
  86. resourcey-0.0.1/src/resourcey/list/list_service.py +270 -0
  87. resourcey-0.0.1/src/resourcey/list/pydantic_2_dto.py +84 -0
  88. resourcey-0.0.1/src/resourcey/mongo/embedded.py +127 -0
  89. resourcey-0.0.1/src/resourcey/mongo/mongo_client.py +170 -0
  90. resourcey-0.0.1/src/resourcey/mongo/mongo_config.py +118 -0
  91. resourcey-0.0.1/src/resourcey/mongo/mongo_filter_converter.py +363 -0
  92. resourcey-0.0.1/src/resourcey/mongo/mongo_resource.py +373 -0
  93. resourcey-0.0.1/src/resourcey/mongo/mongo_service.py +481 -0
  94. resourcey-0.0.1/src/resourcey/mongo/mongo_sort_converter.py +93 -0
  95. resourcey-0.0.1/src/resourcey/py.typed +0 -0
  96. resourcey-0.0.1/src/resourcey/realtime/realtime_asyncapi.py +349 -0
  97. resourcey-0.0.1/src/resourcey/realtime/realtime_channel.py +120 -0
  98. resourcey-0.0.1/src/resourcey/realtime/realtime_config.py +29 -0
  99. resourcey-0.0.1/src/resourcey/realtime/realtime_event.py +52 -0
  100. resourcey-0.0.1/src/resourcey/realtime/realtime_redis_channel.py +116 -0
  101. resourcey-0.0.1/src/resourcey/realtime/realtime_routes.py +386 -0
  102. resourcey-0.0.1/src/resourcey/sql/cursor.py +71 -0
  103. resourcey-0.0.1/src/resourcey/sql/db_config.py +60 -0
  104. resourcey-0.0.1/src/resourcey/sql/filter_converter.py +438 -0
  105. resourcey-0.0.1/src/resourcey/sql/model_type.py +64 -0
  106. resourcey-0.0.1/src/resourcey/sql/session_manager.py +131 -0
  107. resourcey-0.0.1/src/resourcey/sql/sort_converter.py +101 -0
  108. resourcey-0.0.1/src/resourcey/sql/sql_config.py +49 -0
  109. resourcey-0.0.1/src/resourcey/sql/sql_resource.py +345 -0
  110. resourcey-0.0.1/src/resourcey/sql/sql_service.py +453 -0
  111. resourcey-0.0.1/src/resourcey/sql/sqlalchemy_2_dto.py +315 -0
  112. resourcey-0.0.1/src/resourcey/tasks/cli.py +98 -0
  113. resourcey-0.0.1/src/resourcey/tasks/cron.py +231 -0
  114. resourcey-0.0.1/src/resourcey/tasks/scheduler.py +200 -0
  115. resourcey-0.0.1/src/resourcey/tasks/task.py +148 -0
  116. resourcey-0.0.1/src/resourcey/triggers/redis_trigger.py +128 -0
  117. resourcey-0.0.1/src/resourcey/triggers/trigger.py +78 -0
  118. resourcey-0.0.1/src/resourcey/triggers/trigger_config.py +71 -0
  119. resourcey-0.0.1/src/resourcey/triggers/trigger_runner.py +95 -0
  120. resourcey-0.0.1/src/resourcey/triggers/triggered_dependency_builder.py +178 -0
  121. resourcey-0.0.1/src/resourcey/triggers/triggered_resource.py +185 -0
  122. resourcey-0.0.1/src/resourcey/triggers/triggered_service.py +175 -0
  123. resourcey-0.0.1/src/resourcey/triggers/webhook_trigger.py +203 -0
  124. resourcey-0.0.1/src/resourcey/util/cursor.py +132 -0
  125. resourcey-0.0.1/src/resourcey/util/env_parser.py +525 -0
  126. resourcey-0.0.1/src/resourcey/util/import_paths.py +50 -0
  127. resourcey-0.0.1/src/resourcey/util/missing.py +60 -0
  128. resourcey-0.0.1/src/resourcey/util/models.py +367 -0
  129. resourcey-0.0.1/src/resourcey/util/naming.py +57 -0
  130. resourcey-0.0.1/src/resourcey/util/search_filter.py +510 -0
  131. resourcey-0.0.1/src/resourcey/util/secret_serialization.py +76 -0
  132. resourcey-0.0.1/src/resourcey/util/singleton.py +113 -0
  133. resourcey-0.0.1/src/resourcey/util/sort_order.py +96 -0
  134. resourcey-0.0.1/src/resourcey/view/resource_view.py +361 -0
  135. resourcey-0.0.1/src/resourcey/view/view_service.py +136 -0
  136. resourcey-0.0.1/tests/unit/config_helpers.py +66 -0
  137. resourcey-0.0.1/tests/unit/config_lazy_helpers.py +34 -0
  138. resourcey-0.0.1/tests/unit/dup_base.py +15 -0
  139. resourcey-0.0.1/tests/unit/dup_helper_a.py +12 -0
  140. resourcey-0.0.1/tests/unit/dup_helper_b.py +11 -0
  141. resourcey-0.0.1/tests/unit/import_path_helpers.py +27 -0
  142. resourcey-0.0.1/tests/unit/test_app.py +1061 -0
  143. resourcey-0.0.1/tests/unit/test_auth.py +650 -0
  144. resourcey-0.0.1/tests/unit/test_authorization.py +566 -0
  145. resourcey-0.0.1/tests/unit/test_batch_routes.py +975 -0
  146. resourcey-0.0.1/tests/unit/test_cache.py +727 -0
  147. resourcey-0.0.1/tests/unit/test_cached_resource.py +559 -0
  148. resourcey-0.0.1/tests/unit/test_config.py +479 -0
  149. resourcey-0.0.1/tests/unit/test_dto.py +754 -0
  150. resourcey-0.0.1/tests/unit/test_encryption.py +314 -0
  151. resourcey-0.0.1/tests/unit/test_env_parser.py +762 -0
  152. resourcey-0.0.1/tests/unit/test_filestore.py +945 -0
  153. resourcey-0.0.1/tests/unit/test_filtering.py +600 -0
  154. resourcey-0.0.1/tests/unit/test_import_paths.py +93 -0
  155. resourcey-0.0.1/tests/unit/test_isolation.py +506 -0
  156. resourcey-0.0.1/tests/unit/test_jobs.py +598 -0
  157. resourcey-0.0.1/tests/unit/test_list.py +722 -0
  158. resourcey-0.0.1/tests/unit/test_me.py +282 -0
  159. resourcey-0.0.1/tests/unit/test_models.py +305 -0
  160. resourcey-0.0.1/tests/unit/test_mongo.py +1537 -0
  161. resourcey-0.0.1/tests/unit/test_naming.py +62 -0
  162. resourcey-0.0.1/tests/unit/test_oauth.py +1115 -0
  163. resourcey-0.0.1/tests/unit/test_principal.py +378 -0
  164. resourcey-0.0.1/tests/unit/test_rbac.py +621 -0
  165. resourcey-0.0.1/tests/unit/test_realtime.py +1026 -0
  166. resourcey-0.0.1/tests/unit/test_redis_trigger.py +318 -0
  167. resourcey-0.0.1/tests/unit/test_request_limits.py +283 -0
  168. resourcey-0.0.1/tests/unit/test_resource_service.py +1004 -0
  169. resourcey-0.0.1/tests/unit/test_roles.py +598 -0
  170. resourcey-0.0.1/tests/unit/test_singleton.py +294 -0
  171. resourcey-0.0.1/tests/unit/test_sorting.py +545 -0
  172. resourcey-0.0.1/tests/unit/test_sql_config.py +215 -0
  173. resourcey-0.0.1/tests/unit/test_sql_conversion.py +828 -0
  174. resourcey-0.0.1/tests/unit/test_sql_pagination.py +276 -0
  175. resourcey-0.0.1/tests/unit/test_tasks.py +501 -0
  176. resourcey-0.0.1/tests/unit/test_triggers.py +719 -0
  177. resourcey-0.0.1/tests/unit/test_view.py +644 -0
  178. 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
@@ -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.
@@ -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