Skip to content

Commit 9634778

Browse files
committed
Add Firebase Data Connect support
1 parent 429c901 commit 9634778

File tree

3 files changed

+472
-0
lines changed

3 files changed

+472
-0
lines changed
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# Copyright 2022 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+
"""
15+
Module for Cloud Functions that are triggered by Firebase Data Connect.
16+
"""
17+
18+
# pylint: disable=protected-access
19+
import dataclasses as _dataclass
20+
import datetime as _dt
21+
import functools as _functools
22+
import typing as _typing
23+
24+
import cloudevents.http as _ce
25+
26+
import firebase_functions.core as _core
27+
import firebase_functions.private.path_pattern as _path_pattern
28+
import firebase_functions.private.util as _util
29+
from firebase_functions.options import DataConnectOptions
30+
31+
_event_type_mutation_executed = "google.firebase.dataconnect.connector.v1.mutationExecuted"
32+
33+
34+
@_dataclass.dataclass(frozen=True)
35+
class Event(_core.CloudEvent[_core.T]):
36+
"""
37+
A CloudEvent that contains MutationEventData.
38+
"""
39+
40+
location: str
41+
"""
42+
The location of the database.
43+
"""
44+
45+
project: str
46+
"""
47+
The project identifier.
48+
"""
49+
50+
params: dict[str, str]
51+
"""
52+
A dict containing the values of the path patterns.
53+
Only named capture groups are populated - {key}, {key=*}, {key=**}
54+
"""
55+
56+
57+
@_dataclass.dataclass(frozen=True)
58+
class GraphqlErrorExtensions:
59+
"""
60+
GraphqlErrorExtensions contains additional information of `GraphqlError`.
61+
"""
62+
63+
file: str
64+
"""
65+
The source file name where the error occurred.
66+
Included only for `UpdateSchema` and `UpdateConnector`, it corresponds
67+
to `File.path` of the provided `Source`.
68+
"""
69+
70+
code: str
71+
"""
72+
Maps to canonical gRPC codes.
73+
If not specified, it represents `Code.INTERNAL`.
74+
"""
75+
76+
debug_details: str
77+
"""
78+
More detailed error message to assist debugging.
79+
It contains application business logic that are inappropriate to leak
80+
publicly.
81+
82+
In the emulator, Data Connect API always includes it to assist local
83+
development and debugging.
84+
In the backend, ConnectorService always hides it.
85+
GraphqlService without impersonation always include it.
86+
GraphqlService with impersonation includes it only if explicitly opted-in
87+
with `include_debug_details` in `GraphqlRequestExtensions`.
88+
"""
89+
90+
91+
@_dataclass.dataclass(frozen=True)
92+
class SourceLocation:
93+
"""
94+
SourceLocation references a location in a GraphQL source.
95+
"""
96+
97+
line: int
98+
"""
99+
Line number starting at 1.
100+
"""
101+
102+
column: int
103+
"""
104+
Column number starting at 1.
105+
"""
106+
107+
108+
@_dataclass.dataclass(frozen=True)
109+
class GraphQLError:
110+
"""
111+
An error that occurred during the execution of a GraphQL request.
112+
"""
113+
114+
message: str
115+
"""
116+
A string describing the error.
117+
"""
118+
119+
locations: list[dict[str, int]] | None = None
120+
"""
121+
The source locations where the error occurred.
122+
Locations should help developers and toolings identify the source of error
123+
quickly.
124+
125+
Included in admin endpoints (`ExecuteGraphql`, `ExecuteGraphqlRead`,
126+
`UpdateSchema` and `UpdateConnector`) to reference the provided GraphQL
127+
GQL document.
128+
129+
Omitted in `ExecuteMutation` and `ExecuteQuery` since the caller shouldn't
130+
have access access the underlying GQL source.
131+
"""
132+
133+
path: list[str | int] | None = None
134+
"""
135+
The result field which could not be populated due to error.
136+
137+
Clients can use path to identify whether a null result is intentional or
138+
caused by a runtime error.
139+
It should be a list of string or index from the root of GraphQL query
140+
document.
141+
"""
142+
143+
extensions: GraphqlErrorExtensions | None = None
144+
145+
146+
@_dataclass.dataclass(frozen=True)
147+
class MutationEventData:
148+
"""
149+
An object within Firebase Data Connect.
150+
"""
151+
152+
data: _typing.Any
153+
"""
154+
The result of the execution of the requested operation.
155+
If an error was raised before execution begins, the data entry should not
156+
be present in the result. (a request error:
157+
https://spec.graphql.org/draft/#sec-Errors.Request-Errors) If an error was
158+
raised during the execution that prevented a valid response, the data entry
159+
in the response should be null. (a field error:
160+
https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format)
161+
"""
162+
163+
variables: _typing.Any
164+
"""
165+
Values for GraphQL variables provided in this request.
166+
"""
167+
168+
errors: list[GraphQLError] | None = None
169+
"""
170+
Errors of this response.
171+
If the data entry in the response is not present, the errors entry must be
172+
present.
173+
It conforms to https://spec.graphql.org/draft/#sec-Errors.
174+
"""
175+
176+
177+
_E1 = Event[MutationEventData]
178+
_C1 = _typing.Callable[[_E1], None]
179+
180+
181+
def _dataconnect_endpoint_handler(
182+
func: _C1,
183+
event_type: str,
184+
service_pattern: _path_pattern.PathPattern,
185+
connector_pattern: _path_pattern.PathPattern,
186+
operation_pattern: _path_pattern.PathPattern,
187+
raw: _ce.CloudEvent,
188+
) -> None:
189+
# Currently, only mutationExecuted is supported
190+
assert event_type == _event_type_mutation_executed
191+
192+
event_attributes = raw._get_attributes()
193+
event_data: _typing.Any = raw.get_data()
194+
195+
dataconnect_event_data = event_data
196+
197+
event_service = event_attributes["service"]
198+
event_connector = event_attributes["connector"]
199+
event_operation = event_attributes["operation"]
200+
params: dict[str, str] = {
201+
**service_pattern.extract_matches(event_service),
202+
**connector_pattern.extract_matches(event_connector),
203+
**operation_pattern.extract_matches(event_operation),
204+
}
205+
206+
dataconnect_event = Event(
207+
specversion=event_attributes["specversion"],
208+
id=event_attributes["id"],
209+
source=event_attributes["source"],
210+
type=event_attributes["type"],
211+
time=_dt.datetime.strptime(
212+
event_attributes["time"],
213+
"%Y-%m-%dT%H:%M:%S.%f%z",
214+
),
215+
subject=event_attributes.get("subject"),
216+
location=event_attributes["location"],
217+
project=event_attributes["project"],
218+
params=params,
219+
data=dataconnect_event_data,
220+
)
221+
_core._with_init(func)(dataconnect_event)
222+
223+
224+
@_util.copy_func_kwargs(DataConnectOptions)
225+
def on_mutation_executed(**kwargs) -> _typing.Callable[[_C1], _C1]:
226+
"""
227+
Event handler that triggers when a mutation is executed in Firebase Data Connect.
228+
229+
Example:
230+
231+
.. code-block:: python
232+
233+
@on_mutation_executed(
234+
service = "service-id",
235+
connector = "connector-id",
236+
operation = "mutation-name"
237+
)
238+
def mutation_executed_handler(event: Event[MutationEventData]):
239+
pass
240+
241+
:param \\*\\*kwargs: DataConnect options.
242+
:type \\*\\*kwargs: as :exc:`firebase_functions.options.DataConnectOptions`
243+
:rtype: :exc:`typing.Callable`
244+
\\[ \\[ :exc:`firebase_functions.dataconnect_fn.Event` \\[
245+
:exc:`object` \\] \\], `None` \\]
246+
A function that takes a DataConnect event and returns ``None``.
247+
"""
248+
options = DataConnectOptions(**kwargs)
249+
250+
def on_mutation_executed_inner_decorator(func: _C1):
251+
service_pattern = _path_pattern.PathPattern(options.service)
252+
connector_pattern = _path_pattern.PathPattern(options.connector)
253+
operation_pattern = _path_pattern.PathPattern(options.operation)
254+
255+
@_functools.wraps(func)
256+
def on_mutation_executed_wrapped(raw: _ce.CloudEvent):
257+
return _dataconnect_endpoint_handler(
258+
func,
259+
_event_type_mutation_executed,
260+
service_pattern,
261+
connector_pattern,
262+
operation_pattern,
263+
raw,
264+
)
265+
266+
_util.set_func_endpoint_attr(
267+
on_mutation_executed_wrapped,
268+
options._endpoint(
269+
event_type=_event_type_mutation_executed,
270+
func_name=func.__name__,
271+
service_pattern=service_pattern,
272+
connector_pattern=connector_pattern,
273+
operation_pattern=operation_pattern,
274+
),
275+
)
276+
return on_mutation_executed_wrapped
277+
278+
return on_mutation_executed_inner_decorator

src/firebase_functions/options.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,73 @@ def _endpoint(
11521152
return _manifest.ManifestEndpoint(**_typing.cast(dict, kwargs_merged))
11531153

11541154

1155+
@_dataclasses.dataclass(frozen=True, kw_only=True)
1156+
class DataConnectOptions(RuntimeOptions):
1157+
"""
1158+
Options specific to Firebase Data Connect function types.
1159+
Internal use only.
1160+
"""
1161+
1162+
service: str
1163+
"""
1164+
The Firebase Data Connect service ID.
1165+
"""
1166+
1167+
connector: str
1168+
"""
1169+
The Firebase Data Connect connector ID.
1170+
"""
1171+
1172+
operation: str
1173+
"""
1174+
Name of the operation.
1175+
"""
1176+
1177+
def _endpoint(
1178+
self,
1179+
**kwargs,
1180+
) -> _manifest.ManifestEndpoint:
1181+
assert kwargs["event_type"] is not None
1182+
assert kwargs["service_pattern"] is not None
1183+
assert kwargs["connector_pattern"] is not None
1184+
assert kwargs["operation_pattern"] is not None
1185+
1186+
service_pattern: _path_pattern.PathPattern = kwargs["service_pattern"]
1187+
connector_pattern: _path_pattern.PathPattern = kwargs["connector_pattern"]
1188+
operation_pattern: _path_pattern.PathPattern = kwargs["operation_pattern"]
1189+
1190+
event_filters: _typing.Any = {}
1191+
event_filters_path_patterns: _typing.Any = {}
1192+
1193+
if service_pattern.has_wildcards:
1194+
event_filters_path_patterns["service"] = service_pattern.value
1195+
else:
1196+
event_filters["service"] = service_pattern.value
1197+
1198+
if connector_pattern.has_wildcards:
1199+
event_filters_path_patterns["connector"] = connector_pattern.value
1200+
else:
1201+
event_filters["connector"] = connector_pattern.value
1202+
1203+
if operation_pattern.has_wildcards:
1204+
event_filters_path_patterns["operation"] = operation_pattern.value
1205+
else:
1206+
event_filters["operation"] = operation_pattern.value
1207+
1208+
event_trigger = _manifest.EventTrigger(
1209+
eventType=kwargs["event_type"],
1210+
retry=False,
1211+
eventFilters=event_filters,
1212+
eventFilterPathPatterns=event_filters_path_patterns,
1213+
)
1214+
1215+
kwargs_merged = {
1216+
**_dataclasses.asdict(super()._endpoint(**kwargs)),
1217+
"eventTrigger": event_trigger,
1218+
}
1219+
return _manifest.ManifestEndpoint(**_typing.cast(dict, kwargs_merged))
1220+
1221+
11551222
_GLOBAL_OPTIONS = RuntimeOptions()
11561223
"""The current default options for all functions. Internal use only."""
11571224

0 commit comments

Comments
 (0)