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
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-07-08 16:05:11 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-07-12 23:52:28 +0000
@@ -88,6 +88,7 @@
88from lp.services.webapp.breadcrumb import NameBreadcrumb88from lp.services.webapp.breadcrumb import NameBreadcrumb
89from lp.services.webapp.escaping import structured89from lp.services.webapp.escaping import structured
90from lp.services.webapp.interfaces import ICanonicalUrlData90from lp.services.webapp.interfaces import ICanonicalUrlData
91from lp.services.webhooks.browser import WebhookTargetNavigationMixin
9192
9293
93@implementer(ICanonicalUrlData)94@implementer(ICanonicalUrlData)
@@ -112,7 +113,7 @@
112 return self.context.target113 return self.context.target
113114
114115
115class GitRepositoryNavigation(Navigation):116class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation):
116117
117 usedfor = IGitRepository118 usedfor = IGitRepository
118119
119120
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2015-06-18 14:13:40 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2015-07-12 23:52:28 +0000
@@ -77,6 +77,7 @@
77 PersonChoice,77 PersonChoice,
78 PublicPersonChoice,78 PublicPersonChoice,
79 )79 )
80from lp.services.webhooks.interfaces import IWebhookTarget
8081
8182
82GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _(83GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _(
@@ -569,7 +570,7 @@
569 "refs/heads/master.")))570 "refs/heads/master.")))
570571
571572
572class IGitRepositoryEdit(Interface):573class IGitRepositoryEdit(IWebhookTarget):
573 """IGitRepository methods that require launchpad.Edit permission."""574 """IGitRepository methods that require launchpad.Edit permission."""
574575
575 @mutator_for(IGitRepositoryView["name"])576 @mutator_for(IGitRepositoryView["name"])
576577
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2015-07-10 22:24:49 +0000
+++ lib/lp/code/model/gitrepository.py 2015-07-12 23:52:28 +0000
@@ -146,6 +146,7 @@
146 get_property_cache,146 get_property_cache,
147 )147 )
148from lp.services.webapp.authorization import available_with_permission148from lp.services.webapp.authorization import available_with_permission
149from lp.services.webhooks.model import WebhookTargetMixin
149150
150151
151object_type_map = {152object_type_map = {
@@ -168,7 +169,7 @@
168169
169170
170@implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)171@implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
171class GitRepository(StormBase, GitIdentityMixin):172class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
172 """See `IGitRepository`."""173 """See `IGitRepository`."""
173174
174 __storm_table__ = 'GitRepository'175 __storm_table__ = 'GitRepository'
175176
=== modified file 'lib/lp/security.py'
--- lib/lp/security.py 2015-07-12 23:52:28 +0000
+++ lib/lp/security.py 2015-07-12 23:52:28 +0000
@@ -183,7 +183,10 @@
183 )183 )
184from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier184from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier
185from lp.services.webapp.interfaces import ILaunchpadRoot185from lp.services.webapp.interfaces import ILaunchpadRoot
186from lp.services.webhooks.interfaces import IWebhook186from lp.services.webhooks.interfaces import (
187 IWebhook,
188 IWebhookDeliveryJob,
189 )
187from lp.services.worlddata.interfaces.country import ICountry190from lp.services.worlddata.interfaces.country import ICountry
188from lp.services.worlddata.interfaces.language import (191from lp.services.worlddata.interfaces.language import (
189 ILanguage,192 ILanguage,
@@ -3067,3 +3070,13 @@
3067 def checkAuthenticated(self, user):3070 def checkAuthenticated(self, user):
3068 return self.forwardCheckAuthenticated(3071 return self.forwardCheckAuthenticated(
3069 user, self.obj.target, 'launchpad.Edit')3072 user, self.obj.target, 'launchpad.Edit')
3073
3074
3075class ViewWebhookDeliveryJob(DelegatedAuthorization):
3076 """Webhooks can be viewed and edited by someone who can edit the target."""
3077 permission = 'launchpad.View'
3078 usedfor = IWebhookDeliveryJob
3079
3080 def __init__(self, obj):
3081 super(ViewWebhookDeliveryJob, self).__init__(
3082 obj, obj.webhook, 'launchpad.View')
30703083
=== added file 'lib/lp/services/webhooks/browser.py'
--- lib/lp/services/webhooks/browser.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/browser.py 2015-07-12 23:52:28 +0000
@@ -0,0 +1,49 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Webhook browser and API classes."""
5
6__metaclass__ = type
7
8__all__ = [
9 'WebhookNavigation',
10 'WebhookTargetNavigationMixin',
11 ]
12
13from zope.component import getUtility
14
15from lp.services.webapp import (
16 Navigation,
17 stepthrough,
18 )
19from lp.services.webhooks.interfaces import (
20 IWebhook,
21 IWebhookSource,
22 )
23
24
25class WebhookNavigation(Navigation):
26
27 usedfor = IWebhook
28
29 @stepthrough('+delivery')
30 def traverse_delivery(self, id):
31 try:
32 id = int(id)
33 except ValueError:
34 return None
35 return self.context.getDelivery(id)
36
37
38class WebhookTargetNavigationMixin:
39
40 @stepthrough('+webhook')
41 def traverse_webhook(self, id):
42 try:
43 id = int(id)
44 except ValueError:
45 return None
46 webhook = getUtility(IWebhookSource).getByID(id)
47 if webhook is None or webhook.target != self.context:
48 return None
49 return webhook
050
=== modified file 'lib/lp/services/webhooks/configure.zcml'
--- lib/lp/services/webhooks/configure.zcml 2015-07-12 23:52:28 +0000
+++ lib/lp/services/webhooks/configure.zcml 2015-07-12 23:52:28 +0000
@@ -2,7 +2,10 @@
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
5<configure xmlns="http://namespaces.zope.org/zope">5<configure
6 xmlns="http://namespaces.zope.org/zope"
7 xmlns:browser="http://namespaces.zope.org/browser"
8 xmlns:webservice="http://namespaces.canonical.com/webservice">
69
7 <class class="lp.services.webhooks.model.Webhook">10 <class class="lp.services.webhooks.model.Webhook">
8 <require11 <require
@@ -14,6 +17,12 @@
14 for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent"17 for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent"
15 handler="lp.services.webhooks.model.webhook_modified"/>18 handler="lp.services.webhooks.model.webhook_modified"/>
1619
20 <class class="lp.services.webhooks.model.WebhookDeliveryJob">
21 <require
22 permission="launchpad.View"
23 interface="lp.services.webhooks.interfaces.IWebhookDeliveryJob"/>
24 </class>
25
17 <securedutility26 <securedutility
18 class="lp.services.webhooks.model.WebhookSource"27 class="lp.services.webhooks.model.WebhookSource"
19 provides="lp.services.webhooks.interfaces.IWebhookSource">28 provides="lp.services.webhooks.interfaces.IWebhookSource">
@@ -25,4 +34,21 @@
25 factory="lp.services.webhooks.client.WebhookClient"34 factory="lp.services.webhooks.client.WebhookClient"
26 permission="zope.Public"/>35 permission="zope.Public"/>
2736
37 <browser:url
38 for="lp.services.webhooks.interfaces.IWebhook"
39 path_expression="string:+webhook/${id}"
40 attribute_to_parent="target"
41 />
42 <browser:navigation
43 module="lp.services.webhooks.browser" classes="WebhookNavigation" />
44
45 <browser:url
46 for="lp.services.webhooks.interfaces.IWebhookDeliveryJob"
47 path_expression="string:+delivery/${job_id}"
48 attribute_to_parent="webhook"
49 />
50
51 <webservice:register module="lp.services.webhooks.webservice" />
52
53
28</configure>54</configure>
2955
=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py 2015-07-12 23:52:28 +0000
+++ lib/lp/services/webhooks/interfaces.py 2015-07-12 23:52:28 +0000
@@ -12,10 +12,26 @@
12 'IWebhookDeliveryJobSource',12 'IWebhookDeliveryJobSource',
13 'IWebhookJob',13 'IWebhookJob',
14 'IWebhookSource',14 'IWebhookSource',
15 'IWebhookTarget',
16 'WebhookFeatureDisabled',
15 ]17 ]
1618
17from lazr.restful.declarations import exported19import httplib
18from lazr.restful.fields import Reference20
21from lazr.lifecycle.snapshot import doNotSnapshot
22from lazr.restful.declarations import (
23 call_with,
24 error_status,
25 export_as_webservice_entry,
26 export_factory_operation,
27 exported,
28 operation_for_version,
29 REQUEST_USER,
30 )
31from lazr.restful.fields import (
32 CollectionField,
33 Reference,
34 )
19from zope.interface import (35from zope.interface import (
20 Attribute,36 Attribute,
21 Interface,37 Interface,
@@ -36,14 +52,31 @@
36 IJobSource,52 IJobSource,
37 IRunnableJob,53 IRunnableJob,
38 )54 )
55from lp.services.webservice.apihelpers import (
56 patch_collection_property,
57 patch_entry_return_type,
58 patch_reference_property,
59 )
60
61
62@error_status(httplib.UNAUTHORIZED)
63class WebhookFeatureDisabled(Exception):
64 """Only certain users can create new Git repositories."""
65
66 def __init__(self):
67 Exception.__init__(
68 self, "This webhook feature is not available yet.")
3969
4070
41class IWebhook(Interface):71class IWebhook(Interface):
4272
73 export_as_webservice_entry(as_of='beta')
74
43 id = Int(title=_("ID"), readonly=True, required=True)75 id = Int(title=_("ID"), readonly=True, required=True)
4476
45 target = exported(Reference(77 target = exported(Reference(
46 title=_("Target"), schema=IPerson, required=True, readonly=True,78 title=_("Target"), schema=Interface, # Actually IWebhookTarget.
79 required=True, readonly=True,
47 description=_("The object for which this webhook receives events.")))80 description=_("The object for which this webhook receives events.")))
48 event_types = exported(List(81 event_types = exported(List(
49 TextLine(), title=_("Event types"),82 TextLine(), title=_("Event types"),
@@ -53,18 +86,32 @@
53 registrant = exported(Reference(86 registrant = exported(Reference(
54 title=_("Registrant"), schema=IPerson, required=True, readonly=True,87 title=_("Registrant"), schema=IPerson, required=True, readonly=True,
55 description=_("The person who created this webhook.")))88 description=_("The person who created this webhook.")))
89 registrant_id = Int(title=_("Registrant ID"))
56 date_created = exported(Datetime(90 date_created = exported(Datetime(
57 title=_("Date created"), required=True, readonly=True))91 title=_("Date created"), required=True, readonly=True))
58 date_last_modified = exported(Datetime(92 date_last_modified = exported(Datetime(
59 title=_("Date last modified"), required=True, readonly=True))93 title=_("Date last modified"), required=True, readonly=True))
6094
61 delivery_url = exported(Bool(95 delivery_url = exported(TextLine(
62 title=_("URL"), required=True, readonly=False))96 title=_("URL"), required=True, readonly=False))
63 active = exported(Bool(97 active = exported(Bool(
64 title=_("Active"), required=True, readonly=False))98 title=_("Active"), required=True, readonly=False))
65 secret = TextLine(99 secret = TextLine(
66 title=_("Unique name"), required=False, readonly=True)100 title=_("Unique name"), required=False, readonly=True)
67101
102 deliveries = exported(doNotSnapshot(CollectionField(
103 title=_("Recent deliveries for this webhook."),
104 value_type=Reference(schema=Interface),
105 readonly=True)))
106
107 def getDelivery(id):
108 """Retrieve a delivery by ID, or None if it doesn't exist."""
109
110 @export_factory_operation(Interface, []) # Actually IWebhookDelivery.
111 @operation_for_version('devel')
112 def ping():
113 """Send a test event."""
114
68115
69class IWebhookSource(Interface):116class IWebhookSource(Interface):
70117
@@ -81,6 +128,23 @@
81 """Find all webhooks for the given target."""128 """Find all webhooks for the given target."""
82129
83130
131class IWebhookTarget(Interface):
132
133 export_as_webservice_entry(as_of='beta')
134
135 webhooks = exported(doNotSnapshot(CollectionField(
136 title=_("Webhooks for this target."),
137 value_type=Reference(schema=IWebhook),
138 readonly=True)))
139
140 @call_with(registrant=REQUEST_USER)
141 @export_factory_operation(
142 IWebhook, ['delivery_url', 'active', 'event_types'])
143 @operation_for_version("devel")
144 def newWebhook(registrant, delivery_url, event_types, active=True):
145 """Create a new webhook."""
146
147
84class IWebhookJob(Interface):148class IWebhookJob(Interface):
85 """A job related to a webhook."""149 """A job related to a webhook."""
86150
@@ -98,6 +162,8 @@
98class IWebhookDeliveryJob(IRunnableJob):162class IWebhookDeliveryJob(IRunnableJob):
99 """A Job that delivers an event to a webhook consumer."""163 """A Job that delivers an event to a webhook consumer."""
100164
165 export_as_webservice_entry('webhook_delivery', as_of='beta')
166
101 webhook = exported(Reference(167 webhook = exported(Reference(
102 title=_("Webhook"),168 title=_("Webhook"),
103 description=_("The webhook that this delivery is for."),169 description=_("The webhook that this delivery is for."),
@@ -151,3 +217,7 @@
151 return a response, and a DNS error returns a connection_error, but217 return a response, and a DNS error returns a connection_error, but
152 the proxy being offline will raise an exception.218 the proxy being offline will raise an exception.
153 """219 """
220
221patch_collection_property(IWebhook, 'deliveries', IWebhookDeliveryJob)
222patch_entry_return_type(IWebhook, 'ping', IWebhookDeliveryJob)
223patch_reference_property(IWebhook, 'target', IWebhookTarget)
154224
=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py 2015-07-12 23:52:28 +0000
+++ lib/lp/services/webhooks/model.py 2015-07-12 23:52:28 +0000
@@ -7,6 +7,7 @@
7 'Webhook',7 'Webhook',
8 'WebhookJob',8 'WebhookJob',
9 'WebhookJobType',9 'WebhookJobType',
10 'WebhookTargetMixin',
10 ]11 ]
1112
12import datetime13import datetime
@@ -26,6 +27,7 @@
26 Unicode,27 Unicode,
27 )28 )
28from storm.references import Reference29from storm.references import Reference
30from storm.store import Store
29from zope.component import getUtility31from zope.component import getUtility
30from zope.interface import (32from zope.interface import (
31 implementer,33 implementer,
@@ -33,11 +35,15 @@
33 )35 )
34from zope.security.proxy import removeSecurityProxy36from zope.security.proxy import removeSecurityProxy
3537
38from lp.registry.model.person import Person
36from lp.services.config import config39from lp.services.config import config
40from lp.services.database.bulk import load_related
37from lp.services.database.constants import UTC_NOW41from lp.services.database.constants import UTC_NOW
42from lp.services.database.decoratedresultset import DecoratedResultSet
38from lp.services.database.enumcol import EnumCol43from lp.services.database.enumcol import EnumCol
39from lp.services.database.interfaces import IStore44from lp.services.database.interfaces import IStore
40from lp.services.database.stormbase import StormBase45from lp.services.database.stormbase import StormBase
46from lp.services.features import getFeatureFlag
41from lp.services.job.model.job import (47from lp.services.job.model.job import (
42 EnumeratedSubclass,48 EnumeratedSubclass,
43 Job,49 Job,
@@ -49,6 +55,8 @@
49 IWebhookDeliveryJob,55 IWebhookDeliveryJob,
50 IWebhookDeliveryJobSource,56 IWebhookDeliveryJobSource,
51 IWebhookJob,57 IWebhookJob,
58 IWebhookSource,
59 WebhookFeatureDisabled,
52 )60 )
5361
5462
@@ -80,7 +88,7 @@
8088
81 delivery_url = Unicode(allow_none=False)89 delivery_url = Unicode(allow_none=False)
82 active = Bool(default=True, allow_none=False)90 active = Bool(default=True, allow_none=False)
83 secret = Unicode(allow_none=False)91 secret = Unicode(allow_none=True)
8492
85 json_data = JSON(name='json_data')93 json_data = JSON(name='json_data')
8694
@@ -92,6 +100,26 @@
92 raise AssertionError("No target.")100 raise AssertionError("No target.")
93101
94 @property102 @property
103 def deliveries(self):
104 jobs = Store.of(self).find(
105 WebhookJob,
106 WebhookJob.webhook == self,
107 WebhookJob.job_type == WebhookJobType.DELIVERY,
108 ).order_by(WebhookJob.job_id)
109
110 def preload_jobs(rows):
111 load_related(Job, rows, ['job_id'])
112
113 return DecoratedResultSet(
114 jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
115
116 def getDelivery(self, id):
117 return self.deliveries.find(WebhookJob.job_id == id).one()
118
119 def ping(self):
120 return WebhookDeliveryJob.create(self, {'ping': True})
121
122 @property
95 def event_types(self):123 def event_types(self):
96 return (self.json_data or {}).get('event_types', [])124 return (self.json_data or {}).get('event_types', [])
97125
@@ -104,6 +132,7 @@
104 self.json_data = updated_data132 self.json_data = updated_data
105133
106134
135@implementer(IWebhookSource)
107class WebhookSource:136class WebhookSource:
108 """See `IWebhookSource`."""137 """See `IWebhookSource`."""
109138
@@ -121,6 +150,7 @@
121 hook.secret = secret150 hook.secret = secret
122 hook.event_types = event_types151 hook.event_types = event_types
123 IStore(Webhook).add(hook)152 IStore(Webhook).add(hook)
153 IStore(Webhook).flush()
124 return hook154 return hook
125155
126 def delete(self, hooks):156 def delete(self, hooks):
@@ -139,6 +169,24 @@
139 return IStore(Webhook).find(Webhook, target_filter)169 return IStore(Webhook).find(Webhook, target_filter)
140170
141171
172class WebhookTargetMixin:
173
174 @property
175 def webhooks(self):
176 def preload_registrants(rows):
177 load_related(Person, rows, ['registrant_id'])
178
179 return DecoratedResultSet(
180 getUtility(IWebhookSource).findByTarget(self),
181 pre_iter_hook=preload_registrants)
182
183 def newWebhook(self, registrant, delivery_url, event_types, active=True):
184 if not getFeatureFlag('webhooks.new.enabled'):
185 raise WebhookFeatureDisabled()
186 return getUtility(IWebhookSource).new(
187 self, registrant, delivery_url, event_types, active, None)
188
189
142class WebhookJobType(DBEnumeratedType):190class WebhookJobType(DBEnumeratedType):
143 """Values that `IWebhookJob.job_type` can take."""191 """Values that `IWebhookJob.job_type` can take."""
144192
145193
=== modified file 'lib/lp/services/webhooks/tests/test_webhook.py'
--- lib/lp/services/webhooks/tests/test_webhook.py 2015-07-12 23:52:28 +0000
+++ lib/lp/services/webhooks/tests/test_webhook.py 2015-07-12 23:52:28 +0000
@@ -66,8 +66,9 @@
66 def test_get_permissions(self):66 def test_get_permissions(self):
67 expected_get_permissions = {67 expected_get_permissions = {
68 'launchpad.View': set((68 'launchpad.View': set((
69 'active', 'date_created', 'date_last_modified', 'delivery_url',69 'active', 'date_created', 'date_last_modified', 'deliveries',
70 'event_types', 'id', 'registrant', 'secret', 'target')),70 'delivery_url', 'event_types', 'getDelivery', 'id', 'ping',
71 'registrant', 'registrant_id', 'secret', 'target')),
71 }72 }
72 webhook = self.factory.makeWebhook()73 webhook = self.factory.makeWebhook()
73 checker = getChecker(webhook)74 checker = getChecker(webhook)
@@ -76,7 +77,8 @@
7677
77 def test_set_permissions(self):78 def test_set_permissions(self):
78 expected_set_permissions = {79 expected_set_permissions = {
79 'launchpad.View': set(('active', 'delivery_url', 'event_types')),80 'launchpad.View': set((
81 'active', 'delivery_url', 'event_types', 'registrant_id')),
80 }82 }
81 webhook = self.factory.makeWebhook()83 webhook = self.factory.makeWebhook()
82 checker = getChecker(webhook)84 checker = getChecker(webhook)
8385
=== added file 'lib/lp/services/webhooks/tests/test_webservice.py'
--- lib/lp/services/webhooks/tests/test_webservice.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/tests/test_webservice.py 2015-07-12 23:52:28 +0000
@@ -0,0 +1,240 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for the webhook webservice objects."""
5
6__metaclass__ = type
7
8import json
9
10from testtools.matchers import (
11 ContainsDict,
12 Equals,
13 GreaterThan,
14 Is,
15 KeysEqual,
16 MatchesAll,
17 Not,
18 )
19
20from lp.services.features.testing import FeatureFixture
21from lp.services.webapp.interfaces import OAuthPermission
22from lp.testing import (
23 api_url,
24 person_logged_in,
25 record_two_runs,
26 TestCaseWithFactory,
27 )
28from lp.testing.layers import DatabaseFunctionalLayer
29from lp.testing.matchers import HasQueryCount
30from lp.testing.pages import (
31 LaunchpadWebServiceCaller,
32 webservice_for_person,
33 )
34
35
36class TestWebhook(TestCaseWithFactory):
37 layer = DatabaseFunctionalLayer
38
39 def setUp(self):
40 super(TestWebhook, self).setUp()
41 target = self.factory.makeGitRepository()
42 self.owner = target.owner
43 with person_logged_in(self.owner):
44 self.webhook = self.factory.makeWebhook(
45 target=target, delivery_url=u'http://example.com/ep')
46 self.webhook_url = api_url(self.webhook)
47 self.webservice = webservice_for_person(
48 self.owner, permission=OAuthPermission.WRITE_PRIVATE)
49
50 def test_get(self):
51 representation = self.webservice.get(
52 self.webhook_url, api_version='devel').jsonBody()
53 self.assertThat(
54 representation,
55 KeysEqual(
56 'active', 'date_created', 'date_last_modified',
57 'deliveries_collection_link', 'delivery_url', 'event_types',
58 'http_etag', 'registrant_link', 'resource_type_link',
59 'self_link', 'target_link', 'web_link'))
60
61 def test_patch(self):
62 representation = self.webservice.get(
63 self.webhook_url, api_version='devel').jsonBody()
64 self.assertThat(
65 representation,
66 ContainsDict(
67 {'active': Equals(True),
68 'delivery_url': Equals('http://example.com/ep'),
69 'event_types': Equals([])}))
70 old_mtime = representation['date_last_modified']
71 patch = json.dumps(
72 {'active': False, 'delivery_url': 'http://example.com/ep2',
73 'event_types': ['foo', 'bar']})
74 self.webservice.patch(
75 self.webhook_url, 'application/json', patch, api_version='devel')
76 representation = self.webservice.get(
77 self.webhook_url, api_version='devel').jsonBody()
78 self.assertThat(
79 representation,
80 ContainsDict(
81 {'active': Equals(False),
82 'delivery_url': Equals('http://example.com/ep2'),
83 'date_last_modified': GreaterThan(old_mtime),
84 'event_types': Equals(['foo', 'bar'])}))
85
86 def test_anon_forbidden(self):
87 response = LaunchpadWebServiceCaller().get(
88 self.webhook_url, api_version='devel')
89 self.assertEqual(401, response.status)
90 self.assertIn('launchpad.View', response.body)
91
92 def test_deliveries(self):
93 representation = self.webservice.get(
94 self.webhook_url + '/deliveries', api_version='devel').jsonBody()
95 self.assertContentEqual(
96 [], [entry['payload'] for entry in representation['entries']])
97
98 # Send a test event.
99 response = self.webservice.named_post(
100 self.webhook_url, 'ping', api_version='devel')
101 self.assertEqual(201, response.status)
102 delivery = self.webservice.get(
103 response.getHeader("Location")).jsonBody()
104 self.assertEqual({'ping': True}, delivery['payload'])
105
106 # The delivery shows up in the collection.
107 representation = self.webservice.get(
108 self.webhook_url + '/deliveries', api_version='devel').jsonBody()
109 self.assertContentEqual(
110 [delivery['self_link']],
111 [entry['self_link'] for entry in representation['entries']])
112
113 def test_deliveries_query_count(self):
114 def get_deliveries():
115 representation = self.webservice.get(
116 self.webhook_url + '/deliveries',
117 api_version='devel').jsonBody()
118 self.assertIn(len(representation['entries']), (0, 2, 4))
119
120 def create_delivery():
121 with person_logged_in(self.owner):
122 self.webhook.ping()
123
124 get_deliveries()
125 recorder1, recorder2 = record_two_runs(
126 get_deliveries, create_delivery, 2)
127 self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
128
129
130class TestWebhookDelivery(TestCaseWithFactory):
131 layer = DatabaseFunctionalLayer
132
133 def setUp(self):
134 super(TestWebhookDelivery, self).setUp()
135 target = self.factory.makeGitRepository()
136 self.owner = target.owner
137 with person_logged_in(self.owner):
138 self.webhook = self.factory.makeWebhook(
139 target=target, delivery_url=u'http://example.com/ep')
140 self.webhook_url = api_url(self.webhook)
141 self.delivery = self.webhook.ping()
142 self.delivery_url = api_url(self.delivery)
143 self.webservice = webservice_for_person(
144 self.owner, permission=OAuthPermission.WRITE_PRIVATE)
145
146 def test_get(self):
147 representation = self.webservice.get(
148 self.delivery_url, api_version='devel').jsonBody()
149 self.assertThat(
150 representation,
151 MatchesAll(
152 KeysEqual(
153 'date_created', 'date_sent', 'http_etag', 'payload',
154 'pending', 'resource_type_link', 'self_link',
155 'successful', 'web_link', 'webhook_link'),
156 ContainsDict(
157 {'payload': Equals({'ping': True}),
158 'pending': Equals(True),
159 'successful': Is(None),
160 'date_created': Not(Is(None)),
161 'date_sent': Is(None)})))
162
163
164class TestWebhookTarget(TestCaseWithFactory):
165 layer = DatabaseFunctionalLayer
166
167 def setUp(self):
168 super(TestWebhookTarget, self).setUp()
169 self.target = self.factory.makeGitRepository()
170 self.owner = self.target.owner
171 self.target_url = api_url(self.target)
172 self.webservice = webservice_for_person(
173 self.owner, permission=OAuthPermission.WRITE_PRIVATE)
174
175 def test_webhooks(self):
176 with person_logged_in(self.owner):
177 for ep in (u'http://example.com/ep1', u'http://example.com/ep2'):
178 self.factory.makeWebhook(target=self.target, delivery_url=ep)
179 representation = self.webservice.get(
180 self.target_url + '/webhooks', api_version='devel').jsonBody()
181 self.assertContentEqual(
182 ['http://example.com/ep1', 'http://example.com/ep2'],
183 [entry['delivery_url'] for entry in representation['entries']])
184
185 def test_webhooks_permissions(self):
186 webservice = LaunchpadWebServiceCaller()
187 response = webservice.get(
188 self.target_url + '/webhooks', api_version='devel')
189 self.assertEqual(401, response.status)
190 self.assertIn('launchpad.Edit', response.body)
191
192 def test_webhooks_query_count(self):
193 def get_webhooks():
194 representation = self.webservice.get(
195 self.target_url + '/webhooks',
196 api_version='devel').jsonBody()
197 self.assertIn(len(representation['entries']), (0, 2, 4))
198
199 def create_webhook():
200 with person_logged_in(self.owner):
201 self.factory.makeWebhook(target=self.target)
202
203 get_webhooks()
204 recorder1, recorder2 = record_two_runs(
205 get_webhooks, create_webhook, 2)
206 self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
207
208 def test_newWebhook(self):
209 self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
210 response = self.webservice.named_post(
211 self.target_url, 'newWebhook',
212 delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
213 api_version='devel')
214 self.assertEqual(201, response.status)
215
216 representation = self.webservice.get(
217 self.target_url + '/webhooks', api_version='devel').jsonBody()
218 self.assertContentEqual(
219 [('http://example.com/ep', ['foo', 'bar'], True)],
220 [(entry['delivery_url'], entry['event_types'], entry['active'])
221 for entry in representation['entries']])
222
223 def test_newWebhook_permissions(self):
224 self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
225 webservice = LaunchpadWebServiceCaller()
226 response = webservice.named_post(
227 self.target_url, 'newWebhook',
228 delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
229 api_version='devel')
230 self.assertEqual(401, response.status)
231 self.assertIn('launchpad.Edit', response.body)
232
233 def test_newWebhook_feature_flag_guard(self):
234 response = self.webservice.named_post(
235 self.target_url, 'newWebhook',
236 delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
237 api_version='devel')
238 self.assertEqual(401, response.status)
239 self.assertEqual(
240 'This webhook feature is not available yet.', response.body)
0241
=== added file 'lib/lp/services/webhooks/webservice.py'
--- lib/lp/services/webhooks/webservice.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/webservice.py 2015-07-12 23:52:28 +0000
@@ -0,0 +1,18 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Webhook webservice registrations."""
5
6__metaclass__ = type
7
8__all__ = [
9 'IWebhook',
10 'IWebhookDeliveryJob',
11 'IWebhookTarget',
12 ]
13
14from lp.services.webhooks.interfaces import (
15 IWebhook,
16 IWebhookDeliveryJob,
17 IWebhookTarget,
18 )