kytos.core.api_server   F
last analyzed

Complexity

Total Complexity 70

Size/Duplication

Total Lines 517
Duplicated Lines 0 %

Test Coverage

Coverage 97.78%

Importance

Changes 0
Metric Value
eloc 269
dl 0
loc 517
ccs 220
cts 225
cp 0.9778
rs 2.8
c 0
b 0
f 0
wmc 70

28 Methods

Rating   Name   Duplication   Size   Complexity  
A APIServer.__init__() 0 50 1
A APIServer._enable_websocket_rooms() 0 4 1
A APIServer.run() 0 8 2
A APIServer.start_api() 0 17 1
A APIServer._register_web_ui() 0 9 1
A APIServer.web_ui() 0 3 1
A APIServer.shutdown_api() 0 14 2
A APIServer.register_rest_endpoint() 0 8 2
A APIServer.stop_api_server() 0 7 2
A APIServer.get_ui_components() 0 25 3
A APIServer.decorate_as_endpoint() 0 41 3
A APIServer.register_core_endpoint() 0 7 1
B APIServer.update_web_ui() 0 43 8
A APIServer.static_web_ui() 0 6 2
A APIServer.status_api() 0 4 1
A APIServer.register_core_napp_services() 0 22 1
A APIServer._list_enabled_napps() 0 7 2
A APIServer._get_decorated_functions() 0 8 5
A APIServer.register_napp_endpoints() 0 24 3
A APIServer._install_napp() 0 17 4
A APIServer._list_installed_napps() 0 7 2
A APIServer._start_endpoint() 0 10 1
A APIServer._get_napp_metadata() 0 19 3
A APIServer.get_absolute_rule() 0 10 2
A APIServer._uninstall_napp() 0 10 3
A APIServer._enable_napp() 0 24 4
A APIServer._disable_napp() 0 25 4
A APIServer.remove_napp_endpoints() 0 27 5

How to fix   Complexity   

Complexity

Complex classes like kytos.core.api_server often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
"""Module used to handle a API Server."""
2 1
import json
3 1
import logging
4 1
import os
5 1
import shutil
6 1
import sys
7 1
import warnings
8 1
import zipfile
9 1
from datetime import datetime
10 1
from glob import glob
11 1
from http import HTTPStatus
12 1
from urllib.error import HTTPError, URLError
13 1
from urllib.request import urlopen, urlretrieve
14
15 1
from flask import Blueprint, Flask, jsonify, request, send_file
16 1
from flask_cors import CORS
17 1
from flask_socketio import SocketIO, join_room, leave_room
18 1
from werkzeug.exceptions import HTTPException
19
20
21 1
class APIServer:
22
    """Api server used to provide Kytos Controller routes."""
23
24
    #: tuple: Default Flask HTTP methods.
25 1
    DEFAULT_METHODS = ('GET',)
26 1
    _NAPP_PREFIX = "/api/{napp.username}/{napp.name}/"
27 1
    _CORE_PREFIX = "/api/kytos/core/"
28
29
    # pylint: disable=too-many-arguments
30 1
    def __init__(self, app_name, listen='0.0.0.0', port=8181,
31
                 napps_manager=None, napps_dir=None):
32
        """Start a Flask+SocketIO server.
33
34
        Require controller to get NApps dir and NAppsManager
35
36
        Args:
37
            app_name(string): String representing a App Name
38
            listen (string): host name used by api server instance
39
            port (int): Port number used by api server instance
40
            controller(kytos.core.controller): A controller instance.
41
        """
42 1
        dirname = os.path.dirname(os.path.abspath(__file__))
43 1
        self.napps_manager = napps_manager
44 1
        self.napps_dir = napps_dir
45
46 1
        self.flask_dir = os.path.join(dirname, '../web-ui')
47 1
        self.log = logging.getLogger(__name__)
48
49 1
        self.listen = listen
50 1
        self.port = port
51
52 1
        self.app = Flask(app_name, root_path=self.flask_dir,
53
                         static_folder="dist", static_url_path="/dist")
54 1
        self.server = SocketIO(self.app, async_mode='threading')
55 1
        self._enable_websocket_rooms()
56
        # ENABLE CROSS ORIGIN RESOURCE SHARING
57 1
        CORS(self.app)
58
59
        # Disable trailing slash
60 1
        self.app.url_map.strict_slashes = False
61
62
        # Update web-ui if necessary
63 1
        self.update_web_ui(force=False)
64
65 1
        @self.app.errorhandler(HTTPException)
66
        def handle_exception(exception):
67
            # pylint: disable=unused-variable, invalid-name
68
            """Return a json for HTTP errors
69
70
            This handler allows NApps to return HTTP error messages
71
            by raising a HTTPException. When raising the Exception,
72
            create it with a description.
73
            """
74
            response = jsonify({
75
                'code': exception.code,
76
                'name': exception.name,
77
                'description': exception.description
78
            })
79
            return response, exception.code
80
81 1
    def _enable_websocket_rooms(self):
82 1
        socket = self.server
83 1
        socket.on_event('join', join_room)
84 1
        socket.on_event('leave', leave_room)
85
86 1
    def run(self):
87
        """Run the Flask API Server."""
88 1
        try:
89 1
            self.server.run(self.app, self.listen, self.port)
90 1
        except OSError as exception:
91 1
            msg = "Couldn't start API Server: {}".format(exception)
92 1
            self.log.critical(msg)
93 1
            sys.exit(msg)
94
95 1
    def register_rest_endpoint(self, url, function, methods):
96
        """Deprecate in favor of @rest decorator."""
97 1
        warnings.warn("From now on, use @rest decorator.", DeprecationWarning,
98
                      stacklevel=2)
99 1
        if url.startswith('/'):
100
            url = url[1:]
101 1
        self._start_endpoint(self.app, f'/kytos/{url}', function,
102
                             methods=methods)
103
104 1
    def start_api(self):
105
        """Start this APIServer instance API.
106
107
        Start /api/kytos/core/shutdown/ and status/ endpoints, web UI.
108
        """
109 1
        self.register_core_endpoint('shutdown/', self.shutdown_api)
110 1
        self.register_core_endpoint('status/', self.status_api)
111 1
        self.register_core_endpoint('web/update/<version>/',
112
                                    self.update_web_ui,
113
                                    methods=['POST'])
114 1
        self.register_core_endpoint('web/update/',
115
                                    self.update_web_ui,
116
                                    methods=['POST'])
117
118 1
        self.register_core_napp_services()
119
120 1
        self._register_web_ui()
121
122 1
    def register_core_endpoint(self, rule, function, **options):
123
        """Register an endpoint with the URL /api/kytos/core/<rule>.
124
125
        Not used by NApps, but controller.
126
        """
127 1
        self._start_endpoint(self.app, self._CORE_PREFIX + rule, function,
128
                             **options)
129
130 1
    def _register_web_ui(self):
131
        """Register routes to the admin-ui homepage."""
132 1
        self.app.add_url_rule('/', self.web_ui.__name__, self.web_ui)
133 1
        self.app.add_url_rule('/index.html', self.web_ui.__name__, self.web_ui)
134 1
        self.app.add_url_rule('/ui/<username>/<napp_name>/<path:filename>',
135
                              self.static_web_ui.__name__, self.static_web_ui)
136 1
        self.app.add_url_rule('/ui/<path:section_name>',
137
                              self.get_ui_components.__name__,
138
                              self.get_ui_components)
139
140 1
    @staticmethod
141
    def status_api():
142
        """Display kytos status using the route ``/kytos/status/``."""
143 1
        return '{"response": "running"}', HTTPStatus.OK.value
144
145 1
    def stop_api_server(self):
146
        """Send a shutdown request to stop Api Server."""
147 1
        try:
148 1
            url = f'http://127.0.0.1:{self.port}/api/kytos/core/shutdown'
149 1
            urlopen(url)
150
        except URLError:
151
            pass
152
153 1
    def shutdown_api(self):
154
        """Handle shutdown requests received by Api Server.
155
156
        This method must be called by kytos using the method
157
        stop_api_server, otherwise this request will be ignored.
158
        """
159 1
        allowed_host = ['127.0.0.1:'+str(self.port),
160
                        'localhost:'+str(self.port)]
161 1
        if request.host not in allowed_host:
162 1
            return "", HTTPStatus.FORBIDDEN.value
163
164 1
        self.server.stop()
165
166 1
        return 'Server shutting down...', HTTPStatus.OK.value
167
168 1
    def static_web_ui(self, username, napp_name, filename):
169
        """Serve static files from installed napps."""
170 1
        path = f"{self.napps_dir}/{username}/{napp_name}/ui/{filename}"
171 1
        if os.path.exists(path):
172 1
            return send_file(path)
173 1
        return "", HTTPStatus.NOT_FOUND.value
174
175 1
    def get_ui_components(self, section_name):
176
        """Return all napps ui components from an specific section.
177
178
        The component name generated will have the following structure:
179
        {username}-{nappname}-{component-section}-{filename}`
180
181
        Args:
182
            section_name (str): Specific section name
183
184
        Returns:
185
            str: Json with a list of all components found.
186
187
        """
188 1
        section_name = '*' if section_name == "all" else section_name
189 1
        path = f"{self.napps_dir}/*/*/ui/{section_name}/*.kytos"
190 1
        components = []
191 1
        for name in glob(path):
192 1
            dirs_name = name.split('/')
193 1
            dirs_name.remove('ui')
194
195 1
            component_name = '-'.join(dirs_name[-4:]).replace('.kytos', '')
196 1
            url = f'ui/{"/".join(dirs_name[-4:])}'
197 1
            component = {'name': component_name, 'url': url}
198 1
            components.append(component)
199 1
        return jsonify(components)
200
201 1
    def web_ui(self):
202
        """Serve the index.html page for the admin-ui."""
203 1
        return send_file(f"{self.flask_dir}/index.html")
204
205 1
    def update_web_ui(self, version='latest', force=True):
206
        """Update the static files for the Web UI.
207
208
        Download the latest files from the UI github repository and update them
209
        in the ui folder.
210
        The repository link is currently hardcoded here.
211
        """
212 1
        if version == 'latest':
213 1
            try:
214 1
                url = 'https://api.github.com/repos/kytos/ui/releases/latest'
215 1
                response = urlopen(url)
216 1
                data = response.readlines()[0]
217 1
                version = json.loads(data)['tag_name']
218 1
            except URLError:
219 1
                version = '1.1.1'
220
221 1
        repository = "https://github.com/kytos/ui"
222 1
        uri = repository + f"/releases/download/{version}/latest.zip"
223
224 1
        if not os.path.exists(self.flask_dir) or force:
225
            # download from github
226 1
            try:
227 1
                package = urlretrieve(uri)[0]
228 1
            except HTTPError:
229 1
                return f"Uri not found {uri}."
230
231
            # test downloaded zip file
232 1
            zip_ref = zipfile.ZipFile(package, 'r')
233
234 1
            if zip_ref.testzip() is not None:
235 1
                return f'Zip file from {uri} is corrupted.'
236
237
            # backup the old web-ui files and create a new web-ui folder
238 1
            if os.path.exists(self.flask_dir):
239 1
                date = datetime.now().strftime("%Y%m%d%H%M%S")
240 1
                shutil.move(self.flask_dir, f"{self.flask_dir}-{date}")
241 1
                os.mkdir(self.flask_dir)
242
243
            # unzip and extract files to web-ui/*
244 1
            zip_ref.extractall(self.flask_dir)
245 1
            zip_ref.close()
246
247 1
        return "updated the web ui"
248
249
    # BEGIN decorator methods
250
251 1
    @staticmethod
252
    def decorate_as_endpoint(rule, **options):
253
        """Decorate methods as REST endpoints.
254
255
        Example for URL ``/api/myusername/mynapp/sayhello/World``:
256
257
        .. code-block:: python3
258
259
           from flask.json import jsonify
260
           from kytos.core.napps import rest
261
262
           @rest('sayhello/<string:name>')
263
           def say_hello(name):
264
               return jsonify({"data": f"Hello, {name}!"})
265
266
        ``@rest`` parameters are the same as Flask's ``@app.route``. You can
267
        also add ``methods=['POST']``, for example.
268
269
        As we don't have the NApp instance now, we store the parameters in a
270
        method attribute in order to add the route later, after we have both
271
        APIServer and NApp instances.
272
        """
273 1
        def store_route_params(function):
274
            """Store ``Flask`` ``@route`` parameters in a method attribute.
275
276
            There can be many @route decorators in a single function.
277
            """
278
            # To support any order: @classmethod, @rest or @rest, @classmethod
279
            # class and static decorators return a descriptor with the function
280
            # in __func__.
281 1
            if isinstance(function, (classmethod, staticmethod)):
282 1
                inner = function.__func__
283
            else:
284 1
                inner = function
285
            # Add route parameters
286 1
            if not hasattr(inner, 'route_params'):
287 1
                inner.route_params = []
288 1
            inner.route_params.append((rule, options))
289
            # Return the same function, now with "route_params" attribute
290 1
            return function
291 1
        return store_route_params
292
293 1
    def register_napp_endpoints(self, napp):
294
        """Add all NApp REST endpoints with @rest decorator.
295
296
        We are using Flask Blueprints to register these endpoints. Blueprints
297
        are essentially the Flask equivalent of Python modules and are used to
298
        keep related logic and assets grouped and separated from one another.
299
300
        URLs will be prefixed with ``/api/{username}/{napp_name}/``.
301
302
        Args:
303
            napp (Napp): Napp instance to register new endpoints.
304
        """
305
        # Create a Flask Blueprint for a specific NApp
306 1
        napp_blueprint = Blueprint(napp.napp_id, __name__)
307
308
        # Start all endpoints for this NApp
309 1
        for function in self._get_decorated_functions(napp):
310 1
            for rule, options in function.route_params:
311 1
                absolute_rule = self.get_absolute_rule(rule, napp)
312 1
                self._start_endpoint(napp_blueprint, absolute_rule, function,
313
                                     **options)
314
315
        # Register this Flask Blueprint in the Flask App
316 1
        self.app.register_blueprint(napp_blueprint)
317
318 1
    @staticmethod
319
    def _get_decorated_functions(napp):
320
        """Return ``napp``'s methods having the @rest decorator."""
321 1
        for name in dir(napp):
322 1
            if not name.startswith('_'):  # discarding private names
323 1
                pub_attr = getattr(napp, name)
324 1
                if callable(pub_attr) and hasattr(pub_attr, 'route_params'):
325 1
                    yield pub_attr
326
327 1
    @classmethod
328
    def get_absolute_rule(cls, rule, napp):
329
        """Prefix the rule, e.g. "flow" to "/api/user/napp/flow".
330
331
        This code is used by kytos-utils when generating an OpenAPI skel.
332
        """
333
        # Flask does require 2 slashes if specified, so we remove a starting
334
        # slash if applicable.
335 1
        relative_rule = rule[1:] if rule.startswith('/') else rule
336 1
        return cls._NAPP_PREFIX.format(napp=napp) + relative_rule
337
338
    # END decorator methods
339
340 1
    def _start_endpoint(self, app, rule, function, **options):
341
        """Start ``function``'s endpoint.
342
343
        Forward parameters to ``Flask.add_url_rule`` mimicking Flask
344
        ``@route`` decorator.
345
        """
346 1
        endpoint = options.pop('endpoint', None)
347 1
        app.add_url_rule(rule, endpoint, function, **options)
348 1
        self.log.info('Started %s - %s', rule,
349
                      ', '.join(options.get('methods', self.DEFAULT_METHODS)))
350
351 1
    def remove_napp_endpoints(self, napp):
352
        """Remove all decorated endpoints.
353
354
        Args:
355
            napp (Napp): Napp instance to look for rest-decorated methods.
356
        """
357 1
        prefix = self._NAPP_PREFIX.format(napp=napp)
358 1
        indexes = []
359 1
        endpoints = set()
360 1
        for index, rule in enumerate(self.app.url_map.iter_rules()):
361 1
            if rule.rule.startswith(prefix):
362 1
                endpoints.add(rule.endpoint)
363 1
                indexes.append(index)
364 1
                self.log.info('Stopped %s - %s', rule, ','.join(rule.methods))
365
366 1
        for endpoint in endpoints:
367 1
            self.app.view_functions.pop(endpoint)
368
369 1
        for index in reversed(indexes):
370
            # pylint: disable=protected-access
371 1
            self.app.url_map._rules.pop(index)
372
            # pylint: enable=protected-access
373
374
        # Remove the Flask Blueprint of this NApp from the Flask App
375 1
        self.app.blueprints.pop(napp.napp_id)
376
377 1
        self.log.info('The Rest endpoints from %s were disabled.', prefix)
378
379 1
    def register_core_napp_services(self):
380
        """
381
        Register /kytos/core/ services over NApps.
382
383
        It registers enable, disable, install, uninstall NApps that will
384
        be used by kytos-utils.
385
        """
386 1
        self.register_core_endpoint("napps/<username>/<napp_name>/enable",
387
                                    self._enable_napp)
388 1
        self.register_core_endpoint("napps/<username>/<napp_name>/disable",
389
                                    self._disable_napp)
390 1
        self.register_core_endpoint("napps/<username>/<napp_name>/install",
391
                                    self._install_napp)
392 1
        self.register_core_endpoint("napps/<username>/<napp_name>/uninstall",
393
                                    self._uninstall_napp)
394 1
        self.register_core_endpoint("napps_enabled",
395
                                    self._list_enabled_napps)
396 1
        self.register_core_endpoint("napps_installed",
397
                                    self._list_installed_napps)
398 1
        self.register_core_endpoint(
399
            "napps/<username>/<napp_name>/metadata/<key>",
400
            self._get_napp_metadata)
401
402 1
    def _enable_napp(self, username, napp_name):
403
        """
404
        Enable an installed NApp.
405
406
        :param username: NApps user name
407
        :param napp_name: NApp name
408
        :return: JSON content and return code
409
        """
410
        # Check if the NApp is installed
411 1
        if not self.napps_manager.is_installed(username, napp_name):
412 1
            return '{"response": "not installed"}', \
413
                   HTTPStatus.BAD_REQUEST.value
414
415
        # Check if the NApp is already been enabled
416 1
        if not self.napps_manager.is_enabled(username, napp_name):
417 1
            self.napps_manager.enable(username, napp_name)
418
419
        # Check if NApp is enabled
420 1
        if not self.napps_manager.is_enabled(username, napp_name):
421
            # If it is not enabled an admin user must check the log file
422 1
            return '{"response": "error"}', \
423
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
424
425 1
        return '{"response": "enabled"}', HTTPStatus.OK.value
426
427 1
    def _disable_napp(self, username, napp_name):
428
        """
429
        Disable an installed NApp.
430
431
        :param username: NApps user name
432
        :param napp_name: NApp name
433
        :return: JSON content and return code
434
        """
435
        # Check if the NApp is installed
436 1
        if not self.napps_manager.is_installed(username, napp_name):
437 1
            return '{"response": "not installed"}', \
438
                   HTTPStatus.BAD_REQUEST.value
439
440
        # Check if the NApp is enabled
441 1
        if self.napps_manager.is_enabled(username, napp_name):
442 1
            self.napps_manager.disable(username, napp_name)
443
444
        # Check if NApp is still enabled
445 1
        if self.napps_manager.is_enabled(username, napp_name):
446
            # If it is still enabled an admin user must check the log file
447 1
            return '{"response": "error"}', \
448
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
449
450 1
        return '{"response": "disabled"}', \
451
               HTTPStatus.OK.value
452
453 1
    def _install_napp(self, username, napp_name):
454
        # Check if the NApp is installed
455 1
        if self.napps_manager.is_installed(username, napp_name):
456 1
            return '{"response": "installed"}', HTTPStatus.OK.value
457
458 1
        napp = "{}/{}".format(username, napp_name)
459
460
        # Try to install and enable the napp
461 1
        try:
462 1
            if not self.napps_manager.install(napp, enable=True):
463
                # If it is not installed an admin user must check the log file
464 1
                return '{"response": "error"}', \
465
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
466 1
        except HTTPError as exception:
467 1
            return '{"response": "error"}', exception.code
468
469 1
        return '{"response": "installed"}', HTTPStatus.OK.value
470
471 1
    def _uninstall_napp(self, username, napp_name):
472
        # Check if the NApp is installed
473 1
        if self.napps_manager.is_installed(username, napp_name):
474
            # Try to unload/uninstall the napp
475 1
            if not self.napps_manager.uninstall(username, napp_name):
476
                # If it is not uninstalled admin user must check the log file
477 1
                return '{"response": "error"}', \
478
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
479
480 1
        return '{"response": "uninstalled"}', HTTPStatus.OK.value
481
482 1
    def _list_enabled_napps(self):
483
        """Sorted list of (username, napp_name) of enabled napps."""
484 1
        serialized_dict = json.dumps(
485
                            self.napps_manager.get_enabled_napps(),
486
                            default=lambda a: [a.username, a.name])
487
488 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
489
490 1
    def _list_installed_napps(self):
491
        """Sorted list of (username, napp_name) of installed napps."""
492 1
        serialized_dict = json.dumps(
493
                            self.napps_manager.get_installed_napps(),
494
                            default=lambda a: [a.username, a.name])
495
496 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
497
498 1
    def _get_napp_metadata(self, username, napp_name, key):
499
        """Get NApp metadata value.
500
501
        For safety reasons, only some keys can be retrieved:
502
        napp_dependencies, description, version.
503
504
        """
505 1
        valid_keys = ['napp_dependencies', 'description', 'version']
506
507 1
        if not self.napps_manager.is_installed(username, napp_name):
508 1
            return "NApp is not installed.", HTTPStatus.BAD_REQUEST.value
509
510 1
        if key not in valid_keys:
511 1
            return "Invalid key.", HTTPStatus.BAD_REQUEST.value
512
513 1
        data = self.napps_manager.get_napp_metadata(username, napp_name, key)
514 1
        serialized_dict = json.dumps({key: data})
515
516
        return '%s' % serialized_dict, HTTPStatus.OK.value
517