Merge lp:~wgrant/launchpad/webhook-api into lp:launchpad

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
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://code.launchpad.net/~wgrant/launchpad/webhook-model/+merge/264004. git_repository grows a webhooks collection and a newWebhook operation, and webhook and webhook_delivery are exported. newWebhook is feature-flagged until the skeleton is a little more filled in.

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+ )