<reponame>jonohart/voltha<gh_stars>0
# Copyright 2017-present Adtran, Inc.
#
# 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 json

import structlog
import treq
from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.internet.error import ConnectionClosed, ConnectionDone, ConnectionLost

log = structlog.get_logger()


class RestInvalidResponseCode(Exception):
    def __init__(self, message, url, code):
        super(RestInvalidResponseCode, self).__init__(message)
        self.url = url
        self.code = code


class AdtranRestClient(object):
    """
    Performs Adtran RESTCONF requests
    """
    # HTTP shortcuts
    HELLO_URI = '/restconf/adtran-hello:hello'

    REST_GET_REQUEST_HEADER = {'User-Agent': 'Adtran RESTConf',
                               'Accept': ['application/json']}

    REST_POST_REQUEST_HEADER = {'User-Agent': 'Adtran RESTConf',
                                'Content-Type': 'application/json',
                                'Accept': ['application/json']}

    REST_PATCH_REQUEST_HEADER = REST_POST_REQUEST_HEADER
    REST_PUT_REQUEST_HEADER = REST_POST_REQUEST_HEADER
    REST_DELETE_REQUEST_HEADER = REST_GET_REQUEST_HEADER

    HTTP_OK = 200
    HTTP_CREATED = 201
    HTTP_ACCEPTED = 202
    HTTP_NON_AUTHORITATIVE_INFORMATION = 203
    HTTP_NO_CONTENT = 204
    HTTP_RESET_CONTENT = 205
    HTTP_PARTIAL_CONTENT = 206

    _valid_methods = {'GET', 'POST', 'PATCH', 'DELETE'}
    _valid_results = {'GET': [HTTP_OK, HTTP_NO_CONTENT],
                      'POST': [HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT],
                      'PUT': [HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT],
                      'PATCH': [HTTP_OK],
                      'DELETE': [HTTP_OK, HTTP_ACCEPTED, HTTP_NO_CONTENT]
                      }

    for _method in _valid_methods:
        assert _method in _valid_results  # Make sure we have a results entry for each supported method

    def __init__(self, host_ip, port, username='', password='', timeout=10):
        """
        REST Client initialization

        :param host_ip: (string) IP Address of Adtran Device
        :param port: (int) Port number
        :param username: (string) Username for credentials
        :param password: (string) Password for credentials
        :param timeout: (int) Number of seconds to wait for a response before timing out
        """
        self._ip = host_ip
        self._port = port
        self._username = username
        self._password = password
        self._timeout = timeout

    def __str__(self):
        return "AdtranRestClient {}@{}:{}".format(self._username, self._ip, self._port)

    @inlineCallbacks
    def request(self, method, uri, data=None, name='', timeout=None, is_retry=False,
                suppress_error=False):
        """
        Send a REST request to the Adtran device

        :param method: (string) HTTP method
        :param uri: (string) fully URL to perform method on
        :param data: (string) optional data for the request body
        :param name: (string) optional name of the request, useful for logging purposes
        :param timeout: (int) Number of seconds to wait for a response before timing out
        :param is_retry: (boolean) True if this method called recursively in order to recover
                                   from a connection loss. Can happen sometimes in debug sessions
                                   and in the real world.
        :return: (dict) On success with the proper results
        """
        log.debug('request', method=method, uri=uri, data=data, retry=is_retry)

        if method.upper() not in self._valid_methods:
            raise NotImplementedError("REST method '{}' is not supported".format(method))

        url = 'http://{}:{}{}{}'.format(self._ip, self._port,
                                        '/' if uri[0] != '/' else '',
                                        uri)
        response = None
        timeout = timeout or self._timeout

        try:
            if method.upper() == 'GET':
                response = yield treq.get(url,
                                          auth=(self._username, self._password),
                                          timeout=timeout,
                                          headers=self.REST_GET_REQUEST_HEADER)
            elif method.upper() == 'POST' or method.upper() == 'PUT':
                response = yield treq.post(url,
                                           data=data,
                                           auth=(self._username, self._password),
                                           timeout=timeout,
                                           headers=self.REST_POST_REQUEST_HEADER)
            elif method.upper() == 'PATCH':
                response = yield treq.patch(url,
                                            data=data,
                                            auth=(self._username, self._password),
                                            timeout=timeout,
                                            headers=self.REST_PATCH_REQUEST_HEADER)
            elif method.upper() == 'DELETE':
                response = yield treq.delete(url,
                                             auth=(self._username, self._password),
                                             timeout=timeout,
                                             headers=self.REST_DELETE_REQUEST_HEADER)
            else:
                raise NotImplementedError("REST method '{}' is not supported".format(method))

        except NotImplementedError:
            raise

        except (ConnectionDone, ConnectionLost) as e:
            if is_retry:
                raise
            returnValue(self.request(method, uri, data=data, name=name,
                                     timeout=timeout, is_retry=True))

        except ConnectionClosed:
            returnValue(ConnectionClosed)

        except Exception as e:
            log.exception("rest-request", method=method, url=url, name=name, e=e)
            raise

        if response.code not in self._valid_results[method.upper()]:
            message = "REST {} '{}' request to '{}' failed with status code {}".format(method, name,
                                                                                       url, response.code)
            if not suppress_error:
                log.error(message)
            raise RestInvalidResponseCode(message, url, response.code)

        if response.code == self.HTTP_NO_CONTENT:
            returnValue(None)

        else:
            # TODO: May want to support multiple body encodings in the future

            headers = response.headers
            type_key = 'content-type'
            type_val = 'application/json'

            if not headers.hasHeader(type_key) or type_val not in headers.getRawHeaders(type_key, []):
                raise Exception("REST {} '{}' request response from '{}' was not JSON",
                                method, name, url)

            content = yield response.content()
            try:
                result = json.loads(content)

            except Exception as e:
                log.exception("json-decode", method=method, url=url, name=name,
                              content=content, e=e)
                raise

            returnValue(result)
