#!/usr/bin/env bash set -e # Script to generate Python, TypeScript, and Go clients from OpenAPI spec # Note: Rust client is auto-generated at build time via build.rs (uses progenitor) # Usage: ./scripts/generate-clients.sh # Pin openapi-generator version for reproducible builds across local and CI OPENAPI_GENERATOR_VERSION="v7.10.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CLIENTS_DIR="$PROJECT_ROOT/hindsight-clients" OPENAPI_SPEC="$PROJECT_ROOT/hindsight-docs/static/openapi.json" echo "==================================================" echo "Hindsight API Client Generator" echo "==================================================" echo "Project root: $PROJECT_ROOT" echo "Clients directory: $CLIENTS_DIR" echo "OpenAPI spec: $OPENAPI_SPEC" echo "" echo "This script generates clients for:" echo " - Rust (via progenitor in build.rs)" echo " - Python (via openapi-generator)" echo " - TypeScript (via @hey-api/openapi-ts)" echo " - Go (via ogen)" echo "" # Check if OpenAPI spec exists if [ ! -f "$OPENAPI_SPEC" ]; then echo "❌ Error: OpenAPI spec not found at $OPENAPI_SPEC" exit 1 fi echo "✓ OpenAPI spec found" echo "" # Check for Docker (we'll use Docker to run openapi-generator) if ! command -v docker &> /dev/null; then echo "❌ Error: Docker not found. Please install Docker" echo " https://docs.docker.com/get-docker/" exit 1 fi echo "✓ Docker available" echo "✓ Using openapi-generator ${OPENAPI_GENERATOR_VERSION}" echo "" # Generate Rust client echo "==================================================" echo "Generating Rust client..." echo "==================================================" RUST_CLIENT_DIR="$CLIENTS_DIR/rust" # Clean old generated files (keep Cargo.lock for reproducible builds) echo "Cleaning old Rust generated code..." rm -rf "$RUST_CLIENT_DIR/target" # Trigger regeneration by building # Use --locked to ensure reproducible builds from committed Cargo.lock echo "Regenerating Rust client (via build.rs)..." cd "$RUST_CLIENT_DIR" cargo clean cargo build --release --locked echo "✓ Rust client generated at $RUST_CLIENT_DIR" echo "" # Generate Python client echo "==================================================" echo "Generating Python client..." echo "==================================================" PYTHON_CLIENT_DIR="$CLIENTS_DIR/python" # Backup the maintained wrapper file WRAPPER_FILE="$PYTHON_CLIENT_DIR/hindsight_client/hindsight_client.py" WRAPPER_BACKUP="/tmp/hindsight_client_backup.py" if [ -f "$WRAPPER_FILE" ]; then echo "📦 Backing up maintained wrapper: hindsight_client.py" cp "$WRAPPER_FILE" "$WRAPPER_BACKUP" fi # Backup the README.md README_FILE="$PYTHON_CLIENT_DIR/README.md" README_BACKUP="/tmp/hindsight_python_readme_backup.md" if [ -f "$README_FILE" ]; then echo "📦 Backing up README.md" cp "$README_FILE" "$README_BACKUP" fi # Remove old generated code (but keep config and maintained files) if [ -d "$PYTHON_CLIENT_DIR/hindsight_client_api" ]; then echo "Removing old generated code..." rm -rf "$PYTHON_CLIENT_DIR/hindsight_client_api" fi # Remove other generated files but keep pyproject.toml and config for file in setup.py setup.cfg requirements.txt test-requirements.txt tox.ini git_push.sh .travis.yml .gitlab-ci.yml .gitignore README.md; do if [ -f "$PYTHON_CLIENT_DIR/$file" ]; then rm "$PYTHON_CLIENT_DIR/$file" fi done echo "Generating new client with openapi-generator..." cd "$PYTHON_CLIENT_DIR" # Run openapi-generator via Docker (pinned version for reproducibility) # Use --platform linux/amd64 to ensure identical output on both macOS (arm64) and Linux CI (amd64) # Use --user to match current user's UID/GID so generated files are writable # Note: the generator may exit non-zero due to a known bug writing # README_onlypackage.mustache, but all API/model files are generated # before that step, so we allow the failure and verify files below. docker run --rm \ --platform linux/amd64 \ --user "$(id -u):$(id -g)" \ -v "$OPENAPI_SPEC:/local/openapi.json" \ -v "$PYTHON_CLIENT_DIR:/local/out" \ -v "$PYTHON_CLIENT_DIR/openapi-generator-config.yaml:/local/config.yaml" \ "openapitools/openapi-generator-cli:${OPENAPI_GENERATOR_VERSION}" generate \ -i /local/openapi.json \ -g python \ -o /local/out \ -c /local/config.yaml || true # Verify critical generated files exist if [ ! -f "$PYTHON_CLIENT_DIR/hindsight_client_api/api_client.py" ]; then echo "❌ Error: Python client generation failed - api_client.py not found" exit 1 fi echo "Organizing generated files..." # The generator creates files directly, we need to ensure proper structure # openapi-generator puts source code in agent_memory_api_client/ by default # Restore the maintained wrapper file if [ -f "$WRAPPER_BACKUP" ]; then echo "📦 Restoring maintained wrapper: hindsight_client.py" cp "$WRAPPER_BACKUP" "$WRAPPER_FILE" rm "$WRAPPER_BACKUP" fi # Restore the README.md if [ -f "$README_BACKUP" ]; then echo "📦 Restoring README.md" cp "$README_BACKUP" "$README_FILE" rm "$README_BACKUP" fi # Keep our custom pyproject.toml (don't let generator overwrite it) if [ -f "setup.py" ]; then echo "Note: setup.py generated but we're using pyproject.toml" fi # Remove the auto-generated README (we have our own) if [ -f "$PYTHON_CLIENT_DIR/hindsight_client_api_README.md" ]; then echo "Removing auto-generated README..." rm "$PYTHON_CLIENT_DIR/hindsight_client_api_README.md" fi # Patch rest.py to defer aiohttp initialization (fixes "no running event loop" error) # The generated code creates aiohttp.TCPConnector in __init__ which requires a running event loop. # We patch it to defer initialization until the first request (which runs in async context). echo "Patching rest.py for deferred aiohttp initialization..." REST_FILE="$PYTHON_CLIENT_DIR/hindsight_client_api/rest.py" if [ -f "$REST_FILE" ]; then cd "$PROJECT_ROOT" python3 << PATCH_SCRIPT import re rest_file = "$PYTHON_CLIENT_DIR/hindsight_client_api/rest.py" with open(rest_file, 'r') as f: content = f.read() # Replace the __init__ method to defer initialization old_init = '''class RESTClientObject: def __init__(self, configuration) -> None: # maxsize is number of requests to host that are allowed in parallel maxsize = configuration.connection_pool_maxsize ssl_context = ssl.create_default_context( cafile=configuration.ssl_ca_cert ) if configuration.cert_file: ssl_context.load_cert_chain( configuration.cert_file, keyfile=configuration.key_file ) if not configuration.verify_ssl: ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector( limit=maxsize, ssl=ssl_context ) self.proxy = configuration.proxy self.proxy_headers = configuration.proxy_headers # https pool manager self.pool_manager = aiohttp.ClientSession( connector=connector, trust_env=True ) retries = configuration.retries self.retry_client: Optional[aiohttp_retry.RetryClient] if retries is not None: self.retry_client = aiohttp_retry.RetryClient( client_session=self.pool_manager, retry_options=aiohttp_retry.ExponentialRetry( attempts=retries, factor=2.0, start_timeout=0.1, max_timeout=120.0 ) ) else: self.retry_client = None''' new_init = '''class RESTClientObject: def __init__(self, configuration) -> None: # Store configuration for deferred initialization # aiohttp.TCPConnector requires a running event loop, so we defer # creation until the first request (which runs in async context) self._configuration = configuration self._pool_manager: Optional[aiohttp.ClientSession] = None self._retry_client: Optional[aiohttp_retry.RetryClient] = None self.proxy = configuration.proxy self.proxy_headers = configuration.proxy_headers def _ensure_session(self) -> None: """Create aiohttp session lazily (must be called from async context).""" if self._pool_manager is not None: return configuration = self._configuration maxsize = configuration.connection_pool_maxsize ssl_context = ssl.create_default_context( cafile=configuration.ssl_ca_cert ) if configuration.cert_file: ssl_context.load_cert_chain( configuration.cert_file, keyfile=configuration.key_file ) if not configuration.verify_ssl: ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector( limit=maxsize, ssl=ssl_context ) self._pool_manager = aiohttp.ClientSession( connector=connector, trust_env=True ) retries = configuration.retries if retries is not None: self._retry_client = aiohttp_retry.RetryClient( client_session=self._pool_manager, retry_options=aiohttp_retry.ExponentialRetry( attempts=retries, factor=2.0, start_timeout=0.1, max_timeout=120.0 ) ) @property def pool_manager(self) -> aiohttp.ClientSession: """Get the pool manager, initializing if needed.""" self._ensure_session() return self._pool_manager @property def retry_client(self) -> Optional[aiohttp_retry.RetryClient]: """Get the retry client, initializing if needed.""" self._ensure_session() return self._retry_client''' if old_init in content: content = content.replace(old_init, new_init) # Also update the close method to handle None pool_manager old_close = ''' async def close(self): await self.pool_manager.close() if self.retry_client is not None: await self.retry_client.close()''' new_close = ''' async def close(self): if self._pool_manager is not None: await self._pool_manager.close() if self._retry_client is not None: await self._retry_client.close()''' content = content.replace(old_close, new_close) with open(rest_file, 'w') as f: f.write(content) print(" ✓ rest.py patched successfully") else: print(" ⚠ Could not find expected pattern in rest.py - skipping patch") PATCH_SCRIPT fi echo "✓ Python client generated at $PYTHON_CLIENT_DIR" echo "" # Generate TypeScript client echo "==================================================" echo "Generating TypeScript client..." echo "==================================================" TYPESCRIPT_CLIENT_DIR="$CLIENTS_DIR/typescript" # Remove old generated client (keep package.json, tsconfig.json, tests, src/, and config) echo "Removing old TypeScript generated code..." rm -rf "$TYPESCRIPT_CLIENT_DIR/generated" # Also remove legacy structure from old generator if it exists rm -rf "$TYPESCRIPT_CLIENT_DIR/core" rm -rf "$TYPESCRIPT_CLIENT_DIR/models" rm -rf "$TYPESCRIPT_CLIENT_DIR/services" rm -f "$TYPESCRIPT_CLIENT_DIR/index.ts" # Generate new client using @hey-api/openapi-ts # Use npm run generate to use the locally installed version (pinned in package.json) # instead of npx --yes which would fetch the latest version echo "Generating from $OPENAPI_SPEC..." cd "$TYPESCRIPT_CLIENT_DIR" npm run generate # Patch client.gen.ts for Deno compatibility. # Deno's Request constructor rejects a 'client' field in RequestInit because # 'client' is a reserved Deno.HttpClient option name, causing a TypeError. # We destructure it out before spreading opts into RequestInit. echo "Patching client.gen.ts for Deno compatibility..." cd "$PROJECT_ROOT" python3 << PATCH_SCRIPT CLIENT_GEN = "$TYPESCRIPT_CLIENT_DIR/generated/client/client.gen.ts" with open(CLIENT_GEN) as f: content = f.read() OLD = ''' const requestInit: ReqInit = { redirect: "follow", ...opts, body: getValidRequestBody(opts), };''' NEW = ''' // Exclude hey-api internal fields that conflict with Deno's RequestInit.client const { client: _client, ...optsForRequest } = opts as typeof opts & { client?: unknown }; const requestInit: ReqInit = { redirect: "follow", ...optsForRequest, body: getValidRequestBody(opts), };''' if OLD in content: content = content.replace(OLD, NEW) with open(CLIENT_GEN, "w") as f: f.write(content) print(" ✓ client.gen.ts patched successfully") else: print(" ⚠ Could not find expected pattern in client.gen.ts - skipping patch") PATCH_SCRIPT echo "✓ TypeScript client generated at $TYPESCRIPT_CLIENT_DIR" echo "" # Generate Go client echo "==================================================" echo "Generating Go client..." echo "==================================================" GO_CLIENT_DIR="$CLIENTS_DIR/go" if ! command -v go &> /dev/null; then echo "⚠ Go not found, skipping Go client generation" echo " Install Go 1.25+ from https://go.dev/dl/" else echo "Regenerating Go client (via OpenAPI Generator Docker)..." cd "$GO_CLIENT_DIR" # Save maintained files to temp TEMP_DIR=$(mktemp -d) echo "Preserving maintained files..." [ -f "README.md" ] && cp README.md "$TEMP_DIR/" [ -f "integration_test.go" ] && cp integration_test.go "$TEMP_DIR/" [ -f "null_test.go" ] && cp null_test.go "$TEMP_DIR/" [ -f "trace_test.go" ] && cp trace_test.go "$TEMP_DIR/" [ -f "hindsight_client.go" ] && cp hindsight_client.go "$TEMP_DIR/" # Remove old generated files echo "Removing old generated code..." rm -f api_*.go model_*.go client.go configuration.go response.go utils.go rm -rf docs/ .openapi-generator/ rm -f go.mod go.sum # Generate new client via Docker (--platform linux/amd64 ensures identical output on macOS and Linux CI) echo "Generating client from OpenAPI spec..." docker run --rm \ --platform linux/amd64 \ --user "$(id -u):$(id -g)" \ -v "$OPENAPI_SPEC:/local/openapi.json" \ -v "$GO_CLIENT_DIR:/local/out" \ "openapitools/openapi-generator-cli:${OPENAPI_GENERATOR_VERSION}" generate \ -i /local/openapi.json \ -g go \ -o /local/out \ --package-name hindsight \ --git-user-id vectorize-io \ --git-repo-id hindsight/hindsight-clients/go \ --global-property apiDocs=false,apiTests=false,modelDocs=false,modelTests=false # Remove OpenAPI Generator boilerplate files echo "Removing boilerplate files..." rm -rf docs/ git_push.sh .travis.yml .gitlab-ci.yml .openapi-generator-ignore .openapi-generator/ # Restore maintained files from temp echo "Restoring maintained files..." [ -f "$TEMP_DIR/README.md" ] && mv "$TEMP_DIR/README.md" . [ -f "$TEMP_DIR/integration_test.go" ] && mv "$TEMP_DIR/integration_test.go" . [ -f "$TEMP_DIR/null_test.go" ] && mv "$TEMP_DIR/null_test.go" . [ -f "$TEMP_DIR/trace_test.go" ] && mv "$TEMP_DIR/trace_test.go" . [ -f "$TEMP_DIR/hindsight_client.go" ] && mv "$TEMP_DIR/hindsight_client.go" . rm -rf "$TEMP_DIR" # Fix known generator issue: api_files.go uses os.File but generator omits "os" import if [ -f "api_files.go" ] && grep -q 'os\.File' api_files.go && ! grep -q '"os"' api_files.go; then echo "Patching api_files.go: adding missing 'os' import..." sed -i.bak 's|"net/url"|"net/url"\n\t"os"|' api_files.go rm -f api_files.go.bak fi # Initialize module and build echo "Building Go client..." go mod tidy go build ./... echo "✓ Go client generated at $GO_CLIENT_DIR" fi echo "" echo "==================================================" echo "✅ Client generation complete!" echo "==================================================" echo "" echo "Rust client: $RUST_CLIENT_DIR" echo "Python client: $PYTHON_CLIENT_DIR" echo "TypeScript client: $TYPESCRIPT_CLIENT_DIR" echo "Go client: $GO_CLIENT_DIR" echo "" echo "⚠️ Important: The maintained wrapper hindsight_client.py and README.md were preserved" echo "" echo "Next steps:" echo " 1. Review the generated clients" echo " 2. Update package versions if needed" echo " 3. Test the clients" echo " 4. Run 'cargo build' in hindsight-cli to rebuild with new Rust client" echo ""