dex-connector-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.
- dex_connector_contentful-0.1.0/.gitignore +6 -0
- dex_connector_contentful-0.1.0/PKG-INFO +48 -0
- dex_connector_contentful-0.1.0/README.md +37 -0
- dex_connector_contentful-0.1.0/examples/sample_contentful/__init__.py +3 -0
- dex_connector_contentful-0.1.0/examples/sample_contentful/flow.py +74 -0
- dex_connector_contentful-0.1.0/examples/sample_contentful/worker.py +51 -0
- dex_connector_contentful-0.1.0/pyproject.toml +37 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/__init__.py +50 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/__init__.py +2 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/config.py +25 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/connection.py +131 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/definitions.py +57 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/mock.py +113 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/models.py +55 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/_generated/steps.py +131 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/client.py +362 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/connection.py +44 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/contentfulmock/__init__.py +31 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/dex-connector.json +82 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/models.py +23 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/py.typed +0 -0
- dex_connector_contentful-0.1.0/src/dex_connectors/contentful/steps.py +33 -0
- dex_connector_contentful-0.1.0/tests/test_conformance.py +33 -0
- dex_connector_contentful-0.1.0/tests/test_manifest_contract.py +15 -0
- dex_connector_contentful-0.1.0/tests/test_operations.py +197 -0
- dex_connector_contentful-0.1.0/uv.lock +932 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dex-connector-contentful
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Contentful connector for Dex Flows
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: dex-connectors-sdk==0.1.0
|
|
8
|
+
Requires-Dist: dex-python-sdk>=1.28.0
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Contentful connector for Python
|
|
13
|
+
|
|
14
|
+
Install with `pip install dex-connector-contentful==0.1.0`. The package requires
|
|
15
|
+
`dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and implements the
|
|
16
|
+
manifest Queries `getEntry` and `listEntries` with the same
|
|
17
|
+
branches, defaults, and failure classification as the Go module. Each output
|
|
18
|
+
keeps the provider's JSON response untouched in `data`.
|
|
19
|
+
|
|
20
|
+
## Operations
|
|
21
|
+
|
|
22
|
+
| Operation | Factory | Happy branch | Optional branches |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| `getEntry` | `new_get_entry_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
|
|
25
|
+
| `listEntries` | `new_list_entries_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
|
|
26
|
+
|
|
27
|
+
Space, environment, and entry IDs must be letters, digits, `_`, or `-`; `listEntries` accepts `limit` up to 1000 and a nonnegative `skip`. 408, 409, 429, 5xx, and transport failures retry. HTTP 404
|
|
28
|
+
selects `notFound` where the operation declares it; other non-200 responses
|
|
29
|
+
select `providerRejected` with authentication (401), authorization (403), or
|
|
30
|
+
validation (400, 422) failure kinds. A successful body without the expected
|
|
31
|
+
top-level shape is `invalidResponse`.
|
|
32
|
+
|
|
33
|
+
## Authorization
|
|
34
|
+
|
|
35
|
+
Create a Dex Web connection named `contentful` as described in the
|
|
36
|
+
[connector README](../README.md#authorization-and-configuration). The Content Delivery API token travels only in the `Authorization: Bearer` header and is resolved for every attempt.
|
|
37
|
+
|
|
38
|
+
## Example
|
|
39
|
+
|
|
40
|
+
[`examples/sample_contentful`](examples/sample_contentful/flow.py) runs one
|
|
41
|
+
`getEntry` read and records the result. Run the Worker with
|
|
42
|
+
`uv run python -m examples.sample_contentful.worker` after `dexcli dev` has
|
|
43
|
+
loaded a project containing the `contentful` connection.
|
|
44
|
+
|
|
45
|
+
## Tests
|
|
46
|
+
|
|
47
|
+
`contentfulmock.Mock` scripts both Queries and defaults to the manifest answers.
|
|
48
|
+
Run `uv run pytest tests` for provider parity and manifest contract checks.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Contentful connector for Python
|
|
2
|
+
|
|
3
|
+
Install with `pip install dex-connector-contentful==0.1.0`. The package requires
|
|
4
|
+
`dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and implements the
|
|
5
|
+
manifest Queries `getEntry` and `listEntries` with the same
|
|
6
|
+
branches, defaults, and failure classification as the Go module. Each output
|
|
7
|
+
keeps the provider's JSON response untouched in `data`.
|
|
8
|
+
|
|
9
|
+
## Operations
|
|
10
|
+
|
|
11
|
+
| Operation | Factory | Happy branch | Optional branches |
|
|
12
|
+
| --- | --- | --- | --- |
|
|
13
|
+
| `getEntry` | `new_get_entry_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
|
|
14
|
+
| `listEntries` | `new_list_entries_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
|
|
15
|
+
|
|
16
|
+
Space, environment, and entry IDs must be letters, digits, `_`, or `-`; `listEntries` accepts `limit` up to 1000 and a nonnegative `skip`. 408, 409, 429, 5xx, and transport failures retry. HTTP 404
|
|
17
|
+
selects `notFound` where the operation declares it; other non-200 responses
|
|
18
|
+
select `providerRejected` with authentication (401), authorization (403), or
|
|
19
|
+
validation (400, 422) failure kinds. A successful body without the expected
|
|
20
|
+
top-level shape is `invalidResponse`.
|
|
21
|
+
|
|
22
|
+
## Authorization
|
|
23
|
+
|
|
24
|
+
Create a Dex Web connection named `contentful` as described in the
|
|
25
|
+
[connector README](../README.md#authorization-and-configuration). The Content Delivery API token travels only in the `Authorization: Bearer` header and is resolved for every attempt.
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
[`examples/sample_contentful`](examples/sample_contentful/flow.py) runs one
|
|
30
|
+
`getEntry` read and records the result. Run the Worker with
|
|
31
|
+
`uv run python -m examples.sample_contentful.worker` after `dexcli dev` has
|
|
32
|
+
loaded a project containing the `contentful` connection.
|
|
33
|
+
|
|
34
|
+
## Tests
|
|
35
|
+
|
|
36
|
+
`contentfulmock.Mock` scripts both Queries and defaults to the manifest answers.
|
|
37
|
+
Run `uv run pytest tests` for provider parity and manifest contract checks.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Runnable Contentful sample Flow using the getEntry Query."""
|
|
4
|
+
|
|
5
|
+
import dex
|
|
6
|
+
from dex_connectors.sdk import QueryResult
|
|
7
|
+
|
|
8
|
+
from dex_connectors.contentful import Connection, GetEntryInput, GetEntryOutput, new_get_entry_step
|
|
9
|
+
|
|
10
|
+
FLOW_TYPE = "ContentfulSample"
|
|
11
|
+
CONNECTION_NAME = "contentful"
|
|
12
|
+
_CALL_STEP_TYPE = "CallContentful"
|
|
13
|
+
_RESULT = dex.Attribute("contentful-sample-result", GetEntryOutput)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def map_to_operation_input(request: GetEntryInput) -> GetEntryInput:
|
|
17
|
+
"""Send the request unchanged."""
|
|
18
|
+
return request
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# dex:group group-id:contentful group-label:"Contentful"
|
|
22
|
+
# dex:explanation text:"Start the Contentful request."
|
|
23
|
+
class _StartRequest(dex.Step[GetEntryInput]):
|
|
24
|
+
def get_step_type(self) -> str:
|
|
25
|
+
return "StartContentfulRequest"
|
|
26
|
+
|
|
27
|
+
def execute(self, context: dex.Context, input: GetEntryInput) -> dex.StepDecision:
|
|
28
|
+
return dex.go_to(dex.StepRef(_CALL_STEP_TYPE), input)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# dex:group group-id:contentful group-label:"Contentful"
|
|
32
|
+
# dex:explanation text:"Record the provider result and complete the Flow."
|
|
33
|
+
class _CompleteRequest(dex.Step[QueryResult[GetEntryOutput]]):
|
|
34
|
+
def get_step_type(self) -> str:
|
|
35
|
+
return "CompleteContentfulRequest"
|
|
36
|
+
|
|
37
|
+
def execute(self, context: dex.Context, input: QueryResult[GetEntryOutput]) -> dex.StepDecision:
|
|
38
|
+
_RESULT.set(context, input.value)
|
|
39
|
+
return dex.graceful_complete(input.value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SampleFlow(dex.Flow[GetEntryInput]):
|
|
43
|
+
"""Run one getEntry read through the static contentful connection."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, connection: Connection) -> None:
|
|
46
|
+
if connection.reference.name != CONNECTION_NAME:
|
|
47
|
+
raise ValueError("the sample requires the static contentful connection")
|
|
48
|
+
self._start = _StartRequest()
|
|
49
|
+
self._complete = _CompleteRequest()
|
|
50
|
+
self._call = new_get_entry_step(
|
|
51
|
+
step_type=_CALL_STEP_TYPE,
|
|
52
|
+
step_input_type=GetEntryInput,
|
|
53
|
+
annotations={
|
|
54
|
+
"groupId": "contentful",
|
|
55
|
+
"groupLabel": "Contentful",
|
|
56
|
+
"explanation": "Get one published Contentful entry.",
|
|
57
|
+
},
|
|
58
|
+
connection=connection,
|
|
59
|
+
connection_name=CONNECTION_NAME,
|
|
60
|
+
map_to_operation_input=map_to_operation_input,
|
|
61
|
+
found=self._complete,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def get_flow_type(self) -> str:
|
|
65
|
+
"""Return the stable Flow identity used in Dex Web."""
|
|
66
|
+
return FLOW_TYPE
|
|
67
|
+
|
|
68
|
+
def get_steps(self) -> dex.StepList[GetEntryInput]:
|
|
69
|
+
"""Register the start, provider read, and completion Steps."""
|
|
70
|
+
return dex.StepList.start_step(self._start).other_steps(self._call, self._complete)
|
|
71
|
+
|
|
72
|
+
def get_persistence_schema(self) -> dex.PersistenceSchema:
|
|
73
|
+
"""Persist the provider result."""
|
|
74
|
+
return dex.PersistenceSchema.of(_RESULT)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Worker entrypoint for the Contentful sample Flow."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import dex
|
|
11
|
+
from dex_connectors.sdk.projectconfig import load_from_environment
|
|
12
|
+
|
|
13
|
+
from dex_connectors.contentful import new_project_connection
|
|
14
|
+
|
|
15
|
+
from .flow import CONNECTION_NAME, SampleFlow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def run_worker() -> None:
|
|
19
|
+
"""Load the named Dex Web connection and serve Contentful reads."""
|
|
20
|
+
project = await load_from_environment()
|
|
21
|
+
connection = None
|
|
22
|
+
cache = None
|
|
23
|
+
try:
|
|
24
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
25
|
+
registry = dex.Registry([SampleFlow(connection)], allow_async_handlers=True)
|
|
26
|
+
cache_directory = os.environ.get(
|
|
27
|
+
"DEX_BLOB_CACHE_DIR", str(Path(tempfile.gettempdir()) / "dex-contentful-sample-blobs")
|
|
28
|
+
)
|
|
29
|
+
cache = dex.open_blob_cache(dex.BlobCacheConfig(cache_directory, 1 << 30))
|
|
30
|
+
worker = dex.AsyncWorker(
|
|
31
|
+
registry,
|
|
32
|
+
cache,
|
|
33
|
+
dex.WorkerOptions(server_address=os.environ.get("DEX_FLOW_SERVICE_ADDRESS", "localhost:8801")),
|
|
34
|
+
)
|
|
35
|
+
async with worker:
|
|
36
|
+
await worker.start()
|
|
37
|
+
finally:
|
|
38
|
+
if connection is not None:
|
|
39
|
+
await connection.aclose()
|
|
40
|
+
if cache is not None:
|
|
41
|
+
cache.close()
|
|
42
|
+
await project.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
"""Run until the Worker stops or the process receives an interrupt."""
|
|
47
|
+
asyncio.run(run_worker())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dex-connector-contentful"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Contentful connector for Dex Flows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["dex-connectors-sdk==0.1.0", "dex-python-sdk>=1.28.0", "httpx>=0.27"]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.13", "ruff>=0.7", "pyyaml>=6", "types-PyYAML>=6"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/dex_connectors"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
22
|
+
pythonpath = [".", "src"]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
25
|
+
|
|
26
|
+
[tool.mypy]
|
|
27
|
+
python_version = "3.11"
|
|
28
|
+
strict = true
|
|
29
|
+
mypy_path = "src"
|
|
30
|
+
explicit_package_bases = true
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py311"
|
|
34
|
+
line-length = 110
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Contentful Queries for Dex connector Steps."""
|
|
4
|
+
|
|
5
|
+
from .client import (
|
|
6
|
+
CONNECTOR_ID,
|
|
7
|
+
DEFECT,
|
|
8
|
+
FOUND,
|
|
9
|
+
GET_ENTRY_DEFINITION,
|
|
10
|
+
INVALID_RESPONSE,
|
|
11
|
+
LIST_ENTRIES_DEFINITION,
|
|
12
|
+
LISTED,
|
|
13
|
+
NOT_FOUND,
|
|
14
|
+
PROVIDER_REJECTED,
|
|
15
|
+
Client,
|
|
16
|
+
Config,
|
|
17
|
+
Credentials,
|
|
18
|
+
GetEntryOperation,
|
|
19
|
+
ListEntriesOperation,
|
|
20
|
+
)
|
|
21
|
+
from .connection import Connection, new_connection, new_connection_with_operations, new_project_connection
|
|
22
|
+
from .models import GetEntryInput, GetEntryOutput, ListEntriesInput, ListEntriesOutput
|
|
23
|
+
from .steps import new_get_entry_step, new_list_entries_step
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Client",
|
|
27
|
+
"Config",
|
|
28
|
+
"Connection",
|
|
29
|
+
"Credentials",
|
|
30
|
+
"GetEntryInput",
|
|
31
|
+
"GetEntryOperation",
|
|
32
|
+
"GetEntryOutput",
|
|
33
|
+
"ListEntriesInput",
|
|
34
|
+
"ListEntriesOperation",
|
|
35
|
+
"ListEntriesOutput",
|
|
36
|
+
"new_connection",
|
|
37
|
+
"new_connection_with_operations",
|
|
38
|
+
"new_get_entry_step",
|
|
39
|
+
"new_list_entries_step",
|
|
40
|
+
"new_project_connection",
|
|
41
|
+
"CONNECTOR_ID",
|
|
42
|
+
"DEFECT",
|
|
43
|
+
"FOUND",
|
|
44
|
+
"GET_ENTRY_DEFINITION",
|
|
45
|
+
"INVALID_RESPONSE",
|
|
46
|
+
"LISTED",
|
|
47
|
+
"LIST_ENTRIES_DEFINITION",
|
|
48
|
+
"NOT_FOUND",
|
|
49
|
+
"PROVIDER_REJECTED",
|
|
50
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk import SecretString
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Config:
|
|
12
|
+
|
|
13
|
+
base_url: str = "https://cdn.contentful.com"
|
|
14
|
+
timeout_seconds: float = 15
|
|
15
|
+
max_response_bytes: int = 2097152
|
|
16
|
+
|
|
17
|
+
setattr(Config, '__module__', 'dex_connectors.contentful.client')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Credentials:
|
|
22
|
+
|
|
23
|
+
delivery_token: SecretString
|
|
24
|
+
|
|
25
|
+
setattr(Credentials, '__module__', 'dex_connectors.contentful.client')
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from dex_connectors.sdk import ConnectionRef, Query, Call, SecretString
|
|
9
|
+
from dex_connectors.sdk.projectconfig import ConnectionKey, LoadedProject
|
|
10
|
+
|
|
11
|
+
from ..client import Client, Config, Credentials
|
|
12
|
+
from .definitions import CONNECTOR_ID, GET_ENTRY_DEFINITION, LIST_ENTRIES_DEFINITION
|
|
13
|
+
from ..models import GetEntryInput, GetEntryOutput, ListEntriesInput, ListEntriesOutput
|
|
14
|
+
|
|
15
|
+
class Connection:
|
|
16
|
+
|
|
17
|
+
__slots__ = ('_get_entry', '_list_entries', '_client', 'reference')
|
|
18
|
+
|
|
19
|
+
def __init__(self,
|
|
20
|
+
reference: ConnectionRef,
|
|
21
|
+
get_entry: Query[GetEntryInput, GetEntryOutput],
|
|
22
|
+
list_entries: Query[ListEntriesInput, ListEntriesOutput],
|
|
23
|
+
client: Client | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
reference.validate()
|
|
26
|
+
if reference.provider != CONNECTOR_ID or get_entry is None or list_entries is None:
|
|
27
|
+
raise ValueError("Contentful connection provider and operations are required")
|
|
28
|
+
if get_entry.definition().operation != GET_ENTRY_DEFINITION.operation:
|
|
29
|
+
raise ValueError("Contentful connection operation identity differs")
|
|
30
|
+
if list_entries.definition().operation != LIST_ENTRIES_DEFINITION.operation:
|
|
31
|
+
raise ValueError("Contentful connection operation identity differs")
|
|
32
|
+
self.reference = reference
|
|
33
|
+
self._get_entry = get_entry
|
|
34
|
+
self._list_entries = list_entries
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
def get_entry(self) -> Query[GetEntryInput, GetEntryOutput]:
|
|
38
|
+
return self._get_entry
|
|
39
|
+
|
|
40
|
+
def list_entries(self) -> Query[ListEntriesInput, ListEntriesOutput]:
|
|
41
|
+
return self._list_entries
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
if self._client is not None:
|
|
45
|
+
await self._client.aclose()
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return "contentful.Connection([REDACTED])"
|
|
49
|
+
|
|
50
|
+
def __reduce__(self) -> tuple[object, ...]:
|
|
51
|
+
raise TypeError("Contentful connections cannot be serialized")
|
|
52
|
+
|
|
53
|
+
setattr(Connection, '__module__', 'dex_connectors.contentful.connection')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_connection(client: Client, reference: ConnectionRef) -> Connection:
|
|
57
|
+
if client is None:
|
|
58
|
+
raise ValueError("Contentful client is required")
|
|
59
|
+
return Connection(reference, client.get_entry(), client.list_entries(), client)
|
|
60
|
+
|
|
61
|
+
setattr(new_connection, '__module__', 'dex_connectors.contentful.connection')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def new_connection_with_operations(
|
|
65
|
+
get_entry: Query[GetEntryInput, GetEntryOutput],
|
|
66
|
+
list_entries: Query[ListEntriesInput, ListEntriesOutput],
|
|
67
|
+
reference: ConnectionRef,
|
|
68
|
+
) -> Connection:
|
|
69
|
+
return Connection(reference, get_entry, list_entries)
|
|
70
|
+
|
|
71
|
+
setattr(new_connection_with_operations, '__module__', 'dex_connectors.contentful.connection')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _ProjectCredentialSource:
|
|
75
|
+
|
|
76
|
+
def __init__(self, project: LoadedProject, key: ConnectionKey) -> None:
|
|
77
|
+
self.project = project
|
|
78
|
+
self.key = key
|
|
79
|
+
|
|
80
|
+
async def resolve(self, call: Call) -> Credentials:
|
|
81
|
+
material = await self.project.resolve_credential(self.key, str(call.id))
|
|
82
|
+
delivery_token = material.get("delivery_token")
|
|
83
|
+
if not isinstance(delivery_token, str) or not delivery_token:
|
|
84
|
+
raise ValueError("Contentful credential delivery_token is required")
|
|
85
|
+
return Credentials(SecretString(delivery_token))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def new_project_connection(
|
|
89
|
+
project: LoadedProject, connection_name: str, http_client: httpx.AsyncClient | None = None
|
|
90
|
+
) -> Connection:
|
|
91
|
+
if project is None:
|
|
92
|
+
raise ValueError("Contentful connection requires loaded project configuration")
|
|
93
|
+
reference = ConnectionRef(CONNECTOR_ID, connection_name)
|
|
94
|
+
reference.validate()
|
|
95
|
+
key = ConnectionKey(CONNECTOR_ID, connection_name)
|
|
96
|
+
matching = [
|
|
97
|
+
entry
|
|
98
|
+
for entry in project.configuration.connections
|
|
99
|
+
if entry.connector_id == CONNECTOR_ID and entry.connection_name == connection_name
|
|
100
|
+
]
|
|
101
|
+
if len(matching) != 1:
|
|
102
|
+
raise ValueError("Contentful project connection is missing or duplicated")
|
|
103
|
+
if matching[0].provider != CONNECTOR_ID:
|
|
104
|
+
raise ValueError("Contentful project connection provider differs")
|
|
105
|
+
config = _decode_config(matching[0].configuration)
|
|
106
|
+
client = Client(config, _ProjectCredentialSource(project, key), http_client)
|
|
107
|
+
return new_connection(client, reference)
|
|
108
|
+
|
|
109
|
+
setattr(new_project_connection, '__module__', 'dex_connectors.contentful.connection')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode_config(value: dict[str, Any]) -> Config:
|
|
113
|
+
if set(value) - {"baseUrl", "timeout", "maxResponseBytes"}:
|
|
114
|
+
raise ValueError("Contentful configuration has unknown fields")
|
|
115
|
+
config_arguments: dict[str, Any] = {}
|
|
116
|
+
if "baseUrl" in value:
|
|
117
|
+
base_url = value["baseUrl"]
|
|
118
|
+
if not (isinstance(base_url, str)):
|
|
119
|
+
raise ValueError("Contentful configuration baseUrl has an invalid type")
|
|
120
|
+
config_arguments['base_url'] = base_url
|
|
121
|
+
if "timeout" in value:
|
|
122
|
+
timeout = value["timeout"]
|
|
123
|
+
if not (type(timeout) is int):
|
|
124
|
+
raise ValueError("Contentful configuration timeout has an invalid type")
|
|
125
|
+
config_arguments['timeout_seconds'] = timeout / 1_000_000_000
|
|
126
|
+
if "maxResponseBytes" in value:
|
|
127
|
+
max_response_bytes = value["maxResponseBytes"]
|
|
128
|
+
if not (type(max_response_bytes) is int):
|
|
129
|
+
raise ValueError("Contentful configuration maxResponseBytes has an invalid type")
|
|
130
|
+
config_arguments['max_response_bytes'] = max_response_bytes
|
|
131
|
+
return Config(**config_arguments)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk import (
|
|
8
|
+
BranchDefinition,
|
|
9
|
+
MutationDefinition,
|
|
10
|
+
OperationRef,
|
|
11
|
+
QueryDefinition,
|
|
12
|
+
RetryPolicy,
|
|
13
|
+
StepDefaults,
|
|
14
|
+
default_connector_step_execute_retry_policy,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CONNECTOR_ID = "contentful"
|
|
18
|
+
PROVIDER = "contentful"
|
|
19
|
+
GET_ENTRY_OPERATION_ID = "getEntry"
|
|
20
|
+
LIST_ENTRIES_OPERATION_ID = "listEntries"
|
|
21
|
+
DEFECT = "defect"
|
|
22
|
+
FOUND = "found"
|
|
23
|
+
INVALID_RESPONSE = "invalidResponse"
|
|
24
|
+
LISTED = "listed"
|
|
25
|
+
NOT_FOUND = "notFound"
|
|
26
|
+
PROVIDER_REJECTED = "providerRejected"
|
|
27
|
+
|
|
28
|
+
GET_ENTRY_DEFINITION = QueryDefinition(
|
|
29
|
+
OperationRef(CONNECTOR_ID, GET_ENTRY_OPERATION_ID),
|
|
30
|
+
(
|
|
31
|
+
BranchDefinition(FOUND, "Get one published Contentful entry.", False),
|
|
32
|
+
BranchDefinition(NOT_FOUND, "The requested provider resource does not exist or is not visible.", True),
|
|
33
|
+
BranchDefinition(PROVIDER_REJECTED, "The provider conclusively rejected the request or credentials.", True),
|
|
34
|
+
BranchDefinition(INVALID_RESPONSE, "The provider returned malformed or oversized JSON.", True),
|
|
35
|
+
BranchDefinition(DEFECT, "Local input, configuration, or credentials are invalid.", True),
|
|
36
|
+
),
|
|
37
|
+
StepDefaults(
|
|
38
|
+
timedelta(seconds=30),
|
|
39
|
+
2,
|
|
40
|
+
execute_retry=default_connector_step_execute_retry_policy(),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
LIST_ENTRIES_DEFINITION = QueryDefinition(
|
|
45
|
+
OperationRef(CONNECTOR_ID, LIST_ENTRIES_OPERATION_ID),
|
|
46
|
+
(
|
|
47
|
+
BranchDefinition(LISTED, "List one page of published entries in a space and environment.", False),
|
|
48
|
+
BranchDefinition(PROVIDER_REJECTED, "The provider conclusively rejected the request or credentials.", True),
|
|
49
|
+
BranchDefinition(INVALID_RESPONSE, "The provider returned malformed or oversized JSON.", True),
|
|
50
|
+
BranchDefinition(DEFECT, "Local input, configuration, or credentials are invalid.", True),
|
|
51
|
+
),
|
|
52
|
+
StepDefaults(
|
|
53
|
+
timedelta(seconds=30),
|
|
54
|
+
2,
|
|
55
|
+
execute_retry=default_connector_step_execute_retry_policy(),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk import ConnectionRef, Failure, FailureKind
|
|
8
|
+
from dex_connectors.sdk.connectormock import Case, Query
|
|
9
|
+
|
|
10
|
+
from ..connection import Connection, new_connection_with_operations
|
|
11
|
+
from ..models import GetEntryInput, GetEntryOutput, ListEntriesInput, ListEntriesOutput
|
|
12
|
+
from .definitions import CONNECTOR_ID, DEFECT, FOUND, GET_ENTRY_DEFINITION, INVALID_RESPONSE, LISTED, LIST_ENTRIES_DEFINITION, NOT_FOUND, PROVIDER_REJECTED
|
|
13
|
+
|
|
14
|
+
def get_entry_found(value: GetEntryOutput, failure: Failure | None = None) -> Case[GetEntryOutput]:
|
|
15
|
+
return Case.branch_result(FOUND, value, failure)
|
|
16
|
+
|
|
17
|
+
setattr(get_entry_found, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
18
|
+
|
|
19
|
+
def get_entry_not_found(value: GetEntryOutput, failure: Failure | None = None) -> Case[GetEntryOutput]:
|
|
20
|
+
return Case.branch_result(NOT_FOUND, value, failure)
|
|
21
|
+
|
|
22
|
+
setattr(get_entry_not_found, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
23
|
+
|
|
24
|
+
def get_entry_provider_rejected(value: GetEntryOutput, failure: Failure | None = None) -> Case[GetEntryOutput]:
|
|
25
|
+
return Case.branch_result(PROVIDER_REJECTED, value, failure)
|
|
26
|
+
|
|
27
|
+
setattr(get_entry_provider_rejected, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
28
|
+
|
|
29
|
+
def get_entry_invalid_response(value: GetEntryOutput, failure: Failure | None = None) -> Case[GetEntryOutput]:
|
|
30
|
+
return Case.branch_result(INVALID_RESPONSE, value, failure)
|
|
31
|
+
|
|
32
|
+
setattr(get_entry_invalid_response, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
33
|
+
|
|
34
|
+
def get_entry_defect(value: GetEntryOutput, failure: Failure | None = None) -> Case[GetEntryOutput]:
|
|
35
|
+
return Case.branch_result(DEFECT, value, failure)
|
|
36
|
+
|
|
37
|
+
setattr(get_entry_defect, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
38
|
+
|
|
39
|
+
def get_entry_retry(failure: Failure, after_seconds: float = 0) -> Case[GetEntryOutput]:
|
|
40
|
+
return Case.retry(failure, after_seconds)
|
|
41
|
+
|
|
42
|
+
setattr(get_entry_retry, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
43
|
+
|
|
44
|
+
def get_entry_default_get_entry() -> Case[GetEntryOutput]:
|
|
45
|
+
return Case.branch_result(FOUND, GetEntryOutput(data={'sys': {'id': 'entry-meridian', 'type': 'Entry'}, 'fields': {'title': 'Meridian'}}))
|
|
46
|
+
|
|
47
|
+
setattr(get_entry_default_get_entry, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
48
|
+
|
|
49
|
+
def list_entries_listed(value: ListEntriesOutput, failure: Failure | None = None) -> Case[ListEntriesOutput]:
|
|
50
|
+
return Case.branch_result(LISTED, value, failure)
|
|
51
|
+
|
|
52
|
+
setattr(list_entries_listed, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
53
|
+
|
|
54
|
+
def list_entries_provider_rejected(value: ListEntriesOutput, failure: Failure | None = None) -> Case[ListEntriesOutput]:
|
|
55
|
+
return Case.branch_result(PROVIDER_REJECTED, value, failure)
|
|
56
|
+
|
|
57
|
+
setattr(list_entries_provider_rejected, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
58
|
+
|
|
59
|
+
def list_entries_invalid_response(value: ListEntriesOutput, failure: Failure | None = None) -> Case[ListEntriesOutput]:
|
|
60
|
+
return Case.branch_result(INVALID_RESPONSE, value, failure)
|
|
61
|
+
|
|
62
|
+
setattr(list_entries_invalid_response, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
63
|
+
|
|
64
|
+
def list_entries_defect(value: ListEntriesOutput, failure: Failure | None = None) -> Case[ListEntriesOutput]:
|
|
65
|
+
return Case.branch_result(DEFECT, value, failure)
|
|
66
|
+
|
|
67
|
+
setattr(list_entries_defect, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
68
|
+
|
|
69
|
+
def list_entries_retry(failure: Failure, after_seconds: float = 0) -> Case[ListEntriesOutput]:
|
|
70
|
+
return Case.retry(failure, after_seconds)
|
|
71
|
+
|
|
72
|
+
setattr(list_entries_retry, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
73
|
+
|
|
74
|
+
def list_entries_default_list_entries() -> Case[ListEntriesOutput]:
|
|
75
|
+
return Case.branch_result(LISTED, ListEntriesOutput(data={'items': [{'sys': {'id': 'entry-meridian', 'type': 'Entry'}, 'fields': {'title': 'Meridian'}}], 'total': 1, 'skip': 0, 'limit': 100}))
|
|
76
|
+
|
|
77
|
+
setattr(list_entries_default_list_entries, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Mock:
|
|
81
|
+
|
|
82
|
+
def __init__(self, connection_name: str = "contentful") -> None:
|
|
83
|
+
self.get_entry_script: Query[GetEntryInput, GetEntryOutput] = Query(GET_ENTRY_DEFINITION)
|
|
84
|
+
self.get_entry_script.default(get_entry_default_get_entry())
|
|
85
|
+
self.list_entries_script: Query[ListEntriesInput, ListEntriesOutput] = Query(LIST_ENTRIES_DEFINITION)
|
|
86
|
+
self.list_entries_script.default(list_entries_default_list_entries())
|
|
87
|
+
self._connection = new_connection_with_operations(
|
|
88
|
+
get_entry=self.get_entry_script,
|
|
89
|
+
list_entries=self.list_entries_script,
|
|
90
|
+
reference=ConnectionRef(CONNECTOR_ID, connection_name),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def connection(self) -> Connection:
|
|
94
|
+
return self._connection
|
|
95
|
+
|
|
96
|
+
setattr(Mock, '__module__', "dex_connectors.contentful.contentfulmock")
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"Mock",
|
|
100
|
+
"get_entry_found",
|
|
101
|
+
"get_entry_not_found",
|
|
102
|
+
"get_entry_provider_rejected",
|
|
103
|
+
"get_entry_invalid_response",
|
|
104
|
+
"get_entry_defect",
|
|
105
|
+
"get_entry_retry",
|
|
106
|
+
"get_entry_default_get_entry",
|
|
107
|
+
"list_entries_listed",
|
|
108
|
+
"list_entries_provider_rejected",
|
|
109
|
+
"list_entries_invalid_response",
|
|
110
|
+
"list_entries_defect",
|
|
111
|
+
"list_entries_retry",
|
|
112
|
+
"list_entries_default_list_entries",
|
|
113
|
+
]
|