Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions src/firebase_functions/dataconnect_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

# pylint: disable=protected-access
import dataclasses as _dataclass
import datetime as _dt
import functools as _functools
import typing as _typing

Expand Down Expand Up @@ -209,7 +208,9 @@ def _dataconnect_endpoint_handler(
) -> None:
# Currently, only mutationExecuted is supported
if event_type != _event_type_mutation_executed:
raise NotImplementedError(f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported.")
raise NotImplementedError(
f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported."
)

event_attributes = raw._get_attributes()
event_data: _typing.Any = raw.get_data()
Expand All @@ -224,15 +225,9 @@ def _dataconnect_endpoint_handler(
if service_pattern:
params = {**params, **service_pattern.extract_matches(event_service)}
if connector_pattern:
params = {
**params,
**connector_pattern.extract_matches(event_connector)
}
params = {**params, **connector_pattern.extract_matches(event_connector)}
if operation_pattern:
params = {
**params,
**operation_pattern.extract_matches(event_operation)
}
params = {**params, **operation_pattern.extract_matches(event_operation)}

event_auth_type = event_attributes["authtype"]
event_auth_id = event_attributes["authid"]
Expand Down Expand Up @@ -282,12 +277,13 @@ def mutation_executed_handler(event: Event[MutationEventData]):
options = DataConnectOptions(**kwargs)

def on_mutation_executed_inner_decorator(func: _C1):
service_pattern = _path_pattern.PathPattern(
options.service) if options.service else None
connector_pattern = _path_pattern.PathPattern(
options.connector) if options.connector else None
operation_pattern = _path_pattern.PathPattern(
options.operation) if options.operation else None
service_pattern = _path_pattern.PathPattern(options.service) if options.service else None
connector_pattern = (
_path_pattern.PathPattern(options.connector) if options.connector else None
)
operation_pattern = (
_path_pattern.PathPattern(options.operation) if options.operation else None
)

@_functools.wraps(func)
def on_mutation_executed_wrapped(raw: _ce.CloudEvent):
Expand Down
24 changes: 12 additions & 12 deletions src/firebase_functions/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1188,22 +1188,22 @@ def _endpoint(
event_filters_path_patterns: _typing.Any = {}

if self.service:
if service_pattern.has_wildcards:
event_filters_path_patterns["service"] = service_pattern.value
else:
event_filters["service"] = service_pattern.value
if service_pattern.has_wildcards:
event_filters_path_patterns["service"] = service_pattern.value
else:
event_filters["service"] = service_pattern.value

if self.connector:
if connector_pattern.has_wildcards:
event_filters_path_patterns["connector"] = connector_pattern.value
else:
event_filters["connector"] = connector_pattern.value
if connector_pattern.has_wildcards:
event_filters_path_patterns["connector"] = connector_pattern.value
else:
event_filters["connector"] = connector_pattern.value

if self.operation:
if operation_pattern.has_wildcards:
event_filters_path_patterns["operation"] = operation_pattern.value
else:
event_filters["operation"] = operation_pattern.value
if operation_pattern.has_wildcards:
event_filters_path_patterns["operation"] = operation_pattern.value
else:
event_filters["operation"] = operation_pattern.value

event_trigger = _manifest.EventTrigger(
eventType=kwargs["event_type"],
Expand Down
26 changes: 21 additions & 5 deletions src/firebase_functions/scheduler_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import datetime as _dt
import functools as _functools
import typing as _typing
from datetime import timezone as _timezone

from flask import (
Request as _Request,
Expand Down Expand Up @@ -101,12 +102,27 @@ def on_schedule_wrapped(request: _Request) -> _Response:
schedule_time: _dt.datetime
schedule_time_str = request.headers.get("X-CloudScheduler-ScheduleTime")
if schedule_time_str is None:
schedule_time = _dt.datetime.utcnow()
schedule_time = _dt.datetime.now(_timezone.utc)
else:
schedule_time = _dt.datetime.strptime(
schedule_time_str,
"%Y-%m-%dT%H:%M:%S%z",
)
try:
# Try to parse with the stdlib which supports fractional
# seconds and offsets in Python 3.11+ via fromisoformat.
# Normalize RFC3339 'Z' to '+00:00' for fromisoformat.
iso_str = schedule_time_str
if iso_str.endswith("Z"):
iso_str = iso_str[:-1] + "+00:00"
schedule_time = _dt.datetime.fromisoformat(iso_str)
except ValueError:
# Fallback to strict parsing without fractional seconds
try:
schedule_time = _dt.datetime.strptime(
schedule_time_str,
"%Y-%m-%dT%H:%M:%S%z",
)
except ValueError as e:
# If all parsing fails, log and use current UTC time
_logging.exception(e)
schedule_time = _dt.datetime.now(_timezone.utc)
event = ScheduledEvent(
job_name=request.headers.get("X-CloudScheduler-JobName"),
schedule_time=schedule_time,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dataconnect_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def init():
"connector": "connector-id",
"operation": "mutation-name",
"authtype": "app_user",
"authid": "auth-id"
"authid": "auth-id",
},
data=json.dumps({}),
)
Expand Down
113 changes: 111 additions & 2 deletions tests/test_scheduler_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"""Scheduler function tests."""

import unittest
from datetime import datetime
from unittest.mock import Mock
from datetime import datetime, timezone
from unittest.mock import Mock, patch

from flask import Flask, Request
from werkzeug.test import EnvironBuilder
Expand Down Expand Up @@ -81,6 +81,30 @@ def test_on_schedule_call(self):
)
)

def test_on_schedule_call_with_z_suffix(self):
"""
Tests to ensure that timestamps with 'Z' suffix are parsed correctly as UTC.
"""
with Flask(__name__).test_request_context("/"):
environ = EnvironBuilder(
headers={
"X-CloudScheduler-JobName": "example-job",
"X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00Z",
}
).get_environ()
mock_request = Request(environ)
example_func = Mock(__name__="example_func")
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
response = decorated_func(mock_request)

self.assertEqual(response.status_code, 200)
example_func.assert_called_once_with(
scheduler_fn.ScheduledEvent(
job_name="example-job",
schedule_time=datetime(2023, 4, 13, 19, 0, 0, tzinfo=timezone.utc),
)
)

def test_on_schedule_call_with_no_headers(self):
"""
Tests to ensure that if the function is called manually
Expand All @@ -99,6 +123,7 @@ def test_on_schedule_call_with_no_headers(self):
self.assertEqual(example_func.call_count, 1)
self.assertIsNone(example_func.call_args[0][0].job_name)
self.assertIsNotNone(example_func.call_args[0][0].schedule_time)
self.assertIsNotNone(example_func.call_args[0][0].schedule_time.tzinfo)

def test_on_schedule_call_with_exception(self):
"""
Expand All @@ -121,6 +146,90 @@ def test_on_schedule_call_with_exception(self):
self.assertEqual(response.status_code, 500)
self.assertEqual(response.data, b"Test exception")

def test_on_schedule_call_with_fractional_seconds(self):
"""
Tests to ensure that timestamps with fractional seconds are parsed correctly.
"""
with Flask(__name__).test_request_context("/"):
environ = EnvironBuilder(
headers={
"X-CloudScheduler-JobName": "example-job",
"X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00.123456Z",
}
).get_environ()
mock_request = Request(environ)
example_func = Mock(__name__="example_func")
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
response = decorated_func(mock_request)

self.assertEqual(response.status_code, 200)
example_func.assert_called_once_with(
scheduler_fn.ScheduledEvent(
job_name="example-job",
schedule_time=datetime(2023, 4, 13, 19, 0, 0, 123456, tzinfo=timezone.utc),
)
)

def test_on_schedule_call_fallback_parsing(self):
"""
Tests fallback parsing for formats that might fail fromisoformat
but pass strptime (e.g., offset without colon).
"""
with Flask(__name__).test_request_context("/"):
environ = EnvironBuilder(
headers={
"X-CloudScheduler-JobName": "example-job",
# Offset without colon might fail fromisoformat in some versions
# but should pass strptime("%Y-%m-%dT%H:%M:%S%z")
"X-CloudScheduler-ScheduleTime": "2023-04-13T12:00:00-0700",
}
).get_environ()
mock_request = Request(environ)
example_func = Mock(__name__="example_func")
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
response = decorated_func(mock_request)

self.assertEqual(response.status_code, 200)

# Create expected datetime with fixed offset -07:00
tz = datetime.strptime("-0700", "%z").tzinfo
expected_dt = datetime(2023, 4, 13, 12, 0, 0, tzinfo=tz)

example_func.assert_called_once_with(
scheduler_fn.ScheduledEvent(
job_name="example-job",
schedule_time=expected_dt,
)
)

def test_on_schedule_call_invalid_timestamp(self):
"""
Tests that invalid timestamps log an error and fallback to current time.
"""
with Flask(__name__).test_request_context("/"):
environ = EnvironBuilder(
headers={
"X-CloudScheduler-JobName": "example-job",
"X-CloudScheduler-ScheduleTime": "invalid-timestamp",
}
).get_environ()
mock_request = Request(environ)
example_func = Mock(__name__="example_func")

with patch("firebase_functions.scheduler_fn._logging") as mock_logging:
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
response = decorated_func(mock_request)

self.assertEqual(response.status_code, 200)
mock_logging.exception.assert_called_once()

# Should have called with *some* time (current time), so we just check it's not None
self.assertEqual(example_func.call_count, 1)
called_event = example_func.call_args[0][0]
self.assertEqual(called_event.job_name, "example-job")
self.assertIsNotNone(called_event.schedule_time)
self.assertIsNotNone(called_event.schedule_time.tzinfo)

def test_calls_init(self):
hello = None

Expand Down
Loading