diff options
| -rw-r--r-- | data/op-mode-standardized.json | 7 | ||||
| -rw-r--r-- | src/services/api/graphql/bindings.py | 3 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/directives.py | 20 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/mutations.py | 12 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/queries.py | 12 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/schema/schema.graphql | 2 | ||||
| -rwxr-xr-x | src/services/api/graphql/recipes/queries/system_status.py | 15 | ||||
| -rw-r--r-- | src/services/api/graphql/recipes/session.py | 61 | ||||
| -rwxr-xr-x | src/services/api/graphql/utils/schema_from_op_mode.py | 161 | ||||
| -rw-r--r-- | src/services/api/graphql/utils/util.py | 55 | 
10 files changed, 329 insertions, 19 deletions
| diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json new file mode 100644 index 000000000..4dccbba7f --- /dev/null +++ b/data/op-mode-standardized.json @@ -0,0 +1,7 @@ +[ +"cpu.py", +"memory.py", +"neighbor.py", +"route.py", +"version.py" +] diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 84d719fda..049d59de7 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -17,11 +17,14 @@ import vyos.defaults  from . graphql.queries import query  from . graphql.mutations import mutation  from . graphql.directives import directives_dict +from . utils.schema_from_op_mode import generate_op_mode_definitions  from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers  def generate_schema():      api_schema_dir = vyos.defaults.directories['api_schema'] +    generate_op_mode_definitions() +      type_defs = load_schema_from_path(api_schema_dir)      schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict) diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 551d28831..d8ceefae6 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -80,9 +80,27 @@ class ImageDirective(VyosDirective):          super().visit_field_definition(field, object_type,                                         make_resolver=make_image_resolver) +class GenOpQueryDirective(VyosDirective): +    """ +    Class providing implementation of 'genopquery' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_gen_op_query_resolver) + +class GenOpMutationDirective(VyosDirective): +    """ +    Class providing implementation of 'genopmutation' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_gen_op_mutation_resolver) +  directives_dict = {"configure": ConfigureDirective,                     "showconfig": ShowConfigDirective,                     "systemstatus": SystemStatusDirective,                     "configfile": ConfigFileDirective,                     "show": ShowDirective, -                   "image": ImageDirective} +                   "image": ImageDirective, +                   "genopquery": GenOpQueryDirective, +                   "genopmutation": GenOpMutationDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 93e046319..3e89fb239 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -63,6 +63,10 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                       "errors": ['invalid API key']                  } +            # We are finished with the 'key' entry, and may remove so as to +            # pass the rest of data (if any) to function. +            del data['key'] +              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass @@ -71,7 +75,7 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -107,3 +111,7 @@ def make_config_file_resolver(mutation_name):  def make_image_resolver(mutation_name):      return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) + +def make_gen_op_mutation_resolver(mutation_name): +    class_name = mutation_name +    return make_mutation_resolver(mutation_name, class_name, 'gen_op_mutation') diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index eeaa9e19c..f6544709e 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -63,6 +63,10 @@ def make_query_resolver(query_name, class_name, session_func):                       "errors": ['invalid API key']                  } +            # We are finished with the 'key' entry, and may remove so as to +            # pass the rest of data (if any) to function. +            del data['key'] +              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass @@ -71,7 +75,7 @@ def make_query_resolver(query_name, class_name, session_func):                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -101,3 +105,7 @@ def make_system_status_resolver(query_name):  def make_show_resolver(query_name):      class_name = query_name      return make_query_resolver(query_name, class_name, 'show') + +def make_gen_op_query_resolver(query_name): +    class_name = query_name +    return make_query_resolver(query_name, class_name, 'gen_op_query') diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 8ae71f632..624be2620 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -9,6 +9,8 @@ directive @show on FIELD_DEFINITION  directive @showconfig on FIELD_DEFINITION  directive @systemstatus on FIELD_DEFINITION  directive @image on FIELD_DEFINITION +directive @genopquery on FIELD_DEFINITION +directive @genopmutation on FIELD_DEFINITION  scalar Generic diff --git a/src/services/api/graphql/recipes/queries/system_status.py b/src/services/api/graphql/recipes/queries/system_status.py index 00c137443..8dadcc9f3 100755 --- a/src/services/api/graphql/recipes/queries/system_status.py +++ b/src/services/api/graphql/recipes/queries/system_status.py @@ -23,23 +23,16 @@ import importlib.util  from vyos.defaults import directories -OP_PATH = directories['op_mode'] - -def load_as_module(name: str): -    path = os.path.join(OP_PATH, name) -    spec = importlib.util.spec_from_file_location(name, path) -    mod = importlib.util.module_from_spec(spec) -    spec.loader.exec_module(mod) -    return mod +from api.graphql.utils.util import load_op_mode_as_module  def get_system_version() -> dict: -    show_version = load_as_module('version.py') +    show_version = load_op_mode_as_module('version.py')      return show_version.show(raw=True, funny=False)  def get_system_uptime() -> dict: -    show_uptime = load_as_module('show_uptime.py') +    show_uptime = load_op_mode_as_module('show_uptime.py')      return show_uptime.get_raw_data()  def get_system_ram_usage() -> dict: -    show_ram = load_as_module('memory.py') +    show_ram = load_op_mode_as_module('memory.py')      return show_ram.show(raw=True) diff --git a/src/services/api/graphql/recipes/session.py b/src/services/api/graphql/recipes/session.py index c436de08a..6b580af01 100644 --- a/src/services/api/graphql/recipes/session.py +++ b/src/services/api/graphql/recipes/session.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -13,15 +13,20 @@  # You should have received a copy of the GNU Lesser General Public License  # along with this library.  If not, see <http://www.gnu.org/licenses/>. +import os  import json  from ariadne import convert_camel_case_to_snake -import vyos.defaults  from vyos.config import Config  from vyos.configtree import ConfigTree +from vyos.defaults import directories  from vyos.template import render +from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name + +op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') +  class Session:      """      Wrapper for calling configsession functions based on GraphQL requests. @@ -33,6 +38,12 @@ class Session:          self._data = data          self._name = convert_camel_case_to_snake(type(self).__name__) +        try: +            with open(op_mode_include_file) as f: +                self._op_mode_list = f.read() +        except Exception: +            self._op_mode_list = None +      def configure(self):          session = self._session          data = self._data @@ -40,7 +51,7 @@ class Session:          tmpl_file = f'{func_base_name}.tmpl'          cmd_file = f'/tmp/{func_base_name}.cmds' -        tmpl_dir = vyos.defaults.directories['api_templates'] +        tmpl_dir = directories['api_templates']          try:              render(cmd_file, tmpl_file, data, location=tmpl_dir) @@ -150,3 +161,47 @@ class Session:          status['ram'] = system_status.get_system_ram_usage()          return status + +    def gen_op_query(self): +        session = self._session +        data = self._data +        name = self._name +        op_mode_list = self._op_mode_list + +        # handle the case that the op-mode file contains underscores: +        if op_mode_list is None: +            raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") +        (func_name, basename) = split_compound_op_mode_name(name, op_mode_list) +        if basename == '': +            raise FileNotFoundError(f"No op-mode file basename in string '{name}'") + +        mod = load_op_mode_as_module(f'{basename}.py') +        func = getattr(mod, func_name) +        if len(list(data)) > 0: +            res = func(True, **data) +        else: +            res = func(True) + +        return res + +    def gen_op_mutation(self): +        session = self._session +        data = self._data +        name = self._name +        op_mode_list = self._op_mode_list + +        # handle the case that the op-mode file name contains underscores: +        if op_mode_list is None: +            raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") +        (func_name, basename) = split_compound_op_mode_name(name, op_mode_list) +        if basename == '': +            raise FileNotFoundError(f"No op-mode file basename in string '{name}'") + +        mod = load_op_mode_as_module(f'{basename}.py') +        func = getattr(mod, func_name) +        if len(list(data)) > 0: +            res = func(**data) +        else: +            res = func() + +        return res diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/utils/schema_from_op_mode.py new file mode 100755 index 000000000..cdde5f187 --- /dev/null +++ b/src/services/api/graphql/utils/schema_from_op_mode.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# +# A utility to generate GraphQL schema defintions from standardized op-mode +# scripts. + +import os +import json +import typing +from inspect import signature, getmembers, isfunction +from jinja2 import Template + +from vyos.defaults import directories +from . util import load_as_module, is_op_mode_function_name, is_show_function_name + +OP_MODE_PATH = directories['op_mode'] +SCHEMA_PATH = directories['api_schema'] +DATA_DIR = directories['data'] + +op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json') + +schema_data: dict = {'schema_name': '', +                     'schema_fields': []} + +query_template  = """ +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Query { +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopquery +} +""" + +mutation_template  = """ +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +} +""" + +def _snake_to_pascal_case(name: str) -> str: +    res = ''.join(map(str.title, name.split('_'))) +    return res + +def _map_type_name(type_name: type, optional: bool = False) -> str: +    if type_name == str: +        return 'String!' if not optional else 'String = null' +    if type_name == int: +        return 'Int!' if not optional else 'Int = null' +    if type_name == bool: +        return 'Boolean!' if not optional else 'Boolean = false' +    if typing.get_origin(type_name) == list: +        if not optional: +            return f'[{_map_type_name(typing.get_args(type_name)[0])}]!' +        return f'[{_map_type_name(typing.get_args(type_name)[0])}]' +    # typing.Optional is typing.Union[_, NoneType] +    if (typing.get_origin(type_name) is typing.Union and +            typing.get_args(type_name)[1] == type(None)): +        return f'{_map_type_name(typing.get_args(type_name)[0], optional=True)}' + +    # scalar 'Generic' is defined in schema.graphql +    return 'Generic' + +def create_schema(func_name: str, base_name: str, func: callable) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = _map_type_name(sig.parameters[k].annotation) + +    # It is assumed that if one is generating a schema for a 'show_*' +    # function, that 'get_raw_data' is present and 'raw' is desired. +    if 'raw' in list(field_dict): +        del field_dict['raw'] + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = _snake_to_pascal_case(func_name + '_' + base_name) +    schema_data['schema_fields'] = schema_fields + +    if is_show_function_name(func_name): +        j2_template = Template(query_template) +    else: +        j2_template = Template(mutation_template) + +    res = j2_template.render(schema_data) + +    return res + +def generate_op_mode_definitions(): +    with open(op_mode_include_file) as f: +        op_mode_files = json.load(f) + +    for file in op_mode_files: +        basename = os.path.splitext(file)[0] +        module = load_as_module(basename, os.path.join(OP_MODE_PATH, file)) + +        funcs = getmembers(module, isfunction) +        funcs = list(filter(lambda ft: is_op_mode_function_name(ft[0]), funcs)) + +        funcs_dict = {} +        for (name, thunk) in funcs: +            funcs_dict[name] = thunk + +        results = [] +        for name,func in funcs_dict.items(): +            res = create_schema(name, basename, func) +            results.append(res) + +        out = '\n'.join(results) +        with open(f'{SCHEMA_PATH}/{basename}.graphql', 'w') as f: +            f.write(out) + +if __name__ == '__main__': +    generate_op_mode_definitions() diff --git a/src/services/api/graphql/utils/util.py b/src/services/api/graphql/utils/util.py new file mode 100644 index 000000000..e3dea31bf --- /dev/null +++ b/src/services/api/graphql/utils/util.py @@ -0,0 +1,55 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import importlib.util + +from vyos.defaults import directories + +def load_as_module(name: str, path: str): +    spec = importlib.util.spec_from_file_location(name, path) +    mod = importlib.util.module_from_spec(spec) +    spec.loader.exec_module(mod) +    return mod + +def load_op_mode_as_module(name: str): +    path = os.path.join(directories['op_mode'], name) +    name = os.path.splitext(name)[0] +    return load_as_module(name, path) + +def is_op_mode_function_name(name): +    if re.match(r"^(show|clear|reset|restart)", name): +        return True +    return False + +def is_show_function_name(name): +    if re.match(r"^show", name): +        return True +    return False + +def _nth_rsplit(delim: str, n: int, s: str): +    groups = s.split(delim) +    l = len(groups) +    if n > l-1: +        return ('', s) +    return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) + +def split_compound_op_mode_name(name: str, files: list): +    for i in range(1, name.count('_') + 1): +        pair = _nth_rsplit('_', i, name) +        if pair[1] in files: +            return pair +    return (name, '') | 
