← Back to team overview

launchpad-reviewers team mailing list archive

[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