Skip to content

Commit f4ecd4f

Browse files
author
koval
committed
Add docs
1 parent 49c415f commit f4ecd4f

2 files changed

Lines changed: 228 additions & 2 deletions

File tree

README.md

Lines changed: 224 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,230 @@
33
[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
44

55
# huntflow-api-client
6-
Huntflow API Client for Python
76

7+
Async Python client for the [Huntflow API](https://api.huntflow.ai/v2/docs). It wraps [httpx](https://www.python-httpx.org/), adds Bearer authentication, optional automatic token refresh, and typed helpers for major resources.
8+
9+
**Async-only:** every `request()` and entity method is `async` — call them from `async def` code (`asyncio.run`, FastAPI routes, your own event loop, etc.). There is no synchronous client.
810

911
## Installation
10-
Install using `pip install huntflow-api-client`
12+
13+
```bash
14+
pip install huntflow-api-client
15+
```
16+
17+
Requires Python **3.8.1+**. Main dependencies: [httpx](https://www.python-httpx.org/), `pydantic` v2, `email-validator`.
18+
19+
## Integration overview
20+
21+
1. Obtain an access token (and optionally a refresh token) using the flows described in the [Huntflow API documentation](https://api.huntflow.ai/v2/docs) (OAuth, service account, or your Huntflow product settings — whichever applies to your integration).
22+
2. Create a `HuntflowAPI` instance with your API base URL and either a static `ApiToken` (`token=`) or a `token_proxy=`. If both are supplied, **`token_proxy` wins** and `token` is ignored.
23+
3. Call `await api.request(...)` for any endpoint, or use entity classes (e.g. `Applicant`, `Vacancy`) for typed request/response models.
24+
25+
The client appends `/v2` to `base_url` for all requests. Paths you pass to `request()` are relative to that versioned root (for example `GET` `"/accounts"`, not `"/v2/accounts"`).
26+
27+
### Base URL
28+
29+
The constructor default is `https://api.huntflow.dev` (development). For production, pass your real API host, for example:
30+
31+
```python
32+
HuntflowAPI("https://api.huntflow.ai", token=token)
33+
```
34+
35+
Use the base URL Huntflow provides for your environment (no trailing `/v2`).
36+
37+
## Quick start (access token only)
38+
39+
Minimal setup: pass `ApiToken` with an `access_token`. Fine for short scripts. For **persisted** refresh across restarts or processes, use **`HuntflowTokenProxy`** and storage (see [Token proxy, storage, and locks](#token-proxy-storage-and-locks)). You can still set **`auto_refresh_tokens=True`** with `token=` — refresh then updates the in-memory token only (see that section for details).
40+
41+
```python
42+
import asyncio
43+
44+
from huntflow_api_client import HuntflowAPI
45+
from huntflow_api_client.tokens.token import ApiToken
46+
47+
48+
async def main() -> None:
49+
token = ApiToken(access_token="YOUR_ACCESS_TOKEN")
50+
api = HuntflowAPI("https://api.huntflow.ai", token=token)
51+
52+
response = await api.request("GET", "/accounts")
53+
accounts = response.json()
54+
print(accounts)
55+
56+
57+
asyncio.run(main())
58+
```
59+
60+
## Using resource entities
61+
62+
Entity classes take a `HuntflowAPI` instance and return Pydantic models parsed from JSON.
63+
64+
```python
65+
import asyncio
66+
67+
from huntflow_api_client import HuntflowAPI
68+
from huntflow_api_client.entities import Applicant
69+
from huntflow_api_client.tokens.token import ApiToken
70+
71+
72+
async def main() -> None:
73+
api = HuntflowAPI(
74+
"https://api.huntflow.ai",
75+
token=ApiToken(access_token="YOUR_ACCESS_TOKEN"),
76+
)
77+
applicants = Applicant(api)
78+
79+
page = await applicants.list(account_id=1, count=10, page=1)
80+
for item in page.items:
81+
print(item.id, item.first_name, item.last_name)
82+
83+
84+
asyncio.run(main())
85+
```
86+
87+
Other entities live under `huntflow_api_client.entities` (vacancies, webhooks, dictionaries, etc.). Each method docstring links to the matching OpenAPI operation where applicable.
88+
89+
## Token proxy, storage, and locks
90+
91+
`HuntflowAPI` authenticates every request using an **`AbstractTokenProxy`**. Most apps pass **`token=`** or **`token_proxy=HuntflowTokenProxy(...)`**. Subclass **`AbstractTokenProxy`** only for uncommon setups (custom token sources, extra logging, and so on).
92+
93+
- If you pass **`token=`** (`ApiToken`), the client wraps it in **`DummyHuntflowTokenProxy`**. With **`auto_refresh_tokens=True`**, refreshed tokens stay **in memory** on that proxy only (nothing is persisted). You still need **`refresh_token`** set on `ApiToken`, otherwise refresh cannot run.
94+
- For persisted refresh, pass **`token_proxy=`** — typically **`HuntflowTokenProxy`**, which loads and saves tokens through **`AbstractHuntflowTokenStorage`**.
95+
96+
### Storage (`AbstractHuntflowTokenStorage`)
97+
98+
Implementations must:
99+
100+
- **`get()`** — return an `ApiToken` (at least `access_token`; include `refresh_token` if you use refresh).
101+
- **`update(token)`** — persist the token after a successful `/token/refresh` (and any fields you care about, e.g. `expiration_timestamp`).
102+
103+
The built-in **`HuntflowTokenFileStorage`** reads/writes a JSON file with the same keys as `ApiToken` (`access_token`, `refresh_token`, optional timestamps). The file is overwritten on refresh.
104+
105+
### Locker (`AbstractLocker`)
106+
107+
When **`auto_refresh_tokens=True`**, several coroutines can hit token expiry at once. **`HuntflowTokenProxy`** can use a locker so only one refresh runs; others wait or retry.
108+
109+
- If **`locker=None`** (default), no synchronization is applied: concurrent refreshes are possible under load. Prefer a locker whenever **one storage** is shared by **many concurrent requests**.
110+
- **`AsyncioLockLocker`** — sufficient for **one process / one event loop** (see [`examples/api_client_with_simple_locks.py`](examples/api_client_with_simple_locks.py)).
111+
- For **multiple workers or hosts**, use a **distributed lock** (Redis, etc.) implementing **`AbstractLocker`**, together with storage that all instances share.
112+
113+
### Wiring `HuntflowTokenProxy`
114+
115+
```python
116+
from huntflow_api_client import HuntflowAPI
117+
from huntflow_api_client.tokens.locker import AsyncioLockLocker
118+
from huntflow_api_client.tokens.proxy import HuntflowTokenProxy
119+
from huntflow_api_client.tokens.storage import HuntflowTokenFileStorage
120+
121+
storage = HuntflowTokenFileStorage("/secure/huntflow_token.json")
122+
locker = AsyncioLockLocker()
123+
token_proxy = HuntflowTokenProxy(storage, locker=locker)
124+
125+
api = HuntflowAPI(
126+
"https://api.huntflow.ai",
127+
token_proxy=token_proxy,
128+
auto_refresh_tokens=True,
129+
)
130+
```
131+
132+
Seed the JSON file once with `access_token` and `refresh_token` from Huntflow before starting.
133+
134+
### Example: Redis-backed storage and lock
135+
136+
The package does **not** depend on Redis; install it separately (`pip install "redis>=4.2"` so `redis.asyncio` and async locks behave consistently). Use one async Redis client for both storage and the lock. **Populate the token key** before the first API call (same JSON shape as the file storage).
137+
138+
```python
139+
import json
140+
141+
from redis.asyncio import Redis
142+
from redis.exceptions import LockError
143+
144+
from huntflow_api_client import HuntflowAPI
145+
from huntflow_api_client.tokens.locker import AbstractLocker
146+
from huntflow_api_client.tokens.proxy import HuntflowTokenProxy
147+
from huntflow_api_client.tokens.storage import AbstractHuntflowTokenStorage
148+
from huntflow_api_client.tokens.token import ApiToken
149+
150+
151+
class HuntflowTokenRedisStorage(AbstractHuntflowTokenStorage):
152+
def __init__(self, redis: Redis, key: str = "huntflow:token") -> None:
153+
self._redis = redis
154+
self._key = key
155+
156+
async def get(self) -> ApiToken:
157+
raw = await self._redis.get(self._key)
158+
if raw is None:
159+
msg = (
160+
f"Redis key {self._key!r} is empty. "
161+
"SET JSON with access_token and refresh_token before use."
162+
)
163+
raise KeyError(msg)
164+
return ApiToken.from_dict(json.loads(raw))
165+
166+
async def update(self, token: ApiToken) -> None:
167+
await self._redis.set(self._key, json.dumps(token.dict()))
168+
169+
170+
class RedisLockLocker(AbstractLocker):
171+
"""Distributed lock compatible with HuntflowTokenProxy (multi-worker)."""
172+
173+
def __init__(self, redis: Redis, name: str = "huntflow:token_refresh") -> None:
174+
self._lock = redis.lock(name, timeout=30.0, blocking_timeout=60.0)
175+
176+
async def acquire(self) -> bool:
177+
return bool(await self._lock.acquire(blocking=False))
178+
179+
async def wait_for_lock(self) -> None:
180+
async with self._lock:
181+
pass
182+
183+
async def release(self) -> None:
184+
try:
185+
await self._lock.release()
186+
except LockError:
187+
return
188+
189+
190+
def build_api(redis: Redis) -> HuntflowAPI:
191+
storage = HuntflowTokenRedisStorage(redis, key="huntflow:token")
192+
locker = RedisLockLocker(redis, name="huntflow:token_refresh")
193+
token_proxy = HuntflowTokenProxy(storage, locker=locker)
194+
return HuntflowAPI(
195+
"https://api.huntflow.ai",
196+
token_proxy=token_proxy,
197+
auto_refresh_tokens=True,
198+
)
199+
200+
201+
# redis = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
202+
# try:
203+
# api = build_api(redis)
204+
# ...
205+
# finally:
206+
# await redis.aclose()
207+
```
208+
209+
Tune lock **`timeout`** / **`blocking_timeout`** for your network and refresh latency. Keep the **`Redis`** instance for the app lifetime and **`await redis.aclose()`** on shutdown. For fully custom behavior (e.g. KMS-wrapped secrets), subclass **`AbstractTokenProxy`** instead of `HuntflowTokenProxy`.
210+
211+
## Raw HTTP access
212+
213+
Every method on entities ultimately uses `HuntflowAPI.request`, which mirrors [`httpx.AsyncClient.request`](https://www.python-httpx.org/api/#asyncclient) (`json`, `params`, `files`, `timeout`, etc.). Entity methods usually serialize typed request models (for example `ApplicantCreateRequest.jsonable_dict(...)`); with `request()` you build the JSON yourself.
214+
215+
```python
216+
account_id = 1
217+
payload = {"first_name": "Ada", "last_name": "Lovelace"} # match API schema
218+
219+
response = await api.request(
220+
"POST",
221+
f"/accounts/{account_id}/applicants",
222+
json=payload,
223+
)
224+
```
225+
226+
Errors from non-success status codes are turned into typed exceptions in `huntflow_api_client.errors` (for example `NotFoundError`, `BadRequestError`, `TokenExpiredError`, `AuthorizationError`).
227+
228+
## Links
229+
230+
- [Huntflow API v2 documentation](https://api.huntflow.ai/v2/docs)
231+
- [Package on PyPI](https://pypi.org/project/huntflow-api-client/)
232+
- [Source code](https://github.com/huntflow/huntflow-api-client-python)

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ classifiers = [
2222
"Programming Language :: Python :: 3.11",
2323
]
2424

25+
[project.urls]
26+
Documentation = "https://api.huntflow.ai/v2/docs"
27+
Repository = "https://github.com/huntflow/huntflow-api-client-python"
28+
2529
[build-system]
2630
requires = ["pdm-backend"]
2731
build-backend = "pdm.backend"

0 commit comments

Comments
 (0)