diff --git a/.github/workflows/update-module-exports.yml b/.github/workflows/update-module-exports.yml new file mode 100644 index 0000000000..931c08c8c1 --- /dev/null +++ b/.github/workflows/update-module-exports.yml @@ -0,0 +1,81 @@ +name: Update Module Exports + +on: + pull_request: + paths: + - 'httplib.h' + push: + branches: + - master + - main + paths: + - 'httplib.h' + +jobs: + update-exports: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper diff + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Check for changes in httplib.h + id: check_changes + run: | + if git diff --name-only HEAD~1 HEAD | grep -q "httplib.h"; then + echo "changes=true" >> $GITHUB_OUTPUT + else + echo "changes=false" >> $GITHUB_OUTPUT + fi + + - name: Update module exports + if: steps.check_changes.outputs.changes == 'true' + run: | + python3 update_modules.py + + - name: Check if module file was modified + if: steps.check_changes.outputs.changes == 'true' + id: check_module_changes + run: | + if git diff --quiet modules/httplib.cppm; then + echo "modified=false" >> $GITHUB_OUTPUT + else + echo "modified=true" >> $GITHUB_OUTPUT + fi + + - name: Commit changes + if: steps.check_module_changes.outputs.modified == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add modules/httplib.cppm + git commit -m "chore: update module exports for httplib.h changes" + + - name: Push changes (for push events) + if: steps.check_module_changes.outputs.modified == 'true' && github.event_name == 'push' + run: | + git push + + - name: Push changes (for pull requests) + if: steps.check_module_changes.outputs.modified == 'true' && github.event_name == 'pull_request' + run: | + git push origin HEAD:${{ github.head_ref }} + + - name: Add comment to PR + if: steps.check_module_changes.outputs.modified == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'āœ… Module exports have been automatically updated based on changes to `httplib.h`.' + }) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7566dcd807..b80b85d64a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,6 +110,7 @@ option(HTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN "Enable feature to load system cer option(HTTPLIB_USE_NON_BLOCKING_GETADDRINFO "Enables the non-blocking alternatives for getaddrinfo." ON) option(HTTPLIB_REQUIRE_ZSTD "Requires ZSTD to be found & linked, or fails build." OFF) option(HTTPLIB_USE_ZSTD_IF_AVAILABLE "Uses ZSTD (if available) to enable zstd support." ON) +option(HTTPLIB_BUILD_MODULES "Build httplib modules (requires HTTPLIB_COMPILE to be ON)." OFF) # Defaults to static library but respects standard BUILD_SHARED_LIBS if set include(CMakeDependentOption) cmake_dependent_option(HTTPLIB_SHARED "Build the library as a shared library instead of static. Has no effect if using header-only." @@ -367,3 +368,10 @@ if(HTTPLIB_TEST) include(CTest) add_subdirectory(test) endif() + +if(HTTPLIB_BUILD_MODULES) + if(NOT HTTPLIB_COMPILE) + message(FATAL_ERROR "HTTPLIB_BUILD_MODULES requires HTTPLIB_COMPILE to be ON.") + endif() + add_subdirectory(modules) +endif() diff --git a/README.md b/README.md index 9c64a17656..7dbc62eb08 100644 --- a/README.md +++ b/README.md @@ -1253,6 +1253,43 @@ $ ./split.py Wrote out/httplib.h and out/httplib.cc ``` +Build C++ Modules +----------------- + +If using CMake, it is possible to build this as a C++20 module using the `HTTPLIB_BUILD_MODULES` option (which requires `HTTPLIB_COMPILE` to be enabled). + +#### Server (Multi-threaded) +```cpp +import httplib; + +using httplib::Request; +using httplib::Response; +using httplib::SSLServer; + +SSLServer svr; + +svr.Get("/hi", []([[maybe_unused]] const Request& req, Response& res) -> void { + res.set_content("Hello World!", "text/plain"); +}); + +svr.listen("0.0.0.0", 8080); +``` + +#### Client +```cpp +import httplib; + +using httplib::Client; +using httplib::Result; + +Client cli("https://yhirose.github.io"); + +if (Result res = cli.Get("/hi")) { + res->status; + res->body; +} +``` + Dockerfile for Static HTTP Server --------------------------------- diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt new file mode 100644 index 0000000000..a44a80e7ba --- /dev/null +++ b/modules/CMakeLists.txt @@ -0,0 +1,25 @@ +add_library(httplib_module) + +target_sources(httplib_module + PUBLIC + FILE_SET CXX_MODULES FILES + httplib.cppm +) + +target_compile_features(httplib_module PUBLIC cxx_std_20) + +target_include_directories(httplib_module PUBLIC + $ + $ +) + +add_library(httplib::module ALIAS httplib_module) + +# Installation +install(TARGETS httplib_module + EXPORT ${PROJECT_NAME}Targets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/httplib/modules +) diff --git a/modules/httplib.cppm b/modules/httplib.cppm new file mode 100644 index 0000000000..2687cdf0bd --- /dev/null +++ b/modules/httplib.cppm @@ -0,0 +1,93 @@ +// +// httplib.cppm +// +// Copyright (c) 2025 Yuji Hirose. All rights reserved. +// MIT License +// + +module; + +#include "../httplib.h" + +export module httplib; + +export namespace httplib { + using httplib::SSLVerifierResponse; + using httplib::StatusCode; + using httplib::Headers; + using httplib::Params; + using httplib::Match; + using httplib::DownloadProgress; + using httplib::UploadProgress; + using httplib::Response; + using httplib::ResponseHandler; + using httplib::FormData; + using httplib::FormField; + using httplib::FormFields; + using httplib::FormFiles; + using httplib::MultipartFormData; + using httplib::UploadFormData; + using httplib::UploadFormDataItems; + using httplib::DataSink; + using httplib::ContentProvider; + using httplib::ContentProviderWithoutLength; + using httplib::ContentProviderResourceReleaser; + using httplib::FormDataProvider; + using httplib::FormDataProviderItems; + using httplib::ContentReceiverWithProgress; + using httplib::ContentReceiver; + using httplib::FormDataHeader; + using httplib::ContentReader; + using httplib::Range; + using httplib::Ranges; + using httplib::Request; + using httplib::Response; + using httplib::Error; + using httplib::to_string; + using httplib::operator<<; + using httplib::Stream; + using httplib::TaskQueue; + using httplib::ThreadPool; + using httplib::Logger; + using httplib::ErrorLogger; + using httplib::SocketOptions; + using httplib::default_socket_options; + using httplib::status_message; + using httplib::get_bearer_token_auth; + using httplib::Server; + using httplib::Result; + using httplib::ClientConnection; + using httplib::ClientImpl; + using httplib::Client; + + #ifdef CPPHTTPLIB_OPENSSL_SUPPORT + using httplib::SSLServer; + using httplib::SSLClient; + #endif + + using httplib::hosted_at; + using httplib::encode_uri_component; + using httplib::encode_uri; + using httplib::decode_uri_component; + using httplib::decode_uri; + using httplib::encode_path_component; + using httplib::decode_path_component; + using httplib::encode_query_component; + using httplib::decode_query_component; + using httplib::append_query_params; + using httplib::make_range_header; + using httplib::make_basic_authentication_header; + + using httplib::get_client_ip; + + namespace stream { + using httplib::stream::Result; + using httplib::stream::Get; + using httplib::stream::Post; + using httplib::stream::Put; + using httplib::stream::Patch; + using httplib::stream::Delete; + using httplib::stream::Head; + using httplib::stream::Options; + } +} diff --git a/update_modules.py b/update_modules.py new file mode 100644 index 0000000000..2e948bf4df --- /dev/null +++ b/update_modules.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Script to update the httplib.cppm module file based on changes to httplib.h. + +This script: +1. Reads the existing exported symbols from modules/httplib.cppm +2. Analyses git diff to find added/removed declarations in httplib.h +3. Updates httplib.cppm by adding new exports and removing deleted ones +""" + +import re +import subprocess +import sys +from pathlib import Path +from re import Match +from subprocess import CalledProcessError, CompletedProcess +from typing import Set, List, Tuple, Optional + + +def extract_exported_symbols(cppm_content: str) -> Set[str]: + """ + Extract all symbols that are currently exported in the module file. + + @param cppm_content Content of the .cppm module file + @return Set of symbol names that are already exported + """ + exported: Set[str] = set() + + # Match patterns like: using httplib::SymbolName; + pattern: str = r'using\s+httplib::(\w+);' + matches: List[str] = re.findall(pattern, cppm_content) + exported.update(matches) + + # Match patterns in nested namespace like: using httplib::stream::SymbolName; + pattern: str = r'using\s+httplib::stream::(\w+);' + matches: List[str] = re.findall(pattern, cppm_content) + exported.update(matches) + + return exported + + +def extract_exported_symbols(cppm_content: str) -> Set[str]: + """ + Extract all symbols that are currently exported in the module file. + + @param cppm_content Content of the .cppm module file + @return Set of symbol names that are already exported + """ + exported: Set[str] = set() + + pattern: str = r'using\s+httplib::(\w+);' + matches: List[str] = re.findall(pattern, cppm_content) + exported.update(matches) + + pattern: str = r'using\s+httplib::stream::(\w+);' + matches: List[str] = re.findall(pattern, cppm_content) + exported.update(matches) + + return exported + + +def get_git_diff(file_path: str, base_ref: str = "HEAD") -> Optional[str]: + """ + Get the git diff for a specific file. + + @param file_path Path to the file to diff + @param base_ref Git reference to compare against (default: HEAD) + @return The git diff output, or None if error + """ + try: + result: CompletedProcess = subprocess.run( + ["git", "diff", base_ref, "--", file_path], + capture_output=True, + text=True, + check=True + ) + return result.stdout + except CalledProcessError as e: + print(f"Error getting git diff: {e}", file=sys.stderr) + return None + + +def is_in_detail_namespace(line: str) -> bool: + """ + Check if a line appears to be in a detail namespace. + + @param line The line to check + @return True if the line is likely in a detail namespace + """ + return 'detail::' in line or line.strip().startswith('namespace detail') + + +def is_member_function_or_field(line: str, prev_context: List[str]) -> bool: + """ + Heuristic to detect if a declaration is likely a member function or field. + + @param line The current line + @param prev_context Previous few lines for context + @return True if it looks like a member declaration + """ + # Check if we're inside a class/struct by looking at previous context + for prev_line in reversed(prev_context[-10:]): # Look at last 10 lines + stripped: str = prev_line.strip() + # If we see a class/struct declaration recently without a closing brace, likely inside it + if re.match(r'^(?:public|private|protected):', stripped): + return True + # Common member function patterns + if stripped.startswith('~') or stripped.startswith('explicit '): + return True + + stripped: str = line.strip() + # Lines with multiple leading spaces are often inside class definitions + if line.startswith(' ') and not line.startswith(' '): # Exactly 2 spaces + return True + + return False + + +def extract_declarations_from_diff(diff_content: str) -> Tuple[Set[str], Set[str]]: + """ + Extract added and removed declarations from a git diff. + + @param diff_content The git diff output + @return Tuple of (added_symbols, removed_symbols) + """ + added_symbols: Set[str] = set() + removed_symbols: Set[str] = set() + + lines: List[str] = diff_content.split('\n') + context_lines: List[str] = [] + + for line in lines: + if not line.startswith('@@'): + context_lines.append(line) + if len(context_lines) > 20: + context_lines.pop(0) + + if is_in_detail_namespace(line): + continue + + if is_member_function_or_field(line, context_lines): + continue + + if line.startswith('+') and not line.startswith('+++'): + content: str = line[1:].strip() + + enum_match: Optional[Match[str]] = re.match(r'^enum\s+(?:class\s+)?(\w+)', content) + if enum_match: + added_symbols.add(enum_match.group(1)) + + class_match: Optional[Match[str]] = re.match(r'^(?:struct|class)\s+(\w+)(?:\s+final)?(?:\s*:\s*public)?', content) + if class_match and not content.endswith(';'): + added_symbols.add(class_match.group(1)) + + using_match: Optional[Match[str]] = re.match(r'^using\s+(\w+)\s+=', content) + if using_match: + added_symbols.add(using_match.group(1)) + + func_match: Optional[Match[str]] = re.match(r'^(?:inline\s+)?(?:const\s+)?(?:std::)?[\w:]+\s+(\w+)\s*\([^)]*\)\s*(?:const)?;', content) + if func_match and not '->' in content: + symbol: str = func_match.group(1) + if symbol not in {'operator', 'if', 'for', 'while', 'return', 'const', 'static'}: + if not (symbol.endswith('_internal') or symbol.endswith('_impl') or symbol.endswith('_core')): + added_symbols.add(symbol) + + elif line.startswith('-') and not line.startswith('---'): + content: str = line[1:].strip() + + enum_match: Optional[Match[str]] = re.match(r'^enum\s+(?:class\s+)?(\w+)', content) + if enum_match: + removed_symbols.add(enum_match.group(1)) + + class_match: Optional[Match[str]] = re.match(r'^(?:struct|class)\s+(\w+)(?:\s+final)?(?:\s*:\s*public)?', content) + if class_match and not content.endswith(';'): + removed_symbols.add(class_match.group(1)) + + using_match: Optional[Match[str]] = re.match(r'^using\s+(\w+)\s+=', content) + if using_match: + removed_symbols.add(using_match.group(1)) + + func_match: Optional[Match[str]] = re.match(r'^(?:inline\s+)?(?:const\s+)?(?:std::)?[\w:]+\s+(\w+)\s*\([^)]*\)\s*(?:const)?;', content) + if func_match and not '->' in content: + symbol: str = func_match.group(1) + if symbol not in {'operator', 'if', 'for', 'while', 'return', 'const', 'static'}: + if not (symbol.endswith('_internal') or symbol.endswith('_impl') or symbol.endswith('_core')): + removed_symbols.add(symbol) + + return added_symbols, removed_symbols + + +def update_module_exports(cppm_path: Path, symbols_to_add: Set[str], symbols_to_remove: Set[str]) -> bool: + """ + Update the module file by adding and removing symbols. + + @param cppm_path Path to the .cppm file + @param symbols_to_add Symbols to add to exports + @param symbols_to_remove Symbols to remove from exports + @return True if file was modified + """ + content: str = cppm_path.read_text() + original_content: str = content + + for symbol in symbols_to_remove: + pattern: str = rf'^\s*using httplib::{re.escape(symbol)};$' + content: str = re.sub(pattern, '', content, flags=re.MULTILINE) + + pattern: str = rf'^\s*using httplib::stream::{re.escape(symbol)};$' + content: str = re.sub(pattern, '', content, flags=re.MULTILINE) + + if symbols_to_add: + pattern: str = r'(.*using httplib::\w+;)' + matches: List[Match[str]] = list(re.finditer(pattern, content, re.MULTILINE)) + + if matches: + last_match: Match[str] = matches[-1] + insert_pos: int = last_match.end() + + new_exports: str = '\n'.join(f" using httplib::{symbol};" for symbol in sorted(symbols_to_add)) + content: str = content[:insert_pos] + '\n' + new_exports + content[insert_pos:] + + content: str = re.sub(r'\n\n\n+', '\n\n', content) + + if content != original_content: + cppm_path.write_text(content) + return True + + return False + + +def main() -> None: + """Main entry point for the script.""" + script_dir: Path = Path(__file__).parent + header_path: Path = script_dir / "httplib.h" + cppm_path: Path = script_dir / "modules" / "httplib.cppm" + + if not header_path.exists(): + print(f"Error: {header_path} not found") + sys.exit(1) + + if not cppm_path.exists(): + print(f"Error: {cppm_path} not found") + sys.exit(1) + + print("Analyzing git diff for httplib.h...") + diff_content: Optional[str] = get_git_diff(str(header_path)) + + if diff_content is None: + print("Error: Could not get git diff") + sys.exit(1) + + if not diff_content.strip(): + print("No changes detected in httplib.h") + sys.exit(0) + + print("Extracting declarations from diff...") + added_symbols, removed_symbols = extract_declarations_from_diff(diff_content) + + if not added_symbols and not removed_symbols: + print("No declaration changes detected") + sys.exit(0) + + print(f"\nFound {len(added_symbols)} added declarations:") + for symbol in sorted(added_symbols): + print(f" + {symbol}") + + print(f"\nFound {len(removed_symbols)} removed declarations:") + for symbol in sorted(removed_symbols): + print(f" - {symbol}") + + print("\nReading current module exports...") + cppm_content: str = cppm_path.read_text() + current_exports: Set[str] = extract_exported_symbols(cppm_content) + + symbols_to_add: Set[str] = added_symbols - current_exports + symbols_to_remove: Set[str] = removed_symbols & current_exports + + if not symbols_to_add and not symbols_to_remove: + print("\nModule file is already up to date") + sys.exit(0) + + print(f"\nUpdating module file:") + if symbols_to_add: + print(f" Adding {len(symbols_to_add)} symbols") + if symbols_to_remove: + print(f" Removing {len(symbols_to_remove)} symbols") + + modified: bool = update_module_exports(cppm_path, symbols_to_add, symbols_to_remove) + + if modified: + print(f"\nāœ“ Successfully updated {cppm_path}") + else: + print(f"\nāœ“ No changes needed to {cppm_path}") + + +if __name__ == "__main__": + main()