├── test ├── integration │ ├── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── redis.py │ │ ├── runner.ts │ │ ├── live_server.py │ │ ├── mocha.py │ │ └── test.ts │ ├── dcrf_client_test │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── models.py │ │ ├── wsgi.py │ │ ├── routing.py │ │ ├── urls.py │ │ ├── demultiplexer.py │ │ ├── observers.py │ │ ├── settings.py │ │ └── consumers.py │ ├── .babelrc │ └── manage.py ├── global.d.ts └── test.ts ├── .mocharc.yml ├── .gitignore ├── src ├── serializers │ └── json.ts ├── logging.ts ├── subscriptions.ts ├── send_queues │ ├── base.ts │ └── fifo.ts ├── lib │ └── UUID.ts ├── dispatchers │ └── fifo.ts ├── transports │ └── websocket.ts ├── index.ts └── interface.ts ├── tsconfig.json ├── pytest.ini ├── Pipfile ├── .github └── workflows │ ├── mocha.yml │ └── integration.yml ├── package.json ├── CHANGELOG.md ├── README.md └── Pipfile.lock /test/integration/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-es2015-modules-commonjs"] 3 | } 4 | -------------------------------------------------------------------------------- /test/global.d.ts: -------------------------------------------------------------------------------- 1 | import Global = NodeJS.Global; 2 | 3 | export interface DCRFGlobal extends Global { 4 | WebSocket: any, 5 | } 6 | -------------------------------------------------------------------------------- /test/integration/tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = [ 2 | 'tests.live_server', 3 | 'tests.redis', 4 | 'tests.mocha', 5 | ] 6 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | extension: 2 | - ts 3 | include: 4 | - ./test/test.ts 5 | exclude: 6 | - test/global.d.ts 7 | - test/integration 8 | require: 9 | - ts-node/register 10 | - '@babel/register' 11 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Thing(models.Model): 5 | name = models.CharField(max_length=64) 6 | counter = models.IntegerField(default=0) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artifacts 2 | /lib 3 | 4 | # npm dependencies 5 | node_modules 6 | 7 | # python artifacts 8 | *.pyc 9 | .pytest_cache 10 | 11 | # databases 12 | *.sqlite3 13 | 14 | # IntelliJ IDE files 15 | .idea/ 16 | -------------------------------------------------------------------------------- /src/serializers/json.ts: -------------------------------------------------------------------------------- 1 | import {ISerializer} from '../interface'; 2 | 3 | export 4 | class JSONSerializer implements ISerializer { 5 | public serialize = JSON.stringify; 6 | public deserialize = JSON.parse; 7 | } 8 | 9 | export default JSONSerializer; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es6", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "./lib", 8 | "strict": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | -v 4 | --nomigrations 5 | --rootdir=./test/integration/tests 6 | 7 | ./test/integration/tests 8 | 9 | python_paths = ./test/integration 10 | DJANGO_SETTINGS_MODULE = dcrf_client_test.settings 11 | 12 | python_files = test.py 13 | 14 | filterwarnings = 15 | ignore:the imp module is deprecated in favour of importlib.*:DeprecationWarning 16 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "~=6.2.1" 8 | pytest-django = "~=4.3.0" 9 | pytest-pythonpath = "~=0.7.3" 10 | 11 | [packages] 12 | djangochannelsrestframework = "==0.2.2" 13 | channelsmultiplexer = "==0.0.3" 14 | testing-redis = "~=1.1.1" 15 | channels-redis = "~=3.2.0" 16 | 17 | [requires] 18 | python_version = "3.8" 19 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for dcrf_client_test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dcrf_client_test.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.github/workflows/mocha.yml: -------------------------------------------------------------------------------- 1 | name: Mocha tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x, 14.x, 15.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm run test:unit 26 | 27 | -------------------------------------------------------------------------------- /test/integration/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dcrf_client_test.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | 3 | export 4 | const rootLogger = createLogger({ 5 | format: format.combine( 6 | format.splat(), 7 | format.timestamp({ format: 'YYYY-mm-dd HH:MM:SS' }), 8 | format.printf(info => { 9 | const label = info.label || ''; 10 | const { timestamp: ts, message: msg, level } = info; 11 | return `[${ts}][${label}][${level}] ${msg}`; 12 | }), 13 | ), 14 | transports: [ 15 | new transports.Console(), 16 | ], 17 | }); 18 | 19 | 20 | export 21 | const getLogger = (name: string) => rootLogger.child({ 22 | label: name, 23 | }); 24 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import ProtocolTypeRouter, URLRouter 2 | from django.core.asgi import get_asgi_application 3 | from django.urls import path 4 | 5 | from dcrf_client_test.consumers import ThingConsumer, ThingsWithIdConsumer 6 | from dcrf_client_test.demultiplexer import AsyncJsonWebsocketDemultiplexer 7 | 8 | application = ProtocolTypeRouter({ 9 | 'websocket': URLRouter([ 10 | path('ws', AsyncJsonWebsocketDemultiplexer( 11 | things=ThingConsumer(), 12 | things_with_id=ThingsWithIdConsumer(), 13 | )), 14 | ]), 15 | 'http': get_asgi_application(), 16 | }) 17 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-27 04:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Thing', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=64)), 19 | ('counter', models.IntegerField(default=0)), 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /test/integration/tests/redis.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from _pytest.monkeypatch import MonkeyPatch 3 | from testing.redis import RedisServer 4 | 5 | from django.conf import settings 6 | 7 | 8 | @pytest.fixture(scope='session', autouse=True) 9 | def redis_server(): 10 | server = RedisServer() 11 | 12 | dsn = server.dsn() 13 | host = dsn['host'] 14 | port = dsn['port'] 15 | 16 | with MonkeyPatch().context() as patcher: 17 | patcher.setattr(settings, 'CHANNEL_LAYERS', { 18 | 'default': { 19 | 'BACKEND': 'channels_redis.core.RedisChannelLayer', 20 | 'CONFIG': { 21 | 'hosts': [(host, port)], 22 | }, 23 | }, 24 | }) 25 | 26 | yield server 27 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/urls.py: -------------------------------------------------------------------------------- 1 | """dcrf_client_test URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /src/subscriptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Promise representing the response to subscription, and offering an additional 3 | * cancel() method to stop listening for updates. 4 | * 5 | * This is returned from DCRFClient.subscribe. 6 | */ 7 | export 8 | class SubscriptionPromise extends Promise { 9 | protected unsubscribe: () => Promise; 10 | 11 | constructor(executor: (resolve: (value?: (PromiseLike | T)) => void, reject: (reason?: any) => void) => void, 12 | unsubscribe: () => Promise) { 13 | super(executor); 14 | this.unsubscribe = unsubscribe; 15 | } 16 | 17 | /** 18 | * Stop listening for new events on this subscription 19 | * @return true if the subscription was active, false if it was already unsubscribed 20 | */ 21 | public async cancel(): Promise { 22 | return await this.unsubscribe(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 3.9 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.9 20 | - name: Install Python dependencies 21 | run: | 22 | python -m pip install --upgrade pip pipenv 23 | pipenv install --dev 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - name: Install redis-server 29 | uses: shogo82148/actions-setup-redis@v1 30 | with: 31 | redis-version: '6.x' 32 | auto-start: false 33 | - run: npm ci 34 | - run: npm run test:integration 35 | 36 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/demultiplexer.py: -------------------------------------------------------------------------------- 1 | from channelsmultiplexer import ( 2 | AsyncJsonWebsocketDemultiplexer as BaseAsyncJsonWebsocketDemultiplexer, 3 | ) 4 | 5 | 6 | class AsyncJsonWebsocketDemultiplexer(BaseAsyncJsonWebsocketDemultiplexer): 7 | async def websocket_accept(self, message, stream_name): 8 | is_last = self.applications_accepting_frames == set(self.applications) - {stream_name} 9 | self.applications_accepting_frames.add(stream_name) 10 | 11 | ### 12 | # accept the connection after the *last* upstream application accepts. 13 | # 14 | # channelsmultiplexer's implementation of websocket_accept will accept 15 | # the websocket connection when the _first_ upstream application accepts. 16 | # This can get hairy during tests, as the client can only judge a websocket's 17 | # readiness by whether the connection has been accepted — if the test is 18 | # making requests against the second stream, but only the first stream 19 | # is "accepting frames", there *will* be "Invalid multiplexed frame received 20 | # (stream not mapped)" errors. 21 | # 22 | # To combat this, we only accept the websocket connection when *all* the 23 | # streams' applications are ready. The default behaviour may be a bug. 24 | # 25 | if is_last: 26 | await self.accept() 27 | -------------------------------------------------------------------------------- /src/send_queues/base.ts: -------------------------------------------------------------------------------- 1 | import {ISendQueue} from '../interface'; 2 | 3 | 4 | export 5 | class BaseSendQueue implements ISendQueue { 6 | /** 7 | * @param sendNow Function to call to send a message 8 | * @param canSend Function which should return whether send can be called 9 | * @note If parameters are not passed, initialize() should be called later. 10 | */ 11 | constructor(sendNow?: (bytes: string) => number, canSend?: () => boolean) { 12 | if (sendNow && canSend) { 13 | this.initialize(sendNow, canSend); 14 | } else if (sendNow || canSend) { 15 | throw new Error('Both sendNow and canSend must be provided, or neither must.'); 16 | } 17 | } 18 | 19 | public sendNow: (bytes: string) => number = (bytes) => -1; 20 | public canSend: () => boolean = () => false; 21 | 22 | /** 23 | * @param sendNow Function to call to send a message 24 | * @param canSend Function which should return whether send can be called 25 | */ 26 | public initialize(sendNow: (bytes: string) => number, canSend: () => boolean) { 27 | this.sendNow = sendNow; 28 | this.canSend = canSend; 29 | } 30 | 31 | public send(bytes: string): number { 32 | throw new Error('not implemented'); 33 | } 34 | public queueMessage(bytes: string): boolean { 35 | throw new Error('not implemented'); 36 | } 37 | public processQueue(): number { 38 | throw new Error('not implemented'); 39 | } 40 | } 41 | 42 | export default BaseSendQueue; 43 | -------------------------------------------------------------------------------- /src/lib/UUID.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-bitwise */ 2 | 3 | /** 4 | * Fast UUID generator, RFC4122 version 4 compliant. 5 | * @author Jeff Ward (jcward.com). 6 | * @license MIT license 7 | * @link http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136 8 | */ 9 | class UUID { 10 | private static readonly lut: string[] = []; 11 | 12 | private static initialize(): void { 13 | for (let i = 0; i < 256; i++) { 14 | this.lut[i] = (i < 16 ? '0' : '') + (i).toString(16); 15 | } 16 | } 17 | 18 | public static generate(): string { 19 | if (this.lut.length === 0) { 20 | this.initialize(); 21 | } 22 | 23 | const d0 = Math.random()*0xffffffff|0; 24 | const d1 = Math.random()*0xffffffff|0; 25 | const d2 = Math.random()*0xffffffff|0; 26 | const d3 = Math.random()*0xffffffff|0; 27 | 28 | const group0 = this.lut[d0&0xff] + this.lut[d0>>8&0xff] + this.lut[d0>>16&0xff] + this.lut[d0>>24&0xff]; 29 | const group1 = this.lut[d1&0xff] + this.lut[d1>>8&0xff]; 30 | const group2 = this.lut[d1>>16&0x0f|0x40] + this.lut[d1>>24&0xff]; 31 | const group3 = this.lut[d2&0x3f|0x80] + this.lut[d2>>8&0xff]; 32 | const group4 = this.lut[d2>>16&0xff] + this.lut[d2>>24&0xff] + this.lut[d3&0xff] + this.lut[d3>>8&0xff] + this.lut[d3>>16&0xff] + this.lut[d3>>24&0xff]; 33 | 34 | return group0 + '-' + group1 + '-' + group2 + '-' + group3 + '-' + group4; 35 | } 36 | } 37 | 38 | export default UUID; 39 | -------------------------------------------------------------------------------- /src/send_queues/fifo.ts: -------------------------------------------------------------------------------- 1 | import autobind from 'autobind-decorator'; 2 | 3 | import { getLogger } from '../logging'; 4 | import { ISendQueue } from '../interface'; 5 | import BaseSendQueue from './base'; 6 | 7 | const log = getLogger('dcrf.send_queues.fifo'); 8 | 9 | 10 | export 11 | class FifoQueue extends BaseSendQueue implements ISendQueue { 12 | public readonly queue: string[]; 13 | 14 | constructor(sendNow?: (bytes: string) => number, canSend?: () => boolean) { 15 | super(sendNow, canSend); 16 | this.queue = []; 17 | } 18 | 19 | @autobind 20 | public send(bytes: string): number { 21 | if (this.canSend()) { 22 | log.debug(`Sending bytes over the wire: ${bytes}`); 23 | return this.sendNow(bytes); 24 | } else { 25 | this.queueMessage(bytes); 26 | return -1; 27 | } 28 | } 29 | 30 | public queueMessage(bytes: string): boolean { 31 | log.debug('Queueing message to send later: %o', bytes); 32 | this.queue.push(bytes); 33 | return true; 34 | } 35 | 36 | @autobind 37 | public processQueue(): number { 38 | let numProcessed = 0; 39 | 40 | if (this.queue.length) { 41 | log.debug(`Sending ${this.queue.length} queued messages.`); 42 | 43 | while (this.queue.length) { 44 | const object = this.queue.shift(); 45 | if (object !== undefined) { 46 | this.sendNow(object); 47 | } 48 | numProcessed++; 49 | } 50 | } 51 | 52 | return numProcessed; 53 | } 54 | } 55 | 56 | export default FifoQueue; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dcrf-client", 3 | "version": "1.1.0", 4 | "description": "Websocket client for Django Channels v2 APIs powered by hishnash/djangochannelsrestframework", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "files": [ 8 | "lib/**/*" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "prepare": "npm run build", 13 | "test": "npm run test:unit; npm run test:integration", 14 | "test:unit": "mocha", 15 | "test:integration": "pipenv run py.test" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/theY4Kman/dcrf-client.git" 20 | }, 21 | "keywords": [ 22 | "django", 23 | "channels", 24 | "websocket", 25 | "client" 26 | ], 27 | "author": "Zach \"theY4Kman\" Kanzler", 28 | "contributors": [ 29 | "Sandro Salles (https://github.com/sandro-salles)", 30 | "Joel Hillacre (https://github.com/jhillacre)" 31 | ], 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/theY4Kman/dcrf-client/issues" 35 | }, 36 | "homepage": "https://github.com/theY4Kman/dcrf-client#readme", 37 | "devDependencies": { 38 | "@babel/core": "^7.4.0", 39 | "@babel/register": "^7.4.0", 40 | "@types/chai": "^4.1.7", 41 | "@types/chai-subset": "^1.3.2", 42 | "@types/deasync": "^0.1.0", 43 | "@types/lodash.ismatch": "^4.4.6", 44 | "@types/lodash.pull": "^4.1.6", 45 | "@types/mocha": "^5.2.6", 46 | "@types/node": "^11.11.4", 47 | "@types/sinon": "^7.0.10", 48 | "@types/sinon-chai": "^3.2.2", 49 | "@types/ws": "^6.0.1", 50 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 51 | "chai": "^4.2.0", 52 | "chai-subset": "^1.6.0", 53 | "deasync": "^0.1.14", 54 | "flush-promises": "^1.0.2", 55 | "mocha": "^8.2.1", 56 | "sinon": "^7.3.1", 57 | "sinon-chai": "^3.3.0", 58 | "ts-node": "^8.0.3", 59 | "tslint": "^5.14.0", 60 | "typescript": "^3.7.7", 61 | "ws": "^6.2.0" 62 | }, 63 | "dependencies": { 64 | "@types/lodash.uniqby": "^4.7.6", 65 | "autobind-decorator": "^2.4.0", 66 | "lodash.ismatch": "^4.4.0", 67 | "lodash.pull": "^4.1.0", 68 | "lodash.uniqby": "^4.7.0", 69 | "reconnecting-websocket": "^4.4.0", 70 | "winston": "^3.3.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/dispatchers/fifo.ts: -------------------------------------------------------------------------------- 1 | import isMatch from 'lodash.ismatch'; 2 | import pull from 'lodash.pull'; 3 | 4 | import { getLogger } from '../logging'; 5 | import {DispatchListener, IDispatcher} from '../interface'; 6 | 7 | const log = getLogger('dcrf.dispatchers.fifo'); 8 | 9 | 10 | type Listener = { 11 | selector: S, 12 | handler: DispatchListener

, 13 | }; 14 | 15 | 16 | /** 17 | * Invokes listeners on a first-registered, first-called basis 18 | */ 19 | export 20 | class FifoDispatcher implements IDispatcher { 21 | private static listenerCounter: number = 0; 22 | 23 | protected listeners: { 24 | [listenerId:number]: Listener, 25 | }; 26 | protected listenersOrder: number[]; 27 | 28 | constructor() { 29 | this.listeners = {}; 30 | this.listenersOrder = []; 31 | } 32 | 33 | public listen(selector: S, handler: DispatchListener

): number { 34 | const listenerId = ++FifoDispatcher.listenerCounter; 35 | 36 | this.listeners[listenerId] = {selector, handler}; 37 | this.listenersOrder.push(listenerId); 38 | 39 | return listenerId; 40 | } 41 | 42 | public once(selector: S, handler: DispatchListener

): number { 43 | const listenerId = this.listen(selector, (payload: P) => { 44 | this.cancel(listenerId); 45 | handler(payload); 46 | }); 47 | return listenerId; 48 | } 49 | 50 | public cancel(listenerId: number): boolean { 51 | if (!this.listeners.hasOwnProperty(listenerId)) { 52 | return false; 53 | } 54 | 55 | delete this.listeners[listenerId]; 56 | pull(this.listenersOrder, listenerId); 57 | return true 58 | } 59 | 60 | public dispatch(payload: object): number { 61 | const listeners = this.listenersOrder.map(listenerId => this.listeners[listenerId]); 62 | 63 | let matches = 0; 64 | listeners.forEach(({selector, handler}) => { 65 | if (isMatch(payload, selector)) { 66 | log.debug('Matched selector %o with payload %o. Invoking handler %s', 67 | selector, payload, handler.name); 68 | matches++; 69 | handler(payload); 70 | } else { 71 | log.silly('Unable to match selector %o with payload %o. Not invoking handler %s', 72 | selector, payload, handler.name); 73 | } 74 | }); 75 | 76 | return matches; 77 | } 78 | } 79 | 80 | export default FifoDispatcher; 81 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/observers.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from functools import partial 3 | from typing import Dict, Iterable, Set, Type 4 | 5 | from django.db.models import Model 6 | from djangochannelsrestframework.consumers import AsyncAPIConsumer 7 | from djangochannelsrestframework.observer import ModelObserver 8 | 9 | 10 | class RequestIdModelObserver(ModelObserver): 11 | """ModelObserver which keeps track of each subscription's request_id""" 12 | 13 | def __init__(self, func, model_cls: Type[Model], partition: str = "*", **kwargs): 14 | self.group_request_ids: Dict[str, Set[str]] = defaultdict(set) 15 | self.request_id_groups: Dict[str, Set[str]] = defaultdict(set) 16 | super().__init__(func, model_cls, partition, **kwargs) 17 | 18 | async def __call__(self, message, consumer=None, **kwargs): 19 | group = message.pop('group') 20 | 21 | for request_id in self.group_request_ids[group]: 22 | return await super().__call__(message, consumer, request_id=request_id) 23 | 24 | async def subscribe( 25 | self, consumer: AsyncAPIConsumer, *args, request_id: str = None, **kwargs 26 | ) -> Iterable[str]: 27 | if request_id is None: 28 | raise ValueError("request_id must have a value set") 29 | 30 | groups = await super().subscribe(consumer, *args, **kwargs) 31 | 32 | for group in groups: 33 | self.group_request_ids[group].add(request_id) 34 | 35 | self.request_id_groups[request_id].update(groups) 36 | 37 | return groups 38 | 39 | async def unsubscribe( 40 | self, consumer: AsyncAPIConsumer, *args, request_id: str = None, **kwargs 41 | ) -> Iterable[str]: 42 | if request_id is None: 43 | raise ValueError("request_id must have a value set") 44 | 45 | groups = await super().unsubscribe(consumer, *args, **kwargs) 46 | 47 | for group in self.request_id_groups[request_id]: 48 | group_request_ids = self.group_request_ids[group] 49 | group_request_ids.discard(request_id) 50 | 51 | if not group_request_ids: 52 | del self.group_request_ids[group] 53 | 54 | del self.request_id_groups[request_id] 55 | 56 | return groups 57 | 58 | 59 | def model_observer(model, **kwargs): 60 | return partial(RequestIdModelObserver, model_cls=model, kwargs=kwargs) 61 | -------------------------------------------------------------------------------- /src/transports/websocket.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import ReconnectingWebsocket, { Event } from 'reconnecting-websocket'; 3 | import autobind from 'autobind-decorator'; 4 | 5 | import { getLogger } from '../logging'; 6 | import {ITransport} from '../interface'; 7 | 8 | 9 | const log = getLogger('dcrf.transports.websocket'); 10 | 11 | 12 | /** 13 | * Transport backed by a reconnecting websocket 14 | */ 15 | export 16 | class WebsocketTransport extends EventEmitter implements ITransport { 17 | public readonly url: string; 18 | public readonly options: object; 19 | public socket: ReconnectingWebsocket | null; 20 | public hasConnected: boolean; 21 | 22 | /** 23 | * 24 | * @param url Websocket URL to connect to 25 | * @param options Options to pass to ReconnectingWebsocket 26 | */ 27 | constructor(url: string, options: object={}) { 28 | super(); 29 | this.url = url; 30 | this.options = options; 31 | this.socket = null; 32 | this.hasConnected = false; 33 | } 34 | 35 | @autobind 36 | public connect() { 37 | if (this.socket != null) { 38 | log.debug('Attempt to connect already-connected socket ignored (%s)', this.url); 39 | return false; 40 | } 41 | 42 | log.info('Connecting to websocket at %s', this.url); 43 | this.socket = new ReconnectingWebsocket(this.url, [], this.options); 44 | 45 | this.socket.addEventListener('message', this.handleMessage); 46 | this.socket.addEventListener('open', this.handleOpen); 47 | 48 | return true; 49 | } 50 | 51 | @autobind 52 | public disconnect(): boolean { 53 | if (this.socket == null) { 54 | return false; 55 | } 56 | 57 | this.socket.close(); 58 | return true; 59 | } 60 | 61 | @autobind 62 | public isConnected() { 63 | if (this.socket == null) { 64 | return false; 65 | } else { 66 | return this.socket.readyState === this.socket.OPEN; 67 | } 68 | } 69 | 70 | @autobind 71 | public send(bytes: string): void { 72 | if (this.socket === null) { 73 | throw new Error('Socket not connected. Please call `initialize()` first.'); 74 | } 75 | this.socket.send(bytes); 76 | } 77 | 78 | @autobind 79 | protected handleMessage(event: Event) { 80 | this.emit('message', event); 81 | } 82 | 83 | @autobind 84 | protected handleOpen(event: Event) { 85 | this.emit('open', event); 86 | 87 | if (this.hasConnected) { 88 | this.emit('reconnect', event); 89 | } else { 90 | this.emit('connect', event); 91 | this.hasConnected = true; 92 | } 93 | } 94 | } 95 | 96 | 97 | export default WebsocketTransport; 98 | -------------------------------------------------------------------------------- /test/integration/tests/runner.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import Mocha from 'mocha'; 5 | 6 | 7 | const STDIN_FD = 0; 8 | const STDOUT_FD = 1; 9 | 10 | 11 | class PytestReporter extends Mocha.reporters.Base { 12 | constructor(runner: Mocha.Runner) { 13 | super(runner); 14 | 15 | /** 16 | * There is no event fired once all after/afterEach hooks have been run. 17 | * To workaround this, we record the test which has just ended (that is, 18 | * whose test function has completed), and when the next test starts or the 19 | * entire suite completes (whichever comes first), we interpret that as all 20 | * afterEach hooks having been run for the recorded test. 21 | */ 22 | let pendingCompletionTest: Mocha.Test | null = null; 23 | 24 | const processPendingCompletionTest = () => { 25 | if (pendingCompletionTest != null) { 26 | this.writeEvent('test end', this.expressTest(pendingCompletionTest)); 27 | pendingCompletionTest = null; 28 | } 29 | } 30 | 31 | runner.once('start', () => { 32 | const tests: object[] = []; 33 | 34 | runner.suite.eachTest((test: Mocha.Test) => { 35 | tests.push(this.expressTest(test)); 36 | }); 37 | 38 | this.writeEvent('collect', {tests}); 39 | this.waitForAck(); 40 | }); 41 | 42 | runner.on('test', (test: Mocha.Test) => { 43 | processPendingCompletionTest(); 44 | this.writeEvent('test', this.expressTest(test)); 45 | }); 46 | 47 | runner.on('test end', (test: Mocha.Test) => { 48 | pendingCompletionTest = test; 49 | }); 50 | 51 | runner.on('fail', (test: Mocha.Test, err) => { 52 | this.writeEvent('fail', { 53 | ...this.expressTest(test), 54 | err: err.message, 55 | stack: err.stack || null 56 | }); 57 | }); 58 | 59 | runner.on('pass', (test: Mocha.Test) => { 60 | this.writeEvent('fail', this.expressTest(test)); 61 | }); 62 | 63 | runner.once('end', () => { 64 | processPendingCompletionTest(); 65 | this.writeEvent('end'); 66 | }); 67 | } 68 | 69 | public expressTest(test: Mocha.Test): object { 70 | return { 71 | title: test.title, 72 | parents: test.titlePath(), 73 | file: test.file, 74 | state: test.state, 75 | } 76 | } 77 | 78 | protected writeEvent(type: string, event: object = {}) { 79 | const line = { 80 | type, 81 | ...event, 82 | }; 83 | 84 | let buffer = JSON.stringify(line); 85 | do { 86 | let bytesWritten: number; 87 | try { 88 | bytesWritten = fs.writeSync(STDOUT_FD, buffer); 89 | } catch (e) { 90 | if (e.code === 'EAGAIN') { 91 | continue; 92 | } else { 93 | throw e; 94 | } 95 | } 96 | 97 | buffer = buffer.substr(bytesWritten); 98 | } while (buffer); 99 | fs.writeSync(STDOUT_FD, '\n'); 100 | } 101 | 102 | /** 103 | * Wait for the pytest process to give an acknowledgment over stdin 104 | */ 105 | protected waitForAck() { 106 | fs.readSync(STDIN_FD, Buffer.alloc(1), 0, 1, null); 107 | } 108 | } 109 | 110 | 111 | class PytestMocha extends Mocha { 112 | public loadFiles() { 113 | super.loadFiles(); 114 | } 115 | } 116 | 117 | 118 | const mocha = new PytestMocha({ 119 | reporter: PytestReporter, 120 | 121 | // A really long timeout allows us to set breakpoints without fear of Mocha 122 | // aborting our test. 123 | timeout: 300000, 124 | }); 125 | 126 | 127 | mocha.addFile(path.join(__dirname, 'test.ts')); 128 | mocha.loadFiles(); 129 | mocha.run(); 130 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = 'l@n9@k3br3_rfcip+y+7r*526^%^q2!+5&zeasg(*ye91$4ls$' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = [] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | 'django.contrib.admin', 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 29 | 'channels', 30 | 31 | 'dcrf_client_test', 32 | ] 33 | 34 | MIDDLEWARE = [ 35 | 'django.middleware.security.SecurityMiddleware', 36 | 'django.contrib.sessions.middleware.SessionMiddleware', 37 | 'django.middleware.common.CommonMiddleware', 38 | 'django.middleware.csrf.CsrfViewMiddleware', 39 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 40 | 'django.contrib.messages.middleware.MessageMiddleware', 41 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 42 | ] 43 | 44 | ROOT_URLCONF = 'dcrf_client_test.urls' 45 | 46 | TEMPLATES = [ 47 | { 48 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 49 | 'DIRS': [], 50 | 'APP_DIRS': True, 51 | 'OPTIONS': { 52 | 'context_processors': [ 53 | 'django.template.context_processors.debug', 54 | 'django.template.context_processors.request', 55 | 'django.contrib.auth.context_processors.auth', 56 | 'django.contrib.messages.context_processors.messages', 57 | ], 58 | }, 59 | }, 60 | ] 61 | 62 | WSGI_APPLICATION = 'dcrf_client_test.wsgi.application' 63 | 64 | ASGI_APPLICATION = 'dcrf_client_test.routing.application' 65 | 66 | 67 | # NOTE: this will be overridden during testing 68 | CHANNEL_LAYERS = {} 69 | 70 | 71 | # Database 72 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 73 | 74 | DATABASES = { 75 | 'default': { 76 | 'ENGINE': 'django.db.backends.sqlite3', 77 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 78 | 'TEST': { 79 | 'NAME': os.path.join(BASE_DIR, 'db.test.sqlite3'), 80 | } 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | LOGGING = { 119 | 'version': 1, 120 | 'disable_existing_loggers': False, 121 | 'handlers': { 122 | 'console': { 123 | 'level': 'DEBUG', 124 | 'class': 'logging.StreamHandler', 125 | }, 126 | }, 127 | 'loggers': { 128 | '': { 129 | 'handlers': ['console'], 130 | 'level': 'DEBUG', 131 | 'propagate': True, 132 | }, 133 | }, 134 | } 135 | 136 | 137 | # Static files (CSS, JavaScript, Images) 138 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 139 | 140 | STATIC_URL = '/static/' 141 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [Unreleased] 9 | 10 | 11 | ## [1.1.0] — 2021-09-24 12 | ### Added 13 | - Add support for streaming requests ([#31](https://github.com/theY4Kman/dcrf-client/pull/31), thanks [@jhillacre](https://github.com/jhillacre)!) 14 | 15 | 16 | ## 1.0.0 — 2021-06-05 17 | ### Breaking 18 | - Return a Promise from `subscription.cancel()` 19 | - Return a Promise from `subscription.cancel()` 20 | - Return a Promise from `client.close()` 21 | - Return a Promise from `client.unsubscribeAll()` 22 | 23 | ### Added 24 | - Allow custom subscription actions to be used ([#24](https://github.com/theY4Kman/dcrf-client/pull/24), thanks [@jhillacre](https://github.com/jhillacre)!) 25 | - Optionally allow subscription callbacks to handle "create" actions, through `includeCreateEvents` option ([#24](https://github.com/theY4Kman/dcrf-client/pull/24), thanks [@jhillacre](https://github.com/jhillacre)!) 26 | - Unsubscription requests are now sent to the server! ([#24](https://github.com/theY4Kman/dcrf-client/pull/24), thanks [@jhillacre](https://github.com/jhillacre)!) 27 | - Logging switched from [loglevel](https://github.com/pimterry/loglevel) to [winston](https://github.com/winstonjs/winston) 28 | - _Docs:_ Added CHANGELOG.md 29 | 30 | ### Changed 31 | - Return a Promise from `subscription.cancel()` 32 | - Return a Promise from `client.close()` 33 | - Return a Promise from `client.unsubscribeAll()` 34 | - Upgraded TypeScript compiler to 3.7.7 (to support optional chaining and nullish coalescence) 35 | 36 | ### Fixed 37 | - Prevent duplicate transmissions of subscribe requests during resubscribe ([#24](https://github.com/theY4Kman/dcrf-client/pull/24), thanks [@jhillacre](https://github.com/jhillacre)!) 38 | - _Tests:_ Upgrade pytest to 6.2 39 | - _Tests:_ Upgrade integration tests' Python from 3.7 to 3.8 to resolve `importlib_metadata` import error ([#10](https://github.com/theY4Kman/dcrf-client/issues/10)) 40 | - _Tests:_ Change `npm run test:integration` to invoke pytest through `pipenv run` 41 | - _Docs:_ Change instructions on setting up env for integration tests to use `pipenv install --dev` (without `--dev`, necessary deps were not being installed) 42 | - _Tests:_ Resolve stdout buffering issue in integration test runner which resulted in process hang 43 | 44 | 45 | ## 0.3.0 — 2020-12-09 46 | ### Added 47 | - Allow custom PK fields to be used with subscriptions 48 | - Allow generation of selector/payload objects to be controlled 49 | - Add `client.close()` method to explicitly disconnect transport 50 | - Add `client.unsubscribeAll()` method to unsubscribe any active subscriptions (called by default in `close()`) 51 | - _Docs:_ Add docs to README explaining how to utilize custom PK fields (and `ensurePkFieldInDeleteEvents`) 52 | 53 | 54 | ## 0.2.0 — 2020-11-11 55 | ### Added 56 | - Allow all options supported by `ReconnectingWebsocket` to be passed through client (previously, the allowed options were hardcoded) (thanks [@sandro-salles](https://github.com/sandro-salles)) 57 | - _Docs:_ Include docs on reconnecting-websocket options in README (thanks [@sandro-salles](https://github.com/sandro-salles)) 58 | 59 | ### Changed 60 | - Upgraded [reconnecting-websocket](https://github.com/pladaria/reconnecting-websocket) from 4.1.10 to 4.4.0 (thanks [@sandro-salles](https://github.com/sandro-salles)) 61 | 62 | 63 | ## 0.1.2 — 2019-03-30 64 | ### Added 65 | - _Docs:_ Add code example for `client.patch()` in README 66 | 67 | 68 | ## 0.1.1 — 2019-03-30 69 | ### Fixed 70 | - _Docs:_ Update outdated code examples in README 71 | 72 | 73 | ## 0.1.0 — 2019-03-30 74 | ### Added 75 | - Allow request IDs to be manually specified 76 | - _Tests:_ Add integration tests 77 | - _Docs:_ Add instructions for running tests 78 | 79 | ### Fixed 80 | - Update subscription request/listener to match DCRF's semantics (channels-api semantics were being used) 81 | 82 | 83 | ## 0.0.2 — 2019-03-24 84 | ### Fixed 85 | - Ensure a request ID is sent in resubscriptions 86 | 87 | 88 | ## 0.0.1 — 2019-03-20 89 | ### Added 90 | - Initial release: TypeScript port of channels-api 91 | -------------------------------------------------------------------------------- /test/integration/tests/live_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from channels.routing import get_default_application 5 | from channels.staticfiles import StaticFilesWrapper 6 | from daphne.testing import DaphneProcess 7 | from django.core.exceptions import ImproperlyConfigured 8 | from pytest_django.lazy_django import skip_if_no_django 9 | 10 | 11 | class LiveServer: 12 | """The liveserver fixture 13 | 14 | This is the object that the ``live_server`` fixture returns. 15 | The ``live_server`` fixture handles creation and stopping. 16 | """ 17 | 18 | ProtocolServerProcess = DaphneProcess 19 | static_wrapper = StaticFilesWrapper 20 | 21 | def __init__(self, host='localhost'): 22 | from django.db import connections 23 | from django.test.utils import modify_settings 24 | 25 | for connection in connections.all(): 26 | if self._is_in_memory_db(connection): 27 | raise ImproperlyConfigured( 28 | "ChannelLiveServerTestCase can not be used with in memory databases" 29 | ) 30 | 31 | self._live_server_modified_settings = modify_settings( 32 | ALLOWED_HOSTS={"append": host} 33 | ) 34 | 35 | self._server_process = self.ProtocolServerProcess(host, self.get_application()) 36 | self._server_process.start() 37 | self._server_process.ready.wait() 38 | self._host = host 39 | self._port = self._server_process.port.value 40 | 41 | if not self._server_process.errors.empty(): 42 | raise self._server_process.errors.get() 43 | 44 | def get_application(self): 45 | from django.conf import settings 46 | 47 | application = get_default_application() 48 | 49 | if "django.contrib.staticfiles" in settings.INSTALLED_APPS: 50 | application = self.static_wrapper(application) 51 | 52 | return application 53 | 54 | def stop(self): 55 | """Stop the server""" 56 | self._server_process.terminate() 57 | self._server_process.join() 58 | 59 | @property 60 | def url(self): 61 | return f"http://{self._host}:{self._port}" 62 | 63 | @property 64 | def ws_url(self): 65 | return f"ws://{self._host}:{self._port}" 66 | 67 | def __str__(self): 68 | return self.url 69 | 70 | def __add__(self, other): 71 | return str(self) + other 72 | 73 | def __repr__(self): 74 | return f"" 75 | 76 | def _is_in_memory_db(self, connection): 77 | """ 78 | Check if DatabaseWrapper holds in memory database. 79 | """ 80 | if connection.vendor == "sqlite": 81 | return connection.is_in_memory_db() 82 | 83 | 84 | @pytest.fixture(scope="session") 85 | def live_server(request, redis_server, django_db_setup, django_db_blocker): 86 | """Run a live Django Channels server in the background during tests 87 | 88 | The host the server is started from is taken from the 89 | --liveserver command line option or if this is not provided from 90 | the DJANGO_LIVE_TEST_SERVER_ADDRESS environment variable. If 91 | neither is provided ``localhost`` is used. 92 | 93 | NOTE: If the live server needs database access to handle a request 94 | your test will have to request database access. Furthermore 95 | when the tests want to see data added by the live-server (or 96 | the other way around) transactional database access will be 97 | needed as data inside a transaction is not shared between 98 | the live server and test code. 99 | 100 | Static assets will be automatically served when 101 | ``django.contrib.staticfiles`` is available in INSTALLED_APPS. 102 | """ 103 | skip_if_no_django() 104 | 105 | addr = request.config.getvalue("liveserver") or os.getenv( 106 | "DJANGO_LIVE_TEST_SERVER_ADDRESS" 107 | ) 108 | 109 | if addr and ":" in addr: 110 | raise ValueError('Cannot supply port with Django Channels live_server') 111 | 112 | if not addr: 113 | addr = "localhost" 114 | 115 | # NOTE: because our live Daphne server fork()'s from this process, we must 116 | # ensure DB access is allowed before forking — otherwise every test 117 | # will receive the dreaded "use the django_db mark, Luke" error 118 | django_db_blocker.unblock() 119 | server = LiveServer(addr) 120 | django_db_blocker.restore() 121 | 122 | request.addfinalizer(server.stop) 123 | return server 124 | -------------------------------------------------------------------------------- /test/integration/dcrf_client_test/consumers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | from typing import Iterable, Union 4 | 5 | from djangochannelsrestframework.decorators import action 6 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 7 | from djangochannelsrestframework.mixins import ( 8 | CreateModelMixin, 9 | DeleteModelMixin, 10 | ListModelMixin, 11 | PatchModelMixin, 12 | UpdateModelMixin, 13 | ) 14 | from djangochannelsrestframework.observer.generics import ObserverModelInstanceMixin 15 | from rest_framework import serializers, status 16 | from rest_framework.exceptions import NotFound 17 | 18 | from dcrf_client_test.models import Thing 19 | from dcrf_client_test.observers import model_observer 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class ThingSerializer(serializers.ModelSerializer): 25 | class Meta: 26 | model = Thing 27 | fields = [ 28 | 'pk', 29 | 'name', 30 | 'counter', 31 | ] 32 | 33 | 34 | class ThingsWithIdSerializer(serializers.ModelSerializer): 35 | class Meta: 36 | model = Thing 37 | fields = '__all__' 38 | 39 | 40 | class ThingConsumer( 41 | ListModelMixin, 42 | CreateModelMixin, 43 | UpdateModelMixin, 44 | PatchModelMixin, 45 | DeleteModelMixin, 46 | ObserverModelInstanceMixin, 47 | GenericAsyncAPIConsumer, 48 | ): 49 | queryset = Thing.objects.all() 50 | serializer_class = ThingSerializer 51 | 52 | def _unsubscribe(self, request_id: str): 53 | request_id_found = False 54 | to_remove = [] 55 | for group, request_ids in self.subscribed_requests.items(): 56 | if request_id in request_ids: 57 | request_id_found = True 58 | request_ids.remove(request_id) 59 | if not request_ids: 60 | to_remove.append(group) 61 | 62 | if not request_id_found: 63 | raise KeyError(request_id) 64 | 65 | for group in to_remove: 66 | del self.subscribed_requests[group] 67 | 68 | @action() 69 | async def unsubscribe_instance(self, request_id=None, **kwargs): 70 | try: 71 | return await super().unsubscribe_instance(request_id=request_id, **kwargs) 72 | except KeyError: 73 | raise NotFound(detail='Subscription not found') 74 | 75 | @model_observer(Thing) 76 | async def on_thing_activity( 77 | self, message, observer=None, action: str = None, request_id: str = None, **kwargs 78 | ): 79 | try: 80 | reply = partial(self.reply, action=action, request_id=request_id) 81 | 82 | if action == 'delete': 83 | await reply(data=message, status=204) 84 | # send the delete 85 | return 86 | 87 | # the @action decorator will wrap non-async action into async ones. 88 | response = await self.retrieve( 89 | request_id=request_id, action=action, **message 90 | ) 91 | 92 | if isinstance(response, tuple): 93 | data, status = response 94 | else: 95 | data, status = response, 200 96 | await reply(data=data, status=status) 97 | except Exception as exc: 98 | await self.handle_exception(exc, action=action, request_id=request_id) 99 | 100 | @on_thing_activity.groups_for_signal 101 | def on_thing_activity(self, instance: Thing, **kwargs): 102 | yield f'-pk__{instance.pk}' 103 | yield f'-all' 104 | 105 | @on_thing_activity.groups_for_consumer 106 | def on_thing_activity(self, things: Iterable[Union[Thing, int]] = None, **kwargs): 107 | if things is None: 108 | yield f'-all' 109 | else: 110 | for thing in things: 111 | thing_id = thing.pk if isinstance(thing, Thing) else thing 112 | yield f'-pk__{thing_id}' 113 | 114 | @on_thing_activity.serializer 115 | def on_thing_activity(self, instance: Thing, action: str, **kwargs): 116 | return ThingSerializer(instance).data 117 | 118 | @action() 119 | async def subscribe_many(self, request_id: str = None, things: Iterable[int] = None, **kwargs): 120 | await self.on_thing_activity.subscribe(request_id=request_id, things=things) 121 | return None, status.HTTP_201_CREATED 122 | 123 | @action() 124 | async def unsubscribe_many(self, request_id: str = None, things: Iterable[int] = None, **kwargs): 125 | await self.on_thing_activity.unsubscribe(request_id=request_id, things=things) 126 | return None, status.HTTP_204_NO_CONTENT 127 | 128 | 129 | class ThingsWithIdConsumer(ThingConsumer): 130 | serializer_class = ThingsWithIdSerializer 131 | -------------------------------------------------------------------------------- /test/integration/tests/mocha.py: -------------------------------------------------------------------------------- 1 | import json 2 | import linecache 3 | import logging 4 | import re 5 | import subprocess 6 | import sys 7 | import types 8 | from itertools import groupby 9 | from pathlib import Path 10 | from typing import Any, Dict, List, Optional 11 | 12 | import pytest 13 | from _pytest.fixtures import FuncFixtureInfo 14 | from py._path.local import LocalPath 15 | 16 | from tests.live_server import LiveServer 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | SCRIPT_PATH = Path(__file__) 22 | SCRIPT_DIR = SCRIPT_PATH.parent 23 | MOCHA_RUNNER_PATH = SCRIPT_DIR / 'runner.ts' 24 | 25 | 26 | class MochaCoordinator: 27 | def __init__(self, debug: bool = False, debug_port: int = 9229, debug_suspend: bool = False): 28 | self.debug = debug 29 | self.debug_port = debug_port 30 | self.debug_suspend = debug_suspend 31 | 32 | self._did_start = False 33 | self.proc = None 34 | self._init_proc() 35 | 36 | self.tests = None 37 | self._read_tests() 38 | 39 | def _init_proc(self): 40 | args = [] 41 | 42 | if self.debug: 43 | flag = 'inspect-brk' if self.debug_suspend else 'inspect' 44 | args += [ 45 | 'node', 46 | f'--{flag}={self.debug_port}', 47 | '-r', 'ts-node/register', 48 | ] 49 | else: 50 | args.append('ts-node') 51 | 52 | args.append(MOCHA_RUNNER_PATH) 53 | 54 | self.proc = subprocess.Popen( 55 | args=args, 56 | stdin=subprocess.PIPE, 57 | stdout=subprocess.PIPE, 58 | stderr=sys.stderr, 59 | ) 60 | 61 | @property 62 | def did_start(self): 63 | return self._did_start 64 | 65 | def start(self): 66 | """Begin execution of the test suite""" 67 | self._write() 68 | self._did_start = True 69 | logger.debug('Test suite started') 70 | 71 | def _read_tests(self) -> List[Dict[str, Any]]: 72 | if self.tests is None: 73 | event = self.expect('collect') 74 | self.tests = event['tests'] 75 | return self.tests 76 | 77 | def write(self, type, **info): 78 | event = { 79 | 'type': type, 80 | **info, 81 | } 82 | line = json.dumps(event) 83 | self._write(line) 84 | logger.debug(f'Wrote event to Mocha: {event}') 85 | 86 | def _write(self, s: str = None): 87 | if s is not None: 88 | self.proc.stdin.write(s.encode('utf-8')) 89 | 90 | self.proc.stdin.write(b'\n') 91 | self.proc.stdin.flush() 92 | 93 | def read(self) -> Dict[str, Any]: 94 | line = self.proc.stdout.readline() 95 | try: 96 | event = json.loads(line) 97 | except json.JSONDecodeError: 98 | logger.exception(f'Error parsing JSON from Mocha: {line}') 99 | raise 100 | logger.debug(f'Read event from Mocha: {event}') 101 | return event 102 | 103 | def expect(self, *types: str) -> Dict[str, Any]: 104 | logger.debug(f'Expecting event from Mocha of type(s): {",".join(types)}') 105 | 106 | event = self.read() 107 | if event['type'] not in types: 108 | str_types = ', '.join(types) 109 | raise ValueError(f'Expected one of {str_types}, but found: {event["type"]}') 110 | return event 111 | 112 | 113 | coordinator: Optional[MochaCoordinator] = None 114 | 115 | 116 | def pytest_addoption(parser): 117 | group = parser.getgroup('mocha') 118 | group.addoption( 119 | '--mocha-debug', 120 | action='store_true', 121 | dest='mocha_debug', 122 | default=False, 123 | ) 124 | group.addoption( 125 | '--mocha-debug-port', 126 | type=int, 127 | dest='mocha_debug_port', 128 | default=9229, 129 | ) 130 | group.addoption( 131 | '--mocha-debug-suspend', 132 | action='store_true', 133 | dest='mocha_debug_suspend', 134 | default=False, 135 | ) 136 | 137 | 138 | def pytest_cmdline_main(config): 139 | global coordinator 140 | coordinator = MochaCoordinator( 141 | debug=config.option.mocha_debug, 142 | debug_port=config.option.mocha_debug_port, 143 | debug_suspend=config.option.mocha_debug_suspend, 144 | ) 145 | 146 | 147 | class MochaTest(pytest.Function): 148 | def __init__(self, *args, **kwargs): 149 | self._obj = self._testmethod 150 | 151 | super().__init__(*args, **kwargs) 152 | 153 | def _testmethod(self, live_server: LiveServer, **kwargs): 154 | coordinator.expect('test') 155 | 156 | coordinator.write('server info', url=live_server.url, ws_url=live_server.ws_url) 157 | 158 | event = coordinator.expect('pass', 'fail') 159 | 160 | # Wait for all mocha after/afterEach hooks to complete 161 | coordinator.expect('test end') 162 | 163 | if event['state'] == 'failed': 164 | message = event['err'] 165 | stack = event['stack'] 166 | 167 | match = re.search( 168 | r'at (?P\S+) \((?P.+):(?P\d+):(?P\d+)\)$', 169 | stack, 170 | re.MULTILINE, 171 | ) 172 | if not match: 173 | raise RuntimeError(message) 174 | 175 | # 176 | # Juicy JS stack trace found! We can trick Python into printing the 177 | # relevant JS source, by creating a fake Python module with a raise 178 | # statement at the same line number, and filling Python's cache of 179 | # file sources (AKA linecache) with the actual JS code. 180 | # 181 | ### 182 | 183 | file = match.group('file') 184 | lineno = int(match.group('lineno')) 185 | 186 | ## 187 | # Fill line cache with the actual JS source 188 | # 189 | with open(file) as fp: 190 | source = fp.read() 191 | def getsource(): 192 | return source 193 | linecache.cache[file] = (getsource,) 194 | 195 | ### 196 | # Create a fake module, raising an exception from the same 197 | # line number as the error raised in the JS file. 198 | # 199 | mod = types.ModuleType(file) 200 | exc_msg = f'{message}\n\n{stack}' 201 | fake_source = '\n' * (lineno - 1) + f'raise RuntimeError({exc_msg!r})' 202 | co = compile(fake_source, file, 'exec', dont_inherit=True) 203 | exec(co, mod.__dict__) 204 | 205 | 206 | class MochaFile(pytest.File): 207 | obj = None 208 | 209 | 210 | def pytest_collection(session: pytest.Session): 211 | session.items = [] 212 | 213 | for filename, tests in groupby(coordinator.tests, key=lambda test: test['file']): 214 | file = MochaFile.from_parent(session, fspath=LocalPath(filename)) 215 | 216 | for info in tests: 217 | requested_fixtures = ['live_server', '_live_server_helper'] 218 | test = MochaTest.from_parent( 219 | file, 220 | name='::'.join(info['parents']), 221 | fixtureinfo=FuncFixtureInfo( 222 | argnames=tuple(requested_fixtures), 223 | initialnames=tuple(requested_fixtures), 224 | names_closure=requested_fixtures, 225 | name2fixturedefs={}, 226 | ), 227 | keywords={ 228 | 'django_db': pytest.mark.django_db(transaction=True), 229 | } 230 | ) 231 | 232 | session.items.append(test) 233 | 234 | ### 235 | # NOTE: if this counter remains 0 at end of session, an exit code of 5 will be returned. 236 | # This value is normally set by Session.perform_collect(), but we are bypassing that 237 | # implementation. 238 | # 239 | session.testscollected = len(session.items) 240 | 241 | return session.items 242 | 243 | 244 | def pytest_runtestloop(session): 245 | coordinator.start() 246 | -------------------------------------------------------------------------------- /test/integration/tests/test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import chai, {expect} from 'chai'; 4 | import chaiSubset from 'chai-subset'; 5 | import {format, transports} from 'winston'; 6 | chai.use(chaiSubset); 7 | 8 | import {rootLogger, getLogger} from '../../../src/logging'; 9 | 10 | // Enable all logging 11 | rootLogger.level = 'debug'; 12 | // Print all logs to stderr, so pytest may parrot them 13 | rootLogger 14 | .clear() 15 | .add(new transports.Console({ 16 | level: 'silly', 17 | stderrLevels: Object.keys(rootLogger.levels), 18 | })); 19 | // And colorize, for style 20 | rootLogger.format = format.combine( 21 | format.colorize(), 22 | rootLogger.format, 23 | ); 24 | 25 | import WebSocket from 'ws'; 26 | 27 | import dcrf, {DCRFClient} from '../../../src/'; 28 | import {DCRFGlobal} from '../../global'; 29 | 30 | 31 | const log = getLogger('dcrf.test.integration'); 32 | 33 | 34 | declare const global: DCRFGlobal; 35 | global.WebSocket = WebSocket; 36 | 37 | 38 | let serverInfo: {url: string, ws_url: string}; 39 | 40 | 41 | function readEvent(): {[prop: string]: any} { 42 | const buffer = Buffer.alloc(1024); 43 | let line = null; 44 | 45 | for (let i=0; i { 98 | describe(stream, function () { 99 | beforeEach(function() { 100 | client = dcrf.createClient(`${serverInfo.ws_url}/ws`, options); 101 | 102 | // Wait for websocket connection before allowing tests to begin 103 | const onWebsocketConnected = new Promise(resolve => { 104 | client.transport.on('connect', () => resolve()); 105 | }) 106 | client.initialize(); 107 | return onWebsocketConnected; 108 | }); 109 | 110 | afterEach(async function () { 111 | await client.close(); 112 | }) 113 | 114 | describe('create', function() { 115 | 116 | it('returns created values', function() { 117 | return ( 118 | client 119 | .create(stream, {name: 'unique'}) 120 | .then(thing => { 121 | expect(thing).to.containSubset({ 122 | name: 'unique', 123 | counter: 0, 124 | }); 125 | }) 126 | ); 127 | }); 128 | 129 | it('imbues retrieve with data', function() { 130 | return ( 131 | client 132 | .create(stream, {name: 'unique'}) 133 | .then(thing => client.retrieve(stream, thing[client.pkField])) 134 | .then(thing => { 135 | expect(thing).to.containSubset({ 136 | name: 'unique', 137 | counter: 0, 138 | }); 139 | }) 140 | ); 141 | }); 142 | 143 | }); 144 | 145 | 146 | describe('list', function() { 147 | 148 | it('returns empty set', function() { 149 | return ( 150 | client 151 | .list(stream) 152 | .then(things => { 153 | expect(things).to.eql([]); 154 | }) 155 | ) 156 | }); 157 | 158 | it('returns created rows', function() { 159 | const rows = [ 160 | {name: 'max'}, 161 | {name: 'mary', counter: 1}, 162 | {name: 'unique', counter: 1337}, 163 | ]; 164 | 165 | return ( 166 | Promise.all(rows.map(row => client.create(stream, row))) 167 | .then(() => client.list(stream)) 168 | .then(things => { 169 | expect(things).to.containSubset(rows); 170 | }) 171 | ) 172 | }); 173 | 174 | }); 175 | 176 | 177 | describe('subscribe', function() { 178 | 179 | it('invokes callback on delete', function(done) { 180 | expect(2); 181 | 182 | client 183 | .create(stream, {name: 'unique'}) 184 | .then(thing => { 185 | const originalId = thing[client.pkField]; 186 | 187 | client 188 | .subscribe(stream, thing[client.pkField], (thing, action) => { 189 | expect(action).to.equal('delete'); 190 | expect(thing).to.eql({ 191 | [client.pkField]: originalId, 192 | }); 193 | done(); 194 | }) 195 | .then(() => { 196 | client.delete(stream, thing[client.pkField]); 197 | }); 198 | }); 199 | }); 200 | 201 | it('invokes callback on change', function(done) { 202 | expect(2); 203 | 204 | client 205 | .create(stream, {name: 'unique'}) 206 | .then(thing => { 207 | client.subscribe(stream, thing[client.pkField], (thing, action) => { 208 | expect(action).to.equal('update'); 209 | expect(thing.name).to.equal('new'); 210 | done(); 211 | }); 212 | 213 | client.update(stream, thing[client.pkField], {name: 'new'}) 214 | }); 215 | }); 216 | 217 | it('invokes callbacks on changes to multiple objects', async function() { 218 | const getUpdatedName = (name: string) => `${name}-new`; 219 | 220 | const rows = [ 221 | { name: 'alpha' }, 222 | { name: 'bravo' }, 223 | { name: 'charlie' }, 224 | ]; 225 | expect(2 * rows.length); 226 | 227 | const things = await Promise.all(rows.map(row => client.create(stream, row))); 228 | const afterUpdatePromises = [] 229 | 230 | for (let thing of things) { 231 | const originalName = thing.name; 232 | 233 | let afterUpdateResolve: () => any; 234 | afterUpdatePromises.push(new Promise(resolve => { 235 | afterUpdateResolve = resolve; 236 | })); 237 | 238 | const callback = (afterUpdateResolve => (thing: any, action: string) => { 239 | log.debug('Resolving subscription promise for %o', thing); 240 | expect(action).to.equal('update'); 241 | expect(thing.name).to.equal(getUpdatedName(originalName)); 242 | afterUpdateResolve(); 243 | // @ts-ignore 244 | })(afterUpdateResolve); 245 | 246 | await client.subscribe(stream, thing[client.pkField], callback); 247 | } 248 | 249 | for (let thing of things) { 250 | await client.update(stream, thing[client.pkField], { name: getUpdatedName(thing.name) }); 251 | } 252 | 253 | await Promise.all(afterUpdatePromises); 254 | }); 255 | 256 | 257 | describe('custom', function () { 258 | 259 | it('invokes callback on create with includeCreateEvents', function (done) { 260 | expect(2); 261 | 262 | client.subscribe( 263 | stream, 264 | {}, 265 | (thing, action) => { 266 | expect(action).to.equal('create'); 267 | expect(thing.name).to.equal('hello, world!'); 268 | done(); 269 | }, 270 | { 271 | includeCreateEvents: true, 272 | subscribeAction: 'subscribe_many', 273 | unsubscribeAction: 'unsubscribe_many', 274 | }, 275 | ).then(() => { 276 | client.create(stream, {name: 'hello, world!'}); 277 | }); 278 | }); 279 | 280 | it("doesn't invoke callback on create w/o includeCreateEvents", function (done) { 281 | expect(2); 282 | 283 | client.subscribe( 284 | stream, 285 | {}, 286 | (thing, action) => { 287 | expect(action).to.equal('update'); 288 | expect(thing.name).to.equal('updated'); 289 | done(); 290 | }, 291 | { 292 | includeCreateEvents: false, 293 | subscribeAction: 'subscribe_many', 294 | unsubscribeAction: 'unsubscribe_many', 295 | }, 296 | ).then(async () => { 297 | const thing = await client.create(stream, {name: 'hello, world!'}); 298 | await client.update(stream, thing[client.pkField], {name: 'updated'}); 299 | }); 300 | }); 301 | 302 | it('invokes callback on update', function (done) { 303 | expect(2); 304 | 305 | client.create(stream, {name: 'hello, world!'}).then(thing => { 306 | client.subscribe( 307 | stream, 308 | {}, 309 | (thing, action) => { 310 | expect(action).to.equal('update'); 311 | expect(thing.name).to.equal('updated'); 312 | done(); 313 | }, 314 | { 315 | subscribeAction: 'subscribe_many', 316 | unsubscribeAction: 'unsubscribe_many', 317 | }, 318 | ).then(() => { 319 | client.update(stream, thing[client.pkField], {name: 'updated'}); 320 | }); 321 | }); 322 | }); 323 | 324 | it('invokes callback on delete', function (done) { 325 | expect(2); 326 | 327 | client.create(stream, {name: 'hello, world!'}).then(thing => { 328 | const originalId = thing[client.pkField]; 329 | 330 | client.subscribe( 331 | stream, 332 | {}, 333 | (thing, action) => { 334 | expect(action).to.equal('delete'); 335 | expect(thing[client.pkField]).to.equal(originalId); 336 | done(); 337 | }, 338 | { 339 | subscribeAction: 'subscribe_many', 340 | unsubscribeAction: 'unsubscribe_many', 341 | }, 342 | ).then(() => { 343 | client.delete(stream, thing[client.pkField]); 344 | }); 345 | }); 346 | }); 347 | 348 | }); // describe('custom') 349 | 350 | }); // describe('subscribe') 351 | 352 | }); // describe(stream) 353 | }); 354 | 355 | }); // describe('DCRFClient') 356 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dcrf-client 2 | 3 | [![npm version](https://badge.fury.io/js/dcrf-client.svg)](https://badge.fury.io/js/dcrf-client) 4 | 5 | This package aims to provide a **simple**, **reliable**, and **generic** interface to consume [Django Channels REST Framework](https://github.com/hishnash/djangochannelsrestframework) powered WebSocket APIs. 6 | 7 | NOTE: This library is a TypeScript port of [channels-api-client](https://github.com/theY4Kman/channels-api-client) to support Django Channels v2, and [@hishnash](https://github.com/hishnash)'s port of [linuxlewis](https://github.com/linuxlewis)'s [channels-api](https://github.com/linuxlewis/channels-api): [djangochannelsrestframework](https://github.com/hishnash/djangochannelsrestframework) and [channelsmultiplexer](https://github.com/hishnash/channelsmultiplexer). 8 | 9 | 10 | ## Features 11 | 12 | - Promises encapsulating the request/response cycle 13 | - Subscribe to updates with a callback 14 | - Automatically reconnect when connection is broken (with backoff — thanks to [reconnecting-websocket](https://github.com/pladaria/reconnecting-websocket)) 15 | - Automatically restart subscriptions on reconnection 16 | - Requests are queued until a connection is made (no need to wait for connection before sending requests) 17 | 18 | 19 | ## Install 20 | 21 | ```bash 22 | npm install dcrf-client 23 | ``` 24 | 25 | 26 | ## Usage 27 | 28 | ```javascript 29 | const dcrf = require('dcrf-client'); 30 | const client = dcrf.connect('wss://example.com'); 31 | 32 | client.create('people', {name: 'Alex'}).then(person => { 33 | console.info('Created:', person); 34 | }); 35 | 36 | client.retrieve('people', 4).then(person => { 37 | console.info('Retrieved person 4:', person); 38 | }); 39 | 40 | client.update('people', 4, {name: 'Johannes', address: '123 Easy St'}).then(person => { 41 | console.info('Overwrote person 4. Properties after change:', person); 42 | }); 43 | 44 | client.patch('people', 4, {name: 'Jefe'}).then(person => { 45 | console.info('Changed name of person 4. Properties after change:', person); 46 | }); 47 | 48 | client.delete('people', 4).then(() => { 49 | console.info('Deleted person 4. No one liked them, anyway :)'); 50 | }); 51 | 52 | 53 | // Subscribe to updates to person 1 54 | const personalSubscription = client.subscribe('people', 1, (person, action) => { 55 | if (action === 'update') { 56 | console.info('Person 1 was updated:', person); 57 | } 58 | else if (action === 'delete') { 59 | console.info('Person 1 was deleted!'); 60 | } 61 | }); 62 | 63 | // Stop listening for updates 64 | personalSubscription.cancel(); 65 | 66 | 67 | // Make a generic request to a multiplexer stream 68 | client.request('mystream', {key: 'value'}).then(response => { 69 | console.info('Got mystream response, yo:', response); 70 | }); 71 | 72 | // Subscribe using a custom action 73 | const customSubscription = client.subscribe( 74 | 'people', 75 | {}, // Additional arguments may be passed to action 76 | (person, action) => { 77 | if (action === 'create') { 78 | console.info(`Person ${person.pk} was created:`, person); 79 | } 80 | else if (action === 'update') { 81 | console.info(`Person ${person.pk} was updated:`, person); 82 | } 83 | else if (action === 'delete') { 84 | console.info(`Person ${person.pk} was deleted!`); 85 | } 86 | }, 87 | { 88 | includeCreateEvents: true, 89 | subscribeAction: 'subscribe_all', 90 | unsubscribeAction: 'unsubscribe_all', 91 | }, 92 | ); 93 | ``` 94 | 95 | 96 | ## Configuration 97 | 98 | The client can be customized by passing an object as the second argument to `connect()` or `createClient()`. The available options are described below. 99 | 100 | ```typescript 101 | const dcrf = require('dcrf-client'); 102 | 103 | const client = dcrf.connect('wss://example.com', { 104 | /** 105 | * Options to pass along to ReconnectingWebsocket 106 | * 107 | * See https://github.com/pladaria/reconnecting-websocket#available-options for more info 108 | */ 109 | websocket: { 110 | WebSocket?: any; // WebSocket constructor, if none provided, defaults to global WebSocket 111 | maxReconnectionDelay?: number; // max delay in ms between reconnections 112 | minReconnectionDelay?: number; // min delay in ms between reconnections 113 | reconnectionDelayGrowFactor?: number; // how fast the reconnection delay grows 114 | minUptime?: number; // min time in ms to consider connection as stable 115 | connectionTimeout?: number; // retry connect if not connected after this time, in ms 116 | maxRetries?: number; // maximum number of retries 117 | maxEnqueuedMessages?: number; // maximum number of messages to buffer until reconnection 118 | startClosed?: boolean; // start websocket in CLOSED state, call `.reconnect()` to connect 119 | debug?: boolean; // enables debug output 120 | }, 121 | 122 | /** 123 | * Name of serializer field is used to identify objects in subscription event payloads. 124 | * 125 | * Default: 'pk' 126 | */ 127 | pkField: 'id', 128 | 129 | /** 130 | * Whether to ensure subscription delete event payloads store the primary key of the object 131 | * in the configured `pkField`, instead of the default 'pk'. 132 | * 133 | * Because subscription delete payloads aren't run through the configured serializer (as the 134 | * objects do not exist), the DCRF backend must pick a field to store the primary key of the 135 | * object in the payload. DCRF chooses 'pk' for this field. If `pkField` is *not* 'pk' (and is 136 | * instead, say, 'id'), then subscription update payloads will return `{id: 123}`, but delete 137 | * payloads will return `{pk: 123}`. 138 | * 139 | * To address the potential inconsistencies between subscription update and delete payloads, 140 | * setting this option to true (default) will cause dcrf-client to replace the 'pk' field with 141 | * the configured `pkField` setting. 142 | * 143 | * Default: true 144 | */ 145 | ensurePkFieldInDeleteEvents: true, 146 | 147 | /** 148 | * Customizes the format of a multiplexed message to be sent to the server. 149 | * 150 | * In almost all circumstances, the default behaviour is usually desired. 151 | * 152 | * The default behaviour is reproduced here. 153 | */ 154 | buildMultiplexedMessage(stream: string, payload: object): object { 155 | return {stream, payload}; 156 | }, 157 | 158 | /** 159 | * Customizes the selector (a pattern matching an object) for the response to an API request 160 | * 161 | * In almost all circumstances, the default behaviour is usually desired. 162 | * 163 | * The default behaviour is reproduced here. 164 | */ 165 | buildRequestResponseSelector(stream: string, requestId: string): object { 166 | return { 167 | stream, 168 | payload: {request_id: requestId}, 169 | }; 170 | }, 171 | 172 | /** 173 | * Customizes the selector (a pattern matching an object) matching a subscription update event for 174 | * an object. 175 | * 176 | * In almost all circumstances, the default behaviour is usually desired. 177 | * 178 | * The default behaviour is reproduced here. 179 | */ 180 | buildSubscribeUpdateSelector(stream: string, pk: number, requestId: string): object { 181 | return { 182 | stream, 183 | payload: { 184 | action: 'update', 185 | data: {[this.pkField]: pk}, 186 | request_id: requestId, 187 | }, 188 | }; 189 | }, 190 | 191 | /** 192 | * Customizes the selector (a pattern matching an object) matching a subscription delete event for 193 | * an object. 194 | * 195 | * In almost all circumstances, the default behaviour is usually desired. 196 | * 197 | * The default behaviour is reproduced here. 198 | */ 199 | buildSubscribeDeleteSelector(stream: string, pk: number, requestId: string): object { 200 | return { 201 | stream, 202 | payload: { 203 | action: 'delete', 204 | data: {pk}, 205 | request_id: requestId, 206 | }, 207 | }; 208 | }, 209 | 210 | /** 211 | * Customizes the payload sent to begin subscriptions 212 | * 213 | * In almost all circumstances, the default behaviour is usually desired. 214 | * 215 | * The default behaviour is reproduced here. 216 | */ 217 | buildSubscribePayload(pk: number, requestId: string): object { 218 | return { 219 | action: 'subscribe_instance', 220 | request_id: requestId, 221 | pk, // NOTE: the subscribe_instance action REQUIRES the literal argument `pk`. 222 | // this argument is NOT the same as the ID field of the model. 223 | }; 224 | }, 225 | 226 | preprocessPayload: (stream, payload, requestId) => { 227 | // Modify payload any way you see fit, before it's sent over the wire 228 | // For instance, add a custom authentication token: 229 | payload.token = '123'; 230 | // Be sure not to return anything if you modify payload 231 | 232 | // Or, you can overwrite the payload by returning a new object: 233 | return {'this': 'is my new payload'}; 234 | }, 235 | 236 | preprocessMessage: (message) => { 237 | // The "message" is the final value which will be serialized and sent over the wire. 238 | // It includes the stream and the payload. 239 | 240 | // Modify the message any way you see fit, before its sent over the wire. 241 | message.token = 'abc'; 242 | // Don't return anything if you modify message 243 | 244 | // Or, you can overwrite the the message by returning a new object: 245 | return {stream: 'creek', payload: 'craycrayload'}; 246 | }, 247 | }); 248 | ``` 249 | 250 | 251 | ## Development 252 | 253 | There are two main test suites: unit tests (in `test/test.ts`) to verify intended behaviour of the client, and integration tests (in `test/integration/tests/test.ts`) to verify the client interacts with the server properly. 254 | 255 | Both suites utilize Mocha as the test runner, though the integration tests are executed through py.test, to provide a live server to make requests against. 256 | 257 | The integration tests require separate dependencies. To install them, first [install pipenv](https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv), then run `pipenv install --dev`. 258 | 259 | To run both test suites: `npm run test` 260 | 261 | To run unit tests: `npm run test:unit` or `mocha` 262 | 263 | To run integration tests: `npm run test:integration` or `pipenv run py.test` 264 | 265 | 266 | ### How do the integration tests work? 267 | 268 | [pytest](https://docs.pytest.org/en/latest/) provides a number of hooks to modify how tests are collected, executed, and reported. These are utilized to discover tests from a Mocha suite, and execute them on pytest's command. 269 | 270 | Our pytest-mocha plugin first spawns a subprocess to a custom Mocha runner, which collects its own TypeScript-based tests and emits that test info in JSON format to stdout. pytest-mocha reads this info and reports it to pytest, allowing pytest to print out the true names from the Mocha suite. Using [deasync](https://github.com/abbr/deasync), the Mocha process waits for pytest-mocha to send an acknowledgment (a newline) to stdin before continuing. 271 | 272 | pytest-mocha then spins up a live Daphne server for the tests to utilize. Before each test, the Mocha suite emits another JSON message informing pytest-mocha which test is about to run. pytest-mocha replies with the connection info in JSON format to the Mocha runner's stdin. The Mocha suite uses this to initialize a DCRFClient for each test. 273 | 274 | At the end of each test, our custom Mocha runner emits a "test ended" message. pytest-mocha then wipes the database (with the help of pytest-django) for the next test run. 275 | 276 | (Note that technically, Mocha's "test end" event is somewhat misleading, and isn't used directly to denote test end. Mocha's "test end" demarcates when the test _method_ has completed, but not any `afterEach` hooks. Since we use an `afterEach` hook to unsubscribe all subscriptions from the DCRFClient, care must be taken to ensure the DB remains unwiped and test server remains up until the `afterEach` hook has culminated. To this end, we actually emit our "test ended" message right before the next test starts, or the suite ends. See [mochajs/mocha#1860](https://github.com/mochajs/mocha/issues/1860). The logic is inspired by [the workaround](https://github.com/JetBrains/mocha-intellij/commit/03345ee49688e0bca875cba533141c417cefb625) used in JetBrains's mocha-intellij) 277 | 278 | NOTE: this is sorta complicated and brittle. it would be nice to refactor this into something more robust. at least for now it provides some assurance the client interacts with the server properly, and also sorta serves as an example for properly setting up a Django Channels REST Framework project. 279 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | import chai, {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | import sinonChai from 'sinon-chai'; 6 | import {DCRFClient} from '../src'; 7 | import flushPromises from "flush-promises"; 8 | chai.use(sinonChai); 9 | 10 | import FifoDispatcher from '../src/dispatchers/fifo'; 11 | import { 12 | IDispatcher, 13 | ISendQueue, 14 | ISerializer, 15 | ITransport, 16 | MessagePreprocessor, PayloadPreprocessor 17 | } from '../src/interface'; 18 | import FifoQueue from '../src/send_queues/fifo'; 19 | 20 | 21 | describe('FifoDispatcher', function () { 22 | describe('dispatch', function () { 23 | it('should call handler when selector is matched', function() { 24 | const dispatcher = new FifoDispatcher(); 25 | const spy = sinon.spy(); 26 | dispatcher.listen({test: 'unique'}, spy); 27 | dispatcher.dispatch({test: 'unique'}); 28 | expect(spy).to.have.been.called; 29 | }); 30 | 31 | it('should not call handler when selector is not matched', function() { 32 | const dispatcher = new FifoDispatcher(); 33 | const spy = sinon.spy(); 34 | dispatcher.listen({test: 'unique'}, spy); 35 | dispatcher.dispatch({test: 'clearly not unique'}); 36 | expect(spy).not.to.have.been.called; 37 | }); 38 | 39 | it('should match recursively', function() { 40 | const dispatcher = new FifoDispatcher(); 41 | const spy = sinon.spy(); 42 | dispatcher.listen({down: {the: {rabbit: 'hole'}}}, spy); 43 | dispatcher.dispatch({down: {the: {rabbit: 'hole'}}}); 44 | expect(spy).to.have.been.called; 45 | }); 46 | }); 47 | 48 | describe('cancel', function () { 49 | it('should stop calling handler', function () { 50 | const dispatcher = new FifoDispatcher(); 51 | const spy = sinon.spy(); 52 | const listenerId = dispatcher.listen({test: 'unique'}, spy); 53 | dispatcher.dispatch({test: 'unique'}); 54 | expect(spy).to.have.been.calledOnce; 55 | 56 | dispatcher.cancel(listenerId); 57 | dispatcher.dispatch({test: 'unique'}); 58 | expect(spy).to.have.been.calledOnce; 59 | }); 60 | }); 61 | 62 | describe('once', function() { 63 | it('should call handler when selector is matched only once', function() { 64 | const dispatcher = new FifoDispatcher(); 65 | const spy = sinon.spy(); 66 | dispatcher.once({test: 'unique'}, spy); 67 | 68 | dispatcher.dispatch({test: 'unique'}); 69 | expect(spy).to.have.been.calledOnce; 70 | 71 | dispatcher.dispatch({test: 'unique'}); 72 | expect(spy).to.have.been.calledOnce; 73 | }); 74 | }); 75 | }); 76 | 77 | 78 | describe('FifoQueue', function() { 79 | let queue: FifoQueue & {canSend: sinon.SinonStub}; 80 | 81 | beforeEach(function() { 82 | const sendNow = sinon.stub(); 83 | const canSend = sinon.stub().returns(true); 84 | queue = new FifoQueue(sendNow, canSend) as FifoQueue & {canSend: sinon.SinonStub}; 85 | }); 86 | 87 | describe('send', function () { 88 | it('should send message immediately if canSend() == true', function () { 89 | queue.send('test'); 90 | expect(queue.sendNow).to.have.been.calledOnce.and.calledWith('test'); 91 | }); 92 | 93 | it('should queue message if canSend() == false', function () { 94 | sinon.spy(queue, 'queueMessage'); 95 | queue.canSend.returns(false); 96 | 97 | queue.send('test'); 98 | expect(queue.sendNow).not.to.have.been.called; 99 | expect(queue.queueMessage).to.have.been.calledOnce.and.calledWith('test'); 100 | }); 101 | }); 102 | 103 | describe('queueMessage', function() { 104 | it('should push message to queue', function() { 105 | queue.queueMessage('test'); 106 | expect(queue.queue).to.eql(['test']); 107 | }); 108 | }); 109 | 110 | describe('processQueue', function() { 111 | it('should send all queued messages immediately', function () { 112 | queue.queueMessage('test'); 113 | queue.queueMessage('muffin'); 114 | queue.processQueue(); 115 | 116 | expect(queue.sendNow) 117 | .to.have.been.calledTwice 118 | .and.calledWith('test') 119 | .and.calledWith('muffin') 120 | }); 121 | }); 122 | }); 123 | 124 | 125 | describe('DCRFClient', function() { 126 | let dispatcher: IDispatcher, 127 | transport: DummyTransport, 128 | queue: ISendQueue, 129 | serializer: ISerializer, 130 | api: DCRFClient; 131 | 132 | const initClient = (options = {}) => { 133 | dispatcher = new FifoDispatcher(); 134 | transport = new DummyTransport(); 135 | queue = new FifoQueue(); 136 | serializer = new DummySerializer(); 137 | 138 | const client = new DCRFClient(dispatcher, transport, queue, serializer, options); 139 | client.initialize(); 140 | return client; 141 | } 142 | 143 | class DummyTransport extends EventEmitter implements ITransport { 144 | send = sinon.spy(); 145 | hasConnected = false; 146 | 147 | public connect = sinon.spy(() => { 148 | this.isConnected.returns(true); 149 | if (this.hasConnected) { 150 | this.emit('reconnect'); 151 | } else { 152 | this.emit('connect'); 153 | this.hasConnected = true; 154 | } 155 | }) as unknown as () => boolean; 156 | 157 | disconnect = sinon.spy(() => { 158 | const wasConnected = this.isConnected(); 159 | this.isConnected.returns(false); 160 | return wasConnected; 161 | }); 162 | isConnected = sinon.stub().returns(false); 163 | } 164 | 165 | class DummySerializer implements ISerializer { 166 | serialize(message: object) { 167 | return message as unknown as string; 168 | } 169 | 170 | deserialize(bytes: string) { 171 | return bytes; 172 | } 173 | } 174 | 175 | beforeEach(function() { 176 | api = initClient(); 177 | }); 178 | 179 | 180 | describe('request', function() { 181 | it('sends request and listen for response', function() { 182 | const promise = api.request('test', {'key': 'unique'}).then(response => { 183 | expect(response).to.eql({'response': 'unique'}); 184 | }); 185 | 186 | expect(transport.send).to.have.been.calledOnce; 187 | const [{stream, payload: {request_id: requestId}}] = transport.send.firstCall.args; 188 | 189 | transport.emit('message', { 190 | data: { 191 | stream, 192 | payload: { 193 | request_id: requestId, 194 | response_status: 200, 195 | data: {response: 'unique'} 196 | } 197 | } 198 | }); 199 | 200 | return promise; 201 | }); 202 | 203 | it('allows preprocessPayload to change payload before sending', function() { 204 | const preprocessPayload = sinon.spy((stream: string, payload: {[prop: string]: any}, requestId: string) => { 205 | payload.unique = 'muffin'; 206 | }) as unknown as PayloadPreprocessor; 207 | 208 | const api = initClient({preprocessPayload}); 209 | 210 | api.request('test', {}); 211 | 212 | expect(preprocessPayload).to.have.been.calledOnce; 213 | expect(transport.send).to.have.been.calledOnce; 214 | const msg = transport.send.getCall(0).args[0]; 215 | expect(msg.payload).to.have.property('unique', 'muffin'); 216 | }); 217 | 218 | it('allows preprocessMessage to change message before sending', function() { 219 | const preprocessMessage = sinon.spy((message: {[prop: string]: any}) => { 220 | message.unique = 'muffin'; 221 | }) as unknown as MessagePreprocessor; 222 | 223 | const api = initClient({preprocessMessage}); 224 | 225 | api.request('test', {}); 226 | 227 | expect(preprocessMessage).to.have.been.calledOnce; 228 | expect(transport.send).to.have.been.calledOnce; 229 | const msg = transport.send.getCall(0).args[0]; 230 | expect(msg).to.have.property('unique', 'muffin'); 231 | }); 232 | 233 | it('queues request until connected', function () { 234 | transport.disconnect(); 235 | transport.hasConnected = false; 236 | 237 | api.request('test', {'key': 'unique'}); 238 | expect(transport.send).not.to.have.been.called; 239 | 240 | transport.connect(); 241 | expect(transport.send).to.have.been.calledOnce; 242 | }); 243 | }); 244 | 245 | describe('streamingRequest', function() { 246 | it('sends request and listen for responses until cancel', async function () { 247 | const responses: any[] = []; 248 | const cancelable = api.streamingRequest('test', {'key': 'unique'}, (error, response) => { 249 | responses.push(response); 250 | }); 251 | 252 | await cancelable; 253 | 254 | expect(transport.send).to.have.been.calledOnce; 255 | const [{stream, payload: {request_id: requestId}}] = transport.send.firstCall.args; 256 | 257 | transport.emit('message', { 258 | data: { 259 | stream, 260 | payload: { 261 | request_id: requestId, 262 | response_status: 200, 263 | data: {response: 'unique'} 264 | } 265 | } 266 | }); 267 | 268 | transport.emit('message', { 269 | data: { 270 | stream, 271 | payload: { 272 | request_id: requestId, 273 | response_status: 200, 274 | data: {response: 'unique2'} 275 | } 276 | } 277 | }); 278 | 279 | expect(await cancelable.cancel()).to.be.true; 280 | 281 | transport.emit('message', { 282 | data: { 283 | stream, 284 | payload: { 285 | request_id: requestId, 286 | response_status: 200, 287 | data: {response: 'unique3'} 288 | } 289 | } 290 | }); 291 | 292 | expect(responses).to.deep.equal([{'response': 'unique'}, {'response': 'unique2'}]); 293 | expect(await cancelable.cancel()).to.be.false; 294 | }); 295 | 296 | it('cancels when receiving an error.', async function () { 297 | const responses: any[] = []; 298 | const errors: any[] = []; 299 | const cancelable = api.streamingRequest('test', {'key': 'unique'}, (error, response) => { 300 | if (error) { 301 | errors.push(error); 302 | } else { 303 | responses.push(response); 304 | } 305 | }); 306 | 307 | await cancelable; 308 | 309 | expect(transport.send).to.have.been.calledOnce; 310 | const [{stream, payload: {request_id: requestId}}] = transport.send.firstCall.args; 311 | 312 | transport.emit('message', { 313 | data: { 314 | stream, 315 | payload: { 316 | request_id: requestId, 317 | response_status: 200, 318 | data: {response: 'unique'} 319 | } 320 | } 321 | }); 322 | 323 | transport.emit('message', { 324 | data: { 325 | stream, 326 | payload: { 327 | request_id: requestId, 328 | response_status: 400, 329 | data: {response: 'unique2'} 330 | } 331 | } 332 | }); 333 | 334 | transport.emit('message', { 335 | data: { 336 | stream, 337 | payload: { 338 | request_id: requestId, 339 | response_status: 200, 340 | data: {response: 'unique3'} 341 | } 342 | } 343 | }); 344 | 345 | await flushPromises(); 346 | 347 | expect(responses).to.deep.equal([{'response': 'unique'}]); 348 | expect(errors).to.deep.equal([{ 349 | request_id: requestId, 350 | response_status: 400, 351 | data: {response: 'unique2'} 352 | }]); 353 | expect(await cancelable.cancel()).to.be.false; 354 | }); 355 | }); 356 | 357 | describe('subscribe', function() { 358 | it('invokes callback on every update', function() { 359 | const id = 1337; 360 | const requestId = 'fake-request-id'; 361 | 362 | const callback = sinon.spy(); 363 | const handler: (data: {[prop: string]: any}) => void = ({ val }) => callback(val); 364 | const subscription = api.subscribe('stream', id, handler, requestId); 365 | 366 | const testPromise = subscription.then(() => { 367 | const emitUpdate = (val: string) => { 368 | transport.emit('message', { 369 | data: { 370 | stream: 'stream', 371 | payload: { 372 | action: 'update', 373 | data: { 374 | pk: id, 375 | val, 376 | }, 377 | request_id: requestId, 378 | } 379 | } 380 | }); 381 | }; 382 | 383 | emitUpdate('muffin'); 384 | expect(callback).to.have.been.calledOnce.and.calledWith('muffin'); 385 | 386 | emitUpdate('taco'); 387 | expect(callback).to.have.been.calledTwice.and.calledWith('taco'); 388 | }); 389 | 390 | // Acknowledge our subscription 391 | transport.emit('message', { 392 | data: { 393 | stream: 'stream', 394 | payload: { 395 | action: 'subscribe_instance', 396 | request_id: requestId, 397 | response_status: 201, 398 | } 399 | } 400 | }); 401 | 402 | return testPromise; 403 | }); 404 | 405 | [ 406 | // Test without a custom pkField (default is "pk") 407 | { pkField: 'pk' }, 408 | // Test with a custom pkField, with delete payload correction on 409 | { pkField: 'id', ensurePkFieldInDeleteEvents: true }, 410 | // Test with a custom pkField, without delete payload correction on 411 | { pkField: 'id', ensurePkFieldInDeleteEvents: false }, 412 | ].forEach(({ pkField, ensurePkFieldInDeleteEvents }) => { 413 | it(`invokes callback on delete (pkField=${pkField}, ensurePkFieldInDeleteEvents=${ensurePkFieldInDeleteEvents})`, function() { 414 | const api = initClient({ pkField, ensurePkFieldInDeleteEvents }); 415 | 416 | const payloadPkField = ensurePkFieldInDeleteEvents ? pkField : 'pk'; 417 | const id = 1337; 418 | const requestId = 'fake-request-id'; 419 | 420 | const callback = sinon.spy(); 421 | const handler: (data: {[prop: string]: any}) => void = ({ [payloadPkField]: pk }) => callback(pk); 422 | const subscription = api.subscribe('stream', id, handler, requestId); 423 | 424 | const testPromise = subscription.then(() => { 425 | const emitDelete = () => { 426 | transport.emit('message', { 427 | data: { 428 | stream: 'stream', 429 | payload: { 430 | action: 'delete', 431 | data: { 432 | pk: id, 433 | }, 434 | request_id: requestId, 435 | } 436 | } 437 | }); 438 | }; 439 | 440 | emitDelete(); 441 | expect(callback).to.have.been.calledOnce.and.calledWith(1337); 442 | }); 443 | 444 | // Acknowledge our subscription 445 | transport.emit('message', { 446 | data: { 447 | stream: 'stream', 448 | payload: { 449 | action: 'subscribe_instance', 450 | request_id: requestId, 451 | response_status: 201, 452 | } 453 | } 454 | }); 455 | 456 | return testPromise; 457 | }); 458 | }) 459 | 460 | it('resubscribes on reconnect', function () { 461 | const stream = 'stream'; 462 | const id = 1337; 463 | 464 | const subReqMatch = sinon.match({ 465 | stream, 466 | payload: { 467 | action: 'subscribe_instance', 468 | pk: id, 469 | } 470 | }); 471 | 472 | api.subscribe(stream, id, () => {}); 473 | expect(transport.send).to.have.been.calledOnce.and.calledWithMatch(subReqMatch); 474 | 475 | transport.disconnect(); 476 | transport.connect(); 477 | expect(transport.send).to.have.been.calledTwice; 478 | expect(transport.send.secondCall).to.have.been.calledWithMatch(subReqMatch); 479 | }); 480 | 481 | it('stops listening on cancel', function () { 482 | const id = 1337; 483 | const requestId = 'fake-request-id'; 484 | 485 | const callback = sinon.spy(); 486 | const handler: (data: {[prop: string]: any}) => void = ({ val }) => callback(val); 487 | const subscription = api.subscribe('stream', id, handler, requestId); 488 | 489 | const testPromise = subscription.then(() => { 490 | const emitUpdate = (val: string) => { 491 | transport.emit('message', { 492 | data: { 493 | stream: 'stream', 494 | payload: { 495 | action: 'update', 496 | data: { 497 | pk: id, 498 | val 499 | }, 500 | request_id: requestId, 501 | } 502 | } 503 | }); 504 | }; 505 | 506 | emitUpdate('muffin'); 507 | expect(callback).to.have.been.calledOnce.and.calledWith('muffin'); 508 | 509 | subscription.cancel(); 510 | 511 | emitUpdate('taco'); 512 | expect(callback).to.have.been.calledOnce; 513 | }); 514 | 515 | // Acknowledge our subscription 516 | transport.emit('message', { 517 | data: { 518 | stream: 'stream', 519 | payload: { 520 | action: 'subscribe_instance', 521 | request_id: requestId, 522 | response_status: 201, 523 | } 524 | } 525 | }); 526 | 527 | return testPromise; 528 | }); 529 | }); 530 | }); 531 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import autobind from 'autobind-decorator'; 2 | import uniqBy from 'lodash.uniqby'; 3 | 4 | import {getLogger} from './logging'; 5 | import FifoDispatcher from './dispatchers/fifo'; 6 | 7 | import { 8 | DispatchListener, 9 | IDCRFOptions, 10 | IDispatcher, 11 | IMessageEvent, 12 | ISendQueue, 13 | ISerializer, 14 | IStreamingAPI, 15 | ITransport, 16 | StreamingRequestHandler, 17 | SubscribeOptions, 18 | SubscriptionHandler, 19 | } from './interface'; 20 | 21 | import UUID from './lib/UUID'; 22 | import FifoQueue from './send_queues/fifo'; 23 | import JSONSerializer from './serializers/json'; 24 | 25 | import {SubscriptionPromise} from './subscriptions'; 26 | import WebsocketTransport from './transports/websocket'; 27 | 28 | 29 | const log = getLogger('dcrf'); 30 | 31 | 32 | interface ISubscriptionDescriptor { 33 | selector: S, 34 | handler: DispatchListener

, 35 | subscribeMessage: object, 36 | unsubscribeMessage: object, 37 | } 38 | 39 | /** 40 | * Promise representing the listening for responses during a streaming request, and offering an 41 | * cancel() method to stop listening for additional responses. 42 | * 43 | * This is returned from DCRFClient.streamingRequest. 44 | */ 45 | export 46 | class StreamingRequestPromise extends Promise { 47 | protected _dispatcher: IDispatcher; 48 | protected _listenerId: number | null; 49 | 50 | constructor(executor: (resolve: (value?: (PromiseLike | T)) => void, reject: (reason?: any) => void) => void, 51 | dispatcher: IDispatcher, listenerId: number) { 52 | super(executor); 53 | this._dispatcher = dispatcher; 54 | this._listenerId = listenerId; 55 | } 56 | 57 | public get listenerId() { 58 | return this._listenerId; 59 | } 60 | 61 | /** 62 | * Stop listening for new events on this subscription 63 | * @return true if the subscription was active, false if it was already unsubscribed 64 | */ 65 | public async cancel(): Promise { 66 | if (this._listenerId !== null) { 67 | const returnValue = this._dispatcher.cancel(this._listenerId); 68 | this._listenerId = null; 69 | return returnValue; 70 | } 71 | return false; 72 | } 73 | } 74 | 75 | export 76 | class DCRFClient implements IStreamingAPI { 77 | public readonly dispatcher: IDispatcher; 78 | public readonly transport: ITransport; 79 | public readonly queue: ISendQueue; 80 | public readonly serializer: ISerializer; 81 | public readonly options: IDCRFOptions; 82 | public readonly pkField: string; 83 | public readonly ensurePkFieldInDeleteEvents: boolean; 84 | 85 | public readonly subscriptions: {[listenerId: number]: ISubscriptionDescriptor}; 86 | 87 | /** 88 | * @param dispatcher Dispatcher instance to route incoming frames to associated handlers 89 | * @param transport Transport to send and receive messages over the wire. 90 | * @param queue Instance of Queue to queue messages when transport unavailable. 91 | * @param serializer Instance which handles serializing data to be sent, and 92 | * deserializing received data. 93 | * @param options Configuration to customize how DCRFClient operates. See 94 | * the IDCRFOptions type for more information. 95 | */ 96 | constructor(dispatcher: IDispatcher, 97 | transport: ITransport, 98 | queue: ISendQueue, 99 | serializer: ISerializer, 100 | options: IDCRFOptions={}) { 101 | this.dispatcher = dispatcher; 102 | this.transport = transport; 103 | this.queue = queue; 104 | this.serializer = serializer; 105 | this.options = options; 106 | 107 | this.pkField = options.pkField ?? 'pk'; 108 | this.ensurePkFieldInDeleteEvents = options.ensurePkFieldInDeleteEvents ?? true; 109 | 110 | if (this.options.buildMultiplexedMessage) 111 | this.buildMultiplexedMessage = this.options.buildMultiplexedMessage.bind(this); 112 | if (this.options.buildRequestResponseSelector) 113 | this.buildRequestResponseSelector = this.options.buildRequestResponseSelector.bind(this); 114 | if (this.options.buildSubscribeCreateSelector) 115 | this.buildSubscribeCreateSelector = this.options.buildSubscribeCreateSelector.bind(this); 116 | if (this.options.buildSubscribeUpdateSelector) 117 | this.buildSubscribeUpdateSelector = this.options.buildSubscribeUpdateSelector.bind(this); 118 | if (this.options.buildSubscribeDeleteSelector) 119 | this.buildSubscribeDeleteSelector = this.options.buildSubscribeDeleteSelector.bind(this); 120 | if (this.options.buildSubscribePayload) 121 | this.buildSubscribePayload = this.options.buildSubscribePayload.bind(this); 122 | if (this.options.buildUnsubscribePayload) 123 | this.buildUnsubscribePayload = this.options.buildUnsubscribePayload.bind(this); 124 | 125 | this.queue.initialize(this.transport.send, this.transport.isConnected); 126 | this.subscriptions = {}; 127 | } 128 | 129 | public initialize() { 130 | this.transport.connect(); 131 | this.transport.on('message', this.handleTransportMessage); 132 | this.transport.on('connect', this.handleTransportConnect); 133 | this.transport.on('reconnect', this.handleTransportReconnect); 134 | } 135 | 136 | public close(unsubscribe: boolean = true): Promise { 137 | let promise: Promise; 138 | 139 | if (unsubscribe) { 140 | promise = this.unsubscribeAll(); 141 | } else { 142 | promise = Promise.resolve(); 143 | } 144 | 145 | return promise.then(() => {this.transport.disconnect()}); 146 | } 147 | 148 | public list(stream: string, data: object={}, requestId?: string): Promise { 149 | return this.request(stream, { 150 | action: 'list', 151 | data, 152 | }, requestId); 153 | } 154 | 155 | public create(stream: string, props: object, requestId?: string): Promise { 156 | return this.request(stream, { 157 | action: 'create', 158 | data: props, 159 | }, requestId); 160 | } 161 | 162 | public retrieve(stream: string, pk: number, data: object={}, requestId?: string): Promise { 163 | return this.request(stream, { 164 | action: 'retrieve', 165 | pk, 166 | data, 167 | }, requestId); 168 | } 169 | 170 | public update(stream: string, pk: number, props: object, requestId?: string): Promise { 171 | return this.request(stream, { 172 | action: 'update', 173 | pk, 174 | data: props, 175 | }, requestId); 176 | } 177 | 178 | public patch(stream: string, pk: number, props: object, requestId?: string): Promise { 179 | return this.request(stream, { 180 | action: 'patch', 181 | pk, 182 | data: props, 183 | }, requestId); 184 | } 185 | 186 | public delete(stream: string, pk: number, data: object={}, requestId?: string): Promise { 187 | return this.request(stream, { 188 | action: 'delete', 189 | pk, 190 | data, 191 | }, requestId); 192 | } 193 | 194 | // Overloads 195 | public subscribe(stream: string, pk: number, callback: SubscriptionHandler, options?: SubscribeOptions): SubscriptionPromise; 196 | public subscribe(stream: string, pk: number, callback: SubscriptionHandler, requestId?: string): SubscriptionPromise; 197 | public subscribe(stream: string, args: object, callback: SubscriptionHandler, options?: SubscribeOptions): SubscriptionPromise; 198 | public subscribe(stream: string, args: object, callback: SubscriptionHandler, requestId?: string): SubscriptionPromise; 199 | 200 | public subscribe( 201 | stream: string, 202 | args: object | number, 203 | callback: SubscriptionHandler, 204 | options?: SubscribeOptions | string, 205 | ): SubscriptionPromise 206 | { 207 | if (callback == null) { 208 | throw new Error('callback must be provided'); 209 | } 210 | 211 | if (typeof options === 'string') { 212 | options = { 213 | requestId: options 214 | }; 215 | } 216 | 217 | options = options ?? {}; 218 | options.includeCreateEvents = options.includeCreateEvents ?? false; 219 | options.requestId = options.requestId ?? UUID.generate(); 220 | options.subscribeAction = options.subscribeAction ?? 'subscribe_instance'; 221 | options.unsubscribeAction = options.unsubscribeAction ?? 'unsubscribe_instance'; 222 | 223 | if (args !== null && typeof args !== 'object') { 224 | const pk = args; 225 | args = { 226 | [options.subscribeAction === 'subscribe_instance' ? 'pk' : this.pkField]: pk 227 | }; 228 | } 229 | 230 | const requestId = options.requestId; 231 | 232 | const createSelector = this.buildSubscribeCreateSelector(stream, requestId); 233 | const updateSelector = this.buildSubscribeUpdateSelector(stream, requestId); 234 | const deleteSelector = this.buildSubscribeDeleteSelector(stream, requestId); 235 | 236 | const handler: (data: typeof updateSelector & {payload: {data: any, action: string}}) => void 237 | = this.buildSubscribeListener(callback); 238 | const subscribePayload = this.buildSubscribePayload(options.subscribeAction, args, requestId); 239 | const unsubscribePayload = this.buildUnsubscribePayload(options.unsubscribeAction, args, requestId); 240 | 241 | const subscribeMessage = this.buildMultiplexedMessage(stream, subscribePayload); 242 | const unsubscribeMessage = this.buildMultiplexedMessage(stream, unsubscribePayload); 243 | 244 | const listenerIds: number[] = []; 245 | const addListener = (selector: object) => { 246 | const listenerId = this.dispatcher.listen(selector, handler); 247 | listenerIds.push(listenerId); 248 | this.subscriptions[listenerId] = {selector, handler, subscribeMessage, unsubscribeMessage}; 249 | }; 250 | 251 | addListener(updateSelector); 252 | addListener(deleteSelector); 253 | if (options.includeCreateEvents) { 254 | addListener(createSelector); 255 | } 256 | 257 | const requestPromise = this.request(stream, subscribePayload, requestId); 258 | const unsubscribe = async () => { 259 | const wasSubbed = listenerIds.map(this.unsubscribe).some(Boolean); 260 | await this.request(stream, unsubscribePayload, requestId) 261 | return wasSubbed; 262 | }; 263 | 264 | return new SubscriptionPromise((resolve, reject) => { 265 | requestPromise.then(resolve, reject); 266 | }, unsubscribe); 267 | } 268 | 269 | public unsubscribeAll(): Promise { 270 | const subscriptions: Array> = Object.values(this.subscriptions); 271 | const unsubscribeMessages = uniqBy(subscriptions, s => { 272 | // @ts-ignore 273 | return s.unsubscribeMessage?.payload?.request_id 274 | }); 275 | 276 | const listenerIds = Object.keys(this.subscriptions).map(parseInt); 277 | log.info('Removing %d listeners', listenerIds.length); 278 | listenerIds.forEach(listenerId => this._unsubscribeUnsafe(listenerId)); 279 | 280 | log.info('Sending %d unsubscription requests', unsubscribeMessages.length); 281 | const unsubscriptionPromises = []; 282 | for (const {unsubscribeMessage} of unsubscribeMessages) { 283 | const {stream, payload: {request_id, ...payload}}: any = unsubscribeMessage; 284 | unsubscriptionPromises.push(this.request(stream, payload, request_id).catch(() => {})); 285 | } 286 | return Promise.all(unsubscriptionPromises).then(() => listenerIds.length); 287 | } 288 | 289 | /** 290 | * Send subscription requests for all registered subscriptions 291 | */ 292 | public resubscribe() { 293 | const subscriptions: Array> = Object.values(this.subscriptions); 294 | const resubscribeMessages = uniqBy(subscriptions, s => { 295 | // @ts-ignore 296 | return s.subscribeMessage?.payload?.request_id 297 | }); 298 | 299 | log.info('Resending %d subscription requests', subscriptions.length); 300 | 301 | for (const {subscribeMessage} of resubscribeMessages) { 302 | this.sendNow(subscribeMessage); 303 | } 304 | } 305 | 306 | private sendRequest(payload: object, requestId: string, stream: string) { 307 | payload = Object.assign({}, payload, {request_id: requestId}); 308 | if (this.options.preprocessPayload != null) { 309 | // Note: this and the preprocessMessage handler below presume an object will be returned. 310 | // If you really want to return a 0, you're kinda SOL -- wrap it in an object :P 311 | payload = this.options.preprocessPayload(stream, payload, requestId) || payload; 312 | } 313 | 314 | let message = this.buildMultiplexedMessage(stream, payload); 315 | if (this.options.preprocessMessage != null) { 316 | message = this.options.preprocessMessage(message) || message; 317 | } 318 | 319 | this.send(message); 320 | } 321 | 322 | public request(stream: string, payload: object, requestId: string=UUID.generate()): Promise { 323 | return new Promise((resolve, reject) => { 324 | const selector = this.buildRequestResponseSelector(stream, requestId); 325 | 326 | this.dispatcher.once(selector, (data: typeof selector & {payload: {response_status: number, data: any}}) => { 327 | const {payload: response} = data; 328 | const responseStatus = response.response_status; 329 | 330 | // 2xx is success 331 | if (Math.floor(responseStatus / 100) === 2) { 332 | resolve(response.data); 333 | } else { 334 | reject(response); 335 | } 336 | }); 337 | this.sendRequest(payload, requestId, stream); 338 | }); 339 | } 340 | 341 | public streamingRequest(stream: string, payload: object, callback: StreamingRequestHandler, requestId: string = UUID.generate()): StreamingRequestPromise { 342 | const selector = this.buildRequestResponseSelector(stream, requestId); 343 | 344 | let cancelable: StreamingRequestPromise; 345 | let listenerId: number | null = this.dispatcher.listen(selector, (data: typeof selector & { payload: { response_status: number, data: any } }) => { 346 | const {payload: response} = data; 347 | const responseStatus = response.response_status; 348 | 349 | if (!cancelable.listenerId) { 350 | // we promise not to call callback after cancel. 351 | return; 352 | } 353 | 354 | // 2xx is success 355 | if (Math.floor(responseStatus / 100) === 2) { 356 | callback(null, response.data); 357 | } else { 358 | cancelable.cancel().finally(() => { 359 | callback(response, null); 360 | }) 361 | 362 | } 363 | }); 364 | 365 | cancelable = new StreamingRequestPromise((resolve, reject) => { 366 | try { 367 | this.sendRequest(payload, requestId, stream); 368 | resolve(); 369 | } catch (e) { 370 | reject(e); 371 | } 372 | }, this.dispatcher, listenerId); 373 | 374 | return cancelable; 375 | } 376 | 377 | public send(object: object) { 378 | return this.doSend(object, this.queue.send); 379 | } 380 | 381 | public sendNow(object: object) { 382 | return this.doSend(object, this.queue.sendNow); 383 | } 384 | 385 | protected doSend(object: object, send: (bytes: string) => number) { 386 | const bytes: string = this.serializer.serialize(object); 387 | return send(bytes); 388 | } 389 | 390 | @autobind 391 | protected unsubscribe(listenerId: number): boolean { 392 | if (this.subscriptions.hasOwnProperty(listenerId)) { 393 | this._unsubscribeUnsafe(listenerId); 394 | return true; 395 | } else { 396 | return false; 397 | } 398 | } 399 | 400 | protected _unsubscribeUnsafe(listenerId: number): void { 401 | this.dispatcher.cancel(listenerId); 402 | delete this.subscriptions[listenerId]; 403 | } 404 | 405 | @autobind 406 | protected handleTransportMessage(event: IMessageEvent) { 407 | log.debug('Received message over transport: %s', event.data); 408 | const data = this.serializer.deserialize(event.data); 409 | return this.handleMessage(data); 410 | } 411 | 412 | protected handleMessage(data: object) { 413 | this.dispatcher.dispatch(data); 414 | } 415 | 416 | @autobind 417 | protected handleTransportConnect() { 418 | log.debug('Initial API connection over transport %s', this.transport.constructor.name); 419 | this.queue.processQueue(); 420 | } 421 | 422 | @autobind 423 | protected handleTransportReconnect() { 424 | log.debug('Reestablished API connection'); 425 | this.resubscribe(); 426 | this.queue.processQueue(); 427 | } 428 | 429 | public buildMultiplexedMessage(stream: string, payload: object): object { 430 | return {stream, payload}; 431 | } 432 | 433 | public buildRequestResponseSelector(stream: string, requestId: string): object { 434 | return { 435 | stream, 436 | payload: {request_id: requestId}, 437 | }; 438 | } 439 | 440 | public buildSubscribeCreateSelector(stream: string, requestId: string): object { 441 | return { 442 | stream, 443 | payload: { 444 | action: 'create', 445 | request_id: requestId, 446 | }, 447 | }; 448 | } 449 | 450 | public buildSubscribeUpdateSelector(stream: string, requestId: string): object { 451 | return { 452 | stream, 453 | payload: { 454 | action: 'update', 455 | request_id: requestId, 456 | }, 457 | }; 458 | } 459 | 460 | public buildSubscribeDeleteSelector(stream: string, requestId: string): object { 461 | return { 462 | stream, 463 | payload: { 464 | action: 'delete', 465 | request_id: requestId, 466 | }, 467 | }; 468 | } 469 | 470 | public buildSubscribePayload(action: string, args: object, requestId: string): object { 471 | return { 472 | action, 473 | request_id: requestId, 474 | ...args, 475 | }; 476 | } 477 | 478 | public buildUnsubscribePayload(action: string, args: object, requestId: string): object { 479 | return { 480 | action, 481 | request_id: requestId, 482 | ...args, 483 | }; 484 | } 485 | 486 | /** 487 | * Build a function which will take an entire JSON message and return only 488 | * the relevant payload (usually an object). 489 | */ 490 | protected buildListener( 491 | callback: (data: {[prop: string]: any}, response: {[prop: string]: any}) => void 492 | ): DispatchListener<{payload: {data: any}}> 493 | { 494 | return (data: {payload: {data: any}}) => { 495 | return callback(data.payload.data, data); 496 | }; 497 | } 498 | 499 | /** 500 | * Build a function which will take an entire JSON message and return only 501 | * the relevant payload (usually an object). 502 | */ 503 | protected buildSubscribeListener( 504 | callback: (data: object, action: string) => void 505 | ): DispatchListener<{payload: {data: any, action: string}}> 506 | { 507 | return this.buildListener((data, response) => { 508 | const action = response.payload.action; 509 | 510 | if (action === 'delete' 511 | && this.ensurePkFieldInDeleteEvents 512 | && !data.hasOwnProperty(this.pkField) 513 | ) { 514 | // Ensure our configured pkField is used to house primary key 515 | data[this.pkField] = data.pk; 516 | // And clear out `pk` 517 | delete data.pk; 518 | } 519 | 520 | return callback(data, action); 521 | }) 522 | } 523 | } 524 | 525 | 526 | export default { 527 | /** 528 | * Configure a DCRFClient client and begin connection 529 | * 530 | * @param url WebSocket URL to connect to 531 | * @param options Configuration for DCRFClient and ReconnectingWebsocket 532 | */ 533 | connect(url: string, options: IDCRFOptions={}): DCRFClient { 534 | const client = this.createClient(url, options); 535 | client.initialize(); 536 | return client; 537 | }, 538 | 539 | /** 540 | * Create a configured DCRFClient client instance, using default components 541 | * 542 | * @param url WebSocket URL to connect to 543 | * @param options Configuration for DCRFClient and ReconnectingWebsocket 544 | */ 545 | createClient(url: string, options: IDCRFOptions={}): DCRFClient { 546 | const dispatcher: IDispatcher = options.dispatcher || new FifoDispatcher(); 547 | const transport: ITransport = options.transport || new WebsocketTransport(url, options.websocket); 548 | const queue: ISendQueue = options.queue || new FifoQueue(); 549 | const serializer: ISerializer = options.serializer || new JSONSerializer(); 550 | return new DCRFClient(dispatcher, transport, queue, serializer, options); 551 | }, 552 | }; 553 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import { Options as ReconnectingWebsocketOptions } from 'reconnecting-websocket'; 2 | 3 | export 4 | interface IMessageEvent { 5 | data: string 6 | } 7 | 8 | export 9 | type DispatchListener = (response: T) => any; 10 | 11 | export 12 | type SubscriptionHandler = (payload: {[prop: string]: any}, action: string) => any; 13 | 14 | export 15 | type StreamingRequestHandler = (error: {response_status: number, data: any} | null, payload: {[prop: string]: any} | null) => any; 16 | 17 | 18 | /** 19 | * Calls all handlers whose selectors match an incoming payload. 20 | */ 21 | export 22 | interface IDispatcher { 23 | /** 24 | * Pass any matching incoming messages to a handler 25 | * 26 | * @param selector An object to match against incoming JSON message 27 | * @param handler Callback accepting one argument: the entire JSON decoded 28 | * object from the WS message. 29 | * @return A listener ID to pass to cancel 30 | */ 31 | listen(selector: S, handler: DispatchListener

): number; 32 | 33 | /** 34 | * Register a handler called once when selector is matched, but no more. 35 | * 36 | * @param selector An object to match against incoming JSON message 37 | * @param handler Callback accepting one argument: the entire JSON decoded 38 | * object from the WS message. 39 | * @return A listener ID to pass to cancel 40 | */ 41 | once(selector: S, handler: DispatchListener

): number; 42 | 43 | /** 44 | * Stop passing along messages for a listener 45 | * 46 | * @param listenerId An ID returned from listen() or once() 47 | * @return true if listener was active, false if already deactivated 48 | */ 49 | cancel(listenerId: number): boolean; 50 | 51 | /** 52 | * Call handlers of any matching selectors. 53 | * 54 | * @param payload JSON payload 55 | * @return Number of matching selectors whose handlers were called. 56 | */ 57 | dispatch(payload: object): number; 58 | } 59 | 60 | 61 | /** 62 | * If transport available, sends messages immediately. Otherwise, queues 63 | * messages for sending later. 64 | */ 65 | export 66 | interface ISendQueue { 67 | /** 68 | * Initialize the queue 69 | * 70 | * @param sendNow Function to call to send a message 71 | * @param canSend Function which should return whether send can be called 72 | */ 73 | initialize(sendNow: (bytes: string) => number | void, canSend: () => boolean): void; 74 | 75 | /** 76 | * Send a message if possible, otherwise queues message. 77 | * 78 | * @param bytes Message to send over wire 79 | * @return Number of bytes successfully sent (if applicable) 80 | */ 81 | send(bytes: string): number; 82 | 83 | /** 84 | * Send message immediately, bypassing queue. If send fails, no attempt to 85 | * resend message is made. 86 | * 87 | * @param bytes Message to send over wire 88 | * @return Number of bytes successfully sent (if applicable) 89 | */ 90 | sendNow(bytes: string): number; 91 | 92 | /** 93 | * Queue a message for sending later. 94 | * 95 | * @param bytes Message to send eventually over the wire 96 | * @return Whether the message was successfully added to the queue 97 | */ 98 | queueMessage(bytes: string): boolean; 99 | 100 | /** 101 | * Send any queued messages. 102 | * 103 | * @return Number of messages resent 104 | */ 105 | processQueue(): number; 106 | } 107 | 108 | 109 | export type TransportEvent = 'open' | 'connect' | 'reconnect' | 'message'; 110 | 111 | export 112 | interface ITransport { 113 | /** 114 | * Initiate the transport's connection 115 | * 116 | * @return true if the connection is initiated, false if a connection already 117 | * exists. 118 | */ 119 | connect(): boolean; 120 | 121 | /** 122 | * Close the transport's connection 123 | * 124 | * @return true if an already-established connection was closed, 125 | * false if no connection had been established in order to close 126 | */ 127 | disconnect(): boolean; 128 | 129 | /** 130 | * Whether the transport is ready to send/receive messages 131 | */ 132 | isConnected(): boolean; 133 | 134 | /** 135 | * Register a callback for a particular event. The Transport must support 136 | * the following event types: 137 | * 138 | * - "open": when the connection is opened 139 | * - "connect": on initial connection 140 | * - "reconnect": when the connection is lost, then reestablished 141 | * - "message": when a message is received 142 | */ 143 | on(name: TransportEvent, handler: (...args: any) => void): any | null; 144 | 145 | /** 146 | * Send a message over the wire 147 | * @return Number of bytes sent over the wire (if applicable) 148 | */ 149 | send(bytes: string): number | void; 150 | } 151 | 152 | 153 | /** 154 | * Serialize/deserialize messages to and from the wire 155 | */ 156 | export 157 | interface ISerializer { 158 | serialize(message: object): string; 159 | deserialize(bytes: string): any; 160 | } 161 | 162 | 163 | export 164 | interface ICancelable { 165 | /** 166 | * @return true if canceled, false if already canceled. 167 | */ 168 | cancel(): Promise; 169 | } 170 | 171 | 172 | export type CancelablePromise = ICancelable & Promise; 173 | 174 | export type SubscribeOptions = { 175 | requestId?: string, 176 | subscribeAction?: string, 177 | unsubscribeAction?: string, 178 | includeCreateEvents?: boolean, 179 | } 180 | 181 | export type SubscriptionAction = 'create' | 'update' | 'delete'; 182 | 183 | /** 184 | * An API client implementing create, retrieve, update, delete, and subscribe 185 | * for streams of objects. 186 | */ 187 | export 188 | interface IStreamingAPI { 189 | /** 190 | * Initialize connection. Must be called before API. 191 | */ 192 | initialize(): void; 193 | 194 | /** 195 | * Close the connection. 196 | * 197 | * @param unsubscribe Whether to cancel all subscriptions, as well. Defaults to true. 198 | * @return Promise Resolves when all unsubscription requests have completed, or 199 | * immediately if unsubscribe=false 200 | */ 201 | close(unsubscribe?: boolean): Promise; 202 | 203 | /** 204 | * The name of the primary key field, used to identify objects for subscriptions. 205 | * 206 | * NOTE: this field is used by the default buildSubscribeSelector and buildSubscribePayload 207 | * functions. If these are overridden in the DCRFClient options, this pkField value 208 | * may not be honoured. 209 | */ 210 | readonly pkField: string; 211 | 212 | /** 213 | * Whether to ensure payloads for delete events of subscribed models include 214 | * the primary key of the object in [pkField]. 215 | * 216 | * Because payloads for delete events aren't run through the serializer, 217 | * delete events *always* use `pk` to identify the object. This may differ 218 | * from update events, which *are* run through the serializer, leading to 219 | * more complicated logic within subscription handlers to retrieve the primary 220 | * key either from `id` or `pk`, depending on whether it's update or delete. 221 | * 222 | * Since this can be ugly, setting this option to true (by passing it in options 223 | * when instantiating the client) will ensure the configured [pkField] is always 224 | * present in delete event payloads. 225 | */ 226 | readonly ensurePkFieldInDeleteEvents: boolean; 227 | 228 | /** 229 | * Retrieve list of objects from stream 230 | * 231 | * @param stream Name of object's type stream 232 | * @param data Extra data to send to API 233 | * @param requestId Optional value in the payload to send as request_id to the server. 234 | * If not specified, one will be generated. 235 | * @return Promise resolves/rejects when response to list request received. 236 | * On success, the promise will be resolved with list of objects. 237 | * On failure, the promise will be rejected with the entire API response. 238 | */ 239 | list(stream: string, data?: object, requestId?: string): Promise; 240 | 241 | /** 242 | * Create a new object 243 | * 244 | * @param stream Name of object's type stream 245 | * @param props Attributes to create on object 246 | * @param requestId Optional value in the payload to send as request_id to the server. 247 | * If not specified, one will be generated. 248 | * @return Promise resolves/rejects when response to creation request received. 249 | * On success, the promise will be resolved with the created object. 250 | * On failure, the promise will be rejected with the entire API response. 251 | */ 252 | create(stream: string, props: object, requestId?: string): Promise; 253 | 254 | /** 255 | * Retrieve an existing object 256 | * 257 | * @param stream Name of object's type stream 258 | * @param pk ID of object to retrieve 259 | * @param data Extra data to send to API 260 | * @param requestId Optional value in the payload to send as request_id to the server. 261 | * If not specified, one will be generated. 262 | * @return Promise resolves/rejects when response to retrieval request received. 263 | * On success, the promise will be resolved with the retrieved object. 264 | * On failure, the promise will be rejected with the entire API response. 265 | */ 266 | retrieve(stream: string, pk: number, data?: object, requestId?: string): Promise; 267 | 268 | /** 269 | * Overwrite an existing object 270 | * 271 | * @param stream Name of object's type stream 272 | * @param pk ID of object to update 273 | * @param props Attributes to patch on object 274 | * @param requestId Optional value in the payload to send as request_id to the server. 275 | * If not specified, one will be generated. 276 | * @return Promise resolves/rejects when response to update request received. 277 | * On success, the promise will be resolved with the updated object. 278 | * On failure, the promise will be rejected with the entire API response. 279 | */ 280 | update(stream: string, pk: number, props: object, requestId?: string): Promise; 281 | 282 | /** 283 | * Partially update an existing object 284 | * 285 | * @param stream Name of object's type stream 286 | * @param pk ID of object to update 287 | * @param props Attributes to patch on object 288 | * @param requestId Optional value in the payload to send as request_id to the server. 289 | * If not specified, one will be generated. 290 | * @return Promise resolves/rejects when response to update request received. 291 | * On success, the promise will be resolved with the updated object. 292 | * On failure, the promise will be rejected with the entire API response. 293 | */ 294 | patch(stream: string, pk: number, props: object, requestId?: string): Promise; 295 | 296 | /** 297 | * Delete an existing object 298 | * 299 | * @param stream Name of object's type stream 300 | * @param pk ID of object to delete 301 | * @param data Extra data to send to API 302 | * @param requestId Optional value in the payload to send as request_id to the server. 303 | * If not specified, one will be generated. 304 | * @return Promise resolves/rejects when response to deletion request received. 305 | * On success, the promise will be resolved with null, or an empty object. 306 | * On failure, the promise will be rejected with the entire API response. 307 | */ 308 | delete(stream: string, pk: number, data?: object, requestId?: string): Promise; 309 | 310 | /** 311 | * Subscribe to update and delete events for an object, or perform a custom subscription 312 | * 313 | * @param stream Name of object's type stream 314 | * @param pk ID of specific DB object to watch 315 | * @param callback Function to call with payload on new events 316 | * @param options Optional object to configure the subscription 317 | * @param options.requestId Specific request ID to submit with the 318 | * subscription/unsubscription request, and which will be included in 319 | * responses from DCRF. If not specified, one will be automatically generated. 320 | * @param options.subscribeAction Name of action used in subscription request. 321 | * By default, 'subscribe_instance' is used. 322 | * @param options.unsubscribeAction Name of action used in unsubscription request. 323 | * By default, 'unsubscribe_instance' is used. 324 | * @param options.includeCreateEvents Whether to listen for creation events, 325 | * in addition to updates and deletes. By default, this is false. 326 | * @return Promise Resolves/rejects when response to subscription request received. 327 | * On success, the promise will be resolved with null, or an empty object. 328 | * On failure, the promise will be rejected with the entire API response. 329 | * This Promise has an additional method, cancel(), which can be called 330 | * to cancel the subscription. 331 | */ 332 | subscribe(stream: string, pk: number, callback: SubscriptionHandler, options?: SubscribeOptions): CancelablePromise; 333 | 334 | /** 335 | * Subscribe to update and delete events for an object, or perform a custom subscription 336 | * 337 | * @param stream Name of object's type stream 338 | * @param pk ID of specific DB object to watch 339 | * @param callback Function to call with payload on new events 340 | * @param requestId Specific request ID to submit with the subscription/unsubscription 341 | * request, and which will be included in responses from DCRF. 342 | * If not specified, one will be automatically generated. 343 | * @return Promise Resolves/rejects when response to subscription request received. 344 | * On success, the promise will be resolved with null, or an empty object. 345 | * On failure, the promise will be rejected with the entire API response. 346 | * This Promise has an additional method, cancel(), which can be called 347 | * to cancel the subscription. 348 | */ 349 | subscribe(stream: string, pk: number, callback: SubscriptionHandler, requestId?: string): CancelablePromise; 350 | 351 | /** 352 | * Subscribe to update and delete events for an object, or perform a custom subscription 353 | * 354 | * @param stream Name of object's type stream 355 | * @param args Identifying information to be included in subscription request 356 | * @param callback Function to call with payload on new events 357 | * @param options Optional object to configure the subscription 358 | * @param options.requestId Specific request ID to submit with the 359 | * subscription/unsubscription request, and which will be included in 360 | * responses from DCRF. If not specified, one will be automatically generated. 361 | * @param options.subscribeAction Name of action used in subscription request. 362 | * By default, 'subscribe_instance' is used. 363 | * @param options.unsubscribeAction Name of action used in unsubscription request. 364 | * By default, 'unsubscribe_instance' is used. 365 | * @param options.includeCreateEvents Whether to listen for creation events, 366 | * in addition to updates and deletes. By default, this is false. 367 | * @return Promise Resolves/rejects when response to subscription request received. 368 | * On success, the promise will be resolved with null, or an empty object. 369 | * On failure, the promise will be rejected with the entire API response. 370 | * This Promise has an additional method, cancel(), which can be called 371 | * to cancel the subscription. 372 | */ 373 | subscribe(stream: string, args: object, callback: SubscriptionHandler, options?: SubscribeOptions): CancelablePromise; 374 | 375 | /** 376 | * Subscribe to update and delete events for an object, or perform a custom subscription 377 | * 378 | * @param stream Name of object's type stream 379 | * @param args Identifying information to be included in subscription request 380 | * @param callback Function to call with payload on new events 381 | * @param requestId Specific request ID to submit with the subscription/unsubscription 382 | * request, and which will be included in responses from DCRF. 383 | * If not specified, one will be automatically generated. 384 | * @return Promise Resolves/rejects when response to subscription request received. 385 | * On success, the promise will be resolved with null, or an empty object. 386 | * On failure, the promise will be rejected with the entire API response. 387 | * This Promise has an additional method, cancel(), which can be called 388 | * to cancel the subscription. 389 | */ 390 | subscribe(stream: string, args: object, callback: SubscriptionHandler, requestId?: string): CancelablePromise; 391 | 392 | /** 393 | * Cancel all subscriptions 394 | * 395 | * @return Promise resolving when all unsubscription requests have completed, 396 | * with a value representing the number of listeners removed. 397 | */ 398 | unsubscribeAll(): Promise; 399 | 400 | /** 401 | * Perform an asynchronous transaction 402 | * 403 | * @param stream Name of object's type stream 404 | * @param payload Data to send as payload 405 | * @param requestId Value to send as request_id to the server. If not specified, 406 | * one will be generated. 407 | * @return Promise resolves/rejects when response received. 408 | * On success, the promise will be resolved with response.data. 409 | * On failure, the promise will be rejected with the entire API response. 410 | */ 411 | request(stream: string, payload: object, requestId?: string): Promise; 412 | 413 | /** 414 | * Perform an asynchronous transaction where the result can be broken into multiple responses 415 | * 416 | * @param stream Name of object's type stream 417 | * @param payload Data to send as payload 418 | * @param callback Function to call with payload on new responses 419 | * @param requestId Value to send as request_id to the server. If not specified, 420 | * one will be generated. 421 | * @return StreamingRequestCanceler function to call when deciding there will be no more responses. 422 | */ 423 | streamingRequest(stream: string, payload: object, callback: StreamingRequestHandler, requestId?: string): CancelablePromise; 424 | } 425 | 426 | 427 | /** 428 | * Callback which may mutate the payload before being sent to the server. A new 429 | * object may be returned, which will be sent instead. 430 | * 431 | * @param stream Name of object's type stream 432 | * @param payload Data which will be sent as payload. This may be mutated, as 433 | * long as no value is then returned from the callback. 434 | * @param requestId Value in the payload sent as request_id to the server. 435 | * @return undefined to send the same payload object as passed in; or a new 436 | * Object to be sent instead. 437 | * 438 | */ 439 | export type PayloadPreprocessor = (stream: string, payload: object, requestId: string) => object | null; 440 | 441 | 442 | /** 443 | * Callback which may mutate the multiplexed message (which includes the stream 444 | * and payload) before being sent over the wire. 445 | * 446 | * @param message The message to be sent over the wire to the server. 447 | * @return undefined to send the same message object as passed in (and 448 | * potentially mutated); or an Object to be sent instead. 449 | */ 450 | export type MessagePreprocessor = (message: object) => object | undefined; 451 | 452 | 453 | /** 454 | * Function used to format a multiplexed message (i.e. a payload routed to a stream) 455 | * 456 | * By default, DCRFClient simply builds an object containing: {stream, payload} 457 | * 458 | * @param stream The stream to send the payload to 459 | * @param payload The data to send to the stream 460 | */ 461 | export type MultiplexedMessageBuilder = (stream: string, payload: object) => object; 462 | 463 | 464 | /** 465 | * Function used to generate a selector (a pattern matching an object) for the 466 | * response to an API request. Responses to API requests are generally identified 467 | * by stream they belong to, and a request_id returned in the payload. 468 | * 469 | * By default, DCRFClient selects on: {stream, payload: {request_id: requestId}} 470 | * 471 | * @param stream The stream to expect the API response from 472 | * @param requestId The ID of the API request to expect a response to 473 | */ 474 | export type RequestResponseSelectorBuilder = (stream: string, requestId: string) => object; 475 | 476 | /** 477 | * Function used to generate a selector (a pattern matching an object) for an 478 | * create event sent by the server due to a subscription. 479 | * 480 | * Note that because payloads for delete events aren't run through the 481 | * serializer, delete events *always* use `pk` to identify the object. This may 482 | * differ from create and update events, which *are* run through the serializer, 483 | * and thus may have different selectors. 484 | * 485 | * Subscription messages are generally identified by the stream they belong to and 486 | * the request_id of the original subscription request. 487 | * 488 | * By default, DCRFClient selects on {stream, request_id: requestId}} 489 | * 490 | * @param stream The stream to expect the subscription event from 491 | * @param requestId The request ID used to initiate the subscription 492 | */ 493 | export type SubscribeCreateSelectorBuilder = (stream: string, requestId: string) => object; 494 | 495 | /** 496 | * Function used to generate a selector (a pattern matching an object) for an 497 | * update event sent by the server due to a subscription. 498 | * 499 | * Note that because payloads for delete events aren't run through the 500 | * serializer, delete events *always* use `pk` to identify the object. This may 501 | * differ from create and update events, which *are* run through the serializer, 502 | * and thus may have different selectors. 503 | * 504 | * Subscription messages are generally identified by the stream they belong to and 505 | * the request_id of the original subscription request. 506 | * 507 | * By default, DCRFClient selects on {stream, request_id: requestId}} 508 | * 509 | * @param stream The stream to expect the subscription event from 510 | * @param requestId The request ID used to initiate the subscription 511 | */ 512 | export type SubscribeUpdateSelectorBuilder = (stream: string, requestId: string) => object; 513 | 514 | 515 | /** 516 | * Function used to generate a selector (a pattern matching an object) for an 517 | * delete event sent by the server due to a subscription. 518 | * 519 | * Note that because payloads for delete events aren't run through the 520 | * serializer, delete events *always* use `pk` to identify the object. This may 521 | * differ from create and update events, which *are* run through the serializer, 522 | * and thus may have different selectors. 523 | * 524 | * Subscription messages are generally identified by the stream they belong to and 525 | * the request_id of the original subscription request. 526 | * 527 | * By default, DCRFClient selects on {stream, request_id: requestId}} 528 | * 529 | * @param stream The stream to expect the subscription event from 530 | * @param requestId The request ID used to initiate the subscription 531 | */ 532 | export type SubscribeDeleteSelectorBuilder = (stream: string, requestId: string) => object; 533 | 534 | 535 | /** 536 | * Function used to generate the payload for a subscription request. 537 | * 538 | * By default, DCRFClient builds {action: 'subscribe_instance', request_id: requestId, pk} 539 | * 540 | * @param pk The primary key / ID of the object to subscribe to 541 | * @param requestId The request ID to use in the subscription request 542 | */ 543 | export type SubscribePayloadBuilder = (action: string, args: object, requestId: string) => object; 544 | export type UnsubscribePayloadBuilder = (action: string, args: object, requestId: string) => object; 545 | 546 | 547 | export 548 | interface IDCRFOptions { 549 | dispatcher?: IDispatcher, 550 | transport?: ITransport, 551 | queue?: ISendQueue, 552 | serializer?: ISerializer, 553 | 554 | preprocessPayload?: PayloadPreprocessor, 555 | preprocessMessage?: MessagePreprocessor, 556 | 557 | pkField?: string, 558 | ensurePkFieldInDeleteEvents?: boolean, 559 | 560 | buildMultiplexedMessage?: MultiplexedMessageBuilder, 561 | buildRequestResponseSelector?: RequestResponseSelectorBuilder, 562 | buildSubscribeCreateSelector?: SubscribeCreateSelectorBuilder, 563 | buildSubscribeUpdateSelector?: SubscribeUpdateSelectorBuilder, 564 | buildSubscribeDeleteSelector?: SubscribeDeleteSelectorBuilder, 565 | buildSubscribePayload?: SubscribePayloadBuilder, 566 | buildUnsubscribePayload?: UnsubscribePayloadBuilder, 567 | 568 | // ReconnectingWebsocket options 569 | websocket?: ReconnectingWebsocketOptions 570 | } 571 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "fdbb84adb0193278154f2f0fbc5df5142e589eccd954d170ab22adb11e49be4e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aioredis": { 20 | "hashes": [ 21 | "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a", 22 | "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3" 23 | ], 24 | "version": "==1.3.1" 25 | }, 26 | "asgiref": { 27 | "hashes": [ 28 | "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", 29 | "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" 30 | ], 31 | "markers": "python_version >= '3.6'", 32 | "version": "==3.4.1" 33 | }, 34 | "async-timeout": { 35 | "hashes": [ 36 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 37 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 38 | ], 39 | "markers": "python_full_version >= '3.5.3'", 40 | "version": "==3.0.1" 41 | }, 42 | "attrs": { 43 | "hashes": [ 44 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 45 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 46 | ], 47 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 48 | "version": "==21.2.0" 49 | }, 50 | "autobahn": { 51 | "hashes": [ 52 | "sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac", 53 | "sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03" 54 | ], 55 | "markers": "python_version >= '3.7'", 56 | "version": "==21.3.1" 57 | }, 58 | "automat": { 59 | "hashes": [ 60 | "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33", 61 | "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111" 62 | ], 63 | "version": "==20.2.0" 64 | }, 65 | "cffi": { 66 | "hashes": [ 67 | "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d", 68 | "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771", 69 | "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872", 70 | "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c", 71 | "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc", 72 | "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762", 73 | "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202", 74 | "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5", 75 | "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548", 76 | "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a", 77 | "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f", 78 | "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20", 79 | "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218", 80 | "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c", 81 | "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e", 82 | "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56", 83 | "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224", 84 | "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a", 85 | "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2", 86 | "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a", 87 | "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819", 88 | "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346", 89 | "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b", 90 | "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e", 91 | "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534", 92 | "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb", 93 | "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0", 94 | "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156", 95 | "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd", 96 | "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87", 97 | "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc", 98 | "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195", 99 | "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33", 100 | "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f", 101 | "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d", 102 | "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd", 103 | "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728", 104 | "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7", 105 | "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca", 106 | "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99", 107 | "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf", 108 | "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e", 109 | "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c", 110 | "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5", 111 | "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69" 112 | ], 113 | "version": "==1.14.6" 114 | }, 115 | "channels": { 116 | "hashes": [ 117 | "sha256:0ff0422b4224d10efac76e451575517f155fe7c97d369b5973b116f22eeaf86c", 118 | "sha256:fdd9a94987a23d8d7ebd97498ed8b8cc83163f37e53fc6c85098aba7a3bb8b75" 119 | ], 120 | "markers": "python_version >= '3.6'", 121 | "version": "==3.0.4" 122 | }, 123 | "channels-redis": { 124 | "hashes": [ 125 | "sha256:18d63f6462a58011740dc8eeb57ea4b31ec220eb551cb71b27de9c6779a549de", 126 | "sha256:2fb31a63b05373f6402da2e6a91a22b9e66eb8b56626c6bfc93e156c734c5ae6" 127 | ], 128 | "index": "pypi", 129 | "version": "==3.2.0" 130 | }, 131 | "channelsmultiplexer": { 132 | "hashes": [ 133 | "sha256:98e674110f07b1d41940a32d0a921156d17bf9176a65f19f71f703e3bb38f50e" 134 | ], 135 | "index": "pypi", 136 | "version": "==0.0.3" 137 | }, 138 | "constantly": { 139 | "hashes": [ 140 | "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", 141 | "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" 142 | ], 143 | "version": "==15.1.0" 144 | }, 145 | "cryptography": { 146 | "hashes": [ 147 | "sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e", 148 | "sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b", 149 | "sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7", 150 | "sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085", 151 | "sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc", 152 | "sha256:3c4129fc3fdc0fa8e40861b5ac0c673315b3c902bbdc05fc176764815b43dd1d", 153 | "sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a", 154 | "sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498", 155 | "sha256:695104a9223a7239d155d7627ad912953b540929ef97ae0c34c7b8bf30857e89", 156 | "sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9", 157 | "sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c", 158 | "sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7", 159 | "sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb", 160 | "sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14", 161 | "sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af", 162 | "sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e", 163 | "sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5", 164 | "sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06", 165 | "sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7" 166 | ], 167 | "markers": "python_version >= '3.6'", 168 | "version": "==3.4.8" 169 | }, 170 | "daphne": { 171 | "hashes": [ 172 | "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f", 173 | "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393" 174 | ], 175 | "markers": "python_version >= '3.6'", 176 | "version": "==3.0.2" 177 | }, 178 | "django": { 179 | "hashes": [ 180 | "sha256:95b318319d6997bac3595517101ad9cc83fe5672ac498ba48d1a410f47afecd2", 181 | "sha256:e93c93565005b37ddebf2396b4dc4b6913c1838baa82efdfb79acedd5816c240" 182 | ], 183 | "markers": "python_version >= '3.6'", 184 | "version": "==3.2.7" 185 | }, 186 | "djangochannelsrestframework": { 187 | "hashes": [ 188 | "sha256:8c38bfc1cb0cabb405bfb17b4c0dd36dc6bf34f8e4f0e9909ede0b67d03d1f8a", 189 | "sha256:e774c1b087cd8f024a867ace05ec3be0e5efc1590a9ae5da1879a12d29ae3742" 190 | ], 191 | "index": "pypi", 192 | "version": "==0.2.2" 193 | }, 194 | "djangorestframework": { 195 | "hashes": [ 196 | "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf", 197 | "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2" 198 | ], 199 | "markers": "python_version >= '3.5'", 200 | "version": "==3.12.4" 201 | }, 202 | "hiredis": { 203 | "hashes": [ 204 | "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e", 205 | "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27", 206 | "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163", 207 | "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc", 208 | "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26", 209 | "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e", 210 | "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579", 211 | "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a", 212 | "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048", 213 | "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87", 214 | "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63", 215 | "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54", 216 | "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05", 217 | "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb", 218 | "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea", 219 | "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5", 220 | "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e", 221 | "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc", 222 | "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99", 223 | "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a", 224 | "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581", 225 | "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426", 226 | "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db", 227 | "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a", 228 | "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a", 229 | "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d", 230 | "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443", 231 | "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79", 232 | "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d", 233 | "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9", 234 | "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d", 235 | "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485", 236 | "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5", 237 | "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048", 238 | "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0", 239 | "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6", 240 | "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41", 241 | "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298", 242 | "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce", 243 | "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", 244 | "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" 245 | ], 246 | "markers": "python_version >= '3.6'", 247 | "version": "==2.0.0" 248 | }, 249 | "hyperlink": { 250 | "hashes": [ 251 | "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", 252 | "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4" 253 | ], 254 | "version": "==21.0.0" 255 | }, 256 | "idna": { 257 | "hashes": [ 258 | "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", 259 | "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" 260 | ], 261 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 262 | "version": "==3.2" 263 | }, 264 | "incremental": { 265 | "hashes": [ 266 | "sha256:02f5de5aff48f6b9f665d99d48bfc7ec03b6e3943210de7cfc88856d755d6f57", 267 | "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321" 268 | ], 269 | "version": "==21.3.0" 270 | }, 271 | "msgpack": { 272 | "hashes": [ 273 | "sha256:0cb94ee48675a45d3b86e61d13c1e6f1696f0183f0715544976356ff86f741d9", 274 | "sha256:1026dcc10537d27dd2d26c327e552f05ce148977e9d7b9f1718748281b38c841", 275 | "sha256:26a1759f1a88df5f1d0b393eb582ec022326994e311ba9c5818adc5374736439", 276 | "sha256:2a5866bdc88d77f6e1370f82f2371c9bc6fc92fe898fa2dec0c5d4f5435a2694", 277 | "sha256:31c17bbf2ae5e29e48d794c693b7ca7a0c73bd4280976d408c53df421e838d2a", 278 | "sha256:497d2c12426adcd27ab83144057a705efb6acc7e85957a51d43cdcf7f258900f", 279 | "sha256:5a9ee2540c78659a1dd0b110f73773533ee3108d4e1219b5a15a8d635b7aca0e", 280 | "sha256:8521e5be9e3b93d4d5e07cb80b7e32353264d143c1f072309e1863174c6aadb1", 281 | "sha256:87869ba567fe371c4555d2e11e4948778ab6b59d6cc9d8460d543e4cfbbddd1c", 282 | "sha256:8ffb24a3b7518e843cd83538cf859e026d24ec41ac5721c18ed0c55101f9775b", 283 | "sha256:92be4b12de4806d3c36810b0fe2aeedd8d493db39e2eb90742b9c09299eb5759", 284 | "sha256:9ea52fff0473f9f3000987f313310208c879493491ef3ccf66268eff8d5a0326", 285 | "sha256:a4355d2193106c7aa77c98fc955252a737d8550320ecdb2e9ac701e15e2943bc", 286 | "sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192", 287 | "sha256:ac25f3e0513f6673e8b405c3a80500eb7be1cf8f57584be524c4fa78fe8e0c83", 288 | "sha256:b28c0876cce1466d7c2195d7658cf50e4730667196e2f1355c4209444717ee06", 289 | "sha256:b55f7db883530b74c857e50e149126b91bb75d35c08b28db12dcb0346f15e46e", 290 | "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9", 291 | "sha256:c747c0cc08bd6d72a586310bda6ea72eeb28e7505990f342552315b229a19b33", 292 | "sha256:d6c64601af8f3893d17ec233237030e3110f11b8a962cb66720bf70c0141aa54", 293 | "sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f", 294 | "sha256:de6bd7990a2c2dabe926b7e62a92886ccbf809425c347ae7de277067f97c2887", 295 | "sha256:e36a812ef4705a291cdb4a2fd352f013134f26c6ff63477f20235138d1d21009", 296 | "sha256:e89ec55871ed5473a041c0495b7b4e6099f6263438e0bd04ccd8418f92d5d7f2", 297 | "sha256:f3e6aaf217ac1c7ce1563cf52a2f4f5d5b1f64e8729d794165db71da57257f0c", 298 | "sha256:f484cd2dca68502de3704f056fa9b318c94b1539ed17a4c784266df5d6978c87", 299 | "sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984", 300 | "sha256:fe07bc6735d08e492a327f496b7850e98cb4d112c56df69b0c844dbebcbb47f6" 301 | ], 302 | "version": "==1.0.2" 303 | }, 304 | "pyasn1": { 305 | "hashes": [ 306 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 307 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 308 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 309 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 310 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 311 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 312 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 313 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 314 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 315 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 316 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 317 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 318 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 319 | ], 320 | "version": "==0.4.8" 321 | }, 322 | "pyasn1-modules": { 323 | "hashes": [ 324 | "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", 325 | "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", 326 | "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", 327 | "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", 328 | "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", 329 | "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", 330 | "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", 331 | "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", 332 | "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", 333 | "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", 334 | "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", 335 | "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", 336 | "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" 337 | ], 338 | "version": "==0.2.8" 339 | }, 340 | "pycparser": { 341 | "hashes": [ 342 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 343 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 344 | ], 345 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 346 | "version": "==2.20" 347 | }, 348 | "pyopenssl": { 349 | "hashes": [ 350 | "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51", 351 | "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b" 352 | ], 353 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 354 | "version": "==20.0.1" 355 | }, 356 | "pytz": { 357 | "hashes": [ 358 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 359 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 360 | ], 361 | "version": "==2021.1" 362 | }, 363 | "redis": { 364 | "hashes": [ 365 | "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", 366 | "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" 367 | ], 368 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 369 | "version": "==3.5.3" 370 | }, 371 | "service-identity": { 372 | "hashes": [ 373 | "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34", 374 | "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db" 375 | ], 376 | "version": "==21.1.0" 377 | }, 378 | "six": { 379 | "hashes": [ 380 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 381 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 382 | ], 383 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 384 | "version": "==1.16.0" 385 | }, 386 | "sqlparse": { 387 | "hashes": [ 388 | "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", 389 | "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" 390 | ], 391 | "markers": "python_version >= '3.5'", 392 | "version": "==0.4.2" 393 | }, 394 | "testing-redis": { 395 | "hashes": [ 396 | "sha256:1f3030dd85e9d43d79d648c02ec9b08bb288ac0207bed5be4a2c9c834233d7d2", 397 | "sha256:be46aeb951589d3f25f1dc5391934582bdadc196baf6bd261d60e71991f2b4f2" 398 | ], 399 | "index": "pypi", 400 | "version": "==1.1.1" 401 | }, 402 | "testing.common.database": { 403 | "hashes": [ 404 | "sha256:965d80b2985315325dc358c3061b174a712f4d4d5bf6a80b58b11f9a1dd86d73", 405 | "sha256:e3ed492bf480a87f271f74c53b262caf5d85c8bc09989a8f534fa2283ec52492" 406 | ], 407 | "version": "==2.0.3" 408 | }, 409 | "twisted": { 410 | "extras": [ 411 | "tls" 412 | ], 413 | "hashes": [ 414 | "sha256:13c1d1d2421ae556d91e81e66cf0d4f4e4e1e4a36a0486933bee4305c6a4fb9b", 415 | "sha256:2cd652542463277378b0d349f47c62f20d9306e57d1247baabd6d1d38a109006" 416 | ], 417 | "markers": "python_full_version >= '3.6.7'", 418 | "version": "==21.7.0" 419 | }, 420 | "txaio": { 421 | "hashes": [ 422 | "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", 423 | "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" 424 | ], 425 | "markers": "python_version >= '3.6'", 426 | "version": "==21.2.1" 427 | }, 428 | "typing-extensions": { 429 | "hashes": [ 430 | "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", 431 | "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", 432 | "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" 433 | ], 434 | "version": "==3.10.0.2" 435 | }, 436 | "zope.interface": { 437 | "hashes": [ 438 | "sha256:08f9636e99a9d5410181ba0729e0408d3d8748026ea938f3b970a0249daa8192", 439 | "sha256:0b465ae0962d49c68aa9733ba92a001b2a0933c317780435f00be7ecb959c702", 440 | "sha256:0cba8477e300d64a11a9789ed40ee8932b59f9ee05f85276dbb4b59acee5dd09", 441 | "sha256:0cee5187b60ed26d56eb2960136288ce91bcf61e2a9405660d271d1f122a69a4", 442 | "sha256:0ea1d73b7c9dcbc5080bb8aaffb776f1c68e807767069b9ccdd06f27a161914a", 443 | "sha256:0f91b5b948686659a8e28b728ff5e74b1be6bf40cb04704453617e5f1e945ef3", 444 | "sha256:15e7d1f7a6ee16572e21e3576d2012b2778cbacf75eb4b7400be37455f5ca8bf", 445 | "sha256:17776ecd3a1fdd2b2cd5373e5ef8b307162f581c693575ec62e7c5399d80794c", 446 | "sha256:194d0bcb1374ac3e1e023961610dc8f2c78a0f5f634d0c737691e215569e640d", 447 | "sha256:1c0e316c9add0db48a5b703833881351444398b04111188069a26a61cfb4df78", 448 | "sha256:205e40ccde0f37496904572035deea747390a8b7dc65146d30b96e2dd1359a83", 449 | "sha256:273f158fabc5ea33cbc936da0ab3d4ba80ede5351babc4f577d768e057651531", 450 | "sha256:2876246527c91e101184f63ccd1d716ec9c46519cc5f3d5375a3351c46467c46", 451 | "sha256:2c98384b254b37ce50eddd55db8d381a5c53b4c10ee66e1e7fe749824f894021", 452 | "sha256:2e5a26f16503be6c826abca904e45f1a44ff275fdb7e9d1b75c10671c26f8b94", 453 | "sha256:334701327f37c47fa628fc8b8d28c7d7730ce7daaf4bda1efb741679c2b087fc", 454 | "sha256:3748fac0d0f6a304e674955ab1365d515993b3a0a865e16a11ec9d86fb307f63", 455 | "sha256:3c02411a3b62668200910090a0dff17c0b25aaa36145082a5a6adf08fa281e54", 456 | "sha256:3dd4952748521205697bc2802e4afac5ed4b02909bb799ba1fe239f77fd4e117", 457 | "sha256:3f24df7124c323fceb53ff6168da70dbfbae1442b4f3da439cd441681f54fe25", 458 | "sha256:469e2407e0fe9880ac690a3666f03eb4c3c444411a5a5fddfdabc5d184a79f05", 459 | "sha256:4de4bc9b6d35c5af65b454d3e9bc98c50eb3960d5a3762c9438df57427134b8e", 460 | "sha256:5208ebd5152e040640518a77827bdfcc73773a15a33d6644015b763b9c9febc1", 461 | "sha256:52de7fc6c21b419078008f697fd4103dbc763288b1406b4562554bd47514c004", 462 | "sha256:5bb3489b4558e49ad2c5118137cfeaf59434f9737fa9c5deefc72d22c23822e2", 463 | "sha256:5dba5f530fec3f0988d83b78cc591b58c0b6eb8431a85edd1569a0539a8a5a0e", 464 | "sha256:5dd9ca406499444f4c8299f803d4a14edf7890ecc595c8b1c7115c2342cadc5f", 465 | "sha256:5f931a1c21dfa7a9c573ec1f50a31135ccce84e32507c54e1ea404894c5eb96f", 466 | "sha256:63b82bb63de7c821428d513607e84c6d97d58afd1fe2eb645030bdc185440120", 467 | "sha256:66c0061c91b3b9cf542131148ef7ecbecb2690d48d1612ec386de9d36766058f", 468 | "sha256:6f0c02cbb9691b7c91d5009108f975f8ffeab5dff8f26d62e21c493060eff2a1", 469 | "sha256:71aace0c42d53abe6fc7f726c5d3b60d90f3c5c055a447950ad6ea9cec2e37d9", 470 | "sha256:7d97a4306898b05404a0dcdc32d9709b7d8832c0c542b861d9a826301719794e", 471 | "sha256:7df1e1c05304f26faa49fa752a8c690126cf98b40b91d54e6e9cc3b7d6ffe8b7", 472 | "sha256:8270252effc60b9642b423189a2fe90eb6b59e87cbee54549db3f5562ff8d1b8", 473 | "sha256:867a5ad16892bf20e6c4ea2aab1971f45645ff3102ad29bd84c86027fa99997b", 474 | "sha256:877473e675fdcc113c138813a5dd440da0769a2d81f4d86614e5d62b69497155", 475 | "sha256:8892f89999ffd992208754851e5a052f6b5db70a1e3f7d54b17c5211e37a98c7", 476 | "sha256:9a9845c4c6bb56e508651f005c4aeb0404e518c6f000d5a1123ab077ab769f5c", 477 | "sha256:a1e6e96217a0f72e2b8629e271e1b280c6fa3fe6e59fa8f6701bec14e3354325", 478 | "sha256:a8156e6a7f5e2a0ff0c5b21d6bcb45145efece1909efcbbbf48c56f8da68221d", 479 | "sha256:a9506a7e80bcf6eacfff7f804c0ad5350c8c95b9010e4356a4b36f5322f09abb", 480 | "sha256:af310ec8335016b5e52cae60cda4a4f2a60a788cbb949a4fbea13d441aa5a09e", 481 | "sha256:b0297b1e05fd128d26cc2460c810d42e205d16d76799526dfa8c8ccd50e74959", 482 | "sha256:bf68f4b2b6683e52bec69273562df15af352e5ed25d1b6641e7efddc5951d1a7", 483 | "sha256:d0c1bc2fa9a7285719e5678584f6b92572a5b639d0e471bb8d4b650a1a910920", 484 | "sha256:d4d9d6c1a455d4babd320203b918ccc7fcbefe308615c521062bc2ba1aa4d26e", 485 | "sha256:db1fa631737dab9fa0b37f3979d8d2631e348c3b4e8325d6873c2541d0ae5a48", 486 | "sha256:dd93ea5c0c7f3e25335ab7d22a507b1dc43976e1345508f845efc573d3d779d8", 487 | "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4", 488 | "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263" 489 | ], 490 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 491 | "version": "==5.4.0" 492 | } 493 | }, 494 | "develop": { 495 | "attrs": { 496 | "hashes": [ 497 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 498 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 499 | ], 500 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 501 | "version": "==21.2.0" 502 | }, 503 | "iniconfig": { 504 | "hashes": [ 505 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 506 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 507 | ], 508 | "version": "==1.1.1" 509 | }, 510 | "packaging": { 511 | "hashes": [ 512 | "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", 513 | "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" 514 | ], 515 | "markers": "python_version >= '3.6'", 516 | "version": "==21.0" 517 | }, 518 | "pluggy": { 519 | "hashes": [ 520 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 521 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 522 | ], 523 | "markers": "python_version >= '3.6'", 524 | "version": "==1.0.0" 525 | }, 526 | "py": { 527 | "hashes": [ 528 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 529 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 530 | ], 531 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 532 | "version": "==1.10.0" 533 | }, 534 | "pyparsing": { 535 | "hashes": [ 536 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 537 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 538 | ], 539 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 540 | "version": "==2.4.7" 541 | }, 542 | "pytest": { 543 | "hashes": [ 544 | "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", 545 | "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" 546 | ], 547 | "index": "pypi", 548 | "version": "==6.2.5" 549 | }, 550 | "pytest-django": { 551 | "hashes": [ 552 | "sha256:d1c6758a592fb0ef8abaa2fe12dd28858c1dcfc3d466102ffe52aa8934733dca", 553 | "sha256:f96c4556f4e7b15d987dd1dcc1d1526df81d40c1548d31ce840d597ed2be8c46" 554 | ], 555 | "index": "pypi", 556 | "version": "==4.3.0" 557 | }, 558 | "pytest-pythonpath": { 559 | "hashes": [ 560 | "sha256:63fc546ace7d2c845c1ee289e8f7a6362c2b6bae497d10c716e58e253e801d62" 561 | ], 562 | "index": "pypi", 563 | "version": "==0.7.3" 564 | }, 565 | "toml": { 566 | "hashes": [ 567 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 568 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 569 | ], 570 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 571 | "version": "==0.10.2" 572 | } 573 | } 574 | } 575 | --------------------------------------------------------------------------------