Merge lp:~wgrant/launchpad/webhook-api into lp:launchpad
- webhook-api
- Merge into devel
Proposed by
William Grant
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 17633 | ||||
Proposed branch: | lp:~wgrant/launchpad/webhook-api | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~wgrant/launchpad/webhook-model | ||||
Diff against target: |
754 lines (+482/-13) 11 files modified
lib/lp/code/browser/gitrepository.py (+2/-1) lib/lp/code/interfaces/gitrepository.py (+2/-1) lib/lp/code/model/gitrepository.py (+2/-1) lib/lp/security.py (+14/-1) lib/lp/services/webhooks/browser.py (+49/-0) lib/lp/services/webhooks/configure.zcml (+27/-1) lib/lp/services/webhooks/interfaces.py (+74/-4) lib/lp/services/webhooks/model.py (+49/-1) lib/lp/services/webhooks/tests/test_webhook.py (+5/-3) lib/lp/services/webhooks/tests/test_webservice.py (+240/-0) lib/lp/services/webhooks/webservice.py (+18/-0) |
||||
To merge this branch: | bzr merge lp:~wgrant/launchpad/webhook-api | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+264005@code.launchpad.net |
Commit message
Add basic webhook API for Git repositories.
Description of the change
This branch adds a basic webhook API.
It's pretty much the minimal export of https:/
To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/browser/gitrepository.py' | |||
2 | --- lib/lp/code/browser/gitrepository.py 2015-07-08 16:05:11 +0000 | |||
3 | +++ lib/lp/code/browser/gitrepository.py 2015-07-12 23:52:28 +0000 | |||
4 | @@ -88,6 +88,7 @@ | |||
5 | 88 | from lp.services.webapp.breadcrumb import NameBreadcrumb | 88 | from lp.services.webapp.breadcrumb import NameBreadcrumb |
6 | 89 | from lp.services.webapp.escaping import structured | 89 | from lp.services.webapp.escaping import structured |
7 | 90 | from lp.services.webapp.interfaces import ICanonicalUrlData | 90 | from lp.services.webapp.interfaces import ICanonicalUrlData |
8 | 91 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin | ||
9 | 91 | 92 | ||
10 | 92 | 93 | ||
11 | 93 | @implementer(ICanonicalUrlData) | 94 | @implementer(ICanonicalUrlData) |
12 | @@ -112,7 +113,7 @@ | |||
13 | 112 | return self.context.target | 113 | return self.context.target |
14 | 113 | 114 | ||
15 | 114 | 115 | ||
17 | 115 | class GitRepositoryNavigation(Navigation): | 116 | class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation): |
18 | 116 | 117 | ||
19 | 117 | usedfor = IGitRepository | 118 | usedfor = IGitRepository |
20 | 118 | 119 | ||
21 | 119 | 120 | ||
22 | === modified file 'lib/lp/code/interfaces/gitrepository.py' | |||
23 | --- lib/lp/code/interfaces/gitrepository.py 2015-06-18 14:13:40 +0000 | |||
24 | +++ lib/lp/code/interfaces/gitrepository.py 2015-07-12 23:52:28 +0000 | |||
25 | @@ -77,6 +77,7 @@ | |||
26 | 77 | PersonChoice, | 77 | PersonChoice, |
27 | 78 | PublicPersonChoice, | 78 | PublicPersonChoice, |
28 | 79 | ) | 79 | ) |
29 | 80 | from lp.services.webhooks.interfaces import IWebhookTarget | ||
30 | 80 | 81 | ||
31 | 81 | 82 | ||
32 | 82 | GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _( | 83 | GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _( |
33 | @@ -569,7 +570,7 @@ | |||
34 | 569 | "refs/heads/master."))) | 570 | "refs/heads/master."))) |
35 | 570 | 571 | ||
36 | 571 | 572 | ||
38 | 572 | class IGitRepositoryEdit(Interface): | 573 | class IGitRepositoryEdit(IWebhookTarget): |
39 | 573 | """IGitRepository methods that require launchpad.Edit permission.""" | 574 | """IGitRepository methods that require launchpad.Edit permission.""" |
40 | 574 | 575 | ||
41 | 575 | @mutator_for(IGitRepositoryView["name"]) | 576 | @mutator_for(IGitRepositoryView["name"]) |
42 | 576 | 577 | ||
43 | === modified file 'lib/lp/code/model/gitrepository.py' | |||
44 | --- lib/lp/code/model/gitrepository.py 2015-07-10 22:24:49 +0000 | |||
45 | +++ lib/lp/code/model/gitrepository.py 2015-07-12 23:52:28 +0000 | |||
46 | @@ -146,6 +146,7 @@ | |||
47 | 146 | get_property_cache, | 146 | get_property_cache, |
48 | 147 | ) | 147 | ) |
49 | 148 | from lp.services.webapp.authorization import available_with_permission | 148 | from lp.services.webapp.authorization import available_with_permission |
50 | 149 | from lp.services.webhooks.model import WebhookTargetMixin | ||
51 | 149 | 150 | ||
52 | 150 | 151 | ||
53 | 151 | object_type_map = { | 152 | object_type_map = { |
54 | @@ -168,7 +169,7 @@ | |||
55 | 168 | 169 | ||
56 | 169 | 170 | ||
57 | 170 | @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType) | 171 | @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType) |
59 | 171 | class GitRepository(StormBase, GitIdentityMixin): | 172 | class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
60 | 172 | """See `IGitRepository`.""" | 173 | """See `IGitRepository`.""" |
61 | 173 | 174 | ||
62 | 174 | __storm_table__ = 'GitRepository' | 175 | __storm_table__ = 'GitRepository' |
63 | 175 | 176 | ||
64 | === modified file 'lib/lp/security.py' | |||
65 | --- lib/lp/security.py 2015-07-12 23:52:28 +0000 | |||
66 | +++ lib/lp/security.py 2015-07-12 23:52:28 +0000 | |||
67 | @@ -183,7 +183,10 @@ | |||
68 | 183 | ) | 183 | ) |
69 | 184 | from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier | 184 | from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier |
70 | 185 | from lp.services.webapp.interfaces import ILaunchpadRoot | 185 | from lp.services.webapp.interfaces import ILaunchpadRoot |
72 | 186 | from lp.services.webhooks.interfaces import IWebhook | 186 | from lp.services.webhooks.interfaces import ( |
73 | 187 | IWebhook, | ||
74 | 188 | IWebhookDeliveryJob, | ||
75 | 189 | ) | ||
76 | 187 | from lp.services.worlddata.interfaces.country import ICountry | 190 | from lp.services.worlddata.interfaces.country import ICountry |
77 | 188 | from lp.services.worlddata.interfaces.language import ( | 191 | from lp.services.worlddata.interfaces.language import ( |
78 | 189 | ILanguage, | 192 | ILanguage, |
79 | @@ -3067,3 +3070,13 @@ | |||
80 | 3067 | def checkAuthenticated(self, user): | 3070 | def checkAuthenticated(self, user): |
81 | 3068 | return self.forwardCheckAuthenticated( | 3071 | return self.forwardCheckAuthenticated( |
82 | 3069 | user, self.obj.target, 'launchpad.Edit') | 3072 | user, self.obj.target, 'launchpad.Edit') |
83 | 3073 | |||
84 | 3074 | |||
85 | 3075 | class ViewWebhookDeliveryJob(DelegatedAuthorization): | ||
86 | 3076 | """Webhooks can be viewed and edited by someone who can edit the target.""" | ||
87 | 3077 | permission = 'launchpad.View' | ||
88 | 3078 | usedfor = IWebhookDeliveryJob | ||
89 | 3079 | |||
90 | 3080 | def __init__(self, obj): | ||
91 | 3081 | super(ViewWebhookDeliveryJob, self).__init__( | ||
92 | 3082 | obj, obj.webhook, 'launchpad.View') | ||
93 | 3070 | 3083 | ||
94 | === added file 'lib/lp/services/webhooks/browser.py' | |||
95 | --- lib/lp/services/webhooks/browser.py 1970-01-01 00:00:00 +0000 | |||
96 | +++ lib/lp/services/webhooks/browser.py 2015-07-12 23:52:28 +0000 | |||
97 | @@ -0,0 +1,49 @@ | |||
98 | 1 | # Copyright 2015 Canonical Ltd. This software is licensed under the | ||
99 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
100 | 3 | |||
101 | 4 | """Webhook browser and API classes.""" | ||
102 | 5 | |||
103 | 6 | __metaclass__ = type | ||
104 | 7 | |||
105 | 8 | __all__ = [ | ||
106 | 9 | 'WebhookNavigation', | ||
107 | 10 | 'WebhookTargetNavigationMixin', | ||
108 | 11 | ] | ||
109 | 12 | |||
110 | 13 | from zope.component import getUtility | ||
111 | 14 | |||
112 | 15 | from lp.services.webapp import ( | ||
113 | 16 | Navigation, | ||
114 | 17 | stepthrough, | ||
115 | 18 | ) | ||
116 | 19 | from lp.services.webhooks.interfaces import ( | ||
117 | 20 | IWebhook, | ||
118 | 21 | IWebhookSource, | ||
119 | 22 | ) | ||
120 | 23 | |||
121 | 24 | |||
122 | 25 | class WebhookNavigation(Navigation): | ||
123 | 26 | |||
124 | 27 | usedfor = IWebhook | ||
125 | 28 | |||
126 | 29 | @stepthrough('+delivery') | ||
127 | 30 | def traverse_delivery(self, id): | ||
128 | 31 | try: | ||
129 | 32 | id = int(id) | ||
130 | 33 | except ValueError: | ||
131 | 34 | return None | ||
132 | 35 | return self.context.getDelivery(id) | ||
133 | 36 | |||
134 | 37 | |||
135 | 38 | class WebhookTargetNavigationMixin: | ||
136 | 39 | |||
137 | 40 | @stepthrough('+webhook') | ||
138 | 41 | def traverse_webhook(self, id): | ||
139 | 42 | try: | ||
140 | 43 | id = int(id) | ||
141 | 44 | except ValueError: | ||
142 | 45 | return None | ||
143 | 46 | webhook = getUtility(IWebhookSource).getByID(id) | ||
144 | 47 | if webhook is None or webhook.target != self.context: | ||
145 | 48 | return None | ||
146 | 49 | return webhook | ||
147 | 0 | 50 | ||
148 | === modified file 'lib/lp/services/webhooks/configure.zcml' | |||
149 | --- lib/lp/services/webhooks/configure.zcml 2015-07-12 23:52:28 +0000 | |||
150 | +++ lib/lp/services/webhooks/configure.zcml 2015-07-12 23:52:28 +0000 | |||
151 | @@ -2,7 +2,10 @@ | |||
152 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | 2 | GNU Affero General Public License version 3 (see the file LICENSE). |
153 | 3 | --> | 3 | --> |
154 | 4 | 4 | ||
156 | 5 | <configure xmlns="http://namespaces.zope.org/zope"> | 5 | <configure |
157 | 6 | xmlns="http://namespaces.zope.org/zope" | ||
158 | 7 | xmlns:browser="http://namespaces.zope.org/browser" | ||
159 | 8 | xmlns:webservice="http://namespaces.canonical.com/webservice"> | ||
160 | 6 | 9 | ||
161 | 7 | <class class="lp.services.webhooks.model.Webhook"> | 10 | <class class="lp.services.webhooks.model.Webhook"> |
162 | 8 | <require | 11 | <require |
163 | @@ -14,6 +17,12 @@ | |||
164 | 14 | for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent" | 17 | for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent" |
165 | 15 | handler="lp.services.webhooks.model.webhook_modified"/> | 18 | handler="lp.services.webhooks.model.webhook_modified"/> |
166 | 16 | 19 | ||
167 | 20 | <class class="lp.services.webhooks.model.WebhookDeliveryJob"> | ||
168 | 21 | <require | ||
169 | 22 | permission="launchpad.View" | ||
170 | 23 | interface="lp.services.webhooks.interfaces.IWebhookDeliveryJob"/> | ||
171 | 24 | </class> | ||
172 | 25 | |||
173 | 17 | <securedutility | 26 | <securedutility |
174 | 18 | class="lp.services.webhooks.model.WebhookSource" | 27 | class="lp.services.webhooks.model.WebhookSource" |
175 | 19 | provides="lp.services.webhooks.interfaces.IWebhookSource"> | 28 | provides="lp.services.webhooks.interfaces.IWebhookSource"> |
176 | @@ -25,4 +34,21 @@ | |||
177 | 25 | factory="lp.services.webhooks.client.WebhookClient" | 34 | factory="lp.services.webhooks.client.WebhookClient" |
178 | 26 | permission="zope.Public"/> | 35 | permission="zope.Public"/> |
179 | 27 | 36 | ||
180 | 37 | <browser:url | ||
181 | 38 | for="lp.services.webhooks.interfaces.IWebhook" | ||
182 | 39 | path_expression="string:+webhook/${id}" | ||
183 | 40 | attribute_to_parent="target" | ||
184 | 41 | /> | ||
185 | 42 | <browser:navigation | ||
186 | 43 | module="lp.services.webhooks.browser" classes="WebhookNavigation" /> | ||
187 | 44 | |||
188 | 45 | <browser:url | ||
189 | 46 | for="lp.services.webhooks.interfaces.IWebhookDeliveryJob" | ||
190 | 47 | path_expression="string:+delivery/${job_id}" | ||
191 | 48 | attribute_to_parent="webhook" | ||
192 | 49 | /> | ||
193 | 50 | |||
194 | 51 | <webservice:register module="lp.services.webhooks.webservice" /> | ||
195 | 52 | |||
196 | 53 | |||
197 | 28 | </configure> | 54 | </configure> |
198 | 29 | 55 | ||
199 | === modified file 'lib/lp/services/webhooks/interfaces.py' | |||
200 | --- lib/lp/services/webhooks/interfaces.py 2015-07-12 23:52:28 +0000 | |||
201 | +++ lib/lp/services/webhooks/interfaces.py 2015-07-12 23:52:28 +0000 | |||
202 | @@ -12,10 +12,26 @@ | |||
203 | 12 | 'IWebhookDeliveryJobSource', | 12 | 'IWebhookDeliveryJobSource', |
204 | 13 | 'IWebhookJob', | 13 | 'IWebhookJob', |
205 | 14 | 'IWebhookSource', | 14 | 'IWebhookSource', |
206 | 15 | 'IWebhookTarget', | ||
207 | 16 | 'WebhookFeatureDisabled', | ||
208 | 15 | ] | 17 | ] |
209 | 16 | 18 | ||
212 | 17 | from lazr.restful.declarations import exported | 19 | import httplib |
213 | 18 | from lazr.restful.fields import Reference | 20 | |
214 | 21 | from lazr.lifecycle.snapshot import doNotSnapshot | ||
215 | 22 | from lazr.restful.declarations import ( | ||
216 | 23 | call_with, | ||
217 | 24 | error_status, | ||
218 | 25 | export_as_webservice_entry, | ||
219 | 26 | export_factory_operation, | ||
220 | 27 | exported, | ||
221 | 28 | operation_for_version, | ||
222 | 29 | REQUEST_USER, | ||
223 | 30 | ) | ||
224 | 31 | from lazr.restful.fields import ( | ||
225 | 32 | CollectionField, | ||
226 | 33 | Reference, | ||
227 | 34 | ) | ||
228 | 19 | from zope.interface import ( | 35 | from zope.interface import ( |
229 | 20 | Attribute, | 36 | Attribute, |
230 | 21 | Interface, | 37 | Interface, |
231 | @@ -36,14 +52,31 @@ | |||
232 | 36 | IJobSource, | 52 | IJobSource, |
233 | 37 | IRunnableJob, | 53 | IRunnableJob, |
234 | 38 | ) | 54 | ) |
235 | 55 | from lp.services.webservice.apihelpers import ( | ||
236 | 56 | patch_collection_property, | ||
237 | 57 | patch_entry_return_type, | ||
238 | 58 | patch_reference_property, | ||
239 | 59 | ) | ||
240 | 60 | |||
241 | 61 | |||
242 | 62 | @error_status(httplib.UNAUTHORIZED) | ||
243 | 63 | class WebhookFeatureDisabled(Exception): | ||
244 | 64 | """Only certain users can create new Git repositories.""" | ||
245 | 65 | |||
246 | 66 | def __init__(self): | ||
247 | 67 | Exception.__init__( | ||
248 | 68 | self, "This webhook feature is not available yet.") | ||
249 | 39 | 69 | ||
250 | 40 | 70 | ||
251 | 41 | class IWebhook(Interface): | 71 | class IWebhook(Interface): |
252 | 42 | 72 | ||
253 | 73 | export_as_webservice_entry(as_of='beta') | ||
254 | 74 | |||
255 | 43 | id = Int(title=_("ID"), readonly=True, required=True) | 75 | id = Int(title=_("ID"), readonly=True, required=True) |
256 | 44 | 76 | ||
257 | 45 | target = exported(Reference( | 77 | target = exported(Reference( |
259 | 46 | title=_("Target"), schema=IPerson, required=True, readonly=True, | 78 | title=_("Target"), schema=Interface, # Actually IWebhookTarget. |
260 | 79 | required=True, readonly=True, | ||
261 | 47 | description=_("The object for which this webhook receives events."))) | 80 | description=_("The object for which this webhook receives events."))) |
262 | 48 | event_types = exported(List( | 81 | event_types = exported(List( |
263 | 49 | TextLine(), title=_("Event types"), | 82 | TextLine(), title=_("Event types"), |
264 | @@ -53,18 +86,32 @@ | |||
265 | 53 | registrant = exported(Reference( | 86 | registrant = exported(Reference( |
266 | 54 | title=_("Registrant"), schema=IPerson, required=True, readonly=True, | 87 | title=_("Registrant"), schema=IPerson, required=True, readonly=True, |
267 | 55 | description=_("The person who created this webhook."))) | 88 | description=_("The person who created this webhook."))) |
268 | 89 | registrant_id = Int(title=_("Registrant ID")) | ||
269 | 56 | date_created = exported(Datetime( | 90 | date_created = exported(Datetime( |
270 | 57 | title=_("Date created"), required=True, readonly=True)) | 91 | title=_("Date created"), required=True, readonly=True)) |
271 | 58 | date_last_modified = exported(Datetime( | 92 | date_last_modified = exported(Datetime( |
272 | 59 | title=_("Date last modified"), required=True, readonly=True)) | 93 | title=_("Date last modified"), required=True, readonly=True)) |
273 | 60 | 94 | ||
275 | 61 | delivery_url = exported(Bool( | 95 | delivery_url = exported(TextLine( |
276 | 62 | title=_("URL"), required=True, readonly=False)) | 96 | title=_("URL"), required=True, readonly=False)) |
277 | 63 | active = exported(Bool( | 97 | active = exported(Bool( |
278 | 64 | title=_("Active"), required=True, readonly=False)) | 98 | title=_("Active"), required=True, readonly=False)) |
279 | 65 | secret = TextLine( | 99 | secret = TextLine( |
280 | 66 | title=_("Unique name"), required=False, readonly=True) | 100 | title=_("Unique name"), required=False, readonly=True) |
281 | 67 | 101 | ||
282 | 102 | deliveries = exported(doNotSnapshot(CollectionField( | ||
283 | 103 | title=_("Recent deliveries for this webhook."), | ||
284 | 104 | value_type=Reference(schema=Interface), | ||
285 | 105 | readonly=True))) | ||
286 | 106 | |||
287 | 107 | def getDelivery(id): | ||
288 | 108 | """Retrieve a delivery by ID, or None if it doesn't exist.""" | ||
289 | 109 | |||
290 | 110 | @export_factory_operation(Interface, []) # Actually IWebhookDelivery. | ||
291 | 111 | @operation_for_version('devel') | ||
292 | 112 | def ping(): | ||
293 | 113 | """Send a test event.""" | ||
294 | 114 | |||
295 | 68 | 115 | ||
296 | 69 | class IWebhookSource(Interface): | 116 | class IWebhookSource(Interface): |
297 | 70 | 117 | ||
298 | @@ -81,6 +128,23 @@ | |||
299 | 81 | """Find all webhooks for the given target.""" | 128 | """Find all webhooks for the given target.""" |
300 | 82 | 129 | ||
301 | 83 | 130 | ||
302 | 131 | class IWebhookTarget(Interface): | ||
303 | 132 | |||
304 | 133 | export_as_webservice_entry(as_of='beta') | ||
305 | 134 | |||
306 | 135 | webhooks = exported(doNotSnapshot(CollectionField( | ||
307 | 136 | title=_("Webhooks for this target."), | ||
308 | 137 | value_type=Reference(schema=IWebhook), | ||
309 | 138 | readonly=True))) | ||
310 | 139 | |||
311 | 140 | @call_with(registrant=REQUEST_USER) | ||
312 | 141 | @export_factory_operation( | ||
313 | 142 | IWebhook, ['delivery_url', 'active', 'event_types']) | ||
314 | 143 | @operation_for_version("devel") | ||
315 | 144 | def newWebhook(registrant, delivery_url, event_types, active=True): | ||
316 | 145 | """Create a new webhook.""" | ||
317 | 146 | |||
318 | 147 | |||
319 | 84 | class IWebhookJob(Interface): | 148 | class IWebhookJob(Interface): |
320 | 85 | """A job related to a webhook.""" | 149 | """A job related to a webhook.""" |
321 | 86 | 150 | ||
322 | @@ -98,6 +162,8 @@ | |||
323 | 98 | class IWebhookDeliveryJob(IRunnableJob): | 162 | class IWebhookDeliveryJob(IRunnableJob): |
324 | 99 | """A Job that delivers an event to a webhook consumer.""" | 163 | """A Job that delivers an event to a webhook consumer.""" |
325 | 100 | 164 | ||
326 | 165 | export_as_webservice_entry('webhook_delivery', as_of='beta') | ||
327 | 166 | |||
328 | 101 | webhook = exported(Reference( | 167 | webhook = exported(Reference( |
329 | 102 | title=_("Webhook"), | 168 | title=_("Webhook"), |
330 | 103 | description=_("The webhook that this delivery is for."), | 169 | description=_("The webhook that this delivery is for."), |
331 | @@ -151,3 +217,7 @@ | |||
332 | 151 | return a response, and a DNS error returns a connection_error, but | 217 | return a response, and a DNS error returns a connection_error, but |
333 | 152 | the proxy being offline will raise an exception. | 218 | the proxy being offline will raise an exception. |
334 | 153 | """ | 219 | """ |
335 | 220 | |||
336 | 221 | patch_collection_property(IWebhook, 'deliveries', IWebhookDeliveryJob) | ||
337 | 222 | patch_entry_return_type(IWebhook, 'ping', IWebhookDeliveryJob) | ||
338 | 223 | patch_reference_property(IWebhook, 'target', IWebhookTarget) | ||
339 | 154 | 224 | ||
340 | === modified file 'lib/lp/services/webhooks/model.py' | |||
341 | --- lib/lp/services/webhooks/model.py 2015-07-12 23:52:28 +0000 | |||
342 | +++ lib/lp/services/webhooks/model.py 2015-07-12 23:52:28 +0000 | |||
343 | @@ -7,6 +7,7 @@ | |||
344 | 7 | 'Webhook', | 7 | 'Webhook', |
345 | 8 | 'WebhookJob', | 8 | 'WebhookJob', |
346 | 9 | 'WebhookJobType', | 9 | 'WebhookJobType', |
347 | 10 | 'WebhookTargetMixin', | ||
348 | 10 | ] | 11 | ] |
349 | 11 | 12 | ||
350 | 12 | import datetime | 13 | import datetime |
351 | @@ -26,6 +27,7 @@ | |||
352 | 26 | Unicode, | 27 | Unicode, |
353 | 27 | ) | 28 | ) |
354 | 28 | from storm.references import Reference | 29 | from storm.references import Reference |
355 | 30 | from storm.store import Store | ||
356 | 29 | from zope.component import getUtility | 31 | from zope.component import getUtility |
357 | 30 | from zope.interface import ( | 32 | from zope.interface import ( |
358 | 31 | implementer, | 33 | implementer, |
359 | @@ -33,11 +35,15 @@ | |||
360 | 33 | ) | 35 | ) |
361 | 34 | from zope.security.proxy import removeSecurityProxy | 36 | from zope.security.proxy import removeSecurityProxy |
362 | 35 | 37 | ||
363 | 38 | from lp.registry.model.person import Person | ||
364 | 36 | from lp.services.config import config | 39 | from lp.services.config import config |
365 | 40 | from lp.services.database.bulk import load_related | ||
366 | 37 | from lp.services.database.constants import UTC_NOW | 41 | from lp.services.database.constants import UTC_NOW |
367 | 42 | from lp.services.database.decoratedresultset import DecoratedResultSet | ||
368 | 38 | from lp.services.database.enumcol import EnumCol | 43 | from lp.services.database.enumcol import EnumCol |
369 | 39 | from lp.services.database.interfaces import IStore | 44 | from lp.services.database.interfaces import IStore |
370 | 40 | from lp.services.database.stormbase import StormBase | 45 | from lp.services.database.stormbase import StormBase |
371 | 46 | from lp.services.features import getFeatureFlag | ||
372 | 41 | from lp.services.job.model.job import ( | 47 | from lp.services.job.model.job import ( |
373 | 42 | EnumeratedSubclass, | 48 | EnumeratedSubclass, |
374 | 43 | Job, | 49 | Job, |
375 | @@ -49,6 +55,8 @@ | |||
376 | 49 | IWebhookDeliveryJob, | 55 | IWebhookDeliveryJob, |
377 | 50 | IWebhookDeliveryJobSource, | 56 | IWebhookDeliveryJobSource, |
378 | 51 | IWebhookJob, | 57 | IWebhookJob, |
379 | 58 | IWebhookSource, | ||
380 | 59 | WebhookFeatureDisabled, | ||
381 | 52 | ) | 60 | ) |
382 | 53 | 61 | ||
383 | 54 | 62 | ||
384 | @@ -80,7 +88,7 @@ | |||
385 | 80 | 88 | ||
386 | 81 | delivery_url = Unicode(allow_none=False) | 89 | delivery_url = Unicode(allow_none=False) |
387 | 82 | active = Bool(default=True, allow_none=False) | 90 | active = Bool(default=True, allow_none=False) |
389 | 83 | secret = Unicode(allow_none=False) | 91 | secret = Unicode(allow_none=True) |
390 | 84 | 92 | ||
391 | 85 | json_data = JSON(name='json_data') | 93 | json_data = JSON(name='json_data') |
392 | 86 | 94 | ||
393 | @@ -92,6 +100,26 @@ | |||
394 | 92 | raise AssertionError("No target.") | 100 | raise AssertionError("No target.") |
395 | 93 | 101 | ||
396 | 94 | @property | 102 | @property |
397 | 103 | def deliveries(self): | ||
398 | 104 | jobs = Store.of(self).find( | ||
399 | 105 | WebhookJob, | ||
400 | 106 | WebhookJob.webhook == self, | ||
401 | 107 | WebhookJob.job_type == WebhookJobType.DELIVERY, | ||
402 | 108 | ).order_by(WebhookJob.job_id) | ||
403 | 109 | |||
404 | 110 | def preload_jobs(rows): | ||
405 | 111 | load_related(Job, rows, ['job_id']) | ||
406 | 112 | |||
407 | 113 | return DecoratedResultSet( | ||
408 | 114 | jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs) | ||
409 | 115 | |||
410 | 116 | def getDelivery(self, id): | ||
411 | 117 | return self.deliveries.find(WebhookJob.job_id == id).one() | ||
412 | 118 | |||
413 | 119 | def ping(self): | ||
414 | 120 | return WebhookDeliveryJob.create(self, {'ping': True}) | ||
415 | 121 | |||
416 | 122 | @property | ||
417 | 95 | def event_types(self): | 123 | def event_types(self): |
418 | 96 | return (self.json_data or {}).get('event_types', []) | 124 | return (self.json_data or {}).get('event_types', []) |
419 | 97 | 125 | ||
420 | @@ -104,6 +132,7 @@ | |||
421 | 104 | self.json_data = updated_data | 132 | self.json_data = updated_data |
422 | 105 | 133 | ||
423 | 106 | 134 | ||
424 | 135 | @implementer(IWebhookSource) | ||
425 | 107 | class WebhookSource: | 136 | class WebhookSource: |
426 | 108 | """See `IWebhookSource`.""" | 137 | """See `IWebhookSource`.""" |
427 | 109 | 138 | ||
428 | @@ -121,6 +150,7 @@ | |||
429 | 121 | hook.secret = secret | 150 | hook.secret = secret |
430 | 122 | hook.event_types = event_types | 151 | hook.event_types = event_types |
431 | 123 | IStore(Webhook).add(hook) | 152 | IStore(Webhook).add(hook) |
432 | 153 | IStore(Webhook).flush() | ||
433 | 124 | return hook | 154 | return hook |
434 | 125 | 155 | ||
435 | 126 | def delete(self, hooks): | 156 | def delete(self, hooks): |
436 | @@ -139,6 +169,24 @@ | |||
437 | 139 | return IStore(Webhook).find(Webhook, target_filter) | 169 | return IStore(Webhook).find(Webhook, target_filter) |
438 | 140 | 170 | ||
439 | 141 | 171 | ||
440 | 172 | class WebhookTargetMixin: | ||
441 | 173 | |||
442 | 174 | @property | ||
443 | 175 | def webhooks(self): | ||
444 | 176 | def preload_registrants(rows): | ||
445 | 177 | load_related(Person, rows, ['registrant_id']) | ||
446 | 178 | |||
447 | 179 | return DecoratedResultSet( | ||
448 | 180 | getUtility(IWebhookSource).findByTarget(self), | ||
449 | 181 | pre_iter_hook=preload_registrants) | ||
450 | 182 | |||
451 | 183 | def newWebhook(self, registrant, delivery_url, event_types, active=True): | ||
452 | 184 | if not getFeatureFlag('webhooks.new.enabled'): | ||
453 | 185 | raise WebhookFeatureDisabled() | ||
454 | 186 | return getUtility(IWebhookSource).new( | ||
455 | 187 | self, registrant, delivery_url, event_types, active, None) | ||
456 | 188 | |||
457 | 189 | |||
458 | 142 | class WebhookJobType(DBEnumeratedType): | 190 | class WebhookJobType(DBEnumeratedType): |
459 | 143 | """Values that `IWebhookJob.job_type` can take.""" | 191 | """Values that `IWebhookJob.job_type` can take.""" |
460 | 144 | 192 | ||
461 | 145 | 193 | ||
462 | === modified file 'lib/lp/services/webhooks/tests/test_webhook.py' | |||
463 | --- lib/lp/services/webhooks/tests/test_webhook.py 2015-07-12 23:52:28 +0000 | |||
464 | +++ lib/lp/services/webhooks/tests/test_webhook.py 2015-07-12 23:52:28 +0000 | |||
465 | @@ -66,8 +66,9 @@ | |||
466 | 66 | def test_get_permissions(self): | 66 | def test_get_permissions(self): |
467 | 67 | expected_get_permissions = { | 67 | expected_get_permissions = { |
468 | 68 | 'launchpad.View': set(( | 68 | 'launchpad.View': set(( |
471 | 69 | 'active', 'date_created', 'date_last_modified', 'delivery_url', | 69 | 'active', 'date_created', 'date_last_modified', 'deliveries', |
472 | 70 | 'event_types', 'id', 'registrant', 'secret', 'target')), | 70 | 'delivery_url', 'event_types', 'getDelivery', 'id', 'ping', |
473 | 71 | 'registrant', 'registrant_id', 'secret', 'target')), | ||
474 | 71 | } | 72 | } |
475 | 72 | webhook = self.factory.makeWebhook() | 73 | webhook = self.factory.makeWebhook() |
476 | 73 | checker = getChecker(webhook) | 74 | checker = getChecker(webhook) |
477 | @@ -76,7 +77,8 @@ | |||
478 | 76 | 77 | ||
479 | 77 | def test_set_permissions(self): | 78 | def test_set_permissions(self): |
480 | 78 | expected_set_permissions = { | 79 | expected_set_permissions = { |
482 | 79 | 'launchpad.View': set(('active', 'delivery_url', 'event_types')), | 80 | 'launchpad.View': set(( |
483 | 81 | 'active', 'delivery_url', 'event_types', 'registrant_id')), | ||
484 | 80 | } | 82 | } |
485 | 81 | webhook = self.factory.makeWebhook() | 83 | webhook = self.factory.makeWebhook() |
486 | 82 | checker = getChecker(webhook) | 84 | checker = getChecker(webhook) |
487 | 83 | 85 | ||
488 | === added file 'lib/lp/services/webhooks/tests/test_webservice.py' | |||
489 | --- lib/lp/services/webhooks/tests/test_webservice.py 1970-01-01 00:00:00 +0000 | |||
490 | +++ lib/lp/services/webhooks/tests/test_webservice.py 2015-07-12 23:52:28 +0000 | |||
491 | @@ -0,0 +1,240 @@ | |||
492 | 1 | # Copyright 2015 Canonical Ltd. This software is licensed under the | ||
493 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
494 | 3 | |||
495 | 4 | """Tests for the webhook webservice objects.""" | ||
496 | 5 | |||
497 | 6 | __metaclass__ = type | ||
498 | 7 | |||
499 | 8 | import json | ||
500 | 9 | |||
501 | 10 | from testtools.matchers import ( | ||
502 | 11 | ContainsDict, | ||
503 | 12 | Equals, | ||
504 | 13 | GreaterThan, | ||
505 | 14 | Is, | ||
506 | 15 | KeysEqual, | ||
507 | 16 | MatchesAll, | ||
508 | 17 | Not, | ||
509 | 18 | ) | ||
510 | 19 | |||
511 | 20 | from lp.services.features.testing import FeatureFixture | ||
512 | 21 | from lp.services.webapp.interfaces import OAuthPermission | ||
513 | 22 | from lp.testing import ( | ||
514 | 23 | api_url, | ||
515 | 24 | person_logged_in, | ||
516 | 25 | record_two_runs, | ||
517 | 26 | TestCaseWithFactory, | ||
518 | 27 | ) | ||
519 | 28 | from lp.testing.layers import DatabaseFunctionalLayer | ||
520 | 29 | from lp.testing.matchers import HasQueryCount | ||
521 | 30 | from lp.testing.pages import ( | ||
522 | 31 | LaunchpadWebServiceCaller, | ||
523 | 32 | webservice_for_person, | ||
524 | 33 | ) | ||
525 | 34 | |||
526 | 35 | |||
527 | 36 | class TestWebhook(TestCaseWithFactory): | ||
528 | 37 | layer = DatabaseFunctionalLayer | ||
529 | 38 | |||
530 | 39 | def setUp(self): | ||
531 | 40 | super(TestWebhook, self).setUp() | ||
532 | 41 | target = self.factory.makeGitRepository() | ||
533 | 42 | self.owner = target.owner | ||
534 | 43 | with person_logged_in(self.owner): | ||
535 | 44 | self.webhook = self.factory.makeWebhook( | ||
536 | 45 | target=target, delivery_url=u'http://example.com/ep') | ||
537 | 46 | self.webhook_url = api_url(self.webhook) | ||
538 | 47 | self.webservice = webservice_for_person( | ||
539 | 48 | self.owner, permission=OAuthPermission.WRITE_PRIVATE) | ||
540 | 49 | |||
541 | 50 | def test_get(self): | ||
542 | 51 | representation = self.webservice.get( | ||
543 | 52 | self.webhook_url, api_version='devel').jsonBody() | ||
544 | 53 | self.assertThat( | ||
545 | 54 | representation, | ||
546 | 55 | KeysEqual( | ||
547 | 56 | 'active', 'date_created', 'date_last_modified', | ||
548 | 57 | 'deliveries_collection_link', 'delivery_url', 'event_types', | ||
549 | 58 | 'http_etag', 'registrant_link', 'resource_type_link', | ||
550 | 59 | 'self_link', 'target_link', 'web_link')) | ||
551 | 60 | |||
552 | 61 | def test_patch(self): | ||
553 | 62 | representation = self.webservice.get( | ||
554 | 63 | self.webhook_url, api_version='devel').jsonBody() | ||
555 | 64 | self.assertThat( | ||
556 | 65 | representation, | ||
557 | 66 | ContainsDict( | ||
558 | 67 | {'active': Equals(True), | ||
559 | 68 | 'delivery_url': Equals('http://example.com/ep'), | ||
560 | 69 | 'event_types': Equals([])})) | ||
561 | 70 | old_mtime = representation['date_last_modified'] | ||
562 | 71 | patch = json.dumps( | ||
563 | 72 | {'active': False, 'delivery_url': 'http://example.com/ep2', | ||
564 | 73 | 'event_types': ['foo', 'bar']}) | ||
565 | 74 | self.webservice.patch( | ||
566 | 75 | self.webhook_url, 'application/json', patch, api_version='devel') | ||
567 | 76 | representation = self.webservice.get( | ||
568 | 77 | self.webhook_url, api_version='devel').jsonBody() | ||
569 | 78 | self.assertThat( | ||
570 | 79 | representation, | ||
571 | 80 | ContainsDict( | ||
572 | 81 | {'active': Equals(False), | ||
573 | 82 | 'delivery_url': Equals('http://example.com/ep2'), | ||
574 | 83 | 'date_last_modified': GreaterThan(old_mtime), | ||
575 | 84 | 'event_types': Equals(['foo', 'bar'])})) | ||
576 | 85 | |||
577 | 86 | def test_anon_forbidden(self): | ||
578 | 87 | response = LaunchpadWebServiceCaller().get( | ||
579 | 88 | self.webhook_url, api_version='devel') | ||
580 | 89 | self.assertEqual(401, response.status) | ||
581 | 90 | self.assertIn('launchpad.View', response.body) | ||
582 | 91 | |||
583 | 92 | def test_deliveries(self): | ||
584 | 93 | representation = self.webservice.get( | ||
585 | 94 | self.webhook_url + '/deliveries', api_version='devel').jsonBody() | ||
586 | 95 | self.assertContentEqual( | ||
587 | 96 | [], [entry['payload'] for entry in representation['entries']]) | ||
588 | 97 | |||
589 | 98 | # Send a test event. | ||
590 | 99 | response = self.webservice.named_post( | ||
591 | 100 | self.webhook_url, 'ping', api_version='devel') | ||
592 | 101 | self.assertEqual(201, response.status) | ||
593 | 102 | delivery = self.webservice.get( | ||
594 | 103 | response.getHeader("Location")).jsonBody() | ||
595 | 104 | self.assertEqual({'ping': True}, delivery['payload']) | ||
596 | 105 | |||
597 | 106 | # The delivery shows up in the collection. | ||
598 | 107 | representation = self.webservice.get( | ||
599 | 108 | self.webhook_url + '/deliveries', api_version='devel').jsonBody() | ||
600 | 109 | self.assertContentEqual( | ||
601 | 110 | [delivery['self_link']], | ||
602 | 111 | [entry['self_link'] for entry in representation['entries']]) | ||
603 | 112 | |||
604 | 113 | def test_deliveries_query_count(self): | ||
605 | 114 | def get_deliveries(): | ||
606 | 115 | representation = self.webservice.get( | ||
607 | 116 | self.webhook_url + '/deliveries', | ||
608 | 117 | api_version='devel').jsonBody() | ||
609 | 118 | self.assertIn(len(representation['entries']), (0, 2, 4)) | ||
610 | 119 | |||
611 | 120 | def create_delivery(): | ||
612 | 121 | with person_logged_in(self.owner): | ||
613 | 122 | self.webhook.ping() | ||
614 | 123 | |||
615 | 124 | get_deliveries() | ||
616 | 125 | recorder1, recorder2 = record_two_runs( | ||
617 | 126 | get_deliveries, create_delivery, 2) | ||
618 | 127 | self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count))) | ||
619 | 128 | |||
620 | 129 | |||
621 | 130 | class TestWebhookDelivery(TestCaseWithFactory): | ||
622 | 131 | layer = DatabaseFunctionalLayer | ||
623 | 132 | |||
624 | 133 | def setUp(self): | ||
625 | 134 | super(TestWebhookDelivery, self).setUp() | ||
626 | 135 | target = self.factory.makeGitRepository() | ||
627 | 136 | self.owner = target.owner | ||
628 | 137 | with person_logged_in(self.owner): | ||
629 | 138 | self.webhook = self.factory.makeWebhook( | ||
630 | 139 | target=target, delivery_url=u'http://example.com/ep') | ||
631 | 140 | self.webhook_url = api_url(self.webhook) | ||
632 | 141 | self.delivery = self.webhook.ping() | ||
633 | 142 | self.delivery_url = api_url(self.delivery) | ||
634 | 143 | self.webservice = webservice_for_person( | ||
635 | 144 | self.owner, permission=OAuthPermission.WRITE_PRIVATE) | ||
636 | 145 | |||
637 | 146 | def test_get(self): | ||
638 | 147 | representation = self.webservice.get( | ||
639 | 148 | self.delivery_url, api_version='devel').jsonBody() | ||
640 | 149 | self.assertThat( | ||
641 | 150 | representation, | ||
642 | 151 | MatchesAll( | ||
643 | 152 | KeysEqual( | ||
644 | 153 | 'date_created', 'date_sent', 'http_etag', 'payload', | ||
645 | 154 | 'pending', 'resource_type_link', 'self_link', | ||
646 | 155 | 'successful', 'web_link', 'webhook_link'), | ||
647 | 156 | ContainsDict( | ||
648 | 157 | {'payload': Equals({'ping': True}), | ||
649 | 158 | 'pending': Equals(True), | ||
650 | 159 | 'successful': Is(None), | ||
651 | 160 | 'date_created': Not(Is(None)), | ||
652 | 161 | 'date_sent': Is(None)}))) | ||
653 | 162 | |||
654 | 163 | |||
655 | 164 | class TestWebhookTarget(TestCaseWithFactory): | ||
656 | 165 | layer = DatabaseFunctionalLayer | ||
657 | 166 | |||
658 | 167 | def setUp(self): | ||
659 | 168 | super(TestWebhookTarget, self).setUp() | ||
660 | 169 | self.target = self.factory.makeGitRepository() | ||
661 | 170 | self.owner = self.target.owner | ||
662 | 171 | self.target_url = api_url(self.target) | ||
663 | 172 | self.webservice = webservice_for_person( | ||
664 | 173 | self.owner, permission=OAuthPermission.WRITE_PRIVATE) | ||
665 | 174 | |||
666 | 175 | def test_webhooks(self): | ||
667 | 176 | with person_logged_in(self.owner): | ||
668 | 177 | for ep in (u'http://example.com/ep1', u'http://example.com/ep2'): | ||
669 | 178 | self.factory.makeWebhook(target=self.target, delivery_url=ep) | ||
670 | 179 | representation = self.webservice.get( | ||
671 | 180 | self.target_url + '/webhooks', api_version='devel').jsonBody() | ||
672 | 181 | self.assertContentEqual( | ||
673 | 182 | ['http://example.com/ep1', 'http://example.com/ep2'], | ||
674 | 183 | [entry['delivery_url'] for entry in representation['entries']]) | ||
675 | 184 | |||
676 | 185 | def test_webhooks_permissions(self): | ||
677 | 186 | webservice = LaunchpadWebServiceCaller() | ||
678 | 187 | response = webservice.get( | ||
679 | 188 | self.target_url + '/webhooks', api_version='devel') | ||
680 | 189 | self.assertEqual(401, response.status) | ||
681 | 190 | self.assertIn('launchpad.Edit', response.body) | ||
682 | 191 | |||
683 | 192 | def test_webhooks_query_count(self): | ||
684 | 193 | def get_webhooks(): | ||
685 | 194 | representation = self.webservice.get( | ||
686 | 195 | self.target_url + '/webhooks', | ||
687 | 196 | api_version='devel').jsonBody() | ||
688 | 197 | self.assertIn(len(representation['entries']), (0, 2, 4)) | ||
689 | 198 | |||
690 | 199 | def create_webhook(): | ||
691 | 200 | with person_logged_in(self.owner): | ||
692 | 201 | self.factory.makeWebhook(target=self.target) | ||
693 | 202 | |||
694 | 203 | get_webhooks() | ||
695 | 204 | recorder1, recorder2 = record_two_runs( | ||
696 | 205 | get_webhooks, create_webhook, 2) | ||
697 | 206 | self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count))) | ||
698 | 207 | |||
699 | 208 | def test_newWebhook(self): | ||
700 | 209 | self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'})) | ||
701 | 210 | response = self.webservice.named_post( | ||
702 | 211 | self.target_url, 'newWebhook', | ||
703 | 212 | delivery_url='http://example.com/ep', event_types=['foo', 'bar'], | ||
704 | 213 | api_version='devel') | ||
705 | 214 | self.assertEqual(201, response.status) | ||
706 | 215 | |||
707 | 216 | representation = self.webservice.get( | ||
708 | 217 | self.target_url + '/webhooks', api_version='devel').jsonBody() | ||
709 | 218 | self.assertContentEqual( | ||
710 | 219 | [('http://example.com/ep', ['foo', 'bar'], True)], | ||
711 | 220 | [(entry['delivery_url'], entry['event_types'], entry['active']) | ||
712 | 221 | for entry in representation['entries']]) | ||
713 | 222 | |||
714 | 223 | def test_newWebhook_permissions(self): | ||
715 | 224 | self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'})) | ||
716 | 225 | webservice = LaunchpadWebServiceCaller() | ||
717 | 226 | response = webservice.named_post( | ||
718 | 227 | self.target_url, 'newWebhook', | ||
719 | 228 | delivery_url='http://example.com/ep', event_types=['foo', 'bar'], | ||
720 | 229 | api_version='devel') | ||
721 | 230 | self.assertEqual(401, response.status) | ||
722 | 231 | self.assertIn('launchpad.Edit', response.body) | ||
723 | 232 | |||
724 | 233 | def test_newWebhook_feature_flag_guard(self): | ||
725 | 234 | response = self.webservice.named_post( | ||
726 | 235 | self.target_url, 'newWebhook', | ||
727 | 236 | delivery_url='http://example.com/ep', event_types=['foo', 'bar'], | ||
728 | 237 | api_version='devel') | ||
729 | 238 | self.assertEqual(401, response.status) | ||
730 | 239 | self.assertEqual( | ||
731 | 240 | 'This webhook feature is not available yet.', response.body) | ||
732 | 0 | 241 | ||
733 | === added file 'lib/lp/services/webhooks/webservice.py' | |||
734 | --- lib/lp/services/webhooks/webservice.py 1970-01-01 00:00:00 +0000 | |||
735 | +++ lib/lp/services/webhooks/webservice.py 2015-07-12 23:52:28 +0000 | |||
736 | @@ -0,0 +1,18 @@ | |||
737 | 1 | # Copyright 2015 Canonical Ltd. This software is licensed under the | ||
738 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
739 | 3 | |||
740 | 4 | """Webhook webservice registrations.""" | ||
741 | 5 | |||
742 | 6 | __metaclass__ = type | ||
743 | 7 | |||
744 | 8 | __all__ = [ | ||
745 | 9 | 'IWebhook', | ||
746 | 10 | 'IWebhookDeliveryJob', | ||
747 | 11 | 'IWebhookTarget', | ||
748 | 12 | ] | ||
749 | 13 | |||
750 | 14 | from lp.services.webhooks.interfaces import ( | ||
751 | 15 | IWebhook, | ||
752 | 16 | IWebhookDeliveryJob, | ||
753 | 17 | IWebhookTarget, | ||
754 | 18 | ) |