#!/usr/bin/env python3 # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to You under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Validate the checked-in Admin API contract; never download contract inputs.""" import argparse from copy import deepcopy import json from pathlib import Path import re import shlex import sys from urllib.parse import unquote, urlsplit from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError from openapi_spec_validator import OpenAPIV31SpecValidator from referencing import Registry, Resource from referencing.jsonschema import DRAFT202012 ROOT = Path(__file__).resolve().parents[1] METHODS = {'get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'} BASE = 'urn:apisix:admin-api' # These contain instance data, not contract objects or schemas. DATA = {'example', 'default', 'enum', 'const', 'value'} MAPS = {'properties', 'patternProperties', '$defs', 'dependentSchemas', 'schemas', 'paths', 'responses', 'content', 'headers', 'links', 'callbacks', 'pathItems', 'requestBodies', 'parameters', 'securitySchemes', 'examples'} def objects(value, path='#', mapping=False): """Walk contract objects, keeping arbitrary names and instance data separate.""" if isinstance(value, dict): if not mapping: yield path, value for key, child in value.items(): if mapping or (key not in DATA and not key.startswith('x-')): if not mapping and key == 'examples' and isinstance(child, list): continue # JSON Schema examples are instance data. yield from objects(child, path + '/' + key.replace('~', '~0').replace('/', '~1'), not mapping and key in MAPS and isinstance(child, dict)) elif isinstance(value, list): for i, child in enumerate(value): yield from objects(child, path + '/' + str(i)) def resolve(document, ref): if not isinstance(ref, str) or not ref.startswith('#/'): raise ValueError(f'only internal JSON Pointer refs are allowed: {ref!r}') result = document for token in unquote(ref[2:]).split('/'): token = token.replace('~1', '/').replace('~0', '~') try: result = result[int(token)] if isinstance(result, list) else result[token] except (KeyError, IndexError, ValueError, TypeError) as exc: raise ValueError(f'unresolved ref: {ref}') from exc return result def dereference(document, obj): seen = set() while isinstance(obj, dict) and '$ref' in obj: ref = obj['$ref'] if ref in seen: raise ValueError(f'cyclic Reference Object: {ref}') seen.add(ref) obj = resolve(document, ref) return obj def absolute_refs(value): result = deepcopy(value) for _, obj in objects(result): for key in ('$ref', '$dynamicRef'): if key in obj: obj[key] = BASE + obj[key] return result def validate(document, versions): """Return actionable errors. Local ref preflight runs before the OAS library.""" errors = [] if document.get('openapi') != '3.1.0': errors.append('openapi must be 3.1.0') version = document.get('info', {}).get('version') for source, expected in versions.items(): if version != expected: errors.append(f'info.version {version!r} differs from {source}: {expected!r}') for path, obj in objects(document): for key in ('$ref', '$dynamicRef'): if key in obj: try: resolve(document, obj[key]) except ValueError as exc: errors.append(f'{path}: {exc}') if '$id' in obj: errors.append(f'{path}: embedded $id changes local ref scope; use document pointers') if errors: return errors schemas = list(document.get('components', {}).get('schemas', {}).items()) schemas += [(path + '/schema', obj['schema']) for path, obj in objects(document) if 'schema' in obj] for path, schema in schemas: try: Draft202012Validator.check_schema(schema) except SchemaError as exc: errors.append(f'{path}: invalid JSON Schema: {exc.message}') if errors: return errors errors.extend('OpenAPI: ' + str(e) for e in OpenAPIV31SpecValidator(document).iter_errors()) if errors: return errors registry = Registry().with_resource( BASE, Resource.from_contents(absolute_refs(document), default_specification=DRAFT202012)) def check_value(schema, value, location): validator = Draft202012Validator(absolute_refs(schema), registry=registry) errors.extend(f'{location}: {e.message}' for e in validator.iter_errors(value)) def check_schema(schema, location): for path, obj in objects(schema, location): if 'default' in obj: check_value(obj, obj['default'], path + '/default') if 'examples' in obj: for i, example in enumerate(obj['examples']): check_value(obj, example, path + f'/examples/{i}') if 'example' in obj: check_value(obj, obj['example'], path + '/example') # Components plus schemas on parameters, request bodies, response media and headers. for name, schema in document.get('components', {}).get('schemas', {}).items(): check_schema(schema, '#/components/schemas/' + name) for path, obj in objects(document): if 'schema' not in obj: continue schema = obj['schema'] check_schema(schema, path + '/schema') if 'example' in obj: check_value(schema, obj['example'], path + '/example') for name, example in obj.get('examples', {}).items(): example = dereference(document, example) if 'externalValue' in example: errors.append(f'{path}/examples/{name}: use a checked-in value, not externalValue') elif 'value' in example: check_value(schema, example['value'], path + '/examples/' + name) ids = set() for path, raw_item in document.get('paths', {}).items(): item = dereference(document, raw_item) for method in METHODS & item.keys(): op = item[method] location = f'{method.upper()} {path}' if not op.get('responses'): errors.append(f'{location}: responses must be non-empty') op_id = op.get('operationId') if not isinstance(op_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', op_id) or op_id in ids: errors.append(f'{location}: missing or duplicate operationId {op_id!r}') ids.add(op_id) params = {} for group in (item.get('parameters', []), op.get('parameters', [])): for param in group: param = dereference(document, param) params[(param['in'], param['name'])] = param declared = {name for (where, name) in params if where == 'path'} if declared != set(re.findall(r'{([^{}]+)}', path)): errors.append(f'{location}: path parameters do not match template') for (where, name), param in params.items(): if where == 'path' and param.get('required') is not True: errors.append(f'{location}: path parameter {name} must be required') for field in ('requestBody', 'responses'): entries = op.get('responses', {}).values() if field == 'responses' else [op.get(field)] for entry in entries: if entry is None: continue entry = dereference(document, entry) for media, content in entry.get('content', {}).items(): if 'schema' not in content: errors.append(f'{location} {field} {media}: missing schema') for sample in op.get('x-codeSamples', []): if sample.get('lang', '').lower() != 'curl': continue try: args = shlex.split(sample['source']) url = next(x for x in args if x.startswith(('http://', 'https://'))) expected_path = re.sub(r'{[^}]+}', r'[^/]+', path) if not re.fullmatch(expected_path, urlsplit(url).path): errors.append(f'{location}: cURL URL does not match operation') actual_method = args[args.index('-X') + 1].lower() if '-X' in args else 'get' if actual_method != method: errors.append(f'{location}: cURL method does not match operation') for flag in ('-d', '--data', '--data-raw'): if flag in args: body = json.loads(args[args.index(flag) + 1]) schema = dereference(document, op['requestBody'])['content']['application/json']['schema'] check_value(schema, body, location + '/curl') except (ValueError, KeyError, StopIteration) as exc: errors.append(f'{location}: invalid cURL sample: {exc}') return errors def repository_versions(root): versions = {f'docs/{lang}/latest/config.json': json.loads( (root / f'docs/{lang}/latest/config.json').read_text())['version'] for lang in ('en', 'zh')} text = (root / 'apisix/core/version.lua').read_text() versions['apisix/core/version.lua'] = re.search(r'VERSION\s*=\s*"([^"]+)"', text).group(1) return versions def validate_source(document, root): """Compare structural constraints with this checkout's pure Lua schema table. Lupa evaluates schema_def.lua only: no APISIX module or network is loaded. This is not an OpenResty integration test or a replacement for Lua checkers. """ from lupa import LuaRuntime, lua_type def from_lua(value): if lua_type(value) != 'table': return value keys = list(value.keys()) if keys and all(isinstance(key, int) for key in keys): return [from_lua(value[i]) for i in range(1, len(keys) + 1)] result = {key: from_lua(value[key]) for key in keys} if 'dependencies' in result: dependencies = result.pop('dependencies') for keyword, typ in [('dependentSchemas', dict), ('dependentRequired', list)]: entries = {k: v for k, v in dependencies.items() if isinstance(v, typ)} if entries: result[keyword] = entries return result lua = LuaRuntime() lua_schema = lua.execute((root / 'apisix/schema_def.lua').read_text()) source = from_lua(lua_schema) def constraints(value): if isinstance(value, list): return [constraints(item) for item in value] if not isinstance(value, dict): return value if '$ref' in value: return constraints(resolve(document, value['$ref'])) result = {k: constraints(v) for k, v in value.items() if k not in {'description', 'title', 'readOnly', 'examples', 'example'} and not k.startswith('x-')} # APISIX's JSON Schema validator skips structural validation for # $secret:// and $env:// values. OpenAPI models that runtime hook # explicitly, either as an accepted union branch or as an exclusion # where later runtime validation requires an inline PEM value. secret_reference = { 'type': 'string', 'pattern': r'^\$(?:secret|[eE][nN][vV])://', } if result.get('not') == secret_reference: result.pop('not') if set(result) == {'anyOf'} and len(result['anyOf']) == 2: branches = [branch for branch in result['anyOf'] if branch != secret_reference] if len(branches) == 1: return branches[0] return result names = {'ResourceId': 'id_schema', 'Route': 'route', 'Service': 'service', 'Upstream': 'upstream', 'Consumer': 'consumer', 'Credential': 'credential', 'ConsumerGroup': 'consumer_group', 'GlobalRule': 'global_rule', 'PluginConfig': 'plugin_config', 'SSL': 'ssl', 'StreamRoute': 'stream_route', 'Proto': 'proto', 'GraphQLCostDecoration': 'graphql_cost_decoration'} errors = [] schemas = document['components']['schemas'] for name, key in names.items(): expected = constraints(source[key]) # resource.check_conf injects the path id before running these schemas. if name in {'ConsumerGroup', 'GlobalRule', 'PluginConfig'}: expected['required'].remove('id') if constraints(schemas[name]) != expected: errors.append(f'{name}: structural constraints differ from schema_def.lua ({key})') lua.globals().core = lua.table_from({'schema': lua_schema}) for name, backend in [('VaultSecret', 'vault'), ('AwsSecret', 'aws'), ('GcpSecret', 'gcp')]: text = (root / f'apisix/secret/{backend}.lua').read_text() declaration = 'local schema =' + text.split('local schema =', 1)[1].split('local _M', 1)[0] expected = from_lua(lua.execute(declaration + '\nreturn schema')) if constraints(schemas[name]) != constraints(expected): errors.append(f'{name}: structural constraints differ from secret/{backend}.lua') # Check supported canonical CRUD methods against resource.new declarations. modules = {'routes': 'routes', 'services': 'services', 'upstreams': 'upstreams', 'consumers': 'consumers', 'ssls': 'ssl', 'global_rules': 'global_rules', 'consumer_groups': 'consumer_group', 'plugin_configs': 'plugin_config', 'plugin_metadata': 'plugin_metadata', 'stream_routes': 'stream_routes', 'protos': 'proto', 'secrets': 'secrets', 'credentials': 'credentials'} for resource, module in modules.items(): text = (root / f'apisix/admin/{module}.lua').read_text() match = re.search(r'unsupported_methods\s*=\s*{([^}]+)}', text) unsupported = set(re.findall(r'"([a-z]+)"', match.group(1))) if match else set() actual = set() for path, item in document['paths'].items(): if resource == 'credentials': relevant = '/consumers/{username}/credentials' in path else: relevant = path.startswith('/apisix/admin/' + resource) and ( path == '/apisix/admin/' + resource or path.startswith('/apisix/admin/' + resource + '/')) if resource == 'consumers' and '/credentials' in path: relevant = False if relevant: actual.update(METHODS & item.keys()) expected = {'get', 'put', 'post', 'patch', 'delete'} - unsupported if actual != expected: errors.append(f'{resource}: documented methods {sorted(actual)} != {sorted(expected)}') # Resource.put accepts a body ID when the canonical item URL is omitted. expected_ops = set() for resource, module in modules.items(): if resource == 'credentials': base = '/apisix/admin/consumers/{username}/credentials' else: base = '/apisix/admin/' + resource text = (root / f'apisix/admin/{module}.lua').read_text() match = re.search(r'unsupported_methods\s*=\s*{([^}]+)}', text) unsupported = set(re.findall(r'"(\w+)"', match.group(1))) if match else set() suffix = '/{username}' if resource == 'consumers' else '/{plugin_name}' if resource == 'plugin_metadata' else '/{secret_type}/{id}' if resource == 'secrets' else '/{id}' item = base + suffix expected_ops.update([(base, 'get'), (item, 'get'), (item, 'put'), (item, 'delete')]) if resource not in ('secrets', 'plugin_metadata'): expected_ops.add((base, 'put')) if 'post' not in unsupported: expected_ops.add((base, 'post')) if 'patch' not in unsupported: expected_ops.update([(item, 'patch'), (item + '/{sub_path}', 'patch')]) expected_ops.add(('/apisix/admin/secrets/{secret_type}', 'get')) expected_ops.update({ ('/apisix/admin/plugins/{plugin_name}', 'get'), ('/apisix/admin/schema/plugins/{plugin_name}', 'get'), ('/apisix/admin/plugins', 'get'), ('/apisix/admin/plugins/list', 'get'), ('/apisix/admin/plugins/reload', 'put'), ('/apisix/admin/schema/{resource}', 'get'), ('/apisix/admin/schema/validate/{resource}', 'post'), ('/apisix/admin', 'head'), ('/apisix/admin/configs', 'get'), ('/apisix/admin/configs', 'put'), ('/apisix/admin/configs', 'head'), ('/apisix/admin/configs/validate', 'post'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations', 'get'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations', 'post'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations/{decoration_id}', 'get'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations/{decoration_id}', 'put'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations/{decoration_id}', 'patch'), ('/apisix/admin/services/{service_id}/graphql_cost_decorations/{decoration_id}', 'delete'), }) actual_ops = {(path, method) for path, item in document['paths'].items() for method in METHODS & item.keys()} if actual_ops != expected_ops: errors.append(f'operation surface differs: missing {sorted(expected_ops - actual_ops)}, extra {sorted(actual_ops - expected_ops)}') return errors def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('spec', nargs='?', type=Path, default=ROOT / 'docs/en/latest/admin-api.json') args = parser.parse_args() spec_text = args.spec.read_text() if re.search(r'api7|apiseven', spec_text, re.IGNORECASE): print('Admin API specification must not contain API7-specific references', file=sys.stderr) return 1 document = json.loads(spec_text) errors = validate(document, repository_versions(ROOT)) if not errors: errors.extend(validate_source(document, ROOT)) if errors: print('\n'.join(errors), file=sys.stderr) return 1 print('Admin API OpenAPI 3.1 contract is valid (refs, operations, schemas, examples, defaults, versions, release source constraints and methods).') return 0 if __name__ == '__main__': sys.exit(main())