Skip to content

Commit 733bd05

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

File tree

3 files changed

+479
-0
lines changed

3 files changed

+479
-0
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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 Mutation:
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+
@_dataclass.dataclass(frozen=True)
177+
class MutationEventData:
178+
"""
179+
The data within all Mutation events.
180+
"""
181+
182+
payload: Mutation
183+
184+
_E1 = Event[MutationEventData]
185+
_C1 = _typing.Callable[[_E1], None]
186+
187+
188+
def _dataconnect_endpoint_handler(
189+
func: _C1,
190+
event_type: str,
191+
service_pattern: _path_pattern.PathPattern,
192+
connector_pattern: _path_pattern.PathPattern,
193+
operation_pattern: _path_pattern.PathPattern,
194+
raw: _ce.CloudEvent,
195+
) -> None:
196+
# Currently, only mutationExecuted is supported
197+
assert event_type == _event_type_mutation_executed
198+
199+
event_attributes = raw._get_attributes()
200+
event_data: _typing.Any = raw.get_data()
201+
202+
dataconnect_event_data = event_data
203+
204+
event_service = event_attributes["service"]
205+
event_connector = event_attributes["connector"]
206+
event_operation = event_attributes["operation"]
207+
params: dict[str, str] = {
208+
**service_pattern.extract_matches(event_service),
209+
**connector_pattern.extract_matches(event_connector),
210+
**operation_pattern.extract_matches(event_operation),
211+
}
212+
213+
dataconnect_event = Event(
214+
specversion=event_attributes["specversion"],
215+
id=event_attributes["id"],
216+
source=event_attributes["source"],
217+
type=event_attributes["type"],
218+
time=_dt.datetime.strptime(
219+
event_attributes["time"],
220+
"%Y-%m-%dT%H:%M:%S.%f%z",
221+
),
222+
subject=event_attributes.get("subject"),
223+
location=event_attributes["location"],
224+
project=event_attributes["project"],
225+
params=params,
226+
data=dataconnect_event_data,
227+
)
228+
_core._with_init(func)(dataconnect_event)
229+
230+
231+
@_util.copy_func_kwargs(DataConnectOptions)
232+
def on_mutation_executed(**kwargs) -> _typing.Callable[[_C1], _C1]:
233+
"""
234+
Event handler that triggers when a mutation is executed in Firebase Data Connect.
235+
236+
Example:
237+
238+
.. code-block:: python
239+
240+
@on_mutation_executed(
241+
service = "service-id",
242+
connector = "connector-id",
243+
operation = "mutation-name"
244+
)
245+
def mutation_executed_handler(event: Event[MutationEventData]):
246+
pass
247+
248+
:param \\*\\*kwargs: DataConnect options.
249+
:type \\*\\*kwargs: as :exc:`firebase_functions.options.DataConnectOptions`
250+
:rtype: :exc:`typing.Callable`
251+
\\[ \\[ :exc:`firebase_functions.dataconnect_fn.Event` \\[
252+
:exc:`object` \\] \\], `None` \\]
253+
A function that takes a DataConnect event and returns ``None``.
254+
"""
255+
options = DataConnectOptions(**kwargs)
256+
257+
def on_mutation_executed_inner_decorator(func: _C1):
258+
service_pattern = _path_pattern.PathPattern(options.service)
259+
connector_pattern = _path_pattern.PathPattern(options.connector)
260+
operation_pattern = _path_pattern.PathPattern(options.operation)
261+
262+
@_functools.wraps(func)
263+
def on_mutation_executed_wrapped(raw: _ce.CloudEvent):
264+
return _dataconnect_endpoint_handler(
265+
func,
266+
_event_type_mutation_executed,
267+
service_pattern,
268+
connector_pattern,
269+
operation_pattern,
270+
raw,
271+
)
272+
273+
_util.set_func_endpoint_attr(
274+
on_mutation_executed_wrapped,
275+
options._endpoint(
276+
event_type=_event_type_mutation_executed,
277+
func_name=func.__name__,
278+
service_pattern=service_pattern,
279+
connector_pattern=connector_pattern,
280+
operation_pattern=operation_pattern,
281+
),
282+
)
283+
return on_mutation_executed_wrapped
284+
285+
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)