fleet-memory/scripts/generate-clients.sh
Nicolò Boschi 6e30980add
feat: use official go generator for Go client (#377)
* ci: add Go client integration tests

Add test-go-client job to CI workflow following the same pattern as
Python, TypeScript, and Rust client tests. The job:
- Sets up Go 1.23 with dependency caching
- Starts the Hindsight API server
- Runs integration tests using the 'integration' build tag
- Displays server logs on failure

The integration tests (hindsight-clients/go/integration_test.go) cover
all core operations: retain, recall, reflect, bank management, and
end-to-end workflows.

* Move Go cookbook content to hindsight-cookbook repo

Removes Go-specific cookbook content that was added in PR #375:
- applications/go-memory-service.md
- recipes/go-quickstart.md
- recipes/go-concurrent-pipeline.md

These have been moved to the hindsight-cookbook repository where
cookbook content should live per project conventions.

* feat(go): add CI test for Go client and patch for ogen null handling

- Add test-go-client job to GitHub Actions CI workflow
- Create post-generation patch script (patch-ogen.sh) to fix ogen's
  handling of null values in optional string fields
- Patch OptString.Decode() to check jx.Next() type before decoding,
  properly handling explicit null in JSON responses

The patch ensures generated code persists across regenerations and
handles the Hindsight API's nullable optional fields correctly.

Fixes: Go client integration tests for retain and bank operations
Note: Some tests still fail for nullable arrays/objects - those
require additional patches for other Opt* types.

* feat: use official go generator for Go client

* feat: use official go generator for Go client

* ci fixes

* chore: sync Go client with latest OpenAPI spec

- Add model_child_operation_status.go (new model)
- Update model_operation_status_response.go with child operations
- Update go.mod/go.sum dependencies
- Update api/openapi.yaml
2026-02-16 14:04:12 +01:00

424 lines
14 KiB
Bash
Executable file

#!/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 --user to match current user's UID/GID so generated files are writable
docker run --rm \
--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
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
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/"
elif ! command -v java &> /dev/null; then
echo "⚠ Java not found, skipping Go client generation"
echo " Install Java 11+ from https://adoptium.net/"
else
echo "Regenerating Go client (via OpenAPI Generator)..."
cd "$GO_CLIENT_DIR"
# Download OpenAPI Generator if not present
OPENAPI_GEN_VERSION="7.10.0"
OPENAPI_GEN_JAR="openapi-generator-cli.jar"
if [ ! -f "$OPENAPI_GEN_JAR" ]; then
echo "Downloading OpenAPI Generator ${OPENAPI_GEN_VERSION}..."
curl -L "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${OPENAPI_GEN_VERSION}/openapi-generator-cli-${OPENAPI_GEN_VERSION}.jar" -o "$OPENAPI_GEN_JAR"
fi
# 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/"
# 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
echo "Generating client from OpenAPI spec..."
java -jar "$OPENAPI_GEN_JAR" generate \
-i "$OPENAPI_SPEC" \
-g go \
-o . \
--package-name hindsight \
--git-user-id vectorize-io \
--git-repo-id hindsight-client-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" .
rm -rf "$TEMP_DIR"
# 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 ""