kytos.core.api_server.APIServer._start_endpoint()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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