From b168b4cc7da456f14714d917cdc7a1c6b8df9af5 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Thu, 5 Aug 2021 11:24:47 -0500 Subject: http-api: T2768: example using GraphQL for high-level config operations --- src/services/api/graphql/graphql/__init__.py | 0 src/services/api/graphql/graphql/directives.py | 17 ++++++ src/services/api/graphql/graphql/mutations.py | 60 ++++++++++++++++++++++ .../api/graphql/graphql/schema/dhcp_server.graphql | 35 +++++++++++++ .../graphql/schema/interface_ethernet.graphql | 18 +++++++ .../api/graphql/graphql/schema/schema.graphql | 15 ++++++ src/services/api/graphql/recipes/__init__.py | 0 src/services/api/graphql/recipes/dhcp_server.py | 13 +++++ .../api/graphql/recipes/interface_ethernet.py | 13 +++++ src/services/api/graphql/recipes/recipe.py | 49 ++++++++++++++++++ .../api/graphql/recipes/templates/dhcp_server.tmpl | 9 ++++ .../recipes/templates/interface_ethernet.tmpl | 5 ++ src/services/api/graphql/state.py | 4 ++ 13 files changed, 238 insertions(+) create mode 100644 src/services/api/graphql/graphql/__init__.py create mode 100644 src/services/api/graphql/graphql/directives.py create mode 100644 src/services/api/graphql/graphql/mutations.py create mode 100644 src/services/api/graphql/graphql/schema/dhcp_server.graphql create mode 100644 src/services/api/graphql/graphql/schema/interface_ethernet.graphql create mode 100644 src/services/api/graphql/graphql/schema/schema.graphql create mode 100644 src/services/api/graphql/recipes/__init__.py create mode 100644 src/services/api/graphql/recipes/dhcp_server.py create mode 100644 src/services/api/graphql/recipes/interface_ethernet.py create mode 100644 src/services/api/graphql/recipes/recipe.py create mode 100644 src/services/api/graphql/recipes/templates/dhcp_server.tmpl create mode 100644 src/services/api/graphql/recipes/templates/interface_ethernet.tmpl create mode 100644 src/services/api/graphql/state.py (limited to 'src/services/api') diff --git a/src/services/api/graphql/graphql/__init__.py b/src/services/api/graphql/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py new file mode 100644 index 000000000..651421c35 --- /dev/null +++ b/src/services/api/graphql/graphql/directives.py @@ -0,0 +1,17 @@ +from ariadne import SchemaDirectiveVisitor, ObjectType +from . mutations import make_resolver + +class DataDirective(SchemaDirectiveVisitor): + """ + Class providing implementation of 'generate' directive in schema. + + """ + def visit_field_definition(self, field, object_type): + name = f'{field.type}' + # field.type contains the return value of the mutation; trim value + # to produce canonical name + name = name.replace('Result', '', 1) + + func = make_resolver(name) + field.resolve = func + return field diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py new file mode 100644 index 000000000..98c665c9a --- /dev/null +++ b/src/services/api/graphql/graphql/mutations.py @@ -0,0 +1,60 @@ + +from importlib import import_module +from typing import Any, Dict +from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake +from graphql import GraphQLResolveInfo +from makefun import with_signature + +from .. import state + +mutation = ObjectType("Mutation") + +def make_resolver(mutation_name): + """Dynamically generate a resolver for the mutation named in the + schema by 'mutation_name'. + + Dynamic generation is provided using the package 'makefun' (via the + decorator 'with_signature'), which provides signature-preserving + function wrappers; it provides several improvements over, say, + functools.wraps. + + :raise Exception: + encapsulating ConfigErrors, or internal errors + """ + class_name = mutation_name.replace('create', '', 1).replace('delete', '', 1) + func_base_name = convert_camel_case_to_snake(class_name) + resolver_name = f'resolve_create_{func_base_name}' + func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)' + + @mutation.field(mutation_name) + @convert_kwargs_to_snake_case + @with_signature(func_sig, func_name=resolver_name) + async def func_impl(*args, **kwargs): + try: + if 'data' not in kwargs: + return { + "success": False, + "errors": ['missing data'] + } + + data = kwargs['data'] + session = state.settings['app'].state.vyos_session + + mod = import_module(f'api.graphql.recipes.{func_base_name}') + klass = getattr(mod, class_name) + k = klass(session, data) + k.configure() + + return { + "success": True, + "data": data + } + except Exception as error: + return { + "success": False, + "errors": [str(error)] + } + + return func_impl + + diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql new file mode 100644 index 000000000..a7ee75d40 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -0,0 +1,35 @@ +input dhcpServerConfigInput { + sharedNetworkName: String + subnet: String + defaultRouter: String + dnsServer: String + domainName: String + lease: Int + range: Int + start: String + stop: String + dnsForwardingAllowFrom: String + dnsForwardingCacheSize: Int + dnsForwardingListenAddress: String +} + +type dhcpServerConfig { + sharedNetworkName: String + subnet: String + defaultRouter: String + dnsServer: String + domainName: String + lease: Int + range: Int + start: String + stop: String + dnsForwardingAllowFrom: String + dnsForwardingCacheSize: Int + dnsForwardingListenAddress: String +} + +type createDhcpServerResult { + data: dhcpServerConfig + success: Boolean! + errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql new file mode 100644 index 000000000..fdcf97bad --- /dev/null +++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql @@ -0,0 +1,18 @@ +input interfaceEthernetConfigInput { + interface: String + address: String + replace: Boolean = true + description: String +} + +type interfaceEthernetConfig { + interface: String + address: String + description: String +} + +type createInterfaceEthernetResult { + data: interfaceEthernetConfig + success: Boolean! + errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql new file mode 100644 index 000000000..8a5e17962 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -0,0 +1,15 @@ +schema { + query: Query + mutation: Mutation +} + +type Query { + _dummy: String +} + +directive @generate on FIELD_DEFINITION + +type Mutation { + createDhcpServer(data: dhcpServerConfigInput) : createDhcpServerResult @generate + createInterfaceEthernet(data: interfaceEthernetConfigInput) : createInterfaceEthernetResult @generate +} diff --git a/src/services/api/graphql/recipes/__init__.py b/src/services/api/graphql/recipes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/services/api/graphql/recipes/dhcp_server.py b/src/services/api/graphql/recipes/dhcp_server.py new file mode 100644 index 000000000..3edb3028e --- /dev/null +++ b/src/services/api/graphql/recipes/dhcp_server.py @@ -0,0 +1,13 @@ + +from . recipe import Recipe + +class DhcpServer(Recipe): + def __init__(self, session, command_file): + super().__init__(session, command_file) + + # Define any custom processing of parameters here by overriding + # configure: + # + # def configure(self): + # self.data = transform_data(self.data) + # super().configure() diff --git a/src/services/api/graphql/recipes/interface_ethernet.py b/src/services/api/graphql/recipes/interface_ethernet.py new file mode 100644 index 000000000..f88f5924f --- /dev/null +++ b/src/services/api/graphql/recipes/interface_ethernet.py @@ -0,0 +1,13 @@ + +from . recipe import Recipe + +class InterfaceEthernet(Recipe): + def __init__(self, session, command_file): + super().__init__(session, command_file) + + # Define any custom processing of parameters here by overriding + # configure: + # + # def configure(self): + # self.data = transform_data(self.data) + # super().configure() diff --git a/src/services/api/graphql/recipes/recipe.py b/src/services/api/graphql/recipes/recipe.py new file mode 100644 index 000000000..8fbb9e0bf --- /dev/null +++ b/src/services/api/graphql/recipes/recipe.py @@ -0,0 +1,49 @@ +from ariadne import convert_camel_case_to_snake +import vyos.defaults +from vyos.template import render + +class Recipe(object): + def __init__(self, session, data): + self._session = session + self.data = data + self._name = convert_camel_case_to_snake(type(self).__name__) + + @property + def data(self): + return self.__data + + @data.setter + def data(self, data): + if isinstance(data, dict): + self.__data = data + else: + raise ValueError("data must be of type dict") + + def configure(self): + session = self._session + data = self.data + func_base_name = self._name + + tmpl_file = f'{func_base_name}.tmpl' + cmd_file = f'/tmp/{func_base_name}.cmds' + tmpl_dir = vyos.defaults.directories['api_templates'] + + try: + render(cmd_file, tmpl_file, data, location=tmpl_dir) + commands = [] + with open(cmd_file) as f: + lines = f.readlines() + for line in lines: + commands.append(line.split()) + for cmd in commands: + if cmd[0] == 'set': + session.set(cmd[1:]) + elif cmd[0] == 'delete': + session.delete(cmd[1:]) + else: + raise ValueError('Operation must be "set" or "delete"') + session.commit() + except Exception as error: + raise error + + diff --git a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl new file mode 100644 index 000000000..629ce83c1 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/dhcp_server.tmpl @@ -0,0 +1,9 @@ +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} default-router {{ default_router }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} dns-server {{ dns_server }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} domain-name {{ domain_name }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} lease {{ lease }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} start {{ start }} +set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} stop {{ stop }} +set service dns forwarding allow-from {{ dns_forwarding_allow_from }} +set service dns forwarding cache-size {{ dns_forwarding_cache_size }} +set service dns forwarding listen-address {{ dns_forwarding_listen_address }} diff --git a/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl new file mode 100644 index 000000000..d9d7ed691 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl @@ -0,0 +1,5 @@ +{% if replace %} +delete interfaces ethernet {{ interface }} address +{% endif %} +set interfaces ethernet {{ interface }} address {{ address }} +set interfaces ethernet {{ interface }} description {{ description }} diff --git a/src/services/api/graphql/state.py b/src/services/api/graphql/state.py new file mode 100644 index 000000000..63db9f4ef --- /dev/null +++ b/src/services/api/graphql/state.py @@ -0,0 +1,4 @@ + +def init(): + global settings + settings = {} -- cgit v1.2.3