Skip to content

Commit

Permalink
feat(demo-mode): email prompt api endpoint (#83725)
Browse files Browse the repository at this point in the history
  • Loading branch information
obostjancic authored Jan 22, 2025
1 parent da017ea commit a13ce7b
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 0 deletions.
43 changes: 43 additions & 0 deletions src/sentry/api/endpoints/email_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import options
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from sentry.utils.marketo_client import MarketoClient

client = MarketoClient()


class EmailCaptureSerializer(CamelSnakeSerializer):
email = serializers.EmailField(required=True)


@region_silo_endpoint
class EmailCaptureEndpoint(Endpoint):
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
}
owner = ApiOwner.TELEMETRY_EXPERIENCE
# Disable authentication and permission requirements.
permission_classes = ()

def post(self, request: Request) -> Response:
if not options.get("demo-mode.enabled"):
return Response(status=404)

serializer = EmailCaptureSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

email = serializer.validated_data["email"]

# Include other fields in the request and send them to Marketo together.
# There are a undetermined number of optional fields in request.data and we don't validate them.
# Only the email field is required.
form = request.data
form["email"] = email
client.submit_form(form)
return Response(status=200)
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@
SourceMapsEndpoint,
UnknownDebugFilesEndpoint,
)
from .endpoints.email_capture import EmailCaptureEndpoint
from .endpoints.event_apple_crash_report import EventAppleCrashReportEndpoint
from .endpoints.event_attachment_details import EventAttachmentDetailsEndpoint
from .endpoints.event_attachments import EventAttachmentsEndpoint
Expand Down Expand Up @@ -3018,6 +3019,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
InternalEAFeaturesEndpoint.as_view(),
name="sentry-api-0-internal-ea-features",
),
re_path(
r"^demo/email-capture$",
EmailCaptureEndpoint.as_view(),
name="sentry-demo-mode-email-capture",
),
]

urlpatterns = [
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3471,6 +3471,12 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
),
]

MARKETO: Mapping[str, Any] = {
"base-url": os.getenv("MARKETO_BASE_URL"),
"client-id": os.getenv("MARKETO_CLIENT_ID"),
"client-secret": os.getenv("MARKETO_CLIENT_SECRET"),
"form-id": os.getenv("MARKETO_FORM_ID"),
}

# Devserver configuration overrides.
ngrok_host = os.environ.get("SENTRY_DEVSERVER_NGROK")
Expand Down
81 changes: 81 additions & 0 deletions src/sentry/utils/marketo_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import TypedDict

from django.conf import settings

from sentry import http


def marketo_option(field):
return settings.MARKETO[field]


class ErrorDict(TypedDict):
code: str
message: str


class MarketoErrorResponse(TypedDict):
errors: list[ErrorDict]


class MarketoError(Exception):
def __init__(self, data: MarketoErrorResponse):
# just use the first error
error = data["errors"][0]
self.code = error["code"]
self.message = error["message"]

def __str__(self):
return f"MarketoError: {self.code} - {self.message}"


class MarketoClient:
OAUTH_URL = "/identity/oauth/token"
SUBMIT_FORM_URL = "/rest/v1/leads/submitForm.json"

def make_request(self, url: str, *args, method="GET", **kwargs):
base_url = marketo_option("base-url")
full_url = base_url + url
method = kwargs.pop("method", "GET")
session = http.build_session()
resp = getattr(session, method.lower())(full_url, *args, **kwargs)
resp.raise_for_status()
return resp.json()

def make_rest_request(self, url: str, *args, headers=None, **kwargs):
if headers is None:
headers = {}
headers["Authorization"] = f"Bearer {self.token}"
headers["Content-Type"] = "application/json"
data = self.make_request(url, *args, headers=headers, **kwargs)

if not data.get("success"):
raise MarketoError(data)

# not handling field level errors where success=True

return data

def retrieve_token(self):
client_id = marketo_option("client-id")
clint_secret = marketo_option("client-secret")

url = f"{self.OAUTH_URL}?grant_type=client_credentials&client_id={client_id}&client_secret={clint_secret}"
return self.make_request(url)

def submit_form(self, fields):
body = {
"formId": marketo_option("form-id"),
"input": [
{
"leadFormFields": fields,
}
],
}

return self.make_rest_request(self.SUBMIT_FORM_URL, method="POST", json=body)

@property
def token(self):
resp = self.retrieve_token()
return resp["access_token"]
42 changes: 42 additions & 0 deletions tests/sentry/api/endpoints/test_email_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from unittest import mock

from django.urls import reverse

from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.options import override_options
from sentry.utils.marketo_client import MarketoClient


class EmailCaptureTest(APITestCase):
def setUp(self):
super().setUp()
self.organization = self.create_organization()
# demo user
self.demo_user = self.create_user()
self.demo_om = self.create_member(
organization=self.organization, user=self.demo_user, role="member"
)

@mock.patch.object(MarketoClient, "submit_form")
@override_options({"demo-mode.enabled": True})
def test_capture_endpoint(self, mock_submit_form):
self.login_as(self.demo_user)
url = reverse("sentry-demo-mode-email-capture")
response = self.client.post(url, {"email": "[email protected]"})
assert response.status_code == 200, response.content
mock_submit_form.assert_called_once_with({"email": "[email protected]"})

@override_options({"demo-mode.enabled": False})
def test_capture_endpoint_disabled(self):
self.login_as(self.demo_user)
url = reverse("sentry-demo-mode-email-capture")
response = self.client.post(url, {"email": "[email protected]"})
assert response.status_code == 404

@override_options({"demo-mode.enabled": True})
def test_capture_endpoint_bad_request(self):
self.login_as(self.demo_user)
url = reverse("sentry-demo-mode-email-capture")
response = self.client.post(url, {"email": "test123"})
assert response.status_code == 400
assert response.data == {"email": ["Enter a valid email address."]}

0 comments on commit a13ce7b

Please sign in to comment.