Skip to content

Commit e98712d

Browse files
jens-kuertenJens Kürtenalbertsj
authored
Feat: Dev server for running Functions in dev environment (#21)
Co-authored-by: Julian Alberts <julian.alberts@contact-software.com> --------- Co-authored-by: Jens Kürten <jens.kuerten@contact-software.com> Co-authored-by: Julian Alberts <julian.alberts@contact-software.com>
1 parent ac5d301 commit e98712d

File tree

8 files changed

+490
-5
lines changed

8 files changed

+490
-5
lines changed

csfunctions/devserver.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""
2+
The development server looks for an environment.yaml in the given directory and reads the Functions from it.
3+
The Functions are then available via HTTP requests to the server.
4+
5+
The server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file.
6+
7+
Usage:
8+
9+
```bash
10+
python -m csfunctions.devserver
11+
```
12+
13+
Optional arguments:
14+
15+
--dir <directory>
16+
The directory containing the environment.yaml file.
17+
(default: current working directory)
18+
19+
--secret <secret>
20+
The secret token to use for the development server.
21+
22+
--port <port>
23+
The port to run the development server on.
24+
(default: 8000)
25+
26+
--no-reload
27+
Disable auto reloading of the server.
28+
"""
29+
30+
import argparse
31+
import hashlib
32+
import hmac
33+
import json
34+
import logging
35+
import os
36+
import time
37+
from collections.abc import Iterable
38+
from wsgiref.types import StartResponse, WSGIEnvironment
39+
40+
from werkzeug.serving import run_simple
41+
from werkzeug.wrappers import Request, Response
42+
43+
from csfunctions.handler import FunctionNotRegistered, execute
44+
45+
46+
def _is_error_response(function_response: str | dict):
47+
"""
48+
Try to figure out if the response from the function is an error response.
49+
This is the same implementation as in the runtime, to ensure the behavior is the same.
50+
"""
51+
if isinstance(function_response, str):
52+
# function response could be a json encoded dict, so we try to decode it first
53+
try:
54+
function_response = json.loads(function_response)
55+
except json.JSONDecodeError:
56+
# response is not json decoded, so it's not an error response
57+
return False
58+
59+
if isinstance(function_response, dict):
60+
# check if the response dict is an error response
61+
return function_response.get("response_type") == "error"
62+
else:
63+
# function response is neither a dict nor json encoded dict, so can't be an error response
64+
return False
65+
66+
67+
def _verify_hmac_signature(
68+
signature: str | None, timestamp: str | None, body: str, secret_token: str, max_age: int = 60
69+
) -> bool:
70+
"""
71+
Verify the HMAC signature of the request.
72+
If timestamp is older than max_age seconds, the request is rejected. (default: 60 seconds, disable with -1)
73+
"""
74+
if not secret_token:
75+
# this should not happen, since this function should only be called if a secret token is set
76+
raise ValueError("Missing secret token")
77+
78+
if not signature:
79+
logging.warning("Request does not contain a signature")
80+
return False
81+
82+
if not timestamp:
83+
logging.warning("Request does not contain a timestamp")
84+
return False
85+
86+
if max_age >= 0 and int(timestamp) < time.time() - max_age:
87+
logging.warning("Timestamp of request is older than %d seconds", max_age)
88+
return False
89+
90+
return hmac.compare_digest(
91+
signature,
92+
hmac.new(
93+
secret_token.encode("utf-8"),
94+
f"{timestamp}{body}".encode(),
95+
hashlib.sha256,
96+
).hexdigest(),
97+
)
98+
99+
100+
def handle_request(request: Request) -> Response:
101+
"""
102+
Handles a request to the development server.
103+
Extracts the function name from the request path and executes the Function using the execute handler.
104+
"""
105+
function_name = request.path.strip("/")
106+
if not function_name:
107+
return Response("No function name provided", status=400)
108+
body = request.get_data(as_text=True)
109+
signature = request.headers.get("X-CON-Signature-256")
110+
timestamp = request.headers.get("X-CON-Timestamp")
111+
112+
secret_token = os.environ.get("CON_DEV_SECRET", "")
113+
if secret_token and not _verify_hmac_signature(signature, timestamp, body, secret_token):
114+
return Response("Invalid signature", status=401)
115+
116+
try:
117+
function_dir = os.environ.get("CON_DEV_DIR", "")
118+
logging.info("Executing function: %s", function_name)
119+
response = execute(function_name, body, function_dir=function_dir)
120+
except FunctionNotRegistered as e:
121+
logging.warning("Function not found: %s", function_name)
122+
return Response(str(e), status=404)
123+
124+
if _is_error_response(response):
125+
logging.error("Function %s returned error response", function_name)
126+
return Response(response, status=500, content_type="application/json")
127+
128+
return Response(response, content_type="application/json")
129+
130+
131+
def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
132+
request = Request(environ)
133+
response = handle_request(request)
134+
return response(environ, start_response)
135+
136+
137+
def run_server() -> None:
138+
port = int(os.environ.get("CON_DEV_PORT", 8000))
139+
if not 1 <= port <= 65535:
140+
raise ValueError(f"Invalid port number: {port}")
141+
142+
logging.info("Starting development server on port %d", port)
143+
# B104: binding to all interfaces is intentional - this is a development server
144+
run_simple(
145+
"0.0.0.0", # nosec: B104
146+
port,
147+
application,
148+
use_reloader=not bool(os.environ.get("CON_DEV_NO_RELOAD")),
149+
extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")],
150+
)
151+
152+
153+
if __name__ == "__main__":
154+
logging.basicConfig(level=logging.INFO)
155+
156+
parser = argparse.ArgumentParser()
157+
parser.add_argument(
158+
"--dir",
159+
type=str,
160+
help="The directory containing the environment.yaml file. (default: current working directory)",
161+
)
162+
parser.add_argument(
163+
"--secret",
164+
type=str,
165+
help="The secret token to use for the development server.",
166+
)
167+
parser.add_argument("--port", type=int, help="The port to run the development server on. (default: 8000)")
168+
parser.add_argument("--no-reload", action="store_true", help="Disable auto reloading of the server.")
169+
args = parser.parse_args()
170+
171+
# Command line arguments take precedence over environment variables
172+
if args.dir:
173+
os.environ["CON_DEV_DIR"] = args.dir
174+
if args.secret:
175+
os.environ["CON_DEV_SECRET"] = args.secret
176+
if args.port:
177+
os.environ["CON_DEV_PORT"] = str(args.port)
178+
if args.no_reload:
179+
os.environ["CON_DEV_NO_RELOAD"] = "1"
180+
181+
if not os.environ.get("CON_DEV_SECRET"):
182+
logging.warning(
183+
"\033[91m\033[1mNo secret token provided, development server is not secured!"
184+
" It is recommended to provide a secret via --secret <secret> to"
185+
" enable HMAC validation.\033[0m"
186+
)
187+
188+
run_server()

csfunctions/handler.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import sys
44
import traceback
5+
from functools import lru_cache
56
from importlib import import_module
67
from typing import Callable
78

@@ -16,7 +17,14 @@
1617
from csfunctions.service import Service
1718

1819

19-
def _load_config(function_dir) -> ConfigModel:
20+
class FunctionNotRegistered(ValueError):
21+
"""
22+
Raised when a function is not found in the environment.yaml.
23+
"""
24+
25+
26+
@lru_cache(maxsize=1)
27+
def load_environment_config(function_dir: str) -> ConfigModel:
2028
path = os.path.join(function_dir, "environment.yaml")
2129
if not os.path.exists(path):
2230
raise OSError(f"environment file {path} does not exist")
@@ -28,10 +36,10 @@ def _load_config(function_dir) -> ConfigModel:
2836

2937

3038
def _get_function(function_name: str, function_dir: str) -> FunctionModel:
31-
config = _load_config(function_dir)
39+
config = load_environment_config(function_dir)
3240
func = next(func for func in config.functions if func.name == function_name)
3341
if not func:
34-
raise ValueError(f"Could not find function with name {function_name} in the environment.yaml.")
42+
raise FunctionNotRegistered(f"Could not find function with name {function_name} in the environment.yaml.")
3543
return func
3644

3745

32 KB
Loading

docs/development_server.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
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.
2+
3+
This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first.
4+
5+
## Starting the Server
6+
7+
You can start the development server using the following command:
8+
9+
```bash
10+
python -m csfunctions.devserver
11+
```
12+
13+
You can set the port of the server using the `--port` flag (default is 8000), or by setting the `CON_DEV_PORT` environment variable:
14+
15+
```bash
16+
python -m csfunctions.devserver --port 8080
17+
```
18+
19+
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:
20+
21+
```bash
22+
python -m csfunctions.devserver --dir ./my_functions
23+
```
24+
25+
You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable:
26+
27+
```bash
28+
python -m csfunctions.devserver --secret my_secret
29+
```
30+
31+
## Autoreloading
32+
33+
The development server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file.
34+
35+
## Exposing the server
36+
37+
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:
38+
39+
**GitHub Codespaces**
40+
41+
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":
42+
43+
![GitHub Codespaces](./assets/codespace_port_visibility.png)
44+
45+
You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using webhooks.
46+
47+
**ngrok and Cloudflare**
48+
49+
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.
50+
51+
Please refer to the documentation of the specific service for instructions on how to do this.
52+
53+
54+
## Create a webhook in CIM Database Cloud
55+
56+
To test your Functions locally, create a webhook in your CIM Database Cloud instance and point it to your development server.
57+
58+
The webhook URL should combine your development server URL with the Function name from your `environment.yaml` file using this format:
59+
60+
`https://<development-server-url>/<function-name>`
61+
62+
For example the `example` function would be available at:
63+
64+
```https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example```
65+
66+
67+
Make sure to set the webhooks event to the correct event you want to test with your Function.
68+
69+
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).
70+
71+
72+
## Securing the development server
73+
74+
Since the development server is exposed to the outside world, you should secure it to prevent unauthorized access.
75+
76+
You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable:
77+
78+
```bash
79+
python -m csfunctions.devserver --secret my_secret
80+
```
81+
82+
Make sure to use the same secret in your CIM Database Cloud instance when setting up the webhook and enable HMAC signing.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ nav:
3131
- Home: index.md
3232
- Key concepts: key_concepts.md
3333
- Getting started: getting_started.md
34+
- Development server: development_server.md
3435
- Reference:
3536
- reference/events.md
3637
- reference/objects.md

0 commit comments

Comments
 (0)