Skip to content

Commit

Permalink
NC30: webhook_listeners app support (#272)
Browse files Browse the repository at this point in the history
Not finished yet, because... I'm waiting for this to be finalized on the
server.

The technology is very powerful, probably better than what happened with
ExApp.

It can also be used as a Nextcloud client, but the client must have
administrator access.

Some docs(and maybe article?) will come before NC30 release.

Reference: nextcloud/server#46477

---------

Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
  • Loading branch information
bigcat88 authored Jul 19, 2024
1 parent 1926973 commit fb6b2bb
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 4 deletions.
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

All notable changes to this project will be documented in this file.

## [0.14.0 - 2024-07-0X]
## [0.15.0 - 2024-07-19]

### Added

- Initial Webhooks API support for the upcoming Nextcloud 30. #272

### Changed

- NextcloudApp: `fetch_models_task` function now saves paths to downloaded models. #274 Thanks to @kyteinsky

## [0.14.0 - 2024-07-09]

### Added

Expand Down
10 changes: 10 additions & 0 deletions docs/reference/Webhooks.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.. py:currentmodule:: nc_py_api.webhooks
Webhooks API
============

.. autoclass:: nc_py_api.webhooks.WebhookInfo
:members:

.. autoclass:: nc_py_api.webhooks._WebhooksAPI
:members:
1 change: 1 addition & 0 deletions docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ Reference
Notes
Session
LoginFlowV2
Webhooks
7 changes: 7 additions & 0 deletions nc_py_api/nextcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from .users import _AsyncUsersAPI, _UsersAPI
from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI
from .weather_status import _AsyncWeatherStatusAPI, _WeatherStatusAPI
from .webhooks import _AsyncWebhooksAPI, _WebhooksAPI


class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
Expand Down Expand Up @@ -71,6 +72,8 @@ class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
"""Nextcloud API for managing users statuses"""
weather_status: _WeatherStatusAPI
"""Nextcloud API for managing user weather statuses"""
webhooks: _WebhooksAPI
"""Nextcloud API for managing webhooks"""
_session: NcSessionBasic

def __init__(self, session: NcSessionBasic):
Expand All @@ -86,6 +89,7 @@ def __init__(self, session: NcSessionBasic):
self.users_groups = _UsersGroupsAPI(session)
self.user_status = _UserStatusAPI(session)
self.weather_status = _WeatherStatusAPI(session)
self.webhooks = _WebhooksAPI(session)

@property
def capabilities(self) -> dict:
Expand Down Expand Up @@ -169,6 +173,8 @@ class _AsyncNextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
"""Nextcloud API for managing users statuses"""
weather_status: _AsyncWeatherStatusAPI
"""Nextcloud API for managing user weather statuses"""
webhooks: _AsyncWebhooksAPI
"""Nextcloud API for managing webhooks"""
_session: AsyncNcSessionBasic

def __init__(self, session: AsyncNcSessionBasic):
Expand All @@ -184,6 +190,7 @@ def __init__(self, session: AsyncNcSessionBasic):
self.users_groups = _AsyncUsersGroupsAPI(session)
self.user_status = _AsyncUserStatusAPI(session)
self.weather_status = _AsyncWeatherStatusAPI(session)
self.webhooks = _AsyncWebhooksAPI(session)

@property
async def capabilities(self) -> dict:
Expand Down
210 changes: 210 additions & 0 deletions nc_py_api/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Nextcloud Webhooks API."""

import dataclasses

from ._misc import clear_from_params_empty # , require_capabilities
from ._session import AsyncNcSessionBasic, NcSessionBasic


@dataclasses.dataclass
class WebhookInfo:
"""Information about the Webhook."""

def __init__(self, raw_data: dict):
self._raw_data = raw_data

@property
def webhook_id(self) -> int:
"""`ID` of the webhook."""
return self._raw_data["id"]

@property
def app_id(self) -> str:
"""`ID` of the ExApp that registered webhook."""
return self._raw_data["appId"] if self._raw_data["appId"] else ""

@property
def user_id(self) -> str:
"""`UserID` if webhook was registered in user context."""
return self._raw_data["userId"] if self._raw_data["userId"] else ""

@property
def http_method(self) -> str:
"""HTTP method used to call webhook."""
return self._raw_data["httpMethod"]

@property
def uri(self) -> str:
"""URL address that will be called for this webhook."""
return self._raw_data["uri"]

@property
def event(self) -> str:
"""Nextcloud PHP event that triggers this webhook."""
return self._raw_data["event"]

@property
def event_filter(self):
"""Mongo filter to apply to the serialized data to decide if firing."""
return self._raw_data["eventFilter"]

@property
def user_id_filter(self) -> str:
"""Currently unknown."""
return self._raw_data["userIdFilter"]

@property
def headers(self) -> dict:
"""Headers that should be added to request when calling webhook."""
return self._raw_data["headers"] if self._raw_data["headers"] else {}

@property
def auth_method(self) -> str:
"""Currently unknown."""
return self._raw_data["authMethod"]

@property
def auth_data(self) -> dict:
"""Currently unknown."""
return self._raw_data["authData"] if self._raw_data["authData"] else {}

def __repr__(self):
return f"<{self.__class__.__name__} id={self.webhook_id}, event={self.event}>"


class _WebhooksAPI:
"""The class provides the application management API on the Nextcloud server."""

_ep_base: str = "/ocs/v1.php/apps/webhook_listeners/api/v1/webhooks"

def __init__(self, session: NcSessionBasic):
self._session = session

def get_list(self, uri_filter: str = "") -> list[WebhookInfo]:
params = {"uri": uri_filter} if uri_filter else {}
return [WebhookInfo(i) for i in self._session.ocs("GET", f"{self._ep_base}", params=params)]

def get_entry(self, webhook_id: int) -> WebhookInfo:
return WebhookInfo(self._session.ocs("GET", f"{self._ep_base}/{webhook_id}"))

def register(
self,
http_method: str,
uri: str,
event: str,
event_filter: dict | None = None,
user_id_filter: str = "",
headers: dict | None = None,
auth_method: str = "none",
auth_data: dict | None = None,
):
params = {
"httpMethod": http_method,
"uri": uri,
"event": event,
"eventFilter": event_filter,
"userIdFilter": user_id_filter,
"headers": headers,
"authMethod": auth_method,
"authData": auth_data,
}
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}", json=params))

def update(
self,
webhook_id: int,
http_method: str,
uri: str,
event: str,
event_filter: dict | None = None,
user_id_filter: str = "",
headers: dict | None = None,
auth_method: str = "none",
auth_data: dict | None = None,
):
params = {
"id": webhook_id,
"httpMethod": http_method,
"uri": uri,
"event": event,
"eventFilter": event_filter,
"userIdFilter": user_id_filter,
"headers": headers,
"authMethod": auth_method,
"authData": auth_data,
}
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params))

def unregister(self, webhook_id: int) -> bool:
return self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}")


class _AsyncWebhooksAPI:
"""The class provides the async application management API on the Nextcloud server."""

_ep_base: str = "/ocs/v1.php/webhooks"

def __init__(self, session: AsyncNcSessionBasic):
self._session = session

async def get_list(self, uri_filter: str = "") -> list[WebhookInfo]:
params = {"uri": uri_filter} if uri_filter else {}
return [WebhookInfo(i) for i in await self._session.ocs("GET", f"{self._ep_base}", params=params)]

async def get_entry(self, webhook_id: int) -> WebhookInfo:
return WebhookInfo(await self._session.ocs("GET", f"{self._ep_base}/{webhook_id}"))

async def register(
self,
http_method: str,
uri: str,
event: str,
event_filter: dict | None = None,
user_id_filter: str = "",
headers: dict | None = None,
auth_method: str = "none",
auth_data: dict | None = None,
):
params = {
"httpMethod": http_method,
"uri": uri,
"event": event,
"eventFilter": event_filter,
"userIdFilter": user_id_filter,
"headers": headers,
"authMethod": auth_method,
"authData": auth_data,
}
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}", json=params))

async def update(
self,
webhook_id: int,
http_method: str,
uri: str,
event: str,
event_filter: dict | None = None,
user_id_filter: str = "",
headers: dict | None = None,
auth_method: str = "none",
auth_data: dict | None = None,
):
params = {
"id": webhook_id,
"httpMethod": http_method,
"uri": uri,
"event": event,
"eventFilter": event_filter,
"userIdFilter": user_id_filter,
"headers": headers,
"authMethod": auth_method,
"authData": auth_data,
}
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params))

async def unregister(self, webhook_id: int) -> bool:
return await self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ design.max-attributes = 8
design.max-locals = 20
design.max-branches = 16
design.max-returns = 8
design.max-args = 8
design.max-args = 10
basic.good-names = [
"a",
"b",
Expand Down
6 changes: 6 additions & 0 deletions tests/actual_tests/files_sharing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ async def test_share_fields_async(anc_any):
def test_create_permissions(nc_any):
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE)
nc_any.files.sharing.delete(new_share)
# starting from Nextcloud 30 permissions are: FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
# https://github.com/nextcloud/server/commit/0bde47a39256dfad3baa8d3ffa275ac3d113a9d5#diff-dbbe017dd357504abc442a6f1d0305166520ebf80353f42814b3f879a3e241bc
assert (
new_share.permissions
== FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
or new_share.permissions == FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
)
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_DELETE)
nc_any.files.sharing.delete(new_share)
Expand All @@ -107,9 +110,12 @@ async def test_create_permissions_async(anc_any):
"test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE
)
await anc_any.files.sharing.delete(new_share)
# starting from Nextcloud 30 permissions are: FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
# https://github.com/nextcloud/server/commit/0bde47a39256dfad3baa8d3ffa275ac3d113a9d5#diff-dbbe017dd357504abc442a6f1d0305166520ebf80353f42814b3f879a3e241bc
assert (
new_share.permissions
== FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
or new_share.permissions == FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
)
new_share = await anc_any.files.sharing.create(
"test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_DELETE
Expand Down
4 changes: 2 additions & 2 deletions tests/gfixture_set_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
if not environ.get("CI", False): # For local tests
environ["NC_AUTH_USER"] = "admin"
environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ"
environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable27.local")
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable28.local")
environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable29.local")
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable30.local")
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local")
environ["APP_ID"] = "nc_py_api"
environ["APP_VERSION"] = "1.0.0"
Expand Down

0 comments on commit fb6b2bb

Please sign in to comment.