blob: 9020e82223f4981b3e3c36228dd05fc58196c517 [file] [log] [blame]
Chip Bolingf5af85d2019-02-12 15:36:17 -06001# Copyright 2017-present Adtran, Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import json
16
17import structlog
18import treq
19from twisted.internet.defer import inlineCallbacks, returnValue
20from twisted.internet.error import ConnectionClosed, ConnectionDone, ConnectionLost
21
22log = structlog.get_logger()
23
24
25class RestInvalidResponseCode(Exception):
26 def __init__(self, message, url, code):
27 super(RestInvalidResponseCode, self).__init__(message)
28 self.url = url
29 self.code = code
30
31
32class AdtranRestClient(object):
33 """
34 Performs Adtran RESTCONF requests
35 """
36 # HTTP shortcuts
37 HELLO_URI = '/restconf/adtran-hello:hello'
38
39 REST_GET_REQUEST_HEADER = {'User-Agent': 'Adtran RESTConf',
40 'Accept': ['application/json']}
41
42 REST_POST_REQUEST_HEADER = {'User-Agent': 'Adtran RESTConf',
43 'Content-Type': 'application/json',
44 'Accept': ['application/json']}
45
46 REST_PATCH_REQUEST_HEADER = REST_POST_REQUEST_HEADER
47 REST_PUT_REQUEST_HEADER = REST_POST_REQUEST_HEADER
48 REST_DELETE_REQUEST_HEADER = REST_GET_REQUEST_HEADER
49
50 HTTP_OK = 200
51 HTTP_CREATED = 201
52 HTTP_ACCEPTED = 202
53 HTTP_NON_AUTHORITATIVE_INFORMATION = 203
54 HTTP_NO_CONTENT = 204
55 HTTP_RESET_CONTENT = 205
56 HTTP_PARTIAL_CONTENT = 206
57 HTTP_NOT_FOUND = 404
58
59 _valid_methods = {'GET', 'POST', 'PATCH', 'DELETE'}
60 _valid_results = {'GET': [HTTP_OK, HTTP_NO_CONTENT],
61 'POST': [HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT],
62 'PUT': [HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT],
63 'PATCH': [HTTP_OK],
64 'DELETE': [HTTP_OK, HTTP_ACCEPTED, HTTP_NO_CONTENT, HTTP_NOT_FOUND]
65 }
66
67 for _method in _valid_methods:
68 assert _method in _valid_results # Make sure we have a results entry for each supported method
69
70 def __init__(self, host_ip, port, username='', password='', timeout=10):
71 """
72 REST Client initialization
73
74 :param host_ip: (string) IP Address of Adtran Device
75 :param port: (int) Port number
76 :param username: (string) Username for credentials
77 :param password: (string) Password for credentials
78 :param timeout: (int) Number of seconds to wait for a response before timing out
79 """
80 self._ip = host_ip
81 self._port = port
82 self._username = username
83 self._password = password
84 self._timeout = timeout
85
86 def __str__(self):
87 return "AdtranRestClient {}@{}:{}".format(self._username, self._ip, self._port)
88
89 @inlineCallbacks
90 def request(self, method, uri, data=None, name='', timeout=None, is_retry=False,
91 suppress_error=False):
92 """
93 Send a REST request to the Adtran device
94
95 :param method: (string) HTTP method
96 :param uri: (string) fully URL to perform method on
97 :param data: (string) optional data for the request body
98 :param name: (string) optional name of the request, useful for logging purposes
99 :param timeout: (int) Number of seconds to wait for a response before timing out
100 :param is_retry: (boolean) True if this method called recursively in order to recover
101 from a connection loss. Can happen sometimes in debug sessions
102 and in the real world.
103 :param suppress_error: (boolean) If true, do not output ERROR message on REST request failure
104 :return: (dict) On success with the proper results
105 """
106 log.debug('request', method=method, uri=uri, data=data, retry=is_retry)
107
108 if method.upper() not in self._valid_methods:
109 raise NotImplementedError("REST method '{}' is not supported".format(method))
110
111 url = 'http://{}:{}{}{}'.format(self._ip, self._port,
112 '/' if uri[0] != '/' else '',
113 uri)
114 response = None
115 timeout = timeout or self._timeout
116
117 try:
118 if method.upper() == 'GET':
119 response = yield treq.get(url,
120 auth=(self._username, self._password),
121 timeout=timeout,
122 headers=self.REST_GET_REQUEST_HEADER)
123 elif method.upper() == 'POST' or method.upper() == 'PUT':
124 response = yield treq.post(url,
125 data=data,
126 auth=(self._username, self._password),
127 timeout=timeout,
128 headers=self.REST_POST_REQUEST_HEADER)
129 elif method.upper() == 'PATCH':
130 response = yield treq.patch(url,
131 data=data,
132 auth=(self._username, self._password),
133 timeout=timeout,
134 headers=self.REST_PATCH_REQUEST_HEADER)
135 elif method.upper() == 'DELETE':
136 response = yield treq.delete(url,
137 auth=(self._username, self._password),
138 timeout=timeout,
139 headers=self.REST_DELETE_REQUEST_HEADER)
140 else:
141 raise NotImplementedError("REST method '{}' is not supported".format(method))
142
143 except NotImplementedError:
144 raise
145
146 except (ConnectionDone, ConnectionLost) as e:
147 if is_retry:
148 raise
149 returnValue(self.request(method, uri, data=data, name=name,
150 timeout=timeout, is_retry=True))
151
152 except ConnectionClosed:
153 returnValue(ConnectionClosed)
154
155 except Exception as e:
156 log.exception("rest-request", method=method, url=url, name=name, e=e)
157 raise
158
159 if response.code not in self._valid_results[method.upper()]:
160 message = "REST {} '{}' request to '{}' failed with status code {}".format(method, name,
161 url, response.code)
162 if not suppress_error:
163 log.error(message)
164 raise RestInvalidResponseCode(message, url, response.code)
165
166 if response.code in {self.HTTP_NO_CONTENT, self.HTTP_NOT_FOUND}:
167 returnValue(None)
168
169 else:
170 # TODO: May want to support multiple body encodings in the future
171
172 headers = response.headers
173 type_key = 'content-type'
174 type_val = 'application/json'
175
176 if not headers.hasHeader(type_key) or type_val not in headers.getRawHeaders(type_key, []):
177 raise Exception("REST {} '{}' request response from '{}' was not JSON",
178 method, name, url)
179
180 content = yield response.content()
181 try:
182 result = json.loads(content)
183
184 except Exception as e:
185 log.exception("json-decode", method=method, url=url, name=name,
186 content=content, e=e)
187 raise
188
189 returnValue(result)