"""Script for auto-generating the open api spec file.""" # pylint: disable=W0717 import re import sys sys.path.append('') from application import app # noqa: E402 from fastapi.openapi.utils import get_openapi # noqa: E402 from git import Repo # noqa: E402 import yaml # noqa: E402 from moneyhub.config import Config # noqa: E402 ARGS = 'Args' DESCRIPTION_KEY = 'description' NAME_KEY = 'name' HEAD = 'HEAD' HANDLERS = 'moneyhub/handlers/' NEW_LINE = '\n' PARAMETERS_KEY = 'parameters' PATHS = 'paths' ARGUMENT_REGEX = r'([\w]+)( *\([a-zA-z]+\) *)?: *(.+)' SCHEMAS = 'moneyhub/schemas/' RETURNS = 'Returns' SEVERS_KEY = 'servers' URL_KEY = 'url' URL = 'https://qa-ows-moneyhub.theorchard.io' SERVER_DESCRIPTION = 'QA server' SPEC_FILE = f'spec/ows_moneyhub-{Config.SERVICE_VERSION}.yaml' ACCESS_RULES_FILE = 'moneyhub/access_rules.yml' OPEN_API_VERSION = '3.0.0' def generate_openapi(with_git: bool = True): """Generate an openapi file. If the with_git parameter is True it will check the currently staged files and only generate new file if there are relevant changes. It will also stage the new OpenAPI file. Args: with_git (bool): Whether to check the Git repo """ if with_git: repo = Repo('.') if check_file_changes(repo) is False: return print('\nDetected file changes! 👀') print(f'Baking new {SPEC_FILE} file 🍳') json_dict = get_openapi( title=app.title, version=app.version, openapi_version=OPEN_API_VERSION, description=app.description, routes=app.routes ) json_dict = append_server_info(json_dict) json_dict = append_parameter_descriptions(json_dict) create_yaml_file(json_dict) print(f'Generated {SPEC_FILE}! 🍰\n') if with_git: repo.index.add(SPEC_FILE) def append_server_info(json_dict: dict) -> dict: """Given a json dict, append server key and return it. Args: json_dict(dict): a json dict containing openapi specification info Returns: json_dict(dict): a dict containing openapi specification info """ json_dict[SEVERS_KEY] = [ { URL_KEY: URL, DESCRIPTION_KEY: SERVER_DESCRIPTION } ] return json_dict def check_file_changes(repo: Repo) -> bool: """Return True if file changes occur in /handlers or /schemas. Args: repo (Repo): Git repo to check for changes Returns: bool: Whether there have been changes to the watched files """ diff_index = repo.index.diff(HEAD) for diff_item in diff_index: diff_path = diff_item.a_path if diff_path.startswith(HANDLERS) or diff_path.startswith(SCHEMAS): return True return False def append_parameter_descriptions(json_dict: dict) -> dict: """Given a json dictionary, append descriptions to its parameters key array. Args: json_dict(dict): a json dict containing openapi specification info Returns: json_dict(dict): a dict containing openapi specification info """ for path in json_dict[PATHS].values(): for method_dict in path.values(): if DESCRIPTION_KEY not in method_dict: continue method_desc = method_dict[DESCRIPTION_KEY] start_idx = method_desc.find(ARGS) end_idx = method_desc.find(RETURNS) method_dict[DESCRIPTION_KEY] = edit_method_description(method_desc) if PARAMETERS_KEY not in method_dict: continue method_desc_trimmed = method_desc[start_idx:end_idx] args_desc = re.findall(ARGUMENT_REGEX, method_desc_trimmed) args_desc = { arg[0]: arg[2] for arg in args_desc } parameters = method_dict[PARAMETERS_KEY] for i in range(len(parameters)): arg_name = parameters[i][NAME_KEY] if arg_name in args_desc: parameters[i][DESCRIPTION_KEY] = args_desc[arg_name] else: underscore_arg_name = arg_name.replace('-', '_') if underscore_arg_name in args_desc: parameters[i][DESCRIPTION_KEY] = args_desc[underscore_arg_name] return json_dict def edit_method_description(method_desc: str) -> str: """Given a method description, trim it at the newline character and return it. Args: method_desc (str): the endpoint HTTP method description Returns: new_method_desc: the trimmed method description. """ end_idx = method_desc.find(NEW_LINE) new_method_desc = method_desc[:end_idx] return new_method_desc def create_yaml_file(json_dict: dict): """Given a json dict, create an open api yaml file. Args: json_dict(dict): a json dict containing openapi specification info """ try: with open(SPEC_FILE, mode='w', encoding='utf-8') as yaml_file: yaml.dump(json_dict, yaml_file, allow_unicode=True, encoding='utf-8', sort_keys=False) except OSError: print('Error generating file ❌') sys.exit(1) def read_yaml_file(file_path: str) -> dict: """Read a YAML file. Args: file_path (str): Path to the file to read. Returns: dict: Contents of the file. """ with open(file_path, mode='r', encoding='utf-8') as yaml_file: return yaml.load(yaml_file, Loader=yaml.Loader) def validate_access_rules() -> bool: """Validate the paths in the access rules file. Returns: bool: Whether the results are all good (False = has errors). """ print(f'Validating access rules in {ACCESS_RULES_FILE}') spec_data = read_yaml_file(SPEC_FILE) access_rules_data = read_yaml_file(ACCESS_RULES_FILE) all_good = True paths = set() for rule in access_rules_data['rules']: if rule['path'] in paths: print(f"🛑 duplicate path in access rules: {rule['path']}") all_good = False else: paths.add(rule['path']) spec_uris = { re.sub(r'\{.*?\}', '<*>', path) : [ method.upper() for method in spec_data['paths'][path].keys() ] for path in spec_data['paths'].keys() } access_rules_uris = { rule['path'] : [ method.upper() for method in rule['methods'] ] for rule in access_rules_data['rules'] } for uri, methods in spec_uris.items(): for method in methods: if uri not in access_rules_uris or method not in access_rules_uris[uri]: print(f'⚠️ spec URI missing from access rules: {method} {uri}') for uri, methods in access_rules_uris.items(): for method in methods: if uri not in spec_uris or method not in spec_uris[uri]: print(f'🛑 access rule URI missing from spec: {method} {uri}') all_good = False return all_good if __name__ == '__main__': generate_openapi(False) if not validate_access_rules(): exit(1)