Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/actions/setup-api-tools/action.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
name: 'Setup API Tools'
description: 'Setup Rust, Bun, and Python dependencies for API tools'

inputs:
setup_algokit:
description: 'Whether to install and start algokit localnet'
required: false
default: 'false'

runs:
using: 'composite'
steps:
Expand All @@ -24,4 +30,14 @@ runs:
- name: Install uv dependencies
working-directory: api/oas_generator
shell: bash
run: uv sync --extra dev
run: uv sync --extra dev

- name: Optionally install algokit
if: ${{ inputs.setup_algokit == 'true' }}
shell: bash
run: uv tool install algokit

- name: Optionally start localnet
if: ${{ inputs.setup_algokit == 'true' }}
shell: bash
run: algokit localnet start
55 changes: 50 additions & 5 deletions .github/workflows/api_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- "tools/api_tools/**"
- "crates/algod_client/**"
- "crates/indexer_client/**"
- "packages/typescript/**"
- ".github/workflows/api_ci.yml"
pull_request:
branches:
Expand All @@ -21,6 +22,7 @@ on:
- "tools/api_tools/**"
- "crates/algod_client/**"
- "crates/indexer_client/**"
- "packages/typescript/**"
- ".github/workflows/api_ci.yml"
workflow_dispatch:

Expand All @@ -33,10 +35,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-api-tools

- name: Run OAS generator linting
run: cargo api lint-oas

- name: Run OAS generator tests
run: cargo api test-oas

Expand All @@ -54,10 +56,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-api-tools

- name: Generate ${{ matrix.client }} API client
run: cargo api generate-${{ matrix.client }}

- name: Check for output stability
run: |
git add -N ${{ matrix.output_dir }}
Expand All @@ -66,6 +68,49 @@ jobs:
echo "🔧 To fix: Run 'cargo api generate-${{ matrix.client }}' locally and commit the changes"
exit 1
fi

- name: Verify generated code compiles
run: cargo check --manifest-path Cargo.toml -p ${{ matrix.client }}_client

ts_api_client_generation:
runs-on: ubuntu-latest
needs: oas_lint_and_test
strategy:
matrix:
pkg: [algod_client, indexer_client]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-api-tools
with:
setup_algokit: 'true'
- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Generate TypeScript API clients
run: cargo api generate-ts-all

- name: Check output stability for ${{ matrix.pkg }}
run: |
git add -N packages/typescript/${{ matrix.pkg }}
if ! git diff --exit-code --minimal packages/typescript/${{ matrix.pkg }}; then
echo "❌ Generated ${{ matrix.pkg }} TS client differs from committed version!"
echo "🔧 To fix: Run 'cargo api generate-ts-all' locally and commit the changes"
exit 1
fi

- name: Install dependencies (${{ matrix.pkg }})
working-directory: packages/typescript/${{ matrix.pkg }}
run: bun install

- name: Format check (${{ matrix.pkg }})
working-directory: packages/typescript/${{ matrix.pkg }}
run: npx prettier --check .

- name: Build (tsc) ${{ matrix.pkg }}
working-directory: packages/typescript/${{ matrix.pkg }}
run: bun run build

- name: Test (${{ matrix.pkg }})
working-directory: packages/typescript/${{ matrix.pkg }}
run: bun test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ AGENTS.md
.references/
.kiro/


packages/typescript/algod_client/dist/
packages/typescript/indexer_client/dist/
Empty file.
173 changes: 173 additions & 0 deletions api/oas_generator/ts_oas_generator/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Command-line interface for the TypeScript OAS Generator (Phase 2)."""

from __future__ import annotations

import argparse
import contextlib
import json
import shutil
import sys
import tempfile
import traceback
from collections.abc import Generator
from pathlib import Path

from ts_oas_generator import constants
from ts_oas_generator.generator.template_engine import CodeGenerator
from ts_oas_generator.utils.file_utils import write_files_to_disk

# Exit codes for better error reporting
EXIT_SUCCESS = 0
EXIT_FILE_NOT_FOUND = 1
EXIT_INVALID_JSON = 2
EXIT_GENERATION_ERROR = 3


def parse_command_line_args(args: list[str] | None = None) -> argparse.Namespace:
"""Create and configure the command line argument parser for TS generator."""
parser = argparse.ArgumentParser(
description="Generate TypeScript client from OpenAPI specification",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s ../specs/algod.oas3.json --output ./packages/algod_client --package-name algod_client
%(prog)s ../specs/indexer.oas3.json -o ./packages/indexer_client -p indexer_client
""",
)

parser.add_argument(
"spec_file",
type=Path,
help="Path to OpenAPI specification file (JSON or YAML)",
metavar="SPEC_FILE",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=Path(constants.DEFAULT_OUTPUT_DIR),
help="Output directory for generated files (default: %(default)s)",
dest="output_dir",
)
parser.add_argument(
"--package-name",
"-p",
default=constants.DEFAULT_PACKAGE_NAME,
help="Name for the generated TypeScript package (default: %(default)s)",
dest="package_name",
)
parser.add_argument(
"--template-dir",
"-t",
type=Path,
help="Custom template directory (optional)",
dest="template_dir",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Enable verbose output",
)
parser.add_argument(
"--description",
"-d",
help="Custom description for the generated package (overrides spec description)",
dest="custom_description",
)

parsed_args = parser.parse_args(args)

# Validate inputs
if not parsed_args.spec_file.exists():
parser.error(f"Specification file not found: {parsed_args.spec_file}")

return parsed_args


def print_generation_summary(*, file_count: int, files: dict[Path, str], output_dir: Path) -> None:
"""Print summary of generated files."""
print(f"Generated {file_count} files:")
for file_path in sorted(files.keys()):
print(f" {file_path}")
print(f"\nTypeScript client generated successfully in {output_dir}")


@contextlib.contextmanager
def backup_and_prepare_output_dir(output_dir: Path) -> Generator[None, None, None]:
"""Backup and ensure the output directory exists before generation."""
backup_dir: Path | None = None

# Create a backup of the existing directory if it exists and is non-empty
if output_dir.exists() and any(output_dir.iterdir()):
backup_dir = Path(tempfile.mkdtemp(prefix=constants.BACKUP_DIR_PREFIX))
shutil.copytree(output_dir, backup_dir, dirs_exist_ok=True)

# Ensure directory exists
output_dir.mkdir(parents=True, exist_ok=True)

try:
yield
except Exception:
if backup_dir:
print(
"Error: Generation failed. Restoring original content.",
file=sys.stderr,
)
# Restore backup
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(backup_dir, output_dir, dirs_exist_ok=True)
raise
finally:
if backup_dir and backup_dir.exists():
shutil.rmtree(backup_dir)


def main(args: list[str] | None = None) -> int:
parsed_args = parse_command_line_args(args)

try:
with backup_and_prepare_output_dir(parsed_args.output_dir):
generator = CodeGenerator(template_dir=parsed_args.template_dir)

generated_files = generator.generate(
parsed_args.spec_file,
parsed_args.output_dir,
parsed_args.package_name,
custom_description=parsed_args.custom_description,
)

# Write files to disk (overwrite safely)
write_files_to_disk(generated_files)

if parsed_args.verbose:
print_generation_summary(
file_count=len(generated_files),
files=generated_files,
output_dir=parsed_args.output_dir,
)
else:
print(f"TypeScript client generated successfully in {parsed_args.output_dir}")

return EXIT_SUCCESS

except FileNotFoundError:
print(
f"Error: Specification file not found: {parsed_args.spec_file}",
file=sys.stderr,
)
return EXIT_FILE_NOT_FOUND
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in specification file: {e}", file=sys.stderr)
return EXIT_INVALID_JSON
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if parsed_args.verbose:
traceback.print_exc()
return EXIT_GENERATION_ERROR


if __name__ == "__main__":
sys.exit(main())
Loading
Loading