Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
| [`chat`](./chat.md) | 1 |
| [`database`](./database.md) | 18 |
| [`echo`](./echo.md) | 1 |
| [`files`](./files.md) | 9 |
| [`files`](./files.md) | 10 |
| [`membership`](./membership.md) | 18 |
29 changes: 29 additions & 0 deletions docs/api/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Access with `norbix.api.files`.
| `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` |
| `test_files_integration` | `POST` | `/{version}/files/{filesIntegrationId}/test` | `project` |

`filesIntegrationId` names the storage integration to work with. Get it from
`norbix.hub.files.get_files_integrations()`.
Expand Down Expand Up @@ -45,6 +46,34 @@ client.api.files.commit_upload(
browser preview or a download link. `download_file_api` streams the bytes
through Norbix instead.

## Testing an integration

`test_files_integration` checks that a storage integration really works. It
runs a live probe: it uploads a small file, reads it back, lists the folder and
deletes the file again. Because the probe writes to the storage, the API key
needs the `files:create` permission.

```python
result = client.api.files.test_files_integration(integration_id)
for step in result["items"]:
print(step["operation"], step["result"], step.get("errors"))
# UploadFile OK None
# GetFile OK None
# ...
```

Each item has `operation` (`UploadFile`, `GetFile`, `GetAllFiles`,
`DeleteFile`, in that order), `result` (`"OK"`, `"FAILED"`, or `"NOT_TESTED"`
once an earlier step failed) and `errors`.
When the gateway answers with an error status (for example for an unknown
integration id), the call raises a `NorbixError`, like the other methods; the
gateway's `responseStatus` is in the error's `details`.

The dashboard has its own method for the same probe,
[`hub.files.test_files_integration`](../hub/files.md), which takes the id in the
body (`integrationId=...`) instead of the path. This one is for code that uses
an API key.

## Reading a public link

`get_public_file` reads a file somebody made public with
Expand Down
52 changes: 52 additions & 0 deletions src/norbix_python/api/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,32 @@ def request_upload_url(self, files_integration_id: str, *, timeout: float | None
bearer_token=bearer_token,
)

def test_files_integration(self, files_integration_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
"""POST /{version}/files/{filesIntegrationId}/test

Hand-added by slice API-TEST (10b-files, #39) — the public API twin of
``hub.files.test_files_integration`` (``POST /{version}/files/integrations/test``).

Runs a live probe against the storage integration: it uploads a small
file, reads it back, lists the folder and deletes the file again. Needs
the ``files:create`` permission, because the probe writes to the storage.

Gives back a dict with ``items`` — one entry per step, each with
``operation`` (``UploadFile``, ``GetFile``, ``GetAllFiles``, ``DeleteFile``),
``result`` (``"OK"``, ``"FAILED"``, or ``"NOT_TESTED"`` once an earlier
step failed) and ``errors``.
"""
return self._transport.send(
target="api",
path="/{version}/files/{filesIntegrationId}/test",
method="POST",
path_params={"filesIntegrationId": files_integration_id},
request=request,
scope="project",
timeout=timeout,
bearer_token=bearer_token,
)

# ------------------------------------------------------------------
# The public file link (10b-files slice PUB). Hand-added by slice SDK-2.
# ------------------------------------------------------------------
Expand Down Expand Up @@ -266,6 +292,32 @@ async def request_upload_url(self, files_integration_id: str, *, timeout: float
bearer_token=bearer_token,
)

async def test_files_integration(self, files_integration_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
"""POST /{version}/files/{filesIntegrationId}/test

Hand-added by slice API-TEST (10b-files, #39) — the public API twin of
``hub.files.test_files_integration`` (``POST /{version}/files/integrations/test``).

Runs a live probe against the storage integration: it uploads a small
file, reads it back, lists the folder and deletes the file again. Needs
the ``files:create`` permission, because the probe writes to the storage.

Gives back a dict with ``items`` — one entry per step, each with
``operation`` (``UploadFile``, ``GetFile``, ``GetAllFiles``, ``DeleteFile``),
``result`` (``"OK"``, ``"FAILED"``, or ``"NOT_TESTED"`` once an earlier
step failed) and ``errors``.
"""
return await self._transport.send(
target="api",
path="/{version}/files/{filesIntegrationId}/test",
method="POST",
path_params={"filesIntegrationId": files_integration_id},
request=request,
scope="project",
timeout=timeout,
bearer_token=bearer_token,
)

# ------------------------------------------------------------------
# The public file link (10b-files slice PUB). Hand-added by slice SDK-2.
# ------------------------------------------------------------------
Expand Down
141 changes: 140 additions & 1 deletion tests/test_files_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
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.
One test per endpoint: 12 on the hub (the dashboard API) and 9 on the public API,
plus a few more for ``api.files.test_files_integration`` (slice API-TEST, #39).
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 asyncio
import json
from typing import Any
from urllib.parse import urlparse

import httpx
import pytest

from norbix_python import AsyncNorbix, Norbix
from norbix_python.errors import ValidationError

from .helpers import make_client

INTEGRATION_ID = "44444444-4444-4444-4444-444444444444"
Expand Down Expand Up @@ -193,3 +202,133 @@ def test_delete_many_files() -> None:
)
method, path, _, _ = _sent(transport)
assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}/bulk")


# --- public API: testing an integration (slice API-TEST, #39) ----------------
#
# POST /{version}/files/{filesIntegrationId}/test is the public API twin of the
# hub's POST /{version}/files/integrations/test. The two must not be mixed up:
# the id goes in the path here, in the body there.

PROBE_ANSWER = {
"items": [
{"operation": "UploadFile", "result": "OK", "errors": []},
{"operation": "GetFile", "result": "FAILED", "errors": ["Access denied"]},
{"operation": "GetAllFiles", "result": "NOT_TESTED", "errors": []},
{"operation": "DeleteFile", "result": "NOT_TESTED", "errors": []},
],
"responseStatus": {"errorCode": "", "message": ""},
}


class _Answers(httpx.BaseTransport):
"""Records the request and answers with a fixed status and JSON body."""

def __init__(self, status: int, payload: dict[str, Any]) -> None:
self.status = status
self.payload = payload
self.last_request: dict[str, Any] | None = None

def handle_request(self, request: httpx.Request) -> httpx.Response:
self.last_request = {
"method": request.method,
"url": str(request.url),
"headers": dict(request.headers),
"body": request.content.decode("utf-8") if request.content else "",
}
return httpx.Response(self.status, json=self.payload)


class _AsyncAnswers(httpx.AsyncBaseTransport):
def __init__(self, status: int, payload: dict[str, Any]) -> None:
self.status = status
self.payload = payload
self.last_request: dict[str, Any] | None = None

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
self.last_request = {
"method": request.method,
"url": str(request.url),
"headers": dict(request.headers),
"body": request.content.decode("utf-8") if request.content else "",
}
return httpx.Response(self.status, json=self.payload)


def test_api_test_files_integration_route() -> None:
client, transport = make_client()
client.api.files.test_files_integration(INTEGRATION_ID)
method, path, _, body = _sent(transport)
assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/test")
# The id travels in the path only, not in the body as on the hub route.
assert "integrationId" not in body


def test_api_test_files_integration_sends_project_scope_headers() -> None:
client, transport = make_client()
client.api.files.test_files_integration(INTEGRATION_ID)
headers = transport.last_request["headers"]
assert headers["authorization"] == "Bearer test-token"
assert headers["x-cm-projectid"] == "test-project"


def test_api_test_files_integration_gives_back_the_parsed_items() -> None:
transport = _Answers(200, PROBE_ANSWER)
client = Norbix(
project_id="test-project",
bearer_token="test-token",
http_client=httpx.Client(transport=transport),
)

result = client.api.files.test_files_integration(INTEGRATION_ID)

assert [item["operation"] for item in result["items"]] == [
"UploadFile",
"GetFile",
"GetAllFiles",
"DeleteFile",
]
assert result["items"][0]["result"] == "OK"
assert result["items"][1] == {"operation": "GetFile", "result": "FAILED", "errors": ["Access denied"]}


def test_api_test_files_integration_error_status_raises() -> None:
answer = {
"responseStatus": {
"errorCode": "IntegrationNotFound",
"message": "Files integration was not found",
}
}
transport = _Answers(400, answer)
client = Norbix(
project_id="test-project",
bearer_token="test-token",
http_client=httpx.Client(transport=transport),
)

with pytest.raises(ValidationError) as caught:
client.api.files.test_files_integration(INTEGRATION_ID)

assert caught.value.status == 400
assert caught.value.details["responseStatus"]["errorCode"] == "IntegrationNotFound"


def test_api_test_files_integration_async() -> None:
transport = _AsyncAnswers(200, PROBE_ANSWER)
client = AsyncNorbix(
project_id="test-project",
bearer_token="test-token",
http_client=httpx.AsyncClient(transport=transport),
)

async def run() -> Any:
return await client.api.files.test_files_integration(INTEGRATION_ID)

result = asyncio.run(run())

request = transport.last_request
assert request is not None
assert request["method"] == "POST"
assert urlparse(request["url"]).path == f"/v2/files/{INTEGRATION_ID}/test"
assert request["headers"]["x-cm-projectid"] == "test-project"
assert result["items"][2]["operation"] == "GetAllFiles"
Loading