Skip to content

Commit

Permalink
Merge pull request #9 from octue/feature/endpoint-constructor
Browse files Browse the repository at this point in the history
Add event URL generation utility
  • Loading branch information
thclark authored Apr 13, 2022
2 parents f1c1c6b + 9f5abcc commit fedc550
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 38 deletions.
4 changes: 3 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"austin.mode": "Wall time",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"esbonio.server.enabled": true,
"esbonio.sphinx.confDir": "${workspaceFolder}/docs/source",
"jupyter.widgetScriptSources": ["jsdelivr.com", "unpkg.com"],
"prettier.prettierPath": "/usr/local/prettier",
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
Expand All @@ -37,10 +39,10 @@
"python.linting.pylintEnabled": true,
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint",
"python.pythonPath": "/usr/local/bin/python",
"restructuredtext.confPath": "${workspaceFolder}/docs/source",
// Scrolling the editor is a nice idea but it doesn't work, always out of sync and impossible to manage
"restructuredtext.preview.scrollEditorWithPreview": false,
"restructuredtext.preview.scrollPreviewWithEditor": false,
"restructuredtext.linter.doc8.extraArgs": ["--max-line-length 180"],
"terminal.integrated.defaultProfile.linux": "zsh"
},

Expand Down
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
2 changes: 1 addition & 1 deletion docs/source/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ You'll want to avoid injecting a service account json file into your github acti
Locally
-------

We're working on using service account impersonation, but it's not fully available for all the SDKs yet, still a lot of teething problems (like `this one, solved 6 days ago at the time of writing<https://github.com/googleapis/google-auth-library-python/issues/762>`_.
We're working on using service account impersonation, but it's not fully available for all the SDKs yet, still a lot of teething problems (like `this one, solved 6 days ago at the time of writing <https://github.com/googleapis/google-auth-library-python/issues/762>`_).

So you should totally try that (please submit a PR here to show the process if you get it to work!!). In the meantime...

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
31 changes: 0 additions & 31 deletions docs/source/examples.rst

This file was deleted.

5 changes: 3 additions & 2 deletions docs/source/storage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ tools built for the purpose, like terraform or Deployment Manager.

If you're setting up for the first time and don't want to get into that kind of infrastructure-as-code stuff, then
manually create two buckets in your project:
- One with **object-level** permissions for **media** files.
- One with **uniform, public** permissions for **static** files.

- One with **object-level** permissions for **media** files.
- One with **uniform, public** permissions for **static** files.

.. TIP::
Having two buckets like this means it's easier to configure which files are public and which aren't.
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 fedc550

Please sign in to comment.