From 9217ae81c0deeab97b04e26a6577f53f6a1325c0 Mon Sep 17 00:00:00 2001 From: loookashow Date: Fri, 24 Jul 2026 16:34:03 +0200 Subject: [PATCH 1/2] feat(management): type API agent-feature + cors_origins fields; release 0.7.1 --- docs/changelog.md | 20 ++++- docs/management-client.md | 19 +++++ src/foxnose_sdk/_version.py | 2 +- src/foxnose_sdk/management/models.py | 8 ++ tests/test_async_clients.py | 106 ++++++++++++++++++++++++ tests/test_clients.py | 113 +++++++++++++++++++++++++- tests/test_collection_type_aliases.py | 2 +- 7 files changed, 266 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index fadbcbd..705c9df 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 @@ -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 diff --git a/docs/management-client.md b/docs/management-client.md index f8a22cd..837566a 100644 --- a/docs/management-client.md +++ b/docs/management-client.md @@ -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. diff --git a/src/foxnose_sdk/_version.py b/src/foxnose_sdk/_version.py index 7fea4b7..18fcd79 100644 --- a/src/foxnose_sdk/_version.py +++ b/src/foxnose_sdk/_version.py @@ -4,4 +4,4 @@ by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``). """ -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/src/foxnose_sdk/management/models.py b/src/foxnose_sdk/management/models.py index 758fc05..5799d60 100644 --- a/src/foxnose_sdk/management/models.py +++ b/src/foxnose_sdk/management/models.py @@ -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 diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index 71f38bb..a087755 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_clients.py b/tests/test_clients.py index ba23459..457783b 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -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] = {} @@ -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) diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py index 95b919f..efc5954 100644 --- a/tests/test_collection_type_aliases.py +++ b/tests/test_collection_type_aliases.py @@ -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(): From ba83cb01f56a4ef69163cb443c69e36bf980bd99 Mon Sep 17 00:00:00 2001 From: loookashow Date: Fri, 24 Jul 2026 17:01:07 +0200 Subject: [PATCH 2/2] ci: pin ruff to 0.13.2 --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a6f711..7dd7373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/