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, + )