Skip to content

Commit 5af066d

Browse files
authored
Merge pull request #11 from octue/fix/endpoint-rountrip-test
Add missing parameter to example event handler
2 parents fedc550 + 2fee2d7 commit 5af066d

File tree

9 files changed

+460
-838
lines changed

9 files changed

+460
-838
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
strategy:
4343
fail-fast: true
4444
matrix:
45-
python: ["3.8", "3.9", "3.10"]
45+
python: ["3.9", "3.10"]
4646
os: [ubuntu-latest] # [ubuntu-latest, windows-latest, macos-latest] for full coverage but this gets expensive quickly
4747
runs-on: ${{ matrix.os }}
4848

django_gcp/events/utils.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import base64
2+
import json
13
import logging
4+
from datetime import timezone
5+
from dateutil.parser import isoparse
26
from django.conf import settings
37
from django.urls import reverse
48
from django.utils.http import urlencode
@@ -7,6 +11,15 @@
711
logger = logging.getLogger(__name__)
812

913

14+
def _make_naive_utc(value):
15+
"""Converts a timezone-aware datetime.datetime to UTC then makes it naive.
16+
Used for strictly formatting the UTC-in-nanoseconds publish time of pub/sub messages
17+
"""
18+
if value.tzinfo is not None:
19+
value = value.astimezone(timezone.utc).replace(tzinfo=None)
20+
return value
21+
22+
1023
def get_event_url(event_kind, event_reference, event_parameters=None, url_namespace="gcp-events", base_url=None):
1124
"""Returns a fully constructed url for the events endpoint, suitable for receipt and processing of events
1225
:param str event_kind: The kind of the event (must be url-safe)
@@ -33,3 +46,74 @@ def get_event_url(event_kind, event_reference, event_parameters=None, url_namesp
3346
logger.debug("Generated webhook endpoitn url %s", url)
3447

3548
return url
49+
50+
51+
def make_pubsub_message(
52+
data,
53+
attributes=None,
54+
message_id=None,
55+
ordering_key=None,
56+
publish_time=None,
57+
):
58+
"""Make a json-encodable message replicating the GCP Pub/Sub v1 format
59+
60+
For more details see: https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage
61+
62+
:param Union[dict, list] data: JSON-serialisable data to form the body of the message
63+
:param Union[dict, None] attributes: Dict of attributes to attach to the message. Contents must be flat, containing only string keys with string values.
64+
:param Union[str, None] message_id: An optional id for the message.
65+
:param Union[str, None] ordering_key: A string used to order messages.
66+
:param Union[datetime, None] publish_time: If sending a message to PubSub, this will be set by the server on receipt so generally should be left as `None`. However, for the purposes of mocking messages for testing, supply a python datetime specifying the publish time of the message, which will be converted to a string timestamp with nanosecond accuracy.
67+
:return dict: A dict containing a fully composed PubSub message
68+
69+
"""
70+
out = dict()
71+
72+
out["data"] = base64.b64encode(json.dumps(data).encode()).decode()
73+
74+
if publish_time is not None:
75+
publish_time_utc_naive = _make_naive_utc(publish_time)
76+
iso_us = publish_time_utc_naive.isoformat()
77+
iso_ns = f"{iso_us}000Z"
78+
out["publishTime"] = iso_ns
79+
80+
if attributes is not None:
81+
# Check all attributes are k-v pairs of strings
82+
for k, v in attributes.items():
83+
if k.__class__ != str:
84+
raise ValueError("All attribute keys must be strings")
85+
if v.__class__ != str:
86+
raise ValueError("All attribute values must be strings")
87+
out["attributes"] = attributes
88+
89+
if message_id is not None:
90+
if message_id.__class__ != str:
91+
raise ValueError("The message_id, if given, must be a string")
92+
out["messageId"] = message_id
93+
94+
if ordering_key is not None:
95+
if ordering_key.__class__ != str:
96+
raise ValueError("The ordering_key, if given, must be a string")
97+
out["orderingKey"] = ordering_key
98+
99+
return out
100+
101+
102+
def decode_pubsub_message(message):
103+
"""Decode data within a pubsub message
104+
:parameter dict message: The Pub/Sub message, which should already be decoded from a raw JSON string to a dict.
105+
:return: None
106+
"""
107+
108+
decoded = {
109+
"data": json.loads(base64.b64decode(message["data"])),
110+
"attributes": message.get("attributes", None),
111+
"message_id": message.get("messageId", None),
112+
"ordering_key": message.get("orderingKey", None),
113+
"publish_time": message.get("publishTime", None),
114+
}
115+
116+
if decoded["publish_time"] is not None:
117+
decoded["publish_time"] = isoparse(decoded["publish_time"])
118+
119+
return decoded

django_gcp/events/views.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
from django.conf import settings
34
from django.http import HttpResponse
45
from django.utils.decorators import method_decorator
56
from django.views.decorators.csrf import csrf_exempt
@@ -34,9 +35,12 @@ def post(self, request, event_kind, event_reference):
3435
return self._prepare_response(status=201, payload={})
3536

3637
except Exception as e: # pylint: disable=broad-except
37-
msg = f"Unable to handle event of kind {event_kind} with reference {event_reference}"
38-
logger.warning("%s. Exception: %s", msg, str(e))
39-
return self._prepare_response(status=400, payload={"error": msg})
38+
if getattr(settings, "DEBUG", False):
39+
raise e
40+
else:
41+
msg = f"Unable to handle event of kind {event_kind} with reference {event_reference}"
42+
logger.warning("%s. Exception: %s", msg, str(e))
43+
return self._prepare_response(status=400, payload={"error": msg})
4044

4145
def _prepare_response(self, status, payload):
4246
return HttpResponse(status=status, content=json.dumps(payload), content_type="application/json")

docs/source/events.rst

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,14 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
5858
import logging
5959
from django.dispatch import receiver
6060
from django_gcp.events.signals import event_received
61+
from django_gcp.events.utils import decode_pubsub_message
6162
6263
6364
logger = logging.getLogger(__name__)
6465
6566
6667
@receiver(event_received)
67-
def receive_event(sender, event_kind, event_reference, event_payload):
68+
def receive_event(sender, event_kind, event_reference, event_payload, event_parameters):
6869
"""Handle question updates received via pubsub
6970
:param event_kind (str): A kind/variety allowing you to determine the handler to use (eg "something-update"). Required.
7071
:param event_reference (str): A reference value provided by the client allowing events to be sorted/filtered. Required.
@@ -78,7 +79,13 @@ This is how you attach your handler. In ``your-app/signals.py`` file, do:
7879
if event_kind is "something-important":
7980
# Here is where you handle the event using whatever logic you want
8081
# CAREFUL: See the tip above about authentication (verifying the payload is not malicious)
81-
print("DO SOMETHING IMPORTANT")
82+
print("DO SOMETHING IMPORTANT WITH THE PAYLOAD:", event_payload)
83+
#
84+
# Your payload can be from any arbitrary source, and is in the form of decoded json.
85+
# However, if the source is Eventarc or Pub/Sub, the payload contains a formatted message
86+
# with base64 encoded data; we provide a utility to further decode this into something sensible:
87+
message = decode_pubsub_message(event_payload)
88+
print("DECODED PUBSUB MESSAGE:" message)
8289
8390
.. tip::
8491

@@ -131,6 +138,35 @@ It generates absolute URLs by default, because integration with external systems
131138
)
132139
133140
141+
Generating and Consuming Pub/Sub Messages
142+
-----------------------------------------
143+
144+
When hooked up to GCP Pub/Sub or eventarc, the event payload is in the form of a Pub/Sub message.
145+
146+
These messages have a specific format (see https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage).
147+
148+
To allow you to interact directly with Pub/Sub (i.e. publish messages to a topic), or for the purposes of testing your signals,
149+
``django-gcp`` includes a `make_pubsub_message` utility that provides an easy and pythonic way of constructing a Pub/Sub message.
150+
151+
For example, to test the signal receiver above with a replica of a real pubsub message payload, you might do:
152+
153+
.. code-block:: python
154+
155+
from django_gcp.events.utils import make_pubsub_message
156+
from datetime import datetime
157+
158+
class YourTests(TestCase):
159+
def test_your_code_handles_a_payload_from_pubsub(self):
160+
payload = make_pubsub_message({"my": "data"}, publish_time=datetime.now())
161+
162+
response = self.client.post(
163+
reverse("gcp-events", args=["the-event-kind", "the-event-reference"]),
164+
data=json.dumps(payload),
165+
content_type="application/json",
166+
)
167+
168+
self.assertEqual(response.status_code, 201)
169+
134170
135171
Exception Handling
136172
------------------

0 commit comments

Comments
 (0)