From 9ec08e447fb0ce53d9ff9d7f5093356779669047 Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Tue, 12 Apr 2022 17:28:44 +0000 Subject: [PATCH 1/6] CHO: Update restructuredText config --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ef26ff0..ef1cdbe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,6 +12,7 @@ "austin.mode": "Wall time", "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": 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", @@ -37,7 +38,6 @@ "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, From 21c805ad75f2ed23b341955ff369e8631f059f96 Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Wed, 13 Apr 2022 01:03:03 +0000 Subject: [PATCH 2/6] CHO: Remove rst formatting error --- docs/source/storage.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/storage.rst b/docs/source/storage.rst index 8cfc909..0f4d2e9 100644 --- a/docs/source/storage.rst +++ b/docs/source/storage.rst @@ -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. From 98934788b2190ce6b60fc3f18c9f5ada6d5235fa Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Wed, 13 Apr 2022 01:15:02 +0000 Subject: [PATCH 3/6] CHO: Remove unused doc file --- docs/source/examples.rst | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 docs/source/examples.rst diff --git a/docs/source/examples.rst b/docs/source/examples.rst deleted file mode 100644 index f72626f..0000000 --- a/docs/source/examples.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _examples: - -======== -Examples -======== - -Here, we look at example use cases for the library, and show how to use it in python. - -It's also well worth looking at the unit test cases. - -.. _example_tabs: - -Tabs with different examples -============================ - -.. tabs:: - - .. group-tab:: Scenario - - You need to provide stuff - - .. group-tab:: Tab2 - - We need tabs! - - .. code-block:: py - - { - "thats": "right", - "code": "goes in tabs too", - } From 8074f646bd2fcecfec9d3da31282133a561a0d7d Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Wed, 13 Apr 2022 01:20:44 +0000 Subject: [PATCH 4/6] DOC: Fix typo --- docs/source/authentication.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/authentication.rst b/docs/source/authentication.rst index 01dad54..51a8f46 100644 --- a/docs/source/authentication.rst +++ b/docs/source/authentication.rst @@ -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`_. +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 `_). So you should totally try that (please submit a PR here to show the process if you get it to work!!). In the meantime... From 8ef0920ca40491f31f202203c357317204f905de Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Wed, 13 Apr 2022 01:21:21 +0000 Subject: [PATCH 5/6] OPS: Update esbonio and restructuredText settings --- .devcontainer/devcontainer.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ef1cdbe..551c9d3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,6 +12,7 @@ "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", @@ -41,6 +42,7 @@ // 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" }, From 9f5abcc5dc6e21e06684a77553f49c0a601049da Mon Sep 17 00:00:00 2001 From: Tom Clark Date: Wed, 13 Apr 2022 01:21:54 +0000 Subject: [PATCH 6/6] FEA: Add event url generation helper --- django_gcp/events/utils.py | 35 ++++++++++++++++++++++++++++ django_gcp/events/views.py | 2 ++ docs/source/events.rst | 47 ++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/test_events.py | 40 ++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 django_gcp/events/utils.py diff --git a/django_gcp/events/utils.py b/django_gcp/events/utils.py new file mode 100644 index 0000000..a661bca --- /dev/null +++ b/django_gcp/events/utils.py @@ -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 diff --git a/django_gcp/events/views.py b/django_gcp/events/views.py index d594229..ecd88fc 100644 --- a/django_gcp/events/views.py +++ b/django_gcp/events/views.py @@ -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={}) diff --git a/docs/source/events.rst b/docs/source/events.rst index d6c827b..d1d8b6e 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -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! @@ -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: @@ -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 @@ -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 ------------------ diff --git a/pyproject.toml b/pyproject.toml index 64cfb2e..79bb2d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_events.py b/tests/test_events.py index e668ed2..df0fd57 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -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): @@ -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")