dex-connector-docusign 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_docusign-0.1.0/.gitignore +16 -0
- dex_connector_docusign-0.1.0/LICENSE +21 -0
- dex_connector_docusign-0.1.0/PKG-INFO +100 -0
- dex_connector_docusign-0.1.0/README.md +87 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/README.md +7 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/__init__.py +3 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/flow.py +76 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/integration_test.py +197 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/main.py +27 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/sample-input.json +1 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/start.py +59 -0
- dex_connector_docusign-0.1.0/examples/envelope_status/worker.py +28 -0
- dex_connector_docusign-0.1.0/pyproject.toml +41 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/__init__.py +17 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/__init__.py +2 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/config.py +31 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/connection.py +274 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/definitions.py +117 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/mock.py +326 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/models.py +318 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/refresh.py +9 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/steps.py +299 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/_generated/timestamps.py +60 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/client.py +585 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/connection.py +12 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/dex-connector.json +206 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/docusignmock/__init__.py +6 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/models.py +7 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/operations.py +1028 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/py.typed +2 -0
- dex_connector_docusign-0.1.0/src/dex_connectors/docusign/steps.py +5 -0
- dex_connector_docusign-0.1.0/tests/refresh_agreement_cases.json +706 -0
- dex_connector_docusign-0.1.0/tests/test_conformance.py +49 -0
- dex_connector_docusign-0.1.0/tests/test_operations.py +232 -0
- dex_connector_docusign-0.1.0/tests/test_refresh_agreement.py +278 -0
- dex_connector_docusign-0.1.0/uv.lock +934 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
node_modules/
|
|
5
|
+
dist/
|
|
6
|
+
sdkpython/dist/dex_connectors_sdk-0.1.0-py3-none-any.whl
|
|
7
|
+
/.bin/dexcli
|
|
8
|
+
/.bin/dex-server-zd
|
|
9
|
+
/sdkpython/.dexcli-data/
|
|
10
|
+
site/.catalog.yaml
|
|
11
|
+
coverage/
|
|
12
|
+
*.out
|
|
13
|
+
*.test
|
|
14
|
+
go.work
|
|
15
|
+
go.work.sum
|
|
16
|
+
.idea/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Super Durable
|
|
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,100 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dex-connector-docusign
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DocuSign eSignature connector for Dex Flows
|
|
5
|
+
Project-URL: Source, https://github.com/superdurable/dex-connectors-library/tree/main/connectors/docusign/python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: dex-connectors-sdk==0.7.0
|
|
10
|
+
Requires-Dist: dex-python-sdk>=1.28.0
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# DocuSign eSignature Python connector
|
|
15
|
+
|
|
16
|
+
This package implements `createEnvelopeFromTemplate`, `getEnvelope`,
|
|
17
|
+
`listEnvelopeRecipients`, `voidEnvelope`, and `downloadCombinedDocument` for
|
|
18
|
+
Dex Python Flows. The `envelopeEventReceived` Connect Trigger remains Go-only.
|
|
19
|
+
The manifest owns operation identities, retry policies, branches, configuration,
|
|
20
|
+
and OAuth methods. The Python package version is `v0.1.0` and requires
|
|
21
|
+
`dex-connectors-sdk==0.7.0`.
|
|
22
|
+
|
|
23
|
+
`new_project_connection` loads the static connection name from Dex project
|
|
24
|
+
configuration. It uses the generated OAuth credential decoder and refresh
|
|
25
|
+
driver for production and developer accounts. Each provider call resolves the
|
|
26
|
+
current access token, reads `/oauth/userinfo` to choose the account and regional
|
|
27
|
+
API host, and rejects a host outside the selected environment. The PDF operation
|
|
28
|
+
returns a digest, byte count, and optional store location; PDF bytes never enter
|
|
29
|
+
a Dex Result. `CombinedDocumentStore` accepts bounded PDF bytes outside Dex and
|
|
30
|
+
must be idempotent and atomic.
|
|
31
|
+
|
|
32
|
+
## Runnable example
|
|
33
|
+
|
|
34
|
+
[`examples/envelope_status/flow.py`](examples/envelope_status/flow.py) reads
|
|
35
|
+
an envelope with the static `docusign-esignature` connection. It persists only
|
|
36
|
+
the request and safe envelope summary. Its optional `notFound`,
|
|
37
|
+
`providerRejected`, `invalidResponse`, and `defect` branches are intentionally
|
|
38
|
+
unwired, so Dex fails the Flow if one is selected.
|
|
39
|
+
|
|
40
|
+
From `connectors/docusign/python`, install dependencies and produce a strict
|
|
41
|
+
FDG 2.0 file for Dex Web:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv sync --locked
|
|
45
|
+
dexcli visualize examples/envelope_status/flow.py --language python \
|
|
46
|
+
--schema-version 2.0 --lint app --json --out build/envelope-status \
|
|
47
|
+
--python .venv/bin/python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Start `dexcli dev` with that rendering directory. In Dex Web **Connections**,
|
|
51
|
+
create `docusign / docusign-esignature`, choose Production account or Developer
|
|
52
|
+
account, and follow the authorization guide in the connector manifest. Register
|
|
53
|
+
Dex Web's exact redirect URI in the DocuSign app. The OAuth scopes are
|
|
54
|
+
`signature` and `extended`; Dex handles access and refresh tokens. The account
|
|
55
|
+
ID may stay blank to use the authorizing user's default account. A Connect HMAC
|
|
56
|
+
key is optional for this read-only example. Use the owner-only
|
|
57
|
+
`connections.json` exported by Dex Web as `DEX_CONNECTOR_CONFIG_FILE`.
|
|
58
|
+
|
|
59
|
+
Run the Worker and start a Flow with the included sample input:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
63
|
+
DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json \
|
|
64
|
+
uv run python -m examples.envelope_status.main
|
|
65
|
+
|
|
66
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
67
|
+
DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json \
|
|
68
|
+
uv run python -m examples.envelope_status.start \
|
|
69
|
+
docusign-envelope-001 examples/envelope_status/sample-input.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The Worker binding in [`main.py`](examples/envelope_status/main.py) is the
|
|
73
|
+
application entrypoint:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
project = await load_from_environment()
|
|
77
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
78
|
+
try:
|
|
79
|
+
await run_worker(EnvelopeStatusFlow(connection))
|
|
80
|
+
finally:
|
|
81
|
+
await connection.aclose()
|
|
82
|
+
await project.close()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run the real Dex integration test against a local `dexcli dev` server:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
89
|
+
uv run pytest examples/envelope_status/integration_test.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The test binds its HTTP provider stand-in to an OS-assigned free port. It checks
|
|
93
|
+
retry after a 429 and failure on an unwired `notFound` branch through a real Dex
|
|
94
|
+
Worker and Client. The 52 Go-recorded conformance scenarios exercise every
|
|
95
|
+
operation's provider requests and exact SDK-encoded outputs. OAuth refresh
|
|
96
|
+
agreement has its own generated Go and Python suites.
|
|
97
|
+
|
|
98
|
+
Live DocuSign authorization, account selection, regional host resolution,
|
|
99
|
+
provider error codes, envelope read-back timing, PDF downloads, and Connect
|
|
100
|
+
configuration have not been verified with a real DocuSign account.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# DocuSign eSignature Python connector
|
|
2
|
+
|
|
3
|
+
This package implements `createEnvelopeFromTemplate`, `getEnvelope`,
|
|
4
|
+
`listEnvelopeRecipients`, `voidEnvelope`, and `downloadCombinedDocument` for
|
|
5
|
+
Dex Python Flows. The `envelopeEventReceived` Connect Trigger remains Go-only.
|
|
6
|
+
The manifest owns operation identities, retry policies, branches, configuration,
|
|
7
|
+
and OAuth methods. The Python package version is `v0.1.0` and requires
|
|
8
|
+
`dex-connectors-sdk==0.7.0`.
|
|
9
|
+
|
|
10
|
+
`new_project_connection` loads the static connection name from Dex project
|
|
11
|
+
configuration. It uses the generated OAuth credential decoder and refresh
|
|
12
|
+
driver for production and developer accounts. Each provider call resolves the
|
|
13
|
+
current access token, reads `/oauth/userinfo` to choose the account and regional
|
|
14
|
+
API host, and rejects a host outside the selected environment. The PDF operation
|
|
15
|
+
returns a digest, byte count, and optional store location; PDF bytes never enter
|
|
16
|
+
a Dex Result. `CombinedDocumentStore` accepts bounded PDF bytes outside Dex and
|
|
17
|
+
must be idempotent and atomic.
|
|
18
|
+
|
|
19
|
+
## Runnable example
|
|
20
|
+
|
|
21
|
+
[`examples/envelope_status/flow.py`](examples/envelope_status/flow.py) reads
|
|
22
|
+
an envelope with the static `docusign-esignature` connection. It persists only
|
|
23
|
+
the request and safe envelope summary. Its optional `notFound`,
|
|
24
|
+
`providerRejected`, `invalidResponse`, and `defect` branches are intentionally
|
|
25
|
+
unwired, so Dex fails the Flow if one is selected.
|
|
26
|
+
|
|
27
|
+
From `connectors/docusign/python`, install dependencies and produce a strict
|
|
28
|
+
FDG 2.0 file for Dex Web:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv sync --locked
|
|
32
|
+
dexcli visualize examples/envelope_status/flow.py --language python \
|
|
33
|
+
--schema-version 2.0 --lint app --json --out build/envelope-status \
|
|
34
|
+
--python .venv/bin/python
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start `dexcli dev` with that rendering directory. In Dex Web **Connections**,
|
|
38
|
+
create `docusign / docusign-esignature`, choose Production account or Developer
|
|
39
|
+
account, and follow the authorization guide in the connector manifest. Register
|
|
40
|
+
Dex Web's exact redirect URI in the DocuSign app. The OAuth scopes are
|
|
41
|
+
`signature` and `extended`; Dex handles access and refresh tokens. The account
|
|
42
|
+
ID may stay blank to use the authorizing user's default account. A Connect HMAC
|
|
43
|
+
key is optional for this read-only example. Use the owner-only
|
|
44
|
+
`connections.json` exported by Dex Web as `DEX_CONNECTOR_CONFIG_FILE`.
|
|
45
|
+
|
|
46
|
+
Run the Worker and start a Flow with the included sample input:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
50
|
+
DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json \
|
|
51
|
+
uv run python -m examples.envelope_status.main
|
|
52
|
+
|
|
53
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
54
|
+
DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json \
|
|
55
|
+
uv run python -m examples.envelope_status.start \
|
|
56
|
+
docusign-envelope-001 examples/envelope_status/sample-input.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The Worker binding in [`main.py`](examples/envelope_status/main.py) is the
|
|
60
|
+
application entrypoint:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
project = await load_from_environment()
|
|
64
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
65
|
+
try:
|
|
66
|
+
await run_worker(EnvelopeStatusFlow(connection))
|
|
67
|
+
finally:
|
|
68
|
+
await connection.aclose()
|
|
69
|
+
await project.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run the real Dex integration test against a local `dexcli dev` server:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
DEX_FLOW_SERVICE_ADDRESS=127.0.0.1:40901 \
|
|
76
|
+
uv run pytest examples/envelope_status/integration_test.py
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The test binds its HTTP provider stand-in to an OS-assigned free port. It checks
|
|
80
|
+
retry after a 429 and failure on an unwired `notFound` branch through a real Dex
|
|
81
|
+
Worker and Client. The 52 Go-recorded conformance scenarios exercise every
|
|
82
|
+
operation's provider requests and exact SDK-encoded outputs. OAuth refresh
|
|
83
|
+
agreement has its own generated Go and Python suites.
|
|
84
|
+
|
|
85
|
+
Live DocuSign authorization, account selection, regional host resolution,
|
|
86
|
+
provider error codes, envelope read-back timing, PDF downloads, and Connect
|
|
87
|
+
configuration have not been verified with a real DocuSign account.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Envelope status example
|
|
2
|
+
|
|
3
|
+
Run the strict FDG 2.0 generation, Dex Web connection setup, Worker, Client
|
|
4
|
+
start command, and real Dex integration test in the
|
|
5
|
+
[Python package README](../../README.md#runnable-example). The Flow uses
|
|
6
|
+
`docusign-esignature` and the `getEnvelope` Query. Its optional branches fail
|
|
7
|
+
the Flow when selected.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Read a DocuSign envelope through one durable Dex Query Step."""
|
|
4
|
+
|
|
5
|
+
import dex
|
|
6
|
+
from dex_connectors.sdk import QueryResult
|
|
7
|
+
|
|
8
|
+
from dex_connectors.docusign import Connection, Envelope, GetEnvelopeInput, new_get_envelope_step
|
|
9
|
+
|
|
10
|
+
CONNECTION_NAME = "docusign-esignature"
|
|
11
|
+
FLOW_TYPE = "DocuSignEnvelopeStatusPython"
|
|
12
|
+
_OPERATION_STEP_TYPE = "GetDocuSignEnvelope"
|
|
13
|
+
_REQUEST = dex.Attribute("docusign-python-envelope-request", GetEnvelopeInput)
|
|
14
|
+
_ENVELOPE = dex.Attribute("docusign-python-envelope", Envelope)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def map_to_operation_input(request: GetEnvelopeInput) -> GetEnvelopeInput:
|
|
18
|
+
"""Pass the typed request to the provider operation."""
|
|
19
|
+
return request
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# dex:group group-id:docusign-envelope group-label:"DocuSign envelope"
|
|
23
|
+
# dex:explanation text:"Persist the envelope ID before reading DocuSign."
|
|
24
|
+
class _StartEnvelopeStatus(dex.Step[GetEnvelopeInput]):
|
|
25
|
+
def get_step_type(self) -> str:
|
|
26
|
+
return "StartDocuSignEnvelopeStatus"
|
|
27
|
+
|
|
28
|
+
def execute(self, context: dex.Context, input: GetEnvelopeInput) -> dex.StepDecision:
|
|
29
|
+
_REQUEST.set(context, input)
|
|
30
|
+
return dex.go_to(dex.StepRef("GetDocuSignEnvelope"), input)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# dex:group group-id:docusign-envelope group-label:"DocuSign envelope"
|
|
34
|
+
# dex:explanation text:"Persist the envelope status without secret or document bytes."
|
|
35
|
+
class _CompleteEnvelopeStatus(dex.Step[QueryResult[Envelope]]):
|
|
36
|
+
def get_step_type(self) -> str:
|
|
37
|
+
return "CompleteDocuSignEnvelopeStatus"
|
|
38
|
+
|
|
39
|
+
def execute(self, context: dex.Context, input: QueryResult[Envelope]) -> dex.StepDecision:
|
|
40
|
+
_ENVELOPE.set(context, input.value)
|
|
41
|
+
return dex.graceful_complete(input.value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EnvelopeStatusFlow(dex.Flow[GetEnvelopeInput]):
|
|
45
|
+
"""Read one envelope through the static DocuSign connection."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, connection: Connection) -> None:
|
|
48
|
+
if connection.reference.name != CONNECTION_NAME:
|
|
49
|
+
raise ValueError("envelope status requires the static DocuSign connection")
|
|
50
|
+
self._start = _StartEnvelopeStatus()
|
|
51
|
+
self._complete = _CompleteEnvelopeStatus()
|
|
52
|
+
self._get = new_get_envelope_step(
|
|
53
|
+
step_type=_OPERATION_STEP_TYPE,
|
|
54
|
+
step_input_type=GetEnvelopeInput,
|
|
55
|
+
annotations={
|
|
56
|
+
"groupId": "docusign-envelope",
|
|
57
|
+
"groupLabel": "DocuSign envelope",
|
|
58
|
+
"explanation": "Read the envelope status and text custom fields from DocuSign.",
|
|
59
|
+
},
|
|
60
|
+
connection=connection,
|
|
61
|
+
connection_name=CONNECTION_NAME,
|
|
62
|
+
map_to_operation_input=map_to_operation_input,
|
|
63
|
+
found=self._complete,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get_flow_type(self) -> str:
|
|
67
|
+
"""Return this example's stable Flow identity."""
|
|
68
|
+
return "DocuSignEnvelopeStatusPython"
|
|
69
|
+
|
|
70
|
+
def get_steps(self) -> dex.StepList[GetEnvelopeInput]:
|
|
71
|
+
"""Register the start, provider query, and completion Steps."""
|
|
72
|
+
return dex.StepList.start_step(self._start).other_steps(self._get, self._complete)
|
|
73
|
+
|
|
74
|
+
def get_persistence_schema(self) -> dex.PersistenceSchema:
|
|
75
|
+
"""Register both durable, non-secret Attributes."""
|
|
76
|
+
return dex.PersistenceSchema.of(_REQUEST, _ENVELOPE)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Verify the example through a real Dex Worker, Client, retry, and branch route."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
import dex
|
|
17
|
+
import httpx
|
|
18
|
+
import pytest
|
|
19
|
+
from dex_connectors.sdk.projectconfig import load_from_environment
|
|
20
|
+
|
|
21
|
+
from dex_connectors.docusign import Envelope, GetEnvelopeInput, new_project_connection
|
|
22
|
+
|
|
23
|
+
from .flow import CONNECTION_NAME, EnvelopeStatusFlow
|
|
24
|
+
|
|
25
|
+
ACCOUNT_ID = "a4ec37d6-1111-2222-3333-143885c220e1"
|
|
26
|
+
ENVELOPE_ID = "93be49ab-1111-2222-3333-f752070d71ec"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _ProviderStandIn(BaseHTTPRequestHandler):
|
|
30
|
+
requests: list[str] = []
|
|
31
|
+
|
|
32
|
+
def do_GET(self) -> None:
|
|
33
|
+
"""Serve one account, a rate limited first read, and a later envelope."""
|
|
34
|
+
self.requests.append(self.path)
|
|
35
|
+
body: dict[str, object]
|
|
36
|
+
if urlsplit(self.path).path == "/oauth/userinfo":
|
|
37
|
+
status, body = (
|
|
38
|
+
200,
|
|
39
|
+
{
|
|
40
|
+
"accounts": [
|
|
41
|
+
{"account_id": ACCOUNT_ID, "is_default": True, "base_uri": "https://na3.docusign.net"}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
elif "000000000404" in self.path:
|
|
46
|
+
status, body = 404, {"errorCode": "ENVELOPE_DOES_NOT_EXIST"}
|
|
47
|
+
elif sum("/envelopes/" in path and ENVELOPE_ID in path for path in self.requests) == 1:
|
|
48
|
+
status, body = 429, {"errorCode": "RATE_LIMITED"}
|
|
49
|
+
else:
|
|
50
|
+
status, body = (
|
|
51
|
+
200,
|
|
52
|
+
{
|
|
53
|
+
"envelopeId": ENVELOPE_ID,
|
|
54
|
+
"status": "completed",
|
|
55
|
+
"completedDateTime": "2026-10-04T12:00:00Z",
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
encoded = json.dumps(body).encode()
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(encoded)
|
|
64
|
+
|
|
65
|
+
def do_POST(self) -> None:
|
|
66
|
+
"""Refresh an inert OAuth token using the manifest's generated driver."""
|
|
67
|
+
self.requests.append(self.path)
|
|
68
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
69
|
+
self.rfile.read(length)
|
|
70
|
+
encoded = json.dumps(
|
|
71
|
+
{
|
|
72
|
+
"access_token": "inert-refreshed-access-token",
|
|
73
|
+
"refresh_token": "inert-refreshed-refresh-token",
|
|
74
|
+
"token_type": "Bearer",
|
|
75
|
+
"scope": "signature extended",
|
|
76
|
+
"expires_in": 3600,
|
|
77
|
+
}
|
|
78
|
+
).encode()
|
|
79
|
+
self.send_response(200)
|
|
80
|
+
self.send_header("Content-Type", "application/json")
|
|
81
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
82
|
+
self.end_headers()
|
|
83
|
+
self.wfile.write(encoded)
|
|
84
|
+
|
|
85
|
+
def log_message(self, _format: str, *args: object) -> None:
|
|
86
|
+
"""Keep the test log free of request headers."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _LocalProviderTransport(httpx.AsyncBaseTransport):
|
|
90
|
+
def __init__(self, port: int) -> None:
|
|
91
|
+
self.port = port
|
|
92
|
+
self.transport = httpx.AsyncHTTPTransport()
|
|
93
|
+
|
|
94
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
95
|
+
"""Preserve the provider path while sending bytes to the local stand-in."""
|
|
96
|
+
local_url = f"http://127.0.0.1:{self.port}{request.url.raw_path.decode()}"
|
|
97
|
+
headers = [(name, value) for name, value in request.headers.raw if name.lower() != b"host"]
|
|
98
|
+
local_request = httpx.Request(
|
|
99
|
+
request.method, local_url, headers=headers, content=await request.aread()
|
|
100
|
+
)
|
|
101
|
+
return await self.transport.handle_async_request(local_request)
|
|
102
|
+
|
|
103
|
+
async def aclose(self) -> None:
|
|
104
|
+
"""Close the owned HTTP transport."""
|
|
105
|
+
await self.transport.aclose()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_envelope_status_retries_and_fails_unwired_not_found(
|
|
110
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Route the happy path and optional failure through Dex persistence."""
|
|
113
|
+
server_address = os.environ["DEX_FLOW_SERVICE_ADDRESS"]
|
|
114
|
+
_ProviderStandIn.requests = []
|
|
115
|
+
provider = ThreadingHTTPServer(("127.0.0.1", 0), _ProviderStandIn)
|
|
116
|
+
server_thread = threading.Thread(target=provider.serve_forever, daemon=True)
|
|
117
|
+
server_thread.start()
|
|
118
|
+
try:
|
|
119
|
+
async with httpx.AsyncClient(
|
|
120
|
+
transport=_LocalProviderTransport(provider.server_port)
|
|
121
|
+
) as provider_http:
|
|
122
|
+
with tempfile.TemporaryDirectory(prefix="docusign-python-dex-") as directory:
|
|
123
|
+
connections_path = Path(directory) / "connections.json"
|
|
124
|
+
connections_path.write_text(
|
|
125
|
+
json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"schemaVersion": "connectors.dex.dev/local-connections/v1alpha1",
|
|
128
|
+
"connections": [
|
|
129
|
+
{
|
|
130
|
+
"connectorId": "docusign",
|
|
131
|
+
"connectionName": CONNECTION_NAME,
|
|
132
|
+
"modulePath": (
|
|
133
|
+
"github.com/superdurable/dex-connectors-library/"
|
|
134
|
+
"connectors/docusign"
|
|
135
|
+
),
|
|
136
|
+
"provider": "docusign",
|
|
137
|
+
"authMethodId": "docusign-oauth",
|
|
138
|
+
"configuration": {},
|
|
139
|
+
"credentials": {
|
|
140
|
+
"oauth_client_id": "inert-integration-key",
|
|
141
|
+
"oauth_client_secret": "inert-client-secret",
|
|
142
|
+
"access_token": "inert-access-token",
|
|
143
|
+
"refresh_token": "inert-refresh-token",
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
"triggerBindings": [],
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
connections_path.chmod(0o600)
|
|
152
|
+
monkeypatch.setenv("DEX_CONNECTOR_CONFIG_FILE", str(connections_path))
|
|
153
|
+
project = await load_from_environment()
|
|
154
|
+
connection = new_project_connection(project, CONNECTION_NAME, provider_http)
|
|
155
|
+
flow = EnvelopeStatusFlow(connection)
|
|
156
|
+
registry = dex.Registry([flow], allow_async_handlers=True)
|
|
157
|
+
cache = dex.open_blob_cache(dex.BlobCacheConfig(str(Path(directory) / "blobs"), 1 << 24))
|
|
158
|
+
try:
|
|
159
|
+
worker = dex.AsyncWorker(
|
|
160
|
+
registry, cache, dex.WorkerOptions(server_address=server_address)
|
|
161
|
+
)
|
|
162
|
+
worker_task = asyncio.create_task(worker.start())
|
|
163
|
+
try:
|
|
164
|
+
await worker.wait_pull_registered(timeout=15)
|
|
165
|
+
async with dex.AsyncClient(
|
|
166
|
+
registry, cache, dex.ClientOptions(server_address=server_address)
|
|
167
|
+
) as dex_client:
|
|
168
|
+
flow_id = f"docusign-python-{uuid4().hex}"
|
|
169
|
+
await dex_client.start_flow(flow, flow_id, GetEnvelopeInput(ENVELOPE_ID))
|
|
170
|
+
result = await asyncio.wait_for(
|
|
171
|
+
dex_client.wait_for_flow(flow_id, timedelta(seconds=30)), 40
|
|
172
|
+
)
|
|
173
|
+
assert result.status == dex.FlowStatus.COMPLETED, result.error_message
|
|
174
|
+
assert result.single_output(Envelope).status == "completed"
|
|
175
|
+
assert "/oauth/token" in _ProviderStandIn.requests
|
|
176
|
+
assert sum(ENVELOPE_ID in path for path in _ProviderStandIn.requests) == 2
|
|
177
|
+
|
|
178
|
+
missing_id = "93be49ab-1111-2222-3333-000000000404"
|
|
179
|
+
failed_id = f"docusign-python-{uuid4().hex}"
|
|
180
|
+
await dex_client.start_flow(flow, failed_id, GetEnvelopeInput(missing_id))
|
|
181
|
+
failed = await asyncio.wait_for(
|
|
182
|
+
dex_client.wait_for_flow(failed_id, timedelta(seconds=30)), 40
|
|
183
|
+
)
|
|
184
|
+
assert failed.status == dex.FlowStatus.FAILED
|
|
185
|
+
assert failed.error_message is not None
|
|
186
|
+
assert "notFound" in failed.error_message
|
|
187
|
+
finally:
|
|
188
|
+
await worker.stop()
|
|
189
|
+
await worker_task
|
|
190
|
+
finally:
|
|
191
|
+
cache.close()
|
|
192
|
+
await connection.aclose()
|
|
193
|
+
await project.close()
|
|
194
|
+
finally:
|
|
195
|
+
provider.shutdown()
|
|
196
|
+
provider.server_close()
|
|
197
|
+
server_thread.join(timeout=5)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Load the DocuSign project connection and run the example Worker."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk.projectconfig import load_from_environment
|
|
8
|
+
|
|
9
|
+
from dex_connectors.docusign import new_project_connection
|
|
10
|
+
|
|
11
|
+
from .flow import CONNECTION_NAME, EnvelopeStatusFlow
|
|
12
|
+
from .worker import run_worker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main() -> None:
|
|
16
|
+
"""Bind the user-managed connection before Worker registration."""
|
|
17
|
+
project = await load_from_environment()
|
|
18
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
19
|
+
try:
|
|
20
|
+
await run_worker(EnvelopeStatusFlow(connection))
|
|
21
|
+
finally:
|
|
22
|
+
await connection.aclose()
|
|
23
|
+
await project.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
asyncio.run(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"envelopeId":"93be49ab-1111-2222-3333-f752070d71ec"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Start the DocuSign envelope status Flow with a unique Flow ID."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import dex
|
|
12
|
+
from dex_connectors.sdk.projectconfig import load_from_environment
|
|
13
|
+
|
|
14
|
+
from dex_connectors.docusign import GetEnvelopeInput, new_project_connection
|
|
15
|
+
|
|
16
|
+
from .flow import CONNECTION_NAME, EnvelopeStatusFlow
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def start_envelope_status(flow_id: str, input_file: Path) -> str:
|
|
20
|
+
"""Decode one safe envelope ID and start the registered Dex Flow."""
|
|
21
|
+
encoded_input = input_file.read_bytes()
|
|
22
|
+
if len(encoded_input) > 4096:
|
|
23
|
+
raise ValueError("envelope status input exceeds 4096 bytes")
|
|
24
|
+
value = (
|
|
25
|
+
dex.CodecRegistry()
|
|
26
|
+
.resolve(GetEnvelopeInput)
|
|
27
|
+
.decode(dex.Value(dex.WireKind.JSON, encoded_input.decode("utf-8")))
|
|
28
|
+
)
|
|
29
|
+
project = await load_from_environment()
|
|
30
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
31
|
+
try:
|
|
32
|
+
flow = EnvelopeStatusFlow(connection)
|
|
33
|
+
registry = dex.Registry([flow], allow_async_handlers=True)
|
|
34
|
+
with tempfile.TemporaryDirectory(prefix="docusign-python-start-") as directory:
|
|
35
|
+
cache = dex.open_blob_cache(dex.BlobCacheConfig(directory, 1 << 24))
|
|
36
|
+
try:
|
|
37
|
+
address = os.environ.get("DEX_FLOW_SERVICE_ADDRESS", "localhost:8801")
|
|
38
|
+
async with dex.AsyncClient(
|
|
39
|
+
registry, cache, dex.ClientOptions(server_address=address)
|
|
40
|
+
) as client:
|
|
41
|
+
return await client.start_flow(flow, flow_id, value)
|
|
42
|
+
finally:
|
|
43
|
+
cache.close()
|
|
44
|
+
finally:
|
|
45
|
+
await connection.aclose()
|
|
46
|
+
await project.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""Accept a Flow ID and a JSON input path from the command line."""
|
|
51
|
+
parser = argparse.ArgumentParser(description="Read a DocuSign envelope in Dex")
|
|
52
|
+
parser.add_argument("flow_id")
|
|
53
|
+
parser.add_argument("input_file", type=Path)
|
|
54
|
+
arguments = parser.parse_args()
|
|
55
|
+
print(asyncio.run(start_envelope_status(arguments.flow_id, arguments.input_file)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Run the DocuSign example with an asynchronous Dex Worker."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import dex
|
|
10
|
+
|
|
11
|
+
from .flow import EnvelopeStatusFlow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def run_worker(flow: EnvelopeStatusFlow) -> None:
|
|
15
|
+
"""Serve the registered example Flow until shutdown."""
|
|
16
|
+
registry = dex.Registry([flow], allow_async_handlers=True)
|
|
17
|
+
cache_directory = os.environ.get(
|
|
18
|
+
"DEX_BLOB_CACHE_DIR", str(Path(tempfile.gettempdir()) / "dex-docusign-python-blobs")
|
|
19
|
+
)
|
|
20
|
+
cache = dex.open_blob_cache(dex.BlobCacheConfig(cache_directory, 1 << 30))
|
|
21
|
+
try:
|
|
22
|
+
options = dex.WorkerOptions(
|
|
23
|
+
server_address=os.environ.get("DEX_FLOW_SERVICE_ADDRESS", "localhost:8801")
|
|
24
|
+
)
|
|
25
|
+
async with dex.AsyncWorker(registry, cache, options) as worker:
|
|
26
|
+
await worker.start()
|
|
27
|
+
finally:
|
|
28
|
+
cache.close()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dex-connector-docusign"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "DocuSign eSignature connector for Dex Flows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["dex-connectors-sdk==0.7.0", "dex-python-sdk>=1.28.0", "httpx>=0.27"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Source = "https://github.com/superdurable/dex-connectors-library/tree/main/connectors/docusign/python"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.13", "ruff>=0.7", "PyYAML>=6", "types-PyYAML>=6"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/dex_connectors"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
pythonpath = [".", "src"]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
python_version = "3.11"
|
|
32
|
+
strict = true
|
|
33
|
+
mypy_path = "src"
|
|
34
|
+
explicit_package_bases = true
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
line-length = 110
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP", "B"]
|