Skip to content

Commit 6120d0d

Browse files
authored
Merge pull request #117 from huntflow/DEV-22460_add_docs
[DEV-22460] - Add docs
2 parents 49c415f + bbb78e0 commit 6120d0d

2 files changed

Lines changed: 306 additions & 3 deletions

File tree

README.md

Lines changed: 301 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,307 @@
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.
88

99
## Installation
10-
Install using `pip install huntflow-api-client`
10+
11+
```bash
12+
pip install huntflow-api-client
13+
```
14+
15+
Requires Python **3.8.1+**. Main dependencies: [httpx](https://www.python-httpx.org/), `pydantic` v2, `email-validator`.
16+
17+
## Integration overview
18+
19+
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).
20+
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.
21+
3. Call `await api.request(...)` for any endpoint, or use entity classes (e.g. `Applicant`, `Vacancy`) for typed request/response models.
22+
23+
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"`).
24+
25+
### Base URL
26+
27+
The constructor default is `https://api.huntflow.dev` (development). For production, pass your real API host, for example:
28+
29+
```python
30+
HuntflowAPI("https://api.huntflow.ai", token=token)
31+
```
32+
33+
Use the base URL Huntflow provides for your environment (no trailing `/v2`).
34+
35+
## Quick start (access token only)
36+
37+
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).
38+
39+
```python
40+
import asyncio
41+
42+
from huntflow_api_client import HuntflowAPI
43+
from huntflow_api_client.tokens.token import ApiToken
44+
45+
46+
async def main() -> None:
47+
token = ApiToken(access_token="YOUR_ACCESS_TOKEN")
48+
api = HuntflowAPI("https://api.huntflow.ai", token=token)
49+
50+
response = await api.request("GET", "/accounts")
51+
accounts = response.json()
52+
print(accounts)
53+
54+
55+
asyncio.run(main())
56+
```
57+
58+
## Using resource entities
59+
60+
Entity classes take a `HuntflowAPI` instance and return Pydantic models parsed from JSON.
61+
62+
```python
63+
import asyncio
64+
65+
from huntflow_api_client import HuntflowAPI
66+
from huntflow_api_client.entities import Applicant
67+
from huntflow_api_client.tokens.token import ApiToken
68+
69+
70+
async def main() -> None:
71+
api = HuntflowAPI(
72+
"https://api.huntflow.ai",
73+
token=ApiToken(access_token="YOUR_ACCESS_TOKEN"),
74+
)
75+
applicants = Applicant(api)
76+
77+
page = await applicants.list(account_id=1, count=10, page=1)
78+
for item in page.items:
79+
print(item.id, item.first_name, item.last_name)
80+
81+
82+
asyncio.run(main())
83+
```
84+
85+
Other entities live under `huntflow_api_client.entities` (vacancies, webhooks, dictionaries, etc.). Each method docstring links to the matching OpenAPI operation where applicable.
86+
87+
## Token proxy, storage, and locks
88+
89+
`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).
90+
91+
- 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.
92+
- For persisted refresh, pass **`token_proxy=`** — typically **`HuntflowTokenProxy`**, which loads and saves tokens through **`AbstractHuntflowTokenStorage`**.
93+
94+
### Storage (`AbstractHuntflowTokenStorage`)
95+
96+
Implementations must:
97+
98+
- **`get()`** — return an `ApiToken` (at least `access_token`; include `refresh_token` if you use refresh).
99+
- **`update(token)`** — persist the token after a successful `/token/refresh` (and any fields you care about, e.g. `expiration_timestamp`).
100+
101+
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.
102+
103+
### Locker (`AbstractLocker`)
104+
105+
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.
106+
107+
- 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**.
108+
- **`AsyncioLockLocker`** — sufficient for **one process / one event loop** (see [`examples/api_client_with_simple_locks.py`](examples/api_client_with_simple_locks.py)).
109+
- For **multiple workers or hosts**, use a **distributed lock** (Redis, etc.) implementing **`AbstractLocker`**, together with storage that all instances share.
110+
111+
### Wiring `HuntflowTokenProxy`
112+
113+
```python
114+
from huntflow_api_client import HuntflowAPI
115+
from huntflow_api_client.tokens.locker import AsyncioLockLocker
116+
from huntflow_api_client.tokens.proxy import HuntflowTokenProxy
117+
from huntflow_api_client.tokens.storage import HuntflowTokenFileStorage
118+
119+
storage = HuntflowTokenFileStorage("/secure/huntflow_token.json")
120+
locker = AsyncioLockLocker()
121+
token_proxy = HuntflowTokenProxy(storage, locker=locker)
122+
123+
api = HuntflowAPI(
124+
"https://api.huntflow.ai",
125+
token_proxy=token_proxy,
126+
auto_refresh_tokens=True,
127+
)
128+
```
129+
130+
Seed the JSON file once with `access_token` and `refresh_token` from Huntflow before starting.
131+
132+
### Example: Redis-backed storage and lock
133+
134+
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).
135+
136+
This example uses an accessor-style flow:
137+
`RedisTokenProxy -> RedisTokenAccessor -> storage`.
138+
`RedisTokenProxy` implements the SDK token contract, `RedisTokenAccessor` is responsible
139+
for token retrieval and update operations, and `RedisLockLocker` synchronizes refresh
140+
between concurrent requests.
141+
142+
```python
143+
import asyncio
144+
import json
145+
import time
146+
from typing import Any, Dict, Optional
147+
148+
from redis.asyncio import Redis
149+
from redis.asyncio.lock import Lock
150+
from redis.exceptions import LockError
151+
152+
from huntflow_api_client import HuntflowAPI
153+
from huntflow_api_client.tokens.locker import AbstractLocker
154+
from huntflow_api_client.tokens.proxy import (
155+
AbstractTokenProxy,
156+
convert_refresh_result_to_hf_token,
157+
get_auth_headers,
158+
get_refresh_token_data,
159+
)
160+
from huntflow_api_client.tokens.token import ApiToken
161+
162+
POLL_INTERVAL = 0.2
163+
164+
165+
class RedisLockLocker(AbstractLocker):
166+
"""Coordinates token refresh across concurrent workers.
167+
168+
One caller acquires the lock and performs refresh; others wait until
169+
the lock is released and then continue with updated token data.
170+
"""
171+
172+
def __init__(self, redis: Redis, name: str = "huntflow:token_refresh") -> None:
173+
self._lock = Lock(redis, name=name, timeout=30.0, blocking=False)
174+
175+
async def acquire(self) -> bool:
176+
try:
177+
return bool(await self._lock.acquire())
178+
except LockError:
179+
return False
180+
181+
async def wait_for_lock(self) -> None:
182+
while await self._lock.locked():
183+
await asyncio.sleep(POLL_INTERVAL)
184+
185+
async def release(self) -> None:
186+
try:
187+
await self._lock.release()
188+
except LockError:
189+
return
190+
191+
192+
class RedisTokenAccessor:
193+
"""Layer for token read/update operations.
194+
195+
Keeps Redis calls in one place and exposes lock-related operations
196+
used by the proxy.
197+
"""
198+
199+
def __init__(
200+
self,
201+
redis: Redis,
202+
locker: AbstractLocker,
203+
token_key: str = "huntflow:token",
204+
) -> None:
205+
self._redis = redis
206+
self._locker = locker
207+
self._token_key = token_key
208+
209+
async def get(self, bypass_lock: bool = False) -> Optional[Dict[str, Any]]:
210+
if not bypass_lock:
211+
await self._locker.wait_for_lock()
212+
raw = await self._redis.get(self._token_key)
213+
if not raw:
214+
return None
215+
return json.loads(raw)
216+
217+
async def update(self, token: ApiToken) -> None:
218+
await self._redis.set(self._token_key, json.dumps(token.dict()))
219+
220+
async def lock_for_update(self) -> bool:
221+
return await self._locker.acquire()
222+
223+
async def release_lock(self) -> None:
224+
await self._locker.release()
225+
226+
227+
class RedisTokenProxy(AbstractTokenProxy):
228+
"""`AbstractTokenProxy` implementation over accessor + locker.
229+
230+
Returns auth headers, provides refresh payload, saves refreshed token,
231+
and checks whether another worker has already updated the token.
232+
"""
233+
234+
def __init__(self, accessor: RedisTokenAccessor) -> None:
235+
self._accessor = accessor
236+
self._token: Optional[ApiToken] = None
237+
self._last_read_timestamp: Optional[float] = None
238+
239+
async def get_auth_header(self) -> Dict[str, str]:
240+
data = await self._accessor.get()
241+
if data is None:
242+
raise KeyError("Token not found in Redis. Seed access_token and refresh_token first.")
243+
self._token = ApiToken.from_dict(data)
244+
self._last_read_timestamp = time.time()
245+
return get_auth_headers(self._token)
246+
247+
async def get_refresh_data(self) -> Dict[str, str]:
248+
if self._token is None:
249+
data = await self._accessor.get()
250+
if data is None:
251+
raise KeyError("Token not found in Redis. Seed access_token and refresh_token first.")
252+
self._token = ApiToken.from_dict(data)
253+
return get_refresh_token_data(self._token)
254+
255+
async def update(self, refresh_result: dict) -> None:
256+
assert self._token is not None
257+
self._token = convert_refresh_result_to_hf_token(refresh_result, self._token)
258+
await self._accessor.update(self._token)
259+
260+
async def lock_for_update(self) -> bool:
261+
return await self._accessor.lock_for_update()
262+
263+
async def release_lock(self) -> None:
264+
await self._accessor.release_lock()
265+
266+
async def is_updated(self) -> bool:
267+
if self._last_read_timestamp is None:
268+
return False
269+
current_data = await self._accessor.get(bypass_lock=True)
270+
if current_data is None:
271+
return False
272+
current = ApiToken.from_dict(current_data)
273+
last_refresh_timestamp = current.last_refresh_timestamp or 0.0
274+
return last_refresh_timestamp > self._last_read_timestamp
275+
276+
277+
def build_api(redis: Redis) -> HuntflowAPI:
278+
locker = RedisLockLocker(redis, name="huntflow:token_refresh")
279+
accessor = RedisTokenAccessor(redis, locker=locker, token_key="huntflow:token")
280+
token_proxy = RedisTokenProxy(accessor)
281+
return HuntflowAPI(
282+
"https://api.huntflow.ai",
283+
token_proxy=token_proxy,
284+
auto_refresh_tokens=True,
285+
)
286+
```
287+
288+
## Raw HTTP access
289+
290+
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.
291+
292+
```python
293+
account_id = 1
294+
payload = {"first_name": "John", "last_name": "Doe"} # match API schema
295+
296+
response = await api.request(
297+
"POST",
298+
f"/accounts/{account_id}/applicants",
299+
json=payload,
300+
)
301+
```
302+
303+
Errors from non-success status codes are turned into typed exceptions in `huntflow_api_client.errors` (for example `NotFoundError`, `BadRequestError`, `TokenExpiredError`, `AuthorizationError`).
304+
305+
## Links
306+
307+
- [Huntflow API v2 documentation](https://api.huntflow.ai/v2/docs)
308+
- [Package on PyPI](https://pypi.org/project/huntflow-api-client/)
309+
- [Source code](https://github.com/huntflow/huntflow-api-client-python)

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
[project]
33
name = "huntflow-api-client"
4-
version = "2.13.4"
4+
version = "2.13.5"
55
description = "Huntflow API Client for Python"
66
authors = [
77
{name = "Developers huntflow", email = "developer@huntflow.ru"},
@@ -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)