formbridge-sdk 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.
- formbridge_sdk-0.1.0/.gitignore +54 -0
- formbridge_sdk-0.1.0/PKG-INFO +126 -0
- formbridge_sdk-0.1.0/README.md +106 -0
- formbridge_sdk-0.1.0/pyproject.toml +33 -0
- formbridge_sdk-0.1.0/src/formbridge/__init__.py +14 -0
- formbridge_sdk-0.1.0/src/formbridge/client.py +387 -0
- formbridge_sdk-0.1.0/src/formbridge/errors.py +31 -0
- formbridge_sdk-0.1.0/src/formbridge/py.typed +0 -0
- formbridge_sdk-0.1.0/src/formbridge/types.py +73 -0
- formbridge_sdk-0.1.0/tests/__init__.py +0 -0
- formbridge_sdk-0.1.0/tests/test_client.py +266 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Auto Claude data directory
|
|
2
|
+
.auto-claude/
|
|
3
|
+
|
|
4
|
+
# Auto Claude machine-specific config
|
|
5
|
+
.auto-claude-security.json
|
|
6
|
+
.auto-claude-status
|
|
7
|
+
.claude_settings.json
|
|
8
|
+
|
|
9
|
+
# Development verification artifacts
|
|
10
|
+
*VERIFICATION*.md
|
|
11
|
+
|
|
12
|
+
# Dependencies
|
|
13
|
+
node_modules/
|
|
14
|
+
|
|
15
|
+
# Python virtual environment
|
|
16
|
+
.venv/
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.pyc
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Coverage reports
|
|
22
|
+
.coverage
|
|
23
|
+
coverage/
|
|
24
|
+
coverage-report.json
|
|
25
|
+
|
|
26
|
+
# Environment files
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
!.env.example
|
|
30
|
+
.env.local
|
|
31
|
+
.env.*.local
|
|
32
|
+
|
|
33
|
+
# Build outputs
|
|
34
|
+
dist/
|
|
35
|
+
*.tsbuildinfo
|
|
36
|
+
|
|
37
|
+
# Compiled output accidentally emitted into src/
|
|
38
|
+
src/*.js
|
|
39
|
+
src/*.js.map
|
|
40
|
+
src/*.d.ts
|
|
41
|
+
src/*.d.ts.map
|
|
42
|
+
src/**/*.js
|
|
43
|
+
src/**/*.js.map
|
|
44
|
+
src/**/*.d.ts
|
|
45
|
+
src/**/*.d.ts.map
|
|
46
|
+
|
|
47
|
+
# SQLite runtime files
|
|
48
|
+
*.db
|
|
49
|
+
*.db-shm
|
|
50
|
+
*.db-wal
|
|
51
|
+
|
|
52
|
+
# BMAD planning artifacts
|
|
53
|
+
_bmad/
|
|
54
|
+
_bmad-output/
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formbridge-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for FormBridge — async and sync HTTP client
|
|
5
|
+
Author: FormBridge Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: formbridge,forms,sdk,submissions
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: httpx>=0.24.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# formbridge-sdk
|
|
22
|
+
|
|
23
|
+
Python SDK for [FormBridge](https://github.com/agentkitai/formbridge) — async and sync HTTP client for managing form submissions.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
This SDK is not yet published to PyPI. Install it from source:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/agentkitai/formbridge.git
|
|
31
|
+
pip install ./formbridge/sdk/python
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start (Async)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from formbridge import FormBridgeClient, Actor
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with FormBridgeClient(
|
|
42
|
+
url="http://localhost:3000",
|
|
43
|
+
api_key="your-api-key",
|
|
44
|
+
) as client:
|
|
45
|
+
# Create a submission with initial fields
|
|
46
|
+
sub = await client.create_submission(
|
|
47
|
+
"vendor-onboarding",
|
|
48
|
+
fields={"company_name": "Acme Corp"},
|
|
49
|
+
actor=Actor(kind="agent", id="agent-1", name="Onboarding Bot"),
|
|
50
|
+
)
|
|
51
|
+
print(f"Created: {sub.submission_id}, state={sub.state}")
|
|
52
|
+
print(f"Missing fields: {sub.missing_fields}")
|
|
53
|
+
|
|
54
|
+
# Add more fields (resume_token rotates on each call)
|
|
55
|
+
result = await client.set_fields(
|
|
56
|
+
sub.intake_id,
|
|
57
|
+
sub.submission_id,
|
|
58
|
+
sub.resume_token,
|
|
59
|
+
{"contact_email": "alice@acme.com", "phone": "+1-555-0100"},
|
|
60
|
+
)
|
|
61
|
+
print(f"Updated: state={result.state}, missing={result.missing_fields}")
|
|
62
|
+
|
|
63
|
+
# Submit when all fields are filled
|
|
64
|
+
final = await client.submit(
|
|
65
|
+
sub.intake_id,
|
|
66
|
+
sub.submission_id,
|
|
67
|
+
result.resume_token,
|
|
68
|
+
)
|
|
69
|
+
print(f"Submitted: state={final.state}")
|
|
70
|
+
|
|
71
|
+
# Retrieve a submission
|
|
72
|
+
fetched = await client.get_submission(sub.intake_id, sub.submission_id)
|
|
73
|
+
print(f"Fields: {fetched.fields}")
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quick Start (Sync)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from formbridge import FormBridgeClientSync, Actor
|
|
82
|
+
|
|
83
|
+
with FormBridgeClientSync(api_key="your-api-key") as client:
|
|
84
|
+
sub = client.create_submission(
|
|
85
|
+
"vendor-onboarding",
|
|
86
|
+
fields={"company_name": "Acme Corp"},
|
|
87
|
+
actor=Actor(kind="agent", id="agent-1"),
|
|
88
|
+
)
|
|
89
|
+
print(f"Created: {sub.submission_id}")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
| Parameter | Env Var | Default |
|
|
95
|
+
|-----------|---------|---------|
|
|
96
|
+
| `url` | `FORMBRIDGE_URL` | `http://localhost:3000` |
|
|
97
|
+
| `api_key` | `FORMBRIDGE_API_KEY` | — |
|
|
98
|
+
| `timeout` | `FORMBRIDGE_TIMEOUT` | `10` (seconds) |
|
|
99
|
+
|
|
100
|
+
## Error Handling
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from formbridge import FormBridgeClient, FormBridgeError
|
|
104
|
+
|
|
105
|
+
async with FormBridgeClient() as client:
|
|
106
|
+
try:
|
|
107
|
+
sub = await client.get_submission("intake-1", "sub-123")
|
|
108
|
+
except FormBridgeError as e:
|
|
109
|
+
if e.is_connectivity_error:
|
|
110
|
+
print("Server unreachable — degrade gracefully")
|
|
111
|
+
elif e.status_code == 404:
|
|
112
|
+
print("Not found")
|
|
113
|
+
else:
|
|
114
|
+
print(f"API error: {e.error_type} — {e}")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Retry Behavior
|
|
118
|
+
|
|
119
|
+
- **Retries on:** 429 (rate limited), 500, 502, 503, 504
|
|
120
|
+
- **Does NOT retry on:** 400, 401, 403, 404 (client errors)
|
|
121
|
+
- **Backoff:** 0.5s → 1.0s → 2.0s (exponential)
|
|
122
|
+
- **Max retries:** 3 (configurable via `max_retries`)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# formbridge-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [FormBridge](https://github.com/agentkitai/formbridge) — async and sync HTTP client for managing form submissions.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
This SDK is not yet published to PyPI. Install it from source:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/agentkitai/formbridge.git
|
|
11
|
+
pip install ./formbridge/sdk/python
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start (Async)
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import asyncio
|
|
18
|
+
from formbridge import FormBridgeClient, Actor
|
|
19
|
+
|
|
20
|
+
async def main():
|
|
21
|
+
async with FormBridgeClient(
|
|
22
|
+
url="http://localhost:3000",
|
|
23
|
+
api_key="your-api-key",
|
|
24
|
+
) as client:
|
|
25
|
+
# Create a submission with initial fields
|
|
26
|
+
sub = await client.create_submission(
|
|
27
|
+
"vendor-onboarding",
|
|
28
|
+
fields={"company_name": "Acme Corp"},
|
|
29
|
+
actor=Actor(kind="agent", id="agent-1", name="Onboarding Bot"),
|
|
30
|
+
)
|
|
31
|
+
print(f"Created: {sub.submission_id}, state={sub.state}")
|
|
32
|
+
print(f"Missing fields: {sub.missing_fields}")
|
|
33
|
+
|
|
34
|
+
# Add more fields (resume_token rotates on each call)
|
|
35
|
+
result = await client.set_fields(
|
|
36
|
+
sub.intake_id,
|
|
37
|
+
sub.submission_id,
|
|
38
|
+
sub.resume_token,
|
|
39
|
+
{"contact_email": "alice@acme.com", "phone": "+1-555-0100"},
|
|
40
|
+
)
|
|
41
|
+
print(f"Updated: state={result.state}, missing={result.missing_fields}")
|
|
42
|
+
|
|
43
|
+
# Submit when all fields are filled
|
|
44
|
+
final = await client.submit(
|
|
45
|
+
sub.intake_id,
|
|
46
|
+
sub.submission_id,
|
|
47
|
+
result.resume_token,
|
|
48
|
+
)
|
|
49
|
+
print(f"Submitted: state={final.state}")
|
|
50
|
+
|
|
51
|
+
# Retrieve a submission
|
|
52
|
+
fetched = await client.get_submission(sub.intake_id, sub.submission_id)
|
|
53
|
+
print(f"Fields: {fetched.fields}")
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start (Sync)
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from formbridge import FormBridgeClientSync, Actor
|
|
62
|
+
|
|
63
|
+
with FormBridgeClientSync(api_key="your-api-key") as client:
|
|
64
|
+
sub = client.create_submission(
|
|
65
|
+
"vendor-onboarding",
|
|
66
|
+
fields={"company_name": "Acme Corp"},
|
|
67
|
+
actor=Actor(kind="agent", id="agent-1"),
|
|
68
|
+
)
|
|
69
|
+
print(f"Created: {sub.submission_id}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
| Parameter | Env Var | Default |
|
|
75
|
+
|-----------|---------|---------|
|
|
76
|
+
| `url` | `FORMBRIDGE_URL` | `http://localhost:3000` |
|
|
77
|
+
| `api_key` | `FORMBRIDGE_API_KEY` | — |
|
|
78
|
+
| `timeout` | `FORMBRIDGE_TIMEOUT` | `10` (seconds) |
|
|
79
|
+
|
|
80
|
+
## Error Handling
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from formbridge import FormBridgeClient, FormBridgeError
|
|
84
|
+
|
|
85
|
+
async with FormBridgeClient() as client:
|
|
86
|
+
try:
|
|
87
|
+
sub = await client.get_submission("intake-1", "sub-123")
|
|
88
|
+
except FormBridgeError as e:
|
|
89
|
+
if e.is_connectivity_error:
|
|
90
|
+
print("Server unreachable — degrade gracefully")
|
|
91
|
+
elif e.status_code == 404:
|
|
92
|
+
print("Not found")
|
|
93
|
+
else:
|
|
94
|
+
print(f"API error: {e.error_type} — {e}")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Retry Behavior
|
|
98
|
+
|
|
99
|
+
- **Retries on:** 429 (rate limited), 500, 502, 503, 504
|
|
100
|
+
- **Does NOT retry on:** 400, 401, 403, 404 (client errors)
|
|
101
|
+
- **Backoff:** 0.5s → 1.0s → 2.0s (exponential)
|
|
102
|
+
- **Max retries:** 3 (configurable via `max_retries`)
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "formbridge-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for FormBridge — async and sync HTTP client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "FormBridge Team" }]
|
|
13
|
+
keywords = ["formbridge", "sdk", "forms", "submissions"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.24.0"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.21", "ruff>=0.1"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/formbridge"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py39"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FormBridge Python SDK — async and sync HTTP client."""
|
|
2
|
+
|
|
3
|
+
from formbridge.client import FormBridgeClient, FormBridgeClientSync
|
|
4
|
+
from formbridge.errors import FormBridgeError
|
|
5
|
+
from formbridge.types import Actor, FieldsResult, Submission
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FormBridgeClient",
|
|
9
|
+
"FormBridgeClientSync",
|
|
10
|
+
"FormBridgeError",
|
|
11
|
+
"Actor",
|
|
12
|
+
"FieldsResult",
|
|
13
|
+
"Submission",
|
|
14
|
+
]
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""FormBridge async and sync HTTP clients.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
async with FormBridgeClient() as client:
|
|
6
|
+
sub = await client.create_submission("vendor-onboarding", fields={"company": "Acme"})
|
|
7
|
+
result = await client.set_fields(sub.intake_id, sub.submission_id, sub.resume_token, {"email": "a@b.com"})
|
|
8
|
+
final = await client.submit(sub.intake_id, sub.submission_id, result.resume_token)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import httpx
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise ImportError("httpx is required. Install with: pip install formbridge-sdk")
|
|
22
|
+
|
|
23
|
+
from formbridge.errors import FormBridgeError
|
|
24
|
+
from formbridge.types import Actor, FieldsResult, Submission
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("formbridge.client")
|
|
27
|
+
|
|
28
|
+
_DEFAULT_URL = "http://localhost:3000"
|
|
29
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
30
|
+
_RETRY_BACKOFFS = [0.5, 1.0, 2.0]
|
|
31
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _serialize_actor(actor: Optional[Actor]) -> Optional[Dict[str, Any]]:
|
|
35
|
+
if actor is None:
|
|
36
|
+
return None
|
|
37
|
+
d: Dict[str, Any] = {"kind": actor.kind, "id": actor.id}
|
|
38
|
+
if actor.name is not None:
|
|
39
|
+
d["name"] = actor.name
|
|
40
|
+
return d
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FormBridgeClient:
|
|
44
|
+
"""Async HTTP client for FormBridge API.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
url: Base URL. Defaults to ``FORMBRIDGE_URL`` env var or ``http://localhost:3000``.
|
|
48
|
+
api_key: API key for Bearer auth. Defaults to ``FORMBRIDGE_API_KEY`` env var.
|
|
49
|
+
timeout: Request timeout in seconds. Defaults to ``FORMBRIDGE_TIMEOUT`` env var or 10.
|
|
50
|
+
max_retries: Max retry attempts on 429/5xx (default 3).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
url: Optional[str] = None,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
timeout: Optional[float] = None,
|
|
58
|
+
max_retries: int = 3,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._url = (url or os.environ.get("FORMBRIDGE_URL", _DEFAULT_URL)).rstrip("/")
|
|
61
|
+
self._api_key = api_key or os.environ.get("FORMBRIDGE_API_KEY", "")
|
|
62
|
+
raw_timeout = timeout if timeout is not None else os.environ.get("FORMBRIDGE_TIMEOUT")
|
|
63
|
+
self._timeout = float(raw_timeout) if raw_timeout is not None else _DEFAULT_TIMEOUT
|
|
64
|
+
self._max_retries = max_retries
|
|
65
|
+
|
|
66
|
+
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
67
|
+
if self._api_key:
|
|
68
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
69
|
+
|
|
70
|
+
self._http = httpx.AsyncClient(
|
|
71
|
+
base_url=self._url,
|
|
72
|
+
headers=headers,
|
|
73
|
+
timeout=self._timeout,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def __aenter__(self) -> "FormBridgeClient":
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(self, *args: object) -> None:
|
|
80
|
+
await self.close()
|
|
81
|
+
|
|
82
|
+
async def close(self) -> None:
|
|
83
|
+
"""Close the underlying HTTP client."""
|
|
84
|
+
await self._http.aclose()
|
|
85
|
+
|
|
86
|
+
# ── Public API ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async def create_submission(
|
|
89
|
+
self,
|
|
90
|
+
intake_id: str,
|
|
91
|
+
*,
|
|
92
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
93
|
+
actor: Optional[Actor] = None,
|
|
94
|
+
idempotency_key: Optional[str] = None,
|
|
95
|
+
) -> Submission:
|
|
96
|
+
"""Create a new submission.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
intake_id: The intake definition ID.
|
|
100
|
+
fields: Optional initial field values.
|
|
101
|
+
actor: Optional actor performing the action.
|
|
102
|
+
idempotency_key: Optional idempotency key for deduplication.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Submission with id, state, resume_token, etc.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
FormBridgeError: On API or connectivity error.
|
|
109
|
+
"""
|
|
110
|
+
body: Dict[str, Any] = {}
|
|
111
|
+
if fields:
|
|
112
|
+
body["fields"] = fields
|
|
113
|
+
if actor:
|
|
114
|
+
body["actor"] = _serialize_actor(actor)
|
|
115
|
+
if idempotency_key:
|
|
116
|
+
body["idempotencyKey"] = idempotency_key
|
|
117
|
+
|
|
118
|
+
data = await self._request("POST", f"/intake/{intake_id}/submissions", json_data=body)
|
|
119
|
+
sub = Submission.from_response(data)
|
|
120
|
+
# API may not return intakeId on create; fill it in
|
|
121
|
+
if not sub.intake_id:
|
|
122
|
+
sub = Submission(
|
|
123
|
+
submission_id=sub.submission_id,
|
|
124
|
+
intake_id=intake_id,
|
|
125
|
+
state=sub.state,
|
|
126
|
+
resume_token=sub.resume_token,
|
|
127
|
+
fields=sub.fields,
|
|
128
|
+
missing_fields=sub.missing_fields,
|
|
129
|
+
schema=sub.schema,
|
|
130
|
+
created_at=sub.created_at,
|
|
131
|
+
updated_at=sub.updated_at,
|
|
132
|
+
raw=sub.raw,
|
|
133
|
+
)
|
|
134
|
+
return sub
|
|
135
|
+
|
|
136
|
+
async def set_fields(
|
|
137
|
+
self,
|
|
138
|
+
intake_id: str,
|
|
139
|
+
submission_id: str,
|
|
140
|
+
resume_token: str,
|
|
141
|
+
fields: Dict[str, Any],
|
|
142
|
+
actor: Optional[Actor] = None,
|
|
143
|
+
) -> FieldsResult:
|
|
144
|
+
"""Update fields on a submission.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
intake_id: The intake definition ID.
|
|
148
|
+
submission_id: The submission ID.
|
|
149
|
+
resume_token: Current resume token.
|
|
150
|
+
fields: Field values to set.
|
|
151
|
+
actor: Optional actor performing the action.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
FieldsResult with new state, resume_token (may rotate), missing_fields.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
FormBridgeError: On API or connectivity error.
|
|
158
|
+
"""
|
|
159
|
+
body: Dict[str, Any] = {
|
|
160
|
+
"resumeToken": resume_token,
|
|
161
|
+
"fields": fields,
|
|
162
|
+
}
|
|
163
|
+
if actor:
|
|
164
|
+
body["actor"] = _serialize_actor(actor)
|
|
165
|
+
|
|
166
|
+
data = await self._request(
|
|
167
|
+
"PATCH", f"/intake/{intake_id}/submissions/{submission_id}", json_data=body
|
|
168
|
+
)
|
|
169
|
+
return FieldsResult.from_response(data)
|
|
170
|
+
|
|
171
|
+
async def submit(
|
|
172
|
+
self,
|
|
173
|
+
intake_id: str,
|
|
174
|
+
submission_id: str,
|
|
175
|
+
resume_token: str,
|
|
176
|
+
*,
|
|
177
|
+
actor: Optional[Actor] = None,
|
|
178
|
+
) -> Submission:
|
|
179
|
+
"""Submit a submission for processing.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
intake_id: The intake definition ID.
|
|
183
|
+
submission_id: The submission ID.
|
|
184
|
+
resume_token: Current resume token.
|
|
185
|
+
actor: Optional actor performing the action.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Final Submission state.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
FormBridgeError: On API or connectivity error.
|
|
192
|
+
"""
|
|
193
|
+
body: Dict[str, Any] = {"resumeToken": resume_token}
|
|
194
|
+
if actor:
|
|
195
|
+
body["actor"] = _serialize_actor(actor)
|
|
196
|
+
|
|
197
|
+
data = await self._request(
|
|
198
|
+
"POST", f"/intake/{intake_id}/submissions/{submission_id}/submit", json_data=body
|
|
199
|
+
)
|
|
200
|
+
return Submission.from_response(data)
|
|
201
|
+
|
|
202
|
+
async def get_submission(
|
|
203
|
+
self,
|
|
204
|
+
intake_id: str,
|
|
205
|
+
submission_id: str,
|
|
206
|
+
) -> Submission:
|
|
207
|
+
"""Get a submission by ID.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
intake_id: The intake definition ID.
|
|
211
|
+
submission_id: The submission ID.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Submission details.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
FormBridgeError: On API or connectivity error.
|
|
218
|
+
"""
|
|
219
|
+
data = await self._request(
|
|
220
|
+
"GET", f"/intake/{intake_id}/submissions/{submission_id}"
|
|
221
|
+
)
|
|
222
|
+
return Submission.from_response(data)
|
|
223
|
+
|
|
224
|
+
# ── Internals ─────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async def _request(
|
|
227
|
+
self,
|
|
228
|
+
method: str,
|
|
229
|
+
path: str,
|
|
230
|
+
*,
|
|
231
|
+
json_data: Optional[Any] = None,
|
|
232
|
+
) -> Dict[str, Any]:
|
|
233
|
+
"""Make HTTP request with retry and graceful error handling."""
|
|
234
|
+
last_exc: Optional[Exception] = None
|
|
235
|
+
backoffs = _RETRY_BACKOFFS[: self._max_retries]
|
|
236
|
+
|
|
237
|
+
for attempt in range(1 + len(backoffs)):
|
|
238
|
+
try:
|
|
239
|
+
resp = await self._http.request(method, path, json=json_data)
|
|
240
|
+
|
|
241
|
+
if resp.status_code in _RETRYABLE_STATUS and attempt < len(backoffs):
|
|
242
|
+
logger.warning(
|
|
243
|
+
"FormBridge returned %d (attempt %d/%d), retrying in %.1fs",
|
|
244
|
+
resp.status_code,
|
|
245
|
+
attempt + 1,
|
|
246
|
+
len(backoffs) + 1,
|
|
247
|
+
backoffs[attempt],
|
|
248
|
+
)
|
|
249
|
+
await asyncio.sleep(backoffs[attempt])
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
data = resp.json()
|
|
253
|
+
|
|
254
|
+
if resp.status_code >= 400:
|
|
255
|
+
error_info = data.get("error", {}) if isinstance(data, dict) else {}
|
|
256
|
+
raise FormBridgeError(
|
|
257
|
+
error_info.get("message", f"HTTP {resp.status_code}"),
|
|
258
|
+
status_code=resp.status_code,
|
|
259
|
+
error_type=error_info.get("type"),
|
|
260
|
+
response_data=data,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return data
|
|
264
|
+
|
|
265
|
+
except FormBridgeError:
|
|
266
|
+
raise
|
|
267
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout) as exc:
|
|
268
|
+
last_exc = exc
|
|
269
|
+
if attempt < len(backoffs):
|
|
270
|
+
logger.warning(
|
|
271
|
+
"FormBridge connection error (attempt %d/%d): %s, retrying in %.1fs",
|
|
272
|
+
attempt + 1,
|
|
273
|
+
len(backoffs) + 1,
|
|
274
|
+
exc,
|
|
275
|
+
backoffs[attempt],
|
|
276
|
+
)
|
|
277
|
+
await asyncio.sleep(backoffs[attempt])
|
|
278
|
+
continue
|
|
279
|
+
raise FormBridgeError(
|
|
280
|
+
f"Connection failed: {exc}",
|
|
281
|
+
is_connectivity_error=True,
|
|
282
|
+
) from exc
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
raise FormBridgeError(
|
|
285
|
+
f"Unexpected error: {exc}",
|
|
286
|
+
is_connectivity_error=True,
|
|
287
|
+
) from exc
|
|
288
|
+
|
|
289
|
+
# Should not reach here
|
|
290
|
+
if last_exc:
|
|
291
|
+
raise FormBridgeError(
|
|
292
|
+
f"Connection failed after retries: {last_exc}",
|
|
293
|
+
is_connectivity_error=True,
|
|
294
|
+
) from last_exc
|
|
295
|
+
raise RuntimeError("Retry loop exhausted unexpectedly")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class FormBridgeClientSync:
|
|
299
|
+
"""Synchronous wrapper around FormBridgeClient.
|
|
300
|
+
|
|
301
|
+
Works without an existing event loop. Creates its own loop internally.
|
|
302
|
+
|
|
303
|
+
Usage::
|
|
304
|
+
|
|
305
|
+
client = FormBridgeClientSync(api_key="sk-...")
|
|
306
|
+
sub = client.create_submission("vendor-onboarding", fields={"company": "Acme"})
|
|
307
|
+
client.close()
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
311
|
+
self._async_client = FormBridgeClient(**kwargs)
|
|
312
|
+
|
|
313
|
+
def __enter__(self) -> "FormBridgeClientSync":
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def __exit__(self, *args: object) -> None:
|
|
317
|
+
self.close()
|
|
318
|
+
|
|
319
|
+
def close(self) -> None:
|
|
320
|
+
"""Close the underlying HTTP client."""
|
|
321
|
+
self._run(self._async_client.close())
|
|
322
|
+
|
|
323
|
+
def create_submission(
|
|
324
|
+
self,
|
|
325
|
+
intake_id: str,
|
|
326
|
+
*,
|
|
327
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
328
|
+
actor: Optional[Actor] = None,
|
|
329
|
+
idempotency_key: Optional[str] = None,
|
|
330
|
+
) -> Submission:
|
|
331
|
+
"""Create a new submission. See :meth:`FormBridgeClient.create_submission`."""
|
|
332
|
+
return self._run(
|
|
333
|
+
self._async_client.create_submission(
|
|
334
|
+
intake_id, fields=fields, actor=actor, idempotency_key=idempotency_key
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def set_fields(
|
|
339
|
+
self,
|
|
340
|
+
intake_id: str,
|
|
341
|
+
submission_id: str,
|
|
342
|
+
resume_token: str,
|
|
343
|
+
fields: Dict[str, Any],
|
|
344
|
+
actor: Optional[Actor] = None,
|
|
345
|
+
) -> FieldsResult:
|
|
346
|
+
"""Update fields on a submission. See :meth:`FormBridgeClient.set_fields`."""
|
|
347
|
+
return self._run(
|
|
348
|
+
self._async_client.set_fields(intake_id, submission_id, resume_token, fields, actor)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def submit(
|
|
352
|
+
self,
|
|
353
|
+
intake_id: str,
|
|
354
|
+
submission_id: str,
|
|
355
|
+
resume_token: str,
|
|
356
|
+
*,
|
|
357
|
+
actor: Optional[Actor] = None,
|
|
358
|
+
) -> Submission:
|
|
359
|
+
"""Submit a submission. See :meth:`FormBridgeClient.submit`."""
|
|
360
|
+
return self._run(
|
|
361
|
+
self._async_client.submit(intake_id, submission_id, resume_token, actor=actor)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def get_submission(
|
|
365
|
+
self,
|
|
366
|
+
intake_id: str,
|
|
367
|
+
submission_id: str,
|
|
368
|
+
) -> Submission:
|
|
369
|
+
"""Get a submission by ID. See :meth:`FormBridgeClient.get_submission`."""
|
|
370
|
+
return self._run(self._async_client.get_submission(intake_id, submission_id))
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _run(coro: Any) -> Any:
|
|
374
|
+
"""Run a coroutine in a new event loop (safe from any context)."""
|
|
375
|
+
try:
|
|
376
|
+
loop = asyncio.get_running_loop()
|
|
377
|
+
except RuntimeError:
|
|
378
|
+
loop = None
|
|
379
|
+
|
|
380
|
+
if loop and loop.is_running():
|
|
381
|
+
# We're inside an async context — use a thread
|
|
382
|
+
import concurrent.futures
|
|
383
|
+
|
|
384
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
385
|
+
return pool.submit(asyncio.run, coro).result()
|
|
386
|
+
else:
|
|
387
|
+
return asyncio.run(coro)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Error types for FormBridge SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FormBridgeError(Exception):
|
|
9
|
+
"""Base error for FormBridge SDK.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
status_code: HTTP status code (None for connectivity errors).
|
|
13
|
+
error_type: Error type from API response (e.g. 'not_found').
|
|
14
|
+
is_connectivity_error: True if the error is due to a connection failure.
|
|
15
|
+
response_data: Raw response body if available.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status_code: Optional[int] = None,
|
|
23
|
+
error_type: Optional[str] = None,
|
|
24
|
+
is_connectivity_error: bool = False,
|
|
25
|
+
response_data: Optional[Dict[str, Any]] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.error_type = error_type
|
|
30
|
+
self.is_connectivity_error = is_connectivity_error
|
|
31
|
+
self.response_data = response_data
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Response types for FormBridge SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Actor:
|
|
11
|
+
"""Submission actor."""
|
|
12
|
+
|
|
13
|
+
kind: str
|
|
14
|
+
id: str
|
|
15
|
+
name: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Submission:
|
|
20
|
+
"""Submission returned by the API."""
|
|
21
|
+
|
|
22
|
+
submission_id: str
|
|
23
|
+
intake_id: str
|
|
24
|
+
state: str
|
|
25
|
+
resume_token: Optional[str] = None
|
|
26
|
+
fields: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
missing_fields: Optional[List[str]] = None
|
|
28
|
+
schema: Optional[Dict[str, Any]] = None
|
|
29
|
+
created_at: Optional[str] = None
|
|
30
|
+
updated_at: Optional[str] = None
|
|
31
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_response(cls, data: Dict[str, Any]) -> "Submission":
|
|
35
|
+
"""Parse API response into a Submission."""
|
|
36
|
+
return cls(
|
|
37
|
+
submission_id=data.get("submissionId", ""),
|
|
38
|
+
intake_id=data.get("intakeId", ""),
|
|
39
|
+
state=data.get("state", ""),
|
|
40
|
+
resume_token=data.get("resumeToken"),
|
|
41
|
+
fields=data.get("fields", {}),
|
|
42
|
+
missing_fields=data.get("missingFields"),
|
|
43
|
+
schema=data.get("schema"),
|
|
44
|
+
created_at=data.get("metadata", {}).get("createdAt")
|
|
45
|
+
if isinstance(data.get("metadata"), dict)
|
|
46
|
+
else data.get("createdAt"),
|
|
47
|
+
updated_at=data.get("metadata", {}).get("updatedAt")
|
|
48
|
+
if isinstance(data.get("metadata"), dict)
|
|
49
|
+
else data.get("updatedAt"),
|
|
50
|
+
raw=data,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class FieldsResult:
|
|
56
|
+
"""Result from set_fields."""
|
|
57
|
+
|
|
58
|
+
submission_id: str
|
|
59
|
+
state: str
|
|
60
|
+
resume_token: str
|
|
61
|
+
missing_fields: Optional[List[str]] = None
|
|
62
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_response(cls, data: Dict[str, Any]) -> "FieldsResult":
|
|
66
|
+
"""Parse API response into a FieldsResult."""
|
|
67
|
+
return cls(
|
|
68
|
+
submission_id=data.get("submissionId", ""),
|
|
69
|
+
state=data.get("state", ""),
|
|
70
|
+
resume_token=data.get("resumeToken", ""),
|
|
71
|
+
missing_fields=data.get("missingFields"),
|
|
72
|
+
raw=data,
|
|
73
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Unit tests for FormBridge SDK client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import httpx
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from formbridge import (
|
|
10
|
+
FormBridgeClient,
|
|
11
|
+
FormBridgeClientSync,
|
|
12
|
+
FormBridgeError,
|
|
13
|
+
Actor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
BASE_URL = "http://test-formbridge:3000"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def client():
|
|
21
|
+
return FormBridgeClient(url=BASE_URL, api_key="test-key")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def sync_client():
|
|
26
|
+
return FormBridgeClientSync(url=BASE_URL, api_key="test-key")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Auth Header ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@respx.mock
|
|
33
|
+
async def test_auth_header(client: FormBridgeClient):
|
|
34
|
+
"""Bearer token sent on all requests."""
|
|
35
|
+
route = respx.get(f"{BASE_URL}/intake/test/submissions/sub1").mock(
|
|
36
|
+
return_value=httpx.Response(200, json={
|
|
37
|
+
"ok": True, "submissionId": "sub1", "intakeId": "test",
|
|
38
|
+
"state": "draft", "fields": {},
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
await client.get_submission("test", "sub1")
|
|
42
|
+
assert route.called
|
|
43
|
+
assert route.calls[0].request.headers["authorization"] == "Bearer test-key"
|
|
44
|
+
await client.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── create_submission ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@respx.mock
|
|
51
|
+
async def test_create_submission_success(client: FormBridgeClient):
|
|
52
|
+
respx.post(f"{BASE_URL}/intake/vendor/submissions").mock(
|
|
53
|
+
return_value=httpx.Response(201, json={
|
|
54
|
+
"ok": True,
|
|
55
|
+
"submissionId": "sub_123",
|
|
56
|
+
"state": "in_progress",
|
|
57
|
+
"resumeToken": "tok_abc",
|
|
58
|
+
"schema": {"type": "object"},
|
|
59
|
+
"missingFields": ["email"],
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
sub = await client.create_submission(
|
|
64
|
+
"vendor",
|
|
65
|
+
fields={"company": "Acme"},
|
|
66
|
+
actor=Actor(kind="agent", id="agent-1", name="Bot"),
|
|
67
|
+
idempotency_key="idem-1",
|
|
68
|
+
)
|
|
69
|
+
assert sub.submission_id == "sub_123"
|
|
70
|
+
assert sub.state == "in_progress"
|
|
71
|
+
assert sub.resume_token == "tok_abc"
|
|
72
|
+
assert sub.missing_fields == ["email"]
|
|
73
|
+
assert sub.intake_id == "vendor"
|
|
74
|
+
await client.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── set_fields ────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@respx.mock
|
|
81
|
+
async def test_set_fields_success(client: FormBridgeClient):
|
|
82
|
+
respx.patch(f"{BASE_URL}/intake/vendor/submissions/sub_123").mock(
|
|
83
|
+
return_value=httpx.Response(200, json={
|
|
84
|
+
"ok": True,
|
|
85
|
+
"submissionId": "sub_123",
|
|
86
|
+
"state": "in_progress",
|
|
87
|
+
"resumeToken": "tok_new",
|
|
88
|
+
"missingFields": [],
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = await client.set_fields(
|
|
93
|
+
"vendor", "sub_123", "tok_abc", {"email": "a@b.com"},
|
|
94
|
+
actor=Actor(kind="agent", id="agent-1"),
|
|
95
|
+
)
|
|
96
|
+
assert result.submission_id == "sub_123"
|
|
97
|
+
assert result.resume_token == "tok_new"
|
|
98
|
+
await client.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── submit ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@respx.mock
|
|
105
|
+
async def test_submit_success(client: FormBridgeClient):
|
|
106
|
+
respx.post(f"{BASE_URL}/intake/vendor/submissions/sub_123/submit").mock(
|
|
107
|
+
return_value=httpx.Response(200, json={
|
|
108
|
+
"ok": True,
|
|
109
|
+
"submissionId": "sub_123",
|
|
110
|
+
"intakeId": "vendor",
|
|
111
|
+
"state": "submitted",
|
|
112
|
+
})
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
sub = await client.submit("vendor", "sub_123", "tok_abc")
|
|
116
|
+
assert sub.state == "submitted"
|
|
117
|
+
await client.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── get_submission ────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@respx.mock
|
|
124
|
+
async def test_get_submission_success(client: FormBridgeClient):
|
|
125
|
+
respx.get(f"{BASE_URL}/intake/vendor/submissions/sub_123").mock(
|
|
126
|
+
return_value=httpx.Response(200, json={
|
|
127
|
+
"ok": True,
|
|
128
|
+
"submissionId": "sub_123",
|
|
129
|
+
"intakeId": "vendor",
|
|
130
|
+
"state": "draft",
|
|
131
|
+
"fields": {"company": "Acme"},
|
|
132
|
+
"metadata": {"createdAt": "2026-01-01T00:00:00Z"},
|
|
133
|
+
})
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
sub = await client.get_submission("vendor", "sub_123")
|
|
137
|
+
assert sub.submission_id == "sub_123"
|
|
138
|
+
assert sub.fields == {"company": "Acme"}
|
|
139
|
+
assert sub.created_at == "2026-01-01T00:00:00Z"
|
|
140
|
+
await client.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Retry on 429 ──────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@respx.mock
|
|
147
|
+
async def test_retry_on_429(client: FormBridgeClient):
|
|
148
|
+
"""Should retry on 429 and succeed on subsequent attempt."""
|
|
149
|
+
route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
|
|
150
|
+
route.side_effect = [
|
|
151
|
+
httpx.Response(429, json={"ok": False, "error": {"type": "rate_limited"}}),
|
|
152
|
+
httpx.Response(200, json={
|
|
153
|
+
"ok": True, "submissionId": "s1", "intakeId": "v", "state": "draft", "fields": {},
|
|
154
|
+
}),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# Override backoffs for fast test
|
|
158
|
+
import formbridge.client as mod
|
|
159
|
+
original = mod._RETRY_BACKOFFS
|
|
160
|
+
mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
|
|
161
|
+
try:
|
|
162
|
+
sub = await client.get_submission("v", "s1")
|
|
163
|
+
assert sub.submission_id == "s1"
|
|
164
|
+
assert route.call_count == 2
|
|
165
|
+
finally:
|
|
166
|
+
mod._RETRY_BACKOFFS = original
|
|
167
|
+
await client.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Retry on 5xx then fail ────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@respx.mock
|
|
174
|
+
async def test_retry_5xx_then_fail(client: FormBridgeClient):
|
|
175
|
+
"""After max retries on 5xx, should raise FormBridgeError."""
|
|
176
|
+
route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
|
|
177
|
+
route.side_effect = [
|
|
178
|
+
httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
|
|
179
|
+
httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
|
|
180
|
+
httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
|
|
181
|
+
httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
import formbridge.client as mod
|
|
185
|
+
original = mod._RETRY_BACKOFFS
|
|
186
|
+
mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
|
|
187
|
+
try:
|
|
188
|
+
with pytest.raises(FormBridgeError) as exc_info:
|
|
189
|
+
await client.get_submission("v", "s1")
|
|
190
|
+
assert exc_info.value.status_code == 502
|
|
191
|
+
assert route.call_count == 4 # 1 initial + 3 retries
|
|
192
|
+
finally:
|
|
193
|
+
mod._RETRY_BACKOFFS = original
|
|
194
|
+
await client.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── No retry on 400/401/403 ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@respx.mock
|
|
201
|
+
async def test_no_retry_on_client_errors(client: FormBridgeClient):
|
|
202
|
+
"""Should NOT retry on 400, 401, 403."""
|
|
203
|
+
route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
|
|
204
|
+
route.mock(return_value=httpx.Response(401, json={
|
|
205
|
+
"ok": False, "error": {"type": "unauthorized", "message": "bad key"},
|
|
206
|
+
}))
|
|
207
|
+
|
|
208
|
+
with pytest.raises(FormBridgeError) as exc_info:
|
|
209
|
+
await client.get_submission("v", "s1")
|
|
210
|
+
assert exc_info.value.status_code == 401
|
|
211
|
+
assert route.call_count == 1
|
|
212
|
+
await client.close()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Connection refused → graceful error ───────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@respx.mock
|
|
219
|
+
async def test_connection_refused():
|
|
220
|
+
"""Connection error should produce FormBridgeError with is_connectivity_error=True."""
|
|
221
|
+
route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
|
|
222
|
+
route.side_effect = httpx.ConnectError("Connection refused")
|
|
223
|
+
|
|
224
|
+
import formbridge.client as mod
|
|
225
|
+
original = mod._RETRY_BACKOFFS
|
|
226
|
+
mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
|
|
227
|
+
|
|
228
|
+
client = FormBridgeClient(url=BASE_URL, api_key="test-key")
|
|
229
|
+
try:
|
|
230
|
+
with pytest.raises(FormBridgeError) as exc_info:
|
|
231
|
+
await client.get_submission("v", "s1")
|
|
232
|
+
assert exc_info.value.is_connectivity_error is True
|
|
233
|
+
assert "Connection" in str(exc_info.value)
|
|
234
|
+
finally:
|
|
235
|
+
mod._RETRY_BACKOFFS = original
|
|
236
|
+
await client.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Sync client ───────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@respx.mock
|
|
243
|
+
def test_sync_client_create(sync_client: FormBridgeClientSync):
|
|
244
|
+
"""Sync wrapper should work without event loop."""
|
|
245
|
+
respx.post(f"{BASE_URL}/intake/vendor/submissions").mock(
|
|
246
|
+
return_value=httpx.Response(201, json={
|
|
247
|
+
"ok": True,
|
|
248
|
+
"submissionId": "sub_sync",
|
|
249
|
+
"state": "draft",
|
|
250
|
+
"resumeToken": "tok_s",
|
|
251
|
+
})
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
sub = sync_client.create_submission("vendor", fields={"name": "Test"})
|
|
255
|
+
assert sub.submission_id == "sub_sync"
|
|
256
|
+
sync_client.close()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── API key not leaked in logs ────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_api_key_not_in_repr():
|
|
263
|
+
"""API key should not appear in string representations."""
|
|
264
|
+
client = FormBridgeClient(api_key="super-secret-key-123")
|
|
265
|
+
assert "super-secret-key-123" not in repr(client)
|
|
266
|
+
assert "super-secret-key-123" not in str(client)
|