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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ jobs:
python-version: "3.11"

- name: Install ruff
run: pip install ruff
# Pinned: unpinned `ruff` resolves to the latest release on every run, so
# a new ruff version (new/changed default rules) can turn a green repo red
# with no code change. Bump deliberately alongside a repo-wide reformat.
run: pip install ruff==0.13.2

- name: Run ruff check
run: ruff check src/
Expand Down
20 changes: 19 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.1] - 2026-07-24

### Added

- `APIInfo` now types three previously-undocumented fields on the Management API
"API" object, with defaults for forward/backward compatibility with older
servers:
- `mcp_enabled: bool` — whether the MCP endpoint is exposed (default `True`)
- `router_introspection_enabled: bool` — whether router introspection is
exposed (default `True`)
- `cors_origins: list[str]` — allowed browser origins for cross-origin reads
(empty = off, `["*"]` = any origin); server-validated and normalized. Covers
public read traffic only — writes still require a key.
Setting these via `create_api` / `update_api` already worked (the payload is
passed through); this only adds the typed fields on the response model.

## [0.7.0] - 2026-07-22

### Added
Expand Down Expand Up @@ -166,7 +182,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Error handling guide
- Code examples

[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...HEAD
[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...HEAD
[0.7.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.2...v0.5.0
[0.4.2]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.1...v0.4.2
Expand Down
19 changes: 19 additions & 0 deletions docs/management-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ def publish_all(client, folder: FolderRef):
# ...
```

## APIs

```python
api = client.create_api(
{
"name": "Storefront",
"prefix": "shop",
"is_auth_required": False,
"cors_origins": ["*"], # browser CORS for public APIs (reads only)
}
)
print(api.key, api.cors_origins)
```

`cors_origins` is a list of allowed browser origins (each including the scheme,
e.g. `https://app.example.com`) or `["*"]` for any origin; an empty list turns
CORS off. `mcp_enabled` and `router_introspection_enabled` (both default `True`)
control the `/{prefix}/_mcp` and `/{prefix}/_router` endpoints.

## API Folder Route Descriptions

When connecting a folder to a Flux API, you can configure per-route descriptions used by Flux `/_router` introspection.
Expand Down
2 changes: 1 addition & 1 deletion src/foxnose_sdk/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``).
"""

__version__ = "0.7.0"
__version__ = "0.7.1"
8 changes: 8 additions & 0 deletions src/foxnose_sdk/management/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,14 @@ class APIInfo(BaseModel):
environment: str
version: str | None = None
is_auth_required: bool
#: Whether the MCP endpoint (``/{prefix}/_mcp``) is exposed. Defaults to True.
mcp_enabled: bool = True
#: Whether router introspection (``/{prefix}/_router``) is exposed. Defaults to True.
router_introspection_enabled: bool = True
#: Allowed browser origins for cross-origin reads. Empty (the default) means
#: CORS is off; ``["*"]`` allows any origin. Server-validated and normalized.
#: Covers public read traffic only — writes still require a key.
cors_origins: list[str] = Field(default_factory=list)
created_at: datetime
path: str | None = None

Expand Down
106 changes: 106 additions & 0 deletions tests/test_async_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,112 @@ def handler(request: httpx.Request) -> httpx.Response:
await client.aclose()


async def test_async_create_api_passes_agent_and_cors_fields_and_parses_response():
captured: dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["path"] = request.url.path
captured["method"] = request.method
captured["body"] = json.loads(request.content.decode())
return httpx.Response(
201,
json={
"key": "api-1",
"environment": "env123",
"created_at": "2026-07-23T00:00:00Z",
**captured["body"],
# A field this SDK version does not model yet (forward-compat):
"some_future_flag": True,
},
)

client = build_async_management_client(handler)
api = await client.create_api(
{
"name": "Storefront",
"prefix": "shop",
"is_auth_required": False,
"mcp_enabled": False,
"router_introspection_enabled": True,
"cors_origins": ["*"],
}
)

assert captured["method"] == "POST"
assert captured["path"] == "/v1/env123/api/"
assert captured["body"]["cors_origins"] == ["*"]
assert captured["body"]["mcp_enabled"] is False
assert captured["body"]["router_introspection_enabled"] is True

assert api.cors_origins == ["*"]
assert api.mcp_enabled is False
assert api.router_introspection_enabled is True
assert not hasattr(api, "some_future_flag")
await client.aclose()


async def test_async_update_api_passes_agent_and_cors_fields():
captured: dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["path"] = request.url.path
captured["method"] = request.method
captured["body"] = json.loads(request.content.decode())
return httpx.Response(
200,
json={
"key": "api-1",
"name": "Storefront",
"prefix": "shop",
"environment": "env123",
"is_auth_required": False,
"created_at": "2026-07-23T00:00:00Z",
**captured["body"],
},
)

client = build_async_management_client(handler)
api = await client.update_api(
"api-1",
{
"mcp_enabled": False,
"router_introspection_enabled": True,
"cors_origins": ["https://app.example.com"],
},
)
assert captured["method"] == "PUT"
assert captured["path"] == "/v1/env123/api/api-1/"
assert captured["body"]["mcp_enabled"] is False
assert captured["body"]["router_introspection_enabled"] is True
assert captured["body"]["cors_origins"] == ["https://app.example.com"]
assert api.mcp_enabled is False
assert api.router_introspection_enabled is True
assert api.cors_origins == ["https://app.example.com"]
await client.aclose()


async def test_async_api_info_defaults_when_server_omits_new_fields():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"key": "api-1",
"name": "Legacy API",
"prefix": "v1",
"environment": "env123",
"is_auth_required": True,
"created_at": "2026-01-01T00:00:00Z",
},
)

client = build_async_management_client(handler)
api = await client.get_api("api-1")
assert api.cors_origins == []
assert api.mcp_enabled is True
assert api.router_introspection_enabled is True
await client.aclose()


# ---------------------------------------------------------------------------
# AsyncFluxClient tests
# ---------------------------------------------------------------------------
Expand Down
113 changes: 112 additions & 1 deletion tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,113 @@ def handler(request: httpx.Request) -> httpx.Response:
assert updated.description_schema == "Read feed schema"


def test_create_api_passes_agent_and_cors_fields_and_parses_response():
captured: dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["path"] = request.url.path
captured["method"] = request.method
captured["body"] = json.loads(request.content.decode())
return httpx.Response(
201,
json={
"key": "api-1",
"environment": "env123",
"created_at": "2026-07-23T00:00:00Z",
**captured["body"],
# A field this SDK version does not model yet (forward-compat):
"some_future_flag": True,
},
)

client = build_management_client(handler)
api = client.create_api(
{
"name": "Storefront",
"prefix": "shop",
"is_auth_required": False,
"mcp_enabled": False,
"router_introspection_enabled": True,
"cors_origins": ["*"],
}
)

# Payload passes through unchanged.
assert captured["method"] == "POST"
assert captured["path"] == "/v1/env123/api/"
assert captured["body"]["cors_origins"] == ["*"]
assert captured["body"]["mcp_enabled"] is False
assert captured["body"]["router_introspection_enabled"] is True

# Response parses the new typed fields, and ignores unknown future fields.
assert api.cors_origins == ["*"]
assert api.mcp_enabled is False
assert api.router_introspection_enabled is True
assert not hasattr(api, "some_future_flag")


def test_update_api_passes_agent_and_cors_fields():
captured: dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["path"] = request.url.path
captured["method"] = request.method
captured["body"] = json.loads(request.content.decode())
return httpx.Response(
200,
json={
"key": "api-1",
"name": "Storefront",
"prefix": "shop",
"environment": "env123",
"is_auth_required": False,
"created_at": "2026-07-23T00:00:00Z",
**captured["body"],
},
)

client = build_management_client(handler)
api = client.update_api(
"api-1",
{
"mcp_enabled": False,
"router_introspection_enabled": True,
"cors_origins": ["https://app.example.com"],
},
)
assert captured["method"] == "PUT"
assert captured["path"] == "/v1/env123/api/api-1/"
assert captured["body"]["mcp_enabled"] is False
assert captured["body"]["router_introspection_enabled"] is True
assert captured["body"]["cors_origins"] == ["https://app.example.com"]
assert api.mcp_enabled is False
assert api.router_introspection_enabled is True
assert api.cors_origins == ["https://app.example.com"]


def test_api_info_defaults_when_server_omits_new_fields():
# Older servers won't send the new fields; the model must still parse with
# sensible defaults (CORS off, agent features on).
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"key": "api-1",
"name": "Legacy API",
"prefix": "v1",
"environment": "env123",
"is_auth_required": True,
"created_at": "2026-01-01T00:00:00Z",
},
)

client = build_management_client(handler)
api = client.get_api("api-1")
assert api.cors_origins == []
assert api.mcp_enabled is True
assert api.router_introspection_enabled is True


def test_flux_client_builds_paths_correctly():
captured: dict[str, Any] = {}

Expand Down Expand Up @@ -1640,7 +1747,11 @@ def test_flux_create_conflict_raises_typed_error():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
409,
json={"message": "dup", "error_code": "external_id_conflict", "detail": None},
json={
"message": "dup",
"error_code": "external_id_conflict",
"detail": None,
},
)

flux = _build_flux_client(handler)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_collection_type_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def test_top_level_package_reexports_collection_types():
def test_version_string_matches_pyproject():
"""Pin the declared package version (single-sourced from _version.py, which
the build backend also reads for the distribution version)."""
assert foxnose_sdk.__version__ == "0.7.0"
assert foxnose_sdk.__version__ == "0.7.1"


def test_user_agent_tracks_version():
Expand Down
Loading