diff --git a/docs/changelog.md b/docs/changelog.md index 343b7d6..a924b1d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-02-10 + +### Added + +- **`upsert_resource()`** method on `ManagementClient` and `AsyncManagementClient` — create or update a resource by `external_id` in a single call. Uses `PUT /folders/:folder/resources/?external_id=`. +- **`external_id`** optional parameter on `create_resource()` — assign an external identifier when creating a resource via `POST`. +- **`external_id`** field on `ResourceSummary` model — populated in API responses for resources that have an external identifier. + ## [0.2.0] - 2026-01-26 ### Added @@ -52,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error handling guide - Code examples -[Unreleased]: https://github.com/foxnose/python-sdk/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/foxnose/python-sdk/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/foxnose/python-sdk/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/foxnose/python-sdk/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/foxnose/python-sdk/releases/tag/v0.1.0 diff --git a/docs/examples.md b/docs/examples.md index 5a5beea..390451b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -119,6 +119,35 @@ revision = client.create_revision( client.publish_revision("blog-posts", resource.key, revision.key) ``` +### Upsert (Create or Update by External ID) + +Use `upsert_resource` to sync content from an external system. The SDK creates the resource on the first call and updates it on subsequent calls, matched by `external_id`. + +```python +articles = [ + {"id": "ext-1", "title": "First Article", "body": "..."}, + {"id": "ext-2", "title": "Second Article", "body": "..."}, +] + +for article in articles: + resource = client.upsert_resource( + "blog-posts", + {"title": article["title"], "body": article["body"]}, + external_id=article["id"], + ) + print(f"{resource.key} (external_id={resource.external_id})") +``` + +You can also set an `external_id` when creating resources via `create_resource`: + +```python +resource = client.create_resource( + "blog-posts", + {"title": "Imported Post"}, + external_id="legacy-post-99", +) +``` + ### Folder Schema **File:** `examples/folder_schema.py` diff --git a/docs/management-client.md b/docs/management-client.md index f5ad6ca..00972d3 100644 --- a/docs/management-client.md +++ b/docs/management-client.md @@ -163,6 +163,54 @@ resource = client.create_resource( ) ``` +You can assign an `external_id` during creation to identify the resource by your own system's ID: + +```python +resource = client.create_resource( + "folder-key", + {"title": "Imported Article", "content": "..."}, + external_id="cms-article-42", +) +``` + +### Upsert Resource + +Create or update a resource in a single call using an `external_id`. If no resource with the given `external_id` exists in the folder, a new resource is created. If one already exists, a new revision is created for it. + +```python +# First call: creates the resource +resource = client.upsert_resource( + "folder-key", + {"title": "My Article", "content": "First version"}, + external_id="cms-article-42", +) + +# Second call with the same external_id: updates (creates a new revision) +resource = client.upsert_resource( + "folder-key", + {"title": "My Article", "content": "Updated version"}, + external_id="cms-article-42", +) +``` + +For component-based folders, pass the `component` parameter: + +```python +resource = client.upsert_resource( + "folder-key", + {"title": "Product", "price": 29.99}, + external_id="product-100", + component="product-component", +) +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `folder_key` | `FolderRef` | Yes | Target folder key or object | +| `payload` | `dict` | Yes | JSON payload matching the folder schema | +| `external_id` | `str` | Yes | External identifier for the resource | +| `component` | `ComponentRef` | No | Component key for composite folders | + ### Update Resource ```python diff --git a/pyproject.toml b/pyproject.toml index b90bf4c..992f419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "foxnose-sdk" -version = "0.2.0" +version = "0.3.0" description = "Official Python client for FoxNose Management and Flux APIs" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/src/foxnose_sdk/management/client.py b/src/foxnose_sdk/management/client.py index 2ea0d2a..99cf841 100644 --- a/src/foxnose_sdk/management/client.py +++ b/src/foxnose_sdk/management/client.py @@ -1949,6 +1949,7 @@ def create_resource( payload: Mapping[str, Any], *, component: ComponentRef | None = None, + external_id: str | None = None, ) -> ResourceSummary: """ Create a new resource. @@ -1957,15 +1958,53 @@ def create_resource( folder_key: Target folder key. payload: JSON payload that matches the folder/component schema. component: Optional component key for component-based folders. + external_id: Optional external identifier for the resource. """ folder_key = _resolve_key(folder_key) component = _resolve_key(component) if component is not None else None params = {"component": component} if component else None + body: dict[str, Any] = dict(payload) + if external_id is not None: + body["external_id"] = external_id data = self.request( "POST", f"{self._resource_base(folder_key)}/", params=params, + json_body=body, + ) + return ResourceSummary.model_validate(data) + + def upsert_resource( + self, + folder_key: FolderRef, + payload: Mapping[str, Any], + *, + external_id: str, + component: ComponentRef | None = None, + ) -> ResourceSummary: + """ + Create or update a resource by external_id. + + If no resource with the given external_id exists in the folder, + creates a new resource with its first revision (201 Created). + If a resource is found, creates a new revision for it (200 OK). + + Args: + folder_key: Target folder key. + payload: JSON payload matching the folder/component schema. + external_id: External identifier for the resource (required). + component: Optional component key for component-based folders. + """ + folder_key = _resolve_key(folder_key) + component = _resolve_key(component) if component is not None else None + params: dict[str, str] = {"external_id": external_id} + if component: + params["component"] = component + data = self.request( + "PUT", + f"{self._resource_base(folder_key)}/", + params=params, json_body=payload, ) return ResourceSummary.model_validate(data) @@ -3338,14 +3377,52 @@ async def create_resource( payload: Mapping[str, Any], *, component: ComponentRef | None = None, + external_id: str | None = None, ) -> ResourceSummary: folder_key = _resolve_key(folder_key) component = _resolve_key(component) if component is not None else None params = {"component": component} if component else None + body: dict[str, Any] = dict(payload) + if external_id is not None: + body["external_id"] = external_id data = await self.request( "POST", f"{self._resource_base(folder_key)}/", params=params, + json_body=body, + ) + return ResourceSummary.model_validate(data) + + async def upsert_resource( + self, + folder_key: FolderRef, + payload: Mapping[str, Any], + *, + external_id: str, + component: ComponentRef | None = None, + ) -> ResourceSummary: + """ + Create or update a resource by external_id. + + If no resource with the given external_id exists in the folder, + creates a new resource with its first revision (201 Created). + If a resource is found, creates a new revision for it (200 OK). + + Args: + folder_key: Target folder key. + payload: JSON payload matching the folder/component schema. + external_id: External identifier for the resource (required). + component: Optional component key for component-based folders. + """ + folder_key = _resolve_key(folder_key) + component = _resolve_key(component) if component is not None else None + params: dict[str, str] = {"external_id": external_id} + if component: + params["component"] = component + data = await self.request( + "PUT", + f"{self._resource_base(folder_key)}/", + params=params, json_body=payload, ) return ResourceSummary.model_validate(data) diff --git a/src/foxnose_sdk/management/models.py b/src/foxnose_sdk/management/models.py index 5cb89c0..c889173 100644 --- a/src/foxnose_sdk/management/models.py +++ b/src/foxnose_sdk/management/models.py @@ -29,6 +29,7 @@ class ResourceSummary(BaseModel): component: str | None = None resource_owner: str | None = None current_revision: str | None = None + external_id: str | None = None class RevisionSummary(BaseModel): diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index 1e3a0b5..7ff6b1a 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -70,6 +70,7 @@ "component": None, "resource_owner": None, "current_revision": "rev-1", + "external_id": None, } REVISION_JSON = { @@ -1099,6 +1100,107 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() +@pytest.mark.asyncio +async def test_async_create_resource_with_external_id(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "ext-1"} + return httpx.Response(201, json=resource_json) + + client = build_async_management_client(handler) + result = await client.create_resource( + "folder-1", {"data": {"title": "Hello"}}, external_id="ext-1" + ) + assert result.key == "resource-1" + assert result.external_id == "ext-1" + assert captured["body"]["external_id"] == "ext-1" + assert captured["body"]["data"]["title"] == "Hello" + # external_id goes in the body, not in query params + assert "external_id=" not in captured["url"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_create_resource_without_external_id_omits_field(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=RESOURCE_JSON) + + client = build_async_management_client(handler) + await client.create_resource("folder-1", {"data": {"title": "Hello"}}) + assert "external_id" not in captured["body"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_create_resource_does_not_mutate_payload(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=RESOURCE_JSON) + + client = build_async_management_client(handler) + original = {"data": {"title": "Hello"}} + await client.create_resource("folder-1", original, external_id="ext-1") + assert "external_id" not in original + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_upsert_resource_sends_put_with_external_id(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "my-ext-id"} + return httpx.Response(200, json=resource_json) + + client = build_async_management_client(handler) + result = await client.upsert_resource( + "folder-1", + {"data": {"title": "Upserted"}}, + external_id="my-ext-id", + ) + assert captured["method"] == "PUT" + assert captured["url"].endswith("/resources/?external_id=my-ext-id") + assert captured["body"]["data"]["title"] == "Upserted" + # upsert sends external_id as query param, not in body + assert "external_id" not in captured["body"] + assert result.key == "resource-1" + assert result.external_id == "my-ext-id" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_upsert_resource_with_component(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + resource_json = {**RESOURCE_JSON, "external_id": "ext-2", "component": "comp-1"} + return httpx.Response(201, json=resource_json) + + client = build_async_management_client(handler) + result = await client.upsert_resource( + "folder-1", + {"data": {"title": "New"}}, + external_id="ext-2", + component="comp-1", + ) + assert captured["method"] == "PUT" + assert "external_id=ext-2" in captured["url"] + assert "component=comp-1" in captured["url"] + assert result.external_id == "ext-2" + assert result.component == "comp-1" + await client.aclose() + + @pytest.mark.asyncio async def test_async_update_delete_resource_and_get_data(): captured: list[tuple[str, str]] = [] diff --git a/tests/test_clients.py b/tests/test_clients.py index 17715f3..0c01ce4 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -79,6 +79,7 @@ "component": None, "resource_owner": None, "current_revision": "rev-1", + "external_id": None, } COMPONENT_JSON = { @@ -928,6 +929,190 @@ def handler(request: httpx.Request) -> httpx.Response: assert captured["body"]["data"]["title"] == "Hello" +def test_create_resource_with_external_id(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "ext-1"} + return httpx.Response(201, json=resource_json) + + client = build_management_client(handler) + result = client.create_resource( + "folder-1", {"data": {"title": "Hello"}}, external_id="ext-1" + ) + assert result.key == "resource-1" + assert result.external_id == "ext-1" + assert captured["body"]["external_id"] == "ext-1" + assert captured["body"]["data"]["title"] == "Hello" + # external_id goes in the body, not in query params + assert "external_id=" not in captured["url"] + + +def test_create_resource_with_component_and_external_id(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "ext-1", "component": "comp-1"} + return httpx.Response(201, json=resource_json) + + client = build_management_client(handler) + result = client.create_resource( + "folder-1", + {"data": {"title": "Hello"}}, + component="comp-1", + external_id="ext-1", + ) + assert result.key == "resource-1" + assert "component=comp-1" in captured["url"] + assert captured["body"]["external_id"] == "ext-1" + + +def test_upsert_resource_sends_put_with_external_id(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "my-ext-id"} + return httpx.Response(200, json=resource_json) + + client = build_management_client(handler) + result = client.upsert_resource( + "folder-1", + {"data": {"title": "Upserted"}}, + external_id="my-ext-id", + ) + assert captured["method"] == "PUT" + assert captured["url"].endswith("/resources/?external_id=my-ext-id") + assert captured["body"]["data"]["title"] == "Upserted" + assert result.key == "resource-1" + assert result.external_id == "my-ext-id" + + +def test_upsert_resource_with_component(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + resource_json = {**RESOURCE_JSON, "external_id": "ext-2", "component": "comp-1"} + return httpx.Response(201, json=resource_json) + + client = build_management_client(handler) + result = client.upsert_resource( + "folder-1", + {"data": {"title": "New"}}, + external_id="ext-2", + component="comp-1", + ) + assert captured["method"] == "PUT" + assert "external_id=ext-2" in captured["url"] + assert "component=comp-1" in captured["url"] + assert result.external_id == "ext-2" + assert result.component == "comp-1" + + +def test_create_resource_without_external_id_omits_field_from_body(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=RESOURCE_JSON) + + client = build_management_client(handler) + client.create_resource("folder-1", {"data": {"title": "Hello"}}) + assert "external_id" not in captured["body"] + + +def test_create_resource_does_not_mutate_original_payload(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=RESOURCE_JSON) + + client = build_management_client(handler) + original = {"data": {"title": "Hello"}} + client.create_resource("folder-1", original, external_id="ext-1") + # original dict must not be modified + assert "external_id" not in original + + +def test_create_resource_kwarg_overrides_payload_external_id(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + resource_json = {**RESOURCE_JSON, "external_id": "from-kwarg"} + return httpx.Response(201, json=resource_json) + + client = build_management_client(handler) + result = client.create_resource( + "folder-1", + {"data": {"title": "Hello"}, "external_id": "from-body"}, + external_id="from-kwarg", + ) + assert captured["body"]["external_id"] == "from-kwarg" + assert result.external_id == "from-kwarg" + + +def test_upsert_resource_does_not_put_external_id_in_body(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + captured["url"] = str(request.url) + resource_json = {**RESOURCE_JSON, "external_id": "ext-1"} + return httpx.Response(200, json=resource_json) + + client = build_management_client(handler) + client.upsert_resource( + "folder-1", + {"data": {"title": "Hello"}}, + external_id="ext-1", + ) + # upsert sends external_id as query param, not in body + assert "external_id" not in captured["body"] + assert "external_id=ext-1" in captured["url"] + + +def test_upsert_resource_accepts_folder_object(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + resource_json = {**RESOURCE_JSON, "external_id": "ext-1"} + return httpx.Response(200, json=resource_json) + + client = build_management_client(handler) + folder = FolderSummary.model_validate(FOLDER_JSON) + result = client.upsert_resource( + folder, + {"data": {"title": "Via object"}}, + external_id="ext-1", + ) + assert "folder-1" in captured["url"] + assert result.external_id == "ext-1" + + +def test_resource_summary_parses_external_id(): + resource_with_ext = {**RESOURCE_JSON, "external_id": "my-ext"} + summary = ResourceSummary.model_validate(resource_with_ext) + assert summary.external_id == "my-ext" + + summary_without = ResourceSummary.model_validate(RESOURCE_JSON) + assert summary_without.external_id is None + + +def test_resource_summary_parses_without_external_id_field(): + """Backward compatibility: API responses without external_id field parse OK.""" + raw = {k: v for k, v in RESOURCE_JSON.items() if k != "external_id"} + summary = ResourceSummary.model_validate(raw) + assert summary.external_id is None + + def test_publish_revision_uses_nested_path(): captured = {}