Skip to content

Commit fb6b2bb

Browse files
authored
NC30: webhook_listeners app support (#272)
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 <[email protected]>
1 parent 1926973 commit fb6b2bb

File tree

8 files changed

+248
-4
lines changed

8 files changed

+248
-4
lines changed

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22

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

5-
## [0.14.0 - 2024-07-0X]
5+
## [0.15.0 - 2024-07-19]
6+
7+
### Added
8+
9+
- Initial Webhooks API support for the upcoming Nextcloud 30. #272
10+
11+
### Changed
12+
13+
- NextcloudApp: `fetch_models_task` function now saves paths to downloaded models. #274 Thanks to @kyteinsky
14+
15+
## [0.14.0 - 2024-07-09]
616

717
### Added
818

docs/reference/Webhooks.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.. py:currentmodule:: nc_py_api.webhooks
2+
3+
Webhooks API
4+
============
5+
6+
.. autoclass:: nc_py_api.webhooks.WebhookInfo
7+
:members:
8+
9+
.. autoclass:: nc_py_api.webhooks._WebhooksAPI
10+
:members:

docs/reference/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ Reference
1717
Notes
1818
Session
1919
LoginFlowV2
20+
Webhooks

nc_py_api/nextcloud.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .users import _AsyncUsersAPI, _UsersAPI
4545
from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI
4646
from .weather_status import _AsyncWeatherStatusAPI, _WeatherStatusAPI
47+
from .webhooks import _AsyncWebhooksAPI, _WebhooksAPI
4748

4849

4950
class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
@@ -71,6 +72,8 @@ class _NextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
7172
"""Nextcloud API for managing users statuses"""
7273
weather_status: _WeatherStatusAPI
7374
"""Nextcloud API for managing user weather statuses"""
75+
webhooks: _WebhooksAPI
76+
"""Nextcloud API for managing webhooks"""
7477
_session: NcSessionBasic
7578

7679
def __init__(self, session: NcSessionBasic):
@@ -86,6 +89,7 @@ def __init__(self, session: NcSessionBasic):
8689
self.users_groups = _UsersGroupsAPI(session)
8790
self.user_status = _UserStatusAPI(session)
8891
self.weather_status = _WeatherStatusAPI(session)
92+
self.webhooks = _WebhooksAPI(session)
8993

9094
@property
9195
def capabilities(self) -> dict:
@@ -169,6 +173,8 @@ class _AsyncNextcloudBasic(ABC): # pylint: disable=too-many-instance-attributes
169173
"""Nextcloud API for managing users statuses"""
170174
weather_status: _AsyncWeatherStatusAPI
171175
"""Nextcloud API for managing user weather statuses"""
176+
webhooks: _AsyncWebhooksAPI
177+
"""Nextcloud API for managing webhooks"""
172178
_session: AsyncNcSessionBasic
173179

174180
def __init__(self, session: AsyncNcSessionBasic):
@@ -184,6 +190,7 @@ def __init__(self, session: AsyncNcSessionBasic):
184190
self.users_groups = _AsyncUsersGroupsAPI(session)
185191
self.user_status = _AsyncUserStatusAPI(session)
186192
self.weather_status = _AsyncWeatherStatusAPI(session)
193+
self.webhooks = _AsyncWebhooksAPI(session)
187194

188195
@property
189196
async def capabilities(self) -> dict:

nc_py_api/webhooks.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Nextcloud Webhooks API."""
2+
3+
import dataclasses
4+
5+
from ._misc import clear_from_params_empty # , require_capabilities
6+
from ._session import AsyncNcSessionBasic, NcSessionBasic
7+
8+
9+
@dataclasses.dataclass
10+
class WebhookInfo:
11+
"""Information about the Webhook."""
12+
13+
def __init__(self, raw_data: dict):
14+
self._raw_data = raw_data
15+
16+
@property
17+
def webhook_id(self) -> int:
18+
"""`ID` of the webhook."""
19+
return self._raw_data["id"]
20+
21+
@property
22+
def app_id(self) -> str:
23+
"""`ID` of the ExApp that registered webhook."""
24+
return self._raw_data["appId"] if self._raw_data["appId"] else ""
25+
26+
@property
27+
def user_id(self) -> str:
28+
"""`UserID` if webhook was registered in user context."""
29+
return self._raw_data["userId"] if self._raw_data["userId"] else ""
30+
31+
@property
32+
def http_method(self) -> str:
33+
"""HTTP method used to call webhook."""
34+
return self._raw_data["httpMethod"]
35+
36+
@property
37+
def uri(self) -> str:
38+
"""URL address that will be called for this webhook."""
39+
return self._raw_data["uri"]
40+
41+
@property
42+
def event(self) -> str:
43+
"""Nextcloud PHP event that triggers this webhook."""
44+
return self._raw_data["event"]
45+
46+
@property
47+
def event_filter(self):
48+
"""Mongo filter to apply to the serialized data to decide if firing."""
49+
return self._raw_data["eventFilter"]
50+
51+
@property
52+
def user_id_filter(self) -> str:
53+
"""Currently unknown."""
54+
return self._raw_data["userIdFilter"]
55+
56+
@property
57+
def headers(self) -> dict:
58+
"""Headers that should be added to request when calling webhook."""
59+
return self._raw_data["headers"] if self._raw_data["headers"] else {}
60+
61+
@property
62+
def auth_method(self) -> str:
63+
"""Currently unknown."""
64+
return self._raw_data["authMethod"]
65+
66+
@property
67+
def auth_data(self) -> dict:
68+
"""Currently unknown."""
69+
return self._raw_data["authData"] if self._raw_data["authData"] else {}
70+
71+
def __repr__(self):
72+
return f"<{self.__class__.__name__} id={self.webhook_id}, event={self.event}>"
73+
74+
75+
class _WebhooksAPI:
76+
"""The class provides the application management API on the Nextcloud server."""
77+
78+
_ep_base: str = "/ocs/v1.php/apps/webhook_listeners/api/v1/webhooks"
79+
80+
def __init__(self, session: NcSessionBasic):
81+
self._session = session
82+
83+
def get_list(self, uri_filter: str = "") -> list[WebhookInfo]:
84+
params = {"uri": uri_filter} if uri_filter else {}
85+
return [WebhookInfo(i) for i in self._session.ocs("GET", f"{self._ep_base}", params=params)]
86+
87+
def get_entry(self, webhook_id: int) -> WebhookInfo:
88+
return WebhookInfo(self._session.ocs("GET", f"{self._ep_base}/{webhook_id}"))
89+
90+
def register(
91+
self,
92+
http_method: str,
93+
uri: str,
94+
event: str,
95+
event_filter: dict | None = None,
96+
user_id_filter: str = "",
97+
headers: dict | None = None,
98+
auth_method: str = "none",
99+
auth_data: dict | None = None,
100+
):
101+
params = {
102+
"httpMethod": http_method,
103+
"uri": uri,
104+
"event": event,
105+
"eventFilter": event_filter,
106+
"userIdFilter": user_id_filter,
107+
"headers": headers,
108+
"authMethod": auth_method,
109+
"authData": auth_data,
110+
}
111+
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
112+
return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}", json=params))
113+
114+
def update(
115+
self,
116+
webhook_id: int,
117+
http_method: str,
118+
uri: str,
119+
event: str,
120+
event_filter: dict | None = None,
121+
user_id_filter: str = "",
122+
headers: dict | None = None,
123+
auth_method: str = "none",
124+
auth_data: dict | None = None,
125+
):
126+
params = {
127+
"id": webhook_id,
128+
"httpMethod": http_method,
129+
"uri": uri,
130+
"event": event,
131+
"eventFilter": event_filter,
132+
"userIdFilter": user_id_filter,
133+
"headers": headers,
134+
"authMethod": auth_method,
135+
"authData": auth_data,
136+
}
137+
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
138+
return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params))
139+
140+
def unregister(self, webhook_id: int) -> bool:
141+
return self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}")
142+
143+
144+
class _AsyncWebhooksAPI:
145+
"""The class provides the async application management API on the Nextcloud server."""
146+
147+
_ep_base: str = "/ocs/v1.php/webhooks"
148+
149+
def __init__(self, session: AsyncNcSessionBasic):
150+
self._session = session
151+
152+
async def get_list(self, uri_filter: str = "") -> list[WebhookInfo]:
153+
params = {"uri": uri_filter} if uri_filter else {}
154+
return [WebhookInfo(i) for i in await self._session.ocs("GET", f"{self._ep_base}", params=params)]
155+
156+
async def get_entry(self, webhook_id: int) -> WebhookInfo:
157+
return WebhookInfo(await self._session.ocs("GET", f"{self._ep_base}/{webhook_id}"))
158+
159+
async def register(
160+
self,
161+
http_method: str,
162+
uri: str,
163+
event: str,
164+
event_filter: dict | None = None,
165+
user_id_filter: str = "",
166+
headers: dict | None = None,
167+
auth_method: str = "none",
168+
auth_data: dict | None = None,
169+
):
170+
params = {
171+
"httpMethod": http_method,
172+
"uri": uri,
173+
"event": event,
174+
"eventFilter": event_filter,
175+
"userIdFilter": user_id_filter,
176+
"headers": headers,
177+
"authMethod": auth_method,
178+
"authData": auth_data,
179+
}
180+
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
181+
return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}", json=params))
182+
183+
async def update(
184+
self,
185+
webhook_id: int,
186+
http_method: str,
187+
uri: str,
188+
event: str,
189+
event_filter: dict | None = None,
190+
user_id_filter: str = "",
191+
headers: dict | None = None,
192+
auth_method: str = "none",
193+
auth_data: dict | None = None,
194+
):
195+
params = {
196+
"id": webhook_id,
197+
"httpMethod": http_method,
198+
"uri": uri,
199+
"event": event,
200+
"eventFilter": event_filter,
201+
"userIdFilter": user_id_filter,
202+
"headers": headers,
203+
"authMethod": auth_method,
204+
"authData": auth_data,
205+
}
206+
clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params)
207+
return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params))
208+
209+
async def unregister(self, webhook_id: int) -> bool:
210+
return await self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ design.max-attributes = 8
177177
design.max-locals = 20
178178
design.max-branches = 16
179179
design.max-returns = 8
180-
design.max-args = 8
180+
design.max-args = 10
181181
basic.good-names = [
182182
"a",
183183
"b",

tests/actual_tests/files_sharing_test.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,12 @@ async def test_share_fields_async(anc_any):
8383
def test_create_permissions(nc_any):
8484
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE)
8585
nc_any.files.sharing.delete(new_share)
86+
# starting from Nextcloud 30 permissions are: FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
87+
# https://github.com/nextcloud/server/commit/0bde47a39256dfad3baa8d3ffa275ac3d113a9d5#diff-dbbe017dd357504abc442a6f1d0305166520ebf80353f42814b3f879a3e241bc
8688
assert (
8789
new_share.permissions
8890
== FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
91+
or new_share.permissions == FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
8992
)
9093
new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_DELETE)
9194
nc_any.files.sharing.delete(new_share)
@@ -107,9 +110,12 @@ async def test_create_permissions_async(anc_any):
107110
"test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE
108111
)
109112
await anc_any.files.sharing.delete(new_share)
113+
# starting from Nextcloud 30 permissions are: FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
114+
# https://github.com/nextcloud/server/commit/0bde47a39256dfad3baa8d3ffa275ac3d113a9d5#diff-dbbe017dd357504abc442a6f1d0305166520ebf80353f42814b3f879a3e241bc
110115
assert (
111116
new_share.permissions
112117
== FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
118+
or new_share.permissions == FilePermissions.PERMISSION_CREATE | FilePermissions.PERMISSION_SHARE
113119
)
114120
new_share = await anc_any.files.sharing.create(
115121
"test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_DELETE

tests/gfixture_set_env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
if not environ.get("CI", False): # For local tests
44
environ["NC_AUTH_USER"] = "admin"
55
environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ"
6-
environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable27.local")
7-
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable28.local")
6+
environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable29.local")
7+
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable30.local")
88
# environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local")
99
environ["APP_ID"] = "nc_py_api"
1010
environ["APP_VERSION"] = "1.0.0"

0 commit comments

Comments
 (0)