* feat(typescript-client): add Deno compatibility
- Switch build from tsc to tsup for dual CJS + ESM output with proper exports field
- Add deno_setup.ts preload that injects Jest-compatible globals (describe/test/expect) via @std/testing/bdd and @std/expect
- Fix generated client.gen.ts: exclude hey-api internal `client` field from RequestInit spread to avoid conflict with Deno.HttpClient
- Add test:deno npm script using --unstable-sloppy-imports and --preload
- Add test-typescript-client-deno CI job using denoland/setup-deno@v2 (v2.x)
- Update docs: rename page to TypeScript / JavaScript Client, add Deno installation section
* feat: add Deno compatibility to ai-sdk and chat integrations
- Switch ai-sdk and chat builds from tsc to tsup (ESM bundle, eliminates
extension-less import issues in Deno)
- Add deno.json import map to ai-sdk redirecting 'vitest' to a custom
vitest-compat.ts shim and bare npm specifiers to npm: URLs
- Add vitest-compat.ts shim implementing vi.fn()/vi.spyOn()/vi.mocked()
using @std/expect's Symbol.for("@MOCK") interface so toHaveBeenCalledWith
and other mock matchers work under Deno
- Add test:deno script to ai-sdk (all 30 tests pass under Deno)
* ci: add Deno test job for ai-sdk integration
Adds a new test-ai-sdk-integration-deno CI job that runs the ai-sdk
unit tests under Deno LTS, verifying Deno compatibility of the package.
* fix: remove broken link to non-existent n8n blog post in streamlit post
* fix: patch client.gen.ts for Deno compatibility during generation
Add a post-generation patch step to generate-clients.sh that removes
the hey-api internal 'client' field from the RequestInit spread in
client.gen.ts. Deno's Request constructor rejects 'client' because it
conflicts with the Deno.HttpClient option name.
460 lines
16 KiB
Bash
Executable file
460 lines
16 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 --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
|
|
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
|
|
|
|
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 ""
|