Skip to content

Commit

Permalink
FEA: Add event url generation helper
Browse files Browse the repository at this point in the history
  • Loading branch information
thclark committed Apr 13, 2022
1 parent 8ef0920 commit 9f5abcc
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 3 deletions.
35 changes: 35 additions & 0 deletions django_gcp/events/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import logging
from django.conf import settings
from django.urls import reverse
from django.utils.http import urlencode


logger = logging.getLogger(__name__)


def get_event_url(event_kind, event_reference, event_parameters=None, url_namespace="gcp-events", base_url=None):
"""Returns a fully constructed url for the events endpoint, suitable for receipt and processing of events
:param str event_kind: The kind of the event (must be url-safe)
:param str event_reference: A reference allowing either identification of unique events or a group of related events (must be url-safe)
:param Union[dict, None] event_parameters: Dict of additional parameters to encode into the URL querystring, for example use {"token": "abc"} to add a token parameter that gets received by the endpoint.
:param str url_namespace: Default 'gcp-events'. URL namespace of the django-gcp events (see https://docs.djangoproject.com/en/4.0/topics/http/urls/#url-namespaces)
:param Union[str, None] base_url: The base url (eg https://somewhere.com) for the URL. By default, this uses django's BASE_URL setting. To generate an empty value (a relative URL) use an empty string ''.
:return str: The fully constructed webhook endpoint
"""
url = reverse(url_namespace, args=[event_kind, event_reference])
if event_parameters is not None:
url = url + "?" + urlencode(event_parameters)

if base_url is None:
try:
base_url = settings.BASE_URL
except AttributeError as e:
raise AttributeError(
"Either specify BASE_URL in your settings module, or explicitly pass a base_url parameter to get_push_endpoint()"
) from e

url = base_url + url

logger.debug("Generated webhook endpoitn url %s", url)

return url
2 changes: 2 additions & 0 deletions django_gcp/events/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ def post(self, request, event_kind, event_reference):
"""Handle a POSTed event"""
try:
event_payload = json.loads(request.body)
event_parameters = request.GET.dict()
event_received.send(
sender=self.__class__,
event_kind=event_kind,
event_reference=event_reference,
event_payload=event_payload,
event_parameters=event_parameters,
)
return self._prepare_response(status=201, payload={})

Expand Down
47 changes: 45 additions & 2 deletions docs/source/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ About Authentication
In the meantime, it's your responsibility to ensure that your handlers are protected (or otherwise wrap the
urls in a decorator to manage authentication).

The best way of doing this is to scramble/unscramble ``event_reference`` using the itsdangerous library.
The best way of doing this is to generate a single use token and supply it as an event parameter (see `Generating Endpoint URLs`_).

As always, if you want us to help, find us on GitHub!

Expand Down Expand Up @@ -49,7 +49,7 @@ So, if you ``POST`` data to ``https://your-server.com/django-gcp/events/my-kind/
with ``event_kind="my-event"`` and ``event_reference="my-reference"``.

Creating A Receiver
-----------------
-------------------

This is how you attach your handler. In ``your-app/signals.py`` file, do:

Expand All @@ -69,6 +69,7 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
:param event_kind (str): A kind/variety allowing you to determine the handler to use (eg "something-update"). Required.
:param event_reference (str): A reference value provided by the client allowing events to be sorted/filtered. Required.
:param event_payload (dict, array): The event payload to process, already decoded.
:param event_parameters (dict): Extra parameters passed to the endpoint using URL query parameters
:return: None
"""
# There could be many different event types, from your own or other apps, and
Expand All @@ -88,6 +89,48 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
if event_kind.startswith("my-"):
my_handler(event_kind, event_reference, event_payload)
Generating Endpoint URLs
------------------------

A utility is provided to help generate URLs for the events endpoint.
This is similar to, but easier than, generating URLs with django's built-in ``reverse()`` function.

It generates absolute URLs by default, because integration with external systems is the most common use case.

.. code-block:: python
import logging
from django_gcp.events.utils import get_event_url
logger = logging.getLogger(__name__)
get_event_url(
'the-kind',
'the-reference',
event_parameters={"a":"parameter"}, # These get encoded as a querystring, and are decoded back to a dict by the events endpoint. Keep it short!
url_namespace="gcp-events", # You only need to edit this if you define your own urlpatterns with a different namespace
)
.. tip::

By default, ``get_event_url`` generates an absolute URL, using the configured ``settings.BASE_URL``.
To specify a different base url, you can pass it explicitly:

.. code-block:: python
relative_url = get_event_url(
'the-kind',
'the-reference',
base_url=''
)
non_default_base_url = get_event_url(
'the-kind',
'the-reference',
base_url='https://somewhere.else.com'
)
Exception Handling
------------------
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-gcp"
version = "0.3.0"
version = "0.4.0"
description = "Utilities to run Django on Google Cloud Platform"
authors = ["Tom Clark"]
license = "MIT"
Expand Down
40 changes: 40 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from django_gcp.events.utils import get_event_url


def raise_error(*args, **kwargs):
Expand Down Expand Up @@ -68,3 +69,42 @@ def test_handling_errors_are_returned_unhandleable(self):
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

@patch("django_gcp.events.signals.event_received.send")
def test_get_event_url_with_parameters(self, mock):
"""Ensure that push endpoint URLs can be reversed successfully with parameters that are decoded on receipt"""

complex_parameter = "://something?> awkward"
event_url = get_event_url(
"the-kind", "the-reference", event_parameters={"complex_parameter": complex_parameter}, base_url=""
)

response = self.client.post(
event_url,
data="{}",
content_type="application/json",
)

self.assertEqual(response.status_code, 201)
self.assertTrue(mock.called)
self.assertEqual(mock.call_count, 1)
self.assertIn("sender", mock.call_args.kwargs)
self.assertIn("event_kind", mock.call_args.kwargs)
self.assertIn("event_reference", mock.call_args.kwargs)
self.assertIn("event_payload", mock.call_args.kwargs)
self.assertIn("event_parameters", mock.call_args.kwargs)
self.assertIn("complex_parameter", mock.call_args.kwargs["event_parameters"])
self.assertEqual(mock.call_args.kwargs["event_parameters"]["complex_parameter"], complex_parameter)

def test_endpoint_with_no_base_url(self):
"""Ensure that an AttributeError is correctly raised when getting an event url with no settings.BASE_URL"""

with self.assertRaises(AttributeError):
get_event_url("the-kind", "the-reference")

def test_endpoint_with_base_url(self):
"""Ensure that an AttributeError is correctly raised when getting an event url with no settings.BASE_URL"""

with self.settings(BASE_URL="https://something.com"):
event_url = get_event_url("the-kind", "the-reference")
self.assertEqual(event_url, "https://something.com/test-django-gcp/events/the-kind/the-reference")

0 comments on commit 9f5abcc

Please sign in to comment.