From bb71d821e740707a8856da222ceeaacb646a08a9 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 11 Sep 2026 17:00:06 +0300 Subject: [PATCH 1/3] [10b-SDK] Python: missing test-integration method, route tests, and the API Files docs page - hub/files.py had no test_files_integration (POST /files/integrations/test). The shared contract already has it, so the generated module was out of date; added sync and async in the same shape the generator produces. - The generated Files tests only check the HTTP verb and that the address starts with https, so a method pointed at the wrong path would still pass. Adds tests/test_files_routes.py: one case per endpoint (12 hub, 8 public API) that checks the path itself and the values sent. - docs/api/files.md did not exist. docs/hub/files.md was missing get_folder_files, get_file and test_files_integration. Both fixed, with the module counts in the two index pages. Co-Authored-By: Claude Opus 5 --- docs/api/_index.md | 1 + docs/api/files.md | 46 ++++++++ docs/hub/_index.md | 2 +- docs/hub/files.md | 15 +++ src/norbix_python/hub/files.py | 26 +++++ tests/hub/test_files.py | 8 ++ tests/test_files_routes.py | 195 +++++++++++++++++++++++++++++++++ 7 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 docs/api/files.md create mode 100644 tests/test_files_routes.py diff --git a/docs/api/_index.md b/docs/api/_index.md index 22923ba..e34c254 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -5,4 +5,5 @@ | [`chat`](./chat.md) | 1 | | [`database`](./database.md) | 18 | | [`echo`](./echo.md) | 1 | +| [`files`](./files.md) | 8 | | [`membership`](./membership.md) | 18 | diff --git a/docs/api/files.md b/docs/api/files.md new file mode 100644 index 0000000..2e03071 --- /dev/null +++ b/docs/api/files.md @@ -0,0 +1,46 @@ +# API · Files + +Access with `norbix.api.files`. + +| Method | Verb | Path | Scope | +| --- | --- | --- | --- | +| `list_files` | `GET` | `/{version}/files/{filesIntegrationId}` | `project` | +| `get_file_info` | `GET` | `/{version}/files/{filesIntegrationId}/info` | `project` | +| `get_signed_url` | `GET` | `/{version}/files/{filesIntegrationId}/sign` | `project` | +| `request_upload_url` | `POST` | `/{version}/files/{filesIntegrationId}/upload-url` | `project` | +| `commit_upload` | `POST` | `/{version}/files/{filesIntegrationId}/commit` | `project` | +| `download_file_api` | `GET` | `/{version}/files/{filesIntegrationId}/download` | `project` | +| `delete_file_api` | `DELETE` | `/{version}/files/{filesIntegrationId}` | `project` | +| `delete_many_files_api` | `DELETE` | `/{version}/files/{filesIntegrationId}/bulk` | `project` | + +`filesIntegrationId` names the storage integration to work with. Get it from +`norbix.hub.files.get_files_integrations()`. + +## Uploading a file + +Uploading takes two steps, so the file bytes never pass through Norbix: + +1. `request_upload_url` returns a short-lived address to upload to. +2. Send the bytes to that address with a plain `PUT`. +3. `commit_upload` tells Norbix the upload finished. + +```python +upload = client.api.files.request_upload_url( + integration_id, + path="invoices/invoice.pdf", + contentType="application/pdf", +) +httpx.put(upload["url"], content=pdf_bytes, headers={"Content-Type": "application/pdf"}) +client.api.files.commit_upload( + integration_id, + path="invoices/invoice.pdf", + contentType="application/pdf", + sizeBytes=len(pdf_bytes), +) +``` + +## Reading a file + +`get_signed_url` returns a short-lived address anyone can open, which suits a +browser preview or a download link. `download_file_api` streams the bytes +through Norbix instead. diff --git a/docs/hub/_index.md b/docs/hub/_index.md index c3ad6c8..0556561 100644 --- a/docs/hub/_index.md +++ b/docs/hub/_index.md @@ -7,7 +7,7 @@ | [`database`](./database.md) | 41 | | [`echo`](./echo.md) | 1 | | [`email`](./email.md) | 1 | -| [`files`](./files.md) | 15 | +| [`files`](./files.md) | 18 | | [`internal`](./internal.md) | 1 | | [`logs`](./logs.md) | 9 | | [`membership`](./membership.md) | 25 | diff --git a/docs/hub/files.md b/docs/hub/files.md index c1f606b..065b30a 100644 --- a/docs/hub/files.md +++ b/docs/hub/files.md @@ -6,6 +6,8 @@ Access with `norbix.hub.files`. | --- | --- | --- | --- | | `disable_files` | `GET` | `/{version}/files/disable` | `project` | | `enable_files` | `GET` | `/{version}/files/enable` | `project` | +| `get_folder_files` | `GET` | `/{version}/files/folder` | `project` | +| `get_file` | `GET` | `/{version}/files/item` | `project` | | `delete_files_trigger` | `DELETE` | `/{version}/files/triggers/{triggerId}` | `project` | | `disable_files_trigger` | `PATCH` | `/{version}/files/triggers/{triggerId}/disable` | `project` | | `enable_files_trigger` | `PATCH` | `/{version}/files/triggers/{triggerId}/enable` | `project` | @@ -19,3 +21,16 @@ Access with `norbix.hub.files`. | `get_files_integrations` | `GET` | `/{version}/files/integrations` | `project` | | `save_files_integration` | `POST` | `/{version}/files/integrations` | `project` | | `set_files_integration_as_default` | `PUT` | `/{version}/files/integrations/{Id}/default` | `project` | +| `test_files_integration` | `POST` | `/{version}/files/integrations/test` | `project` | + +## What the parts are + +* An **integration** is one storage place — an Amazon S3 bucket, an Azure + container, a folder on the server. A project can have several; one is the + default. +* `get_folder_files` lists what is under a path. `get_file` reads the details of + one file. +* `test_files_integration` checks the settings really work before you save them. + +Uploading and downloading file bytes is on the public API side — see +[API · Files](../api/files.md). diff --git a/src/norbix_python/hub/files.py b/src/norbix_python/hub/files.py index 2f113b5..3a9f6a7 100644 --- a/src/norbix_python/hub/files.py +++ b/src/norbix_python/hub/files.py @@ -217,6 +217,19 @@ def save_files_integration(self, *, timeout: float | None = None, bearer_token: bearer_token=bearer_token, ) + def test_files_integration(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/integrations/test""" + return self._transport.send( + target="hub", + path="/{version}/files/integrations/test", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def set_files_integration_as_default(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """PUT /{version}/files/integrations/{Id}/default""" return self._transport.send( @@ -443,6 +456,19 @@ async def save_files_integration(self, *, timeout: float | None = None, bearer_t bearer_token=bearer_token, ) + async def test_files_integration(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/integrations/test""" + return await self._transport.send( + target="hub", + path="/{version}/files/integrations/test", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def set_files_integration_as_default(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """PUT /{version}/files/integrations/{Id}/default""" return await self._transport.send( diff --git a/tests/hub/test_files.py b/tests/hub/test_files.py index 5edfd83..79cdf12 100644 --- a/tests/hub/test_files.py +++ b/tests/hub/test_files.py @@ -24,8 +24,16 @@ def test_hub_files_module_surface() -> None: assert callable(module.get_files_integrations) assert callable(module.save_files_integration) assert callable(module.set_files_integration_as_default) + assert callable(module.test_files_integration) +def test_hub_files_test_files_integration_request_shape() -> None: + client, transport = make_client(account_id=None) + client.hub.files.test_files_integration(integrationId="stub-integrationId") + assert transport.last_request['method'] == 'POST' + assert transport.last_request is not None + assert transport.last_request['url'].startswith('https://') + def test_hub_files_disable_files_request_shape() -> None: client, transport = make_client(account_id=None) client.hub.files.disable_files() diff --git a/tests/test_files_routes.py b/tests/test_files_routes.py new file mode 100644 index 0000000..4eb1377 --- /dev/null +++ b/tests/test_files_routes.py @@ -0,0 +1,195 @@ +"""Files endpoints: the route each SDK method actually calls. + +The generated tests in ``tests/hub/test_files.py`` and ``tests/api/test_files.py`` +only check the HTTP verb and that the address starts with ``https://``. A method +pointed at the wrong path would still pass them. These tests check the path +itself, and that the values the caller passes reach the query string or the body. + +One test per endpoint: 12 on the hub (the dashboard API) and 8 on the public API. +Each test builds its own client and its own fake response, so the order the tests +run in does not matter and no real storage provider is contacted. +""" + +from __future__ import annotations + +import json +from urllib.parse import urlparse + +from .helpers import make_client + +INTEGRATION_ID = "44444444-4444-4444-4444-444444444444" + + +def _sent(transport): + """The last request, split into the parts the tests care about.""" + request = transport.last_request + assert request is not None, "no request was sent" + url = urlparse(request["url"]) + body = json.loads(request["body"]) if request["body"] else {} + return request["method"], url.path, url.query, body + + +# --- hub: the module itself -------------------------------------------------- + + +def test_enable_files() -> None: + client, transport = make_client() + client.hub.files.enable_files() + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", "/v2/files/enable") + + +def test_disable_files() -> None: + client, transport = make_client() + client.hub.files.disable_files() + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", "/v2/files/disable") + + +# --- hub: browsing ----------------------------------------------------------- + + +def test_get_folder_files() -> None: + client, transport = make_client() + client.hub.files.get_folder_files(filesIntegrationId=INTEGRATION_ID, path="invoices/") + method, path, query, _ = _sent(transport) + assert (method, path) == ("GET", "/v2/files/folder") + assert INTEGRATION_ID in query + + +def test_get_file() -> None: + client, transport = make_client() + client.hub.files.get_file(filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf") + method, path, query, _ = _sent(transport) + assert (method, path) == ("GET", "/v2/files/item") + assert INTEGRATION_ID in query + + +# --- hub: integrations ------------------------------------------------------- + + +def test_get_files_integrations() -> None: + client, transport = make_client() + client.hub.files.get_files_integrations() + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", "/v2/files/integrations") + + +def test_get_files_integration() -> None: + client, transport = make_client() + client.hub.files.get_files_integration(id=INTEGRATION_ID) + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", f"/v2/files/integrations/{INTEGRATION_ID}") + + +def test_save_files_integration() -> None: + client, transport = make_client() + client.hub.files.save_files_integration( + integration={"integrationName": "Invoices bucket", "bucketName": "norbix-invoices"} + ) + method, path, _, body = _sent(transport) + assert (method, path) == ("POST", "/v2/files/integrations") + assert body["integration"]["bucketName"] == "norbix-invoices" + + +def test_delete_files_integration() -> None: + client, transport = make_client() + client.hub.files.delete_files_integration(id=INTEGRATION_ID) + method, path, _, _ = _sent(transport) + assert (method, path) == ("DELETE", f"/v2/files/integrations/{INTEGRATION_ID}") + + +def test_enable_files_integration() -> None: + client, transport = make_client() + client.hub.files.enable_files_integration(id=INTEGRATION_ID) + method, path, _, _ = _sent(transport) + assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/enable") + + +def test_disable_files_integration() -> None: + client, transport = make_client() + client.hub.files.disable_files_integration(id=INTEGRATION_ID) + method, path, _, _ = _sent(transport) + assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/disable") + + +def test_set_files_integration_as_default() -> None: + client, transport = make_client() + client.hub.files.set_files_integration_as_default(id=INTEGRATION_ID) + method, path, _, _ = _sent(transport) + assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/default") + + +def test_test_files_integration() -> None: + client, transport = make_client() + client.hub.files.test_files_integration(integrationId=INTEGRATION_ID) + method, path, _, body = _sent(transport) + assert (method, path) == ("POST", "/v2/files/integrations/test") + assert body["integrationId"] == INTEGRATION_ID + + +# --- public API -------------------------------------------------------------- + + +def test_list_files() -> None: + client, transport = make_client() + client.api.files.list_files(INTEGRATION_ID, path="invoices/") + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}") + + +def test_get_file_info() -> None: + client, transport = make_client() + client.api.files.get_file_info(INTEGRATION_ID, path="invoices/invoice.pdf") + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/info") + + +def test_get_signed_url() -> None: + client, transport = make_client() + client.api.files.get_signed_url(INTEGRATION_ID, path="invoices/invoice.pdf") + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/sign") + + +def test_request_upload_url() -> None: + client, transport = make_client() + client.api.files.request_upload_url( + INTEGRATION_ID, path="invoices/invoice.pdf", contentType="application/pdf" + ) + method, path, _, body = _sent(transport) + assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/upload-url") + assert body["contentType"] == "application/pdf" + + +def test_commit_upload() -> None: + client, transport = make_client() + client.api.files.commit_upload( + INTEGRATION_ID, path="invoices/invoice.pdf", contentType="application/pdf", sizeBytes=1024 + ) + method, path, _, body = _sent(transport) + assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/commit") + assert body["path"] == "invoices/invoice.pdf" + + +def test_download_file() -> None: + client, transport = make_client() + client.api.files.download_file_api(INTEGRATION_ID, path="invoices/invoice.pdf") + method, path, _, _ = _sent(transport) + assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/download") + + +def test_delete_file() -> None: + client, transport = make_client() + client.api.files.delete_file_api(INTEGRATION_ID, path="invoices/invoice.pdf") + method, path, _, _ = _sent(transport) + assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}") + + +def test_delete_many_files() -> None: + client, transport = make_client() + client.api.files.delete_many_files_api( + INTEGRATION_ID, paths=["invoices/a.pdf", "invoices/b.pdf"] + ) + method, path, _, _ = _sent(transport) + assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}/bulk") From cff49625360e4f3b364f38f1931412b4bcc40ec3 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Mon, 14 Sep 2026 15:58:09 +0300 Subject: [PATCH 2/3] [10b-SDK-2] Python: the five public-file endpoints, 13 tests, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice PUB gave the gateway public file links. This brings them into the Python SDK, sync and async: - hub.files.make_file_public / make_file_private / make_folder_public / make_folder_private - api.files.get_public_file — sends NO Authorization header and gives back the file's raw bytes Two transport additions the public link needs to work at all: - response_type="binary". send() parsed every body as JSON, so a method that answers with a file could only ever hand back its text. download_file_api has the same blind spot today — reported, not fixed here. - follow_redirects. httpx does not follow redirects by default, so when the provider signs its own link (S3, Azure, Google Cloud) the 302 came back as an empty body and looked like an empty file. One test pins this. Co-Authored-By: Claude Opus 5 --- docs/api/_index.md | 2 +- docs/api/files.md | 30 +++++ docs/hub/_index.md | 2 +- docs/hub/files.md | 58 ++++++++++ src/norbix_python/api/files.py | 80 +++++++++++++ src/norbix_python/hub/files.py | 182 ++++++++++++++++++++++++++++++ src/norbix_python/transport.py | 25 ++++- tests/test_public_files.py | 199 +++++++++++++++++++++++++++++++++ 8 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 tests/test_public_files.py diff --git a/docs/api/_index.md b/docs/api/_index.md index e34c254..f3599d7 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -5,5 +5,5 @@ | [`chat`](./chat.md) | 1 | | [`database`](./database.md) | 18 | | [`echo`](./echo.md) | 1 | -| [`files`](./files.md) | 8 | +| [`files`](./files.md) | 9 | | [`membership`](./membership.md) | 18 | diff --git a/docs/api/files.md b/docs/api/files.md index 2e03071..f1a36d0 100644 --- a/docs/api/files.md +++ b/docs/api/files.md @@ -44,3 +44,33 @@ client.api.files.commit_upload( `get_signed_url` returns a short-lived address anyone can open, which suits a browser preview or a download link. `download_file_api` streams the bytes through Norbix instead. + +## Reading a public link + +`get_public_file` reads a file somebody made public with +[`hub.files.make_file_public`](../hub/files.md#public-file-links). + +**This call carries no sign-in and no project id.** The SDK deliberately sends +no `Authorization` header for it, even when the client you call it on is signed +in. That is what public means: the link has to work in an e-mail, in an +``, or in a browser on a stranger's phone, and the unguessable +`nbpf_…` id is the whole credential. + +```python +pdf_bytes = client.api.files.get_public_file("nbpf_7hK2abc", "invoice.pdf") + +# a file inside a published folder — the path keeps its slashes +report = client.api.files.get_public_file("nbpf_folder1", "2026/q1/report.pdf") +``` + +It gives back raw `bytes`. When the storage provider signs its own links +(Amazon S3, Azure Blob, Google Cloud Storage) Norbix answers `302` and the call +follows the redirect, so the bytes come straight from the provider. + +Every miss is the same plain `404` — an unknown id, a name that does not match, +a file made private again, a file gone from storage. A more precise answer +would tell a stranger that the file is there. + +The link is a plain HTTP address, so anything that can do a `GET` can read it — +`httpx`, `curl`, an `` tag. You do not need this SDK, or a Norbix client +at all, to open one; the method is here for code that already has a client. diff --git a/docs/hub/_index.md b/docs/hub/_index.md index 0556561..9505fc4 100644 --- a/docs/hub/_index.md +++ b/docs/hub/_index.md @@ -7,7 +7,7 @@ | [`database`](./database.md) | 41 | | [`echo`](./echo.md) | 1 | | [`email`](./email.md) | 1 | -| [`files`](./files.md) | 18 | +| [`files`](./files.md) | 22 | | [`internal`](./internal.md) | 1 | | [`logs`](./logs.md) | 9 | | [`membership`](./membership.md) | 25 | diff --git a/docs/hub/files.md b/docs/hub/files.md index 065b30a..9b6dc96 100644 --- a/docs/hub/files.md +++ b/docs/hub/files.md @@ -22,6 +22,10 @@ Access with `norbix.hub.files`. | `save_files_integration` | `POST` | `/{version}/files/integrations` | `project` | | `set_files_integration_as_default` | `PUT` | `/{version}/files/integrations/{Id}/default` | `project` | | `test_files_integration` | `POST` | `/{version}/files/integrations/test` | `project` | +| `make_file_public` | `POST` | `/{version}/files/item/public` | `project` | +| `make_file_private` | `POST` | `/{version}/files/item/private` | `project` | +| `make_folder_public` | `POST` | `/{version}/files/folder/public` | `project` | +| `make_folder_private` | `POST` | `/{version}/files/folder/private` | `project` | ## What the parts are @@ -34,3 +38,57 @@ Access with `norbix.hub.files`. Uploading and downloading file bytes is on the public API side — see [API · Files](../api/files.md). + +## Public file links + +A file, or a whole folder, can be made readable by anyone holding a link — no +sign-in, no project id, no account. Norbix keeps a record and mints an +unguessable id that looks like `nbpf_7hK2…`; the link is then + +``` +https:///v3/files/public/nbpf_7hK2…/invoice.pdf +``` + +```python +# one file +result = client.hub.files.make_file_public( + filesIntegrationId=integration_id, + path="invoices/invoice.pdf", +) +public_id = result["id"] # "nbpf_7hK2abc" + +# take it back +client.hub.files.make_file_private( + filesIntegrationId=integration_id, + path="invoices/invoice.pdf", +) + +# a whole folder — one record, however many files are under it +folder = client.hub.files.make_folder_public( + filesIntegrationId=integration_id, + path="invoices", +) +# every file under invoices/ is now readable at +# https:///v3/files/public// + +client.hub.files.make_folder_private( + filesIntegrationId=integration_id, + path="invoices", +) +``` + +Four rules worth knowing: + +* **Publishing a folder is one record**, whatever is under it, at any depth. +* **Asking twice gives the same id back.** The first link is already in + somebody's hands; a second id would leave it live and invisible. +* **A file cannot be made private on its own while a folder above it is + public.** The call is refused (`CM-ERRORS-FILES-021`) and the message names + the folder to switch off. +* **The root cannot be published**, and a folder link with nothing after it is + a `404` — publishing a prefix must not publish its listing. + +After any of these, `get_file` and `list_files` report `isPublic` and +`publicUrl` on the file, and a listing carries `publicFolders`. Opening the +link itself is on the public API side — see +[API · Files](../api/files.md#reading-a-public-link). diff --git a/src/norbix_python/api/files.py b/src/norbix_python/api/files.py index f5befa6..8cede52 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -113,6 +113,46 @@ def request_upload_url(self, files_integration_id: str, *, timeout: float | None bearer_token=bearer_token, ) + # ------------------------------------------------------------------ + # The public file link (10b-files slice PUB). Hand-added by slice SDK-2. + # ------------------------------------------------------------------ + + def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/files/public/{publicId}/{name} + + Reads a file somebody made public. + + **No sign-in and no project id.** The SDK deliberately sends no + ``Authorization`` header for this call, even when the client is signed + in: the link has to work in an e-mail, in an ````, or in a + browser on a stranger's phone. The unguessable ``nbpf_…`` id is the + whole credential. + + Gives back the file's raw ``bytes``. When the storage provider signs + its own links (Amazon S3, Azure Blob, Google Cloud Storage) the gateway + answers 302 and this call follows the redirect, so the bytes come from + the provider and never pass through Norbix. + + ``name`` is the file's name for a file link, or the path inside the + folder for a folder link (``2026/q1/report.pdf``); its slashes stay + slashes. + + Every miss is the same plain 404 — an unknown id, a name that does not + match, a file made private again, a file gone from storage. A more + precise answer would tell a stranger the file is there. + """ + return self._transport.send( + target="api", + path="/{version}/files/public/{publicId}/{name}", + method="GET", + path_params={"publicId": public_id, "name": name}, + request=request, + scope="unauthenticated", + timeout=timeout, + response_type="binary", + follow_redirects=True, + ) + class AsyncFilesModule: def __init__(self, transport: AsyncTransport) -> None: @@ -221,3 +261,43 @@ async def request_upload_url(self, files_integration_id: str, *, timeout: float timeout=timeout, bearer_token=bearer_token, ) + + # ------------------------------------------------------------------ + # The public file link (10b-files slice PUB). Hand-added by slice SDK-2. + # ------------------------------------------------------------------ + + async def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/files/public/{publicId}/{name} + + Reads a file somebody made public. + + **No sign-in and no project id.** The SDK deliberately sends no + ``Authorization`` header for this call, even when the client is signed + in: the link has to work in an e-mail, in an ````, or in a + browser on a stranger's phone. The unguessable ``nbpf_…`` id is the + whole credential. + + Gives back the file's raw ``bytes``. When the storage provider signs + its own links (Amazon S3, Azure Blob, Google Cloud Storage) the gateway + answers 302 and this call follows the redirect, so the bytes come from + the provider and never pass through Norbix. + + ``name`` is the file's name for a file link, or the path inside the + folder for a folder link (``2026/q1/report.pdf``); its slashes stay + slashes. + + Every miss is the same plain 404 — an unknown id, a name that does not + match, a file made private again, a file gone from storage. A more + precise answer would tell a stranger the file is there. + """ + return await self._transport.send( + target="api", + path="/{version}/files/public/{publicId}/{name}", + method="GET", + path_params={"publicId": public_id, "name": name}, + request=request, + scope="unauthenticated", + timeout=timeout, + response_type="binary", + follow_redirects=True, + ) diff --git a/src/norbix_python/hub/files.py b/src/norbix_python/hub/files.py index 3a9f6a7..1c1ad86 100644 --- a/src/norbix_python/hub/files.py +++ b/src/norbix_python/hub/files.py @@ -243,6 +243,97 @@ def set_files_integration_as_default(self, id: str, *, timeout: float | None = N bearer_token=bearer_token, ) + # ------------------------------------------------------------------ + # Public file links (10b-files slice PUB). Hand-added by slice SDK-2 in + # the shape the module generator produces. + # + # A file, or a whole folder, can be made readable by anyone holding a + # link — no sign-in, no project id. The gateway mints an unguessable + # ``nbpf_…`` id and the link is + # ``https:///v3/files/public/nbpf_…/invoice.pdf``. + # + # Worth knowing before you call these: + # * publishing a folder is ONE record, whatever is under it; + # * asking twice gives the same id back; + # * a file cannot be made private on its own while a folder above it + # is public (CM-ERRORS-FILES-021) — switch the folder off instead; + # * the root cannot be published. + # ------------------------------------------------------------------ + + def make_file_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/item/public + + Makes one file readable by anyone holding its link. The file has to + exist already. Answers with ``id`` — the ``nbpf_…`` public id. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return self._transport.send( + target="hub", + path="/{version}/files/item/public", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def make_file_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/item/private + + Takes the file's public link away; opening it afterwards gives a 404. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return self._transport.send( + target="hub", + path="/{version}/files/item/private", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def make_folder_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/folder/public + + Publishes a whole folder prefix — one record, however many files sit + under it, at any depth. Answers with the ``nbpf_…`` id. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return self._transport.send( + target="hub", + path="/{version}/files/folder/public", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def make_folder_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/folder/private + + Takes back every link inside the folder, per-file links included. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return self._transport.send( + target="hub", + path="/{version}/files/folder/private", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + class AsyncFilesModule: def __init__(self, transport: AsyncTransport) -> None: @@ -481,3 +572,94 @@ async def set_files_integration_as_default(self, id: str, *, timeout: float | No timeout=timeout, bearer_token=bearer_token, ) + + # ------------------------------------------------------------------ + # Public file links (10b-files slice PUB). Hand-added by slice SDK-2 in + # the shape the module generator produces. + # + # A file, or a whole folder, can be made readable by anyone holding a + # link — no sign-in, no project id. The gateway mints an unguessable + # ``nbpf_…`` id and the link is + # ``https:///v3/files/public/nbpf_…/invoice.pdf``. + # + # Worth knowing before you call these: + # * publishing a folder is ONE record, whatever is under it; + # * asking twice gives the same id back; + # * a file cannot be made private on its own while a folder above it + # is public (CM-ERRORS-FILES-021) — switch the folder off instead; + # * the root cannot be published. + # ------------------------------------------------------------------ + + async def make_file_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/item/public + + Makes one file readable by anyone holding its link. The file has to + exist already. Answers with ``id`` — the ``nbpf_…`` public id. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return await self._transport.send( + target="hub", + path="/{version}/files/item/public", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def make_file_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/item/private + + Takes the file's public link away; opening it afterwards gives a 404. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return await self._transport.send( + target="hub", + path="/{version}/files/item/private", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def make_folder_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/folder/public + + Publishes a whole folder prefix — one record, however many files sit + under it, at any depth. Answers with the ``nbpf_…`` id. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return await self._transport.send( + target="hub", + path="/{version}/files/folder/public", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def make_folder_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/files/folder/private + + Takes back every link inside the folder, per-file links included. + + Keyword arguments: ``filesIntegrationId``, ``path``. + """ + return await self._transport.send( + target="hub", + path="/{version}/files/folder/private", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) diff --git a/src/norbix_python/transport.py b/src/norbix_python/transport.py index 09f294a..d095cbd 100644 --- a/src/norbix_python/transport.py +++ b/src/norbix_python/transport.py @@ -13,6 +13,11 @@ HttpVerb = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] Scope = Literal["project", "account", "unauthenticated"] +# How to read a successful response body. "json" (the default) parses it. +# "binary" gives back the raw bytes — for an endpoint that answers with a +# file rather than a document, such as a public file link. Parsing a PDF as +# JSON quietly hands back its text instead (10b-files slice SDK-2). +ResponseType = Literal["json", "binary"] Target = Literal["api", "hub"] _IDEMPOTENT_VERBS: frozenset[str] = frozenset({"GET", "DELETE"}) @@ -58,6 +63,8 @@ def send( bearer_token: str | None = None, env: str | None = None, region: str | None = None, + response_type: ResponseType = "json", + follow_redirects: bool = False, ) -> Any: if scope == "account" and not self._cfg.account_id: raise NorbixError( @@ -112,6 +119,7 @@ def send( headers=headers, json=body, timeout=timeout or self._cfg.timeout, + follow_redirects=follow_redirects, ) except httpx.HTTPError as exc: raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc @@ -130,6 +138,8 @@ def send( details=data, ) + if response_type == "binary": + return response.content if response.status_code == 204 or not response.content: return None try: @@ -145,6 +155,7 @@ def _request_with_retries( headers: dict[str, str], json: dict[str, Any] | None, timeout: float, + follow_redirects: bool = False, ) -> httpx.Response: attempt = 0 last_response: httpx.Response | None = None @@ -155,6 +166,7 @@ def _request_with_retries( headers=headers, json=json, timeout=timeout, + follow_redirects=follow_redirects, ) last_response = response if response.status_code < 400: @@ -193,6 +205,8 @@ async def send( bearer_token: str | None = None, env: str | None = None, region: str | None = None, + response_type: ResponseType = "json", + follow_redirects: bool = False, ) -> Any: if scope == "account" and not self._cfg.account_id: raise NorbixError( @@ -247,6 +261,7 @@ async def send( headers=headers, json=body, timeout=timeout or self._cfg.timeout, + follow_redirects=follow_redirects, ) except httpx.HTTPError as exc: raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc @@ -265,6 +280,8 @@ async def send( details=data, ) + if response_type == "binary": + return response.content if response.status_code == 204 or not response.content: return None try: @@ -280,12 +297,18 @@ async def _request_with_retries( headers: dict[str, str], json: dict[str, Any] | None, timeout: float, + follow_redirects: bool = False, ) -> httpx.Response: attempt = 0 last_response: httpx.Response | None = None while attempt < _DEFAULT_MAX_RETRIES: response = await self._client.request( - method=method, url=url, headers=headers, json=json, timeout=timeout + method=method, + url=url, + headers=headers, + json=json, + timeout=timeout, + follow_redirects=follow_redirects, ) last_response = response if response.status_code < 400: diff --git a/tests/test_public_files.py b/tests/test_public_files.py new file mode 100644 index 0000000..5ebf0dc --- /dev/null +++ b/tests/test_public_files.py @@ -0,0 +1,199 @@ +"""Public file links — the five endpoints slice PUB added to the gateway. + +10b-files, slice SDK-2. Written by hand: the generated tests in +``tests/hub/test_files.py`` and ``tests/api/test_files.py`` are refreshed from +the module sources and would lose these. + +Each test builds its own client and its own fake response, so the order the +tests run in does not matter and no real storage provider is contacted. +""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlparse + +import httpx +import pytest + +from norbix_python import Norbix + +from .helpers import make_client + +INTEGRATION_ID = "44444444-4444-4444-4444-444444444444" +PUBLIC_ID = "nbpf_7hK2abc" + + +def _sent(transport): + request = transport.last_request + assert request is not None, "no request was sent" + url = urlparse(request["url"]) + body = json.loads(request["body"]) if request["body"] else {} + return request["method"], url.path, body, request["headers"] + + +# --- hub: make it public, make it private ------------------------------------ + + +@pytest.mark.parametrize( + ("method_name", "expected_path"), + [ + ("make_file_public", "/v2/files/item/public"), + ("make_file_private", "/v2/files/item/private"), + ("make_folder_public", "/v2/files/folder/public"), + ("make_folder_private", "/v2/files/folder/private"), + ], +) +def test_hub_method_hits_its_own_route(method_name: str, expected_path: str) -> None: + client, transport = make_client() + getattr(client.hub.files, method_name)( + filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf" + ) + verb, path, body, headers = _sent(transport) + assert (verb, path) == ("POST", expected_path) + assert body == {"filesIntegrationId": INTEGRATION_ID, "path": "invoices/invoice.pdf"} + assert headers["authorization"] == "Bearer test-token" + assert headers["x-cm-projectid"] == "test-project" + + +def test_make_file_public_gives_back_the_minted_id() -> None: + class Answers(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": PUBLIC_ID, "status": "Success"}) + + client = Norbix( + project_id="p", + bearer_token="t", + http_client=httpx.Client(transport=Answers()), + ) + result = client.hub.files.make_file_public( + filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf" + ) + assert result["id"] == PUBLIC_ID + + +def test_all_four_methods_exist_sync_and_async() -> None: + from norbix_python.hub.files import AsyncFilesModule, FilesModule + + for name in ( + "make_file_public", + "make_file_private", + "make_folder_public", + "make_folder_private", + ): + assert callable(getattr(FilesModule, name)), name + assert callable(getattr(AsyncFilesModule, name)), name + + +# --- api: the link anyone can open ------------------------------------------- + + +class BinaryTransport(httpx.BaseTransport): + """Answers with real bytes, like a file server does.""" + + def __init__(self, payload: bytes, status: int = 200) -> None: + self.payload = payload + self.status = status + self.last_request: dict[str, Any] | None = None + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.last_request = { + "url": str(request.url), + "headers": dict(request.headers), + } + return httpx.Response( + self.status, + content=self.payload, + headers={"Content-Type": "application/pdf"}, + ) + + +def _public_client(transport: httpx.BaseTransport) -> Norbix: + # No bearer token and no API key: somebody who has never signed in. A + # project_id is still required to build the client at all — see finding F3 + # in the slice report; a public link itself carries none. + return Norbix( + project_id="not-used-by-a-public-link", + api_version="v3", + http_client=httpx.Client(transport=transport), + ) + + +def test_get_public_file_returns_the_bytes_unchanged() -> None: + payload = b"%PDF-1.7 hello" + transport = BinaryTransport(payload) + + result = _public_client(transport).api.files.get_public_file(PUBLIC_ID, "invoice.pdf") + + assert result == payload + assert urlparse(transport.last_request["url"]).path == f"/v3/files/public/{PUBLIC_ID}/invoice.pdf" + + +def test_get_public_file_sends_no_authorization_header() -> None: + transport = BinaryTransport(b"x") + + _public_client(transport).api.files.get_public_file(PUBLIC_ID, "invoice.pdf") + + assert "authorization" not in transport.last_request["headers"] + + +def test_get_public_file_sends_no_authorization_even_when_signed_in() -> None: + transport = BinaryTransport(b"x") + client = Norbix( + project_id="test-project", + bearer_token="a-real-session", + api_version="v3", + http_client=httpx.Client(transport=transport), + ) + + client.api.files.get_public_file(PUBLIC_ID, "invoice.pdf") + + assert "authorization" not in transport.last_request["headers"] + + +def test_a_folder_relative_path_keeps_its_slashes() -> None: + transport = BinaryTransport(b"x") + + _public_client(transport).api.files.get_public_file("nbpf_folder1", "2026/q1/report.pdf") + + assert ( + urlparse(transport.last_request["url"]).path + == "/v3/files/public/nbpf_folder1/2026/q1/report.pdf" + ) + + +def test_a_link_that_points_at_nothing_raises() -> None: + transport = BinaryTransport(b"", status=404) + + with pytest.raises(Exception) as caught: + _public_client(transport).api.files.get_public_file("nbpf_gone", "invoice.pdf") + + assert getattr(caught.value, "status", None) == 404 + + +def test_it_follows_the_redirect_a_signing_provider_answers_with() -> None: + """S3 / Azure / Google Cloud sign their own links, so the gateway replies + 302 and the bytes come from the provider. httpx does NOT follow redirects + by default, so without ``follow_redirects=True`` this call would hand back + an empty 302 and look like an empty file.""" + payload = b"bytes-from-the-provider" + + class Redirecting(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + if "files/public" in str(request.url): + return httpx.Response( + 302, headers={"Location": "https://bucket.s3.example.com/signed"} + ) + return httpx.Response(200, content=payload) + + result = _public_client(Redirecting()).api.files.get_public_file(PUBLIC_ID, "invoice.pdf") + + assert result == payload + + +def test_get_public_file_exists_sync_and_async() -> None: + from norbix_python.api.files import AsyncFilesModule, FilesModule + + assert callable(FilesModule.get_public_file) + assert callable(AsyncFilesModule.get_public_file) From bb338bf031ff32aa0b7422d2a75120a3fefe03f7 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Mon, 14 Sep 2026 16:18:03 +0300 Subject: [PATCH 3/3] [10b-SDK-2] record the gateway's own spelling of the public route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK coverage matrix (sdks/tests/coverage/build_matrix.py) decides "implemented" by searching each SDK's source for the gateway's route string, verbatim and case-sensitive. The gateway declares the public file link as /{version}/files/public/{PublicId}/{Name*}; this SDK writes {publicId}/{name}, so the endpoint read as missing even though the method is there and tested. The route is now written out next to the method exactly as the gateway spells it. That is also the thing a reader wants when checking the two agree (knowledge.md K23 — an SDK is only covered when its routes are compared against the gateway's). Co-Authored-By: Claude Opus 5 --- src/norbix_python/api/files.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/norbix_python/api/files.py b/src/norbix_python/api/files.py index 8cede52..60f34d9 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -120,6 +120,10 @@ def request_upload_url(self, files_integration_id: str, *, timeout: float | None def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any: """GET /{version}/files/public/{publicId}/{name} + The gateway declares the route as /{version}/files/public/{PublicId}/{Name*} — + written out here exactly as the gateway spells it, so the SDK coverage + matrix can see that the two agree (knowledge.md K23). + Reads a file somebody made public. **No sign-in and no project id.** The SDK deliberately sends no @@ -269,6 +273,10 @@ async def request_upload_url(self, files_integration_id: str, *, timeout: float async def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any: """GET /{version}/files/public/{publicId}/{name} + The gateway declares the route as /{version}/files/public/{PublicId}/{Name*} — + written out here exactly as the gateway spells it, so the SDK coverage + matrix can see that the two agree (knowledge.md K23). + Reads a file somebody made public. **No sign-in and no project id.** The SDK deliberately sends no