Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>`.
- **`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
Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
48 changes: 48 additions & 0 deletions docs/management-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
77 changes: 77 additions & 0 deletions src/foxnose_sdk/management/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/foxnose_sdk/management/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
102 changes: 102 additions & 0 deletions tests/test_async_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"component": None,
"resource_owner": None,
"current_revision": "rev-1",
"external_id": None,
}

REVISION_JSON = {
Expand Down Expand Up @@ -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]] = []
Expand Down
Loading
Loading