tiferet-openapi 0.1.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.
- tiferet_openapi-0.1.0/LICENSE +21 -0
- tiferet_openapi-0.1.0/PKG-INFO +197 -0
- tiferet_openapi-0.1.0/README.md +179 -0
- tiferet_openapi-0.1.0/pyproject.toml +43 -0
- tiferet_openapi-0.1.0/setup.cfg +4 -0
- tiferet_openapi-0.1.0/tiferet_openapi/__init__.py +26 -0
- tiferet_openapi-0.1.0/tiferet_openapi/contexts/__init__.py +7 -0
- tiferet_openapi-0.1.0/tiferet_openapi/contexts/openapi.py +140 -0
- tiferet_openapi-0.1.0/tiferet_openapi/contexts/request.py +72 -0
- tiferet_openapi-0.1.0/tiferet_openapi/domain/__init__.py +6 -0
- tiferet_openapi-0.1.0/tiferet_openapi/domain/openapi.py +76 -0
- tiferet_openapi-0.1.0/tiferet_openapi/events/__init__.py +6 -0
- tiferet_openapi-0.1.0/tiferet_openapi/events/openapi.py +147 -0
- tiferet_openapi-0.1.0/tiferet_openapi/interfaces/__init__.py +6 -0
- tiferet_openapi-0.1.0/tiferet_openapi/interfaces/openapi.py +61 -0
- tiferet_openapi-0.1.0/tiferet_openapi/mappers/__init__.py +11 -0
- tiferet_openapi-0.1.0/tiferet_openapi/mappers/openapi.py +230 -0
- tiferet_openapi-0.1.0/tiferet_openapi/repos/__init__.py +6 -0
- tiferet_openapi-0.1.0/tiferet_openapi/repos/openapi.py +129 -0
- tiferet_openapi-0.1.0/tiferet_openapi.egg-info/PKG-INFO +197 -0
- tiferet_openapi-0.1.0/tiferet_openapi.egg-info/SOURCES.txt +22 -0
- tiferet_openapi-0.1.0/tiferet_openapi.egg-info/dependency_links.txt +1 -0
- tiferet_openapi-0.1.0/tiferet_openapi.egg-info/requires.txt +5 -0
- tiferet_openapi-0.1.0/tiferet_openapi.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Great Strength Systems
|
|
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,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tiferet-openapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A shared OpenAPI abstraction layer for the Tiferet Framework.
|
|
5
|
+
Author-email: "Andrew Shatz, Great Strength Systems" <andrew@greatstrength.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/greatstrength/tiferet-openapi
|
|
8
|
+
Project-URL: Repository, https://github.com/greatstrength/tiferet-openapi
|
|
9
|
+
Project-URL: Download, https://github.com/greatstrength/tiferet-openapi
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: tiferet>=2.0.0b1
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8.3.3; extra == "test"
|
|
16
|
+
Requires-Dist: pytest_env>=1.1.5; extra == "test"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Tiferet OpenAPI — A Shared OpenAPI Abstraction Layer for the Tiferet Framework
|
|
20
|
+
|
|
21
|
+
## Introduction
|
|
22
|
+
|
|
23
|
+
Tiferet OpenAPI provides the shared abstraction layer that both [tiferet-flask](https://github.com/greatstrength/tiferet-flask) and [tiferet-fast](https://github.com/greatstrength/tiferet-fast) depend on for OpenAPI-style API development. It extracts the common domain objects, service interfaces, domain events, mappers, YAML-backed repository, and context classes that were previously duplicated across both framework adapters.
|
|
24
|
+
|
|
25
|
+
By unifying these components into a single package, tiferet-openapi eliminates code duplication, ensures behavioral consistency between Flask and FastAPI adapters, and provides a clean foundation for building new framework adapters.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
### From PyPI
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install tiferet-openapi
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### For Development
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/greatstrength/tiferet-openapi.git
|
|
39
|
+
cd tiferet-openapi
|
|
40
|
+
python3.10 -m venv .venv
|
|
41
|
+
source .venv/bin/activate
|
|
42
|
+
pip install -e ".[test]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Architecture
|
|
46
|
+
|
|
47
|
+
Tiferet OpenAPI follows the Tiferet framework's layered Domain-Driven Design architecture:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
tiferet_openapi/
|
|
51
|
+
├── __init__.py — Version and public exports
|
|
52
|
+
├── domain/ — ApiRoute, ApiRouter (DomainObject, Pydantic v2)
|
|
53
|
+
├── interfaces/ — OpenApiService (Service ABC)
|
|
54
|
+
├── events/ — GetRouters, GetRoute, GetStatusCode (DomainEvent)
|
|
55
|
+
├── mappers/ — Aggregates and TransferObjects for YAML round-trip
|
|
56
|
+
├── repos/ — OpenApiYamlRepository (YamlLoader-backed OpenApiService)
|
|
57
|
+
└── contexts/ — OpenApiContext (AppInterfaceContext), OpenApiRequestContext
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Domain Objects
|
|
61
|
+
|
|
62
|
+
`ApiRoute` and `ApiRouter` are read-only Pydantic v2 domain models that represent API routing configuration:
|
|
63
|
+
|
|
64
|
+
- **`ApiRoute`** — An individual route with `id`, `endpoint` (format: `router_name.route_id`), `path`, `methods`, and `status_code`.
|
|
65
|
+
- **`ApiRouter`** — A named group of routes with an optional URL `prefix`.
|
|
66
|
+
|
|
67
|
+
### Service Interface
|
|
68
|
+
|
|
69
|
+
`OpenApiService` is the abstract contract for API configuration access:
|
|
70
|
+
|
|
71
|
+
- `get_routers()` — Retrieve all configured routers.
|
|
72
|
+
- `get_route(route_id, router_name=None)` — Look up a single route.
|
|
73
|
+
- `get_status_code(error_code)` — Map an error code to an HTTP status code.
|
|
74
|
+
|
|
75
|
+
### Domain Events
|
|
76
|
+
|
|
77
|
+
Three domain events encapsulate the service operations for use in the feature workflow:
|
|
78
|
+
|
|
79
|
+
- **`GetRouters`** — Retrieves all routers via the injected `OpenApiService`.
|
|
80
|
+
- **`GetRoute`** — Parses a dotted endpoint string (e.g., `calc.add`) and retrieves the matching route.
|
|
81
|
+
- **`GetStatusCode`** — Looks up the HTTP status code for a given error code.
|
|
82
|
+
|
|
83
|
+
### Mappers
|
|
84
|
+
|
|
85
|
+
Aggregates and TransferObjects bridge YAML configuration and runtime domain objects:
|
|
86
|
+
|
|
87
|
+
- **`ApiRouteAggregate`**, **`ApiRouterAggregate`** — Mutable aggregates with route management methods.
|
|
88
|
+
- **`ApiRouteYamlObject`**, **`ApiRouterYamlObject`** — YAML serialization with `_ROLES`-based role control, `map()` for aggregate construction, `from_model()` for reverse mapping.
|
|
89
|
+
|
|
90
|
+
### Repository
|
|
91
|
+
|
|
92
|
+
`OpenApiYamlRepository` is the YAML-backed implementation of `OpenApiService`. It accepts a parameterized `root_key` (defaults to `"openapi"`) enabling compatibility with multiple YAML formats:
|
|
93
|
+
|
|
94
|
+
- `root_key="openapi"` — unified `openapi.yml` format
|
|
95
|
+
- `root_key="flask"` — legacy `flask.yml` format
|
|
96
|
+
- `root_key="fast"` — legacy `fast.yml` format
|
|
97
|
+
|
|
98
|
+
### Contexts
|
|
99
|
+
|
|
100
|
+
- **`OpenApiContext(AppInterfaceContext)`** — Shared API context that receives `DomainEvent` instances for route and status code lookup. Provides `parse_request`, `handle_error` (with HTTP status code resolution), and `handle_response` (returning `(response, status_code)` tuples).
|
|
101
|
+
- **`OpenApiRequestContext(RequestContext)`** — Pydantic-aware request context that serializes `BaseModel` results via `model_dump()`, with support for lists, dicts, `None`, and primitives.
|
|
102
|
+
|
|
103
|
+
## YAML Configuration Format
|
|
104
|
+
|
|
105
|
+
The repository reads configuration from a YAML file with the following structure:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
openapi: # root_key (can be 'flask', 'fast', or any custom key)
|
|
109
|
+
routers:
|
|
110
|
+
calc:
|
|
111
|
+
prefix: /calc
|
|
112
|
+
routes:
|
|
113
|
+
add:
|
|
114
|
+
path: /add
|
|
115
|
+
methods:
|
|
116
|
+
- POST
|
|
117
|
+
status_code: 200
|
|
118
|
+
subtract:
|
|
119
|
+
path: /subtract
|
|
120
|
+
methods:
|
|
121
|
+
- POST
|
|
122
|
+
status_code: 200
|
|
123
|
+
health:
|
|
124
|
+
routes:
|
|
125
|
+
ping:
|
|
126
|
+
path: /ping
|
|
127
|
+
methods:
|
|
128
|
+
- GET
|
|
129
|
+
status_code: 200
|
|
130
|
+
errors:
|
|
131
|
+
INVALID_INPUT: 400
|
|
132
|
+
DIVISION_BY_ZERO: 422
|
|
133
|
+
NOT_FOUND: 404
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Usage
|
|
137
|
+
|
|
138
|
+
Tiferet OpenAPI is consumed by framework-specific adapters. Here's how the shared components integrate:
|
|
139
|
+
|
|
140
|
+
### In tiferet-flask / tiferet-fast
|
|
141
|
+
|
|
142
|
+
Framework adapters extend `OpenApiContext` and use `OpenApiYamlRepository` as their configuration backend:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
# Framework adapter context (e.g., FlaskApiContext)
|
|
146
|
+
from tiferet_openapi import OpenApiContext, OpenApiRequestContext
|
|
147
|
+
|
|
148
|
+
class FlaskApiContext(OpenApiContext):
|
|
149
|
+
# Inherits parse_request, handle_error, handle_response
|
|
150
|
+
# Adds Flask-specific builder logic
|
|
151
|
+
pass
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Direct Repository Usage
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from tiferet_openapi import OpenApiYamlRepository
|
|
158
|
+
|
|
159
|
+
# Load configuration
|
|
160
|
+
repo = OpenApiYamlRepository(
|
|
161
|
+
openapi_yaml_file='app/configs/openapi.yml',
|
|
162
|
+
root_key='openapi',
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Retrieve all routers
|
|
166
|
+
routers = repo.get_routers()
|
|
167
|
+
for router in routers:
|
|
168
|
+
print(f"{router.name}: {router.prefix}")
|
|
169
|
+
for route in router.routes:
|
|
170
|
+
print(f" {route.endpoint} -> {route.path} [{', '.join(route.methods)}]")
|
|
171
|
+
|
|
172
|
+
# Look up a specific route
|
|
173
|
+
route = repo.get_route('add', router_name='calc')
|
|
174
|
+
print(f"Route: {route.endpoint}, Status: {route.status_code}")
|
|
175
|
+
|
|
176
|
+
# Map error code to HTTP status
|
|
177
|
+
status = repo.get_status_code('INVALID_INPUT') # Returns 400
|
|
178
|
+
status = repo.get_status_code('UNKNOWN') # Returns 500 (default)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
Run the test suite:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
pytest tiferet_openapi/ -v
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Tests are co-located in `<package>/tests/` directories:
|
|
190
|
+
- **Domain/mapper tests** use direct Pydantic constructors.
|
|
191
|
+
- **Event tests** use `DomainEvent.handle()` with mocked `OpenApiService`.
|
|
192
|
+
- **Repo tests** are integration tests using `tmp_path` with real YAML files.
|
|
193
|
+
- **Context tests** use `mock.Mock(spec=DomainEvent)` for event dependencies.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Tiferet OpenAPI — A Shared OpenAPI Abstraction Layer for the Tiferet Framework
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
Tiferet OpenAPI provides the shared abstraction layer that both [tiferet-flask](https://github.com/greatstrength/tiferet-flask) and [tiferet-fast](https://github.com/greatstrength/tiferet-fast) depend on for OpenAPI-style API development. It extracts the common domain objects, service interfaces, domain events, mappers, YAML-backed repository, and context classes that were previously duplicated across both framework adapters.
|
|
6
|
+
|
|
7
|
+
By unifying these components into a single package, tiferet-openapi eliminates code duplication, ensures behavioral consistency between Flask and FastAPI adapters, and provides a clean foundation for building new framework adapters.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
### From PyPI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install tiferet-openapi
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### For Development
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/greatstrength/tiferet-openapi.git
|
|
21
|
+
cd tiferet-openapi
|
|
22
|
+
python3.10 -m venv .venv
|
|
23
|
+
source .venv/bin/activate
|
|
24
|
+
pip install -e ".[test]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
Tiferet OpenAPI follows the Tiferet framework's layered Domain-Driven Design architecture:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
tiferet_openapi/
|
|
33
|
+
├── __init__.py — Version and public exports
|
|
34
|
+
├── domain/ — ApiRoute, ApiRouter (DomainObject, Pydantic v2)
|
|
35
|
+
├── interfaces/ — OpenApiService (Service ABC)
|
|
36
|
+
├── events/ — GetRouters, GetRoute, GetStatusCode (DomainEvent)
|
|
37
|
+
├── mappers/ — Aggregates and TransferObjects for YAML round-trip
|
|
38
|
+
├── repos/ — OpenApiYamlRepository (YamlLoader-backed OpenApiService)
|
|
39
|
+
└── contexts/ — OpenApiContext (AppInterfaceContext), OpenApiRequestContext
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Domain Objects
|
|
43
|
+
|
|
44
|
+
`ApiRoute` and `ApiRouter` are read-only Pydantic v2 domain models that represent API routing configuration:
|
|
45
|
+
|
|
46
|
+
- **`ApiRoute`** — An individual route with `id`, `endpoint` (format: `router_name.route_id`), `path`, `methods`, and `status_code`.
|
|
47
|
+
- **`ApiRouter`** — A named group of routes with an optional URL `prefix`.
|
|
48
|
+
|
|
49
|
+
### Service Interface
|
|
50
|
+
|
|
51
|
+
`OpenApiService` is the abstract contract for API configuration access:
|
|
52
|
+
|
|
53
|
+
- `get_routers()` — Retrieve all configured routers.
|
|
54
|
+
- `get_route(route_id, router_name=None)` — Look up a single route.
|
|
55
|
+
- `get_status_code(error_code)` — Map an error code to an HTTP status code.
|
|
56
|
+
|
|
57
|
+
### Domain Events
|
|
58
|
+
|
|
59
|
+
Three domain events encapsulate the service operations for use in the feature workflow:
|
|
60
|
+
|
|
61
|
+
- **`GetRouters`** — Retrieves all routers via the injected `OpenApiService`.
|
|
62
|
+
- **`GetRoute`** — Parses a dotted endpoint string (e.g., `calc.add`) and retrieves the matching route.
|
|
63
|
+
- **`GetStatusCode`** — Looks up the HTTP status code for a given error code.
|
|
64
|
+
|
|
65
|
+
### Mappers
|
|
66
|
+
|
|
67
|
+
Aggregates and TransferObjects bridge YAML configuration and runtime domain objects:
|
|
68
|
+
|
|
69
|
+
- **`ApiRouteAggregate`**, **`ApiRouterAggregate`** — Mutable aggregates with route management methods.
|
|
70
|
+
- **`ApiRouteYamlObject`**, **`ApiRouterYamlObject`** — YAML serialization with `_ROLES`-based role control, `map()` for aggregate construction, `from_model()` for reverse mapping.
|
|
71
|
+
|
|
72
|
+
### Repository
|
|
73
|
+
|
|
74
|
+
`OpenApiYamlRepository` is the YAML-backed implementation of `OpenApiService`. It accepts a parameterized `root_key` (defaults to `"openapi"`) enabling compatibility with multiple YAML formats:
|
|
75
|
+
|
|
76
|
+
- `root_key="openapi"` — unified `openapi.yml` format
|
|
77
|
+
- `root_key="flask"` — legacy `flask.yml` format
|
|
78
|
+
- `root_key="fast"` — legacy `fast.yml` format
|
|
79
|
+
|
|
80
|
+
### Contexts
|
|
81
|
+
|
|
82
|
+
- **`OpenApiContext(AppInterfaceContext)`** — Shared API context that receives `DomainEvent` instances for route and status code lookup. Provides `parse_request`, `handle_error` (with HTTP status code resolution), and `handle_response` (returning `(response, status_code)` tuples).
|
|
83
|
+
- **`OpenApiRequestContext(RequestContext)`** — Pydantic-aware request context that serializes `BaseModel` results via `model_dump()`, with support for lists, dicts, `None`, and primitives.
|
|
84
|
+
|
|
85
|
+
## YAML Configuration Format
|
|
86
|
+
|
|
87
|
+
The repository reads configuration from a YAML file with the following structure:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
openapi: # root_key (can be 'flask', 'fast', or any custom key)
|
|
91
|
+
routers:
|
|
92
|
+
calc:
|
|
93
|
+
prefix: /calc
|
|
94
|
+
routes:
|
|
95
|
+
add:
|
|
96
|
+
path: /add
|
|
97
|
+
methods:
|
|
98
|
+
- POST
|
|
99
|
+
status_code: 200
|
|
100
|
+
subtract:
|
|
101
|
+
path: /subtract
|
|
102
|
+
methods:
|
|
103
|
+
- POST
|
|
104
|
+
status_code: 200
|
|
105
|
+
health:
|
|
106
|
+
routes:
|
|
107
|
+
ping:
|
|
108
|
+
path: /ping
|
|
109
|
+
methods:
|
|
110
|
+
- GET
|
|
111
|
+
status_code: 200
|
|
112
|
+
errors:
|
|
113
|
+
INVALID_INPUT: 400
|
|
114
|
+
DIVISION_BY_ZERO: 422
|
|
115
|
+
NOT_FOUND: 404
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
Tiferet OpenAPI is consumed by framework-specific adapters. Here's how the shared components integrate:
|
|
121
|
+
|
|
122
|
+
### In tiferet-flask / tiferet-fast
|
|
123
|
+
|
|
124
|
+
Framework adapters extend `OpenApiContext` and use `OpenApiYamlRepository` as their configuration backend:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Framework adapter context (e.g., FlaskApiContext)
|
|
128
|
+
from tiferet_openapi import OpenApiContext, OpenApiRequestContext
|
|
129
|
+
|
|
130
|
+
class FlaskApiContext(OpenApiContext):
|
|
131
|
+
# Inherits parse_request, handle_error, handle_response
|
|
132
|
+
# Adds Flask-specific builder logic
|
|
133
|
+
pass
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Direct Repository Usage
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from tiferet_openapi import OpenApiYamlRepository
|
|
140
|
+
|
|
141
|
+
# Load configuration
|
|
142
|
+
repo = OpenApiYamlRepository(
|
|
143
|
+
openapi_yaml_file='app/configs/openapi.yml',
|
|
144
|
+
root_key='openapi',
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Retrieve all routers
|
|
148
|
+
routers = repo.get_routers()
|
|
149
|
+
for router in routers:
|
|
150
|
+
print(f"{router.name}: {router.prefix}")
|
|
151
|
+
for route in router.routes:
|
|
152
|
+
print(f" {route.endpoint} -> {route.path} [{', '.join(route.methods)}]")
|
|
153
|
+
|
|
154
|
+
# Look up a specific route
|
|
155
|
+
route = repo.get_route('add', router_name='calc')
|
|
156
|
+
print(f"Route: {route.endpoint}, Status: {route.status_code}")
|
|
157
|
+
|
|
158
|
+
# Map error code to HTTP status
|
|
159
|
+
status = repo.get_status_code('INVALID_INPUT') # Returns 400
|
|
160
|
+
status = repo.get_status_code('UNKNOWN') # Returns 500 (default)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Testing
|
|
164
|
+
|
|
165
|
+
Run the test suite:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
pytest tiferet_openapi/ -v
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Tests are co-located in `<package>/tests/` directories:
|
|
172
|
+
- **Domain/mapper tests** use direct Pydantic constructors.
|
|
173
|
+
- **Event tests** use `DomainEvent.handle()` with mocked `OpenApiService`.
|
|
174
|
+
- **Repo tests** are integration tests using `tmp_path` with real YAML files.
|
|
175
|
+
- **Context tests** use `mock.Mock(spec=DomainEvent)` for event dependencies.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tiferet-openapi"
|
|
3
|
+
dynamic = ["version"] # Mark version as dynamic
|
|
4
|
+
description = "A shared OpenAPI abstraction layer for the Tiferet Framework."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Andrew Shatz, Great Strength Systems", email = "andrew@greatstrength.me" }
|
|
7
|
+
]
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"tiferet>=2.0.0b1"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/greatstrength/tiferet-openapi"
|
|
17
|
+
Repository = "https://github.com/greatstrength/tiferet-openapi"
|
|
18
|
+
Download = "https://github.com/greatstrength/tiferet-openapi"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest>=8.3.3",
|
|
23
|
+
"pytest_env>=1.1.5"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = [
|
|
33
|
+
"tiferet_openapi",
|
|
34
|
+
"tiferet_openapi.contexts",
|
|
35
|
+
"tiferet_openapi.domain",
|
|
36
|
+
"tiferet_openapi.events",
|
|
37
|
+
"tiferet_openapi.interfaces",
|
|
38
|
+
"tiferet_openapi.mappers",
|
|
39
|
+
"tiferet_openapi.repos"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.dynamic]
|
|
43
|
+
version = { attr = "tiferet_openapi.__version__" }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# *** exports
|
|
2
|
+
|
|
3
|
+
# ** app
|
|
4
|
+
# Export the main domain objects, interfaces, events, mappers, repos, and contexts.
|
|
5
|
+
# Use a try-except block to avoid import errors on build systems.
|
|
6
|
+
try:
|
|
7
|
+
from .domain import ApiRoute, ApiRouter
|
|
8
|
+
from .interfaces import OpenApiService
|
|
9
|
+
from .events import GetRouters, GetRoute, GetStatusCode
|
|
10
|
+
from .mappers import (
|
|
11
|
+
ApiRouteAggregate,
|
|
12
|
+
ApiRouterAggregate,
|
|
13
|
+
ApiRouteYamlObject,
|
|
14
|
+
ApiRouterYamlObject,
|
|
15
|
+
)
|
|
16
|
+
from .repos import OpenApiYamlRepository
|
|
17
|
+
from .contexts import OpenApiContext, OpenApiRequestContext
|
|
18
|
+
except Exception as e:
|
|
19
|
+
import os, sys
|
|
20
|
+
# Only print warning if TIFERET_SILENT_IMPORTS is not set to a truthy value
|
|
21
|
+
if not os.getenv('TIFERET_SILENT_IMPORTS'):
|
|
22
|
+
print(f"Warning: Failed to import Tiferet OpenAPI modules: {e}", file=sys.stderr)
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# *** version
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'''Tiferet OpenAPI Context'''
|
|
2
|
+
|
|
3
|
+
# *** imports
|
|
4
|
+
|
|
5
|
+
# ** core
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
# ** infra
|
|
9
|
+
from tiferet import TiferetError
|
|
10
|
+
from tiferet.assets.exceptions import TiferetAPIError
|
|
11
|
+
from tiferet.contexts import (
|
|
12
|
+
AppInterfaceContext,
|
|
13
|
+
FeatureContext,
|
|
14
|
+
ErrorContext,
|
|
15
|
+
LoggingContext,
|
|
16
|
+
)
|
|
17
|
+
from tiferet.events import DomainEvent
|
|
18
|
+
|
|
19
|
+
# ** app
|
|
20
|
+
from .request import OpenApiRequestContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# *** contexts
|
|
24
|
+
|
|
25
|
+
# ** context: open_api_context
|
|
26
|
+
class OpenApiContext(AppInterfaceContext):
|
|
27
|
+
'''
|
|
28
|
+
A shared API context for managing OpenAPI interactions within the Tiferet framework.
|
|
29
|
+
'''
|
|
30
|
+
|
|
31
|
+
# * attribute: get_route_handler
|
|
32
|
+
get_route_handler: Callable
|
|
33
|
+
|
|
34
|
+
# * attribute: get_status_code_handler
|
|
35
|
+
get_status_code_handler: Callable
|
|
36
|
+
|
|
37
|
+
# * init
|
|
38
|
+
def __init__(self,
|
|
39
|
+
interface_id: str,
|
|
40
|
+
features: FeatureContext,
|
|
41
|
+
errors: ErrorContext,
|
|
42
|
+
logging: LoggingContext,
|
|
43
|
+
get_route_evt: DomainEvent,
|
|
44
|
+
get_status_code_evt: DomainEvent,
|
|
45
|
+
):
|
|
46
|
+
'''
|
|
47
|
+
Initialize the OpenAPI context.
|
|
48
|
+
|
|
49
|
+
:param interface_id: The interface ID.
|
|
50
|
+
:type interface_id: str
|
|
51
|
+
:param features: The feature context.
|
|
52
|
+
:type features: FeatureContext
|
|
53
|
+
:param errors: The error context.
|
|
54
|
+
:type errors: ErrorContext
|
|
55
|
+
:param logging: The logging context.
|
|
56
|
+
:type logging: LoggingContext
|
|
57
|
+
:param get_route_evt: The domain event for retrieving a route.
|
|
58
|
+
:type get_route_evt: DomainEvent
|
|
59
|
+
:param get_status_code_evt: The domain event for retrieving a status code.
|
|
60
|
+
:type get_status_code_evt: DomainEvent
|
|
61
|
+
'''
|
|
62
|
+
|
|
63
|
+
# Call the parent constructor.
|
|
64
|
+
super().__init__(interface_id, features, errors, logging)
|
|
65
|
+
|
|
66
|
+
# Set the domain event handlers.
|
|
67
|
+
self.get_route_handler = get_route_evt.execute
|
|
68
|
+
self.get_status_code_handler = get_status_code_evt.execute
|
|
69
|
+
|
|
70
|
+
# * method: parse_request
|
|
71
|
+
def parse_request(self, headers: dict = {}, data: dict = {}, feature_id: str = None, **kwargs) -> OpenApiRequestContext:
|
|
72
|
+
'''
|
|
73
|
+
Parse the incoming request and return an OpenApiRequestContext instance.
|
|
74
|
+
|
|
75
|
+
:param headers: The request headers.
|
|
76
|
+
:type headers: dict
|
|
77
|
+
:param data: The request data.
|
|
78
|
+
:type data: dict
|
|
79
|
+
:param feature_id: The feature ID.
|
|
80
|
+
:type feature_id: str
|
|
81
|
+
:param kwargs: Additional keyword arguments.
|
|
82
|
+
:type kwargs: dict
|
|
83
|
+
:return: An OpenApiRequestContext instance.
|
|
84
|
+
:rtype: OpenApiRequestContext
|
|
85
|
+
'''
|
|
86
|
+
|
|
87
|
+
# Return an OpenApiRequestContext instance.
|
|
88
|
+
return OpenApiRequestContext(
|
|
89
|
+
headers=headers,
|
|
90
|
+
data=data,
|
|
91
|
+
feature_id=feature_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# * method: handle_error
|
|
95
|
+
def handle_error(self, error: Exception, **kwargs) -> Any:
|
|
96
|
+
'''
|
|
97
|
+
Handle the error and raise TiferetAPIError with status_code.
|
|
98
|
+
|
|
99
|
+
:param error: The error to handle.
|
|
100
|
+
:type error: Exception
|
|
101
|
+
:param kwargs: Additional keyword arguments.
|
|
102
|
+
:type kwargs: dict
|
|
103
|
+
:return: The error response.
|
|
104
|
+
:rtype: Any
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
# Get the status code via event if it's a TiferetError.
|
|
108
|
+
if isinstance(error, TiferetError):
|
|
109
|
+
status_code = self.get_status_code_handler(error_code=error.error_code)
|
|
110
|
+
else:
|
|
111
|
+
status_code = 500
|
|
112
|
+
|
|
113
|
+
# Delegate formatting to parent (which raises TiferetAPIError).
|
|
114
|
+
try:
|
|
115
|
+
return super().handle_error(error, **kwargs)
|
|
116
|
+
except TiferetAPIError as api_error:
|
|
117
|
+
api_error.status_code = status_code
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
# * method: handle_response
|
|
121
|
+
def handle_response(self, request: OpenApiRequestContext, **kwargs) -> Any:
|
|
122
|
+
'''
|
|
123
|
+
Handle the response from the request context.
|
|
124
|
+
|
|
125
|
+
:param request: The request context.
|
|
126
|
+
:type request: OpenApiRequestContext
|
|
127
|
+
:param kwargs: Additional keyword arguments.
|
|
128
|
+
:type kwargs: dict
|
|
129
|
+
:return: The response and status code.
|
|
130
|
+
:rtype: Any
|
|
131
|
+
'''
|
|
132
|
+
|
|
133
|
+
# Handle the response from the request context.
|
|
134
|
+
response = super().handle_response(request, **kwargs)
|
|
135
|
+
|
|
136
|
+
# Retrieve the route by the request feature id.
|
|
137
|
+
route = self.get_route_handler(endpoint=request.feature_id)
|
|
138
|
+
|
|
139
|
+
# Return the result with the specified status code.
|
|
140
|
+
return response, route.status_code if route else 200
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'''Tiferet OpenAPI Request Context'''
|
|
2
|
+
|
|
3
|
+
# *** imports
|
|
4
|
+
|
|
5
|
+
# ** core
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# ** infra
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from tiferet.contexts.request import RequestContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# *** contexts
|
|
14
|
+
|
|
15
|
+
# ** context: open_api_request_context
|
|
16
|
+
class OpenApiRequestContext(RequestContext):
|
|
17
|
+
'''
|
|
18
|
+
A context for handling OpenAPI request data and responses with Pydantic model serialization.
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
# * method: handle_response
|
|
22
|
+
def handle_response(self) -> Any:
|
|
23
|
+
'''
|
|
24
|
+
Handle the response for the OpenAPI request context.
|
|
25
|
+
|
|
26
|
+
:return: The response.
|
|
27
|
+
:rtype: Any
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
# Set the result using the set_result method to ensure proper formatting.
|
|
31
|
+
self.set_result(self.result)
|
|
32
|
+
|
|
33
|
+
# Handle the response using the parent method.
|
|
34
|
+
return super().handle_response()
|
|
35
|
+
|
|
36
|
+
# * method: set_result
|
|
37
|
+
def set_result(self, result: Any, data_key: str = None):
|
|
38
|
+
'''
|
|
39
|
+
Set the result of the request context.
|
|
40
|
+
|
|
41
|
+
:param result: The result to set.
|
|
42
|
+
:type result: Any
|
|
43
|
+
:param data_key: The key in the request data to set the result to.
|
|
44
|
+
If provided, the raw result is stored for downstream commands.
|
|
45
|
+
If None, the result is serialized for the final response.
|
|
46
|
+
:type data_key: str
|
|
47
|
+
'''
|
|
48
|
+
|
|
49
|
+
# If a data key is provided, delegate to the parent to store the raw result.
|
|
50
|
+
if data_key:
|
|
51
|
+
super().set_result(result, data_key=data_key)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# If the response is None, return an empty response.
|
|
55
|
+
if result is None:
|
|
56
|
+
self.result = ''
|
|
57
|
+
|
|
58
|
+
# Convert the response to a dictionary if it's a BaseModel.
|
|
59
|
+
elif isinstance(result, BaseModel):
|
|
60
|
+
self.result = result.model_dump()
|
|
61
|
+
|
|
62
|
+
# If the response is a list containing BaseModel instances, convert each to a dictionary.
|
|
63
|
+
elif isinstance(result, list) and all(isinstance(item, BaseModel) for item in result):
|
|
64
|
+
self.result = [item.model_dump() for item in result]
|
|
65
|
+
|
|
66
|
+
# If the response is a dict containing BaseModel instances, convert each to a dictionary.
|
|
67
|
+
elif isinstance(result, dict) and all(isinstance(value, BaseModel) for value in result.values()):
|
|
68
|
+
self.result = {key: value.model_dump() for key, value in result.items()}
|
|
69
|
+
|
|
70
|
+
# Otherwise, set the result directly.
|
|
71
|
+
else:
|
|
72
|
+
self.result = result
|