diff --git a/.github/workflows/open-release-prs.yml b/.github/workflows/open-release-prs.yml
deleted file mode 100644
index cd6b23d7..00000000
--- a/.github/workflows/open-release-prs.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: Open release PRs
-on:
- push:
- branches:
- - next
-
-jobs:
- release:
- name: release
- if: github.ref == 'refs/heads/next' && github.repository == 'Finch-API/finch-api-python'
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
-
- - uses: stainless-api/trigger-release-please@v1
- id: release
- with:
- repo: ${{ github.event.repository.full_name }}
- stainless-api-key: ${{ secrets.STAINLESS_API_KEY }}
- branch-with-changes: next
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 3d2ac0bd..5547f83e 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.1.0"
+ ".": "0.1.1"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5dc481cd..b10f02f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## 0.1.1 (2023-09-11)
+
+Full Changelog: [v0.1.0...v0.1.1](https://github.com/Finch-API/finch-api-python/compare/v0.1.0...v0.1.1)
+
+### Features
+
+* add webhook verification methods ([#89](https://github.com/Finch-API/finch-api-python/issues/89)) ([483ffef](https://github.com/Finch-API/finch-api-python/commit/483ffefdaf118a1f9d496dfb5309ced17fe1ffb5))
+
+
+### Bug Fixes
+
+* **client:** properly handle optional file params ([#88](https://github.com/Finch-API/finch-api-python/issues/88)) ([77b9914](https://github.com/Finch-API/finch-api-python/commit/77b991409d71ca0e608082f54c00824f00d0841d))
+* **pagination:** don't duplicate shared types ([#86](https://github.com/Finch-API/finch-api-python/issues/86)) ([3f5d18f](https://github.com/Finch-API/finch-api-python/commit/3f5d18f2a71488e6d5248acaed8e9b68c1d6a15d))
+
+
+### Chores
+
+* **internal:** add example for configuration ([#81](https://github.com/Finch-API/finch-api-python/issues/81)) ([a71605a](https://github.com/Finch-API/finch-api-python/commit/a71605a090760aa91120c16265f81bd742376d04))
+* **internal:** minor formatting changes ([#87](https://github.com/Finch-API/finch-api-python/issues/87)) ([f940fb2](https://github.com/Finch-API/finch-api-python/commit/f940fb2320ce901025eb34f6fbab42a70f80dd37))
+* **internal:** minor restructuring ([#84](https://github.com/Finch-API/finch-api-python/issues/84)) ([fafab61](https://github.com/Finch-API/finch-api-python/commit/fafab61d4b247255be99c3c57d7e8975c8ee73ad))
+* **internal:** minor update ([#91](https://github.com/Finch-API/finch-api-python/issues/91)) ([1bd2f68](https://github.com/Finch-API/finch-api-python/commit/1bd2f68229cb690b04b2610f0e3bc7cc7c8932d4))
+* **internal:** update base client ([#90](https://github.com/Finch-API/finch-api-python/issues/90)) ([ee7284a](https://github.com/Finch-API/finch-api-python/commit/ee7284a84d96158765307011cf8a674b17579894))
+* **internal:** update pyright ([#94](https://github.com/Finch-API/finch-api-python/issues/94)) ([9209bca](https://github.com/Finch-API/finch-api-python/commit/9209bca492b41adbd91b00f13d48570e44954e1e))
+* **internal:** updates ([#93](https://github.com/Finch-API/finch-api-python/issues/93)) ([01662d9](https://github.com/Finch-API/finch-api-python/commit/01662d9c27a615ae0a638a55df606a77bc98ccbb))
+
+
+### Documentation
+
+* **readme:** add link to api.md ([#92](https://github.com/Finch-API/finch-api-python/issues/92)) ([4770119](https://github.com/Finch-API/finch-api-python/commit/477011938cf7128f06364fa69db8dd9fa244f5b8))
+* **readme:** reference pydantic helpers ([#85](https://github.com/Finch-API/finch-api-python/issues/85)) ([95d0870](https://github.com/Finch-API/finch-api-python/commit/95d087023e69a17aabcc18521078ee2fad106bf7))
+
## 0.1.0 (2023-08-29)
Full Changelog: [v0.0.11...v0.1.0](https://github.com/Finch-API/finch-api-python/compare/v0.0.11...v0.1.0)
diff --git a/README.md b/README.md
index 639d2622..0ae153d8 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ pip install finch-api
## Usage
+The full API of this library can be found in [api.md](https://www.github.com/Finch-API/finch-api-python/blob/main/api.md).
+
```python
from finch import Finch
@@ -57,9 +59,9 @@ Functionality between the synchronous and asynchronous clients is otherwise iden
## Using Types
-Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict), while responses are [Pydantic](https://pydantic-docs.helpmanual.io/) models. This helps provide autocomplete and documentation within your editor.
+Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev), which provide helper methods for things like serializing back into json ([v1](https://docs.pydantic.dev/1.10/usage/models/), [v2](https://docs.pydantic.dev/latest/usage/serialization/)). To get a dictionary, you can call `dict(model)`.
-If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `"basic"`.
+This helps provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `"basic"`.
## Pagination
@@ -141,6 +143,29 @@ client.hris.directory.list_individuals(
)
```
+## Webhook Verification
+
+We provide helper methods for verifying that a webhook request came from Finch, and not a malicious third party.
+
+You can use `finch.webhooks.verify_signature(body: string, headers, secret?) -> None` or `finch.webhooks.unwrap(body: string, headers, secret?) -> Payload`,
+both of which will raise an error if the signature is invalid.
+
+Note that the "body" parameter must be the raw JSON string sent from the server (do not parse it first).
+The `.unwrap()` method can parse this JSON for you into a `Payload` object.
+
+For example, in [FastAPI](https://fastapi.tiangolo.com/):
+
+```py
+@app.post('/my-webhook-handler')
+async def handler(request: Request):
+ body = await request.body()
+ secret = os.environ['FINCH_WEBHOOK_SECRET'] # env var used by default; explicit here.
+ payload = client.webhooks.unwrap(body, request.headers, secret)
+ print(payload)
+
+ return {'ok': True}
+```
+
## Handling errors
When the library is unable to connect to the API (e.g., due to network connection problems or a timeout), a subclass of `finch.APIConnectionError` is raised.
diff --git a/api.md b/api.md
index 4205ef26..9c37bfe7 100644
--- a/api.md
+++ b/api.md
@@ -223,3 +223,10 @@ Methods:
- client.account.disconnect() -> DisconnectResponse
- client.account.introspect() -> Introspection
+
+# Webhooks
+
+Methods:
+
+- client.webhooks.unwrap(\*args) -> object
+- client.webhooks.verify_signature(\*args) -> None
diff --git a/poetry.lock b/poetry.lock
index 888bd24e..ffedd33d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -697,14 +697,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pyright"
-version = "1.1.318"
+version = "1.1.326"
description = "Command line wrapper for pyright"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
- {file = "pyright-1.1.318-py3-none-any.whl", hash = "sha256:056c1b2e711c3526e32919de1684ae599d34b7ec27e94398858a43f56ac9ba9b"},
- {file = "pyright-1.1.318.tar.gz", hash = "sha256:69dcf9c32d5be27d531750de627e76a7cadc741d333b547c09044278b508db7b"},
+ {file = "pyright-1.1.326-py3-none-any.whl", hash = "sha256:f3c5047465138558d3d106a9464cc097cf2c3611da6edcf5b535cc1fdebd45db"},
+ {file = "pyright-1.1.326.tar.gz", hash = "sha256:cecbe026b14034ba0750db605718a8c2605552387c5772dfaf7f3e632cb7212a"},
]
[package.dependencies]
@@ -1054,4 +1054,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.7"
-content-hash = "a5044d71110571fb09eee526f26aca0906007643da1b1853cadb845444e3f829"
+content-hash = "f3a5042311972f24c3fb3c59a448c6f3ac3deec9c788271f0b726fbeb7c58250"
diff --git a/pyproject.toml b/pyproject.toml
index 859f823b..75bc97e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "finch-api"
-version = "0.1.0"
+version = "0.1.1"
description = "Client library for the Finch API"
readme = "README.md"
authors = ["Finch "]
@@ -21,7 +21,7 @@ distro = ">= 1.7.0, < 2"
[tool.poetry.group.dev.dependencies]
-pyright = "1.1.318"
+pyright = "1.1.326"
mypy = "1.4.1"
black = "23.3.0"
respx = "0.19.2"
@@ -34,6 +34,7 @@ nox = "^2023.4.22"
nox-poetry = "^1.0.3"
+
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py
index de73b186..58309b6b 100644
--- a/src/finch/_base_client.py
+++ b/src/finch/_base_client.py
@@ -24,7 +24,7 @@
overload,
)
from functools import lru_cache
-from typing_extensions import Literal, get_origin
+from typing_extensions import Literal, get_args, get_origin
import anyio
import httpx
@@ -458,6 +458,14 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o
serialized[key] = value
return serialized
+ def _extract_stream_chunk_type(self, stream_cls: type) -> type:
+ args = get_args(stream_cls)
+ if not args:
+ raise TypeError(
+ f"Expected stream_cls to have been given a generic type argument, e.g. Stream[Foo] but received {stream_cls}",
+ )
+ return cast(type, args[0])
+
def _process_response(
self,
*,
@@ -793,7 +801,10 @@ def _request(
raise APIConnectionError(request=request) from err
if stream:
- stream_cls = stream_cls or cast("type[_StreamT] | None", self._default_stream_cls)
+ if stream_cls:
+ return stream_cls(cast_to=self._extract_stream_chunk_type(stream_cls), response=response, client=self)
+
+ stream_cls = cast("type[_StreamT] | None", self._default_stream_cls)
if stream_cls is None:
raise MissingStreamClassError()
return stream_cls(cast_to=cast_to, response=response, client=self)
@@ -1156,7 +1167,10 @@ async def _request(
raise APIConnectionError(request=request) from err
if stream:
- stream_cls = stream_cls or cast("type[_AsyncStreamT] | None", self._default_stream_cls)
+ if stream_cls:
+ return stream_cls(cast_to=self._extract_stream_chunk_type(stream_cls), response=response, client=self)
+
+ stream_cls = cast("type[_AsyncStreamT] | None", self._default_stream_cls)
if stream_cls is None:
raise MissingStreamClassError()
return stream_cls(cast_to=cast_to, response=response, client=self)
diff --git a/src/finch/_client.py b/src/finch/_client.py
index 1efed683..2399240c 100644
--- a/src/finch/_client.py
+++ b/src/finch/_client.py
@@ -49,17 +49,20 @@ class Finch(SyncAPIClient):
ats: resources.ATS
providers: resources.Providers
account: resources.Account
+ webhooks: resources.Webhooks
# client options
access_token: str | None
client_id: str | None
client_secret: str | None
+ webhook_secret: str | None
def __init__(
self,
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT,
@@ -87,6 +90,7 @@ def __init__(
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `client_id` from `FINCH_CLIENT_ID`
- `client_secret` from `FINCH_CLIENT_SECRET`
+ - `webhook_secret` from `FINCH_WEBHOOK_SECRET`
"""
self.access_token = access_token
@@ -96,6 +100,9 @@ def __init__(
client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None)
self.client_secret = client_secret or client_secret_envvar or None
+ webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None)
+ self.webhook_secret = webhook_secret or webhook_secret_envvar or None
+
if base_url is None:
base_url = f"https://api.tryfinch.com"
@@ -116,6 +123,7 @@ def __init__(
self.ats = resources.ATS(self)
self.providers = resources.Providers(self)
self.account = resources.Account(self)
+ self.webhooks = resources.Webhooks(self)
@property
def qs(self) -> Querystring:
@@ -151,6 +159,7 @@ def copy(
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
access_token: str | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -189,6 +198,7 @@ def copy(
return self.__class__(
client_id=client_id or self.client_id,
client_secret=client_secret or self.client_secret,
+ webhook_secret=webhook_secret or self.webhook_secret,
base_url=base_url or str(self.base_url),
access_token=access_token or self.access_token,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
@@ -272,17 +282,20 @@ class AsyncFinch(AsyncAPIClient):
ats: resources.AsyncATS
providers: resources.AsyncProviders
account: resources.AsyncAccount
+ webhooks: resources.AsyncWebhooks
# client options
access_token: str | None
client_id: str | None
client_secret: str | None
+ webhook_secret: str | None
def __init__(
self,
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT,
@@ -310,6 +323,7 @@ def __init__(
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `client_id` from `FINCH_CLIENT_ID`
- `client_secret` from `FINCH_CLIENT_SECRET`
+ - `webhook_secret` from `FINCH_WEBHOOK_SECRET`
"""
self.access_token = access_token
@@ -319,6 +333,9 @@ def __init__(
client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None)
self.client_secret = client_secret or client_secret_envvar or None
+ webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None)
+ self.webhook_secret = webhook_secret or webhook_secret_envvar or None
+
if base_url is None:
base_url = f"https://api.tryfinch.com"
@@ -339,6 +356,7 @@ def __init__(
self.ats = resources.AsyncATS(self)
self.providers = resources.AsyncProviders(self)
self.account = resources.AsyncAccount(self)
+ self.webhooks = resources.AsyncWebhooks(self)
@property
def qs(self) -> Querystring:
@@ -374,6 +392,7 @@ def copy(
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
access_token: str | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -412,6 +431,7 @@ def copy(
return self.__class__(
client_id=client_id or self.client_id,
client_secret=client_secret or self.client_secret,
+ webhook_secret=webhook_secret or self.webhook_secret,
base_url=base_url or str(self.base_url),
access_token=access_token or self.access_token,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
diff --git a/src/finch/_compat.py b/src/finch/_compat.py
index 7b5f798f..fed1df05 100644
--- a/src/finch/_compat.py
+++ b/src/finch/_compat.py
@@ -120,10 +120,10 @@ def model_copy(model: _ModelT) -> _ModelT:
return model.copy() # type: ignore
-def model_json(model: pydantic.BaseModel) -> str:
+def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str:
if PYDANTIC_V2:
- return model.model_dump_json()
- return model.json() # type: ignore
+ return model.model_dump_json(indent=indent)
+ return model.json(indent=indent) # type: ignore
def model_dump(model: pydantic.BaseModel) -> dict[str, Any]:
@@ -132,6 +132,12 @@ def model_dump(model: pydantic.BaseModel) -> dict[str, Any]:
return cast("dict[str, Any]", model.dict()) # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
+def model_parse(model: type[_ModelT], data: Any) -> _ModelT:
+ if PYDANTIC_V2:
+ return model.model_validate(data)
+ return model.parse_obj(data) # pyright: ignore[reportDeprecated]
+
+
# generic models
if TYPE_CHECKING:
@@ -147,6 +153,7 @@ class GenericModel(pydantic.BaseModel):
...
else:
+ import pydantic.generics
class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel):
...
diff --git a/src/finch/_models.py b/src/finch/_models.py
index 164987a7..296d36c0 100644
--- a/src/finch/_models.py
+++ b/src/finch/_models.py
@@ -215,7 +215,7 @@ def validate_type(*, type_: type[_T], value: object) -> _T:
if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel):
return cast(_T, parse_obj(type_, value))
- return _validate_non_model_type(type_=type_, value=value)
+ return cast(_T, _validate_non_model_type(type_=type_, value=value))
# our use of subclasssing here causes weirdness for type checkers,
diff --git a/src/finch/_resource.py b/src/finch/_resource.py
index 2e07d57a..f832efb9 100644
--- a/src/finch/_resource.py
+++ b/src/finch/_resource.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import time
+import asyncio
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -20,6 +22,9 @@ def __init__(self, client: Finch) -> None:
self._delete = client.delete
self._get_api_list = client.get_api_list
+ def _sleep(self, seconds: float) -> None:
+ time.sleep(seconds)
+
class AsyncAPIResource:
_client: AsyncFinch
@@ -32,3 +37,6 @@ def __init__(self, client: AsyncFinch) -> None:
self._put = client.put
self._delete = client.delete
self._get_api_list = client.get_api_list
+
+ async def _sleep(self, seconds: float) -> None:
+ await asyncio.sleep(seconds)
diff --git a/src/finch/_types.py b/src/finch/_types.py
index 17148bdd..cb759eb2 100644
--- a/src/finch/_types.py
+++ b/src/finch/_types.py
@@ -31,7 +31,8 @@
_T = TypeVar("_T")
# Approximates httpx internal ProxiesTypes and RequestFiles types
-ProxiesTypes = Union[str, Proxy, Dict[str, Union[None, str, Proxy]]]
+ProxiesDict = Dict[str, Union[None, str, Proxy]]
+ProxiesTypes = Union[str, Proxy, ProxiesDict]
FileContent = Union[IO[bytes], bytes]
FileTypes = Union[
# file (or bytes)
diff --git a/src/finch/_utils/__init__.py b/src/finch/_utils/__init__.py
index 05a82c73..b45dc1b1 100644
--- a/src/finch/_utils/__init__.py
+++ b/src/finch/_utils/__init__.py
@@ -1,6 +1,7 @@
from ._utils import flatten as flatten
from ._utils import is_dict as is_dict
from ._utils import is_list as is_list
+from ._utils import is_given as is_given
from ._utils import is_mapping as is_mapping
from ._utils import parse_date as parse_date
from ._utils import coerce_float as coerce_float
diff --git a/src/finch/_utils/_utils.py b/src/finch/_utils/_utils.py
index af575663..dde4b457 100644
--- a/src/finch/_utils/_utils.py
+++ b/src/finch/_utils/_utils.py
@@ -7,7 +7,7 @@
from pathlib import Path
from typing_extensions import Required, Annotated, TypeGuard, get_args, get_origin
-from .._types import NotGiven, FileTypes
+from .._types import NotGiven, FileTypes, NotGivenOr
from .._compat import is_union as _is_union
from .._compat import parse_date as parse_date
from .._compat import parse_datetime as parse_datetime
@@ -38,12 +38,15 @@ def _extract_items(
path: Sequence[str],
*,
index: int,
- # TODO: rename
flattened_key: str | None,
) -> list[tuple[str, FileTypes]]:
try:
key = path[index]
except IndexError:
+ if isinstance(obj, NotGiven):
+ # no value was provided - we can safely ignore
+ return []
+
# We have exhausted the path, return the entry we found.
if not isinstance(obj, bytes) and not isinstance(obj, tuple):
raise RuntimeError(
@@ -97,6 +100,10 @@ def _extract_items(
return []
+def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]:
+ return not isinstance(obj, NotGiven)
+
+
# Type safe methods for narrowing types with TypeVars.
# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown],
# however this cause Pyright to rightfully report errors. As we know we don't
diff --git a/src/finch/_version.py b/src/finch/_version.py
index 3e2604f3..dee1fc53 100644
--- a/src/finch/_version.py
+++ b/src/finch/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless.
__title__ = "finch"
-__version__ = "0.1.0" # x-release-please-version
+__version__ = "0.1.1" # x-release-please-version
diff --git a/src/finch/pagination.py b/src/finch/pagination.py
index 20e2dabb..e1a57f18 100644
--- a/src/finch/pagination.py
+++ b/src/finch/pagination.py
@@ -4,6 +4,7 @@
from httpx import Response
+from .types import Paging
from ._types import ModelT
from ._utils import is_mapping
from ._models import BaseModel
@@ -101,17 +102,13 @@ def next_page_info(self) -> None:
return None
-class IndividualsPagePaging(BaseModel):
- count: Optional[int] = None
- """The total number of elements for the entire query (not just the given page)"""
-
- offset: Optional[int] = None
- """The current start index of the returned list of elements"""
+IndividualsPagePaging = Paging
+"""This is deprecated, Paging should be used instead"""
class SyncIndividualsPage(BaseSyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: IndividualsPagePaging
individuals: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.individuals
@@ -135,8 +132,8 @@ def next_page_info(self) -> Optional[PageInfo]:
class AsyncIndividualsPage(BaseAsyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: IndividualsPagePaging
individuals: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.individuals
@@ -159,17 +156,13 @@ def next_page_info(self) -> Optional[PageInfo]:
return None
-class CandidatesPagePaging(BaseModel):
- count: Optional[int] = None
- """The total number of elements for the entire query (not just the given page)"""
-
- offset: Optional[int] = None
- """The current start index of the returned list of elements"""
+CandidatesPagePaging = Paging
+"""This is deprecated, Paging should be used instead"""
class SyncCandidatesPage(BaseSyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: CandidatesPagePaging
candidates: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.candidates
@@ -193,8 +186,8 @@ def next_page_info(self) -> Optional[PageInfo]:
class AsyncCandidatesPage(BaseAsyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: CandidatesPagePaging
candidates: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.candidates
@@ -217,17 +210,13 @@ def next_page_info(self) -> Optional[PageInfo]:
return None
-class ApplicationsPagePaging(BaseModel):
- count: Optional[int] = None
- """The total number of elements for the entire query (not just the given page)"""
-
- offset: Optional[int] = None
- """The current start index of the returned list of elements"""
+ApplicationsPagePaging = Paging
+"""This is deprecated, Paging should be used instead"""
class SyncApplicationsPage(BaseSyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: ApplicationsPagePaging
applications: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.applications
@@ -251,8 +240,8 @@ def next_page_info(self) -> Optional[PageInfo]:
class AsyncApplicationsPage(BaseAsyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: ApplicationsPagePaging
applications: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.applications
@@ -275,17 +264,13 @@ def next_page_info(self) -> Optional[PageInfo]:
return None
-class JobsPagePaging(BaseModel):
- count: Optional[int] = None
- """The total number of elements for the entire query (not just the given page)"""
-
- offset: Optional[int] = None
- """The current start index of the returned list of elements"""
+JobsPagePaging = Paging
+"""This is deprecated, Paging should be used instead"""
class SyncJobsPage(BaseSyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: JobsPagePaging
jobs: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.jobs
@@ -309,8 +294,8 @@ def next_page_info(self) -> Optional[PageInfo]:
class AsyncJobsPage(BaseAsyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: JobsPagePaging
jobs: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.jobs
@@ -333,17 +318,13 @@ def next_page_info(self) -> Optional[PageInfo]:
return None
-class OffersPagePaging(BaseModel):
- count: Optional[int] = None
- """The total number of elements for the entire query (not just the given page)"""
-
- offset: Optional[int] = None
- """The current start index of the returned list of elements"""
+OffersPagePaging = Paging
+"""This is deprecated, Paging should be used instead"""
class SyncOffersPage(BaseSyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: OffersPagePaging
offers: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.offers
@@ -367,8 +348,8 @@ def next_page_info(self) -> Optional[PageInfo]:
class AsyncOffersPage(BaseAsyncPage[ModelT], BasePage[ModelT], Generic[ModelT]):
- paging: OffersPagePaging
offers: List[ModelT]
+ paging: Paging
def _get_page_items(self) -> List[ModelT]:
return self.offers
diff --git a/src/finch/resources/__init__.py b/src/finch/resources/__init__.py
index 4c349ae2..2008f217 100644
--- a/src/finch/resources/__init__.py
+++ b/src/finch/resources/__init__.py
@@ -3,6 +3,18 @@
from .ats import ATS, AsyncATS
from .hris import HRIS, AsyncHRIS
from .account import Account, AsyncAccount
+from .webhooks import Webhooks, AsyncWebhooks
from .providers import Providers, AsyncProviders
-__all__ = ["HRIS", "AsyncHRIS", "ATS", "AsyncATS", "Providers", "AsyncProviders", "Account", "AsyncAccount"]
+__all__ = [
+ "HRIS",
+ "AsyncHRIS",
+ "ATS",
+ "AsyncATS",
+ "Providers",
+ "AsyncProviders",
+ "Account",
+ "AsyncAccount",
+ "Webhooks",
+ "AsyncWebhooks",
+]
diff --git a/src/finch/resources/hris/benefits/benefits.py b/src/finch/resources/hris/benefits/benefits.py
index bf9b6870..1068dab2 100644
--- a/src/finch/resources/hris/benefits/benefits.py
+++ b/src/finch/resources/hris/benefits/benefits.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Optional
from ...._types import NOT_GIVEN, Body, Query, Headers, NotGiven
from ...._utils import maybe_transform
@@ -38,8 +38,8 @@ def create(
self,
*,
description: str | NotGiven = NOT_GIVEN,
- frequency: BenefitFrequency | NotGiven = NOT_GIVEN,
- type: BenefitType | NotGiven = NOT_GIVEN,
+ frequency: Optional[BenefitFrequency] | NotGiven = NOT_GIVEN,
+ type: Optional[BenefitType] | NotGiven = NOT_GIVEN,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -211,8 +211,8 @@ async def create(
self,
*,
description: str | NotGiven = NOT_GIVEN,
- frequency: BenefitFrequency | NotGiven = NOT_GIVEN,
- type: BenefitType | NotGiven = NOT_GIVEN,
+ frequency: Optional[BenefitFrequency] | NotGiven = NOT_GIVEN,
+ type: Optional[BenefitType] | NotGiven = NOT_GIVEN,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
diff --git a/src/finch/resources/webhooks.py b/src/finch/resources/webhooks.py
new file mode 100644
index 00000000..f5388ae5
--- /dev/null
+++ b/src/finch/resources/webhooks.py
@@ -0,0 +1,215 @@
+# File generated from our OpenAPI spec by Stainless.
+
+from __future__ import annotations
+
+import hmac
+import json
+import math
+import base64
+import hashlib
+from datetime import datetime, timezone, timedelta
+
+from .._types import HeadersLike
+from .._resource import SyncAPIResource, AsyncAPIResource
+
+__all__ = ["Webhooks", "AsyncWebhooks"]
+
+
+class Webhooks(SyncAPIResource):
+ def unwrap(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> object:
+ """Validates that the given payload was sent by Finch and parses the payload."""
+ self.verify_signature(payload=payload, headers=headers, secret=secret)
+ return json.loads(payload)
+
+ def verify_signature(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> None:
+ """Validates whether or not the webhook payload was sent by Finch.
+
+ An error will be raised if the webhook payload was not sent by Finch.
+ """
+ if secret is None:
+ secret = self._client.webhook_secret
+
+ if secret is None:
+ raise ValueError(
+ "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function"
+ )
+
+ try:
+ parsedSecret = base64.b64decode(secret)
+ except Exception:
+ raise ValueError("Bad secret")
+
+ msg_id = headers.get("finch-event-id")
+ if not msg_id:
+ raise ValueError("Could not find finch-event-id header")
+
+ msg_timestamp = headers.get("finch-timestamp")
+ if not msg_timestamp:
+ raise ValueError("Could not find finch-timestamp header")
+
+ # validate the timestamp
+ webhook_tolerance = timedelta(minutes=5)
+ now = datetime.now(tz=timezone.utc)
+
+ try:
+ timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc)
+ except Exception:
+ raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp")
+
+ # too old
+ if timestamp < (now - webhook_tolerance):
+ raise ValueError("Webhook timestamp is too old")
+
+ # too new
+ if timestamp > (now + webhook_tolerance):
+ raise ValueError("Webhook timestamp is too new")
+
+ # create the signature
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
+ if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise ValueError(
+ "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary."
+ )
+
+ timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp()))
+
+ to_sign = f"{msg_id}.{timestamp_str}.{body}".encode()
+ expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest()
+
+ msg_signature = headers.get("finch-signature")
+ if not msg_signature:
+ raise ValueError("Could not find finch-signature header")
+
+ # Signature header can contain multiple signatures delimited by spaces
+ passed_sigs = msg_signature.split(" ")
+
+ for versioned_sig in passed_sigs:
+ values = versioned_sig.split(",")
+ if len(values) != 2:
+ # signature is not formatted like {version},{signature}
+ continue
+
+ (version, signature) = values
+
+ # Only verify prefix v1
+ if version != "v1":
+ continue
+
+ sig_bytes = base64.b64decode(signature)
+ if hmac.compare_digest(expected_signature, sig_bytes):
+ # valid!
+ return None
+
+ raise ValueError("None of the given webhook signatures match the expected signature")
+
+
+class AsyncWebhooks(AsyncAPIResource):
+ def unwrap(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> object:
+ """Validates that the given payload was sent by Finch and parses the payload."""
+ self.verify_signature(payload=payload, headers=headers, secret=secret)
+ return json.loads(payload)
+
+ def verify_signature(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> None:
+ """Validates whether or not the webhook payload was sent by Finch.
+
+ An error will be raised if the webhook payload was not sent by Finch.
+ """
+ if secret is None:
+ secret = self._client.webhook_secret
+
+ if secret is None:
+ raise ValueError(
+ "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function"
+ )
+
+ try:
+ parsedSecret = base64.b64decode(secret)
+ except Exception:
+ raise ValueError("Bad secret")
+
+ msg_id = headers.get("finch-event-id")
+ if not msg_id:
+ raise ValueError("Could not find finch-event-id header")
+
+ msg_timestamp = headers.get("finch-timestamp")
+ if not msg_timestamp:
+ raise ValueError("Could not find finch-timestamp header")
+
+ # validate the timestamp
+ webhook_tolerance = timedelta(minutes=5)
+ now = datetime.now(tz=timezone.utc)
+
+ try:
+ timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc)
+ except Exception:
+ raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp")
+
+ # too old
+ if timestamp < (now - webhook_tolerance):
+ raise ValueError("Webhook timestamp is too old")
+
+ # too new
+ if timestamp > (now + webhook_tolerance):
+ raise ValueError("Webhook timestamp is too new")
+
+ # create the signature
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
+ if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise ValueError(
+ "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary."
+ )
+
+ timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp()))
+
+ to_sign = f"{msg_id}.{timestamp_str}.{body}".encode()
+ expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest()
+
+ msg_signature = headers.get("finch-signature")
+ if not msg_signature:
+ raise ValueError("Could not find finch-signature header")
+
+ # Signature header can contain multiple signatures delimited by spaces
+ passed_sigs = msg_signature.split(" ")
+
+ for versioned_sig in passed_sigs:
+ values = versioned_sig.split(",")
+ if len(values) != 2:
+ # signature is not formatted like {version},{signature}
+ continue
+
+ (version, signature) = values
+
+ # Only verify prefix v1
+ if version != "v1":
+ continue
+
+ sig_bytes = base64.b64decode(signature)
+ if hmac.compare_digest(expected_signature, sig_bytes):
+ # valid!
+ return None
+
+ raise ValueError("None of the given webhook signatures match the expected signature")
diff --git a/src/finch/types/hris/benefit_create_params.py b/src/finch/types/hris/benefit_create_params.py
index 195e2ee3..e42f3bb0 100644
--- a/src/finch/types/hris/benefit_create_params.py
+++ b/src/finch/types/hris/benefit_create_params.py
@@ -3,7 +3,10 @@
from __future__ import annotations
from typing import Optional
-from typing_extensions import Literal, TypedDict
+from typing_extensions import TypedDict
+
+from .benefit_type import BenefitType
+from .benefit_frequency import BenefitFrequency
__all__ = ["BenefitCreateParams"]
@@ -11,29 +14,7 @@
class BenefitCreateParams(TypedDict, total=False):
description: str
- frequency: Optional[Literal["one_time", "every_paycheck"]]
-
- type: Optional[
- Literal[
- "401k",
- "401k_roth",
- "401k_loan",
- "403b",
- "403b_roth",
- "457",
- "457_roth",
- "s125_medical",
- "s125_dental",
- "s125_vision",
- "hsa_pre",
- "hsa_post",
- "fsa_medical",
- "fsa_dependent_care",
- "simple_ira",
- "simple",
- "commuter",
- "custom_post_tax",
- "custom_pre_tax",
- ]
- ]
+ frequency: Optional[BenefitFrequency]
+
+ type: Optional[BenefitType]
"""Type of benefit."""
diff --git a/src/finch/types/hris/supported_benefit.py b/src/finch/types/hris/supported_benefit.py
index a79866e1..d99bfae2 100644
--- a/src/finch/types/hris/supported_benefit.py
+++ b/src/finch/types/hris/supported_benefit.py
@@ -5,6 +5,7 @@
from ..._models import BaseModel
from .benefit_type import BenefitType
+from .benefit_frequency import BenefitFrequency
__all__ = ["SupportedBenefit"]
@@ -33,7 +34,7 @@ class SupportedBenefit(BaseModel):
An empty array indicates deductions are not supported.
"""
- frequencies: Optional[List[Optional[Literal["one_time", "every_paycheck"]]]] = None
+ frequencies: Optional[List[Optional[BenefitFrequency]]] = None
"""The list of frequencies supported by the provider for this benefit"""
hsa_contribution_limit: Optional[List[Literal["individual", "family"]]] = None
diff --git a/tests/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py
new file mode 100644
index 00000000..61175521
--- /dev/null
+++ b/tests/api_resources/test_webhooks.py
@@ -0,0 +1,216 @@
+# File generated from our OpenAPI spec by Stainless.
+
+from __future__ import annotations
+
+import os
+import base64
+from typing import Any, cast
+from datetime import datetime, timezone, timedelta
+
+import pytest
+import time_machine
+
+from finch import Finch, AsyncFinch
+
+base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010")
+access_token = os.environ.get("API_KEY", "something1234")
+
+
+class TestWebhooks:
+ strict_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ loose_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"])
+
+ timestamp = "1676312382"
+ fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc)
+
+ payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}"""
+ signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s="
+ headers = {
+ "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj",
+ "finch-timestamp": timestamp,
+ "finch-signature": f"v1,{signature}",
+ }
+ secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
+
+ @time_machine.travel(fake_now)
+ def test_unwrap(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+
+ self.strict_client.webhooks.unwrap(payload, headers, secret=secret)
+
+ @time_machine.travel(fake_now)
+ def test_verify_signature(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+ signature = self.signature
+ verify = self.strict_client.webhooks.verify_signature
+
+ assert verify(payload=payload, headers=headers, secret=secret) is None
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too old"):
+ with time_machine.travel(self.fake_now + timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too new"):
+ with time_machine.travel(self.fake_now - timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ # wrong secret
+ with pytest.raises(ValueError, match=r"Bad secret"):
+ verify(payload=payload, headers=headers, secret="invalid secret")
+
+ invalid_signature_message = "None of the given webhook signatures match the expected signature"
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers=headers,
+ secret=base64.b64encode(b"foo").decode("utf-8"),
+ )
+
+ # multiple signatures
+ invalid_signature = base64.b64encode(b"my_sig").decode("utf-8")
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # different signaature version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature}"},
+ secret=secret,
+ )
+
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # missing version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": signature},
+ secret=secret,
+ )
+
+ # non-string payload
+ with pytest.raises(ValueError, match=r"Webhook body should be a string"):
+ verify(
+ payload=cast(Any, {"payload": payload}),
+ headers=headers,
+ secret=secret,
+ )
+
+
+class TestAsyncWebhooks:
+ strict_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ loose_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"])
+
+ timestamp = "1676312382"
+ fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc)
+
+ payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}"""
+ signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s="
+ headers = {
+ "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj",
+ "finch-timestamp": timestamp,
+ "finch-signature": f"v1,{signature}",
+ }
+ secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
+
+ @time_machine.travel(fake_now)
+ def test_unwrap(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+
+ self.strict_client.webhooks.unwrap(payload, headers, secret=secret)
+
+ @time_machine.travel(fake_now)
+ def test_verify_signature(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+ signature = self.signature
+ verify = self.strict_client.webhooks.verify_signature
+
+ assert verify(payload=payload, headers=headers, secret=secret) is None
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too old"):
+ with time_machine.travel(self.fake_now + timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too new"):
+ with time_machine.travel(self.fake_now - timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ # wrong secret
+ with pytest.raises(ValueError, match=r"Bad secret"):
+ verify(payload=payload, headers=headers, secret="invalid secret")
+
+ invalid_signature_message = "None of the given webhook signatures match the expected signature"
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers=headers,
+ secret=base64.b64encode(b"foo").decode("utf-8"),
+ )
+
+ # multiple signatures
+ invalid_signature = base64.b64encode(b"my_sig").decode("utf-8")
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # different signaature version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature}"},
+ secret=secret,
+ )
+
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # missing version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": signature},
+ secret=secret,
+ )
+
+ # non-string payload
+ with pytest.raises(ValueError, match=r"Webhook body should be a string"):
+ verify(
+ payload=cast(Any, {"payload": payload}),
+ headers=headers,
+ secret=secret,
+ )