From b8f14ad1db2bac935aa0986bca18755fa3ef0b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikit=D0=B0=20Tsvetk=D0=BEv?= Date: Mon, 14 Oct 2024 20:58:52 +0400 Subject: [PATCH] Add spec generator (#2) --- .github/workflows/publish.yml | 8 +- .github/workflows/test.yml | 9 +- CHANGELOG.md | 8 +- README.md | 2 +- codecov.yml | 2 +- requirements-dev.txt | 15 +- requirements.txt | 1 + setup.py | 6 + tests/spec_generator/__init__.py | 0 tests/spec_generator/test_utils.py | 30 ++++ vedro_httpx/__main__.py | 46 ++++++ vedro_httpx/_async_http_interface.py | 13 +- vedro_httpx/_sync_http_interface.py | 13 +- vedro_httpx/recorder/_async_har_formatter.py | 3 + vedro_httpx/recorder/_har_builder.py | 5 +- vedro_httpx/recorder/_sync_har_formatter.py | 3 + vedro_httpx/recorder/har/__init__.py | 2 + vedro_httpx/spec_generator/__init__.py | 5 + .../spec_generator/_api_spec_builder.py | 103 +++++++++++++ vedro_httpx/spec_generator/_har_reader.py | 57 ++++++++ .../_open_api_spec_generator.py | 135 ++++++++++++++++++ vedro_httpx/spec_generator/_utils.py | 35 +++++ 22 files changed, 479 insertions(+), 22 deletions(-) create mode 100644 tests/spec_generator/__init__.py create mode 100644 tests/spec_generator/test_utils.py create mode 100644 vedro_httpx/__main__.py create mode 100644 vedro_httpx/spec_generator/__init__.py create mode 100644 vedro_httpx/spec_generator/_api_spec_builder.py create mode 100644 vedro_httpx/spec_generator/_har_reader.py create mode 100644 vedro_httpx/spec_generator/_open_api_spec_generator.py create mode 100644 vedro_httpx/spec_generator/_utils.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9669f4b..d5a4762 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Build run: make build - name: Publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c6f721..2a4e748 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,10 +21,10 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -34,6 +34,7 @@ jobs: - name: Test run: make coverage - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af119b..52b7655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ # Changelog +## v0.5.0 (2024-10-14) + +- Add spec generator [#2](https://github.com/vedro-universe/vedro-httpx/pull/2) + ## v0.4.0 (2024-04-07) -- Add request recording [#1](https://github.com/vedro-universe/vedro-httpx/pull/1) +- Add request recording feature [#1](https://github.com/vedro-universe/vedro-httpx/pull/1) ## v0.3.0 (2023-07-28) -- Add render width [#a56356b](https://github.com/vedro-universe/vedro-httpx/commit/a56356b089522f6381018bad9ead0f9f5226d939) +- Added functionality to control the rendering width of HTTP responses [#a56356b](https://github.com/vedro-universe/vedro-httpx/commit/a56356b089522f6381018bad9ead0f9f5226d939) diff --git a/README.md b/README.md index bda1b85..61b37db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # vedro-httpx -[![Codecov](https://img.shields.io/codecov/c/github/vedro-universe/vedro-httpx/master.svg?style=flat-square)](https://codecov.io/gh/vedro-universe/vedro-httpx) +[![Codecov](https://img.shields.io/codecov/c/github/vedro-universe/vedro-httpx/main.svg?style=flat-square)](https://codecov.io/gh/vedro-universe/vedro-httpx) [![PyPI](https://img.shields.io/pypi/v/vedro-httpx.svg?style=flat-square)](https://pypi.python.org/pypi/vedro-httpx/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/vedro-httpx?style=flat-square)](https://pypi.python.org/pypi/vedro-httpx/) [![Python Version](https://img.shields.io/pypi/pyversions/vedro-httpx.svg?style=flat-square)](https://pypi.python.org/pypi/vedro-httpx/) diff --git a/codecov.yml b/codecov.yml index 81d2dbd..0ab12f6 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,7 +2,7 @@ coverage: status: project: default: - threshold: 5% + threshold: 15% patch: default: threshold: 100% diff --git a/requirements-dev.txt b/requirements-dev.txt index 1230fcc..afdd469 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,13 +1,14 @@ -baby-steps==1.3.0 +baby-steps==1.3.1 bump2version==1.0.1 codecov==2.1.13 -coverage==7.4.4 -flake8==7.0.0 +coverage==7.6.1 +flake8==7.1.1 isort==5.13.2 -mypy==1.9.0 -pytest==8.1.1 -pytest-asyncio==0.23.6 +mypy==1.12.0 +pytest==8.3.3 +pytest-asyncio==0.24.0 pytest-clarity==1.0.1 pytest-cov==5.0.0 -types-Pygments==2.17.0.20240310 +types-Pygments==2.18.0.20240506 +types-PyYAML==6.0.12.20240917 respx==0.21.1 diff --git a/requirements.txt b/requirements.txt index 3cbc0a4..75b8ef6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ httpx>=0.24,<1.0 vedro>=1.7,<2.0 +PyYAML>6.0,<7.0 diff --git a/setup.py b/setup.py index 51d0718..42d8cb6 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,12 @@ def find_dev_required(): license="Apache-2.0", packages=find_packages(exclude=["tests", "tests.*"]), package_data={"vedro_httpx": ["py.typed"]}, + entry_points={ + "console_scripts": [ + "vedro-httpx = vedro_httpx.__main__:main", + "vedro_httpx = vedro_httpx.__main__:main", + ] + }, install_requires=find_required(), tests_require=find_dev_required(), classifiers=[ diff --git a/tests/spec_generator/__init__.py b/tests/spec_generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/spec_generator/test_utils.py b/tests/spec_generator/test_utils.py new file mode 100644 index 0000000..5f959ff --- /dev/null +++ b/tests/spec_generator/test_utils.py @@ -0,0 +1,30 @@ +import pytest +from baby_steps import given, then, when + +from vedro_httpx.spec_generator._utils import humanize_identifier + + +@pytest.mark.parametrize(("name", "expected"), [ + ("", ""), + ("client id", "Client id"), + ("client_id", "Client id"), + ("_client_id_", "Client id"), + ("clientId", "Client id"), + ("ClientName", "Client name"), + ("clientHTTPStatus", "Client http status"), + ("HTTPResponse", "Http response"), + ("StatusOK", "Status ok"), + ("version2Name", "Version2 name"), + ("CLIENT_ID", "Client id"), + ("singleA", "Single a"), + ("X-Client-ID", "X client id"), +]) +def test_humanize_identifier(name: str, expected: str): + with given: + name = name + + with when: + result = humanize_identifier(name) + + with then: + assert result == expected diff --git a/vedro_httpx/__main__.py b/vedro_httpx/__main__.py new file mode 100644 index 0000000..f2c7008 --- /dev/null +++ b/vedro_httpx/__main__.py @@ -0,0 +1,46 @@ +import sys + +from vedro_httpx.spec_generator import APISpecBuilder, HARReader, OpenAPISpecGenerator + + +def generate_spec(har_directory: str) -> str: + """ + Generate an OpenAPI specification from HAR files in a specified directory. + + This function reads all HAR files from the provided directory, extracts + HTTP request and response data, builds an API specification, and converts + it into an OpenAPI specification format. + + :param har_directory: The directory path where HAR files are located. + :return: The generated OpenAPI specification as a YAML string. + """ + har_reader = HARReader(har_directory) + entries = har_reader.get_entries() + + api_spec_builder = APISpecBuilder() + api_spec = api_spec_builder.build_spec(entries) + + open_api_spec_generator = OpenAPISpecGenerator() + open_api_spec = open_api_spec_generator.generate_spec(api_spec) + + return open_api_spec + + +def main() -> None: + """ + Main entry point of the script. + + This function checks for the correct usage of the script by verifying + the command-line arguments, generates an OpenAPI specification from the + HAR directory provided as input, and prints the result. + """ + if len(sys.argv) != 2: + print("Usage: vedro-httpx ") + sys.exit(1) + + open_api_spec = generate_spec(sys.argv[1]) + print(open_api_spec) + + +if __name__ == "__main__": + main() diff --git a/vedro_httpx/_async_http_interface.py b/vedro_httpx/_async_http_interface.py index e60f9ea..ac2a848 100644 --- a/vedro_httpx/_async_http_interface.py +++ b/vedro_httpx/_async_http_interface.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Optional, Union, cast +from typing import Any, Dict, Optional, Union, cast import vedro from httpx import AsyncClient as _AsyncClient @@ -101,6 +101,7 @@ async def _request(self, follow_redirects: Union[bool, UseClientDefault] = USE_CLIENT_DEFAULT, timeout: Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT, extensions: Optional[RequestExtensions] = None, + segments: Optional[Dict[str, Any]] = None, **kwargs: Any ) -> Response: """ @@ -126,6 +127,16 @@ async def _request(self, :param kwargs: Additional keyword arguments to pass to the request method. :return: A `Response` object containing the server's response to the HTTP request. """ + if segments: + if extensions is None: + extensions = {} + + parameterized_url = str(url) + if self._base_url: + parameterized_url = str(self._base_url).lstrip("/") + parameterized_url + extensions["vedro_httpx_parameterized_url"] = parameterized_url + url = str(url).format(**segments) + async with self._client() as client: return cast(Response, await client.request( method=method, diff --git a/vedro_httpx/_sync_http_interface.py b/vedro_httpx/_sync_http_interface.py index 69e8d86..39fcedd 100644 --- a/vedro_httpx/_sync_http_interface.py +++ b/vedro_httpx/_sync_http_interface.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Optional, Union, cast +from typing import Any, Dict, Optional, Union, cast import vedro from httpx import Client as _SyncClient @@ -100,6 +100,7 @@ def _request(self, follow_redirects: Union[bool, UseClientDefault] = USE_CLIENT_DEFAULT, timeout: Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT, extensions: Optional[RequestExtensions] = None, + segments: Optional[Dict[str, Any]] = None, **kwargs: Any ) -> Response: """ @@ -125,6 +126,16 @@ def _request(self, :param kwargs: Additional keyword arguments to pass to the request method. :return: A `Response` object containing the server's response to the HTTP request. """ + if segments: + if extensions is None: + extensions = {} + + parameterized_url = str(url) + if self._base_url: + parameterized_url = str(self._base_url).lstrip("/") + parameterized_url + extensions["vedro_httpx_parameterized_url"] = parameterized_url + url = str(url).format(**segments) + with self._client() as client: return cast(Response, client.request( method=method, diff --git a/vedro_httpx/recorder/_async_har_formatter.py b/vedro_httpx/recorder/_async_har_formatter.py index 239f2f3..bd4e9f6 100644 --- a/vedro_httpx/recorder/_async_har_formatter.py +++ b/vedro_httpx/recorder/_async_har_formatter.py @@ -67,6 +67,8 @@ async def format_request(self, request: httpx.Request, *, else: post_data = None + parameterized_url = request.extensions.get("vedro_httpx_parameterized_url", None) + return self._builder.build_request( method=request.method, url=str(request.url), @@ -75,6 +77,7 @@ async def format_request(self, request: httpx.Request, *, headers=self._format_headers(request.headers), cookies=self._format_cookies(self._get_request_cookies(request.headers)), post_data=post_data, + parameterized_url=parameterized_url, ) async def format_response(self, response: httpx.Response) -> har.Response: diff --git a/vedro_httpx/recorder/_har_builder.py b/vedro_httpx/recorder/_har_builder.py index 09b6070..db72ae8 100644 --- a/vedro_httpx/recorder/_har_builder.py +++ b/vedro_httpx/recorder/_har_builder.py @@ -100,7 +100,8 @@ def build_request(self, query_string: List[har.QueryParam], headers: List[har.Header], cookies: List[har.Cookie], - post_data: Optional[har.PostData] = None) -> har.Request: + post_data: Optional[har.PostData] = None, + parameterized_url: Optional[str] = None) -> har.Request: """ Construct a request component for a HAR entry. @@ -125,6 +126,8 @@ def build_request(self, } if post_data is not None: request["postData"] = post_data + if parameterized_url is not None: + request["_parameterized_url"] = parameterized_url return request def build_query_param(self, name: str, value: str) -> har.QueryParam: diff --git a/vedro_httpx/recorder/_sync_har_formatter.py b/vedro_httpx/recorder/_sync_har_formatter.py index 91a1de2..7791772 100644 --- a/vedro_httpx/recorder/_sync_har_formatter.py +++ b/vedro_httpx/recorder/_sync_har_formatter.py @@ -67,6 +67,8 @@ def format_request(self, request: httpx.Request, *, else: post_data = None + parameterized_url = request.extensions.get("vedro_httpx_parameterized_url", None) + return self._builder.build_request( method=request.method, url=str(request.url), @@ -75,6 +77,7 @@ def format_request(self, request: httpx.Request, *, headers=self._format_headers(request.headers), cookies=self._format_cookies(self._get_request_cookies(request.headers)), post_data=post_data, + parameterized_url=parameterized_url, ) def format_response(self, response: httpx.Response) -> har.Response: diff --git a/vedro_httpx/recorder/har/__init__.py b/vedro_httpx/recorder/har/__init__.py index d425245..226b9da 100644 --- a/vedro_httpx/recorder/har/__init__.py +++ b/vedro_httpx/recorder/har/__init__.py @@ -178,6 +178,8 @@ class Request(TypedDict): # Request method (GET, POST, ...). method: str + _parameterized_url: NotRequired[str] + # Absolute URL of the request (fragments are not included). url: str diff --git a/vedro_httpx/spec_generator/__init__.py b/vedro_httpx/spec_generator/__init__.py new file mode 100644 index 0000000..c20ff5e --- /dev/null +++ b/vedro_httpx/spec_generator/__init__.py @@ -0,0 +1,5 @@ +from ._api_spec_builder import APISpecBuilder +from ._har_reader import HARReader +from ._open_api_spec_generator import OpenAPISpecGenerator + +__all__ = ("OpenAPISpecGenerator", "APISpecBuilder", "HARReader",) diff --git a/vedro_httpx/spec_generator/_api_spec_builder.py b/vedro_httpx/spec_generator/_api_spec_builder.py new file mode 100644 index 0000000..8265341 --- /dev/null +++ b/vedro_httpx/spec_generator/_api_spec_builder.py @@ -0,0 +1,103 @@ +from typing import Any, Dict, List, Set, Tuple +from urllib.parse import urlparse + +import vedro_httpx.recorder.har as har + +__all__ = ("APISpecBuilder",) + + +class APISpecBuilder: + """ + Builds a structured API specification from a list of HAR entries. + + This class processes HTTP request and response data from HAR entries, + organizing them into an API specification format suitable for further + documentation or testing. + """ + + def build_spec(self, entries: List[har.Entry]) -> Dict[str, Any]: + """ + Build an API specification from the given HAR entries. + + :param entries: A list of HAR entries containing HTTP request and response data. + :return: A dictionary representing the API specification. + """ + urls = {self._get_url(entry["request"]) for entry in entries} + base_path = self._get_base_path(urls) + + spec: Dict[str, Any] = { + base_path: {} + } + + for entry in entries: + method, url = entry["request"]["method"], self._get_url(entry["request"]) + path = url[len(base_path):] + + route, details = self._create_route(method, path) + if route not in spec[base_path]: + spec[base_path][route] = details + + spec[base_path][route]["total"] += 1 + + params = entry["request"]["queryString"] + for param in params: + name, value = param["name"], param["value"] + if name not in spec[base_path][route]["params"]: + spec[base_path][route]["params"][name] = {"requests": 0, "example": value} + spec[base_path][route]["params"][name]["requests"] += 1 + + headers = entry["request"]["headers"] + for header in headers: + name, value = header["name"].lower(), header["value"] + if name not in spec[base_path][route]["headers"]: + spec[base_path][route]["headers"][name] = {"requests": 0, "example": value} + spec[base_path][route]["headers"][name]["requests"] += 1 + + response_status = entry["response"]["status"] + response_reason = entry["response"]["statusText"] + spec[base_path][route]["responses"][response_status] = response_reason + + return spec + + def _create_route(self, method: str, path: str) -> Tuple[Tuple[str, str], Dict[str, Any]]: + """ + Create a route dictionary for an API method and path. + + :param method: The HTTP method (e.g., GET, POST). + :param path: The API endpoint path. + :return: A tuple containing the method-path tuple and the route details dictionary. + """ + return (method, path), { + "total": 0, + "headers": {}, + "params": {}, + "responses": {}, + } + + def _get_url(self, request: har.Request) -> str: + """ + Extract the full URL from a HAR request object. + + :param request: A dictionary containing the HAR request data. + :return: The full URL as a string. + """ + parsed_url = urlparse(request.get("_parameterized_url", request["url"])) + return f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + + def _get_base_path(self, urls: Set[str]) -> str: + """ + Determine the common base path from a set of URLs. + + :param urls: A set of URLs from the HAR entries. + :return: The common base path shared by the URLs. + """ + parts = [url.split("/") for url in urls] + common_path = [] + + for part in zip(*parts): + if all(p == part[0] for p in part): + common_path.append(part[0]) + else: + break + + return "/".join(common_path) diff --git a/vedro_httpx/spec_generator/_har_reader.py b/vedro_httpx/spec_generator/_har_reader.py new file mode 100644 index 0000000..c1e8af5 --- /dev/null +++ b/vedro_httpx/spec_generator/_har_reader.py @@ -0,0 +1,57 @@ +import json +import os +from typing import Generator, List, cast + +import vedro_httpx.recorder.har as har + +__all__ = ("HARReader",) + + +class HARReader: + """ + Reads HAR (HTTP Archive) files from a directory. + + This class provides methods to search for HAR files in a specified directory, + read their contents, and extract HTTP request and response entries. + """ + + def __init__(self, directory: str) -> None: + """ + Initialize the HARReader with the directory containing HAR files. + + :param directory: The path to the directory where HAR files are stored. + """ + self.directory = directory + + def _find_har_file_paths(self) -> Generator[str, None, None]: + """ + Search for all HAR file paths in the directory. + + :yield: The full path of each HAR file found. + """ + for root, _, files in os.walk(self.directory): + for file in files: + if file.endswith(".har"): + yield os.path.join(root, file) + + def _read_har_file(self, file_path: str) -> har.HAR: + """ + Read a HAR file and parse its content. + + :param file_path: The full path to the HAR file. + :return: The parsed HAR file as a dictionary-like object. + """ + with open(file_path) as file: + return cast(har.HAR, json.load(file)) + + def get_entries(self) -> List[har.Entry]: + """ + Retrieve all HTTP entries from the HAR files in the directory. + + :return: A list of HTTP request and response entries from the HAR files. + """ + entries = [] + for file_path in self._find_har_file_paths(): + har = self._read_har_file(file_path) + entries.extend(har["log"]["entries"]) + return entries diff --git a/vedro_httpx/spec_generator/_open_api_spec_generator.py b/vedro_httpx/spec_generator/_open_api_spec_generator.py new file mode 100644 index 0000000..f09686d --- /dev/null +++ b/vedro_httpx/spec_generator/_open_api_spec_generator.py @@ -0,0 +1,135 @@ +from typing import Any, Dict, List, Sequence + +import yaml + +from ._utils import humanize_identifier + +__all__ = ("OpenAPISpecGenerator",) + +STANDARD_HEADERS = ( + "host", + "accept", + "accept-encoding", + "connection", + "user-agent", + "content-length", + "content-type" +) + + +class OpenAPISpecGenerator: + """ + Generates an OpenAPI specification from API request and response data. + + This class provides methods to generate an OpenAPI specification based on + API request methods, parameters, headers, and response codes, excluding + standard headers like 'accept' or 'content-type' by default. + """ + + def __init__(self, *, standard_headers: Sequence[str] = STANDARD_HEADERS) -> None: + """ + Initialize the OpenAPISpecGenerator with standard headers to be excluded. + + :param standard_headers: A sequence of headers to be excluded from the spec, + defaults to standard headers like 'accept' and 'content-type'. + """ + self._standard_headers = set(standard_headers) + + def generate_spec(self, api_spec: Dict[str, Any]) -> str: + """ + Generate the OpenAPI specification for the given API. + + :param api_spec: A dictionary containing the API's base paths, methods, and details. + :return: The generated OpenAPI specification in YAML format. + """ + openapi_spec = { + "openapi": "3.0.0", + "info": { + "title": "API", + "version": "1.0.0" + }, + "servers": [{"url": url} for url in api_spec.keys()], + "paths": {} + } + + for base_path, methods in api_spec.items(): + for (method, path), details in methods.items(): + if path not in openapi_spec["paths"]: + openapi_spec["paths"][path] = {} # type: ignore + openapi_spec["paths"][path][method.lower()] = { # type: ignore + "summary": f"Endpoint for {method} {path}", + "operationId": self._get_operation_id(method, path), + "parameters": self._build_params(details) + self._build_headers(details), + "responses": self._build_responses(details), + } + + return yaml.dump(openapi_spec, sort_keys=False, allow_unicode=True) + + def _get_operation_id(self, method: str, path: str) -> str: + """ + Generate a unique operation ID for an API method and path. + + :param method: The HTTP method (e.g., GET, POST). + :param path: The API endpoint path. + :return: A unique operation ID string. + """ + replacements = {"/": "_", "{": "", "}": ""} + for old, new in replacements.items(): + path = path.replace(old, new) + return method.lower() + "_" + path.strip("_") + + def _build_params(self, details: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Build the query parameters section of the OpenAPI spec. + + :param details: A dictionary containing details of API parameters. + :return: A list of dictionaries representing each query parameter. + """ + parameters = [] + for param, info in details["params"].items(): + parameters.append({ + "name": param, + "in": "query", + "required": info["requests"] == details["total"], + "description": f"{humanize_identifier(param)} param", + "schema": { + "type": "string" + } + }) + return parameters + + def _build_headers(self, details: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Build the headers section of the OpenAPI spec. + + :param details: A dictionary containing details of request headers. + :return: A list of dictionaries representing each request header. + """ + headers = [] + for header, info in details["headers"].items(): + if header.lower() in self._standard_headers: + continue + headers.append({ + "name": header, + "in": "header", + "required": info["requests"] == details["total"], + "description": f"{humanize_identifier(header)} header", + "schema": { + "type": "string" + } + }) + return headers + + def _build_responses(self, details: Dict[str, Any]) -> Dict[str, Dict[str, str]]: + """ + Build the responses section of the OpenAPI spec. + + :param details: A dictionary containing details of API responses. + :return: A dictionary mapping HTTP status codes to response descriptions. + """ + responses = {} + for response_status, response_reason in details["responses"].items(): + responses[str(response_status)] = { + "description": response_reason + } + return responses diff --git a/vedro_httpx/spec_generator/_utils.py b/vedro_httpx/spec_generator/_utils.py new file mode 100644 index 0000000..0580f26 --- /dev/null +++ b/vedro_httpx/spec_generator/_utils.py @@ -0,0 +1,35 @@ +import re + + +def humanize_identifier(name: str) -> str: + """ + Converts a given identifier into a human-readable format. + + This function replaces underscores, hyphens, and camelCase or TitleCase + conventions with spaces and capitalizes the result. It handles various + common identifier naming conventions such as snake_case, kebab-case, + camelCase, and TitleCase. + + :param name: The identifier to be converted. + :return: A human-readable version of the identifier, with the first letter capitalized. + """ + + if not name: + return "" + + # Step 1: Replace underscores with spaces to handle snake_case + name = name.replace('_', ' ') + + # Step 2: Replace hyphens with spaces to handle hyphenated words + name = name.replace('-', ' ') + + # Step 3: Add spaces before uppercase letters that follow lowercase letters + # or numbers (camelCase, TitleCase) + name = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ', name) + + # Step 4: Add spaces between consecutive uppercase letters followed by + # lowercase letters (for acronyms) + name = re.sub(r'(?<=[A-Z])(?=[A-Z][a-z])', ' ', name) + + # Step 5: Strip leading/trailing spaces and capitalize the first letter of the result + return name.strip().capitalize()