zopy-takibiapi 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.
- zopy_takibiapi-0.1.0/LICENSE +21 -0
- zopy_takibiapi-0.1.0/PKG-INFO +97 -0
- zopy_takibiapi-0.1.0/README.md +73 -0
- zopy_takibiapi-0.1.0/pyproject.toml +50 -0
- zopy_takibiapi-0.1.0/pyproject.toml.orig +37 -0
- zopy_takibiapi-0.1.0/python/zopy_takibiapi/__init__.py +53 -0
- zopy_takibiapi-0.1.0/python/zopy_takibiapi/app.py +21 -0
- zopy_takibiapi-0.1.0/python/zopy_takibiapi/problems.py +38 -0
- zopy_takibiapi-0.1.0/python/zopy_takibiapi/py.typed +0 -0
- zopy_takibiapi-0.1.0/python/zopy_takibiapi/schemas.py +187 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 - present, zopy-takibiapi Contributors
|
|
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,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zopy-takibiapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: takibiapi with zopy (Zod for Python) schemas, checked strictly
|
|
5
|
+
Keywords: openapi,asgi,zopy,zod,rest,api,validation
|
|
6
|
+
Author: nakita628
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: takibiapi>=0.1.0,<0.2
|
|
19
|
+
Requires-Dist: zopy>=0.1.1
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Repository, https://github.com/nakita628/takibi
|
|
22
|
+
Project-URL: Issues, https://github.com/nakita628/takibi/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# zopy-takibiapi
|
|
26
|
+
|
|
27
|
+
[TakibiAPI](../takibiapi) with [zopy](https://github.com/nakita628/zopy) (Zod for Python) schemas, checked strictly: checks, unknown keys and responses included.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install zopy-takibiapi
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from zopy_takibiapi import Context, TakibiAPI, createRoute, z
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Message(z.StrictObject):
|
|
40
|
+
message = z.string()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
MessageSchema = Message.openapi("Message") # components.schemas.Message, referenced with $ref
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class getTakibiRoute(
|
|
47
|
+
createRoute(
|
|
48
|
+
{
|
|
49
|
+
"method": "get",
|
|
50
|
+
"path": "/takibi",
|
|
51
|
+
"responses": {200: {"description": "takibiš„", "content": {"application/json": {"schema": MessageSchema}}}},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
):
|
|
55
|
+
"""Say takibiš„."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def getTakibiRouteHandler(c: Context[getTakibiRoute]):
|
|
59
|
+
return c.json(Message(message="takibiš„"), 200)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app = TakibiAPI()
|
|
63
|
+
app.openapi(getTakibiRoute, getTakibiRouteHandler)
|
|
64
|
+
app.doc("/doc", {"openapi": "3.0.0", "info": {"title": "takibi API", "version": "1.0.0"}})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
granian --interface rsgi myapp:app # or: uvicorn myapp:app
|
|
69
|
+
curl localhost:8000/takibi # {"message":"takibiš„"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Import `TakibiAPI` and `z` from `zopy_takibiapi`; everything else works as in Takibi.
|
|
73
|
+
|
|
74
|
+
## Schemas
|
|
75
|
+
|
|
76
|
+
Every object, nested ones included, must be `z.StrictObject`; `app.openapi` raises `TypeError` for a loose object or a non-zopy schema. Parameters arrive as text and are parsed as Zod parses them, so convert explicitly: `todoId = z.coerce.number().int()`.
|
|
77
|
+
|
|
78
|
+
## Checks
|
|
79
|
+
|
|
80
|
+
- **Requests**: `c.req.valid(...)` gives the parsed value. A failure gets **422** with Zod's issue codes as `code`.
|
|
81
|
+
- **Responses**: `c.json(value, status)` encodes `value` with the schema declared for that status (`safeEncode`: checked, codecs to their wire form). A mismatch raises `ResponseValidationError` and answers **500** (details in the log, not in the response).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
app.onError(lambda c: c.json({"error": str(c.error)}, 500)) # replaces the 500, ResponseValidationError included
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## OpenAPI
|
|
88
|
+
|
|
89
|
+
As in Zod, only `.openapi("Name")` schemas become components; others are written in place. Documents come from zopy's `toJSONSchema` (`openapi-3.0` or JSON Schema 2020-12).
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
From the repository root: `bazel test //pypi/zopy-takibiapi:test`. See the root README.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibi/blob/main/pypi/zopy-takibiapi/LICENSE) for more information.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# zopy-takibiapi
|
|
2
|
+
|
|
3
|
+
[TakibiAPI](../takibiapi) with [zopy](https://github.com/nakita628/zopy) (Zod for Python) schemas, checked strictly: checks, unknown keys and responses included.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install zopy-takibiapi
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from zopy_takibiapi import Context, TakibiAPI, createRoute, z
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Message(z.StrictObject):
|
|
16
|
+
message = z.string()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MessageSchema = Message.openapi("Message") # components.schemas.Message, referenced with $ref
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class getTakibiRoute(
|
|
23
|
+
createRoute(
|
|
24
|
+
{
|
|
25
|
+
"method": "get",
|
|
26
|
+
"path": "/takibi",
|
|
27
|
+
"responses": {200: {"description": "takibiš„", "content": {"application/json": {"schema": MessageSchema}}}},
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
):
|
|
31
|
+
"""Say takibiš„."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def getTakibiRouteHandler(c: Context[getTakibiRoute]):
|
|
35
|
+
return c.json(Message(message="takibiš„"), 200)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app = TakibiAPI()
|
|
39
|
+
app.openapi(getTakibiRoute, getTakibiRouteHandler)
|
|
40
|
+
app.doc("/doc", {"openapi": "3.0.0", "info": {"title": "takibi API", "version": "1.0.0"}})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
granian --interface rsgi myapp:app # or: uvicorn myapp:app
|
|
45
|
+
curl localhost:8000/takibi # {"message":"takibiš„"}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Import `TakibiAPI` and `z` from `zopy_takibiapi`; everything else works as in Takibi.
|
|
49
|
+
|
|
50
|
+
## Schemas
|
|
51
|
+
|
|
52
|
+
Every object, nested ones included, must be `z.StrictObject`; `app.openapi` raises `TypeError` for a loose object or a non-zopy schema. Parameters arrive as text and are parsed as Zod parses them, so convert explicitly: `todoId = z.coerce.number().int()`.
|
|
53
|
+
|
|
54
|
+
## Checks
|
|
55
|
+
|
|
56
|
+
- **Requests**: `c.req.valid(...)` gives the parsed value. A failure gets **422** with Zod's issue codes as `code`.
|
|
57
|
+
- **Responses**: `c.json(value, status)` encodes `value` with the schema declared for that status (`safeEncode`: checked, codecs to their wire form). A mismatch raises `ResponseValidationError` and answers **500** (details in the log, not in the response).
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
app.onError(lambda c: c.json({"error": str(c.error)}, 500)) # replaces the 500, ResponseValidationError included
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## OpenAPI
|
|
64
|
+
|
|
65
|
+
As in Zod, only `.openapi("Name")` schemas become components; others are written in place. Documents come from zopy's `toJSONSchema` (`openapi-3.0` or JSON Schema 2020-12).
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
From the repository root: `bazel test //pypi/zopy-takibiapi:test`. See the root README.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibi/blob/main/pypi/zopy-takibiapi/LICENSE) for more information.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zopy-takibiapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "takibiapi with zopy (Zod for Python) schemas, checked strictly"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"openapi",
|
|
11
|
+
"asgi",
|
|
12
|
+
"zopy",
|
|
13
|
+
"zod",
|
|
14
|
+
"rest",
|
|
15
|
+
"api",
|
|
16
|
+
"validation",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"takibiapi>=0.1.0,<0.2",
|
|
31
|
+
"zopy>=0.1.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "nakita628"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Repository = "https://github.com/nakita628/takibi"
|
|
39
|
+
Issues = "https://github.com/nakita628/takibi/issues"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[tool.uv.build-backend]
|
|
46
|
+
module-root = "python"
|
|
47
|
+
module-name = "zopy_takibiapi"
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources.takibiapi]
|
|
50
|
+
workspace = true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zopy-takibiapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "takibiapi with zopy (Zod for Python) schemas, checked strictly"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "nakita628" }]
|
|
10
|
+
keywords = ["openapi", "asgi", "zopy", "zod", "rest", "api", "validation"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["takibiapi>=0.1.0,<0.2", "zopy>=0.1.1"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/nakita628/takibi"
|
|
26
|
+
Issues = "https://github.com/nakita628/takibi/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-root = "python"
|
|
34
|
+
module-name = "zopy_takibiapi"
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
takibiapi = { workspace = true }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""zopy-takibiapi: takibi with zopy (Zod for Python) schemas, checked strictly.
|
|
2
|
+
|
|
3
|
+
from zopy_takibiapi import Context, Response, TakibiAPI, createRoute, z
|
|
4
|
+
|
|
5
|
+
Schemas are zopy's (``z.StrictObject`` classes, ``z.string().min(1)``, ...), named for the
|
|
6
|
+
document with ``.openapi("Todo")``. Requests are parsed with them before the handler runs, checks
|
|
7
|
+
included, and responses are encoded with them; every object must reject unknown keys.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from zopy import z
|
|
11
|
+
|
|
12
|
+
from takibiapi import (
|
|
13
|
+
Context,
|
|
14
|
+
Middleware,
|
|
15
|
+
Next,
|
|
16
|
+
OpenAPIConfig,
|
|
17
|
+
Request,
|
|
18
|
+
RequestConfig,
|
|
19
|
+
Response,
|
|
20
|
+
ResponseConfig,
|
|
21
|
+
ResponseValidationError,
|
|
22
|
+
Route,
|
|
23
|
+
RouteConfig,
|
|
24
|
+
ValidationError,
|
|
25
|
+
createRoute,
|
|
26
|
+
problem,
|
|
27
|
+
)
|
|
28
|
+
from zopy_takibiapi.app import TakibiAPI
|
|
29
|
+
from zopy_takibiapi.problems import ValidationErrorItem, ValidationProblem
|
|
30
|
+
from zopy_takibiapi.schemas import ZopySchemas
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Context",
|
|
34
|
+
"Middleware",
|
|
35
|
+
"Next",
|
|
36
|
+
"OpenAPIConfig",
|
|
37
|
+
"Request",
|
|
38
|
+
"RequestConfig",
|
|
39
|
+
"Response",
|
|
40
|
+
"ResponseConfig",
|
|
41
|
+
"ResponseValidationError",
|
|
42
|
+
"Route",
|
|
43
|
+
"RouteConfig",
|
|
44
|
+
"TakibiAPI",
|
|
45
|
+
"ValidationError",
|
|
46
|
+
"ValidationErrorItem",
|
|
47
|
+
"ValidationProblem",
|
|
48
|
+
"ZopySchemas",
|
|
49
|
+
"createRoute",
|
|
50
|
+
"problem",
|
|
51
|
+
"z",
|
|
52
|
+
]
|
|
53
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""``TakibiAPI``: takibi with zopy (Zod for Python) schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import takibiapi
|
|
8
|
+
|
|
9
|
+
from .schemas import ZopySchemas
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TakibiAPI(takibiapi.TakibiAPI):
|
|
13
|
+
"""``takibiapi.TakibiAPI`` whose routes are described with zopy schemas: requests are parsed with
|
|
14
|
+
them before the handler runs (422 when they do not match), and ``c.json(...)`` encodes the
|
|
15
|
+
response with the declared schema (500 when the handler breaks its own contract). Everything
|
|
16
|
+
else (routing, middleware, ASGI / RSGI, the OpenAPI document) is takibi's."""
|
|
17
|
+
|
|
18
|
+
schemas: Any = ZopySchemas()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["TakibiAPI"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The RFC 9457 Problem Details body answered when a request part does not match its schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from zopy import z
|
|
8
|
+
|
|
9
|
+
# "in" is a Python keyword, so this one shape is built from a namespace instead of a class body.
|
|
10
|
+
ValidationErrorItem: Any = cast(Any, type(z.StrictObject))(
|
|
11
|
+
"ValidationErrorItem",
|
|
12
|
+
(z.StrictObject,),
|
|
13
|
+
{
|
|
14
|
+
"in": z.enum(("path", "query", "header", "cookie", "body")).openapi(
|
|
15
|
+
{"description": "Where the value was sent (an OpenAPI parameter location, or body)"}
|
|
16
|
+
),
|
|
17
|
+
"path": z.string().openapi({"description": "Dot-separated path inside that part", "example": "title"}),
|
|
18
|
+
"code": z.string().openapi({"description": "The Zod issue code", "example": "too_small"}),
|
|
19
|
+
"message": z.string().openapi({"example": "Too small: expected string to have >=1 characters"}),
|
|
20
|
+
},
|
|
21
|
+
).openapi("ValidationErrorItem")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationProblemFields(z.StrictObject):
|
|
25
|
+
type = z.literal("/problems/validation-error")
|
|
26
|
+
title = z.literal("Unprocessable Content")
|
|
27
|
+
status = z.literal(422)
|
|
28
|
+
detail = z.string()
|
|
29
|
+
instance = z.string().openapi({"description": "The request path"})
|
|
30
|
+
errors = z.array(ValidationErrorItem)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ValidationProblem = ValidationProblemFields.openapi(
|
|
34
|
+
"ValidationProblem",
|
|
35
|
+
{"description": "RFC 9457 Problem Details for a request part that does not match its schema."},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = ["ValidationErrorItem", "ValidationProblem"]
|
|
File without changes
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""``ZopySchemas``: zopy (Zod for Python) schemas as takibi's schemas (see ``takibiapi.schemas``).
|
|
2
|
+
|
|
3
|
+
Requests are parsed with the route's schemas exactly as Zod parses: checks included, no
|
|
4
|
+
conversion unless the schema asks for it (``z.coerce.number()`` for a numeric path parameter), and
|
|
5
|
+
no unknown keys (every object must be ``z.StrictObject``). Responses are encoded with the schema
|
|
6
|
+
the route declares for the status (``safeEncode``: checked, and codecs turned into their wire
|
|
7
|
+
form) before they are written. As in Zod, ``.openapi("Todo")`` names a schema: it is written once to
|
|
8
|
+
``components.schemas`` and referenced with ``$ref``; other schemas are written in place.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterator, Mapping
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from zopy import z
|
|
17
|
+
from zopy.core import ZodType
|
|
18
|
+
from zopy.declarative import Object, ObjectMeta
|
|
19
|
+
from zopy.registries import globalRegistry, openapiParamRegistry
|
|
20
|
+
|
|
21
|
+
from takibiapi import ValidationError
|
|
22
|
+
from takibiapi.schema import dumps, loads
|
|
23
|
+
|
|
24
|
+
from .problems import ValidationProblem
|
|
25
|
+
|
|
26
|
+
COMPONENT_URI = "#/components/schemas/{}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_schema(tp: Any) -> bool:
|
|
30
|
+
return isinstance(tp, (ZodType, ObjectMeta))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _children(value: Any) -> Iterator[Any]:
|
|
34
|
+
"""The schemas directly inside ``value``'s definition (object fields, array items, ...)."""
|
|
35
|
+
if isinstance(value, ObjectMeta):
|
|
36
|
+
yield from value.shape.values()
|
|
37
|
+
for item in value._def.values():
|
|
38
|
+
if is_schema(item):
|
|
39
|
+
yield item
|
|
40
|
+
elif isinstance(item, (list, tuple)):
|
|
41
|
+
yield from (i for i in item if is_schema(i))
|
|
42
|
+
elif isinstance(item, Mapping):
|
|
43
|
+
yield from (i for i in item.values() if is_schema(i))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def strict_error(schema: Any) -> str | None:
|
|
47
|
+
"""Why ``schema`` is not strict enough, or None: every object must reject unknown keys."""
|
|
48
|
+
seen: set[int] = set()
|
|
49
|
+
stack = [schema]
|
|
50
|
+
while stack:
|
|
51
|
+
current = stack.pop()
|
|
52
|
+
if id(current) in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(id(current))
|
|
55
|
+
definition = current._def
|
|
56
|
+
if definition.get("type") == "object" and not isinstance(definition.get("catchall"), z.ZodNever):
|
|
57
|
+
name = current.__name__ if isinstance(current, type) else type(current).__name__
|
|
58
|
+
return f"{name} is not strict: unknown keys must be an error (subclass z.StrictObject)"
|
|
59
|
+
stack.extend(_children(current))
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _checked(tp: Any, target: str) -> Any:
|
|
64
|
+
if not is_schema(tp):
|
|
65
|
+
raise TypeError(f"request {target} / response schemas must be zopy schemas, got {tp!r}")
|
|
66
|
+
error = strict_error(tp)
|
|
67
|
+
if error is not None:
|
|
68
|
+
raise TypeError(error)
|
|
69
|
+
return tp
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _issues(error: Any) -> list[dict[str, Any]]:
|
|
73
|
+
return [{"path": list(i["path"]), "code": i["code"], "message": i["message"]} for i in error.issues]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unwrapped(schema: Any) -> Any:
|
|
77
|
+
"""The schema inside ``.optional()`` / ``.nullable()`` / ``.default()``."""
|
|
78
|
+
while not isinstance(schema, ObjectMeta) and "innerType" in schema._def:
|
|
79
|
+
schema = schema._def["innerType"]
|
|
80
|
+
return schema
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Checker:
|
|
84
|
+
__slots__ = ("_schema", "names")
|
|
85
|
+
|
|
86
|
+
def __init__(self, tp: Any, target: str) -> None:
|
|
87
|
+
self._schema = _checked(tp, target)
|
|
88
|
+
if target == "json":
|
|
89
|
+
self.names: list[tuple[str, bool]] = []
|
|
90
|
+
return
|
|
91
|
+
if not isinstance(tp, ObjectMeta):
|
|
92
|
+
raise TypeError(f"request {target} parts must be an object schema (z.StrictObject), got {tp!r}")
|
|
93
|
+
self.names = [(name, isinstance(_unwrapped(field), z.ZodArray)) for name, field in tp.shape.items()]
|
|
94
|
+
|
|
95
|
+
def decode(self, value: Any, *, coerce: bool) -> Any:
|
|
96
|
+
# Parameters arrive as text and are parsed as the schema says (z.coerce converts), as in Zod.
|
|
97
|
+
result = self._schema.safeParse(value)
|
|
98
|
+
if not result.success:
|
|
99
|
+
raise ValidationError(_issues(result.error))
|
|
100
|
+
return result.data
|
|
101
|
+
|
|
102
|
+
def decode_json(self, data: bytes) -> Any:
|
|
103
|
+
return self.decode(loads(data), coerce=False) # loads raises ValueError for malformed JSON
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wire(value: Any) -> Any:
|
|
107
|
+
"""``dumps``'s ``default`` for an encoded result: a z.Object instance as its wire dict (the
|
|
108
|
+
JSON writer then writes its values, calling this again for nested objects)."""
|
|
109
|
+
if isinstance(value, Object):
|
|
110
|
+
# Without optional fields to leave out, the instance's own dict is the wire dict.
|
|
111
|
+
return value._wire_dict() if type(value)._absent_when_none() else vars(value)
|
|
112
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Encoder:
|
|
116
|
+
__slots__ = ("_schema",)
|
|
117
|
+
|
|
118
|
+
def __init__(self, tp: Any) -> None:
|
|
119
|
+
self._schema = _checked(tp, "response")
|
|
120
|
+
|
|
121
|
+
def encode_json(self, value: Any) -> bytes:
|
|
122
|
+
result = self._schema.safeEncode(value)
|
|
123
|
+
if not result.success:
|
|
124
|
+
raise ValidationError(_issues(result.error))
|
|
125
|
+
return dumps(result.data, default=_wire)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _DocBuilder:
|
|
129
|
+
"""Schemas as zopy's ``toJSONSchema`` writes them, named ones (``.openapi("Todo")``) collected
|
|
130
|
+
into ``components``. Bodies are documented in their wire form (``io: "input"``, a codec's JSON
|
|
131
|
+
side) both ways: requests are parsed from it and responses encoded into it."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, openapi31: bool) -> None:
|
|
134
|
+
self.openapi31 = openapi31
|
|
135
|
+
self.components: dict[str, Any] = {}
|
|
136
|
+
|
|
137
|
+
def of(self, tp: Any, io: Literal["input", "output"] = "input") -> dict[str, Any]:
|
|
138
|
+
params = {
|
|
139
|
+
"target": "draft-2020-12" if self.openapi31 else "openapi-3.0",
|
|
140
|
+
"io": "input",
|
|
141
|
+
"unrepresentable": "any",
|
|
142
|
+
"uri": COMPONENT_URI.format,
|
|
143
|
+
}
|
|
144
|
+
out = z.toJSONSchema(tp, params)
|
|
145
|
+
out.pop("$schema", None)
|
|
146
|
+
for key in ("$defs", "definitions"):
|
|
147
|
+
for name, schema in out.pop(key, {}).items():
|
|
148
|
+
known = self.components.setdefault(name, schema)
|
|
149
|
+
if known != schema:
|
|
150
|
+
raise TypeError(f"two different schemas are named {name!r} (.openapi({name!r}))")
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
def parameters(self, tp: Any, location: str) -> list[dict[str, Any]]:
|
|
154
|
+
if not isinstance(tp, ObjectMeta):
|
|
155
|
+
raise TypeError(f"{location} parameters must be an object schema, got {tp!r}")
|
|
156
|
+
out: list[dict[str, Any]] = []
|
|
157
|
+
for name, field in tp.shape.items():
|
|
158
|
+
param: dict[str, Any] = {
|
|
159
|
+
"name": name,
|
|
160
|
+
"in": location,
|
|
161
|
+
"required": location == "path" or not field.isOptional(),
|
|
162
|
+
"schema": self.of(field),
|
|
163
|
+
}
|
|
164
|
+
meta = globalRegistry.get(field) or {}
|
|
165
|
+
if "description" in meta:
|
|
166
|
+
param["description"] = meta["description"]
|
|
167
|
+
param.update(openapiParamRegistry.get(field) or {})
|
|
168
|
+
out.append(param)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ZopySchemas:
|
|
173
|
+
"""zopy schemas as takibi's schemas: request and response checks, Zod's semantics."""
|
|
174
|
+
|
|
175
|
+
validation_problem: Any = ValidationProblem
|
|
176
|
+
|
|
177
|
+
def checker(self, tp: Any, target: str) -> _Checker:
|
|
178
|
+
return _Checker(tp, target)
|
|
179
|
+
|
|
180
|
+
def response_encoder(self, tp: Any) -> _Encoder:
|
|
181
|
+
return _Encoder(tp)
|
|
182
|
+
|
|
183
|
+
def builder(self, openapi31: bool) -> _DocBuilder:
|
|
184
|
+
return _DocBuilder(openapi31)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["ZopySchemas", "is_schema", "strict_error"]
|