From 093ac258c11894b07afd9e85a61778d23e356718 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 23 Oct 2022 11:05:33 -0500 Subject: graphql: T4574: call all schema definition generation on init --- src/services/api/graphql/bindings.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src/services/api/graphql/bindings.py') diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 0b1260912..c5c4560dd 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -19,12 +19,16 @@ from . graphql.mutations import mutation from . graphql.directives import directives_dict from . graphql.errors import op_mode_error from . utils.schema_from_op_mode import generate_op_mode_definitions +from . utils.schema_from_config_session import generate_config_session_definitions +from . utils.schema_from_composite import generate_composite_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() + generate_config_session_definitions() + generate_composite_definitions() type_defs = load_schema_from_path(api_schema_dir) -- cgit v1.2.3 From 7038b761302be2ec90338981830b8cd7cf887381 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 23 Oct 2022 11:06:03 -0500 Subject: graphql: T4574: reorganize directory structure for clarity --- src/services/api/graphql/__init__.py | 0 src/services/api/graphql/bindings.py | 6 +- .../api/graphql/generate/composite_function.py | 11 ++ .../graphql/generate/config_session_function.py | 28 ++++ .../api/graphql/generate/schema_from_composite.py | 121 ++++++++++++++ .../graphql/generate/schema_from_config_session.py | 121 ++++++++++++++ .../api/graphql/generate/schema_from_op_mode.py | 185 +++++++++++++++++++++ src/services/api/graphql/graphql/mutations.py | 2 +- src/services/api/graphql/graphql/queries.py | 2 +- src/services/api/graphql/key_auth.py | 18 -- src/services/api/graphql/libs/key_auth.py | 18 ++ src/services/api/graphql/libs/op_mode.py | 100 +++++++++++ .../api/graphql/session/composite/system_status.py | 2 +- src/services/api/graphql/session/session.py | 2 +- .../api/graphql/utils/composite_function.py | 11 -- .../api/graphql/utils/config_session_function.py | 28 ---- .../api/graphql/utils/schema_from_composite.py | 119 ------------- .../graphql/utils/schema_from_config_session.py | 119 ------------- .../api/graphql/utils/schema_from_op_mode.py | 183 -------------------- src/services/api/graphql/utils/util.py | 100 ----------- 20 files changed, 591 insertions(+), 585 deletions(-) create mode 100644 src/services/api/graphql/__init__.py create mode 100644 src/services/api/graphql/generate/composite_function.py create mode 100644 src/services/api/graphql/generate/config_session_function.py create mode 100755 src/services/api/graphql/generate/schema_from_composite.py create mode 100755 src/services/api/graphql/generate/schema_from_config_session.py create mode 100755 src/services/api/graphql/generate/schema_from_op_mode.py delete mode 100644 src/services/api/graphql/key_auth.py create mode 100644 src/services/api/graphql/libs/key_auth.py create mode 100644 src/services/api/graphql/libs/op_mode.py delete mode 100644 src/services/api/graphql/utils/composite_function.py delete mode 100644 src/services/api/graphql/utils/config_session_function.py delete mode 100755 src/services/api/graphql/utils/schema_from_composite.py delete mode 100755 src/services/api/graphql/utils/schema_from_config_session.py delete mode 100755 src/services/api/graphql/utils/schema_from_op_mode.py delete mode 100644 src/services/api/graphql/utils/util.py (limited to 'src/services/api/graphql/bindings.py') diff --git a/src/services/api/graphql/__init__.py b/src/services/api/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index c5c4560dd..d3cff21c7 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -18,9 +18,9 @@ from . graphql.queries import query from . graphql.mutations import mutation from . graphql.directives import directives_dict from . graphql.errors import op_mode_error -from . utils.schema_from_op_mode import generate_op_mode_definitions -from . utils.schema_from_config_session import generate_config_session_definitions -from . utils.schema_from_composite import generate_composite_definitions +from . generate.schema_from_op_mode import generate_op_mode_definitions +from . generate.schema_from_config_session import generate_config_session_definitions +from . generate.schema_from_composite import generate_composite_definitions from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers def generate_schema(): diff --git a/src/services/api/graphql/generate/composite_function.py b/src/services/api/graphql/generate/composite_function.py new file mode 100644 index 000000000..bc9d80fbb --- /dev/null +++ b/src/services/api/graphql/generate/composite_function.py @@ -0,0 +1,11 @@ +# typing information for composite functions: those that invoke several +# elementary requests, and return the result as a single dict +import typing + +def system_status(): + pass + +queries = {'system_status': system_status} + +mutations = {} + diff --git a/src/services/api/graphql/generate/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py new file mode 100644 index 000000000..fc0dd7a87 --- /dev/null +++ b/src/services/api/graphql/generate/config_session_function.py @@ -0,0 +1,28 @@ +# typing information for native configsession functions; used to generate +# schema definition files +import typing + +def show_config(path: list[str], configFormat: typing.Optional[str]): + pass + +def show(path: list[str]): + pass + +queries = {'show_config': show_config, + 'show': show} + +def save_config_file(fileName: typing.Optional[str]): + pass +def load_config_file(fileName: str): + pass +def add_system_image(location: str): + pass +def delete_system_image(name: str): + pass + +mutations = {'save_config_file': save_config_file, + 'load_config_file': load_config_file, + 'add_system_image': add_system_image, + 'delete_system_image': delete_system_image} + + diff --git a/src/services/api/graphql/generate/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py new file mode 100755 index 000000000..7187047a0 --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_composite.py @@ -0,0 +1,121 @@ +#!/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 . +# +# +# A utility to generate GraphQL schema defintions from typing information of +# composite functions comprising several requests. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name + from composite_function import queries, mutations +else: + from .. libs.op_mode import snake_to_pascal_case, map_type_name + from . composite_function import queries, mutations + +SCHEMA_PATH = directories['api_schema'] + +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 @compositequery +} +""" + +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 @compositemutation +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: + sig = signature(func) + + field_dict = {} + for k in sig.parameters: + field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + + schema_fields = [] + for k,v in field_dict.items(): + schema_fields.append(k+': '+v) + + schema_data['schema_name'] = snake_to_pascal_case(func_name) + schema_data['schema_fields'] = schema_fields + + j2_template = Template(template) + res = j2_template.render(schema_data) + + return res + +def generate_composite_definitions(): + results = [] + for name,func in queries.items(): + res = create_schema(name, func, query_template) + results.append(res) + + for name,func in mutations.items(): + res = create_schema(name, func, mutation_template) + results.append(res) + + out = '\n'.join(results) + with open(f'{SCHEMA_PATH}/composite.graphql', 'w') as f: + f.write(out) + +if __name__ == '__main__': + generate_composite_definitions() diff --git a/src/services/api/graphql/generate/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py new file mode 100755 index 000000000..cf69cbafd --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -0,0 +1,121 @@ +#!/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 . +# +# +# A utility to generate GraphQL schema defintions from typing information of +# (wrappers of) native configsession functions. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name + from config_session_function import queries, mutations +else: + from .. libs.op_mode import snake_to_pascal_case, map_type_name + from . config_session_function import queries, mutations + +SCHEMA_PATH = directories['api_schema'] + +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 @configsessionquery +} +""" + +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 @configsessionmutation +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: + sig = signature(func) + + field_dict = {} + for k in sig.parameters: + field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + + schema_fields = [] + for k,v in field_dict.items(): + schema_fields.append(k+': '+v) + + schema_data['schema_name'] = snake_to_pascal_case(func_name) + schema_data['schema_fields'] = schema_fields + + j2_template = Template(template) + res = j2_template.render(schema_data) + + return res + +def generate_config_session_definitions(): + results = [] + for name,func in queries.items(): + res = create_schema(name, func, query_template) + results.append(res) + + for name,func in mutations.items(): + res = create_schema(name, func, mutation_template) + results.append(res) + + out = '\n'.join(results) + with open(f'{SCHEMA_PATH}/configsession.graphql', 'w') as f: + f.write(out) + +if __name__ == '__main__': + generate_config_session_definitions() diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py new file mode 100755 index 000000000..a88816b34 --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -0,0 +1,185 @@ +#!/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 . +# +# +# A utility to generate GraphQL schema defintions from standardized op-mode +# scripts. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': + sys.path.append("/usr/libexec/vyos/services/api") + from graphql.libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from graphql.libs.op_mode import snake_to_pascal_case, map_type_name +else: + from .. libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from .. libs.op_mode import snake_to_pascal_case, map_type_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') +op_mode_error_schema = 'op_mode_error.graphql' + +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 }} + op_mode_error: OpModeError + 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 }} + op_mode_error: OpModeError + success: Boolean! + errors: [String] +} + +extend type Mutation { + {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +} +""" + +error_template = """ +interface OpModeError { + name: String! + message: String! + vyos_code: Int! +} +{% for name in error_names %} +type {{ name }} implements OpModeError { + name: String! + message: String! + vyos_code: Int! +} +{%- endfor %} +""" + +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 create_error_schema(): + from vyos import opmode + + e = Exception + err_types = getmembers(opmode, isclass) + err_types = [k for k in err_types if issubclass(k[1], e)] + # drop base class, to be replaced by interface type. Find the class + # programmatically, in case the base class name changes. + for i in range(len(err_types)): + if err_types[i][1] in getmro(err_types[i-1][1]): + del err_types[i] + break + err_names = [k[0] for k in err_types] + error_data = {'error_names': err_names} + j2_template = Template(error_template) + res = j2_template.render(error_data) + + return res + +def generate_op_mode_definitions(): + out = create_error_schema() + with open(f'{SCHEMA_PATH}/{op_mode_error_schema}', 'w') as f: + f.write(out) + + 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].replace('-', '_') + 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/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 32da0eeb7..f0c8b438f 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -20,7 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state -from .. import key_auth +from .. libs import key_auth from api.graphql.session.session import Session from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code from vyos.opmode import Error as OpModeError diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 791b0d3e0..13eb59ae4 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -20,7 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state -from .. import key_auth +from .. libs import key_auth from api.graphql.session.session import Session from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code from vyos.opmode import Error as OpModeError diff --git a/src/services/api/graphql/key_auth.py b/src/services/api/graphql/key_auth.py deleted file mode 100644 index f756ed6d8..000000000 --- a/src/services/api/graphql/key_auth.py +++ /dev/null @@ -1,18 +0,0 @@ - -from . import state - -def check_auth(key_list, key): - if not key_list: - return None - key_id = None - for k in key_list: - if k['key'] == key: - key_id = k['id'] - return key_id - -def auth_required(key): - api_keys = None - api_keys = state.settings['app'].state.vyos_keys - key_id = check_auth(api_keys, key) - state.settings['app'].state.vyos_id = key_id - return key_id diff --git a/src/services/api/graphql/libs/key_auth.py b/src/services/api/graphql/libs/key_auth.py new file mode 100644 index 000000000..2db0f7d48 --- /dev/null +++ b/src/services/api/graphql/libs/key_auth.py @@ -0,0 +1,18 @@ + +from .. import state + +def check_auth(key_list, key): + if not key_list: + return None + key_id = None + for k in key_list: + if k['key'] == key: + key_id = k['id'] + return key_id + +def auth_required(key): + api_keys = None + api_keys = state.settings['app'].state.vyos_keys + key_id = check_auth(api_keys, key) + state.settings['app'].state.vyos_id = key_id + return key_id diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py new file mode 100644 index 000000000..da2bcdb5b --- /dev/null +++ b/src/services/api/graphql/libs/op_mode.py @@ -0,0 +1,100 @@ +# Copyright 2022 VyOS maintainers and contributors +# +# 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 . + +import os +import re +import typing +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].replace('-', '_') + 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_split(delim: str, n: int, s: str): + groups = s.split(delim) + l = len(groups) + if n > l-1 or n < 1: + return (s, '') + return (delim.join(groups[:n]), delim.join(groups[n:])) + +def _nth_rsplit(delim: str, n: int, s: str): + groups = s.split(delim) + l = len(groups) + if n > l-1 or n < 1: + return (s, '') + return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) + +# Since we have mangled possible hyphens in the file name while constructing +# the snake case of the query/mutation name, we will need to recover the +# file name by searching with mangling: +def _filter_on_mangled(test): + def func(elem): + mangle = os.path.splitext(elem)[0].replace('-', '_') + return test == mangle + return func + +# Find longest name in concatenated string that matches the basename of an +# op-mode script. Should one prefer to concatenate in the reverse order +# (script_name + '_' + function_name), use _nth_rsplit. +def split_compound_op_mode_name(name: str, files: list): + for i in range(1, name.count('_') + 1): + pair = _nth_split('_', i, name) + f = list(filter(_filter_on_mangled(pair[1]), files)) + if f: + pair = (pair[0], f[0]) + return pair + return (name, '') + +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' diff --git a/src/services/api/graphql/session/composite/system_status.py b/src/services/api/graphql/session/composite/system_status.py index 3c1a3d45b..d809f32e3 100755 --- a/src/services/api/graphql/session/composite/system_status.py +++ b/src/services/api/graphql/session/composite/system_status.py @@ -23,7 +23,7 @@ import importlib.util from vyos.defaults import directories -from api.graphql.utils.util import load_op_mode_as_module +from api.graphql.libs.op_mode import load_op_mode_as_module def get_system_version() -> dict: show_version = load_op_mode_as_module('version.py') diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py index f990e63d0..c2c1db1df 100644 --- a/src/services/api/graphql/session/session.py +++ b/src/services/api/graphql/session/session.py @@ -24,7 +24,7 @@ from vyos.defaults import directories from vyos.template import render from vyos.opmode import Error as OpModeError -from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name +from api.graphql.libs.op_mode import load_op_mode_as_module, split_compound_op_mode_name op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') diff --git a/src/services/api/graphql/utils/composite_function.py b/src/services/api/graphql/utils/composite_function.py deleted file mode 100644 index bc9d80fbb..000000000 --- a/src/services/api/graphql/utils/composite_function.py +++ /dev/null @@ -1,11 +0,0 @@ -# typing information for composite functions: those that invoke several -# elementary requests, and return the result as a single dict -import typing - -def system_status(): - pass - -queries = {'system_status': system_status} - -mutations = {} - diff --git a/src/services/api/graphql/utils/config_session_function.py b/src/services/api/graphql/utils/config_session_function.py deleted file mode 100644 index fc0dd7a87..000000000 --- a/src/services/api/graphql/utils/config_session_function.py +++ /dev/null @@ -1,28 +0,0 @@ -# typing information for native configsession functions; used to generate -# schema definition files -import typing - -def show_config(path: list[str], configFormat: typing.Optional[str]): - pass - -def show(path: list[str]): - pass - -queries = {'show_config': show_config, - 'show': show} - -def save_config_file(fileName: typing.Optional[str]): - pass -def load_config_file(fileName: str): - pass -def add_system_image(location: str): - pass -def delete_system_image(name: str): - pass - -mutations = {'save_config_file': save_config_file, - 'load_config_file': load_config_file, - 'add_system_image': add_system_image, - 'delete_system_image': delete_system_image} - - diff --git a/src/services/api/graphql/utils/schema_from_composite.py b/src/services/api/graphql/utils/schema_from_composite.py deleted file mode 100755 index d5e0ecdf6..000000000 --- a/src/services/api/graphql/utils/schema_from_composite.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/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 . -# -# -# A utility to generate GraphQL schema defintions from typing information of -# composite functions comprising several requests. - -import os -import json -from inspect import signature, getmembers, isfunction, isclass, getmro -from jinja2 import Template - -from vyos.defaults import directories -if __package__ is None or __package__ == '': - from util import snake_to_pascal_case, map_type_name - from composite_function import queries, mutations -else: - from . util import snake_to_pascal_case, map_type_name - from . composite_function import queries, mutations - -SCHEMA_PATH = directories['api_schema'] - -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 @compositequery -} -""" - -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 @compositemutation -} -""" - -def create_schema(func_name: str, func: callable, template: str) -> str: - sig = signature(func) - - field_dict = {} - for k in sig.parameters: - field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) - - schema_fields = [] - for k,v in field_dict.items(): - schema_fields.append(k+': '+v) - - schema_data['schema_name'] = snake_to_pascal_case(func_name) - schema_data['schema_fields'] = schema_fields - - j2_template = Template(template) - res = j2_template.render(schema_data) - - return res - -def generate_composite_definitions(): - results = [] - for name,func in queries.items(): - res = create_schema(name, func, query_template) - results.append(res) - - for name,func in mutations.items(): - res = create_schema(name, func, mutation_template) - results.append(res) - - out = '\n'.join(results) - with open(f'{SCHEMA_PATH}/composite.graphql', 'w') as f: - f.write(out) - -if __name__ == '__main__': - generate_composite_definitions() diff --git a/src/services/api/graphql/utils/schema_from_config_session.py b/src/services/api/graphql/utils/schema_from_config_session.py deleted file mode 100755 index b6609357e..000000000 --- a/src/services/api/graphql/utils/schema_from_config_session.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/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 . -# -# -# A utility to generate GraphQL schema defintions from typing information of -# (wrappers of) native configsession functions. - -import os -import json -from inspect import signature, getmembers, isfunction, isclass, getmro -from jinja2 import Template - -from vyos.defaults import directories -if __package__ is None or __package__ == '': - from util import snake_to_pascal_case, map_type_name - from config_session_function import queries, mutations -else: - from . util import snake_to_pascal_case, map_type_name - from . config_session_function import queries, mutations - -SCHEMA_PATH = directories['api_schema'] - -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 @configsessionquery -} -""" - -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 @configsessionmutation -} -""" - -def create_schema(func_name: str, func: callable, template: str) -> str: - sig = signature(func) - - field_dict = {} - for k in sig.parameters: - field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) - - schema_fields = [] - for k,v in field_dict.items(): - schema_fields.append(k+': '+v) - - schema_data['schema_name'] = snake_to_pascal_case(func_name) - schema_data['schema_fields'] = schema_fields - - j2_template = Template(template) - res = j2_template.render(schema_data) - - return res - -def generate_config_session_definitions(): - results = [] - for name,func in queries.items(): - res = create_schema(name, func, query_template) - results.append(res) - - for name,func in mutations.items(): - res = create_schema(name, func, mutation_template) - results.append(res) - - out = '\n'.join(results) - with open(f'{SCHEMA_PATH}/configsession.graphql', 'w') as f: - f.write(out) - -if __name__ == '__main__': - generate_config_session_definitions() diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/utils/schema_from_op_mode.py deleted file mode 100755 index 57d63628b..000000000 --- a/src/services/api/graphql/utils/schema_from_op_mode.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/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 . -# -# -# A utility to generate GraphQL schema defintions from standardized op-mode -# scripts. - -import os -import json -from inspect import signature, getmembers, isfunction, isclass, getmro -from jinja2 import Template - -from vyos.defaults import directories -if __package__ is None or __package__ == '': - from util import load_as_module, is_op_mode_function_name, is_show_function_name - from util import snake_to_pascal_case, map_type_name -else: - from . util import load_as_module, is_op_mode_function_name, is_show_function_name - from . util import snake_to_pascal_case, map_type_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') -op_mode_error_schema = 'op_mode_error.graphql' - -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 }} - op_mode_error: OpModeError - 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 }} - op_mode_error: OpModeError - success: Boolean! - errors: [String] -} - -extend type Mutation { - {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation -} -""" - -error_template = """ -interface OpModeError { - name: String! - message: String! - vyos_code: Int! -} -{% for name in error_names %} -type {{ name }} implements OpModeError { - name: String! - message: String! - vyos_code: Int! -} -{%- endfor %} -""" - -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 create_error_schema(): - from vyos import opmode - - e = Exception - err_types = getmembers(opmode, isclass) - err_types = [k for k in err_types if issubclass(k[1], e)] - # drop base class, to be replaced by interface type. Find the class - # programmatically, in case the base class name changes. - for i in range(len(err_types)): - if err_types[i][1] in getmro(err_types[i-1][1]): - del err_types[i] - break - err_names = [k[0] for k in err_types] - error_data = {'error_names': err_names} - j2_template = Template(error_template) - res = j2_template.render(error_data) - - return res - -def generate_op_mode_definitions(): - out = create_error_schema() - with open(f'{SCHEMA_PATH}/{op_mode_error_schema}', 'w') as f: - f.write(out) - - 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].replace('-', '_') - 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 deleted file mode 100644 index da2bcdb5b..000000000 --- a/src/services/api/graphql/utils/util.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright 2022 VyOS maintainers and contributors -# -# 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 . - -import os -import re -import typing -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].replace('-', '_') - 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_split(delim: str, n: int, s: str): - groups = s.split(delim) - l = len(groups) - if n > l-1 or n < 1: - return (s, '') - return (delim.join(groups[:n]), delim.join(groups[n:])) - -def _nth_rsplit(delim: str, n: int, s: str): - groups = s.split(delim) - l = len(groups) - if n > l-1 or n < 1: - return (s, '') - return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) - -# Since we have mangled possible hyphens in the file name while constructing -# the snake case of the query/mutation name, we will need to recover the -# file name by searching with mangling: -def _filter_on_mangled(test): - def func(elem): - mangle = os.path.splitext(elem)[0].replace('-', '_') - return test == mangle - return func - -# Find longest name in concatenated string that matches the basename of an -# op-mode script. Should one prefer to concatenate in the reverse order -# (script_name + '_' + function_name), use _nth_rsplit. -def split_compound_op_mode_name(name: str, files: list): - for i in range(1, name.count('_') + 1): - pair = _nth_split('_', i, name) - f = list(filter(_filter_on_mangled(pair[1]), files)) - if f: - pair = (pair[0], f[0]) - return pair - return (name, '') - -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' -- cgit v1.2.3 From f76a6f68b08fce1feee2dbbb84658b8eede09655 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 23 Oct 2022 11:07:16 -0500 Subject: graphql: T4574: add mutation for requesting JWT token --- src/services/api/graphql/bindings.py | 8 +++- .../api/graphql/graphql/auth_token_mutation.py | 44 ++++++++++++++++++++++ .../api/graphql/graphql/schema/auth_token.graphql | 19 ++++++++++ src/services/api/graphql/libs/token_auth.py | 38 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/services/api/graphql/graphql/auth_token_mutation.py create mode 100644 src/services/api/graphql/graphql/schema/auth_token.graphql create mode 100644 src/services/api/graphql/libs/token_auth.py (limited to 'src/services/api/graphql/bindings.py') diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index d3cff21c7..aa1ba0eb0 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -18,9 +18,12 @@ from . graphql.queries import query from . graphql.mutations import mutation from . graphql.directives import directives_dict from . graphql.errors import op_mode_error +from . graphql.auth_token_mutation import auth_token_mutation from . generate.schema_from_op_mode import generate_op_mode_definitions from . generate.schema_from_config_session import generate_config_session_definitions from . generate.schema_from_composite import generate_composite_definitions +from . libs.token_auth import init_secret +from . import state from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers def generate_schema(): @@ -30,8 +33,11 @@ def generate_schema(): generate_config_session_definitions() generate_composite_definitions() + if state.settings['app'].state.vyos_auth_type == 'token': + init_secret() + type_defs = load_schema_from_path(api_schema_dir) - schema = make_executable_schema(type_defs, query, op_mode_error, mutation, snake_case_fallback_resolvers, directives=directives_dict) + schema = make_executable_schema(type_defs, query, op_mode_error, mutation, auth_token_mutation, snake_case_fallback_resolvers, directives=directives_dict) return schema diff --git a/src/services/api/graphql/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py new file mode 100644 index 000000000..33779d4f0 --- /dev/null +++ b/src/services/api/graphql/graphql/auth_token_mutation.py @@ -0,0 +1,44 @@ +# Copyright 2022 VyOS maintainers and contributors +# +# 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 . + +import jwt +from typing import Any, Dict +from ariadne import ObjectType, UnionType +from graphql import GraphQLResolveInfo + +from .. libs.token_auth import generate_token +from .. import state + +auth_token_mutation = ObjectType("Mutation") + +@auth_token_mutation.field('AuthToken') +def auth_token_resolver(obj: Any, info: GraphQLResolveInfo, data: Dict): + # non-nullable fields + user = data['username'] + passwd = data['password'] + + secret = state.settings['secret'] + res = generate_token(user, passwd, secret) + if res: + data['result'] = res + return { + "success": True, + "data": data + } + + return { + "success": False, + "errors": ['token generation failed'] + } diff --git a/src/services/api/graphql/graphql/schema/auth_token.graphql b/src/services/api/graphql/graphql/schema/auth_token.graphql new file mode 100644 index 000000000..af53a293a --- /dev/null +++ b/src/services/api/graphql/graphql/schema/auth_token.graphql @@ -0,0 +1,19 @@ + +input AuthTokenInput { + username: String! + password: String! +} + +type AuthToken { + result: Generic +} + +type AuthTokenResult { + data: AuthToken + success: Boolean! + errors: [String] +} + +extend type Mutation { + AuthToken(data: AuthTokenInput) : AuthTokenResult +} diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py new file mode 100644 index 000000000..c53e354b1 --- /dev/null +++ b/src/services/api/graphql/libs/token_auth.py @@ -0,0 +1,38 @@ +import jwt +import uuid +import pam +from secrets import token_hex + +from .. import state + +def _check_passwd_pam(username: str, passwd: str) -> bool: + if pam.authenticate(username, passwd): + return True + return False + +def init_secret(): + secret = token_hex(16) + state.settings['secret'] = secret + +def generate_token(user: str, passwd: str, secret: str) -> dict: + if user is None or passwd is None: + return {} + if _check_passwd_pam(user, passwd): + app = state.settings['app'] + try: + users = app.state.vyos_token_users + except AttributeError: + app.state.vyos_token_users = {} + users = app.state.vyos_token_users + user_id = uuid.uuid1().hex + payload_data = {'iss': user, 'sub': user_id} + secret = state.settings.get('secret') + if secret is None: + return { + "success": False, + "errors": ['failed secret generation'] + } + token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256") + + users |= {user_id: user} + return {'token': token} -- cgit v1.2.3