Skip to content

Commit b8d3e0d

Browse files
authored
Merge pull request #45 from invertase/main
feat(db): support path patterns
2 parents e7faccb + 8983037 commit b8d3e0d

File tree

4 files changed

+394
-18
lines changed

4 files changed

+394
-18
lines changed

src/firebase_functions/db_fn.py

+71-16
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import typing as _typing
2121
import datetime as _dt
2222
import firebase_functions.private.util as _util
23+
import firebase_functions.private.path_pattern as _path_pattern
2324
import firebase_functions.core as _core
2425
import cloudevents.http as _ce
2526

@@ -91,12 +92,12 @@ class Event(_core.CloudEvent[_core.T]):
9192
def _db_endpoint_handler(
9293
func: _C1 | _C2,
9394
event_type: str,
95+
ref_pattern: _path_pattern.PathPattern,
96+
instance_pattern: _path_pattern.PathPattern,
9497
raw: _ce.CloudEvent,
9598
) -> None:
9699
event_attributes = raw._get_attributes()
97100
event_data: _typing.Any = raw.get_data()
98-
# TODO Params are built locally via path pattern which is currently unimplemented
99-
params: dict[str, str] = {}
100101
database_event_data = event_data
101102
if event_type == _event_type_deleted:
102103
database_event_data = database_event_data["data"]
@@ -112,10 +113,16 @@ def _db_endpoint_handler(
112113
before=before,
113114
after=after,
114115
)
116+
event_instance = event_attributes["instance"]
117+
event_ref = event_attributes["ref"]
118+
params: dict[str, str] = {
119+
**ref_pattern.extract_matches(event_ref),
120+
**instance_pattern.extract_matches(event_instance),
121+
}
115122
database_event = Event(
116123
firebase_database_host=event_attributes["firebasedatabasehost"],
117-
instance=event_attributes["instance"],
118-
reference=event_attributes["ref"],
124+
instance=event_instance,
125+
reference=event_ref,
119126
location=event_attributes["location"],
120127
specversion=event_attributes["specversion"],
121128
id=event_attributes["id"],
@@ -155,15 +162,27 @@ def example(event: Event[Change[object]]) -> None:
155162
options = DatabaseOptions(**kwargs)
156163

157164
def on_value_written_inner_decorator(func: _C1):
165+
ref_pattern = _path_pattern.PathPattern(options.reference)
166+
instance_pattern = _path_pattern.PathPattern(
167+
options.instance if options.instance is not None else "*")
158168

159169
@_functools.wraps(func)
160170
def on_value_written_wrapped(raw: _ce.CloudEvent):
161-
return _db_endpoint_handler(func, _event_type_written, raw)
171+
return _db_endpoint_handler(
172+
func,
173+
_event_type_written,
174+
ref_pattern,
175+
instance_pattern,
176+
raw,
177+
)
162178

163179
_util.set_func_endpoint_attr(
164180
on_value_written_wrapped,
165-
options._endpoint(event_type=_event_type_written,
166-
func_name=func.__name__),
181+
options._endpoint(
182+
event_type=_event_type_written,
183+
func_name=func.__name__,
184+
instance_pattern=instance_pattern,
185+
),
167186
)
168187
return on_value_written_wrapped
169188

@@ -193,15 +212,27 @@ def example(event: Event[Change[object]]) -> None:
193212
options = DatabaseOptions(**kwargs)
194213

195214
def on_value_updated_inner_decorator(func: _C1):
215+
ref_pattern = _path_pattern.PathPattern(options.reference)
216+
instance_pattern = _path_pattern.PathPattern(
217+
options.instance if options.instance is not None else "*")
196218

197219
@_functools.wraps(func)
198220
def on_value_updated_wrapped(raw: _ce.CloudEvent):
199-
return _db_endpoint_handler(func, _event_type_updated, raw)
221+
return _db_endpoint_handler(
222+
func,
223+
_event_type_updated,
224+
ref_pattern,
225+
instance_pattern,
226+
raw,
227+
)
200228

201229
_util.set_func_endpoint_attr(
202230
on_value_updated_wrapped,
203-
options._endpoint(event_type=_event_type_updated,
204-
func_name=func.__name__),
231+
options._endpoint(
232+
event_type=_event_type_updated,
233+
func_name=func.__name__,
234+
instance_pattern=instance_pattern,
235+
),
205236
)
206237
return on_value_updated_wrapped
207238

@@ -231,15 +262,27 @@ def example(event: Event[object]):
231262
options = DatabaseOptions(**kwargs)
232263

233264
def on_value_created_inner_decorator(func: _C2):
265+
ref_pattern = _path_pattern.PathPattern(options.reference)
266+
instance_pattern = _path_pattern.PathPattern(
267+
options.instance if options.instance is not None else "*")
234268

235269
@_functools.wraps(func)
236270
def on_value_created_wrapped(raw: _ce.CloudEvent):
237-
return _db_endpoint_handler(func, _event_type_created, raw)
271+
return _db_endpoint_handler(
272+
func,
273+
_event_type_created,
274+
ref_pattern,
275+
instance_pattern,
276+
raw,
277+
)
238278

239279
_util.set_func_endpoint_attr(
240280
on_value_created_wrapped,
241-
options._endpoint(event_type=_event_type_created,
242-
func_name=func.__name__),
281+
options._endpoint(
282+
event_type=_event_type_created,
283+
func_name=func.__name__,
284+
instance_pattern=instance_pattern,
285+
),
243286
)
244287
return on_value_created_wrapped
245288

@@ -269,15 +312,27 @@ def example(event: Event[object]) -> None:
269312
options = DatabaseOptions(**kwargs)
270313

271314
def on_value_deleted_inner_decorator(func: _C2):
315+
ref_pattern = _path_pattern.PathPattern(options.reference)
316+
instance_pattern = _path_pattern.PathPattern(
317+
options.instance if options.instance is not None else "*")
272318

273319
@_functools.wraps(func)
274320
def on_value_deleted_wrapped(raw: _ce.CloudEvent):
275-
return _db_endpoint_handler(func, _event_type_deleted, raw)
321+
return _db_endpoint_handler(
322+
func,
323+
_event_type_deleted,
324+
ref_pattern,
325+
instance_pattern,
326+
raw,
327+
)
276328

277329
_util.set_func_endpoint_attr(
278330
on_value_deleted_wrapped,
279-
options._endpoint(event_type=_event_type_deleted,
280-
func_name=func.__name__),
331+
options._endpoint(
332+
event_type=_event_type_deleted,
333+
func_name=func.__name__,
334+
instance_pattern=instance_pattern,
335+
),
281336
)
282337
return on_value_deleted_wrapped
283338

src/firebase_functions/options.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import firebase_functions.private.manifest as _manifest
2525
import firebase_functions.private.util as _util
26+
import firebase_functions.private.path_pattern as _path_pattern
2627
from firebase_functions.params import SecretParam, Expression
2728

2829
USE_DEFAULT = _util.Sentinel(
@@ -394,13 +395,15 @@ def _endpoint(
394395
**kwargs,
395396
) -> _manifest.ManifestEndpoint:
396397
assert kwargs["event_type"] is not None
397-
event_filter_instance = self.instance if self.instance is not None else "*"
398+
assert kwargs["instance_pattern"] is not None
399+
instance_pattern: _path_pattern.PathPattern = kwargs["instance_pattern"]
400+
event_filter_instance = instance_pattern.value
398401
event_filters: _typing.Any = {}
399402
event_filters_path_patterns: _typing.Any = {
400403
# Note: Eventarc always treats ref as a path pattern
401404
"ref": self.reference.strip("/"),
402405
}
403-
if "*" in event_filter_instance:
406+
if instance_pattern.has_wildcards:
404407
event_filters_path_patterns["instance"] = event_filter_instance
405408
else:
406409
event_filters["instance"] = event_filter_instance
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Copyright 2023 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Path pattern matching utilities."""
15+
16+
from enum import Enum
17+
import re
18+
19+
20+
def path_parts(path: str) -> list[str]:
21+
if not path or path == "" or path == "/":
22+
return []
23+
return path.strip("/").split("/")
24+
25+
26+
def join_path(base: str, child: str) -> str:
27+
return "/".join(path_parts(base) + path_parts(child))
28+
29+
30+
def trim_param(param: str) -> str:
31+
param_no_braces = param[1:-1]
32+
if "=" in param_no_braces:
33+
return param_no_braces[:param_no_braces.index("=")]
34+
return param_no_braces
35+
36+
37+
_WILDCARD_CAPTURE_REGEX = re.compile(r"{[^/{}]+}", re.IGNORECASE)
38+
39+
40+
class SegmentName(Enum):
41+
SEGMENT = "segment"
42+
SINGLE_CAPTURE = "single-capture"
43+
MULTI_CAPTURE = "multi-capture"
44+
45+
46+
class PathSegment:
47+
"""
48+
A segment of a path pattern.
49+
"""
50+
name: SegmentName
51+
value: str
52+
trimmed: str
53+
54+
def __str__(self):
55+
return self.value
56+
57+
@property
58+
def is_single_segment_wildcard(self):
59+
pass
60+
61+
@property
62+
def is_multi_segment_wildcard(self):
63+
pass
64+
65+
66+
class Segment(PathSegment):
67+
"""
68+
A segment of a path pattern.
69+
"""
70+
71+
def __init__(self, value: str):
72+
self.value = value
73+
self.trimmed = value
74+
self.name = SegmentName.SEGMENT
75+
76+
@property
77+
def is_single_segment_wildcard(self):
78+
return "*" in self.value and not self.is_multi_segment_wildcard
79+
80+
@property
81+
def is_multi_segment_wildcard(self):
82+
return "**" in self.value
83+
84+
85+
class SingleCaptureSegment(PathSegment):
86+
"""
87+
A segment of a path pattern that captures a single segment.
88+
"""
89+
name = SegmentName.SINGLE_CAPTURE
90+
91+
def __init__(self, value):
92+
self.value = value
93+
self.trimmed = trim_param(value)
94+
95+
@property
96+
def is_single_segment_wildcard(self):
97+
return True
98+
99+
@property
100+
def is_multi_segment_wildcard(self):
101+
return False
102+
103+
104+
class MultiCaptureSegment(PathSegment):
105+
"""
106+
A segment of a path pattern that captures multiple segments.
107+
"""
108+
109+
name = SegmentName.MULTI_CAPTURE
110+
111+
def __init__(self, value):
112+
self.value = value
113+
self.trimmed = trim_param(value)
114+
115+
@property
116+
def is_single_segment_wildcard(self):
117+
return False
118+
119+
@property
120+
def is_multi_segment_wildcard(self):
121+
return True
122+
123+
124+
class PathPattern:
125+
"""
126+
Implements Eventarc's path pattern from the spec
127+
https://cloud.google.com/eventarc/docs/path-patterns
128+
"""
129+
segments: list[PathSegment]
130+
131+
def __init__(self, raw_path: str):
132+
self.raw = raw_path
133+
self.segments = []
134+
self.init_path_segments(raw_path)
135+
136+
def init_path_segments(self, raw: str):
137+
parts = raw.split("/")
138+
for part in parts:
139+
segment: PathSegment | None = None
140+
capture = re.findall(_WILDCARD_CAPTURE_REGEX, part)
141+
if capture is not None and len(capture) == 1:
142+
if "**" in part:
143+
segment = MultiCaptureSegment(part)
144+
else:
145+
segment = SingleCaptureSegment(part)
146+
else:
147+
segment = Segment(part)
148+
self.segments.append(segment)
149+
150+
@property
151+
def value(self) -> str:
152+
return self.raw
153+
154+
@property
155+
def has_wildcards(self) -> bool:
156+
return any(segment.is_single_segment_wildcard or
157+
segment.is_multi_segment_wildcard
158+
for segment in self.segments)
159+
160+
@property
161+
def has_captures(self) -> bool:
162+
return any(segment.name in (SegmentName.SINGLE_CAPTURE,
163+
SegmentName.MULTI_CAPTURE)
164+
for segment in self.segments)
165+
166+
def extract_matches(self, path: str) -> dict[str, str]:
167+
matches: dict[str, str] = {}
168+
if not self.has_captures:
169+
return matches
170+
path_segments = path_parts(path)
171+
path_ndx = 0
172+
for segment_ndx in range(len(self.segments)):
173+
segment = self.segments[segment_ndx]
174+
remaining_segments = len(self.segments) - 1 - segment_ndx
175+
next_path_ndx = len(path_segments) - remaining_segments
176+
if segment.name == SegmentName.SINGLE_CAPTURE:
177+
matches[segment.trimmed] = path_segments[path_ndx]
178+
elif segment.name == SegmentName.MULTI_CAPTURE:
179+
matches[segment.trimmed] = "/".join(
180+
path_segments[path_ndx:next_path_ndx])
181+
path_ndx = next_path_ndx if segment.is_multi_segment_wildcard else path_ndx + 1
182+
return matches

0 commit comments

Comments
 (0)