Files
nocobase-mcp/nocobase_crm_mcp/nocobase_http.py
T

203 lines
8.5 KiB
Python

from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
class NocoBaseHttpError(RuntimeError):
def __init__(
self,
status_code: int,
message: str,
*,
authentication_failed: bool | None = None,
) -> None:
self.status_code = status_code
self.authentication_failed = status_code == 401 if authentication_failed is None else authentication_failed
super().__init__(message)
class NocoBaseHttpClient:
"""Small synchronous client for the fixed NocoBase attachment endpoints."""
_PROJECT_RESOURCE = "t_projects"
_ATTACHMENT_FIELD = "t_projects.f_projects_attachments"
def __init__(
self,
api_base_url: str | None = None,
api_token: str | None = None,
timeout: float = 30.0,
transport: httpx.BaseTransport | None = None,
credential_provider: Callable[[], tuple[str, str]] | None = None,
) -> None:
self.api_base_url = api_base_url.strip().rstrip("/") if api_base_url else ""
self.api_token = api_token.strip() if api_token else ""
self._credential_provider = credential_provider
if credential_provider is None and not self.api_base_url:
raise ValueError("NocoBase API base URL must not be blank")
if credential_provider is None and not self.api_token:
raise ValueError("NocoBase API token must not be blank")
if credential_provider is not None and (self.api_base_url or self.api_token):
raise ValueError("Provide static NocoBase credentials or a credential provider, not both")
self._client = httpx.Client(
timeout=timeout,
transport=transport,
)
def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, Any]:
api_base_url, api_token = self._credentials()
self._ensure_project_write_access(api_base_url, api_token)
with Path(path).open("rb") as handle:
response = self._client.post(
f"{api_base_url}/attachments:create",
params={"attachmentField": self._ATTACHMENT_FIELD},
files={"file": (filename, handle, mimetype or "application/octet-stream")},
headers={"Authorization": f"Bearer {api_token}"},
)
payload = self._json_response(response, api_token)
data = payload.get("data")
if not isinstance(data, dict):
raise NocoBaseHttpError(response.status_code, "NocoBase response did not contain an attachment object")
return data
def _ensure_project_write_access(self, api_base_url: str, api_token: str) -> None:
response = self._client.get(
f"{api_base_url}/roles:check",
headers={"Authorization": f"Bearer {api_token}"},
)
payload = self._json_response(response, api_token)
role_data = payload.get("data")
if not isinstance(role_data, Mapping):
raise NocoBaseHttpError(502, "NocoBase roles:check response did not contain role data")
effective_role = dict(role_data)
meta = payload.get("meta")
data_sources = meta.get("dataSources") if isinstance(meta, Mapping) else None
main_role = data_sources.get("main") if isinstance(data_sources, Mapping) else None
if isinstance(main_role, Mapping):
effective_role.update(main_role)
if any(_role_allows_action(effective_role, self._PROJECT_RESOURCE, action) for action in ("create", "update")):
return
raise NocoBaseHttpError(403, "Current NocoBase role cannot create or update projects")
def add_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None:
self._post_attachment_ids(project_id, attachment_ids, "add")
def remove_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any]) -> None:
self._post_attachment_ids(project_id, attachment_ids, "remove")
def _post_attachment_ids(self, project_id: Any, attachment_ids: Sequence[Any], action: str) -> None:
project_id_segment = _project_id_segment(project_id)
api_base_url, api_token = self._credentials()
response = self._client.post(
f"{api_base_url}/t_projects/{project_id_segment}/f_projects_attachments:{action}",
json={"tk": list(attachment_ids)},
headers={"Authorization": f"Bearer {api_token}"},
)
self._raise_for_error(response, api_token)
def _json_response(self, response: httpx.Response, api_token: str) -> dict[str, Any]:
self._raise_for_error(response, api_token)
try:
payload = response.json()
except ValueError as error:
raise NocoBaseHttpError(response.status_code, "NocoBase response was not valid JSON") from error
if not isinstance(payload, dict):
raise NocoBaseHttpError(response.status_code, "NocoBase response was not a JSON object")
return payload
def _raise_for_error(self, response: httpx.Response, api_token: str) -> None:
if response.is_success:
return
message = response.text.strip() or response.reason_phrase or "NocoBase request failed"
raise NocoBaseHttpError(
response.status_code,
self._redact(message, api_token),
authentication_failed=_is_authentication_failure(response),
)
def _credentials(self) -> tuple[str, str]:
try:
api_base_url, api_token = (
self._credential_provider()
if self._credential_provider is not None
else (self.api_base_url, self.api_token)
)
except NocoBaseHttpError:
raise
except Exception as error:
raise NocoBaseHttpError(401, str(error), authentication_failed=True) from error
normalized_url = str(api_base_url).strip().rstrip("/")
normalized_token = str(api_token).strip()
if not normalized_url or not normalized_token:
raise NocoBaseHttpError(401, "NocoBase CLI environment is not authenticated", authentication_failed=True)
return normalized_url, normalized_token
@staticmethod
def _redact(value: str, api_token: str) -> str:
return value.replace(api_token, "<redacted>")
def _project_id_segment(project_id: Any) -> str:
return quote(validate_project_id(project_id), safe="")
def _role_allows_action(role_data: Mapping[str, Any], resource: str, action: str) -> bool:
if role_data.get("allowAll") is True:
return True
aliases = role_data.get("actionAlias")
resolved_action = aliases.get(action, action) if isinstance(aliases, Mapping) else action
resources = role_data.get("resources")
if isinstance(resources, Sequence) and not isinstance(resources, (str, bytes)) and resource in resources:
actions = role_data.get("actions")
if not isinstance(actions, Mapping):
return False
for action_path in (f"{resource}:{resolved_action}", f"{resource}:{action}"):
if action_path in actions and actions[action_path] is not None and actions[action_path] is not False:
return True
return False
strategy = role_data.get("strategy")
strategy_actions = strategy.get("actions") if isinstance(strategy, Mapping) else None
if not isinstance(strategy_actions, Sequence) or isinstance(strategy_actions, (str, bytes)):
return False
return any(
isinstance(strategy_action, str) and strategy_action.split(":", 1)[0] == resolved_action
for strategy_action in strategy_actions
)
def validate_project_id(project_id: Any) -> str:
if isinstance(project_id, bool) or not isinstance(project_id, (str, int)):
raise ValueError("project_id must be a scalar identifier")
if isinstance(project_id, str):
if not project_id.strip():
raise ValueError("project_id must not be blank")
if project_id in {".", ".."}:
raise ValueError("project_id must not be a dot path segment")
return str(project_id)
def _is_authentication_failure(response: httpx.Response) -> bool:
if response.status_code == 401:
return True
response_text = response.text.upper()
return any(
code in response_text
for code in (
"EMPTY_TOKEN",
"INVALID_TOKEN",
"EXPIRED_TOKEN",
"BLOCKED_TOKEN",
"EXPIRED_SESSION",
"NOT_EXIST_USER",
)
)