diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 29cff116..ba4aecb7 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -15,6 +15,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python37' + start-delay: 5 python38: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -24,6 +25,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python38' + start-delay: 5 python39: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -33,6 +35,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python39' + start-delay: 5 python310: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -41,4 +44,5 @@ jobs: cloudevent-builder-source: 'tests/conformance' cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' - builder-runtime: 'python310' \ No newline at end of file + builder-runtime: 'python310' + start-delay: 5 \ No newline at end of file diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 09752c94..03f27450 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -75,4 +75,13 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" \ No newline at end of file + cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" + + - name: Run Typed tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + with: + version: 'v1.6.0' + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/main.py --target write_typed_event_declarative'" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index ea147493..39bd1be1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - python: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba019d5..26e83453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.1...v3.3.0) (2022-12-16) + + +### Features + +* Support strongly typed functions signature ([#208](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/208)) ([aa59a6b](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/aa59a6b6839820946dc72ceea7fd97b3dfd839c2)) + + +### Bug Fixes + +* remove DRY_RUN env var and --dry-run flag ([#210](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/210)) ([f013ab4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/f013ab4e4c00acae827ad85e6e2ac5698859605f)) + ## [3.2.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.0...v3.2.1) (2022-11-09) diff --git a/README.md b/README.md index 192a9b55..9dffc60c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,6 @@ You can configure the Functions Framework using command-line flags or environmen | `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http`, `event` or `cloudevent` | | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | -| `--dry-run` | `DRY_RUN` | A flag that allows for testing the function build from the configuration without creating a server. Default: `False` | ## Enable Google Cloud Function Events diff --git a/setup.py b/setup.py index 8be93e27..46b4bdfe 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.2.1", + version="3.3.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index fa638505..c2a52d74 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,12 +13,17 @@ # limitations under the License. import functools +import inspect import io import json import logging import os.path import pathlib import sys +import types + +from inspect import signature +from typing import Type import cloudevents.exceptions as cloud_exceptions import flask @@ -26,7 +31,7 @@ from cloudevents.http import from_http, is_binary -from functions_framework import _function_registry, event_conversion +from functions_framework import _function_registry, _typed_event, event_conversion from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, @@ -67,6 +72,33 @@ def wrapper(*args, **kwargs): return wrapper +def typed(*args): + def _typed(func): + _typed_event.register_typed_event(input_type, func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + # no input type provided as a parameter, we need to use reflection + # e.g function declaration: + # @typed + # def myfunc(x:input_type) + if len(args) == 1 and isinstance(args[0], types.FunctionType): + input_type = None + return _typed(args[0]) + + # input type provided as a parameter to the decorator + # e.g. function declaration + # @typed(input_type) + # def myfunc(x) + else: + input_type = args[0] + return _typed + + def http(func): """Decorator that registers http as user function signature type.""" _function_registry.REGISTRY_MAP[ @@ -106,6 +138,26 @@ def _run_cloud_event(function, request): function(event) +def _typed_event_func_wrapper(function, request, inputType: Type): + def view_func(path): + try: + data = request.get_json() + input = inputType.from_dict(data) + response = function(input) + if response is None: + return "", 200 + if response.__class__.__module__ == "builtins": + return response + _typed_event._validate_return_type(response) + return json.dumps(response.to_dict()) + except Exception as e: + raise FunctionsFrameworkException( + "Function execution failed with the error" + ) from e + + return view_func + + def _cloud_event_view_func_wrapper(function, request): def view_func(path): ce_exception = None @@ -216,6 +268,21 @@ def _configure_app(app, function, signature_type): app.view_functions[signature_type] = _cloud_event_view_func_wrapper( function, flask.request ) + elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule( + "/", endpoint=signature_type, methods=["POST"] + ) + ) + input_type = _function_registry.get_func_input_type(function.__name__) + app.view_functions[signature_type] = _typed_event_func_wrapper( + function, flask.request, input_type + ) else: raise FunctionsFrameworkException( "Invalid signature type: {signature_type}".format( diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 663ea50f..773dd4cd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -26,18 +26,12 @@ @click.option( "--signature-type", envvar="FUNCTION_SIGNATURE_TYPE", - type=click.Choice(["http", "event", "cloudevent"]), + type=click.Choice(["http", "event", "cloudevent", "typed"]), default="http", ) @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) -@click.option("--dry-run", envvar="DRY_RUN", is_flag=True) -def _cli(target, source, signature_type, host, port, debug, dry_run): +def _cli(target, source, signature_type, host, port, debug): app = create_app(target, source, signature_type) - if dry_run: - click.echo("Function: {}".format(target)) - click.echo("URL: http://{}:{}/".format(host, port)) - click.echo("Dry run successful, shutting down.") - else: - create_server(app, debug).run(host, port) + create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index fdcf383f..f266ee82 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -16,6 +16,9 @@ import sys import types +from re import T +from typing import Type + from functions_framework.exceptions import ( InvalidConfigurationException, InvalidTargetTypeException, @@ -28,11 +31,16 @@ HTTP_SIGNATURE_TYPE = "http" CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" BACKGROUNDEVENT_SIGNATURE_TYPE = "event" +TYPED_SIGNATURE_TYPE = "typed" # REGISTRY_MAP stores the registered functions. # Keys are user function names, values are user function signature types. REGISTRY_MAP = {} +# INPUT_TYPE_MAP stores the input type of the typed functions. +# Keys are the user function name, values are the type of the function input +INPUT_TYPE_MAP = {} + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" @@ -120,3 +128,8 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: if os.environ.get("ENTRY_POINT"): os.environ["FUNCTION_TRIGGER_TYPE"] = sig_type return sig_type + + +def get_func_input_type(func_name: str) -> Type: + registered_type = INPUT_TYPE_MAP[func_name] if func_name in INPUT_TYPE_MAP else "" + return registered_type diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py new file mode 100644 index 00000000..40e715ae --- /dev/null +++ b/src/functions_framework/_typed_event.py @@ -0,0 +1,105 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import inspect + +from inspect import signature + +from functions_framework import _function_registry +from functions_framework.exceptions import FunctionsFrameworkException + +"""Registers user function in the REGISTRY_MAP and the INPUT_TYPE_MAP. +Also performs some validity checks for the input type of the function + +Args: + decorator_type: The type provided by the @typed(input_type) decorator + func: User function +""" + + +def register_typed_event(decorator_type, func): + try: + sig = signature(func) + annotation_type = list(sig.parameters.values())[0].annotation + input_type = _select_input_type(decorator_type, annotation_type) + _validate_input_type(input_type) + except IndexError: + raise FunctionsFrameworkException( + "Function signature is missing an input parameter." + "The function should be defined as 'def your_fn(in: inputType)'" + ) + except Exception as e: + raise FunctionsFrameworkException( + "Functions using the @typed decorator must provide " + "the type of the input parameter by specifying @typed(inputType) and/or using python " + "type annotations 'def your_fn(in: inputType)'" + ) + + _function_registry.INPUT_TYPE_MAP[func.__name__] = input_type + _function_registry.REGISTRY_MAP[ + func.__name__ + ] = _function_registry.TYPED_SIGNATURE_TYPE + + +""" Checks whether the response type of the typed function has a to_dict method""" + + +def _validate_return_type(response): + if not (hasattr(response, "to_dict") and callable(getattr(response, "to_dict"))): + raise AttributeError( + "The type {response} does not have the required method called " + " 'to_dict'.".format(response=type(response)) + ) + + +"""Selects the input type for the typed function provided through the @typed(input_type) +decorator or through the parameter annotation in the user function +""" + + +def _select_input_type(decorator_type, annotation_type): + if decorator_type == None and annotation_type is inspect._empty: + raise TypeError( + "The function defined does not contain Type of the input object." + ) + + if ( + decorator_type != None + and annotation_type is not inspect._empty + and decorator_type != annotation_type + ): + raise TypeError( + "The object type provided via 'typed' decorator: '{decorator_type}'" + "is different than the one specified by the function parameter's type annotation : '{annotation_type}'.".format( + decorator_type=decorator_type, annotation_type=annotation_type + ) + ) + + if decorator_type == None: + return annotation_type + return decorator_type + + +"""Checks for the from_dict method implementation in the input type class""" + + +def _validate_input_type(input_type): + if not ( + hasattr(input_type, "from_dict") and callable(getattr(input_type, "from_dict")) + ): + raise AttributeError( + "The type {decorator_type} does not have the required method called " + " 'from_dict'.".format(decorator_type=input_type) + ) diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 67926ff6..057edaa7 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -8,6 +8,17 @@ filename = "function_output.json" +class ConformanceType: + json_request: str + + def __init__(self, json_request: str) -> None: + self.json_request = json_request + + @staticmethod + def from_dict(obj: dict) -> "ConformanceType": + return ConformanceType(json.dumps(obj)) + + def _write_output(content): with open(filename, "w") as f: f.write(content) @@ -53,3 +64,9 @@ def write_cloud_event_declarative(cloud_event): def write_http_declarative_concurrent(request): time.sleep(1) return "OK", 200 + + +@functions_framework.typed(ConformanceType) +def write_typed_event_declarative(x): + _write_output(x.json_request) + return "OK" diff --git a/tests/test_cli.py b/tests/test_cli.py index aa4a901e..7613b649 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,18 +69,6 @@ def test_cli_no_arguments(): [pretend.call("foo", None, "event")], [pretend.call("0.0.0.0", 8080)], ), - ( - ["--target", "foo", "--dry-run"], - {}, - [pretend.call("foo", None, "http")], - [], - ), - ( - [], - {"FUNCTION_TARGET": "foo", "DRY_RUN": "True"}, - [pretend.call("foo", None, "http")], - [], - ), ( ["--target", "foo", "--host", "127.0.0.1"], {}, diff --git a/tests/test_functions/typed_events/mismatch_types.py b/tests/test_functions/typed_events/mismatch_types.py new file mode 100644 index 00000000..0f238d9c --- /dev/null +++ b/tests/test_functions/typed_events/mismatch_types.py @@ -0,0 +1,43 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" + +import flask + +import functions_framework + + +class TestType1: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + +class TestType2: + name: str + + def __init__(self, name: str) -> None: + self.name = name + + +@functions_framework.typed(TestType2) +def function_typed_mismatch_types(test_type: TestType1): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_from_dict.py b/tests/test_functions/typed_events/missing_from_dict.py new file mode 100644 index 00000000..73a2cf93 --- /dev/null +++ b/tests/test_functions/typed_events/missing_from_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingFromDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +@functions_framework.typed(TestTypeMissingFromDict) +def function_typed_missing_from_dict(test_type: TestTypeMissingFromDict): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_parameter.py b/tests/test_functions/typed_events/missing_parameter.py new file mode 100644 index 00000000..64681d8e --- /dev/null +++ b/tests/test_functions/typed_events/missing_parameter.py @@ -0,0 +1,23 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(): + print("hello") diff --git a/tests/test_functions/typed_events/missing_to_dict.py b/tests/test_functions/typed_events/missing_to_dict.py new file mode 100644 index 00000000..76c95344 --- /dev/null +++ b/tests/test_functions/typed_events/missing_to_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingToDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestTypeMissingToDict": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestTypeMissingToDict(name, age) + + +@functions_framework.typed(TestTypeMissingToDict) +def function_typed_missing_to_dict(testType: TestTypeMissingToDict): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/missing_type.py b/tests/test_functions/typed_events/missing_type.py new file mode 100644 index 00000000..1f35c0d6 --- /dev/null +++ b/tests/test_functions/typed_events/missing_type.py @@ -0,0 +1,26 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(testType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/typed_event.py b/tests/test_functions/typed_events/typed_event.py new file mode 100644 index 00000000..ac00d2fe --- /dev/null +++ b/tests/test_functions/typed_events/typed_event.py @@ -0,0 +1,141 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, Type, TypeVar, cast + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + +class TestType: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestType": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestType(name, age) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +class SampleType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population) + + def to_dict(self) -> dict: + result: dict = {} + result["country"] = from_str(self.country) + result["population"] = from_int(self.population) + return result + + +class FaultyType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population / 0) + + +@functions_framework.typed(TestType) +def function_typed(testType: TestType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_reflect(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_no_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + + +@functions_framework.typed +def function_typed_string_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return "Hello " + testType.name + + +@functions_framework.typed(TestType) +def function_typed_different_types(testType: TestType) -> SampleType: + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + sampleType = SampleType("Monaco", 40000) + return sampleType + + +@functions_framework.typed +def function_typed_faulty_from_dict(input: FaultyType): + valid_event = input.country == "Monaco" and input.population == 40000 + if not valid_event: + raise Exception("Received invalid input") diff --git a/tests/test_typed_event_functions.py b/tests/test_typed_event_functions.py new file mode 100644 index 00000000..3b8d5da1 --- /dev/null +++ b/tests/test_typed_event_functions.py @@ -0,0 +1,123 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import pytest + +from functions_framework import create_app +from functions_framework.exceptions import FunctionsFrameworkException + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +# Python 3.5: ModuleNotFoundError does not exist +try: + _ModuleNotFoundError = ModuleNotFoundError +except: + _ModuleNotFoundError = ImportError + + +@pytest.fixture +def typed_decorator_client(function_name): + source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py" + target = function_name + return create_app(target, source).test_client() + + +@pytest.fixture +def typed_decorator_missing_to_dict(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_to_dict.py" + target = "function_typed_missing_to_dict" + return create_app(target, source).test_client() + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "john", "age": 10}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "john", "age": 10}' + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_malformed_json(typed_decorator_client): + resp = typed_decorator_client.post("/", data="abc", content_type="application/json") + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_faulty_from_dict"]) +def test_typed_faulty_from_dict(typed_decorator_client): + resp = typed_decorator_client.post( + "/", json={"country": "Monaco", "population": 40000} + ) + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_reflect"]) +def test_typed_reflect_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "jane", "age": 20}' + + +@pytest.mark.parametrize("function_name", ["function_typed_different_types"]) +def test_typed_different_types(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"country": "Monaco", "population": 40000}' + + +@pytest.mark.parametrize("function_name", ["function_typed_no_return"]) +def test_typed_no_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"" + + +@pytest.mark.parametrize("function_name", ["function_typed_string_return"]) +def test_typed_string_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"Hello jane" + + +def test_missing_from_dict_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_from_dict.py" + target = "function_typed_missing_from_dict" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_mismatch_types_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "mismatch_types.py" + target = "function_typed_mismatch_types" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_missing_type_information_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_type.py" + target = "function_typed_missing_type_information" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +def test_missing_parameter_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_parameter.py" + target = "function_typed_missing_parameter" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +def test_missing_to_dict_typed_decorator(typed_decorator_missing_to_dict): + resp = typed_decorator_missing_to_dict.post("/", json={"name": "john", "age": 10}) + assert resp.status_code == 500