├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── VERSION ├── docker ├── docker-compose.yml ├── pywebhooks-server.Dockerfile └── pywebhooks-worker.Dockerfile ├── pylintrc ├── pywebhooks ├── __init__.py ├── api │ ├── __init__.py │ ├── decorators │ │ ├── __init__.py │ │ ├── authorization.py │ │ └── validation.py │ ├── handlers │ │ ├── __init__.py │ │ ├── pagination_handler.py │ │ └── resources_handler.py │ └── resources │ │ ├── __init__.py │ │ └── v1 │ │ ├── __init__.py │ │ ├── account │ │ ├── __init__.py │ │ ├── account_api.py │ │ ├── accounts_api.py │ │ └── reset │ │ │ ├── __init__.py │ │ │ ├── api_key_api.py │ │ │ └── secret_key_api.py │ │ └── webhook │ │ ├── __init__.py │ │ ├── registration_api.py │ │ ├── registrations_api.py │ │ ├── subscription.py │ │ ├── subscriptions.py │ │ └── triggered_api.py ├── app.py ├── database │ ├── __init__.py │ └── rethinkdb │ │ ├── __init__.py │ │ ├── bootstrap_admin.py │ │ ├── drop.py │ │ ├── initialize.py │ │ └── interactions.py ├── examples │ ├── __init__.py │ ├── endpoint_development_server.py │ └── ruby_endpoint_developement_server.rb ├── tasks │ ├── __init__.py │ └── webhook_notification.py └── utils │ ├── __init__.py │ ├── common.py │ ├── request_handler.py │ └── rethinkdb_helper.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── tests ├── __init__.py ├── functional │ ├── __init__.py │ └── http_interactions.py └── unit │ ├── __init__.py │ ├── api │ ├── __init__.py │ ├── decorators │ │ ├── __init__.py │ │ ├── test_authorization.py │ │ └── test_validation.py │ ├── handlers │ │ ├── __init__.py │ │ ├── test_pagination_handler.py │ │ └── test_resources_handler.py │ └── resources │ │ ├── __init__.py │ │ └── v1 │ │ ├── __init__.py │ │ └── test_account_api.py │ ├── database │ ├── __init__.py │ └── rethinkdb │ │ ├── __init__.py │ │ ├── test_bootstrap_admin.py │ │ ├── test_drop.py │ │ └── test_initialize.py │ ├── tasks │ ├── __init__.py │ └── test_webhook_notification.py │ ├── test_app.py │ └── utils │ ├── __init__.py │ ├── test_common.py │ ├── test_request_handler.py │ └── test_rethinkdb_helper.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pywebhooks 4 | 5 | [report] 6 | omit = *tests/*,*examples/*,*__init__*,*/database/rethinkdb/interactions.py 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | .DS_Store 56 | 57 | # virtualenv venv 58 | .venv/ 59 | venv/ 60 | ENV/ 61 | 62 | # VSCode 63 | .vscode/ 64 | settings.json 65 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | # command to install dependencies 5 | install: 6 | - pip install -r requirements.txt 7 | - pip install -r test-requirements.txt 8 | - pip install coveralls 9 | # command to run tests 10 | script: nosetests 11 | # run on container-based infrastructure 12 | sudo: false 13 | 14 | script: 15 | - python setup.py install 16 | - "coverage run --source=pywebhooks setup.py test" 17 | after_success: 18 | - coveralls 19 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | * Chad Lung (chadlung) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this file except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | include LICENSE 3 | include README.md 4 | include setup.cfg 5 | include requirements.txt 6 | include test-requirements.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyWebhooks 2 | ========== 3 | 4 | *A simple webhooks service* 5 | 6 | .. image:: https://travis-ci.org/chadlung/pywebhooks.svg?branch=master 7 | :target: https://travis-ci.org/chadlung/pywebhooks 8 | .. image:: https://coveralls.io/repos/chadlung/pywebhooks/badge.svg?branch=master&service=github 9 | :target: https://coveralls.io/github/chadlung/pywebhooks?branch=master 10 | .. image:: https://badge.fury.io/py/pywebhooks.svg 11 | :target: https://badge.fury.io/py/pywebhooks 12 | 13 | **Note:** PyWebhooks is ideally deployed on an internal private cloud/network where you 14 | know and trust the end users and services using it. It should not be considered 15 | secure enough (currently) to be a publicly deployed service. 16 | 17 | Don't like something? Need a feature? Please submit a pull request complete with 18 | tests and an update to the readme if required. 19 | 20 | In order to run PyWebhooks you'll need to have `RethinkDB `__ 21 | and `Redis `__ installed on a server or server(s). RethinkDB is 22 | used to store the account, webooks, etc. data. Redis is used by 23 | `Celery `__ to handle the calls to the 24 | webhook endpoints. 25 | 26 | **Note:** PyWebhooks has been tested on Ubuntu 16.04 and OS X. 27 | PyWebhooks has been tested with Python 3.5.x and 3.6.x. Prior Python 3.x versions have not 28 | been tested and Python 2.x support is not planned. 29 | 30 | Why PyWebhooks? 31 | ^^^^^^^^^^^^^^^ 32 | 33 | I looked all over for a project that did something similar to this. You can find 34 | plenty of code to listen for incoming webhooks as well as some code for sending webhooks. 35 | However, I couldn't find anything that wrapped it into a complete service where you could 36 | run a server to allow for adding new accounts, letting those users create their 37 | own webhooks and then allow others to listen (subscribe) to those webhooks. 38 | 39 | Update - Feb. 9, 2019 40 | ^^^^^^^^^^^^^^^^^^^^^ 41 | 42 | - Vagrant support is dropped. The feedback I've received is only based on Docker support. 43 | - I'm planning to swap out the RethinkDB backend with Postgres/MySQL. 44 | - Also planned is no more static webhook messages - you could have messages sent with custom values. 45 | - Potentially removing Flask and replacing with Falcon. 46 | - More features and updates planned but too early to post them here. Its possible these new features 47 | and changes will just end up in an entirely new repository. 48 | 49 | Quickstart - Docker-Compose 50 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 51 | 52 | Make sure you are running Docker version ``1.10+`` and Docker Compose ``1.6+`` or newer. From a command line run the following from the project's ``docker`` folder: 53 | 54 | :: 55 | 56 | $ cd docker 57 | $ docker-compose up 58 | 59 | If you can don't run that in daemon mode as you can more easily capture the admin ``secret_key`` and ``api_key`` from the console output. 60 | It will look similar to this: 61 | 62 | :: 63 | 64 | pywebhooks-server | Adding admin account 65 | pywebhooks-server | {'secret_key': 'd620fb92a70b7e5c127de74fcd717aa803f7e300', 'api_key': '7e8d21dda1c5738a30882e4520fbbfac55eebe3f'} 66 | 67 | Make sure to record those keys. 68 | 69 | Non-Quickstart 70 | ^^^^^^^^^^^^^^ 71 | 72 | If you did't use the quick start mentioned above: 73 | 74 | Once you have Redis and RethinkDB setup and running you can initialize the database and 75 | admin accounts by running the following: 76 | 77 | :: 78 | 79 | $ python app.py --initdb 80 | 81 | **Response:** 82 | 83 | :: 84 | 85 | Dropping database... 86 | Creating database... 87 | Adding admin account 88 | {'secret_key': 'a6d8ff11a7cdb51130ea184b7228e179f3fd3a4c', 'api_key': 'ba86c64c24f361ddbcfe27be187d8d3002c9f43c'} 89 | Complete 90 | 91 | Make note of the admin ``api_key`` as it will be stored as a hash. 92 | 93 | When you create a new user account there are a few things to consider. First, 94 | you need to have an endpoint setup where the account creation process can verify 95 | against. The endpoint can be whatever you want, a simple example would be a 96 | service listening on: ``http://127.0.0.1:9090/account/endpoint`` 97 | 98 | When you send the command to create the account if all goes well the PyWebhooks 99 | server will hit the endpoint you specified with a challenge you need to echo back. 100 | This helps ensure that you are actually setting up an endpoint that you control. 101 | 102 | The PyWebhooks server will hit the endpoint you specified like this: 103 | ``/account/endpoint?echo=2cac9beaa2f3b3aa72cc86faefb7575ba9c3c4b8`` 104 | 105 | It is your server's job to take that echo value and return it. In Python (using Flask) 106 | this would look like: 107 | 108 | :: 109 | 110 | @app.route('/account/endpoint', methods=['GET']) 111 | def echo(): 112 | return make_response(jsonify({'echo': request.args.get('echo')}), client.OK) 113 | 114 | **Note:** PyWebhooks doesn't require your service be written in Python, any 115 | language will work as long as it returns what is expected (in this case the echo value). 116 | 117 | In Ruby 2.2.x using Sinatra a minimal endpoint server (handles Webhook POST traffic 118 | and GET echo requests) might look like this: 119 | 120 | :: 121 | 122 | require 'rubygems' 123 | require 'openssl' 124 | require 'sinatra' 125 | require 'json' 126 | 127 | 128 | SHARED_SECRET = 'c27e823b0a500a537990dcccfc50334fe814fbd2' 129 | 130 | # Handle echo requests 131 | get '/account/endpoint' do 132 | content_type :json 133 | echo_value = params['echo'] 134 | puts 'echo value:' 135 | puts(echo_value) 136 | 137 | status 200 138 | { :echo => echo_value }.to_json 139 | end 140 | 141 | # Handle the incoming webhook events 142 | post '/account/endpoint' do 143 | request.body.rewind 144 | data = request.body.read 145 | HMAC_DIGEST = OpenSSL::Digest.new('sha1') 146 | signature = OpenSSL::HMAC.hexdigest(HMAC_DIGEST, SHARED_SECRET, data) 147 | incoming_signature = env['HTTP_PYWEBHOOKS_SIGNATURE'] 148 | 149 | puts 'hmac verification results:' 150 | puts Rack::Utils.secure_compare(signature, incoming_signature) 151 | 152 | incoming_event = env['HTTP_EVENT'] 153 | puts 'incoming event is:' 154 | puts incoming_event 155 | puts 'incoming json is:' 156 | puts data 157 | 158 | status 200 159 | '{}' 160 | end 161 | 162 | 163 | **Note:** Pardon my Ruby, I'm rusty with it. 164 | 165 | A full Python endpoint example server code (for testing) can be as simple as: 166 | 167 | :: 168 | 169 | import hashlib 170 | import hmac 171 | from http import client 172 | import json 173 | 174 | from flask import Flask 175 | from flask import request, make_response, jsonify 176 | 177 | 178 | app = Flask(__name__) 179 | 180 | # Adjust this as needed 181 | SECRET_KEY = 'c27e823b0a500a537990dcccfc50334fe814fbd2' 182 | 183 | 184 | def verify_hmac_hash(incoming_json, secret_key, incoming_signature): 185 | signature = hmac.new( 186 | str(secret_key).encode('utf-8'), 187 | str(incoming_json).encode('utf-8'), 188 | digestmod=hashlib.sha1 189 | ).hexdigest() 190 | 191 | return hmac.compare_digest(signature, incoming_signature) 192 | 193 | 194 | def create_response(req): 195 | if request.args.get('echo'): 196 | return make_response(jsonify({'echo': req.args.get('echo')}), client.OK) 197 | if request.args.get('api_key'): 198 | print('New api_key: {0}'.format(req.args.get('api_key'))) 199 | return make_response(jsonify({}), client.OK) 200 | if request.args.get('secret_key'): 201 | print('New secret_key: {0}'.format(req.args.get('secret_key'))) 202 | return make_response(jsonify({}), client.OK) 203 | 204 | 205 | def webhook_listener(request): 206 | print(request.headers) 207 | print(request.data) 208 | print(json.dumps(request.json)) 209 | 210 | is_signature_valid = verify_hmac_hash( 211 | json.dumps(request.json), 212 | SECRET_KEY, 213 | request.headers['pywebhooks-signature'] 214 | ) 215 | 216 | print('Is Signature Valid?: {0}'.format(is_signature_valid)) 217 | 218 | return make_response(jsonify({}), client.OK) 219 | 220 | 221 | @app.route('/account/endpoint', methods=['GET']) 222 | def echo(): 223 | return create_response(request) 224 | 225 | 226 | @app.route('/account/alternate/endpoint', methods=['GET']) 227 | def echo_alternate(): 228 | return create_response(request) 229 | 230 | 231 | @app.route('/account/alternate/endpoint', methods=['POST']) 232 | def account_alternate_listener(): 233 | return webhook_listener(request) 234 | 235 | 236 | @app.route('/account/endpoint', methods=['POST']) 237 | def account_listener(): 238 | return webhook_listener(request) 239 | 240 | 241 | if __name__ == '__main__': 242 | app.run(debug=True, port=9090, host='0.0.0.0') 243 | 244 | 245 | You can save that code off into it's own project if you want just make sure to 246 | install Flask. 247 | 248 | Next, start one or more celery workers from the project root: 249 | 250 | :: 251 | 252 | $ celery -A pywebhooks.tasks.webhook_notification worker --loglevel=info 253 | 254 | Start the main project in development mode: 255 | 256 | :: 257 | 258 | $ python app.py 259 | 260 | With your endpoint service and Celery worker running you can now perform 261 | the following calls. 262 | 263 | Account Actions 264 | ^^^^^^^^^^^^^^^ 265 | 266 | **Creating an account:** 267 | 268 | The examples below use human readable user names. The reality is you should use 269 | a complex username to avoid any potential possibility of someone abusing the 270 | ``api_key`` reset as you only need a ``username`` to trigger a reset which could 271 | allow for a denial of service on your endpoint. A complex username not shared 272 | such as ``cRee82jfkjf09ij23`` is better than ``johndoe``. One potential fix 273 | I will look at is limiting how many ``api_key`` resets can be done in a given 274 | period (rate limiting). Also, the term "username" applies to the endpoint possibly 275 | being a service which is most likely the case so your username may actually be 276 | something like "myservice-listener-001" (as an example). 277 | 278 | If ``127.0.0.1`` is not working below try ``localhost`` or lookup the IP Docker is using. 279 | Make sure to set that IP address in the ``endpoint`` below. 280 | 281 | **Note:** Make sure you are running an endpoint since creating an account will verfiy 282 | the endpoint. You can use the example code above. 283 | 284 | :: 285 | 286 | curl -v -X POST "http://127.0.0.1:8081/v1/account" -d '{"endpoint": "http://127.0.0.1:9090/account/endpoint", "username": "sarahfranks"}' -H "content-type: application/json" 287 | 288 | **Response:** 289 | 290 | **HTTP/1.0 201 CREATED** 291 | 292 | :: 293 | 294 | { 295 | "api_key": "be23d9ccb29082c489ba629077553ba1d8314005", 296 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 297 | "epoch": 1441164550.515677, 298 | "id": "45712a61-a1b3-41a4-aa89-9593b909ae3d", 299 | "is_admin": false, 300 | "failed_count": 0, 301 | "secret_key": "5a4a1cf4895441a1dfaa504c471510be819198e7", 302 | "username": "sarahfranks" 303 | } 304 | 305 | Make note of the ``id``, ``secret_key`` and ``api_key`` (because the ``api_key`` will be 306 | stored hashed). 307 | 308 | The ``secret_key`` will be used to validate the data coming into your endpoint 309 | is indeed from the PyWebhooks server and not something/someone else. If you are 310 | following along on a local dev machine make sure to stop your example endpoint server now 311 | and paste in the new ``secret_key`` value before running the next API call below. Now you 312 | can re-start the example endpoint server. 313 | 314 | The ``api_key`` will be used for any communication with the PyWebhooks server that 315 | isn't a publicly accessible call. 316 | 317 | The ``id`` will be the account id. 318 | 319 | The ``failed_count`` field tracks how many times an attempt (webhook POST) has 320 | failed to contact the specified endpoint. ``MAX_FAILED_COUNT`` is a config value 321 | that can be set (default is 250). If the ``failed_count`` exceeds the 322 | ``MAX_FAILED_COUNT`` value then no more webhook posts will occur for the user 323 | until this is reset. A successful endpoint contact will automatically reset 324 | this value to 0 if ``MAX_FAILED_COUNT`` has not been exceeded. This helps 325 | prevent an endpoint that is no longer responsive or moved (and not updated) 326 | from continuing to utilize system resources. In addition, updating the endpoint 327 | for a account will also reset the ``failed_count``. 328 | 329 | Retries on webhook endpoints are done three times before giving up. The 330 | ``DEFAULT_RETRY`` config value (defaults to 2 minutes) and ``DEFAULT_FINAL_RETRY`` 331 | config value (defaults to 1 hour) can be adjusted for the three retries. Each 332 | failed attempt to contact the endpoint results in an increment in the ``failed_count`` 333 | field of the user's account. If an endpoint is unreachable through the initial 334 | attempt to contact and the three retires then the ``failed_count`` value will 335 | be four. 336 | 337 | **Get a single account record:** 338 | 339 | You can only look-up your own account record. 340 | 341 | :: 342 | 343 | curl -v -X GET "http://127.0.0.1:8081/v1/account/45712a61-a1b3-41a4-aa89-9593b909ae3d" -H "content-type: application/json" -H "api-key: be23d9ccb29082c489ba629077553ba1d8314005" -H "username: sarahfranks" 344 | 345 | **Response:** 346 | 347 | **HTTP/1.0 200 OK** 348 | 349 | :: 350 | 351 | { 352 | "api_key": "pbkdf2:sha1:1000$vTuQRKeb$eec0bdffebde0d3c28290d41f4d848fbde04571c", 353 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 354 | "epoch": 1441164550.515677, 355 | "id": "45712a61-a1b3-41a4-aa89-9593b909ae3d", 356 | "is_admin": false, 357 | "failed_count": 0, 358 | "secret_key": "5a4a1cf4895441a1dfaa504c471510be819198e7", 359 | "username": "sarahfranks" 360 | } 361 | 362 | **Get all account records (admin only):** 363 | 364 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 365 | 366 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 367 | 368 | **REQUIRED** ``limit`` is how many records to return 369 | 370 | In the example below I started at record #0 and asked for up to 10 records to return. 371 | You may also notice that a ``next_start`` field will show up in the JSON so you 372 | know where to set your next start (assuming you want to keep paging the records) 373 | 374 | :: 375 | 376 | curl -v -X GET "http://127.0.0.1:8081/v1/accounts?start=0&limit=10" -H "content-type: application/json" -H "api-key: ba86c64c24f361ddbcfe27be187d8d3002c9f43c" -H "username: admin" 377 | 378 | **Response:** 379 | 380 | **HTTP/1.0 200 OK** 381 | 382 | :: 383 | 384 | { 385 | "accounts": [ 386 | { 387 | "api_key": "pbkdf2:sha1:1000$rQDzv29j$5895b2393171d0cc238157c130fc2129d3e871c3", 388 | "endpoint": "", 389 | "epoch": 1441164269.341982, 390 | "id": "ed408f85-200e-481f-a672-30f454e8dcf4", 391 | "is_admin": true, 392 | "secret_key": "ab502753cbb68b90601cace345fe84fb2bb5b8dd", 393 | "username": "admin" 394 | }, 395 | { 396 | "api_key": "pbkdf2:sha1:1000$I5r0MTsM$fc50fcce05c526fa19919d874087623571c0c9e0", 397 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 398 | "epoch": 1441164337.607172, 399 | "id": "d969a56d-e520-405d-a24f-497ac6923781", 400 | "is_admin": false, 401 | "failed_count": 0, 402 | "secret_key": "2381a87ba4725786f29ca414d3217e202615f757", 403 | "username": "johndoe" 404 | }, 405 | { 406 | "api_key": "pbkdf2:sha1:1000$an7K8KqL$127bb4796de21a832969512fc7c2edea0524e54b", 407 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 408 | "epoch": 1441164337.630147, 409 | "id": "556daec0-fcad-4cae-8d4b-7564d2424669", 410 | "is_admin": false, 411 | "failed_count": 0, 412 | "secret_key": "25b83d9a713e16f1b4fe936787acdf532162ea73", 413 | "username": "janedoe" 414 | }, 415 | { 416 | "api_key": "pbkdf2:sha1:1000$nbvEItNd$9d0ab21a122bca95855f6ba0ab271444168e17f4", 417 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 418 | "epoch": 1441164337.65272, 419 | "id": "776236bc-5ca9-4083-bb20-b12043ec87de", 420 | "is_admin": false, 421 | "failed_count": 0, 422 | "secret_key": "d615166b1818ef41b925c40b5483474522bffc94", 423 | "username": "samjones" 424 | }, 425 | { 426 | "api_key": "pbkdf2:sha1:1000$vTuQRKeb$eec0bdffebde0d3c28290d41f4d848fbde04571c", 427 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 428 | "epoch": 1441164550.515677, 429 | "id": "45712a61-a1b3-41a4-aa89-9593b909ae3d", 430 | "is_admin": false, 431 | "failed_count": 0, 432 | "secret_key": "5a4a1cf4895441a1dfaa504c471510be819198e7", 433 | "username": "sarahfranks" 434 | } 435 | ] 436 | } 437 | 438 | Example output with ``next_start``: 439 | 440 | :: 441 | 442 | curl -v -X GET "http://127.0.0.1:8081/v1/accounts?start=0&limit=3" -H "content-type: application/json" -H "api-key: 5b3a973f4980f65d5b61101ddf3b40808933f12a" -H "username: admin" 443 | 444 | :: 445 | 446 | { 447 | "accounts": [ 448 | { 449 | "api_key": "pbkdf2:sha1:1000$rQDzv29j$5895b2393171d0cc238157c130fc2129d3e871c3", 450 | "endpoint": "", 451 | "epoch": 1441164269.341982, 452 | "id": "ed408f85-200e-481f-a672-30f454e8dcf4", 453 | "is_admin": true, 454 | "secret_key": "ab502753cbb68b90601cace345fe84fb2bb5b8dd", 455 | "username": "admin" 456 | }, 457 | { 458 | "api_key": "pbkdf2:sha1:1000$I5r0MTsM$fc50fcce05c526fa19919d874087623571c0c9e0", 459 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 460 | "epoch": 1441164337.607172, 461 | "id": "d969a56d-e520-405d-a24f-497ac6923781", 462 | "is_admin": false, 463 | "failed_count": 0, 464 | "secret_key": "2381a87ba4725786f29ca414d3217e202615f757", 465 | "username": "johndoe" 466 | }, 467 | { 468 | "api_key": "pbkdf2:sha1:1000$an7K8KqL$127bb4796de21a832969512fc7c2edea0524e54b", 469 | "endpoint": "http://127.0.0.1:9090/account/endpoint", 470 | "epoch": 1441164337.630147, 471 | "id": "556daec0-fcad-4cae-8d4b-7564d2424669", 472 | "is_admin": false, 473 | "failed_count": 0, 474 | "secret_key": "25b83d9a713e16f1b4fe936787acdf532162ea73", 475 | "username": "janedoe" 476 | } 477 | ], 478 | "next_start": 3 479 | } 480 | 481 | **Update the endpoint field for a username specified account:** 482 | 483 | The only field that can be updated on an account is the ``endpoint`` and when you 484 | do so PyWebhooks will contact that endpoint with the echo challenge as mentioned above 485 | in the section on creating a new account. 486 | 487 | **Note:** The ``api_key`` and ``secret_key`` can both be reset, those calls are 488 | further down this document. 489 | 490 | For this call you need to supply your username and ``api_key`` in the headers. 491 | 492 | :: 493 | 494 | curl -v -X PATCH "http://127.0.0.1:8081/v1/account" -d '{"endpoint": "http://127.0.0.1:9090/account/alternate/endpoint"}' -H "content-type: application/json" -H "api-key: d615166b1818ef41b925c40b5483474522bffc94" -H "username: samjones" 495 | 496 | **Response:** 497 | 498 | **HTTP/1.0 200 OK** 499 | 500 | :: 501 | 502 | { 503 | "deleted": 0, 504 | "errors": 0, 505 | "inserted": 0, 506 | "replaced": 1, 507 | "skipped": 0, 508 | "unchanged": 0 509 | } 510 | 511 | **Delete a single account record:** 512 | 513 | User's can only delete their account record. 514 | 515 | :: 516 | 517 | curl -v -X DELETE "http://127.0.0.1:8081/v1/account/776236bc-5ca9-4083-bb20-b12043ec87de" -H "content-type: application/json" -H "api-key: d615166b1818ef41b925c40b5483474522bffc94" -H "username: samjones" 518 | 519 | **Response:** 520 | 521 | **HTTP/1.0 200 OK** 522 | 523 | :: 524 | 525 | { 526 | "deleted": 1, 527 | "errors": 0, 528 | "inserted": 0, 529 | "replaced": 0, 530 | "skipped": 0, 531 | "unchanged": 0 532 | } 533 | 534 | **Delete all account records (admin only):** 535 | 536 | **Careful:** This deletes all account records (except admin). The ``deleted`` 537 | field in the response will contain how many records were deleted. 538 | 539 | :: 540 | 541 | curl -v -X DELETE "http://127.0.0.1:8081/v1/accounts" -H "content-type: application/json" -H "api-key: f2fe92411648dab36532d4256a5d36be0b219d53" -H "username: admin" 542 | 543 | **Response:** 544 | 545 | **HTTP/1.0 200 OK** 546 | 547 | :: 548 | 549 | { 550 | "deleted": 4, 551 | "errors": 0, 552 | "inserted": 0, 553 | "replaced": 0, 554 | "skipped": 0, 555 | "unchanged": 0 556 | } 557 | 558 | **Reset an account API key:** 559 | 560 | Ensure your service endpoint is running as the PyWebhooks server will perform a 561 | ``GET`` against your endpoint with the new ``api_key`` in the querystring as: 562 | 563 | :: 564 | 565 | GET /account/alternate/endpoint?api_key=768a8c2530956c0f2ac52faee785cadf3f5bc68d 566 | 567 | **Note:** A ``GET`` is used on the endpoint like the echo challenge since ``POST`` is 568 | used by incoming webhooks. 569 | 570 | :: 571 | 572 | curl -v -X POST "http://127.0.0.1:8081/v1/account/reset/apikey" -H "content-type: application/json" -H "username: sarahfranks" 573 | 574 | **Response:** 575 | 576 | **HTTP/1.0 200 OK** 577 | 578 | :: 579 | 580 | { 581 | "Message": "New key sent to endpoint" 582 | } 583 | 584 | **Reset an account secret key:** 585 | 586 | Ensure your service endpoint is running as the PyWebhooks server will perform a 587 | ``GET`` against your endpoint with the new ``secret_key`` in the querystring as: 588 | 589 | :: 590 | 591 | GET /account/alternate/endpoint?secret_key=0d7929e61c97e10a70dd71cb839853bcd4f9e230 592 | 593 | **Note:** A ``GET`` is used on the endpoint like the echo challenge since ``POST`` is 594 | used by incoming webhooks. 595 | 596 | :: 597 | 598 | curl -v -X POST "http://127.0.0.1:8081/v1/account/reset/secretkey" -H "content-type: application/json" -H "username: johndoe" -H "api-key: 9241a57a6b4d785d7acb0fe9d99f7983f4d7584b" 599 | 600 | **Response:** 601 | 602 | **HTTP/1.0 200 OK** 603 | 604 | :: 605 | 606 | { 607 | "Message": "New key sent to endpoint" 608 | } 609 | 610 | Webhook Actions 611 | ^^^^^^^^^^^^^^^ 612 | 613 | The real essence of PyWebhooks is ultimately registering a webhook with the system 614 | and then having users/services subscribe to those webhooks and posting the data 615 | to your endpoint. 616 | 617 | **Creating a new webhook registration:** 618 | 619 | In this example we will register the following webhook from the ``johndoe`` 620 | account. 621 | 622 | :: 623 | 624 | { 625 | "items": [ 626 | { 627 | "item1": 1 628 | }, 629 | { 630 | "item2": 2 631 | } 632 | ], 633 | "message": "hello world" 634 | } 635 | 636 | There are a few things you need to include in the JSON payload. 637 | 638 | ``description`` is a user comsumable description of what your webhook is about 639 | ``event_data`` is the actual JSON payload that will be delivered to each 640 | subscribed user/service of this webhook when you trigger it 641 | ``event`` is a header field that is a short description of what kind of event 642 | this is 643 | 644 | The full payload would be something like this: 645 | 646 | :: 647 | 648 | { 649 | "description": "This is my registered webhook", 650 | "event_data": { 651 | "items": [ 652 | { 653 | "item1": 1 654 | }, 655 | { 656 | "item2": 2 657 | } 658 | ], 659 | "message": "hello world" 660 | }, 661 | "event": "mywebhook.event" 662 | } 663 | 664 | Create the webhook: 665 | 666 | :: 667 | 668 | curl -v -X POST "http://127.0.0.1:8081/v1/webhook/registration" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" -d '{"description": "This is my registered webhook", "event_data": {"items": [{"item1": 1}, {"item2": 2}], "message": "hello world"}, "event": "mywebhook.event"}' 669 | 670 | **Response:** 671 | 672 | **HTTP/1.0 201 CREATED** 673 | 674 | :: 675 | 676 | { 677 | "account_id": "d969a56d-e520-405d-a24f-497ac6923781", 678 | "description": "This is my registered webhook", 679 | "epoch": 1441166640.359496, 680 | "event": "mywebhook.event", 681 | "event_data": { 682 | "items": [ 683 | { 684 | "item1": 1 685 | }, 686 | { 687 | "item2": 2 688 | } 689 | ], 690 | "message": "hello world" 691 | }, 692 | "id": "3e25a22e-6a83-4cf0-a2bf-d7617aa32551" 693 | } 694 | 695 | **Delete a webhook registration:** 696 | 697 | Deletes registration record, will also remove the records for this registration 698 | id in the subscription table as well. 699 | 700 | :: 701 | 702 | curl -v -X DELETE "http://127.0.0.1:8081/v1/webhook/registration/0c296ca8-69ce-4274-b377-3010072363f9" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 703 | 704 | **Response:** 705 | 706 | **HTTP/1.0 200 OK** 707 | 708 | :: 709 | 710 | { 711 | "deleted": 1, 712 | "errors": 0, 713 | "inserted": 0, 714 | "replaced": 0, 715 | "skipped": 0, 716 | "unchanged": 0 717 | } 718 | 719 | **Get all your registered webhook records:** 720 | 721 | Lists all the calling username's registered webhooks. 722 | 723 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 724 | 725 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 726 | 727 | **REQUIRED** ``limit`` is how many records to return 728 | 729 | :: 730 | 731 | curl -v -X GET "http://127.0.0.1:8081/v1/webhook/registration?start=0&limit=10" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 732 | 733 | **Response:** 734 | 735 | **HTTP/1.0 200 OK** 736 | 737 | :: 738 | 739 | { 740 | "next_start": 1, 741 | "registrations": [ 742 | { 743 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 744 | "description": "This is my registered webhook", 745 | "epoch": 1441139002.671599, 746 | "event": "mywebhook.event", 747 | "event_data": { 748 | "items": [ 749 | { 750 | "item1": 1 751 | }, 752 | { 753 | "item2": 2 754 | } 755 | ], 756 | "message": "hello world" 757 | }, 758 | "id": "4618dc47-aaf9-401e-9aa4-8fda5d59eb25" 759 | } 760 | ] 761 | } 762 | 763 | **Get all registered webhook records:** 764 | 765 | Lists all registered webhooks. 766 | 767 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 768 | 769 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 770 | 771 | **REQUIRED** ``limit`` is how many records to return 772 | 773 | :: 774 | 775 | curl -v -X GET "http://127.0.0.1:8081/v1/webhook/registrations?start=0&limit=2" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 776 | 777 | **Response:** 778 | 779 | **HTTP/1.0 200 OK** 780 | 781 | :: 782 | 783 | { 784 | "next_start": 2, 785 | "registrations": [ 786 | { 787 | "account_id": "a6903d9f-de93-4910-8d8c-06e22f434d05", 788 | "description": "Some description goes here", 789 | "epoch": 1441138315.006409, 790 | "event": "webhook.event.hello", 791 | "event_data": { 792 | "msg": "hello world" 793 | }, 794 | "id": "ae8dc785-d4bf-4614-98a7-32dcf03314e8" 795 | }, 796 | { 797 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 798 | "description": "This is my registered webhook", 799 | "epoch": 1441139002.671599, 800 | "event": "mywebhook.event", 801 | "event_data": { 802 | "items": [ 803 | { 804 | "item1": 1 805 | }, 806 | { 807 | "item2": 2 808 | } 809 | ], 810 | "message": "hello world" 811 | }, 812 | "id": "4618dc47-aaf9-401e-9aa4-8fda5d59eb25" 813 | } 814 | ] 815 | } 816 | 817 | **Delete all webhook registration records (admin only):** 818 | 819 | **Careful:** This deletes all registration records. The ``deleted`` 820 | field in the response will contain how many records were deleted. 821 | 822 | :: 823 | 824 | curl -v -X DELETE "http://127.0.0.1:8081/v1/webhook/registrations" -H "content-type: application/json" -H "api-key: ba86c64c24f361ddbcfe27be187d8d3002c9f43c" -H "username: admin" 825 | 826 | **Response:** 827 | 828 | **HTTP/1.0 200 OK** 829 | 830 | :: 831 | 832 | { 833 | "deleted": 2, 834 | "errors": 0, 835 | "inserted": 0, 836 | "replaced": 0, 837 | "skipped": 0, 838 | "unchanged": 0 839 | } 840 | 841 | **Update a webhook registration record:** 842 | 843 | Only the ``description`` field can be updated on an registration. 844 | 845 | Make sure to supply the webhook registration id as per the example. 846 | 847 | :: 848 | 849 | curl -v -X PATCH "http://127.0.0.1:8081/v1/webhook/registration/4618dc47-aaf9-401e-9aa4-8fda5d59eb25" -d '{"description": "New Description"}' -H "content-type: application/json" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" -H "username: johndoe" 850 | 851 | **Response:** 852 | 853 | **HTTP/1.0 200 OK** 854 | 855 | :: 856 | 857 | { 858 | "deleted": 0, 859 | "errors": 0, 860 | "inserted": 0, 861 | "replaced": 1, 862 | "skipped": 0, 863 | "unchanged": 0 864 | } 865 | 866 | Subscription Actions 867 | ^^^^^^^^^^^^^^^^^^^^ 868 | 869 | **Creating a subscription:** 870 | 871 | Create a subscription for a registered webhook that you want to receive 872 | notifications from when they are triggered. 873 | 874 | :: 875 | 876 | curl -v -X POST "http://127.0.0.1:8081/v1/webhook/subscription/ae8dc785-d4bf-4614-98a7-32dcf03314e8" -H "content-type: application/json" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" -H "username: johndoe" 877 | 878 | 879 | **Response:** 880 | 881 | **HTTP/1.0 201 CREATED** 882 | 883 | :: 884 | 885 | { 886 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 887 | "epoch": 1441145067.959285, 888 | "id": "cf20c039-6355-40b9-a601-cad4e79dbe52", 889 | "registration_id": "ae8dc785-d4bf-4614-98a7-32dcf03314e8" 890 | } 891 | 892 | **Get all your subscription records:** 893 | 894 | Lists all the calling username's subscription records. 895 | 896 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 897 | 898 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 899 | 900 | **REQUIRED** ``limit`` is how many records to return 901 | 902 | :: 903 | 904 | curl -v -X GET "http://127.0.0.1:8081/v1/webhook/subscription?start=0&limit=5" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 905 | 906 | **Response:** 907 | 908 | **HTTP/1.0 200 OK** 909 | 910 | :: 911 | 912 | { 913 | "subscriptions": [ 914 | { 915 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 916 | "epoch": 1441144968.505692, 917 | "id": "9e596765-da94-46d2-9f9d-a4d7ecc374ab", 918 | "registration_id": "ae8dc785-d4bf-4614-98a7-32dcf03314e8" 919 | }, 920 | { 921 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 922 | "epoch": 1441145067.959285, 923 | "id": "cf20c039-6355-40b9-a601-cad4e79dbe52", 924 | "registration_id": "ac18dc47-abf9-401e-8bb3-8fda5d51af48" 925 | } 926 | ] 927 | } 928 | 929 | **Get all subscription records:** 930 | 931 | Lists all subscriptions. 932 | 933 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 934 | 935 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 936 | 937 | **REQUIRED** ``limit`` is how many records to return 938 | 939 | :: 940 | 941 | curl -v -X GET "http://127.0.0.1:8081/v1/webhook/subscriptions?start=0&limit=2" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 942 | 943 | **Response:** 944 | 945 | **HTTP/1.0 200 OK** 946 | 947 | :: 948 | 949 | { 950 | "next_start": 2, 951 | "subscriptions": [ 952 | { 953 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 954 | "epoch": 1441144968.505692, 955 | "id": "9e596765-da94-46d2-9f9d-a4d7ecc374ab", 956 | "registration_id": "ae8dc785-d4bf-4614-98a7-32dcf03314e8" 957 | }, 958 | { 959 | "account_id": "fb8854ba-b7f7-4552-bc13-4d5cdbb444dd", 960 | "epoch": 1441145067.959285, 961 | "id": "cf20c039-6355-40b9-a601-cad4e79dbe52", 962 | "registration_id": "ae8dc785-d4bf-4614-98a7-32dcf03314e8" 963 | } 964 | ] 965 | } 966 | 967 | **Delete a single subscription record:** 968 | 969 | Deletes subscription record. 970 | 971 | :: 972 | 973 | curl -v -X DELETE "http://127.0.0.1:8081/v1/webhook/subscription/bfbafaa0-5816-456d-9639-98023ec5dc2e" -H "content-type: application/json" -H "username: johndoe" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" 974 | 975 | **Response:** 976 | 977 | **HTTP/1.0 200 OK** 978 | 979 | :: 980 | 981 | { 982 | "deleted": 1, 983 | "errors": 0, 984 | "inserted": 0, 985 | "replaced": 0, 986 | "skipped": 0, 987 | "unchanged": 0 988 | } 989 | 990 | **Delete all subscription records (admin only):** 991 | 992 | **Careful:** This deletes all subscription records. The ``deleted`` 993 | field in the response will contain how many records were deleted. 994 | 995 | :: 996 | 997 | curl -v -X DELETE "http://127.0.0.1:8081/v1/webhook/subscriptions" -H "content-type: application/json" -H "api-key: ba86c64c24f361ddbcfe27be187d8d3002c9f43c" -H "username: admin" 998 | 999 | **Response:** 1000 | 1001 | **HTTP/1.0 200 OK** 1002 | 1003 | :: 1004 | 1005 | { 1006 | "deleted": 4, 1007 | "errors": 0, 1008 | "inserted": 0, 1009 | "replaced": 0, 1010 | "skipped": 0, 1011 | "unchanged": 0 1012 | } 1013 | 1014 | Triggered Actions 1015 | ^^^^^^^^^^^^^^^^^ 1016 | 1017 | There are two actions that can be done: 1018 | 1019 | 1. Trigger a webhook 1020 | 1021 | 2. List all the triggered webhooks 1022 | 1023 | **Trigger a webhook:** 1024 | 1025 | Use a registration id to trigger the webhook (inserts a triggered record). 1026 | 1027 | :: 1028 | 1029 | curl -v -X POST "http://127.0.0.1:8081/v1/webhook/triggered/bfbafaa0-5816-456d-9639-98023ec5dc2e" -H "content-type: application/json" -H "api-key: ee98cb7b5da901c12bac7c263b28f7a028a5de97" -H "username: johndoe" 1030 | 1031 | **Response:** 1032 | 1033 | **HTTP/1.0 201 CREATED** 1034 | 1035 | :: 1036 | 1037 | { 1038 | "epoch": 1441334032.467688, 1039 | "id": "7c9cfb5c-dd9b-47cc-8579-32e06337e0f9", 1040 | "registration_id": "bfbafaa0-5816-456d-9639-98023ec5dc2e" 1041 | } 1042 | 1043 | **Get all triggered webhooks:** 1044 | 1045 | Lists all triggered records. 1046 | 1047 | This is a paginated call with ``start`` and ``limit`` params in the querystring. 1048 | 1049 | **REQUIRED** ``start`` is where in the records you want to start listing (0..n) 1050 | 1051 | **REQUIRED** ``limit`` is how many records to return 1052 | 1053 | :: 1054 | 1055 | { 1056 | "triggered_webhooks": [ 1057 | { 1058 | "epoch": 1441333750.649395, 1059 | "id": "fc20ee3f-2278-4d14-1058-afab5b2c1b34", 1060 | "registration_id": "bfbafaa0-5816-456d-9639-98023ec5dc2e" 1061 | }, 1062 | { 1063 | "epoch": 1441333775.45855, 1064 | "id": "abf196cf-e3cd-47d5-9458-ecc22e5e1ae3", 1065 | "registration_id": "3279b8af-3a90-4cf1-afb8-12872849b2ac" 1066 | }, 1067 | { 1068 | "epoch": 1441333841.789931, 1069 | "id": "77c674fc-1907-499e-8e52-3faa57804977", 1070 | "registration_id": "3279b8af-3a90-4cf1-afb8-12872849b2ac" 1071 | }, 1072 | { 1073 | "epoch": 1441334032.467688, 1074 | "id": "7c9cfb5c-dd9b-47cc-8579-32e06337e0f9", 1075 | "registration_id": "3279b8af-3a90-4cf1-afb8-12872849b2ac" 1076 | } 1077 | ] 1078 | } 1079 | 1080 | **Response:** 1081 | 1082 | **HTTP/1.0 200 OK** 1083 | 1084 | License 1085 | ^^^^^^^ 1086 | 1087 | Licensed under the Apache License, Version 2.0 (the "License"); 1088 | you may not use this file except in compliance with the License. 1089 | You may obtain a copy of the License at 1090 | 1091 | http://www.apache.org/licenses/LICENSE-2.0 1092 | 1093 | Unless required by applicable law or agreed to in writing, software 1094 | distributed under the License is distributed on an "AS IS" BASIS, 1095 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 1096 | See the License for the specific language governing permissions and 1097 | limitations under the License. 1098 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.5.5 -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | rethinkdb: 4 | image: rethinkdb:latest 5 | 6 | redis: 7 | image: redis:latest 8 | 9 | pywebhooks-worker: 10 | container_name: pywebhooks-worker 11 | build: 12 | context: ../ 13 | dockerfile: docker/pywebhooks-worker.Dockerfile 14 | command: "celery -A pywebhooks.tasks.webhook_notification worker --loglevel=info" 15 | links: 16 | - rethinkdb 17 | - redis 18 | depends_on: 19 | - rethinkdb 20 | - redis 21 | 22 | pywebhooks-server: 23 | container_name: pywebhooks-server 24 | build: 25 | context: ../ 26 | dockerfile: docker/pywebhooks-server.Dockerfile 27 | command: "bash -c 'pywebhooks --initdb && pywebhooks'" 28 | # If you don't want to wipe out the database each time do this instead: 29 | # command: "pywebhooks'" 30 | ports: 31 | - "8081:8081" 32 | links: 33 | - rethinkdb 34 | - redis 35 | depends_on: 36 | - rethinkdb 37 | - redis 38 | -------------------------------------------------------------------------------- /docker/pywebhooks-server.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | COPY . /opt/pywebhooks 4 | WORKDIR /opt/pywebhooks 5 | 6 | RUN pip install -U pip 7 | RUN pip install -r requirements.txt 8 | RUN pip install -e . 9 | 10 | EXPOSE 8081 11 | -------------------------------------------------------------------------------- /docker/pywebhooks-worker.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-slim 2 | 3 | RUN groupadd user && useradd --create-home --home-dir /home/user -g user user 4 | WORKDIR /home/user 5 | 6 | COPY . /home/user/pywebhooks-worker 7 | WORKDIR /home/user/pywebhooks-worker 8 | 9 | RUN pip install -U pip 10 | RUN pip install -r requirements.txt 11 | RUN pip install -e . 12 | 13 | USER user 14 | CMD ["celery", "worker"] 15 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | [REPORTS] 4 | # set the output format. Available formats are text, parseable, colorized and 5 | # html 6 | output-format=colorized 7 | # Include message's id in output 8 | include-ids=yes 9 | # Put messages in a separate file for each module / package specified on the 10 | # command line instead of printing them on stdout. Reports (if any) will be 11 | # written in a file name "pylint_global.[txt|html]". 12 | files-output=no 13 | # Tells whether to display a full report or only the messages 14 | reports=yes 15 | [DESIGN] 16 | # Maximum number of arguments for function / method 17 | max-args=9 18 | # Maximum number of attributes for a class (see R0902). 19 | max-attributes=10 20 | -------------------------------------------------------------------------------- /pywebhooks/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_DB_NAME = 'pywebhooks' 2 | DEFAULT_ACCOUNTS_TABLE = 'accounts' 3 | DEFAULT_REGISTRATIONS_TABLE = 'registrations' 4 | DEFAULT_TRIGGERED_TABLE = 'triggered_webhooks' 5 | DEFAULT_SUBSCRIPTIONS_TABLE = 'subscriptions' 6 | 7 | DEFAULT_TABLE_NAMES = [ 8 | DEFAULT_ACCOUNTS_TABLE, 9 | DEFAULT_REGISTRATIONS_TABLE, 10 | DEFAULT_TRIGGERED_TABLE, 11 | DEFAULT_SUBSCRIPTIONS_TABLE 12 | ] 13 | 14 | # This is the timeout for the response time from the client's endpoint. This is 15 | # used when validating a new account or they attempt to change a secret or 16 | # api key and in sending out webhook events. This should be a low value and end 17 | # users should be aware of this time (in seconds) in which to respond. 18 | REQUEST_TIMEOUT = 5.0 19 | 20 | # Retry a failed webhook notification to an endpoint in 2 minutes 21 | DEFAULT_RETRY = 120 22 | DEFAULT_FINAL_RETRY = 3600 # On the final retry, try again in an hour 23 | 24 | # How many times a webhook post can fail to contact the endpoint before 25 | # its ignored 26 | MAX_FAILED_COUNT = 250 27 | 28 | RETHINK_HOST = 'rethinkdb' 29 | CELERY_BROKER_URL = 'redis://redis:6379/0' 30 | 31 | RETHINK_PORT = 28015 32 | RETHINK_AUTH_KEY = '' 33 | -------------------------------------------------------------------------------- /pywebhooks/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/decorators/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/decorators/authorization.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | from functools import wraps 4 | 5 | # Third-party imports 6 | from flask import request, jsonify, make_response 7 | from werkzeug.security import check_password_hash 8 | 9 | # Project-level imports 10 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 11 | from pywebhooks.database.rethinkdb.interactions import Interactions 12 | 13 | 14 | def api_key_restricted_resource(verify_admin=False): 15 | """ 16 | Validate the API Key and Username in the header 17 | Note: This is very basic authorization for a proof of concept 18 | """ 19 | 20 | def decorated(f): 21 | @wraps(f) 22 | def wrapper(*args, **kwargs): 23 | try: 24 | api_key = request.headers['api-key'] 25 | except KeyError: 26 | return make_response( 27 | jsonify( 28 | {'Error': 'Missing API key header value'} 29 | ), client.UNAUTHORIZED 30 | ) 31 | 32 | try: 33 | username = request.headers['username'] 34 | except KeyError: 35 | return make_response( 36 | jsonify( 37 | {'Error': 'Missing username header value'} 38 | ), client.UNAUTHORIZED 39 | ) 40 | 41 | record = Interactions.query(DEFAULT_ACCOUNTS_TABLE, 42 | filters={'username': username}) 43 | 44 | if not record: 45 | return make_response( 46 | jsonify({'Error': 'Invalid API key or Username'}), 47 | client.UNAUTHORIZED 48 | ) 49 | 50 | if not check_password_hash(record[0]['api_key'], api_key): 51 | return make_response( 52 | jsonify({'Error': 'Invalid API key'}), client.UNAUTHORIZED) 53 | 54 | if verify_admin: 55 | if not record[0]['is_admin']: 56 | return make_response( 57 | jsonify({'Error': 'Not an Admin'}), 58 | client.UNAUTHORIZED 59 | ) 60 | 61 | return f(*args, **kwargs) 62 | 63 | return wrapper 64 | return decorated 65 | -------------------------------------------------------------------------------- /pywebhooks/api/decorators/validation.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | from functools import wraps 4 | 5 | # Third-party imports 6 | from flask import request, jsonify, make_response 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 10 | from pywebhooks.database.rethinkdb.interactions import Interactions 11 | 12 | 13 | def validate_pagination_params(): 14 | """ 15 | Validate the API Key and Username in the header 16 | Note: This is very basic authorization for a proof of concept 17 | """ 18 | 19 | def decorated(f): 20 | @wraps(f) 21 | def wrapper(*args, **kwargs): 22 | try: 23 | limit = int(request.args.get('limit')) 24 | start = int(request.args.get('start')) 25 | 26 | if start > 999999999999999 or start < 0: 27 | raise ValueError() 28 | if limit > 100 or limit <= 0: 29 | raise ValueError() 30 | except (ValueError, TypeError): 31 | return make_response( 32 | jsonify({'Error': 'Invalid limit or start parameter'}), 33 | client.BAD_REQUEST 34 | ) 35 | 36 | return f(*args, **kwargs) 37 | 38 | return wrapper 39 | return decorated 40 | 41 | 42 | def validate_username_in_header(): 43 | """ 44 | Validate that the username header is set and exists in the accounts table 45 | """ 46 | 47 | def decorated(f): 48 | @wraps(f) 49 | def wrapper(*args, **kwargs): 50 | try: 51 | username = request.headers['username'] 52 | except KeyError: 53 | return make_response( 54 | jsonify( 55 | {'Error': 'Missing the username header value'} 56 | ), client.BAD_REQUEST 57 | ) 58 | 59 | record = Interactions.query(DEFAULT_ACCOUNTS_TABLE, 60 | filters={'username': username}) 61 | 62 | if not record: 63 | return make_response( 64 | jsonify({'Error': 'Username not found'}), client.NOT_FOUND) 65 | 66 | return f(*args, **kwargs) 67 | 68 | return wrapper 69 | return decorated 70 | 71 | 72 | def validate_id_params(param_name): 73 | """ 74 | Validate the that the param_name is in the request 75 | """ 76 | def decorated(f): 77 | @wraps(f) 78 | def wrapper(*args, **kwargs): 79 | try: 80 | if not kwargs.get(param_name): 81 | return make_response(jsonify( 82 | {'Error': 'Missing {0}'.format(param_name)}), 83 | client.BAD_REQUEST) 84 | except Exception as ex: 85 | return make_response( 86 | jsonify({'Error': ex}), 87 | client.BAD_REQUEST 88 | ) 89 | 90 | return f(*args, **kwargs) 91 | 92 | return wrapper 93 | return decorated 94 | -------------------------------------------------------------------------------- /pywebhooks/api/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/handlers/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/handlers/pagination_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import make_response, jsonify 6 | 7 | # Project-level imports 8 | from pywebhooks.database.rethinkdb.interactions import Interactions 9 | 10 | 11 | def paginate(request, table_name, resource_name, filters=None): 12 | 13 | limit = int(request.args.get('limit')) 14 | start = int(request.args.get('start')) 15 | 16 | if not filters: 17 | filters = {} 18 | 19 | end = start + limit 20 | 21 | returned_records = Interactions.list( 22 | table_name, start, end, 'epoch', filters=filters) 23 | 24 | records = [] 25 | 26 | for item in returned_records: 27 | records.append(item) 28 | 29 | if len(returned_records) == 0: 30 | return make_response('', client.NO_CONTENT) 31 | 32 | if len(returned_records) < limit: 33 | return_json = { 34 | resource_name: records 35 | } 36 | else: 37 | return_json = { 38 | 'next_start': end, 39 | resource_name: records 40 | } 41 | 42 | return make_response(jsonify(return_json), client.OK) 43 | -------------------------------------------------------------------------------- /pywebhooks/api/handlers/resources_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import make_response, jsonify 6 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 7 | from werkzeug.security import generate_password_hash 8 | 9 | # Project-level imports 10 | from pywebhooks import DEFAULT_SUBSCRIPTIONS_TABLE, \ 11 | REQUEST_TIMEOUT, DEFAULT_ACCOUNTS_TABLE, DEFAULT_REGISTRATIONS_TABLE 12 | from pywebhooks.database.rethinkdb.interactions import Interactions 13 | from pywebhooks.utils import common 14 | from pywebhooks.utils.request_handler import RequestHandler 15 | 16 | 17 | def insert_account(table_name, **kwargs): 18 | """ 19 | Creates new account records (handles POST traffic) 20 | Salts the api_key 21 | """ 22 | try: 23 | # Username cannot already exist 24 | record = Interactions.query( 25 | table_name, filters={'username': kwargs['username']}) 26 | 27 | if record: 28 | return make_response( 29 | jsonify({'Error': 'Username already exists'}), client.CONFLICT) 30 | 31 | original_api_key = kwargs['api_key'] 32 | kwargs['api_key'] = generate_password_hash(kwargs['api_key']) 33 | account_data = Interactions.insert(table_name, **kwargs) 34 | account_data['api_key'] = original_api_key 35 | 36 | return make_response(jsonify(account_data), client.CREATED) 37 | except RqlRuntimeError as runtime_err: 38 | return make_response(jsonify({'Error': runtime_err.message}), 39 | client.INTERNAL_SERVER_ERROR) 40 | except RqlDriverError as rql_err: 41 | return make_response(jsonify({'Error': rql_err.message}), 42 | client.INTERNAL_SERVER_ERROR) 43 | except TypeError: 44 | return make_response( 45 | jsonify({'Error': 'Invalid parameter(s)'}), client.BAD_REQUEST) 46 | 47 | 48 | def insert(table_name, **kwargs): 49 | """ 50 | Creates new records (handles POST traffic) 51 | """ 52 | try: 53 | return make_response( 54 | jsonify(Interactions.insert(table_name, **kwargs)), client.CREATED) 55 | except RqlRuntimeError as runtime_err: 56 | return make_response(jsonify({'Error': runtime_err.message}), 57 | client.INTERNAL_SERVER_ERROR) 58 | except RqlDriverError as rql_err: 59 | return make_response(jsonify({'Error': rql_err.message}), 60 | client.INTERNAL_SERVER_ERROR) 61 | except TypeError: 62 | return make_response( 63 | jsonify({'Error': 'Invalid parameter(s)'}), client.BAD_REQUEST) 64 | 65 | 66 | def delete(table_name, record_id): 67 | """ 68 | Deletes a single record (handles DELETE traffic) 69 | """ 70 | try: 71 | return make_response( 72 | jsonify(Interactions.delete(table_name, record_id)), client.OK) 73 | except RqlRuntimeError as runtime_err: 74 | return make_response(jsonify({'Error': runtime_err.message}), 75 | client.INTERNAL_SERVER_ERROR) 76 | except RqlDriverError as rql_err: 77 | return make_response(jsonify({'Error': rql_err.message}), 78 | client.INTERNAL_SERVER_ERROR) 79 | except TypeError: 80 | return make_response( 81 | jsonify({'Error': 'Invalid id parameter'}), client.BAD_REQUEST) 82 | 83 | 84 | def delete_account(record_id): 85 | """ 86 | Deletes a single account record, removes all traces of account from other 87 | tables 88 | """ 89 | try: 90 | # Delete this account's subscriptions 91 | Interactions.delete_specific( 92 | DEFAULT_SUBSCRIPTIONS_TABLE, filters={'account_id': record_id}) 93 | 94 | # Loop and delete any records subscribed to their registrations 95 | registrations = Interactions.query(DEFAULT_REGISTRATIONS_TABLE, 96 | filters={'account_id': record_id}) 97 | 98 | for registration in registrations: 99 | delete_registration(registration['id']) 100 | 101 | return make_response( 102 | jsonify(Interactions.delete(DEFAULT_ACCOUNTS_TABLE, record_id)), 103 | client.OK) 104 | except RqlRuntimeError as runtime_err: 105 | return make_response(jsonify({'Error': runtime_err.message}), 106 | client.INTERNAL_SERVER_ERROR) 107 | except RqlDriverError as rql_err: 108 | return make_response(jsonify({'Error': rql_err.message}), 109 | client.INTERNAL_SERVER_ERROR) 110 | except TypeError: 111 | return make_response( 112 | jsonify({'Error': 'Invalid id parameter'}), client.BAD_REQUEST) 113 | 114 | 115 | def delete_registration(registration_id): 116 | """ 117 | Deletes a single registration record, removes all traces of this 118 | registration from the subscription table 119 | """ 120 | try: 121 | Interactions.delete_specific( 122 | DEFAULT_SUBSCRIPTIONS_TABLE, 123 | filters={'registration_id': registration_id}) 124 | 125 | return make_response( 126 | jsonify(Interactions.delete_specific( 127 | DEFAULT_REGISTRATIONS_TABLE, 128 | filters={'id': registration_id})), client.OK) 129 | except RqlRuntimeError as runtime_err: 130 | return make_response(jsonify({'Error': runtime_err.message}), 131 | client.INTERNAL_SERVER_ERROR) 132 | except RqlDriverError as rql_err: 133 | return make_response(jsonify({'Error': rql_err.message}), 134 | client.INTERNAL_SERVER_ERROR) 135 | except TypeError: 136 | return make_response( 137 | jsonify({'Error': 'Invalid id parameter'}), client.BAD_REQUEST) 138 | 139 | 140 | def delete_accounts_except_admins(): 141 | """ 142 | Deletes all account records except those marked as admins, removes all 143 | traces of account from other tables 144 | """ 145 | try: 146 | return make_response( 147 | jsonify(Interactions.delete_specific( 148 | DEFAULT_ACCOUNTS_TABLE, 149 | filters={'is_admin': False})), client.OK) 150 | except RqlRuntimeError as runtime_err: 151 | return make_response(jsonify({'Error': runtime_err.message}), 152 | client.INTERNAL_SERVER_ERROR) 153 | except RqlDriverError as rql_err: 154 | return make_response(jsonify({'Error': rql_err.message}), 155 | client.INTERNAL_SERVER_ERROR) 156 | 157 | 158 | def delete_all(table_name): 159 | """ 160 | Deletes all records (handles DELETE traffic) 161 | """ 162 | try: 163 | return make_response( 164 | jsonify(Interactions.delete_all(table_name)), client.OK) 165 | except RqlRuntimeError as runtime_err: 166 | return make_response(jsonify({'Error': runtime_err.message}), 167 | client.INTERNAL_SERVER_ERROR) 168 | except RqlDriverError as rql_err: 169 | return make_response(jsonify({'Error': rql_err.message}), 170 | client.INTERNAL_SERVER_ERROR) 171 | 172 | 173 | def query(table_name, record_id): 174 | """ 175 | Gets a single record (handles GET traffic) 176 | """ 177 | try: 178 | return make_response( 179 | jsonify(Interactions.get(table_name, record_id)), client.OK) 180 | except RqlRuntimeError as runtime_err: 181 | return make_response(jsonify({'Error': runtime_err.message}), 182 | client.INTERNAL_SERVER_ERROR) 183 | except RqlDriverError as rql_err: 184 | return make_response(jsonify({'Error': rql_err.message}), 185 | client.INTERNAL_SERVER_ERROR) 186 | except TypeError: 187 | return make_response( 188 | jsonify({'Error': 'Invalid id parameter'}), client.BAD_REQUEST) 189 | 190 | 191 | def update(table_name, record_id=None, username=None, updates={}): 192 | """ 193 | Updates a single record (handles GET traffic) 194 | """ 195 | try: 196 | if record_id: 197 | return make_response( 198 | jsonify(Interactions.update( 199 | table_name, record_id=record_id, updates=updates)), 200 | client.OK) 201 | else: 202 | return make_response( 203 | jsonify(Interactions.update( 204 | table_name, 205 | filters={'username': username}, 206 | updates=updates) 207 | ), client.OK) 208 | except RqlRuntimeError as runtime_err: 209 | return make_response(jsonify({'Error': runtime_err.message}), 210 | client.INTERNAL_SERVER_ERROR) 211 | except RqlDriverError as rql_err: 212 | return make_response(jsonify({'Error': rql_err.message}), 213 | client.INTERNAL_SERVER_ERROR) 214 | except TypeError: 215 | return make_response( 216 | jsonify({'Error': 'Invalid parameter(s)'}), client.BAD_REQUEST) 217 | 218 | 219 | def client_echo_valid(endpoint): 220 | """ 221 | This will validate if the user's endpoint is valid and returning the echo 222 | data sent to it 223 | """ 224 | try: 225 | request_handler = RequestHandler( 226 | verify_ssl=False, request_timeout=REQUEST_TIMEOUT) 227 | validation_key = common.generate_key() 228 | 229 | try: 230 | returned_json, status_code = request_handler.get( 231 | endpoint, params={'echo': validation_key}) 232 | # pylint: disable=W0703 233 | except: 234 | return False 235 | 236 | if status_code != client.OK: 237 | return False 238 | if returned_json['echo'] != validation_key: 239 | return False 240 | # pylint: disable=W0703 241 | except Exception: 242 | return False 243 | 244 | return True 245 | 246 | 247 | def client_reset_key(endpoint, key_type, key_value): 248 | """ 249 | This will send an api_key or secret_key to the configured endpoint 250 | (assists with resets of an api_key or secret_key) 251 | """ 252 | try: 253 | request_handler = RequestHandler( 254 | verify_ssl=False, request_timeout=REQUEST_TIMEOUT) 255 | 256 | try: 257 | returned_json, status_code = request_handler.get( 258 | endpoint, params={key_type: key_value}) 259 | # pylint: disable=W0703 260 | except: 261 | return False 262 | 263 | if status_code != client.OK: 264 | return False 265 | # pylint: disable=W0703 266 | except Exception: 267 | return False 268 | 269 | return True 270 | 271 | 272 | def reset_key(username, key_type): 273 | """ 274 | Resets either a secret key or api key 275 | """ 276 | try: 277 | # Note: The validate_username_in_header decorator will verify the 278 | # username and record. The api_key_restricted_resource will validate 279 | # the username as well as a valid API key 280 | record = Interactions.query(DEFAULT_ACCOUNTS_TABLE, 281 | filters={"username": username}) 282 | endpoint = record[0]['endpoint'] 283 | 284 | if not endpoint: 285 | return make_response( 286 | jsonify({'Error': 'Endpoint not found'}), 287 | client.NOT_FOUND 288 | ) 289 | 290 | new_key = common.generate_key() 291 | salted_new_key = generate_password_hash(new_key) 292 | 293 | if not client_reset_key(endpoint, key_type, new_key): 294 | return make_response( 295 | jsonify({'Error': 'Failed to contact the endpoint or wrong ' 296 | 'HTTP status code returned'}), 297 | client.BAD_REQUEST 298 | ) 299 | 300 | if key_type == 'api_key': 301 | update = {key_type: salted_new_key} 302 | else: 303 | update = {key_type: new_key} 304 | 305 | Interactions.update(DEFAULT_ACCOUNTS_TABLE, 306 | filters={"username": username}, 307 | updates=update) 308 | 309 | return make_response(jsonify({'Message': 'New key sent to endpoint'}), 310 | client.OK) 311 | except RqlRuntimeError as runtime_err: 312 | return make_response(jsonify({'Error': runtime_err.message}), 313 | client.INTERNAL_SERVER_ERROR) 314 | except RqlDriverError as rql_err: 315 | return make_response(jsonify({'Error': rql_err.message}), 316 | client.INTERNAL_SERVER_ERROR) 317 | 318 | 319 | def lookup_account_id(username): 320 | """ 321 | Looks up the user's account id based on username 322 | """ 323 | try: 324 | record = Interactions.query( 325 | DEFAULT_ACCOUNTS_TABLE, filters={'username': username}) 326 | return record[0]['id'] 327 | except RqlRuntimeError as runtime_err: 328 | return runtime_err 329 | except RqlDriverError as rql_err: 330 | return rql_err 331 | 332 | 333 | def lookup_registration_id(account_id, registration_id): 334 | """ 335 | Looks up registration based on account_id and pass the record back 336 | """ 337 | try: 338 | return Interactions.query( 339 | DEFAULT_REGISTRATIONS_TABLE, 340 | filters={'account_id': account_id, 'id': registration_id}) 341 | except RqlRuntimeError as runtime_err: 342 | return runtime_err 343 | except RqlDriverError as rql_err: 344 | return rql_err 345 | 346 | 347 | def lookup_subscription_id(account_id, subscription_id): 348 | """ 349 | Looks up subscription based on account_id and pass the record back 350 | """ 351 | try: 352 | return Interactions.query( 353 | DEFAULT_SUBSCRIPTIONS_TABLE, 354 | filters={'account_id': account_id, 'id': subscription_id}) 355 | except RqlRuntimeError as runtime_err: 356 | return runtime_err 357 | except RqlDriverError as rql_err: 358 | return rql_err 359 | 360 | 361 | def validate_access(username, registration_id=None, subscription_id=None, 362 | incoming_account_id=None): 363 | """ 364 | Validate access to resources 365 | """ 366 | if username == 'admin': 367 | return None 368 | 369 | account_id = lookup_account_id(username) 370 | 371 | try: 372 | if registration_id: 373 | if not lookup_registration_id(account_id, registration_id): 374 | return make_response( 375 | jsonify({'Error': 'Not authorized'}), client.UNAUTHORIZED) 376 | if subscription_id: 377 | if not lookup_subscription_id(account_id, subscription_id): 378 | return make_response( 379 | jsonify({'Error': 'Not authorized'}), client.UNAUTHORIZED) 380 | if incoming_account_id: 381 | if incoming_account_id != account_id: 382 | return make_response( 383 | jsonify({'Error': 'Not authorized'}), client.UNAUTHORIZED) 384 | except (RqlRuntimeError, RqlDriverError, Exception): 385 | return make_response( 386 | jsonify({'Error': 'Account or registration record not found'}), 387 | client.NOT_FOUND) 388 | 389 | return None 390 | 391 | 392 | def registration_id_exists(registration_id): 393 | """ 394 | Looks up registration based on record_id and pass the record back 395 | """ 396 | try: 397 | registration = Interactions.query( 398 | DEFAULT_REGISTRATIONS_TABLE, filters={'id': registration_id}) 399 | if registration: 400 | return True 401 | return False 402 | except RqlRuntimeError as runtime_err: 403 | return runtime_err 404 | except RqlDriverError as rql_err: 405 | return rql_err 406 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/resources/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/resources/v1/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/resources/v1/account/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/account_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import request, jsonify, make_response 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 10 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 11 | from pywebhooks.api.handlers.resources_handler import client_echo_valid, \ 12 | insert_account, delete_account, update, query, lookup_account_id, \ 13 | validate_access 14 | from pywebhooks.utils.common import generate_key 15 | from pywebhooks.api.decorators.validation import validate_id_params 16 | 17 | 18 | class AccountAPI(Resource): 19 | """ 20 | Handles the REST API interaction for accounts 21 | """ 22 | 23 | @validate_id_params('account_id') 24 | @api_key_restricted_resource(verify_admin=False) 25 | def get(self, account_id): 26 | """ 27 | Gets a user account. Users can only see their own account 28 | """ 29 | if lookup_account_id(request.headers['username']) == account_id: 30 | return query(DEFAULT_ACCOUNTS_TABLE, account_id) 31 | else: 32 | return make_response(jsonify( 33 | {'Error': 'Not authorized'}), 34 | client.UNAUTHORIZED) 35 | 36 | @api_key_restricted_resource(verify_admin=False) 37 | def patch(self): 38 | """ 39 | Updates account. Only one field can be updated: endpoint 40 | Updating the endpoint also resets the failed_count 41 | """ 42 | json_data = request.get_json() 43 | username = request.headers['username'] 44 | 45 | if 'endpoint' in json_data: 46 | update_json = { 47 | 'endpoint': json_data['endpoint'], 48 | 'failed_count': 0 49 | } 50 | else: 51 | return make_response(jsonify( 52 | {'Error': 'Missing endpoint field'}), client.BAD_REQUEST) 53 | 54 | return update(DEFAULT_ACCOUNTS_TABLE, username=username, 55 | updates=update_json) 56 | 57 | def post(self): 58 | """ 59 | Creates a new account 60 | """ 61 | json_data = request.get_json() 62 | 63 | if not client_echo_valid(json_data['endpoint']): 64 | return make_response(jsonify({'Error': 'Echo response failed'}), 65 | client.BAD_REQUEST) 66 | 67 | return insert_account(DEFAULT_ACCOUNTS_TABLE, 68 | **{'username': json_data['username'], 69 | 'endpoint': json_data['endpoint'], 70 | 'is_admin': False, 71 | 'failed_count': 0, 72 | 'api_key': generate_key(), 73 | 'secret_key': generate_key()}) 74 | 75 | @validate_id_params('account_id') 76 | @api_key_restricted_resource(verify_admin=False) 77 | def delete(self, account_id): 78 | """ 79 | Deletes account record 80 | """ 81 | return_val = validate_access( 82 | request.headers['username'], 83 | incoming_account_id=account_id) 84 | 85 | if return_val: 86 | return return_val 87 | 88 | return delete_account(account_id) 89 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/accounts_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | # Third-party imports 4 | from flask import request 5 | from flask_restful import Resource 6 | 7 | # Project-level imports 8 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 9 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 10 | from pywebhooks.api.handlers.pagination_handler import paginate 11 | from pywebhooks.api.handlers.resources_handler import \ 12 | delete_accounts_except_admins 13 | from pywebhooks.api.decorators.validation import validate_pagination_params 14 | 15 | 16 | class AccountsAPI(Resource): 17 | """ 18 | Handles the REST API interaction for accounts 19 | """ 20 | 21 | @api_key_restricted_resource(verify_admin=True) 22 | @validate_pagination_params() 23 | def get(self): 24 | """ 25 | Get a listing of accounts (paginated if need be) 26 | """ 27 | return paginate(request, DEFAULT_ACCOUNTS_TABLE, 'accounts') 28 | 29 | @api_key_restricted_resource(verify_admin=True) 30 | def delete(self): 31 | """ 32 | Deletes all records (except admin) in the Accounts table 33 | """ 34 | return delete_accounts_except_admins() 35 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/reset/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/resources/v1/account/reset/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/reset/api_key_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | from flask import request 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks.api.handlers.resources_handler import reset_key 10 | from pywebhooks.api.decorators.validation import validate_username_in_header 11 | 12 | 13 | class ApiKeyAPI(Resource): 14 | """ 15 | Handles the REST API interaction for resetting api keys 16 | """ 17 | 18 | @validate_username_in_header() 19 | def post(self): 20 | """ 21 | Resets an api key 22 | """ 23 | return reset_key(request.headers['username'], 'api_key') 24 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/account/reset/secret_key_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | from flask import request 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks.api.handlers.resources_handler import reset_key 10 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 11 | 12 | 13 | class SecretKeyAPI(Resource): 14 | """ 15 | Handles the REST API interaction for resetting secret keys 16 | """ 17 | 18 | @api_key_restricted_resource() 19 | def post(self): 20 | """ 21 | Resets a secret key 22 | """ 23 | return reset_key(request.headers['username'], 'secret_key') 24 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/api/resources/v1/webhook/__init__.py -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/registration_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import request, jsonify, make_response 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_REGISTRATIONS_TABLE 10 | from pywebhooks.api.handlers.pagination_handler import paginate 11 | from pywebhooks.api.handlers.resources_handler import lookup_account_id, \ 12 | validate_access, delete_registration, insert, update 13 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 14 | from pywebhooks.api.decorators.validation import validate_pagination_params, \ 15 | validate_id_params 16 | 17 | 18 | class RegistrationAPI(Resource): 19 | 20 | @api_key_restricted_resource(verify_admin=False) 21 | @validate_pagination_params() 22 | def get(self): 23 | """ 24 | Get the user's registered webhooks 25 | """ 26 | account_id = lookup_account_id(request.headers['username']) 27 | 28 | return paginate(request, DEFAULT_REGISTRATIONS_TABLE, 'registrations', 29 | filters={'account_id': account_id}) 30 | 31 | @validate_id_params('registration_id') 32 | @api_key_restricted_resource(verify_admin=False) 33 | def patch(self, registration_id): 34 | """ 35 | Updates registration. Only one field can be updated: description 36 | """ 37 | return_val = validate_access( 38 | request.headers['username'], 39 | registration_id=registration_id) 40 | 41 | if return_val: 42 | return return_val 43 | 44 | json_data = request.get_json() 45 | update_json = {} 46 | 47 | if 'description' in json_data: 48 | update_json['description'] = json_data['description'] 49 | else: 50 | return make_response( 51 | jsonify({'Error': 'Description field missing'}), 52 | client.BAD_REQUEST) 53 | 54 | return update(DEFAULT_REGISTRATIONS_TABLE, 55 | record_id=registration_id, 56 | updates=update_json) 57 | 58 | @api_key_restricted_resource(verify_admin=False) 59 | def post(self): 60 | """ 61 | Creates a new registration 62 | """ 63 | json_data = request.get_json() 64 | 65 | # Look up account id based on username, username will be valid since 66 | # the api_key_restricted_resource decorator runs first 67 | account_id = lookup_account_id(request.headers['username']) 68 | 69 | return insert(DEFAULT_REGISTRATIONS_TABLE, 70 | **{'account_id': account_id, 71 | 'event': json_data['event'], 72 | 'description': json_data['description'], 73 | 'event_data': json_data['event_data']}) 74 | 75 | @validate_id_params('registration_id') 76 | @api_key_restricted_resource(verify_admin=False) 77 | def delete(self, registration_id): 78 | """ 79 | Deletes registration record, will also remove the records for this 80 | registration_id in the subscription table as well 81 | """ 82 | return_val = validate_access( 83 | request.headers['username'], 84 | registration_id=registration_id) 85 | 86 | if return_val: 87 | return return_val 88 | 89 | return delete_registration(registration_id) 90 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/registrations_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | from flask import request 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_REGISTRATIONS_TABLE 10 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 11 | from pywebhooks.api.handlers.pagination_handler import paginate 12 | from pywebhooks.api.handlers.resources_handler import delete_all 13 | from pywebhooks.api.decorators.validation import validate_pagination_params 14 | 15 | 16 | class RegistrationsAPI(Resource): 17 | """ 18 | Handles the REST API interaction for Registrations 19 | """ 20 | 21 | @api_key_restricted_resource(verify_admin=False) 22 | @validate_pagination_params() 23 | def get(self): 24 | """ 25 | Get a listing of Registrations (paginated if need be) 26 | """ 27 | return paginate(request, DEFAULT_REGISTRATIONS_TABLE, 'registrations') 28 | 29 | @api_key_restricted_resource(verify_admin=True) 30 | def delete(self): 31 | """ 32 | Deletes all records in the Registrations table 33 | """ 34 | return delete_all(DEFAULT_REGISTRATIONS_TABLE) 35 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/subscription.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import request, jsonify, make_response 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_SUBSCRIPTIONS_TABLE 10 | from pywebhooks.api.handlers.pagination_handler import paginate 11 | from pywebhooks.api.handlers.resources_handler import insert, delete, \ 12 | registration_id_exists, lookup_account_id, validate_access 13 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 14 | from pywebhooks.api.decorators.validation import validate_pagination_params, \ 15 | validate_id_params 16 | 17 | 18 | class Subscription(Resource): 19 | """ 20 | Handles the (webhook) subscriptions table interactions 21 | """ 22 | 23 | @api_key_restricted_resource(verify_admin=False) 24 | @validate_pagination_params() 25 | def get(self): 26 | """ 27 | Get the user's webhook subscriptions 28 | """ 29 | try: 30 | account_id = lookup_account_id(request.headers['username']) 31 | # pylint: disable=W0703 32 | except Exception: 33 | return make_response( 34 | jsonify({'Error': 'Invalid username or account'}), 35 | client.BAD_REQUEST) 36 | 37 | return paginate(request, DEFAULT_SUBSCRIPTIONS_TABLE, 'subscriptions', 38 | filters={'account_id': account_id}) 39 | 40 | @validate_id_params('subscription_id') 41 | @api_key_restricted_resource(verify_admin=False) 42 | def post(self, subscription_id): 43 | """ 44 | Creates new subscription 45 | """ 46 | # subscription_id is actually the registration_id 47 | registration_id = subscription_id 48 | 49 | account_id = lookup_account_id(request.headers['username']) 50 | 51 | if not registration_id_exists(registration_id): 52 | return make_response( 53 | jsonify({'Error': 'The registration id does not exist'}), 54 | client.NOT_FOUND) 55 | 56 | return insert(DEFAULT_SUBSCRIPTIONS_TABLE, 57 | **{'account_id': account_id, 58 | 'registration_id': registration_id}) 59 | 60 | @validate_id_params('subscription_id') 61 | @api_key_restricted_resource(verify_admin=False) 62 | def delete(self, subscription_id): 63 | """ 64 | Deletes subscription record 65 | """ 66 | return_val = validate_access( 67 | request.headers['username'], 68 | subscription_id=subscription_id) 69 | 70 | if return_val: 71 | return return_val 72 | 73 | return delete(DEFAULT_SUBSCRIPTIONS_TABLE, subscription_id) 74 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/subscriptions.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | 3 | # Third-party imports 4 | from flask import request 5 | from flask_restful import Resource 6 | 7 | # Project-level imports 8 | from pywebhooks import DEFAULT_SUBSCRIPTIONS_TABLE 9 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 10 | from pywebhooks.api.handlers.resources_handler import delete_all 11 | from pywebhooks.api.handlers.pagination_handler import paginate 12 | from pywebhooks.api.decorators.validation import validate_pagination_params 13 | 14 | 15 | class Subscriptions(Resource): 16 | """ 17 | Handles the (webhook) subscriptions table interactions 18 | """ 19 | 20 | @validate_pagination_params() 21 | @api_key_restricted_resource(verify_admin=False) 22 | def get(self): 23 | """ 24 | Get a listing of subscriptions (paginated if need be) 25 | """ 26 | return paginate(request, DEFAULT_SUBSCRIPTIONS_TABLE, 'subscriptions') 27 | 28 | @api_key_restricted_resource(verify_admin=True) 29 | def delete(self): 30 | """ 31 | Deletes all records in the subscriptions table 32 | """ 33 | return delete_all(DEFAULT_SUBSCRIPTIONS_TABLE) 34 | -------------------------------------------------------------------------------- /pywebhooks/api/resources/v1/webhook/triggered_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | 4 | # Third-party imports 5 | from flask import request, jsonify, make_response 6 | from flask_restful import Resource 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_TRIGGERED_TABLE, DEFAULT_REGISTRATIONS_TABLE, \ 10 | DEFAULT_SUBSCRIPTIONS_TABLE, DEFAULT_ACCOUNTS_TABLE, MAX_FAILED_COUNT 11 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 12 | from pywebhooks.api.decorators.validation import validate_pagination_params, \ 13 | validate_id_params 14 | from pywebhooks.api.handlers.resources_handler import insert, \ 15 | lookup_account_id, lookup_registration_id 16 | from pywebhooks.api.handlers.pagination_handler import paginate 17 | from pywebhooks.database.rethinkdb.interactions import Interactions 18 | 19 | 20 | class TriggeredAPI(Resource): 21 | 22 | @validate_pagination_params() 23 | @api_key_restricted_resource(verify_admin=False) 24 | def get(self): 25 | """ 26 | Get a listing of triggered webhooks (paginated if need be) 27 | """ 28 | return paginate(request, DEFAULT_TRIGGERED_TABLE, 'triggered_webhooks') 29 | 30 | @validate_id_params('registration_id') 31 | @api_key_restricted_resource(verify_admin=False) 32 | def post(self, registration_id): 33 | """ 34 | Creates new triggered webhook event 35 | """ 36 | registration = Interactions.query( 37 | DEFAULT_REGISTRATIONS_TABLE, filters={'id': registration_id}) 38 | 39 | if not registration: 40 | return make_response( 41 | jsonify( 42 | {'Error': 'Registration id not found'} 43 | ), client.NOT_FOUND) 44 | 45 | # Other users cannot trigger webhooks they didn't create 46 | calling_account_id = lookup_account_id(request.headers['username']) 47 | 48 | if not lookup_registration_id(calling_account_id, registration_id): 49 | return make_response( 50 | jsonify({'Error': 'You don\'t have access ' 51 | 'to this registration record or it no ' 52 | 'longer exists'}), 53 | client.UNAUTHORIZED) 54 | 55 | # Notify subscribed endpoints (send the webhooks out) 56 | subscriptions = Interactions.list_all( 57 | DEFAULT_SUBSCRIPTIONS_TABLE, order_by='epoch', 58 | filters={'registration_id': registration_id}) 59 | 60 | if subscriptions: 61 | for record in subscriptions: 62 | account = Interactions.get(DEFAULT_ACCOUNTS_TABLE, 63 | record['account_id']) 64 | # Only hit the endpoint if their failed count is low enough 65 | if int(account['failed_count']) < MAX_FAILED_COUNT: 66 | # This import is required to be here so the flask-restful 67 | # piece works properly with Celery 68 | from pywebhooks.tasks.webhook_notification import \ 69 | notify_subscribed_accounts 70 | 71 | notify_subscribed_accounts.delay( 72 | event=registration[0]['event'], 73 | event_data=registration[0]['event_data'], 74 | secret_key=account['secret_key'], 75 | endpoint=account['endpoint'], 76 | account_id=record['account_id']) 77 | 78 | return insert(DEFAULT_TRIGGERED_TABLE, 79 | **{'registration_id': registration_id}) 80 | -------------------------------------------------------------------------------- /pywebhooks/app.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import argparse 3 | import sys 4 | 5 | # Third-party imports 6 | from celery import Celery 7 | from flask import Flask, request 8 | from flask_restful import Api 9 | 10 | # Project-level imports 11 | from pywebhooks import CELERY_BROKER_URL 12 | from pywebhooks.api.resources.v1.account.account_api import AccountAPI 13 | from pywebhooks.api.resources.v1.account.accounts_api import AccountsAPI 14 | from pywebhooks.api.resources.v1.webhook.registration_api import \ 15 | RegistrationAPI 16 | from pywebhooks.api.resources.v1.webhook.registrations_api import \ 17 | RegistrationsAPI 18 | from pywebhooks.api.resources.v1.account.reset.secret_key_api import \ 19 | SecretKeyAPI 20 | from pywebhooks.api.resources.v1.account.reset.api_key_api import ApiKeyAPI 21 | from pywebhooks.api.resources.v1.webhook.subscription import Subscription 22 | from pywebhooks.api.resources.v1.webhook.subscriptions import Subscriptions 23 | from pywebhooks.api.resources.v1.webhook.triggered_api import TriggeredAPI 24 | from pywebhooks.database.rethinkdb.initialize import create_database 25 | from pywebhooks.database.rethinkdb.drop import drop_database 26 | from pywebhooks.database.rethinkdb.bootstrap_admin import create_admin_account 27 | 28 | 29 | def create_wsgi_app(): 30 | flask_app = Flask(__name__) 31 | flask_app.url_map.strict_slashes = False 32 | api = Api(flask_app) 33 | 34 | api.add_resource(AccountsAPI, '/v1/accounts') 35 | api.add_resource(AccountAPI, '/v1/account/', '/v1/account') 36 | 37 | api.add_resource(SecretKeyAPI, '/v1/account/reset/secretkey') 38 | 39 | api.add_resource(ApiKeyAPI, '/v1/account/reset/apikey') 40 | 41 | api.add_resource(RegistrationAPI, '/v1/webhook/registration', 42 | '/v1/webhook/registration/') 43 | api.add_resource(RegistrationsAPI, '/v1/webhook/registrations') 44 | 45 | api.add_resource(TriggeredAPI, '/v1/webhook/triggered', 46 | '/v1/webhook/triggered/') 47 | 48 | api.add_resource(Subscriptions, '/v1/webhook/subscriptions') 49 | api.add_resource(Subscription, '/v1/webhook/subscription', 50 | '/v1/webhook/subscription/') 51 | 52 | # There is no need for rate limits so it can be turned off 53 | flask_app.config['CELERY_DISABLE_RATE_LIMITS'] = True 54 | CELERY.conf.update(flask_app.config) 55 | 56 | return flask_app 57 | 58 | 59 | CELERY = Celery(__name__, broker=CELERY_BROKER_URL) 60 | CELERY.conf.update(CELERY_ACCEPT_CONTENT=['json']) 61 | app = create_wsgi_app() 62 | 63 | 64 | @app.before_request 65 | def before_request(): 66 | if request.headers['content-type'].lower().find('application/json'): 67 | return 'Unsupported Media Type', 415 68 | 69 | 70 | def main(arguments=None): # pragma: no cover 71 | parser = argparse.ArgumentParser(description='Run the PyWebHooks app') 72 | parser.add_argument('--initdb', dest='initdb', action='store_true') 73 | args = parser.parse_args(arguments) 74 | 75 | if args.initdb: 76 | print('Dropping database...') 77 | try: 78 | drop_database() 79 | except Exception as ex: 80 | print(str(ex)) 81 | print('Creating database...') 82 | create_database() 83 | print('Adding admin account') 84 | print(create_admin_account()) 85 | print('Complete') 86 | else: 87 | app.run(debug=True, port=8081, host='0.0.0.0') 88 | 89 | 90 | if __name__ == "__main__": # pragma: no cover 91 | main(sys.argv[1:]) 92 | -------------------------------------------------------------------------------- /pywebhooks/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/database/__init__.py -------------------------------------------------------------------------------- /pywebhooks/database/rethinkdb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/database/rethinkdb/__init__.py -------------------------------------------------------------------------------- /pywebhooks/database/rethinkdb/bootstrap_admin.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 6 | from werkzeug.security import generate_password_hash 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 10 | from pywebhooks.database.rethinkdb.interactions import Interactions 11 | from pywebhooks.utils.common import generate_key 12 | 13 | 14 | def create_admin_account(): 15 | """ 16 | Creates a new admin account 17 | """ 18 | try: 19 | original_api_key = generate_key() 20 | secret_key = generate_key() 21 | hashed_api_key = generate_password_hash(original_api_key) 22 | 23 | Interactions.insert(DEFAULT_ACCOUNTS_TABLE, 24 | **{'username': 'admin', 25 | 'endpoint': '', 26 | 'is_admin': True, 27 | 'api_key': hashed_api_key, 28 | 'secret_key': secret_key}) 29 | 30 | return {'api_key': original_api_key, 'secret_key': secret_key} 31 | except (RqlRuntimeError, RqlDriverError) as err: 32 | raise err 33 | -------------------------------------------------------------------------------- /pywebhooks/database/rethinkdb/drop.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | import rethinkdb as rethink 6 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_DB_NAME 10 | from pywebhooks.utils.rethinkdb_helper import get_connection 11 | 12 | 13 | def drop_database(): 14 | """ 15 | Deletes the RethinkDB database 16 | """ 17 | try: 18 | with get_connection() as conn: 19 | rethink.db_drop(DEFAULT_DB_NAME).run(conn) 20 | except (RqlRuntimeError, RqlDriverError) as err: 21 | raise err 22 | -------------------------------------------------------------------------------- /pywebhooks/database/rethinkdb/initialize.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | # None 3 | 4 | # Third-party imports 5 | import rethinkdb as rethink 6 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 7 | 8 | # Project-level imports 9 | from pywebhooks import DEFAULT_DB_NAME, DEFAULT_TABLE_NAMES 10 | from pywebhooks.utils.rethinkdb_helper import get_connection 11 | 12 | 13 | def create_database(): 14 | """ 15 | Creates a new RethinkDB database with tables if it doesn't already exist 16 | """ 17 | try: 18 | with get_connection() as conn: 19 | db_list = rethink.db_list().run(conn) 20 | 21 | if DEFAULT_DB_NAME not in db_list: 22 | # Default db doesn't exist so add it 23 | rethink.db_create(DEFAULT_DB_NAME).run(conn) 24 | 25 | table_list = rethink.db(DEFAULT_DB_NAME).table_list().run(conn) 26 | 27 | for table_name in DEFAULT_TABLE_NAMES: 28 | if table_name not in table_list: 29 | # Add the missing table(s) 30 | rethink.db(DEFAULT_DB_NAME).table_create(table_name)\ 31 | .run(conn) 32 | 33 | except (RqlRuntimeError, RqlDriverError) as err: 34 | raise err 35 | -------------------------------------------------------------------------------- /pywebhooks/database/rethinkdb/interactions.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from time import time 3 | import uuid 4 | 5 | # Third-party imports 6 | import rethinkdb as rethink 7 | 8 | # Project-level imports 9 | from pywebhooks.utils.rethinkdb_helper import get_connection 10 | 11 | 12 | class Interactions(object): 13 | """ 14 | Handles basic table interactions 15 | """ 16 | 17 | @staticmethod 18 | def list(table_name, start, limit, order_by='epoch', filters=None): 19 | """ 20 | Gets a list of records, meant for pagination. 21 | """ 22 | if not filters: 23 | filters = {} 24 | 25 | with get_connection() as conn: 26 | return rethink.table(table_name)\ 27 | .filter(filters).order_by(order_by) \ 28 | .slice(start, limit).run(conn) 29 | 30 | @staticmethod 31 | def list_all(table_name, order_by='epoch', filters=None): 32 | """ 33 | Gets a full list of records - no pagination. 34 | """ 35 | if not filters: 36 | filters = {} 37 | 38 | with get_connection() as conn: 39 | return list(rethink.table(table_name) 40 | .order_by(order_by).filter(filters).run(conn)) 41 | 42 | @staticmethod 43 | def query(table_name, order_by='epoch', filters=None): 44 | """ 45 | Query for record(s) 46 | """ 47 | if not filters: 48 | filters = {} 49 | 50 | with get_connection() as conn: 51 | return rethink.table(table_name)\ 52 | .order_by(order_by).filter(filters).run(conn) 53 | 54 | @staticmethod 55 | def get(table_name, record_id): 56 | """ 57 | Get a single record based on id. 58 | """ 59 | with get_connection() as conn: 60 | return rethink.table(table_name).get(record_id).run(conn) 61 | 62 | @staticmethod 63 | def insert(table_name, **kwargs): 64 | """ 65 | Inserts a new record. id and epoch are common to all records. 66 | """ 67 | if 'id' not in kwargs: 68 | kwargs['id'] = str(uuid.uuid4()) 69 | 70 | kwargs['epoch'] = time() 71 | 72 | with get_connection() as conn: 73 | rethink.table(table_name).insert(kwargs).run(conn) 74 | return kwargs 75 | 76 | @staticmethod 77 | def delete_all(table_name): 78 | """ 79 | Deletes all records in a specified table 80 | """ 81 | with get_connection() as conn: 82 | return rethink.table(table_name).delete().run(conn) 83 | 84 | @staticmethod 85 | def delete(table_name, record_id): 86 | """ 87 | Deletes a single record in a specified table 88 | """ 89 | with get_connection() as conn: 90 | return rethink.table(table_name).get(record_id).delete().run(conn) 91 | 92 | @staticmethod 93 | def delete_specific(table_name, filters=None): 94 | """ 95 | Deletes all records in a table matching the filter 96 | """ 97 | if not filters: 98 | filters = {} 99 | 100 | with get_connection() as conn: 101 | return rethink.table(table_name).filter(filters).delete().run(conn) 102 | 103 | @staticmethod 104 | def update(table_name, record_id=None, filters=None, updates=None): 105 | """ 106 | Perform an update on one more fields 107 | """ 108 | if not filters: 109 | filters = {} 110 | if not updates: 111 | updates = {} 112 | 113 | with get_connection() as conn: 114 | if record_id: 115 | return rethink.table(table_name).get(record_id)\ 116 | .update(updates).run(conn) 117 | else: 118 | return rethink.table(table_name).filter(filters)\ 119 | .update(updates).run(conn) 120 | -------------------------------------------------------------------------------- /pywebhooks/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/examples/__init__.py -------------------------------------------------------------------------------- /pywebhooks/examples/endpoint_development_server.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | from http import client 4 | import json 5 | 6 | from flask import Flask 7 | from flask import request, make_response, jsonify 8 | 9 | 10 | app = Flask(__name__) 11 | 12 | # Adjust this as needed 13 | SECRET_KEY = 'c27e823b0a500a537990dcccfc50334fe814fbd2' 14 | 15 | 16 | def verify_hmac_hash(incoming_json, secret_key, incoming_signature): 17 | signature = hmac.new( 18 | str(secret_key).encode('utf-8'), 19 | str(incoming_json).encode('utf-8'), 20 | digestmod=hashlib.sha1 21 | ).hexdigest() 22 | 23 | return hmac.compare_digest(signature, incoming_signature) 24 | 25 | 26 | def create_response(req): 27 | if request.args.get('echo'): 28 | return make_response(jsonify({'echo': req.args.get('echo')}), 29 | client.OK) 30 | if request.args.get('api_key'): 31 | print('New api_key: {0}'.format(req.args.get('api_key'))) 32 | return make_response(jsonify({}), client.OK) 33 | if request.args.get('secret_key'): 34 | print('New secret_key: {0}'.format(req.args.get('secret_key'))) 35 | return make_response(jsonify({}), client.OK) 36 | 37 | 38 | def webhook_listener(request): 39 | print(request.headers) 40 | print(request.data) 41 | print(json.dumps(request.json)) 42 | 43 | is_signature_valid = verify_hmac_hash( 44 | json.dumps(request.json), 45 | SECRET_KEY, 46 | request.headers['pywebhooks-signature'] 47 | ) 48 | 49 | print('Is Signature Valid?: {0}'.format(is_signature_valid)) 50 | 51 | return make_response(jsonify({}), client.OK) 52 | 53 | 54 | @app.route('/account/endpoint', methods=['GET']) 55 | def echo(): 56 | return create_response(request) 57 | 58 | 59 | @app.route('/account/alternate/endpoint', methods=['GET']) 60 | def echo_alternate(): 61 | return create_response(request) 62 | 63 | 64 | @app.route('/account/alternate/endpoint', methods=['POST']) 65 | def account_alternate_listener(): 66 | return webhook_listener(request) 67 | 68 | 69 | @app.route('/account/endpoint', methods=['POST']) 70 | def account_listener(): 71 | return webhook_listener(request) 72 | 73 | 74 | if __name__ == '__main__': 75 | app.run(debug=True, port=9090, host='0.0.0.0') 76 | -------------------------------------------------------------------------------- /pywebhooks/examples/ruby_endpoint_developement_server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'openssl' 3 | require 'sinatra' 4 | require 'json' 5 | 6 | 7 | SHARED_SECRET = 'c27e823b0a500a537990dcccfc50334fe814fbd2' 8 | 9 | # Handle echo requests 10 | get '/account/endpoint' do 11 | content_type :json 12 | echo_value = params['echo'] 13 | puts 'echo value:' 14 | puts(echo_value) 15 | 16 | status 200 17 | { :echo => echo_value }.to_json 18 | end 19 | 20 | # Handle the incoming webhook events 21 | post '/account/endpoint' do 22 | request.body.rewind 23 | data = request.body.read 24 | HMAC_DIGEST = OpenSSL::Digest.new('sha1') 25 | signature = OpenSSL::HMAC.hexdigest(HMAC_DIGEST, SHARED_SECRET, data) 26 | incoming_signature = env['HTTP_PYWEBHOOKS_SIGNATURE'] 27 | 28 | puts 'hmac verification results:' 29 | puts Rack::Utils.secure_compare(signature, incoming_signature) 30 | 31 | incoming_event = env['HTTP_EVENT'] 32 | puts 'incoming event is:' 33 | puts incoming_event 34 | puts 'incoming json is:' 35 | puts data 36 | 37 | status 200 38 | '{}' 39 | end 40 | -------------------------------------------------------------------------------- /pywebhooks/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/tasks/__init__.py -------------------------------------------------------------------------------- /pywebhooks/tasks/webhook_notification.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import logging 4 | 5 | # Third-party imports 6 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 7 | 8 | # Project-level imports 9 | from pywebhooks.app import CELERY 10 | from pywebhooks import DEFAULT_RETRY, DEFAULT_FINAL_RETRY, REQUEST_TIMEOUT, \ 11 | DEFAULT_ACCOUNTS_TABLE 12 | from pywebhooks.database.rethinkdb.interactions import Interactions 13 | from pywebhooks.utils.common import create_signature 14 | from pywebhooks.utils.request_handler import RequestHandler 15 | 16 | 17 | _LOG = logging.getLogger(__name__) 18 | 19 | 20 | def update_failed_count(account_id=None, increment_failed_count=False): 21 | """ 22 | Update the failed_count field on the user's account 23 | """ 24 | try: 25 | # Get the failed_count value 26 | account_record = Interactions.get( 27 | DEFAULT_ACCOUNTS_TABLE, record_id=account_id) 28 | failed_count = int(account_record['failed_count']) 29 | 30 | if increment_failed_count: 31 | failed_count += 1 32 | else: 33 | failed_count = 0 34 | 35 | Interactions.update(DEFAULT_ACCOUNTS_TABLE, record_id=account_id, 36 | updates={'failed_count': failed_count}) 37 | except (RqlRuntimeError, RqlDriverError, Exception): 38 | pass 39 | 40 | 41 | # Running the Worker from the project root: 42 | # 43 | # celery -A pywebhooks.tasks.webhook_notification worker --loglevel=info 44 | # 45 | @CELERY.task(bind=True, serializer='json', max_retries=3, ignore_result=True) 46 | def notify_subscribed_accounts(self, event=None, event_data=None, 47 | secret_key=None, endpoint=None, 48 | account_id=None): 49 | """ 50 | Send Webhook requests to all subscribed accounts 51 | """ 52 | signature = create_signature(secret_key, event_data) 53 | 54 | request_handler = RequestHandler( 55 | verify_ssl=False, request_timeout=REQUEST_TIMEOUT) 56 | 57 | try: 58 | _, status_code = request_handler.post( 59 | url=endpoint, 60 | json_payload=event_data, event=event, 61 | signature=signature) 62 | 63 | # We don't care about anything but the return status code 64 | if client.OK != status_code: 65 | raise Exception('Endpoint returning non HTTP 200 status. ' 66 | 'Actual code returned: {0}'.format(status_code)) 67 | 68 | if client.OK == status_code: 69 | # Failed count will reset on a good contact 70 | update_failed_count(account_id, increment_failed_count=False) 71 | 72 | except Exception as exc: 73 | update_failed_count(account_id, increment_failed_count=True) 74 | 75 | if self.request.retries == 3: 76 | raise self.retry(exc=exc, countdown=DEFAULT_FINAL_RETRY) 77 | else: 78 | raise self.retry(exc=exc, countdown=DEFAULT_RETRY) 79 | -------------------------------------------------------------------------------- /pywebhooks/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/pywebhooks/utils/__init__.py -------------------------------------------------------------------------------- /pywebhooks/utils/common.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import json 3 | import hashlib 4 | import hmac 5 | import os 6 | 7 | # Third-party imports 8 | # None 9 | 10 | # Project-level imports 11 | # None 12 | 13 | 14 | def generate_key(): 15 | return str(hashlib.sha1(os.urandom(128)).hexdigest()) 16 | 17 | 18 | def create_signature(secret_key, json_data, digestmod=hashlib.sha1): 19 | return hmac.new( 20 | str(secret_key).encode('utf-8'), 21 | str(json.dumps(json_data)).encode('utf-8'), 22 | digestmod=digestmod 23 | ).hexdigest() 24 | -------------------------------------------------------------------------------- /pywebhooks/utils/request_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import json 3 | 4 | # Third party imports 5 | import requests 6 | 7 | # Project level imports 8 | # None 9 | 10 | 11 | # Suppress the insecure request warning 12 | # https://urllib3.readthedocs.org/en/ 13 | # latest/security.html#insecurerequestwarning 14 | requests.packages.urllib3.disable_warnings() 15 | 16 | 17 | class RequestHandler(object): 18 | 19 | def __init__(self, verify_ssl=False, request_timeout=15.0): 20 | 21 | self.verify_ssl = verify_ssl 22 | self.request_timeout = request_timeout 23 | self._session = requests.Session() 24 | self.headers = {'Accept': 'application/json', 25 | 'Content-Type': 'application/json'} 26 | 27 | def get(self, url, params=None, api_key=None, username=None): 28 | return self._request(url, params=params, api_key=api_key, 29 | username=username) 30 | 31 | def post(self, url, json_payload='{}', api_key=None, username=None, 32 | event=None, signature=None): 33 | return self._request(url, json_payload, http_verb='POST', 34 | api_key=api_key, username=username, 35 | event=event, signature=signature) 36 | 37 | def patch(self, url, json_payload='{}', api_key=None, username=None): 38 | return self._request(url, json_payload, http_verb='PATCH', 39 | api_key=api_key, username=username) 40 | 41 | def put(self, url, json_payload='{}', api_key=None, username=None): 42 | return self._request(url, json_payload, http_verb='PUT', 43 | api_key=api_key, username=username) 44 | 45 | def delete(self, url, params=None, api_key=None, username=None): 46 | return self._request(url, params=params, http_verb='DELETE', 47 | api_key=api_key, username=username) 48 | 49 | def _request(self, url, json_payload='{}', http_verb='GET', params=None, 50 | api_key=None, username=None, event=None, signature=None): 51 | 52 | json_payload = json.dumps(json_payload) 53 | 54 | if api_key: 55 | self.headers['api-key'] = api_key 56 | if username: 57 | self.headers['username'] = username 58 | if event: 59 | self.headers['event'] = event 60 | if signature: 61 | self.headers['pywebhooks-signature'] = signature 62 | 63 | if http_verb == "PUT": 64 | req = self._session.put( 65 | url=url, 66 | verify=self.verify_ssl, 67 | headers=self.headers, 68 | timeout=self.request_timeout, 69 | data=json_payload) 70 | elif http_verb == 'POST': 71 | req = self._session.post( 72 | url=url, 73 | verify=self.verify_ssl, 74 | headers=self.headers, 75 | timeout=self.request_timeout, 76 | data=json_payload) 77 | elif http_verb == 'PATCH': 78 | req = self._session.patch( 79 | url=url, 80 | verify=self.verify_ssl, 81 | headers=self.headers, 82 | timeout=self.request_timeout, 83 | data=json_payload) 84 | elif http_verb == 'DELETE': 85 | req = self._session.delete( 86 | url=url, 87 | verify=self.verify_ssl, 88 | headers=self.headers, 89 | timeout=self.request_timeout, 90 | params=params) 91 | else: # Default to GET 92 | req = self._session.get( 93 | url=url, 94 | verify=self.verify_ssl, 95 | headers=self.headers, 96 | timeout=self.request_timeout, 97 | params=params) 98 | 99 | return req.json(), req.status_code 100 | -------------------------------------------------------------------------------- /pywebhooks/utils/rethinkdb_helper.py: -------------------------------------------------------------------------------- 1 | import rethinkdb as rethink 2 | 3 | from pywebhooks import DEFAULT_DB_NAME, RETHINK_PORT, \ 4 | RETHINK_HOST, RETHINK_AUTH_KEY 5 | 6 | 7 | def get_connection(): 8 | return rethink.connect( 9 | host=RETHINK_HOST, 10 | port=RETHINK_PORT, 11 | auth_key=RETHINK_AUTH_KEY, 12 | db=DEFAULT_DB_NAME 13 | ) 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | celery 2 | flask 3 | flask-restful 4 | redis 5 | requests 6 | requests-mock 7 | rethinkdb 8 | werkzeug 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | where=tests 3 | nocapture=1 4 | cover-package=pywebhooks 5 | cover-erase=1 6 | [metadata] 7 | description-file=README.rst 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyWebHooks Setup 3 | """ 4 | 5 | try: 6 | from setuptools import setup, find_packages 7 | except ImportError: 8 | from ez_setup import use_setuptools 9 | use_setuptools() 10 | from setuptools import setup, find_packages 11 | 12 | 13 | def read(relative): 14 | """ 15 | Read file contents and return a list of lines. 16 | ie, read the VERSION file 17 | """ 18 | contents = open(relative, 'r').read() 19 | return [l for l in contents.split('\n') if l != ''] 20 | 21 | 22 | with open('README.rst', 'r') as f: 23 | readme = f.read() 24 | 25 | setup( 26 | name='pywebhooks', 27 | url='https://github.com/chadlung/pywebhooks', 28 | keywords=['WebHooks'], 29 | long_description=readme, 30 | version=read('VERSION')[0], 31 | description='WebHooks Service', 32 | author='Chad Lung', 33 | author_email='chad.lung@gmail.com', 34 | tests_require=read('./test-requirements.txt'), 35 | install_requires=read('./requirements.txt'), 36 | test_suite='nose.collector', 37 | zip_safe=False, 38 | include_package_data=True, 39 | classifiers=[ 40 | 'Programming Language :: Python :: 3.6', 41 | 'Development Status :: 4 - Beta', 42 | 'Environment :: Web Environment', 43 | 'Intended Audience :: Developers', 44 | 'License :: OSI Approved :: Apache Software License', 45 | 'Operating System :: MacOS :: MacOS X', 46 | 'Operating System :: POSIX :: Linux' 47 | ], 48 | packages=find_packages(exclude=['ez_setup']), 49 | entry_points={ 50 | 'console_scripts': [ 51 | 'pywebhooks = pywebhooks.app:main' 52 | ] 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | flake8==3.5.0 3 | nose 4 | pep8==1.7.1 5 | pycodestyle==2.3.1 6 | pyflakes==1.6.0 7 | pylint==2.1.1 8 | testtools 9 | tox 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/__init__.py -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/http_interactions.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | # Standard lib imports 4 | # None 5 | 6 | # Third-party imports 7 | # None 8 | 9 | # Project-level imports 10 | from pywebhooks.database.rethinkdb.initialize import create_database 11 | from pywebhooks.database.rethinkdb.drop import drop_database 12 | from pywebhooks.database.rethinkdb.bootstrap_admin import create_admin_account 13 | from pywebhooks.utils.request_handler import RequestHandler 14 | 15 | 16 | BASE_URL = 'http://127.0.0.1:8081/v1/{0}' 17 | 18 | 19 | def initdb(): 20 | drop_database() 21 | create_database() 22 | return create_admin_account() 23 | 24 | 25 | def create_account_records(request_handler): 26 | url = BASE_URL.format('account') 27 | user_keys = {} 28 | 29 | accounts = [{'username': 'johndoe', 30 | 'endpoint': 'http://127.0.0.1:9090/account/endpoint'}, 31 | {'username': 'janedoe', 32 | 'endpoint': 'http://127.0.0.1:9090/account/endpoint'}, 33 | {'username': 'samjones', 34 | 'endpoint': 'http://127.0.0.1:9090/account/endpoint'}, 35 | {'username': 'leahrichards', 36 | 'endpoint': 'http://127.0.0.1:9090/account/endpoint'}] 37 | 38 | for account in accounts: 39 | data, status_code = request_handler.post(url, account) 40 | # Store the generated api and secret keys 41 | user_keys[account['username']] = {'api_key': data['api_key'], 42 | 'secret_key': data['secret_key'], 43 | 'id': data['id']} 44 | return user_keys 45 | 46 | 47 | def get_accounts_records(request_handler, username=None, api_key=None): 48 | url = BASE_URL.format('accounts') 49 | 50 | data, status_code = request_handler.get( 51 | url, params={'start': 0, 'limit': 20}, 52 | api_key=api_key, username=username) 53 | return data, status_code 54 | 55 | 56 | def reset_key(request_handler, username=None, api_key=None, key_type='api_key'): 57 | if key_type == 'api_key': 58 | url = BASE_URL.format('account/reset/apikey') 59 | else: 60 | url = BASE_URL.format('account/reset/secretkey') 61 | 62 | data, status_code = request_handler.post( 63 | url, username=username, api_key=api_key) 64 | return data, status_code 65 | 66 | 67 | def update_account_record(request_handler, username=None, api_key=None, 68 | endpoint=None): 69 | url = BASE_URL.format('account') 70 | 71 | data, status_code = request_handler.patch( 72 | url, json_payload={'endpoint': endpoint}, 73 | username=username, 74 | api_key=api_key) 75 | return data, status_code 76 | 77 | 78 | def get_account_record(request_handler, username=None, api_key=None, 79 | account_id=None): 80 | account_url = 'account/{0}'.format(account_id) 81 | url = BASE_URL.format(account_url) 82 | 83 | data, status_code = request_handler.get(url, username=username, api_key=api_key) 84 | return data, status_code 85 | 86 | 87 | def delete_account_record(request_handler, username=None, api_key=None, 88 | account_id=None): 89 | account_url = 'account/{0}'.format(account_id) 90 | url = BASE_URL.format(account_url) 91 | 92 | data, status_code = request_handler.delete( 93 | url, username=username, api_key=api_key) 94 | return data, status_code 95 | 96 | 97 | def delete_accounts_records(request_handler, username=None, api_key=None): 98 | url = BASE_URL.format('accounts') 99 | 100 | data, status_code = request_handler.delete( 101 | url, username=username, api_key=api_key) 102 | return data, status_code 103 | 104 | 105 | def insert_registration_record(request_handler, username=None, api_key=None, 106 | json_payload={}): 107 | url = BASE_URL.format('webhook/registration') 108 | 109 | data, status_code = request_handler.post( 110 | url, username=username, api_key=api_key, json_payload=json_payload) 111 | return data, status_code 112 | 113 | 114 | def get_registration_records(request_handler, username=None, api_key=None): 115 | url = BASE_URL.format('webhook/registration') 116 | 117 | data, status_code = request_handler.get( 118 | url, params={'start': 0, 'limit': 20}, 119 | username=username, api_key=api_key) 120 | return data, status_code 121 | 122 | 123 | def get_registrations_records(request_handler, username=None, api_key=None): 124 | url = BASE_URL.format('webhook/registrations') 125 | 126 | data, status_code = request_handler.get( 127 | url, params={'start': 0, 'limit': 20}, 128 | username=username, api_key=api_key) 129 | return data, status_code 130 | 131 | 132 | def update_registration_record(request_handler, username=None, api_key=None, 133 | registration_id=None, json_payload={}): 134 | registration_url = 'webhook/registration/{0}'.format(registration_id) 135 | url = BASE_URL.format(registration_url) 136 | 137 | data, status_code = request_handler.patch( 138 | url, json_payload=json_payload, username=username, api_key=api_key) 139 | return data, status_code 140 | 141 | 142 | def delete_registration_record(request_handler, username=None, api_key=None, 143 | registration_id=None): 144 | registration_url = 'webhook/registration/{0}'.format(registration_id) 145 | url = BASE_URL.format(registration_url) 146 | 147 | data, status_code = request_handler.delete( 148 | url, username=username, api_key=api_key) 149 | return data, status_code 150 | 151 | 152 | def delete_registrations_records(request_handler, username=None, api_key=None): 153 | url = BASE_URL.format('webhook/registrations') 154 | 155 | data, status_code = request_handler.delete( 156 | url, username=username, api_key=api_key) 157 | return data, status_code 158 | 159 | 160 | def get_subscription_records(request_handler, username=None, api_key=None): 161 | url = BASE_URL.format('webhook/subscription') 162 | 163 | data, status_code = request_handler.get( 164 | url,params={'start': 0, 'limit': 20}, 165 | username=username, api_key=api_key) 166 | return data, status_code 167 | 168 | 169 | def insert_subscription_record(request_handler, username=None, api_key=None, 170 | registration_id=None): 171 | registration_url = 'webhook/subscription/{0}'.format(registration_id) 172 | url = BASE_URL.format(registration_url) 173 | 174 | data, status_code = request_handler.post( 175 | url, username=username, api_key=api_key) 176 | return data, status_code 177 | 178 | 179 | def get_subscriptions_records(request_handler, username=None, api_key=None): 180 | url = BASE_URL.format('webhook/subscriptions') 181 | 182 | try: 183 | data, status_code = request_handler.get( 184 | url,params={'start': 0, 'limit': 20}, 185 | username=username, api_key=api_key) 186 | except ValueError: 187 | # No records are left 188 | return None, 204 189 | 190 | return data, status_code 191 | 192 | 193 | def delete_subscriptions_records(request_handler, username=None, api_key=None): 194 | url = BASE_URL.format('webhook/subscriptions') 195 | 196 | data, status_code = request_handler.delete( 197 | url, username=username, api_key=api_key) 198 | return data, status_code 199 | 200 | 201 | def delete_subscription_record(request_handler, username=None, api_key=None, 202 | subscription_id=None): 203 | subscription_url = 'webhook/subscription/{0}'.format(subscription_id) 204 | url = BASE_URL.format(subscription_url) 205 | 206 | data, status_code = request_handler.delete( 207 | url, username=username, api_key=api_key) 208 | return data, status_code 209 | 210 | 211 | def insert_triggered_record(request_handler, username=None, api_key=None, 212 | registration_id=None): 213 | registration_url = 'webhook/triggered/{0}'.format(registration_id) 214 | url = BASE_URL.format(registration_url) 215 | 216 | data, status_code = request_handler.post( 217 | url, username=username, api_key=api_key) 218 | return data, status_code 219 | 220 | 221 | def get_triggered_records(request_handler, username=None, api_key=None): 222 | url = BASE_URL.format('webhook/triggered') 223 | 224 | data, status_code = request_handler.get(url, 225 | params={'start': 0, 'limit': 20}, 226 | username=username, 227 | api_key=api_key) 228 | return data, status_code 229 | 230 | 231 | def validate_account_actions(request_handler, users_and_keys=None, 232 | admin_username=None, admin_api_key=None): 233 | john_doe_info = users_and_keys['johndoe'] 234 | jane_doe_info = users_and_keys['janedoe'] 235 | sam_jones_info = users_and_keys['samjones'] 236 | 237 | # Regular (non-admin) users should not be able to list accounts 238 | _, status = get_accounts_records(request_handler, username='johndoe', 239 | api_key=john_doe_info['api_key']) 240 | assert status == 401 241 | 242 | # Reset a secret key 243 | _, status = reset_key(request_handler, username='samjones', 244 | api_key=sam_jones_info['api_key'], 245 | key_type='secret_key') 246 | assert status == 200 247 | 248 | # Reset an API key 249 | _, status = reset_key(request_handler, username='samjones', 250 | key_type='api_key') 251 | assert status == 200 252 | 253 | # Update an endpoint 254 | json_data, status = update_account_record( 255 | request_handler, 'johndoe', api_key=john_doe_info['api_key'], 256 | endpoint='http://127.0.0.1:9090/account/alternate/endpoint') 257 | 258 | assert status == 200 259 | assert json_data['replaced'] == 1 260 | 261 | # Get a single account record (johndoe) 262 | json_data, status = get_account_record( 263 | request_handler, username='johndoe', 264 | api_key=john_doe_info['api_key'], 265 | account_id=john_doe_info['id']) 266 | 267 | assert status == 200 268 | assert json_data['username'] == 'johndoe' 269 | 270 | # Users should not be able to get someone else's account record 271 | json_data, status = get_account_record( 272 | request_handler, username='johndoe', 273 | api_key=john_doe_info['api_key'], 274 | account_id=jane_doe_info['id']) 275 | 276 | assert status == 401 277 | 278 | # User's should not be able to delete another user's account record 279 | _, status = delete_account_record( 280 | request_handler, 281 | username='johndoe', 282 | api_key=john_doe_info['api_key'], 283 | account_id=sam_jones_info['id']) 284 | assert status == 401 285 | 286 | # Admin can delete any account record 287 | json_data, status = delete_account_record( 288 | request_handler, 289 | username=admin_username, 290 | api_key=admin_api_key, 291 | account_id=sam_jones_info['id']) 292 | assert status == 200 293 | assert json_data['deleted'] == 1 294 | 295 | 296 | def validate_misc_actions(request_handler, users_and_keys=None): 297 | sam_jones_info = users_and_keys['samjones'] 298 | leah_richards_info = users_and_keys['leahrichards'] 299 | 300 | # Insert new leah richards registration record 301 | json_data, status = insert_registration_record( 302 | request_handler, username='leahrichards', 303 | api_key=leah_richards_info['api_key'], 304 | json_payload={'event': 'leahrichards.event', 305 | 'description': 'leah richards registered webhook', 306 | 'event_data': {'message': 'Leah Richards'}}) 307 | assert status == 201 308 | leah_richards_registration_id = json_data['id'] 309 | 310 | # User's should not be able to update another user's registration 311 | json_data, status = update_registration_record( 312 | request_handler, username='leahrichards', 313 | api_key=sam_jones_info['api_key'], 314 | registration_id=leah_richards_registration_id, 315 | json_payload={'description': 'leah new'}) 316 | assert status == 401 317 | 318 | # User's should be able to delete their own account 319 | json_data, status = delete_account_record( 320 | request_handler, 321 | username='samjones', 322 | api_key=sam_jones_info['api_key'], 323 | account_id=sam_jones_info['id']) 324 | assert status == 200 325 | assert json_data['deleted'] == 1 326 | 327 | 328 | def validate_registration_actions(request_handler, users_and_keys=None): 329 | john_doe_info = users_and_keys['johndoe'] 330 | jane_doe_info = users_and_keys['janedoe'] 331 | leah_richards_info = users_and_keys['leahrichards'] 332 | 333 | # Insert new janedoe registration record 334 | json_data, status = insert_registration_record( 335 | request_handler, username='janedoe', 336 | api_key=jane_doe_info['api_key'], 337 | json_payload={'event': 'janedoe.event', 338 | 'description': 'jane doe registered webhook', 339 | 'event_data': {'message': 'Jane Doe'}}) 340 | 341 | assert status == 201 342 | jane_doe_registration_id = json_data['id'] 343 | 344 | # Get the user's registration record(s) 345 | json_data, status = get_registration_records(request_handler, 346 | 'janedoe', 347 | jane_doe_info['api_key']) 348 | 349 | assert 'registrations' in json_data 350 | assert len(json_data['registrations']) == 1 351 | 352 | # Insert new johndoe registration record 353 | json_data, status = insert_registration_record( 354 | request_handler, username='johndoe', 355 | api_key=john_doe_info['api_key'], 356 | json_payload={'event': 'johndoe.event', 357 | 'description': 'john doe registered webhook', 358 | 'event_data': {'message': 'John Doe'}}) 359 | 360 | assert status == 201 361 | john_doe_registration_id = json_data['id'] 362 | 363 | # Get the user's registration record(s) 364 | json_data, status = get_registration_records(request_handler, 365 | 'johndoe', 366 | john_doe_info['api_key']) 367 | 368 | assert 'registrations' in json_data 369 | assert len(json_data['registrations']) == 1 370 | 371 | # Insert new leahrichards registration record 372 | json_data, status = insert_registration_record( 373 | request_handler, username='leahrichards', 374 | api_key=leah_richards_info['api_key'], 375 | json_payload={'event': 'leahrichards.event', 376 | 'description': 'leah richards registered webhook', 377 | 'event_data': {'message': 'Leah Richards'}}) 378 | 379 | assert status == 201 380 | leah_richards_registration_id = json_data['id'] 381 | 382 | # Get all registrations (should be 3 of them) 383 | json_data, status = get_registrations_records( 384 | request_handler, 'leahrichards', leah_richards_info['api_key']) 385 | 386 | assert 'registrations' in json_data 387 | assert len(json_data['registrations']) == 3 388 | 389 | # Update a registration record (description) 390 | json_data, status = update_registration_record( 391 | request_handler, username='leahrichards', 392 | api_key=leah_richards_info['api_key'], 393 | registration_id=leah_richards_registration_id, 394 | json_payload={'description': 'leah new'}) 395 | 396 | assert status == 200 397 | assert json_data['replaced'] == 1 398 | 399 | json_data, status = get_registration_records( 400 | request_handler, 'leahrichards', leah_richards_info['api_key']) 401 | 402 | assert len(json_data['registrations']) == 1 403 | assert json_data['registrations'][0]['description'] == 'leah new' 404 | 405 | # Delete registration record 406 | json_data, status = delete_registration_record( 407 | request_handler, username='leahrichards', 408 | api_key=leah_richards_info['api_key'], 409 | registration_id=leah_richards_registration_id) 410 | 411 | assert status == 200 412 | assert json_data['deleted'] == 1 413 | 414 | # Another user should not be able to delete someone else's 415 | # registration record 416 | _, status = delete_registration_record( 417 | request_handler, username='johndoe', 418 | api_key=john_doe_info['api_key'], 419 | registration_id=leah_richards_registration_id) 420 | 421 | assert status == 401 422 | 423 | # Get all registrations (should be 2 of them) 424 | json_data, status = get_registrations_records( 425 | request_handler, 'johndoe', john_doe_info['api_key']) 426 | 427 | assert len(json_data['registrations']) == 2 428 | 429 | return { 430 | 'janedoe': jane_doe_registration_id, 431 | 'johndoe': john_doe_registration_id, 432 | 'leahrichards': leah_richards_registration_id 433 | } 434 | 435 | 436 | def validate_subscription_actions(request_handler, users_and_keys=None, 437 | registration_ids=None, 438 | admin_username=None, admin_api_key=None): 439 | john_doe_info = users_and_keys['johndoe'] 440 | jane_doe_info = users_and_keys['janedoe'] 441 | leah_richards_info = users_and_keys['leahrichards'] 442 | 443 | # You should not be able to create a new subscription with a 444 | # registration id that doesn't exist 445 | json_data, status = insert_subscription_record( 446 | request_handler, username='johndoe', 447 | api_key=john_doe_info['api_key'], 448 | registration_id='01d248ae-babb-4802-8060-47820c3bd018') 449 | 450 | assert status == 404 451 | 452 | # Create a new subscription 453 | json_data, status = insert_subscription_record( 454 | request_handler, username='johndoe', 455 | api_key=john_doe_info['api_key'], 456 | registration_id=registration_ids['janedoe']) 457 | 458 | # John Doe has subscribed to Jane Doe's webhook 459 | assert status == 201 460 | assert json_data['registration_id'] == registration_ids['janedoe'] 461 | assert json_data['account_id'] == john_doe_info['id'] 462 | john_doe_subscription_id = json_data['id'] 463 | 464 | # Another user should not be able to delete someone else's 465 | # registration record. Jane Doe should not be able to delete 466 | # John Doe's subscription 467 | _, status = delete_subscription_record( 468 | request_handler, username='janedoe', 469 | api_key=jane_doe_info['api_key'], 470 | subscription_id=john_doe_subscription_id) 471 | 472 | assert status == 401 473 | 474 | # Insert new leahrichards registration record 475 | json_data, status = insert_registration_record( 476 | request_handler, username='leahrichards', 477 | api_key=leah_richards_info['api_key'], 478 | json_payload={'event': 'leahrichards.event', 479 | 'description': 'leah richards registered webhook', 480 | 'event_data': {'message': 'Leah Richards'}}) 481 | 482 | assert status == 201 483 | 484 | # Create a new subscription 485 | json_data, status = insert_subscription_record( 486 | request_handler, username='johndoe', 487 | api_key=john_doe_info['api_key'], 488 | registration_id=registration_ids['janedoe']) 489 | 490 | # John Doe has subscribed to Leah Richard's webhook 491 | assert status == 201 492 | assert json_data['registration_id'] == registration_ids['janedoe'] 493 | assert json_data['account_id'] == john_doe_info['id'] 494 | 495 | # Get john doe's subscriptions (should be two, leah's and janes') 496 | json_data, status = get_subscription_records( 497 | request_handler, username='johndoe', 498 | api_key=john_doe_info['api_key']) 499 | 500 | assert status == 200 501 | assert 'subscriptions' in json_data 502 | assert len(json_data['subscriptions']) == 2 503 | 504 | # If we delete john doe's account record the subscriptions and 505 | # registrations should be gone as well 506 | # First, get a count of all registrations (should be 3) 507 | json_data, _ = get_registrations_records( 508 | request_handler, username='janedoe', 509 | api_key=jane_doe_info['api_key']) 510 | 511 | assert len(json_data['registrations']) == 3 512 | 513 | # Get a count of all subscription records (should be 2) 514 | json_data, status = get_subscriptions_records( 515 | request_handler, username='janedoe', 516 | api_key=jane_doe_info['api_key']) 517 | 518 | assert len(json_data['subscriptions']) == 2 519 | 520 | json_data, status = delete_account_record( 521 | request_handler, username=admin_username, api_key=admin_api_key, 522 | account_id=john_doe_info['id']) 523 | 524 | assert status == 200 525 | assert json_data['deleted'] == 1 526 | 527 | # Get a count of all registrations (should be 2) 528 | json_data, _ = get_registrations_records( 529 | request_handler, username='janedoe', 530 | api_key=jane_doe_info['api_key']) 531 | 532 | assert len(json_data['registrations']) == 2 533 | 534 | # Get a count of all subscription records 535 | json_data, status = get_subscriptions_records( 536 | request_handler, username='janedoe', 537 | api_key=jane_doe_info['api_key']) 538 | 539 | assert json_data is None 540 | 541 | # Create a new subscription (for delete check later) 542 | _, status = insert_subscription_record( 543 | request_handler, username='leahrichards', 544 | api_key=leah_richards_info['api_key'], 545 | registration_id=registration_ids['janedoe']) 546 | 547 | assert status == 201 548 | 549 | 550 | def validate_accounts_creation(request_handler, username=None, api_key=None): 551 | # List accounts, there should be 5 accounts, the admin and 4 users 552 | json_data, status = get_accounts_records(request_handler, 553 | username=username, 554 | api_key=api_key) 555 | 556 | assert len(json_data['accounts']) == 5 557 | assert status == 200 558 | 559 | 560 | def validate_chain_scenario_one(request_handler, users_and_keys=None): 561 | john_doe_info = users_and_keys['johndoe'] 562 | jane_doe_info = users_and_keys['janedoe'] 563 | sam_jones_info = users_and_keys['samjones'] 564 | leah_richards_info = users_and_keys['leahrichards'] 565 | 566 | # John Doe will create a registration and everyone will subscribe it it 567 | json_data, status = insert_registration_record( 568 | request_handler, username='johndoe', 569 | api_key=john_doe_info['api_key'], 570 | json_payload={'event': 'johndoe.event', 571 | 'description': 'john doe registered webhook', 572 | 'event_data': {'message': 'John Doe'}}) 573 | 574 | assert status == 201 575 | john_doe_registration_id = json_data['id'] 576 | 577 | # Everyone subscribes including John Doe 578 | # JOHN DOE: 579 | json_data, status = insert_subscription_record( 580 | request_handler, username='johndoe', 581 | api_key=john_doe_info['api_key'], 582 | registration_id=john_doe_registration_id) 583 | assert status == 201 584 | # JANE DOE: 585 | json_data, status = insert_subscription_record( 586 | request_handler, username='janedoe', 587 | api_key=jane_doe_info['api_key'], 588 | registration_id=john_doe_registration_id) 589 | assert status == 201 590 | # SAM JONES: 591 | json_data, status = insert_subscription_record( 592 | request_handler, username='samjones', 593 | api_key=sam_jones_info['api_key'], 594 | registration_id=john_doe_registration_id) 595 | assert status == 201 596 | # LEAH RICHARDS: 597 | json_data, status = insert_subscription_record( 598 | request_handler, username='leahrichards', 599 | api_key=leah_richards_info['api_key'], 600 | registration_id=john_doe_registration_id) 601 | assert status == 201 602 | 603 | # Everyone has subscribed, now add one more registration that is not 604 | # john doe's and have leah richards subscribe to it, this subscription 605 | # should not be deleted 606 | # Jane Doe's new registration 607 | json_data, status = insert_registration_record( 608 | request_handler, username='janedoe', 609 | api_key=jane_doe_info['api_key'], 610 | json_payload={'event': 'janedoe.event', 611 | 'description': 'jane doe registered webhook', 612 | 'event_data': {'message': 'Jane Doe'}}) 613 | 614 | assert status == 201 615 | jane_doe_registration_id = json_data['id'] 616 | 617 | # Leah Richards subscribes to Jane Doe's webhook: 618 | json_data, status = insert_subscription_record( 619 | request_handler, username='leahrichards', 620 | api_key=leah_richards_info['api_key'], 621 | registration_id=jane_doe_registration_id) 622 | assert status == 201 623 | 624 | # There should now be 2 registrations 625 | json_data, _ = get_registrations_records( 626 | request_handler, username='janedoe', 627 | api_key=jane_doe_info['api_key']) 628 | 629 | assert len(json_data['registrations']) == 2 630 | 631 | # There should now be 5 subscriptions 632 | json_data, _ = get_subscriptions_records( 633 | request_handler, username='janedoe', 634 | api_key=jane_doe_info['api_key']) 635 | 636 | assert len(json_data['subscriptions']) == 5 637 | 638 | # Delete John Doe's registration 639 | json_data, status = delete_registration_record( 640 | request_handler, username='johndoe', 641 | api_key=john_doe_info['api_key'], 642 | registration_id=john_doe_registration_id) 643 | 644 | assert status == 200 645 | assert json_data['deleted'] == 1 646 | 647 | # There should now be 1 registration 648 | json_data, _ = get_registrations_records( 649 | request_handler, username='janedoe', 650 | api_key=jane_doe_info['api_key']) 651 | 652 | assert len(json_data['registrations']) == 1 653 | # That last subscription should belong to Jane Doe 654 | assert json_data['registrations'][0]['account_id'] == \ 655 | jane_doe_info['id'] 656 | 657 | # There should now be 1 subscription 658 | json_data, _ = get_subscriptions_records( 659 | request_handler, username='janedoe', 660 | api_key=jane_doe_info['api_key']) 661 | 662 | assert len(json_data['subscriptions']) == 1 663 | # That last subscription should belong to Leah Richard 664 | assert json_data['subscriptions'][0]['account_id'] == \ 665 | leah_richards_info['id'] 666 | 667 | 668 | def validate_chain_scenario_two(request_handler, users_and_keys=None, 669 | admin_username=None, admin_api_key=None): 670 | john_doe_info = users_and_keys['johndoe'] 671 | jane_doe_info = users_and_keys['janedoe'] 672 | sam_jones_info = users_and_keys['samjones'] 673 | leah_richards_info = users_and_keys['leahrichards'] 674 | 675 | # Like scenario #1, John Doe will create a registration and everyone 676 | # will subscribe it it 677 | json_data, status = insert_registration_record( 678 | request_handler, username='johndoe', 679 | api_key=john_doe_info['api_key'], 680 | json_payload={'event': 'johndoe.event', 681 | 'description': 'john doe registered webhook', 682 | 'event_data': {'message': 'John Doe'}}) 683 | 684 | assert status == 201 685 | john_doe_registration_id = json_data['id'] 686 | 687 | # Everyone subscribes including John Doe 688 | # JOHN DOE: 689 | json_data, status = insert_subscription_record( 690 | request_handler, username='johndoe', 691 | api_key=john_doe_info['api_key'], 692 | registration_id=john_doe_registration_id) 693 | assert status == 201 694 | # JANE DOE: 695 | json_data, status = insert_subscription_record( 696 | request_handler, username='janedoe', 697 | api_key=jane_doe_info['api_key'], 698 | registration_id=john_doe_registration_id) 699 | assert status == 201 700 | # SAM JONES: 701 | json_data, status = insert_subscription_record( 702 | request_handler, username='samjones', 703 | api_key=sam_jones_info['api_key'], 704 | registration_id=john_doe_registration_id) 705 | assert status == 201 706 | # LEAH RICHARDS: 707 | json_data, status = insert_subscription_record( 708 | request_handler, username='leahrichards', 709 | api_key=leah_richards_info['api_key'], 710 | registration_id=john_doe_registration_id) 711 | assert status == 201 712 | 713 | # Everyone has subscribed, now add one more registration that is not 714 | # john doe's and have leah richards subscribe to it, this subscription 715 | # should not be deleted 716 | # Jane Doe's new registration 717 | json_data, status = insert_registration_record( 718 | request_handler, username='janedoe', 719 | api_key=jane_doe_info['api_key'], 720 | json_payload={'event': 'janedoe.event', 721 | 'description': 'jane doe registered webhook', 722 | 'event_data': {'message': 'Jane Doe'}}) 723 | 724 | assert status == 201 725 | jane_doe_registration_id = json_data['id'] 726 | 727 | # Leah Richards subscribes to Jane Doe's webhook: 728 | json_data, status = insert_subscription_record( 729 | request_handler, username='leahrichards', 730 | api_key=leah_richards_info['api_key'], 731 | registration_id=jane_doe_registration_id) 732 | assert status == 201 733 | 734 | # There should now be 2 registrations 735 | json_data, _ = get_registrations_records( 736 | request_handler, username='janedoe', 737 | api_key=jane_doe_info['api_key']) 738 | 739 | assert len(json_data['registrations']) == 2 740 | 741 | # There should now be 5 subscriptions 742 | json_data, _ = get_subscriptions_records( 743 | request_handler, username='janedoe', 744 | api_key=jane_doe_info['api_key']) 745 | 746 | assert len(json_data['subscriptions']) == 5 747 | 748 | # Delete John Doe's account 749 | json_data, status = delete_account_record( 750 | request_handler, username=admin_username, 751 | api_key=admin_api_key, 752 | account_id=john_doe_info['id']) 753 | 754 | assert status == 200 755 | assert json_data['deleted'] == 1 756 | 757 | # There should now be 1 registration 758 | json_data, _ = get_registrations_records( 759 | request_handler, username='janedoe', 760 | api_key=jane_doe_info['api_key']) 761 | 762 | assert len(json_data['registrations']) == 1 763 | # That last subscription should belong to Jane Doe 764 | assert json_data['registrations'][0]['account_id'] == \ 765 | jane_doe_info['id'] 766 | 767 | # There should now be 1 subscription 768 | json_data, _ = get_subscriptions_records( 769 | request_handler, username='janedoe', 770 | api_key=jane_doe_info['api_key']) 771 | 772 | assert len(json_data['subscriptions']) == 1 773 | # That last subscription should belong to Leah Richard 774 | assert json_data['subscriptions'][0]['account_id'] == \ 775 | leah_richards_info['id'] 776 | 777 | 778 | def validate_webhook_actions(request_handler, users_and_keys=None): 779 | john_doe_info = users_and_keys['johndoe'] 780 | jane_doe_info = users_and_keys['janedoe'] 781 | sam_jones_info = users_and_keys['samjones'] 782 | leah_richards_info = users_and_keys['leahrichards'] 783 | 784 | # John Doe will create a registration and everyone 785 | # will subscribe it it 786 | json_data, status = insert_registration_record( 787 | request_handler, username='johndoe', 788 | api_key=john_doe_info['api_key'], 789 | json_payload={'event': 'johndoe.event', 790 | 'description': 'john doe registered webhook', 791 | 'event_data': {'message': 'John Doe'}}) 792 | 793 | assert status == 201 794 | john_doe_registration_id = json_data['id'] 795 | 796 | # Everyone subscribes except John Doe 797 | # JANE DOE: 798 | json_data, status = insert_subscription_record( 799 | request_handler, username='janedoe', 800 | api_key=jane_doe_info['api_key'], 801 | registration_id=john_doe_registration_id) 802 | assert status == 201 803 | # SAM JONES: 804 | json_data, status = insert_subscription_record( 805 | request_handler, username='samjones', 806 | api_key=sam_jones_info['api_key'], 807 | registration_id=john_doe_registration_id) 808 | assert status == 201 809 | # LEAH RICHARDS: 810 | json_data, status = insert_subscription_record( 811 | request_handler, username='leahrichards', 812 | api_key=leah_richards_info['api_key'], 813 | registration_id=john_doe_registration_id) 814 | assert status == 201 815 | 816 | # John Doe will trigger his registered webhook 817 | json_data, status = insert_triggered_record( 818 | request_handler, 819 | username='johndoe', 820 | api_key=john_doe_info['api_key'], 821 | registration_id=john_doe_registration_id) 822 | assert status == 201 823 | assert json_data['registration_id'] == john_doe_registration_id 824 | 825 | # There should be one triggered webhook record 826 | json_data, status = get_triggered_records( 827 | request_handler, 828 | username='johndoe', 829 | api_key=john_doe_info['api_key'] 830 | ) 831 | assert status == 200 832 | assert 'triggered_webhooks' in json_data 833 | assert json_data['triggered_webhooks'][0]['registration_id'] == \ 834 | john_doe_registration_id 835 | 836 | # Other people cannot trigger webhooks other than their own 837 | json_data, status = insert_triggered_record( 838 | request_handler, 839 | username='leahrichards', 840 | api_key=leah_richards_info['api_key'], 841 | registration_id=john_doe_registration_id) 842 | assert status == 401 843 | 844 | 845 | def validate_table_deletions(request_handler, admin_username=None, 846 | admin_api_key=None): 847 | json_data, status = delete_accounts_records( 848 | request_handler, username=admin_username, api_key=admin_api_key) 849 | assert status == 200 850 | assert json_data['deleted'] == 2 851 | 852 | json_data, status = delete_subscriptions_records( 853 | request_handler, username=admin_username, api_key=admin_api_key) 854 | assert status == 200 855 | assert json_data['deleted'] == 1 856 | 857 | json_data, status = delete_registrations_records( 858 | request_handler, username=admin_username, api_key=admin_api_key) 859 | assert status == 200 860 | assert json_data['deleted'] == 2 861 | 862 | 863 | def perform_table_deletions(request_handler, admin_username=None, 864 | admin_api_key=None): 865 | json_data, status = delete_accounts_records( 866 | request_handler, username=admin_username, api_key=admin_api_key) 867 | assert status == 200 868 | 869 | json_data, status = delete_subscriptions_records( 870 | request_handler, username=admin_username, api_key=admin_api_key) 871 | assert status == 200 872 | 873 | json_data, status = delete_registrations_records( 874 | request_handler, username=admin_username, api_key=admin_api_key) 875 | assert status == 200 876 | 877 | 878 | def run_tests(): 879 | """ 880 | Running this code requires you have RethinkDB setup and running as well 881 | as Redis and the Celery worker. These tests should run through all the 882 | possible scenarios. 883 | """ 884 | request_handler = RequestHandler(verify_ssl=False, 885 | request_timeout=10.0) 886 | 887 | print('Functional Testing Starting...') 888 | 889 | # Start with a clean database 890 | admin_account = initdb() 891 | 892 | admin_username = 'admin' 893 | admin_api_key = admin_account['api_key'] 894 | 895 | # Create four user accounts 896 | users_and_keys = create_account_records(request_handler) 897 | 898 | # ********************************** 899 | # *** Validate Account Creations *** 900 | # ********************************** 901 | validate_accounts_creation(request_handler, 902 | username=admin_username, 903 | api_key=admin_api_key) 904 | 905 | # ******************************** 906 | # *** Validate Account Actions *** 907 | # ******************************** 908 | validate_account_actions( 909 | request_handler, users_and_keys=users_and_keys, 910 | admin_username=admin_username, admin_api_key=admin_api_key) 911 | 912 | # ************************************* 913 | # *** Validate Registration Actions *** 914 | # ************************************* 915 | user_registration_ids = validate_registration_actions( 916 | request_handler, users_and_keys=users_and_keys) 917 | 918 | # ************************************* 919 | # *** Validate Subscription Actions *** 920 | # ************************************* 921 | validate_subscription_actions( 922 | request_handler, 923 | users_and_keys=users_and_keys, 924 | registration_ids=user_registration_ids, 925 | admin_username=admin_username, 926 | admin_api_key=admin_api_key) 927 | 928 | # *************************************** 929 | # *** Validate Table Deletion Actions *** 930 | # *************************************** 931 | validate_table_deletions( 932 | request_handler, 933 | admin_username=admin_username, 934 | admin_api_key=admin_api_key) 935 | 936 | # ***************************************** 937 | # *** Validate Chained Deletion Actions *** 938 | # ***************************************** 939 | # 940 | # The database should be empty so run the more complex tests: 941 | # 942 | # 1.) When deleting a registration record it should also remove the records 943 | # for that registration_id in the subscription table 944 | # 945 | # 2.) When deleting an account it should delete that user's account, 946 | # registrations and subscriptions. It should also delete other user's 947 | # subscriptions to those registrations that were deleted 948 | # 949 | # Re-populate the users and test scenario #1 950 | validate_chain_scenario_one( 951 | request_handler, 952 | users_and_keys=create_account_records(request_handler)) 953 | 954 | # Clean the tables out again (no need to count the deletes as this has been 955 | # tested prior 956 | perform_table_deletions( 957 | request_handler, 958 | admin_username=admin_username, 959 | admin_api_key=admin_api_key) 960 | # Re-populate the users and test scenario #2 961 | validate_chain_scenario_two( 962 | request_handler, 963 | users_and_keys=create_account_records(request_handler), 964 | admin_username=admin_username, 965 | admin_api_key=admin_api_key) 966 | 967 | # ******************************* 968 | # *** Validate Misc. Actions *** 969 | # ******************************* 970 | 971 | # Clean the tables out again (no need to count the deletes as this has been 972 | # tested prior 973 | perform_table_deletions( 974 | request_handler, 975 | admin_username=admin_username, 976 | admin_api_key=admin_api_key) 977 | # These are tests that don't work with the flow prior so they are on 978 | # their own 979 | validate_misc_actions( 980 | request_handler, 981 | users_and_keys=create_account_records(request_handler)) 982 | 983 | # ******************************** 984 | # *** Validate WebHook Actions *** 985 | # ******************************** 986 | 987 | # Clean the tables out again (no need to count the deletes as this has been 988 | # tested prior 989 | perform_table_deletions( 990 | request_handler, 991 | admin_username=admin_username, 992 | admin_api_key=admin_api_key) 993 | 994 | validate_webhook_actions( 995 | request_handler, 996 | users_and_keys=create_account_records(request_handler)) 997 | 998 | print('Functional Testing Complete') 999 | 1000 | 1001 | if __name__ == "__main__": 1002 | run_tests() 1003 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/api/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/api/decorators/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/decorators/test_authorization.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | # None 8 | 9 | # Project level imports 10 | from pywebhooks.app import create_wsgi_app 11 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 12 | from pywebhooks.api.decorators.authorization import api_key_restricted_resource 13 | from pywebhooks.database.rethinkdb.interactions import Interactions 14 | 15 | 16 | def suite(): 17 | test_suite = unittest.TestSuite() 18 | test_suite.addTest(WhenTestingAuthorization()) 19 | return test_suite 20 | 21 | 22 | class WhenTestingAuthorization(unittest.TestCase): 23 | 24 | def setUp(self): 25 | self.app = create_wsgi_app() 26 | self.app.config['TESTING'] = True 27 | self.test_headers = [('api-key', '12345'), ('username', 'johndoe')] 28 | 29 | def test_validate_id_params_unauthorized(self): 30 | with patch.object(Interactions, 'query', return_value=False) as \ 31 | query_method: 32 | @api_key_restricted_resource(verify_admin=False) 33 | def test_func(): 34 | pass 35 | 36 | missing_api_key_header = [('test', 'test')] 37 | 38 | with self.app.test_request_context(headers=missing_api_key_header): 39 | response = test_func() 40 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 41 | 42 | missing_username_header = [('api-key', '12345')] 43 | 44 | with self.app.test_request_context(headers=missing_username_header): 45 | response = test_func() 46 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 47 | 48 | with self.app.test_request_context(headers=self.test_headers): 49 | response = test_func() 50 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 51 | 52 | query_method.assert_called_with( 53 | DEFAULT_ACCOUNTS_TABLE, 54 | filters={'username': 'johndoe'} 55 | ) 56 | 57 | def test_validate_id_params_unauthorized_invalid_api_key(self): 58 | with patch.object(Interactions, 'query', 59 | return_value=[{'api_key': '12345'}]): 60 | 61 | @api_key_restricted_resource(verify_admin=False) 62 | def test_func(): 63 | pass 64 | 65 | with self.app.test_request_context(headers=self.test_headers): 66 | response = test_func() 67 | self.assertEqual( 68 | response.data, 69 | b'{"Error":"Invalid API key"}\n' 70 | ) 71 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 72 | -------------------------------------------------------------------------------- /tests/unit/api/decorators/test_validation.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | # None 8 | 9 | # Project level imports 10 | from pywebhooks.app import create_wsgi_app 11 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 12 | from pywebhooks.api.decorators.validation import validate_id_params,\ 13 | validate_username_in_header, validate_pagination_params 14 | from pywebhooks.database.rethinkdb.interactions import Interactions 15 | 16 | 17 | def suite(): 18 | test_suite = unittest.TestSuite() 19 | test_suite.addTest(WhenTestingValidation()) 20 | return test_suite 21 | 22 | 23 | class WhenTestingValidation(unittest.TestCase): 24 | 25 | def setUp(self): 26 | self.app = create_wsgi_app() 27 | self.app.config['TESTING'] = True 28 | 29 | def test_validate_id_params_bad_request(self): 30 | 31 | @validate_id_params(None) 32 | def test_func(): 33 | pass 34 | 35 | with self.app.test_request_context(): 36 | response = test_func() 37 | self.assertEqual(response.status_code, client.BAD_REQUEST) 38 | 39 | def test_validate_username_in_header_bad_request(self): 40 | 41 | @validate_username_in_header() 42 | def test_func(): 43 | pass 44 | 45 | with self.app.test_request_context(): 46 | response = test_func() 47 | self.assertEqual(response.status_code, client.BAD_REQUEST) 48 | 49 | def test_validate_username_in_header_not_found(self): 50 | with patch.object(Interactions, 'query', return_value=False): 51 | @validate_username_in_header() 52 | def test_func(): 53 | pass 54 | 55 | test_header = [('username', 'johndoe')] 56 | 57 | with self.app.test_request_context(headers=test_header): 58 | response = test_func() 59 | self.assertEqual(response.status_code, client.NOT_FOUND) 60 | 61 | def test_validate_username_in_header(self): 62 | with patch.object(Interactions, 'query', return_value=True) as \ 63 | query_method: 64 | @validate_username_in_header() 65 | def test_func(): 66 | pass 67 | 68 | test_header = [('username', 'johndoe')] 69 | 70 | with self.app.test_request_context(headers=test_header): 71 | test_func() 72 | query_method.assert_called_with( 73 | DEFAULT_ACCOUNTS_TABLE, 74 | filters={'username': 'johndoe'} 75 | ) 76 | 77 | def test_validate_pagination_params_invalid_start(self): 78 | @validate_pagination_params() 79 | def test_func(): 80 | pass 81 | 82 | with self.app.test_request_context('/?limit=10&start=-1'): 83 | response = test_func() 84 | self.assertEqual(response.status_code, client.BAD_REQUEST) 85 | 86 | with self.app.test_request_context('/?limit=10' 87 | '&start=9999999999999991'): 88 | response = test_func() 89 | self.assertEqual(response.status_code, client.BAD_REQUEST) 90 | 91 | def test_validate_pagination_params_invalid_limit(self): 92 | @validate_pagination_params() 93 | def test_func(): 94 | pass 95 | 96 | with self.app.test_request_context('/?limit=-1&start=0'): 97 | response = test_func() 98 | self.assertEqual(response.status_code, client.BAD_REQUEST) 99 | 100 | with self.app.test_request_context('/?limit=101&start=0'): 101 | response = test_func() 102 | self.assertEqual(response.status_code, client.BAD_REQUEST) 103 | 104 | def test_validate_pagination_params(self): 105 | @validate_pagination_params() 106 | def test_func(): 107 | pass 108 | 109 | with self.app.test_request_context('/?limit=1&start=0'): 110 | self.assertIsNone(test_func()) 111 | -------------------------------------------------------------------------------- /tests/unit/api/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/api/handlers/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/handlers/test_pagination_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | from flask import request 8 | 9 | # Project level imports 10 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 11 | from pywebhooks.api.handlers.pagination_handler import paginate 12 | from pywebhooks.app import create_wsgi_app 13 | from pywebhooks.database.rethinkdb.interactions import Interactions 14 | 15 | 16 | def suite(): 17 | test_suite = unittest.TestSuite() 18 | test_suite.addTest(WhenTestingpaginationHanlder()) 19 | return test_suite 20 | 21 | 22 | class WhenTestingpaginationHanlder(unittest.TestCase): 23 | 24 | def setUp(self): 25 | self.app = create_wsgi_app() 26 | self.app.config['TESTING'] = True 27 | 28 | self.returned_records = \ 29 | [{'description': 'jane doe registered webhook', 30 | 'event_data': {'message': 'Jane Doe'}, 31 | 'epoch': 1441563242.268688, 32 | 'account_id': '04ee97a8-2f77-4117-bc96-fe8a33497c36', 33 | 'id': '069bce36-b2bf-4771-96c5-468eb37665d5', 34 | 'event': 'janedoe.event'}, 35 | {'description': 'john doe registered webhook', 36 | 'event_data': {'message': 'John Doe'}, 37 | 'epoch': 1441563242.300409, 38 | 'account_id': 'd382e86c-913d-4a06-abdc-232b963a8f8f', 39 | 'id': '047e549a-24a1-4194-8a41-c56b525cb815', 40 | 'event': 'johndoe.event'}, 41 | { 42 | 'description': 'leah richards registered webhook', 43 | 'event_data': {'message': 'Leah Richards'}, 44 | 'epoch': 1441563242.331244, 45 | 'account_id': '45012169-902e-4d24-80ba-d2f2061baef3', 46 | 'id': '50e2148f-7b43-4b85-a524-4dc64fc521fc', 47 | 'event': 'leahrichards.event'}] 48 | 49 | def test_validate_pagination_params_no_content(self): 50 | with self.app.test_request_context('/?limit=10&start=0', method='GET'): 51 | with patch.object(Interactions, 'list', return_value=[]): 52 | response = paginate( 53 | request, DEFAULT_ACCOUNTS_TABLE, 'accounts' 54 | ) 55 | self.assertEqual(response.status_code, client.NO_CONTENT) 56 | 57 | def test_validate_pagination_params(self): 58 | with self.app.test_request_context('/?limit=10&start=0', method='GET'): 59 | with patch.object(Interactions, 'list', 60 | return_value=self.returned_records): 61 | response = paginate( 62 | request, DEFAULT_ACCOUNTS_TABLE, 'accounts', filters=None 63 | ) 64 | self.assertEqual(response.status_code, client.OK) 65 | self.assertTrue('next_start' not in str(response.data)) 66 | 67 | def test_validate_pagination_params_wth_next_marker(self): 68 | with self.app.test_request_context('/?limit=1&start=0', method='GET'): 69 | with patch.object(Interactions, 'list', 70 | return_value=self.returned_records): 71 | response = paginate( 72 | request, DEFAULT_ACCOUNTS_TABLE, 'accounts' 73 | ) 74 | self.assertTrue('next_start' in str(response.data)) 75 | self.assertEqual(response.status_code, client.OK) 76 | -------------------------------------------------------------------------------- /tests/unit/api/handlers/test_resources_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import Mock 5 | from unittest.mock import patch 6 | 7 | # Third party imports 8 | import rethinkdb as rethink 9 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 10 | import requests_mock 11 | 12 | # Project level imports 13 | from pywebhooks.app import create_wsgi_app 14 | from pywebhooks import DEFAULT_REGISTRATIONS_TABLE, \ 15 | DEFAULT_SUBSCRIPTIONS_TABLE, DEFAULT_ACCOUNTS_TABLE 16 | from pywebhooks.database.rethinkdb.interactions import Interactions 17 | from pywebhooks.api.handlers.resources_handler import \ 18 | registration_id_exists, lookup_subscription_id, lookup_registration_id, \ 19 | lookup_account_id, validate_access, update, query, delete_all, \ 20 | delete_accounts_except_admins, delete_registration, delete, insert, \ 21 | insert_account, delete_account, client_reset_key, client_echo_valid, \ 22 | reset_key 23 | 24 | 25 | def suite(): 26 | test_suite = unittest.TestSuite() 27 | test_suite.addTest(WhenTestingResourcesHandler()) 28 | return test_suite 29 | 30 | 31 | class WhenTestingResourcesHandler(unittest.TestCase): 32 | 33 | def setUp(self): 34 | self.app = create_wsgi_app() 35 | self.app.config['TESTING'] = True 36 | 37 | self.param_kwargs = { 38 | 'username': 'johndoe', 39 | 'api_key': '123456789abcdef' 40 | } 41 | 42 | def test_registration_id_exists(self): 43 | with patch.object(Interactions, 'query', return_value=True) as \ 44 | query_method: 45 | 46 | self.assertTrue(registration_id_exists('123')) 47 | 48 | query_method.assert_called_with( 49 | DEFAULT_REGISTRATIONS_TABLE, 50 | filters={'id': '123'} 51 | ) 52 | 53 | with patch.object(Interactions, 'query', return_value=False) as \ 54 | query_method: 55 | 56 | self.assertFalse(registration_id_exists('321')) 57 | 58 | query_method.assert_called_with( 59 | DEFAULT_REGISTRATIONS_TABLE, 60 | filters={'id': '321'} 61 | ) 62 | 63 | def test_lookup_subscription_id(self): 64 | filters = {'account_id': '12345', 'id': '55555'} 65 | 66 | with patch.object(Interactions, 'query', return_value=None) as \ 67 | query_method: 68 | 69 | lookup_subscription_id('12345', '55555') 70 | 71 | query_method.assert_called_with( 72 | DEFAULT_SUBSCRIPTIONS_TABLE, 73 | filters=filters 74 | ) 75 | 76 | def test_lookup_registration_id(self): 77 | filters = {'account_id': '4545', 'id': '5353'} 78 | 79 | with patch.object(Interactions, 'query', return_value=None) as \ 80 | query_method: 81 | 82 | lookup_registration_id('4545', '5353') 83 | 84 | query_method.assert_called_with( 85 | DEFAULT_REGISTRATIONS_TABLE, 86 | filters=filters 87 | ) 88 | 89 | def test_lookup_account_id(self): 90 | return_value = [ 91 | { 92 | 'id': '123' 93 | } 94 | ] 95 | 96 | filters = {'username': 'johndoe'} 97 | 98 | with patch.object(Interactions, 'query', 99 | return_value=return_value) as query_method: 100 | 101 | ret = lookup_account_id('johndoe') 102 | 103 | self.assertEqual(ret, '123') 104 | 105 | query_method.assert_called_with( 106 | DEFAULT_ACCOUNTS_TABLE, 107 | filters=filters 108 | ) 109 | 110 | def test_validate_access_admin(self): 111 | self.assertIsNone(validate_access('admin')) 112 | 113 | @patch('pywebhooks.api.handlers.resources_handler.lookup_account_id') 114 | @patch('pywebhooks.api.handlers.resources_handler.lookup_registration_id') 115 | def test_validate_access_registration_id(self, 116 | lookup_registration_id_method, 117 | lookup_account_id_method,): 118 | with self.app.test_request_context(): 119 | account_id = '555' 120 | registration_id = '444' 121 | 122 | lookup_account_id_method.return_value = account_id 123 | lookup_registration_id_method.return_value = True 124 | return_value = validate_access('fred', registration_id='444') 125 | 126 | self.assertIsNone(return_value) 127 | lookup_account_id_method.assert_called_with('fred') 128 | lookup_registration_id_method.assert_called_with( 129 | account_id, registration_id) 130 | lookup_registration_id_method.return_value = False 131 | response = validate_access('fred', registration_id='444') 132 | 133 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 134 | 135 | @patch('pywebhooks.api.handlers.resources_handler.lookup_account_id') 136 | @patch('pywebhooks.api.handlers.resources_handler.lookup_subscription_id') 137 | def test_validate_access_subscription_id(self, 138 | lookup_subscription_id_method, 139 | lookup_account_id_method,): 140 | with self.app.test_request_context(): 141 | account_id = '123' 142 | subscription_id = '775' 143 | 144 | lookup_account_id_method.return_value = account_id 145 | lookup_subscription_id_method.return_value = True 146 | return_value = validate_access('fred', subscription_id='775') 147 | 148 | self.assertIsNone(return_value) 149 | lookup_account_id_method.assert_called_with('fred') 150 | 151 | lookup_subscription_id_method.assert_called_with( 152 | account_id, subscription_id) 153 | lookup_subscription_id_method.return_value = False 154 | response = validate_access('fred', subscription_id='775') 155 | 156 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 157 | 158 | @patch('pywebhooks.api.handlers.resources_handler.lookup_account_id') 159 | def test_validate_access_incoming_account_id(self, 160 | lookup_account_id_method): 161 | 162 | with self.app.test_request_context(): 163 | account_id = '111222' 164 | lookup_account_id_method.return_value = account_id 165 | 166 | response = validate_access( 167 | 'fred', incoming_account_id='333444') 168 | 169 | lookup_account_id_method.assert_called_with('fred') 170 | self.assertEqual(response.status_code, client.UNAUTHORIZED) 171 | 172 | response = validate_access( 173 | 'fred', incoming_account_id='111222') 174 | self.assertIsNone(response) 175 | 176 | @patch('pywebhooks.database.rethinkdb.interactions.get_connection') 177 | def test_update_bad_request(self, connection_method): 178 | connection_method.return_value = Mock(__enter__=Mock, __exit__=Mock()) 179 | with self.app.test_request_context(): 180 | with patch.object(rethink, 'table', return_value=Mock()) as \ 181 | table_method: 182 | response = update(DEFAULT_REGISTRATIONS_TABLE, 183 | record_id='123', 184 | username=None, 185 | updates={}) 186 | self.assertEqual(response.status_code, client.BAD_REQUEST) 187 | table_method.assert_called_once_with( 188 | DEFAULT_REGISTRATIONS_TABLE 189 | ) 190 | 191 | def test_update_record_id(self): 192 | with self.app.test_request_context(): 193 | with patch.object(Interactions, 'update', return_value={}): 194 | response = update(DEFAULT_REGISTRATIONS_TABLE, 195 | record_id='123', 196 | username=None, 197 | updates={}) 198 | self.assertEqual(response.status_code, client.OK) 199 | 200 | def test_update_rql_runtime_error(self): 201 | with self.app.test_request_context(): 202 | with patch.object(Interactions, 'update', 203 | side_effect=RqlRuntimeError(None, None, None)): 204 | response = update(DEFAULT_REGISTRATIONS_TABLE, 205 | record_id='123', username=None, updates={}) 206 | self.assertRaises(RqlRuntimeError) 207 | self.assertEqual(response.status_code, 208 | client.INTERNAL_SERVER_ERROR) 209 | 210 | def test_update_rql_driver_error(self): 211 | with self.app.test_request_context(): 212 | with patch.object(Interactions, 'update', 213 | side_effect=RqlDriverError(None)): 214 | update(DEFAULT_REGISTRATIONS_TABLE, 215 | record_id='123', 216 | username=None, 217 | updates={}) 218 | self.assertRaises(RqlDriverError) 219 | 220 | def test_update_no_record_id(self): 221 | with self.app.test_request_context(): 222 | with patch.object(Interactions, 'update', return_value={}): 223 | response = update(DEFAULT_REGISTRATIONS_TABLE, 224 | record_id=None, 225 | username='johndoe', 226 | updates={}) 227 | self.assertEqual(response.status_code, client.OK) 228 | 229 | def test_query(self): 230 | with self.app.test_request_context(): 231 | with patch.object(Interactions, 'get', return_value={}): 232 | response = query(DEFAULT_REGISTRATIONS_TABLE, record_id='123') 233 | self.assertEqual(response.status_code, client.OK) 234 | 235 | def test_query_rql_runtime_error(self): 236 | with self.app.test_request_context(): 237 | with patch.object(Interactions, 'get', 238 | side_effect=RqlRuntimeError(None, None, None)): 239 | response = query(DEFAULT_REGISTRATIONS_TABLE, record_id='123') 240 | self.assertRaises(RqlRuntimeError) 241 | self.assertEqual(response.status_code, 242 | client.INTERNAL_SERVER_ERROR) 243 | 244 | def test_query_rql_driver_error(self): 245 | with self.app.test_request_context(): 246 | with patch.object(Interactions, 'get', 247 | side_effect=RqlDriverError(None)): 248 | response = query(DEFAULT_REGISTRATIONS_TABLE, record_id='123') 249 | self.assertRaises(RqlDriverError) 250 | self.assertEqual(response.status_code, 251 | client.INTERNAL_SERVER_ERROR) 252 | 253 | def test_delete_all(self): 254 | with self.app.test_request_context(): 255 | with patch.object(Interactions, 'delete_all', return_value={}): 256 | response = delete_all(DEFAULT_REGISTRATIONS_TABLE) 257 | self.assertEqual(response.status_code, client.OK) 258 | 259 | def test_delete_all_rql_runtime_error(self): 260 | with self.app.test_request_context(): 261 | with patch.object(Interactions, 'delete_all', 262 | side_effect=RqlRuntimeError(None, None, None)): 263 | response = delete_all(DEFAULT_REGISTRATIONS_TABLE) 264 | self.assertRaises(RqlRuntimeError) 265 | self.assertEqual(response.status_code, 266 | client.INTERNAL_SERVER_ERROR) 267 | 268 | def test_delete_all_rql_driver_error(self): 269 | with self.app.test_request_context(): 270 | with patch.object(Interactions, 'delete_all', 271 | side_effect=RqlDriverError(None)): 272 | response = delete_all(DEFAULT_REGISTRATIONS_TABLE) 273 | self.assertRaises(RqlDriverError) 274 | self.assertEqual(response.status_code, 275 | client.INTERNAL_SERVER_ERROR) 276 | 277 | def test_delete_accounts_except_admins(self): 278 | with self.app.test_request_context(): 279 | with patch.object(Interactions, 'delete_specific', 280 | return_value={}): 281 | response = delete_accounts_except_admins() 282 | self.assertEqual(response.status_code, client.OK) 283 | 284 | def test_delete_accounts_except_admins_rql_runtime_error(self): 285 | with self.app.test_request_context(): 286 | with patch.object(Interactions, 'delete_specific', 287 | side_effect=RqlRuntimeError(None, None, None)): 288 | response = delete_accounts_except_admins() 289 | self.assertRaises(RqlRuntimeError) 290 | self.assertEqual(response.status_code, 291 | client.INTERNAL_SERVER_ERROR) 292 | 293 | def test_delete_accounts_except_admins_rql_driver_error(self): 294 | with self.app.test_request_context(): 295 | with patch.object(Interactions, 'delete_specific', 296 | side_effect=RqlDriverError(None)): 297 | response = delete_accounts_except_admins() 298 | self.assertRaises(RqlDriverError) 299 | self.assertEqual(response.status_code, 300 | client.INTERNAL_SERVER_ERROR) 301 | 302 | def test_delete_registration(self): 303 | with self.app.test_request_context(): 304 | with patch.object(Interactions, 'delete_specific', 305 | return_value={}): 306 | response = delete_registration('123') 307 | self.assertEqual(response.status_code, client.OK) 308 | 309 | def test_delete_registration_rql_runtime_error(self): 310 | with self.app.test_request_context(): 311 | with patch.object(Interactions, 'delete_specific', 312 | side_effect=RqlRuntimeError(None, None, None)): 313 | response = delete_registration('123') 314 | self.assertRaises(RqlRuntimeError) 315 | self.assertEqual(response.status_code, 316 | client.INTERNAL_SERVER_ERROR) 317 | 318 | def test_delete_registration_rql_driver_error(self): 319 | with self.app.test_request_context(): 320 | with patch.object(Interactions, 'delete_specific', 321 | side_effect=RqlDriverError(None)): 322 | response = delete_registration('123') 323 | self.assertRaises(RqlDriverError) 324 | self.assertEqual(response.status_code, 325 | client.INTERNAL_SERVER_ERROR) 326 | 327 | def test_delete_registration_type_error(self): 328 | with self.app.test_request_context(): 329 | with patch.object(Interactions, 'delete_specific', 330 | side_effect=TypeError): 331 | response = delete_registration('123') 332 | self.assertRaises(TypeError) 333 | self.assertEqual(response.status_code, 334 | client.BAD_REQUEST) 335 | 336 | def test_delete(self): 337 | with self.app.test_request_context(): 338 | with patch.object(Interactions, 'delete', 339 | return_value={}): 340 | response = delete(DEFAULT_SUBSCRIPTIONS_TABLE, '123') 341 | self.assertEqual(response.status_code, client.OK) 342 | 343 | def test_delete_rql_runtime_error(self): 344 | with self.app.test_request_context(): 345 | with patch.object(Interactions, 'delete', 346 | side_effect=RqlRuntimeError(None, None, None)): 347 | response = delete(DEFAULT_SUBSCRIPTIONS_TABLE, '123') 348 | self.assertRaises(RqlRuntimeError) 349 | self.assertEqual(response.status_code, 350 | client.INTERNAL_SERVER_ERROR) 351 | 352 | def test_delete_rql_driver_error(self): 353 | with self.app.test_request_context(): 354 | with patch.object(Interactions, 'delete', 355 | side_effect=RqlDriverError(None)): 356 | response = delete(DEFAULT_SUBSCRIPTIONS_TABLE, '123') 357 | self.assertRaises(RqlDriverError) 358 | self.assertEqual(response.status_code, 359 | client.INTERNAL_SERVER_ERROR) 360 | 361 | def test_delete_type_error(self): 362 | with self.app.test_request_context(): 363 | with patch.object(Interactions, 'delete', 364 | side_effect=TypeError): 365 | response = delete(DEFAULT_SUBSCRIPTIONS_TABLE, '123') 366 | self.assertRaises(TypeError) 367 | self.assertEqual(response.status_code, 368 | client.BAD_REQUEST) 369 | 370 | def test_insert(self): 371 | with self.app.test_request_context(): 372 | with patch.object(Interactions, 'insert', 373 | return_value={}): 374 | response = insert(DEFAULT_SUBSCRIPTIONS_TABLE, 375 | **self.param_kwargs) 376 | self.assertEqual(response.status_code, client.CREATED) 377 | 378 | def test_insert_rql_runtime_error(self): 379 | with self.app.test_request_context(): 380 | with patch.object(Interactions, 'insert', 381 | side_effect=RqlRuntimeError(None, None, None)): 382 | response = insert(DEFAULT_SUBSCRIPTIONS_TABLE, 383 | **self.param_kwargs) 384 | self.assertRaises(RqlRuntimeError) 385 | self.assertEqual(response.status_code, 386 | client.INTERNAL_SERVER_ERROR) 387 | 388 | def test_insert_rql_driver_error(self): 389 | with self.app.test_request_context(): 390 | with patch.object(Interactions, 'insert', 391 | side_effect=RqlDriverError(None)): 392 | response = insert(DEFAULT_SUBSCRIPTIONS_TABLE, 393 | **self.param_kwargs) 394 | self.assertRaises(RqlDriverError) 395 | self.assertEqual(response.status_code, 396 | client.INTERNAL_SERVER_ERROR) 397 | 398 | def test_insert_type_error(self): 399 | with self.app.test_request_context(): 400 | with patch.object(Interactions, 'insert', 401 | side_effect=TypeError): 402 | response = insert(DEFAULT_SUBSCRIPTIONS_TABLE, 403 | **self.param_kwargs) 404 | self.assertRaises(TypeError) 405 | self.assertEqual(response.status_code, 406 | client.BAD_REQUEST) 407 | 408 | def test_insert_account(self): 409 | with self.app.test_request_context(): 410 | with patch.object(Interactions, 'query', 411 | return_value={}): 412 | with patch.object(Interactions, 'insert', 413 | return_value={'id': '123'}): 414 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 415 | **self.param_kwargs) 416 | self.assertEqual(response.status_code, client.CREATED) 417 | 418 | def test_insert_account_conflict(self): 419 | with self.app.test_request_context(): 420 | with patch.object(Interactions, 'query', 421 | return_value={'username': 'johndoe'}): 422 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 423 | **self.param_kwargs) 424 | self.assertEqual(response.status_code, client.CONFLICT) 425 | 426 | def test_insert_account_rql_runtime_error(self): 427 | with self.app.test_request_context(): 428 | with patch.object(Interactions, 'query', 429 | side_effect=RqlRuntimeError(None, None, None)): 430 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 431 | **self.param_kwargs) 432 | self.assertRaises(RqlRuntimeError) 433 | self.assertEqual(response.status_code, 434 | client.INTERNAL_SERVER_ERROR) 435 | 436 | def test_insert_account_rql_driver_error(self): 437 | with self.app.test_request_context(): 438 | with patch.object(Interactions, 'query', 439 | side_effect=RqlDriverError(None)): 440 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 441 | **self.param_kwargs) 442 | self.assertRaises(RqlDriverError) 443 | self.assertEqual(response.status_code, 444 | client.INTERNAL_SERVER_ERROR) 445 | 446 | def test_insert_account_type_error(self): 447 | with self.app.test_request_context(): 448 | with patch.object(Interactions, 'query', 449 | side_effect=TypeError): 450 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 451 | **self.param_kwargs) 452 | self.assertRaises(TypeError) 453 | self.assertEqual(response.status_code, 454 | client.BAD_REQUEST) 455 | 456 | @patch('pywebhooks.api.handlers.resources_handler.delete_registration') 457 | def test_delete_account(self, delete_registration_method): 458 | delete_registration_method.return_value = None 459 | 460 | with self.app.test_request_context(): 461 | with patch.object(Interactions, 'delete_specific', 462 | return_value=[{'id': '123'}]): 463 | with patch.object(Interactions, 'query', 464 | return_value={}): 465 | with patch.object(Interactions, 'delete', 466 | return_value={}): 467 | response = delete_account('123') 468 | self.assertEqual(response.status_code, client.OK) 469 | 470 | def test_delete_account_rql_runtime_error(self): 471 | with self.app.test_request_context(): 472 | with patch.object(Interactions, 'query', 473 | side_effect=RqlRuntimeError(None, None, None)): 474 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 475 | **self.param_kwargs) 476 | self.assertRaises(RqlRuntimeError) 477 | self.assertEqual(response.status_code, 478 | client.INTERNAL_SERVER_ERROR) 479 | 480 | def test_delete_account_rql_driver_error(self): 481 | with self.app.test_request_context(): 482 | with patch.object(Interactions, 'query', 483 | side_effect=RqlDriverError(None)): 484 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 485 | **self.param_kwargs) 486 | self.assertRaises(RqlDriverError) 487 | self.assertEqual(response.status_code, 488 | client.INTERNAL_SERVER_ERROR) 489 | 490 | def test_delete_account_type_error(self): 491 | with self.app.test_request_context(): 492 | with patch.object(Interactions, 'query', 493 | side_effect=TypeError): 494 | response = insert_account(DEFAULT_SUBSCRIPTIONS_TABLE, 495 | **self.param_kwargs) 496 | self.assertRaises(TypeError) 497 | self.assertEqual(response.status_code, 498 | client.BAD_REQUEST) 499 | 500 | def test_client_reset_key_success(self): 501 | with requests_mock.Mocker() as mocker: 502 | mocker.register_uri('GET', 'http://localhost/endpoint', 503 | json={'api_key': '54321'}, 504 | status_code=client.OK) 505 | 506 | return_value = client_reset_key('http://localhost/endpoint', 507 | 'api_key', '12345') 508 | 509 | self.assertTrue(return_value) 510 | 511 | def test_client_reset_key_fail(self): 512 | with requests_mock.Mocker() as mocker: 513 | mocker.register_uri('GET', 'http://localhost/endpoint/hello', 514 | json={'api_key': '54321'}, 515 | status_code=client.OK) 516 | 517 | return_value = client_reset_key('http://localhost/endpoint', 518 | 'api_key', '12345') 519 | 520 | self.assertFalse(return_value) 521 | 522 | @patch('pywebhooks.utils.common.generate_key') 523 | def test_client_echo_valid_success(self, generate_key_method): 524 | generate_key_method.return_value = '12345GENKEY' 525 | 526 | with requests_mock.Mocker() as mocker: 527 | mocker.register_uri('GET', 'http://localhost/endpoint', 528 | json={'echo': '12345GENKEY'}, 529 | status_code=client.OK) 530 | 531 | self.assertTrue(client_echo_valid('http://localhost/endpoint')) 532 | 533 | def test_client_echo_valid_fail_wrong_key(self): 534 | with requests_mock.Mocker() as mocker: 535 | mocker.register_uri('GET', 'http://localhost/endpoint', 536 | json={'echo': '12345GENKEY'}, 537 | status_code=client.OK) 538 | 539 | self.assertFalse(client_echo_valid('http://localhost/endpoint')) 540 | 541 | def test_client_echo_valid_fail_wrong_status(self): 542 | with requests_mock.Mocker() as mocker: 543 | mocker.register_uri('GET', 'http://localhost/endpoint', 544 | json={'echo': '12345GENKEY'}, 545 | status_code=client.INTERNAL_SERVER_ERROR) 546 | 547 | self.assertFalse(client_echo_valid('http://localhost/endpoint')) 548 | 549 | def test_reset_key_endpoint_not_found(self): 550 | records = [{'endpoint': ''}] 551 | 552 | with self.app.test_request_context(): 553 | with patch.object(Interactions, 'query', return_value=records): 554 | response = reset_key('johndoe', 'api_key') 555 | self.assertEqual(response.status_code, 556 | client.NOT_FOUND) 557 | 558 | def test_reset_key_endpoint_call_fail(self): 559 | records = [{'endpoint': 'http://localhost/endpoint'}] 560 | 561 | with self.app.test_request_context(): 562 | with patch.object(Interactions, 'query', return_value=records): 563 | response = reset_key('johndoe', 'api_key') 564 | self.assertEqual(response.status_code, 565 | client.BAD_REQUEST) 566 | 567 | @patch('pywebhooks.api.handlers.resources_handler.client_reset_key') 568 | def test_reset_key_api_key(self, client_reset_key_method): 569 | records = [{'endpoint': 'http://localhost/endpoint'}] 570 | client_reset_key_method.return_value = True 571 | 572 | with self.app.test_request_context(): 573 | with patch.object(Interactions, 'query', return_value=records): 574 | with patch.object(Interactions, 'update', return_value=None): 575 | response = reset_key('johndoe', 'api_key') 576 | self.assertEqual(response.status_code, client.OK) 577 | 578 | @patch('pywebhooks.api.handlers.resources_handler.client_reset_key') 579 | def test_reset_key_secret_key(self, client_reset_key_method): 580 | records = [{'endpoint': 'http://localhost/endpoint'}] 581 | client_reset_key_method.return_value = True 582 | 583 | with self.app.test_request_context(): 584 | with patch.object(Interactions, 'query', return_value=records): 585 | with patch.object(Interactions, 'update', return_value=None): 586 | response = reset_key('johndoe', 'secret_key') 587 | self.assertEqual(response.status_code, client.OK) 588 | 589 | def test_reset_key_rql_runtime_error(self): 590 | with self.app.test_request_context(): 591 | with patch.object(Interactions, 'query', 592 | side_effect=RqlRuntimeError(None, None, None)): 593 | response = reset_key('johndoe', 'api_key') 594 | self.assertRaises(RqlRuntimeError) 595 | self.assertEqual(response.status_code, 596 | client.INTERNAL_SERVER_ERROR) 597 | 598 | def test_reset_key_rql_driver_error(self): 599 | with self.app.test_request_context(): 600 | with patch.object(Interactions, 'query', 601 | side_effect=RqlDriverError(None)): 602 | response = reset_key('johndoe', 'api_key') 603 | self.assertRaises(RqlDriverError) 604 | self.assertEqual(response.status_code, 605 | client.INTERNAL_SERVER_ERROR) 606 | -------------------------------------------------------------------------------- /tests/unit/api/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/api/resources/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/resources/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/api/resources/v1/__init__.py -------------------------------------------------------------------------------- /tests/unit/api/resources/v1/test_account_api.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | # None 8 | 9 | # Project level imports 10 | from pywebhooks.app import app 11 | from pywebhooks.api.resources.v1.account import account_api 12 | from pywebhooks.database.rethinkdb.interactions import Interactions 13 | from pywebhooks.api.decorators import authorization 14 | 15 | 16 | def suite(): 17 | test_suite = unittest.TestSuite() 18 | test_suite.addTest(WhenTestingAccountAPI()) 19 | return test_suite 20 | 21 | 22 | class WhenTestingAccountAPI(unittest.TestCase): 23 | 24 | def setUp(self): 25 | app.config['TESTING'] = True 26 | self.test_headers = [ 27 | ('api-key', '12345'), 28 | ('username', 'johndoe') 29 | ] 30 | self.client = app.test_client() 31 | 32 | def test_get_should_return_unsupported_media_type(self): 33 | resp = self.client.get('/v1/account/') 34 | self.assertEqual(resp.status_code, client.UNSUPPORTED_MEDIA_TYPE) 35 | 36 | def test_get_should_return_bad_request(self): 37 | resp = self.client.get('/v1/account/', content_type='application/json') 38 | self.assertEqual(resp.status_code, client.BAD_REQUEST) 39 | 40 | def test_get_should_return_unauthorized(self): 41 | with patch.object(Interactions, 'query', return_value=False): 42 | resp = self.client.get( 43 | '/v1/account/45712a61-a1b3-41a4-aa89-9593b909ae3d', 44 | content_type='application/json', 45 | headers=self.test_headers 46 | ) 47 | self.assertEqual(resp.status_code, client.UNAUTHORIZED) 48 | 49 | def test_get_should_return_authorized(self): 50 | account_id = '45712a61-a1b3-41a4-aa89-9593b909ae3d' 51 | record = [ 52 | { 53 | 'api_key': '12345' 54 | } 55 | ] 56 | 57 | with patch.object(authorization, 'check_password_hash', 58 | return_value=True): 59 | with patch.object(account_api, 'lookup_account_id', 60 | return_value=account_id): 61 | with patch.object(Interactions, 'query', return_value=record): 62 | with patch.object(account_api, 'query', return_value={}): 63 | resp = self.client.get( 64 | '/v1/account/{0}'.format(account_id), 65 | content_type='application/json', 66 | headers=self.test_headers 67 | ) 68 | self.assertEqual(resp.status_code, client.OK) 69 | -------------------------------------------------------------------------------- /tests/unit/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/database/__init__.py -------------------------------------------------------------------------------- /tests/unit/database/rethinkdb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/database/rethinkdb/__init__.py -------------------------------------------------------------------------------- /tests/unit/database/rethinkdb/test_bootstrap_admin.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | # Third party imports 6 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 7 | 8 | # Project level imports 9 | from pywebhooks.database.rethinkdb.bootstrap_admin import create_admin_account 10 | from pywebhooks.database.rethinkdb.interactions import Interactions 11 | 12 | 13 | def suite(): 14 | test_suite = unittest.TestSuite() 15 | test_suite.addTest(WhenTestingBootstrapAdminFunctions()) 16 | return test_suite 17 | 18 | 19 | class WhenTestingBootstrapAdminFunctions(unittest.TestCase): 20 | 21 | def setUp(self): 22 | pass 23 | 24 | def test_create_admin_account(self): 25 | with patch.object(Interactions, 'insert', return_value=None) as \ 26 | insert_method: 27 | 28 | return_data = create_admin_account() 29 | 30 | self.assertTrue(insert_method.called) 31 | 32 | self.assertTrue('api_key' in return_data) 33 | self.assertTrue('secret_key' in return_data) 34 | 35 | self.assertEqual(len(return_data['api_key']), 40) 36 | self.assertEqual(len(return_data['secret_key']), 40) 37 | 38 | def test_create_admin_account_throws_rql_runtime_error(self): 39 | with patch.object(Interactions, 'insert', 40 | side_effect=RqlRuntimeError(None, None, None)): 41 | with self.assertRaises(RqlRuntimeError) as cm: 42 | create_admin_account() 43 | self.assertEqual(cm.exception, 44 | RqlRuntimeError(None, None, None)) 45 | 46 | def test_create_admin_account_throws_rql_driver_error(self): 47 | with patch.object(Interactions, 'insert', 48 | side_effect=RqlDriverError(None)): 49 | with self.assertRaises(RqlDriverError) as cm: 50 | create_admin_account() 51 | self.assertEqual(cm.exception, 52 | RqlDriverError(None)) 53 | -------------------------------------------------------------------------------- /tests/unit/database/rethinkdb/test_drop.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import unittest 3 | from unittest.mock import Mock 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | import rethinkdb as rethink 8 | 9 | # Project level imports 10 | from pywebhooks import DEFAULT_DB_NAME 11 | from pywebhooks.database.rethinkdb.drop import drop_database 12 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 13 | 14 | 15 | def suite(): 16 | test_suite = unittest.TestSuite() 17 | test_suite.addTest(WhenTestingDrop()) 18 | return test_suite 19 | 20 | 21 | class WhenTestingDrop(unittest.TestCase): 22 | 23 | def setUp(self): 24 | pass 25 | 26 | @patch('pywebhooks.database.rethinkdb.drop.get_connection') 27 | def test_drop_database(self, connection_method): 28 | connection_method.return_value = Mock(__enter__=Mock, __exit__=Mock()) 29 | 30 | with patch.object(rethink, 'db_drop', return_value=Mock()) as \ 31 | db_drop_method: 32 | 33 | drop_database() 34 | 35 | db_drop_method.assert_called_once_with(DEFAULT_DB_NAME) 36 | 37 | @patch('pywebhooks.database.rethinkdb.drop.get_connection', 38 | side_effect=RqlDriverError(None)) 39 | def test_drop_database_throws_rql_driver_error(self, _): 40 | with self.assertRaises(RqlDriverError) as cm: 41 | drop_database() 42 | self.assertEqual(cm.exception, RqlDriverError(None)) 43 | 44 | @patch('pywebhooks.database.rethinkdb.drop.get_connection', 45 | side_effect=RqlRuntimeError(None, None, None)) 46 | def test_drop_database_throws_rql_runtime_error(self, _): 47 | with self.assertRaises(RqlRuntimeError) as cm: 48 | drop_database() 49 | self.assertEqual(cm.exception, RqlRuntimeError(None, None, None)) 50 | -------------------------------------------------------------------------------- /tests/unit/database/rethinkdb/test_initialize.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import unittest 3 | from unittest.mock import Mock 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | import rethinkdb as rethink 8 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 9 | 10 | # Project level imports 11 | from pywebhooks.database.rethinkdb.initialize import create_database 12 | 13 | 14 | def suite(): 15 | test_suite = unittest.TestSuite() 16 | test_suite.addTest(WhenTestingInitialize()) 17 | return test_suite 18 | 19 | 20 | class WhenTestingInitialize(unittest.TestCase): 21 | 22 | def setUp(self): 23 | pass 24 | 25 | @patch('pywebhooks.database.rethinkdb.initialize.get_connection') 26 | def test_create_database(self, connection_method): 27 | connection_method.return_value = Mock(__enter__=Mock, __exit__=Mock()) 28 | 29 | with patch.object(rethink, 'db_list') as db_list_method: 30 | db_list_method.return_value.run.return_value = ['rethinkdb'] 31 | 32 | with patch.object(rethink, 'db_create') as db_create_method: 33 | db_create_method.return_value.run.return_value = None 34 | 35 | with patch.object(rethink, 'db') as db_method: 36 | db_method.return_value.table_list.return_value. \ 37 | run.return_value = [] 38 | 39 | create_database() 40 | 41 | self.assertTrue(db_list_method.called) 42 | self.assertTrue(db_create_method.called) 43 | self.assertTrue(db_method.called) 44 | 45 | @patch('pywebhooks.database.rethinkdb.initialize.get_connection', 46 | side_effect=RqlDriverError(None)) 47 | def test_create_database_throws_rql_driver_error(self, _): 48 | with self.assertRaises(RqlDriverError) as cm: 49 | create_database() 50 | self.assertEqual(cm.exception, RqlDriverError(None)) 51 | 52 | @patch('pywebhooks.database.rethinkdb.initialize.get_connection', 53 | side_effect=RqlRuntimeError(None, None, None)) 54 | def test_create_database_throws_rql_runtime_error(self, _): 55 | with self.assertRaises(RqlRuntimeError) as cm: 56 | create_database() 57 | self.assertEqual(cm.exception, RqlRuntimeError(None, None, None)) 58 | -------------------------------------------------------------------------------- /tests/unit/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/tasks/__init__.py -------------------------------------------------------------------------------- /tests/unit/tasks/test_webhook_notification.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | from rethinkdb.errors import RqlRuntimeError, RqlDriverError 8 | 9 | # Project level imports 10 | from pywebhooks import DEFAULT_ACCOUNTS_TABLE 11 | from pywebhooks.database.rethinkdb.interactions import Interactions 12 | from pywebhooks.tasks.webhook_notification import update_failed_count, \ 13 | notify_subscribed_accounts 14 | from pywebhooks.utils.request_handler import RequestHandler 15 | 16 | 17 | def suite(): 18 | test_suite = unittest.TestSuite() 19 | test_suite.addTest(WhenTestingWebHookNotifications()) 20 | return test_suite 21 | 22 | 23 | class WhenTestingWebHookNotifications(unittest.TestCase): 24 | 25 | def setUp(self): 26 | pass 27 | 28 | def test_update_failed_count_exceptions(self): 29 | with patch.object(Interactions, 'get', 30 | side_effect=RqlRuntimeError(None, None, None)): 31 | self.assertIsNone(update_failed_count(account_id='123')) 32 | 33 | with patch.object(Interactions, 'get', 34 | side_effect=RqlDriverError(None)): 35 | self.assertIsNone(update_failed_count(account_id='123')) 36 | 37 | def test_update_failed_count(self): 38 | account_record = { 39 | 'failed_count': 0 40 | } 41 | 42 | with patch.object(Interactions, 'get', 43 | return_value=account_record) as \ 44 | get_method: 45 | with patch.object(Interactions, 'update', return_value=None) as \ 46 | update_method: 47 | 48 | update_failed_count( 49 | account_id='123', 50 | increment_failed_count=True 51 | ) 52 | 53 | get_method.assert_called_with( 54 | DEFAULT_ACCOUNTS_TABLE, 55 | record_id='123' 56 | ) 57 | 58 | update_method.assert_called_with( 59 | DEFAULT_ACCOUNTS_TABLE, 60 | record_id='123', 61 | updates={'failed_count': 1} 62 | ) 63 | 64 | update_failed_count( 65 | account_id='123', 66 | increment_failed_count=False 67 | ) 68 | 69 | update_method.assert_called_with( 70 | DEFAULT_ACCOUNTS_TABLE, 71 | record_id='123', 72 | updates={'failed_count': 0} 73 | ) 74 | 75 | @patch('pywebhooks.tasks.webhook_notification.update_failed_count') 76 | def test_notify_subscribed_accounts(self, update_failed_count_method): 77 | 78 | account_id = '123' 79 | update_failed_count_method.return_value = None 80 | request_handler_return = None, client.OK 81 | 82 | with patch.object(RequestHandler, 'post', 83 | return_value=request_handler_return): 84 | 85 | notify_subscribed_accounts(event=None, event_data=None, 86 | secret_key=None, endpoint=None, 87 | account_id=account_id) 88 | 89 | update_failed_count_method.assert_called_with( 90 | account_id, 91 | increment_failed_count=False 92 | ) 93 | 94 | @patch('pywebhooks.tasks.webhook_notification.update_failed_count') 95 | def test_notify_subscribed_accounts_endppoint_issue( 96 | self, update_failed_count_method): 97 | 98 | update_failed_count_method.return_value = None 99 | request_handler_return = None, client.INTERNAL_SERVER_ERROR 100 | 101 | with patch.object(RequestHandler, 'post', 102 | return_value=request_handler_return): 103 | # Catch the raise 104 | try: 105 | notify_subscribed_accounts(event=None, event_data=None, 106 | secret_key=None, endpoint=None, 107 | account_id=None) 108 | except Exception as exc: 109 | self.assertEqual('Endpoint returning non HTTP 200 status. ' 110 | 'Actual code returned: 500', exc.args[0]) 111 | self.assertRaises(Exception) 112 | -------------------------------------------------------------------------------- /tests/unit/test_app.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | # Third party imports 7 | # None 8 | 9 | # Project level imports 10 | from pywebhooks.app import before_request, create_wsgi_app 11 | 12 | 13 | def suite(): 14 | test_suite = unittest.TestSuite() 15 | test_suite.addTest(WhenTestingAppFunctions()) 16 | return test_suite 17 | 18 | 19 | class WhenTestingAppFunctions(unittest.TestCase): 20 | 21 | def setUp(self): 22 | self.app = create_wsgi_app() 23 | self.app.config['TESTING'] = True 24 | 25 | @patch('pywebhooks.app.before_request') 26 | def test_before_request(self, before_request_decorator): 27 | before_request_decorator.return_value = None 28 | 29 | with self.app.test_request_context(): 30 | msg, status = before_request() 31 | 32 | self.assertEqual(status, client.UNSUPPORTED_MEDIA_TYPE) 33 | self.assertEqual('Unsupported Media Type', msg) 34 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chadlung/pywebhooks/4b5f41be7c3c498a31cb0225cbde8e63c48ce999/tests/unit/utils/__init__.py -------------------------------------------------------------------------------- /tests/unit/utils/test_common.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import hashlib 3 | import hmac 4 | import json 5 | import unittest 6 | 7 | # Third party imports 8 | # None 9 | 10 | # Project level imports 11 | from pywebhooks.utils.common import create_signature, generate_key 12 | 13 | 14 | def suite(): 15 | test_suite = unittest.TestSuite() 16 | test_suite.addTest(WhenTestingCommonFunctions()) 17 | return test_suite 18 | 19 | 20 | class WhenTestingCommonFunctions(unittest.TestCase): 21 | 22 | def setUp(self): 23 | self.secret_key = 'secret-key' 24 | self.json_data = "{'message': 'hello world'}" 25 | 26 | self.signature = hmac.new( 27 | str(self.secret_key).encode('utf-8'), 28 | str(json.dumps(self.json_data)).encode('utf-8'), 29 | digestmod=hashlib.sha1 30 | ).digest() 31 | 32 | def test_bad_secret_key(self): 33 | test_signature = hmac.new( 34 | str('bad-secret-key').encode('utf-8'), 35 | str(json.dumps(self.json_data)).encode('utf-8'), 36 | digestmod=hashlib.sha1 37 | ).hexdigest() 38 | 39 | self.assertNotEqual( 40 | test_signature, 41 | create_signature(self.secret_key, self.json_data) 42 | ) 43 | 44 | def test_good_secret_key(self): 45 | test_signature = hmac.new( 46 | str(self.secret_key).encode('utf-8'), 47 | str(json.dumps(self.json_data)).encode('utf-8'), 48 | digestmod=hashlib.sha1 49 | ).hexdigest() 50 | 51 | self.assertEqual( 52 | test_signature, 53 | create_signature(self.secret_key, self.json_data) 54 | ) 55 | 56 | def test_generate_key(self): 57 | self.assertTrue(generate_key()) 58 | self.assertEqual(len(generate_key()), 40) 59 | -------------------------------------------------------------------------------- /tests/unit/utils/test_request_handler.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | from http import client 3 | import unittest 4 | 5 | # Third party imports 6 | import requests_mock 7 | 8 | # Project level imports 9 | from pywebhooks.utils.request_handler import RequestHandler 10 | 11 | 12 | def suite(): 13 | test_suite = unittest.TestSuite() 14 | test_suite.addTest(WhenTestingRequestHandler()) 15 | return test_suite 16 | 17 | 18 | class WhenTestingRequestHandler(unittest.TestCase): 19 | 20 | def setUp(self): 21 | pass 22 | 23 | def test_get(self): 24 | 25 | with requests_mock.Mocker() as mocker: 26 | mocker.register_uri('GET', 'http://localhost?test=123', 27 | json={'test': 'value'}, 28 | status_code=200) 29 | 30 | request_handler = RequestHandler() 31 | data, status = request_handler.get( 32 | 'http://localhost', 33 | params={'test': 123}, 34 | api_key='12345', 35 | username='johndoe' 36 | ) 37 | self.assertEqual(status, client.OK) 38 | self.assertEqual({'test': 'value'}, data) 39 | self.assertEqual(request_handler.headers['username'], 'johndoe') 40 | self.assertEqual(request_handler.headers['api-key'], '12345') 41 | self.assertEqual( 42 | request_handler.headers['Content-Type'], 'application/json') 43 | self.assertEqual( 44 | request_handler.headers['Accept'], 'application/json') 45 | 46 | def test_put(self): 47 | 48 | with requests_mock.Mocker() as mocker: 49 | mocker.register_uri('PUT', 'http://localhost', 50 | json={'test': 'value'}, 51 | status_code=200) 52 | 53 | request_handler = RequestHandler() 54 | data, status = request_handler.put( 55 | 'http://localhost', 56 | json_payload={'hello': 'world'}, 57 | api_key='555', 58 | username='janedoe' 59 | ) 60 | self.assertEqual(status, client.OK) 61 | self.assertEqual({'test': 'value'}, data) 62 | self.assertEqual(request_handler.headers['username'], 'janedoe') 63 | self.assertEqual(request_handler.headers['api-key'], '555') 64 | self.assertEqual( 65 | request_handler.headers['Content-Type'], 'application/json') 66 | self.assertEqual( 67 | request_handler.headers['Accept'], 'application/json') 68 | 69 | def test_post(self): 70 | 71 | with requests_mock.Mocker() as mocker: 72 | mocker.register_uri('POST', 'http://localhost', 73 | json={'test': 'value'}, 74 | status_code=201) 75 | 76 | request_handler = RequestHandler() 77 | data, status = request_handler.post( 78 | 'http://localhost', 79 | json_payload={'hello': 'world'}, 80 | api_key='8900', 81 | username='samjones', 82 | event='myevent', 83 | signature='mysignature' 84 | ) 85 | self.assertEqual(status, client.CREATED) 86 | self.assertEqual({'test': 'value'}, data) 87 | self.assertEqual(request_handler.headers['username'], 'samjones') 88 | self.assertEqual(request_handler.headers['api-key'], '8900') 89 | self.assertEqual(request_handler.headers['event'], 'myevent') 90 | self.assertEqual( 91 | request_handler.headers['pywebhooks-signature'], 'mysignature') 92 | self.assertEqual( 93 | request_handler.headers['Content-Type'], 'application/json') 94 | self.assertEqual( 95 | request_handler.headers['Accept'], 'application/json') 96 | 97 | def test_patch(self): 98 | 99 | with requests_mock.Mocker() as mocker: 100 | mocker.register_uri('PATCH', 'http://localhost', 101 | json={'test': 'value'}, 102 | status_code=200) 103 | 104 | request_handler = RequestHandler() 105 | data, status = request_handler.patch( 106 | 'http://localhost', 107 | json_payload={'hello': 'world'}, 108 | api_key='01245', 109 | username='natml' 110 | ) 111 | self.assertEqual(status, client.OK) 112 | self.assertEqual({'test': 'value'}, data) 113 | self.assertEqual(request_handler.headers['username'], 'natml') 114 | self.assertEqual(request_handler.headers['api-key'], '01245') 115 | self.assertEqual( 116 | request_handler.headers['Content-Type'], 'application/json') 117 | self.assertEqual( 118 | request_handler.headers['Accept'], 'application/json') 119 | 120 | def test_delete(self): 121 | 122 | with requests_mock.Mocker() as mocker: 123 | mocker.register_uri('DELETE', 'http://localhost/45678', 124 | json={'test': 'value'}, 125 | status_code=200) 126 | 127 | request_handler = RequestHandler() 128 | data, status = request_handler.delete( 129 | 'http://localhost/45678', 130 | api_key='765434', 131 | username='birk' 132 | ) 133 | self.assertEqual(status, client.OK) 134 | self.assertEqual({'test': 'value'}, data) 135 | self.assertEqual(request_handler.headers['username'], 'birk') 136 | self.assertEqual(request_handler.headers['api-key'], '765434') 137 | self.assertEqual( 138 | request_handler.headers['Content-Type'], 'application/json') 139 | self.assertEqual( 140 | request_handler.headers['Accept'], 'application/json') 141 | -------------------------------------------------------------------------------- /tests/unit/utils/test_rethinkdb_helper.py: -------------------------------------------------------------------------------- 1 | # Standard lib imports 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | # Third party imports 6 | import rethinkdb as rethink 7 | 8 | # Project level imports 9 | from pywebhooks import DEFAULT_DB_NAME 10 | from pywebhooks.utils.rethinkdb_helper import get_connection 11 | 12 | 13 | def suite(): 14 | test_suite = unittest.TestSuite() 15 | test_suite.addTest(WhenTestingRethinkDBHelper()) 16 | return test_suite 17 | 18 | 19 | class WhenTestingRethinkDBHelper(unittest.TestCase): 20 | 21 | def setUp(self): 22 | pass 23 | 24 | def test_get_connection(self): 25 | with patch.object(rethink, 'connect', return_value=None) as \ 26 | connect_method: 27 | 28 | get_connection() 29 | 30 | connect_method.assert_called_once_with(host='rethinkdb', 31 | port=28015, 32 | auth_key='', 33 | db=DEFAULT_DB_NAME) 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = flake8, py36 3 | 4 | [testenv] 5 | deps = -r{toxinidir}/test-requirements.txt 6 | 7 | commands = 8 | coverage erase 9 | nosetests --with-coverage 10 | coverage report -m 11 | 12 | [testenv:flake8] 13 | exclude = 14 | .tox, 15 | .git, 16 | __pycache__, 17 | docs/source/conf.py, 18 | build, 19 | dist, 20 | tests/fixtures/*, 21 | *.pyc, 22 | *.egg-info, 23 | .cache, 24 | .eggs, 25 | .venv 26 | --------------------------------------------------------------------------------