render-lab-tasks-contentful 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.
- render_lab_tasks_contentful-0.1.0/.gitignore +11 -0
- render_lab_tasks_contentful-0.1.0/LICENSE +21 -0
- render_lab_tasks_contentful-0.1.0/PKG-INFO +85 -0
- render_lab_tasks_contentful-0.1.0/README.md +71 -0
- render_lab_tasks_contentful-0.1.0/pyproject.toml +23 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/__init__.py +1 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/_app.py +3 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/client.py +261 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/create_asset.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/create_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/delete_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_assets.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_content_types.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_entries.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/publish_asset.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/publish_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/py.typed +0 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/retry.py +4 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/tasks.py +38 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/types.py +129 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/unpublish_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/update_entry.py +18 -0
- render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/webhooks.py +89 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Render Lab
|
|
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,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: render-lab-tasks-contentful
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: contentful tasks for Render Workflows
|
|
5
|
+
Project-URL: Repository, https://github.com/render-lab/render-tasks-python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
10
|
+
Requires-Dist: render-lab-tasks-core<0.2,>=0.1.1
|
|
11
|
+
Requires-Dist: render-lab-triggers<0.2,>=0.1.0
|
|
12
|
+
Requires-Dist: render==1.0.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# render-lab-tasks-contentful
|
|
16
|
+
|
|
17
|
+
Python port of all 11 registered tasks in the pinned TypeScript pack at
|
|
18
|
+
`45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
|
|
19
|
+
|
|
20
|
+
Install this distribution and its dependencies:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install render-lab-tasks-contentful==0.1.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Import definitions from `render_lab_tasks_contentful.tasks`; combine `app` objects
|
|
27
|
+
with `Workflows.from_workflows(...)`. JSON fields retain TS spelling; functions
|
|
28
|
+
use snake_case. Package root imports are inert.
|
|
29
|
+
|
|
30
|
+
## Tasks
|
|
31
|
+
|
|
32
|
+
| Registered name | Python export (also exposes `_impl`) |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `contentful.createAsset` | `create_asset` |
|
|
35
|
+
| `contentful.createEntry` | `create_entry` |
|
|
36
|
+
| `contentful.deleteEntry` | `delete_entry` |
|
|
37
|
+
| `contentful.getAssets` | `get_assets` |
|
|
38
|
+
| `contentful.getContentTypes` | `get_content_types` |
|
|
39
|
+
| `contentful.getEntries` | `get_entries` |
|
|
40
|
+
| `contentful.getEntry` | `get_entry` |
|
|
41
|
+
| `contentful.publishAsset` | `publish_asset` |
|
|
42
|
+
| `contentful.publishEntry` | `publish_entry` |
|
|
43
|
+
| `contentful.unpublishEntry` | `unpublish_entry` |
|
|
44
|
+
| `contentful.updateEntry` | `update_entry` |
|
|
45
|
+
|
|
46
|
+
## Dependencies and retries
|
|
47
|
+
|
|
48
|
+
Raw implementations accept context first and keyword-only `deps`. The `.client`
|
|
49
|
+
module exports typed `Port`, `Deps`, and `Client` classes. Inject a fake port, or
|
|
50
|
+
pass a caller-owned `httpx.AsyncClient` to `Client`. Default dependencies close
|
|
51
|
+
their client after one operation. Credentials resolve at first use. Transport
|
|
52
|
+
retries are disabled; `retry.py` owns durable policies. DTO types are in `.types`.
|
|
53
|
+
Requires core >=0.1.1.
|
|
54
|
+
|
|
55
|
+
## Environment
|
|
56
|
+
|
|
57
|
+
| Variable | Contract |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `CONTENTFUL_SPACE_ID` | Required space. |
|
|
60
|
+
| `CONTENTFUL_ENVIRONMENT` | Optional environment; defaults to `master`. |
|
|
61
|
+
| `CONTENTFUL_CDA_TOKEN` | Required for delivery reads: getEntries/getAssets. |
|
|
62
|
+
| `CONTENTFUL_CMA_TOKEN` | Required for management reads and writes. |
|
|
63
|
+
|
|
64
|
+
## Verification and limitations
|
|
65
|
+
|
|
66
|
+
Delivery and management credentials are separate. DTOs flatten sys metadata and
|
|
67
|
+
preserve localized fields. Versioned writes and creates disable durable retries;
|
|
68
|
+
delete treats 404 as already deleted. Asset creation processes each requested
|
|
69
|
+
locale and rereads its version, but does not wait for processing completion.
|
|
70
|
+
Collection results at or above 4 MiB are rejected. Each read is a single page.
|
|
71
|
+
|
|
72
|
+
Shared fixtures execute the actual pinned TS implementations and compare Python
|
|
73
|
+
outputs and requests. Sanity's SDK calls are translated into separately asserted
|
|
74
|
+
REST requests. These are hermetic checks, not real account or hosted verification.
|
|
75
|
+
See [the testing backlog](https://github.com/render-lab/render-tasks-python/issues/1)
|
|
76
|
+
and the root verification tracker. Licensed under MIT.
|
|
77
|
+
|
|
78
|
+
## Webhook adapter
|
|
79
|
+
|
|
80
|
+
`render_lab_tasks_contentful.webhooks.contentful_adapter(on_event, ...)` returns a
|
|
81
|
+
registration-free adapter for `render_lab_triggers`. The callback receives the
|
|
82
|
+
verified event and returns `{"task": "namespace.task", "args": [event]}` or `None`.
|
|
83
|
+
The signing credential `CONTENTFUL_WEBHOOK_SECRET` is read inside verification unless
|
|
84
|
+
provided explicitly. Importing this module does not register tasks. Signature
|
|
85
|
+
checks use the raw body; live delivery remains in the testing backlog.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# render-lab-tasks-contentful
|
|
2
|
+
|
|
3
|
+
Python port of all 11 registered tasks in the pinned TypeScript pack at
|
|
4
|
+
`45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
|
|
5
|
+
|
|
6
|
+
Install this distribution and its dependencies:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install render-lab-tasks-contentful==0.1.0
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Import definitions from `render_lab_tasks_contentful.tasks`; combine `app` objects
|
|
13
|
+
with `Workflows.from_workflows(...)`. JSON fields retain TS spelling; functions
|
|
14
|
+
use snake_case. Package root imports are inert.
|
|
15
|
+
|
|
16
|
+
## Tasks
|
|
17
|
+
|
|
18
|
+
| Registered name | Python export (also exposes `_impl`) |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `contentful.createAsset` | `create_asset` |
|
|
21
|
+
| `contentful.createEntry` | `create_entry` |
|
|
22
|
+
| `contentful.deleteEntry` | `delete_entry` |
|
|
23
|
+
| `contentful.getAssets` | `get_assets` |
|
|
24
|
+
| `contentful.getContentTypes` | `get_content_types` |
|
|
25
|
+
| `contentful.getEntries` | `get_entries` |
|
|
26
|
+
| `contentful.getEntry` | `get_entry` |
|
|
27
|
+
| `contentful.publishAsset` | `publish_asset` |
|
|
28
|
+
| `contentful.publishEntry` | `publish_entry` |
|
|
29
|
+
| `contentful.unpublishEntry` | `unpublish_entry` |
|
|
30
|
+
| `contentful.updateEntry` | `update_entry` |
|
|
31
|
+
|
|
32
|
+
## Dependencies and retries
|
|
33
|
+
|
|
34
|
+
Raw implementations accept context first and keyword-only `deps`. The `.client`
|
|
35
|
+
module exports typed `Port`, `Deps`, and `Client` classes. Inject a fake port, or
|
|
36
|
+
pass a caller-owned `httpx.AsyncClient` to `Client`. Default dependencies close
|
|
37
|
+
their client after one operation. Credentials resolve at first use. Transport
|
|
38
|
+
retries are disabled; `retry.py` owns durable policies. DTO types are in `.types`.
|
|
39
|
+
Requires core >=0.1.1.
|
|
40
|
+
|
|
41
|
+
## Environment
|
|
42
|
+
|
|
43
|
+
| Variable | Contract |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `CONTENTFUL_SPACE_ID` | Required space. |
|
|
46
|
+
| `CONTENTFUL_ENVIRONMENT` | Optional environment; defaults to `master`. |
|
|
47
|
+
| `CONTENTFUL_CDA_TOKEN` | Required for delivery reads: getEntries/getAssets. |
|
|
48
|
+
| `CONTENTFUL_CMA_TOKEN` | Required for management reads and writes. |
|
|
49
|
+
|
|
50
|
+
## Verification and limitations
|
|
51
|
+
|
|
52
|
+
Delivery and management credentials are separate. DTOs flatten sys metadata and
|
|
53
|
+
preserve localized fields. Versioned writes and creates disable durable retries;
|
|
54
|
+
delete treats 404 as already deleted. Asset creation processes each requested
|
|
55
|
+
locale and rereads its version, but does not wait for processing completion.
|
|
56
|
+
Collection results at or above 4 MiB are rejected. Each read is a single page.
|
|
57
|
+
|
|
58
|
+
Shared fixtures execute the actual pinned TS implementations and compare Python
|
|
59
|
+
outputs and requests. Sanity's SDK calls are translated into separately asserted
|
|
60
|
+
REST requests. These are hermetic checks, not real account or hosted verification.
|
|
61
|
+
See [the testing backlog](https://github.com/render-lab/render-tasks-python/issues/1)
|
|
62
|
+
and the root verification tracker. Licensed under MIT.
|
|
63
|
+
|
|
64
|
+
## Webhook adapter
|
|
65
|
+
|
|
66
|
+
`render_lab_tasks_contentful.webhooks.contentful_adapter(on_event, ...)` returns a
|
|
67
|
+
registration-free adapter for `render_lab_triggers`. The callback receives the
|
|
68
|
+
verified event and returns `{"task": "namespace.task", "args": [event]}` or `None`.
|
|
69
|
+
The signing credential `CONTENTFUL_WEBHOOK_SECRET` is read inside verification unless
|
|
70
|
+
provided explicitly. Importing this module does not register tasks. Signature
|
|
71
|
+
checks use the raw body; live delivery remains in the testing backlog.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "render-lab-tasks-contentful"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "contentful tasks for Render Workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = ["render-lab-triggers>=0.1.0,<0.2", "render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.1,<0.2"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/render-lab/render-tasks-python"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
render-lab-triggers = { workspace = true }
|
|
20
|
+
render-lab-tasks-core = { workspace = true }
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/render_lab_tasks_contentful"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Import .tasks explicitly to register durable tasks."""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Injected vendor interface and one-attempt async HTTP adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator, Mapping
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from render_lab_tasks_core.bounded import assert_json_limit
|
|
13
|
+
from render_lab_tasks_core.http import (
|
|
14
|
+
ApiError,
|
|
15
|
+
HttpClient,
|
|
16
|
+
enc,
|
|
17
|
+
js_string,
|
|
18
|
+
nullish,
|
|
19
|
+
required,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from . import types as t
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nonempty(value: str, message: str) -> None:
|
|
26
|
+
if not value.strip():
|
|
27
|
+
raise ValueError(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def obj(value: Any) -> dict[str, Any]:
|
|
31
|
+
return value if isinstance(value, dict) else {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def number(value: Any, default: Any = None) -> Any:
|
|
35
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def document(value: Any, kind: str) -> dict[str, Any]:
|
|
39
|
+
row = obj(value)
|
|
40
|
+
sys = obj(row.get("sys"))
|
|
41
|
+
result = {
|
|
42
|
+
"id": nullish(sys.get("id"), ""),
|
|
43
|
+
"type": kind,
|
|
44
|
+
"version": nullish(sys.get("version"), nullish(sys.get("revision"), 0)),
|
|
45
|
+
"publishedVersion": number(sys.get("publishedVersion")),
|
|
46
|
+
"createdAt": sys.get("createdAt"),
|
|
47
|
+
"updatedAt": sys.get("updatedAt"),
|
|
48
|
+
"fields": obj(row.get("fields")),
|
|
49
|
+
}
|
|
50
|
+
if kind == "Entry":
|
|
51
|
+
result["contentType"] = obj(obj(sys.get("contentType")).get("sys")).get("id")
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def content_type(value: Any) -> dict[str, Any]:
|
|
56
|
+
row = obj(value)
|
|
57
|
+
fields = row.get("fields", [])
|
|
58
|
+
return {
|
|
59
|
+
"id": nullish(obj(row.get("sys")).get("id"), ""),
|
|
60
|
+
"name": row.get("name") if isinstance(row.get("name"), str) else "",
|
|
61
|
+
"displayField": row.get("displayField")
|
|
62
|
+
if isinstance(row.get("displayField"), str)
|
|
63
|
+
else None,
|
|
64
|
+
"fields": [
|
|
65
|
+
{
|
|
66
|
+
**{
|
|
67
|
+
k: f.get(k) if isinstance(f.get(k), str) else "" for k in ("id", "name", "type")
|
|
68
|
+
},
|
|
69
|
+
"required": f.get("required") is True,
|
|
70
|
+
"localized": f.get("localized") is True,
|
|
71
|
+
}
|
|
72
|
+
for f in map(obj, fields)
|
|
73
|
+
]
|
|
74
|
+
if isinstance(fields, list)
|
|
75
|
+
else [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Port(Protocol):
|
|
80
|
+
async def create_asset(self, input: t.CreateAssetInput) -> t.AssetDTO: ...
|
|
81
|
+
async def create_entry(self, input: t.CreateEntryInput) -> t.EntryDTO: ...
|
|
82
|
+
async def delete_entry(self, input: t.DeleteEntryInput) -> t.DeleteEntryResult: ...
|
|
83
|
+
async def get_assets(self, input: t.GetAssetsInput) -> t.AssetCollection: ...
|
|
84
|
+
async def get_content_types(self, input: t.GetContentTypesInput) -> t.ContentTypeCollection: ...
|
|
85
|
+
async def get_entries(self, input: t.GetEntriesInput) -> t.EntryCollection: ...
|
|
86
|
+
async def get_entry(self, input: t.GetEntryInput) -> t.EntryDTO: ...
|
|
87
|
+
async def publish_asset(self, input: t.PublishAssetInput) -> t.AssetDTO: ...
|
|
88
|
+
async def publish_entry(self, input: t.PublishEntryInput) -> t.EntryDTO: ...
|
|
89
|
+
async def unpublish_entry(self, input: t.UnpublishEntryInput) -> t.EntryDTO: ...
|
|
90
|
+
async def update_entry(self, input: t.UpdateEntryInput) -> t.EntryDTO: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Deps:
|
|
95
|
+
contentful: Port
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Client:
|
|
99
|
+
def __init__(self, http: httpx.AsyncClient, env: Mapping[str, str] | None = None) -> None:
|
|
100
|
+
self.env = os.environ if env is None else env
|
|
101
|
+
self.http = http
|
|
102
|
+
self.cda = HttpClient(
|
|
103
|
+
http,
|
|
104
|
+
lambda: self._base("cdn.contentful.com"),
|
|
105
|
+
auth=lambda: self._auth(False),
|
|
106
|
+
label="Contentful CDA",
|
|
107
|
+
)
|
|
108
|
+
self.cma = HttpClient(
|
|
109
|
+
http,
|
|
110
|
+
lambda: self._base("api.contentful.com"),
|
|
111
|
+
auth=lambda: self._auth(True),
|
|
112
|
+
label="Contentful CMA",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _base(self, host: str) -> str:
|
|
116
|
+
return (
|
|
117
|
+
"https://"
|
|
118
|
+
+ host
|
|
119
|
+
+ "/spaces/"
|
|
120
|
+
+ enc(required(self.env, "CONTENTFUL_SPACE_ID"))
|
|
121
|
+
+ "/environments/"
|
|
122
|
+
+ enc(self.env.get("CONTENTFUL_ENVIRONMENT") or "master")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _auth(self, management: bool) -> dict[str, str]:
|
|
126
|
+
result = {
|
|
127
|
+
"authorization": "Bearer "
|
|
128
|
+
+ required(self.env, "CONTENTFUL_CMA_TOKEN" if management else "CONTENTFUL_CDA_TOKEN")
|
|
129
|
+
}
|
|
130
|
+
result["content-type" if management else "accept"] = (
|
|
131
|
+
"application/vnd.contentful.management.v1+json" if management else "application/json"
|
|
132
|
+
)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
async def _collection(
|
|
136
|
+
self, kind: str, input: Mapping[str, Any], management: bool = False
|
|
137
|
+
) -> Any:
|
|
138
|
+
params = input.get("query", {})
|
|
139
|
+
suffix = "&".join(
|
|
140
|
+
enc(k) + "=" + enc(js_string(v)) for k, v in params.items() if v is not None
|
|
141
|
+
)
|
|
142
|
+
row = await (self.cma if management else self.cda).call(
|
|
143
|
+
"/" + kind + ("?" + suffix if suffix else "")
|
|
144
|
+
)
|
|
145
|
+
mapper = (
|
|
146
|
+
content_type
|
|
147
|
+
if kind == "content_types"
|
|
148
|
+
else lambda v: document(v, "Entry" if kind == "entries" else "Asset")
|
|
149
|
+
)
|
|
150
|
+
result = {key: number(row.get(key), 0) for key in ("total", "skip", "limit")}
|
|
151
|
+
result["items"] = (
|
|
152
|
+
[mapper(item) for item in row.get("items", [])]
|
|
153
|
+
if isinstance(row.get("items"), list)
|
|
154
|
+
else []
|
|
155
|
+
)
|
|
156
|
+
assert_json_limit("contentful." + kind, result, 4 * 1024 * 1024 - 1)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
async def get_entries(self, input: t.GetEntriesInput) -> t.EntryCollection:
|
|
160
|
+
return cast(t.EntryCollection, await self._collection("entries", input))
|
|
161
|
+
|
|
162
|
+
async def get_assets(self, input: t.GetAssetsInput) -> t.AssetCollection:
|
|
163
|
+
return cast(t.AssetCollection, await self._collection("assets", input))
|
|
164
|
+
|
|
165
|
+
async def get_content_types(self, input: t.GetContentTypesInput) -> t.ContentTypeCollection:
|
|
166
|
+
return cast(t.ContentTypeCollection, await self._collection("content_types", input, True))
|
|
167
|
+
|
|
168
|
+
async def get_entry(self, input: t.GetEntryInput) -> t.EntryDTO:
|
|
169
|
+
nonempty(input["id"], "contentful.getEntry requires a nonempty id.")
|
|
170
|
+
return cast(
|
|
171
|
+
t.EntryDTO, document(await self.cma.call("/entries/" + enc(input["id"])), "Entry")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def create_entry(self, input: t.CreateEntryInput) -> t.EntryDTO:
|
|
175
|
+
nonempty(input["contentType"], "contentful.createEntry requires a contentType id.")
|
|
176
|
+
id = input.get("entryId")
|
|
177
|
+
row = await self.cma.call(
|
|
178
|
+
"/entries" + ("/" + enc(id) if id else ""),
|
|
179
|
+
method="PUT" if id else "POST",
|
|
180
|
+
headers={"x-contentful-content-type": input["contentType"]},
|
|
181
|
+
body={"fields": input["fields"]},
|
|
182
|
+
)
|
|
183
|
+
return cast(t.EntryDTO, document(row, "Entry"))
|
|
184
|
+
|
|
185
|
+
async def update_entry(self, input: t.UpdateEntryInput) -> t.EntryDTO:
|
|
186
|
+
nonempty(input["id"], "contentful.updateEntry requires a nonempty id.")
|
|
187
|
+
row = await self.cma.call(
|
|
188
|
+
"/entries/" + enc(input["id"]),
|
|
189
|
+
method="PUT",
|
|
190
|
+
headers={"x-contentful-version": js_string(input["version"])},
|
|
191
|
+
body={"fields": input["fields"]},
|
|
192
|
+
)
|
|
193
|
+
return cast(t.EntryDTO, document(row, "Entry"))
|
|
194
|
+
|
|
195
|
+
async def _publish(self, input: Mapping[str, Any], kind: str, method: str) -> Any:
|
|
196
|
+
nonempty(input["id"], "contentful publishing requires a nonempty id.")
|
|
197
|
+
row = await self.cma.call(
|
|
198
|
+
"/" + kind + "/" + enc(input["id"]) + "/published",
|
|
199
|
+
method=method,
|
|
200
|
+
headers={"x-contentful-version": js_string(input["version"])},
|
|
201
|
+
)
|
|
202
|
+
return document(row, "Entry" if kind == "entries" else "Asset")
|
|
203
|
+
|
|
204
|
+
async def publish_entry(self, input: t.PublishEntryInput) -> t.EntryDTO:
|
|
205
|
+
return cast(t.EntryDTO, await self._publish(input, "entries", "PUT"))
|
|
206
|
+
|
|
207
|
+
async def unpublish_entry(self, input: t.UnpublishEntryInput) -> t.EntryDTO:
|
|
208
|
+
return cast(t.EntryDTO, await self._publish(input, "entries", "DELETE"))
|
|
209
|
+
|
|
210
|
+
async def publish_asset(self, input: t.PublishAssetInput) -> t.AssetDTO:
|
|
211
|
+
return cast(t.AssetDTO, await self._publish(input, "assets", "PUT"))
|
|
212
|
+
|
|
213
|
+
async def delete_entry(self, input: t.DeleteEntryInput) -> t.DeleteEntryResult:
|
|
214
|
+
nonempty(input["id"], "contentful.deleteEntry requires a nonempty id.")
|
|
215
|
+
try:
|
|
216
|
+
await self.cma.call(
|
|
217
|
+
"/entries/" + enc(input["id"]), method="DELETE", response_type="text"
|
|
218
|
+
)
|
|
219
|
+
except ApiError as error:
|
|
220
|
+
if error.status != 404:
|
|
221
|
+
raise
|
|
222
|
+
return {"id": input["id"], "deleted": True}
|
|
223
|
+
|
|
224
|
+
async def create_asset(self, input: t.CreateAssetInput) -> t.AssetDTO:
|
|
225
|
+
file = input["fields"].get("file")
|
|
226
|
+
if not isinstance(file, dict):
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"contentful.createAsset requires fields.file to be a locale-keyed object"
|
|
229
|
+
)
|
|
230
|
+
id = input.get("assetId")
|
|
231
|
+
row = await self.cma.call(
|
|
232
|
+
"/assets" + ("/" + enc(id) if id else ""),
|
|
233
|
+
method="PUT" if id else "POST",
|
|
234
|
+
body={"fields": input["fields"]},
|
|
235
|
+
)
|
|
236
|
+
for locale in input.get("processLocales", list(file)):
|
|
237
|
+
id = row["sys"]["id"]
|
|
238
|
+
await self.cma.call(
|
|
239
|
+
"/assets/" + enc(id) + "/files/" + enc(locale) + "/process",
|
|
240
|
+
method="PUT",
|
|
241
|
+
headers={"x-contentful-version": js_string(row["sys"].get("version", 0))},
|
|
242
|
+
response_type="text",
|
|
243
|
+
)
|
|
244
|
+
row = await self.cma.call("/assets/" + enc(id))
|
|
245
|
+
return cast(t.AssetDTO, document(row, "Asset"))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@asynccontextmanager
|
|
249
|
+
async def dependencies(deps: Deps | None) -> AsyncIterator[Deps]:
|
|
250
|
+
if deps is not None:
|
|
251
|
+
yield deps
|
|
252
|
+
else:
|
|
253
|
+
async with httpx.AsyncClient(
|
|
254
|
+
timeout=30, transport=httpx.AsyncHTTPTransport(retries=0)
|
|
255
|
+
) as http:
|
|
256
|
+
yield Deps(contentful=Client(http))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
ContentfulPort = Port
|
|
260
|
+
ContentfulClient = Client
|
|
261
|
+
ContentfulDeps = Deps
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import AssetDTO, CreateAssetInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def create_asset_impl(
|
|
10
|
+
ctx: TaskContext, input: CreateAssetInput, *, deps: Deps | None = None
|
|
11
|
+
) -> AssetDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.create_asset(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.createAsset", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def create_asset(ctx: TaskContext, input: CreateAssetInput) -> AssetDTO:
|
|
18
|
+
return await create_asset_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import CreateEntryInput, EntryDTO
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def create_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: CreateEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.create_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.createEntry", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def create_entry(ctx: TaskContext, input: CreateEntryInput) -> EntryDTO:
|
|
18
|
+
return await create_entry_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_RETRY
|
|
6
|
+
from .types import DeleteEntryInput, DeleteEntryResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def delete_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: DeleteEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> DeleteEntryResult:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.delete_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.deleteEntry", retry=CONTENTFUL_RETRY)
|
|
17
|
+
async def delete_entry(ctx: TaskContext, input: DeleteEntryInput) -> DeleteEntryResult:
|
|
18
|
+
return await delete_entry_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_RETRY
|
|
6
|
+
from .types import AssetCollection, GetAssetsInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_assets_impl(
|
|
10
|
+
ctx: TaskContext, input: GetAssetsInput, *, deps: Deps | None = None
|
|
11
|
+
) -> AssetCollection:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.get_assets(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.getAssets", retry=CONTENTFUL_RETRY)
|
|
17
|
+
async def get_assets(ctx: TaskContext, input: GetAssetsInput) -> AssetCollection:
|
|
18
|
+
return await get_assets_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_RETRY
|
|
6
|
+
from .types import ContentTypeCollection, GetContentTypesInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_content_types_impl(
|
|
10
|
+
ctx: TaskContext, input: GetContentTypesInput, *, deps: Deps | None = None
|
|
11
|
+
) -> ContentTypeCollection:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.get_content_types(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.getContentTypes", retry=CONTENTFUL_RETRY)
|
|
17
|
+
async def get_content_types(ctx: TaskContext, input: GetContentTypesInput) -> ContentTypeCollection:
|
|
18
|
+
return await get_content_types_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_RETRY
|
|
6
|
+
from .types import EntryCollection, GetEntriesInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_entries_impl(
|
|
10
|
+
ctx: TaskContext, input: GetEntriesInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryCollection:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.get_entries(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.getEntries", retry=CONTENTFUL_RETRY)
|
|
17
|
+
async def get_entries(ctx: TaskContext, input: GetEntriesInput) -> EntryCollection:
|
|
18
|
+
return await get_entries_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_RETRY
|
|
6
|
+
from .types import EntryDTO, GetEntryInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: GetEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.get_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.getEntry", retry=CONTENTFUL_RETRY)
|
|
17
|
+
async def get_entry(ctx: TaskContext, input: GetEntryInput) -> EntryDTO:
|
|
18
|
+
return await get_entry_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import AssetDTO, PublishAssetInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def publish_asset_impl(
|
|
10
|
+
ctx: TaskContext, input: PublishAssetInput, *, deps: Deps | None = None
|
|
11
|
+
) -> AssetDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.publish_asset(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.publishAsset", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def publish_asset(ctx: TaskContext, input: PublishAssetInput) -> AssetDTO:
|
|
18
|
+
return await publish_asset_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import EntryDTO, PublishEntryInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def publish_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: PublishEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.publish_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.publishEntry", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def publish_entry(ctx: TaskContext, input: PublishEntryInput) -> EntryDTO:
|
|
18
|
+
return await publish_entry_impl(ctx, input)
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from ._app import app
|
|
2
|
+
from .create_asset import create_asset, create_asset_impl
|
|
3
|
+
from .create_entry import create_entry, create_entry_impl
|
|
4
|
+
from .delete_entry import delete_entry, delete_entry_impl
|
|
5
|
+
from .get_assets import get_assets, get_assets_impl
|
|
6
|
+
from .get_content_types import get_content_types, get_content_types_impl
|
|
7
|
+
from .get_entries import get_entries, get_entries_impl
|
|
8
|
+
from .get_entry import get_entry, get_entry_impl
|
|
9
|
+
from .publish_asset import publish_asset, publish_asset_impl
|
|
10
|
+
from .publish_entry import publish_entry, publish_entry_impl
|
|
11
|
+
from .unpublish_entry import unpublish_entry, unpublish_entry_impl
|
|
12
|
+
from .update_entry import update_entry, update_entry_impl
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"app",
|
|
16
|
+
"create_asset",
|
|
17
|
+
"create_asset_impl",
|
|
18
|
+
"create_entry",
|
|
19
|
+
"create_entry_impl",
|
|
20
|
+
"delete_entry",
|
|
21
|
+
"delete_entry_impl",
|
|
22
|
+
"get_assets",
|
|
23
|
+
"get_assets_impl",
|
|
24
|
+
"get_content_types",
|
|
25
|
+
"get_content_types_impl",
|
|
26
|
+
"get_entries",
|
|
27
|
+
"get_entries_impl",
|
|
28
|
+
"get_entry",
|
|
29
|
+
"get_entry_impl",
|
|
30
|
+
"publish_asset",
|
|
31
|
+
"publish_asset_impl",
|
|
32
|
+
"publish_entry",
|
|
33
|
+
"publish_entry_impl",
|
|
34
|
+
"unpublish_entry",
|
|
35
|
+
"unpublish_entry_impl",
|
|
36
|
+
"update_entry",
|
|
37
|
+
"update_entry_impl",
|
|
38
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""JSON contracts ported from the pinned TypeScript pack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContentTypeDTOFieldsItem(TypedDict):
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
type: str
|
|
12
|
+
required: bool
|
|
13
|
+
localized: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
type JsonPrimitive = str | float | bool | None
|
|
17
|
+
|
|
18
|
+
type JsonValue = JsonPrimitive | list[JsonValue] | dict[str, JsonValue]
|
|
19
|
+
|
|
20
|
+
type JsonObject = dict[str, JsonValue]
|
|
21
|
+
|
|
22
|
+
type ContentfulQuery = dict[str, str | float | bool | list[str | float]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EntryDTO(TypedDict):
|
|
26
|
+
id: str
|
|
27
|
+
type: Literal["Entry"]
|
|
28
|
+
contentType: str | None
|
|
29
|
+
version: float
|
|
30
|
+
publishedVersion: float | None
|
|
31
|
+
createdAt: str | None
|
|
32
|
+
updatedAt: str | None
|
|
33
|
+
fields: JsonObject
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AssetDTO(TypedDict):
|
|
37
|
+
id: str
|
|
38
|
+
type: Literal["Asset"]
|
|
39
|
+
version: float
|
|
40
|
+
publishedVersion: float | None
|
|
41
|
+
createdAt: str | None
|
|
42
|
+
updatedAt: str | None
|
|
43
|
+
fields: JsonObject
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContentTypeDTO(TypedDict):
|
|
47
|
+
id: str
|
|
48
|
+
name: str
|
|
49
|
+
displayField: str | None
|
|
50
|
+
fields: list[ContentTypeDTOFieldsItem]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EntryCollection(TypedDict):
|
|
54
|
+
items: list[EntryDTO]
|
|
55
|
+
total: float
|
|
56
|
+
skip: float
|
|
57
|
+
limit: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AssetCollection(TypedDict):
|
|
61
|
+
items: list[AssetDTO]
|
|
62
|
+
total: float
|
|
63
|
+
skip: float
|
|
64
|
+
limit: float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ContentTypeCollection(TypedDict):
|
|
68
|
+
items: list[ContentTypeDTO]
|
|
69
|
+
total: float
|
|
70
|
+
skip: float
|
|
71
|
+
limit: float
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GetEntriesInput(TypedDict):
|
|
75
|
+
query: NotRequired[ContentfulQuery]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GetEntryInput(TypedDict):
|
|
79
|
+
id: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CreateEntryInput(TypedDict):
|
|
83
|
+
contentType: str
|
|
84
|
+
fields: JsonObject
|
|
85
|
+
entryId: NotRequired[str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UpdateEntryInput(TypedDict):
|
|
89
|
+
id: str
|
|
90
|
+
version: float
|
|
91
|
+
fields: JsonObject
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PublishEntryInput(TypedDict):
|
|
95
|
+
id: str
|
|
96
|
+
version: float
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class UnpublishEntryInput(TypedDict):
|
|
100
|
+
id: str
|
|
101
|
+
version: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DeleteEntryInput(TypedDict):
|
|
105
|
+
id: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GetAssetsInput(TypedDict):
|
|
109
|
+
query: NotRequired[ContentfulQuery]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CreateAssetInput(TypedDict):
|
|
113
|
+
fields: JsonObject
|
|
114
|
+
assetId: NotRequired[str]
|
|
115
|
+
processLocales: NotRequired[list[str]]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PublishAssetInput(TypedDict):
|
|
119
|
+
id: str
|
|
120
|
+
version: float
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GetContentTypesInput(TypedDict):
|
|
124
|
+
query: NotRequired[ContentfulQuery]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeleteEntryResult(TypedDict):
|
|
128
|
+
id: str
|
|
129
|
+
deleted: Literal[True]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import EntryDTO, UnpublishEntryInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def unpublish_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: UnpublishEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.unpublish_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.unpublishEntry", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def unpublish_entry(ctx: TaskContext, input: UnpublishEntryInput) -> EntryDTO:
|
|
18
|
+
return await unpublish_entry_impl(ctx, input)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from render import TaskContext
|
|
2
|
+
|
|
3
|
+
from ._app import app
|
|
4
|
+
from .client import Deps, dependencies
|
|
5
|
+
from .retry import CONTENTFUL_NO_RETRY
|
|
6
|
+
from .types import EntryDTO, UpdateEntryInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def update_entry_impl(
|
|
10
|
+
ctx: TaskContext, input: UpdateEntryInput, *, deps: Deps | None = None
|
|
11
|
+
) -> EntryDTO:
|
|
12
|
+
async with dependencies(deps) as resolved:
|
|
13
|
+
return await resolved.contentful.update_entry(input)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.task(name="contentful.updateEntry", retry=CONTENTFUL_NO_RETRY)
|
|
17
|
+
async def update_entry(ctx: TaskContext, input: UpdateEntryInput) -> EntryDTO:
|
|
18
|
+
return await update_entry_impl(ctx, input)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Registration-free contentful webhook verification and event mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from render_lab_tasks_core.signatures import digest, safe_equal
|
|
13
|
+
from render_lab_triggers.types import Adapter, Json, WebhookContext, WebhookDispatch, WebhookRequest
|
|
14
|
+
|
|
15
|
+
type OnEvent = Callable[[dict[str, Json]], WebhookDispatch | None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CanonicalRequest(TypedDict):
|
|
19
|
+
method: str
|
|
20
|
+
path: str
|
|
21
|
+
headers: dict[str, str]
|
|
22
|
+
body: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute_contentful_signature(
|
|
26
|
+
secret: str, request: CanonicalRequest, signed_header_names: list[str]
|
|
27
|
+
) -> str:
|
|
28
|
+
normalized = {k.lower().strip(): v.strip() for k, v in request["headers"].items()}
|
|
29
|
+
picked = {
|
|
30
|
+
key: normalized[key]
|
|
31
|
+
for name in signed_header_names
|
|
32
|
+
if (key := name.lower().strip()) in normalized
|
|
33
|
+
}
|
|
34
|
+
picked["x-contentful-signed-headers"] = ",".join(sorted(picked))
|
|
35
|
+
headers = ";".join(f"{key}:{value}" for key, value in sorted(picked.items()))
|
|
36
|
+
parts = request["path"].split("?")
|
|
37
|
+
query = quote(parts[1], safe="~!*'()") if len(parts) > 1 and parts[1] else ""
|
|
38
|
+
path = parts[0] + ("?" + query if query else "")
|
|
39
|
+
path = quote(path, safe=";/?:@&=+$,#~!*'()-._")
|
|
40
|
+
return digest(secret, "\n".join([request["method"], path, headers, request["body"]]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_contentful_request(
|
|
44
|
+
secret: str,
|
|
45
|
+
request: CanonicalRequest,
|
|
46
|
+
ttl_seconds: float = 30,
|
|
47
|
+
now: Callable[[], float] | None = None,
|
|
48
|
+
) -> bool:
|
|
49
|
+
headers = {k.lower().strip(): v.strip() for k, v in request["headers"].items()}
|
|
50
|
+
signature = headers.get("x-contentful-signature")
|
|
51
|
+
names = [name for name in headers.get("x-contentful-signed-headers", "").split(",") if name]
|
|
52
|
+
timestamp = re.match(r"^[+-]?\d+", headers.get("x-contentful-timestamp", ""))
|
|
53
|
+
if not signature or not names or timestamp is None:
|
|
54
|
+
return False
|
|
55
|
+
now_ms = (now or (lambda: time.time() * 1000))()
|
|
56
|
+
if ttl_seconds != 0 and now_ms - int(timestamp[0]) >= ttl_seconds * 1000:
|
|
57
|
+
return False
|
|
58
|
+
return safe_equal(signature, compute_contentful_signature(secret, request, names))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def contentful_adapter(
|
|
62
|
+
on_event: OnEvent,
|
|
63
|
+
*,
|
|
64
|
+
path: str,
|
|
65
|
+
secret: str | None = None,
|
|
66
|
+
method: str = "POST",
|
|
67
|
+
ttl_seconds: float = 30,
|
|
68
|
+
now: Callable[[], float] | None = None,
|
|
69
|
+
) -> Adapter:
|
|
70
|
+
def verify(req: WebhookRequest) -> bool:
|
|
71
|
+
key = secret if secret is not None else os.getenv("CONTENTFUL_WEBHOOK_SECRET")
|
|
72
|
+
return bool(key) and verify_contentful_request(
|
|
73
|
+
key or "",
|
|
74
|
+
{"method": method, "path": path, "headers": req["headers"], "body": req["rawBody"]},
|
|
75
|
+
ttl_seconds,
|
|
76
|
+
now,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def map_event(ctx: WebhookContext) -> WebhookDispatch | None:
|
|
80
|
+
payload = ctx["body"] if ctx["body"] is not None else {}
|
|
81
|
+
headers = ctx["headers"]
|
|
82
|
+
return on_event(
|
|
83
|
+
{
|
|
84
|
+
"topic": headers.get("x-contentful-topic", headers.get("X-Contentful-Topic", "")),
|
|
85
|
+
"payload": payload,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return Adapter(verify, map_event)
|