launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18951
[Merge] lp:~wgrant/launchpad/webhook-api into lp:launchpad
William Grant has proposed merging lp:~wgrant/launchpad/webhook-api into lp:launchpad with lp:~wgrant/launchpad/webhook-model as a prerequisite.
Commit message:
Add basic webhook API for Git repositories.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wgrant/launchpad/webhook-api/+merge/264005
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.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/webhook-api into lp:launchpad.
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-06-18 20:18:16 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-07-07 08:04:33 +0000
@@ -88,6 +88,7 @@
from lp.services.webapp.breadcrumb import NameBreadcrumb
from lp.services.webapp.escaping import structured
from lp.services.webapp.interfaces import ICanonicalUrlData
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
class GitRepositoryURL:
@@ -113,7 +114,7 @@
return self.context.target
-class GitRepositoryNavigation(Navigation):
+class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation):
usedfor = IGitRepository
=== 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-07 08:04:33 +0000
@@ -77,6 +77,7 @@
PersonChoice,
PublicPersonChoice,
)
+from lp.services.webhooks.interfaces import IWebhookTarget
GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _(
@@ -569,7 +570,7 @@
"refs/heads/master.")))
-class IGitRepositoryEdit(Interface):
+class IGitRepositoryEdit(IWebhookTarget):
"""IGitRepository methods that require launchpad.Edit permission."""
@mutator_for(IGitRepositoryView["name"])
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2015-06-25 04:42:48 +0000
+++ lib/lp/code/model/gitrepository.py 2015-07-07 08:04:33 +0000
@@ -146,6 +146,7 @@
get_property_cache,
)
from lp.services.webapp.authorization import available_with_permission
+from lp.services.webhooks.model import WebhookTargetMixin
object_type_map = {
@@ -167,7 +168,7 @@
send_git_repository_modified_notifications(repository, event)
-class GitRepository(StormBase, GitIdentityMixin):
+class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
"""See `IGitRepository`."""
__storm_table__ = 'GitRepository'
=== modified file 'lib/lp/security.py'
--- lib/lp/security.py 2015-07-07 08:04:32 +0000
+++ lib/lp/security.py 2015-07-07 08:04:33 +0000
@@ -183,7 +183,10 @@
)
from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier
from lp.services.webapp.interfaces import ILaunchpadRoot
-from lp.services.webhooks.interfaces import IWebhook
+from lp.services.webhooks.interfaces import (
+ IWebhook,
+ IWebhookDeliveryJob,
+ )
from lp.services.worlddata.interfaces.country import ICountry
from lp.services.worlddata.interfaces.language import (
ILanguage,
@@ -3067,3 +3070,13 @@
def checkAuthenticated(self, user):
return self.forwardCheckAuthenticated(
user, self.obj.target, 'launchpad.Edit')
+
+
+class ViewWebhookDeliveryJob(DelegatedAuthorization):
+ """Webhooks can be viewed and edited by someone who can edit the target."""
+ permission = 'launchpad.View'
+ usedfor = IWebhookDeliveryJob
+
+ def __init__(self, obj):
+ super(ViewWebhookDeliveryJob, self).__init__(
+ obj, obj.webhook, 'launchpad.View')
=== 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-07 08:04:33 +0000
@@ -0,0 +1,49 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Webhook browser and API classes."""
+
+__metaclass__ = type
+
+__all__ = [
+ 'WebhookNavigation',
+ 'WebhookTargetNavigationMixin',
+ ]
+
+from zope.component import getUtility
+
+from lp.services.webapp import (
+ Navigation,
+ stepthrough,
+ )
+from lp.services.webhooks.interfaces import (
+ IWebhook,
+ IWebhookSource,
+ )
+
+
+class WebhookNavigation(Navigation):
+
+ usedfor = IWebhook
+
+ @stepthrough('+delivery')
+ def traverse_delivery(self, id):
+ try:
+ id = int(id)
+ except ValueError:
+ return None
+ return self.context.getDelivery(id)
+
+
+class WebhookTargetNavigationMixin:
+
+ @stepthrough('+webhook')
+ def traverse_webhook(self, id):
+ try:
+ id = int(id)
+ except ValueError:
+ return None
+ webhook = getUtility(IWebhookSource).getByID(id)
+ if webhook is None or webhook.target != self.context:
+ return None
+ return webhook
=== modified file 'lib/lp/services/webhooks/configure.zcml'
--- lib/lp/services/webhooks/configure.zcml 2015-07-07 08:04:32 +0000
+++ lib/lp/services/webhooks/configure.zcml 2015-07-07 08:04:33 +0000
@@ -2,7 +2,10 @@
GNU Affero General Public License version 3 (see the file LICENSE).
-->
-<configure xmlns="http://namespaces.zope.org/zope">
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ xmlns:browser="http://namespaces.zope.org/browser"
+ xmlns:webservice="http://namespaces.canonical.com/webservice">
<class class="lp.services.webhooks.model.Webhook">
<require
@@ -14,6 +17,12 @@
for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent"
handler="lp.services.webhooks.model.webhook_modified"/>
+ <class class="lp.services.webhooks.model.WebhookDeliveryJob">
+ <require
+ permission="launchpad.View"
+ interface="lp.services.webhooks.interfaces.IWebhookDeliveryJob"/>
+ </class>
+
<securedutility
class="lp.services.webhooks.model.WebhookSource"
provides="lp.services.webhooks.interfaces.IWebhookSource">
@@ -25,4 +34,21 @@
factory="lp.services.webhooks.client.WebhookClient"
permission="zope.Public"/>
+ <browser:url
+ for="lp.services.webhooks.interfaces.IWebhook"
+ path_expression="string:+webhook/${id}"
+ attribute_to_parent="target"
+ />
+ <browser:navigation
+ module="lp.services.webhooks.browser" classes="WebhookNavigation" />
+
+ <browser:url
+ for="lp.services.webhooks.interfaces.IWebhookDeliveryJob"
+ path_expression="string:+delivery/${job_id}"
+ attribute_to_parent="webhook"
+ />
+
+ <webservice:register module="lp.services.webhooks.webservice" />
+
+
</configure>
=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py 2015-07-07 08:04:32 +0000
+++ lib/lp/services/webhooks/interfaces.py 2015-07-07 08:04:33 +0000
@@ -12,10 +12,26 @@
'IWebhookDeliveryJobSource',
'IWebhookJob',
'IWebhookSource',
+ 'IWebhookTarget',
+ 'WebhookFeatureDisabled',
]
-from lazr.restful.declarations import exported
-from lazr.restful.fields import Reference
+import httplib
+
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+ call_with,
+ error_status,
+ export_as_webservice_entry,
+ export_factory_operation,
+ exported,
+ operation_for_version,
+ REQUEST_USER,
+ )
+from lazr.restful.fields import (
+ CollectionField,
+ Reference,
+ )
from zope.interface import (
Attribute,
Interface,
@@ -36,14 +52,31 @@
IJobSource,
IRunnableJob,
)
+from lp.services.webservice.apihelpers import (
+ patch_collection_property,
+ patch_entry_return_type,
+ patch_reference_property,
+ )
+
+
+@error_status(httplib.UNAUTHORIZED)
+class WebhookFeatureDisabled(Exception):
+ """Only certain users can create new Git repositories."""
+
+ def __init__(self):
+ Exception.__init__(
+ self, "This webhook feature is not available yet.")
class IWebhook(Interface):
+ export_as_webservice_entry(as_of='beta')
+
id = Int(title=_("ID"), readonly=True, required=True)
target = exported(Reference(
- title=_("Target"), schema=IPerson, required=True, readonly=True,
+ title=_("Target"), schema=Interface, # Actually IWebhookTarget.
+ required=True, readonly=True,
description=_("The object for which this webhook receives events.")))
event_types = exported(List(
TextLine(), title=_("Event types"),
@@ -53,18 +86,32 @@
registrant = exported(Reference(
title=_("Registrant"), schema=IPerson, required=True, readonly=True,
description=_("The person who created this webhook.")))
+ registrant_id = Int(title=_("Registrant ID"))
date_created = exported(Datetime(
title=_("Date created"), required=True, readonly=True))
date_last_modified = exported(Datetime(
title=_("Date last modified"), required=True, readonly=True))
- delivery_url = exported(Bool(
+ delivery_url = exported(TextLine(
title=_("URL"), required=True, readonly=False))
active = exported(Bool(
title=_("Active"), required=True, readonly=False))
secret = TextLine(
title=_("Unique name"), required=False, readonly=True)
+ deliveries = exported(doNotSnapshot(CollectionField(
+ title=_("Recent deliveries for this webhook."),
+ value_type=Reference(schema=Interface),
+ readonly=True)))
+
+ def getDelivery(id):
+ """Retrieve a delivery by ID, or None if it doesn't exist."""
+
+ @export_factory_operation(Interface, []) # Actually IWebhookDelivery.
+ @operation_for_version('devel')
+ def ping():
+ """Send a test event."""
+
class IWebhookSource(Interface):
@@ -81,6 +128,23 @@
"""Find all webhooks for the given target."""
+class IWebhookTarget(Interface):
+
+ export_as_webservice_entry(as_of='beta')
+
+ webhooks = exported(doNotSnapshot(CollectionField(
+ title=_("Webhooks for this target."),
+ value_type=Reference(schema=IWebhook),
+ readonly=True)))
+
+ @call_with(registrant=REQUEST_USER)
+ @export_factory_operation(
+ IWebhook, ['delivery_url', 'active', 'event_types'])
+ @operation_for_version("devel")
+ def newWebhook(registrant, delivery_url, event_types, active=True):
+ """Create a new webhook."""
+
+
class IWebhookJob(Interface):
"""A job related to a webhook."""
@@ -98,6 +162,8 @@
class IWebhookDeliveryJob(IRunnableJob):
"""A Job that delivers an event to a webhook consumer."""
+ export_as_webservice_entry('webhook_delivery', as_of='beta')
+
webhook = exported(Reference(
title=_("Webhook"),
description=_("The webhook that this delivery is for."),
@@ -151,3 +217,7 @@
return a response, and a DNS error returns a connection_error, but
the proxy being offline will raise an exception.
"""
+
+patch_collection_property(IWebhook, 'deliveries', IWebhookDeliveryJob)
+patch_entry_return_type(IWebhook, 'ping', IWebhookDeliveryJob)
+patch_reference_property(IWebhook, 'target', IWebhookTarget)
=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py 2015-07-07 08:04:32 +0000
+++ lib/lp/services/webhooks/model.py 2015-07-07 08:04:33 +0000
@@ -7,6 +7,7 @@
'Webhook',
'WebhookJob',
'WebhookJobType',
+ 'WebhookTargetMixin',
]
import datetime
@@ -34,11 +35,15 @@
)
from zope.security.proxy import removeSecurityProxy
+from lp.registry.model.person import Person
from lp.services.config import config
+from lp.services.database.bulk import load_related
from lp.services.database.constants import UTC_NOW
+from lp.services.database.decoratedresultset import DecoratedResultSet
from lp.services.database.enumcol import EnumCol
from lp.services.database.interfaces import IStore
from lp.services.database.stormbase import StormBase
+from lp.services.features import getFeatureFlag
from lp.services.job.model.job import (
EnumeratedSubclass,
Job,
@@ -50,6 +55,8 @@
IWebhookDeliveryJob,
IWebhookDeliveryJobSource,
IWebhookJob,
+ IWebhookSource,
+ WebhookFeatureDisabled,
)
@@ -82,7 +89,7 @@
delivery_url = Unicode(allow_none=False)
active = Bool(default=True, allow_none=False)
- secret = Unicode(allow_none=False)
+ secret = Unicode(allow_none=True)
json_data = JSON(name='json_data')
@@ -94,6 +101,26 @@
raise AssertionError("No target.")
@property
+ def deliveries(self):
+ jobs = Store.of(self).find(
+ WebhookJob,
+ WebhookJob.webhook == self,
+ WebhookJob.job_type == WebhookJobType.DELIVERY,
+ ).order_by(WebhookJob.job_id)
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ['job_id'])
+
+ return DecoratedResultSet(
+ jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
+
+ def getDelivery(self, id):
+ return self.deliveries.find(WebhookJob.job_id == id).one()
+
+ def ping(self):
+ return WebhookDeliveryJob.create(self, {'ping': True})
+
+ @property
def event_types(self):
return (self.json_data or {}).get('event_types', [])
@@ -109,6 +136,8 @@
class WebhookSource:
"""See `IWebhookSource`."""
+ implements(IWebhookSource)
+
def new(self, target, registrant, delivery_url, event_types, active,
secret):
from lp.code.interfaces.gitrepository import IGitRepository
@@ -123,6 +152,7 @@
hook.secret = secret
hook.event_types = event_types
IStore(Webhook).add(hook)
+ IStore(Webhook).flush()
return hook
def delete(self, hooks):
@@ -141,6 +171,24 @@
return IStore(Webhook).find(Webhook, target_filter)
+class WebhookTargetMixin:
+
+ @property
+ def webhooks(self):
+ def preload_registrants(rows):
+ load_related(Person, rows, ['registrant_id'])
+
+ return DecoratedResultSet(
+ getUtility(IWebhookSource).findByTarget(self),
+ pre_iter_hook=preload_registrants)
+
+ def newWebhook(self, registrant, delivery_url, event_types, active=True):
+ if not getFeatureFlag('webhooks.new.enabled'):
+ raise WebhookFeatureDisabled()
+ return getUtility(IWebhookSource).new(
+ self, registrant, delivery_url, event_types, active, None)
+
+
class WebhookJobType(DBEnumeratedType):
"""Values that `IWebhookJob.job_type` can take."""
=== modified file 'lib/lp/services/webhooks/tests/test_webhook.py'
--- lib/lp/services/webhooks/tests/test_webhook.py 2015-07-07 08:04:32 +0000
+++ lib/lp/services/webhooks/tests/test_webhook.py 2015-07-07 08:04:33 +0000
@@ -66,8 +66,9 @@
def test_get_permissions(self):
expected_get_permissions = {
'launchpad.View': set((
- 'active', 'date_created', 'date_last_modified', 'delivery_url',
- 'event_types', 'id', 'registrant', 'secret', 'target')),
+ 'active', 'date_created', 'date_last_modified', 'deliveries',
+ 'delivery_url', 'event_types', 'getDelivery', 'id', 'ping',
+ 'registrant', 'secret', 'target')),
}
webhook = self.factory.makeWebhook()
checker = getChecker(webhook)
=== 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-07 08:04:33 +0000
@@ -0,0 +1,240 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the webhook webservice objects."""
+
+__metaclass__ = type
+
+import json
+
+from testtools.matchers import (
+ ContainsDict,
+ Equals,
+ GreaterThan,
+ Is,
+ KeysEqual,
+ MatchesAll,
+ Not,
+ )
+
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.testing import (
+ api_url,
+ person_logged_in,
+ record_two_runs,
+ TestCaseWithFactory,
+ )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import (
+ LaunchpadWebServiceCaller,
+ webservice_for_person,
+ )
+
+
+class TestWebhook(TestCaseWithFactory):
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestWebhook, self).setUp()
+ target = self.factory.makeGitRepository()
+ self.owner = target.owner
+ with person_logged_in(self.owner):
+ self.webhook = self.factory.makeWebhook(
+ target=target, delivery_url=u'http://example.com/ep')
+ self.webhook_url = api_url(self.webhook)
+ self.webservice = webservice_for_person(
+ self.owner, permission=OAuthPermission.WRITE_PRIVATE)
+
+ def test_get(self):
+ representation = self.webservice.get(
+ self.webhook_url, api_version='devel').jsonBody()
+ self.assertThat(
+ representation,
+ KeysEqual(
+ 'active', 'date_created', 'date_last_modified',
+ 'deliveries_collection_link', 'delivery_url', 'event_types',
+ 'http_etag', 'registrant_link', 'resource_type_link',
+ 'self_link', 'target_link', 'web_link'))
+
+ def test_patch(self):
+ representation = self.webservice.get(
+ self.webhook_url, api_version='devel').jsonBody()
+ self.assertThat(
+ representation,
+ ContainsDict(
+ {'active': Equals(True),
+ 'delivery_url': Equals('http://example.com/ep'),
+ 'event_types': Equals([])}))
+ old_mtime = representation['date_last_modified']
+ patch = json.dumps(
+ {'active': False, 'delivery_url': 'http://example.com/ep2',
+ 'event_types': ['foo', 'bar']})
+ self.webservice.patch(
+ self.webhook_url, 'application/json', patch, api_version='devel')
+ representation = self.webservice.get(
+ self.webhook_url, api_version='devel').jsonBody()
+ self.assertThat(
+ representation,
+ ContainsDict(
+ {'active': Equals(False),
+ 'delivery_url': Equals('http://example.com/ep2'),
+ 'date_last_modified': GreaterThan(old_mtime),
+ 'event_types': Equals(['foo', 'bar'])}))
+
+ def test_anon_forbidden(self):
+ response = LaunchpadWebServiceCaller().get(
+ self.webhook_url, api_version='devel')
+ self.assertEqual(401, response.status)
+ self.assertIn('launchpad.View', response.body)
+
+ def test_deliveries(self):
+ representation = self.webservice.get(
+ self.webhook_url + '/deliveries', api_version='devel').jsonBody()
+ self.assertContentEqual(
+ [], [entry['payload'] for entry in representation['entries']])
+
+ # Send a test event.
+ response = self.webservice.named_post(
+ self.webhook_url, 'ping', api_version='devel')
+ self.assertEqual(201, response.status)
+ delivery = self.webservice.get(
+ response.getHeader("Location")).jsonBody()
+ self.assertEqual({'ping': True}, delivery['payload'])
+
+ # The delivery shows up in the collection.
+ representation = self.webservice.get(
+ self.webhook_url + '/deliveries', api_version='devel').jsonBody()
+ self.assertContentEqual(
+ [delivery['self_link']],
+ [entry['self_link'] for entry in representation['entries']])
+
+ def test_deliveries_query_count(self):
+ def get_deliveries():
+ representation = self.webservice.get(
+ self.webhook_url + '/deliveries',
+ api_version='devel').jsonBody()
+ self.assertIn(len(representation['entries']), (0, 2, 4))
+
+ def create_delivery():
+ with person_logged_in(self.owner):
+ self.webhook.ping()
+
+ get_deliveries()
+ recorder1, recorder2 = record_two_runs(
+ get_deliveries, create_delivery, 2)
+ self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
+
+
+class TestWebhookDelivery(TestCaseWithFactory):
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestWebhookDelivery, self).setUp()
+ target = self.factory.makeGitRepository()
+ self.owner = target.owner
+ with person_logged_in(self.owner):
+ self.webhook = self.factory.makeWebhook(
+ target=target, delivery_url=u'http://example.com/ep')
+ self.webhook_url = api_url(self.webhook)
+ self.delivery = self.webhook.ping()
+ self.delivery_url = api_url(self.delivery)
+ self.webservice = webservice_for_person(
+ self.owner, permission=OAuthPermission.WRITE_PRIVATE)
+
+ def test_get(self):
+ representation = self.webservice.get(
+ self.delivery_url, api_version='devel').jsonBody()
+ self.assertThat(
+ representation,
+ MatchesAll(
+ KeysEqual(
+ 'date_created', 'date_sent', 'http_etag', 'payload',
+ 'pending', 'resource_type_link', 'self_link',
+ 'successful', 'web_link', 'webhook_link'),
+ ContainsDict(
+ {'payload': Equals({'ping': True}),
+ 'pending': Equals(True),
+ 'successful': Is(None),
+ 'date_created': Not(Is(None)),
+ 'date_sent': Is(None)})))
+
+
+class TestWebhookTarget(TestCaseWithFactory):
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestWebhookTarget, self).setUp()
+ self.target = self.factory.makeGitRepository()
+ self.owner = self.target.owner
+ self.target_url = api_url(self.target)
+ self.webservice = webservice_for_person(
+ self.owner, permission=OAuthPermission.WRITE_PRIVATE)
+
+ def test_webhooks(self):
+ with person_logged_in(self.owner):
+ for ep in (u'http://example.com/ep1', u'http://example.com/ep2'):
+ self.factory.makeWebhook(target=self.target, delivery_url=ep)
+ representation = self.webservice.get(
+ self.target_url + '/webhooks', api_version='devel').jsonBody()
+ self.assertContentEqual(
+ ['http://example.com/ep1', 'http://example.com/ep2'],
+ [entry['delivery_url'] for entry in representation['entries']])
+
+ def test_webhooks_permissions(self):
+ webservice = LaunchpadWebServiceCaller()
+ response = webservice.get(
+ self.target_url + '/webhooks', api_version='devel')
+ self.assertEqual(401, response.status)
+ self.assertIn('launchpad.Edit', response.body)
+
+ def test_webhooks_query_count(self):
+ def get_webhooks():
+ representation = self.webservice.get(
+ self.target_url + '/webhooks',
+ api_version='devel').jsonBody()
+ self.assertIn(len(representation['entries']), (0, 2, 4))
+
+ def create_webhook():
+ with person_logged_in(self.owner):
+ self.factory.makeWebhook(target=self.target)
+
+ get_webhooks()
+ recorder1, recorder2 = record_two_runs(
+ get_webhooks, create_webhook, 2)
+ self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
+
+ def test_newWebhook(self):
+ self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+ response = self.webservice.named_post(
+ self.target_url, 'newWebhook',
+ delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
+ api_version='devel')
+ self.assertEqual(201, response.status)
+
+ representation = self.webservice.get(
+ self.target_url + '/webhooks', api_version='devel').jsonBody()
+ self.assertContentEqual(
+ [('http://example.com/ep', ['foo', 'bar'], True)],
+ [(entry['delivery_url'], entry['event_types'], entry['active'])
+ for entry in representation['entries']])
+
+ def test_newWebhook_permissions(self):
+ self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+ webservice = LaunchpadWebServiceCaller()
+ response = webservice.named_post(
+ self.target_url, 'newWebhook',
+ delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
+ api_version='devel')
+ self.assertEqual(401, response.status)
+ self.assertIn('launchpad.Edit', response.body)
+
+ def test_newWebhook_feature_flag_guard(self):
+ response = self.webservice.named_post(
+ self.target_url, 'newWebhook',
+ delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
+ api_version='devel')
+ self.assertEqual(401, response.status)
+ self.assertEqual(
+ 'This webhook feature is not available yet.', response.body)
=== 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-07 08:04:33 +0000
@@ -0,0 +1,18 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Webhook webservice registrations."""
+
+__metaclass__ = type
+
+__all__ = [
+ 'IWebhook',
+ 'IWebhookDeliveryJob',
+ 'IWebhookTarget',
+ ]
+
+from lp.services.webhooks.interfaces import (
+ IWebhook,
+ IWebhookDeliveryJob,
+ IWebhookTarget,
+ )
Follow ups