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