From d095e1678a69acc8f8391cf11a4e85082ea42964 Mon Sep 17 00:00:00 2001
From: James Swan <122404367+swan-amazon@users.noreply.github.com>
Date: Thu, 20 Feb 2025 16:01:36 -0800
Subject: [PATCH] [Testing] Refactor mock server to support distributed
configuration loading (#37695)
* Refactor mock server to support distributed configuration loading
Refactors the mock server implementation to support loading configuration from
multiple JSON files, enabling flexible mocking of different services during
Matter device commissioning validation.
Key Changes:
- Restructured configuration loading to support directory-based routing configs
- Added dataclass-based type safety for configuration and route definitions
- Updated path handling to use pathlib.Path for better cross-platform support
- Modified server launch configuration to support routing config directory
- Added configurations for mocking multiple services:
* Distributed Compliance Ledger (DCL)
* Product Terms & Conditions server
Technical Improvements:
- Introduced strongly typed Route and Configuration classes
- Simplified route matching logic with dedicated matcher
- Improved error handling for configuration loading
- Updated unit tests to support new configuration structure
The changes enable quick iteration of mock service responses during preproduction
testing and PlugFest validation, particularly for testing new commissioning
flows that rely on DCL-based configuration with indirect product server
references.
Test Configuration:
- Added example configurations for VID:65521/65522, PID:32769
- Updated TC URL endpoints to use port 44538
- Included sample Terms & Conditions responses
* server: Add validation checks for SSL certificate and key files
Add input validation to verify that SSL certificate and key files exist
and are regular files before attempting to create the SSL context.
This provides clearer error messages to users when certificate files
are missing or invalid, following the same validation pattern used for
config files.
---
.github/.wordlist.txt | 5 +
.vscode/launch.json | 29 +-
.../chip-tool/commands/dcl/test_dcl_server.py | 251 ------------------
integrations/mock_server/README.md | 56 ++++
.../dcl-model-models-65521-32769.json | 42 +++
.../dcl-model-models-65521-32785.json | 42 +++
.../dcl-model-models-65521-32786.json | 42 +++
.../dcl-model-models-65522-32769.json | 42 +++
.../terms-and-conditions-65521-32769-v1.json | 105 ++++++++
.../terms-and-conditions-65521-32785-v1.json | 105 ++++++++
.../terms-and-conditions-65521-32786-v1.json | 105 ++++++++
.../terms-and-conditions-65522-32769-v1.json | 34 +++
.../configurations/server_config.json | 1 +
.../configurations/unittest/config.json | 17 ++
integrations/mock_server/src/__init__.py | 13 +
integrations/mock_server/src/handler.py | 112 ++++++++
integrations/mock_server/src/main.py | 83 ++++++
.../mock_server/src/route_configuration.py | 181 +++++++++++++
integrations/mock_server/src/router.py | 106 ++++++++
integrations/mock_server/src/server.py | 97 +++++++
integrations/mock_server/tests/__init__.py | 13 +
.../mock_server/tests/test_mock_server.py | 151 +++++++++++
22 files changed, 1379 insertions(+), 253 deletions(-)
delete mode 100755 examples/chip-tool/commands/dcl/test_dcl_server.py
create mode 100644 integrations/mock_server/README.md
create mode 100644 integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32769.json
create mode 100644 integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32785.json
create mode 100644 integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32786.json
create mode 100644 integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65522-32769.json
create mode 100644 integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32769-v1.json
create mode 100644 integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32785-v1.json
create mode 100644 integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32786-v1.json
create mode 100644 integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65522-32769-v1.json
create mode 100644 integrations/mock_server/configurations/server_config.json
create mode 100644 integrations/mock_server/configurations/unittest/config.json
create mode 100644 integrations/mock_server/src/__init__.py
create mode 100644 integrations/mock_server/src/handler.py
create mode 100644 integrations/mock_server/src/main.py
create mode 100644 integrations/mock_server/src/route_configuration.py
create mode 100644 integrations/mock_server/src/router.py
create mode 100644 integrations/mock_server/src/server.py
create mode 100644 integrations/mock_server/tests/__init__.py
create mode 100644 integrations/mock_server/tests/test_mock_server.py
diff --git a/.github/.wordlist.txt b/.github/.wordlist.txt
index 5c24cdec825ac1..21e4b6c71059c8 100644
--- a/.github/.wordlist.txt
+++ b/.github/.wordlist.txt
@@ -326,6 +326,7 @@ CurrentHue
CurrentLevel
CurrentSaturation
customAcl
+customizable
customizations
cvfJ
cxx
@@ -1039,6 +1040,7 @@ otatesting
otaURL
OTBR
otcli
+OU
outform
outgoingCommands
overridable
@@ -1150,6 +1152,7 @@ PyObject
pypi
PyRun
pytest
+PYTHONPATH
QEMU
Qorvo
QPG
@@ -1326,6 +1329,7 @@ SRP
SRV
SSBL
SSID
+SSL
startoffset
StartScan
startsWith
@@ -1490,6 +1494,7 @@ unfocus
Unicast
UniFlash
UnitLocalization
+unittest
unpair
unprovisioned
Unsecure
diff --git a/.vscode/launch.json b/.vscode/launch.json
index b86e385f9921e4..5e15018e96c46f 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,12 +4,37 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ "name": "Python: Mock Server Tests",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "unittest",
+ "args": [
+ "${workspaceFolder}/integrations/mock_server/tests/test_mock_server.py"
+ ],
+ "env": {
+ "PYTHONPATH": "${workspaceFolder}/integrations/mock_server/src:${PYTHONPATH}"
+ },
+ "console": "integratedTerminal",
+ "cwd": "${workspaceFolder}"
+ },
{
"name": "Python Debugger: test_dcl_server",
"type": "debugpy",
"request": "launch",
- "program": "/workspace/connectedhomeip/examples/chip-tool/commands/dcl/test_dcl_server.py",
- "args": [],
+ "program": "${workspaceFolder}/integrations/mock_server/src/main.py",
+ "args": [
+ "--port",
+ "8443",
+ "--config",
+ "${workspaceFolder}/integrations/mock_server/configurations/server_config.json",
+ "--routing-config-dir",
+ "${workspaceFolder}/integrations/mock_server/configurations/fake_distributed_compliance_ledger",
+ "--cert",
+ "${workspaceFolder}/server.crt",
+ "--key",
+ "${workspaceFolder}/server.key"
+ ],
"console": "integratedTerminal"
},
{
diff --git a/examples/chip-tool/commands/dcl/test_dcl_server.py b/examples/chip-tool/commands/dcl/test_dcl_server.py
deleted file mode 100755
index 1b68904b4f64b0..00000000000000
--- a/examples/chip-tool/commands/dcl/test_dcl_server.py
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/usr/bin/env -S python3 -B
-
-# Copyright (c) 2025 Project CHIP Authors
-#
-# 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 base64
-import hashlib
-import http.server
-import json
-import os
-import re
-import ssl
-
-DEFAULT_HOSTNAME = "localhost"
-DEFAULT_PORT = 4443
-
-
-TC = {
- 0XFFF1: {
- 0x8001: {
- "schemaVersion": 1,
- "esfRevision": 1,
- "defaultCountry": "US",
- "countryEntries": {
- "US": {
- "defaultLanguage": "en",
- "languageEntries": {
- "en": [
- {
- "ordinal": 0,
- "required": True,
- "title": "Terms and Conditions",
- "text": "
Feature 1 Text
Please accept these.
"
- },
- {
- "ordinal": 1,
- "required": False,
- "title": "Privacy Policy",
- "text": "Feature 2 Text
"
- }
- ],
- "es": [
- {
- "ordinal": 0,
- "required": True,
- "title": "Términos y condiciones",
- "text": "Característica 1 Texto
Por favor acéptelos.
"
- },
- {
- "ordinal": 1,
- "required": False,
- "title": "Política de privacidad",
- "text": "Característica 2 Texto
"
- }
- ]
- }
- },
- "MX": {
- "defaultLanguage": "es",
- "languageEntries": {
- "es": [
- {
- "ordinal": 0,
- "required": True,
- "title": "Términos y condiciones",
- "text": "Característica 1 Texto
Por favor acéptelos.
"
- }
- ]
- }
- },
- "CN": {
- "defaultLanguage": "zh",
- "languageEntries": {
- "zh": [
- {
- "ordinal": 0,
- "required": True,
- "title": "条款和条件",
- "text": "产品1文字
"
- },
- {
- "ordinal": 1,
- "required": False,
- "title": "隐私条款",
- "text": "产品2文字
"
- }
- ]
- }
- },
- "RU": {
- "defaultLanguage": "ru",
- "languageEntries": {
- "ru": [
- {
- "ordinal": 0,
- "required": True,
- "title": "Условия и положения",
- "text": "Текст функции 1
Пожалуйста, примите эти условия пользования.
"
- },
- {
- "ordinal": 1,
- "required": False,
- "title": "Положение о конфиденциальности",
- "text": "Текст функции 2
"
- }
- ]
- }
- }
- }
- }
- }
-}
-
-MODELS = {
- 0XFFF1: {
- 0x8001: {
- "model":
- {
- "vid": 65521,
- "pid": 32769,
- "deviceTypeId": 65535,
- "productName": "TEST_PRODUCT",
- "productLabel": "All Clusters App",
- "partNumber": "",
- "commissioningCustomFlow": 2,
- "commissioningCustomFlowUrl": "",
- "commissioningModeInitialStepsHint": 0,
- "commissioningModeInitialStepsInstruction": "",
- "commissioningModeSecondaryStepsHint": 0,
- "commissioningModeSecondaryStepsInstruction": "",
- "creator": "chip project",
- "lsfRevision": 0,
- "lsfUrl": "",
- "productUrl": "https://github.com/project-chip/connectedhomeip/tree/master/examples/all-clusters-app",
- "supportUrl": "https://github.com/project-chip/connectedhomeip/",
- "userManualUrl": "",
- "enhancedSetupFlowOptions": 1,
- "enhancedSetupFlowTCUrl": f"https://{DEFAULT_HOSTNAME}:{DEFAULT_PORT}/tc/65521/32769",
- "enhancedSetupFlowTCRevision": 1,
- "enhancedSetupFlowTCDigest": "",
- "enhancedSetupFlowTCFileSize": 0,
- "enhancedSetupFlowMaintenanceUrl": ""
- }
- }
- }
-}
-
-
-class RESTRequestHandler(http.server.BaseHTTPRequestHandler):
- def __init__(self, *args, **kwargs):
- self.routes = {
- r"/dcl/model/models/(\d+)/(\d+)": self.handle_model_request,
- r"/tc/(\d+)/(\d+)": self.handle_tc_request,
- }
- super().__init__(*args, **kwargs)
-
- def do_GET(self):
- for pattern, handler in self.routes.items():
- match = re.match(pattern, self.path)
- if match:
- response = handler(*match.groups())
- if response:
- self.send_response(200)
- self.send_header("Content-Type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps(response).encode("utf-8"))
- return
-
- # Handle 404 for unmatched paths
- self.send_response(404)
- self.send_header("Content-Type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Not found"}).encode("utf-8"))
-
- def handle_model_request(self, vendor_id, product_id):
- vendor_id = int(vendor_id)
- product_id = int(product_id)
- if vendor_id in MODELS and product_id in MODELS[vendor_id]:
- model = MODELS[int(vendor_id)][int(product_id)]
- # We will return a model that contains the file size and the digest of the TC.
- # Instead of manually setting them, it is calculated on the fly.
- tc = TC[int(vendor_id)][int(product_id)]
- tc_encoded = json.dumps(tc).encode("utf-8")
- sha256_hash = hashlib.sha256(tc_encoded).digest()
- model['model']['enhancedSetupFlowTCFileSize'] = len(tc_encoded)
- model['model']['enhancedSetupFlowTCDigest'] = base64.b64encode(
- sha256_hash).decode("utf-8")
-
- return model
-
- return None
-
- def handle_tc_request(self, vendor_id, product_id):
- vendor_id = int(vendor_id)
- product_id = int(product_id)
- if vendor_id in TC and product_id in TC[vendor_id]:
- return TC[int(vendor_id)][int(product_id)]
-
- return None
-
-
-def run_https_server(cert_file="cert.pem", key_file="key.pem"):
- # Creates a basic HTTP server instance that listens on DEFAULT_HOSTNAME and DEFAULT_PORT
- # RESTRequestHandler handles incoming HTTP requests
- httpd = http.server.HTTPServer((DEFAULT_HOSTNAME, DEFAULT_PORT), RESTRequestHandler)
-
- # Creates an SSL context using TLS protocol for secure communications
- context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
-
- # Loads the SSL certificate and private key for the server
- # cert_file: contains the server's public certificate
- # key_file: contains the server's private key
- context.load_cert_chain(certfile=cert_file, keyfile=key_file)
-
- # Uses a context manager (with statement) to wrap the HTTP server's socket with SSL
- # server_side=True indicates this is a server socket
- # The wrapped socket is automatically closed when exiting the with block
- with context.wrap_socket(httpd.socket, server_side=True) as httpd.socket:
- print(f"Serving on https://{DEFAULT_HOSTNAME}:{DEFAULT_PORT}")
- # Starts the server and runs indefinitely, handling incoming HTTPS requests
- httpd.serve_forever()
-
-
-# Generate self-signed certificates if needed
-def generate_self_signed_cert(cert_file="cert.pem", key_file="key.pem"):
- from subprocess import run
- run([
- "openssl", "req", "-x509", "-nodes", "-days", "365", "-newkey", "rsa:2048",
- "-keyout", key_file, "-out", cert_file,
- "-subj", f"/C=US/ST=Test/L=Test/O=Test/OU=Test/CN={DEFAULT_HOSTNAME}"
- ])
-
-
-# Check if certificates exist; if not, generate them
-if not os.path.exists("cert.pem") or not os.path.exists("key.pem"):
- print("Generating self-signed certificates...")
- generate_self_signed_cert()
-
-# Run the server
-run_https_server()
diff --git a/integrations/mock_server/README.md b/integrations/mock_server/README.md
new file mode 100644
index 00000000000000..49945d190dd776
--- /dev/null
+++ b/integrations/mock_server/README.md
@@ -0,0 +1,56 @@
+# Mock HTTP/HTTPS Server
+
+## Overview
+
+This project provides a configurable mock HTTP/HTTPS server designed for API
+testing, dynamic response generation, and automated request handling. It
+supports static responses, dynamic custom response handlers, query parameter
+matching, request body validation (including regex), and both HTTP and HTTPS
+protocols.
+
+## Setup
+
+```bash
+openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes -subj "/C=US/ST= /L= /O= /OU= /CN=localhost"
+```
+
+## Features
+
+### Server Functionality
+
+- Secure HTTPS with TLS support
+- CLI-driven execution with customizable options (port, configuration files,
+ SSL options)
+- Handles GET, POST, PUT, and DELETE requests
+- Concurrent request handling via threading
+
+### Route Matching
+
+- Exact path and wildcard (\*) path matching
+- Query parameter validation
+- Priority-based route matching
+
+### Configuration
+
+- Main server configuration file
+- Separate routing configuration directory
+- JSON-based configuration format
+- Dynamic route loading
+
+### Security & Logging
+
+- TLS encryption (HTTPS only)
+- Structured logging with DEBUG level
+- Graceful error handling for invalid routes
+
+## Running Tests
+
+### Test Execution
+
+You can run the tests using one of these methods:
+
+1. Using Python unittest with PYTHONPATH:
+
+```bash
+PYTHONPATH=$PYTHONPATH:/workspace/connectedhomeip/integrations/mock_server/src python3 -m unittest integrations/mock_server/tests/test_mock_server.py
+```
diff --git a/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32769.json b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32769.json
new file mode 100644
index 00000000000000..1b558438b7bb7b
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32769.json
@@ -0,0 +1,42 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/dcl/model/models/65521/32769",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "model": {
+ "vid": 65521,
+ "pid": 32769,
+ "deviceTypeId": 259,
+ "productName": "TEST_PRODUCT",
+ "productLabel": "Terms and Conditions App",
+ "partNumber": "",
+ "commissioningCustomFlow": 2,
+ "commissioningCustomFlowUrl": "",
+ "commissioningModeInitialStepsHint": 0,
+ "commissioningModeInitialStepsInstruction": "",
+ "commissioningModeSecondaryStepsHint": 0,
+ "commissioningModeSecondaryStepsInstruction": "",
+ "creator": "chip project",
+ "lsfRevision": 0,
+ "lsfUrl": "",
+ "productUrl": "https://github.com/project-chip/connectedhomeip/tree/master/examples/terms-and-conditions-app",
+ "supportUrl": "https://github.com/project-chip/connectedhomeip/",
+ "userManualUrl": "",
+ "enhancedSetupFlowOptions": 1,
+ "enhancedSetupFlowTCUrl": "https://localhost:44538/tc/65521/32769",
+ "enhancedSetupFlowTCRevision": 1,
+ "enhancedSetupFlowTCDigest": "Q+zDGwvJk+tZtBV28b/L4YLw5LIUklk46bTZfyDpJkE=",
+ "enhancedSetupFlowTCFileSize": 2060,
+ "enhancedSetupFlowMaintenanceUrl": ""
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32785.json b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32785.json
new file mode 100644
index 00000000000000..67eaa4531f1e04
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32785.json
@@ -0,0 +1,42 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/dcl/model/models/65521/32785",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "model": {
+ "vid": 65521,
+ "pid": 32785,
+ "deviceTypeId": 259,
+ "productName": "TEST_PRODUCT",
+ "productLabel": "Terms and Conditions App",
+ "partNumber": "",
+ "commissioningCustomFlow": 2,
+ "commissioningCustomFlowUrl": "",
+ "commissioningModeInitialStepsHint": 0,
+ "commissioningModeInitialStepsInstruction": "",
+ "commissioningModeSecondaryStepsHint": 0,
+ "commissioningModeSecondaryStepsInstruction": "",
+ "creator": "chip project",
+ "lsfRevision": 0,
+ "lsfUrl": "",
+ "productUrl": "https://github.com/project-chip/connectedhomeip/tree/master/examples/terms-and-conditions-app",
+ "supportUrl": "https://github.com/project-chip/connectedhomeip/",
+ "userManualUrl": "",
+ "enhancedSetupFlowOptions": 1,
+ "enhancedSetupFlowTCUrl": "https://localhost:44538/tc/65521/32785",
+ "enhancedSetupFlowTCRevision": 1,
+ "enhancedSetupFlowTCDigest": "Q+zDGwvJk+tZtBV28b/L4YLw5LIUklk46bTZfyDpJkE=",
+ "enhancedSetupFlowTCFileSize": 2060,
+ "enhancedSetupFlowMaintenanceUrl": ""
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32786.json b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32786.json
new file mode 100644
index 00000000000000..854099117fc670
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65521-32786.json
@@ -0,0 +1,42 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/dcl/model/models/65521/32786",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "model": {
+ "vid": 65521,
+ "pid": 32786,
+ "deviceTypeId": 259,
+ "productName": "TEST_PRODUCT",
+ "productLabel": "Terms and Conditions App",
+ "partNumber": "",
+ "commissioningCustomFlow": 2,
+ "commissioningCustomFlowUrl": "",
+ "commissioningModeInitialStepsHint": 0,
+ "commissioningModeInitialStepsInstruction": "",
+ "commissioningModeSecondaryStepsHint": 0,
+ "commissioningModeSecondaryStepsInstruction": "",
+ "creator": "chip project",
+ "lsfRevision": 0,
+ "lsfUrl": "",
+ "productUrl": "https://github.com/project-chip/connectedhomeip/tree/master/examples/terms-and-conditions-app",
+ "supportUrl": "https://github.com/project-chip/connectedhomeip/",
+ "userManualUrl": "",
+ "enhancedSetupFlowOptions": 1,
+ "enhancedSetupFlowTCUrl": "https://localhost:44538/tc/65521/32786",
+ "enhancedSetupFlowTCRevision": 1,
+ "enhancedSetupFlowTCDigest": "Q+zDGwvJk+tZtBV28b/L4YLw5LIUklk46bTZfyDpJkE=",
+ "enhancedSetupFlowTCFileSize": 2060,
+ "enhancedSetupFlowMaintenanceUrl": ""
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65522-32769.json b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65522-32769.json
new file mode 100644
index 00000000000000..3511e6fb8c7b1c
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_distributed_compliance_ledger/dcl-model-models-65522-32769.json
@@ -0,0 +1,42 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/dcl/model/models/65522/32769",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "model": {
+ "vid": 65522,
+ "pid": 32769,
+ "deviceTypeId": 259,
+ "productName": "TEST_PRODUCT",
+ "productLabel": "Terms and Conditions App",
+ "partNumber": "",
+ "commissioningCustomFlow": 2,
+ "commissioningCustomFlowUrl": "",
+ "commissioningModeInitialStepsHint": 0,
+ "commissioningModeInitialStepsInstruction": "",
+ "commissioningModeSecondaryStepsHint": 0,
+ "commissioningModeSecondaryStepsInstruction": "",
+ "creator": "chip project",
+ "lsfRevision": 0,
+ "lsfUrl": "",
+ "productUrl": "https://github.com/project-chip/connectedhomeip/tree/master/examples/terms-and-conditions-app",
+ "supportUrl": "https://github.com/project-chip/connectedhomeip/",
+ "userManualUrl": "",
+ "enhancedSetupFlowOptions": 1,
+ "enhancedSetupFlowTCUrl": "https://localhost:44538/tc/65522/32769",
+ "enhancedSetupFlowTCRevision": 1,
+ "enhancedSetupFlowTCDigest": "fcqhuEAkq6nNQARcqrGGMcZc4KCFZCfJmrmK6ysd7zo=",
+ "enhancedSetupFlowTCFileSize": 279,
+ "enhancedSetupFlowMaintenanceUrl": ""
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32769-v1.json b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32769-v1.json
new file mode 100644
index 00000000000000..f17028df7583f9
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32769-v1.json
@@ -0,0 +1,105 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/tc/65521/32769",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "schemaVersion": 1,
+ "esfRevision": 1,
+ "defaultCountry": "US",
+ "countryEntries": {
+ "US": {
+ "defaultLanguage": "en",
+ "languageEntries": {
+ "en": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Terms and Conditions",
+ "text": "Feature 1 Text
Please accept these.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Privacy Policy",
+ "text": "Feature 2 Text
"
+ }
+ ],
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Política de privacidad",
+ "text": "Característica 2 Texto
"
+ }
+ ]
+ }
+ },
+ "MX": {
+ "defaultLanguage": "es",
+ "languageEntries": {
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ }
+ ]
+ }
+ },
+ "CN": {
+ "defaultLanguage": "zh",
+ "languageEntries": {
+ "zh": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "条款和条件",
+ "text": "产品1文字
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "隐私条款",
+ "text": "产品2文字
"
+ }
+ ]
+ }
+ },
+ "RU": {
+ "defaultLanguage": "ru",
+ "languageEntries": {
+ "ru": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Условия и положения",
+ "text": "Текст функции 1
Пожалуйста, примите эти условия пользования.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "оложение о конфиденциальности",
+ "text": "Текст функции 2
"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32785-v1.json b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32785-v1.json
new file mode 100644
index 00000000000000..cbd762900b99c9
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32785-v1.json
@@ -0,0 +1,105 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/tc/65521/32785",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "schemaVersion": 1,
+ "esfRevision": 1,
+ "defaultCountry": "US",
+ "countryEntries": {
+ "US": {
+ "defaultLanguage": "en",
+ "languageEntries": {
+ "en": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Terms and Conditions",
+ "text": "Feature 1 Text
Please accept these.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Privacy Policy",
+ "text": "Feature 2 Text
"
+ }
+ ],
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Política de privacidad",
+ "text": "Característica 2 Texto
"
+ }
+ ]
+ }
+ },
+ "MX": {
+ "defaultLanguage": "es",
+ "languageEntries": {
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ }
+ ]
+ }
+ },
+ "CN": {
+ "defaultLanguage": "zh",
+ "languageEntries": {
+ "zh": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "条款和条件",
+ "text": "产品1文字
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "隐私条款",
+ "text": "产品2文字
"
+ }
+ ]
+ }
+ },
+ "RU": {
+ "defaultLanguage": "ru",
+ "languageEntries": {
+ "ru": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Условия и положения",
+ "text": "Текст функции 1
Пожалуйста, примите эти условия пользования.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "оложение о конфиденциальности",
+ "text": "Текст функции 2
"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32786-v1.json b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32786-v1.json
new file mode 100644
index 00000000000000..f2166dd201ed18
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65521-32786-v1.json
@@ -0,0 +1,105 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/tc/65521/32786",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "schemaVersion": 1,
+ "esfRevision": 1,
+ "defaultCountry": "US",
+ "countryEntries": {
+ "US": {
+ "defaultLanguage": "en",
+ "languageEntries": {
+ "en": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Terms and Conditions",
+ "text": "Feature 1 Text
Please accept these.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Privacy Policy",
+ "text": "Feature 2 Text
"
+ }
+ ],
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "Política de privacidad",
+ "text": "Característica 2 Texto
"
+ }
+ ]
+ }
+ },
+ "MX": {
+ "defaultLanguage": "es",
+ "languageEntries": {
+ "es": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Términos y condiciones",
+ "text": "Característica 1 Texto
Por favor acéptelos.
"
+ }
+ ]
+ }
+ },
+ "CN": {
+ "defaultLanguage": "zh",
+ "languageEntries": {
+ "zh": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "条款和条件",
+ "text": "产品1文字
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "隐私条款",
+ "text": "产品2文字
"
+ }
+ ]
+ }
+ },
+ "RU": {
+ "defaultLanguage": "ru",
+ "languageEntries": {
+ "ru": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Условия и положения",
+ "text": "Текст функции 1
Пожалуйста, примите эти условия пользования.
"
+ },
+ {
+ "ordinal": 1,
+ "required": false,
+ "title": "оложение о конфиденциальности",
+ "text": "Текст функции 2
"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65522-32769-v1.json b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65522-32769-v1.json
new file mode 100644
index 00000000000000..a8dd6d541c9791
--- /dev/null
+++ b/integrations/mock_server/configurations/fake_product_server/terms-and-conditions-65522-32769-v1.json
@@ -0,0 +1,34 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/tc/65522/32769",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "schemaVersion": 1,
+ "esfRevision": 1,
+ "defaultCountry": "US",
+ "countryEntries": {
+ "US": {
+ "defaultLanguage": "en",
+ "languageEntries": {
+ "en": [
+ {
+ "ordinal": 0,
+ "required": true,
+ "title": "Terms and Conditions",
+ "text": "Feature 1 Text
Please accept these.
"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/configurations/server_config.json b/integrations/mock_server/configurations/server_config.json
new file mode 100644
index 00000000000000..0967ef424bce67
--- /dev/null
+++ b/integrations/mock_server/configurations/server_config.json
@@ -0,0 +1 @@
+{}
diff --git a/integrations/mock_server/configurations/unittest/config.json b/integrations/mock_server/configurations/unittest/config.json
new file mode 100644
index 00000000000000..019f0909c143fd
--- /dev/null
+++ b/integrations/mock_server/configurations/unittest/config.json
@@ -0,0 +1,17 @@
+{
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/api/data",
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "message": "Hello, World!"
+ }
+ }
+ }
+ ]
+}
diff --git a/integrations/mock_server/src/__init__.py b/integrations/mock_server/src/__init__.py
new file mode 100644
index 00000000000000..1c89d20d346a80
--- /dev/null
+++ b/integrations/mock_server/src/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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.
diff --git a/integrations/mock_server/src/handler.py b/integrations/mock_server/src/handler.py
new file mode 100644
index 00000000000000..076f1ad8650428
--- /dev/null
+++ b/integrations/mock_server/src/handler.py
@@ -0,0 +1,112 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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 http.server
+import json
+import urllib.parse
+from typing import List, Optional, Type
+
+from route_configuration import Configuration, Route
+from router import match_route
+
+
+def createMockServerHandler(config: Configuration) -> Type[http.server.BaseHTTPRequestHandler]:
+ """
+ Creates a custom HTTP request handler class configured with predefined routes and responses.
+
+ This factory function generates a new HTTP request handler class that processes incoming
+ HTTP requests according to the provided routing configuration. The handler supports
+ route matching based on HTTP method, path, and query parameters, returning predefined
+ responses for matched routes.
+
+ Args:
+ config (Configuration): A Configuration object containing route definitions.
+ Each route should specify:
+ - HTTP method (GET, POST, PUT, DELETE)
+ - URL path pattern
+ - Response details (status code, headers, body)
+ - Optional query parameters for matching
+
+ Returns:
+ Type[http.server.BaseHTTPRequestHandler]: A new handler class that:
+ - Processes standard HTTP methods (GET, POST, PUT, DELETE)
+ - Matches incoming requests against configured routes
+ - Returns JSON or plain text responses based on configuration
+ - Provides 404 responses for unmatched routes
+
+ Example Configuration:
+ {
+ "routing": [
+ {
+ "method": "GET",
+ "path": "/api/users",
+ "response": {
+ "status": 200,
+ "headers": {"Content-Type": "application/json"},
+ "body": {"users": []}
+ }
+ }
+ ]
+ }
+
+ Note:
+ - The handler automatically sets appropriate Content-Type headers
+ - JSON responses are automatically encoded to UTF-8
+ - Non-JSON responses are converted to strings before encoding
+ - All responses include standard HTTP headers and status codes
+ """
+
+ class MockServerHandler(http.server.BaseHTTPRequestHandler):
+ def _set_headers(self, status_code=200, headers=None) -> None:
+ self.send_response(status_code)
+ if headers:
+ for key, value in headers.items():
+ self.send_header(key, value)
+ self.end_headers()
+
+ def do_GET(self) -> None:
+ self.handle_request()
+
+ def do_POST(self) -> None:
+ self.handle_request()
+
+ def do_PUT(self) -> None:
+ self.handle_request()
+
+ def do_DELETE(self) -> None:
+ self.handle_request()
+
+ def handle_request(self) -> None:
+ parsed_path: urllib.parse.ParseResult = urllib.parse.urlparse(self.path)
+ path: str = parsed_path.path
+ query_params: dict[str, list[str]] = urllib.parse.parse_qs(parsed_path.query)
+
+ # Find the matching route from the configuration
+ routes: List[Route] = config.routing
+ route: Optional[Route] = match_route(routes, self.command, path, query_params)
+
+ if not route:
+ # No matching route found; return a 404 error response
+ self._set_headers(404, {"Content-Type": "application/json"})
+ self.wfile.write(json.dumps({}).encode("utf-8"))
+ return
+
+ # Use the static response defined in the configuration
+ self._set_headers(route.response.status, route.response.headers)
+ if route.response.headers.get("Content-Type") == "application/json":
+ self.wfile.write(json.dumps(route.response.body).encode("utf-8"))
+ else:
+ self.wfile.write(str(route.response.body).encode("utf-8"))
+
+ return MockServerHandler
diff --git a/integrations/mock_server/src/main.py b/integrations/mock_server/src/main.py
new file mode 100644
index 00000000000000..d5ffb24989ccf5
--- /dev/null
+++ b/integrations/mock_server/src/main.py
@@ -0,0 +1,83 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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 argparse
+from pathlib import Path
+
+from server import run_server
+
+"""
+Mock HTTP/HTTPS Server for API Testing
+
+This module serves as the entry point for a configurable mock HTTPS server designed
+for API testing. It provides a secure, flexible way to simulate API endpoints with
+configurable responses.
+
+The server supports:
+- Custom routing configurations via JSON files
+- HTTPS with TLS certificate support
+- Configurable port binding
+- Multiple concurrent connections
+- Dynamic response configuration
+
+Usage:
+ python main.py [--port PORT] [--config CONFIG_FILE]
+ [--routing-config-dir ROUTE_DIR]
+ [--cert CERT_FILE] [--key KEY_FILE]
+
+Arguments:
+ --port PORT Port number to listen on (default: 8443)
+ --config CONFIG_FILE Path to main configuration file (default: config.json)
+ --routing-config-dir DIR Directory containing route configurations
+ (default: config.json)
+ --cert CERT_FILE Path to SSL certificate file (default: server.crt)
+ --key KEY_FILE Path to SSL private key file (default: server.key)
+
+Example:
+ python main.py --port 8443 --config ./config/main.json
+ --routing-config-dir ./config/routes
+ --cert ./certs/server.crt --key ./certs/server.key
+
+Configuration:
+ The server requires two types of configuration:
+ 1. Main configuration file (--config):
+ Contains server-wide settings and default behaviors
+
+ 2. Route configuration directory (--routing-config-dir):
+ Contains JSON files defining endpoint behaviors, responses,
+ and matching criteria
+
+Security:
+ - HTTPS only, no HTTP support
+ - Requires valid SSL certificate and private key
+ - TLS configuration uses server's default security settings
+
+Notes:
+ - Server runs until interrupted (Ctrl+C)
+ - All endpoints return JSON by default
+ - Logs to stdout with DEBUG level
+ - Supports concurrent requests via threading
+"""
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Mock HTTP/HTTPS Server for API testing")
+ parser.add_argument("--port", type=int, default=8443, help="Set the server port")
+ parser.add_argument("--config", type=str, default="config.json", help="Path to the common config file")
+ parser.add_argument("--routing-config-dir", type=str, default="config.json", help="Path to the common config file")
+ parser.add_argument("--cert", type=str, default="server.crt", help="SSL Certificate file")
+ parser.add_argument("--key", type=str, default="server.key", help="SSL Private Key file")
+
+ args = parser.parse_args()
+ run_server(args.port, Path(args.config), Path(args.routing_config_dir), Path(args.cert), Path(args.key))
diff --git a/integrations/mock_server/src/route_configuration.py b/integrations/mock_server/src/route_configuration.py
new file mode 100644
index 00000000000000..6a885d63b0c973
--- /dev/null
+++ b/integrations/mock_server/src/route_configuration.py
@@ -0,0 +1,181 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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 json
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class RouteResponse:
+ status: int
+ headers: Dict[str, str]
+ body: Dict[str, Any]
+
+
+@dataclass
+class QueryConfig:
+ params: Dict[str, Any]
+
+
+@dataclass
+class Route:
+ method: str
+ path: str
+ response: RouteResponse
+ body: Optional[Any] = None
+ query: Optional[QueryConfig] = None
+
+
+@dataclass
+class Configuration:
+ routing: List[Route] = field(default_factory=list)
+
+
+def load_configurations(config_path: Path, routing_config_dir: Path) -> Configuration:
+ """
+ Load and combine configuration files from specified paths.
+
+ Args:
+ config_path (Path): Path to the main configuration file.
+ routing_config_dir (Path): Directory containing routing configuration files.
+
+ Returns:
+ Configuration: A Configuration object containing all merged routing configurations.
+
+ Raises:
+ ValueError: If config_path is not a file or routing_config_dir is not a directory.
+ """
+ if not config_path.is_file():
+ raise ValueError(f"'{config_path}' is not a file")
+
+ if not routing_config_dir.is_dir():
+ raise ValueError(f"'{routing_config_dir}' is not a directory")
+
+ # Load routing configuration
+ routing_config: List[Route] = load_routing_configuration_dir(routing_config_dir)
+
+ # Create and return the Configuration object
+ return Configuration(routing=routing_config)
+
+
+def load_routing_configuration_dir(directory: Path) -> List[Route]:
+ """
+ Load and merge all routing configuration files from a specified directory.
+
+ Args:
+ directory (Path): Directory path containing JSON configuration files.
+
+ Returns:
+ List[Route]: A list of Route objects containing all merged routing configurations.
+
+ Raises:
+ ValueError: If the specified directory path is not a directory.
+
+ Note:
+ - Processes all .json files in the specified directory
+ - Continues processing even if one file fails to load, logging the error
+ """
+ if not directory.is_dir():
+ raise ValueError(f"'{directory}' is not a directory")
+
+ all_routes: List[Route] = []
+
+ # Process all JSON files in the directory
+ for file_path in directory.glob("*.json"):
+ try:
+ routes = load_routing_configuration_file(file_path)
+ all_routes.extend(routes)
+ except Exception as e:
+ print(f"Error loading configuration from {file_path}: {str(e)}")
+ continue
+
+ return all_routes
+
+
+def load_routing_configuration_file(file_path: Path) -> List[Route]:
+ """
+ Load and parse a single routing configuration JSON file.
+
+ Args:
+ file_path (Path): Path to the JSON configuration file.
+
+ Returns:
+ List[Route]: A list of Route objects parsed from the configuration file.
+
+ Raises:
+ FileNotFoundError: If the configuration file doesn't exist.
+ json.JSONDecodeError: If the file contains invalid JSON.
+ KeyError: If the required 'routes' key is missing in the configuration.
+
+ Format:
+ The JSON file should contain:
+ {
+ "routes": [
+ {
+ "method": str,
+ "path": str,
+ "response": {
+ "status": int,
+ "headers": dict[str, str],
+ "body": dict[str, Any]
+ },
+ "body": Any, # Optional
+ "query": dict[str, Any] # Optional
+ }
+ ]
+ }
+ """
+ try:
+ with open(file_path, "r") as file:
+ config = json.load(file)
+ if "routes" not in config:
+ logging.error("Missing required 'routes' key in configuration file: %s", file_path)
+ raise KeyError("Configuration file must contain 'routes' key")
+
+ logging.debug("Routes configuration loaded successfully from %s", file_path)
+ routes = []
+ for route_config in config["routes"]:
+ # Create RouteResponse object
+ response = RouteResponse(
+ status=route_config["response"]["status"],
+ headers=route_config["response"].get("headers", {}),
+ body=route_config["response"].get("body", {}),
+ )
+
+ # Create QueryConfig if query params exist
+ query = None
+ if "query" in route_config:
+ query = QueryConfig(params=route_config["query"])
+
+ # Create Route object
+ route = Route(
+ method=route_config["method"],
+ path=route_config["path"],
+ response=response,
+ body=route_config.get("body"),
+ query=query,
+ )
+ routes.append(route)
+
+ return routes
+
+ except FileNotFoundError:
+ logging.error("Configuration file not found: %s", file_path)
+ raise
+ except json.JSONDecodeError as e:
+ logging.error("Invalid JSON in configuration file: %s", file_path)
+ raise e
diff --git a/integrations/mock_server/src/router.py b/integrations/mock_server/src/router.py
new file mode 100644
index 00000000000000..429d7699a54ffc
--- /dev/null
+++ b/integrations/mock_server/src/router.py
@@ -0,0 +1,106 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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.
+
+from typing import Any, Dict, List, Optional
+from urllib.parse import parse_qs
+
+from route_configuration import Route
+
+
+def match_route(routing: List[Route], method: str, path: str, query: Optional[Dict[str, Any] | str] = None) -> Optional[Route]:
+ """
+ Finds the best matching route configuration for an incoming HTTP request.
+
+ Evaluates incoming requests against a list of routes based on three criteria
+ in order of priority:
+ 1. HTTP method (e.g., GET, POST, PUT, DELETE)
+ 2. URL path pattern (including wildcard support)
+ 3. Query parameters (if specified in route configuration)
+
+ Args:
+ routing (List[Route]): List of available route configurations
+ method (str): HTTP method from the incoming request
+ path (str): URL path from the incoming request
+ query (Optional[Dict[str, Any] | str]): Query parameters from the request,
+ either as a dictionary or URL-encoded string. Defaults to None.
+
+ Returns:
+ Optional[Route]: The first matching Route object that satisfies all criteria,
+ or None if no match is found. When multiple routes match, returns the
+ first matching route in the original routing list order.
+
+ Examples:
+ # Available routes
+ routes = [
+ Route(method="GET", path="/api/device/*", query=None),
+ Route(method="GET", path="/api/device/status", query={"type": "sensor"})
+ ]
+
+ # These would match:
+ match_route(routes, "GET", "/api/device/123") # Returns first route
+ match_route(routes, "GET", "/api/device/status", {"type": "sensor"}) # Returns second route
+
+ # These would return None:
+ match_route(routes, "POST", "/api/device/123")
+ match_route(routes, "GET", "/api/other")
+
+ Note:
+ - Routes are evaluated in order, returning the first match
+ - Wildcard paths (ending in *) match any path with the specified prefix
+ - Query parameters must match exactly if specified in the route
+ - Method matching is case-sensitive
+ - Path matching is case-sensitive
+ - Query strings are automatically parsed into dictionaries
+ """
+
+ # Filter routes to only those matching the HTTP method
+ method_routes: List[Route] = []
+ for route in routing:
+ if route.method == method:
+ method_routes.append(route)
+ if not method_routes:
+ return None
+
+ # Filter routes to only those matching the path
+ path_routes: List[Route] = []
+ for route in method_routes:
+ if route.path == path:
+ path_routes.append(route)
+ elif route.path.endswith("*"):
+ # Handle wildcard paths
+ if path.startswith(route.path[:-1]):
+ path_routes.append(route)
+ if not path_routes:
+ return None
+
+ # Parse query parameters if present
+ query_params = {}
+ if query:
+ if isinstance(query, str):
+ # parse_qs returns values as lists, we'll take the first value for each parameter
+ parsed = parse_qs(query)
+ query_params = {k: v[0] if v else "" for k, v in parsed.items()}
+ else:
+ query_params = query
+
+ # Find the first route that matches the path and query parameters
+ for route in path_routes:
+ if not route.query:
+ return route
+
+ # Check if all required query parameters are present
+ if all(param in query_params for param in route.query.params):
+ return route
+
+ return None
diff --git a/integrations/mock_server/src/server.py b/integrations/mock_server/src/server.py
new file mode 100644
index 00000000000000..b6a7d090d79ec6
--- /dev/null
+++ b/integrations/mock_server/src/server.py
@@ -0,0 +1,97 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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 http.server
+import logging
+import socketserver
+import ssl
+from pathlib import Path
+
+from handler import createMockServerHandler
+from route_configuration import Configuration, load_configurations
+
+
+def run_server(port: int, config_path: Path, routing_config_dir: Path, cert_path: Path, key_path: Path) -> None:
+ """
+ Starts a secure HTTPS server with mock endpoints defined by configuration files.
+
+ Initializes and runs a threaded HTTPS server that handles requests according to
+ configured routes. The server uses TLS for secure communication and supports
+ multiple concurrent connections.
+
+ Args:
+ port (int): Port number on which the server will listen
+ config_path (Path): Path to the main configuration file
+ routing_config_dir (Path): Directory containing additional routing configuration files
+ cert_path (Path): Path to the SSL/TLS certificate file
+ key_path (Path): Path to the SSL/TLS private key file
+
+ Returns:
+ None
+
+ Raises:
+ ssl.SSLError: If there are issues with the SSL/TLS certificate or key
+ OSError: If the port is already in use or permission is denied
+ ValueError: If configuration files are invalid or missing
+ KeyboardInterrupt: When the server is stopped using Ctrl+C
+
+ Example:
+ run_server(
+ port=8443,
+ config_path=Path("config/main.json"),
+ routing_config_dir=Path("config/routes"),
+ cert_path=Path("certs/server.crt"),
+ key_path=Path("certs/server.key")
+ )
+
+ Note:
+ - Server runs until interrupted by keyboard (Ctrl+C)
+ - Logs are written to stdout with DEBUG level
+ - Server binds to all available network interfaces ("")
+ - Uses ThreadingHTTPServer for concurrent request handling
+ - TLS configuration uses server's default security settings
+ - All endpoints are HTTPS-only
+ """
+
+ logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(message)s")
+
+ if not config_path.is_file():
+ raise ValueError(f"'{config_path}' is not a file")
+
+ if not routing_config_dir.is_dir():
+ raise ValueError(f"'{routing_config_dir}' is not a directory")
+
+ if not cert_path.is_file():
+ raise ValueError(f"'{cert_path}' is not a file")
+
+ if not key_path.is_file():
+ raise ValueError(f"'{key_path}' is not a file")
+
+ config: Configuration = load_configurations(config_path, routing_config_dir)
+ server_address: socketserver._AfInetAddress = ("", port)
+ context: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(certfile=cert_path, keyfile=key_path)
+
+ theMockServerHandler = createMockServerHandler(config)
+ httpd = http.server.ThreadingHTTPServer(server_address, theMockServerHandler)
+
+ logging.info("Server starting on port %s", port)
+ with context.wrap_socket(httpd.socket, server_side=True) as httpd.socket:
+ logging.info("Server started on port %s", port)
+ logging.info("HTTPS enabled with cert: %s and key: %s", cert_path, key_path)
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ logging.info("Server is shutting down due to keyboard interrupt.")
+ httpd.server_close()
diff --git a/integrations/mock_server/tests/__init__.py b/integrations/mock_server/tests/__init__.py
new file mode 100644
index 00000000000000..1c89d20d346a80
--- /dev/null
+++ b/integrations/mock_server/tests/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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.
diff --git a/integrations/mock_server/tests/test_mock_server.py b/integrations/mock_server/tests/test_mock_server.py
new file mode 100644
index 00000000000000..5b27f5a11c21ca
--- /dev/null
+++ b/integrations/mock_server/tests/test_mock_server.py
@@ -0,0 +1,151 @@
+# Copyright (c) 2025 Project CHIP Authors
+#
+# 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 json
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+from typing import List
+
+from route_configuration import QueryConfig, Route, RouteResponse, load_configurations
+from router import match_route
+
+
+class TestConfigLoader(unittest.TestCase):
+ def setUp(self):
+ # Create a temporary directory for our test files
+ self.test_dir = tempfile.mkdtemp()
+ self.config_dir = Path(self.test_dir) / "routes"
+ self.config_dir.mkdir()
+
+ # Create an empty config.json
+ self.empty_config = Path(self.test_dir) / "config.json"
+ self.empty_config.write_text("{}")
+
+ # Create two route configuration files
+ simple_route = {
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/test",
+ "response": {
+ "status": 200,
+ "headers": {"Content-Type": "application/json"},
+ "body": {"message": "ok"},
+ },
+ }
+ ]
+ }
+
+ route_with_query = {
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/api/data",
+ "query": {},
+ "response": {
+ "status": 200,
+ "headers": {"Content-Type": "application/json"},
+ "body": {"version": "1.0", "data": "sample"},
+ },
+ }
+ ]
+ }
+
+ # Write the route configurations to files
+ (self.config_dir / "simple_route.json").write_text(json.dumps(simple_route))
+ (self.config_dir / "data_route.json").write_text(json.dumps(route_with_query))
+
+ def tearDown(self):
+ # Clean up temporary files and directory
+ shutil.rmtree(self.test_dir)
+
+ def test_load_valid_config(self):
+ # Test loading empty config file
+ loaded_config = load_configurations(self.empty_config, self.config_dir)
+
+ # Verify we loaded both routes
+ self.assertEqual(len(loaded_config.routing), 2)
+
+ # Verify simple route
+ simple_route = match_route(loaded_config.routing, "GET", "/test", None)
+ self.assertIsNotNone(simple_route)
+ self.assertIsNotNone(simple_route.response) # type: ignore
+ self.assertEqual(simple_route.response.status, 200) # type: ignore
+ self.assertEqual(simple_route.response.body["message"], "ok") # type: ignore
+
+ # Verify route with query parameters
+ query_route = match_route(loaded_config.routing, "GET", "/api/data", "{}")
+ self.assertIsNotNone(query_route)
+ self.assertEqual(query_route.response.status, 200) # type: ignore
+ self.assertEqual(query_route.response.body["version"], "1.0") # type: ignore
+
+
+class TestRouter(unittest.TestCase):
+ routes: List[Route] = []
+
+ def setUp(self):
+ self.routes = [
+ Route(
+ method="GET",
+ path="/api/data",
+ response=RouteResponse(status=200, headers={}, body={"message": "Hello, World!"}),
+ ),
+ Route(
+ method="GET",
+ path="/api/query",
+ query=QueryConfig(params={"key": "value"}),
+ response=RouteResponse(status=200, headers={}, body={"message": "Query matched"}),
+ ),
+ Route(
+ method="GET",
+ path="/api/*",
+ response=RouteResponse(status=200, headers={}, body={"message": "Wildcard matched"}),
+ ),
+ Route(
+ method="POST",
+ path="/api/echo",
+ body={"regex": ".*hello.*"},
+ response=RouteResponse(status=200, headers={}, body={"message": "Echo"}),
+ ),
+ ]
+
+ def test_exact_route_match(self):
+ route = match_route(self.routes, "GET", "/api/data", {})
+ self.assertIsNotNone(route)
+ self.assertEqual(route.response.body, {"message": "Hello, World!"}) # type: ignore
+
+ def test_wildcard_route_match(self):
+ route = match_route(self.routes, "GET", "/api/anything", {})
+ self.assertIsNotNone(route)
+ self.assertEqual(route.response.body, {"message": "Wildcard matched"}) # type: ignore
+
+ def test_query_parameter_matching(self):
+ route = match_route(self.routes, "GET", "/api/query", {"key": ["value"]})
+ self.assertIsNotNone(route)
+ self.assertEqual(route.response.body, {"message": "Query matched"}) # type: ignore
+
+ def test_body_regex_match(self):
+ route = match_route(self.routes, "POST", "/api/echo", "This is a hello message")
+ self.assertIsNotNone(route)
+ self.assertEqual(route.response.body, {"message": "Echo"}) # type: ignore
+
+ def test_no_match(self):
+ route = match_route(self.routes, "DELETE", "/nonexistent", {})
+ self.assertIsNone(route)
+
+
+if __name__ == "__main__":
+ unittest.main()