Skip to content

Commit fedc550

Browse files
authored
Merge pull request #9 from octue/feature/endpoint-constructor
Add event URL generation utility
2 parents f1c1c6b + 9f5abcc commit fedc550

File tree

9 files changed

+130
-38
lines changed

9 files changed

+130
-38
lines changed

.devcontainer/devcontainer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"austin.mode": "Wall time",
1313
"editor.defaultFormatter": "esbenp.prettier-vscode",
1414
"editor.formatOnSave": true,
15+
"esbonio.server.enabled": true,
16+
"esbonio.sphinx.confDir": "${workspaceFolder}/docs/source",
1517
"jupyter.widgetScriptSources": ["jsdelivr.com", "unpkg.com"],
1618
"prettier.prettierPath": "/usr/local/prettier",
1719
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
@@ -37,10 +39,10 @@
3739
"python.linting.pylintEnabled": true,
3840
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint",
3941
"python.pythonPath": "/usr/local/bin/python",
40-
"restructuredtext.confPath": "${workspaceFolder}/docs/source",
4142
// Scrolling the editor is a nice idea but it doesn't work, always out of sync and impossible to manage
4243
"restructuredtext.preview.scrollEditorWithPreview": false,
4344
"restructuredtext.preview.scrollPreviewWithEditor": false,
45+
"restructuredtext.linter.doc8.extraArgs": ["--max-line-length 180"],
4446
"terminal.integrated.defaultProfile.linux": "zsh"
4547
},
4648

django_gcp/events/utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
from django.conf import settings
3+
from django.urls import reverse
4+
from django.utils.http import urlencode
5+
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def get_event_url(event_kind, event_reference, event_parameters=None, url_namespace="gcp-events", base_url=None):
11+
"""Returns a fully constructed url for the events endpoint, suitable for receipt and processing of events
12+
:param str event_kind: The kind of the event (must be url-safe)
13+
:param str event_reference: A reference allowing either identification of unique events or a group of related events (must be url-safe)
14+
: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.
15+
: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)
16+
: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 ''.
17+
:return str: The fully constructed webhook endpoint
18+
"""
19+
url = reverse(url_namespace, args=[event_kind, event_reference])
20+
if event_parameters is not None:
21+
url = url + "?" + urlencode(event_parameters)
22+
23+
if base_url is None:
24+
try:
25+
base_url = settings.BASE_URL
26+
except AttributeError as e:
27+
raise AttributeError(
28+
"Either specify BASE_URL in your settings module, or explicitly pass a base_url parameter to get_push_endpoint()"
29+
) from e
30+
31+
url = base_url + url
32+
33+
logger.debug("Generated webhook endpoitn url %s", url)
34+
35+
return url

django_gcp/events/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ def post(self, request, event_kind, event_reference):
2323
"""Handle a POSTed event"""
2424
try:
2525
event_payload = json.loads(request.body)
26+
event_parameters = request.GET.dict()
2627
event_received.send(
2728
sender=self.__class__,
2829
event_kind=event_kind,
2930
event_reference=event_reference,
3031
event_payload=event_payload,
32+
event_parameters=event_parameters,
3133
)
3234
return self._prepare_response(status=201, payload={})
3335

docs/source/authentication.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ You'll want to avoid injecting a service account json file into your github acti
4040
Locally
4141
-------
4242

43-
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>`_.
43+
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>`_).
4444

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

docs/source/events.rst

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ About Authentication
1919
In the meantime, it's your responsibility to ensure that your handlers are protected (or otherwise wrap the
2020
urls in a decorator to manage authentication).
2121

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

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

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

5151
Creating A Receiver
52-
-----------------
52+
-------------------
5353

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

@@ -69,6 +69,7 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
6969
:param event_kind (str): A kind/variety allowing you to determine the handler to use (eg "something-update"). Required.
7070
:param event_reference (str): A reference value provided by the client allowing events to be sorted/filtered. Required.
7171
:param event_payload (dict, array): The event payload to process, already decoded.
72+
:param event_parameters (dict): Extra parameters passed to the endpoint using URL query parameters
7273
:return: None
7374
"""
7475
# There could be many different event types, from your own or other apps, and
@@ -88,6 +89,48 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
8889
if event_kind.startswith("my-"):
8990
my_handler(event_kind, event_reference, event_payload)
9091
92+
Generating Endpoint URLs
93+
------------------------
94+
95+
A utility is provided to help generate URLs for the events endpoint.
96+
This is similar to, but easier than, generating URLs with django's built-in ``reverse()`` function.
97+
98+
It generates absolute URLs by default, because integration with external systems is the most common use case.
99+
100+
.. code-block:: python
101+
102+
import logging
103+
from django_gcp.events.utils import get_event_url
104+
105+
logger = logging.getLogger(__name__)
106+
107+
get_event_url(
108+
'the-kind',
109+
'the-reference',
110+
event_parameters={"a":"parameter"}, # These get encoded as a querystring, and are decoded back to a dict by the events endpoint. Keep it short!
111+
url_namespace="gcp-events", # You only need to edit this if you define your own urlpatterns with a different namespace
112+
)
113+
114+
.. tip::
115+
116+
By default, ``get_event_url`` generates an absolute URL, using the configured ``settings.BASE_URL``.
117+
To specify a different base url, you can pass it explicitly:
118+
119+
.. code-block:: python
120+
121+
relative_url = get_event_url(
122+
'the-kind',
123+
'the-reference',
124+
base_url=''
125+
)
126+
127+
non_default_base_url = get_event_url(
128+
'the-kind',
129+
'the-reference',
130+
base_url='https://somewhere.else.com'
131+
)
132+
133+
91134
92135
Exception Handling
93136
------------------

docs/source/examples.rst

Lines changed: 0 additions & 31 deletions
This file was deleted.

docs/source/storage.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ tools built for the purpose, like terraform or Deployment Manager.
1616

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

2223
.. TIP::
2324
Having two buckets like this means it's easier to configure which files are public and which aren't.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-gcp"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Utilities to run Django on Google Cloud Platform"
55
authors = ["Tom Clark"]
66
license = "MIT"

tests/test_events.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from unittest.mock import patch
1111
from django.test import TestCase
1212
from django.urls import reverse
13+
from django_gcp.events.utils import get_event_url
1314

1415

1516
def raise_error(*args, **kwargs):
@@ -68,3 +69,42 @@ def test_handling_errors_are_returned_unhandleable(self):
6869
content_type="application/json",
6970
)
7071
self.assertEqual(response.status_code, 400)
72+
73+
@patch("django_gcp.events.signals.event_received.send")
74+
def test_get_event_url_with_parameters(self, mock):
75+
"""Ensure that push endpoint URLs can be reversed successfully with parameters that are decoded on receipt"""
76+
77+
complex_parameter = "://something?> awkward"
78+
event_url = get_event_url(
79+
"the-kind", "the-reference", event_parameters={"complex_parameter": complex_parameter}, base_url=""
80+
)
81+
82+
response = self.client.post(
83+
event_url,
84+
data="{}",
85+
content_type="application/json",
86+
)
87+
88+
self.assertEqual(response.status_code, 201)
89+
self.assertTrue(mock.called)
90+
self.assertEqual(mock.call_count, 1)
91+
self.assertIn("sender", mock.call_args.kwargs)
92+
self.assertIn("event_kind", mock.call_args.kwargs)
93+
self.assertIn("event_reference", mock.call_args.kwargs)
94+
self.assertIn("event_payload", mock.call_args.kwargs)
95+
self.assertIn("event_parameters", mock.call_args.kwargs)
96+
self.assertIn("complex_parameter", mock.call_args.kwargs["event_parameters"])
97+
self.assertEqual(mock.call_args.kwargs["event_parameters"]["complex_parameter"], complex_parameter)
98+
99+
def test_endpoint_with_no_base_url(self):
100+
"""Ensure that an AttributeError is correctly raised when getting an event url with no settings.BASE_URL"""
101+
102+
with self.assertRaises(AttributeError):
103+
get_event_url("the-kind", "the-reference")
104+
105+
def test_endpoint_with_base_url(self):
106+
"""Ensure that an AttributeError is correctly raised when getting an event url with no settings.BASE_URL"""
107+
108+
with self.settings(BASE_URL="https://something.com"):
109+
event_url = get_event_url("the-kind", "the-reference")
110+
self.assertEqual(event_url, "https://something.com/test-django-gcp/events/the-kind/the-reference")

0 commit comments

Comments
 (0)