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()