Passed
Push — master ( 789688...a1436a )
by Humberto
05:08 queued 15s
created

kytos.core.api_server   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 553
Duplicated Lines 0 %

Test Coverage

Coverage 93.63%

Importance

Changes 0
Metric Value
eloc 296
dl 0
loc 553
ccs 235
cts 251
cp 0.9363
rs 2
c 0
b 0
f 0
wmc 80

30 Methods

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