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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,35 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.pinned.txt
pip install build
pip install build pyyaml

# Step 6: Run the Python script to generate json for openApi-merge
- name: generate json for openApi-merge
run: python3 ./utils/json_merge_generator.py

# Step 7: Merge the YAML files
# Step 7: Promote global security to operation level in each spec
- name: Promote global security to operation level
run: python3 ./utils/promote_security.py

# Step 8: Merge the YAML files
- name: Merge the yaml files
run: npx openapi-merge-cli --config ./openapi-merge.json

# Step 8: Bump version in config.json
# Step 9: Normalize security scheme aliases in the merged spec
- name: Normalize security scheme aliases
run: python3 ./utils/normalize_security_schemes.py

# Step 10: Bump version in config.json
- name: Bump version in config.json
run: python3 ./utils/bump.py

# Step 9: Generate the SDK
# Step 11: Generate the SDK
- name: Generate the SDK
run: npx @openapitools/openapi-generator-cli generate -i ./wordliftApiSpec.yaml -g python -c config.json --package-name 'wordlift_client' --library 'asyncio' --additional-properties=pythonVersion=3.8

# Step 12: Validate auth settings — fail fast before committing a broken client
- name: Validate auth settings
run: python3 ./utils/check_auth_settings.py

- name: Patch generated files
run: |
Expand Down
2 changes: 2 additions & 0 deletions api/base/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ components:
type: oauth2
servers:
- url: https://api.wordlift.io
security:
- ApiKey: []
44 changes: 44 additions & 0 deletions utils/check_auth_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Validates that every operation in every generated API file has a non-empty
_auth_settings list. Exits non-zero if any are empty, so CI fails fast
before committing a broken client.

Mirrors the pre-v1.175.0 baseline: all operations should carry 'ApiKey'.
"""
import os
import re
import sys

api_dir = './wordlift_client/api'

# Matches the opening of an _auth_settings block followed immediately by ]
# (with only whitespace between), meaning it is empty.
EMPTY_PATTERN = re.compile(r'_auth_settings: List\[str\] = \[\s*\]')
TOTAL_PATTERN = re.compile(r'_auth_settings: List\[str\] = \[')

broken = []
total_ops = 0
total_files = 0

for fname in sorted(os.listdir(api_dir)):
if not fname.endswith('.py') or fname == '__init__.py':
continue

total_files += 1
content = open(os.path.join(api_dir, fname)).read()

ops = len(TOTAL_PATTERN.findall(content))
empty = len(EMPTY_PATTERN.findall(content))
total_ops += ops

if empty:
broken.append((fname, empty, ops))

if broken:
print(f'FAIL — {sum(e for _, e, _ in broken)} operation(s) with empty _auth_settings '
f'across {len(broken)} file(s):\n')
for fname, empty, ops in broken:
print(f' {fname}: {empty}/{ops} broken')
sys.exit(1)

print(f'OK — {total_ops} operation(s) across {total_files} file(s) all have auth settings')
4 changes: 2 additions & 2 deletions utils/json_merge_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
# Gather all YAML files in the directory. The openapi merge operation takes the first security scheme and api server
# that if finds, so that we provide them using the base.yaml file, see https://github.com/robertmassaioli/openapi-merge/tree/main/packages/openapi-merge#merging-behaviour
# We do this because it happened that bogus yaml files broke the Python Client.
yaml_files = [os.path.join(yaml_directory, "base", "base.yaml")] + [
yaml_files = [os.path.join(yaml_directory, "base", "base.yaml")] + sorted([
os.path.join(yaml_directory, f)
for f in os.listdir(yaml_directory)
if f.endswith(".yaml")
]
])

# Construct the JSON structure
json_data = {
Expand Down
23 changes: 23 additions & 0 deletions utils/normalize_security_schemes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import sys

# Maps security scheme names used in sub-specs to the canonical name defined in base/base.yaml.
# openapi-merge keeps only the first securitySchemes block it finds (base.yaml), so sub-spec
# scheme names survive only in operation-level security references — pointing to a now-undefined
# scheme and causing the generator to emit empty _auth_settings lists.
SECURITY_SCHEME_ALIASES = {
"WordliftApiKey": "ApiKey",
}

merged_spec = "./wordliftApiSpec.yaml"

with open(merged_spec, "r") as f:
content = f.read()

for alias, canonical in SECURITY_SCHEME_ALIASES.items():
count = content.count(alias)
if count:
content = content.replace(alias, canonical)
print(f"Replaced '{alias}' → '{canonical}' ({count} occurrence(s))")

with open(merged_spec, "w") as f:
f.write(content)
57 changes: 57 additions & 0 deletions utils/promote_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
For every spec in ./api/ that declares a top-level `security:` block,
promote that requirement to each operation that lacks an explicit one,
then drop the global block.

After this runs, the openapi-merge step has nothing to race over:
every operation carries its own security declaration, so the ordering
of specs in the merge is irrelevant to auth correctness.
"""
import os
import sys
import yaml

HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'}
api_dir = './api'

promoted_total = 0

for fname in sorted(os.listdir(api_dir)):
if not fname.endswith('.yaml'):
continue

fpath = os.path.join(api_dir, fname)
with open(fpath) as f:
spec = yaml.safe_load(f)

if not isinstance(spec, dict):
continue

global_security = spec.get('security')
if not global_security:
continue

paths = spec.get('paths', {}) or {}
promoted = 0

for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS:
continue
if not isinstance(operation, dict):
continue
if 'security' not in operation:
operation['security'] = global_security
promoted += 1

del spec['security']

with open(fpath, 'w') as f:
yaml.dump(spec, f, default_flow_style=False, allow_unicode=True, sort_keys=False)

promoted_total += promoted
print(f'{fname}: injected global security into {promoted} operation(s), dropped global block')

print(f'\nTotal operations promoted: {promoted_total}')
Loading