Commit 208b0018 authored by Lluis Gifre Renom's avatar Lluis Gifre Renom
Browse files

Common - Tools - Rest Conf

- Implemented Callbacks to be executed when selected paths are changed
- Renamed Dispatcher to DispatcherData
parent e4af25c9
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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
+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()
+39 −8
Original line number Diff line number Diff line
@@ -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)
@@ -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
@@ -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(
@@ -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'
@@ -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(
@@ -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
@@ -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:
@@ -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
+9 −5
Original line number Diff line number Diff line
@@ -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
@@ -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

@@ -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: