├── test ├── __init__.py ├── README.md ├── subscribe_test.py ├── examples │ ├── disconnect.py │ ├── stream.py │ ├── chat_manual_reconnect.py │ ├── client_stream.py │ ├── chat.py │ ├── chat_exception.py │ ├── azure.py │ ├── chat_msgpack.py │ └── chat_auth.py ├── client_streaming_test.py ├── playground │ └── raw_socket_connection.py ├── open_close_test.py ├── send_auth_errors_test.py ├── base_test_case.py ├── streaming_test.py ├── configuration_test.py ├── send_auth_test.py ├── reconnection_test.py └── send_test.py ├── signalrcore ├── __init__.py ├── hub │ ├── __init__.py │ ├── errors.py │ ├── auth_hub_connection.py │ ├── handlers.py │ └── base_hub_connection.py ├── messages │ ├── __init__.py │ ├── handshake │ │ ├── __init__.py │ │ ├── response.py │ │ └── request.py │ ├── message_type.py │ ├── ping_message.py │ ├── base_message.py │ ├── cancel_invocation_message.py │ ├── close_message.py │ ├── stream_item_message.py │ ├── stream_invocation_message.py │ ├── completion_message.py │ └── invocation_message.py ├── protocol │ ├── __init__.py │ ├── handshake │ │ └── __init__.py │ ├── json_hub_protocol.py │ ├── base_hub_protocol.py │ └── messagepack_protocol.py ├── transport │ ├── __init__.py │ ├── websockets │ │ ├── __init__.py │ │ ├── connection.py │ │ ├── reconnection.py │ │ ├── websocket_client.py │ │ └── websocket_transport.py │ └── base_transport.py ├── subject.py ├── helpers.py └── hub_connection_builder.py ├── MANIFEST.in ├── _config.yml ├── docs ├── _config.yml ├── img │ ├── logo_temp.svg.png │ ├── logo_temp.svg.xcf │ └── logo_temp.128.svg.png ├── README.md ├── transport.md └── hubs.md ├── desktop.ini ├── packages-microsoft-prod.deb ├── signalrcore.code-workspace ├── BACKERS.md ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── publish-release-to-twitter.yml │ ├── python-publish.yml │ ├── python-lint.yml │ └── python-test.yml ├── Makefile ├── LICENSE ├── setup.py ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /signalrcore/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /signalrcore/hub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /signalrcore/messages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /signalrcore/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /signalrcore/transport/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day -------------------------------------------------------------------------------- /signalrcore/messages/handshake/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /signalrcore/protocol/handshake/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day -------------------------------------------------------------------------------- /signalrcore/transport/websockets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /desktop.ini: -------------------------------------------------------------------------------- 1 | [ViewState] 2 | Mode= 3 | Vid= 4 | FolderType=Generic 5 | -------------------------------------------------------------------------------- /docs/img/logo_temp.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mandrewcito/signalrcore/HEAD/docs/img/logo_temp.svg.png -------------------------------------------------------------------------------- /docs/img/logo_temp.svg.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mandrewcito/signalrcore/HEAD/docs/img/logo_temp.svg.xcf -------------------------------------------------------------------------------- /packages-microsoft-prod.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mandrewcito/signalrcore/HEAD/packages-microsoft-prod.deb -------------------------------------------------------------------------------- /docs/img/logo_temp.128.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mandrewcito/signalrcore/HEAD/docs/img/logo_temp.128.svg.png -------------------------------------------------------------------------------- /signalrcore.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | } 9 | } -------------------------------------------------------------------------------- /BACKERS.md: -------------------------------------------------------------------------------- 1 | # Sponsors & Backers 2 | 3 | * [Robert Bartram]() 4 | * [Carlos calvaradocl](https://github.com/calvaradocl) 5 | 6 | -------------------------------------------------------------------------------- /signalrcore/messages/handshake/response.py: -------------------------------------------------------------------------------- 1 | class HandshakeResponseMessage(object): 2 | 3 | def __init__(self, error): 4 | self.error = error 5 | -------------------------------------------------------------------------------- /signalrcore/messages/handshake/request.py: -------------------------------------------------------------------------------- 1 | class HandshakeRequestMessage(object): 2 | 3 | def __init__(self, protocol, version): 4 | self.protocol = protocol 5 | self.version = version 6 | -------------------------------------------------------------------------------- /signalrcore/transport/websockets/connection.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ConnectionState(Enum): 5 | connecting = 0 6 | connected = 1 7 | reconnecting = 2 8 | disconnected = 4 9 | -------------------------------------------------------------------------------- /signalrcore/hub/errors.py: -------------------------------------------------------------------------------- 1 | class HubError(OSError): 2 | pass 3 | 4 | 5 | class UnAuthorizedHubError(HubError): 6 | pass 7 | 8 | 9 | class HubConnectionError(ValueError): 10 | """Hub connection error 11 | """ 12 | pass 13 | -------------------------------------------------------------------------------- /signalrcore/messages/message_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class MessageType(Enum): 5 | invocation = 1 6 | stream_item = 2 7 | completion = 3 8 | stream_invocation = 4 9 | cancel_invocation = 5 10 | ping = 6 11 | close = 7 12 | invocation_binding_failure = -1 13 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Testing signalrcore lib 2 | 3 | ## Pre testing 4 | 5 | For testing this python lib since version 0.7.6 you will need docker, 6 | i will upload dotnet code with signalr chat, chat auth, streaming and a dockerfile 7 | to build it with a bash script to previously set up the environment. :D 8 | 9 | Signalr dotnet code from [here](https://codeload.github.com/aspnet/Docs/zip/master) or download my test server from [here](https://github.com/mandrewcito/signalrcore-containertestservers)., -------------------------------------------------------------------------------- /signalrcore/messages/ping_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseMessage 2 | """ 3 | A `Ping` message is a JSON object with the following properties: 4 | 5 | * `type` - A `Number` with the literal value `6`, 6 | indicating that this message is a `Ping`. 7 | 8 | Example 9 | ```json 10 | { 11 | "type": 6 12 | } 13 | ``` 14 | """ 15 | 16 | 17 | class PingMessage(BaseMessage): 18 | def __init__( 19 | self, **kwargs): 20 | super(PingMessage, self).__init__(6, **kwargs) 21 | -------------------------------------------------------------------------------- /signalrcore/messages/base_message.py: -------------------------------------------------------------------------------- 1 | from .message_type import MessageType 2 | 3 | 4 | class BaseMessage(object): 5 | def __init__(self, message_type, **kwargs): 6 | self.type = MessageType(message_type) 7 | 8 | 9 | class BaseHeadersMessage(BaseMessage): 10 | """ 11 | All messages expct ping can carry aditional headers 12 | """ 13 | def __init__(self, message_type, headers={}, **kwargs): 14 | super(BaseHeadersMessage, self).__init__(message_type) 15 | self.headers = headers 16 | -------------------------------------------------------------------------------- /test/subscribe_test.py: -------------------------------------------------------------------------------- 1 | from test.base_test_case import BaseTestCase, Urls 2 | 3 | 4 | class TestSendMethod(BaseTestCase): 5 | server_url = Urls.server_url_ssl 6 | 7 | def test_unsubscribe(self): 8 | def fake_callback(_): 9 | pass 10 | 11 | self.connection = self.get_connection() 12 | self.connection.on("ReceiveMessage", fake_callback) 13 | 14 | self.assertEqual(len(self.connection.handlers), 1) 15 | 16 | self.connection.unsubscribe("ReceiveMessage", fake_callback) 17 | 18 | self.assertEqual(len(self.connection.handlers), 0) 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mandrewcito] 4 | patreon: mandrewcito # Replace with a single Patreon username 5 | #open_collective: # Replace with a single Open Collective username 6 | ko_fi: mandrewcito # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | #custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-release-to-twitter.yml: -------------------------------------------------------------------------------- 1 | name: publish-release-to-twitter 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: InfraWay/tweet-action@v1.0.1 11 | with: 12 | status: Signalrcore ${{ github.event.release.tag_name }} released, https://pypi.org/project/signalrcore/ 13 | 14 | ${{ github.event.release.body }} 15 | 16 | #SignalR #AspNetCore #Python 17 | api_key: ${{ secrets.TWITTER_API_KEY }} 18 | api_key_secret: ${{ secrets.TWITTER_API_KEY_SECRET }} 19 | access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} 20 | access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 21 | -------------------------------------------------------------------------------- /signalrcore/messages/cancel_invocation_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | A `CancelInvocation` message is a JSON object with the following properties 4 | 5 | * `type` - A `Number` with the literal value `5`, 6 | indicating that this message is a `CancelInvocation`. 7 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 8 | 9 | Example 10 | ```json 11 | { 12 | "type": 5, 13 | "invocationId": "123" 14 | } 15 | """ 16 | 17 | 18 | class CancelInvocationMessage(BaseHeadersMessage): 19 | def __init__( 20 | self, 21 | invocation_id, 22 | **kwargs): 23 | super(CancelInvocationMessage, self).__init__(5, **kwargs) 24 | self.invocation_id = invocation_id 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | tests: 3 | venv/bin/python3 -m unittest discover -s test/ -p "*_test.py" -v 4 | 5 | package: 6 | python3 setup.py sdist bdist_wheel 7 | 8 | lint: 9 | flake8 signalrcore test 10 | 11 | all: 12 | tests package 13 | 14 | upload: 15 | twine upload dist/* --verbose 16 | 17 | coverage: 18 | coverage run -m unittest discover -s test/ -p "*_test.py" 19 | coverage html --omit="venv/*" -d coverage_html 20 | 21 | pytest-cov: 22 | pytest --junitxml=reports/junit.xml --cov=. --cov-report=xml:coverage.xml --cov-report=term 23 | 24 | clean: 25 | @find . -name "*.pyc" -exec rm -f '{}' + 26 | @find . -name "*~" -exec rm -f '{}' + 27 | @find . -name "__pycache__" -exec rm -R -f '{}' + 28 | @rm -rf build/* 29 | @rm -rf coverage_html/* 30 | @rm -rf dist/* 31 | @rm -rf json_repository.egg-info/* 32 | @echo "Done!" 33 | -------------------------------------------------------------------------------- /signalrcore/messages/close_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | A `Close` message is a JSON object with the following properties 4 | 5 | * `type` - A `Number` with the literal value `7`, 6 | indicating that this message is a `Close`. 7 | * `error` - An optional `String` encoding the error message. 8 | 9 | Example - A `Close` message without an error 10 | ```json 11 | { 12 | "type": 7 13 | } 14 | ``` 15 | 16 | Example - A `Close` message with an error 17 | ```json 18 | { 19 | "type": 7, 20 | "error": "Connection closed because of an error!" 21 | } 22 | ``` 23 | """ 24 | 25 | 26 | class CloseMessage(BaseHeadersMessage): 27 | def __init__( 28 | self, 29 | error, 30 | **kwargs): 31 | super(CloseMessage, self).__init__(7, **kwargs) 32 | self.error = error 33 | -------------------------------------------------------------------------------- /test/examples/disconnect.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import logging 3 | import sys 4 | import time 5 | 6 | sys.path.append("./") 7 | 8 | from signalrcore.hub_connection_builder\ 9 | import HubConnectionBuilder # noqa: E402 10 | 11 | connection = HubConnectionBuilder()\ 12 | .with_url("wss://localhost:5001/chathub", options={"verify_ssl": False})\ 13 | .configure_logging(logging.ERROR)\ 14 | .build() 15 | 16 | _lock = threading.Lock() 17 | 18 | connection.on_open(lambda: _lock.release()) 19 | connection.on_close(lambda: _lock.release()) 20 | 21 | connection.on("ReceiveMessage", lambda _: _lock.release()) 22 | 23 | (_lock.acquire(timeout=30)) # Released on open 24 | 25 | connection.start() 26 | 27 | (_lock.acquire(timeout=30)) # Released on ReOpen 28 | 29 | connection.send("DisconnectMe", []) 30 | 31 | time.sleep(30) 32 | 33 | (_lock.acquire(timeout=30)) 34 | 35 | connection.send("DisconnectMe", []) 36 | -------------------------------------------------------------------------------- /signalrcore/messages/stream_item_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | A `StreamItem` message is a JSON object with the following properties: 4 | 5 | * `type` - A `Number` with the literal value 2, indicating 6 | that this message is a `StreamItem`. 7 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 8 | * `item` - A `Token` encoding the stream item 9 | (see "JSON Payload Encoding" for details). 10 | 11 | Example 12 | 13 | ```json 14 | { 15 | "type": 2, 16 | "invocationId": "123", 17 | "item": 42 18 | } 19 | ``` 20 | """ 21 | 22 | 23 | class StreamItemMessage(BaseHeadersMessage): 24 | def __init__( 25 | self, 26 | invocation_id, 27 | item, 28 | **kwargs): 29 | super(StreamItemMessage, self).__init__(2, **kwargs) 30 | self.invocation_id = invocation_id 31 | self.item = item 32 | -------------------------------------------------------------------------------- /test/client_streaming_test.py: -------------------------------------------------------------------------------- 1 | from signalrcore.subject import Subject 2 | from test.base_test_case import BaseTestCase, Urls 3 | 4 | 5 | class TestClientStreamMethod(BaseTestCase): 6 | 7 | def test_stream(self): 8 | self.complete = False 9 | self.items = list(range(0, 10)) 10 | subject = Subject() 11 | self.connection.send("UploadStream", subject) 12 | while (len(self.items) > 0): 13 | subject.next(str(self.items.pop())) 14 | subject.complete() 15 | self.assertTrue(len(self.items) == 0) 16 | 17 | 18 | class TestClientStreamMethodMsgPack(TestClientStreamMethod): 19 | def get_connection(self): 20 | return super().get_connection(msgpack=True) 21 | 22 | 23 | class TestClientNoSslStreamMethodMsgPack(TestClientStreamMethodMsgPack): 24 | server_url = Urls.server_url_no_ssl 25 | 26 | 27 | class TestClientNoSslStreamMethod(TestClientStreamMethod): 28 | server_url = Urls.server_url_no_ssl 29 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /signalrcore/hub/auth_hub_connection.py: -------------------------------------------------------------------------------- 1 | from .base_hub_connection import BaseHubConnection 2 | from ..helpers import Helpers 3 | 4 | 5 | class AuthHubConnection(BaseHubConnection): 6 | def __init__(self, auth_function, headers=None, **kwargs): 7 | if headers is None: 8 | self.headers = dict() 9 | else: 10 | self.headers = headers 11 | self.auth_function = auth_function 12 | super(AuthHubConnection, self).__init__(headers=headers, **kwargs) 13 | 14 | def start(self): 15 | try: 16 | Helpers.get_logger().debug("Starting connection ...") 17 | self.token = self.auth_function() 18 | Helpers.get_logger()\ 19 | .debug("auth function result {0}".format(self.token)) 20 | self.headers["Authorization"] = "Bearer " + self.token 21 | return super(AuthHubConnection, self).start() 22 | except Exception as ex: 23 | Helpers.get_logger().warning(self.__class__.__name__) 24 | Helpers.get_logger().warning(str(ex)) 25 | raise ex 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrés Baamonde Lozano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Linter - flake8 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.8 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.8 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install .[dev] 27 | - name: Lint with flake8 28 | run: | 29 | # stop the build if there are Python syntax errors or undefined names 30 | flake8 . --exclude test,venv --count --select=E9,F63,F7,F82 --show-source --statistics 31 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 32 | flake8 . --exclude test,venv --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="signalrcore", 8 | version="0.9.6", 9 | author="mandrewcito", 10 | author_email="signalrcore@mandrewcito.dev", 11 | description="A Python SignalR Core client(json and messagepack)," 12 | "with invocation auth and two way streaming." 13 | "Compatible with azure / serverless functions." 14 | "Also with automatic reconnect and manually reconnect.", 15 | keywords="signalr core client 3.1", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | license_file="LICENSE", 19 | url="https://github.com/mandrewcito/signalrcore", 20 | packages=setuptools.find_packages(), 21 | classifiers=[ 22 | "Programming Language :: Python :: 3.8", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent" 25 | ], 26 | install_requires=[ 27 | "msgpack==1.0.2" 28 | ], 29 | extras_require={ 30 | 'dev': [ 31 | 'requests', 32 | 'flake8', 33 | 'coverage', 34 | 'pytest', 35 | 'pytest-cov' 36 | ] 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /signalrcore/transport/base_transport.py: -------------------------------------------------------------------------------- 1 | from ..protocol.json_hub_protocol import JsonHubProtocol 2 | from ..helpers import Helpers 3 | 4 | 5 | class BaseTransport(object): 6 | def __init__(self, protocol=JsonHubProtocol(), on_message=None): 7 | self.protocol = protocol 8 | self._on_message = on_message 9 | self.logger = Helpers.get_logger() 10 | self._on_open = lambda: self.logger.info("on_connect not defined") 11 | self._on_close = lambda: self.logger.info("on_disconnect not defined") 12 | self._on_reconnect =\ 13 | lambda: self.logger.info("on_reconnect not defined") 14 | 15 | def on_open_callback(self, callback): 16 | self._on_open = callback 17 | 18 | def on_close_callback(self, callback): 19 | self._on_close = callback 20 | 21 | def on_reconnect_callback(self, callback): 22 | self._on_reconnect = callback 23 | 24 | def start(self): # pragma: no cover 25 | raise NotImplementedError() 26 | 27 | def stop(self): # pragma: no cover 28 | raise NotImplementedError() 29 | 30 | def is_running(self): # pragma: no cover 31 | raise NotImplementedError() 32 | 33 | def send(self, message, on_invocation=None): # pragma: no cover 34 | raise NotImplementedError() 35 | -------------------------------------------------------------------------------- /test/examples/stream.py: -------------------------------------------------------------------------------- 1 | 2 | import time 3 | import sys 4 | import logging 5 | sys.path.append("./") 6 | from signalrcore.hub_connection_builder import HubConnectionBuilder # noqa E402 7 | 8 | 9 | def input_with_default(input_text, default_value): 10 | value = input(input_text.format(default_value)) 11 | return default_value if value is None or value.strip() == "" else value 12 | 13 | 14 | server_url = input_with_default( 15 | 'Enter your server url(default: {0}): ', "wss://localhost:5001/chatHub") 16 | 17 | hub_connection = HubConnectionBuilder()\ 18 | .with_url(server_url, options={"verify_ssl": False}) \ 19 | .configure_logging(logging.DEBUG, socket_trace=True) \ 20 | .build() 21 | hub_connection.start() 22 | time.sleep(10) 23 | 24 | end = False 25 | 26 | 27 | def bye(error, x): 28 | global end 29 | end = True 30 | if error: 31 | print("error {0}".format(x)) 32 | else: 33 | print("complete! ") 34 | global hub_connection 35 | 36 | 37 | hub_connection.stream( 38 | "Counter", 39 | [10, 500]).subscribe({ 40 | "next": lambda x: print("next callback: ", x), 41 | "complete": lambda x: bye(False, x), 42 | "error": lambda x: bye(True, x) 43 | }) 44 | 45 | while not end: 46 | time.sleep(1) 47 | 48 | hub_connection.stop() 49 | -------------------------------------------------------------------------------- /signalrcore/messages/stream_invocation_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | A `StreamInvocation` message is a JSON object with the following properties: 4 | 5 | * `type` - A `Number` with the literal value 4, indicating that 6 | this message is a StreamInvocation. 7 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 8 | * `target` - A `String` encoding the `Target` name, as expected 9 | by the Callee's Binder. 10 | * `arguments` - An `Array` containing arguments to apply to 11 | the method referred to in Target. This is a sequence of JSON 12 | `Token`s, encoded as indicated below in the 13 | "JSON Payload Encoding" section. 14 | 15 | Example: 16 | 17 | ```json 18 | { 19 | "type": 4, 20 | "invocationId": "123", 21 | "target": "Send", 22 | "arguments": [ 23 | 42, 24 | "Test Message" 25 | ] 26 | } 27 | ``` 28 | """ 29 | 30 | 31 | class StreamInvocationMessage(BaseHeadersMessage): 32 | def __init__( 33 | self, 34 | invocation_id, 35 | target, 36 | arguments, 37 | **kwargs): 38 | super(StreamInvocationMessage, self).__init__(4, **kwargs) 39 | self.invocation_id = invocation_id 40 | self.target = target 41 | self.arguments = arguments 42 | self.stream_ids = [] 43 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Integration tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - run: git clone https://github.com/mandrewcito/signalrcore-containertestservers containers 19 | - run: docker compose -f containers/docker-compose.yml up -d 20 | - uses: actions/checkout@v2 21 | - name: Set up Python 3.8 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: 3.8 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install .[dev] 29 | pip install codecov 30 | - name: Test and coverage 31 | run: | 32 | # Test 33 | pytest --junitxml=reports/junit.xml --cov=. --cov-report=xml:coverage.xml --cov-report=term 34 | - name: Upload test results to Codecov 35 | uses: codecov/codecov-action@v4 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | files: | 39 | coverage.xml 40 | reports/junit.xml 41 | fail_ci_if_error: true -------------------------------------------------------------------------------- /test/examples/chat_manual_reconnect.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import time 4 | from signalrcore.hub_connection_builder import HubConnectionBuilder 5 | 6 | 7 | def input_with_default(input_text, default_value): 8 | value = input(input_text.format(default_value)) 9 | return default_value if value is None or value.strip() == "" else value 10 | 11 | 12 | server_url = input_with_default( 13 | 'Enter your server url(default: {0}): ', "ws://localhost:62342/chathub") 14 | username = input_with_default( 15 | 'Enter your username (default: {0}): ', "mandrewcito") 16 | 17 | hub_connection = HubConnectionBuilder()\ 18 | .with_url(server_url)\ 19 | .configure_logging(logging.DEBUG)\ 20 | .build() 21 | 22 | hub_connection.on_open( 23 | lambda: print( 24 | "connection opened and handshake received ready to send messages")) 25 | hub_connection.on_close(lambda: reconnect) 26 | 27 | 28 | def reconnect(): 29 | print("connection closed") 30 | time.sleep(20) 31 | print("try reconnect") 32 | hub_connection.start() 33 | 34 | 35 | hub_connection.on("ReceiveMessage", print) 36 | hub_connection.start() 37 | message = None 38 | 39 | # Do login 40 | 41 | while message != "exit()": 42 | message = input(">> ") 43 | if message is not None and message != "" and message != "exit()": 44 | hub_connection.send("SendMessage", [username, message]) 45 | 46 | hub_connection.stop() 47 | 48 | sys.exit(0) 49 | -------------------------------------------------------------------------------- /test/playground/raw_socket_connection.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import random 4 | import base64 5 | import json 6 | import logging 7 | sys.path.append("./") 8 | from signalrcore.transport.websockets.websocket_client import WebSocketClient # noqa E402 9 | 10 | logging.basicConfig(level=logging.DEBUG) 11 | 12 | 13 | def on_open(): 14 | print("socket opened mi rey") 15 | 16 | 17 | def on_error(ex: Exception = None): 18 | print("socket errored mi rey") 19 | if ex: 20 | print(ex) 21 | 22 | 23 | def on_close(): 24 | print("socket closed mi rey") 25 | 26 | 27 | app = WebSocketClient( 28 | url="https://localhost:5001/chathub", 29 | headers={}, 30 | verify_ssl=False, 31 | on_open=on_open, 32 | on_error=on_error, 33 | on_close=on_close, 34 | on_message=print) 35 | 36 | app.connect() 37 | 38 | time.sleep(2) 39 | 40 | msg = json.dumps({"protocol": "json", "version": 1}) 41 | app.send(msg + str(chr(0x1E))) 42 | 43 | time.sleep(2) 44 | 45 | while msg != "exit": 46 | msg = input("> ") 47 | if msg != exit and msg is not None and len(msg) > 0: 48 | key = base64.b64encode(f"{random.randint(1, 100)}".encode()).decode() 49 | app.send(json.dumps({ 50 | "type": 1, 51 | "invocationId": key, 52 | "target": "SendMessage", 53 | "arguments": [ 54 | "mandrewcito", 55 | msg 56 | ] 57 | })) 58 | 59 | app.close() 60 | 61 | print("END SCRIPT") 62 | -------------------------------------------------------------------------------- /test/examples/client_stream.py: -------------------------------------------------------------------------------- 1 | 2 | import time 3 | import sys 4 | import logging 5 | sys.path.append("./") 6 | from signalrcore.hub_connection_builder\ 7 | import HubConnectionBuilder # noqa: E402 8 | from signalrcore.subject import Subject # noqa: E402 9 | 10 | 11 | def input_with_default(input_text, default_value): 12 | value = input(input_text.format(default_value)) 13 | return default_value if value is None or value.strip() == "" else value 14 | 15 | 16 | server_url = input_with_default( 17 | 'Enter your server url(default: {0}): ', "wss://localhost:5001/chatHub") 18 | 19 | hub_connection = HubConnectionBuilder()\ 20 | .with_url(server_url, options={"verify_ssl": False}) \ 21 | .configure_logging(logging.DEBUG) \ 22 | .with_automatic_reconnect({ 23 | "type": "interval", 24 | "keep_alive_interval": 10, 25 | "intervals": [1, 3, 5, 6, 7, 87, 3] 26 | })\ 27 | .build() 28 | hub_connection.start() 29 | time.sleep(10) 30 | 31 | 32 | def bye(error, x): 33 | if error: 34 | print("error {0}".format(x)) 35 | else: 36 | print("complete! ") 37 | global hub_connection 38 | hub_connection.stop() 39 | sys.exit(0) 40 | 41 | 42 | iteration = 0 43 | subject = Subject() 44 | 45 | 46 | def interval_handle(): 47 | global iteration 48 | iteration += 1 49 | subject.next(str(iteration)) 50 | if iteration == 10: 51 | subject.complete() 52 | 53 | 54 | hub_connection.send("UploadStream", subject) 55 | 56 | while iteration != 10: 57 | interval_handle() 58 | time.sleep(0.5) 59 | -------------------------------------------------------------------------------- /test/examples/chat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | sys.path.append("./") 4 | from signalrcore.hub_connection_builder import HubConnectionBuilder # noqa E402 5 | 6 | 7 | def input_with_default(input_text, default_value): 8 | value = input(input_text.format(default_value)) 9 | return default_value if value is None or value.strip() == "" else value 10 | 11 | 12 | server_url = input_with_default( 13 | 'Enter your server url(default: {0}): ', "http://localhost:5000/chathub") 14 | username = input_with_default( 15 | 'Enter your username (default: {0}): ', "mandrewcito") 16 | handler = logging.StreamHandler() 17 | handler.setLevel(logging.DEBUG) 18 | hub_connection = HubConnectionBuilder()\ 19 | .with_url(server_url, options={"verify_ssl": False}) \ 20 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 21 | .with_automatic_reconnect({ 22 | "type": "interval", 23 | "keep_alive_interval": 10, 24 | "intervals": [1, 3, 5, 6, 7, 87, 3] 25 | }).build() 26 | 27 | hub_connection.on_open(lambda: print( 28 | "connection opened and handshake received ready to send messages")) 29 | hub_connection.on_close(lambda: print("connection closed")) 30 | 31 | hub_connection.on("ReceiveMessage", print) 32 | hub_connection.start() 33 | message = None 34 | 35 | # Do login 36 | 37 | while message != "exit()": 38 | message = input(">> ") 39 | if message is not None and message != "" and message != "exit()": 40 | hub_connection.send("SendMessage", [username, message]) 41 | 42 | hub_connection.stop() 43 | 44 | sys.exit(0) 45 | -------------------------------------------------------------------------------- /test/examples/chat_exception.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | sys.path.append("./") 4 | from signalrcore.hub_connection_builder import HubConnectionBuilder # noqa E402 5 | 6 | 7 | def input_with_default(input_text, default_value): 8 | value = input(input_text.format(default_value)) 9 | return default_value if value is None or value.strip() == "" else value 10 | 11 | 12 | server_url = input_with_default( 13 | 'Enter your server url(default: {0}): ', "wss://localhost:5001/chatHub") 14 | 15 | handler = logging.StreamHandler() 16 | handler.setLevel(logging.DEBUG) 17 | hub_connection = HubConnectionBuilder()\ 18 | .with_url(server_url, options={"verify_ssl": False}) \ 19 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 20 | .with_automatic_reconnect({ 21 | "type": "interval", 22 | "keep_alive_interval": 10, 23 | "intervals": [1, 3, 5, 6, 7, 87, 3] 24 | }).build() 25 | 26 | hub_connection.on_open( 27 | lambda: print( 28 | "connection opened and handshake received ready to send messages")) 29 | hub_connection.on_close( 30 | lambda: print( 31 | "connection closed>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<")) 32 | hub_connection.on_error(lambda err: print(f"error {err}")) 33 | 34 | hub_connection.on("ThrowExceptionCall", lambda x: print(f">>>{x}")) 35 | hub_connection.start() 36 | message = None 37 | 38 | # Do login 39 | 40 | while message != "exit()": 41 | message = input(">> ") 42 | if message is not None and message != "" and message != "exit()": 43 | hub_connection.send("ThrowException", [message]) 44 | 45 | hub_connection.stop() 46 | 47 | sys.exit(0) 48 | -------------------------------------------------------------------------------- /test/examples/azure.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import requests 4 | from signalrcore.hub_connection_builder import HubConnectionBuilder 5 | 6 | 7 | def input_with_default(input_text, default_value): 8 | value = input(input_text.format(default_value)) 9 | return default_value if value is None or value.strip() == "" else value 10 | 11 | 12 | server_url = input_with_default( 13 | 'Enter your server url(default: {0}): ', 14 | "localhost:7071/api") 15 | username = input_with_default( 16 | 'Enter your username (default: {0}): ', "mandrewcito") 17 | handler = logging.StreamHandler() 18 | handler.setLevel(logging.DEBUG) 19 | formatter = logging.Formatter( 20 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s') 21 | handler.setFormatter(formatter) 22 | hub_connection = HubConnectionBuilder() \ 23 | .with_url("ws://"+server_url, options={ 24 | "verify_ssl": False, 25 | "skip_negotiation": False, 26 | "headers": { 27 | } 28 | }) \ 29 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 30 | .build() 31 | 32 | hub_connection.on_open(lambda: print( 33 | "connection opened and handshake received ready to send messages")) 34 | hub_connection.on_close(lambda: print("connection closed")) 35 | 36 | hub_connection.on("newMessage", print) 37 | hub_connection.start() 38 | message = None 39 | 40 | # Do login 41 | 42 | while message != "exit()": 43 | message = input(">> ") 44 | if message is not None and message != "" and message != "exit()": 45 | # hub_connection.send("sendMessage", [username, message]) 46 | requests.post( 47 | "http://localhost:7071/api/messages", 48 | json={"sender": username, "text": message}) 49 | 50 | hub_connection.stop() 51 | 52 | sys.exit(0) 53 | -------------------------------------------------------------------------------- /signalrcore/protocol/json_hub_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .base_hub_protocol import BaseHubProtocol 4 | 5 | from ..messages.message_type import MessageType 6 | from json import JSONEncoder 7 | 8 | from signalrcore.helpers import Helpers 9 | 10 | 11 | class MyEncoder(JSONEncoder): 12 | # https://github.com/PyCQA/pylint/issues/414 13 | def default(self, o): 14 | if type(o) is MessageType: 15 | return o.value 16 | data = o.__dict__ 17 | if "invocation_id" in data: 18 | data["invocationId"] = data["invocation_id"] 19 | del data["invocation_id"] 20 | if "stream_ids" in data: 21 | data["streamIds"] = data["stream_ids"] 22 | del data["stream_ids"] 23 | return data 24 | 25 | 26 | class JsonHubProtocol(BaseHubProtocol): 27 | def __init__(self): 28 | super(JsonHubProtocol, self).__init__("json", 1, "Text", chr(0x1E)) 29 | self.encoder = MyEncoder() 30 | 31 | def parse_messages(self, raw): 32 | Helpers.get_logger().debug("Raw message incomming: ") 33 | Helpers.get_logger().debug(raw) 34 | raw_messages = [ 35 | record.replace(self.record_separator, "") 36 | for record in raw.split(self.record_separator) 37 | if record is not None and record != "" 38 | and record != self.record_separator 39 | ] 40 | result = [] 41 | for raw_message in raw_messages: 42 | dict_message = json.loads(raw_message) 43 | if len(dict_message.keys()) > 0: 44 | result.append(self.get_message(dict_message)) 45 | return result 46 | 47 | def encode(self, message): 48 | Helpers.get_logger()\ 49 | .debug(self.encoder.encode(message) + self.record_separator) 50 | return self.encoder.encode(message) + self.record_separator 51 | -------------------------------------------------------------------------------- /test/examples/chat_msgpack.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | sys.path.append("./") 4 | from signalrcore.hub_connection_builder\ 5 | import HubConnectionBuilder # noqa: E402 6 | from signalrcore.protocol.messagepack_protocol\ 7 | import MessagePackHubProtocol # noqa: E402 8 | 9 | 10 | def input_with_default(input_text, default_value): 11 | value = input(input_text.format(default_value)) 12 | return default_value if value is None or value.strip() == "" else value 13 | 14 | 15 | server_url = input_with_default( 16 | 'Enter your server url(default: {0}): ', "wss://localhost:5001/chatHub") 17 | username = input_with_default( 18 | 'Enter your username (default: {0}): ', "mandrewcito") 19 | handler = logging.StreamHandler() 20 | handler.setLevel(logging.DEBUG) 21 | hub_connection = HubConnectionBuilder()\ 22 | .with_url(server_url, options={"verify_ssl": False}) \ 23 | .configure_logging(logging.ERROR, socket_trace=False, handler=handler) \ 24 | .with_automatic_reconnect({ 25 | "type": "interval", 26 | "keep_alive_interval": 10, 27 | "intervals": [1, 3, 5, 6, 7, 87, 3] 28 | })\ 29 | .with_hub_protocol(MessagePackHubProtocol())\ 30 | .build() 31 | 32 | hub_connection.on_open( 33 | lambda: print( 34 | "connection opened and handshake received ready to send messages")) 35 | hub_connection.on_close(lambda: print("connection closed")) 36 | 37 | hub_connection.on("ReceiveMessage", print) 38 | hub_connection.start() 39 | message = None 40 | 41 | # Do login 42 | 43 | while message != "exit()": 44 | message = input(">> ") 45 | if message is not None and message != "" and message != "exit()": 46 | hub_connection.send( 47 | "SendMessage", 48 | [username, message], 49 | lambda args: print(args, "<-------------------")) 50 | 51 | hub_connection.stop() 52 | 53 | sys.exit(0) 54 | -------------------------------------------------------------------------------- /test/open_close_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import uuid 4 | 5 | from signalrcore.hub_connection_builder import HubConnectionBuilder 6 | from test.base_test_case import BaseTestCase 7 | 8 | LOCKS = {} 9 | 10 | 11 | class TestClientStreamMethod(BaseTestCase): 12 | def setUp(self): 13 | pass 14 | 15 | def tearDown(self): 16 | pass 17 | 18 | def test_start(self): 19 | connection = HubConnectionBuilder()\ 20 | .with_url(self.server_url, options={"verify_ssl": False})\ 21 | .configure_logging(logging.ERROR)\ 22 | .build() 23 | 24 | identifier = str(uuid.uuid4()) 25 | LOCKS[identifier] = threading.Lock() 26 | 27 | def release(): 28 | LOCKS[identifier].release() 29 | 30 | self.assertTrue(LOCKS[identifier].acquire(timeout=30)) 31 | 32 | connection.on_open(release) 33 | connection.on_close(release) 34 | 35 | result = connection.start() 36 | 37 | self.assertTrue(result) 38 | 39 | self.assertTrue(LOCKS[identifier].acquire(timeout=30)) 40 | # Released on open 41 | 42 | result = connection.start() 43 | 44 | self.assertFalse(result) 45 | 46 | connection.stop() 47 | 48 | del LOCKS[identifier] 49 | 50 | def test_open_close(self): 51 | self.connection = self.get_connection() 52 | 53 | identifier = str(uuid.uuid4()) 54 | LOCKS[identifier] = threading.Lock() 55 | 56 | def release(): 57 | LOCKS[identifier].release() 58 | 59 | self.connection.on_open(release) 60 | self.connection.on_close(release) 61 | 62 | self.assertTrue(LOCKS[identifier].acquire()) 63 | 64 | self.connection.start() 65 | 66 | self.assertTrue(LOCKS[identifier].acquire()) 67 | 68 | self.connection.stop() 69 | 70 | self.assertTrue(LOCKS[identifier].acquire()) 71 | 72 | release() 73 | del LOCKS[identifier] 74 | -------------------------------------------------------------------------------- /test/send_auth_errors_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from signalrcore.hub_connection_builder import HubConnectionBuilder 4 | from signalrcore.protocol.messagepack_protocol import MessagePackHubProtocol 5 | from test.base_test_case import BaseTestCase, Urls 6 | 7 | 8 | class TestSendAuthErrorMethod(BaseTestCase): 9 | server_url = Urls.server_url_ssl_auth 10 | login_url = Urls.login_url_ssl 11 | email = "test" 12 | password = "ff" 13 | received = False 14 | message = None 15 | 16 | def login(self): 17 | response = requests.post( 18 | self.login_url, 19 | json={ 20 | "username": self.email, 21 | "password": self.password 22 | }, 23 | verify=False) 24 | if response.status_code == 200: # pragma: no cover 25 | return response.json()["token"] 26 | raise requests.exceptions.ConnectionError() 27 | 28 | def setUp(self): 29 | pass 30 | 31 | def test_send_json(self): 32 | self._test_send(msgpack=False) 33 | 34 | def test_send_msgpack(self): 35 | self._test_send(msgpack=True) 36 | 37 | def _test_send(self, msgpack=False): 38 | builder = HubConnectionBuilder()\ 39 | .with_url( 40 | self.server_url, 41 | options={ 42 | "verify_ssl": False, 43 | "access_token_factory": self.login, 44 | }) 45 | 46 | if msgpack: 47 | builder.with_hub_protocol(MessagePackHubProtocol()) 48 | 49 | builder.configure_logging(logging.ERROR) 50 | self.connection = builder.build() 51 | self.connection.on_open(self.on_open) 52 | self.connection.on_close(self.on_close) 53 | self.assertRaises( 54 | requests.exceptions.ConnectionError, 55 | lambda: self.connection.start()) 56 | 57 | 58 | class TestSendNoSslAuthMethod(TestSendAuthErrorMethod): 59 | server_url = Urls.server_url_no_ssl_auth 60 | login_url = Urls.login_url_no_ssl 61 | -------------------------------------------------------------------------------- /test/base_test_case.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | import time 4 | from signalrcore.hub_connection_builder import HubConnectionBuilder 5 | from signalrcore.protocol.messagepack_protocol import MessagePackHubProtocol 6 | 7 | 8 | class Urls: 9 | server_url_no_ssl = "ws://localhost:5000/chatHub" 10 | server_url_ssl = "wss://localhost:5001/chatHub" 11 | server_url_no_ssl_auth = "ws://localhost:5000/authHub" 12 | server_url_ssl_auth = "wss://localhost:5001/authHub" 13 | login_url_ssl = "https://localhost:5001/users/authenticate" 14 | login_url_no_ssl = "http://localhost:5000/users/authenticate" 15 | 16 | 17 | class InternalTestCase(unittest.TestCase): 18 | connection = None 19 | connected = False 20 | 21 | def get_connection(self): 22 | raise NotImplementedError() 23 | 24 | def setUp(self): 25 | self.connection = self.get_connection() 26 | self.connection.start() 27 | t0 = time.time() 28 | while not self.connected: 29 | time.sleep(0.1) 30 | if time.time() - t0 > 20: 31 | raise ValueError("TIMEOUT ") 32 | 33 | def tearDown(self): 34 | self.connection.stop() 35 | 36 | def on_open(self): 37 | self.connected = True 38 | 39 | def on_close(self): 40 | self.connected = False 41 | 42 | 43 | class BaseTestCase(InternalTestCase): 44 | server_url = Urls.server_url_ssl 45 | 46 | def get_connection(self, msgpack=False): 47 | builder = HubConnectionBuilder()\ 48 | .with_url(self.server_url, options={"verify_ssl": False})\ 49 | .configure_logging(logging.ERROR)\ 50 | .with_automatic_reconnect({ 51 | "type": "raw", 52 | "keep_alive_interval": 10, 53 | "reconnect_interval": 5, 54 | "max_attempts": 5 55 | }) 56 | 57 | if msgpack: 58 | builder.with_hub_protocol(MessagePackHubProtocol()) 59 | 60 | hub = builder.build() 61 | hub.on_open(self.on_open) 62 | hub.on_close(self.on_close) 63 | return hub 64 | -------------------------------------------------------------------------------- /signalrcore/hub/handlers.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from ..helpers import Helpers 3 | 4 | 5 | class StreamHandler(object): 6 | def __init__(self, event: str, invocation_id: str): 7 | self.event = event 8 | self.invocation_id = invocation_id 9 | self.logger = Helpers.get_logger() 10 | self.next_callback =\ 11 | lambda _: self.logger.warning( 12 | "next stream handler fired, no callback configured") 13 | self.complete_callback =\ 14 | lambda _: self.logger.warning( 15 | "next complete handler fired, no callback configured") 16 | self.error_callback =\ 17 | lambda _: self.logger.warning( 18 | "next error handler fired, no callback configured") 19 | 20 | def subscribe(self, subscribe_callbacks: dict): 21 | error =\ 22 | " subscribe object must be a dict like {0}"\ 23 | .format({ 24 | "next": None, 25 | "complete": None, 26 | "error": None 27 | }) 28 | 29 | if subscribe_callbacks is None or\ 30 | type(subscribe_callbacks) is not dict: 31 | raise TypeError(error) 32 | 33 | if "next" not in subscribe_callbacks or\ 34 | "complete" not in subscribe_callbacks \ 35 | or "error" not in subscribe_callbacks: 36 | raise KeyError(error) 37 | 38 | if not callable(subscribe_callbacks["next"])\ 39 | or not callable(subscribe_callbacks["next"]) \ 40 | or not callable(subscribe_callbacks["next"]): 41 | raise ValueError("Suscribe callbacks must be functions") 42 | 43 | self.next_callback = subscribe_callbacks["next"] 44 | self.complete_callback = subscribe_callbacks["complete"] 45 | self.error_callback = subscribe_callbacks["error"] 46 | 47 | 48 | class InvocationHandler(object): 49 | def __init__(self, invocation_id: str, complete_callback: Callable): 50 | self.invocation_id = invocation_id 51 | self.complete_callback = complete_callback 52 | -------------------------------------------------------------------------------- /test/streaming_test.py: -------------------------------------------------------------------------------- 1 | import time 2 | from test.base_test_case import BaseTestCase, Urls 3 | 4 | 5 | class TestSendMethod(BaseTestCase): 6 | server_url = Urls.server_url_ssl 7 | received = False 8 | items = list(range(0, 10)) 9 | 10 | def on_complete(self, x): 11 | self.complete = True 12 | 13 | def on_next(self, x): 14 | item = self.items[0] 15 | self.items = self.items[1:] 16 | self.assertEqual(x, item) 17 | 18 | def test_stream(self): 19 | self.complete = False 20 | self.items = list(range(0, 10)) 21 | self.connection.stream( 22 | "Counter", 23 | [len(self.items), 500]).subscribe({ 24 | "next": self.on_next, 25 | "complete": self.on_complete, 26 | "error": self.fail # TestcaseFail 27 | }) 28 | while not self.complete: 29 | time.sleep(0.1) 30 | 31 | def test_stream_error(self): 32 | self.complete = False 33 | self.items = list(range(0, 10)) 34 | 35 | my_stream = self.connection.stream( 36 | "Counter", 37 | [len(self.items), 500]) 38 | 39 | self.assertRaises(TypeError, lambda: my_stream.subscribe(None)) 40 | 41 | self.assertRaises( 42 | TypeError, 43 | lambda: my_stream.subscribe([self.on_next])) 44 | 45 | self.assertRaises(KeyError, lambda: my_stream.subscribe({ 46 | "key": self.on_next 47 | })) 48 | 49 | self.assertRaises(ValueError, lambda: my_stream.subscribe({ 50 | "next": "", 51 | "complete": 1, 52 | "error": [] # TestcaseFail 53 | })) 54 | 55 | 56 | class TestSendNoSslMethod(TestSendMethod): 57 | server_url = Urls.server_url_no_ssl 58 | 59 | 60 | class TestSendMethodMsgPack(TestSendMethod): 61 | def get_connection(self): 62 | return super().get_connection(msgpack=True) 63 | 64 | 65 | class TestSendMethodNoSslMsgPack(TestSendNoSslMethod): 66 | def get_connection(self): 67 | return super().get_connection(msgpack=True) 68 | -------------------------------------------------------------------------------- /signalrcore/messages/completion_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | A `Completion` message is a JSON object with the following properties 4 | 5 | * `type` - A `Number` with the literal value `3`, 6 | indicating that this message is a `Completion`. 7 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 8 | * `result` - A `Token` encoding the result value 9 | (see "JSON Payload Encoding" for details). 10 | This field is **ignored** if `error` is present. 11 | * `error` - A `String` encoding the error message. 12 | 13 | It is a protocol error to include both a `result` and an `error` property 14 | in the `Completion` message. A conforming endpoint may immediately 15 | terminate the connection upon receiving such a message. 16 | 17 | Example - A `Completion` message with no result or error 18 | 19 | ```json 20 | { 21 | "type": 3, 22 | "invocationId": "123" 23 | } 24 | ``` 25 | 26 | Example - A `Completion` message with a result 27 | 28 | ```json 29 | { 30 | "type": 3, 31 | "invocationId": "123", 32 | "result": 42 33 | } 34 | ``` 35 | 36 | Example - A `Completion` message with an error 37 | 38 | ```json 39 | { 40 | "type": 3, 41 | "invocationId": "123", 42 | "error": "It didn't work!" 43 | } 44 | ``` 45 | 46 | Example - The following `Completion` message is a protocol error 47 | because it has both of `result` and `error` 48 | 49 | ```json 50 | { 51 | "type": 3, 52 | "invocationId": "123", 53 | "result": 42, 54 | "error": "It didn't work!" 55 | } 56 | ``` 57 | """ 58 | 59 | 60 | class CompletionClientStreamMessage(BaseHeadersMessage): 61 | def __init__( 62 | self, invocation_id, **kwargs): 63 | super(CompletionClientStreamMessage, self).__init__(3, **kwargs) 64 | self.invocation_id = invocation_id 65 | 66 | 67 | class CompletionMessage(BaseHeadersMessage): 68 | def __init__( 69 | self, 70 | invocation_id, 71 | result, 72 | error, 73 | **kwargs): 74 | super(CompletionMessage, self).__init__(3, **kwargs) 75 | self.invocation_id = invocation_id 76 | self.result = result 77 | self.error = error 78 | -------------------------------------------------------------------------------- /test/examples/chat_auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import sys 3 | import logging 4 | sys.path.append("./") 5 | from signalrcore.hub_connection_builder import HubConnectionBuilder # noqa E402 6 | 7 | 8 | def input_with_default(input_text, default_value): 9 | value = input(input_text.format(default_value)) 10 | return default_value if value is None or value.strip() == "" else value 11 | 12 | 13 | def signalr_core_example_login(url, user, username_password): 14 | response = requests.post( 15 | url, 16 | json={"username": user, "password": username_password}, 17 | verify=False) 18 | return response.json()["token"] 19 | 20 | 21 | login_url = input_with_default( 22 | 'Enter your server login url({0}):', 23 | "https://localhost:5001/users/authenticate") 24 | server_url = input_with_default( 25 | 'Enter your server url(default: {0}): ', "wss://localhost:5001/authHub") 26 | username = input_with_default('Enter your username (default: {0}): ', "test") 27 | password = input_with_default('Enter your password (default: {0}): ', "test") 28 | 29 | hub_connection = HubConnectionBuilder()\ 30 | .configure_logging(logging_level=logging.DEBUG)\ 31 | .with_url(server_url, options={ 32 | "access_token_factory": lambda: signalr_core_example_login( 33 | login_url, username, password), 34 | "verify_ssl": False 35 | }).with_automatic_reconnect({ 36 | "type": "interval", 37 | "keep_alive_interval": 10, 38 | "intervals": [1, 3, 5, 6, 7, 87, 3] 39 | })\ 40 | .build() 41 | 42 | hub_connection.on_open( 43 | lambda: print( 44 | "connection opened and handshake received ready to send messages")) 45 | hub_connection.on_close(lambda: print("connection closed")) 46 | 47 | hub_connection.on("ReceiveSystemMessage", print) 48 | hub_connection.on("ReceiveChatMessage", print) 49 | hub_connection.on("ReceiveDirectMessage", print) 50 | 51 | hub_connection.start() 52 | message = None 53 | while message != "exit()": 54 | message = input(">> ") 55 | if message is not None and message != "" and message != "exit()": 56 | hub_connection.send("Send", [message]) 57 | hub_connection.stop() 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | /.vs/slnx.sqlite-journal 118 | /.vs/slnx.sqlite 119 | .vscode/settings.json 120 | .vscode/launch.json 121 | # Containers code 122 | ./test/containers/code 123 | #coverga 124 | coverage_html 125 | #venvwin 126 | venvwin -------------------------------------------------------------------------------- /signalrcore/messages/invocation_message.py: -------------------------------------------------------------------------------- 1 | from .base_message import BaseHeadersMessage 2 | """ 3 | 4 | An `Invocation` message is a JSON object with the following properties: 5 | 6 | * `type` - A `Number` with the literal value 1, indicating that this message 7 | is an Invocation. 8 | * `invocationId` - An optional `String` encoding the `Invocation ID` 9 | for a message. 10 | * `target` - A `String` encoding the `Target` name, as expected by the Callee's 11 | Binder 12 | * `arguments` - An `Array` containing arguments to apply to the method 13 | referred to in Target. This is a sequence of JSON `Token`s, 14 | encoded as indicated below in the "JSON Payload Encoding" section 15 | 16 | Example: 17 | 18 | ```json 19 | { 20 | "type": 1, 21 | "invocationId": "123", 22 | "target": "Send", 23 | "arguments": [ 24 | 42, 25 | "Test Message" 26 | ] 27 | } 28 | ``` 29 | Example (Non-Blocking): 30 | 31 | ```json 32 | { 33 | "type": 1, 34 | "target": "Send", 35 | "arguments": [ 36 | 42, 37 | "Test Message" 38 | ] 39 | } 40 | ``` 41 | 42 | """ 43 | 44 | 45 | class InvocationMessage(BaseHeadersMessage): 46 | def __init__( 47 | self, 48 | invocation_id, 49 | target, 50 | arguments, **kwargs): 51 | super(InvocationMessage, self).__init__(1, **kwargs) 52 | self.invocation_id = invocation_id 53 | self.target = target 54 | self.arguments = arguments 55 | 56 | def __repr__(self): 57 | repr_str =\ 58 | "InvocationMessage: invocation_id {0}, target {1}, arguments {2}" 59 | return repr_str.format(self.invocation_id, self.target, self.arguments) 60 | 61 | 62 | class InvocationClientStreamMessage(BaseHeadersMessage): 63 | def __init__( 64 | self, 65 | stream_ids, 66 | target, 67 | arguments, 68 | **kwargs): 69 | super(InvocationClientStreamMessage, self).__init__(1, **kwargs) 70 | self.target = target 71 | self.arguments = arguments 72 | self.stream_ids = stream_ids 73 | 74 | def __repr__(self): 75 | repr_str =\ 76 | "InvocationMessage: stream_ids {0}, target {1}, arguments {2}" 77 | return repr_str.format( 78 | self.stream_ids, self.target, self.arguments) 79 | -------------------------------------------------------------------------------- /signalrcore/subject.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import threading 3 | from typing import Any 4 | from .messages.invocation_message import InvocationClientStreamMessage 5 | from .messages.stream_item_message import StreamItemMessage 6 | from .messages.completion_message import CompletionClientStreamMessage 7 | 8 | 9 | class Subject(object): 10 | """Client to server streaming 11 | https://docs.microsoft.com/en-gb/aspnet/core/signalr/streaming?view=aspnetcore-5.0#client-to-server-streaming 12 | items = list(range(0,10)) 13 | subject = Subject() 14 | connection.send("UploadStream", subject) 15 | while(len(self.items) > 0): 16 | subject.next(str(self.items.pop())) 17 | subject.complete() 18 | """ 19 | 20 | def __init__(self): 21 | self.connection = None 22 | self.target = None 23 | self.invocation_id = str(uuid.uuid4()) 24 | self.lock = threading.RLock() 25 | 26 | def check(self): 27 | """Ensures that invocation streaming object is correct 28 | 29 | Raises: 30 | ValueError: if object is not valid, exception will be raised 31 | """ 32 | if self.connection is None\ 33 | or self.target is None\ 34 | or self.invocation_id is None: 35 | raise ValueError( 36 | "subject must be passed as an agument to a send function. " 37 | + "hub_connection.send([method],[subject]") 38 | 39 | def next(self, item: Any): 40 | """Send next item to the server 41 | 42 | Args: 43 | item (any): Item that will be streamed 44 | """ 45 | self.check() 46 | with self.lock: 47 | self.connection.transport.send(StreamItemMessage( 48 | self.invocation_id, 49 | item)) 50 | 51 | def start(self): 52 | """Starts streaming 53 | """ 54 | self.check() 55 | with self.lock: 56 | self.connection.transport.send( 57 | InvocationClientStreamMessage( 58 | [self.invocation_id], 59 | self.target, 60 | [])) 61 | 62 | def complete(self): 63 | """Finish streaming 64 | """ 65 | self.check() 66 | with self.lock: 67 | self.connection.transport.send(CompletionClientStreamMessage( 68 | self.invocation_id)) 69 | -------------------------------------------------------------------------------- /test/configuration_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from signalrcore.hub_connection_builder import HubConnectionBuilder 3 | 4 | from test.base_test_case import BaseTestCase 5 | 6 | 7 | class TestConfiguration(BaseTestCase): 8 | 9 | def test_bad_auth_function(self): 10 | with self.assertRaises(TypeError): 11 | self.connection = HubConnectionBuilder()\ 12 | .with_url( 13 | self.server_url, 14 | options={ 15 | "verify_ssl": False, 16 | "access_token_factory": 1234, 17 | "headers": { 18 | "mycustomheader": "mycustomheadervalue" 19 | } 20 | }) 21 | 22 | def test_bad_url(self): 23 | with self.assertRaises(ValueError): 24 | self.connection = HubConnectionBuilder()\ 25 | .with_url("") 26 | 27 | def test_bad_options(self): 28 | with self.assertRaises(TypeError): 29 | self.connection = HubConnectionBuilder()\ 30 | .with_url( 31 | self.server_url, 32 | options=["ssl", True]) 33 | 34 | def test_auth_configured(self): 35 | with self.assertRaises(TypeError): 36 | hub = HubConnectionBuilder()\ 37 | .with_url( 38 | self.server_url, 39 | options={ 40 | "verify_ssl": False, 41 | "headers": { 42 | "mycustomheader": "mycustomheadervalue" 43 | } 44 | }) 45 | hub.has_auth_configured = True 46 | hub.options["access_token_factory"] = "" 47 | _ = hub.build() 48 | 49 | def test_enable_trace(self): 50 | hub = HubConnectionBuilder()\ 51 | .with_url(self.server_url, options={"verify_ssl": False})\ 52 | .configure_logging(logging.WARNING, socket_trace=True)\ 53 | .with_automatic_reconnect({ 54 | "type": "raw", 55 | "keep_alive_interval": 10, 56 | "reconnect_interval": 5, 57 | "max_attempts": 5 58 | })\ 59 | .build() 60 | hub.on_open(self.on_open) 61 | hub.on_close(self.on_close) 62 | hub.start() 63 | self.assertTrue(hub.transport.is_trace_enabled()) 64 | hub.stop() 65 | -------------------------------------------------------------------------------- /signalrcore/transport/websockets/reconnection.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from enum import Enum 4 | 5 | 6 | class ConnectionStateChecker(object): 7 | def __init__( 8 | self, 9 | ping_function, 10 | keep_alive_interval, 11 | sleep=1): 12 | self.sleep = sleep 13 | self.keep_alive_interval = keep_alive_interval 14 | self.last_message = time.time() 15 | self.ping_function = ping_function 16 | self.running = False 17 | self._thread = None 18 | 19 | def start(self): 20 | self.running = True 21 | self._thread = threading.Thread(target=self.run) 22 | self._thread.daemon = True 23 | self._thread.start() 24 | 25 | def run(self): 26 | while self.running: 27 | time.sleep(self.sleep) 28 | time_without_messages = time.time() - self.last_message 29 | if self.keep_alive_interval < time_without_messages: 30 | self.ping_function() 31 | 32 | def stop(self): 33 | self.running = False 34 | 35 | 36 | class ReconnectionType(Enum): 37 | raw = 0 # Reconnection with max reconnections and constant sleep time 38 | interval = 1 # variable sleep time 39 | 40 | 41 | class ReconnectionHandler(object): 42 | def __init__(self): 43 | self.reconnecting = False 44 | self.attempt_number = 0 45 | self.last_attempt = time.time() 46 | 47 | def next(self): 48 | raise NotImplementedError() 49 | 50 | def reset(self): 51 | self.attempt_number = 0 52 | self.reconnecting = False 53 | 54 | 55 | class RawReconnectionHandler(ReconnectionHandler): 56 | def __init__(self, sleep_time, max_attempts): 57 | super(RawReconnectionHandler, self).__init__() 58 | self.sleep_time = sleep_time 59 | self.max_reconnection_attempts = max_attempts 60 | 61 | def next(self): 62 | self.reconnecting = True 63 | if self.max_reconnection_attempts is not None: 64 | if self.attempt_number <= self.max_reconnection_attempts: 65 | self.attempt_number += 1 66 | return self.sleep_time 67 | else: 68 | raise ValueError( 69 | "Max attemps reached {0}" 70 | .format(self.max_reconnection_attempts)) 71 | else: # Infinite reconnect 72 | return self.sleep_time 73 | 74 | 75 | class IntervalReconnectionHandler(ReconnectionHandler): 76 | def __init__(self, intervals): 77 | super(IntervalReconnectionHandler, self).__init__() 78 | self._intervals = intervals 79 | 80 | def next(self): 81 | self.reconnecting = True 82 | index = self.attempt_number 83 | self.attempt_number += 1 84 | if index >= len(self._intervals): 85 | raise ValueError( 86 | "Max intervals reached {0}".format(self._intervals)) 87 | return self._intervals[index] 88 | -------------------------------------------------------------------------------- /test/send_auth_test.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import logging 3 | import uuid 4 | import requests 5 | from signalrcore.hub_connection_builder import HubConnectionBuilder 6 | from signalrcore.protocol.messagepack_protocol import MessagePackHubProtocol 7 | from test.base_test_case import BaseTestCase, Urls 8 | 9 | 10 | class TestSendAuthMethod(BaseTestCase): 11 | server_url = Urls.server_url_ssl_auth 12 | login_url = Urls.login_url_ssl 13 | email = "test" 14 | password = "test" 15 | received = False 16 | message = None 17 | _lock = None 18 | 19 | def login(self): 20 | response = requests.post( 21 | self.login_url, 22 | json={ 23 | "username": self.email, 24 | "password": self.password 25 | }, 26 | verify=False) 27 | return response.json()["token"] 28 | 29 | def _setUp(self, msgpack=False): 30 | builder = HubConnectionBuilder()\ 31 | .with_url( 32 | self.server_url, 33 | options={ 34 | "verify_ssl": False, 35 | "access_token_factory": self.login, 36 | "headers": { 37 | "mycustomheader": "mycustomheadervalue" 38 | } 39 | }) 40 | 41 | if msgpack: 42 | builder.with_hub_protocol(MessagePackHubProtocol()) 43 | 44 | builder.configure_logging(logging.WARNING)\ 45 | .with_automatic_reconnect({ 46 | "type": "raw", 47 | "keep_alive_interval": 10, 48 | "reconnect_interval": 5, 49 | "max_attempts": 5 50 | }) 51 | self.connection = builder.build() 52 | self.connection.on("ReceiveMessage", self.receive_message) 53 | self.connection.on_open(self.on_open) 54 | self.connection.on_close(self.on_close) 55 | self._lock = threading.Lock() 56 | self.assertTrue(self._lock.acquire(timeout=30)) 57 | self.connection.start() 58 | 59 | def on_open(self): 60 | self._lock.release() 61 | 62 | def setUp(self): 63 | self._setUp() 64 | 65 | def receive_message(self, args): 66 | self._lock.release() 67 | self.assertEqual(args[0], self.message) 68 | 69 | def test_send(self): 70 | self.message = "new message {0}".format(uuid.uuid4()) 71 | self.username = "mandrewcito" 72 | self.assertTrue(self._lock.acquire(timeout=30)) 73 | self.connection.send("SendMessage", [self.message]) 74 | self.assertTrue(self._lock.acquire(timeout=30)) 75 | del self._lock 76 | 77 | 78 | class TestSendNoSslAuthMethod(TestSendAuthMethod): 79 | server_url = Urls.server_url_no_ssl_auth 80 | login_url = Urls.login_url_no_ssl 81 | 82 | 83 | class TestSendAuthMethodMsgPack(TestSendAuthMethod): 84 | def setUp(self): 85 | self._setUp(msgpack=True) 86 | 87 | 88 | class TestSendNoSslAuthMethodMsgPack(TestSendAuthMethod): 89 | server_url = Urls.server_url_no_ssl_auth 90 | login_url = Urls.login_url_no_ssl 91 | -------------------------------------------------------------------------------- /signalrcore/protocol/base_hub_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..messages.handshake.request import HandshakeRequestMessage 4 | from ..messages.handshake.response import HandshakeResponseMessage 5 | from ..messages.invocation_message import InvocationMessage # 1 6 | from ..messages.stream_item_message import StreamItemMessage # 2 7 | from ..messages.completion_message import CompletionMessage # 3 8 | from ..messages.stream_invocation_message import StreamInvocationMessage # 4 9 | from ..messages.cancel_invocation_message import CancelInvocationMessage # 5 10 | from ..messages.ping_message import PingMessage # 6 11 | from ..messages.close_message import CloseMessage # 7 12 | from ..messages.message_type import MessageType 13 | 14 | 15 | class BaseHubProtocol(object): 16 | def __init__(self, protocol, version, transfer_format, record_separator): 17 | self.protocol = protocol 18 | self.version = version 19 | self.transfer_format = transfer_format 20 | self.record_separator = record_separator 21 | 22 | @staticmethod 23 | def get_message(dict_message): 24 | message_type = MessageType.close\ 25 | if "type" not in dict_message.keys() else\ 26 | MessageType(dict_message["type"]) 27 | 28 | dict_message["invocation_id"] = dict_message.get("invocationId", None) 29 | dict_message["headers"] = dict_message.get("headers", {}) 30 | dict_message["error"] = dict_message.get("error", None) 31 | dict_message["result"] = dict_message.get("result", None) 32 | if message_type is MessageType.invocation: 33 | return InvocationMessage(**dict_message) 34 | if message_type is MessageType.stream_item: 35 | return StreamItemMessage(**dict_message) 36 | if message_type is MessageType.completion: 37 | return CompletionMessage(**dict_message) 38 | if message_type is MessageType.stream_invocation: 39 | return StreamInvocationMessage(**dict_message) 40 | if message_type is MessageType.cancel_invocation: 41 | return CancelInvocationMessage(**dict_message) 42 | if message_type is MessageType.ping: 43 | return PingMessage() 44 | if message_type is MessageType.close: 45 | return CloseMessage(**dict_message) 46 | 47 | def decode_handshake(self, raw_message: str) -> HandshakeResponseMessage: 48 | messages = raw_message.split(self.record_separator) 49 | messages = list(filter(lambda x: x != "", messages)) 50 | data = json.loads(messages[0]) 51 | idx = raw_message.index(self.record_separator) 52 | return ( 53 | HandshakeResponseMessage(data.get("error", None)), 54 | self.parse_messages(raw_message[idx + 1:]) 55 | if len(messages) > 1 else []) 56 | 57 | def handshake_message(self) -> HandshakeRequestMessage: 58 | return HandshakeRequestMessage(self.protocol, self.version) 59 | 60 | def parse_messages(self, raw_message: str): 61 | raise ValueError("Protocol must implement this method") 62 | 63 | def write_message(self, hub_message): 64 | raise ValueError("Protocol must implement this method") 65 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at anbaalo@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /test/reconnection_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import uuid 4 | import threading 5 | 6 | from signalrcore.hub_connection_builder import HubConnectionBuilder 7 | from signalrcore.hub.errors import HubConnectionError 8 | from test.base_test_case import BaseTestCase 9 | from signalrcore.transport.websockets.reconnection\ 10 | import RawReconnectionHandler, IntervalReconnectionHandler 11 | 12 | 13 | LOCKS = {} 14 | 15 | 16 | class TestReconnectMethods(BaseTestCase): 17 | 18 | def test_reconnect_interval_config(self): 19 | connection = HubConnectionBuilder()\ 20 | .with_url(self.server_url, options={"verify_ssl": False})\ 21 | .configure_logging(logging.ERROR)\ 22 | .with_automatic_reconnect({ 23 | "type": "interval", 24 | "intervals": [1, 2, 4, 45, 6, 7, 8, 9, 10] 25 | })\ 26 | .build() 27 | 28 | identifier = str(uuid.uuid4()) 29 | LOCKS[identifier] = threading.Lock() 30 | 31 | def release(): 32 | LOCKS[identifier].release() 33 | 34 | connection.on_open(release) 35 | connection.on_close(release) 36 | 37 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 38 | 39 | connection.start() 40 | 41 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 42 | 43 | connection.stop() 44 | 45 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 46 | 47 | del LOCKS[identifier] 48 | 49 | def test_reconnect_interval(self): 50 | connection = HubConnectionBuilder()\ 51 | .with_url(self.server_url, options={"verify_ssl": False})\ 52 | .configure_logging(logging.ERROR)\ 53 | .with_automatic_reconnect({ 54 | "type": "interval", 55 | "intervals": [1, 2, 4, 45, 6, 7, 8, 9, 10], 56 | "keep_alive_interval": 3 57 | })\ 58 | .build() 59 | self.reconnect_test(connection) 60 | 61 | def test_no_reconnect(self): 62 | connection = HubConnectionBuilder()\ 63 | .with_url(self.server_url, options={"verify_ssl": False})\ 64 | .configure_logging(logging.ERROR)\ 65 | .build() 66 | 67 | identifier = str(uuid.uuid4()) 68 | LOCKS[identifier] = threading.Lock() 69 | 70 | def release(msg=None): 71 | LOCKS[identifier].release() 72 | 73 | LOCKS[identifier].acquire(timeout=10) 74 | 75 | connection.on_open(release) 76 | 77 | connection.on("ReceiveMessage", release) 78 | 79 | connection.start() 80 | 81 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 82 | # Released on ReOpen 83 | 84 | connection.send("DisconnectMe", []) 85 | 86 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 87 | 88 | time.sleep(10) 89 | 90 | self.assertRaises( 91 | HubConnectionError, 92 | lambda: connection.send("DisconnectMe", [])) 93 | 94 | connection.stop() 95 | del LOCKS[identifier] 96 | 97 | def reconnect_test(self, connection): 98 | identifier = str(uuid.uuid4()) 99 | LOCKS[identifier] = threading.Lock() 100 | 101 | def release(): 102 | LOCKS[identifier].release() 103 | 104 | connection.on_open(release) 105 | 106 | connection.start() 107 | 108 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 109 | # Release on Open 110 | 111 | connection.send("DisconnectMe", []) 112 | 113 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 114 | # released on open 115 | 116 | connection.stop() 117 | del LOCKS[identifier] 118 | 119 | def test_raw_reconnection(self): 120 | connection = HubConnectionBuilder()\ 121 | .with_url(self.server_url, options={"verify_ssl": False})\ 122 | .configure_logging(logging.ERROR)\ 123 | .with_automatic_reconnect({ 124 | "type": "raw", 125 | "keep_alive_interval": 10, 126 | "max_attempts": 4 127 | })\ 128 | .build() 129 | 130 | self.reconnect_test(connection) 131 | 132 | def test_raw_handler(self): 133 | handler = RawReconnectionHandler(5, 10) 134 | attempt = 0 135 | 136 | while attempt <= 10: 137 | self.assertEqual(handler.next(), 5) 138 | attempt = attempt + 1 139 | 140 | self.assertRaises(ValueError, handler.next) 141 | 142 | def test_interval_handler(self): 143 | intervals = [1, 2, 4, 5, 6] 144 | handler = IntervalReconnectionHandler(intervals) 145 | for interval in intervals: 146 | self.assertEqual(handler.next(), interval) 147 | self.assertRaises(ValueError, handler.next) 148 | 149 | def tearDown(self): 150 | pass 151 | 152 | def setUp(self): 153 | pass 154 | -------------------------------------------------------------------------------- /signalrcore/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse as parse 3 | import urllib 4 | import urllib.request 5 | import ssl 6 | from typing import Tuple 7 | import json 8 | 9 | 10 | class RequestHelpers: 11 | @staticmethod 12 | def post( 13 | url: str, 14 | headers: dict = {}, 15 | proxies: dict = {}, 16 | verify_ssl: bool = False) -> Tuple[int, dict]: 17 | return RequestHelpers.request( 18 | url, 19 | "POST", 20 | headers=headers, 21 | proxies=proxies, 22 | verify_ssl=verify_ssl 23 | ) 24 | 25 | @staticmethod 26 | def request( 27 | url: str, 28 | method: str, 29 | headers: dict = {}, 30 | proxies: dict = {}, 31 | verify_ssl: bool = False) -> Tuple[int, dict]: 32 | context = ssl.create_default_context() 33 | if not verify_ssl: 34 | context.check_hostname = False 35 | context.verify_mode = ssl.CERT_NONE 36 | headers.update({'Content-Type': 'application/json'}) 37 | proxy_handler = None 38 | 39 | if len(proxies.keys()) > 0: 40 | proxy_handler = urllib.request.ProxyHandler(proxies) 41 | 42 | req = urllib.request.Request( 43 | url, 44 | method=method, 45 | headers=headers) 46 | 47 | opener = urllib.request.build_opener(proxy_handler)\ 48 | if proxy_handler is not None else\ 49 | urllib.request.urlopen 50 | 51 | with opener( 52 | req, 53 | context=context) as response: 54 | status_code = response.getcode() 55 | response_body = response.read().decode('utf-8') 56 | 57 | try: 58 | json_data = json.loads(response_body) 59 | except json.JSONDecodeError: 60 | json_data = None 61 | 62 | return status_code, json_data 63 | 64 | 65 | class Helpers: 66 | 67 | @staticmethod 68 | def configure_logger(level=logging.INFO, handler=None): 69 | logger = Helpers.get_logger() 70 | if handler is None: 71 | handler = logging.StreamHandler() 72 | handler.setFormatter( 73 | logging.Formatter( 74 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 75 | handler.setLevel(level) 76 | logger.addHandler(handler) 77 | logger.setLevel(level) 78 | 79 | @staticmethod 80 | def get_logger(): 81 | return logging.getLogger("SignalRCoreClient") 82 | 83 | @staticmethod 84 | def has_querystring(url): 85 | return "?" in url 86 | 87 | @staticmethod 88 | def split_querystring(url): 89 | parts = url.split("?") 90 | return parts[0], parts[1] 91 | 92 | @staticmethod 93 | def replace_scheme( 94 | url, 95 | root_scheme, 96 | source, 97 | secure_source, 98 | destination, 99 | secure_destination): 100 | url_parts = parse.urlsplit(url) 101 | 102 | if root_scheme not in url_parts.scheme: 103 | if url_parts.scheme == secure_source: 104 | url_parts = url_parts._replace(scheme=secure_destination) 105 | if url_parts.scheme == source: 106 | url_parts = url_parts._replace(scheme=destination) 107 | 108 | return parse.urlunsplit(url_parts) 109 | 110 | @staticmethod 111 | def websocket_to_http(url): 112 | return Helpers.replace_scheme( 113 | url, 114 | "http", 115 | "ws", 116 | "wss", 117 | "http", 118 | "https") 119 | 120 | @staticmethod 121 | def http_to_websocket(url): 122 | return Helpers.replace_scheme( 123 | url, 124 | "ws", 125 | "http", 126 | "https", 127 | "ws", 128 | "wss" 129 | ) 130 | 131 | @staticmethod 132 | def get_negotiate_url(url): 133 | querystring = "" 134 | if Helpers.has_querystring(url): 135 | url, querystring = Helpers.split_querystring(url) 136 | 137 | url_parts = parse.urlsplit(Helpers.websocket_to_http(url)) 138 | 139 | negotiate_suffix = "negotiate"\ 140 | if url_parts.path.endswith('/')\ 141 | else "/negotiate" 142 | 143 | url_parts = url_parts._replace(path=url_parts.path + negotiate_suffix) 144 | 145 | return parse.urlunsplit(url_parts) \ 146 | if querystring == "" else\ 147 | parse.urlunsplit(url_parts) + "?" + querystring 148 | 149 | @staticmethod 150 | def encode_connection_id(url, id): 151 | url_parts = parse.urlsplit(url) 152 | query_string_parts = parse.parse_qs(url_parts.query) 153 | query_string_parts["id"] = id 154 | 155 | url_parts = url_parts._replace( 156 | query=parse.urlencode( 157 | query_string_parts, 158 | doseq=True)) 159 | 160 | return Helpers.http_to_websocket(parse.urlunsplit(url_parts)) 161 | -------------------------------------------------------------------------------- /test/send_test.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import threading 4 | 5 | from signalrcore.hub.errors import HubConnectionError 6 | from test.base_test_case import BaseTestCase, Urls 7 | 8 | LOCKS = {} 9 | 10 | 11 | class TestSendException(BaseTestCase): 12 | def receive_message(self, _): 13 | raise Exception() 14 | 15 | def setUp(self): 16 | self.connection = self.get_connection() 17 | self.connection.start() 18 | while not self.connected: 19 | time.sleep(0.1) 20 | 21 | def test_send_exception(self): 22 | identifier = str(uuid.uuid4()) 23 | LOCKS[identifier] = threading.Lock() 24 | LOCKS[identifier].acquire() 25 | self.connection.send("SendMessage", ["user", "msg"]) 26 | del LOCKS[identifier] 27 | 28 | def test_hub_error(self): 29 | identifier = str(uuid.uuid4()) 30 | LOCKS[identifier] = threading.Lock() 31 | 32 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 33 | 34 | def on_error(err=None): 35 | LOCKS[identifier].release() 36 | 37 | self.connection.on_error(on_error) 38 | 39 | def on_message(_): 40 | LOCKS[identifier].release() 41 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 42 | 43 | self.connection.on("ThrowExceptionCall", on_message) 44 | self.connection.send("ThrowException", ["msg"]) 45 | 46 | 47 | class TestSendExceptionMsgPack(TestSendException): 48 | def setUp(self): 49 | self.connection = self.get_connection(msgpack=True) 50 | self.connection.start() 51 | while not self.connected: 52 | time.sleep(0.1) 53 | 54 | 55 | class TestSendWarning(BaseTestCase): 56 | def setUp(self): 57 | self.connection = self.get_connection() 58 | self.connection.start() 59 | while not self.connected: 60 | time.sleep(0.1) 61 | 62 | def test_send_warning(self): 63 | identifier = str(uuid.uuid4()) 64 | LOCKS[identifier] = threading.Lock() 65 | LOCKS[identifier].acquire() 66 | 67 | def release(msg=None): 68 | global LOCKS 69 | LOCKS[identifier].release() 70 | 71 | self.connection.send("SendMessage", ["user", "msg"], release) 72 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 73 | del LOCKS[identifier] 74 | 75 | 76 | class TestSendWarningMsgPack(TestSendWarning): 77 | def setUp(self): 78 | self.connection = super().get_connection(msgpack=True) 79 | self.connection.start() 80 | while not self.connected: 81 | time.sleep(0.1) 82 | 83 | 84 | class TestSendMethod(BaseTestCase): 85 | received = False 86 | message = None 87 | 88 | def setUp(self): 89 | self.connection = self.get_connection() 90 | self.connection.on("ReceiveMessage", self.receive_message) 91 | self.connection.start() 92 | while not self.connected: 93 | time.sleep(0.1) 94 | 95 | def receive_message(self, args): 96 | self.assertEqual(args[1], self.message) 97 | self.received = True 98 | 99 | def test_send_bad_args(self): 100 | class A(): 101 | pass 102 | 103 | self.assertRaises( 104 | TypeError, 105 | lambda: self.connection.send("SendMessage", A())) 106 | 107 | def test_send(self): 108 | self.message = "new message {0}".format(uuid.uuid4()) 109 | self.username = "mandrewcito" 110 | self.received = False 111 | self.connection.send("SendMessage", [self.username, self.message]) 112 | while not self.received: 113 | time.sleep(0.1) 114 | self.assertTrue(self.received) 115 | 116 | def test_send_with_callback(self): 117 | self.message = "new message {0}".format(uuid.uuid4()) 118 | self.username = "mandrewcito" 119 | self.received = False 120 | identifier = str(uuid.uuid4()) 121 | LOCKS[identifier] = threading.Lock() 122 | 123 | LOCKS[identifier].acquire() 124 | uid = str(uuid.uuid4()) 125 | 126 | def release(m): 127 | global LOCKS 128 | self.assertTrue(m.invocation_id, uid) 129 | LOCKS[identifier].release() 130 | 131 | self.connection.send( 132 | "SendMessage", 133 | [self.username, self.message], 134 | release, 135 | invocation_id=uid) 136 | 137 | self.assertTrue(LOCKS[identifier].acquire(timeout=10)) 138 | del LOCKS[identifier] 139 | 140 | 141 | class TestSendNoSslMethod(TestSendMethod): 142 | server_url = Urls.server_url_no_ssl 143 | 144 | 145 | class TestSendMethodMsgPack(TestSendMethod): 146 | def setUp(self): 147 | self.connection = super().get_connection(msgpack=True) 148 | self.connection.on("ReceiveMessage", super().receive_message) 149 | self.connection.start() 150 | while not self.connected: 151 | time.sleep(0.1) 152 | 153 | 154 | class TestSendNoSslMethodMsgPack(TestSendMethodMsgPack): 155 | server_url = Urls.server_url_no_ssl 156 | 157 | 158 | class TestSendErrorMethod(BaseTestCase): 159 | received = False 160 | message = None 161 | 162 | def setUp(self): 163 | self.connection = self.get_connection() 164 | self.connection.on("ReceiveMessage", self.receive_message) 165 | 166 | def receive_message(self, args): 167 | self.assertEqual(args[1], self.message) 168 | self.received = True 169 | 170 | def test_send_with_error(self): 171 | self.message = "new message {0}".format(uuid.uuid4()) 172 | self.username = "mandrewcito" 173 | 174 | self.assertRaises( 175 | HubConnectionError, 176 | lambda: self.connection.send( 177 | "SendMessage", [self.username, self.message])) 178 | 179 | self.connection.start() 180 | while not self.connected: 181 | time.sleep(0.1) 182 | 183 | self.received = False 184 | self.connection.send("SendMessage", [self.username, self.message]) 185 | while not self.received: 186 | time.sleep(0.1) 187 | self.assertTrue(self.received) 188 | 189 | 190 | class TestSendErrorNoSslMethod(TestSendErrorMethod): 191 | server_url = Urls.server_url_no_ssl 192 | 193 | 194 | class TestSendErrorMethodMsgPack(TestSendErrorMethod): 195 | def get_connection(self): 196 | return super().get_connection(msgpack=True) 197 | 198 | 199 | class TestSendErrorNoSslMethodMsgPack(TestSendErrorNoSslMethod): 200 | def get_connection(self): 201 | return super().get_connection(msgpack=True) 202 | -------------------------------------------------------------------------------- /signalrcore/protocol/messagepack_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import msgpack 3 | from .base_hub_protocol import BaseHubProtocol 4 | from ..messages.handshake.request import HandshakeRequestMessage 5 | from ..messages.handshake.response import HandshakeResponseMessage 6 | from ..messages.invocation_message\ 7 | import InvocationMessage, InvocationClientStreamMessage # 1 8 | from ..messages.stream_item_message import StreamItemMessage # 2 9 | from ..messages.completion_message import CompletionMessage # 3 10 | from ..messages.stream_invocation_message import StreamInvocationMessage # 4 11 | from ..messages.cancel_invocation_message import CancelInvocationMessage # 5 12 | from ..messages.ping_message import PingMessage # 6 13 | from ..messages.close_message import CloseMessage # 7 14 | from ..helpers import Helpers 15 | 16 | 17 | class MessagePackHubProtocol(BaseHubProtocol): 18 | 19 | _priority = [ 20 | "type", 21 | "headers", 22 | "invocation_id", 23 | "target", 24 | "arguments", 25 | "item", 26 | "result_kind", 27 | "result", 28 | "stream_ids" 29 | ] 30 | 31 | def __init__(self): 32 | super(MessagePackHubProtocol, self).__init__( 33 | "messagepack", 1, "Text", chr(0x1E)) 34 | self.logger = Helpers.get_logger() 35 | 36 | def parse_messages(self, raw): 37 | try: 38 | messages = [] 39 | offset = 0 40 | max_length_prefix_size = 5 41 | num_bits_to_shift = [0, 7, 14, 21, 28] 42 | while offset < len(raw): 43 | length = 0 44 | num_bytes = 0 45 | while True: 46 | byte_read = raw[offset + num_bytes] 47 | length |=\ 48 | (byte_read & 0x7F) << num_bits_to_shift[num_bytes] 49 | num_bytes += 1 50 | if byte_read & 0x80 == 0: 51 | break 52 | if offset == max_length_prefix_size\ 53 | or offset + num_bytes > len(raw): 54 | raise Exception("Cannot read message length") 55 | offset = offset + num_bytes 56 | values = msgpack.unpackb(raw[offset: offset + length]) 57 | offset = offset + length 58 | message = self._decode_message(values) 59 | messages.append(message) 60 | except Exception as ex: 61 | self.logger.error("Parse messages Error {0}".format(ex)) 62 | self.logger.error("raw msg '{0}'".format(raw)) 63 | return messages 64 | 65 | def decode_handshake(self, raw_message): 66 | try: 67 | has_various_messages = 0x1E in raw_message 68 | handshake_data = raw_message[0: raw_message.index(0x1E)]\ 69 | if has_various_messages else raw_message 70 | messages = self.parse_messages( 71 | raw_message[raw_message.index(0x1E) + 1:])\ 72 | if has_various_messages else [] 73 | data = json.loads(handshake_data) 74 | return HandshakeResponseMessage(data.get("error", None)), messages 75 | except Exception as ex: 76 | if type(raw_message) is str: 77 | data = json.loads(raw_message[0: raw_message.index("}") + 1]) 78 | return HandshakeResponseMessage(data.get("error", None)), [] 79 | self.logger.error(raw_message) 80 | self.logger.error(ex) 81 | raise ex 82 | 83 | def encode(self, message): 84 | if type(message) is HandshakeRequestMessage: 85 | content = json.dumps(message.__dict__) 86 | return content + self.record_separator 87 | 88 | msg = self._encode_message(message) 89 | encoded_message = msgpack.packb(msg) 90 | varint_length = self._to_varint(len(encoded_message)) 91 | return varint_length + encoded_message 92 | 93 | def _encode_message(self, message): 94 | result = [] 95 | 96 | # sort attributes 97 | for attribute in self._priority: 98 | if hasattr(message, attribute): 99 | if (attribute == "type"): 100 | result.append(getattr(message, attribute).value) 101 | else: 102 | result.append(getattr(message, attribute)) 103 | return result 104 | 105 | def _decode_message(self, raw): 106 | # {} {"error"} 107 | # [1, Headers, InvocationId, Target, [Arguments], [StreamIds]] 108 | # [2, Headers, InvocationId, Item] 109 | # [3, Headers, InvocationId, ResultKind, Result] 110 | # [4, Headers, InvocationId, Target, [Arguments], [StreamIds]] 111 | # [5, Headers, InvocationId] 112 | # [6] 113 | # [7, Error, AllowReconnect?] 114 | 115 | if raw[0] == 1: # InvocationMessage 116 | if len(raw[5]) > 0: 117 | return InvocationClientStreamMessage( 118 | headers=raw[1], 119 | stream_ids=raw[5], 120 | target=raw[3], 121 | arguments=raw[4]) 122 | else: 123 | return InvocationMessage( 124 | headers=raw[1], 125 | invocation_id=raw[2], 126 | target=raw[3], 127 | arguments=raw[4]) 128 | 129 | elif raw[0] == 2: # StreamItemMessage 130 | return StreamItemMessage( 131 | headers=raw[1], 132 | invocation_id=raw[2], 133 | item=raw[3]) 134 | 135 | elif raw[0] == 3: # CompletionMessage 136 | result_kind = raw[3] 137 | if result_kind == 1: 138 | return CompletionMessage( 139 | headers=raw[1], 140 | invocation_id=raw[2], 141 | result=None, 142 | error=raw[4]) 143 | 144 | elif result_kind == 2: 145 | return CompletionMessage( 146 | headers=raw[1], invocation_id=raw[2], 147 | result=None, error=None) 148 | 149 | elif result_kind == 3: 150 | return CompletionMessage( 151 | headers=raw[1], invocation_id=raw[2], 152 | result=raw[4], error=None) 153 | else: 154 | raise Exception("Unknown result kind.") 155 | 156 | elif raw[0] == 4: # StreamInvocationMessage 157 | return StreamInvocationMessage( 158 | headers=raw[1], invocation_id=raw[2], 159 | target=raw[3], arguments=raw[4]) # stream_id missing? 160 | 161 | elif raw[0] == 5: # CancelInvocationMessage 162 | return CancelInvocationMessage( 163 | headers=raw[1], invocation_id=raw[2]) 164 | 165 | elif raw[0] == 6: # PingMessageEncoding 166 | return PingMessage() 167 | 168 | elif raw[0] == 7: # CloseMessageEncoding 169 | return CloseMessage(error=raw[1]) # AllowReconnect is missing 170 | print(".......................................") 171 | print(raw) 172 | print("---------------------------------------") 173 | raise Exception("Unknown message type.") 174 | 175 | def _to_varint(self, value): 176 | buffer = b'' 177 | 178 | while True: 179 | 180 | byte = value & 0x7f 181 | value >>= 7 182 | 183 | if value: 184 | buffer += bytes((byte | 0x80, )) 185 | else: 186 | buffer += bytes((byte, )) 187 | break 188 | 189 | return buffer 190 | -------------------------------------------------------------------------------- /signalrcore/transport/websockets/websocket_client.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ssl 3 | import base64 4 | import threading 5 | import os 6 | import struct 7 | import urllib.parse as parse 8 | from typing import Optional, Callable, Union 9 | from signalrcore.helpers import Helpers 10 | 11 | 12 | THREAD_NAME = "Signalrcore websocket client" 13 | WINDOW_SIZE = 1024 14 | 15 | 16 | class NoHeaderException(Exception): 17 | """Error reading messages from socket, empty content 18 | """ 19 | pass 20 | 21 | 22 | class SocketHandshakeError(Exception): 23 | """Error during connection 24 | 25 | Args: 26 | msg (str): message 27 | """ 28 | def __init__(self, msg: str): 29 | super().__init__(msg) 30 | 31 | 32 | class WebSocketClient(object): 33 | """Minimal websocket client 34 | 35 | Args: 36 | url (str): Websocket url 37 | headers (Optional[dict]): additional headers 38 | verify_ssl (bool): Verify SSL y/n 39 | on_message (callable): on message callback 40 | on_error (callable): on error callback 41 | on_open (callable): on open callback 42 | on_close (callable): on close callback 43 | """ 44 | def __init__( 45 | self, 46 | url: str, 47 | is_binary: bool = False, 48 | headers: Optional[dict] = None, 49 | proxies: dict = {}, 50 | verify_ssl: bool = True, 51 | enable_trace: bool = False, 52 | on_message: Callable = None, 53 | on_open: Callable = None, 54 | on_error: Callable = None, 55 | on_close: Callable = None): 56 | self.is_trace_enabled = enable_trace 57 | self.proxies = proxies 58 | self.url = url 59 | self.is_binary = is_binary 60 | self.headers = headers or {} 61 | self.verify_ssl = verify_ssl 62 | self.sock = None 63 | self.ssl_context = ssl.create_default_context()\ 64 | if verify_ssl else\ 65 | ssl._create_unverified_context() 66 | self.logger = Helpers.get_logger() 67 | self.recv_thread = None 68 | self.on_message = on_message 69 | self.on_close = on_close 70 | self.on_error = on_error 71 | self.on_open = on_open 72 | self.running = False 73 | self.is_closing = False 74 | 75 | def connect(self): 76 | # ToDo URL PARSE 77 | parsed_url = parse.urlparse(self.url) 78 | is_secure_connection = parsed_url.scheme == "wss"\ 79 | or parsed_url.scheme == "https" 80 | 81 | proxy_info = None 82 | if is_secure_connection\ 83 | and self.proxies.get("https", None) is not None: 84 | proxy_info = parse.urlparse(self.proxies.get("https")) 85 | 86 | if not is_secure_connection\ 87 | and self.proxies.get("http", None) is not None: 88 | proxy_info = parse.urlparse(self.proxies.get("http")) 89 | 90 | host, port = parsed_url.hostname, parsed_url.port 91 | 92 | if proxy_info is not None: 93 | host = proxy_info.hostname, 94 | port = proxy_info.port 95 | 96 | raw_sock = socket.create_connection((host, port)) 97 | 98 | if is_secure_connection: 99 | raw_sock = self.ssl_context.wrap_socket( 100 | raw_sock, 101 | server_hostname=host) 102 | 103 | self.sock = raw_sock 104 | 105 | # Perform the WebSocket handshake 106 | key = base64.b64encode(os.urandom(16)).decode("utf-8") 107 | request_headers = [ 108 | f"GET {parsed_url.path} HTTP/1.1", 109 | f"Host: {parsed_url.hostname}", 110 | "Upgrade: websocket", 111 | "Connection: Upgrade", 112 | f"Sec-WebSocket-Key: {key}", 113 | "Sec-WebSocket-Version: 13" 114 | ] 115 | for k, v in self.headers.items(): 116 | request_headers.append(f"{k}: {v}") 117 | 118 | request = "\r\n".join(request_headers) + "\r\n\r\n" 119 | req = request.encode("utf-8") 120 | 121 | if self.is_trace_enabled: 122 | self.logger.debug(req) 123 | 124 | self.sock.sendall(req) 125 | 126 | # Read handshake response 127 | response = b"" 128 | while b"\r\n\r\n" not in response: 129 | chunk = self.sock.recv(WINDOW_SIZE) 130 | if self.is_trace_enabled: 131 | self.logger.debug(chunk) 132 | 133 | if not chunk: 134 | raise SocketHandshakeError( 135 | "Connection closed during handshake") 136 | 137 | response += chunk 138 | 139 | if b"101" not in response: 140 | raise SocketHandshakeError( 141 | f"Handshake failed: {response.decode()}") 142 | 143 | self.running = True 144 | self.recv_thread = threading.Thread( 145 | target=self._recv_loop, 146 | name=THREAD_NAME) 147 | self.recv_thread.daemon = True 148 | self.recv_thread.start() 149 | 150 | def _recv_loop(self): 151 | self.on_open() 152 | try: 153 | while self.running: 154 | message = self._recv_frame() 155 | 156 | if self.on_message: 157 | self.on_message(self, message) 158 | except Exception as e: 159 | self.running = False 160 | 161 | # is closing and no header indicates 162 | # that socket has not received anything 163 | has_no_content = type(e) is NoHeaderException 164 | 165 | # is closing and errno indicates 166 | # that file descriptor points to a closed file 167 | has_closed_fd = type(e) is OSError and e.errno == 9 168 | 169 | if (has_no_content or has_closed_fd) and self.is_closing: 170 | return 171 | 172 | if self.logger: 173 | self.logger.error(f"Receive error: {e}") 174 | self.on_error(e) 175 | 176 | def _recv_frame(self): 177 | # Very basic, single-frame, unfragmented 178 | try: 179 | header = self.sock.recv(2) 180 | except ssl.SSLError as ex: 181 | self.logger.error(ex) 182 | header = None 183 | 184 | if header is None or len(header) < 2: 185 | raise NoHeaderException() 186 | 187 | fin_opcode = header[0] 188 | masked_len = header[1] 189 | 190 | if self.logger: 191 | self.logger.debug( 192 | f"fin opcode: {fin_opcode}, masked len: {masked_len}") 193 | 194 | payload_len = masked_len & 0x7F 195 | if payload_len == 126: 196 | payload_len = struct.unpack(">H", self.sock.recv(2))[0] 197 | elif payload_len == 127: 198 | payload_len = struct.unpack(">Q", self.sock.recv(8))[0] 199 | 200 | if masked_len & 0x80: 201 | masking_key = self.sock.recv(4) 202 | masked_data = self.sock.recv(payload_len) 203 | data = bytes( 204 | b ^ masking_key[i % 4] 205 | for i, b in enumerate(masked_data)) 206 | else: 207 | data = self.sock.recv(payload_len) 208 | 209 | if self.is_trace_enabled: 210 | self.logger.debug(data) 211 | 212 | if self.is_binary: 213 | return data 214 | 215 | return data.decode("utf-8") 216 | 217 | def send( 218 | self, 219 | message: Union[str, bytes], 220 | opcode=0x1): 221 | # Text or binary opcode (no fragmentation) 222 | payload = message.encode("utf-8")\ 223 | if type(message) is str else message 224 | header = bytes([0x80 | opcode]) 225 | length = len(payload) 226 | if length <= 125: 227 | header += bytes([0x80 | length]) 228 | elif length <= 65535: 229 | header += bytes([0x80 | 126]) + struct.pack(">H", length) 230 | else: 231 | header += bytes([0x80 | 127]) + struct.pack(">Q", length) 232 | 233 | # Mask the payload 234 | masking_key = os.urandom(4) 235 | masked_payload = bytes( 236 | b ^ masking_key[i % 4] 237 | for i, b in enumerate(payload)) 238 | frame = header + masking_key + masked_payload 239 | self.sock.sendall(frame) 240 | 241 | def dispose(self): 242 | if self.sock: 243 | self.sock.close() 244 | is_same_thread = threading.current_thread().name == THREAD_NAME 245 | if self.recv_thread and not is_same_thread: 246 | self.recv_thread.join() 247 | self.recv_thread = None 248 | 249 | def close(self): 250 | self.logger.debug("Start closing socket") 251 | try: 252 | self.is_closing = True 253 | self.running = False 254 | 255 | self.dispose() 256 | 257 | self.on_close() 258 | self.logger.debug("socket closed successfully") 259 | except Exception as ex: 260 | self.logger.error(ex) 261 | self.on_error(ex) 262 | finally: 263 | self.is_closing = False 264 | -------------------------------------------------------------------------------- /signalrcore/transport/websockets/websocket_transport.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import time 3 | from .reconnection import ConnectionStateChecker 4 | from .connection import ConnectionState 5 | from ...messages.ping_message import PingMessage 6 | from ...hub.errors import HubError, UnAuthorizedHubError 7 | from ...protocol.messagepack_protocol import MessagePackHubProtocol 8 | from ..base_transport import BaseTransport 9 | from ...helpers import Helpers, RequestHelpers 10 | from .websocket_client import WebSocketClient 11 | 12 | 13 | class WebsocketTransport(BaseTransport): 14 | def __init__( 15 | self, 16 | url="", 17 | headers=None, 18 | keep_alive_interval=15, 19 | reconnection_handler=None, 20 | verify_ssl=False, 21 | skip_negotiation=False, 22 | enable_trace=False, 23 | proxies: dict = {}, 24 | **kwargs): 25 | super(WebsocketTransport, self).__init__(**kwargs) 26 | self._ws = None 27 | self.enable_trace = enable_trace 28 | self.skip_negotiation = skip_negotiation 29 | self.url = url 30 | self.proxies = proxies 31 | if headers is None: 32 | self.headers = dict() 33 | else: 34 | self.headers = headers 35 | self.handshake_received = False 36 | self.token = None # auth 37 | self.state = ConnectionState.disconnected 38 | self.connection_alive = False 39 | self._ws = None 40 | self.verify_ssl = verify_ssl 41 | self.connection_checker = ConnectionStateChecker( 42 | lambda: self.send(PingMessage()), 43 | keep_alive_interval 44 | ) 45 | self.reconnection_handler = reconnection_handler 46 | 47 | def is_running(self): 48 | return self.state != ConnectionState.disconnected 49 | 50 | def stop(self): 51 | if self.state == ConnectionState.connected: 52 | self.connection_checker.stop() 53 | self._ws.close() 54 | self.state = ConnectionState.disconnected 55 | self.handshake_received = False 56 | 57 | def is_trace_enabled(self) -> bool: 58 | return self._ws.is_trace_enabled 59 | 60 | def start(self): 61 | if not self.skip_negotiation: 62 | self.negotiate() 63 | 64 | if self.state == ConnectionState.connected: 65 | self.logger.warning("Already connected unable to start") 66 | return False 67 | 68 | self.state = ConnectionState.connecting 69 | self.logger.debug("start url:" + self.url) 70 | 71 | self._ws = WebSocketClient( 72 | self.url, 73 | headers=self.headers, 74 | is_binary=type(self.protocol) is MessagePackHubProtocol, 75 | verify_ssl=self.verify_ssl, 76 | on_message=self.on_message, 77 | on_error=self.on_socket_error, 78 | on_close=self.on_close, 79 | on_open=self.on_open, 80 | enable_trace=self.enable_trace 81 | ) 82 | 83 | # ToDo 84 | # if len(self.logger.handlers) > 0: 85 | # self._ws.enableTrace(self.enable_trace, self.logger.handlers[0]) 86 | self._ws.connect() 87 | return True 88 | 89 | def negotiate(self): 90 | negotiate_url = Helpers.get_negotiate_url(self.url) 91 | self.logger.debug("Negotiate url:{0}".format(negotiate_url)) 92 | 93 | status_code, data = RequestHelpers.post( 94 | negotiate_url, 95 | headers=self.headers, 96 | proxies=self.proxies, 97 | verify_ssl=self.verify_ssl) 98 | 99 | self.logger.debug( 100 | "Response status code{0}".format(status_code)) 101 | 102 | if status_code != 200: 103 | raise HubError(status_code)\ 104 | if status_code != 401 else UnAuthorizedHubError() 105 | 106 | if "connectionId" in data.keys(): 107 | self.url = Helpers.encode_connection_id( 108 | self.url, data["connectionId"]) 109 | 110 | # Azure 111 | if 'url' in data.keys() and 'accessToken' in data.keys(): 112 | Helpers.get_logger().debug( 113 | "Azure url, reformat headers, token and url {0}".format(data)) 114 | self.url = data["url"]\ 115 | if data["url"].startswith("ws") else\ 116 | Helpers.http_to_websocket(data["url"]) 117 | self.token = data["accessToken"] 118 | self.headers = {"Authorization": "Bearer " + self.token} 119 | 120 | def evaluate_handshake(self, message): 121 | self.logger.debug("Evaluating handshake {0}".format(message)) 122 | msg, messages = self.protocol.decode_handshake(message) 123 | if msg.error is None or msg.error == "": 124 | self.handshake_received = True 125 | self.state = ConnectionState.connected 126 | if self.reconnection_handler is not None: 127 | self.reconnection_handler.reconnecting = False 128 | if not self.connection_checker.running: 129 | self.connection_checker.start() 130 | else: 131 | self.logger.error(msg.error) 132 | self.on_socket_error(msg.error) 133 | self.stop() 134 | self.state = ConnectionState.disconnected 135 | return messages 136 | 137 | def on_open(self): 138 | self.logger.debug("-- web socket open --") 139 | msg = self.protocol.handshake_message() 140 | self.send(msg) 141 | 142 | def on_close(self): 143 | self.logger.debug("-- web socket close --") 144 | self.state = ConnectionState.disconnected 145 | if self._on_close is not None and callable(self._on_close): 146 | self._on_close() 147 | 148 | def on_reconnect(self): 149 | self.logger.debug("-- web socket reconnecting --") 150 | self.state = ConnectionState.disconnected 151 | if self._on_close is not None and callable(self._on_close): 152 | self._on_close() 153 | 154 | def on_socket_error(self, error: Exception): 155 | """ 156 | Args: 157 | error (Exception): websocket error 158 | 159 | Raises: 160 | HubError: [description] 161 | """ 162 | self.logger.debug("-- web socket error --") 163 | self.logger.error(traceback.format_exc(10, True)) 164 | self.logger.error("{0} {1}".format(self, error)) 165 | self.logger.error("{0} {1}".format(error, type(error))) 166 | self._on_close() 167 | self.state = ConnectionState.disconnected 168 | # raise HubError(error) 169 | 170 | def on_message(self, app, raw_message): 171 | self.logger.debug("Message received{0}".format(raw_message)) 172 | if not self.handshake_received: 173 | messages = self.evaluate_handshake(raw_message) 174 | if self._on_open is not None and callable(self._on_open): 175 | self.state = ConnectionState.connected 176 | self._on_open() 177 | 178 | if len(messages) > 0: 179 | return self._on_message(messages) 180 | 181 | return [] 182 | 183 | return self._on_message( 184 | self.protocol.parse_messages(raw_message)) 185 | 186 | def send(self, message): 187 | self.logger.debug("Sending message {0}".format(message)) 188 | try: 189 | self._ws.send( 190 | self.protocol.encode(message), 191 | opcode=0x2 192 | if type(self.protocol) is MessagePackHubProtocol else 193 | 0x1) 194 | self.connection_checker.last_message = time.time() 195 | if self.reconnection_handler is not None: 196 | self.reconnection_handler.reset() 197 | except OSError as ex: 198 | self.handshake_received = False 199 | self.logger.warning("Connection closed {0}".format(ex)) 200 | self.state = ConnectionState.disconnected 201 | if self.reconnection_handler is None: 202 | if self._on_close is not None and\ 203 | callable(self._on_close): 204 | self._on_close() 205 | raise ValueError(str(ex)) 206 | # Connection closed 207 | self.handle_reconnect() 208 | except Exception as ex: 209 | raise ex 210 | 211 | def handle_reconnect(self): 212 | if not self.reconnection_handler.reconnecting \ 213 | and self._on_reconnect is not None and \ 214 | callable(self._on_reconnect): 215 | self._on_reconnect() 216 | self.reconnection_handler.reconnecting = True 217 | try: 218 | self.stop() 219 | self.start() 220 | except Exception as ex: 221 | self.logger.error(ex) 222 | sleep_time = self.reconnection_handler.next() 223 | self.deferred_reconnect(sleep_time) 224 | 225 | def deferred_reconnect(self, sleep_time): 226 | time.sleep(sleep_time) 227 | try: 228 | if not self.connection_alive: 229 | if not self.connection_checker.running: 230 | self.send(PingMessage()) 231 | except Exception as ex: 232 | self.logger.error(ex) 233 | self.reconnection_handler.reconnecting = False 234 | self.connection_alive = False 235 | -------------------------------------------------------------------------------- /signalrcore/hub_connection_builder.py: -------------------------------------------------------------------------------- 1 | from .hub.base_hub_connection import BaseHubConnection 2 | from .hub.auth_hub_connection import AuthHubConnection 3 | from .transport.websockets.reconnection import \ 4 | IntervalReconnectionHandler, RawReconnectionHandler, ReconnectionType 5 | from .helpers import Helpers 6 | from .protocol.json_hub_protocol import JsonHubProtocol 7 | 8 | 9 | class HubConnectionBuilder(object): 10 | """ 11 | Hub connection class, manages handshake and messaging 12 | 13 | Args: 14 | hub_url: SignalR core url 15 | 16 | Raises: 17 | HubConnectionError: Raises an Exception if url is empty or None 18 | """ 19 | 20 | def __init__(self): 21 | self.hub_url = None 22 | self.hub = None 23 | self.options = { 24 | "access_token_factory": None 25 | } 26 | self.token = None 27 | self.headers = dict() 28 | self.negotiate_headers = None 29 | self.has_auth_configured = None 30 | self.protocol = None 31 | self.reconnection_handler = None 32 | self.keep_alive_interval = None 33 | self.verify_ssl = True 34 | self.enable_trace = False # socket trace 35 | self.skip_negotiation = False # By default do not skip negotiation 36 | self.running = False 37 | self.proxies = dict() 38 | 39 | def with_url( 40 | self, 41 | hub_url: str, 42 | options: dict = None): 43 | """Configure the hub url and options like negotiation and auth function 44 | 45 | def login(self): 46 | response = requests.post( 47 | self.login_url, 48 | json={ 49 | "username": self.email, 50 | "password": self.password 51 | },verify=False) 52 | return response.json()["token"] 53 | 54 | self.connection = HubConnectionBuilder()\ 55 | .with_url(self.server_url, 56 | options={ 57 | "verify_ssl": False, 58 | "access_token_factory": self.login, 59 | "headers": { 60 | "mycustomheader": "mycustomheadervalue" 61 | } 62 | })\ 63 | .configure_logging(logging.ERROR)\ 64 | .with_automatic_reconnect({ 65 | "type": "raw", 66 | "keep_alive_interval": 10, 67 | "reconnect_interval": 5, 68 | "max_attempts": 5 69 | }).build() 70 | 71 | Args: 72 | hub_url (string): Hub URL 73 | options ([dict], optional): [description]. Defaults to None. 74 | 75 | Raises: 76 | ValueError: If url is invalid 77 | TypeError: If options are not a dict or auth function 78 | is not callable 79 | 80 | Returns: 81 | [HubConnectionBuilder]: configured connection 82 | """ 83 | if hub_url is None or hub_url.strip() == "": 84 | raise ValueError("hub_url must be a valid url.") 85 | 86 | if options is not None and type(options) is not dict: 87 | raise TypeError( 88 | "options must be a dict {0}.".format(self.options)) 89 | 90 | if options is not None \ 91 | and "access_token_factory" in options.keys()\ 92 | and not callable(options["access_token_factory"]): 93 | raise TypeError( 94 | "access_token_factory must be a function without params") 95 | 96 | if options is not None: 97 | self.has_auth_configured = \ 98 | "access_token_factory" in options.keys()\ 99 | and callable(options["access_token_factory"]) 100 | 101 | self.skip_negotiation = "skip_negotiation" in options.keys()\ 102 | and options["skip_negotiation"] 103 | 104 | self.hub_url = hub_url 105 | self.hub = None 106 | self.options = self.options if options is None else options 107 | return self 108 | 109 | def configure_logging( 110 | self, logging_level, socket_trace=False, handler=None): 111 | """Configures signalr logging 112 | 113 | Args: 114 | logging_level ([type]): logging.INFO | logging.DEBUG ... 115 | from python logging class 116 | socket_trace (bool, optional): Enables socket package trace. 117 | Defaults to False. 118 | handler ([type], optional): Custom logging handler. 119 | Defaults to None. 120 | 121 | Returns: 122 | [HubConnectionBuilder]: Instance hub with logging configured 123 | """ 124 | Helpers.configure_logger(logging_level, handler) 125 | self.enable_trace = socket_trace 126 | return self 127 | 128 | def configure_proxies( 129 | self, 130 | proxies: dict): 131 | """configures proxies 132 | 133 | Args: 134 | proxies (dict): { 135 | "http" : "http://host:port", 136 | "https" : "https://host:port", 137 | "ftp" : "ftp://port:port" 138 | } 139 | 140 | Returns: 141 | [HubConnectionBuilder]: Instance hub with proxies configured 142 | """ 143 | 144 | if "http" not in proxies.keys() or "https" not in proxies.keys(): 145 | raise ValueError("Only http and https keys are allowed") 146 | 147 | self.proxies = proxies 148 | return self 149 | 150 | def with_hub_protocol(self, protocol): 151 | """Changes transport protocol 152 | from signalrcore.protocol.messagepack_protocol\ 153 | import MessagePackHubProtocol 154 | 155 | HubConnectionBuilder()\ 156 | .with_url(self.server_url, options={"verify_ssl":False})\ 157 | ... 158 | .with_hub_protocol(MessagePackHubProtocol())\ 159 | ... 160 | .build() 161 | Args: 162 | protocol (JsonHubProtocol|MessagePackHubProtocol): 163 | protocol instance 164 | 165 | Returns: 166 | HubConnectionBuilder: instance configured 167 | """ 168 | self.protocol = protocol 169 | return self 170 | 171 | def build(self): 172 | """Configures the connection hub 173 | 174 | Raises: 175 | TypeError: Checks parameters an raises TypeError 176 | if one of them is wrong 177 | 178 | Returns: 179 | [HubConnectionBuilder]: [self object for fluent interface purposes] 180 | """ 181 | if self.protocol is None: 182 | self.protocol = JsonHubProtocol() 183 | 184 | if "headers" in self.options.keys()\ 185 | and type(self.options["headers"]) is dict: 186 | self.headers.update(self.options["headers"]) 187 | 188 | if self.has_auth_configured: 189 | auth_function = self.options["access_token_factory"] 190 | if auth_function is None or not callable(auth_function): 191 | raise TypeError( 192 | "access_token_factory is not function") 193 | if "verify_ssl" in self.options.keys()\ 194 | and type(self.options["verify_ssl"]) is bool: 195 | self.verify_ssl = self.options["verify_ssl"] 196 | 197 | return AuthHubConnection( 198 | headers=self.headers, 199 | auth_function=auth_function, 200 | url=self.hub_url, 201 | protocol=self.protocol, 202 | keep_alive_interval=self.keep_alive_interval, 203 | reconnection_handler=self.reconnection_handler, 204 | verify_ssl=self.verify_ssl, 205 | proxies=self.proxies, 206 | skip_negotiation=self.skip_negotiation, 207 | enable_trace=self.enable_trace)\ 208 | if self.has_auth_configured else\ 209 | BaseHubConnection( 210 | url=self.hub_url, 211 | protocol=self.protocol, 212 | keep_alive_interval=self.keep_alive_interval, 213 | reconnection_handler=self.reconnection_handler, 214 | headers=self.headers, 215 | verify_ssl=self.verify_ssl, 216 | proxies=self.proxies, 217 | skip_negotiation=self.skip_negotiation, 218 | enable_trace=self.enable_trace) 219 | 220 | def with_automatic_reconnect(self, data: dict): 221 | """Configures automatic reconnection 222 | https://devblogs.microsoft.com/aspnet/asp-net-core-updates-in-net-core-3-0-preview-4/ 223 | 224 | hub = HubConnectionBuilder()\ 225 | .with_url(self.server_url, options={"verify_ssl":False})\ 226 | .configure_logging(logging.ERROR)\ 227 | .with_automatic_reconnect({ 228 | "type": "raw", 229 | "keep_alive_interval": 10, 230 | "reconnect_interval": 5, 231 | "max_attempts": 5 232 | })\ 233 | .build() 234 | 235 | Args: 236 | data (dict): [dict with autmatic reconnection parameters] 237 | 238 | Returns: 239 | [HubConnectionBuilder]: [self object for fluent interface purposes] 240 | """ 241 | reconnect_type = data.get("type", "raw") 242 | 243 | # Infinite reconnect attempts 244 | max_attempts = data.get("max_attempts", None) 245 | 246 | # 5 sec interval 247 | reconnect_interval = data.get("reconnect_interval", 5) 248 | 249 | keep_alive_interval = data.get("keep_alive_interval", 15) 250 | 251 | intervals = data.get("intervals", []) # Reconnection intervals 252 | 253 | self.keep_alive_interval = keep_alive_interval 254 | 255 | reconnection_type = ReconnectionType[reconnect_type] 256 | 257 | if reconnection_type == ReconnectionType.raw: 258 | self.reconnection_handler = RawReconnectionHandler( 259 | reconnect_interval, 260 | max_attempts 261 | ) 262 | if reconnection_type == ReconnectionType.interval: 263 | self.reconnection_handler = IntervalReconnectionHandler( 264 | intervals 265 | ) 266 | return self 267 | -------------------------------------------------------------------------------- /signalrcore/hub/base_hub_connection.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Callable 3 | from signalrcore.messages.message_type import MessageType 4 | from signalrcore.messages.stream_invocation_message\ 5 | import StreamInvocationMessage 6 | from .errors import HubConnectionError 7 | from signalrcore.helpers import Helpers 8 | from .handlers import StreamHandler, InvocationHandler 9 | from ..transport.websockets.websocket_transport import WebsocketTransport 10 | from ..subject import Subject 11 | from ..messages.invocation_message import InvocationMessage 12 | from collections import defaultdict 13 | 14 | 15 | class InvocationResult(object): 16 | def __init__(self, invocation_id) -> None: 17 | self.invocation_id = invocation_id 18 | self.message = None 19 | 20 | 21 | class BaseHubConnection(object): 22 | def __init__( 23 | self, 24 | url, 25 | protocol, 26 | headers=None, 27 | **kwargs): 28 | if headers is None: 29 | self.headers = dict() 30 | else: 31 | self.headers = headers 32 | self.logger = Helpers.get_logger() 33 | self.handlers = defaultdict(list) 34 | self.stream_handlers = defaultdict(list) 35 | 36 | self._on_error = lambda error: self.logger.info( 37 | "on_error not defined {0}".format(error)) 38 | 39 | self.transport = WebsocketTransport( 40 | url=url, 41 | protocol=protocol, 42 | headers=self.headers, 43 | on_message=self.on_message, 44 | **kwargs) 45 | 46 | def start(self): 47 | self.logger.debug("Connection started") 48 | return self.transport.start() 49 | 50 | def stop(self): 51 | self.logger.debug("Connection stop") 52 | return self.transport.stop() 53 | 54 | def on_close(self, callback): 55 | """Configures on_close connection callback. 56 | It will be raised on connection closed event 57 | connection.on_close(lambda: print("connection closed")) 58 | Args: 59 | callback (function): function without params 60 | """ 61 | self.transport.on_close_callback(callback) 62 | 63 | def on_open(self, callback): 64 | """Configures on_open connection callback. 65 | It will be raised on connection open event 66 | connection.on_open(lambda: print( 67 | "connection opened ")) 68 | Args: 69 | callback (function): function without params 70 | """ 71 | self.transport.on_open_callback(callback) 72 | 73 | def on_error(self, callback): 74 | """Configures on_error connection callback. It will be raised 75 | if any hub method throws an exception. 76 | connection.on_error(lambda data: 77 | print(f"An exception was thrown closed{data.error}")) 78 | Args: 79 | callback (function): function with one parameter. 80 | A CompletionMessage object. 81 | """ 82 | self._on_error = callback 83 | 84 | def on_reconnect(self, callback): 85 | """Configures on_reconnect reconnection callback. 86 | It will be raised on reconnection event 87 | connection.on_reconnect(lambda: print( 88 | "connection lost, reconnection in progress ")) 89 | Args: 90 | callback (function): function without params 91 | """ 92 | self.transport.on_reconnect_callback(callback) 93 | 94 | def on(self, event, callback_function: Callable): 95 | """Register a callback on the specified event 96 | Args: 97 | event (string): Event name 98 | callback_function (Function): callback function, 99 | arguments will be bound 100 | """ 101 | self.logger.debug("Handler registered started {0}".format(event)) 102 | self.handlers[event].append(callback_function) 103 | 104 | def unsubscribe(self, event, callback_function: Callable): 105 | """Removes a callback from the specified event 106 | Args: 107 | event (string): Event name 108 | callback_function (Function): callback function, 109 | arguments will be bound 110 | """ 111 | self.logger.debug("Handler removed {0}".format(event)) 112 | 113 | self.handlers[event].remove(callback_function) 114 | 115 | if len(self.handlers[event]) == 0: 116 | del self.handlers[event] 117 | 118 | def send(self, method, arguments, on_invocation=None, invocation_id=None)\ 119 | -> InvocationResult: 120 | """Sends a message 121 | 122 | Args: 123 | method (string): Method name 124 | arguments (list|Subject): Method parameters 125 | on_invocation (function, optional): On invocation send callback 126 | will be raised on send server function ends. Defaults to None. 127 | invocation_id (string, optional): Override invocation ID. 128 | Exceptions thrown by the hub will use this ID, 129 | making it easier to handle with the on_error call. 130 | 131 | Raises: 132 | HubConnectionError: If hub is not ready to send 133 | TypeError: If arguments are invalid list or Subject 134 | """ 135 | if invocation_id is None: 136 | invocation_id = str(uuid.uuid4()) 137 | 138 | if not self.transport.is_running(): 139 | raise HubConnectionError( 140 | "Cannot connect to SignalR hub. Unable to transmit messages") 141 | 142 | if type(arguments) is not list and type(arguments) is not Subject: 143 | raise TypeError("Arguments of a message must be a list or subject") 144 | 145 | result = InvocationResult(invocation_id) 146 | 147 | if type(arguments) is list: 148 | message = InvocationMessage( 149 | invocation_id, 150 | method, 151 | arguments, 152 | headers=self.headers) 153 | 154 | if on_invocation: 155 | self.stream_handlers[message.invocation_id].append( 156 | InvocationHandler( 157 | message.invocation_id, 158 | on_invocation)) 159 | 160 | self.transport.send(message) 161 | result.message = message 162 | 163 | if type(arguments) is Subject: 164 | arguments.connection = self 165 | arguments.target = method 166 | arguments.start() 167 | result.invocation_id = arguments.invocation_id 168 | result.message = arguments 169 | 170 | return result 171 | 172 | def on_message(self, messages): 173 | for message in messages: 174 | if message.type == MessageType.invocation_binding_failure: 175 | self.logger.error(message) 176 | self._on_error(message) 177 | continue 178 | 179 | if message.type == MessageType.ping: 180 | continue 181 | 182 | if message.type == MessageType.invocation: 183 | 184 | fired_handlers = self.handlers.get(message.target, []) 185 | 186 | if len(fired_handlers) == 0: 187 | self.logger.debug( 188 | f"event '{message.target}' hasn't fired any handler") 189 | 190 | for handler in fired_handlers: 191 | handler(message.arguments) 192 | 193 | if message.type == MessageType.close: 194 | self.logger.info("Close message received from server") 195 | self.stop() 196 | return 197 | 198 | if message.type == MessageType.completion: 199 | if message.error is not None and len(message.error) > 0: 200 | self._on_error(message) 201 | 202 | # Send callbacks 203 | fired_handlers = self.stream_handlers.get( 204 | message.invocation_id, []) 205 | 206 | # Stream callbacks 207 | for handler in fired_handlers: 208 | handler.complete_callback(message) 209 | 210 | # unregister handler 211 | if message.invocation_id in self.stream_handlers: 212 | del self.stream_handlers[message.invocation_id] 213 | 214 | if message.type == MessageType.stream_item: 215 | fired_handlers = self.stream_handlers.get( 216 | message.invocation_id, []) 217 | 218 | if len(fired_handlers) == 0: 219 | self.logger.warning( 220 | "id '{0}' hasn't fire any stream handler".format( 221 | message.invocation_id)) 222 | 223 | for handler in fired_handlers: 224 | handler.next_callback(message.item) 225 | 226 | if message.type == MessageType.stream_invocation: 227 | pass 228 | 229 | if message.type == MessageType.cancel_invocation: 230 | fired_handlers = self.stream_handlers.get( 231 | message.invocation_id, []) 232 | 233 | if len(fired_handlers) == 0: 234 | self.logger.warning( 235 | "id '{0}' hasn't fire any stream handler".format( 236 | message.invocation_id)) 237 | 238 | for handler in fired_handlers: 239 | handler.error_callback(message) 240 | 241 | # unregister handler 242 | if message.invocation_id in self.stream_handlers: 243 | del self.stream_handlers[message.invocation_id] 244 | 245 | def stream(self, event, event_params): 246 | """Starts server streaming 247 | connection.stream( 248 | "Counter", 249 | [len(self.items), 500])\ 250 | .subscribe({ 251 | "next": self.on_next, 252 | "complete": self.on_complete, 253 | "error": self.on_error 254 | }) 255 | Args: 256 | event (string): Method Name 257 | event_params (list): Method parameters 258 | 259 | Returns: 260 | [StreamHandler]: stream handler 261 | """ 262 | invocation_id = str(uuid.uuid4()) 263 | stream_obj = StreamHandler(event, invocation_id) 264 | self.stream_handlers[invocation_id].append(stream_obj) 265 | self.transport.send( 266 | StreamInvocationMessage( 267 | invocation_id, 268 | event, 269 | event_params, 270 | headers=self.headers)) 271 | return stream_obj 272 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # SignalR core client 2 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal&style=flat-square)](https://www.paypal.me/mandrewcito/1) 3 | ![Pypi](https://img.shields.io/pypi/v/signalrcore.svg) 4 | [![Downloads](https://pepy.tech/badge/signalrcore/month)](https://pepy.tech/project/signalrcore/month) 5 | [![Downloads](https://pepy.tech/badge/signalrcore)](https://pepy.tech/project/signalrcore) 6 | ![Issues](https://img.shields.io/github/issues/mandrewcito/signalrcore.svg) 7 | ![Open issues](https://img.shields.io/github/issues-raw/mandrewcito/signalrcore.svg) 8 | ![travis build](https://img.shields.io/travis/mandrewcito/signalrcore.svg) 9 | ![codecov.io](https://codecov.io/github/mandrewcito/signalrcore/coverage.svg?branch=master) 10 | 11 | ![logo alt](https://raw.githubusercontent.com/mandrewcito/signalrcore/master/docs/img/logo_temp.128.svg.png) 12 | 13 | 14 | # Links 15 | 16 | * [Dev to posts with library examples and implementation](https://dev.to/mandrewcito/singlar-core-python-client-58e7) 17 | 18 | * [Pypi](https://pypi.org/project/signalrcore/) 19 | 20 | * [Wiki - This Doc](https://mandrewcito.github.io/signalrcore/) 21 | 22 | # Develop 23 | 24 | Test server will be available in [here](https://github.com/mandrewcito/signalrcore-containertestservers) and docker compose is required. 25 | 26 | ```bash 27 | git clone https://github.com/mandrewcito/signalrcore-containertestservers 28 | cd signalrcore-containertestservers 29 | docker compose up 30 | cd ../signalrcore 31 | make tests 32 | ``` 33 | 34 | # A tiny How To 35 | 36 | ## Connect to a server without auth 37 | 38 | ```python 39 | hub_connection = HubConnectionBuilder()\ 40 | .with_url(server_url)\ 41 | .configure_logging(logging.DEBUG)\ 42 | .with_automatic_reconnect({ 43 | "type": "raw", 44 | "keep_alive_interval": 10, 45 | "reconnect_interval": 5, 46 | "max_attempts": 5 47 | }).build() 48 | ``` 49 | ## Connect to a server with auth 50 | 51 | login_function must provide auth token 52 | 53 | ```python 54 | hub_connection = HubConnectionBuilder()\ 55 | .with_url(server_url, 56 | options={ 57 | "access_token_factory": login_function, 58 | "headers": { 59 | "mycustomheader": "mycustomheadervalue" 60 | } 61 | })\ 62 | .configure_logging(logging.DEBUG)\ 63 | .with_automatic_reconnect({ 64 | "type": "raw", 65 | "keep_alive_interval": 10, 66 | "reconnect_interval": 5, 67 | "max_attempts": 5 68 | }).build() 69 | ``` 70 | ### Unauthorized errors 71 | A login function must provide a error control if authorization fails. When connection starts, if authorization fails exception will be raised. 72 | 73 | ```python 74 | def login(self): 75 | response = requests.post( 76 | self.login_url, 77 | json={ 78 | "username": self.email, 79 | "password": self.password 80 | },verify=False) 81 | if response.status_code == 200: 82 | return response.json()["token"] 83 | raise requests.exceptions.ConnectionError() 84 | 85 | hub_connection.start() # this code will raise requests.exceptions.ConnectionError() if auth fails 86 | ``` 87 | ## Configure logging 88 | 89 | ```python 90 | HubConnectionBuilder()\ 91 | .with_url(server_url, 92 | .configure_logging(logging.DEBUG) 93 | ... 94 | ``` 95 | ## Configure socket trace 96 | ```python 97 | HubConnectionBuilder()\ 98 | .with_url(server_url, 99 | .configure_logging(logging.DEBUG, socket_trace=True) 100 | ... 101 | ``` 102 | ## Configure your own handler 103 | ```python 104 | import logging 105 | handler = logging.StreamHandler() 106 | handler.setLevel(logging.DEBUG) 107 | hub_connection = HubConnectionBuilder()\ 108 | .with_url(server_url, options={"verify_ssl": False}) \ 109 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) 110 | ... 111 | ``` 112 | ## Configuring reconnection 113 | After reaching max_attempts an exception will be thrown and on_disconnect event will be 114 | fired. 115 | ```python 116 | hub_connection = HubConnectionBuilder()\ 117 | .with_url(server_url)\ 118 | ... 119 | .build() 120 | ``` 121 | ## Configuring additional headers 122 | ```python 123 | hub_connection = HubConnectionBuilder()\ 124 | .with_url(server_url, 125 | options={ 126 | "headers": { 127 | "mycustomheader": "mycustomheadervalue" 128 | } 129 | }) 130 | ... 131 | .build() 132 | ``` 133 | ## Configuring additional querystring parameters 134 | ```python 135 | server_url ="http.... /?myQueryStringParam=134&foo=bar" 136 | connection = HubConnectionBuilder()\ 137 | .with_url(server_url, 138 | options={ 139 | })\ 140 | .build() 141 | ``` 142 | ## Configure skip negotiation 143 | ```python 144 | hub_connection = HubConnectionBuilder() \ 145 | .with_url("ws://"+server_url, options={ 146 | "verify_ssl": False, 147 | "skip_negotiation": False, 148 | "headers": { 149 | } 150 | }) \ 151 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 152 | .build() 153 | 154 | ``` 155 | ## Configuring ping(keep alive) 156 | 157 | keep_alive_interval sets the seconds of ping message 158 | 159 | ```python 160 | hub_connection = HubConnectionBuilder()\ 161 | .with_url(server_url)\ 162 | .configure_logging(logging.DEBUG)\ 163 | .with_automatic_reconnect({ 164 | "type": "raw", 165 | "keep_alive_interval": 10, 166 | "reconnect_interval": 5, 167 | "max_attempts": 5 168 | }).build() 169 | ``` 170 | ## Configuring logging 171 | ```python 172 | hub_connection = HubConnectionBuilder()\ 173 | .with_url(server_url)\ 174 | .configure_logging(logging.DEBUG)\ 175 | .with_automatic_reconnect({ 176 | "type": "raw", 177 | "keep_alive_interval": 10, 178 | "reconnect_interval": 5, 179 | "max_attempts": 5 180 | }).build() 181 | ``` 182 | 183 | ## Configure messagepack 184 | 185 | ```python 186 | from signalrcore.protocol.messagepack_protocol import MessagePackHubProtocol 187 | 188 | HubConnectionBuilder()\ 189 | .with_url(self.server_url, options={"verify_ssl":False})\ 190 | ... 191 | .with_hub_protocol(MessagePackHubProtocol())\ 192 | ... 193 | .build() 194 | ``` 195 | ## Events 196 | 197 | ### On connect / On disconnect 198 | 199 | on_open - fires when connection is opened and ready to send messages 200 | on_close - fires when connection is closed 201 | 202 | ```python 203 | hub_connection.on_open(lambda: print("connection opened and handshake received ready to send messages")) 204 | hub_connection.on_close(lambda: print("connection closed")) 205 | hub_connection.on_reconnect(lambda: print("reconnection in progress")) 206 | 207 | ``` 208 | ### On hub error (Hub Exceptions ...) 209 | ``` 210 | hub_connection.on_error(lambda data: print(f"An exception was thrown closed{data.error}")) 211 | ``` 212 | ### Register an operation 213 | ReceiveMessage - signalr method 214 | print - function that has as parameters args of signalr method 215 | ```python 216 | hub_connection.on("ReceiveMessage", print) 217 | ``` 218 | ## Sending messages 219 | SendMessage - signalr method 220 | username, message - parameters of signalr method 221 | ```python 222 | hub_connection.send("SendMessage", [username, message]) 223 | ``` 224 | ## Sending messages with callback 225 | SendMessage - signalr method 226 | username, message - parameters of signalr method 227 | ```python 228 | send_callback_received = threading.Lock() 229 | send_callback_received.acquire() 230 | self.connection.send( 231 | "SendMessage", # Method 232 | [self.username, self.message], # Params 233 | lambda m: send_callback_received.release()) # Callback 234 | if not send_callback_received.acquire(timeout=1): 235 | raise ValueError("CALLBACK NOT RECEIVED") 236 | ``` 237 | ## Requesting streaming (Server to client) 238 | ```python 239 | hub_connection.stream( 240 | "Counter", 241 | [len(self.items), 500]).subscribe({ 242 | "next": self.on_next, 243 | "complete": self.on_complete, 244 | "error": self.on_error 245 | }) 246 | ``` 247 | ## Client side Streaming 248 | ```python 249 | from signalrcore.subject import Subject 250 | 251 | subject = Subject() 252 | 253 | # Start Streaming 254 | hub_connection.send("UploadStream", subject) 255 | 256 | # Each iteration 257 | subject.next(str(iteration)) 258 | 259 | # End streaming 260 | subject.complete() 261 | 262 | 263 | 264 | 265 | ``` 266 | 267 | # Full Examples 268 | 269 | Examples will be available [here](https://github.com/mandrewcito/signalrcore/tree/master/test/examples) 270 | It were developed using package from [aspnet core - SignalRChat](https://codeload.github.com/aspnet/Docs/zip/master) 271 | 272 | ## Chat example 273 | A mini example could be something like this: 274 | 275 | ```python 276 | import logging 277 | import sys 278 | from signalrcore.hub_connection_builder import HubConnectionBuilder 279 | 280 | 281 | def input_with_default(input_text, default_value): 282 | value = input(input_text.format(default_value)) 283 | return default_value if value is None or value.strip() == "" else value 284 | 285 | 286 | server_url = input_with_default('Enter your server url(default: {0}): ', "wss://localhost:44376/chatHub") 287 | username = input_with_default('Enter your username (default: {0}): ', "mandrewcito") 288 | handler = logging.StreamHandler() 289 | handler.setLevel(logging.DEBUG) 290 | hub_connection = HubConnectionBuilder()\ 291 | .with_url(server_url, options={"verify_ssl": False}) \ 292 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 293 | .with_automatic_reconnect({ 294 | "type": "interval", 295 | "keep_alive_interval": 10, 296 | "intervals": [1, 3, 5, 6, 7, 87, 3] 297 | }).build() 298 | 299 | hub_connection.on_open(lambda: print("connection opened and handshake received ready to send messages")) 300 | hub_connection.on_close(lambda: print("connection closed")) 301 | hub_connection.on_reconnect(lambda: print("reconnection in progress")) 302 | 303 | hub_connection.on("ReceiveMessage", print) 304 | hub_connection.start() 305 | message = None 306 | 307 | # Do login 308 | 309 | while message != "exit()": 310 | message = input(">> ") 311 | if message is not None and message != "" and message != "exit()": 312 | hub_connection.send("SendMessage", [username, message]) 313 | 314 | hub_connection.stop() 315 | 316 | sys.exit(0) 317 | 318 | ``` 319 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SignalR core client 2 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal&style=flat-square)](https://www.paypal.me/mandrewcito/1) 3 | ![Pypi](https://img.shields.io/pypi/v/signalrcore.svg) 4 | [![Downloads](https://pepy.tech/badge/signalrcore/month)](https://pepy.tech/project/signalrcore/month) 5 | [![Downloads](https://pepy.tech/badge/signalrcore)](https://pepy.tech/project/signalrcore) 6 | ![Issues](https://img.shields.io/github/issues/mandrewcito/signalrcore.svg) 7 | ![Open issues](https://img.shields.io/github/issues-raw/mandrewcito/signalrcore.svg) 8 | ![codecov.io](https://codecov.io/github/mandrewcito/signalrcore/coverage.svg?branch=master) 9 | 10 | ![logo alt](https://raw.githubusercontent.com/mandrewcito/signalrcore/master/docs/img/logo_temp.128.svg.png) 11 | 12 | 13 | # Links 14 | 15 | * [Dev to posts with library examples and implementation](https://dev.to/mandrewcito/singlar-core-python-client-58e7) 16 | 17 | * [Pypi](https://pypi.org/project/signalrcore/) 18 | 19 | * [Wiki - This Doc](https://mandrewcito.github.io/signalrcore/) 20 | 21 | # Develop 22 | 23 | Test server will be available in [here](https://github.com/mandrewcito/signalrcore-containertestservers) and docker compose is required. 24 | 25 | ```bash 26 | git clone https://github.com/mandrewcito/signalrcore-containertestservers 27 | cd signalrcore-containertestservers 28 | docker compose up 29 | cd ../signalrcore 30 | make tests 31 | ``` 32 | 33 | # A Tiny How To 34 | 35 | ## Connect to a server without auth 36 | 37 | ```python 38 | hub_connection = HubConnectionBuilder()\ 39 | .with_url(server_url)\ 40 | .configure_logging(logging.DEBUG)\ 41 | .with_automatic_reconnect({ 42 | "type": "raw", 43 | "keep_alive_interval": 10, 44 | "reconnect_interval": 5, 45 | "max_attempts": 5 46 | }).build() 47 | ``` 48 | ## Connect to a server with auth 49 | 50 | login_function must provide auth token 51 | 52 | ```python 53 | hub_connection = HubConnectionBuilder()\ 54 | .with_url(server_url, 55 | options={ 56 | "access_token_factory": login_function, 57 | "headers": { 58 | "mycustomheader": "mycustomheadervalue" 59 | } 60 | })\ 61 | .configure_logging(logging.DEBUG)\ 62 | .with_automatic_reconnect({ 63 | "type": "raw", 64 | "keep_alive_interval": 10, 65 | "reconnect_interval": 5, 66 | "max_attempts": 5 67 | }).build() 68 | ``` 69 | ### Unauthorized errors 70 | A login function must provide an error controller if authorization fails. When connection starts, if authorization fails exception will be propagated. 71 | 72 | ```python 73 | def login(self): 74 | response = requests.post( 75 | self.login_url, 76 | json={ 77 | "username": self.email, 78 | "password": self.password 79 | },verify=False) 80 | if response.status_code == 200: 81 | return response.json()["token"] 82 | raise requests.exceptions.ConnectionError() 83 | 84 | hub_connection.start() # this code will raise requests.exceptions.ConnectionError() if auth fails 85 | ``` 86 | ## Configure logging 87 | 88 | ```python 89 | HubConnectionBuilder()\ 90 | .with_url(server_url, 91 | .configure_logging(logging.DEBUG) 92 | ... 93 | ``` 94 | ## Configure socket trace 95 | ```python 96 | HubConnectionBuilder()\ 97 | .with_url(server_url, 98 | .configure_logging(logging.DEBUG, socket_trace=True) 99 | ... 100 | ``` 101 | ## Configure your own handler 102 | ```python 103 | import logging 104 | handler = logging.StreamHandler() 105 | handler.setLevel(logging.DEBUG) 106 | hub_connection = HubConnectionBuilder()\ 107 | .with_url(server_url, options={"verify_ssl": False}) \ 108 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) 109 | ... 110 | ``` 111 | ## Configuring reconnection 112 | After reaching max_attempts an exeption will be thrown and on_disconnect event will be fired. 113 | ```python 114 | hub_connection = HubConnectionBuilder()\ 115 | .with_url(server_url)\ 116 | ... 117 | .build() 118 | ``` 119 | ## Configuring additional headers 120 | ```python 121 | hub_connection = HubConnectionBuilder()\ 122 | .with_url(server_url, 123 | options={ 124 | "headers": { 125 | "mycustomheader": "mycustomheadervalue" 126 | } 127 | }) 128 | ... 129 | .build() 130 | ``` 131 | ## Configuring additional querystring parameters 132 | ```python 133 | server_url ="http.... /?myquerystringparam=134&foo=bar" 134 | connection = HubConnectionBuilder()\ 135 | .with_url(server_url, 136 | options={ 137 | })\ 138 | .build() 139 | ``` 140 | ## Configuring skip negotiation 141 | ```python 142 | hub_connection = HubConnectionBuilder() \ 143 | .with_url("ws://"+server_url, options={ 144 | "verify_ssl": False, 145 | "skip_negotiation": False, 146 | "headers": { 147 | } 148 | }) \ 149 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 150 | .build() 151 | 152 | ``` 153 | ## Configuring ping(keep alive) 154 | 155 | keep_alive_interval sets the seconds of ping message 156 | 157 | ```python 158 | hub_connection = HubConnectionBuilder()\ 159 | .with_url(server_url)\ 160 | .configure_logging(logging.DEBUG)\ 161 | .with_automatic_reconnect({ 162 | "type": "raw", 163 | "keep_alive_interval": 10, 164 | "reconnect_interval": 5, 165 | "max_attempts": 5 166 | }).build() 167 | ``` 168 | ## Configuring logging 169 | ```python 170 | hub_connection = HubConnectionBuilder()\ 171 | .with_url(server_url)\ 172 | .configure_logging(logging.DEBUG)\ 173 | .with_automatic_reconnect({ 174 | "type": "raw", 175 | "keep_alive_interval": 10, 176 | "reconnect_interval": 5, 177 | "max_attempts": 5 178 | }).build() 179 | ``` 180 | 181 | ## Configure messagepack 182 | 183 | ```python 184 | from signalrcore.protocol.messagepack_protocol import MessagePackHubProtocol 185 | 186 | HubConnectionBuilder()\ 187 | .with_url(self.server_url, options={"verify_ssl":False})\ 188 | ... 189 | .with_hub_protocol(MessagePackHubProtocol())\ 190 | ... 191 | .build() 192 | ``` 193 | ## Events 194 | 195 | ### On Connect / On Disconnect 196 | on_open - fires when connection is opened and ready to send messages 197 | on_close - fires when connection is closed 198 | ```python 199 | hub_connection.on_open(lambda: print("connection opened and handshake received ready to send messages")) 200 | hub_connection.on_close(lambda: print("connection closed")) 201 | 202 | ``` 203 | ### On Hub Error (Hub Exceptions ...) 204 | ``` 205 | hub_connection.on_error(lambda data: print(f"An exception was thrown closed{data.error}")) 206 | ``` 207 | ### Register an operation 208 | ReceiveMessage - signalr method 209 | print - function that has as parameters args of signalr method 210 | ```python 211 | hub_connection.on("ReceiveMessage", print) 212 | ``` 213 | ## Sending messages 214 | SendMessage - signalr method 215 | username, message - parameters of signalrmethod 216 | ```python 217 | hub_connection.send("SendMessage", [username, message]) 218 | ``` 219 | 220 | ## Sending messages with callback 221 | SendMessage - signalr method 222 | username, message - parameters of signalrmethod 223 | ```python 224 | send_callback_received = threading.Lock() 225 | send_callback_received.acquire() 226 | self.connection.send( 227 | "SendMessage", # Method 228 | [self.username, self.message], # Params 229 | lambda m: send_callback_received.release()) # Callback 230 | if not send_callback_received.acquire(timeout=1): 231 | raise ValueError("CALLBACK NOT RECEIVED") 232 | ``` 233 | 234 | ## Requesting streaming (Server to client) 235 | ```python 236 | hub_connection.stream( 237 | "Counter", 238 | [len(self.items), 500]).subscribe({ 239 | "next": self.on_next, 240 | "complete": self.on_complete, 241 | "error": self.on_error 242 | }) 243 | ``` 244 | ## Client side Streaming 245 | ```python 246 | from signalrcore.subject import Subject 247 | 248 | subject = Subject() 249 | 250 | # Start Streaming 251 | hub_connection.send("UploadStream", subject) 252 | 253 | # Each iteration 254 | subject.next(str(iteration)) 255 | 256 | # End streaming 257 | subject.complete() 258 | ``` 259 | 260 | # Full Examples 261 | 262 | Examples will be avaiable [here](https://github.com/mandrewcito/signalrcore/tree/master/test/examples) 263 | It were developed using package from [aspnet core - SignalRChat](https://codeload.github.com/aspnet/Docs/zip/master) 264 | 265 | ## Chat example 266 | A mini example could be something like this: 267 | 268 | ```python 269 | import logging 270 | import sys 271 | from signalrcore.hub_connection_builder import HubConnectionBuilder 272 | 273 | 274 | def input_with_default(input_text, default_value): 275 | value = input(input_text.format(default_value)) 276 | return default_value if value is None or value.strip() == "" else value 277 | 278 | 279 | server_url = input_with_default('Enter your server url(default: {0}): ', "wss://localhost:44376/chatHub") 280 | username = input_with_default('Enter your username (default: {0}): ', "mandrewcito") 281 | handler = logging.StreamHandler() 282 | handler.setLevel(logging.DEBUG) 283 | hub_connection = HubConnectionBuilder()\ 284 | .with_url(server_url, options={"verify_ssl": False}) \ 285 | .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ 286 | .with_automatic_reconnect({ 287 | "type": "interval", 288 | "keep_alive_interval": 10, 289 | "intervals": [1, 3, 5, 6, 7, 87, 3] 290 | }).build() 291 | 292 | hub_connection.on_open(lambda: print("connection opened and handshake received ready to send messages")) 293 | hub_connection.on_close(lambda: print("connection closed")) 294 | 295 | hub_connection.on("ReceiveMessage", print) 296 | hub_connection.start() 297 | message = None 298 | 299 | # Do login 300 | 301 | while message != "exit()": 302 | message = input(">> ") 303 | if message is not None and message != "" and message != "exit()": 304 | hub_connection.send("SendMessage", [username, message]) 305 | 306 | hub_connection.stop() 307 | 308 | sys.exit(0) 309 | 310 | ``` 311 | -------------------------------------------------------------------------------- /docs/transport.md: -------------------------------------------------------------------------------- 1 | # Transport Protocols 2 | 3 | This document describes the protocols used by the three ASP.NET Endpoint Transports: WebSockets, Server-Sent Events and Long Polling 4 | 5 | ## Transport Requirements 6 | 7 | A transport is required to have the following attributes: 8 | 9 | 1. Duplex - Able to send messages from Server to Client and from Client to Server 10 | 1. Binary-safe - Able to transmit arbitrary binary data, regardless of content 11 | 1. Text-safe - Able to transmit arbitrary text data, preserving the content. Line-endings must be preserved **but may be converted to a different format**. For example `\r\n` may be converted to `\n`. This is due to quirks in some transports (Server Sent Events). If the exact line-ending needs to be preserved, the data should be sent as a `Binary` message. 12 | 13 | The only transport which fully implements the duplex requirement is WebSockets, the others are "half-transports" which implement one end of the duplex connection. They are used in combination to achieve a duplex connection. 14 | 15 | Throughout this document, the term `[endpoint-base]` is used to refer to the route assigned to a particular end point. The term `[connection-id]` is used to refer to the connection ID provided by the `POST [endpoint-base]/negotiate` request. 16 | 17 | **NOTE on errors:** In all error cases, by default, the detailed exception message is **never** provided; a short description string may be provided. However, an application developer may elect to allow detailed exception messages to be emitted, which should only be used in the `Development` environment. Unexpected errors are communicated by HTTP `500 Server Error` status codes or WebSockets non-`1000 Normal Closure` close frames; in these cases the connection should be considered to be terminated. 18 | 19 | ## `POST [endpoint-base]/negotiate` request 20 | 21 | The `POST [endpoint-base]/negotiate` request is used to establish a connection between the client and the server. The content type of the response is `application/json`. The response to the `POST [endpoint-base]/negotiate` request contains one of three types of responses: 22 | 23 | 1. A response that contains the `connectionId` which will be used to identify the connection on the server and the list of the transports supported by the server. 24 | 25 | ```json 26 | { 27 | "connectionId":"807809a5-31bf-470d-9e23-afaee35d8a0d", 28 | "availableTransports":[ 29 | { 30 | "transport": "WebSockets", 31 | "transferFormats": [ "Text", "Binary" ] 32 | }, 33 | { 34 | "transport": "ServerSentEvents", 35 | "transferFormats": [ "Text" ] 36 | }, 37 | { 38 | "transport": "LongPolling", 39 | "transferFormats": [ "Text", "Binary" ] 40 | } 41 | ] 42 | } 43 | ``` 44 | 45 | The payload returned from this endpoint provides the following data: 46 | 47 | * The `connectionId` which is **required** by the Long Polling and Server-Sent Events transports (in order to correlate sends and receives). 48 | * The `availableTransports` list which describes the transports the server supports. For each transport, the name of the transport (`transport`) is listed, as is a list of "transfer formats" supported by the transport (`transferFormats`) 49 | 50 | 51 | 2. A redirect response which tells the client which URL and optionally access token to use as a result. 52 | 53 | ```json 54 | { 55 | "url": "https://myapp.com/chat", 56 | "accessToken": "accessToken" 57 | } 58 | ``` 59 | 60 | The payload returned from this endpoint provides the following data: 61 | 62 | * The `url` which is the URL the client should connect to. 63 | * The `accessToken` which is an optional bearer token for accessing the specified url. 64 | 65 | 66 | 3. A response that contains an `error` which should stop the connection attempt. 67 | 68 | ```json 69 | { 70 | "error": "This connection is not allowed." 71 | } 72 | ``` 73 | 74 | The payload returned from this endpoint provides the following data: 75 | 76 | * The `error` that gives details about why the negotiate failed. 77 | 78 | ## Transfer Formats 79 | 80 | ASP.NET Endpoints support two different transfer formats: `Text` and `Binary`. `Text` refers to UTF-8 text, and `Binary` refers to any arbitrary binary data. The transfer format serves two purposes. First, in the WebSockets transport, it is used to determine if `Text` or `Binary` WebSocket frames should be used to carry data. This is useful in debugging as most browser Dev Tools only show the content of `Text` frames. When using a text-based protocol like JSON, it is preferable for the WebSockets transport to use `Text` frames. How a client/server indicate the transfer format currently being used is implementation-defined. 81 | 82 | Some transports are limited to supporting only `Text` data (specifically, Server-Sent Events). These transports cannot carry arbitrary binary data (without additional encoding, such as Base-64) due to limitations in their protocol. The transfer formats supported by each transport are described as part of the `POST [endpoint-base]/negotiate` response to allow clients to ignore transports that cannot support arbitrary binary data when they have a need to send/receive that data. How the client indicates the transfer format it wishes to use is also implementation-defined. 83 | 84 | ## WebSockets (Full Duplex) 85 | 86 | The WebSockets transport is unique in that it is full duplex, and a persistent connection that can be established in a single operation. As a result, the client is not required to use the `POST [endpoint-base]/negotiate` request to establish a connection in advance. It also includes all the necessary metadata in it's own frame metadata. 87 | 88 | The WebSocket transport is activated by making a WebSocket connection to `[endpoint-base]`. The **optional** `id` query string value is used to identify the connection to attach to. If there is no `id` query string value, a new connection is established. If the parameter is specified but there is no connection with the specified ID value, a `404 Not Found` response is returned. Upon receiving this request, the connection is established and the server responds with a WebSocket upgrade (`101 Switching Protocols`) immediately ready for frames to be sent/received. The WebSocket OpCode field is used to indicate the type of the frame (Text or Binary). 89 | 90 | Establishing a second WebSocket connection when there is already a WebSocket connection associated with the Endpoints connection is not permitted and will fail with a `409 Conflict` status code. 91 | 92 | Errors while establishing the connection are handled by returning a `500 Server Error` status code as the response to the upgrade request. This includes errors initializing EndPoint types. Unhandled application errors trigger a WebSocket `Close` frame with reason code that matches the error as per the spec (for errors like messages being too large, or invalid UTF-8). For other unexpected errors during the connection, a non-`1000 Normal Closure` status code is used. 93 | 94 | ## HTTP Post (Client-to-Server only) 95 | 96 | HTTP Post is a half-transport, it is only able to send messages from the Client to the Server, as such it is **always** used with one of the other half-transports which can send from Server to Client (Server Sent Events and Long Polling). 97 | 98 | This transport requires that a connection be established using the `POST [endpoint-base]/negotiate` request. 99 | 100 | The HTTP POST request is made to the URL `[endpoint-base]`. The **mandatory** `id` query string value is used to identify the connection to send to. If there is no `id` query string value, a `400 Bad Request` response is returned. Upon receipt of the **entire** payload, the server will process the payload and responds with `200 OK` if the payload was successfully processed. If a client makes another request to `/` while an existing request is outstanding, the new request is immediately terminated by the server with the `409 Conflict` status code. 101 | 102 | If a client receives a `409 Conflict` request, the connection remains open. Any other response indicates that the connection has been terminated due to an error. 103 | 104 | If the relevant connection has been terminated, a `404 Not Found` status code is returned. If there is an error instantiating an EndPoint or dispatching the message, a `500 Server Error` status code is returned. 105 | 106 | ## Server-Sent Events (Server-to-Client only) 107 | 108 | Server-Sent Events (SSE) is a protocol specified by WHATWG at [https://html.spec.whatwg.org/multipage/comms.html#server-sent-events](https://html.spec.whatwg.org/multipage/comms.html#server-sent-events). It is capable of sending data from server to client only, so it must be paired with the HTTP Post transport. It also requires a connection already be established using the `POST [endpoint-base]/negotiate` request. 109 | 110 | The protocol is similar to Long Polling in that the client opens a request to an endpoint and leaves it open. The server transmits frames as "events" using the SSE protocol. The protocol encodes a single event as a sequence of key-value pair lines, separated by `:` and using any of `\r\n`, `\n` or `\r` as line-terminators, followed by a final blank line. Keys can be duplicated and their values are concatenated with `\n`. So the following represents two events: 111 | 112 | ``` 113 | foo: bar 114 | baz: boz 115 | baz: biz 116 | quz: qoz 117 | baz: flarg 118 | 119 | foo: boz 120 | 121 | ``` 122 | 123 | In the first event, the value of `baz` would be `boz\nbiz\nflarg`, due to the concatenation behavior above. Full details can be found in the spec linked above. 124 | 125 | In this transport, the client establishes an SSE connection to `[endpoint-base]` with an `Accept` header of `text/event-stream`, and the server responds with an HTTP response with a `Content-Type` of `text/event-stream`. The **mandatory** `id` query string value is used to identify the connection to send to. If there is no `id` query string value, a `400 Bad Request` response is returned, if there is no connection with the specified ID, a `404 Not Found` response is returned. Each SSE event represents a single frame from client to server. The transport uses unnamed events, which means only the `data` field is available. Thus we use the first line of the `data` field for frame metadata. 126 | 127 | The Server-Sent Events transport only supports text data, because it is a text-based protocol. As a result, it is reported by the server as supporting only the `Text` transfer format. If a client wishes to send arbitrary binary data, it should skip the Server-Sent Events transport when selecting an appropriate transport. 128 | 129 | When the client has finished with the connection, it can terminate the event stream connection (send a TCP reset). The server will clean up the necessary resources. 130 | 131 | ## Long Polling (Server-to-Client only) 132 | 133 | Long Polling is a server-to-client half-transport, so it is always paired with HTTP Post. It requires a connection already be established using the `POST [endpoint-base]/negotiate` request. 134 | 135 | Long Polling requires that the client poll the server for new messages. Unlike traditional polling, if there is no data available, the server will simply wait for messages to be dispatched. At some point, the server, client or an upstream proxy will likely terminate the connection, at which point the client should immediately re-send the request. Long Polling is the only transport that allows a "reconnection" where a new request can be received while the server believes an existing request is in process. This can happen because of a time out. When this happens, the existing request is immediately terminated with status code `204 No Content`. Any messages which have already been written to the existing request will be flushed and considered sent. In the case of a server side timeout with no data, a `200 OK` with a 0 `Content-Length` will be sent and the client should poll again for more data. 136 | 137 | A Poll is established by sending an HTTP GET request to `[endpoint-base]` with the following query string parameters 138 | 139 | * `id` (Required) - The Connection ID of the destination connection. 140 | 141 | When data is available, the server responds with a body in one of the two formats below (depending upon the value of the `Accept` header). The response may be chunked, as per the chunked encoding part of the HTTP spec. 142 | 143 | If the `id` parameter is missing, a `400 Bad Request` response is returned. If there is no connection with the ID specified in `id`, a `404 Not Found` response is returned. 144 | 145 | When the client has finished with the connection, it can issue a `DELETE` request to `[endpoint-base]` (with the `id` in the querystring) to gracefully terminate the connection. The server will complete the latest poll with `204` to indicate that it has shut down. 146 | -------------------------------------------------------------------------------- /docs/hubs.md: -------------------------------------------------------------------------------- 1 | # SignalR Hub Protocol 2 | 3 | The SignalR Protocol is a protocol for two-way RPC over any Message-based transport. Either party in the connection may invoke procedures on the other party, and procedures can return zero or more results or an error. 4 | 5 | ## Terms 6 | 7 | * Caller - The node that is issuing an `Invocation`, `StreamInvocation`, `CancelInvocation`, `Ping` messages and receiving `Completion`, `StreamItem` and `Ping` messages (a node can be both Caller and Callee for different invocations simultaneously) 8 | * Callee - The node that is receiving an `Invocation`, `StreamInvocation`, `CancelInvocation`, `Ping` messages and issuing `Completion`, `StreamItem` and `Ping` messages (a node can be both Callee and Caller for different invocations simultaneously) 9 | * Binder - The component on each node that handles mapping `Invocation` and `StreamInvocation` messages to method calls and return values to `Completion` and `StreamItem` messages 10 | 11 | ## Transport Requirements 12 | 13 | The SignalR Protocol requires the following attributes from the underlying transport. 14 | 15 | * Reliable, in-order, delivery of messages - Specifically, the SignalR protocol provides no facility for retransmission or reordering of messages. If that is important to an application scenario, the application must either use a transport that guarantees it (i.e. TCP) or provide their own system for managing message order. 16 | 17 | ## Overview 18 | 19 | This document describes two encodings of the SignalR protocol: [JSON](http://www.json.org/) and [MessagePack](http://msgpack.org/). Only one format can be used for the duration of a connection, and the format must be agreed on by both sides after opening the connection and before sending any other messages. However, each format shares a similar overall structure. 20 | 21 | In the SignalR protocol, the following types of messages can be sent: 22 | 23 | | Message Name | Sender | Description | 24 | | ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | 25 | | `HandshakeRequest` | Client | Sent by the client to agree on the message format. | 26 | | `HandshakeResponse` | Server | Sent by the server as an acknowledgment of the previous `HandshakeRequest` message. Contains an error if the handshake failed. | 27 | | `Close` | Callee, Caller | Sent by the server when a connection is closed. Contains an error if the connection was closed because of an error. | 28 | | `Invocation` | Caller | Indicates a request to invoke a particular method (the Target) with provided Arguments on the remote endpoint. | 29 | | `StreamInvocation` | Caller | Indicates a request to invoke a streaming method (the Target) with provided Arguments on the remote endpoint. | 30 | | `StreamItem` | Callee | Indicates individual items of streamed response data from a previous `StreamInvocation` message. | 31 | | `Completion` | Callee | Indicates a previous `Invocation` or `StreamInvocation` has completed. Contains an error if the invocation concluded with an error or the result of a non-streaming method invocation. The result will be absent for `void` methods. In case of streaming invocations no further `StreamItem` messages will be received. | 32 | | `CancelInvocation` | Caller | Sent by the client to cancel a streaming invocation on the server. | 33 | | `Ping` | Caller, Callee | Sent by either party to check if the connection is active. | 34 | 35 | After opening a connection to the server the client must send a `HandshakeRequest` message to the server as its first message. The handshake message is **always** a JSON message and contains the name of the format (protocol) as well as the version of the protocol that will be used for the duration of the connection. The server will reply with a `HandshakeResponse`, also always JSON, containing an error if the server does not support the protocol. If the server does not support the protocol requested by the client or the first message received from the client is not a `HandshakeRequest` message the server must close the connection. Both the `HandshakeRequest` and `HandshakeResponse` messages must be terminated by the ASCII character `0x1E` (record separator). 36 | 37 | The `HandshakeRequest` message contains the following properties: 38 | 39 | * `protocol` - the name of the protocol to be used for messages exchanged between the server and the client 40 | * `version` - the value must always be 1, for both MessagePack and Json protocols 41 | 42 | Example: 43 | 44 | ```json 45 | { 46 | "protocol": "messagepack", 47 | "version": 1 48 | } 49 | ``` 50 | 51 | The `HandshakeResponse` message contains the following properties: 52 | 53 | * `error` - the optional error message if the server does not support the requested protocol 54 | 55 | Example: 56 | 57 | ```json 58 | { 59 | "error": "Requested protocol 'messagepack' is not available." 60 | } 61 | ``` 62 | 63 | ## Communication between the Caller and the Callee 64 | 65 | There are three kinds of interactions between the Caller and the Callee: 66 | 67 | * Invocations - the Caller sends a message to the Callee and expects a message indicating that the invocation has been completed and optionally a result of the invocation 68 | * Non-Blocking Invocations - the Caller sends a message to the Callee and does not expect any further messages for this invocation 69 | * Streaming Invocations - the Caller sends a message to the Callee and expects one or more results returned by the Callee followed by a message indicating the end of invocation 70 | 71 | ## Invocations 72 | 73 | In order to perform a single invocation, the Caller follows the following basic flow: 74 | 75 | 1. Allocate a unique `Invocation ID` value (arbitrary string, chosen by the Caller) to represent the invocation 76 | 2. Send an `Invocation` or `StreamingInvocation` message containing the `Invocation ID`, the name of the `Target` being invoked, and the `Arguments` to provide to the method. 77 | 3. If the `Invocation` is marked as non-blocking (see "Non-Blocking Invocations" below), stop here and immediately yield back to the application. 78 | 4. Wait for a `StreamItem` or `Completion` message with a matching `Invocation ID` 79 | 5. If a `Completion` message arrives, go to 8 80 | 6. If the `StreamItem` message has a payload, dispatch the payload to the application (i.e. by yielding a result to an `IObservable`, or by collecting the result for dispatching in step 8) 81 | 7. Go to 4 82 | 8. Complete the invocation, dispatching the final payload item (if any) or the error (if any) to the application 83 | 84 | The `Target` of an `Invocation` message must refer to a specific method, overloading is **not** permitted. In the .NET Binder, the `Target` value for a method is defined as the simple name of the Method (i.e. without qualifying type name, since a SignalR endpoint is specific to a single Hub class). `Target` is case-sensitive 85 | 86 | **NOTE**: `Invocation ID`s are arbitrarily chosen by the Caller and the Callee is expected to use the same string in all response messages. Callees may establish reasonable limits on `Invocation ID` lengths and terminate the connection when an `Invocation ID` that is too long is received. 87 | 88 | ## Message Headers 89 | 90 | All messages, except the `Ping` message, can carry additional headers. Headers are transmitted as a dictionary with string keys and string values. Clients and servers should disregard headers they do not understand. Since there are no headers defined in this spec, a client or server is never expected to interpret headers. However, clients and servers are expected to be able to process messages containing headers and disregard the headers. 91 | 92 | ## Non-Blocking Invocations 93 | 94 | Invocations can be sent without an `Invocation ID` value. This indicates that the invocation is "non-blocking", and thus the caller does not expect a response. When a Callee receives an invocation without an `Invocation ID` value, it **must not** send any response to that invocation. 95 | 96 | ## Streaming 97 | 98 | The SignalR protocol allows for multiple `StreamItem` messages to be transmitted in response to a `StreamingInvocation` message, and allows the receiver to dispatch these results as they arrive, to allow for streaming data from one endpoint to another. 99 | 100 | On the Callee side, it is up to the Callee's Binder to determine if a method call will yield multiple results. For example, in .NET certain return types may indicate multiple results, while others may indicate a single result. Even then, applications may wish for multiple results to be buffered and returned in a single `Completion` frame. It is up to the Binder to decide how to map this. The Callee's Binder must encode each result in separate `StreamItem` messages, indicating the end of results by sending a `Completion` message. 101 | 102 | On the Caller side, the user code which performs the invocation indicates how it would like to receive the results and it is up the Caller's Binder to handle the result. If the Caller expects only a single result, but multiple results are returned, or if the caller expects multiple results but only one result is returned, the Caller's Binder should yield an error. If the Caller wants to stop receiving `StreamItem` messages before the Callee sends a `Completion` message, the Caller can send a `CancelInvocation` message with the same `Invocation ID` used for the `StreamInvocation` message that started the stream. When the Callee receives a `CancelInvocation` message it will stop sending `StreamItem` messages and will send a `Completion` message. The Caller is free to ignore any `StreamItem` messages as well as the `Completion` message after sending `CancelInvocation`. 103 | 104 | ## Completion and results 105 | 106 | An Invocation is only considered completed when the `Completion` message is received. Receiving **any** message using the same `Invocation ID` after a `Completion` message has been received for that invocation is considered a protocol error and the recipient may immediately terminate the connection. 107 | 108 | If a Callee is going to stream results, it **MUST** send each individual result in a separate `StreamItem` message, and complete the invocation with a `Completion`. If the Callee is going to return a single result, it **MUST** not send any `StreamItem` messages, and **MUST** send the single result in a `Completion` message. If the Callee receives an `Invocation` message for a method that would yield multiple results or the Callee receives a `StreamInvocation` message for a method that would return a single result it **MUST** complete the invocation with a `Completion` message containing an error. 109 | 110 | ## Errors 111 | 112 | Errors are indicated by the presence of the `error` field in a `Completion` message. Errors always indicate the immediate end of the invocation. In the case of streamed responses, the arrival of a `Completion` message indicating an error should **not** stop the dispatching of previously-received results. The error is only yielded after the previously-received results have been dispatched. 113 | 114 | If either endpoint commits a Protocol Error (see examples below), the other endpoint may immediately terminate the underlying connection. 115 | 116 | * It is a protocol error for any message to be missing a required field, or to have an unrecognized field. 117 | * It is a protocol error for a Caller to send a `StreamItem` or `Completion` message with an `Invocation ID` that has not been received in an `Invocation` message from the Callee 118 | * It is a protocol error for a Caller to send a `StreamItem` or `Completion` message in response to a Non-Blocking Invocation (see "Non-Blocking Invocations" above) 119 | * It is a protocol error for a Caller to send a `Completion` message with a result when a `StreamItem` message has previously been sent for the same `Invocation ID`. 120 | * It is a protocol error for a Caller to send a `Completion` message carrying both a result and an error. 121 | * It is a protocol error for an `Invocation` or `StreamInvocation` message to have an `Invocation ID` that has already been used by *that* endpoint. However, it is **not an error** for one endpoint to use an `Invocation ID` that was previously used by the other endpoint (allowing each endpoint to track it's own IDs). 122 | 123 | ## Ping (aka "Keep Alive") 124 | 125 | The SignalR Hub protocol supports "Keep Alive" messages used to ensure that the underlying transport connection remains active. These messages help ensure: 126 | 127 | 1. Proxies don't close the underlying connection during idle times (when few messages are being sent) 128 | 2. If the underlying connection is dropped without being terminated gracefully, the application is informed as quickly as possible. 129 | 130 | Keep alive behavior is achieved via the `Ping` message type. **Either endpoint** may send a `Ping` message at any time. The receiving endpoint may choose to ignore the message, it has no obligation to respond in anyway. Most implementations will want to reset a timeout used to determine if the other party is present. 131 | 132 | Ping messages do not have any payload, they are completely empty messages (aside from the encoding necessary to identify the message as a `Ping` message). 133 | 134 | The default ASP.NET Core implementation automatically pings both directions on active connections. These pings are at regular intervals, and allow detection of unexpected disconnects (for example, unplugging a server). If the client detects that the server has stopped pinging, the client will close the connection, and vice versa. If there's other traffic through the connection, keep-alive pings aren't needed. A `Ping` is only sent if the interval has elapsed without a message being sent. 135 | 136 | ## Example 137 | 138 | Consider the following C# methods 139 | 140 | ```csharp 141 | public int Add(int x, int y) 142 | { 143 | return x + y; 144 | } 145 | 146 | public int SingleResultFailure(int x, int y) 147 | { 148 | throw new Exception("It didn't work!"); 149 | } 150 | 151 | public IEnumerable Batched(int count) 152 | { 153 | for(var i = 0; i < count; i++) 154 | { 155 | yield return i; 156 | } 157 | } 158 | 159 | [return: Streamed] // This is a made-up attribute that is used to indicate to the .NET Binder that it should stream results 160 | public IEnumerable Stream(int count) 161 | { 162 | for(var i = 0; i < count; i++) 163 | { 164 | yield return i; 165 | } 166 | } 167 | 168 | [return: Streamed] // This is a made-up attribute that is used to indicate to the .NET Binder that it should stream results 169 | public IEnumerable StreamFailure(int count) 170 | { 171 | for(var i = 0; i < count; i++) 172 | { 173 | yield return i; 174 | } 175 | throw new Exception("Ran out of data!"); 176 | } 177 | 178 | private List _callers = new List(); 179 | public void NonBlocking(string caller) 180 | { 181 | _callers.Add(caller); 182 | } 183 | ``` 184 | 185 | In each of the below examples, lines starting `C->S` indicate messages sent from the Caller ("Client") to the Callee ("Server"), and lines starting `S->C` indicate messages sent from the Callee ("Server") back to the Caller ("Client"). Message syntax is just a pseudo-code and is not intended to match any particular encoding. 186 | 187 | ### Single Result (`Add` example above) 188 | 189 | ``` 190 | C->S: Invocation { Id = 42, Target = "Add", Arguments = [ 40, 2 ] } 191 | S->C: Completion { Id = 42, Result = 42 } 192 | ``` 193 | 194 | **NOTE:** The following is **NOT** an acceptable encoding of this invocation: 195 | 196 | ``` 197 | C->S: Invocation { Id = 42, Target = "Add", Arguments = [ 40, 2 ] } 198 | S->C: StreamItem { Id = 42, Item = 42 } 199 | S->C: Completion { Id = 42 } 200 | ``` 201 | 202 | ### Single Result with Error (`SingleResultFailure` example above) 203 | 204 | ``` 205 | C->S: Invocation { Id = 42, Target = "SingleResultFailure", Arguments = [ 40, 2 ] } 206 | S->C: Completion { Id = 42, Error = "It didn't work!" } 207 | ``` 208 | 209 | ### Batched Result (`Batched` example above) 210 | 211 | ``` 212 | C->S: Invocation { Id = 42, Target = "Batched", Arguments = [ 5 ] } 213 | S->C: Completion { Id = 42, Result = [ 0, 1, 2, 3, 4 ] } 214 | ``` 215 | 216 | ### Streamed Result (`Stream` example above) 217 | 218 | ``` 219 | C->S: StreamInvocation { Id = 42, Target = "Stream", Arguments = [ 5 ] } 220 | S->C: StreamItem { Id = 42, Item = 0 } 221 | S->C: StreamItem { Id = 42, Item = 1 } 222 | S->C: StreamItem { Id = 42, Item = 2 } 223 | S->C: StreamItem { Id = 42, Item = 3 } 224 | S->C: StreamItem { Id = 42, Item = 4 } 225 | S->C: Completion { Id = 42 } 226 | ``` 227 | 228 | **NOTE:** The following is **NOT** an acceptable encoding of this invocation: 229 | 230 | ``` 231 | C->S: StreamInvocation { Id = 42, Target = "Stream", Arguments = [ 5 ] } 232 | S->C: StreamItem { Id = 42, Item = 0 } 233 | S->C: StreamItem { Id = 42, Item = 1 } 234 | S->C: StreamItem { Id = 42, Item = 2 } 235 | S->C: StreamItem { Id = 42, Item = 3 } 236 | S->C: Completion { Id = 42, Result = 4 } 237 | ``` 238 | 239 | This is invalid because the `Completion` message for streaming invocations must not contain any result. 240 | 241 | ### Streamed Result with Error (`StreamFailure` example above) 242 | 243 | ``` 244 | C->S: StreamInvocation { Id = 42, Target = "Stream", Arguments = [ 5 ] } 245 | S->C: StreamItem { Id = 42, Item = 0 } 246 | S->C: StreamItem { Id = 42, Item = 1 } 247 | S->C: StreamItem { Id = 42, Item = 2 } 248 | S->C: StreamItem { Id = 42, Item = 3 } 249 | S->C: StreamItem { Id = 42, Item = 4 } 250 | S->C: Completion { Id = 42, Error = "Ran out of data!" } 251 | ``` 252 | 253 | This should manifest to the Calling code as a sequence which emits `0`, `1`, `2`, `3`, `4`, but then fails with the error `Ran out of data!`. 254 | 255 | ### Streamed Result closed early (`Stream` example above) 256 | 257 | ``` 258 | C->S: StreamInvocation { Id = 42, Target = "Stream", Arguments = [ 5 ] } 259 | S->C: StreamItem { Id = 42, Item = 0 } 260 | S->C: StreamItem { Id = 42, Item = 1 } 261 | C->S: CancelInvocation { Id = 42 } 262 | S->C: StreamItem { Id = 42, Item = 2} // This can be ignored 263 | S->C: Completion { Id = 42 } // This can be ignored 264 | ``` 265 | 266 | ### Non-Blocking Call (`NonBlocking` example above) 267 | 268 | ``` 269 | C->S: Invocation { Target = "NonBlocking", Arguments = [ "foo" ] } 270 | ``` 271 | 272 | ### Ping 273 | 274 | ``` 275 | C->S: Ping 276 | ``` 277 | 278 | ## JSON Encoding 279 | 280 | In the JSON Encoding of the SignalR Protocol, each Message is represented as a single JSON object, which should be the only content of the underlying message from the Transport. All property names are case-sensitive. The underlying protocol is expected to handle encoding and decoding of the text, so the JSON string should be encoded in whatever form is expected by the underlying transport. For example, when using the ASP.NET Sockets transports, UTF-8 encoding is always used for text. 281 | 282 | All JSON messages must be terminated by the ASCII character `0x1E` (record separator). 283 | 284 | ### Invocation Message Encoding 285 | 286 | An `Invocation` message is a JSON object with the following properties: 287 | 288 | * `type` - A `Number` with the literal value 1, indicating that this message is an Invocation. 289 | * `invocationId` - An optional `String` encoding the `Invocation ID` for a message. 290 | * `target` - A `String` encoding the `Target` name, as expected by the Callee's Binder 291 | * `arguments` - An `Array` containing arguments to apply to the method referred to in Target. This is a sequence of JSON `Token`s, encoded as indicated below in the "JSON Payload Encoding" section 292 | 293 | Example: 294 | 295 | ```json 296 | { 297 | "type": 1, 298 | "invocationId": "123", 299 | "target": "Send", 300 | "arguments": [ 301 | 42, 302 | "Test Message" 303 | ] 304 | } 305 | ``` 306 | Example (Non-Blocking): 307 | 308 | ```json 309 | { 310 | "type": 1, 311 | "target": "Send", 312 | "arguments": [ 313 | 42, 314 | "Test Message" 315 | ] 316 | } 317 | ``` 318 | 319 | ### StreamInvocation Message Encoding 320 | 321 | A `StreamInvocation` message is a JSON object with the following properties: 322 | 323 | * `type` - A `Number` with the literal value 4, indicating that this message is a StreamInvocation. 324 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 325 | * `target` - A `String` encoding the `Target` name, as expected by the Callee's Binder. 326 | * `arguments` - An `Array` containing arguments to apply to the method referred to in Target. This is a sequence of JSON `Token`s, encoded as indicated below in the "JSON Payload Encoding" section. 327 | 328 | Example: 329 | 330 | ```json 331 | { 332 | "type": 4, 333 | "invocationId": "123", 334 | "target": "Send", 335 | "arguments": [ 336 | 42, 337 | "Test Message" 338 | ] 339 | } 340 | ``` 341 | 342 | ### StreamItem Message Encoding 343 | 344 | A `StreamItem` message is a JSON object with the following properties: 345 | 346 | * `type` - A `Number` with the literal value 2, indicating that this message is a `StreamItem`. 347 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 348 | * `item` - A `Token` encoding the stream item (see "JSON Payload Encoding" for details). 349 | 350 | Example 351 | 352 | ```json 353 | { 354 | "type": 2, 355 | "invocationId": "123", 356 | "item": 42 357 | } 358 | ``` 359 | 360 | ### Completion Message Encoding 361 | 362 | A `Completion` message is a JSON object with the following properties 363 | 364 | * `type` - A `Number` with the literal value `3`, indicating that this message is a `Completion`. 365 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 366 | * `result` - A `Token` encoding the result value (see "JSON Payload Encoding" for details). This field is **ignored** if `error` is present. 367 | * `error` - A `String` encoding the error message. 368 | 369 | It is a protocol error to include both a `result` and an `error` property in the `Completion` message. A conforming endpoint may immediately terminate the connection upon receiving such a message. 370 | 371 | Example - A `Completion` message with no result or error 372 | 373 | ```json 374 | { 375 | "type": 3, 376 | "invocationId": "123" 377 | } 378 | ``` 379 | 380 | Example - A `Completion` message with a result 381 | 382 | ```json 383 | { 384 | "type": 3, 385 | "invocationId": "123", 386 | "result": 42 387 | } 388 | ``` 389 | 390 | Example - A `Completion` message with an error 391 | 392 | ```json 393 | { 394 | "type": 3, 395 | "invocationId": "123", 396 | "error": "It didn't work!" 397 | } 398 | ``` 399 | 400 | Example - The following `Completion` message is a protocol error because it has both of `result` and `error` 401 | 402 | ```json 403 | { 404 | "type": 3, 405 | "invocationId": "123", 406 | "result": 42, 407 | "error": "It didn't work!" 408 | } 409 | ``` 410 | 411 | ### CancelInvocation Message Encoding 412 | A `CancelInvocation` message is a JSON object with the following properties 413 | 414 | * `type` - A `Number` with the literal value `5`, indicating that this message is a `CancelInvocation`. 415 | * `invocationId` - A `String` encoding the `Invocation ID` for a message. 416 | 417 | Example 418 | ```json 419 | { 420 | "type": 5, 421 | "invocationId": "123" 422 | } 423 | ``` 424 | 425 | ### Ping Message Encoding 426 | A `Ping` message is a JSON object with the following properties: 427 | 428 | * `type` - A `Number` with the literal value `6`, indicating that this message is a `Ping`. 429 | 430 | Example 431 | ```json 432 | { 433 | "type": 6 434 | } 435 | ``` 436 | 437 | ### Close Message Encoding 438 | A `Close` message is a JSON object with the following properties 439 | 440 | * `type` - A `Number` with the literal value `7`, indicating that this message is a `Close`. 441 | * `error` - An optional `String` encoding the error message. 442 | 443 | Example - A `Close` message without an error 444 | ```json 445 | { 446 | "type": 7 447 | } 448 | ``` 449 | 450 | Example - A `Close` message with an error 451 | ```json 452 | { 453 | "type": 7, 454 | "error": "Connection closed because of an error!" 455 | } 456 | ``` 457 | 458 | ### JSON Header Encoding 459 | 460 | Message headers are encoded into a JSON object, with string values, that are stored in the `headers` property. For example: 461 | 462 | ```json 463 | { 464 | "type": 1, 465 | "headers": { 466 | "Foo": "Bar" 467 | }, 468 | "invocationId": "123", 469 | "target": "Send", 470 | "arguments": [ 471 | 42, 472 | "Test Message" 473 | ] 474 | } 475 | ``` 476 | 477 | 478 | ### JSON Payload Encoding 479 | 480 | Items in the arguments array within the `Invocation` message type, as well as the `item` value of the `StreamItem` message and the `result` value of the `Completion` message, encode values which have meaning to each particular Binder. A general guideline for encoding/decoding these values is provided in the "Type Mapping" section at the end of this document, but Binders should provide configuration to applications to allow them to customize these mappings. These mappings need not be self-describing, because when decoding the value, the Binder is expected to know the destination type (by looking up the definition of the method indicated by the Target). 481 | 482 | ## MessagePack (MsgPack) encoding 483 | 484 | In the MsgPack Encoding of the SignalR Protocol, each Message is represented as a single MsgPack array containing items that correspond to properties of the given hub protocol message. The array items may be primitive values, arrays (e.g. method arguments) or objects (e.g. argument value). The first item in the array is the message type. 485 | 486 | MessagePack uses different formats to encode values. Refer to the [MsgPack format spec](https://github.com/msgpack/msgpack/blob/master/spec.md#formats) for format definitions. 487 | 488 | ### Invocation Message Encoding 489 | 490 | `Invocation` messages have the following structure: 491 | 492 | ``` 493 | [1, Headers, InvocationId, NonBlocking, Target, [Arguments]] 494 | ``` 495 | 496 | * `1` - Message Type - `1` indicates this is an `Invocation` message. 497 | * `Headers` - A MsgPack Map containing the headers, with string keys and string values (see MessagePack Headers Encoding below) 498 | * InvocationId - One of: 499 | * A `Nil`, indicating that there is no Invocation ID, OR 500 | * A `String` encoding the Invocation ID for the message. 501 | * Target - A `String` encoding the Target name, as expected by the Callee's Binder. 502 | * Arguments - An Array containing arguments to apply to the method referred to in Target. 503 | 504 | #### Example: 505 | 506 | The following payload 507 | 508 | ``` 509 | 0x94 0x01 0x80 0xa3 0x78 0x79 0x7a 0xa6 0x6d 0x65 0x74 0x68 0x6f 0x64 0x91 0x2a 510 | ``` 511 | 512 | is decoded as follows: 513 | 514 | * `0x95` - 5-element array 515 | * `0x01` - `1` (Message Type - `Invocation` message) 516 | * `0x80` - Map of length 0 (Headers) 517 | * `0xa3` - string of length 3 (InvocationId) 518 | * `0x78` - `x` 519 | * `0x79` - `y` 520 | * `0x7a` - `z` 521 | * `0xa6` - string of length 6 (Target) 522 | * `0x6d` - `m` 523 | * `0x65` - `e` 524 | * `0x74` - `t` 525 | * `0x68` - `h` 526 | * `0x6f` - `o` 527 | * `0x64` - `d` 528 | * `0x91` - 1-element array (Arguments) 529 | * `0x2a` - `42` (Argument value) 530 | 531 | #### Non-Blocking Example: 532 | 533 | The following payload 534 | ``` 535 | 0x95 0x01 0x80 0xc0 0xa6 0x6d 0x65 0x74 0x68 0x6f 0x64 0x91 0x2a 536 | ``` 537 | 538 | is decoded as follows: 539 | 540 | * `0x95` - 5-element array 541 | * `0x01` - `1` (Message Type - `Invocation` message) 542 | * `0x80` - Map of length 0 (Headers) 543 | * `0xc0` - `nil` (Invocation ID) 544 | * `0xa6` - string of length 6 (Target) 545 | * `0x6d` - `m` 546 | * `0x65` - `e` 547 | * `0x74` - `t` 548 | * `0x68` - `h` 549 | * `0x6f` - `o` 550 | * `0x64` - `d` 551 | * `0x91` - 1-element array (Arguments) 552 | * `0x2a` - `42` (Argument value) 553 | 554 | ### StreamInvocation Message Encoding 555 | 556 | `StreamInvocation` messages have the following structure: 557 | 558 | ``` 559 | [4, Headers, InvocationId, Target, [Arguments]] 560 | ``` 561 | 562 | * `4` - Message Type - `4` indicates this is a `StreamInvocation` message. 563 | * `Headers` - A MsgPack Map containing the headers, with string keys and string values (see MessagePack Headers Encoding below) 564 | * InvocationId - A `String` encoding the Invocation ID for the message. 565 | * Target - A `String` encoding the Target name, as expected by the Callee's Binder. 566 | * Arguments - An Array containing arguments to apply to the method referred to in Target. 567 | 568 | Example: 569 | 570 | The following payload 571 | 572 | ``` 573 | 0x95 0x04 0x80 0xa3 0x78 0x79 0x7a 0xa6 0x6d 0x65 0x74 0x68 0x6f 0x64 0x91 0x2a 574 | ``` 575 | 576 | is decoded as follows: 577 | 578 | * `0x95` - 5-element array 579 | * `0x04` - `4` (Message Type - `StreamInvocation` message) 580 | * `0x80` - Map of length 0 (Headers) 581 | * `0xa3` - string of length 3 (InvocationId) 582 | * `0x78` - `x` 583 | * `0x79` - `y` 584 | * `0x7a` - `z` 585 | * `0xa6` - string of length 6 (Target) 586 | * `0x6d` - `m` 587 | * `0x65` - `e` 588 | * `0x74` - `t` 589 | * `0x68` - `h` 590 | * `0x6f` - `o` 591 | * `0x64` - `d` 592 | * `0x91` - 1-element array (Arguments) 593 | * `0x2a` - `42` (Argument value) 594 | 595 | ### StreamItem Message Encoding 596 | 597 | `StreamItem` messages have the following structure: 598 | 599 | ``` 600 | [2, Headers, InvocationId, Item] 601 | ``` 602 | 603 | * `2` - Message Type - `2` indicates this is a `StreamItem` message 604 | * `Headers` - A MsgPack Map containing the headers, with string keys and string values (see MessagePack Headers Encoding below) 605 | * InvocationId - A `String` encoding the Invocation ID for the message 606 | * Item - the value of the stream item 607 | 608 | Example: 609 | 610 | The following payload: 611 | ``` 612 | 0x94 0x02 0x80 0xa3 0x78 0x79 0x7a 0x2a 613 | ``` 614 | 615 | is decoded as follows: 616 | 617 | * `0x94` - 4-element array 618 | * `0x02` - `2` (Message Type - `StreamItem` message) 619 | * `0x80` - Map of length 0 (Headers) 620 | * `0xa3` - string of length 3 (InvocationId) 621 | * `0x78` - `x` 622 | * `0x79` - `y` 623 | * `0x7a` - `z` 624 | * `0x2a` - `42` (Item) 625 | 626 | ### Completion Message Encoding 627 | 628 | `Completion` messages have the following structure 629 | 630 | ``` 631 | [3, Headers, InvocationId, ResultKind, Result?] 632 | ``` 633 | 634 | * `3` - Message Type - `3` indicates this is a `Completion` message 635 | * `Headers` - A MsgPack Map containing the headers, with string keys and string values (see MessagePack Headers Encoding below) 636 | * InvocationId - A `String` encoding the Invocation ID for the message 637 | * ResultKind - A flag indicating the invocation result kind: 638 | * `1` - Error result - Result contains a `String` with the error message 639 | * `2` - Void result - Result is absent 640 | * `3` - Non-Void result - Result contains the value returned by the server 641 | * Result - An optional item containing the result of invocation. Absent if the server did not return any value (void methods) 642 | 643 | Examples: 644 | 645 | #### Error Result: 646 | 647 | The following payload: 648 | ``` 649 | 0x95 0x03 0x80 0xa3 0x78 0x79 0x7a 0x01 0xa5 0x45 0x72 0x72 0x6f 0x72 650 | ``` 651 | 652 | is decoded as follows: 653 | 654 | * `0x94` - 4-element array 655 | * `0x03` - `3` (Message Type - `Result` message) 656 | * `0x80` - Map of length 0 (Headers) 657 | * `0xa3` - string of length 3 (InvocationId) 658 | * `0x78` - `x` 659 | * `0x79` - `y` 660 | * `0x7a` - `z` 661 | * `0x01` - `1` (ResultKind - Error result) 662 | * `0xa5` - string of length 5 663 | * `0x45` - `E` 664 | * `0x72` - `r` 665 | * `0x72` - `r` 666 | * `0x6f` - `o` 667 | * `0x72` - `r` 668 | 669 | #### Void Result: 670 | 671 | The following payload: 672 | ``` 673 | 0x94 0x03 0x80 0xa3 0x78 0x79 0x7a 0x02 674 | ``` 675 | 676 | is decoded as follows: 677 | 678 | * `0x94` - 4-element array 679 | * `0x03` - `3` (Message Type - `Result` message) 680 | * `0x80` - Map of length 0 (Headers) 681 | * `0xa3` - string of length 3 (InvocationId) 682 | * `0x78` - `x` 683 | * `0x79` - `y` 684 | * `0x7a` - `z` 685 | * `0x02` - `2` (ResultKind - Void result) 686 | 687 | #### Non-Void Result: 688 | 689 | The following payload: 690 | ``` 691 | 0x95 0x03 0x80 0xa3 0x78 0x79 0x7a 0x03 0x2a 692 | ``` 693 | 694 | is decoded as follows: 695 | 696 | * `0x95` - 5-element array 697 | * `0x03` - `3` (Message Type - `Result` message) 698 | * `0x80` - Map of length 0 (Headers) 699 | * `0xa3` - string of length 3 (InvocationId) 700 | * `0x78` - `x` 701 | * `0x79` - `y` 702 | * `0x7a` - `z` 703 | * `0x03` - `3` (ResultKind - Non-Void result) 704 | * `0x2a` - `42` (Result) 705 | 706 | ### CancelInvocation Message Encoding 707 | 708 | `CancelInvocation` messages have the following structure 709 | 710 | ``` 711 | [5, Headers, InvocationId] 712 | ``` 713 | 714 | * `5` - Message Type - `5` indicates this is a `CancelInvocation` message 715 | * `Headers` - A MsgPack Map containing the headers, with string keys and string values (see MessagePack Headers Encoding below) 716 | * InvocationId - A `String` encoding the Invocation ID for the message 717 | 718 | Example: 719 | 720 | The following payload: 721 | ``` 722 | 0x93 0x05 0x80 0xa3 0x78 0x79 0x7a 723 | ``` 724 | 725 | is decoded as follows: 726 | 727 | * `0x93` - 3-element array 728 | * `0x05` - `5` (Message Type `CancelInvocation` message) 729 | * `0x80` - Map of length 0 (Headers) 730 | * `0xa3` - string of length 3 (InvocationId) 731 | * `0x78` - `x` 732 | * `0x79` - `y` 733 | * `0x7a` - `z` 734 | 735 | ### Ping Message Encoding 736 | 737 | `Ping` messages have the following structure 738 | 739 | ``` 740 | [6] 741 | ``` 742 | 743 | * `6` - Message Type - `6` indicates this is a `Ping` message. 744 | 745 | Examples: 746 | 747 | #### Ping message 748 | 749 | The following payload: 750 | ``` 751 | 0x91 0x06 752 | ``` 753 | 754 | is decoded as follows: 755 | 756 | * `0x91` - 1-element array 757 | * `0x06` - `6` (Message Type - `Ping` message) 758 | 759 | ### Close Message Encoding 760 | 761 | `Close` messages have the following structure 762 | 763 | ``` 764 | [7, Error] 765 | ``` 766 | 767 | * `7` - Message Type - `7` indicates this is a `Close` message. 768 | * `Error` - Error - A `String` encoding the error for the message. 769 | 770 | Examples: 771 | 772 | #### Close message 773 | 774 | The following payload: 775 | ``` 776 | 0x92 0x07 0xa3 0x78 0x79 0x7a 777 | ``` 778 | 779 | is decoded as follows: 780 | 781 | * `0x92` - 2-element array 782 | * `0x07` - `7` (Message Type - `Close` message) 783 | * `0xa3` - string of length 3 (Error) 784 | * `0x78` - `x` 785 | * `0x79` - `y` 786 | * `0x7a` - `z` 787 | 788 | ### MessagePack Headers Encoding 789 | 790 | Headers are encoded in MessagePack messages as a Map that immediately follows the type value. The Map can be empty, in which case it is represented by the byte `0x80`. If there are items in the map, 791 | both the keys and values must be String values. 792 | 793 | Headers are not valid in a Ping message. The Ping message is **always exactly encoded** as `0x91 0x06` 794 | 795 | Below shows an example encoding of a message containing headers: 796 | 797 | ``` 798 | 0x95 0x01 0x82 0xa1 0x78 0xa1 0x79 0xa1 0x7a 0xa1 0x7a 0xa3 0x78 0x79 0x7a 0xa6 0x6d 0x65 0x74 0x68 0x6f 0x64 0x91 0x2a 799 | ``` 800 | 801 | and is decoded as follows: 802 | 803 | * `0x95` - 5-element array 804 | * `0x01` - `1` (Message Type - `Invocation` message) 805 | * `0x82` - Map of length 2 806 | * `0xa1` - string of length 1 (Key) 807 | * `0x78` - `x` 808 | * `0xa1` - string of length 1 (Value) 809 | * `0x79` - `y` 810 | * `0xa1` - string of length 1 (Key) 811 | * `0x7a` - `z` 812 | * `0xa1` - string of length 1 (Value) 813 | * `0x7a` - `z` 814 | * `0xa3` - string of length 3 (InvocationId) 815 | * `0x78` - `x` 816 | * `0x79` - `y` 817 | * `0x7a` - `z` 818 | * `0xa6` - string of length 6 (Target) 819 | * `0x6d` - `m` 820 | * `0x65` - `e` 821 | * `0x74` - `t` 822 | * `0x68` - `h` 823 | * `0x6f` - `o` 824 | * `0x64` - `d` 825 | * `0x91` - 1-element array (Arguments) 826 | * `0x2a` - `42` (Argument value) 827 | 828 | and interpreted as an Invocation message with headers: `'x' = 'y'` and `'z' = 'z'`. 829 | 830 | ## Type Mappings 831 | 832 | Below are some sample type mappings between JSON types and the .NET client. This is not an exhaustive or authoritative list, just informative guidance. Official clients will provide ways for users to override the default mapping behavior for a particular method, parameter, or parameter type 833 | 834 | | .NET Type | JSON Type | MsgPack format family | 835 | | ----------------------------------------------- | ---------------------------- |---------------------------| 836 | | `System.Byte`, `System.UInt16`, `System.UInt32` | `Number` | `positive fixint`, `uint` | 837 | | `System.SByte`, `System.Int16`, `System.Int32` | `Number` | `fixit`, `int` | 838 | | `System.UInt64` | `Number` | `positive fixint`, `uint` | 839 | | `System.Int64` | `Number` | `fixint`, `int` | 840 | | `System.Single` | `Number` | `float` | 841 | | `System.Double` | `Number` | `float` | 842 | | `System.Boolean` | `true` or `false` | `true`, `false` | 843 | | `System.String` | `String` | `fixstr`, `str` | 844 | | `System.Byte`[] | `String` (Base64-encoded) | `bin` | 845 | | `IEnumerable` | `Array` | `bin` | 846 | | custom `enum` | `Number` | `fixint`, `int` | 847 | | custom `struct` or `class` | `Object` | `fixmap`, `map` | 848 | 849 | MessagePack payloads are wrapped in an outer message framing described below. 850 | 851 | #### Binary encoding 852 | 853 | ``` 854 | ([Length][Body])([Length][Body])... continues until end of the connection ... 855 | ``` 856 | 857 | * `[Length]` - A 32-bit unsigned integer encoded as VarInt. Variable size - 1-5 bytes. 858 | * `[Body]` - The body of the message, exactly `[Length]` bytes in length. 859 | 860 | 861 | ##### VarInt 862 | 863 | VarInt encodes the most significant bit as a marker indicating whether the byte is the last byte of the VarInt or if it spans to the next byte. Bytes appear in the reverse order - i.e. the first byte contains the least significant bits of the value. 864 | 865 | Examples: 866 | * VarInt: `0x35` (`%00110101`) - the most significant bit is 0 so the value is %x0110101 i.e. 0x35 (53) 867 | * VarInt: `0x80 0x25` (`%10000000 %00101001`) - the most significant bit of the first byte is 1 so the remaining bits (%x0000000) are the lowest bits of the value. The most significant bit of the second byte is 0 meaning this is last byte of the VarInt. The actual value bits (%x0101001) need to be prepended to the bits we already read so the values is %01010010000000 i.e. 0x1480 (5248) 868 | 869 | The biggest supported payloads are 2GB in size so the biggest number we need to support is 0x7fffffff which when encoded as VarInt is 0xFF 0xFF 0xFF 0xFF 0x07 - hence the maximum size of the length prefix is 5 bytes. 870 | 871 | For example, when sending the following frames (`\n` indicates the actual Line Feed character, not an escape sequence): 872 | 873 | * "Hello\nWorld" 874 | * `0x01 0x02` 875 | 876 | The encoding will be as follows, as a list of binary digits in hex (text in parentheses `()` are comments). Whitespace and newlines are irrelevant and for illustration only. 877 | ``` 878 | 0x0B (start of frame; VarInt value: 11) 879 | 0x68 0x65 0x6C 0x6C 0x6F 0x0A 0x77 0x6F 0x72 0x6C 0x64 (UTF-8 encoding of 'Hello\nWorld') 880 | 0x02 (start of frame; VarInt value: 2) 881 | 0x01 0x02 (body) 882 | ``` 883 | --------------------------------------------------------------------------------