Skip to content

openapi-update

openapi-update #355

name: Sync OpenAPI and Publish to PyPI
on:
# Trigger when the main server repo pushes changes
repository_dispatch:
types: [openapi-update]
# Manual trigger
workflow_dispatch:
# Also trigger on push to main (for testing)
push:
branches:
- main
jobs:
generate-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout python-sdk repo
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install OpenAPI Generator CLI
run: npm install -g @openapitools/openapi-generator-cli
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.9'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine setuptools wheel
- name: Use synced OpenAPI Spec (or download as fallback)
run: |
if [ -f "openapi.json" ]; then
echo "✅ Using synced openapi.json from server repo"
echo "📄 File size: $(wc -c < openapi.json) bytes"
else
echo "⚠️ No synced openapi.json found, downloading from production..."
curl -s https://api.mixpeek.com/docs/openapi.json -o openapi.json
echo "📥 Downloaded from production API"
fi
- name: Extract version from OpenAPI spec
id: get-version
run: |
VERSION=$(python -c "import json; print(json.load(open('openapi.json'))['info']['version'])")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Version: $VERSION"
- name: Check if version exists on PyPI
id: check-pypi
continue-on-error: true
run: |
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/mixpeek/${{ steps.get-version.outputs.version }}/json)
if [ $RESPONSE -eq 200 ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "⚠️ Version ${{ steps.get-version.outputs.version }} already exists on PyPI"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "✅ Version ${{ steps.get-version.outputs.version }} is new"
fi
- name: Clean OpenAPI spec
if: steps.check-pypi.outputs.exists != 'true'
run: |
echo "🧼 Cleaning OpenAPI specification..."
python3 << 'PYTHON_SCRIPT'
import json
import re
with open('openapi.json', 'r') as f:
spec = json.load(f)
def clean_schema(schema):
"""Recursively clean anyOf/oneOf that include null type"""
if isinstance(schema, dict):
if 'anyOf' in schema and isinstance(schema['anyOf'], list):
cleaned = [s for s in schema['anyOf'] if s.get('type') != 'null']
if len(cleaned) == 1 and len(schema['anyOf']) > 1:
for key, value in cleaned[0].items():
schema[key] = value
del schema['anyOf']
schema['nullable'] = True
elif len(cleaned) < len(schema['anyOf']):
schema['anyOf'] = cleaned
schema['nullable'] = True
if 'oneOf' in schema and isinstance(schema['oneOf'], list):
cleaned = [s for s in schema['oneOf'] if s.get('type') != 'null']
if len(cleaned) == 1 and len(schema['oneOf']) > 1:
for key, value in cleaned[0].items():
schema[key] = value
del schema['oneOf']
schema['nullable'] = True
elif len(cleaned) < len(schema['oneOf']):
schema['oneOf'] = cleaned
schema['nullable'] = True
for key, value in list(schema.items()):
if isinstance(value, (dict, list)):
clean_schema(value)
elif isinstance(schema, list):
for item in schema:
clean_schema(item)
return schema
def simplify_operation_id(operation_id):
"""Simplify operation IDs to be more developer-friendly"""
if not operation_id:
return operation_id
# Remove version prefix (v1, v2, etc.)
operation_id = re.sub(r'_v\d+_', '_', operation_id)
# Remove HTTP method suffix
operation_id = re.sub(r'_(post|get|put|delete|patch)$', '', operation_id)
# Remove common path patterns
operation_id = re.sub(r'_identifier_', '_', operation_id)
operation_id = re.sub(r'__{1,}', '_', operation_id)
# Split into parts
parts = operation_id.split('_')
# Extract action verb
action_verbs = ['create', 'get', 'list', 'update', 'delete', 'patch', 'execute',
'upsert', 'describe', 'add', 'remove', 'set']
action = None
if parts and parts[0] in action_verbs:
action = parts[0]
parts = parts[1:]
# Remove generic/redundant words
skip_words = ['route', 'endpoint', 'api', 'private', 'public', 'v1', 'v2', 'v3']
# Track singular/plural forms to avoid duplication
seen_roots = set()
cleaned_parts = []
for part in parts:
if part in skip_words:
continue
# Get root form (simple pluralization check)
root = part.rstrip('s') if part.endswith('s') and len(part) > 3 else part
# Skip if we've seen this root (or very similar)
if root.lower() in seen_roots:
continue
cleaned_parts.append(part)
seen_roots.add(root.lower())
# Rebuild operation ID
if action:
result = action + ('_' + '_'.join(cleaned_parts) if cleaned_parts else '')
else:
result = '_'.join(cleaned_parts)
# Final cleanup
result = result.strip('_')
# Remove action duplication at the end
for verb in action_verbs:
pattern = f'_{verb}$'
if result.startswith(verb + '_') and result.endswith(f'_{verb}'):
result = re.sub(pattern, '', result)
# Remove duplicate nouns
parts_to_check = result.split('_')
if len(parts_to_check) > 2:
generic_suffixes = ['collections', 'list', 'id', 'identifier', 'document', 'documents']
while len(parts_to_check) > 2 and parts_to_check[-1] in generic_suffixes:
parts_to_check = parts_to_check[:-1]
result = '_'.join(parts_to_check)
return result
def clean_operation_ids(spec):
"""Clean all operation IDs in the spec"""
if 'paths' not in spec:
return
for path, path_item in spec['paths'].items():
if not isinstance(path_item, dict):
continue
for method in ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']:
if method in path_item and isinstance(path_item[method], dict):
operation = path_item[method]
if 'operationId' in operation:
old_id = operation['operationId']
new_id = simplify_operation_id(old_id)
operation['operationId'] = new_id
if 'components' in spec and 'schemas' in spec['components']:
clean_schema(spec['components']['schemas'])
if 'paths' in spec:
clean_schema(spec['paths'])
clean_operation_ids(spec)
with open('openapi-cleaned.json', 'w') as f:
json.dump(spec, f, indent=2)
print("✅ Cleaned OpenAPI spec with simplified method names")
PYTHON_SCRIPT
- name: Generate SDK
if: steps.check-pypi.outputs.exists != 'true'
run: |
echo "🚀 Generating SDK..."
# Backup custom wrapper code (preserved across regenerations)
if [ -d "mixpeek/_client" ]; then
echo "📦 Backing up custom wrapper..."
cp -r mixpeek/_client /tmp/_client_backup
fi
# Clean previous generation (except important files)
rm -rf mixpeek test docs .openapi-generator-ignore .gitlab-ci.yml git_push.sh tox.ini test-requirements.txt .travis.yml || true
# Generate the SDK
openapi-generator-cli generate \
-i openapi-cleaned.json \
-g python \
-o . \
--skip-validate-spec \
--package-name mixpeek \
--additional-properties=projectName=mixpeek,packageVersion=${{ steps.get-version.outputs.version }},packageUrl=https://github.com/mixpeek/python-sdk,library=urllib3
# Cleanup unnecessary files
rm -f .travis.yml git_push.sh .gitlab-ci.yml || true
# Restore custom wrapper code
if [ -d "/tmp/_client_backup" ]; then
echo "📦 Restoring custom wrapper..."
cp -r /tmp/_client_backup mixpeek/_client
fi
- name: Inject modern client wrapper
if: steps.check-pypi.outputs.exists != 'true'
run: |
echo "🔧 Injecting modern Mixpeek client..."
python3 -c "
# Read the generated __init__.py
with open('mixpeek/__init__.py', 'r') as f:
content = f.read()
# Add Mixpeek to __all__ (at the beginning)
content = content.replace(
'__all__ = [',
'__all__ = [\n \"Mixpeek\",'
)
# Add the import at the end of the file
wrapper_import = '\n# Modern client wrapper (preserved across regenerations)\nfrom mixpeek._client import Mixpeek as Mixpeek\n'
if 'from mixpeek._client import Mixpeek' not in content:
content += wrapper_import
with open('mixpeek/__init__.py', 'w') as f:
f.write(content)
print('✅ Injected Mixpeek client wrapper')
"
- name: Fix pyproject.toml
if: steps.check-pypi.outputs.exists != 'true'
run: |
echo "🔧 Fixing pyproject.toml..."
python3 -c "
import re
with open('pyproject.toml', 'r') as f:
content = f.read()
# Fix license format (NoLicense -> MIT)
content = re.sub(r'license\s*=\s*\"NoLicense\"', 'license = \"MIT\"', content)
# Fix dependency format - remove parentheses
content = re.sub(r'\"([^\"]+)\s+\(([^)]+)\)\"', r'\"\1\2\"', content)
# Fix repository URL
content = re.sub(r'Repository\s*=\s*\"https://github.com/GIT_USER_ID/GIT_REPO_ID\"',
'Repository = \"https://github.com/mixpeek/python-sdk\"', content)
# Add Homepage and Documentation if not present
if 'Homepage' not in content:
content = content.replace('[project.urls]', '[project.urls]\nHomepage = \"https://mixpeek.com\"\nDocumentation = \"https://docs.mixpeek.com\"')
with open('pyproject.toml', 'w') as f:
f.write(content)
print('✅ Fixed pyproject.toml')
"
- name: Build package
if: steps.check-pypi.outputs.exists != 'true'
run: python -m build
- name: Publish to PyPI
if: steps.check-pypi.outputs.exists != 'true'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
twine upload dist/* --verbose
- name: Commit and push changes
if: steps.check-pypi.outputs.exists != 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git diff --staged --quiet || git commit -m "🤖 Auto-generate SDK v${{ steps.get-version.outputs.version }}"
git push
- name: Create GitHub Release
if: steps.check-pypi.outputs.exists != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.get-version.outputs.version }}" \
--title "Release v${{ steps.get-version.outputs.version }}" \
--notes "🎉 Auto-generated SDK from OpenAPI specification
Version: ${{ steps.get-version.outputs.version }}
Install via pip:
\`\`\`bash
pip install mixpeek==${{ steps.get-version.outputs.version }}
\`\`\`"
- name: Skip - Version already published
if: steps.check-pypi.outputs.exists == 'true'
run: |
echo "⏭️ Skipping: Version ${{ steps.get-version.outputs.version }} already exists on PyPI"