diff --git a/docs/api/_index.md b/docs/api/_index.md index 22923ba..f3599d7 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) | 9 | | [`membership`](./membership.md) | 18 | diff --git a/docs/api/files.md b/docs/api/files.md new file mode 100644 index 0000000..f1a36d0 --- /dev/null +++ b/docs/api/files.md @@ -0,0 +1,76 @@ +# 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. + +## 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 c3ad6c8..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) | 15 | +| [`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 c1f606b..9b6dc96 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,74 @@ 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` | +| `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 + +* 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). + +## 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..60f34d9 100644 --- a/src/norbix_python/api/files.py +++ b/src/norbix_python/api/files.py @@ -113,6 +113,50 @@ 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} + + 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 + ``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 +265,47 @@ 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} + + 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 + ``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 2f113b5..1c1ad86 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( @@ -230,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: @@ -443,6 +547,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( @@ -455,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/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") 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)