From 30311db5a00c78872c9ad9b29e7081e0d81a5362 Mon Sep 17 00:00:00 2001
From: John Estabrook <jestabro@vyos.io>
Date: Sun, 12 Dec 2021 17:30:56 -0600
Subject: graphql: T3993: distinguish queries and mutations; update
 README.graphql

---
 src/services/api/graphql/README.graphql            | 55 +++++++++++--
 src/services/api/graphql/bindings.py               | 18 ++++-
 src/services/api/graphql/graphql/directives.py     | 16 ++++
 src/services/api/graphql/graphql/mutations.py      | 52 ++++++-------
 src/services/api/graphql/graphql/queries.py        | 89 ++++++++++++++++++++++
 .../api/graphql/graphql/schema/schema.graphql      | 11 ++-
 .../remove_firewall_address_group_members.py       | 14 ++++
 src/services/api/graphql/recipes/session.py        | 15 ++++
 8 files changed, 230 insertions(+), 40 deletions(-)
 create mode 100644 src/services/api/graphql/graphql/queries.py

diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql
index a3c30b005..6aa834329 100644
--- a/src/services/api/graphql/README.graphql
+++ b/src/services/api/graphql/README.graphql
@@ -1,7 +1,12 @@
 
+The following examples are in the form as entered in the GraphQL
+'playground', which is found at:
+
+https://{{ host_address }}/graphql
+
 Example using GraphQL mutations to configure a DHCP server:
 
-This assumes that the http-api is running:
+All examples assume that the http-api is running:
 
 'set service https api'
 
@@ -58,8 +63,8 @@ N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to
 save to /config/config.boot; to save to an alternative path, specify
 fileName.
 
-Similarly, using the same 'endpoint' (meaning the form of the request and
-resolver; the actual enpoint for all GraphQL requests is
+Similarly, using an analogous 'endpoint' (meaning the form of the request
+and resolver; the actual enpoint for all GraphQL requests is
 https://hostname/graphql), one can load an arbitrary config file from a
 path.
 
@@ -75,7 +80,7 @@ mutation {
 
 Op-mode 'show' commands may be requested by path, e.g.:
 
-mutation {
+query {
   Show (data: {path: ["interfaces", "ethernet", "detail"]}) {
     success
     errors
@@ -88,16 +93,52 @@ mutation {
 N.B. to see the output the 'data' field 'result' must be present in the
 request.
 
-The GraphQL playground will be found at:
+Mutations to manipulate firewall address groups:
 
-https://{{ host_address }}/graphql
+mutation {
+  CreateFirewallAddressGroup (data: {name: "ADDR-GRP", address: "10.0.0.1"}) {
+    success
+    errors
+  }
+}
+
+mutation {
+  UpdateFirewallAddressGroupMembers (data: {name: "ADDR-GRP",
+                                            address: ["10.0.0.1-10.0.0.8", "192.168.0.1"]}) {
+    success
+    errors
+  }
+}
 
-An equivalent curl command to the first example above would be:
+mutation {
+  RemoveFirewallAddressGroupMembers (data: {name: "ADDR-GRP",
+                                            address: "192.168.0.1"}) {
+    success
+    errors
+  }
+}
+
+N.B. The schema for the above specify that 'address' be of the form 'list of
+strings' (SDL type [String!]! for UpdateFirewallAddressGroupMembers, where
+the ! indicates that the input is required; SDL type [String] in
+CreateFirewallAddressGroup, since a group may be created without any
+addresses). However, notice that a single string may be passed without being
+a member of a list, in which case the specification allows for 'input
+coercion':
+
+http://spec.graphql.org/October2021/#sec-Scalars.Input-Coercion
+
+
+Instead of using the GraphQL playground, an equivalent curl command to the
+first example above would be:
 
 curl -k 'https://192.168.100.168/graphql' -H 'Content-Type: application/json' --data-binary '{"query": "mutation {createInterfaceEthernet (data: {interface: \"eth1\", address: \"192.168.0.1/24\", description: \"BOB\"}) {success errors data {address}}}"}'
 
 Note that the 'mutation' term is prefaced by 'query' in the curl command.
 
+Curl equivalents may be read from within the GraphQL playground at the 'copy
+curl' button.
+
 What's here:
 
 services
diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py
index 1fbe13d0c..84d719fda 100644
--- a/src/services/api/graphql/bindings.py
+++ b/src/services/api/graphql/bindings.py
@@ -1,4 +1,20 @@
+# Copyright 2021 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 vyos.defaults
+from . graphql.queries import query
 from . graphql.mutations import mutation
 from . graphql.directives import directives_dict
 from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers
@@ -8,6 +24,6 @@ def generate_schema():
 
     type_defs = load_schema_from_path(api_schema_dir)
 
-    schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives=directives_dict)
+    schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict)
 
     return schema
diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py
index 10bc522db..0a9298f55 100644
--- a/src/services/api/graphql/graphql/directives.py
+++ b/src/services/api/graphql/graphql/directives.py
@@ -1,4 +1,20 @@
+# Copyright 2021 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/>.
+
 from ariadne import SchemaDirectiveVisitor, ObjectType
+from . queries import *
 from . mutations import *
 
 def non(arg):
diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py
index 8e5aab56d..0c3eb702a 100644
--- a/src/services/api/graphql/graphql/mutations.py
+++ b/src/services/api/graphql/graphql/mutations.py
@@ -1,3 +1,17 @@
+# Copyright 2021 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/>.
 
 from importlib import import_module
 from typing import Any, Dict
@@ -10,7 +24,7 @@ from api.graphql.recipes.session import Session
 
 mutation = ObjectType("Mutation")
 
-def make_resolver(mutation_name, class_name, session_func):
+def make_mutation_resolver(mutation_name, class_name, session_func):
     """Dynamically generate a resolver for the mutation named in the
     schema by 'mutation_name'.
 
@@ -66,34 +80,20 @@ def make_resolver(mutation_name, class_name, session_func):
 
     return func_impl
 
-def make_configure_resolver(mutation_name):
-    class_name = mutation_name
-    return make_resolver(mutation_name, class_name, 'configure')
+def make_prefix_resolver(mutation_name, prefix=[]):
+    for pre in prefix:
+        Pre = pre.capitalize()
+        if Pre in mutation_name:
+            class_name = mutation_name.replace(Pre, '', 1)
+            return make_mutation_resolver(mutation_name, class_name, pre)
+    raise Exception
 
-def make_show_config_resolver(mutation_name):
+def make_configure_resolver(mutation_name):
     class_name = mutation_name
-    return make_resolver(mutation_name, class_name, 'show_config')
+    return make_mutation_resolver(mutation_name, class_name, 'configure')
 
 def make_config_file_resolver(mutation_name):
-    if 'Save' in mutation_name:
-        class_name = mutation_name.replace('Save', '', 1)
-        return make_resolver(mutation_name, class_name, 'save')
-    elif 'Load' in mutation_name:
-        class_name = mutation_name.replace('Load', '', 1)
-        return make_resolver(mutation_name, class_name, 'load')
-    else:
-        raise Exception
-
-def make_show_resolver(mutation_name):
-    class_name = mutation_name
-    return make_resolver(mutation_name, class_name, 'show')
+    return make_prefix_resolver(mutation_name, prefix=['save', 'load'])
 
 def make_image_resolver(mutation_name):
-    if 'Add' in mutation_name:
-        class_name = mutation_name.replace('Add', '', 1)
-        return make_resolver(mutation_name, class_name, 'add')
-    elif 'Delete' in mutation_name:
-        class_name = mutation_name.replace('Delete', '', 1)
-        return make_resolver(mutation_name, class_name, 'delete')
-    else:
-        raise Exception
+    return make_prefix_resolver(mutation_name, prefix=['add', 'delete'])
diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py
new file mode 100644
index 000000000..e1868091e
--- /dev/null
+++ b/src/services/api/graphql/graphql/queries.py
@@ -0,0 +1,89 @@
+# Copyright 2021 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/>.
+
+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
+from api.graphql.recipes.session import Session
+
+query = ObjectType("Query")
+
+def make_query_resolver(query_name, class_name, session_func):
+    """Dynamically generate a resolver for the query named in the
+    schema by 'query_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:
+        raising ConfigErrors, or internal errors
+    """
+
+    func_base_name = convert_camel_case_to_snake(class_name)
+    resolver_name = f'resolve_{func_base_name}'
+    func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)'
+
+    @query.field(query_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
+
+            # one may override the session functions with a local subclass
+            try:
+                mod = import_module(f'api.graphql.recipes.{func_base_name}')
+                klass = getattr(mod, class_name)
+            except ImportError:
+                # otherwise, dynamically generate subclass to invoke subclass
+                # name based templates
+                klass = type(class_name, (Session,), {})
+            k = klass(session, data)
+            method = getattr(k, session_func)
+            result = method()
+            data['result'] = result
+
+            return {
+                "success": True,
+                "data": data
+            }
+        except Exception as error:
+            return {
+                "success": False,
+                "errors": [str(error)]
+            }
+
+    return func_impl
+
+def make_show_config_resolver(query_name):
+    class_name = query_name
+    return make_query_resolver(query_name, class_name, 'show_config')
+
+def make_show_resolver(query_name):
+    class_name = query_name
+    return make_query_resolver(query_name, class_name, 'show')
diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql
index c6899bee6..ce58b991a 100644
--- a/src/services/api/graphql/graphql/schema/schema.graphql
+++ b/src/services/api/graphql/graphql/schema/schema.graphql
@@ -3,16 +3,17 @@ schema {
     mutation: Mutation
 }
 
-type Query {
-    _dummy: String
-}
-
 directive @configure on FIELD_DEFINITION
 directive @configfile on FIELD_DEFINITION
 directive @show on FIELD_DEFINITION
 directive @showconfig on FIELD_DEFINITION
 directive @image on FIELD_DEFINITION
 
+type Query {
+    Show(data: ShowInput) : ShowResult @show
+    ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig
+}
+
 type Mutation {
     CreateDhcpServer(data: DhcpServerConfigInput) : CreateDhcpServerResult @configure
     CreateInterfaceEthernet(data: InterfaceEthernetConfigInput) : CreateInterfaceEthernetResult @configure
@@ -21,8 +22,6 @@ type Mutation {
     RemoveFirewallAddressGroupMembers(data: RemoveFirewallAddressGroupMembersInput) : RemoveFirewallAddressGroupMembersResult @configure
     SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configfile
     LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configfile
-    Show(data: ShowInput) : ShowResult @show
-    ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig
     AddSystemImage(data: AddSystemImageInput) : AddSystemImageResult @image
     DeleteSystemImage(data: DeleteSystemImageInput) : DeleteSystemImageResult @image
 }
diff --git a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py
index cde30c27a..b91932e14 100644
--- a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py
+++ b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py
@@ -1,3 +1,17 @@
+# Copyright 2021 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/>.
 
 from . session import Session
 
diff --git a/src/services/api/graphql/recipes/session.py b/src/services/api/graphql/recipes/session.py
index 5ece78ee6..1f844ff70 100644
--- a/src/services/api/graphql/recipes/session.py
+++ b/src/services/api/graphql/recipes/session.py
@@ -1,3 +1,18 @@
+# Copyright 2021 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 json
 
 from ariadne import convert_camel_case_to_snake
-- 
cgit v1.2.3