From d27627c78c66e900a9f8a219efaabd999faf6901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 6 Mar 2025 14:48:18 +0100 Subject: [PATCH 01/25] add dev server with werkzeug --- csfunctions/devserver.py | 37 +++++++++++++++++ poetry.lock | 89 +++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 csfunctions/devserver.py diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py new file mode 100644 index 0000000..42e4122 --- /dev/null +++ b/csfunctions/devserver.py @@ -0,0 +1,37 @@ +""" +The development server looks for an environment.yaml in the current working directory and reads the Functions from it. +The Functions are then available via HTTP requests to the server. + +""" + +from werkzeug.serving import run_simple +from werkzeug.wrappers import Request, Response + +from csfunctions.handler import execute + + +def handle_request(request: Request) -> Response: + """ + Handles a request to the development server. + """ + function_name = request.path.strip("/") + if not function_name: + return Response("No function name provided", status=400) + body = request.get_data(as_text=True) + response = execute(function_name, body, "src") + return Response(response, content_type="application/json") + + +def application(environ, start_response): + request = Request(environ) + response = handle_request(request) + return response(environ, start_response) + + +def run_server(): + # B104: binding to all interfaces is intentional - this is a development server + run_simple("0.0.0.0", 8000, application, use_reloader=True) # nosec: B104 + + +if __name__ == "__main__": + run_server() diff --git a/poetry.lock b/poetry.lock index cba20aa..6f9f1dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -173,6 +173,76 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "packaging" version = "24.2" @@ -522,7 +592,24 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c4a2b2cdab5bcd39f8c4770f6c72638412bad45ca2805e5b37dcaef18e6817af" +content-hash = "d4a6b08e6492e15e68b80664e161bea0954afcdbf07835046729cf3899049d70" diff --git a/pyproject.toml b/pyproject.toml index 64fe5e2..d283477 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ python = "^3.10" requests = "^2.27.1" pydantic = ">=2.3,<3" pyyaml = "^6.0.2" +werkzeug = "^3.1.3" [tool.poetry.group.test.dependencies] pytest = "^8.3.4" From f6c1a15eca0f2027f670e6d420d369ec8e53b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 6 Mar 2025 15:03:39 +0100 Subject: [PATCH 02/25] improve error handling --- csfunctions/devserver.py | 39 +++++++++++++++++++++++++++++++++++++-- csfunctions/handler.py | 14 +++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 42e4122..b5cb27a 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -4,21 +4,56 @@ """ +import json + from werkzeug.serving import run_simple from werkzeug.wrappers import Request, Response -from csfunctions.handler import execute +from csfunctions.handler import FunctionNotRegistered, execute + + +def _is_error_response(function_response: str | dict): + """ + Try to figure out if the response from the function is an error response. + This is the same implementation as in the runtime, to ensure the behavior is the same. + """ + if isinstance(function_response, str): + # function response could be a json encoded dict, so we try to decode it first + try: + function_response = json.loads(function_response) + except json.JSONDecodeError: + # response is not json decoded, so it's not an error response + return False + + if isinstance(function_response, dict): + # check if the response dict is an error response + return function_response.get("response_type") == "error" + else: + # function response is neither a dict nor json encoded dict, so can't be an error response + return False def handle_request(request: Request) -> Response: """ Handles a request to the development server. + Extracts the function name from the request path and executes the Function using the execute handler. """ function_name = request.path.strip("/") if not function_name: return Response("No function name provided", status=400) body = request.get_data(as_text=True) - response = execute(function_name, body, "src") + + try: + # we assume the function is in the current working directory + response = execute(function_name, body, function_dir="") + except FunctionNotRegistered as e: + return Response(str(e), status=404) + + if _is_error_response(response): + # If a Function raises an error the execute handler will wrap the error in an ErrorResponse + # We need to check for that to return the correct status code. + return Response(response, status=500, content_type="application/json") + return Response(response, content_type="application/json") diff --git a/csfunctions/handler.py b/csfunctions/handler.py index 4487c42..8ef8a10 100644 --- a/csfunctions/handler.py +++ b/csfunctions/handler.py @@ -2,6 +2,7 @@ import os import sys import traceback +from functools import lru_cache from importlib import import_module from typing import Callable @@ -16,7 +17,14 @@ from csfunctions.service import Service -def _load_config(function_dir) -> ConfigModel: +class FunctionNotRegistered(ValueError): + """ + Raised when a function is not found in the environment.yaml. + """ + + +@lru_cache(maxsize=1) +def load_environment_config(function_dir: str) -> ConfigModel: path = os.path.join(function_dir, "environment.yaml") if not os.path.exists(path): raise OSError(f"environment file {path} does not exist") @@ -28,10 +36,10 @@ def _load_config(function_dir) -> ConfigModel: def _get_function(function_name: str, function_dir: str) -> FunctionModel: - config = _load_config(function_dir) + config = load_environment_config(function_dir) func = next(func for func in config.functions if func.name == function_name) if not func: - raise ValueError(f"Could not find function with name {function_name} in the environment.yaml.") + raise FunctionNotRegistered(f"Could not find function with name {function_name} in the environment.yaml.") return func From abd726795c1d599af559c973fe343da537ae0340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 13 Mar 2025 09:38:28 +0100 Subject: [PATCH 03/25] Enhance dev server functionality by adding command-line argument support for function directory and refactoring request handling. The application now accepts a directory parameter to locate the environment.yaml file. --- csfunctions/devserver.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index b5cb27a..dbc6c9f 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -4,6 +4,7 @@ """ +import argparse import json from werkzeug.serving import run_simple @@ -33,7 +34,7 @@ def _is_error_response(function_response: str | dict): return False -def handle_request(request: Request) -> Response: +def handle_request(request: Request, function_dir: str = "") -> Response: """ Handles a request to the development server. Extracts the function name from the request path and executes the Function using the execute handler. @@ -45,7 +46,7 @@ def handle_request(request: Request) -> Response: try: # we assume the function is in the current working directory - response = execute(function_name, body, function_dir="") + response = execute(function_name, body, function_dir=function_dir) except FunctionNotRegistered as e: return Response(str(e), status=404) @@ -57,16 +58,22 @@ def handle_request(request: Request) -> Response: return Response(response, content_type="application/json") -def application(environ, start_response): - request = Request(environ) - response = handle_request(request) - return response(environ, start_response) +def create_application(function_dir: str = ""): + def application(environ, start_response): + request = Request(environ) + response = handle_request(request, function_dir=function_dir) + return response(environ, start_response) + return application -def run_server(): + +def run_server(function_dir: str = ""): # B104: binding to all interfaces is intentional - this is a development server - run_simple("0.0.0.0", 8000, application, use_reloader=True) # nosec: B104 + run_simple("0.0.0.0", 8000, create_application(function_dir=function_dir), use_reloader=True) # nosec: B104 if __name__ == "__main__": - run_server() + parser = argparse.ArgumentParser() + parser.add_argument("--dir", type=str, default="", help="The directory containing the environment.yaml file") + args = parser.parse_args() + run_server(function_dir=args.dir) From 92cb97b972aa19c644f99c831549e7f4e8b188c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 13 Mar 2025 10:29:00 +0100 Subject: [PATCH 04/25] verify signature --- csfunctions/devserver.py | 62 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index dbc6c9f..cb306df 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -5,7 +5,11 @@ """ import argparse +import hashlib +import hmac import json +import logging +import time from werkzeug.serving import run_simple from werkzeug.wrappers import Request, Response @@ -34,7 +38,40 @@ def _is_error_response(function_response: str | dict): return False -def handle_request(request: Request, function_dir: str = "") -> Response: +def _verify_hmac_signature( + signature: str | None, timestamp: str | None, body: str, secret_token: str, max_age: int = 60 +) -> bool: + """ + Verify the HMAC signature of the request. + If timestamp is older than max_age seconds, the request is rejected. (default: 60 seconds, disable with -1) + """ + if not secret_token: + # this should not happen, since this function should only be called if a secret token is set + raise ValueError("Missing secret token") + + if not signature: + logging.warning("Request does not contain a signature") + return False + + if not timestamp: + logging.warning("Request does not contain a timestamp") + return False + + if max_age >= 0 and int(timestamp) < time.time() - max_age: + logging.warning("Timestamp of request is older than %d seconds", max_age) + return False + + return hmac.compare_digest( + signature, + hmac.new( + secret_token.encode("utf-8"), + f"{timestamp}{body}".encode(), + hashlib.sha256, + ).hexdigest(), + ) + + +def handle_request(request: Request, function_dir: str = "", secret_token: str | None = None) -> Response: """ Handles a request to the development server. Extracts the function name from the request path and executes the Function using the execute handler. @@ -43,6 +80,11 @@ def handle_request(request: Request, function_dir: str = "") -> Response: if not function_name: return Response("No function name provided", status=400) body = request.get_data(as_text=True) + signature = request.headers.get("X-CON-Signature-256") + timestamp = request.headers.get("X-CON-Timestamp") + + if secret_token and not _verify_hmac_signature(signature, timestamp, body, secret_token): + return Response("Invalid signature", status=401) try: # we assume the function is in the current working directory @@ -58,22 +100,30 @@ def handle_request(request: Request, function_dir: str = "") -> Response: return Response(response, content_type="application/json") -def create_application(function_dir: str = ""): +def create_application(function_dir: str = "", secret_token: str | None = None): def application(environ, start_response): request = Request(environ) - response = handle_request(request, function_dir=function_dir) + response = handle_request(request, function_dir=function_dir, secret_token=secret_token) return response(environ, start_response) return application -def run_server(function_dir: str = ""): +def run_server(function_dir: str = "", secret_token: str | None = None): # B104: binding to all interfaces is intentional - this is a development server - run_simple("0.0.0.0", 8000, create_application(function_dir=function_dir), use_reloader=True) # nosec: B104 + run_simple( + "0.0.0.0", # nosec: B104 + 8000, + create_application(function_dir=function_dir, secret_token=secret_token), + use_reloader=True, + ) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--dir", type=str, default="", help="The directory containing the environment.yaml file") + parser.add_argument( + "--secret-token", type=str, default="", help="The secret token to use for the development server" + ) args = parser.parse_args() - run_server(function_dir=args.dir) + run_server(function_dir=args.dir, secret_token=args.secret_token) From 23faf3235c423377c360be242978df90db125951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 13 Mar 2025 10:55:24 +0100 Subject: [PATCH 05/25] Refactor dev server to use environment variables for function directory and secret token, removing parameters from request handling and server functions. Update command-line argument parsing to set environment variables if provided. --- csfunctions/devserver.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index cb306df..de00b93 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -9,6 +9,7 @@ import hmac import json import logging +import os import time from werkzeug.serving import run_simple @@ -71,7 +72,7 @@ def _verify_hmac_signature( ) -def handle_request(request: Request, function_dir: str = "", secret_token: str | None = None) -> Response: +def handle_request(request: Request) -> Response: """ Handles a request to the development server. Extracts the function name from the request path and executes the Function using the execute handler. @@ -83,47 +84,51 @@ def handle_request(request: Request, function_dir: str = "", secret_token: str | signature = request.headers.get("X-CON-Signature-256") timestamp = request.headers.get("X-CON-Timestamp") + secret_token = os.environ.get("CFC_SECRET_TOKEN", "") if secret_token and not _verify_hmac_signature(signature, timestamp, body, secret_token): return Response("Invalid signature", status=401) try: - # we assume the function is in the current working directory + function_dir = os.environ.get("CFC_FUNCTION_DIR", "") response = execute(function_name, body, function_dir=function_dir) except FunctionNotRegistered as e: return Response(str(e), status=404) if _is_error_response(response): - # If a Function raises an error the execute handler will wrap the error in an ErrorResponse - # We need to check for that to return the correct status code. return Response(response, status=500, content_type="application/json") return Response(response, content_type="application/json") -def create_application(function_dir: str = "", secret_token: str | None = None): +def create_application(): def application(environ, start_response): request = Request(environ) - response = handle_request(request, function_dir=function_dir, secret_token=secret_token) + response = handle_request(request) return response(environ, start_response) return application -def run_server(function_dir: str = "", secret_token: str | None = None): +def run_server(): # B104: binding to all interfaces is intentional - this is a development server run_simple( "0.0.0.0", # nosec: B104 8000, - create_application(function_dir=function_dir, secret_token=secret_token), + create_application(), use_reloader=True, ) if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--dir", type=str, default="", help="The directory containing the environment.yaml file") - parser.add_argument( - "--secret-token", type=str, default="", help="The secret token to use for the development server" - ) + parser.add_argument("--dir", type=str, help="The directory containing the environment.yaml file") + parser.add_argument("--secret-token", type=str, help="The secret token to use for the development server") args = parser.parse_args() - run_server(function_dir=args.dir, secret_token=args.secret_token) + + # Command line arguments take precedence over environment variables + if args.dir: + os.environ["CFC_FUNCTION_DIR"] = args.dir + if args.secret_token: + os.environ["CFC_SECRET_TOKEN"] = args.secret_token + + run_server() From e4661b4f6d2d6ae657bbcc6c23434e4937ab370e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Thu, 13 Mar 2025 11:03:13 +0100 Subject: [PATCH 06/25] add tests for devserver --- tests/test_devserver.py | 118 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/test_devserver.py diff --git a/tests/test_devserver.py b/tests/test_devserver.py new file mode 100644 index 0000000..a2e514f --- /dev/null +++ b/tests/test_devserver.py @@ -0,0 +1,118 @@ +import hashlib +import hmac +import json +import time +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from werkzeug.datastructures import Headers +from werkzeug.wrappers import Request + +from csfunctions.devserver import _is_error_response, _verify_hmac_signature, handle_request +from csfunctions.handler import FunctionNotRegistered + + +class TestDevServer(TestCase): + def setUp(self): + self.secret_token = "test-secret-token" # nosec: B105 + self.function_name = "test-function" + self.request_body = "test-body" + self.timestamp = str(int(time.time())) + self.signature = hmac.new( + self.secret_token.encode("utf-8"), + f"{self.timestamp}{self.request_body}".encode(), + hashlib.sha256, + ).hexdigest() + + def create_request(self, path="/test-function", body="test-body", signature=None, timestamp=None): + environ = { + "PATH_INFO": path, + "wsgi.input": MagicMock(), + "REQUEST_METHOD": "POST", + } + request = Request(environ) + request.get_data = MagicMock(return_value=body) + + if signature: + request.headers = Headers({"X-CON-Signature-256": signature, "X-CON-Timestamp": timestamp}) + return request + + def test_handle_request_no_function_name(self): + """Test handling request with no function name""" + request = self.create_request(path="/") + response = handle_request(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.get_data(as_text=True), "No function name provided") + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_success(self, mock_execute): + """Test successful request handling""" + expected_response = {"result": "success"} + mock_execute.return_value = json.dumps(expected_response) + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(json.loads(response.get_data(as_text=True)), expected_response) + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_function_not_registered(self, mock_execute): + """Test handling of non-existent function""" + mock_execute.side_effect = FunctionNotRegistered("Function not found") + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_data(as_text=True), "Function not found") + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_error_response(self, mock_execute): + """Test handling of error response from function""" + error_response = {"response_type": "error", "message": "Something went wrong"} + mock_execute.return_value = json.dumps(error_response) + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(json.loads(response.get_data(as_text=True)), error_response) + + def test_verify_hmac_signature_valid(self): + """Test HMAC signature verification with valid signature""" + result = _verify_hmac_signature(self.signature, self.timestamp, self.request_body, self.secret_token) + self.assertTrue(result) + + def test_verify_hmac_signature_invalid(self): + """Test HMAC signature verification with invalid signature""" + result = _verify_hmac_signature("invalid-signature", self.timestamp, self.request_body, self.secret_token) + self.assertFalse(result) + + def test_verify_hmac_signature_expired(self): + """Test HMAC signature verification with expired timestamp""" + old_timestamp = str(int(time.time()) - 120) # 2 minutes old + result = _verify_hmac_signature(self.signature, old_timestamp, self.request_body, self.secret_token) + self.assertFalse(result) + + def test_is_error_response(self): + """Test error response detection""" + # Test string response + self.assertFalse(_is_error_response("not an error")) + + # Test JSON string response + error_response = json.dumps({"response_type": "error"}) + self.assertTrue(_is_error_response(error_response)) + + # Test dict response + self.assertTrue(_is_error_response({"response_type": "error"})) + self.assertFalse(_is_error_response({"response_type": "success"})) + + # Test invalid JSON string + self.assertFalse(_is_error_response("{invalid json}")) From 063b31a869f4e52ed3fcc13eb143b1e4ea2774f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:01:23 +0100 Subject: [PATCH 07/25] rename variables --- csfunctions/devserver.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index de00b93..a340dff 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -84,12 +84,12 @@ def handle_request(request: Request) -> Response: signature = request.headers.get("X-CON-Signature-256") timestamp = request.headers.get("X-CON-Timestamp") - secret_token = os.environ.get("CFC_SECRET_TOKEN", "") + secret_token = os.environ.get("CON_DEV_SECRET", "") if secret_token and not _verify_hmac_signature(signature, timestamp, body, secret_token): return Response("Invalid signature", status=401) try: - function_dir = os.environ.get("CFC_FUNCTION_DIR", "") + function_dir = os.environ.get("CON_DEV_DIR", "") response = execute(function_name, body, function_dir=function_dir) except FunctionNotRegistered as e: return Response(str(e), status=404) @@ -113,7 +113,7 @@ def run_server(): # B104: binding to all interfaces is intentional - this is a development server run_simple( "0.0.0.0", # nosec: B104 - 8000, + int(os.environ.get("CON_DEV_PORT", 8000)), create_application(), use_reloader=True, ) @@ -122,13 +122,17 @@ def run_server(): if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--dir", type=str, help="The directory containing the environment.yaml file") - parser.add_argument("--secret-token", type=str, help="The secret token to use for the development server") + parser.add_argument("--secret", type=str, help="The secret token to use for the development server") + parser.add_argument("--port", type=int, help="The port to run the development server on") args = parser.parse_args() # Command line arguments take precedence over environment variables if args.dir: - os.environ["CFC_FUNCTION_DIR"] = args.dir + os.environ["CON_DEV_DIR"] = args.dir if args.secret_token: - os.environ["CFC_SECRET_TOKEN"] = args.secret_token + os.environ["CON_DEV_SECRET"] = args.secret_token + + if args.port: + os.environ["CON_DEV_PORT"] = str(args.port) run_server() From 82afd3b22b9d9fc780df10ca3158c2c1a1ea2106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:01:36 +0100 Subject: [PATCH 08/25] add documentation --- docs/assets/codespace_port_visibility.png | Bin 0 -> 32811 bytes docs/development_server.md | 95 ++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 96 insertions(+) create mode 100644 docs/assets/codespace_port_visibility.png create mode 100644 docs/development_server.md diff --git a/docs/assets/codespace_port_visibility.png b/docs/assets/codespace_port_visibility.png new file mode 100644 index 0000000000000000000000000000000000000000..d194d58e0bf7a7a0e359df68e948569fae1786af GIT binary patch literal 32811 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 rzrTh5-FyBYF7DsM|9{G<1uOV}{QUm_jFXh4 literal 0 HcmV?d00001 diff --git a/docs/development_server.md b/docs/development_server.md new file mode 100644 index 0000000..0033ebc --- /dev/null +++ b/docs/development_server.md @@ -0,0 +1,95 @@ +The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes the Functions available via HTTP endpoints. You can then connect those Functions to your CIM Database Cloud instance using Webhooks. + +This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first. + +## Starting the Server + +You can start the development server using the following command: + +```bash +python -m functions.server +``` + +You can set the port of the server using the `--port` flag (default is 8000), or by setting the `CON_DEV_PORT` environment variable: + +```bash +python -m functions.server --port 8080 +``` + +You can set the directory containing the `environment.yaml` file using the `--dir` flag (by default the current working directory is used) or by setting the `CON_DEV_DIR` environment variable: + +```bash +python -m functions.server --dir ./my_functions +``` + +You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: + +```bash +python -m functions.server --secret my_secret +``` + +## Autoreloading + +The development server will automatically restart if you make changes to your Functions code. However it will not restart if you make changes to the `environment.yaml` file. + +## Exposing the server + +After starting the server, you need to expose the server to the outside world, to enable your CIM Database Cloud instance to send webhook requests to your Functions. +There are multiple ways to do this: + +### GitHub Codespaces + +If you are developing Functions in a GitHub Codespace you can simply forward the port of the server, by right clicking on the dev servers port in the the "Ports" tab and changing the visibility to "Public": + +![GitHub Codespaces](./assets/codespace_port_visibility.png) + +You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using Webhooks. +The URL of the webhook will be the URL of the forwarded port, combined with the Functions set in the `environment.yaml` file. + +For example the `example` function would be available at: + +``` +https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example +``` + +### ngrok and Cloudflare + +If you are developing Functions locally, you can use services like [ngrok](https://ngrok.com/) or [Cloudflare](https://cloudflare.com) to expose the server. + +Please refer to the documentation of the specific service for instructions on how to do this. + +The URL for the webhook will be the URL of the service, combined with the Functions set in the `environment.yaml` file. + +For example the `example` function would be available at: + +``` +https://my-ngrok-tunnel.ngrok.app/example +``` + +## Create a webhook in CIM Database Cloud + +To test your Functions locally, you can create a webhook in your CIM Database Cloud instance and point it to your development server. + +The URL of the webhook will be the URL of the development server, combined with the Functions name set in the `environment.yaml` file using the following format: `https:///` + +For example the `example` function would be available at: + +```https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example``` + + +Make sure to set the webhooks event to the correct event you want to test with your Function. + +For more detaikled information on how to create a webhook in CIM Database Cloud, please refer to the [CIM Database Cloud documentation](https://saas-docs.contact-cloud.com/2025.7.0-en/admin/admin-contact_cloud/saas_admin/webhooks). + + +## Securing the development server + +Since the development server is exposed to the outside world, you should secure it to prevent unauthorized access. + +You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: + +```bash +python -m functions.server --secret my_secret +``` + +Make sure to use the same secret in your CIM Database Cloud instance when setting up the webhook and enable HMAC signing. diff --git a/mkdocs.yml b/mkdocs.yml index cee3628..78586a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Home: index.md - Key concepts: key_concepts.md - Getting started: getting_started.md + - Development server: development_server.md - Reference: - reference/events.md - reference/objects.md From 1e99711b8b114841952f0326015e61dd679f4853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:03:39 +0100 Subject: [PATCH 09/25] watch environment.yaml --- csfunctions/devserver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index a340dff..77d9cb0 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -116,6 +116,7 @@ def run_server(): int(os.environ.get("CON_DEV_PORT", 8000)), create_application(), use_reloader=True, + extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")], ) From a646ddbd70c2d0dc9a1e8e8273784dbec7ad1c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:04:25 +0100 Subject: [PATCH 10/25] Update development server documentation to clarify that it will restart on changes to both Functions code and the environment.yaml file. --- docs/development_server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development_server.md b/docs/development_server.md index 0033ebc..28aa9a5 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -30,7 +30,7 @@ python -m functions.server --secret my_secret ## Autoreloading -The development server will automatically restart if you make changes to your Functions code. However it will not restart if you make changes to the `environment.yaml` file. +The development server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file. ## Exposing the server From 5e082e31982bb17d40fccdc58f64c7d661808cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:13:26 +0100 Subject: [PATCH 11/25] Enable conditional reloading for the development server based on command-line argument. Add support for --no-reload option to disable auto-reloading and update argument parsing for secret token handling. --- csfunctions/devserver.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 77d9cb0..f4096df 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -115,7 +115,7 @@ def run_server(): "0.0.0.0", # nosec: B104 int(os.environ.get("CON_DEV_PORT", 8000)), create_application(), - use_reloader=True, + use_reloader=not bool(os.environ.get("CON_DEV_NO_RELOAD")), extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")], ) @@ -125,15 +125,17 @@ def run_server(): parser.add_argument("--dir", type=str, help="The directory containing the environment.yaml file") parser.add_argument("--secret", type=str, help="The secret token to use for the development server") parser.add_argument("--port", type=int, help="The port to run the development server on") + parser.add_argument("--no-reload", action="store_true", help="Disable auto reloading of the server") args = parser.parse_args() # Command line arguments take precedence over environment variables if args.dir: os.environ["CON_DEV_DIR"] = args.dir - if args.secret_token: - os.environ["CON_DEV_SECRET"] = args.secret_token - + if args.secret: + os.environ["CON_DEV_SECRET"] = args.secret if args.port: os.environ["CON_DEV_PORT"] = str(args.port) + if args.no_reload: + os.environ["CON_DEV_NO_RELOAD"] = "0" run_server() From 5c8dad7091285d0ec38f08349cf1cc4a34484474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:18:37 +0100 Subject: [PATCH 12/25] formatting --- docs/development_server.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development_server.md b/docs/development_server.md index 28aa9a5..3532f32 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -37,7 +37,7 @@ The development server will automatically restart if you make changes to your Fu After starting the server, you need to expose the server to the outside world, to enable your CIM Database Cloud instance to send webhook requests to your Functions. There are multiple ways to do this: -### GitHub Codespaces +**GitHub Codespaces** If you are developing Functions in a GitHub Codespace you can simply forward the port of the server, by right clicking on the dev servers port in the the "Ports" tab and changing the visibility to "Public": @@ -52,7 +52,7 @@ For example the `example` function would be available at: https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example ``` -### ngrok and Cloudflare +**ngrok and Cloudflare** If you are developing Functions locally, you can use services like [ngrok](https://ngrok.com/) or [Cloudflare](https://cloudflare.com) to expose the server. From 53c9fad0b8b8432661e489110d37b89fd395b756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:24:42 +0100 Subject: [PATCH 13/25] add port number validation and additional logging --- csfunctions/devserver.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index f4096df..bc238b8 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -90,11 +90,14 @@ def handle_request(request: Request) -> Response: try: function_dir = os.environ.get("CON_DEV_DIR", "") + logging.info("Executing function: %s", function_name) response = execute(function_name, body, function_dir=function_dir) except FunctionNotRegistered as e: + logging.warning("Function not found: %s", function_name) return Response(str(e), status=404) if _is_error_response(response): + logging.error("Function %s returned error response", function_name) return Response(response, status=500, content_type="application/json") return Response(response, content_type="application/json") @@ -110,10 +113,15 @@ def application(environ, start_response): def run_server(): + port = int(os.environ.get("CON_DEV_PORT", 8000)) + if not 1 <= port <= 65535: + raise ValueError(f"Invalid port number: {port}") + + logging.info("Starting development server on port %d", port) # B104: binding to all interfaces is intentional - this is a development server run_simple( "0.0.0.0", # nosec: B104 - int(os.environ.get("CON_DEV_PORT", 8000)), + port, create_application(), use_reloader=not bool(os.environ.get("CON_DEV_NO_RELOAD")), extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")], @@ -121,6 +129,8 @@ def run_server(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + parser = argparse.ArgumentParser() parser.add_argument("--dir", type=str, help="The directory containing the environment.yaml file") parser.add_argument("--secret", type=str, help="The secret token to use for the development server") @@ -136,6 +146,6 @@ def run_server(): if args.port: os.environ["CON_DEV_PORT"] = str(args.port) if args.no_reload: - os.environ["CON_DEV_NO_RELOAD"] = "0" + os.environ["CON_DEV_NO_RELOAD"] = "1" run_server() From 6042c2b456f37c7ac7aebb9dfdde2b829c8c6678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:32:35 +0100 Subject: [PATCH 14/25] improve typing --- csfunctions/devserver.py | 58 +++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index bc238b8..53edd03 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -2,6 +2,31 @@ The development server looks for an environment.yaml in the current working directory and reads the Functions from it. The Functions are then available via HTTP requests to the server. +The server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file. + +Usage: + +```bash +python -m csfunctions.devserver +``` + +Optional arguments: + +--dir + The directory containing the environment.yaml file. + (default: current working directory) + +--secret + The secret token to use for the development server. + (default: empty) + +--port + The port to run the development server on. + (default: 8000) + +--no-reload + Disable auto reloading of the server. + (default: false) """ import argparse @@ -11,6 +36,8 @@ import logging import os import time +from collections.abc import Iterable +from wsgiref.types import StartResponse, WSGIEnvironment from werkzeug.serving import run_simple from werkzeug.wrappers import Request, Response @@ -103,16 +130,13 @@ def handle_request(request: Request) -> Response: return Response(response, content_type="application/json") -def create_application(): - def application(environ, start_response): - request = Request(environ) - response = handle_request(request) - return response(environ, start_response) - - return application +def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: + request = Request(environ) + response = handle_request(request) + return response(environ, start_response) -def run_server(): +def run_server() -> None: port = int(os.environ.get("CON_DEV_PORT", 8000)) if not 1 <= port <= 65535: raise ValueError(f"Invalid port number: {port}") @@ -122,7 +146,7 @@ def run_server(): run_simple( "0.0.0.0", # nosec: B104 port, - create_application(), + application, use_reloader=not bool(os.environ.get("CON_DEV_NO_RELOAD")), extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")], ) @@ -132,10 +156,18 @@ def run_server(): logging.basicConfig(level=logging.INFO) parser = argparse.ArgumentParser() - parser.add_argument("--dir", type=str, help="The directory containing the environment.yaml file") - parser.add_argument("--secret", type=str, help="The secret token to use for the development server") - parser.add_argument("--port", type=int, help="The port to run the development server on") - parser.add_argument("--no-reload", action="store_true", help="Disable auto reloading of the server") + parser.add_argument( + "--dir", + type=str, + help="The directory containing the environment.yaml file. (default: current working directory)", + ) + parser.add_argument( + "--secret", + type=str, + help="The secret token to use for the development server.", + ) + parser.add_argument("--port", type=int, help="The port to run the development server on. (default: 8000)") + parser.add_argument("--no-reload", action="store_true", help="Disable auto reloading of the server.") args = parser.parse_args() # Command line arguments take precedence over environment variables From 27699bbf085271894e7a2b9a55942d773e0796a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:33:32 +0100 Subject: [PATCH 15/25] improve comments --- csfunctions/devserver.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 53edd03..90b3d38 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -18,7 +18,6 @@ --secret The secret token to use for the development server. - (default: empty) --port The port to run the development server on. @@ -26,7 +25,6 @@ --no-reload Disable auto reloading of the server. - (default: false) """ import argparse From cda821f7536d081bb202091cc892bb4cf991fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:40:23 +0100 Subject: [PATCH 16/25] add warning if no secret is set --- csfunctions/devserver.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 90b3d38..82c6524 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -178,4 +178,11 @@ def run_server() -> None: if args.no_reload: os.environ["CON_DEV_NO_RELOAD"] = "1" + if not os.environ.get("CON_DEV_SECRET"): + logging.warning( + "No secret token provided, development server is not secured!" + " It is recommended to provide a secret via --secret to" + " enable HMAC validation." + ) + run_server() From d46b184c01f45ae17628ebe8a607c4659a706cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:41:49 +0100 Subject: [PATCH 17/25] make secret warning more pronounced --- csfunctions/devserver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 82c6524..2529dd9 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -180,9 +180,9 @@ def run_server() -> None: if not os.environ.get("CON_DEV_SECRET"): logging.warning( - "No secret token provided, development server is not secured!" + "\033[91m\033[1mNo secret token provided, development server is not secured!" " It is recommended to provide a secret via --secret to" - " enable HMAC validation." + " enable HMAC validation.\033[0m" ) run_server() From e73a917e467692a8d684aa1665aea2fd556d6fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:49:08 +0100 Subject: [PATCH 18/25] typo --- docs/development_server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development_server.md b/docs/development_server.md index 3532f32..88d225c 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -79,7 +79,7 @@ For example the `example` function would be available at: Make sure to set the webhooks event to the correct event you want to test with your Function. -For more detaikled information on how to create a webhook in CIM Database Cloud, please refer to the [CIM Database Cloud documentation](https://saas-docs.contact-cloud.com/2025.7.0-en/admin/admin-contact_cloud/saas_admin/webhooks). +For more detailed information on how to create a webhook in CIM Database Cloud, please refer to the [CIM Database Cloud documentation](https://saas-docs.contact-cloud.com/2025.7.0-en/admin/admin-contact_cloud/saas_admin/webhooks). ## Securing the development server From 2d16ae2ae8cd5521b74550cbfbc95d2a67e826b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:51:26 +0100 Subject: [PATCH 19/25] improve sentence structure in the docs --- docs/development_server.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/development_server.md b/docs/development_server.md index 88d225c..d2683de 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -1,4 +1,4 @@ -The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes the Functions available via HTTP endpoints. You can then connect those Functions to your CIM Database Cloud instance using Webhooks. +The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using Webhooks. This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first. @@ -34,12 +34,11 @@ The development server will automatically restart if you make changes to your Fu ## Exposing the server -After starting the server, you need to expose the server to the outside world, to enable your CIM Database Cloud instance to send webhook requests to your Functions. -There are multiple ways to do this: +To enable your CIM Database Cloud instance to send webhook requests to your Functions, you need to make the server accessible from the internet. Here are several ways to do this: **GitHub Codespaces** -If you are developing Functions in a GitHub Codespace you can simply forward the port of the server, by right clicking on the dev servers port in the the "Ports" tab and changing the visibility to "Public": +If you are developing Functions in a GitHub Codespace, you can expose the server by right-clicking on the dev server's port in the "Ports" tab and changing the visibility to "Public": ![GitHub Codespaces](./assets/codespace_port_visibility.png) @@ -54,7 +53,7 @@ https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example **ngrok and Cloudflare** -If you are developing Functions locally, you can use services like [ngrok](https://ngrok.com/) or [Cloudflare](https://cloudflare.com) to expose the server. +If you are developing Functions locally, you can use services like [ngrok](https://ngrok.com/) or [Cloudflare](https://cloudflare.com) to expose your server to the internet. Please refer to the documentation of the specific service for instructions on how to do this. @@ -68,9 +67,11 @@ https://my-ngrok-tunnel.ngrok.app/example ## Create a webhook in CIM Database Cloud -To test your Functions locally, you can create a webhook in your CIM Database Cloud instance and point it to your development server. +To test your Functions locally, create a webhook in your CIM Database Cloud instance and point it to your development server. -The URL of the webhook will be the URL of the development server, combined with the Functions name set in the `environment.yaml` file using the following format: `https:///` +The webhook URL should combine your development server URL with the Function name from your `environment.yaml` file using this format: + +`https:///` For example the `example` function would be available at: From df3babee3117336db117cce2b95259d9f05573cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:52:20 +0100 Subject: [PATCH 20/25] remove redundant information --- docs/development_server.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/development_server.md b/docs/development_server.md index d2683de..61820e9 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -43,13 +43,6 @@ If you are developing Functions in a GitHub Codespace, you can expose the server ![GitHub Codespaces](./assets/codespace_port_visibility.png) You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using Webhooks. -The URL of the webhook will be the URL of the forwarded port, combined with the Functions set in the `environment.yaml` file. - -For example the `example` function would be available at: - -``` -https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example -``` **ngrok and Cloudflare** @@ -57,13 +50,6 @@ If you are developing Functions locally, you can use services like [ngrok](https Please refer to the documentation of the specific service for instructions on how to do this. -The URL for the webhook will be the URL of the service, combined with the Functions set in the `environment.yaml` file. - -For example the `example` function would be available at: - -``` -https://my-ngrok-tunnel.ngrok.app/example -``` ## Create a webhook in CIM Database Cloud From 598e0218ca3e6ebaaad1ea7020fb56944a0a4dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:53:27 +0100 Subject: [PATCH 21/25] don't capitalize webhooks --- docs/development_server.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development_server.md b/docs/development_server.md index 61820e9..c64d652 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -1,4 +1,4 @@ -The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using Webhooks. +The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using webhooks. This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first. @@ -42,7 +42,7 @@ If you are developing Functions in a GitHub Codespace, you can expose the server ![GitHub Codespaces](./assets/codespace_port_visibility.png) -You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using Webhooks. +You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using webhooks. **ngrok and Cloudflare** From eab83fc18d6d61bc53f65b5f19e8267f78715071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:53:51 +0100 Subject: [PATCH 22/25] dev version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d283477..c93a437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "contactsoftware-functions" -version = "0.8.3" +version = "0.11.0.dev1" readme = "README.md" license = "MIT" From adf56a243d03d580cabc6030fab92d4dbf17f3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 12:55:28 +0100 Subject: [PATCH 23/25] fix incorrect commands in doc --- docs/development_server.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/development_server.md b/docs/development_server.md index c64d652..a21bb2c 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -7,25 +7,25 @@ This speeds up the development of Functions, because you can instantly test your You can start the development server using the following command: ```bash -python -m functions.server +python -m csfunctions.devserver ``` You can set the port of the server using the `--port` flag (default is 8000), or by setting the `CON_DEV_PORT` environment variable: ```bash -python -m functions.server --port 8080 +python -m csfunctions.devserver --port 8080 ``` You can set the directory containing the `environment.yaml` file using the `--dir` flag (by default the current working directory is used) or by setting the `CON_DEV_DIR` environment variable: ```bash -python -m functions.server --dir ./my_functions +python -m csfunctions.devserver --dir ./my_functions ``` You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: ```bash -python -m functions.server --secret my_secret +python -m csfunctions.devserver --secret my_secret ``` ## Autoreloading @@ -76,7 +76,7 @@ Since the development server is exposed to the outside world, you should secure You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: ```bash -python -m functions.server --secret my_secret +python -m csfunctions.devserver --secret my_secret ``` Make sure to use the same secret in your CIM Database Cloud instance when setting up the webhook and enable HMAC signing. From 7cdf8ea27cd5c28d7261b000addd91a35c230dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 13:05:22 +0100 Subject: [PATCH 24/25] Clarify documentation by removing redundant reference to the `environment.yaml` file location in the development server section. --- docs/development_server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development_server.md b/docs/development_server.md index a21bb2c..805bd47 100644 --- a/docs/development_server.md +++ b/docs/development_server.md @@ -1,4 +1,4 @@ -The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file in your working directory and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using webhooks. +The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using webhooks. This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first. From 7c55ab3851655137772ad6bdb8ae31efd669ebc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20K=C3=BCrten?= Date: Wed, 19 Mar 2025 13:06:16 +0100 Subject: [PATCH 25/25] Update csfunctions/devserver.py Co-authored-by: Julian Alberts --- csfunctions/devserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py index 2529dd9..bacf7ef 100644 --- a/csfunctions/devserver.py +++ b/csfunctions/devserver.py @@ -1,5 +1,5 @@ """ -The development server looks for an environment.yaml in the current working directory and reads the Functions from it. +The development server looks for an environment.yaml in the given directory and reads the Functions from it. The Functions are then available via HTTP requests to the server. The server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file.