Completed
Pull Request — master (#2513)
by Manas
05:38
created

_get_normalized_url()   A

Complexity

Conditions 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 6
rs 9.4285
cc 1
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
try:
17
    import simplejson as json
18
except ImportError:
19
    import json
20
21
import six
22
import pecan
23
import uuid
24
from pecan import abort
25
from pecan.rest import RestController
26
from six.moves.urllib import parse as urlparse
27
urljoin = urlparse.urljoin
28
29
from st2common import log as logging
30
from st2common.constants.triggers import WEBHOOK_TRIGGER_TYPES
31
from st2common.models.api.base import jsexpose
32
from st2common.models.api.trace import TraceContext
33
import st2common.services.triggers as trigger_service
34
from st2common.services.triggerwatcher import TriggerWatcher
35
from st2common.transport.reactor import TriggerDispatcher
36
from st2common.util.http import parse_content_type_header
37
from st2common.rbac.types import PermissionType
38
from st2common.rbac.decorators import request_user_has_webhook_permission
39
40
http_client = six.moves.http_client
41
42
LOG = logging.getLogger(__name__)
43
44
TRACE_TAG_HEADER = 'St2-Trace-Tag'
45
46
47
class WebhooksController(RestController):
48
    def __init__(self, *args, **kwargs):
49
        super(WebhooksController, self).__init__(*args, **kwargs)
50
        self._hooks = {}
51
        self._base_url = '/webhooks/'
52
        self._trigger_types = WEBHOOK_TRIGGER_TYPES.keys()
53
54
        self._trigger_dispatcher = TriggerDispatcher(LOG)
55
        queue_suffix = self.__class__.__name__
56
        self._trigger_watcher = TriggerWatcher(create_handler=self._handle_create_trigger,
57
                                               update_handler=self._handle_update_trigger,
58
                                               delete_handler=self._handle_delete_trigger,
59
                                               trigger_types=self._trigger_types,
60
                                               queue_suffix=queue_suffix,
61
                                               exclusive=True)
62
        self._trigger_watcher.start()
63
        self._register_webhook_trigger_types()
64
65
    @jsexpose()
66
    def get_all(self):
67
        # Return only the hooks known by this controller.
68
        return [trigger for trigger in six.itervalues(self._hooks)]
69
70
    @jsexpose()
71
    def get_one(self, name):
72
        hook = self._hooks.get(name, None)
73
74
        if not hook:
75
            abort(http_client.NOT_FOUND)
76
            return
77
78
        return hook
79
80
    @request_user_has_webhook_permission(permission_type=PermissionType.WEBHOOK_SEND)
81
    @jsexpose(arg_types=[str], status_code=http_client.ACCEPTED)
82
    def post(self, *args, **kwargs):
83
        hook = '/'.join(args)  # TODO: There must be a better way to do this.
84
85
        # Note: For backward compatibility reasons we default to application/json if content
86
        # type is not explicitly provided
87
        content_type = pecan.request.headers.get('Content-Type', 'application/json')
88
        content_type = parse_content_type_header(content_type=content_type)[0]
89
        body = pecan.request.body
90
91
        try:
92
            body = self._parse_request_body(content_type=content_type, body=body)
93
        except Exception as e:
94
            self._log_request('Failed to parse request body: %s.' % (str(e)), pecan.request)
95
            msg = 'Failed to parse request body "%s": %s' % (body, str(e))
96
            return pecan.abort(http_client.BAD_REQUEST, msg)
97
98
        headers = self._get_headers_as_dict(pecan.request.headers)
99
        # If webhook contains a trace-tag use that else create create a unique trace-tag.
100
        trace_context = self._create_trace_context(trace_tag=headers.pop(TRACE_TAG_HEADER, None),
101
                                                   hook=hook)
102
103
        if hook == 'st2' or hook == 'st2/':
104
            return self._handle_st2_webhook(body, trace_context=trace_context)
105
106
        if not self._is_valid_hook(hook):
107
            self._log_request('Invalid hook.', pecan.request)
108
            msg = 'Webhook %s not registered with st2' % hook
109
            return pecan.abort(http_client.NOT_FOUND, msg)
110
111
        trigger = self._get_trigger_for_hook(hook)
112
        payload = {}
113
114
        payload['headers'] = headers
115
        payload['body'] = body
116
        self._trigger_dispatcher.dispatch(trigger, payload=payload, trace_context=trace_context)
117
118
        return body
119
120
    def _parse_request_body(self, content_type, body):
121
        if content_type == 'application/json':
122
            self._log_request('Parsing request body as JSON', request=pecan.request)
123
            body = json.loads(body)
124
        elif content_type in ['application/x-www-form-urlencoded', 'multipart/form-data']:
125
            self._log_request('Parsing request body as form encoded data', request=pecan.request)
126
            body = urlparse.parse_qs(body)
127
        else:
128
            raise ValueError('Unsupported Content-Type: "%s"' % (content_type))
129
130
        return body
131
132
    def _handle_st2_webhook(self, body, trace_context):
133
        trigger = body.get('trigger', None)
134
        payload = body.get('payload', None)
135
        if not trigger:
136
            msg = 'Trigger not specified.'
137
            return pecan.abort(http_client.BAD_REQUEST, msg)
138
        self._trigger_dispatcher.dispatch(trigger, payload=payload, trace_context=trace_context)
139
140
        return body
141
142
    def _is_valid_hook(self, hook):
143
        # TODO: Validate hook payload with payload_schema.
144
        return hook in self._hooks
145
146
    def _get_trigger_for_hook(self, hook):
147
        return self._hooks[hook]
148
149
    def _register_webhook_trigger_types(self):
150
        for trigger_type in WEBHOOK_TRIGGER_TYPES.values():
151
            trigger_service.create_trigger_type_db(trigger_type)
152
153
    def _create_trace_context(self, trace_tag, hook):
154
        # if no trace_tag then create a unique one
155
        if not trace_tag:
156
            trace_tag = 'webhook-%s-%s' % (hook, uuid.uuid4().hex)
157
        return TraceContext(trace_tag=trace_tag)
158
159
    def add_trigger(self, trigger):
160
        # Note: Permission checking for creating and deleting a webhook is done during rule
161
        # creation
162
        url = self._get_normalized_url(trigger)
163
        LOG.info('Listening to endpoint: %s', urljoin(self._base_url, url))
164
        self._hooks[url] = trigger
165
166
    def update_trigger(self, trigger):
167
        pass
168
169
    def remove_trigger(self, trigger):
170
        # Note: Permission checking for creating and deleting a webhook is done during rule
171
        # creation
172
        url = self._get_normalized_url(trigger)
173
174
        if url in self._hooks:
175
            LOG.info('Stop listening to endpoint: %s', urljoin(self._base_url, url))
176
            del self._hooks[url]
177
178
    def _get_normalized_url(self, trigger):
179
        """
180
        remove the trailing and leading / so that the hook url and those coming
181
        from trigger parameters end up being the same.
182
        """
183
        return trigger['parameters']['url'].strip('/')
184
185
    def _get_headers_as_dict(self, headers):
186
        headers_dict = {}
187
        for key, value in headers.items():
188
            headers_dict[key] = value
189
        return headers_dict
190
191
    def _log_request(self, msg, request, log_method=LOG.debug):
192
        headers = self._get_headers_as_dict(request.headers)
193
        body = str(request.body)
194
        log_method('%s\n\trequest.header: %s.\n\trequest.body: %s.', msg, headers, body)
195
196
    ##############################################
197
    # Event handler methods for the trigger events
198
    ##############################################
199
200
    def _handle_create_trigger(self, trigger):
201
        LOG.debug('Calling "add_trigger" method (trigger.type=%s)' % (trigger.type))
202
        trigger = self._sanitize_trigger(trigger=trigger)
203
        self.add_trigger(trigger=trigger)
204
205
    def _handle_update_trigger(self, trigger):
206
        LOG.debug('Calling "update_trigger" method (trigger.type=%s)' % (trigger.type))
207
        trigger = self._sanitize_trigger(trigger=trigger)
208
        self.update_trigger(trigger=trigger)
209
210
    def _handle_delete_trigger(self, trigger):
211
        LOG.debug('Calling "remove_trigger" method (trigger.type=%s)' % (trigger.type))
212
        trigger = self._sanitize_trigger(trigger=trigger)
213
        self.remove_trigger(trigger=trigger)
214
215
    def _sanitize_trigger(self, trigger):
216
        sanitized = trigger._data
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _data was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
217
        if 'id' in sanitized:
218
            # Friendly objectid rather than the MongoEngine representation.
219
            sanitized['id'] = str(sanitized['id'])
220
        return sanitized
221