Test Failed
Push — master ( e380d0...f5671d )
by W
02:58
created

st2common/st2common/router.py (2 issues)

1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
from __future__ import absolute_import
17
import copy
18
import functools
19
import re
20
import six
21
import sys
22
import traceback
23
24
from flex.core import validate
25
import jsonschema
26
from oslo_config import cfg
27
import routes
28
from six.moves.urllib import parse as urlparse  # pylint: disable=import-error
29
import webob
30
from webob import cookies, exc, Request
31
from webob.compat import url_unquote
32
33
from st2common.exceptions import rbac as rbac_exc
34
from st2common.exceptions import auth as auth_exc
35
from st2common import log as logging
36
from st2common.persistence.auth import User
37
from st2common.rbac import resolvers
38
from st2common.util import date as date_utils
39
from st2common.util.jsonify import json_encode
40
from st2common.util.jsonify import get_json_type_for_python_value
41
from st2common.util.http import parse_content_type_header
42
43
__all__ = [
44
    'Router',
45
46
    'Response',
47
48
    'NotFoundException',
49
50
    'abort',
51
    'abort_unauthorized',
52
    'exc'
53
]
54
55
LOG = logging.getLogger(__name__)
56
57
58
def op_resolver(op_id):
59
    module_name, func_name = op_id.split(':', 1)
60
    __import__(module_name)
61
    module = sys.modules[module_name]
62
    return functools.reduce(getattr, func_name.split('.'), module)
63
64
65
def abort(status_code=exc.HTTPInternalServerError.code, message='Unhandled exception'):
66
    raise exc.status_map[status_code](message)
67
68
69
def abort_unauthorized(msg=None):
70
    raise exc.HTTPUnauthorized('Unauthorized - %s' % msg if msg else 'Unauthorized')
71
72
73
def extend_with_default(validator_class):
74
    validate_properties = validator_class.VALIDATORS["properties"]
75
76
    def set_defaults(validator, properties, instance, schema):
77
        for property, subschema in six.iteritems(properties):
0 ignored issues
show
Bug Best Practice introduced by
This seems to re-define the built-in property.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
78
            if "default" in subschema:
79
                instance.setdefault(property, subschema["default"])
80
81
        for error in validate_properties(
82
            validator, properties, instance, schema,
83
        ):
84
            yield error
85
86
    return jsonschema.validators.extend(
87
        validator_class, {"properties": set_defaults},
88
    )
89
90
91
def extend_with_additional_check(validator_class):
92
    def set_additional_check(validator, properties, instance, schema):
93
        ref = schema.get("x-additional-check")
94
        func = op_resolver(ref)
95
        for error in func(validator, properties, instance, schema):
96
            yield error
97
98
    return jsonschema.validators.extend(
99
        validator_class, {"x-additional-check": set_additional_check},
100
    )
101
102
103
def extend_with_nullable(validator_class):
104
    validate_type = validator_class.VALIDATORS["type"]
105
106
    def set_type_draft4(validator, types, instance, schema):
107
        is_nullable = schema.get("x-nullable", False)
108
109
        if is_nullable and instance is None:
110
            return
111
112
        for error in validate_type(validator, types, instance, schema):
113
            yield error
114
115
    return jsonschema.validators.extend(
116
        validator_class, {"type": set_type_draft4},
117
    )
118
119
120
CustomValidator = jsonschema.Draft4Validator
121
CustomValidator = extend_with_nullable(CustomValidator)
122
CustomValidator = extend_with_additional_check(CustomValidator)
123
CustomValidator = extend_with_default(CustomValidator)
124
125
126
class NotFoundException(Exception):
127
    pass
128
129
130
class Response(webob.Response):
131
    def __init__(self, body=None, status=None, headerlist=None, app_iter=None, content_type=None,
132
                 *args, **kwargs):
133
        # Do some sanity checking, and turn json_body into an actual body
134
        if app_iter is None and body is None and ('json_body' in kwargs or 'json' in kwargs):
135
            if 'json_body' in kwargs:
136
                json_body = kwargs.pop('json_body')
137
            else:
138
                json_body = kwargs.pop('json')
139
            body = json_encode(json_body).encode('UTF-8')
140
141
            if content_type is None:
142
                content_type = 'application/json'
143
144
        super(Response, self).__init__(body, status, headerlist, app_iter, content_type,
145
                                       *args, **kwargs)
146
147
    def _json_body__get(self):
148
        return super(Response, self)._json_body__get()
149
150
    def _json_body__set(self, value):
151
        self.body = json_encode(value).encode('UTF-8')
152
153
    def _json_body__del(self):
154
        return super(Response, self)._json_body__del()
155
156
    json = json_body = property(_json_body__get, _json_body__set, _json_body__del)
157
158
159
class Router(object):
160
    def __init__(self, arguments=None, debug=False, auth=True, is_gunicorn=True):
161
        self.debug = debug
162
        self.auth = auth
163
        self.is_gunicorn = is_gunicorn
164
165
        self.arguments = arguments or {}
166
167
        self.spec = {}
168
        self.spec_resolver = None
169
        self.routes = routes.Mapper()
170
171
    def add_spec(self, spec, transforms):
172
        info = spec.get('info', {})
173
        LOG.debug('Adding API: %s %s', info.get('title', 'untitled'), info.get('version', '0.0.0'))
174
175
        self.spec = spec
176
        self.spec_resolver = jsonschema.RefResolver('', self.spec)
177
178
        validate(copy.deepcopy(self.spec))
179
180
        for filter in transforms:
0 ignored issues
show
Bug Best Practice introduced by
This seems to re-define the built-in filter.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
181
            for (path, methods) in six.iteritems(spec['paths']):
182
                if not re.search(filter, path):
183
                    continue
184
185
                for (method, endpoint) in six.iteritems(methods):
186
                    conditions = {
187
                        'method': [method.upper()]
188
                    }
189
190
                    connect_kw = {}
191
                    if 'x-requirements' in endpoint:
192
                        connect_kw['requirements'] = endpoint['x-requirements']
193
194
                    m = self.routes.submapper(_api_path=path, _api_method=method,
195
                                              conditions=conditions)
196
                    for transform in transforms[filter]:
197
                        m.connect(None, re.sub(filter, transform, path), **connect_kw)
198
199
                    module_name = endpoint['operationId'].split(':', 1)[0]
200
                    __import__(module_name)
201
202
        for route in sorted(self.routes.matchlist, key=lambda r: r.routepath):
203
            LOG.debug('Route registered: %+6s %s', route.conditions['method'][0], route.routepath)
204
205
    def match(self, req):
206
        path = url_unquote(req.path)
207
        LOG.debug("Match path: %s", path)
208
209
        if len(path) > 1 and path.endswith('/'):
210
            path = path[:-1]
211
212
        match = self.routes.match(path, req.environ)
213
214
        if match is None:
215
            raise NotFoundException('No route matches "%s" path' % req.path)
216
217
        # To account for situation when match may return multiple values
218
        try:
219
            path_vars = match[0]
220
        except KeyError:
221
            path_vars = match
222
223
        path_vars = dict(path_vars)
224
225
        path = path_vars.pop('_api_path')
226
        method = path_vars.pop('_api_method')
227
        endpoint = self.spec['paths'][path][method]
228
229
        return endpoint, path_vars
230
231
    def __call__(self, req):
232
        """
233
        The method is invoked on every request and shows the lifecycle of the request received from
234
        the middleware.
235
236
        Although some middleware may use parts of the API spec, it is safe to assume that if you're
237
        looking for the particular spec property handler, it's most likely a part of this method.
238
239
        At the time of writing, the only property being utilized by middleware was `x-log-result`.
240
        """
241
        LOG.debug("Received call with WebOb: %s", req)
242
        endpoint, path_vars = self.match(req)
243
        LOG.debug("Parsed endpoint: %s", endpoint)
244
        LOG.debug("Parsed path_vars: %s", path_vars)
245
246
        context = copy.copy(getattr(self, 'mock_context', {}))
247
        cookie_token = None
248
249
        # Handle security
250
        if 'security' in endpoint:
251
            security = endpoint.get('security')
252
        else:
253
            security = self.spec.get('security', [])
254
255
        if self.auth and security:
256
            try:
257
                security_definitions = self.spec.get('securityDefinitions', {})
258
                for statement in security:
259
                    declaration, options = statement.copy().popitem()
260
                    definition = security_definitions[declaration]
261
262
                    if definition['type'] == 'apiKey':
263
                        if definition['in'] == 'header':
264
                            token = req.headers.get(definition['name'])
265
                        elif definition['in'] == 'query':
266
                            token = req.GET.get(definition['name'])
267
                        elif definition['in'] == 'cookie':
268
                            token = req.cookies.get(definition['name'])
269
                        else:
270
                            token = None
271
272
                        if token:
273
                            auth_func = op_resolver(definition['x-operationId'])
274
                            auth_resp = auth_func(token)
275
276
                            # Include information on how user authenticated inside the context
277
                            if 'auth-token' in definition['name'].lower():
278
                                auth_method = 'authentication token'
279
                            elif 'api-key' in definition['name'].lower():
280
                                auth_method = 'API key'
281
282
                            context['user'] = User.get_by_name(auth_resp.user)
283
                            context['auth_info'] = {
284
                                'method': auth_method,
285
                                'location': definition['in']
286
                            }
287
288
                            # Also include token expiration time when authenticated via auth token
289
                            if 'auth-token' in definition['name'].lower():
290
                                context['auth_info']['token_expire'] = auth_resp.expiry
291
292
                            if 'x-set-cookie' in definition:
293
                                max_age = auth_resp.expiry - date_utils.get_datetime_utc_now()
294
                                cookie_token = cookies.make_cookie(definition['x-set-cookie'],
295
                                                                   token,
296
                                                                   max_age=max_age,
297
                                                                   httponly=True)
298
299
                            break
300
301
                if 'user' not in context:
302
                    raise auth_exc.NoAuthSourceProvidedError('One of Token or API key required.')
303
            except (auth_exc.NoAuthSourceProvidedError,
304
                    auth_exc.MultipleAuthSourcesError) as e:
305
                LOG.error(str(e))
306
                return abort_unauthorized(str(e))
307
            except auth_exc.TokenNotProvidedError as e:
308
                LOG.exception('Token is not provided.')
309
                return abort_unauthorized(str(e))
310
            except auth_exc.TokenNotFoundError as e:
311
                LOG.exception('Token is not found.')
312
                return abort_unauthorized(str(e))
313
            except auth_exc.TokenExpiredError as e:
314
                LOG.exception('Token has expired.')
315
                return abort_unauthorized(str(e))
316
            except auth_exc.ApiKeyNotProvidedError as e:
317
                LOG.exception('API key is not provided.')
318
                return abort_unauthorized(str(e))
319
            except auth_exc.ApiKeyNotFoundError as e:
320
                LOG.exception('API key is not found.')
321
                return abort_unauthorized(str(e))
322
            except auth_exc.ApiKeyDisabledError as e:
323
                LOG.exception('API key is disabled.')
324
                return abort_unauthorized(str(e))
325
326
            if cfg.CONF.rbac.enable:
327
                user_db = context['user']
328
329
                permission_type = endpoint.get('x-permissions', None)
330
                if permission_type:
331
                    resolver = resolvers.get_resolver_for_permission_type(permission_type)
332
                    has_permission = resolver.user_has_permission(user_db, permission_type)
333
334
                    if not has_permission:
335
                        raise rbac_exc.ResourceTypeAccessDeniedError(user_db,
336
                                                                     permission_type)
337
338
        # Collect parameters
339
        kw = {}
340
        for param in endpoint.get('parameters', []) + endpoint.get('x-parameters', []):
341
            name = param['name']
342
            argument_name = param.get('x-as', None) or name
343
            source = param['in']
344
            default = param.get('default', None)
345
346
            # Collecting params from different sources
347
            if source == 'query':
348
                kw[argument_name] = req.GET.get(name, default)
349
            elif source == 'path':
350
                kw[argument_name] = path_vars[name]
351
            elif source == 'header':
352
                kw[argument_name] = req.headers.get(name, default)
353
            elif source == 'formData':
354
                kw[argument_name] = req.POST.get(name, default)
355
            elif source == 'environ':
356
                kw[argument_name] = req.environ.get(name.upper(), default)
357
            elif source == 'context':
358
                kw[argument_name] = context.get(name, default)
359
            elif source == 'request':
360
                kw[argument_name] = getattr(req, name)
361
            elif source == 'body':
362
                content_type = req.headers.get('Content-Type', 'application/json')
363
                content_type = parse_content_type_header(content_type=content_type)[0]
364
                schema = param['schema']
365
366
                # NOTE: HACK: Workaround for eventlet wsgi server which sets Content-Type to
367
                # text/plain if Content-Type is not provided in the request.
368
                # All ouf our API endpoints except /exp/validation/mistral expect application/json
369
                # so we explicitly set it to that if not provided (set to text/plain by the base
370
                # http server) and if it's not /exp/validation/mistral API endpoint
371
                if not self.is_gunicorn and content_type == 'text/plain':
372
                    operation_id = endpoint['operationId']
373
374
                    if 'mistral_validation_controller' not in operation_id:
375
                        content_type = 'application/json'
376
377
                # Note: We also want to perform validation if no body is explicitly provided - in a
378
                # lot of POST, PUT scenarios, body is mandatory
379
                if not req.body and content_type == 'application/json':
380
                    req.body = b'{}'
381
382
                try:
383
                    if content_type == 'application/json':
384
                        data = req.json
385
                    elif content_type == 'text/plain':
386
                        data = req.body
387
                    elif content_type in ['application/x-www-form-urlencoded',
388
                                          'multipart/form-data']:
389
                        data = urlparse.parse_qs(req.body)
390
                    else:
391
                        raise ValueError('Unsupported Content-Type: "%s"' % (content_type))
392
                except Exception as e:
393
                    detail = 'Failed to parse request body: %s' % str(e)
394
                    raise exc.HTTPBadRequest(detail=detail)
395
396
                # Special case for Python 3
397
                if six.PY3 and content_type == 'text/plain' and isinstance(data, six.binary_type):
398
                    # Convert bytes to text type (string / unicode)
399
                    data = data.decode('utf-8')
400
401
                try:
402
                    CustomValidator(schema, resolver=self.spec_resolver).validate(data)
403
                except (jsonschema.ValidationError, ValueError) as e:
404
                    raise exc.HTTPBadRequest(detail=e.message,
405
                                             comment=traceback.format_exc())
406
407
                if content_type == 'text/plain':
408
                    kw[argument_name] = data
409
                else:
410
                    class Body(object):
411
                        def __init__(self, **entries):
412
                            self.__dict__.update(entries)
413
414
                    ref = schema.get('$ref', None)
415
                    if ref:
416
                        with self.spec_resolver.resolving(ref) as resolved:
417
                            schema = resolved
418
419
                    if 'x-api-model' in schema:
420
                        input_type = schema.get('type', [])
421
                        Model = op_resolver(schema['x-api-model'])
422
423
                        if input_type and not isinstance(input_type, (list, tuple)):
424
                            input_type = [input_type]
425
426
                        # root attribute is not an object, we need to use wrapper attribute to
427
                        # make it work with **kwarg expansion
428
                        if input_type and 'array' in input_type:
429
                            data = {'data': data}
430
431
                        instance = self._get_model_instance(model_cls=Model, data=data)
432
433
                        # Call validate on the API model - note we should eventually move all
434
                        # those model schema definitions into openapi.yaml
435
                        try:
436
                            instance = instance.validate()
437
                        except (jsonschema.ValidationError, ValueError) as e:
438
                            raise exc.HTTPBadRequest(detail=e.message,
439
                                                     comment=traceback.format_exc())
440
                    else:
441
                        LOG.debug('Missing x-api-model definition for %s, using generic Body '
442
                                  'model.' % (endpoint['operationId']))
443
                        model = Body
444
                        instance = self._get_model_instance(model_cls=model, data=data)
445
446
                    kw[argument_name] = instance
447
448
            # Making sure all required params are present
449
            required = param.get('required', False)
450
            if required and kw[argument_name] is None:
451
                detail = 'Required parameter "%s" is missing' % name
452
                raise exc.HTTPBadRequest(detail=detail)
453
454
            # Validating and casting param types
455
            param_type = param.get('type', None)
456
            if kw[argument_name] is not None:
457
                if param_type == 'boolean':
458
                    positive = ('true', '1', 'yes', 'y')
459
                    negative = ('false', '0', 'no', 'n')
460
461
                    if str(kw[argument_name]).lower() not in positive + negative:
462
                        detail = 'Parameter "%s" is not of type boolean' % argument_name
463
                        raise exc.HTTPBadRequest(detail=detail)
464
465
                    kw[argument_name] = str(kw[argument_name]).lower() in positive
466
                elif param_type == 'integer':
467
                    regex = r'^-?[0-9]+$'
468
469
                    if not re.search(regex, str(kw[argument_name])):
470
                        detail = 'Parameter "%s" is not of type integer' % argument_name
471
                        raise exc.HTTPBadRequest(detail=detail)
472
473
                    kw[argument_name] = int(kw[argument_name])
474
                elif param_type == 'number':
475
                    regex = r'^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$'
476
477
                    if not re.search(regex, str(kw[argument_name])):
478
                        detail = 'Parameter "%s" is not of type float' % argument_name
479
                        raise exc.HTTPBadRequest(detail=detail)
480
481
                    kw[argument_name] = float(kw[argument_name])
482
                elif param_type == 'array' and param.get('items', {}).get('type', None) == 'string':
483
                    if kw[argument_name] is None:
484
                        kw[argument_name] = []
485
                    elif isinstance(kw[argument_name], (list, tuple)):
486
                        # argument is already an array
487
                        pass
488
                    else:
489
                        kw[argument_name] = kw[argument_name].split(',')
490
491
        # Call the controller
492
        try:
493
            func = op_resolver(endpoint['operationId'])
494
        except Exception as e:
495
            LOG.exception('Failed to load controller for operation "%s": %s' %
496
                          (endpoint['operationId'], str(e)))
497
            raise e
498
499
        try:
500
            resp = func(**kw)
501
        except Exception as e:
502
            LOG.exception('Failed to call controller function "%s" for operation "%s": %s' %
503
                          (func.__name__, endpoint['operationId'], str(e)))
504
            raise e
505
506
        # Handle response
507
        if resp is None:
508
            resp = Response()
509
510
        if not hasattr(resp, '__call__'):
511
            resp = Response(json=resp)
512
513
        responses = endpoint.get('responses', {})
514
        response_spec = responses.get(str(resp.status_code), None)
515
        default_response_spec = responses.get('default', None)
516
517
        if not response_spec and default_response_spec:
518
            LOG.debug('No custom response spec found for endpoint "%s", using a default one' %
519
                      (endpoint['operationId']))
520
            response_spec_name = 'default'
521
        else:
522
            response_spec_name = str(resp.status_code)
523
524
        response_spec = response_spec or default_response_spec
525
526
        if response_spec and 'schema' in response_spec:
527
            LOG.debug('Using response spec "%s" for endpoint %s and status code %s' %
528
                     (response_spec_name, endpoint['operationId'], resp.status_code))
529
530
            try:
531
                validator = CustomValidator(response_spec['schema'], resolver=self.spec_resolver)
532
                validator.validate(resp.json)
533
            except (jsonschema.ValidationError, ValueError):
534
                LOG.exception('Response validation failed.')
535
                resp.headers.add('Warning', '199 OpenAPI "Response validation failed"')
536
        else:
537
            LOG.debug('No response spec found for endpoint "%s"' % (endpoint['operationId']))
538
539
        if cookie_token:
540
            resp.headerlist.append(('Set-Cookie', cookie_token))
541
542
        return resp
543
544
    def as_wsgi(self, environ, start_response):
545
        """
546
        Converts WSGI request to webob.Request and initiates the response returned by controller.
547
        """
548
        req = Request(environ)
549
        resp = self(req)
550
        return resp(environ, start_response)
551
552
    def _get_model_instance(self, model_cls, data):
553
        try:
554
            instance = model_cls(**data)
555
        except TypeError as e:
556
            # Throw a more user-friendly exception when input data is not an object
557
            if 'type object argument after ** must be a mapping, not' in str(e):
558
                type_string = get_json_type_for_python_value(data)
559
                msg = ('Input body needs to be an object, got: %s' % (type_string))
560
                raise ValueError(msg)
561
562
            raise e
563
564
        return instance
565