Loading src/common/tools/rest_conf/server/requirements.in +1 −0 Original line number Diff line number Diff line Loading @@ -13,6 +13,7 @@ # limitations under the License. cryptography==39.0.1 deepdiff==6.7.* eventlet==0.39.0 Flask-HTTPAuth==4.5.0 Flask-RESTful==0.3.9 Loading src/common/tools/rest_conf/server/restconf_server/Callbacks.py 0 → 100644 +134 −0 Original line number Diff line number Diff line # Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging, re from typing import Dict, List, Optional, Union LOGGER = logging.getLogger(__name__) class _Callback: def __init__(self, path_pattern : Union[str, re.Pattern]) -> None: ''' Initialize a Callback @param path_pattern: A regular expression (string or compiled `re.Pattern`) ''' if isinstance(path_pattern, str): path_pattern = re.compile('^{:s}/?$'.format(path_pattern)) self._path_pattern = path_pattern def match(self, path : str) -> Optional[re.Match]: ''' Match method used to check if this callback should be executed. @param path: A RESTCONF request path to test @returns `re.Match` object if pattern fully matches `path`, otherwise `None` ''' return self._path_pattern.fullmatch(path) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: ''' Execute the callback action for a matched path. This method should be implemented for each specific callback. @param match: `re.Match` object returned by `match()`. @param path: Original request path that was matched. @param old_data: Resource representation before change, if applicable, otherwise `None` @param new_data: Resource representation after change, if applicable, otherwise `None` @returns boolean indicating whether additional callbacks should be executed, defaults to False ''' MSG = 'match={:s}, path={:s}, old_data={:s}, new_data={:s}' msg = MSG.format(match.groupdict(), path, old_data, new_data) raise NotImplementedError(msg) class CallbackDispatcher: def __init__(self): self._callbacks : List[_Callback] = list() def register(self, callback : _Callback) -> None: self._callbacks.append(callback) def dispatch( self, path : str, old_data : Optional[Dict] = None, new_data : Optional[Dict] = None ) -> None: LOGGER.warning('Checking Callbacks for path={:s}'.format(str(path))) for callback in self._callbacks: match = callback.match(path) if match is None: continue keep_running_callbacks = callback.execute(match, path, old_data, new_data) if not keep_running_callbacks: break # ===== EXAMPLE ========================================================================================== class CallbackOnNetwork(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_network]', match.groupdict(), path, old_data, new_data) return False class CallbackOnNode(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' pattern += r'/node=(?P<node_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_node]', match.groupdict(), path, old_data, new_data) return False class CallbackOnLink(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' pattern += r'/ietf-network-topology:link=(?P<link_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_link]', match.groupdict(), path, old_data, new_data) return False def main() -> None: callbacks = CallbackDispatcher() callbacks.register(CallbackOnNetwork()) callbacks.register(CallbackOnNode()) callbacks.register(CallbackOnLink()) callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/node=P-PE2') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/ietf-network-topology:link=L6') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/node=P-PE1/') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/ietf-network-topology:link=L4/') if __name__ == '__main__': main() src/common/tools/rest_conf/server/restconf_server/Dispatch.py→src/common/tools/rest_conf/server/restconf_server/DispatchData.py +39 −8 Original line number Diff line number Diff line Loading @@ -13,18 +13,22 @@ # limitations under the License. import json, logging import deepdiff, json, logging from flask import Response, abort, jsonify, request from flask_restful import Resource from .Callbacks import CallbackDispatcher from .HttpStatusCodesEnum import HttpStatusCodesEnum from .YangHandler import YangHandler LOGGER = logging.getLogger(__name__) class RestConfDispatch(Resource): def __init__(self, yang_handler : YangHandler) -> None: class RestConfDispatchData(Resource): def __init__( self, yang_handler : YangHandler, callback_dispatcher : CallbackDispatcher ) -> None: super().__init__() self._yang_handler = yang_handler self._callback_dispatcher = callback_dispatcher def get(self, subpath : str = '/') -> Response: data = self._yang_handler.get(subpath) Loading Loading @@ -66,6 +70,10 @@ class RestConfDispatch(Resource): LOGGER.info('[POST] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=None, new_data=json_data ) response = jsonify({'status': 'created'}) response.status_code = HttpStatusCodesEnum.SUCCESS_CREATED.value return response Loading @@ -78,8 +86,10 @@ class RestConfDispatch(Resource): LOGGER.exception('Invalid JSON') abort(HttpStatusCodesEnum.CLI_ERR_BAD_REQUEST.value, desctiption='Invalid JSON') old_data = self._yang_handler.get(subpath) try: json_data = self._yang_handler.update(subpath, payload) new_data = self._yang_handler.update(subpath, payload) except Exception as e: LOGGER.exception('Update failed') abort( Loading @@ -87,8 +97,14 @@ class RestConfDispatch(Resource): description=str(e) ) LOGGER.info('[PUT] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) updated = False # TODO: compute if create or update LOGGER.info('[PUT] {:s} {:s} => {:s}'.format(subpath, str(payload), str(new_data))) diff_data = deepdiff.DeepDiff(old_data, new_data) updated = len(diff_data) > 0 self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=new_data ) response = jsonify({'status': ( 'updated' if updated else 'created' Loading @@ -108,8 +124,10 @@ class RestConfDispatch(Resource): LOGGER.exception('Invalid JSON') abort(HttpStatusCodesEnum.CLI_ERR_BAD_REQUEST.value, desctiption='Invalid JSON') old_data = self._yang_handler.get(subpath) try: json_data = self._yang_handler.update(subpath, payload) new_data = self._yang_handler.update(subpath, payload) except Exception as e: LOGGER.exception('Update failed') abort( Loading @@ -117,7 +135,14 @@ class RestConfDispatch(Resource): description=str(e) ) LOGGER.info('[PATCH] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) LOGGER.info('[PATCH] {:s} {:s} => {:s}'.format(subpath, str(payload), str(new_data))) #diff_data = deepdiff.DeepDiff(old_data, new_data) #updated = len(diff_data) > 0 self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=new_data ) response = jsonify({'status': 'patched'}) response.status_code = HttpStatusCodesEnum.SUCCESS_NO_CONTENT.value Loading @@ -126,6 +151,8 @@ class RestConfDispatch(Resource): def delete(self, subpath : str) -> Response: # NOTE: client should provide identifier of element to be patched old_data = self._yang_handler.get(subpath) try: deleted_node = self._yang_handler.delete(subpath) except Exception as e: Loading @@ -143,6 +170,10 @@ class RestConfDispatch(Resource): description='Path({:s}) not found'.format(str(subpath)) ) self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=None ) response = jsonify({}) response.status_code = HttpStatusCodesEnum.SUCCESS_NO_CONTENT.value return response src/common/tools/rest_conf/server/restconf_server/RestConfServerApplication.py +9 −5 Original line number Diff line number Diff line Loading @@ -16,8 +16,9 @@ import json, logging, time from flask import Flask, request from flask_restful import Api from .Callbacks import CallbackDispatcher from .Config import RESTCONF_PREFIX, SECRET_KEY, STARTUP_FILE, YANG_SEARCH_PATH from .Dispatch import RestConfDispatch from .DispatchData import RestConfDispatchData from .HostMeta import HostMeta from .YangHandler import YangHandler from .YangModelDiscoverer import YangModuleDiscoverer Loading Loading @@ -53,11 +54,16 @@ class RestConfServerApplication: YANG_SEARCH_PATH, self._yang_module_names, self._yang_startup_data ) self._callback_dispatcher = CallbackDispatcher() self._app = Flask(__name__) self._app.config['SECRET_KEY'] = SECRET_KEY self._app.after_request(log_request) self._api = Api(self._app) @property def callback_dispatcher(self): return self._callback_dispatcher def get_startup_data(self) -> None: return self._yang_startup_data Loading @@ -70,12 +76,10 @@ class RestConfServerApplication: def register_restconf(self) -> None: self._api.add_resource( RestConfDispatch, RESTCONF_PREFIX + '/data', RESTCONF_PREFIX + '/data/', RestConfDispatchData, RESTCONF_PREFIX + '/data/<path:subpath>', RESTCONF_PREFIX + '/data/<path:subpath>/', resource_class_args=(self._yang_handler,) resource_class_args=(self._yang_handler, self._callback_dispatcher) ) def get_flask_app(self) -> Flask: Loading Loading
src/common/tools/rest_conf/server/requirements.in +1 −0 Original line number Diff line number Diff line Loading @@ -13,6 +13,7 @@ # limitations under the License. cryptography==39.0.1 deepdiff==6.7.* eventlet==0.39.0 Flask-HTTPAuth==4.5.0 Flask-RESTful==0.3.9 Loading
src/common/tools/rest_conf/server/restconf_server/Callbacks.py 0 → 100644 +134 −0 Original line number Diff line number Diff line # Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging, re from typing import Dict, List, Optional, Union LOGGER = logging.getLogger(__name__) class _Callback: def __init__(self, path_pattern : Union[str, re.Pattern]) -> None: ''' Initialize a Callback @param path_pattern: A regular expression (string or compiled `re.Pattern`) ''' if isinstance(path_pattern, str): path_pattern = re.compile('^{:s}/?$'.format(path_pattern)) self._path_pattern = path_pattern def match(self, path : str) -> Optional[re.Match]: ''' Match method used to check if this callback should be executed. @param path: A RESTCONF request path to test @returns `re.Match` object if pattern fully matches `path`, otherwise `None` ''' return self._path_pattern.fullmatch(path) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: ''' Execute the callback action for a matched path. This method should be implemented for each specific callback. @param match: `re.Match` object returned by `match()`. @param path: Original request path that was matched. @param old_data: Resource representation before change, if applicable, otherwise `None` @param new_data: Resource representation after change, if applicable, otherwise `None` @returns boolean indicating whether additional callbacks should be executed, defaults to False ''' MSG = 'match={:s}, path={:s}, old_data={:s}, new_data={:s}' msg = MSG.format(match.groupdict(), path, old_data, new_data) raise NotImplementedError(msg) class CallbackDispatcher: def __init__(self): self._callbacks : List[_Callback] = list() def register(self, callback : _Callback) -> None: self._callbacks.append(callback) def dispatch( self, path : str, old_data : Optional[Dict] = None, new_data : Optional[Dict] = None ) -> None: LOGGER.warning('Checking Callbacks for path={:s}'.format(str(path))) for callback in self._callbacks: match = callback.match(path) if match is None: continue keep_running_callbacks = callback.execute(match, path, old_data, new_data) if not keep_running_callbacks: break # ===== EXAMPLE ========================================================================================== class CallbackOnNetwork(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_network]', match.groupdict(), path, old_data, new_data) return False class CallbackOnNode(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' pattern += r'/node=(?P<node_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_node]', match.groupdict(), path, old_data, new_data) return False class CallbackOnLink(_Callback): def __init__(self) -> None: pattern = r'/restconf/data' pattern += r'/ietf-network:networks/network=(?P<network_id>[^/]+)' pattern += r'/ietf-network-topology:link=(?P<link_id>[^/]+)' super().__init__(pattern) def execute( self, match : re.Match, path : str, old_data : Optional[Dict], new_data : Optional[Dict] ) -> bool: print('[on_link]', match.groupdict(), path, old_data, new_data) return False def main() -> None: callbacks = CallbackDispatcher() callbacks.register(CallbackOnNetwork()) callbacks.register(CallbackOnNode()) callbacks.register(CallbackOnLink()) callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/node=P-PE2') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/ietf-network-topology:link=L6') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/node=P-PE1/') callbacks.dispatch('/restconf/data/ietf-network:networks/network=admin/ietf-network-topology:link=L4/') if __name__ == '__main__': main()
src/common/tools/rest_conf/server/restconf_server/Dispatch.py→src/common/tools/rest_conf/server/restconf_server/DispatchData.py +39 −8 Original line number Diff line number Diff line Loading @@ -13,18 +13,22 @@ # limitations under the License. import json, logging import deepdiff, json, logging from flask import Response, abort, jsonify, request from flask_restful import Resource from .Callbacks import CallbackDispatcher from .HttpStatusCodesEnum import HttpStatusCodesEnum from .YangHandler import YangHandler LOGGER = logging.getLogger(__name__) class RestConfDispatch(Resource): def __init__(self, yang_handler : YangHandler) -> None: class RestConfDispatchData(Resource): def __init__( self, yang_handler : YangHandler, callback_dispatcher : CallbackDispatcher ) -> None: super().__init__() self._yang_handler = yang_handler self._callback_dispatcher = callback_dispatcher def get(self, subpath : str = '/') -> Response: data = self._yang_handler.get(subpath) Loading Loading @@ -66,6 +70,10 @@ class RestConfDispatch(Resource): LOGGER.info('[POST] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=None, new_data=json_data ) response = jsonify({'status': 'created'}) response.status_code = HttpStatusCodesEnum.SUCCESS_CREATED.value return response Loading @@ -78,8 +86,10 @@ class RestConfDispatch(Resource): LOGGER.exception('Invalid JSON') abort(HttpStatusCodesEnum.CLI_ERR_BAD_REQUEST.value, desctiption='Invalid JSON') old_data = self._yang_handler.get(subpath) try: json_data = self._yang_handler.update(subpath, payload) new_data = self._yang_handler.update(subpath, payload) except Exception as e: LOGGER.exception('Update failed') abort( Loading @@ -87,8 +97,14 @@ class RestConfDispatch(Resource): description=str(e) ) LOGGER.info('[PUT] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) updated = False # TODO: compute if create or update LOGGER.info('[PUT] {:s} {:s} => {:s}'.format(subpath, str(payload), str(new_data))) diff_data = deepdiff.DeepDiff(old_data, new_data) updated = len(diff_data) > 0 self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=new_data ) response = jsonify({'status': ( 'updated' if updated else 'created' Loading @@ -108,8 +124,10 @@ class RestConfDispatch(Resource): LOGGER.exception('Invalid JSON') abort(HttpStatusCodesEnum.CLI_ERR_BAD_REQUEST.value, desctiption='Invalid JSON') old_data = self._yang_handler.get(subpath) try: json_data = self._yang_handler.update(subpath, payload) new_data = self._yang_handler.update(subpath, payload) except Exception as e: LOGGER.exception('Update failed') abort( Loading @@ -117,7 +135,14 @@ class RestConfDispatch(Resource): description=str(e) ) LOGGER.info('[PATCH] {:s} {:s} => {:s}'.format(subpath, str(payload), str(json_data))) LOGGER.info('[PATCH] {:s} {:s} => {:s}'.format(subpath, str(payload), str(new_data))) #diff_data = deepdiff.DeepDiff(old_data, new_data) #updated = len(diff_data) > 0 self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=new_data ) response = jsonify({'status': 'patched'}) response.status_code = HttpStatusCodesEnum.SUCCESS_NO_CONTENT.value Loading @@ -126,6 +151,8 @@ class RestConfDispatch(Resource): def delete(self, subpath : str) -> Response: # NOTE: client should provide identifier of element to be patched old_data = self._yang_handler.get(subpath) try: deleted_node = self._yang_handler.delete(subpath) except Exception as e: Loading @@ -143,6 +170,10 @@ class RestConfDispatch(Resource): description='Path({:s}) not found'.format(str(subpath)) ) self._callback_dispatcher.dispatch( '/restconf/data/' + subpath, old_data=old_data, new_data=None ) response = jsonify({}) response.status_code = HttpStatusCodesEnum.SUCCESS_NO_CONTENT.value return response
src/common/tools/rest_conf/server/restconf_server/RestConfServerApplication.py +9 −5 Original line number Diff line number Diff line Loading @@ -16,8 +16,9 @@ import json, logging, time from flask import Flask, request from flask_restful import Api from .Callbacks import CallbackDispatcher from .Config import RESTCONF_PREFIX, SECRET_KEY, STARTUP_FILE, YANG_SEARCH_PATH from .Dispatch import RestConfDispatch from .DispatchData import RestConfDispatchData from .HostMeta import HostMeta from .YangHandler import YangHandler from .YangModelDiscoverer import YangModuleDiscoverer Loading Loading @@ -53,11 +54,16 @@ class RestConfServerApplication: YANG_SEARCH_PATH, self._yang_module_names, self._yang_startup_data ) self._callback_dispatcher = CallbackDispatcher() self._app = Flask(__name__) self._app.config['SECRET_KEY'] = SECRET_KEY self._app.after_request(log_request) self._api = Api(self._app) @property def callback_dispatcher(self): return self._callback_dispatcher def get_startup_data(self) -> None: return self._yang_startup_data Loading @@ -70,12 +76,10 @@ class RestConfServerApplication: def register_restconf(self) -> None: self._api.add_resource( RestConfDispatch, RESTCONF_PREFIX + '/data', RESTCONF_PREFIX + '/data/', RestConfDispatchData, RESTCONF_PREFIX + '/data/<path:subpath>', RESTCONF_PREFIX + '/data/<path:subpath>/', resource_class_args=(self._yang_handler,) resource_class_args=(self._yang_handler, self._callback_dispatcher) ) def get_flask_app(self) -> Flask: Loading