formbridge-sdk 0.1.0__py3-none-any.whl
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/__init__.py +14 -0
- formbridge/client.py +387 -0
- formbridge/errors.py +31 -0
- formbridge/py.typed +0 -0
- formbridge/types.py +73 -0
- formbridge_sdk-0.1.0.dist-info/METADATA +126 -0
- formbridge_sdk-0.1.0.dist-info/RECORD +8 -0
- formbridge_sdk-0.1.0.dist-info/WHEEL +4 -0
formbridge/__init__.py
ADDED
|
@@ -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
|
+
]
|
formbridge/client.py
ADDED
|
@@ -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)
|
formbridge/errors.py
ADDED
|
@@ -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
|
formbridge/py.typed
ADDED
|
File without changes
|
formbridge/types.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -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,8 @@
|
|
|
1
|
+
formbridge/__init__.py,sha256=k4W7Z4XnpDK3P5mxLINfpf1xLCx4ccRSxi_43ioHDG4,392
|
|
2
|
+
formbridge/client.py,sha256=HwlRJAFiEG4XqYAdeVjofA_mrduC_CWbmT5cFIC6lxQ,13327
|
|
3
|
+
formbridge/errors.py,sha256=sXh5Mt8MlvjJKN40xy8GA6RvwEHaAF07X6vlFdO9YmU,998
|
|
4
|
+
formbridge/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
formbridge/types.py,sha256=-K3M0HyLAocvAx9piOsVVxbSLve1GvNNS63eJUeO95I,2297
|
|
6
|
+
formbridge_sdk-0.1.0.dist-info/METADATA,sha256=PZKEueirSZgL3uPF61SafZljG5IcxomQA4G0oEV0MnI,3730
|
|
7
|
+
formbridge_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
formbridge_sdk-0.1.0.dist-info/RECORD,,
|