Skip to content

Commit 1d3be2b

Browse files
authored
Merge pull request #5 from FoxNoseTech/feat/external-id
Add upsert_resource() and external_id support
2 parents cd5ffd5 + d1a1a62 commit 1d3be2b

8 files changed

Lines changed: 453 additions & 2 deletions

File tree

docs/changelog.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.0] - 2026-02-10
11+
12+
### Added
13+
14+
- **`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>`.
15+
- **`external_id`** optional parameter on `create_resource()` — assign an external identifier when creating a resource via `POST`.
16+
- **`external_id`** field on `ResourceSummary` model — populated in API responses for resources that have an external identifier.
17+
1018
## [0.2.0] - 2026-01-26
1119

1220
### Added
@@ -52,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5260
- Error handling guide
5361
- Code examples
5462

55-
[Unreleased]: https://github.com/foxnose/python-sdk/compare/v0.2.0...HEAD
63+
[Unreleased]: https://github.com/foxnose/python-sdk/compare/v0.3.0...HEAD
64+
[0.3.0]: https://github.com/foxnose/python-sdk/compare/v0.2.0...v0.3.0
5665
[0.2.0]: https://github.com/foxnose/python-sdk/compare/v0.1.0...v0.2.0
5766
[0.1.0]: https://github.com/foxnose/python-sdk/releases/tag/v0.1.0

docs/examples.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ revision = client.create_revision(
117117
client.publish_revision("blog-posts", resource.key, revision.key)
118118
```
119119

120+
### Upsert (Create or Update by External ID)
121+
122+
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`.
123+
124+
```python
125+
articles = [
126+
{"id": "ext-1", "title": "First Article", "body": "..."},
127+
{"id": "ext-2", "title": "Second Article", "body": "..."},
128+
]
129+
130+
for article in articles:
131+
resource = client.upsert_resource(
132+
"blog-posts",
133+
{"title": article["title"], "body": article["body"]},
134+
external_id=article["id"],
135+
)
136+
print(f"{resource.key} (external_id={resource.external_id})")
137+
```
138+
139+
You can also set an `external_id` when creating resources via `create_resource`:
140+
141+
```python
142+
resource = client.create_resource(
143+
"blog-posts",
144+
{"title": "Imported Post"},
145+
external_id="legacy-post-99",
146+
)
147+
```
148+
120149
### Folder Schema
121150

122151
**File:** `examples/folder_schema.py`

docs/management-client.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,54 @@ resource = client.create_resource(
165165
)
166166
```
167167

168+
You can assign an `external_id` during creation to identify the resource by your own system's ID:
169+
170+
```python
171+
resource = client.create_resource(
172+
"folder-key",
173+
{"title": "Imported Article", "content": "..."},
174+
external_id="cms-article-42",
175+
)
176+
```
177+
178+
### Upsert Resource
179+
180+
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.
181+
182+
```python
183+
# First call: creates the resource
184+
resource = client.upsert_resource(
185+
"folder-key",
186+
{"title": "My Article", "content": "First version"},
187+
external_id="cms-article-42",
188+
)
189+
190+
# Second call with the same external_id: updates (creates a new revision)
191+
resource = client.upsert_resource(
192+
"folder-key",
193+
{"title": "My Article", "content": "Updated version"},
194+
external_id="cms-article-42",
195+
)
196+
```
197+
198+
For component-based folders, pass the `component` parameter:
199+
200+
```python
201+
resource = client.upsert_resource(
202+
"folder-key",
203+
{"title": "Product", "price": 29.99},
204+
external_id="product-100",
205+
component="product-component",
206+
)
207+
```
208+
209+
| Parameter | Type | Required | Description |
210+
|-----------|------|----------|-------------|
211+
| `folder_key` | `FolderRef` | Yes | Target folder key or object |
212+
| `payload` | `dict` | Yes | JSON payload matching the folder schema |
213+
| `external_id` | `str` | Yes | External identifier for the resource |
214+
| `component` | `ComponentRef` | No | Component key for composite folders |
215+
168216
### Update Resource
169217

170218
```python

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "foxnose-sdk"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "Official Python client for FoxNose Management and Flux APIs"
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}

src/foxnose_sdk/management/client.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,6 +1949,7 @@ def create_resource(
19491949
payload: Mapping[str, Any],
19501950
*,
19511951
component: ComponentRef | None = None,
1952+
external_id: str | None = None,
19521953
) -> ResourceSummary:
19531954
"""
19541955
Create a new resource.
@@ -1957,15 +1958,53 @@ def create_resource(
19571958
folder_key: Target folder key.
19581959
payload: JSON payload that matches the folder/component schema.
19591960
component: Optional component key for component-based folders.
1961+
external_id: Optional external identifier for the resource.
19601962
"""
19611963
folder_key = _resolve_key(folder_key)
19621964
component = _resolve_key(component) if component is not None else None
19631965

19641966
params = {"component": component} if component else None
1967+
body: dict[str, Any] = dict(payload)
1968+
if external_id is not None:
1969+
body["external_id"] = external_id
19651970
data = self.request(
19661971
"POST",
19671972
f"{self._resource_base(folder_key)}/",
19681973
params=params,
1974+
json_body=body,
1975+
)
1976+
return ResourceSummary.model_validate(data)
1977+
1978+
def upsert_resource(
1979+
self,
1980+
folder_key: FolderRef,
1981+
payload: Mapping[str, Any],
1982+
*,
1983+
external_id: str,
1984+
component: ComponentRef | None = None,
1985+
) -> ResourceSummary:
1986+
"""
1987+
Create or update a resource by external_id.
1988+
1989+
If no resource with the given external_id exists in the folder,
1990+
creates a new resource with its first revision (201 Created).
1991+
If a resource is found, creates a new revision for it (200 OK).
1992+
1993+
Args:
1994+
folder_key: Target folder key.
1995+
payload: JSON payload matching the folder/component schema.
1996+
external_id: External identifier for the resource (required).
1997+
component: Optional component key for component-based folders.
1998+
"""
1999+
folder_key = _resolve_key(folder_key)
2000+
component = _resolve_key(component) if component is not None else None
2001+
params: dict[str, str] = {"external_id": external_id}
2002+
if component:
2003+
params["component"] = component
2004+
data = self.request(
2005+
"PUT",
2006+
f"{self._resource_base(folder_key)}/",
2007+
params=params,
19692008
json_body=payload,
19702009
)
19712010
return ResourceSummary.model_validate(data)
@@ -3338,14 +3377,52 @@ async def create_resource(
33383377
payload: Mapping[str, Any],
33393378
*,
33403379
component: ComponentRef | None = None,
3380+
external_id: str | None = None,
33413381
) -> ResourceSummary:
33423382
folder_key = _resolve_key(folder_key)
33433383
component = _resolve_key(component) if component is not None else None
33443384
params = {"component": component} if component else None
3385+
body: dict[str, Any] = dict(payload)
3386+
if external_id is not None:
3387+
body["external_id"] = external_id
33453388
data = await self.request(
33463389
"POST",
33473390
f"{self._resource_base(folder_key)}/",
33483391
params=params,
3392+
json_body=body,
3393+
)
3394+
return ResourceSummary.model_validate(data)
3395+
3396+
async def upsert_resource(
3397+
self,
3398+
folder_key: FolderRef,
3399+
payload: Mapping[str, Any],
3400+
*,
3401+
external_id: str,
3402+
component: ComponentRef | None = None,
3403+
) -> ResourceSummary:
3404+
"""
3405+
Create or update a resource by external_id.
3406+
3407+
If no resource with the given external_id exists in the folder,
3408+
creates a new resource with its first revision (201 Created).
3409+
If a resource is found, creates a new revision for it (200 OK).
3410+
3411+
Args:
3412+
folder_key: Target folder key.
3413+
payload: JSON payload matching the folder/component schema.
3414+
external_id: External identifier for the resource (required).
3415+
component: Optional component key for component-based folders.
3416+
"""
3417+
folder_key = _resolve_key(folder_key)
3418+
component = _resolve_key(component) if component is not None else None
3419+
params: dict[str, str] = {"external_id": external_id}
3420+
if component:
3421+
params["component"] = component
3422+
data = await self.request(
3423+
"PUT",
3424+
f"{self._resource_base(folder_key)}/",
3425+
params=params,
33493426
json_body=payload,
33503427
)
33513428
return ResourceSummary.model_validate(data)

src/foxnose_sdk/management/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class ResourceSummary(BaseModel):
2929
component: str | None = None
3030
resource_owner: str | None = None
3131
current_revision: str | None = None
32+
external_id: str | None = None
3233

3334

3435
class RevisionSummary(BaseModel):

tests/test_async_clients.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"component": None,
7171
"resource_owner": None,
7272
"current_revision": "rev-1",
73+
"external_id": None,
7374
}
7475

7576
REVISION_JSON = {
@@ -1099,6 +1100,107 @@ def handler(request: httpx.Request) -> httpx.Response:
10991100
await client.aclose()
11001101

11011102

1103+
@pytest.mark.asyncio
1104+
async def test_async_create_resource_with_external_id():
1105+
captured: dict[str, Any] = {}
1106+
1107+
def handler(request: httpx.Request) -> httpx.Response:
1108+
captured["url"] = str(request.url)
1109+
captured["body"] = json.loads(request.content.decode())
1110+
resource_json = {**RESOURCE_JSON, "external_id": "ext-1"}
1111+
return httpx.Response(201, json=resource_json)
1112+
1113+
client = build_async_management_client(handler)
1114+
result = await client.create_resource(
1115+
"folder-1", {"data": {"title": "Hello"}}, external_id="ext-1"
1116+
)
1117+
assert result.key == "resource-1"
1118+
assert result.external_id == "ext-1"
1119+
assert captured["body"]["external_id"] == "ext-1"
1120+
assert captured["body"]["data"]["title"] == "Hello"
1121+
# external_id goes in the body, not in query params
1122+
assert "external_id=" not in captured["url"]
1123+
await client.aclose()
1124+
1125+
1126+
@pytest.mark.asyncio
1127+
async def test_async_create_resource_without_external_id_omits_field():
1128+
captured: dict[str, Any] = {}
1129+
1130+
def handler(request: httpx.Request) -> httpx.Response:
1131+
captured["body"] = json.loads(request.content.decode())
1132+
return httpx.Response(201, json=RESOURCE_JSON)
1133+
1134+
client = build_async_management_client(handler)
1135+
await client.create_resource("folder-1", {"data": {"title": "Hello"}})
1136+
assert "external_id" not in captured["body"]
1137+
await client.aclose()
1138+
1139+
1140+
@pytest.mark.asyncio
1141+
async def test_async_create_resource_does_not_mutate_payload():
1142+
def handler(request: httpx.Request) -> httpx.Response:
1143+
return httpx.Response(201, json=RESOURCE_JSON)
1144+
1145+
client = build_async_management_client(handler)
1146+
original = {"data": {"title": "Hello"}}
1147+
await client.create_resource("folder-1", original, external_id="ext-1")
1148+
assert "external_id" not in original
1149+
await client.aclose()
1150+
1151+
1152+
@pytest.mark.asyncio
1153+
async def test_async_upsert_resource_sends_put_with_external_id():
1154+
captured: dict[str, Any] = {}
1155+
1156+
def handler(request: httpx.Request) -> httpx.Response:
1157+
captured["method"] = request.method
1158+
captured["url"] = str(request.url)
1159+
captured["body"] = json.loads(request.content.decode())
1160+
resource_json = {**RESOURCE_JSON, "external_id": "my-ext-id"}
1161+
return httpx.Response(200, json=resource_json)
1162+
1163+
client = build_async_management_client(handler)
1164+
result = await client.upsert_resource(
1165+
"folder-1",
1166+
{"data": {"title": "Upserted"}},
1167+
external_id="my-ext-id",
1168+
)
1169+
assert captured["method"] == "PUT"
1170+
assert captured["url"].endswith("/resources/?external_id=my-ext-id")
1171+
assert captured["body"]["data"]["title"] == "Upserted"
1172+
# upsert sends external_id as query param, not in body
1173+
assert "external_id" not in captured["body"]
1174+
assert result.key == "resource-1"
1175+
assert result.external_id == "my-ext-id"
1176+
await client.aclose()
1177+
1178+
1179+
@pytest.mark.asyncio
1180+
async def test_async_upsert_resource_with_component():
1181+
captured: dict[str, Any] = {}
1182+
1183+
def handler(request: httpx.Request) -> httpx.Response:
1184+
captured["url"] = str(request.url)
1185+
captured["method"] = request.method
1186+
resource_json = {**RESOURCE_JSON, "external_id": "ext-2", "component": "comp-1"}
1187+
return httpx.Response(201, json=resource_json)
1188+
1189+
client = build_async_management_client(handler)
1190+
result = await client.upsert_resource(
1191+
"folder-1",
1192+
{"data": {"title": "New"}},
1193+
external_id="ext-2",
1194+
component="comp-1",
1195+
)
1196+
assert captured["method"] == "PUT"
1197+
assert "external_id=ext-2" in captured["url"]
1198+
assert "component=comp-1" in captured["url"]
1199+
assert result.external_id == "ext-2"
1200+
assert result.component == "comp-1"
1201+
await client.aclose()
1202+
1203+
11021204
@pytest.mark.asyncio
11031205
async def test_async_update_delete_resource_and_get_data():
11041206
captured: list[tuple[str, str]] = []

0 commit comments

Comments
 (0)