CODE & STREAM
ABOUT | CONTACT


OpenAPI 3.0 → Markdown Converter
Problem

I needed the ability to take an OpenAPI 3.0 spec and convert it to Markdown docs. The docs couldn't be generic Markdown docs, they needed to closely mirror the look and feel of existing API docs on a site that gets over 100 million page views a month.

NOTE: If the problem was just to take OpenAPI 3.0 specs and generate docs this would have been a solved problem with numerous open source libraries and tools available.

Gif of the UX for OpenAPI 3.0 -> Markdown converter

README.MD
# OpenAPI to Docs Converter

Convert OpenAPI 3.0 JSON specifications into comprehensive, human-readable Markdown documentation.

Supports both a **GUI** (desktop application) and **CLI** (command-line) workflow.

---

## Requirements

- **Python 3.8+**
- **Operating System**: Windows 10/11 (primary), macOS and Linux also supported
- **GPU**: Any OpenGL-capable GPU (for the GUI only)

---

## Installation

### 1. Clone or download this folder

Place the entire `release/` folder wherever you like. The tool is self-contained.

### 2. Install Python dependencies

```bash
pip install -r requirements.txt
```

This installs:

| Package | Purpose |
|---------|---------|
| `requests` | Downloading specs and examples from GitHub URLs |
| `imgui[glfw]` | Immediate-mode GUI framework + GLFW window backend |
| `glfw` | Cross-platform window/input management |
| `PyOpenGL` | OpenGL rendering for the GUI |

> **Note:** If you only plan to use the CLI, you only need `requests`. The GUI packages (`imgui`, `glfw`, `PyOpenGL`) are only needed for `gui.py`.

---

## Usage

### GUI Mode (Recommended)

```bash
python gui.py
```

This opens a desktop window with three sections:

![Screenshot of the UX for spec conversion](./media/UX.png)

1. **INPUT SOURCE** (blue) — Choose between local file or GitHub URL. Browse for your `.json` spec file and optionally provide an examples directory.

2. **GENERATE** (green) — Click "Generate Docs" to start. A live log shows progress during generation.

3. **OUTPUT** (orange) — Appears after generation completes. Shows:
   - **Action buttons**: Copy to Clipboard, Open File
   - **Document Stats**: Character count, line count, file size
   - **Spec Stats**: Endpoint count, schema count, etc.
   - **Spec Hierarchy**: Interactive tree view of all endpoints grouped by tag, with color-coded HTTP methods (GET=green, POST=blue, PUT=orange, DELETE=red, PATCH=yellow)

#### GUI Controls

| Shortcut | Action |
|----------|--------|
| `Ctrl +` | Zoom in |
| `Ctrl -` | Zoom out |
| `Ctrl 0` | Reset zoom to default |
| Arrow keys | Navigate file browser |
| Enter | Confirm selection in file browser |
| Backspace | Go up one directory in file browser |
| Escape | Close file browser |

### CLI Mode

```bash
python markdown_gen  [examples_dir]
```

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| `spec_file` | Yes | Path to an OpenAPI 3.0 JSON file |
| `examples_dir` | No | Path to a folder containing example JSON/YAML files |

**Examples:**

```bash
# Generate docs from a local spec (output saved as _.md)
python markdown_gen sample_specs/openai.json

# Generate docs with an examples directory
python markdown_gen sample_specs/AIProject.json examples/

# Generate docs from a GitHub URL
python markdown_gen https://github.com/user/repo/blob/main/openapi.json
```

The output Markdown file is saved to the current directory with an auto-generated name: `_.md`

---

## Folder Structure

```
release/
  gui.py              — GUI application (desktop interface)
  markdown_gen         — Core conversion engine + CLI entry point
  requirements.txt     — Python dependencies
  README.md            — This file
  examples/            — Sample x-ms-examples files (JSON and YAML)
  sample_specs/        — Sample OpenAPI 3.0 specs for testing
    AIProject.json     — Microsoft AI Foundry API spec (~1.1 MB)
    openai.json        — OpenAI API spec (~660 KB)
    openai_backup.json — OpenAI API spec variant (~730 KB)
```

---

## Input Formats

### Spec File

Any valid **OpenAPI 3.0** specification in **JSON** format. The spec should include:

- `info` block (title, version)
- `paths` with operations (GET, POST, PUT, DELETE, PATCH, etc.)
- `components` with schemas, parameters, and responses (optional but recommended)

### Examples Directory (Optional)

A folder containing JSON or YAML files that match `x-ms-examples` references in the spec. These are injected as inline request/response examples in the generated documentation.

The tool auto-discovers example files when:
- The spec contains `x-ms-examples` references
- The examples directory is provided and contains matching filenames

---

## Output

The tool generates a single Markdown (`.md`) file containing:

1. **API title and version** from the spec's `info` block
2. **Endpoints** grouped by tag, each with:
   - HTTP method and path
   - Description and summary
   - Request parameters (path, query, header)
   - Request body schema with property tables
   - Response schemas for each status code
   - Inline examples (when available)
3. **Components** section with:
   - Schema definitions with full property tables
   - Parameter definitions
   - Response definitions
   - Security scheme details

---

## Troubleshooting

### GUI won't launch

- Ensure you have OpenGL drivers installed
- Try updating your GPU drivers
- Verify the GUI dependencies are installed: `pip install imgui[glfw] glfw PyOpenGL`

### "Failed to initialize GLFW"

- On Windows: Make sure you're not running in a headless/remote session without GPU access
- On Linux: You may need to install additional packages: `sudo apt-get install libglfw3 libglfw3-dev`
- On macOS: GLFW should work out of the box with Homebrew Python

### Generation errors

- Verify your spec file is valid JSON: `python -c "import json; json.load(open('yourspec.json'))"`
- Check that the spec follows OpenAPI 3.0 format (not Swagger 2.0 or OpenAPI 3.1)
- For GitHub URLs, ensure the repository is public or that you have access

### Font rendering issues

- The GUI tries to load system fonts (Consolas, Segoe UI, Arial) on Windows
- On other platforms, it falls back to the imgui default font
- Use `Ctrl +/-` to adjust zoom if text appears too small or large

requirements.txt
# OpenAPI to Docs Converter — Python dependencies
# Install with: pip install -r requirements.txt

requests>=2.28.0
imgui[glfw]>=2.0.0
glfw>=2.10.0
PyOpenGL>=3.1.6
markdown_gen.py
"""
OpenAPI to Docs Converter — Core Engine & CLI

Converts OpenAPI 3.0 JSON specifications into comprehensive Markdown documentation.
Handles $ref resolution, allOf/anyOf/oneOf composition, nested properties, enums,
discriminators, x-ms-examples injection, and more.

Usage:
    python markdown_gen --spec  [--examples ] [--output ]
    python markdown_gen                     # launches the GUI (requires gui.py)
"""

import os
import requests
import json
from datetime import datetime
import urllib3

# Suppress InsecureRequestWarning when behind a local proxy
# I run claude code and all related traffic through a proxy that outputs
# to a separate terminal window so I monitor all traffic and see how the context window is being used
# in real-time which results in some fun exceptions that no one would see otherwise.

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# Configuration — set to True to enable verbose debug output for schema resolution
DEBUG_TOOL_RESOURCES = False


def debug_print(message):
    """Print a debug message (only when DEBUG_TOOL_RESOURCES is enabled)."""
    if DEBUG_TOOL_RESOURCES:
        print(f"[DEBUG] {message}")

# --- URL utilities ---

def is_url(value):
    """Check if a string is a URL."""
    return value.startswith(('http://', 'https://'))

def normalize_to_raw_url(github_url):
    """Convert any GitHub blob/tree URL to a raw.githubusercontent.com URL.
    Works with branch names AND commit SHAs (PR files)."""
    import re
    # Already a raw URL? Return as-is
    if 'raw.githubusercontent.com' in github_url:
        return github_url
    # Convert github.com/owner/repo/blob/ref/path → raw.githubusercontent.com/owner/repo/ref/path
    m = re.match(r'https://github\.com/([^/]+)/([^/]+)/(blob|tree)/(.+)', github_url)
    if m:
        owner, repo, _, rest = m.groups()
        return f"https://raw.githubusercontent.com/{owner}/{repo}/{rest}"
    raise ValueError(f"Cannot parse GitHub URL: {github_url}")

def parse_github_tree_url(tree_url):
    """Parse a GitHub tree URL into (owner, repo, ref, path) for API calls."""
    import re
    m = re.match(r'https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.+)', tree_url)
    if not m:
        raise ValueError(f"Cannot parse GitHub tree URL: {tree_url}")
    return m.groups()  # (owner, repo, ref, path)

def download_file(url, dest_path):
    """Download a single file from a URL."""
    print(f"Downloading: {url}")
    resp = requests.get(url, verify=False)
    resp.raise_for_status()
    os.makedirs(os.path.dirname(dest_path) or '.', exist_ok=True)
    with open(dest_path, 'wb') as f:
        f.write(resp.content)
    print(f"  → saved to {dest_path}")
    return dest_path

def download_github_folder_contents(tree_url, dest_dir):
    """Download all files from a GitHub tree URL using the GitHub API."""
    owner, repo, ref, path = parse_github_tree_url(tree_url)
    api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={ref}"
    print(f"Listing folder: {api_url}")
    resp = requests.get(api_url, verify=False)
    resp.raise_for_status()
    os.makedirs(dest_dir, exist_ok=True)
    for item in resp.json():
        if item['type'] == 'file':
            file_url = item['download_url']
            download_file(file_url, os.path.join(dest_dir, item['name']))


def resolve_inputs(spec_arg, examples_arg=None):
    """Resolve spec and examples arguments into local file paths.

    Returns (spec_path, examples_dir_or_None).
    For remote inputs, downloads to a temp staging directory.
    """
    import tempfile

    spec_is_url = is_url(spec_arg)
    examples_is_url = examples_arg and is_url(examples_arg)

    # --- Local spec ---
    if not spec_is_url:
        spec_path = os.path.abspath(spec_arg)
        if not os.path.isfile(spec_path):
            raise FileNotFoundError(f"Spec file not found: {spec_path}")
        examples_dir = None
        if examples_arg:
            if examples_is_url:
                raise ValueError("Cannot mix local spec with remote examples folder")
            examples_dir = os.path.abspath(examples_arg)
            if not os.path.isdir(examples_dir):
                raise FileNotFoundError(f"Examples directory not found: {examples_dir}")
        return spec_path, examples_dir

    # --- Remote spec (GitHub URL — branch or commit SHA) ---
    staging = tempfile.mkdtemp(prefix="openapi_docs_")
    print(f"Staging directory: {staging}")

    # Download spec
    raw_url = normalize_to_raw_url(spec_arg)
    spec_filename = raw_url.split('/')[-1]
    spec_path = os.path.join(staging, spec_filename)
    download_file(raw_url, spec_path)

    # Auto-discover examples from x-ms-examples refs in the spec
    with open(spec_path, 'r', encoding='utf-8') as f:
        spec_data = json.load(f)
    example_refs = []
    for path_obj in spec_data.get('paths', {}).values():
        for op in path_obj.values():
            if isinstance(op, dict) and 'x-ms-examples' in op:
                for ex in op['x-ms-examples'].values():
                    if '$ref' in ex:
                        example_refs.append(ex['$ref'])

    # Download examples if they exist (from --examples URL or auto-discovered refs)
    examples_dir = None
    if examples_arg and examples_is_url:
        examples_dir = os.path.join(staging, "examples")
        download_github_folder_contents(examples_arg, examples_dir)
    elif example_refs:
        # Auto-download examples relative to spec URL
        examples_dir = os.path.join(staging, "examples")
        os.makedirs(examples_dir, exist_ok=True)
        spec_base_url = '/'.join(raw_url.split('/')[:-1])  # directory of the spec
        for ref in example_refs:
            # refs are relative paths like "./examples/foo.json"
            ref_clean = ref.lstrip('./')
            example_url = f"{spec_base_url}/{ref_clean}"
            filename = ref.split('/')[-1]
            try:
                download_file(example_url, os.path.join(examples_dir, filename))
            except requests.HTTPError as e:
                print(f"  Warning: could not download example {filename}: {e}")

    return spec_path, examples_dir

# --- Text utilities ---

def safe_description_handling(description):
    """Safely handle description fields that might be strings or dictionaries.

    Some specs embed JSON objects in description fields. This normalizes
    any value to a plain string for safe Markdown rendering.
    """
    if isinstance(description, dict):
        # If description is a dictionary, convert to a JSON string
        try:
            return json.dumps(description)
        except (TypeError, ValueError):
            return str(description)
    elif description is None:
        return ''
    elif not isinstance(description, str):
        # For other non-string types
        return str(description)
    return description

def convert_to_anchor(name):
    """Convert a string to a proper Markdown anchor by removing special characters and replacing spaces with hyphens"""
    # Remove periods and convert to lowercase
    anchor = name.lower().replace('.', '')
    # Replace spaces with hyphens
    anchor = anchor.replace(' ', '-')
    # Remove any other special characters that might cause issues
    anchor = ''.join(c for c in anchor if c.isalnum() or c == '-')
    return anchor

# --- Schema resolution ---

def resolve_ref(openapi_spec, ref):
    """Resolve a JSON Reference ($ref) like '#/components/schemas/Foo' to the target object.

    Walks the spec tree following each path segment. Returns an empty dict
    if any segment is missing (graceful fallback for broken refs).
    """
    parts = ref[2:].split('/')
    value = openapi_spec
    for part in parts:
        value = value.get(part, {})

    if DEBUG_TOOL_RESOURCES and 'tool_resources' in str(value):
        debug_print(f"resolve_ref result for tool_resources: {value}")

    return value

def handle_complex_schema(openapi_spec, schema):
    """Central dispatcher for schema processing.

    Routes a raw schema object to the appropriate handler based on which
    OpenAPI keywords are present ($ref, properties, allOf, anyOf, oneOf,
    array items, format). Returns a normalized dict with 'type',
    'description', 'properties', etc. ready for property extraction.

    Dispatch priority (first match wins):
      1. $ref          → handle_ref_schema
      2. properties + allOf → handle_properties_with_allOf_schema
      3. properties    → handle_properties_schema
      4. allOf         → handle_allOf_schema
      5. anyOf         → handle_anyOf_schema
      6. oneOf         → handle_oneOf_schema
      7. array + items → handle_array_schema
      8. format        → handle_format_schema
    """
    if DEBUG_TOOL_RESOURCES and isinstance(schema, dict) and 'tool_resources' in schema:
        debug_print(f"handle_complex_schema encountered tool_resources: {schema}")
    if isinstance(schema, dict):
        if '$ref' in schema:
            return handle_ref_schema(openapi_spec, schema)
        elif 'properties' in schema and 'allOf' in schema:
            return handle_properties_with_allOf_schema(openapi_spec, schema)
        elif 'properties' in schema:
            return handle_properties_schema(openapi_spec, schema)
        elif 'allOf' in schema:
            return handle_allOf_schema(openapi_spec, schema)
        elif 'anyOf' in schema:
            return handle_anyOf_schema(openapi_spec, schema)
        elif 'oneOf' in schema:
            return handle_oneOf_schema(openapi_spec, schema)
        elif schema.get('type') == 'array' and 'items' in schema:
            return handle_array_schema(openapi_spec, schema)
        elif 'format' in schema:
            return handle_format_schema(schema)
    elif isinstance(schema, list):
        return handle_list_schema(openapi_spec, schema)
    return schema

def handle_ref_schema(openapi_spec, schema):
    """Resolve a $ref schema and return a type link + description.

    Produces a Markdown link like [SchemaName](#schemaname) so the generated
    docs cross-reference the full component definition.
    """
    ref_path = schema['$ref']
    resolved_schema = resolve_ref(openapi_spec, ref_path)
    ref_parts = ref_path.split('/')
    ref_name = ref_parts[-1]
    return {
        'type': f"[{ref_name}](#{convert_to_anchor(ref_name)})",
        'description': safe_description_handling(resolved_schema.get('description', '')),
        'default': resolved_schema.get('default', ''),
    }

def handle_properties_schema(openapi_spec, schema):
    """Process a schema with explicit 'properties' (no allOf).

    Recursively processes each property value through handle_complex_schema,
    then returns a normalized dict with type='object', the processed
    properties, required list, description, and discriminator.
    """
    required_properties = schema.get('required', [])
    properties = {k: handle_complex_schema(openapi_spec, v) for k, v in schema['properties'].items()}
    return {
        'type': 'object',
        'properties': properties,
        'required': required_properties,
        'description': safe_description_handling(schema.get('description', '')),
        'discriminator': schema.get('discriminator', None)
    }

def handle_properties_with_allOf_schema(openapi_spec, schema):
    """Handle schemas that have both 'properties' and 'allOf' (inheritance pattern).
    Resolves allOf refs to get base properties, then merges with local properties."""
    combined_properties = {}
    combined_required = []

    # Resolve allOf to get inherited base properties
    for sub_schema in schema['allOf']:
        if '$ref' in sub_schema:
            resolved = resolve_ref(openapi_spec, sub_schema['$ref'])
            combined_properties.update(resolved.get('properties', {}))
            combined_required.extend(resolved.get('required', []))
        else:
            combined_properties.update(sub_schema.get('properties', {}))
            combined_required.extend(sub_schema.get('required', []))

    # Overlay local properties (local takes precedence over inherited)
    combined_properties.update(schema['properties'])
    combined_required.extend(schema.get('required', []))

    # Build a combined schema and process through the standard handler
    combined_schema = {
        'properties': combined_properties,
        'required': list(set(combined_required)),
        'description': schema.get('description', ''),
        'discriminator': schema.get('discriminator', None)
    }
    return handle_properties_schema(openapi_spec, combined_schema)

def handle_allOf_schema(openapi_spec, schema):
    """Handle allOf composition by merging all sub-schemas into one.

    After merging, preserves sibling keywords (description, nullable,
    readOnly, type, default) from the parent schema that aren't already
    present in the merged result.
    """
    all_of_schemas = schema['allOf']
    merged_schema = merge_schemas(openapi_spec, all_of_schemas)

    # Preserve sibling keywords from the parent schema alongside allOf
    for key in ('description', 'nullable', 'readOnly', 'type', 'default'):
        if key in schema and key not in merged_schema:
            merged_schema[key] = schema[key]

    return merged_schema

def handle_anyOf_schema(openapi_spec, schema):
    """Handle anyOf composition schemas.

    Recognizes several common patterns:
      1. Model enum: anyOf with [type_info, {enum: [...]}] → model picker
      2. Extensible enum: anyOf with two strings, one having enum values
      3. General case: builds an "X or Y or Z" type string from each branch
    """
    any_of_schemas = schema['anyOf']

    # Special handling for model enums with anyOf pattern
    if any_of_schemas and len(any_of_schemas) == 2 and 'enum' in any_of_schemas[1]:
        # This is likely a model enum list
        enum_values = any_of_schemas[1].get('enum', [])
        type_info = any_of_schemas[0].get('type', 'string')
        return {
            'type': f"{type_info} (see valid models below)",
            'description': schema.get('description', ''),
            'enum_values': enum_values,
            'is_model_enum': True
        }

    # Extensible enum pattern: anyOf with string + string-with-enum (enum in first element)
    if (any_of_schemas and len(any_of_schemas) == 2
            and any_of_schemas[0].get('type') == 'string'
            and any_of_schemas[1].get('type') == 'string'
            and ('enum' in any_of_schemas[0] or 'enum' in any_of_schemas[1])):
        enum_source = any_of_schemas[0] if 'enum' in any_of_schemas[0] else any_of_schemas[1]
        return {
            'type': 'string',
            'description': safe_description_handling(schema.get('description', '')),
            'enum': enum_source.get('enum', []),
        }

    # Build type descriptions for each branch instead of merging
    any_of_types = []
    for sub_schema in any_of_schemas:
        if '$ref' in sub_schema:
            ref_parts = sub_schema['$ref'].split('/')
            ref_name = ref_parts[-1]
            any_of_types.append(f"[{ref_name}](#{convert_to_anchor(ref_name)})")
        elif sub_schema.get('type') == 'array' and 'items' in sub_schema:
            handled = handle_array_schema(openapi_spec, sub_schema)
            any_of_types.append(handled['type'])
        elif 'allOf' in sub_schema:
            handled = handle_allOf_schema(openapi_spec, sub_schema)
            any_of_types.append(handled.get('type', 'object'))
        elif 'type' in sub_schema:
            any_of_types.append(sub_schema['type'])
        else:
            any_of_types.append('object')

    result = {
        'type': ' or '.join(any_of_types),
    }
    desc = safe_description_handling(schema.get('description', ''))
    if desc:
        result['description'] = desc
    return result

def handle_oneOf_schema(openapi_spec, schema):
    """Handle oneOf composition by building a union type string.

    Each branch is processed through handle_complex_schema and contributes
    to an "X or Y or Z" type. Also collects per-branch details (type + description)
    for richer downstream rendering.
    """
    one_of_schemas = schema['oneOf']
    one_of_types = []
    one_of_description = schema.get('description', '')
    one_of_details = []

    for sub_schema in one_of_schemas:
        handled_schema = handle_complex_schema(openapi_spec, sub_schema)
        if 'type' in handled_schema:
            one_of_types.append(handled_schema['type'])
        elif 'properties' in handled_schema:
            one_of_types.append('object')

        # Store more details about each option
        if '$ref' in sub_schema:
            ref_path = sub_schema['$ref']
            ref_parts = ref_path.split('/')
            ref_name = ref_parts[-1]
            one_of_details.append({
                'type': f"[{ref_name}](#{convert_to_anchor(ref_name)})",
                'description': handled_schema.get('description', '')
            })
        else:
            one_of_details.append({
                'type': handled_schema.get('type', 'object'),
                'description': handled_schema.get('description', '')
            })

    return {
        'type': ' or '.join(one_of_types),
        'description': one_of_description,
        'oneOf_details': one_of_details
    }

def handle_array_schema(openapi_spec, schema):
    """Handle array schemas by resolving the items type."""
    items = schema['items']
    if '$ref' in items:
        ref_path = items['$ref']
        ref_parts = ref_path.split('/')
        ref_name = ref_parts[-1]
        item_type = f"[{ref_name}](#{convert_to_anchor(ref_name)})"
    elif 'anyOf' in items:
        any_of_types = []
        for sub in items['anyOf']:
            if '$ref' in sub:
                rp = sub['$ref'].split('/')
                any_of_types.append(f"[{rp[-1]}](#{convert_to_anchor(rp[-1])})")
            elif 'type' in sub:
                any_of_types.append(sub['type'])
            else:
                any_of_types.append('object')
        item_type = ' or '.join(any_of_types)
    elif 'oneOf' in items:
        one_of_types = []
        for sub in items['oneOf']:
            if '$ref' in sub:
                rp = sub['$ref'].split('/')
                one_of_types.append(f"[{rp[-1]}](#{convert_to_anchor(rp[-1])})")
            elif 'type' in sub:
                one_of_types.append(sub['type'])
            else:
                one_of_types.append('object')
        item_type = ' or '.join(one_of_types)
    elif 'type' in items:
        item_type = items['type']
    else:
        item_type = 'object'
    result = {
        'type': f"array of {item_type}",
        'default': schema.get('default', ''),
    }
    desc = safe_description_handling(schema.get('description', ''))
    if desc:
        result['description'] = desc
    return result

def handle_format_schema(schema):
    """Handle schemas that specify a 'format' (e.g. date-time, uri, binary).

    Returns type, format, description, and default for simple formatted types.
    """
    return {
        'type': schema.get('type', ''),
        'format': schema['format'],
        'description': safe_description_handling(schema.get('description', '')),
        'default': schema.get('default', ''),
    }

def handle_list_schema(openapi_spec, schema):
    """Process a list of schemas (rare edge case where schema is an array)."""
    return [handle_complex_schema(openapi_spec, item) for item in schema]

# --- Schema merging ---

def merge_schemas(openapi_spec, schemas):
    """Merge a list of sub-schemas (from allOf) into a single combined schema.

    Merges properties, required lists, type info, description (first-wins),
    and enum values. For $ref sub-schemas, also re-resolves the ref to pull
    in raw properties/required that may not survive handle_complex_schema
    processing (e.g. when a ref resolves to a type link rather than an object).
    """
    merged_schema = {}
    for sub_schema in schemas:
        handled_schema = handle_complex_schema(openapi_spec, sub_schema)
        if 'properties' in handled_schema:
            merged_schema.setdefault('properties', {}).update(handled_schema['properties'])
        if 'required' in handled_schema:
            merged_schema.setdefault('required', []).extend(handled_schema['required'])
        if 'type' in handled_schema:
            if 'type' not in merged_schema:
                merged_schema['type'] = handled_schema['type']
            elif merged_schema['type'] != handled_schema['type']:
                if isinstance(merged_schema['type'], list):
                    if handled_schema['type'] not in merged_schema['type']:
                        merged_schema['type'].append(handled_schema['type'])
                else:
                    merged_schema['type'] = [merged_schema['type'], handled_schema['type']]
        if 'description' in handled_schema and 'description' not in merged_schema:
            merged_schema['description'] = handled_schema['description']
        if 'enum' in handled_schema and 'enum' not in merged_schema:
            merged_schema['enum'] = handled_schema['enum']
        if '$ref' in sub_schema:
            ref_path = sub_schema['$ref']
            resolved_schema = resolve_ref(openapi_spec, ref_path)
            merged_schema.setdefault('properties', {}).update(resolved_schema.get('properties', {}))
            merged_schema.setdefault('required', []).extend(resolved_schema.get('required', []))
    return merged_schema

def handle_discriminator(schema, component_name):
    """Generate Markdown for a discriminator mapping table.

    Renders a table showing which property value maps to which schema,
    with links to the referenced component definitions.
    """
    if 'discriminator' not in schema:
        return ""

    discriminator = schema['discriminator']
    property_name = discriminator.get('propertyName', '')
    mapping = discriminator.get('mapping', {})

    markdown = f"\n### Discriminator for {component_name}\n\n"
    markdown += f"This component uses the property `{property_name}` to discriminate between different types:\n\n"
    markdown += "| Type Value | Schema |\n"
    markdown += "|------------|--------|\n"

    for type_value, ref in mapping.items():
        ref_parts = ref.split('/')
        ref_name = ref_parts[-1]
        markdown += f"| `{type_value}` | [{ref_name}](#{convert_to_anchor(ref_name)}) |\n"

    return markdown.rstrip()

# --- Property extraction and table generation ---

def extract_properties_from_schema(openapi_spec, schema, required_list, indent_level=0, parent_prefix=''):
    """Recursively extract property metadata from a schema for table rendering.

    Walks schema['properties'], resolving each property value through
    handle_complex_schema. Handles special patterns (model enums, plain enums)
    and recursively descends into nested object properties and inline array
    item properties to produce indented child rows.

    Args:
        openapi_spec: The full OpenAPI spec (for $ref resolution).
        schema: A schema dict containing 'properties'.
        required_list: List of required property names at this level.
        indent_level: Current nesting depth (0 = top-level).
        parent_prefix: Dotted path prefix for nested properties (e.g. 'config.').

    Returns:
        dict mapping full dotted property names to metadata dicts containing
        type, description, required, default, example, nullable, readOnly,
        deprecated, constraints, and indent_level.
    """
    properties = schema.get('properties', {})
    properties_data = {}

    for prop_name, prop_schema in properties.items():
        full_prop_name = f"{parent_prefix}{prop_name}"

        # Special handling for model property with anyOf and enum
        if prop_name == 'model' and 'anyOf' in prop_schema:
            model_info = handle_anyOf_schema(openapi_spec, prop_schema)
            if model_info.get('is_model_enum', False):
                properties_data[full_prop_name] = {
                    'type': model_info.get('type', 'string'),
                    'description': safe_description_handling(model_info.get('description', '')),
                    'required': prop_name in required_list,
                    'enum_values': model_info.get('enum_values', []),
                    'indent_level': indent_level,
                    'is_model_enum': True
                }
                continue

        # Check for enums without x-ms-enum
        if 'enum' in prop_schema and 'x-ms-enum' not in prop_schema:
            # Handle the enum without x-ms-enum
            enum_values = prop_schema['enum']
            enum_description = safe_description_handling(prop_schema.get('description', ''))

            properties_data[full_prop_name] = {
                'type': 'enum',
                'description': enum_description,
                'required': prop_name in required_list,
                'enum_values': enum_values,
                'indent_level': indent_level
            }
        else:
            resolved_prop_schema = handle_complex_schema(openapi_spec, prop_schema)
            type_info = resolved_prop_schema.get('type', '')

            # Preserve type links from already-processed schemas (e.g. allOf $ref merges)
            # handle_properties_schema pre-processes values; reprocessing loses link types
            original_type = prop_schema.get('type', '')
            if isinstance(original_type, str) and '[' in original_type and type_info == 'object':
                type_info = original_type

            # Safely handle potentially dictionary descriptions
            prop_description = safe_description_handling(prop_schema.get('description', '')) or safe_description_handling(resolved_prop_schema.get('description', ''))

            if '$ref' in prop_schema:
                ref_path = prop_schema['$ref']
                ref_parts = ref_path.split('/')
                ref_name = ref_parts[-1]
                type_info = f"[{ref_name}](#{convert_to_anchor(ref_name)})"

            prop_required = prop_name in required_list
            prop_default = prop_schema['default'] if 'default' in prop_schema else resolved_prop_schema.get('default', '')
            prop_example = resolved_prop_schema.get('example', '')
            prop_nullable = prop_schema.get('nullable', False) or resolved_prop_schema.get('nullable', False)
            prop_read_only = prop_schema.get('readOnly', False) or resolved_prop_schema.get('readOnly', False)
            prop_deprecated = prop_schema.get('deprecated', False) or resolved_prop_schema.get('deprecated', False)

            # Extract validation constraints from both raw and resolved schemas
            constraints = {}
            for src in (prop_schema, resolved_prop_schema):
                if not isinstance(src, dict):
                    continue
                for key in ('minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum',
                            'minLength', 'maxLength', 'pattern', 'minItems', 'maxItems'):
                    if key in src and key not in constraints:
                        constraints[key] = src[key]

            properties_data[full_prop_name] = {
                'type': type_info,
                'description': prop_description,
                'required': prop_required,
                'default': prop_default,
                'example': prop_example,
                'nullable': prop_nullable,
                'readOnly': prop_read_only,
                'deprecated': prop_deprecated,
                'constraints': constraints,
                'indent_level': indent_level
            }

            # Add nested properties directly to the properties_data
            if isinstance(prop_schema, dict) and 'properties' in prop_schema:
                nested_required = prop_schema.get('required', [])
                nested_props = extract_properties_from_schema(
                    openapi_spec,
                    prop_schema,
                    nested_required,
                    indent_level + 1,
                    f"{full_prop_name}."
                )
                properties_data.update(nested_props)
            # Expand inline array items with properties
            elif (isinstance(prop_schema, dict)
                    and prop_schema.get('type') == 'array'
                    and isinstance(prop_schema.get('items'), dict)
                    and 'properties' in prop_schema['items']):
                items_schema = prop_schema['items']
                nested_required = items_schema.get('required', [])
                nested_props = extract_properties_from_schema(
                    openapi_spec,
                    items_schema,
                    nested_required,
                    indent_level + 1,
                    f"{full_prop_name}[]."
                )
                properties_data.update(nested_props)

    return properties_data

def generate_property_table(properties_data):
    """Render extracted property data as a Markdown table.

    Produces a table with columns: Name, Type, Description, Required, Default.
    Nested properties are indented with └─ prefixes. Type qualifiers (nullable,
    read-only, deprecated) are appended in parentheses. Validation constraints
    and enum values are appended to descriptions.
    """
    table_headers = "| Name | Type | Description | Required | Default |\n"
    table_divider = "|------|------|-------------|----------|---------|\n"
    table_rows = ""

    # Sort properties to keep parent properties before their children
    sorted_props = sorted(properties_data.items(), key=lambda x: x[0])

    for full_prop_name, prop_info in sorted_props:
        indent_level = prop_info['indent_level']

        # Extract just the property name (not the full path)
        if "." in full_prop_name:
            prop_name = full_prop_name.split(".")[-1]
        else:
            prop_name = full_prop_name

        # Create the indentation prefix
        if indent_level > 0:
            indent_prefix = "└─ " if indent_level == 1 else "  " * (indent_level - 1) + "└─ "
            prop_display_name = f"{indent_prefix}{prop_name}"
        else:
            prop_display_name = prop_name

        required = 'Yes' if prop_info['required'] else 'No'
        # Ensure description is a properly formatted string
        description = safe_description_handling(prop_info['description']).replace('\n', '
').replace('|', '|') # Append validation constraints to description constraints = prop_info.get('constraints', {}) if constraints: constraint_parts = [] if 'minimum' in constraints: constraint_parts.append(f"min: {constraints['minimum']}") if 'maximum' in constraints: constraint_parts.append(f"max: {constraints['maximum']}") if 'exclusiveMinimum' in constraints: constraint_parts.append(f"exclusiveMin: {constraints['exclusiveMinimum']}") if 'exclusiveMaximum' in constraints: constraint_parts.append(f"exclusiveMax: {constraints['exclusiveMaximum']}") if 'minLength' in constraints: constraint_parts.append(f"minLength: {constraints['minLength']}") if 'maxLength' in constraints: constraint_parts.append(f"maxLength: {constraints['maxLength']}") if 'pattern' in constraints: constraint_parts.append(f"pattern: `{constraints['pattern']}`") if 'minItems' in constraints: constraint_parts.append(f"minItems: {constraints['minItems']}") if 'maxItems' in constraints: constraint_parts.append(f"maxItems: {constraints['maxItems']}") if constraint_parts: constraint_str = ', '.join(constraint_parts) description = f"{description}
**Constraints:** {constraint_str}" if description else f"**Constraints:** {constraint_str}" default_value = str(prop_info.get('default', '')).replace('|', '|') # Build type string with nullable/readOnly suffixes type_str = prop_info.get('type', '') qualifiers = [] if prop_info.get('nullable', False): qualifiers.append('nullable') if prop_info.get('readOnly', False): qualifiers.append('read-only') if prop_info.get('deprecated', False): qualifiers.append('deprecated') if qualifiers: type_str = f"{type_str} ({', '.join(qualifiers)})" if type_str else ', '.join(qualifiers) if prop_info.get('is_model_enum', False): # For model enum, we'll add a note and list values later table_rows += f"| {prop_display_name} | {type_str} | {description} | {required} | {default_value} |\n" elif prop_info.get('type') == 'enum': enum_values = ', '.join([f"`{v}`" for v in prop_info['enum_values']]) qualifier_suffix = f" ({', '.join(qualifiers)})" if qualifiers else "" table_rows += f"| {prop_display_name} | enum{qualifier_suffix} | {description}
Possible values: {enum_values} | {required} | {default_value} |\n" else: table_rows += f"| {prop_display_name} | {type_str} | {description} | {required} | {default_value} |\n" return table_headers + table_divider + table_rows def generate_model_enum_documentation(properties_data): """Generate an 'Available Models' section if any property is a model enum. Returns a Markdown code block listing all valid model identifiers, or an empty string if no model enum property is present. """ for prop_name, prop_info in properties_data.items(): if prop_info.get('is_model_enum', False) and prop_info.get('enum_values'): model_docs = "\n### Available Models\n\n" model_docs += "The following model identifiers are supported:\n\n```\n" for model in prop_info['enum_values']: model_docs += f"{model}\n" model_docs += "```\n" return model_docs return "" def generate_enum_info(schema, skip_description=False): """Generate a Markdown property/value table for an enum schema. Renders type, nullable, default, and all enum values. The description row can be skipped when it's already rendered as a heading above. """ enum_info = "" enum_values = schema.get('enum', []) enum_description = schema.get('description', '').replace('\n', '
') enum_default = schema.get('default', '') # Create a comprehensive table for the enum component enum_info += "| Property | Value |\n" enum_info += "|----------|-------|\n" if enum_description and not skip_description: enum_info += f"| **Description** | {enum_description} |\n" enum_info += f"| **Type** | {schema.get('type', '')} |\n" if schema.get('nullable', False): enum_info += "| **Nullable** | Yes |\n" if enum_default: enum_info += f"| **Default** | {enum_default} |\n" if enum_values: # Format enum values as a joined string with each value on a new line formatted_values = "
".join([f"`{v}`" for v in enum_values]) enum_info += f"| **Values** | {formatted_values} |\n" return enum_info def resolve_and_extract_schema(openapi_spec, content_schema, include_discriminator=False): """ Extract schema information and optionally include discriminator details Args: openapi_spec: The complete OpenAPI specification content_schema: The schema to process include_discriminator: Whether to include discriminator information or not """ schema = handle_complex_schema(openapi_spec, content_schema) required_list = schema.get('required', []) if 'required' in schema else [] properties_info = '' # Only add discriminator information if specifically requested discriminator_info = "" if include_discriminator and 'discriminator' in content_schema: discriminator_info = handle_discriminator(content_schema, "Schema") if 'additionalProperties' in content_schema and not content_schema.get('properties'): additional_properties = content_schema.get('additionalProperties', {}) # Create a description of the additionalProperties add_props_info = "This object supports additional properties.\n\n" # Check if additionalProperties has a specific type or is just a boolean if isinstance(additional_properties, dict) and additional_properties: # If there's a type specified if 'type' in additional_properties: add_props_info += f"Additional properties are of type: **{additional_properties['type']}**\n\n" # If it's a reference elif '$ref' in additional_properties: ref_path = additional_properties['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] add_props_info += f"Additional properties are of type: **[{ref_name}](#{convert_to_anchor(ref_name)})**\n\n" return add_props_info if 'properties' in schema: properties_data = extract_properties_from_schema(openapi_spec, schema, required_list) properties_info = generate_property_table(properties_data) # Add model enum documentation if present model_enum_docs = generate_model_enum_documentation(properties_data) if model_enum_docs: properties_info += model_enum_docs # Note additionalProperties when schema also has properties if 'additionalProperties' in content_schema: add_props = content_schema['additionalProperties'] if isinstance(add_props, dict) and 'type' in add_props: properties_info += f"\n> This object also accepts additional properties of type: **{add_props['type']}**\n\n" elif isinstance(add_props, dict) and '$ref' in add_props: ref_parts = add_props['$ref'].split('/') ref_name = ref_parts[-1] properties_info += f"\n> This object also accepts additional properties of type: **[{ref_name}](#{convert_to_anchor(ref_name)})**\n\n" else: properties_info += "\n> This object also accepts additional properties.\n\n" elif 'enum' in schema: properties_info = generate_enum_info(schema) elif 'anyOf' in content_schema: any_of_schema = content_schema['anyOf'] # If handle_complex_schema already produced a useful type with links, use it schema_type = schema.get('type', '') if schema_type and '[' in schema_type: # Type string contains markdown links — render it directly desc = safe_description_handling(schema.get('description', content_schema.get('description', ''))) properties_info = f"**Type**: {schema_type}\n\n" if desc: properties_info += f"{desc}\n\n" else: # If anyOf has enum values in one of its options enum_info = "" for option in any_of_schema: if 'enum' in option: enum_values = option.get('enum', []) type_info = option.get('type', '') description = option.get('description', '') or content_schema.get('description', '') # Generate enum table enum_info += "| Property | Value |\n" enum_info += "|----------|-------|\n" if description: enum_info += f"| **Description** | {description} |\n" enum_info += f"| **Type** | {type_info} |\n" if enum_values: # Format enum values as a joined string with each value on a new line formatted_values = "
".join([f"`{v}`" for v in enum_values]) enum_info += f"| **Values** | {formatted_values} |\n" properties_info = enum_info break # If no enum was found but we still have anyOf schema options if not properties_info and any_of_schema: # Check if branches are inline objects with properties — if so, merge them has_inline_props = any( isinstance(opt, dict) and 'properties' in opt for opt in any_of_schema ) if has_inline_props: merged_properties = {} merged_required = [] for option in any_of_schema: if isinstance(option, dict) and 'properties' in option: option_processed = handle_complex_schema(openapi_spec, option) if 'properties' in option_processed: sub_props = extract_properties_from_schema( openapi_spec, option_processed, option_processed.get('required', [])) merged_properties.update(sub_props) if 'required' in option_processed: merged_required.extend(option_processed['required']) if merged_properties: properties_info = generate_property_table(merged_properties) else: properties_info = "No properties defined.\n\n" else: any_of_info = "This schema accepts one of the following types:\n\n" for option in any_of_schema: if '$ref' in option: ref_parts = option['$ref'].split('/') ref_name = ref_parts[-1] any_of_info += f"- [{ref_name}](#{convert_to_anchor(ref_name)})\n" else: option_type = option.get('type', '') option_desc = option.get('description', '') if option_type: any_of_info += f"- **{option_type}**" if option_desc: any_of_info += f": {option_desc}" any_of_info += "\n" properties_info = any_of_info elif 'oneOf' in content_schema: one_of_types = content_schema['oneOf'] one_of_info = "This schema can be one of the following types:\n\n" for one_of_type in one_of_types: if '$ref' in one_of_type: ref_path = one_of_type['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] one_of_info += f"- [{ref_name}](#{convert_to_anchor(ref_name)})\n" else: one_of_type_info = handle_complex_schema(openapi_spec, one_of_type) type_info = one_of_type_info.get('type', '') description = one_of_type_info.get('description', '') required = one_of_type_info.get('required', []) properties = one_of_type_info.get('properties', {}) if type_info == 'object' and not description and not required and not properties: continue if type_info: one_of_info += f"**Type**: {type_info}\n\n" if description: one_of_info += f"**Description**: {description}\n\n" if required: one_of_info += f"**Required**: {', '.join(required)}\n\n" if properties: one_of_info += "**Properties**:\n\n" properties_data = extract_properties_from_schema(openapi_spec, {'properties': properties}, []) properties_table = generate_property_table(properties_data) one_of_info += properties_table + "\n" one_of_info += "---\n\n" properties_info = one_of_info.strip() # Ensure proper spacing between discriminator info and properties info if discriminator_info and properties_info: return f"{discriminator_info}\n\n{properties_info}" else: return discriminator_info or properties_info def extract_response_type_info(openapi_spec, schema): """Extract detailed type information from response schema with proper anyOf handling""" # Handle direct references if '$ref' in schema: ref_path = schema['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] return f"[{ref_name}](#{convert_to_anchor(ref_name)})", "" # Handle anyOf schemas by listing all options if 'anyOf' in schema: any_of_types = [] description = schema.get('description', '') for sub_schema in schema['anyOf']: if '$ref' in sub_schema: ref_path = sub_schema['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] any_of_types.append(f"[{ref_name}](#{convert_to_anchor(ref_name)})") elif 'type' in sub_schema and sub_schema['type'] == 'array' and 'items' in sub_schema: items = sub_schema['items'] item_type, _ = extract_response_type_info(openapi_spec, items) any_of_types.append(f"array of {item_type}") elif 'type' in sub_schema: any_of_types.append(sub_schema['type']) else: any_of_types.append("object") return " or ".join(any_of_types), description # Handle arrays if 'type' in schema and schema['type'] == 'array' and 'items' in schema: items = schema['items'] item_type, _ = extract_response_type_info(openapi_spec, items) return f"array of {item_type}", schema.get('description', '') # Handle basic types if 'type' in schema: return schema['type'], schema.get('description', '') # Default to object for any other case return "object", schema.get('description', '') def generate_response_table(status_code, description, content_info): """Generate a markdown table for a specific response""" response_table = f"**Status Code:** {status_code}\n\n" response_table += f"**Description**: {description} \n\n" if content_info: response_table += "|**Content-Type**|**Type**|**Description**|\n" response_table += "|:---|:---|:---|\n" for content_type, type_info, description in content_info: response_table += f"|{content_type} | {type_info} | {description}|\n" response_table += "\n" return response_table # --- Endpoint documentation helpers --- def format_operation_id(operation_id): """Format an operationId like 'list_users' into a heading like 'List - Users'.""" parts = operation_id.split('_') formatted_parts = [part.capitalize() for part in parts] return ' '.join(formatted_parts).replace(' ', ' - ', 1) def read_example_file(file_path): """Read and parse a JSON example file. Returns {} on error.""" try: with open(file_path, 'r') as file: example_data = json.load(file) return example_data except (FileNotFoundError, json.JSONDecodeError): print(f"Warning: Could not read example file {file_path}") return {} def generate_example_markdown(openapi_spec, example_data, path, method): """Render an x-ms-examples example as a Markdown HTTP request/response block.""" api_version = openapi_spec.get('info', {}).get('version', '') base_url = openapi_spec.get('servers', [{}])[0].get('url', '') markdown = "### Example\n\n" markdown += f"{example_data.get('title', '')}\n\n" parameters = example_data.get('parameters', {}) url_suffix = f"?api-version={api_version}" if 'api-version' in parameters else "" markdown += f"```HTTP\n{method.upper()} {base_url}{path}{url_suffix}\n\n" if 'body' in parameters: markdown += f"{json.dumps(parameters['body'], indent=1)}\n\n" markdown += "```\n\n" responses = example_data.get('responses', {}) if responses: markdown += "**Responses**:\n" for status_code, response_data in responses.items(): markdown += f"Status Code: {status_code}\n\n" markdown += f"```json\n{json.dumps(response_data, indent=2)}\n```\n" markdown += "\n" return markdown # --- Main documentation generators --- def generate_documentation_with_responses(openapi_spec): """Generate the Endpoints section of the Markdown documentation. Produces: - API title, description, version from spec info block - Server variables table - Authentication section from security schemes (OAuth2, API Key, HTTP) - All endpoint operations grouped by tag, each with: - HTTP method + path - Summary and description - URI and header parameter tables (with server variable rows) - Request body schema tables - Response tables with type info and response headers - Inline x-ms-examples (when example files are available) Returns the complete Markdown string for the endpoints portion. """ info = openapi_spec.get('info', {}) title = info.get('title', 'API Reference') markdown_docs = f"# {title}\n\n" if info.get('description'): markdown_docs += f"{info['description']}\n\n" if info.get('version'): markdown_docs += f"**API Version:** {info['version']}\n\n" # Render server info with variables servers = openapi_spec.get('servers', []) if servers: server = servers[0] base_url = server.get('url', '') variables = server.get('variables', {}) if variables: markdown_docs += "**Server Variables:**\n\n" markdown_docs += "| Variable | Default | Description |\n" markdown_docs += "| --- | --- | --- |\n" for var_name, var_def in variables.items(): var_default = var_def.get('default', '') var_desc = safe_description_handling(var_def.get('description', '')).replace('\n', '
') enum_vals = var_def.get('enum', []) if enum_vals: var_desc += f"
Possible values: {', '.join([f'`{v}`' for v in enum_vals])}" markdown_docs += f"| {var_name} | {var_default} | {var_desc} |\n" markdown_docs += "\n" else: base_url = '' variables = {} api_version = info.get('version', '') # Generate Authentication section from security schemes security_schemes = openapi_spec.get('components', {}).get('securitySchemes', {}) if security_schemes: markdown_docs += "## Authentication\n\n" for scheme_name, scheme in security_schemes.items(): scheme_type = scheme.get('type', '') if scheme_type == 'oauth2': markdown_docs += f"### {scheme_name} (OAuth 2.0)\n\n" flows = scheme.get('flows', {}) for flow_name, flow in flows.items(): markdown_docs += f"**Flow:** {flow_name}\n\n" if 'authorizationUrl' in flow: markdown_docs += f"**Authorization URL:** `{flow['authorizationUrl']}`\n\n" if 'tokenUrl' in flow: markdown_docs += f"**Token URL:** `{flow['tokenUrl']}`\n\n" scopes = flow.get('scopes', {}) if scopes: markdown_docs += "**Scopes:**\n\n" for scope, scope_desc in scopes.items(): markdown_docs += f"- `{scope}`" if scope_desc: markdown_docs += f" — {scope_desc}" markdown_docs += "\n" markdown_docs += "\n" elif scheme_type == 'apiKey': header_name = scheme.get('name', '') param_in = scheme.get('in', 'header') markdown_docs += f"### {scheme_name} (API Key)\n\n" markdown_docs += f"Pass your API key in the `{header_name}` {param_in}.\n\n" elif scheme_type == 'http': http_scheme = scheme.get('scheme', 'bearer') markdown_docs += f"### {scheme_name} (HTTP {http_scheme})\n\n" markdown_docs += f"Pass a {http_scheme} token in the `Authorization` header.\n\n" def resolve_param_type(param_schema): """Resolve a parameter schema to a type string and optional enum values. Handles $ref, anyOf, oneOf, allOf, and simple type schemas.""" if not param_schema: return '', [] # Direct $ref — resolve and recurse if '$ref' in param_schema: resolved = resolve_ref(openapi_spec, param_schema['$ref']) return resolve_param_type(resolved) # anyOf — collect enum values, determine base type if 'anyOf' in param_schema: base_type = '' all_enums = [] for sub in param_schema['anyOf']: if '$ref' in sub: resolved = resolve_ref(openapi_spec, sub['$ref']) sub_type, sub_enums = resolve_param_type(resolved) if sub_type and not base_type: base_type = sub_type all_enums.extend(sub_enums) else: if sub.get('type') and not base_type: base_type = sub['type'] if 'enum' in sub: all_enums.extend(sub['enum']) return base_type or 'string', all_enums # oneOf — same logic as anyOf if 'oneOf' in param_schema: base_type = '' all_enums = [] for sub in param_schema['oneOf']: if '$ref' in sub: resolved = resolve_ref(openapi_spec, sub['$ref']) sub_type, sub_enums = resolve_param_type(resolved) if sub_type and not base_type: base_type = sub_type all_enums.extend(sub_enums) else: if sub.get('type') and not base_type: base_type = sub['type'] if 'enum' in sub: all_enums.extend(sub['enum']) return base_type or 'string', all_enums # allOf — merge type info if 'allOf' in param_schema: base_type = '' all_enums = [] for sub in param_schema['allOf']: if '$ref' in sub: resolved = resolve_ref(openapi_spec, sub['$ref']) sub_type, sub_enums = resolve_param_type(resolved) if sub_type and not base_type: base_type = sub_type all_enums.extend(sub_enums) else: if sub.get('type') and not base_type: base_type = sub['type'] if 'enum' in sub: all_enums.extend(sub['enum']) return base_type or 'string', all_enums # Simple type with optional enum return param_schema.get('type', ''), param_schema.get('enum', []) # Collect operations grouped by tag paths = openapi_spec.get('paths', {}) tag_descriptions = {t['name']: t.get('description', '') for t in openapi_spec.get('tags', [])} tag_order = [t['name'] for t in openapi_spec.get('tags', [])] tagged_ops = {} # tag -> [(path, method, operation, path_item), ...] for path, path_item in paths.items(): for method, operation in path_item.items(): if method in ['parameters', '$ref']: continue op_tags = operation.get('tags', []) tag = op_tags[0] if op_tags else '(Other)' if tag not in tagged_ops: tagged_ops[tag] = [] tagged_ops[tag].append((path, method, operation, path_item)) # Build ordered tag list: defined tags first, then any extras all_tags = list(tag_order) for tag in tagged_ops: if tag not in all_tags: all_tags.append(tag) # Document endpoint operations grouped by tag for tag in all_tags: if tag not in tagged_ops: continue markdown_docs += f"## {tag}\n\n" if tag_descriptions.get(tag): markdown_docs += f"{tag_descriptions[tag]}\n\n" for path, method, operation, path_item in tagged_ops[tag]: operation_id = operation.get('operationId', '') formatted_operation_id = format_operation_id(operation_id) markdown_docs += f"### {formatted_operation_id}\n\n" # Check if operation has api-version query param has_api_version = any( (resolve_ref(openapi_spec, p['$ref']) if '$ref' in p else p).get('name') == 'api-version' for p in operation.get('parameters', []) + path_item.get('parameters', []) ) url_suffix = f"?api-version={api_version}" if has_api_version else "" markdown_docs += f"```HTTP\n{method.upper()} {base_url}{path}{url_suffix}\n```\n\n" # Append summary if available markdown_docs += f"{operation.get('summary', '')}\n" # Append description if available markdown_docs += f"{operation.get('description', '')}\n" # Merge path-level and operation-level parameters (operation overrides path) path_level_params = path_item.get('parameters', []) op_params = operation.get('parameters', []) # Build lookup of operation params by (name, in) to allow overriding op_param_keys = set() for p in op_params: resolved_p = resolve_ref(openapi_spec, p['$ref']) if '$ref' in p else p op_param_keys.add((resolved_p.get('name'), resolved_p.get('in'))) parameters = list(op_params) for p in path_level_params: resolved_p = resolve_ref(openapi_spec, p['$ref']) if '$ref' in p else p if (resolved_p.get('name'), resolved_p.get('in')) not in op_param_keys: parameters.append(p) uri_params = [] header_params = [] for parameter in parameters: # Handle parameter references if '$ref' in parameter: ref_path = parameter['$ref'] parameter = resolve_ref(openapi_spec, ref_path) param_in = parameter.get('in', '') if param_in == 'header': header_params.append(parameter) else: uri_params.append(parameter) if uri_params or parameters: markdown_docs += "\n#### URI Parameters\n\n" markdown_docs += "| Name | In | Required | Type | Description |\n" markdown_docs += "|------|------|----------|------|-----------|\n" # Add rows for server variables referenced in the base URL for var_name, var_def in variables.items(): var_desc = safe_description_handling(var_def.get('description', '')).replace('\n', '
') markdown_docs += f"| {var_name} | path | Yes | string | {var_desc} |\n" for parameter in uri_params: param_name = parameter.get('name', 'unknown') param_schema = parameter.get('schema', {}) param_required = parameter.get('required', False) param_in = parameter.get('in', '') param_description = safe_description_handling(parameter.get('description', '')).replace('\n', '
') param_type, param_enums = resolve_param_type(param_schema) if param_enums: param_type += f"
Possible values: {', '.join([f'`{v}`' for v in param_enums])}" markdown_docs += f"| {param_name} | {param_in} | {'Yes' if param_required else 'No'} | {param_type} | {param_description} |\n" markdown_docs += "\n" if header_params: markdown_docs += "#### Request Header\n\n" markdown_docs += "| Name | Required | Type | Description |\n" markdown_docs += "| --- | --- | --- | --- |\n" for parameter in header_params: param_name = parameter.get('name', 'unknown') param_schema = parameter.get('schema', {}) param_required = parameter.get('required', False) param_description = safe_description_handling(parameter.get('description', '')).replace('\n', '
') param_type, param_enums = resolve_param_type(param_schema) if param_enums: param_type += f"
Possible values: {', '.join([f'`{v}`' for v in param_enums])}" markdown_docs += f"| {param_name} | {'True' if param_required else 'False'} | {param_type} | {param_description} |\n" # Append request body info request_body = operation.get('requestBody', {}) if isinstance(request_body, dict) and '$ref' in request_body: request_body = resolve_ref(openapi_spec, request_body['$ref']) if request_body: content = request_body.get('content', {}) for content_type, content_item in content.items(): markdown_docs += f"#### Request Body\n\n" markdown_docs += f"**Content-Type**: {content_type}\n\n" content_schema = content_item.get('schema', {}) if '$ref' in content_schema: ref_path = content_schema['$ref'] resolved_schema = resolve_ref(openapi_spec, ref_path) # Don't include discriminator here - it will be handled in component docs properties_info = resolve_and_extract_schema(openapi_spec, resolved_schema, include_discriminator=False) else: properties_info = resolve_and_extract_schema(openapi_spec, content_schema, include_discriminator=False) markdown_docs += properties_info if properties_info else "No request body parameters.\n\n" # Append responses info using the improved functions responses = operation.get('responses', {}) if responses: markdown_docs += "\n#### Responses\n\n" # Sort responses, with "default" coming last sorted_responses = sorted(responses.items(), key=lambda x: float('inf') if x[0] == "default" else int(x[0]) if x[0].isdigit() else float('inf')) for status_code, response in sorted_responses: if isinstance(response, dict) and '$ref' in response: response = resolve_ref(openapi_spec, response['$ref']) description = response.get('description', '') response_content = response.get('content', {}) content_info = [] for content_type, content_item in response_content.items(): schema = content_item.get('schema', {}) type_info, schema_description = extract_response_type_info(openapi_spec, schema) # If no description was returned, use the one from the schema if not schema_description: schema_description = schema.get('description', '') content_info.append((content_type, type_info, schema_description)) markdown_docs += generate_response_table(status_code, description, content_info) # Render response headers if present response_headers = response.get('headers', {}) if response_headers: markdown_docs += "**Response Headers:**\n\n" markdown_docs += "| Header | Type | Description |\n" markdown_docs += "| --- | --- | --- |\n" for header_name, header_def in response_headers.items(): if '$ref' in header_def: header_def = resolve_ref(openapi_spec, header_def['$ref']) h_schema = header_def.get('schema', {}) h_type = h_schema.get('type', '') if isinstance(h_schema, dict) else '' h_desc = safe_description_handling(header_def.get('description', '')).replace('\n', '
') markdown_docs += f"| {header_name} | {h_type} | {h_desc} |\n" markdown_docs += "\n" # Append examples examples = operation.get('x-ms-examples', {}) if examples: markdown_docs += "#### Examples\n\n" for example_name, example_ref in examples.items(): example_file_path = example_ref.get('$ref', '').replace('#', '') if os.path.exists(example_file_path): example_data = read_example_file(example_file_path) example_markdown = generate_example_markdown(openapi_spec, example_data, path, method) markdown_docs += example_markdown else: markdown_docs += f"Example file not found: {example_file_path}\n\n" return markdown_docs def generate_component_markdown(openapi_spec): """Generate the Components section of the Markdown documentation. Iterates over all component schemas and renders each as a subsection with: - Description - Discriminator mapping table (if present) - Property tables for object schemas - Enum value tables for enum schemas - Array item type for array schemas - Simple type info for scalar schemas - oneOf branch listing (with sub-schema details and x-ms-enum tables) - x-ms-enum tables for individual properties Returns the complete Markdown string for the components portion. """ components = openapi_spec.get('components', {}) schemas = components.get('schemas', {}) markdown_docs = "## Components\n\n" for schema_name, schema in schemas.items(): markdown_docs += f"### {schema_name}\n\n" schema_description = schema.get('description', '') if schema_description: markdown_docs += f"{schema_description}\n\n" # Add discriminator information if present - ensure proper spacing if 'discriminator' in schema: discriminator_info = handle_discriminator(schema, schema_name) markdown_docs += discriminator_info + "\n\n" # Always add two newlines after discriminator # Handle single $ref component if '$ref' in schema: ref_path = schema['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] markdown_docs += f"References: [{ref_name}](#{convert_to_anchor(ref_name)})\n\n" continue # Skip the rest of the processing for this reference-only schema # Special handling for model enums in properties if 'properties' in schema and 'model' in schema['properties']: model_prop = schema['properties']['model'] if 'anyOf' in model_prop and len(model_prop['anyOf']) == 2 and 'enum' in model_prop['anyOf'][1]: enum_values = model_prop['anyOf'][1].get('enum', []) markdown_docs += "\n**Valid models:**\n\n" markdown_docs += "```\n" + "\n".join(enum_values) + "\n```\n\n" # Special handling for enums if 'enum' in schema: markdown_docs += generate_enum_info(schema, skip_description=True) # Handle array type components elif schema.get('type') == 'array' and 'items' in schema: items = schema['items'] item_type_info = "" if '$ref' in items: ref_path = items['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] item_type_info = f"**Array of**: [{ref_name}](#{convert_to_anchor(ref_name)})\n\n" elif 'type' in items: item_type_info = f"**Array of**: {items['type']}\n\n" if 'description' in items: item_type_info += f"{items['description']}\n\n" markdown_docs += item_type_info # Handle simple type components (like boolean, string, etc.) elif 'type' in schema and not 'properties' in schema and not 'oneOf' in schema: markdown_docs += f"**Type**: {schema['type']}\n\n" if schema.get('nullable', False): markdown_docs += "**Nullable**: Yes\n\n" if 'format' in schema: markdown_docs += f"**Format**: {schema['format']}\n\n" if 'default' in schema: markdown_docs += f"**Default**: {schema['default']}\n\n" # Continue with regular property documentation - no discriminator needed here elif 'oneOf' in schema: one_of_schemas = schema['oneOf'] markdown_docs += "This component can be one of the following:\n\n" for one_of_schema in one_of_schemas: if 'properties' in one_of_schema: event_property = one_of_schema['properties'].get('event', {}) event_enum = event_property.get('enum', []) event_name = event_enum[0] if event_enum else '' markdown_docs += f"### {event_name}\n\n" description = one_of_schema.get('description', '') markdown_docs += f"{description}\n\n" # No discriminator in sub-schemas properties_info = resolve_and_extract_schema(openapi_spec, one_of_schema, include_discriminator=False) markdown_docs += properties_info if properties_info else "No additional properties.\n\n" data_ref = one_of_schema['properties'].get('data', {}).get('$ref', '') if data_ref: ref_parts = data_ref.split('/') ref_name = ref_parts[-1] markdown_docs += f"\n\n**Data**: [{ref_name}](#{convert_to_anchor(ref_name)})\n\n" if 'x-ms-enum' in event_property: x_ms_enum = event_property['x-ms-enum'] enum_name = x_ms_enum.get('name', '') enum_values = x_ms_enum.get('values', []) markdown_docs += f"**Event Enum**: {enum_name}\n\n" markdown_docs += "| Value | Description |\n" markdown_docs += "|-------|-------------|\n" for enum_value in enum_values: value = enum_value.get('value', '') description = enum_value.get('description', '') markdown_docs += f"| {value} | {description} |\n" markdown_docs += "\n" data_property = one_of_schema['properties'].get('data', {}) if 'x-ms-enum' in data_property: x_ms_enum = data_property['x-ms-enum'] enum_name = x_ms_enum.get('name', '') enum_values = x_ms_enum.get('values', []) markdown_docs += f"**Data Enum**: {enum_name}\n\n" markdown_docs += "| Value | Description |\n" markdown_docs += "|-------|-------------|\n" for enum_value in enum_values: value = enum_value.get('value', '') description = enum_value.get('description', '') markdown_docs += f"| {value} | {description} |\n" markdown_docs += "\n" markdown_docs += "---\n\n" elif '$ref' in one_of_schema: ref_path = one_of_schema['$ref'] ref_parts = ref_path.split('/') ref_name = ref_parts[-1] markdown_docs += f"- [{ref_name}](#{convert_to_anchor(ref_name)})\n" else: # Simple type branch (no properties, no $ref) branch_type = one_of_schema.get('type', 'object') enum_values = one_of_schema.get('enum', []) if enum_values: formatted = ', '.join([f'`{v}`' for v in enum_values]) markdown_docs += f"- **{branch_type}**: {formatted}\n" else: markdown_docs += f"- **{branch_type}**\n" else: # No need for discriminator here - it was handled above properties_info = resolve_and_extract_schema(openapi_spec, schema, include_discriminator=False) if not properties_info: if 'additionalProperties' in schema: additional_props = schema.get('additionalProperties', {}) properties_info = "This object supports additional properties with no predefined schema.\n\n" # If additionalProperties is a non-empty object, provide more details if isinstance(additional_props, dict) and additional_props: if 'type' in additional_props: properties_info = f"This object supports additional properties of type: **{additional_props['type']}**\n\n" else: properties_info = "No properties defined for this component.\n\n" markdown_docs += properties_info for prop_name, prop_schema in schema.get('properties', {}).items(): if 'x-ms-enum' in prop_schema: x_ms_enum = prop_schema['x-ms-enum'] enum_name = x_ms_enum.get('name', '') enum_values = x_ms_enum.get('values', []) markdown_docs += f"\n\n**{prop_name} Enum**: {enum_name}\n\n" markdown_docs += "| Value | Description |\n" markdown_docs += "|-------|-------------|\n" for enum_value in enum_values: value = enum_value.get('value', '') description = enum_value.get('description', '') markdown_docs += f"| {value} | {description} |\n" markdown_docs += "\n" markdown_docs += "\n" return markdown_docs # --- CLI entry point --- def main(): """CLI entry point. Parses arguments, resolves inputs, and generates docs. With --spec: runs in CLI mode, generating Markdown to stdout/file. Without arguments: launches the GUI (requires gui.py and its dependencies). """ import argparse parser = argparse.ArgumentParser( description="Convert OpenAPI 3.0 JSON specs to Markdown documentation." ) parser.add_argument('--spec', '-s', type=str, default=None, help='Path or GitHub URL to an OpenAPI 3.0 JSON spec file') parser.add_argument('--examples', '-e', type=str, default=None, help='Path or GitHub tree URL to an examples folder') parser.add_argument('--output', '-o', type=str, default=None, help='Output markdown file path (default: auto-generated with timestamp)') args = parser.parse_args() # No arguments → launch GUI if args.spec is None: from gui import launch_gui launch_gui() return # Resolve inputs to local paths spec_path, examples_dir = resolve_inputs(args.spec, args.examples) # If examples were downloaded alongside the spec, chdir so relative $ref paths resolve original_cwd = os.getcwd() spec_dir = os.path.dirname(spec_path) if spec_dir: os.chdir(spec_dir) spec_path = os.path.basename(spec_path) # Load spec print(f"Loading OpenAPI spec from: {spec_path}") with open(spec_path, 'r', encoding='utf-8') as f: openapi_spec = json.load(f) # Debug check if DEBUG_TOOL_RESOURCES and 'components' in openapi_spec and 'schemas' in openapi_spec['components']: for schema_name, schema in openapi_spec['components']['schemas'].items(): if 'tool_resources' in str(schema): debug_print(f"Found tool_resources in schema {schema_name}: {schema}") # Generate documentation print("Generating API documentation...") api_docs = generate_documentation_with_responses(openapi_spec) print("Generating component documentation...") component_docs = generate_component_markdown(openapi_spec) full_docs = api_docs + component_docs # Determine output path os.chdir(original_cwd) # restore cwd for output if args.output: output_path = args.output else: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") base_name = os.path.splitext(os.path.basename(args.spec.split('/')[-1]))[0] output_path = f"{base_name}_{timestamp}.md" print(f"Saving documentation to: {output_path}") with open(output_path, 'w', encoding='utf-8') as f: f.write(full_docs) print("Done.") if __name__ == '__main__': main()
gui.py
"""
OpenAPI to Docs Converter — GUI
imgui + GLFW + OpenGL interface for generating markdown docs from OpenAPI 3.0 specs.
"""

import os
import sys
import json
import string
import subprocess
import threading
import time
import io
from datetime import datetime
from collections import deque

import imgui
import glfw
from imgui.integrations.glfw import GlfwRenderer
from OpenGL import GL

from markdown_gen import (
    resolve_inputs,
    generate_documentation_with_responses,
    generate_component_markdown,
    is_url,
)

# ─── Global State ───────────────────────────────────────────────────────────────

font_scale = 1.0

# Input mode: 0 = Local File, 1 = GitHub URL
input_mode = 0

# Local file fields
local_spec_path = ""
local_examples_path = ""

# GitHub URL fields
github_spec_url = ""
github_examples_url = ""

# Output
output_path = ""
auto_generate_output = True

# File browser state
browser_open = False
browser_phase = "drive_select"
browser_current_dir = ""
browser_selected_file = ""
browser_entries = []
browser_scroll_to_top = False
available_drives = []
browser_open_requested = False
browser_highlight_index = -1
browser_mode = "file"          # "file" (.json filter) or "folder" (directories only)
browser_callback = None        # function(path) called when selection is confirmed

# Generation state
generation_in_progress = False
generated_markdown = ""
status_message = "Select an OpenAPI spec to begin."
generation_thread = None

# Log capture
log_lines = deque(maxlen=500)
log_lock = threading.Lock()
log_scroll_to_bottom = False


# Key press tracking
keys_pressed_this_frame = []

# Clipboard
clipboard_message = ""
clipboard_message_time = 0.0

# Spec info (populated after generation)
spec_info = {}

# ─── Console Output Capture ─────────────────────────────────────────────────────

class LogCapture(io.TextIOBase):
    """Captures writes to stdout/stderr into the shared log buffer,
    while also forwarding to the original stream."""

    def __init__(self, original_stream):
        super().__init__()
        self.original = original_stream
        self._line_buffer = ""

    def write(self, text):
        if text is None:
            return 0
        try:
            self.original.write(text)
            self.original.flush()
        except Exception:
            pass

        self._line_buffer += text
        while '\n' in self._line_buffer:
            line, self._line_buffer = self._line_buffer.split('\n', 1)
            stripped = line.rstrip()
            if stripped:
                _add_log_line(stripped)

        while '\r' in self._line_buffer:
            parts = self._line_buffer.split('\r')
            self._line_buffer = parts[-1]
            stripped = self._line_buffer.rstrip()
            if stripped:
                _add_log_line(stripped)
                self._line_buffer = ""

        return len(text)

    def flush(self):
        try:
            self.original.flush()
        except Exception:
            pass
        if self._line_buffer.strip():
            _add_log_line(self._line_buffer.strip())
            self._line_buffer = ""

    def fileno(self):
        return self.original.fileno()

    def isatty(self):
        return False


def _add_log_line(line):
    """Append a line to the GUI log panel (thread-safe)."""
    global log_scroll_to_bottom
    with log_lock:
        log_lines.append(line)
        log_scroll_to_bottom = True


def clear_log():
    """Clear all log lines (thread-safe)."""
    with log_lock:
        log_lines.clear()


def get_log_snapshot():
    """Return a copy of current log lines (thread-safe)."""
    with log_lock:
        return list(log_lines)


# ─── Drive Detection ────────────────────────────────────────────────────────────

def detect_drives():
    """Detect available filesystem drives/mount points for the file browser."""
    drives = []
    if sys.platform == "win32":
        for letter in string.ascii_uppercase:
            drive_path = f"{letter}:\\"
            if os.path.exists(drive_path):
                try:
                    os.listdir(drive_path)
                    drives.append((f"{letter}:", drive_path))
                except PermissionError:
                    drives.append((f"{letter}: (restricted)", drive_path))
    else:
        drives.append(("/", "/"))
        home = os.path.expanduser("~")
        if os.path.isdir(home):
            drives.append((f"Home ({home})", home))
        if os.path.isdir("/mnt"):
            for item in os.listdir("/mnt"):
                mnt_path = os.path.join("/mnt", item)
                if os.path.isdir(mnt_path):
                    drives.append((f"/mnt/{item}", mnt_path))
    return drives


# ─── DPI Scale Detection ────────────────────────────────────────────────────────

def get_dpi_scale():
    """Detect the system DPI scale factor for proper HiDPI rendering."""
    try:
        monitor = glfw.get_primary_monitor()
        sx, sy = glfw.get_monitor_content_scale(monitor)
        scale = max(sx, sy)
        if scale >= 1.0:
            return scale
    except Exception:
        pass
    try:
        monitor = glfw.get_primary_monitor()
        mode = glfw.get_video_mode(monitor)
        if mode.size.width > 2560:
            return mode.size.width / 1920.0
    except Exception:
        pass
    return 1.0


# ─── Section Drawing Helper ─────────────────────────────────────────────────────

def begin_section(label, accent_r, accent_g, accent_b):
    """Begin a colored section panel with an accent bar and label."""
    draw_list = imgui.get_window_draw_list()
    cursor_screen = imgui.get_cursor_screen_position()
    bar_width = 4 * font_scale
    bar_height = imgui.get_text_line_height() + 8 * font_scale
    draw_list.add_rect_filled(
        cursor_screen[0], cursor_screen[1],
        cursor_screen[0] + bar_width, cursor_screen[1] + bar_height,
        imgui.get_color_u32_rgba(accent_r, accent_g, accent_b, 1.0),
    )
    imgui.dummy(bar_width + 6 * font_scale, 0)
    imgui.same_line()
    imgui.text_colored(label, accent_r, accent_g, accent_b)
    imgui.spacing()

    imgui.push_style_color(imgui.COLOR_CHILD_BACKGROUND, accent_r * 0.08, accent_g * 0.08, accent_b * 0.08, 1.0)
    imgui.push_style_var(imgui.STYLE_CHILD_ROUNDING, 6.0 * font_scale)
    imgui.push_style_var(imgui.STYLE_WINDOW_PADDING, (10 * font_scale, 8 * font_scale))


def end_section():
    """End a colored section panel (pops style vars/colors pushed by begin_section)."""
    imgui.pop_style_var(2)
    imgui.pop_style_color()
    imgui.spacing()
    imgui.spacing()



# ─── File Browser Helpers ────────────────────────────────────────────────────────

def refresh_browser_entries(directory):
    """List directory contents filtered by browser_mode."""
    try:
        items = os.listdir(directory)
    except PermissionError:
        return [("[Permission Denied]", "", False, False)]
    except Exception as e:
        return [(f"[Error: {e}]", "", False, False)]

    dirs = []
    files = []
    for item in items:
        if item.startswith('.'):
            continue
        full_path = os.path.join(directory, item)
        try:
            if os.path.isdir(full_path):
                dirs.append((item, full_path, True, False))
            elif browser_mode == "file" and item.lower().endswith('.json'):
                sz = os.path.getsize(full_path)
                if sz < 1024:
                    ss = f"{sz} B"
                elif sz < 1024 * 1024:
                    ss = f"{sz / 1024:.1f} KB"
                else:
                    ss = f"{sz / (1024 * 1024):.1f} MB"
                files.append((f"{item}  ({ss})", full_path, False, True))
        except (PermissionError, OSError):
            continue

    dirs.sort(key=lambda x: x[0].lower())
    files.sort(key=lambda x: x[0].lower())
    return dirs + files


def open_browser(mode, callback):
    """Open the file/folder browser popup."""
    global browser_open, browser_phase, browser_current_dir, browser_selected_file
    global browser_entries, browser_highlight_index, available_drives
    global browser_open_requested, browser_mode, browser_callback

    browser_mode = mode
    browser_callback = callback
    browser_open = True
    browser_phase = "drive_select"
    browser_current_dir = ""
    browser_selected_file = ""
    browser_entries = []
    browser_highlight_index = -1
    available_drives = detect_drives()
    browser_open_requested = True


# ─── Generation Thread ──────────────────────────────────────────────────────────

def compute_output_path(spec_input):
    """Compute auto-generated output path from spec input."""
    if is_url(spec_input):
        base_name = spec_input.rstrip('/').split('/')[-1]
    else:
        base_name = os.path.basename(spec_input)
    base_name = os.path.splitext(base_name)[0]
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return f"{base_name}_{timestamp}.md"


def extract_spec_info(openapi_spec):
    """Extract hierarchy information from a loaded OpenAPI spec."""
    info = openapi_spec.get('info', {})
    title = info.get('title', 'Untitled API')
    version = info.get('version', '')

    # Gather endpoints grouped by first tag
    tags = {}
    endpoint_count = 0
    paths = openapi_spec.get('paths', {})

    for path, path_item in paths.items():
        if not isinstance(path_item, dict):
            continue
        for method in ('get', 'post', 'put', 'delete', 'patch', 'options', 'head'):
            if method in path_item:
                operation = path_item[method]
                if not isinstance(operation, dict):
                    continue
                endpoint_count += 1
                op_tags = operation.get('tags', ['Untagged'])
                tag_name = op_tags[0] if op_tags else 'Untagged'
                summary = operation.get('summary', '')
                if tag_name not in tags:
                    tags[tag_name] = []
                tags[tag_name].append({
                    'path': path,
                    'method': method.upper(),
                    'summary': summary,
                })

    # Component counts
    components = openapi_spec.get('components', {})

    return {
        'title': title,
        'version': version,
        'tags': tags,
        'endpoint_count': endpoint_count,
        'path_count': len(paths),
        'schemas': len(components.get('schemas', {})),
        'parameters': len(components.get('parameters', {})),
        'responses': len(components.get('responses', {})),
        'security_schemes': len(components.get('securitySchemes', {})),
    }


def run_generation(spec_input, examples_input, out_path):
    """Thread target: resolve inputs, generate docs, save output."""
    global generation_in_progress, status_message, generated_markdown, spec_info

    clear_log()
    _add_log_line("Starting documentation generation...")

    original_stdout = sys.stdout
    original_stderr = sys.stderr
    stdout_capture = LogCapture(original_stdout)
    stderr_capture = LogCapture(original_stderr)

    try:
        sys.stdout = stdout_capture
        sys.stderr = stderr_capture

        # 1. Resolve inputs (may download from GitHub)
        status_message = "Resolving inputs..."
        _add_log_line("Resolving inputs...")
        spec_path, examples_dir = resolve_inputs(spec_input, examples_input)

        # 2. chdir to spec directory for relative refs
        original_cwd = os.getcwd()
        spec_dir = os.path.dirname(spec_path)
        if spec_dir:
            os.chdir(spec_dir)
            spec_path = os.path.basename(spec_path)

        # 3. Load spec
        status_message = "Loading spec..."
        _add_log_line(f"Loading spec: {spec_path}")
        with open(spec_path, 'r', encoding='utf-8') as f:
            openapi_spec = json.load(f)

        # 3b. Extract spec info for hierarchy display
        spec_info = extract_spec_info(openapi_spec)

        # 4. Generate docs
        status_message = "Generating API documentation..."
        _add_log_line("Generating API documentation...")
        api_docs = generate_documentation_with_responses(openapi_spec)

        status_message = "Generating component documentation..."
        _add_log_line("Generating component documentation...")
        component_docs = generate_component_markdown(openapi_spec)
        generated_markdown = api_docs + component_docs

        # 5. Save
        os.chdir(original_cwd)
        status_message = f"Saving to {out_path}..."
        _add_log_line(f"Saving to {out_path}...")
        os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
        with open(out_path, 'w', encoding='utf-8') as f:
            f.write(generated_markdown)

        _add_log_line(f"Done! {len(generated_markdown):,} characters saved.")
        status_message = f"Saved to {out_path}"

    except Exception as e:
        status_message = f"ERROR: {e}"
        _add_log_line(f"ERROR: {e}")
        import traceback
        traceback.print_exc()
    finally:
        stdout_capture.flush()
        stderr_capture.flush()
        sys.stdout = original_stdout
        sys.stderr = original_stderr
        generation_in_progress = False


def start_generation():
    """Kick off a generation run in a background thread."""
    global generation_in_progress, generation_thread, generated_markdown
    global status_message, output_path

    if generation_in_progress:
        return

    # Determine spec input
    if input_mode == 0:
        spec_input = local_spec_path.strip()
    else:
        spec_input = github_spec_url.strip()

    if not spec_input:
        status_message = "No spec provided."
        return

    # Determine examples input
    if input_mode == 0:
        examples_input = local_examples_path.strip() or None
    else:
        examples_input = github_examples_url.strip() or None

    # Determine output path
    if auto_generate_output or not output_path.strip():
        output_path = compute_output_path(spec_input)

    out_path = output_path.strip()

    generation_in_progress = True
    generated_markdown = ""
    status_message = "Starting..."

    generation_thread = threading.Thread(
        target=run_generation,
        args=(spec_input, examples_input, out_path),
        daemon=True,
    )
    generation_thread.start()


# ─── Main GUI Entry Point ───────────────────────────────────────────────────────

def launch_gui():
    global font_scale, available_drives
    global input_mode, local_spec_path, local_examples_path
    global github_spec_url, github_examples_url
    global output_path
    global browser_open, browser_phase, browser_current_dir, browser_selected_file
    global browser_entries, browser_scroll_to_top, browser_open_requested
    global browser_highlight_index, browser_mode, browser_callback
    global generation_in_progress, generated_markdown, status_message
    global log_scroll_to_bottom
    global keys_pressed_this_frame
    global clipboard_message, clipboard_message_time
    global spec_info

    # ─── GLFW Setup ─────────────────────────────────────────────────────

    if not glfw.init():
        print("Failed to initialize GLFW")
        sys.exit(1)

    dpi_scale = get_dpi_scale()
    font_scale = dpi_scale * 1.3

    primary_monitor = glfw.get_primary_monitor()
    video_mode = glfw.get_video_mode(primary_monitor)
    screen_w = video_mode.size.width
    screen_h = video_mode.size.height

    win_w = int(screen_w * 0.9)
    win_h = int(screen_h * 0.9)

    window = glfw.create_window(win_w, win_h, "OpenAPI to Docs Converter", None, None)
    if not window:
        glfw.terminate()
        print("Failed to create GLFW window")
        sys.exit(1)

    glfw.make_context_current(window)
    pos_x = (screen_w - win_w) // 2
    pos_y = (screen_h - win_h) // 2
    glfw.set_window_pos(window, pos_x, pos_y)
    glfw.swap_interval(1)

    imgui.create_context()

    # Load a system font with extended Unicode coverage (box drawing, etc.)
    io_init = imgui.get_io()
    font_loaded = False
    for font_candidate in [
        r"C:\Windows\Fonts\consola.ttf",
        r"C:\Windows\Fonts\segoeui.ttf",
        r"C:\Windows\Fonts\arial.ttf",
    ]:
        if os.path.exists(font_candidate):
            try:
                io_init.fonts.add_font_from_file_ttf(
                    font_candidate, 16.0,
                    glyph_ranges=io_init.fonts.get_glyph_ranges_japanese(),
                )
                font_loaded = True
                break
            except Exception:
                continue

    impl = GlfwRenderer(window)

    def key_callback(win, key, scancode, action, mods):
        nonlocal dpi_scale
        global font_scale
        impl.keyboard_callback(win, key, scancode, action, mods)
        if action == glfw.PRESS or action == glfw.REPEAT:
            keys_pressed_this_frame.append(key)
            if mods & glfw.MOD_CONTROL:
                if key == glfw.KEY_EQUAL or key == glfw.KEY_KP_ADD:
                    font_scale = min(font_scale + 0.1, 4.0)
                elif key == glfw.KEY_MINUS or key == glfw.KEY_KP_SUBTRACT:
                    font_scale = max(font_scale - 0.1, 0.5)
                elif key == glfw.KEY_0:
                    font_scale = dpi_scale * 1.3

    glfw.set_key_callback(window, key_callback)

    available_drives = detect_drives()
    spinner_start_time = time.time()
    browser_scroll_to_highlight = False

    # ─── Main Loop ──────────────────────────────────────────────────────

    while not glfw.window_should_close(window):
        glfw.poll_events()
        impl.process_inputs()

        frame_keys = list(keys_pressed_this_frame)
        keys_pressed_this_frame.clear()

        imgui.new_frame()

        io_obj = imgui.get_io()
        io_obj.font_global_scale = font_scale

        display_width, display_height = glfw.get_framebuffer_size(window)

        # ═══════════════════════════════════════════════════════════════════
        # MAIN WINDOW
        # ═══════════════════════════════════════════════════════════════════

        imgui.set_next_window_position(0, 0)
        imgui.set_next_window_size(display_width, display_height)
        imgui.begin(
            "main",
            flags=(
                imgui.WINDOW_NO_MOVE
                | imgui.WINDOW_NO_RESIZE
                | imgui.WINDOW_NO_COLLAPSE
                | imgui.WINDOW_NO_TITLE_BAR
            ),
        )

        # ─── Title Row ─────────────────────────────────────────────────
        imgui.text("")
        imgui.same_line()
        imgui.dummy(40, 0)
        imgui.same_line()
        imgui.text_colored(
            f"Zoom: {font_scale:.1f}x  (Ctrl +/- to adjust, Ctrl+0 reset)  [DPI: {dpi_scale:.1f}x]",
            0.5, 0.5, 0.5,
        )
        imgui.separator()
        imgui.spacing()

        # ─── Deferred popup open ───────────────────────────────────────
        if browser_open_requested:
            imgui.open_popup("##file_browser")
            browser_open_requested = False

        # ═══════════════════════════════════════════════════════════════════
        # SECTION 1: Input Source (blue accent)
        # ═══════════════════════════════════════════════════════════════════

        begin_section("INPUT SOURCE", 0.4, 0.7, 1.0)

        s1_h = 120 * font_scale
        imgui.begin_child("s1_content", 0, s1_h, border=True)

        # Mode radio buttons
        if imgui.radio_button("Local File", input_mode == 0):
            input_mode = 0
        imgui.same_line()
        imgui.dummy(20, 0)
        imgui.same_line()
        if imgui.radio_button("GitHub URL", input_mode == 1):
            input_mode = 1

        imgui.spacing()

        label_w = 140 * font_scale

        if input_mode == 0:
            # ── Local File mode ──
            imgui.align_text_to_frame_padding()
            imgui.text("Spec file:")
            imgui.same_line(label_w)
            field_w = imgui.get_content_region_available_width() - 90 * font_scale
            imgui.push_item_width(field_w)
            changed, local_spec_path = imgui.input_text("##spec_path", local_spec_path, 2048)
            imgui.pop_item_width()
            imgui.same_line()
            if imgui.button("Browse##spec"):
                def on_spec_selected(path):
                    global local_spec_path
                    local_spec_path = path
                open_browser("file", on_spec_selected)

            imgui.align_text_to_frame_padding()
            imgui.text("Examples dir:")
            imgui.same_line(label_w)
            imgui.push_item_width(field_w)
            changed, local_examples_path = imgui.input_text("##examples_path", local_examples_path, 2048)
            imgui.pop_item_width()
            imgui.same_line()
            if imgui.button("Browse##examples"):
                def on_examples_selected(path):
                    global local_examples_path
                    local_examples_path = path
                open_browser("folder", on_examples_selected)

        else:
            # ── GitHub URL mode ──
            imgui.align_text_to_frame_padding()
            imgui.text("Spec URL:")
            imgui.same_line(label_w)
            field_w = imgui.get_content_region_available_width()
            imgui.push_item_width(field_w)
            changed, github_spec_url = imgui.input_text("##spec_url", github_spec_url, 4096)
            imgui.pop_item_width()

            imgui.align_text_to_frame_padding()
            imgui.text("Examples URL:")
            imgui.same_line(label_w)
            imgui.push_item_width(field_w)
            changed, github_examples_url = imgui.input_text("##examples_url", github_examples_url, 4096)
            imgui.pop_item_width()

        imgui.text_colored(
            "Optional — examples will be auto-discovered from x-ms-examples",
            0.5, 0.5, 0.5,
        )

        imgui.end_child()
        end_section()

        # ═══════════════════════════════════════════════════════════════════
        # SECTION 2: Generate (green accent)
        # ═══════════════════════════════════════════════════════════════════

        begin_section("GENERATE", 0.4, 1.0, 0.5)

        # Fixed height: taller when generating (button + log), compact when idle
        if generation_in_progress:
            s2_h = 160 * font_scale
        else:
            s2_h = 50 * font_scale

        imgui.begin_child("s2_content", 0, s2_h, border=True)

        # ── Top row: Generate button + status ──
        has_spec = (input_mode == 0 and local_spec_path.strip()) or (input_mode == 1 and github_spec_url.strip())
        can_generate = has_spec and not generation_in_progress

        if not can_generate:
            imgui.push_style_var(imgui.STYLE_ALPHA, 0.35)
        if imgui.button("  Generate Docs  ") and can_generate:
            start_generation()
        if not can_generate:
            imgui.pop_style_var()

        imgui.same_line()
        imgui.dummy(10, 0)
        imgui.same_line()

        if generation_in_progress:
            spinner_chars = "|/-\\"
            spinner_idx = int((time.time() - spinner_start_time) * 4) % len(spinner_chars)
            spinner = spinner_chars[spinner_idx]
            imgui.text_colored(f"{spinner} {status_message}", 1.0, 1.0, 0.0)
        elif generated_markdown:
            imgui.text_colored(status_message, 0.4, 1.0, 0.5)
        else:
            imgui.text_colored(status_message, 0.7, 0.7, 0.7)

        # ── During generation: log area ──
        if generation_in_progress:
            imgui.spacing()
            log_h = s2_h - 50 * font_scale
            if log_h < 40:
                log_h = 40
            imgui.push_style_color(imgui.COLOR_CHILD_BACKGROUND, 0.05, 0.05, 0.1, 1.0)
            imgui.begin_child("gen_log", 0, log_h, border=True)

            log_snapshot = get_log_snapshot()
            for log_line in log_snapshot:
                line_lower = log_line.lower()
                if "error" in line_lower or "fail" in line_lower:
                    imgui.text_colored(log_line, 1.0, 0.3, 0.3)
                elif "warning" in line_lower or "warn" in line_lower:
                    imgui.text_colored(log_line, 1.0, 0.8, 0.2)
                elif "complete" in line_lower or "done" in line_lower or "saved" in line_lower:
                    imgui.text_colored(log_line, 0.3, 1.0, 0.3)
                elif "download" in line_lower or "loading" in line_lower:
                    imgui.text_colored(log_line, 0.5, 0.8, 1.0)
                elif "%" in log_line:
                    imgui.text_colored(log_line, 0.6, 0.9, 1.0)
                else:
                    imgui.text_colored(log_line, 0.6, 0.6, 0.6)

            if log_scroll_to_bottom:
                imgui.set_scroll_here_y(1.0)
                log_scroll_to_bottom = False

            imgui.end_child()
            imgui.pop_style_color()

        imgui.end_child()
        end_section()

        # ═══════════════════════════════════════════════════════════════════
        # SECTION 3: Output (orange accent)
        # ═══════════════════════════════════════════════════════════════════

        if generated_markdown and not generation_in_progress:
            begin_section("OUTPUT", 1.0, 0.6, 0.3)

            # Fill remaining space to status bar
            cursor_y = imgui.get_cursor_pos_y()
            status_bar_h = 30 * font_scale
            s3_h = display_height - cursor_y - status_bar_h - 20 * font_scale
            if s3_h < 150:
                s3_h = 150

            imgui.begin_child("s3_output", 0, s3_h, border=True)

            # ── Top row: Action buttons ──
            if imgui.button("  Copy to Clipboard  "):
                try:
                    glfw.set_clipboard_string(window, generated_markdown)
                    clipboard_message = "Copied!"
                    clipboard_message_time = time.time()
                except Exception as e:
                    clipboard_message = f"Copy failed: {e}"
                    clipboard_message_time = time.time()

            imgui.same_line()
            if imgui.button("  Open File  ") and output_path and os.path.isfile(output_path):
                try:
                    os.startfile(output_path)
                except AttributeError:
                    subprocess.Popen(["xdg-open", output_path])
                except Exception:
                    pass

            # Show clipboard feedback for 2 seconds
            if clipboard_message and (time.time() - clipboard_message_time) < 2.0:
                imgui.same_line()
                imgui.text_colored(clipboard_message, 0.4, 1.0, 0.4)

            imgui.spacing()
            imgui.separator()
            imgui.spacing()

            # ── Two-column layout: Stats (left) + Hierarchy (right) ──
            avail_w = imgui.get_content_region_available_width()
            avail_h = imgui.get_content_region_available()[1]
            col_left_w = avail_w * 0.38
            col_right_w = avail_w - col_left_w - 10 * font_scale

            # Left column: Stats
            imgui.begin_child("stats_panel", col_left_w, avail_h, border=True)

            line_count = generated_markdown.count('\n') + 1
            file_size = len(generated_markdown.encode('utf-8'))
            if file_size < 1024:
                size_str = f"{file_size} B"
            elif file_size < 1024 * 1024:
                size_str = f"{file_size / 1024:.1f} KB"
            else:
                size_str = f"{file_size / (1024 * 1024):.1f} MB"

            imgui.text_colored("Document Stats", 1.0, 0.6, 0.3)
            imgui.separator()
            imgui.spacing()
            imgui.text(f"Characters:  {len(generated_markdown):,}")
            imgui.text(f"Lines:       {line_count:,}")
            imgui.text(f"File size:   {size_str}")

            imgui.spacing()
            imgui.separator()
            imgui.spacing()

            imgui.text_colored("Spec Stats", 1.0, 0.6, 0.3)
            imgui.separator()
            imgui.spacing()
            imgui.text(f"Endpoints:   {spec_info.get('endpoint_count', 0)}")
            imgui.text(f"Paths:       {spec_info.get('path_count', 0)}")
            imgui.text(f"Schemas:     {spec_info.get('schemas', 0)}")
            imgui.text(f"Parameters:  {spec_info.get('parameters', 0)}")
            imgui.text(f"Responses:   {spec_info.get('responses', 0)}")
            imgui.text(f"Security:    {spec_info.get('security_schemes', 0)}")
            imgui.text(f"Tags:        {len(spec_info.get('tags', {}))}")

            imgui.end_child()
            imgui.same_line()

            # Right column: Spec Hierarchy (scrollable tree)
            imgui.begin_child("hierarchy_panel", col_right_w, avail_h, border=True)

            imgui.text_colored("Spec Hierarchy", 1.0, 0.6, 0.3)
            imgui.separator()
            imgui.spacing()

            api_title = spec_info.get('title', 'API')
            api_version = spec_info.get('version', '')
            header = f"API: {api_title}"
            if api_version:
                header += f" (v{api_version})"

            if imgui.tree_node(header, imgui.TREE_NODE_DEFAULT_OPEN):
                # Endpoints by tag
                tags = spec_info.get('tags', {})
                total_endpoints = spec_info.get('endpoint_count', 0)
                if imgui.tree_node(f"Endpoints ({total_endpoints} total)"):
                    for tag_name in sorted(tags.keys()):
                        operations = tags[tag_name]
                        if imgui.tree_node(f"{tag_name} ({len(operations)})"):
                            for op in operations:
                                method = op['method']
                                # Color-code method verbs
                                if method == 'GET':
                                    color = (0.3, 1.0, 0.3)
                                elif method == 'POST':
                                    color = (0.4, 0.7, 1.0)
                                elif method == 'PUT':
                                    color = (1.0, 0.6, 0.3)
                                elif method == 'DELETE':
                                    color = (1.0, 0.3, 0.3)
                                elif method == 'PATCH':
                                    color = (1.0, 1.0, 0.3)
                                else:
                                    color = (0.7, 0.7, 0.7)
                                imgui.text_colored(f"{method:7s}", *color)
                                imgui.same_line()
                                summary = op.get('summary', '')
                                if summary:
                                    imgui.text(f"{op['path']} - {summary}")
                                else:
                                    imgui.text(op['path'])
                            imgui.tree_pop()
                    imgui.tree_pop()

                # Components summary
                schemas_count = spec_info.get('schemas', 0)
                params_count = spec_info.get('parameters', 0)
                responses_count = spec_info.get('responses', 0)
                security_count = spec_info.get('security_schemes', 0)
                total_components = schemas_count + params_count + responses_count + security_count

                if total_components > 0:
                    if imgui.tree_node(f"Components ({total_components} total)"):
                        if schemas_count:
                            imgui.bullet_text(f"Schemas ({schemas_count})")
                        if params_count:
                            imgui.bullet_text(f"Parameters ({params_count})")
                        if responses_count:
                            imgui.bullet_text(f"Responses ({responses_count})")
                        if security_count:
                            imgui.bullet_text(f"Security Schemes ({security_count})")
                        imgui.tree_pop()

                imgui.tree_pop()

            imgui.end_child()

            imgui.end_child()  # s3_output
            end_section()

        # ─── Status Bar ─────────────────────────────────────────────────
        imgui.text_colored(status_message, 0.7, 0.7, 0.7)

        # ═══════════════════════════════════════════════════════════════════
        # FILE BROWSER MODAL POPUP
        # ═══════════════════════════════════════════════════════════════════

        popup_w = min(750 * font_scale, display_width * 0.9)
        popup_h = min(550 * font_scale, display_height * 0.9)
        imgui.set_next_window_size(popup_w, popup_h, imgui.ALWAYS)
        imgui.set_next_window_position(
            (display_width - popup_w) * 0.5,
            (display_height - popup_h) * 0.5,
            imgui.ALWAYS,
        )

        if imgui.begin_popup_modal("##file_browser", flags=imgui.WINDOW_NO_RESIZE)[0]:

            kb_down = glfw.KEY_DOWN in frame_keys
            kb_up = glfw.KEY_UP in frame_keys
            kb_enter = glfw.KEY_ENTER in frame_keys or glfw.KEY_KP_ENTER in frame_keys
            kb_backspace = glfw.KEY_BACKSPACE in frame_keys
            kb_escape = glfw.KEY_ESCAPE in frame_keys

            if browser_phase == "drive_select":
                if browser_mode == "folder":
                    imgui.text("SELECT A DRIVE (folder browser)")
                else:
                    imgui.text("SELECT A DRIVE")
                imgui.separator()
                imgui.text("")

                drive_count = len(available_drives)
                if drive_count > 0:
                    if kb_down:
                        if browser_highlight_index < drive_count - 1:
                            browser_highlight_index += 1
                        browser_scroll_to_highlight = True
                    if kb_up:
                        if browser_highlight_index > 0:
                            browser_highlight_index -= 1
                        elif browser_highlight_index < 0:
                            browser_highlight_index = 0
                        browser_scroll_to_highlight = True
                    if kb_enter and 0 <= browser_highlight_index < drive_count:
                        _, drive_path = available_drives[browser_highlight_index]
                        browser_current_dir = drive_path
                        browser_entries = refresh_browser_entries(drive_path)
                        browser_phase = "file_browse"
                        browser_scroll_to_top = True
                        browser_highlight_index = -1

                if kb_escape:
                    browser_open = False
                    imgui.close_current_popup()

                imgui.push_style_var(imgui.STYLE_SCROLLBAR_SIZE, 20.0 * font_scale)
                imgui.begin_child("drive_list", 0, -50 * font_scale, border=True)

                btn_w = popup_w - 50 * font_scale
                btn_h = 36 * font_scale

                for i, (drive_label, drive_path) in enumerate(available_drives):
                    is_highlighted = (i == browser_highlight_index)
                    if is_highlighted:
                        imgui.push_style_color(imgui.COLOR_BUTTON, 0.3, 0.5, 0.8, 1.0)
                    if imgui.button(f"    {drive_label}    ##drive_{drive_path}", width=btn_w, height=btn_h):
                        browser_current_dir = drive_path
                        browser_entries = refresh_browser_entries(drive_path)
                        browser_phase = "file_browse"
                        browser_scroll_to_top = True
                        browser_highlight_index = -1
                    if is_highlighted:
                        imgui.pop_style_color()
                        if browser_scroll_to_highlight:
                            imgui.set_scroll_here_y(0.5)
                    imgui.dummy(0, 4)

                if browser_scroll_to_highlight:
                    browser_scroll_to_highlight = False

                imgui.end_child()
                imgui.pop_style_var()

                imgui.dummy(0, 5)
                cancel_x = popup_w - 110 * font_scale
                imgui.same_line(cancel_x)
                if imgui.button("  Cancel  ##drive_cancel"):
                    browser_open = False
                    imgui.close_current_popup()

            elif browser_phase == "file_browse":

                imgui.text("Location:")
                imgui.same_line()
                imgui.text_colored(browser_current_dir, 0.6, 0.8, 1.0)

                if imgui.button("  < Drives  "):
                    browser_phase = "drive_select"
                    browser_current_dir = ""
                    browser_entries = []
                    browser_selected_file = ""
                    browser_highlight_index = -1

                imgui.same_line()
                parent = os.path.dirname(browser_current_dir)
                can_go_up = parent and parent != browser_current_dir
                if can_go_up:
                    if imgui.button("  Up  "):
                        browser_current_dir = parent
                        browser_entries = refresh_browser_entries(parent)
                        browser_selected_file = ""
                        browser_scroll_to_top = True
                        browser_highlight_index = -1

                imgui.separator()

                # Keyboard navigation
                entry_count = len(browser_entries)
                if entry_count > 0:
                    if kb_down:
                        if browser_highlight_index < entry_count - 1:
                            browser_highlight_index += 1
                        browser_scroll_to_highlight = True
                    if kb_up:
                        if browser_highlight_index > 0:
                            browser_highlight_index -= 1
                        elif browser_highlight_index < 0:
                            browser_highlight_index = 0
                        browser_scroll_to_highlight = True
                    if kb_enter and 0 <= browser_highlight_index < entry_count:
                        display_name, full_path, is_dir, is_file_match = browser_entries[browser_highlight_index]
                        if is_dir:
                            browser_current_dir = full_path
                            browser_entries = refresh_browser_entries(full_path)
                            browser_selected_file = ""
                            browser_scroll_to_top = True
                            browser_highlight_index = -1
                        elif is_file_match and browser_mode == "file":
                            if browser_selected_file == full_path:
                                if browser_callback:
                                    browser_callback(full_path)
                                browser_open = False
                                imgui.close_current_popup()
                            else:
                                browser_selected_file = full_path

                if kb_backspace and can_go_up:
                    browser_current_dir = parent
                    browser_entries = refresh_browser_entries(parent)
                    browser_selected_file = ""
                    browser_scroll_to_top = True
                    browser_highlight_index = -1

                if kb_escape:
                    browser_selected_file = ""
                    browser_open = False
                    imgui.close_current_popup()

                list_h = popup_h - 170 * font_scale
                if list_h < 100:
                    list_h = 100

                imgui.push_style_var(imgui.STYLE_SCROLLBAR_SIZE, 20.0 * font_scale)
                imgui.begin_child("file_list", 0, list_h, border=True)

                if browser_scroll_to_top:
                    imgui.set_scroll_y(0)
                    browser_scroll_to_top = False

                if not browser_entries:
                    imgui.text_colored("  (empty or inaccessible)", 0.5, 0.5, 0.5)

                for i, (display_name, full_path, is_dir, is_file_match) in enumerate(browser_entries):
                    is_kb_highlight = (i == browser_highlight_index)

                    if is_dir:
                        if is_kb_highlight:
                            imgui.push_style_color(imgui.COLOR_TEXT, 1.0, 1.0, 0.0, 1.0)
                        else:
                            imgui.push_style_color(imgui.COLOR_TEXT, 1.0, 0.9, 0.3, 1.0)
                        clicked, _ = imgui.selectable(
                            f"  [DIR]  {display_name}", is_kb_highlight, imgui.SELECTABLE_DONT_CLOSE_POPUPS
                        )
                        imgui.pop_style_color()
                        if clicked:
                            browser_current_dir = full_path
                            browser_entries = refresh_browser_entries(full_path)
                            browser_selected_file = ""
                            browser_scroll_to_top = True
                            browser_highlight_index = -1

                    elif is_file_match and browser_mode == "file":
                        is_sel = (browser_selected_file == full_path) or is_kb_highlight
                        if browser_selected_file == full_path:
                            imgui.push_style_color(imgui.COLOR_TEXT, 0.3, 1.0, 0.3, 1.0)
                        elif is_kb_highlight:
                            imgui.push_style_color(imgui.COLOR_TEXT, 0.6, 0.9, 1.0, 1.0)
                        clicked, _ = imgui.selectable(
                            f"         {display_name}", is_sel, imgui.SELECTABLE_DONT_CLOSE_POPUPS
                        )
                        if browser_selected_file == full_path or is_kb_highlight:
                            imgui.pop_style_color()
                        if clicked:
                            browser_selected_file = full_path
                            browser_highlight_index = i
                        if imgui.is_item_hovered() and imgui.is_mouse_double_clicked(0):
                            if browser_callback:
                                browser_callback(full_path)
                            browser_open = False
                            imgui.close_current_popup()

                    if is_kb_highlight and browser_scroll_to_highlight:
                        imgui.set_scroll_here_y(0.5)

                if browser_scroll_to_highlight:
                    browser_scroll_to_highlight = False

                imgui.end_child()
                imgui.pop_style_var()

                imgui.separator()

                # Bottom bar: different for file vs folder mode
                if browser_mode == "folder":
                    imgui.text_colored(f"Current: {browser_current_dir}", 0.6, 0.8, 1.0)
                    btn_area_x = popup_w - 330 * font_scale
                    imgui.same_line(btn_area_x)

                    if imgui.button("  Select This Folder  "):
                        if browser_callback and browser_current_dir:
                            browser_callback(browser_current_dir)
                        browser_open = False
                        imgui.close_current_popup()

                    imgui.same_line()
                    if imgui.button("  Cancel  ##browse_cancel"):
                        browser_selected_file = ""
                        browser_open = False
                        imgui.close_current_popup()

                else:
                    # File mode bottom bar — buttons first, then hint below
                    if browser_selected_file:
                        imgui.text(os.path.basename(browser_selected_file))
                        imgui.same_line(popup_w - 230 * font_scale)
                    else:
                        imgui.dummy(0, 0)
                        imgui.same_line(popup_w - 230 * font_scale)

                    ok_ok = bool(browser_selected_file)
                    if not ok_ok:
                        imgui.push_style_var(imgui.STYLE_ALPHA, 0.35)
                    if imgui.button("    OK    ") and ok_ok:
                        if browser_callback:
                            browser_callback(browser_selected_file)
                        browser_open = False
                        imgui.close_current_popup()
                    if not ok_ok:
                        imgui.pop_style_var()

                    imgui.same_line()
                    if imgui.button("  Cancel  ##browse_cancel"):
                        browser_selected_file = ""
                        browser_open = False
                        imgui.close_current_popup()

                    if not browser_selected_file:
                        imgui.spacing()
                        imgui.spacing()
                        imgui.text_colored("Click a .json file to select, double-click to confirm", 0.5, 0.5, 0.5)

            imgui.end_popup()

        imgui.end()

        # ─── Render ─────────────────────────────────────────────────────
        GL.glClearColor(0.1, 0.1, 0.1, 1.0)
        GL.glClear(GL.GL_COLOR_BUFFER_BIT)
        imgui.render()
        impl.render(imgui.get_draw_data())
        glfw.swap_buffers(window)

    # ─── Cleanup ────────────────────────────────────────────────────────
    impl.shutdown()
    glfw.terminate()


# ─── Standalone launch ──────────────────────────────────────────────────────────

if __name__ == "__main__":
    launch_gui()
Background

For context, another team owned the reference docs generation pipeline for this site. They supported Swagger 2.0, and downcasting, but not OpenAPI 3.0. This was problematic in that from my testing downcasting was not a viable option. It was the other team's job to add official support, but it became clear that it was at best going to take them months to deliver support, and getting them to commit was going to turn into a less than fun process of meetings, politics, and escalations.

This was one of many high priority projects I had going at once on a tight deadline, and I realized it would be much faster just to code a good enough solution myself as a temporary patch on my own time, and save myself weeks of contentious meetings that I didn't have time for. OpenAPI 3.0 had been the industry standard since 2017, so this was not a brand new standard at this point. OpenAPI is currently on 3.2.0 released in September 2025

Experiment

Initially at the team's request I had experimented with their downcasting solution, but I found that it would generate largely empty docs. Downcasting works great if you have an OpenAPI 3.x spec that isn't using any of the new features that the spec allows for. In my case, I had a 38,000+ line API spec which heavily used OpenAPI's latest features so any downcasted version would result in massive amounts of data loss.

To prove this to the other engineering team, I had written a lightweight Python program that could read OpenAPI 3.0 JSON and output passable Markdown docs. This made it very easy to compare my docs against the engineering team's docs and show them that their tool didn't work for my team's use case. It was this initial experiment that gave me confidence that this was a problem that I could tackle on my own until official support could be added to the ref docs pipeline.

I suspect that many people would look at a problem as described up to this point and say just give a LLM the spec and have it generate docs.

Here is a non-exhaustive list from my testing of why this isn't a great idea:

Solution

So what I landed on was write a program to handle the docs generation, but heavily lean on an LLM to help with the code generation process to get the best of both worlds. This speeds up the authoring process because an LLM is much better than I am at writing JSON parsing code, but I get the benefit of a deterministic program that I could guarantee the output of.

I wrote the first version of this back in the Fall of 2024. It was very iterative with me gradually adding a feature at a time with the help of an LLM and carefully diffing the source and output and inspecting between each run to avoid regressions. Since I knew the job of creating the official solution was not mine I didn't approach the problem the way I would if I wanted to create a general OpenAPI docs generator. I was looking specifically at the spec I needed docs for and providing coverage only for the features of OpenAPI 3.0 that I needed. This admittedly resulted in a very brittle final product of toy quality, it was not generalized, there was hardcoding to handle some sections, but it gave me what I needed to solve my specific problem and I was able to build it over a couple of evenings.

Unfortunately time has passed and the team that owns adding official support has continued to get tugged in other directions. Now other groups I work with want the same capability for their specs.

(If it is not clear from my other writing, while I once had the title of engineer and senior engineer in various roles, my current day job actually doesn't expect me to do much coding. The fact that I can and do write code often is an added bonus but I am engineering team adjacent and spend more of my time explaining how complex systems work and working with PM and engineering teams rather than writing code myself — which is frankly for the best, I am great at prototypes, throwaway code, and finding bugs, I am not the person you want writing code that people actually depend on.)

Since other teams now need this capability and have asked me to help out I decided to more properly generalize my code, remove any hardcoding, and add a UX to make it super easy for anyone to be able to run it. Since the latest generation of models are now much more powerful than what I was using back in 2024 I decided to see how Claude Code with Opus 4.6 would do if I set up a self-improving loop where it had clear guidance and made it follow the same process I used when I made the first iteration. For each change diff the code, diff the output, and very gradually add support and squash bugs while checking for regressions. For the most part it did quite well and was fun to watch up until the point that it had to use compaction. Even though it saved to Claude's "memories" it completely lost the thread post compaction, but it was easy enough to nudge it back on track

I would still put this code very much in the category of a toy, but it is interesting to see a bit of the flywheel of self improvement that is possible when you have a very clearly defined spec/problem.