blob: 79abbe433f9000e8bf7d9aae962d414da0a0229d [file] [log] [blame]
# 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
log = logging.getLogger(__name__)
@dataclass
class RouteResponse:
status: int
headers: Dict[str, str]
body: Any # Dict for inline JSON, or bytes for $ref content (raw file bytes)
@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 resolve_ref(body: Dict[str, Any], base_path: Path) -> bytes:
"""
Resolve $ref reference and return raw file content.
Args:
body (Dict[str, Any]): The body object containing a $ref key.
base_path (Path): The directory path to resolve relative references from.
Returns:
bytes: The raw file content as bytes.
Example:
body = {"$ref": "../static/tc-65521-32769-v1.json"}
content = resolve_ref(body, Path("configurations/fake_product_server"))
# Returns raw contents of configurations/static/tc-65521-32769-v1.json
"""
ref_path_str = body["$ref"]
ref_path = (base_path / ref_path_str).resolve()
# To prevent path traversal, ensure the resolved path is within the parent directory of the config file.
config_root = base_path.parent.resolve()
try:
ref_path.relative_to(config_root)
except ValueError:
log.error("Path traversal attempt in $ref: '%s' resolves to a path outside of '%s'", ref_path_str, config_root)
raise ValueError("Invalid $ref path: path traversal is not allowed.")
log.debug("Resolving $ref: %s -> %s", ref_path_str, ref_path)
try:
with open(ref_path, "rb") as ref_file:
return ref_file.read()
except FileNotFoundError:
log.error("Referenced file not found: %s", ref_path)
raise
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] | {"$ref": "path/to/file.json"}
},
"body": Any, # Optional
"query": dict[str, Any] # Optional
}
]
}
Note:
The "body" field in "response" can contain a "$ref" key pointing to an
external JSON file. The reference is resolved relative to the config file's
directory. This allows response bodies to be stored as separate static files
that can be hosted independently (e.g., on GitHub for DCL references).
"""
try:
with open(file_path, "r") as file:
config = json.load(file)
if "routes" not in config:
log.error("Missing required 'routes' key in configuration file: %s", file_path)
raise KeyError("Configuration file must contain 'routes' key")
log.debug("Routes configuration loaded successfully from %s", file_path)
routes = []
base_path = file_path.parent
for route_config in config["routes"]:
# Get the body, resolving $ref if present
body = route_config["response"].get("body", {})
if isinstance(body, dict) and "$ref" in body:
# $ref: load raw file content as bytes (preserves exact bytes for hash verification)
resolved_body = resolve_ref(body, base_path)
else:
# Inline body: keep as-is (dict or other)
resolved_body = body
# Create RouteResponse object
response = RouteResponse(
status=route_config["response"]["status"],
headers=route_config["response"].get("headers", {}),
body=resolved_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:
log.error("Configuration file not found: %s", file_path)
raise
except json.JSONDecodeError as e:
log.error("Invalid JSON in configuration file: %s", file_path)
raise e