From 0d2358f6b66ef6044937cc2a2fa983a02ffe900c Mon Sep 17 00:00:00 2001 From: courier-codegen Date: Thu, 29 Oct 2026 19:32:15 +0000 Subject: [PATCH] feat(api)!: PATCH /providers/{id} takes JSON Patch operations [C-22022] --- src/courier/resources/providers/providers.py | 84 +++++---------- src/courier/types/provider_patch_params.py | 51 +++++---- tests/api_resources/test_providers.py | 104 ++++++++++++++----- 3 files changed, 138 insertions(+), 101 deletions(-) diff --git a/src/courier/resources/providers/providers.py b/src/courier/resources/providers/providers.py index b359dd4..b02a657 100644 --- a/src/courier/resources/providers/providers.py +++ b/src/courier/resources/providers/providers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, Optional +from typing import Dict, Iterable import httpx @@ -317,10 +317,7 @@ def patch( self, id: str, *, - alias: Optional[str] | Omit = omit, - provider: str | Omit = omit, - settings: Dict[str, object] | Omit = omit, - title: str | Omit = omit, + patch: Iterable[provider_patch_params.Patch], # 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, @@ -329,23 +326,19 @@ def patch( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Provider: """ - Updates part of a provider's configuration and keeps every field you omit. - Settings merge into the stored settings key by key, and a setting sent as null - is removed. + Applies JSON Patch (RFC 6902) operations to a provider configuration and keeps + every field the operations don't touch. Paths address the provider as it is + returned: /title, /alias and /settings/. The operations apply in order, and + if one fails, nothing is saved. Args: - alias: Updated alias. Set to null to clear it. - - provider: The provider key identifying the type. Optional. When sent, it must match the - stored provider, otherwise the request returns a 400. - - settings: Provider-specific settings (snake_case keys) to merge into the stored settings, - using JSON Merge Patch (RFC 7396). Each key you send replaces the stored value, - a key set to null is removed, nested objects merge recursively, and arrays - replace the stored value. Keys you omit keep their stored values. Set - `rate_limit` to null to remove this configuration's send rate limit. - - title: Updated display title. + patch: + Operations to apply, in order. Paths address the provider as it is returned: + `/title`, `/alias`, `/provider`, and `/settings/` with the snake_case + setting keys, including nested paths such as `/settings/rate_limit/rate`. If any + operation fails, for example a `test`, nothing is saved and the request returns + a 400. The result can't change `provider`, remove `title`, or add other + top-level fields. Removing `/alias` clears it. extra_headers: Send extra headers @@ -359,15 +352,7 @@ def patch( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._patch( path_template("/providers/{id}", id=id), - body=maybe_transform( - { - "alias": alias, - "provider": provider, - "settings": settings, - "title": title, - }, - provider_patch_params.ProviderPatchParams, - ), + body=maybe_transform({"patch": patch}, provider_patch_params.ProviderPatchParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -660,10 +645,7 @@ async def patch( self, id: str, *, - alias: Optional[str] | Omit = omit, - provider: str | Omit = omit, - settings: Dict[str, object] | Omit = omit, - title: str | Omit = omit, + patch: Iterable[provider_patch_params.Patch], # 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, @@ -672,23 +654,19 @@ async def patch( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Provider: """ - Updates part of a provider's configuration and keeps every field you omit. - Settings merge into the stored settings key by key, and a setting sent as null - is removed. + Applies JSON Patch (RFC 6902) operations to a provider configuration and keeps + every field the operations don't touch. Paths address the provider as it is + returned: /title, /alias and /settings/. The operations apply in order, and + if one fails, nothing is saved. Args: - alias: Updated alias. Set to null to clear it. - - provider: The provider key identifying the type. Optional. When sent, it must match the - stored provider, otherwise the request returns a 400. - - settings: Provider-specific settings (snake_case keys) to merge into the stored settings, - using JSON Merge Patch (RFC 7396). Each key you send replaces the stored value, - a key set to null is removed, nested objects merge recursively, and arrays - replace the stored value. Keys you omit keep their stored values. Set - `rate_limit` to null to remove this configuration's send rate limit. - - title: Updated display title. + patch: + Operations to apply, in order. Paths address the provider as it is returned: + `/title`, `/alias`, `/provider`, and `/settings/` with the snake_case + setting keys, including nested paths such as `/settings/rate_limit/rate`. If any + operation fails, for example a `test`, nothing is saved and the request returns + a 400. The result can't change `provider`, remove `title`, or add other + top-level fields. Removing `/alias` clears it. extra_headers: Send extra headers @@ -702,15 +680,7 @@ async def patch( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( path_template("/providers/{id}", id=id), - body=await async_maybe_transform( - { - "alias": alias, - "provider": provider, - "settings": settings, - "title": title, - }, - provider_patch_params.ProviderPatchParams, - ), + body=await async_maybe_transform({"patch": patch}, provider_patch_params.ProviderPatchParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/courier/types/provider_patch_params.py b/src/courier/types/provider_patch_params.py index aa60d80..e9c8674 100644 --- a/src/courier/types/provider_patch_params.py +++ b/src/courier/types/provider_patch_params.py @@ -2,31 +2,42 @@ from __future__ import annotations -from typing import Dict, Optional -from typing_extensions import TypedDict +from typing import Iterable +from typing_extensions import Literal, Required, TypedDict -__all__ = ["ProviderPatchParams"] +__all__ = ["ProviderPatchParams", "Patch"] class ProviderPatchParams(TypedDict, total=False): - alias: Optional[str] - """Updated alias. Set to null to clear it.""" + patch: Required[Iterable[Patch]] + """Operations to apply, in order. + + Paths address the provider as it is returned: `/title`, `/alias`, `/provider`, + and `/settings/` with the snake_case setting keys, including nested paths + such as `/settings/rate_limit/rate`. If any operation fails, for example a + `test`, nothing is saved and the request returns a 400. The result can't change + `provider`, remove `title`, or add other top-level fields. Removing `/alias` + clears it. + """ - provider: str - """The provider key identifying the type. - Optional. When sent, it must match the stored provider, otherwise the request - returns a 400. - """ +_PatchReservedKeywords = TypedDict( + "_PatchReservedKeywords", + { + "from": str, + }, + total=False, +) - settings: Dict[str, object] - """ - Provider-specific settings (snake_case keys) to merge into the stored settings, - using JSON Merge Patch (RFC 7396). Each key you send replaces the stored value, - a key set to null is removed, nested objects merge recursively, and arrays - replace the stored value. Keys you omit keep their stored values. Set - `rate_limit` to null to remove this configuration's send rate limit. - """ - title: str - """Updated display title.""" +class Patch(_PatchReservedKeywords, total=False): + """One JSON Patch (RFC 6902) operation.""" + + op: Required[Literal["add", "remove", "replace", "move", "copy", "test"]] + """The operation to perform.""" + + path: Required[str] + """JSON Pointer to the field to operate on, such as `/settings/api_key`.""" + + value: object + """The value for `add`, `replace` and `test`. Any JSON value.""" diff --git a/tests/api_resources/test_providers.py b/tests/api_resources/test_providers.py index 02b8517..cbebea5 100644 --- a/tests/api_resources/test_providers.py +++ b/tests/api_resources/test_providers.py @@ -250,18 +250,16 @@ def test_path_params_delete(self, client: Courier) -> None: def test_method_patch(self, client: Courier) -> None: provider = client.providers.patch( id="id", - ) - assert_matches_type(Provider, provider, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - def test_method_patch_with_all_params(self, client: Courier) -> None: - provider = client.providers.patch( - id="id", - alias="alias", - provider="provider", - settings={"api_key": "bar"}, - title="Production SendGrid", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) assert_matches_type(Provider, provider, path=["response"]) @@ -270,6 +268,16 @@ def test_method_patch_with_all_params(self, client: Courier) -> None: def test_raw_response_patch(self, client: Courier) -> None: response = client.providers.with_raw_response.patch( id="id", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) assert response.is_closed is True @@ -282,6 +290,16 @@ def test_raw_response_patch(self, client: Courier) -> None: def test_streaming_response_patch(self, client: Courier) -> None: with client.providers.with_streaming_response.patch( id="id", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -297,6 +315,16 @@ def test_path_params_patch(self, client: Courier) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): client.providers.with_raw_response.patch( id="", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) @@ -535,18 +563,16 @@ async def test_path_params_delete(self, async_client: AsyncCourier) -> None: async def test_method_patch(self, async_client: AsyncCourier) -> None: provider = await async_client.providers.patch( id="id", - ) - assert_matches_type(Provider, provider, path=["response"]) - - @pytest.mark.skip(reason="Mock server tests are disabled") - @parametrize - async def test_method_patch_with_all_params(self, async_client: AsyncCourier) -> None: - provider = await async_client.providers.patch( - id="id", - alias="alias", - provider="provider", - settings={"api_key": "bar"}, - title="Production SendGrid", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) assert_matches_type(Provider, provider, path=["response"]) @@ -555,6 +581,16 @@ async def test_method_patch_with_all_params(self, async_client: AsyncCourier) -> async def test_raw_response_patch(self, async_client: AsyncCourier) -> None: response = await async_client.providers.with_raw_response.patch( id="id", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) assert response.is_closed is True @@ -567,6 +603,16 @@ async def test_raw_response_patch(self, async_client: AsyncCourier) -> None: async def test_streaming_response_patch(self, async_client: AsyncCourier) -> None: async with async_client.providers.with_streaming_response.patch( id="id", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -582,4 +628,14 @@ async def test_path_params_patch(self, async_client: AsyncCourier) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): await async_client.providers.with_raw_response.patch( id="", + patch=[ + { + "op": "replace", + "path": "/title", + }, + { + "op": "replace", + "path": "/settings/api_key", + }, + ], )